@intentius/chant-lexicon-sql 0.116.0 → 0.118.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/clickhouse/access.d.ts +132 -0
- package/dist/clickhouse/access.d.ts.map +1 -0
- package/dist/clickhouse/apply/apply.d.ts.map +1 -1
- package/dist/clickhouse/apply/statements.d.ts +2 -0
- package/dist/clickhouse/apply/statements.d.ts.map +1 -1
- package/dist/clickhouse/entities.d.ts +171 -2
- package/dist/clickhouse/entities.d.ts.map +1 -1
- package/dist/clickhouse/http.d.ts +3 -0
- package/dist/clickhouse/http.d.ts.map +1 -1
- package/dist/clickhouse/import/generator.d.ts +28 -0
- package/dist/clickhouse/import/generator.d.ts.map +1 -1
- package/dist/clickhouse/import/ir.d.ts.map +1 -1
- package/dist/clickhouse/import/parser.d.ts +3 -3
- package/dist/clickhouse/import/parser.d.ts.map +1 -1
- package/dist/clickhouse/live/bind.d.ts.map +1 -1
- package/dist/clickhouse/live/catalog.d.ts +41 -6
- package/dist/clickhouse/live/catalog.d.ts.map +1 -1
- package/dist/clickhouse/live/describe-resources.d.ts +3 -2
- package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
- package/dist/clickhouse/parser.d.ts +54 -3
- package/dist/clickhouse/parser.d.ts.map +1 -1
- package/dist/clickhouse/plan/commands.d.ts +3 -1
- package/dist/clickhouse/plan/commands.d.ts.map +1 -1
- package/dist/clickhouse/plan/deep.d.ts.map +1 -1
- package/dist/clickhouse/plan/diff.d.ts +12 -0
- package/dist/clickhouse/plan/diff.d.ts.map +1 -1
- package/dist/clickhouse/plan/normalize.d.ts +61 -1
- package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
- package/dist/clickhouse/plan/report.d.ts.map +1 -1
- package/dist/clickhouse/plan/rules.d.ts +7 -0
- package/dist/clickhouse/plan/rules.d.ts.map +1 -1
- package/dist/clickhouse/plan/schema.d.ts +8 -4
- package/dist/clickhouse/plan/schema.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/backfill.d.ts +3 -1
- package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/observe.d.ts +7 -3
- package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/op.d.ts +8 -0
- package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/steps.d.ts +48 -0
- package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
- package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
- package/dist/clickhouse/testing/writable-server.d.ts.map +1 -1
- package/dist/clickhouse/topology.d.ts.map +1 -1
- package/dist/clickhouse.d.ts +1 -1
- package/dist/clickhouse.d.ts.map +1 -1
- package/dist/codegen/classifier-page.d.ts.map +1 -1
- package/dist/codegen/registry.d.ts.map +1 -1
- package/dist/config.d.ts +70 -7
- package/dist/config.d.ts.map +1 -1
- package/dist/core/references.d.ts +4 -2
- package/dist/core/references.d.ts.map +1 -1
- package/dist/core/statements.d.ts +11 -0
- package/dist/core/statements.d.ts.map +1 -1
- package/dist/files/build-root.d.ts +26 -0
- package/dist/files/build-root.d.ts.map +1 -0
- package/dist/files/clickhouse.d.ts +19 -0
- package/dist/files/clickhouse.d.ts.map +1 -0
- package/dist/files/common.d.ts +25 -0
- package/dist/files/common.d.ts.map +1 -0
- package/dist/files/postgres.d.ts +36 -0
- package/dist/files/postgres.d.ts.map +1 -0
- package/dist/import-parser.d.ts +25 -0
- package/dist/import-parser.d.ts.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +8 -8
- package/dist/lint/rules/sqlch001.d.ts +1 -1
- package/dist/lint/rules/sqlch001.d.ts.map +1 -1
- package/dist/lint/rules/sqlch002.d.ts.map +1 -1
- package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
- package/dist/lint/rules/templates.d.ts +1 -1
- package/dist/lint/rules/templates.d.ts.map +1 -1
- package/dist/lsp/postgres-template.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/meta.json +50 -0
- package/dist/okf/index.md +11 -1
- package/dist/okf/rules/SQLCH001.md +2 -2
- package/dist/okf/types/Dictionary.md +9 -0
- package/dist/okf/types/Function.md +9 -0
- package/dist/okf/types/Grant.md +9 -0
- package/dist/okf/types/PostgresDefaultPrivileges.md +9 -0
- package/dist/okf/types/PostgresGrant.md +9 -0
- package/dist/okf/types/PostgresPolicy.md +9 -0
- package/dist/okf/types/PostgresRole.md +9 -0
- package/dist/okf/types/Role.md +9 -0
- package/dist/okf/types/RowPolicy.md +9 -0
- package/dist/okf/types/User.md +9 -0
- package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
- package/dist/op/activities/postgres-migration.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +19 -0
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/postgres/access/acl.d.ts +119 -0
- package/dist/postgres/access/acl.d.ts.map +1 -0
- package/dist/postgres/access/import.d.ts +13 -0
- package/dist/postgres/access/import.d.ts.map +1 -0
- package/dist/postgres/access/live.d.ts +24 -0
- package/dist/postgres/access/live.d.ts.map +1 -0
- package/dist/postgres/apply/apply.d.ts.map +1 -1
- package/dist/postgres/apply/statements.d.ts +71 -0
- package/dist/postgres/apply/statements.d.ts.map +1 -1
- package/dist/postgres/entities.d.ts +106 -4
- package/dist/postgres/entities.d.ts.map +1 -1
- package/dist/postgres/entity-types.d.ts +4 -0
- package/dist/postgres/entity-types.d.ts.map +1 -1
- package/dist/postgres/import/generator.d.ts +37 -1
- package/dist/postgres/import/generator.d.ts.map +1 -1
- package/dist/postgres/import/live-export.d.ts +6 -0
- package/dist/postgres/import/live-export.d.ts.map +1 -1
- package/dist/postgres/live/bind.d.ts +2 -0
- package/dist/postgres/live/bind.d.ts.map +1 -1
- package/dist/postgres/live/catalog.d.ts +11 -2
- package/dist/postgres/live/catalog.d.ts.map +1 -1
- package/dist/postgres/live/client.d.ts +3 -0
- package/dist/postgres/live/client.d.ts.map +1 -1
- package/dist/postgres/live/describe-resources.d.ts +9 -0
- package/dist/postgres/live/describe-resources.d.ts.map +1 -1
- package/dist/postgres/migrate/op.d.ts +29 -0
- package/dist/postgres/migrate/op.d.ts.map +1 -1
- package/dist/postgres/parser.d.ts +76 -3
- package/dist/postgres/parser.d.ts.map +1 -1
- package/dist/postgres/plan/commands.d.ts +12 -0
- package/dist/postgres/plan/commands.d.ts.map +1 -1
- package/dist/postgres/plan/deep.d.ts.map +1 -1
- package/dist/postgres/plan/diff.d.ts +10 -1
- package/dist/postgres/plan/diff.d.ts.map +1 -1
- package/dist/postgres/plan/normalize.d.ts +8 -1
- package/dist/postgres/plan/normalize.d.ts.map +1 -1
- package/dist/postgres/plan/rules.d.ts +8 -0
- package/dist/postgres/plan/rules.d.ts.map +1 -1
- package/dist/postgres/plan/schema.d.ts +2 -1
- package/dist/postgres/plan/schema.d.ts.map +1 -1
- package/dist/postgres/plan/server-normalize.d.ts +4 -1
- package/dist/postgres/plan/server-normalize.d.ts.map +1 -1
- package/dist/postgres.d.ts +1 -1
- package/dist/postgres.d.ts.map +1 -1
- package/dist/rules/postgres-templates.ts +2 -2
- package/dist/rules/sqlch001.ts +26 -4
- package/dist/rules/sqlch002.ts +3 -2
- package/dist/rules/sqlpg001.ts +7 -4
- package/dist/rules/templates.ts +2 -2
- package/dist/serializer.d.ts +2 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/sql-files.d.ts +79 -0
- package/dist/sql-files.d.ts.map +1 -0
- package/dist/token-source.d.ts +88 -0
- package/dist/token-source.d.ts.map +1 -0
- package/dist/validate.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/clickhouse/access.test.ts +203 -0
- package/src/clickhouse/access.ts +595 -0
- package/src/clickhouse/apply/apply.ts +29 -20
- package/src/clickhouse/apply/statements.ts +105 -8
- package/src/clickhouse/dictionary.test.ts +177 -0
- package/src/clickhouse/entities.ts +356 -6
- package/src/clickhouse/function.test.ts +91 -0
- package/src/clickhouse/http.ts +11 -2
- package/src/clickhouse/import/generator.ts +81 -46
- package/src/clickhouse/import/import.test.ts +9 -3
- package/src/clickhouse/import/ir.ts +6 -2
- package/src/clickhouse/import/parser.ts +10 -18
- package/src/clickhouse/live/access.e2e.test.ts +218 -0
- package/src/clickhouse/live/bind.ts +17 -6
- package/src/clickhouse/live/catalog.ts +106 -10
- package/src/clickhouse/live/describe-resources.ts +23 -10
- package/src/clickhouse/live/dictionaries.e2e.test.ts +186 -0
- package/src/clickhouse/live/functions.e2e.test.ts +155 -0
- package/src/clickhouse/live/live.test.ts +2 -2
- package/src/clickhouse/parser.ts +136 -4
- package/src/clickhouse/plan/commands.ts +18 -7
- package/src/clickhouse/plan/deep.ts +71 -11
- package/src/clickhouse/plan/diff.ts +65 -2
- package/src/clickhouse/plan/normalize.ts +200 -6
- package/src/clickhouse/plan/report.ts +14 -3
- package/src/clickhouse/plan/rules.ts +44 -1
- package/src/clickhouse/plan/schema.ts +40 -17
- package/src/clickhouse/plan/server-format.ts +1 -1
- package/src/clickhouse/rebuild/backfill.ts +5 -25
- package/src/clickhouse/rebuild/observe.ts +7 -3
- package/src/clickhouse/rebuild/op.ts +9 -0
- package/src/clickhouse/rebuild/rebuild.e2e.test.ts +62 -0
- package/src/clickhouse/rebuild/rebuild.test.ts +54 -0
- package/src/clickhouse/rebuild/steps.ts +136 -1
- package/src/clickhouse/testing/fake-server.ts +2 -0
- package/src/clickhouse/testing/writable-server.ts +2 -0
- package/src/clickhouse/topology.ts +17 -2
- package/src/clickhouse.ts +19 -0
- package/src/codegen/classifier-page.ts +2 -0
- package/src/codegen/registry.ts +10 -0
- package/src/config.ts +22 -3
- package/src/core/references.ts +5 -3
- package/src/core/statements.ts +11 -1
- package/src/files/build-root.ts +62 -0
- package/src/files/clickhouse.ts +52 -0
- package/src/files/common.ts +54 -0
- package/src/files/postgres.ts +371 -0
- package/src/generated/lexicon-sql.json +50 -0
- package/src/import-parser.ts +61 -0
- package/src/index.ts +14 -1
- package/src/lint/rules/postgres-templates.ts +2 -2
- package/src/lint/rules/sqlch001.ts +26 -4
- package/src/lint/rules/sqlch002.ts +3 -2
- package/src/lint/rules/sqlpg001.ts +7 -4
- package/src/lint/rules/templates.ts +2 -2
- package/src/lsp/postgres-template.ts +2 -0
- package/src/lsp/template.ts +3 -3
- package/src/mcp/index.ts +6 -6
- package/src/mcp/postgres.ts +1 -1
- package/src/migration-statements.test.ts +25 -0
- package/src/migration-statements.ts +8 -0
- package/src/op/activities/clickhouse-rebuild.ts +1 -0
- package/src/op/activities/postgres-migration.ts +27 -2
- package/src/op/activity-contracts.ts +2 -0
- package/src/plugin.ts +29 -5
- package/src/postgres/access/access.e2e.test.ts +191 -0
- package/src/postgres/access/access.test.ts +147 -0
- package/src/postgres/access/acl.ts +400 -0
- package/src/postgres/access/import.ts +64 -0
- package/src/postgres/access/live.ts +123 -0
- package/src/postgres/apply/apply.ts +117 -4
- package/src/postgres/apply/constraint-sequences.e2e.test.ts +179 -0
- package/src/postgres/apply/safe-sequences.e2e.test.ts +176 -0
- package/src/postgres/apply/statements.test.ts +138 -6
- package/src/postgres/apply/statements.ts +210 -18
- package/src/postgres/entities.ts +287 -11
- package/src/postgres/entity-types.ts +4 -0
- package/src/postgres/import/generator.ts +129 -46
- package/src/postgres/import/live-export.ts +12 -2
- package/src/postgres/live/bind.ts +20 -6
- package/src/postgres/live/catalog.ts +79 -3
- package/src/postgres/live/client.ts +10 -2
- package/src/postgres/live/describe-resources.ts +27 -3
- package/src/postgres/migrate/migrate.test.ts +24 -1
- package/src/postgres/migrate/op.ts +80 -35
- package/src/postgres/migrate/outer.e2e.test.ts +163 -0
- package/src/postgres/parser.ts +247 -8
- package/src/postgres/plan/commands.ts +60 -7
- package/src/postgres/plan/deep.ts +24 -3
- package/src/postgres/plan/diff.ts +71 -6
- package/src/postgres/plan/normalize.ts +115 -2
- package/src/postgres/plan/plan.test.ts +2 -2
- package/src/postgres/plan/rules.ts +49 -5
- package/src/postgres/plan/schema.ts +4 -2
- package/src/postgres/plan/server-normalize.ts +29 -1
- package/src/postgres.ts +11 -0
- package/src/serializer.ts +14 -9
- package/src/skill-defs.ts +1 -1
- package/src/skills/chant-sql-postgres-migration.md +3 -1
- package/src/skills/chant-sql-postgres-plan.md +3 -3
- package/src/skills/chant-sql-postgres.md +9 -2
- package/src/skills/chant-sql-rebuild.md +3 -2
- package/src/skills/chant-sql.md +7 -1
- package/src/sql-files.test.ts +188 -0
- package/src/sql-files.ts +218 -0
- package/src/token-source.e2e.test.ts +142 -0
- package/src/token-source.test.ts +119 -0
- package/src/token-source.ts +189 -0
- package/src/validate.ts +10 -0
|
@@ -152,6 +152,8 @@ export function postgresDeclarations(content: string, fileName = "file.ts"): Pos
|
|
|
152
152
|
case "function":
|
|
153
153
|
case "procedure":
|
|
154
154
|
case "trigger":
|
|
155
|
+
case "policy":
|
|
156
|
+
case "role":
|
|
155
157
|
sqlName = text(node.name.span);
|
|
156
158
|
kind = node.statement;
|
|
157
159
|
break;
|
package/src/lsp/template.ts
CHANGED
|
@@ -143,13 +143,13 @@ export function declarations(content: string, fileName = "file.ts"): Declared[]
|
|
|
143
143
|
const tokens = tokenize(found.parts);
|
|
144
144
|
const node = parseCreate(tokens);
|
|
145
145
|
const columns =
|
|
146
|
-
node.statement === "database"
|
|
146
|
+
node.statement === "database" || node.statement === "function"
|
|
147
147
|
? []
|
|
148
|
-
: node.columns.map((c) => ({
|
|
148
|
+
: (node.statement === "dictionary" ? node.attributes : node.columns).map((c) => ({
|
|
149
149
|
name: c.name || spanText(found, tokens, c.nameSpan, source) || "",
|
|
150
150
|
...(spanText(found, tokens, c.type, source) ? { type: spanText(found, tokens, c.type, source)! } : {}),
|
|
151
151
|
}));
|
|
152
|
-
const engine = node.engine?.name || spanText(found, tokens, node.engine?.nameSpan, source);
|
|
152
|
+
const engine = node.statement === "dictionary" || node.statement === "function" ? undefined : node.engine?.name || spanText(found, tokens, node.engine?.nameSpan, source);
|
|
153
153
|
out.push({
|
|
154
154
|
name: decl.name.text,
|
|
155
155
|
tag: found.tag,
|
package/src/mcp/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type { McpResourceContribution, McpToolContribution } from "@intentius/ch
|
|
|
10
10
|
import { createCatalogResource, createDiffTool } from "@intentius/chant/lexicon-plugin-helpers";
|
|
11
11
|
import { sqlSerializer } from "../serializer";
|
|
12
12
|
import { catalogIndex } from "../lsp/catalog";
|
|
13
|
-
import { database, table, view } from "../clickhouse/entities";
|
|
13
|
+
import { database, dictionary, func, grant, policy, role, table, user, view } from "../clickhouse/entities";
|
|
14
14
|
import { SqlSyntaxError } from "../clickhouse/tokens";
|
|
15
15
|
import { existsSync, readFileSync } from "fs";
|
|
16
16
|
import { diffSchemas } from "../clickhouse/plan/diff";
|
|
@@ -122,12 +122,12 @@ const searchTool: McpToolContribution = {
|
|
|
122
122
|
const parseTool: McpToolContribution = {
|
|
123
123
|
name: "parse-ddl",
|
|
124
124
|
description:
|
|
125
|
-
"Parse one CREATE statement the way its tag does (dialect clickhouse, the default: table, view, database; dialect postgres: schema, table, index, view, sequence, type, domain, extension), and return the entity it builds (columns, engine, keys, settings, and for a view its lineage). A statement that does not parse comes back as the error with its line and column, the same one SQLCH001 (ClickHouse) or SQLPG001 (Postgres) reports. The statement is plain DDL: there are no interpolations.",
|
|
125
|
+
"Parse one CREATE statement the way its tag does (dialect clickhouse, the default: table, view, database, dictionary, func, user, role, policy, grant; dialect postgres: schema, table, index, view, sequence, type, domain, extension), and return the entity it builds (columns, engine, keys, settings, and for a view its lineage). A statement that does not parse comes back as the error with its line and column, the same one SQLCH001 (ClickHouse) or SQLPG001 (Postgres) reports. The statement is plain DDL: there are no interpolations.",
|
|
126
126
|
inputSchema: {
|
|
127
127
|
type: "object",
|
|
128
128
|
properties: {
|
|
129
129
|
dialect: dialectProp,
|
|
130
|
-
tag: { type: "string", enum: [...new Set(["table", "view", "database", ...POSTGRES_TAGS])], description: "The tag the statement belongs in (ClickHouse: table, view, database; Postgres: " + POSTGRES_TAGS.join(", ") + ")" },
|
|
130
|
+
tag: { type: "string", enum: [...new Set(["table", "view", "database", "dictionary", "func", "user", "role", "policy", "grant", ...POSTGRES_TAGS])], description: "The tag the statement belongs in (ClickHouse: table, view, database, dictionary, func, user, role, policy, grant; Postgres: " + POSTGRES_TAGS.join(", ") + ")" },
|
|
131
131
|
ddl: { type: "string", description: "The CREATE statement" },
|
|
132
132
|
},
|
|
133
133
|
required: ["tag", "ddl"],
|
|
@@ -136,8 +136,8 @@ const parseTool: McpToolContribution = {
|
|
|
136
136
|
const ddl = String(params.ddl ?? "");
|
|
137
137
|
const tag = String(params.tag);
|
|
138
138
|
if (asDialect(params.dialect) === "postgres") return postgresParse(tag, ddl, position);
|
|
139
|
-
const build = tag === "table" ? table : tag === "view" ? view : tag === "database" ? database : undefined;
|
|
140
|
-
if (!build) throw new Error("tag must be table, view or
|
|
139
|
+
const build = tag === "table" ? table : tag === "view" ? view : tag === "database" ? database : tag === "dictionary" ? dictionary : tag === "func" ? func : tag === "user" ? user : tag === "role" ? role : tag === "policy" ? policy : tag === "grant" ? grant : undefined;
|
|
140
|
+
if (!build) throw new Error("tag must be table, view, database, dictionary, func, user, role, policy or grant");
|
|
141
141
|
const strings = Object.assign([ddl], { raw: [ddl] }) as unknown as TemplateStringsArray;
|
|
142
142
|
try {
|
|
143
143
|
const entity = build(strings);
|
|
@@ -276,7 +276,7 @@ export function sqlMcpResources(): McpResourceContribution[] {
|
|
|
276
276
|
createCatalogResource(
|
|
277
277
|
new URL("../plugin.ts", import.meta.url).href,
|
|
278
278
|
"SQL Entity Catalog",
|
|
279
|
-
"The entity kinds the sql lexicon declares (database, table, view, materialized view)",
|
|
279
|
+
"The entity kinds the sql lexicon declares (database, table, view, materialized view, dictionary, function, user, role, row policy, grant)",
|
|
280
280
|
"lexicon-sql.json",
|
|
281
281
|
"sql",
|
|
282
282
|
),
|
package/src/mcp/postgres.ts
CHANGED
|
@@ -81,7 +81,7 @@ export function postgresSearch(params: Record<string, unknown>): unknown {
|
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
const TAGS = { schema: pg.schema, table: pg.table, index: pg.index, view: pg.view, sequence: pg.sequence, type: pg.type, domain: pg.domain, extension: pg.extension, func: pg.func, procedure: pg.procedure, trigger: pg.trigger } as const;
|
|
84
|
+
const TAGS = { schema: pg.schema, table: pg.table, index: pg.index, view: pg.view, sequence: pg.sequence, type: pg.type, domain: pg.domain, extension: pg.extension, func: pg.func, procedure: pg.procedure, trigger: pg.trigger, policy: pg.policy, role: pg.role, grant: pg.grant } as const;
|
|
85
85
|
export const POSTGRES_TAGS = Object.keys(TAGS);
|
|
86
86
|
|
|
87
87
|
/** Parse one statement with the Postgres tag; an error comes back as SQLPG001 with its line and column. */
|
|
@@ -150,6 +150,31 @@ const PG_AFTER = build("postgres", [APP, USERS_V2, EMAIL_IDX, ORDERS_RENAMED], {
|
|
|
150
150
|
describe("Postgres", () => {
|
|
151
151
|
const doc = diffStatements(PG_BEFORE, PG_AFTER, { marker: MARKER, env: "prod" });
|
|
152
152
|
|
|
153
|
+
test("a unique index on a table that exists carries its duplicate count, one on a new table none (#3686)", () => {
|
|
154
|
+
const unique: Obj = { export: "usersNick", type: "Postgres::Index", dependsOn: ["users"], ddl: "CREATE UNIQUE INDEX CONCURRENTLY users_nick_key ON app.users (nick) WHERE nick <> ''" };
|
|
155
|
+
const d = diffStatements(build("postgres", [APP, USERS]), build("postgres", [APP, USERS, unique]));
|
|
156
|
+
const built = d.steps.find((s): s is StatementStep => s.kind === "statement" && s.sql.startsWith("CREATE UNIQUE INDEX"));
|
|
157
|
+
expect(built?.precheck).toEqual({
|
|
158
|
+
sql: "SELECT (SELECT count(*) FROM (SELECT 1 FROM app.users WHERE nick IS NOT NULL AND (nick <> '') GROUP BY nick HAVING count(*) > 1) AS duplicates) AS n",
|
|
159
|
+
detail: "values of (nick) held by more than one row, which a unique index fails on",
|
|
160
|
+
});
|
|
161
|
+
expect(renderStatements(d)).toContain(`-- pre-check, must return 0 (values of (nick) held by more than one row, which a unique index fails on): ${built!.precheck!.sql}`);
|
|
162
|
+
const fresh = diffStatements(build("postgres", [APP]), build("postgres", [APP, USERS, unique]));
|
|
163
|
+
expect(fresh.steps.some((s) => s.kind === "statement" && s.precheck !== undefined)).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("SET NOT NULL renders as its four statements, the first with the NULL count (#3686)", () => {
|
|
167
|
+
const d = diffStatements(build("postgres", [APP, USERS]), build("postgres", [APP, { ...USERS, ddl: USERS.ddl.replace("nick text", "nick text NOT NULL") }]));
|
|
168
|
+
const st = d.steps.filter((s): s is StatementStep => s.kind === "statement");
|
|
169
|
+
expect(st.map((s) => [s.sql, s.rule, s.class])).toEqual([
|
|
170
|
+
["ALTER TABLE app.users ADD CONSTRAINT nick__chant_nn CHECK (nick IS NOT NULL) NOT VALID", "SQLPG217", "metadata"],
|
|
171
|
+
["ALTER TABLE app.users VALIDATE CONSTRAINT nick__chant_nn", "SQLPG220", "validate"],
|
|
172
|
+
["ALTER TABLE app.users ALTER COLUMN nick SET NOT NULL", "SQLPG210", "metadata"],
|
|
173
|
+
["ALTER TABLE app.users DROP CONSTRAINT nick__chant_nn", "SQLPG223", "metadata"],
|
|
174
|
+
]);
|
|
175
|
+
expect(st[0]!.precheck?.sql).toBe("SELECT count(*) AS n FROM app.users WHERE nick IS NULL");
|
|
176
|
+
});
|
|
177
|
+
|
|
153
178
|
test("the statements are ordered, each with its change's rule and class and whether it may run in a transaction", () => {
|
|
154
179
|
expect(doc).toMatchObject({ dialect: "postgres", defaultSchema: "public", major: 18 });
|
|
155
180
|
expect(doc.steps.filter((s): s is StatementStep => s.kind === "statement").map((s) => [s.object, s.sql, s.rule, s.class, s.transactional])).toEqual([
|
|
@@ -234,9 +234,17 @@ function postgresStatements(beforeJson: string, afterJson: string, options: Diff
|
|
|
234
234
|
class: s.class,
|
|
235
235
|
transactional: s.transactional,
|
|
236
236
|
...(s.rule === "SQLPG204" || isDestructive(entry.changes, s.rule) ? { destructive: true } : {}),
|
|
237
|
+
...(s.precheck ? { precheck: { sql: s.precheck.sql, detail: s.precheck.detail } } : {}),
|
|
237
238
|
});
|
|
238
239
|
}
|
|
239
240
|
}
|
|
241
|
+
// Privileges (#3681), after the objects they are on: one statement each, under the declaration behind it.
|
|
242
|
+
const afterByExport = new Map(after.map((o) => [o.exportName, o]));
|
|
243
|
+
for (const c of diff.access ?? []) {
|
|
244
|
+
const decl = c.exports.map((e) => afterByExport.get(e)).find((o) => o !== undefined);
|
|
245
|
+
const where = decl ? at(decl.exportName, decl.type, decl.name) : at(c.change.object, c.target.kind === "default" ? POSTGRES_ENTITY_TYPES.defaultPrivileges : POSTGRES_ENTITY_TYPES.grant, c.change.object.slice(4));
|
|
246
|
+
for (const sql of c.sql) steps.push({ kind: "statement", ...where, sql, rule: c.change.rule, class: c.change.class, transactional: true });
|
|
247
|
+
}
|
|
240
248
|
const beforeByExport = new Map(before.map((o) => [o.exportName, o]));
|
|
241
249
|
for (const d of plan.drops) {
|
|
242
250
|
const o = beforeByExport.get(d.key);
|
|
@@ -71,6 +71,7 @@ export async function rebuildRun(args: ClickHouseRebuildArgs, signal: AbortSigna
|
|
|
71
71
|
retainMs: parseDuration(args.retain ?? "7d"),
|
|
72
72
|
...(args.mutationTimeout ? { mutationTimeoutMs: parseDuration(args.mutationTimeout) } : {}),
|
|
73
73
|
...(args.replicaTimeout ? { replicaTimeoutMs: parseDuration(args.replicaTimeout) } : {}),
|
|
74
|
+
...(args.cutoverTimeout ? { cutoverTimeoutMs: parseDuration(args.cutoverTimeout) } : {}),
|
|
74
75
|
log: deps.log ?? ((line: string) => console.log(line)),
|
|
75
76
|
...(signal ? { signal } : {}),
|
|
76
77
|
...(runId ? { runId } : {}),
|
|
@@ -17,8 +17,8 @@ import { resolveBoundTarget } from "../../postgres/live/bind";
|
|
|
17
17
|
import { connectPostgres, type PostgresClient, type PostgresEndpoint } from "../../postgres/live/client";
|
|
18
18
|
import { DEFAULT_POSTGRES_APPLY_TIMEOUTS, declaredObjects } from "../../postgres/apply/apply";
|
|
19
19
|
import { PG_CLASSIFIER_RULES } from "../../postgres/plan/rules";
|
|
20
|
-
import { declaredTable, migrationPlanDigest, observeMigration } from "../../postgres/migrate/observe";
|
|
21
|
-
import { backfill, verifyMigration, type BackfillDeps, type BackfillResult, type VerifyResult } from "../../postgres/migrate/backfill";
|
|
20
|
+
import { declaredTable, migrationPlanDigest, MigrationRefusal, observeMigration } from "../../postgres/migrate/observe";
|
|
21
|
+
import { backfill, MigrationVerificationError, verifyMigration, type BackfillDeps, type BackfillResult, type VerifyResult } from "../../postgres/migrate/backfill";
|
|
22
22
|
import {
|
|
23
23
|
carryOver,
|
|
24
24
|
compensate,
|
|
@@ -60,8 +60,33 @@ async function loadConfig(cwd: string): Promise<Pick<ChantConfig, "sql" | "owner
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* With `onFailure: "keep"` (#3687) no onFailure drops what the expand added,
|
|
65
|
+
* so a step that fails says what a person does instead: a refusal or a
|
|
66
|
+
* failed verification would otherwise fail every run after it the same way.
|
|
67
|
+
*/
|
|
68
|
+
const ON_FAILURE_DROPS = "onFailure drops what the expand added, and the next run starts again.";
|
|
69
|
+
|
|
70
|
+
function noting(args: PostgresMigrationArgs, err: unknown): unknown {
|
|
71
|
+
if (args.keepOnFailure && (err instanceof MigrationRefusal || err instanceof MigrationVerificationError)) {
|
|
72
|
+
const kept =
|
|
73
|
+
`This migration keeps what the expand added on failure (onFailure: "keep"), so nothing drops it: ` +
|
|
74
|
+
`to start again from a new column, run the Op once with onFailure: "drop" (the default), whose onFailure drops it when this step fails again.`;
|
|
75
|
+
err.message = err.message.includes(ON_FAILURE_DROPS) ? err.message.replace(ON_FAILURE_DROPS, kept) : `${err.message.replace(/\.?$/, ".")} ${kept}`;
|
|
76
|
+
}
|
|
77
|
+
return err;
|
|
78
|
+
}
|
|
79
|
+
|
|
63
80
|
/** Bind the server, connect and read the declaration, run `body` with it, then close the connection. */
|
|
64
81
|
async function withRun<T>(args: PostgresMigrationArgs, signal: AbortSignal | undefined, deps: PostgresMigrationDeps, body: (run: MigrationRun) => Promise<T>): Promise<T> {
|
|
82
|
+
try {
|
|
83
|
+
return await bound(args, signal, deps, body);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
throw noting(args, err);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function bound<T>(args: PostgresMigrationArgs, signal: AbortSignal | undefined, deps: PostgresMigrationDeps, body: (run: MigrationRun) => Promise<T>): Promise<T> {
|
|
65
90
|
const cwd = args.cwd ?? process.cwd();
|
|
66
91
|
const config = deps.config ?? (await loadConfig(cwd));
|
|
67
92
|
const marker = resolveOwnershipMarker(args, config, "PostgresMigrationOp");
|
|
@@ -31,6 +31,7 @@ const rebuildArgs = z.strictObject({
|
|
|
31
31
|
retain: z.string().optional(),
|
|
32
32
|
mutationTimeout: z.string().optional(),
|
|
33
33
|
replicaTimeout: z.string().optional(),
|
|
34
|
+
cutoverTimeout: z.string().optional(),
|
|
34
35
|
stack: z.string().optional(),
|
|
35
36
|
ownershipEnv: z.string().optional(),
|
|
36
37
|
cwd: z.string().optional(),
|
|
@@ -127,6 +128,7 @@ const migrationArgs = z.strictObject({
|
|
|
127
128
|
stack: z.string().optional(),
|
|
128
129
|
ownershipEnv: z.string().optional(),
|
|
129
130
|
cwd: z.string().optional(),
|
|
131
|
+
keepOnFailure: z.boolean().optional(),
|
|
130
132
|
});
|
|
131
133
|
|
|
132
134
|
const migrationState = z.enum(["migrate", "switched", "done"]);
|
package/src/plugin.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { completions } from "./lsp/completions";
|
|
|
18
18
|
import { hover } from "./lsp/hover";
|
|
19
19
|
import { sqlMcpResources, sqlMcpTools } from "./mcp";
|
|
20
20
|
import { sqlConfigSchema } from "./config";
|
|
21
|
-
import {
|
|
21
|
+
import { SqlFileParser, dialectOption } from "./import-parser";
|
|
22
22
|
import { sqlTemplateGenerator } from "./import-generator";
|
|
23
23
|
import { sqlCommands } from "./clickhouse/plan/commands";
|
|
24
24
|
import { sqlDeepNormalizationHooks } from "./clickhouse/plan/deep";
|
|
@@ -97,15 +97,20 @@ export const sqlPlugin: LexiconPlugin = {
|
|
|
97
97
|
isTag: true,
|
|
98
98
|
description: "A CREATE VIEW or CREATE MATERIALIZED VIEW, parsed into a view entity with its lineage",
|
|
99
99
|
},
|
|
100
|
+
{ name: "dictionary", isTag: true, description: "A ClickHouse CREATE DICTIONARY, parsed into a dictionary entity with its attributes, key, source and layout" },
|
|
101
|
+
{ name: "user", isTag: true, description: "A ClickHouse CREATE USER, parsed into a user entity; its password is the environment's" },
|
|
100
102
|
{ name: "schema", isTag: true, description: "A Postgres CREATE SCHEMA, parsed into a schema entity" },
|
|
101
103
|
{ name: "index", isTag: true, description: "A Postgres CREATE INDEX, parsed into an index entity" },
|
|
102
104
|
{ name: "sequence", isTag: true, description: "A Postgres CREATE SEQUENCE, parsed into a sequence entity" },
|
|
103
105
|
{ name: "type", isTag: true, description: "A Postgres CREATE TYPE ... AS ENUM, parsed into an enum entity" },
|
|
104
106
|
{ name: "domain", isTag: true, description: "A Postgres CREATE DOMAIN, parsed into a domain entity" },
|
|
105
107
|
{ name: "extension", isTag: true, description: "A Postgres CREATE EXTENSION, parsed into an extension entity" },
|
|
106
|
-
{ name: "func", isTag: true, description: "A Postgres CREATE FUNCTION, parsed into a function entity with its body kept verbatim" },
|
|
108
|
+
{ name: "func", isTag: true, description: "A Postgres CREATE FUNCTION, parsed into a function entity with its body kept verbatim, or a ClickHouse CREATE FUNCTION (a named lambda)" },
|
|
107
109
|
{ name: "procedure", isTag: true, description: "A Postgres CREATE PROCEDURE, parsed into a procedure entity with its body kept verbatim" },
|
|
108
110
|
{ name: "trigger", isTag: true, description: "A Postgres CREATE TRIGGER, parsed into a trigger entity on its table" },
|
|
111
|
+
{ name: "policy", isTag: true, description: "A Postgres CREATE POLICY, parsed into a row-level security policy on its table, or a ClickHouse CREATE ROW POLICY" },
|
|
112
|
+
{ name: "role", isTag: true, description: "A Postgres CREATE ROLE without a password or memberships, parsed into a role entity, or a ClickHouse CREATE ROLE" },
|
|
113
|
+
{ name: "grant", isTag: true, description: "A Postgres GRANT, REVOKE or ALTER DEFAULT PRIVILEGES, parsed into the privileges it declares, or a ClickHouse GRANT of privileges or roles" },
|
|
109
114
|
{
|
|
110
115
|
name: "literal",
|
|
111
116
|
isTag: false,
|
|
@@ -152,9 +157,28 @@ export const sqlPlugin: LexiconPlugin = {
|
|
|
152
157
|
return sqlMcpResources();
|
|
153
158
|
},
|
|
154
159
|
|
|
155
|
-
/** `chant import schema.sql`: a file of ClickHouse
|
|
156
|
-
templateParser() {
|
|
157
|
-
return new
|
|
160
|
+
/** `chant import schema.sql`: a file of Postgres or ClickHouse DDL (`./import-parser.ts`). */
|
|
161
|
+
templateParser(options) {
|
|
162
|
+
return new SqlFileParser(dialectOption(options));
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
parserOptions() {
|
|
166
|
+
return [
|
|
167
|
+
{
|
|
168
|
+
name: "dialect",
|
|
169
|
+
type: "string" as const,
|
|
170
|
+
description: "the DDL's dialect, clickhouse or postgres; read off its statements when omitted",
|
|
171
|
+
},
|
|
172
|
+
];
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The `.sql` files in the source directory, read into declarations beside
|
|
177
|
+
* the tagged templates (`./files/build-root.ts`, #3646).
|
|
178
|
+
*/
|
|
179
|
+
async buildRoots(ctx) {
|
|
180
|
+
const { sqlFilesBuildRoot } = await import("./files/build-root");
|
|
181
|
+
return sqlFilesBuildRoot(ctx);
|
|
158
182
|
},
|
|
159
183
|
|
|
160
184
|
/** One generator for both dialects: each IR resource's type says whose declarations it is. */
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access against a live server (#3681): a declared role, a table with
|
|
3
|
+
* row-level security and a policy, grants on a schema, a table, a column and
|
|
4
|
+
* a function, a revoke from PUBLIC, and default privileges, applied where the
|
|
5
|
+
* profile manages access; then a grant and a revoke made by hand are drift
|
|
6
|
+
* the plan reports and an apply undoes; a profile that does not manage access
|
|
7
|
+
* plans none of it; and an import writes the access back as declarations.
|
|
8
|
+
*
|
|
9
|
+
* The server: `CHANT_SQL_E2E_POSTGRES_URL` (a `postgres://user@host:port/db`
|
|
10
|
+
* URL, with `CHANT_SQL_E2E_POSTGRES_PASSWORD`) names a running one, such as
|
|
11
|
+
* the one `chant emulator up --lexicon sql` starts. Without it, a throwaway
|
|
12
|
+
* server at the pin, skipped cleanly when Docker is not available. The test
|
|
13
|
+
* writes only to the schema `chant_e2e_3681` and the roles
|
|
14
|
+
* `chant_e2e_3681_reader` and `chant_e2e_3681_writer`, all dropped afterwards.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
21
|
+
import { dockerAvailable, startTestPostgres, type TestPostgres } from "../testing/server";
|
|
22
|
+
import { connectPostgres, type PostgresClient, type PostgresEndpoint } from "../live/client";
|
|
23
|
+
import { readLiveSchema } from "../live/catalog";
|
|
24
|
+
import { planPgAgainstServer } from "../plan/commands";
|
|
25
|
+
import { postgresApply } from "../../op/activities/postgres-apply";
|
|
26
|
+
import { objectsToIR } from "../import/ir";
|
|
27
|
+
import { PostgresGenerator } from "../import/generator";
|
|
28
|
+
import { importedAccess } from "./import";
|
|
29
|
+
|
|
30
|
+
const S = "chant_e2e_3681";
|
|
31
|
+
const READER = "chant_e2e_3681_reader";
|
|
32
|
+
const WRITER = "chant_e2e_3681_writer";
|
|
33
|
+
const given = process.env.CHANT_SQL_E2E_POSTGRES_URL;
|
|
34
|
+
const enabled = given ? true : await dockerAvailable();
|
|
35
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-pg-access-"));
|
|
36
|
+
let server: TestPostgres | undefined;
|
|
37
|
+
let endpoint: PostgresEndpoint;
|
|
38
|
+
let admin: PostgresClient;
|
|
39
|
+
|
|
40
|
+
const cleanup = async () => {
|
|
41
|
+
await admin.query(`DROP SCHEMA IF EXISTS ${S} CASCADE`);
|
|
42
|
+
for (const r of [READER, WRITER]) await admin.query(`DROP ROLE IF EXISTS ${r}`);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
beforeAll(async () => {
|
|
46
|
+
if (!enabled) return;
|
|
47
|
+
if (given) endpoint = { url: given, password: process.env.CHANT_SQL_E2E_POSTGRES_PASSWORD ?? "" };
|
|
48
|
+
else {
|
|
49
|
+
server = await startTestPostgres();
|
|
50
|
+
endpoint = server.endpoint();
|
|
51
|
+
}
|
|
52
|
+
admin = await connectPostgres(endpoint);
|
|
53
|
+
await cleanup();
|
|
54
|
+
// The writer is the environment's: provisioned outside the declarations, only named in them.
|
|
55
|
+
await admin.query(`CREATE ROLE ${WRITER} NOLOGIN`);
|
|
56
|
+
}, 600_000);
|
|
57
|
+
|
|
58
|
+
afterAll(async () => {
|
|
59
|
+
if (enabled) {
|
|
60
|
+
await cleanup().catch(() => undefined);
|
|
61
|
+
await admin?.end();
|
|
62
|
+
}
|
|
63
|
+
await server?.stop();
|
|
64
|
+
rmSync(dir, { recursive: true, force: true });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const MARKER = { stack: "e2e3681", env: "test" };
|
|
68
|
+
const profile = (access: boolean) => ({
|
|
69
|
+
config: { ownership: MARKER, sql: { profiles: { e2e: { url: endpoint.url, password: { env: "PG_E2E_PASSWORD" }, schemas: [S], ...(access ? { access: true } : {}) } } } },
|
|
70
|
+
env: { PG_E2E_PASSWORD: endpoint.password ?? "" },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
interface Obj {
|
|
74
|
+
export: string;
|
|
75
|
+
type: string;
|
|
76
|
+
ddl: string;
|
|
77
|
+
dependsOn?: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const OBJECTS: Obj[] = [
|
|
81
|
+
{ export: "app", type: "Postgres::Schema", ddl: `CREATE SCHEMA ${S}` },
|
|
82
|
+
{ export: "reader", type: "Postgres::Role", ddl: `CREATE ROLE ${READER} NOLOGIN` },
|
|
83
|
+
{
|
|
84
|
+
export: "orders",
|
|
85
|
+
type: "Postgres::Table",
|
|
86
|
+
dependsOn: ["app"],
|
|
87
|
+
ddl: `CREATE TABLE ${S}.orders (id bigint PRIMARY KEY, tenant text NOT NULL, note varchar(40));\nALTER TABLE ${S}.orders ENABLE ROW LEVEL SECURITY`,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
export: "ordersTenant",
|
|
91
|
+
type: "Postgres::Policy",
|
|
92
|
+
dependsOn: ["orders", "reader"],
|
|
93
|
+
ddl: `CREATE POLICY orders_tenant ON ${S}.orders FOR SELECT TO ${READER} USING (tenant = current_setting('app.tenant', true) AND note <> 'hidden')`,
|
|
94
|
+
},
|
|
95
|
+
{ export: "total", type: "Postgres::Function", dependsOn: ["app"], ddl: `CREATE FUNCTION ${S}.total() RETURNS bigint LANGUAGE sql AS 'SELECT 1::bigint'` },
|
|
96
|
+
{ export: "useApp", type: "Postgres::Grant", dependsOn: ["app", "reader"], ddl: `GRANT USAGE ON SCHEMA ${S} TO ${READER}` },
|
|
97
|
+
{ export: "readOrders", type: "Postgres::Grant", dependsOn: ["orders", "reader"], ddl: `GRANT SELECT ON ${S}.orders TO ${READER}` },
|
|
98
|
+
{ export: "writeNote", type: "Postgres::Grant", dependsOn: ["orders"], ddl: `GRANT UPDATE (note) ON ${S}.orders TO ${WRITER}` },
|
|
99
|
+
{ export: "totalPrivate", type: "Postgres::Grant", dependsOn: ["total"], ddl: `REVOKE EXECUTE ON FUNCTION ${S}.total() FROM PUBLIC` },
|
|
100
|
+
{ export: "totalReader", type: "Postgres::Grant", dependsOn: ["total", "reader"], ddl: `GRANT EXECUTE ON FUNCTION ${S}.total() TO ${READER}` },
|
|
101
|
+
{ export: "readNewTables", type: "Postgres::DefaultPrivileges", dependsOn: ["app", "reader"], ddl: `ALTER DEFAULT PRIVILEGES IN SCHEMA ${S} GRANT SELECT ON TABLES TO ${READER}` },
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
function writeBuild(file: string, objects: Obj[]): string {
|
|
105
|
+
const path = join(dir, file);
|
|
106
|
+
writeFileSync(path, JSON.stringify({ dialect: "postgres", postgresMajor: 18, objects: objects.map((o) => ({ dependsOn: [], ...o })) }));
|
|
107
|
+
return path;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const apply = (path: string, access = true) => postgresApply({ buildPath: path, environment: "e2e" }, undefined, { ...profile(access), log: () => undefined });
|
|
111
|
+
const plan = (path: string, access = true) => planPgAgainstServer("e2e", path, profile(access));
|
|
112
|
+
const rules = async (path: string) => (await plan(path)).changes.map((c) => [c.object, c.rule]);
|
|
113
|
+
|
|
114
|
+
describe.skipIf(!enabled)("access on a live server", () => {
|
|
115
|
+
test("applied, then drift by hand reported and undone, unmanaged left alone, imported back", async () => {
|
|
116
|
+
const build = writeBuild("v1.json", OBJECTS);
|
|
117
|
+
|
|
118
|
+
// A profile that does not manage access: the access declarations are filtered, the table is created without row-level security.
|
|
119
|
+
const off = await apply(build, false);
|
|
120
|
+
expect(off.failed).toEqual([]);
|
|
121
|
+
expect(off.notAttempted.map((n) => n.reason)).toEqual(Array(8).fill("filtered"));
|
|
122
|
+
expect(off.notAttempted).toHaveLength(8);
|
|
123
|
+
expect((await admin.query<{ rls: boolean }>(`SELECT relrowsecurity AS rls FROM pg_class WHERE oid = '${S}.orders'::regclass`))[0]!.rls).toBe(false);
|
|
124
|
+
const unmanaged = await plan(build, false);
|
|
125
|
+
expect(unmanaged.changes).toEqual([]);
|
|
126
|
+
expect(unmanaged.hints.join(" ")).toContain("9 access declarations are not planned");
|
|
127
|
+
|
|
128
|
+
// Managed: every access declaration applied.
|
|
129
|
+
expect(await rules(build)).toEqual([
|
|
130
|
+
[`reader (${READER})`, "SQLPG200"],
|
|
131
|
+
[`orders (${S}.orders)`, "SQLPG293"],
|
|
132
|
+
[`ordersTenant (${S}.orders_tenant ON ${S}.orders)`, "SQLPG290"],
|
|
133
|
+
[`default privileges in ${S} on tables TO ${READER}`, "SQLPG298"],
|
|
134
|
+
[`relation ${S}.orders (note) TO ${WRITER}`, "SQLPG296"],
|
|
135
|
+
[`relation ${S}.orders TO ${READER}`, "SQLPG296"],
|
|
136
|
+
[`routine ${S}.total() TO PUBLIC`, "SQLPG297"],
|
|
137
|
+
[`routine ${S}.total() TO ${READER}`, "SQLPG296"],
|
|
138
|
+
[`schema ${S} TO ${READER}`, "SQLPG296"],
|
|
139
|
+
]);
|
|
140
|
+
const on = await apply(build);
|
|
141
|
+
expect(on.failed).toEqual([]);
|
|
142
|
+
expect(on.applied.map((a) => [a.name, a.action])).toEqual([
|
|
143
|
+
[S, "unchanged"],
|
|
144
|
+
[`${S}.total()`, "unchanged"],
|
|
145
|
+
[READER, "created"],
|
|
146
|
+
[`${S}.orders`, "updated"],
|
|
147
|
+
[`orders_tenant ON ${S}.orders`, "created"],
|
|
148
|
+
[`grant usage on schema ${S} to ${READER}`, "updated"],
|
|
149
|
+
[`grant select on table ${S}.orders to ${READER}`, "updated"],
|
|
150
|
+
[`grant update(note) on table ${S}.orders to ${WRITER}`, "updated"],
|
|
151
|
+
[`revoke execute on function ${S}.total() from public`, "updated"],
|
|
152
|
+
[`grant execute on function ${S}.total() to ${READER}`, "updated"],
|
|
153
|
+
[`default privileges grant select on tables in ${S} to ${READER}`, "updated"],
|
|
154
|
+
]);
|
|
155
|
+
expect(await rules(build)).toEqual([]);
|
|
156
|
+
const can = async (sql: string) => (await admin.query<{ ok: boolean }>(sql))[0]!.ok;
|
|
157
|
+
expect(await can(`SELECT has_table_privilege('${READER}', '${S}.orders', 'SELECT') AS ok`)).toBe(true);
|
|
158
|
+
expect(await can(`SELECT has_column_privilege('${WRITER}', '${S}.orders', 'note', 'UPDATE') AS ok`)).toBe(true);
|
|
159
|
+
expect(await can(`SELECT has_function_privilege('public', '${S}.total()', 'EXECUTE') AS ok`)).toBe(false);
|
|
160
|
+
expect(await can(`SELECT relrowsecurity AS ok FROM pg_class WHERE oid = '${S}.orders'::regclass`)).toBe(true);
|
|
161
|
+
|
|
162
|
+
// By hand: a grant nobody declared, and a declared grant revoked. The plan reports both; an apply undoes both.
|
|
163
|
+
await admin.query(`GRANT INSERT ON ${S}.orders TO ${WRITER}`);
|
|
164
|
+
await admin.query(`REVOKE SELECT ON ${S}.orders FROM ${READER}`);
|
|
165
|
+
expect(await rules(build)).toEqual([
|
|
166
|
+
[`relation ${S}.orders TO ${READER}`, "SQLPG296"],
|
|
167
|
+
[`relation ${S}.orders TO ${WRITER}`, "SQLPG297"],
|
|
168
|
+
]);
|
|
169
|
+
const undo = await apply(build);
|
|
170
|
+
expect(undo.failed).toEqual([]);
|
|
171
|
+
expect(undo.statements.map((s) => s.sql)).toEqual(expect.arrayContaining([`GRANT SELECT ON TABLE ${S}.orders TO ${READER}`, `REVOKE INSERT ON TABLE ${S}.orders FROM ${WRITER}`]));
|
|
172
|
+
expect(await rules(build)).toEqual([]);
|
|
173
|
+
|
|
174
|
+
// A policy changed by hand is drift too.
|
|
175
|
+
await admin.query(`ALTER POLICY orders_tenant ON ${S}.orders USING (true)`);
|
|
176
|
+
expect(await rules(build)).toEqual([[`ordersTenant (${S}.orders_tenant ON ${S}.orders)`, "SQLPG291"]]);
|
|
177
|
+
await apply(build);
|
|
178
|
+
expect(await rules(build)).toEqual([]);
|
|
179
|
+
|
|
180
|
+
// Import: the policy and the row-level security with the table, the privileges as grant declarations.
|
|
181
|
+
const live = await readLiveSchema(admin, { schemas: [S], access: true });
|
|
182
|
+
const access = await importedAccess(admin, live, [S]);
|
|
183
|
+
const [file] = new PostgresGenerator().generate(objectsToIR([...live.map((o) => ({ type: o.type, schema: o.schema, name: o.name, ddl: o.statement })), ...access]));
|
|
184
|
+
expect(file!.content).toContain("export const ordersTenant = policy`");
|
|
185
|
+
expect(file!.content).toContain(`ALTER TABLE \${chantE2e3681Schema}.orders ENABLE ROW LEVEL SECURITY`);
|
|
186
|
+
expect(file!.content).toContain(`GRANT SELECT ON TABLE \${orders} TO ${READER}`);
|
|
187
|
+
expect(file!.content).toContain(`REVOKE EXECUTE ON FUNCTION \${total}() FROM PUBLIC`);
|
|
188
|
+
expect(file!.content).toContain(`ALTER DEFAULT PRIVILEGES IN SCHEMA \${chantE2e3681Schema} GRANT SELECT ON TABLES TO ${READER}`);
|
|
189
|
+
expect(file!.content).toContain(`GRANT USAGE ON SCHEMA \${chantE2e3681Schema} TO ${READER}`);
|
|
190
|
+
}, 600_000);
|
|
191
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row-level security, roles, grants and default privileges (#3681): what the
|
|
3
|
+
* tags parse and refuse, how the declarations add up to access, and how a
|
|
4
|
+
* change is classified and made.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "vitest";
|
|
8
|
+
import { func, grant, policy, role, schema, table } from "../entities";
|
|
9
|
+
import { diffObject, keyedByQualifiedName, pgSchemaFromLive, type PgSchemaObject } from "../plan/schema";
|
|
10
|
+
import { diffPgSchemas, matchPgObjects } from "../plan/diff";
|
|
11
|
+
import { accessScoped } from "../plan/commands";
|
|
12
|
+
import { alterSteps } from "../apply/statements";
|
|
13
|
+
import { declaredObjects } from "../apply/apply";
|
|
14
|
+
import type { LivePgObject } from "../live/catalog";
|
|
15
|
+
import { declaredAccess, diffAccess } from "./acl";
|
|
16
|
+
|
|
17
|
+
const app = schema`CREATE SCHEMA app`;
|
|
18
|
+
const reader = role`CREATE ROLE app_reader NOLOGIN`;
|
|
19
|
+
const orders = table`
|
|
20
|
+
CREATE TABLE ${app}.orders (id bigint PRIMARY KEY, tenant text NOT NULL, note text);
|
|
21
|
+
ALTER TABLE ${app}.orders ENABLE ROW LEVEL SECURITY;
|
|
22
|
+
ALTER TABLE ${app}.orders FORCE ROW LEVEL SECURITY`;
|
|
23
|
+
|
|
24
|
+
describe("the tags", () => {
|
|
25
|
+
test("a table's row-level security, a policy, a role", () => {
|
|
26
|
+
expect(orders.props).toMatchObject({ rowSecurity: true, forceRowSecurity: true });
|
|
27
|
+
const p = policy`CREATE POLICY tenant_rows ON ${orders} AS RESTRICTIVE FOR SELECT TO ${reader}, PUBLIC USING (${orders.columns.tenant} = current_setting('app.tenant'))`;
|
|
28
|
+
expect(p.entityType).toBe("Postgres::Policy");
|
|
29
|
+
expect(p.props).toMatchObject({ schema: "app", name: "tenant_rows", tableName: "app.orders", permissive: false, command: "select", roles: ["app_reader", "public"], using: "tenant = current_setting('app.tenant')" });
|
|
30
|
+
expect(p.dependsOn).toEqual(expect.arrayContaining([orders, reader]));
|
|
31
|
+
expect(reader.props).toMatchObject({ name: "app_reader", options: ["nologin"] });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("grants on a schema, a table's columns and a routine, a revoke, default privileges", () => {
|
|
35
|
+
const f = func`CREATE FUNCTION ${app}.total(n int) RETURNS bigint LANGUAGE sql AS 'select 1'`;
|
|
36
|
+
expect(grant`GRANT USAGE ON SCHEMA ${app} TO ${reader}`.props).toMatchObject({ action: "grant", on: "schema", objectNames: ["app"], grantees: ["app_reader"] });
|
|
37
|
+
expect(grant`GRANT SELECT, UPDATE (note) ON ${orders} TO writer WITH GRANT OPTION`.props).toMatchObject({
|
|
38
|
+
on: "table",
|
|
39
|
+
privileges: [{ privilege: "select" }, { privilege: "update", columns: ["note"] }],
|
|
40
|
+
withGrantOption: true,
|
|
41
|
+
});
|
|
42
|
+
expect(grant`REVOKE EXECUTE ON FUNCTION ${f} FROM PUBLIC`.props).toMatchObject({ action: "revoke", objectNames: ["app.total(int)"], grantees: ["public"] });
|
|
43
|
+
const d = grant`ALTER DEFAULT PRIVILEGES IN SCHEMA ${app} GRANT SELECT ON TABLES TO ${reader}`;
|
|
44
|
+
expect(d.entityType).toBe("Postgres::DefaultPrivileges");
|
|
45
|
+
expect(d.props).toMatchObject({ inSchemas: ["app"], on: "tables", privileges: ["select"] });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("secrets, memberships, ALL ... IN SCHEMA and an unnamed overload are refused", () => {
|
|
49
|
+
expect(() => role`CREATE ROLE r LOGIN PASSWORD 'x'`).toThrow(/environment's secret/);
|
|
50
|
+
expect(() => role`CREATE ROLE r IN ROLE admins`).toThrow(/environment's/);
|
|
51
|
+
expect(() => grant`GRANT admins TO r`).toThrow(/membership/);
|
|
52
|
+
expect(() => grant`GRANT SELECT ON ALL TABLES IN SCHEMA app TO r`).toThrow(/ALTER DEFAULT PRIVILEGES/);
|
|
53
|
+
expect(() => grant`GRANT EXECUTE ON FUNCTION app.f TO r`).toThrow(/parameter types/);
|
|
54
|
+
expect(() => table`ALTER TABLE app.t ENABLE ROW LEVEL SECURITY`).toThrow(/after the table's CREATE TABLE/);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── Access ─────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
const decl = (type: string, ddl: string, key = ddl): PgSchemaObject => ({ key, canonical: { ...diffObject(type, ddl, "public"), exportName: key } });
|
|
61
|
+
const T = (ddl: string) => decl("Postgres::Table", ddl);
|
|
62
|
+
const F = (ddl: string) => decl("Postgres::Function", ddl);
|
|
63
|
+
const G = (ddl: string) => decl("Postgres::Grant", ddl);
|
|
64
|
+
const D = (ddl: string) => decl("Postgres::DefaultPrivileges", ddl);
|
|
65
|
+
|
|
66
|
+
const entries = (objects: PgSchemaObject[]) =>
|
|
67
|
+
Object.fromEntries([...declaredAccess(objects, { major: 18 }).state].map(([k, e]) => [k, [...e.privileges].map(([p, g]) => `${p}${g ? "*" : ""}`).sort().join(",")]));
|
|
68
|
+
|
|
69
|
+
describe("the access declarations add up to", () => {
|
|
70
|
+
test("a routine's EXECUTE to PUBLIC unless revoked; grants added, revokes taken away, in order", () => {
|
|
71
|
+
expect(entries([F("CREATE FUNCTION app.f() RETURNS int LANGUAGE sql AS 'select 1'")])).toEqual({ "routine app.f() TO PUBLIC": "execute" });
|
|
72
|
+
expect(
|
|
73
|
+
entries([
|
|
74
|
+
F("CREATE FUNCTION app.f() RETURNS int LANGUAGE sql AS 'select 1'"),
|
|
75
|
+
T("CREATE TABLE app.t (a int, b int)"),
|
|
76
|
+
G("REVOKE EXECUTE ON FUNCTION app.f() FROM PUBLIC"),
|
|
77
|
+
G("GRANT ALL ON app.t TO r WITH GRANT OPTION"),
|
|
78
|
+
G("REVOKE GRANT OPTION FOR DELETE ON app.t FROM r"),
|
|
79
|
+
G("REVOKE TRUNCATE, MAINTAIN ON app.t FROM r"),
|
|
80
|
+
G("GRANT SELECT (b) ON app.t TO s"),
|
|
81
|
+
]),
|
|
82
|
+
).toEqual({ "relation app.t TO r": "delete,insert*,references*,select*,trigger*,update*", "relation app.t (b) TO s": "select" });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("default privileges are compared themselves, and given to the objects chant creates", () => {
|
|
86
|
+
expect(
|
|
87
|
+
entries([
|
|
88
|
+
T("CREATE TABLE app.t (a int)"),
|
|
89
|
+
D("ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT SELECT ON TABLES TO r"),
|
|
90
|
+
D("ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC"),
|
|
91
|
+
F("CREATE FUNCTION app.f() RETURNS int LANGUAGE sql AS 'select 1'"),
|
|
92
|
+
]),
|
|
93
|
+
).toEqual({ "default privileges in app on tables TO r": "select", "relation app.t TO r": "select" });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a change is a GRANT, a REVOKE, or ALTER DEFAULT PRIVILEGES", () => {
|
|
97
|
+
const before = declaredAccess([T("CREATE TABLE app.t (a int)"), G("GRANT SELECT, INSERT ON app.t TO r")], { major: 18 });
|
|
98
|
+
const after = declaredAccess([T("CREATE TABLE app.t (a int)"), G("GRANT SELECT ON app.t TO r WITH GRANT OPTION"), G("GRANT USAGE ON SCHEMA app TO r"), D("ALTER DEFAULT PRIVILEGES FOR ROLE owner IN SCHEMA app GRANT SELECT ON TABLES TO r")], { major: 18 });
|
|
99
|
+
expect(diffAccess(before.state, after.state).map((c) => [c.change.object, c.change.rule, c.sql])).toEqual([
|
|
100
|
+
["acl default privileges for owner in app on tables TO r", "SQLPG298", ["ALTER DEFAULT PRIVILEGES FOR ROLE owner IN SCHEMA app GRANT SELECT ON TABLES TO r"]],
|
|
101
|
+
["acl relation app.t TO r", "SQLPG296", ["REVOKE INSERT ON TABLE app.t FROM r", "GRANT SELECT ON TABLE app.t TO r WITH GRANT OPTION"]],
|
|
102
|
+
["acl schema app TO r", "SQLPG296", ["GRANT USAGE ON SCHEMA app TO r"]],
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("between two builds, a new function's EXECUTE to PUBLIC is not a change", () => {
|
|
107
|
+
const f = "CREATE FUNCTION app.f() RETURNS int LANGUAGE sql AS 'select 1'";
|
|
108
|
+
expect(diffPgSchemas([], [F(f)]).changes.map((c) => c.rule)).toEqual(["SQLPG200"]);
|
|
109
|
+
expect(diffPgSchemas([], [F(f), G("REVOKE EXECUTE ON FUNCTION app.f() FROM PUBLIC")]).changes.map((c) => [c.object, c.rule])).toEqual([
|
|
110
|
+
[f, "SQLPG200"],
|
|
111
|
+
["acl routine app.f() TO PUBLIC", "SQLPG297"],
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a profile that does not manage access leaves out the access declarations and a table's row-level security", () => {
|
|
116
|
+
const { kept, unmanaged } = accessScoped(keyedByQualifiedName([T("CREATE TABLE app.t (a int);\nALTER TABLE app.t ENABLE ROW LEVEL SECURITY"), G("GRANT SELECT ON app.t TO r")]), false);
|
|
117
|
+
expect(unmanaged).toBe(2);
|
|
118
|
+
expect(kept.map((o) => [o.canonical.kind, o.canonical.fields.rowSecurity])).toEqual([["table", undefined]]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── Policies, row-level security, roles ────────────────────────────────
|
|
123
|
+
|
|
124
|
+
const live = (type: string, statement: string) => pgSchemaFromLive([{ type, statement, name: "", oid: "1" } as LivePgObject], "public");
|
|
125
|
+
function steps(type: string, liveDdl: string, declaredDdl: string): string[] {
|
|
126
|
+
const [obj] = declaredObjects(JSON.stringify({ dialect: "postgres", objects: [{ export: "x", type, ddl: declaredDdl, dependsOn: [] }] }));
|
|
127
|
+
const after = keyedByQualifiedName([{ key: "x", canonical: obj!.canonical }]);
|
|
128
|
+
const before = live(type, liveDdl);
|
|
129
|
+
const m = matchPgObjects(before, after).find((x) => x.kind === "matched")!;
|
|
130
|
+
return alterSteps(obj!, m.before!.canonical, diffPgSchemas(before, after).changes, { major: 18 }).steps.map((s) => s.sql);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
describe("the classifier and the statements", () => {
|
|
134
|
+
const P = "Postgres::Policy";
|
|
135
|
+
test("a policy's roles and expressions are ALTER POLICY; its command is a drop and a create", () => {
|
|
136
|
+
const p1 = "CREATE POLICY p ON app.t FOR SELECT TO r USING (a > 0)";
|
|
137
|
+
expect(steps(P, p1, "CREATE POLICY p ON app.t FOR SELECT TO r, s USING (a > 1)")).toEqual(["ALTER POLICY p ON app.t TO r, s USING (a > 1)"]);
|
|
138
|
+
expect(steps(P, p1, "CREATE POLICY p ON app.t FOR UPDATE TO r USING (a > 0)").slice(0, 2)).toEqual(["DROP POLICY p ON app.t", "CREATE POLICY p ON app.t FOR UPDATE TO r USING (a > 0)"]);
|
|
139
|
+
expect(diffPgSchemas([], keyedByQualifiedName([decl(P, p1)])).changes.map((c) => c.rule)).toEqual(["SQLPG290"]);
|
|
140
|
+
expect(diffPgSchemas(live(P, p1), []).changes.map((c) => c.rule)).toEqual(["SQLPG292"]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("row-level security toggled, and a role's attributes changed", () => {
|
|
144
|
+
expect(steps("Postgres::Table", "CREATE TABLE app.t (a int)", "CREATE TABLE app.t (a int);\nALTER TABLE app.t ENABLE ROW LEVEL SECURITY")).toEqual(["ALTER TABLE app.t ENABLE ROW LEVEL SECURITY"]);
|
|
145
|
+
expect(steps("Postgres::Role", "CREATE ROLE r", "CREATE ROLE r LOGIN NOINHERIT CONNECTION LIMIT 5")).toEqual(["ALTER ROLE r WITH LOGIN NOINHERIT CONNECTION LIMIT 5"]);
|
|
146
|
+
});
|
|
147
|
+
});
|