forked from EduCraft/curriculum-project-hub
192 lines
7.6 KiB
PL/PgSQL
192 lines
7.6 KiB
PL/PgSQL
-- Derived project discovery projection. Project/Folder remain authoritative;
|
|
-- triggers prevent index drift through ordinary database mutations.
|
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
|
|
|
CREATE TYPE "FolderKind" AS ENUM ('REGULAR', 'SYSTEM_INBOX');
|
|
ALTER TABLE "Folder" ADD COLUMN "kind" "FolderKind" NOT NULL DEFAULT 'REGULAR';
|
|
|
|
DO $$
|
|
BEGIN
|
|
IF EXISTS (
|
|
SELECT 1 FROM "Folder"
|
|
WHERE "parentId" IS NULL AND "name" = 'Inbox' AND "archivedAt" IS NULL
|
|
GROUP BY "organizationId" HAVING count(*) > 1
|
|
) THEN
|
|
RAISE EXCEPTION 'cannot identify system Inbox: organization has multiple active root Inbox folders';
|
|
END IF;
|
|
END $$;
|
|
|
|
UPDATE "Folder" f SET "kind" = 'SYSTEM_INBOX'
|
|
WHERE f."parentId" IS NULL AND f."name" = 'Inbox' AND f."archivedAt" IS NULL;
|
|
|
|
INSERT INTO "Folder" ("id", "organizationId", "parentId", "name", "kind", "sortKey", "createdAt", "updatedAt")
|
|
SELECT o."id" || ':system-inbox', o."id", NULL, 'Inbox', 'SYSTEM_INBOX', '000000', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
|
FROM "Organization" o
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM "Folder" f
|
|
WHERE f."organizationId" = o."id" AND f."kind" = 'SYSTEM_INBOX' AND f."archivedAt" IS NULL
|
|
);
|
|
|
|
CREATE UNIQUE INDEX "Folder_one_active_system_inbox_per_org"
|
|
ON "Folder"("organizationId") WHERE "kind" = 'SYSTEM_INBOX' AND "archivedAt" IS NULL;
|
|
|
|
CREATE OR REPLACE FUNCTION cph_protect_system_inbox() RETURNS trigger
|
|
LANGUAGE plpgsql AS $$
|
|
BEGIN
|
|
IF TG_OP = 'UPDATE' AND OLD."kind" = 'SYSTEM_INBOX' AND (
|
|
NEW."id" IS DISTINCT FROM OLD."id" OR
|
|
NEW."organizationId" IS DISTINCT FROM OLD."organizationId" OR
|
|
NEW."kind" IS DISTINCT FROM OLD."kind" OR
|
|
NEW."name" IS DISTINCT FROM OLD."name" OR
|
|
NEW."parentId" IS DISTINCT FROM OLD."parentId" OR
|
|
NEW."archivedAt" IS DISTINCT FROM OLD."archivedAt"
|
|
) THEN
|
|
RAISE EXCEPTION 'system Inbox identity cannot be changed';
|
|
END IF;
|
|
IF TG_OP IN ('INSERT', 'UPDATE') AND NEW."kind" = 'SYSTEM_INBOX' AND (
|
|
NEW."name" <> 'Inbox' OR NEW."parentId" IS NOT NULL OR NEW."archivedAt" IS NOT NULL
|
|
) THEN
|
|
RAISE EXCEPTION 'system Inbox must be an active root folder named Inbox';
|
|
END IF;
|
|
IF TG_OP = 'DELETE' AND OLD."kind" = 'SYSTEM_INBOX' AND EXISTS (
|
|
SELECT 1 FROM "Organization" WHERE "id" = OLD."organizationId"
|
|
) THEN
|
|
RAISE EXCEPTION 'system Inbox cannot be deleted while its organization exists';
|
|
END IF;
|
|
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
END;
|
|
$$;
|
|
|
|
CREATE TRIGGER cph_protect_system_inbox
|
|
BEFORE INSERT OR UPDATE OR DELETE ON "Folder"
|
|
FOR EACH ROW EXECUTE FUNCTION cph_protect_system_inbox();
|
|
|
|
ALTER TABLE "Project" ADD COLUMN "code" TEXT;
|
|
|
|
CREATE TABLE "ProjectSearchDocument" (
|
|
"projectId" TEXT NOT NULL,
|
|
"organizationId" TEXT NOT NULL,
|
|
"name" TEXT NOT NULL,
|
|
"code" TEXT,
|
|
"normalizedCode" TEXT NOT NULL,
|
|
"normalizedName" TEXT NOT NULL,
|
|
"breadcrumb" TEXT NOT NULL,
|
|
"normalizedBreadcrumb" TEXT NOT NULL,
|
|
"normalizedSearchText" TEXT NOT NULL,
|
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
CONSTRAINT "ProjectSearchDocument_pkey" PRIMARY KEY ("projectId")
|
|
);
|
|
|
|
CREATE INDEX "ProjectSearchDocument_organizationId_idx" ON "ProjectSearchDocument"("organizationId");
|
|
CREATE INDEX "ProjectSearchDocument_normalizedName_trgm_idx"
|
|
ON "ProjectSearchDocument" USING GIN ("normalizedName" gin_trgm_ops);
|
|
CREATE INDEX "ProjectSearchDocument_normalizedCode_trgm_idx"
|
|
ON "ProjectSearchDocument" USING GIN ("normalizedCode" gin_trgm_ops);
|
|
CREATE INDEX "ProjectSearchDocument_normalizedBreadcrumb_trgm_idx"
|
|
ON "ProjectSearchDocument" USING GIN ("normalizedBreadcrumb" gin_trgm_ops);
|
|
CREATE INDEX "ProjectSearchDocument_normalizedSearchText_trgm_idx"
|
|
ON "ProjectSearchDocument" USING GIN ("normalizedSearchText" gin_trgm_ops);
|
|
|
|
ALTER TABLE "ProjectSearchDocument" ADD CONSTRAINT "ProjectSearchDocument_projectId_fkey"
|
|
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
ALTER TABLE "ProjectSearchDocument" ADD CONSTRAINT "ProjectSearchDocument_organizationId_fkey"
|
|
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
|
|
CREATE OR REPLACE FUNCTION cph_project_search_normalize(value TEXT) RETURNS TEXT
|
|
LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS $$
|
|
SELECT lower(regexp_replace(normalize(value, NFKC), '[[:space:]_.:/\\-]+', '', 'g'))
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION cph_folder_breadcrumb(folder_id TEXT) RETURNS TEXT
|
|
LANGUAGE sql STABLE PARALLEL SAFE AS $$
|
|
WITH RECURSIVE ancestors AS (
|
|
SELECT f."id", f."parentId", f."name", f."kind", 0 AS depth
|
|
FROM "Folder" f
|
|
WHERE f."id" = folder_id
|
|
UNION ALL
|
|
SELECT parent."id", parent."parentId", parent."name", parent."kind", child.depth + 1
|
|
FROM "Folder" parent
|
|
JOIN ancestors child ON parent."id" = child."parentId"
|
|
)
|
|
SELECT CASE
|
|
WHEN count(*) = 1 AND bool_and("kind" = 'SYSTEM_INBOX') THEN '未分类'
|
|
ELSE coalesce(string_agg("name", ' / ' ORDER BY depth DESC), '')
|
|
END
|
|
FROM ancestors
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION cph_project_search_code(explicit_code TEXT, project_name TEXT) RETURNS TEXT
|
|
LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
|
|
SELECT CASE
|
|
WHEN explicit_code IS NOT NULL AND btrim(explicit_code) <> ''
|
|
THEN cph_project_search_normalize(explicit_code)
|
|
ELSE coalesce(substring(cph_project_search_normalize(project_name) FROM '^[a-z]+[0-9]+'), '')
|
|
END
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION cph_refresh_project_search_document(target_project_id TEXT) RETURNS void
|
|
LANGUAGE plpgsql AS $$
|
|
BEGIN
|
|
INSERT INTO "ProjectSearchDocument" (
|
|
"projectId", "organizationId", "name", "code", "normalizedCode", "normalizedName",
|
|
"breadcrumb", "normalizedBreadcrumb", "normalizedSearchText", "updatedAt"
|
|
)
|
|
SELECT p."id", p."organizationId", p."name", p."code",
|
|
cph_project_search_code(p."code", p."name"),
|
|
cph_project_search_normalize(p."name"),
|
|
cph_folder_breadcrumb(p."folderId"),
|
|
cph_project_search_normalize(cph_folder_breadcrumb(p."folderId")),
|
|
cph_project_search_normalize(cph_folder_breadcrumb(p."folderId") || ' ' || p."name"),
|
|
CURRENT_TIMESTAMP
|
|
FROM "Project" p WHERE p."id" = target_project_id
|
|
ON CONFLICT ("projectId") DO UPDATE SET
|
|
"organizationId" = EXCLUDED."organizationId",
|
|
"name" = EXCLUDED."name",
|
|
"code" = EXCLUDED."code",
|
|
"normalizedCode" = EXCLUDED."normalizedCode",
|
|
"normalizedName" = EXCLUDED."normalizedName",
|
|
"breadcrumb" = EXCLUDED."breadcrumb",
|
|
"normalizedBreadcrumb" = EXCLUDED."normalizedBreadcrumb",
|
|
"normalizedSearchText" = EXCLUDED."normalizedSearchText",
|
|
"updatedAt" = CURRENT_TIMESTAMP;
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION cph_project_search_project_trigger() RETURNS trigger
|
|
LANGUAGE plpgsql AS $$
|
|
BEGIN
|
|
PERFORM cph_refresh_project_search_document(NEW."id");
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE TRIGGER cph_project_search_project_changed
|
|
AFTER INSERT OR UPDATE OF "name", "code", "folderId", "organizationId", "archivedAt" ON "Project"
|
|
FOR EACH ROW EXECUTE FUNCTION cph_project_search_project_trigger();
|
|
|
|
CREATE OR REPLACE FUNCTION cph_project_search_folder_trigger() RETURNS trigger
|
|
LANGUAGE plpgsql AS $$
|
|
DECLARE project_id TEXT;
|
|
BEGIN
|
|
FOR project_id IN
|
|
WITH RECURSIVE descendants AS (
|
|
SELECT NEW."id"
|
|
UNION ALL
|
|
SELECT child."id" FROM "Folder" child
|
|
JOIN descendants parent ON child."parentId" = parent."id"
|
|
)
|
|
SELECT p."id" FROM "Project" p
|
|
WHERE p."folderId" IN (SELECT "id" FROM descendants)
|
|
LOOP
|
|
PERFORM cph_refresh_project_search_document(project_id);
|
|
END LOOP;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE TRIGGER cph_project_search_folder_changed
|
|
AFTER UPDATE OF "name", "kind", "parentId", "archivedAt" ON "Folder"
|
|
FOR EACH ROW EXECUTE FUNCTION cph_project_search_folder_trigger();
|
|
|
|
SELECT cph_refresh_project_search_document("id") FROM "Project";
|