@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
|
@@ -84,6 +84,9 @@ import type { PgChangeClass } from "../plan/rules";
|
|
|
84
84
|
import type { CanonicalKind } from "../plan/normalize";
|
|
85
85
|
import { quoteIdent } from "../keywords";
|
|
86
86
|
import { planPgStatements, scansRows, type DeclaredPgObject, type PgDropStatement, type PgStep } from "./statements";
|
|
87
|
+
import { ACCESS_KINDS, ACCESS_MANAGED_KINDS } from "../plan/normalize";
|
|
88
|
+
import { declaredAccess, diffAccess, targetKey } from "../access/acl";
|
|
89
|
+
import { readLiveAccess } from "../access/live";
|
|
87
90
|
|
|
88
91
|
/** The timeouts a statement runs with, in milliseconds; 0 is no limit. */
|
|
89
92
|
export interface PostgresApplyTimeouts {
|
|
@@ -144,9 +147,9 @@ export class PostgresApplyError extends SqlApplyError<PostgresApplyOutcome> {
|
|
|
144
147
|
* (`app.touch()`), a trigger's with its table (`touch ON app.users`).
|
|
145
148
|
*/
|
|
146
149
|
export const refName = (o: { kind: CanonicalKind; schema?: string; name: string; signature?: string }): string =>
|
|
147
|
-
o.kind === "trigger"
|
|
150
|
+
o.kind === "trigger" || o.kind === "policy"
|
|
148
151
|
? `${o.name}${o.signature ?? ""}`
|
|
149
|
-
: o.kind === "schema" || o.kind === "extension" || o.schema === undefined
|
|
152
|
+
: o.kind === "schema" || o.kind === "extension" || o.kind === "role" || o.schema === undefined
|
|
150
153
|
? o.name
|
|
151
154
|
: `${o.schema}.${o.name}${o.kind === "function" || o.kind === "procedure" ? (o.signature ?? "") : ""}`;
|
|
152
155
|
|
|
@@ -259,7 +262,21 @@ export async function applyPostgres(
|
|
|
259
262
|
});
|
|
260
263
|
const normalizedByKey = new Map(plan.declared.map((o) => [o.key, o.canonical]));
|
|
261
264
|
const liveRawByKey = new Map(plan.liveObjects.map((o) => [qualifiedKey(liveCanonicalAddress(o)), o]));
|
|
262
|
-
const
|
|
265
|
+
const managed = target.access === true;
|
|
266
|
+
// Access declarations a profile does not manage are not applied; grants and default privileges run after every object, as access.
|
|
267
|
+
for (const o of declared) {
|
|
268
|
+
if (!managed && ACCESS_MANAGED_KINDS.has(o.canonical.kind)) {
|
|
269
|
+
outcome.notAttempted.push({
|
|
270
|
+
kind: o.type,
|
|
271
|
+
name: o.name,
|
|
272
|
+
reason: "filtered",
|
|
273
|
+
detail: `access is not managed in this environment: set sql.profiles.<env>.access to plan and apply policies, roles, grants and row-level security`,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const objects = declared
|
|
278
|
+
.filter((o) => !ACCESS_KINDS.has(o.canonical.kind) && (managed || !ACCESS_MANAGED_KINDS.has(o.canonical.kind)))
|
|
279
|
+
.map((original) => ({ ...original, canonical: { ...original.canonical, ...normalizedByKey.get(original.key) } }));
|
|
263
280
|
const matchedLive = new Set<string>();
|
|
264
281
|
for (const m of matchPgObjects(plan.live, plan.declared)) if (m.kind === "matched") matchedLive.add(m.after.key);
|
|
265
282
|
|
|
@@ -275,6 +292,7 @@ export async function applyPostgres(
|
|
|
275
292
|
allowDestructive: opts.prune === true,
|
|
276
293
|
carriesMarker: (_live, key) => carriesMarker(liveRawByKey.get(key)?.comment, opts.marker),
|
|
277
294
|
extensionComment: (name) => extensionComments.get(name),
|
|
295
|
+
access: managed,
|
|
278
296
|
...(opts.marker ? { marker: opts.marker } : {}),
|
|
279
297
|
});
|
|
280
298
|
|
|
@@ -436,11 +454,102 @@ export async function applyPostgres(
|
|
|
436
454
|
}
|
|
437
455
|
await commit();
|
|
438
456
|
|
|
457
|
+
if (managed) await applyAccess(client, declared, onServer, exportNames, { ...opts, major: plan.major ?? opts.major ?? POSTGRES_LATEST_MAJOR, ...(plan.scope ? { schemas: plan.scope } : {}) }, timeouts, outcome, log);
|
|
458
|
+
|
|
439
459
|
if (opts.prune) await prune(client, statements.drops, liveRawByKey, opts, timeouts, outcome, log);
|
|
440
460
|
if (outcome.failed.length > 0) throw new PostgresApplyError(outcome);
|
|
441
461
|
return outcome;
|
|
442
462
|
}
|
|
443
463
|
|
|
464
|
+
/**
|
|
465
|
+
* Access, after every object (#3681): the privileges and default privileges
|
|
466
|
+
* the declarations add up to, against what the server holds now that the
|
|
467
|
+
* objects exist, made in one transaction. A grant or default-privileges
|
|
468
|
+
* declaration is `updated` when a statement ran for it and `unchanged`
|
|
469
|
+
* otherwise; a privilege nothing declares is revoked under the object's name.
|
|
470
|
+
*/
|
|
471
|
+
async function applyAccess(
|
|
472
|
+
client: PostgresClient,
|
|
473
|
+
declared: readonly DeclaredPgObject[],
|
|
474
|
+
onServer: ReadonlySet<string>,
|
|
475
|
+
exportNames: ReadonlySet<string>,
|
|
476
|
+
opts: PostgresApplyOptions & { major: number; schemas?: string[] },
|
|
477
|
+
timeouts: PostgresApplyTimeouts,
|
|
478
|
+
outcome: PostgresApplyOutcome,
|
|
479
|
+
log: (line: string) => void,
|
|
480
|
+
): Promise<void> {
|
|
481
|
+
const decls = declared.filter((o) => ACCESS_KINDS.has(o.canonical.kind));
|
|
482
|
+
const ref = (o: DeclaredPgObject) => ({ kind: o.type, name: o.canonical.name });
|
|
483
|
+
const ready: DeclaredPgObject[] = [];
|
|
484
|
+
for (const o of decls) {
|
|
485
|
+
const missing = missingDependencies(o.exportName, o.dependsOn, exportNames, onServer);
|
|
486
|
+
if (missing.length > 0) outcome.notAttempted.push({ ...ref(o), reason: "dependency-failed", detail: dependencyFailedDetail(missing) });
|
|
487
|
+
else ready.push(o);
|
|
488
|
+
}
|
|
489
|
+
const skipped = new Set(decls.filter((o) => !ready.includes(o)).map((o) => o.exportName));
|
|
490
|
+
const [me] = await client.query<{ self: string }>("SELECT current_user AS self");
|
|
491
|
+
const want = declaredAccess(
|
|
492
|
+
declared.filter((o) => !skipped.has(o.exportName)).map((o) => ({ key: o.exportName, canonical: o.canonical })),
|
|
493
|
+
{ major: opts.major, ...(me?.self ? { self: me.self } : {}) },
|
|
494
|
+
);
|
|
495
|
+
const have = await readLiveAccess(client, want.targets, {
|
|
496
|
+
...(opts.schemas ? { schemas: opts.schemas } : {}),
|
|
497
|
+
forRoles: want.forRoles,
|
|
498
|
+
declaredDefaults: want.targets.filter((t) => t.kind === "default"),
|
|
499
|
+
});
|
|
500
|
+
// A target that is not on the server (its object failed) is left for the next apply.
|
|
501
|
+
const changes = diffAccess(have.state, want.state, want.exportsOf).filter(
|
|
502
|
+
(c) => c.target.kind === "default" || have.present.has(targetKey(c.target.kind === "column" ? { kind: "table", name: c.target.name } : c.target)),
|
|
503
|
+
);
|
|
504
|
+
const ranFor = new Map<string, string[]>();
|
|
505
|
+
if (changes.length > 0) {
|
|
506
|
+
const t = { lockTimeoutMs: timeouts.lockTimeoutMs, statementTimeoutMs: timeouts.statementTimeoutMs };
|
|
507
|
+
const number = outcome.transactions.length;
|
|
508
|
+
const members = new Set<string>();
|
|
509
|
+
log("BEGIN");
|
|
510
|
+
await client.query("BEGIN");
|
|
511
|
+
try {
|
|
512
|
+
for (const set of [`SET LOCAL lock_timeout = ${ms(t.lockTimeoutMs)}`, `SET LOCAL statement_timeout = ${ms(t.statementTimeoutMs)}`]) {
|
|
513
|
+
log(set);
|
|
514
|
+
await client.query(set);
|
|
515
|
+
}
|
|
516
|
+
for (const c of changes) {
|
|
517
|
+
const object = c.exports[0] !== undefined ? (declared.find((o) => o.exportName === c.exports[0])?.canonical.name ?? c.change.object) : c.change.object.slice(4);
|
|
518
|
+
members.add(object);
|
|
519
|
+
for (const sql of c.sql) {
|
|
520
|
+
opts.signal?.throwIfAborted();
|
|
521
|
+
log(sql);
|
|
522
|
+
outcome.statements.push({ object, sql, class: c.change.class, ...t, transaction: number });
|
|
523
|
+
await client.query(sql);
|
|
524
|
+
for (const e of c.exports) ranFor.set(e, [...(ranFor.get(e) ?? []), sql]);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
log("COMMIT");
|
|
528
|
+
await client.query("COMMIT");
|
|
529
|
+
outcome.transactions.push({ objects: [...members], result: "committed" });
|
|
530
|
+
} catch (err) {
|
|
531
|
+
if (opts.signal?.aborted) {
|
|
532
|
+
await client.query("ROLLBACK").catch(() => undefined);
|
|
533
|
+
throw err;
|
|
534
|
+
}
|
|
535
|
+
const error = errorText(err, t);
|
|
536
|
+
const failedAt = outcome.statements[outcome.statements.length - 1]?.sql ?? "BEGIN";
|
|
537
|
+
log("ROLLBACK");
|
|
538
|
+
await client.query("ROLLBACK").catch(() => undefined);
|
|
539
|
+
outcome.transactions.push({ objects: [...members], result: "rolled-back", failedAt: { sql: failedAt, error } });
|
|
540
|
+
for (const o of ready) {
|
|
541
|
+
if (changes.some((c) => c.exports.includes(o.exportName))) outcome.failed.push({ ...ref(o), error: `${error}, in the access transaction at ${failedAt}`, statements: [] });
|
|
542
|
+
else outcome.applied.push({ ...ref(o), action: "unchanged", statements: [] });
|
|
543
|
+
}
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
for (const o of ready) {
|
|
548
|
+
const ran = ranFor.get(o.exportName) ?? [];
|
|
549
|
+
outcome.applied.push({ ...ref(o), action: ran.length > 0 ? "updated" : "unchanged", statements: ran });
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
444
553
|
/** The address a live object is keyed by in the diff. */
|
|
445
554
|
function liveCanonicalAddress(o: LivePgObject): { kind: CanonicalKind; schema?: string; name: string; signature?: string } {
|
|
446
555
|
const kind = (
|
|
@@ -457,11 +566,15 @@ function liveCanonicalAddress(o: LivePgObject): { kind: CanonicalKind; schema?:
|
|
|
457
566
|
"Postgres::Function": "function",
|
|
458
567
|
"Postgres::Procedure": "procedure",
|
|
459
568
|
"Postgres::Trigger": "trigger",
|
|
569
|
+
"Postgres::Policy": "policy",
|
|
570
|
+
"Postgres::Role": "role",
|
|
571
|
+
"Postgres::Grant": "grant",
|
|
572
|
+
"Postgres::DefaultPrivileges": "defaultPrivileges",
|
|
460
573
|
} as const
|
|
461
574
|
)[o.type];
|
|
462
575
|
return {
|
|
463
576
|
kind,
|
|
464
|
-
...(o.schema !== undefined && kind !== "schema" && kind !== "extension" ? { schema: o.schema } : {}),
|
|
577
|
+
...(o.schema !== undefined && kind !== "schema" && kind !== "extension" && kind !== "role" ? { schema: o.schema } : {}),
|
|
465
578
|
name: o.name,
|
|
466
579
|
...(o.signature !== undefined ? { signature: o.signature } : {}),
|
|
467
580
|
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A CHECK or a foreign key added to a table that exists, against a live
|
|
3
|
+
* server (#3703).
|
|
4
|
+
*
|
|
5
|
+
* - The applier adds each one NOT VALID, then validates it in a transaction
|
|
6
|
+
* of its own, and both end valid.
|
|
7
|
+
* - Step by step, each statement's transaction held open: a concurrent
|
|
8
|
+
* writer is not blocked while VALIDATE reads the 200,000 rows, and the
|
|
9
|
+
* probe is shown to detect locks by being blocked during the NOT VALID add.
|
|
10
|
+
* - A run that stopped after the NOT VALID add resumes at VALIDATE.
|
|
11
|
+
*
|
|
12
|
+
* The server: `CHANT_SQL_E2E_POSTGRES_URL` (with
|
|
13
|
+
* `CHANT_SQL_E2E_POSTGRES_PASSWORD`) names a running one, else a throwaway
|
|
14
|
+
* server at the pin, skipped when Docker is not available. The test writes
|
|
15
|
+
* only to the schema `chant_e2e_3703`, dropped afterwards.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import pg from "pg";
|
|
22
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
23
|
+
import { dockerAvailable, startTestPostgres, type TestPostgres } from "../testing/server";
|
|
24
|
+
import { connectPostgres, type PostgresClient, type PostgresEndpoint } from "../live/client";
|
|
25
|
+
import { postgresApply } from "../../op/activities/postgres-apply";
|
|
26
|
+
import { diffStatements, type StatementStep } from "../../migration-statements";
|
|
27
|
+
|
|
28
|
+
const S = "chant_e2e_3703";
|
|
29
|
+
const given = process.env.CHANT_SQL_E2E_POSTGRES_URL;
|
|
30
|
+
const enabled = given ? true : await dockerAvailable();
|
|
31
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-pg-constraints-"));
|
|
32
|
+
let server: TestPostgres | undefined;
|
|
33
|
+
let endpoint: PostgresEndpoint;
|
|
34
|
+
let admin: PostgresClient;
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
if (!enabled) return;
|
|
38
|
+
if (given) endpoint = { url: given, password: process.env.CHANT_SQL_E2E_POSTGRES_PASSWORD ?? "" };
|
|
39
|
+
else {
|
|
40
|
+
server = await startTestPostgres();
|
|
41
|
+
endpoint = server.endpoint();
|
|
42
|
+
}
|
|
43
|
+
admin = await connectPostgres(endpoint);
|
|
44
|
+
await admin.query(`DROP SCHEMA IF EXISTS ${S} CASCADE`);
|
|
45
|
+
}, 600_000);
|
|
46
|
+
|
|
47
|
+
afterAll(async () => {
|
|
48
|
+
if (enabled) {
|
|
49
|
+
await admin?.query(`DROP SCHEMA IF EXISTS ${S} CASCADE`).catch(() => undefined);
|
|
50
|
+
await admin?.end();
|
|
51
|
+
}
|
|
52
|
+
await server?.stop();
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
interface Obj {
|
|
57
|
+
export: string;
|
|
58
|
+
type: string;
|
|
59
|
+
ddl: string;
|
|
60
|
+
dependsOn?: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const doc = (objects: Obj[]) => ({ dialect: "postgres", postgresMajor: 18, objects: objects.map((o) => ({ dependsOn: [], ...o })) });
|
|
64
|
+
const schema: Obj = { export: "app", type: "Postgres::Schema", ddl: `CREATE SCHEMA ${S}` };
|
|
65
|
+
const parents: Obj = { export: "parents", type: "Postgres::Table", dependsOn: ["app"], ddl: `CREATE TABLE ${S}.parents (id bigint PRIMARY KEY)` };
|
|
66
|
+
/** `items` with the constraints named in `with` declared. */
|
|
67
|
+
const items = (...with_: Array<"pos" | "fk" | "small">): Obj => ({
|
|
68
|
+
export: "items",
|
|
69
|
+
type: "Postgres::Table",
|
|
70
|
+
dependsOn: ["app", "parents"],
|
|
71
|
+
ddl: [
|
|
72
|
+
`CREATE TABLE ${S}.items (id bigint PRIMARY KEY, n int, parent bigint`,
|
|
73
|
+
with_.includes("pos") ? ", CONSTRAINT items_n_pos CHECK (n > 0)" : "",
|
|
74
|
+
with_.includes("fk") ? `, CONSTRAINT items_parent_fkey FOREIGN KEY (parent) REFERENCES ${S}.parents (id)` : "",
|
|
75
|
+
with_.includes("small") ? ", CONSTRAINT items_n_small CHECK (n < 1000000)" : "",
|
|
76
|
+
")",
|
|
77
|
+
].join(""),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
function writeBuild(file: string, objects: Obj[]): string {
|
|
81
|
+
const path = join(dir, file);
|
|
82
|
+
writeFileSync(path, JSON.stringify(doc(objects)));
|
|
83
|
+
return path;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const profile = () => ({
|
|
87
|
+
config: { ownership: { stack: "e2e3703", env: "test" }, sql: { profiles: { e2e: { url: endpoint.url, password: { env: "PG_E2E_PASSWORD" }, schemas: [S] } } } },
|
|
88
|
+
env: { PG_E2E_PASSWORD: endpoint.password ?? "" },
|
|
89
|
+
});
|
|
90
|
+
const apply = (path: string) => postgresApply({ buildPath: path, environment: "e2e" }, undefined, { ...profile(), log: () => undefined });
|
|
91
|
+
const validated = async (name: string) =>
|
|
92
|
+
(await admin.query<{ v: boolean }>(`SELECT convalidated AS v FROM pg_constraint WHERE conrelid = '${S}.items'::regclass AND conname = '${name}'`))[0]?.v;
|
|
93
|
+
|
|
94
|
+
/** A raw node-postgres session, for holding a transaction open. */
|
|
95
|
+
async function session(): Promise<pg.Client> {
|
|
96
|
+
const url = new URL(endpoint.url);
|
|
97
|
+
if (endpoint.password !== undefined) url.password = encodeURIComponent(endpoint.password);
|
|
98
|
+
const c = new pg.Client({ connectionString: url.toString() });
|
|
99
|
+
c.on("error", () => undefined);
|
|
100
|
+
await c.connect();
|
|
101
|
+
return c;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe.skipIf(!enabled)("a CHECK and a foreign key added NOT VALID, then validated, on a live server", () => {
|
|
105
|
+
beforeAll(async () => {
|
|
106
|
+
const first = await apply(writeBuild("v1.json", [schema, parents, items()]));
|
|
107
|
+
expect(first.failed).toEqual([]);
|
|
108
|
+
await admin.query(`INSERT INTO ${S}.parents SELECT g FROM generate_series(1, 200000) g`);
|
|
109
|
+
await admin.query(`INSERT INTO ${S}.items (id, n, parent) SELECT g, g, g FROM generate_series(1, 200000) g`);
|
|
110
|
+
}, 120_000);
|
|
111
|
+
|
|
112
|
+
test("the applier sends ADD ... NOT VALID, then VALIDATE in a transaction of its own, and both end valid", async () => {
|
|
113
|
+
const out = await apply(writeBuild("v2.json", [schema, parents, items("pos", "fk")]));
|
|
114
|
+
expect(out.failed).toEqual([]);
|
|
115
|
+
const mine = out.statements.filter((s) => /items_n_pos|items_parent_fkey/.test(s.sql));
|
|
116
|
+
expect(mine.map((s) => [s.sql, s.class])).toEqual([
|
|
117
|
+
[`ALTER TABLE ${S}.items ADD CONSTRAINT items_n_pos CHECK (n > 0) NOT VALID`, "metadata"],
|
|
118
|
+
[`ALTER TABLE ${S}.items VALIDATE CONSTRAINT items_n_pos`, "validate"],
|
|
119
|
+
[`ALTER TABLE ${S}.items ADD CONSTRAINT items_parent_fkey FOREIGN KEY (parent) REFERENCES ${S}.parents (id) NOT VALID`, "metadata"],
|
|
120
|
+
[`ALTER TABLE ${S}.items VALIDATE CONSTRAINT items_parent_fkey`, "validate"],
|
|
121
|
+
]);
|
|
122
|
+
const [addCheck, validateCheck, addFk, validateFk] = mine.map((s) => s.transaction);
|
|
123
|
+
expect(validateCheck).not.toBe(addCheck);
|
|
124
|
+
expect(validateCheck).not.toBe(addFk);
|
|
125
|
+
expect(validateFk).not.toBe(addFk);
|
|
126
|
+
expect(await validated("items_n_pos")).toBe(true);
|
|
127
|
+
expect(await validated("items_parent_fkey")).toBe(true);
|
|
128
|
+
}, 120_000);
|
|
129
|
+
|
|
130
|
+
test("a concurrent writer is not blocked while VALIDATE reads the table", async () => {
|
|
131
|
+
await admin.query(`ALTER TABLE ${S}.items DROP CONSTRAINT items_parent_fkey`);
|
|
132
|
+
const before = doc([schema, parents, items("pos")]);
|
|
133
|
+
const steps = diffStatements(before, doc([schema, parents, items("pos", "fk", "small")])).steps.filter((s): s is StatementStep => s.kind === "statement");
|
|
134
|
+
expect(steps.map((s) => [s.rule, s.class])).toEqual([
|
|
135
|
+
["SQLPG217", "metadata"],
|
|
136
|
+
["SQLPG220", "validate"],
|
|
137
|
+
["SQLPG217", "metadata"],
|
|
138
|
+
["SQLPG220", "validate"],
|
|
139
|
+
]);
|
|
140
|
+
for (const s of steps.filter((s) => s.precheck)) expect((await admin.query<{ n: string }>(s.precheck!.sql))[0]!.n, s.sql).toBe("0");
|
|
141
|
+
|
|
142
|
+
const applier = await session();
|
|
143
|
+
const writer = await session();
|
|
144
|
+
try {
|
|
145
|
+
await writer.query("SET lock_timeout = '300ms'");
|
|
146
|
+
let id = 1_000_000;
|
|
147
|
+
for (const s of steps) {
|
|
148
|
+
await applier.query("BEGIN");
|
|
149
|
+
await applier.query(s.sql);
|
|
150
|
+
// The applier's transaction is open: does a write to the table wait for it?
|
|
151
|
+
const blocked = await writer.query(`INSERT INTO ${S}.items (id, n, parent) VALUES (${id++}, 1, 1)`).then(
|
|
152
|
+
() => false,
|
|
153
|
+
(e: { code?: string }) => e.code === "55P03",
|
|
154
|
+
);
|
|
155
|
+
await applier.query("COMMIT");
|
|
156
|
+
// VALIDATE reads every row, and the writer goes on.
|
|
157
|
+
if (s.class === "validate") expect(blocked, s.sql).toBe(false);
|
|
158
|
+
// The probe sees a lock: the NOT VALID add does hold the writer while its transaction is open.
|
|
159
|
+
else expect(blocked, s.sql).toBe(true);
|
|
160
|
+
}
|
|
161
|
+
} finally {
|
|
162
|
+
await applier.end();
|
|
163
|
+
await writer.end();
|
|
164
|
+
}
|
|
165
|
+
expect(await validated("items_parent_fkey")).toBe(true);
|
|
166
|
+
expect(await validated("items_n_small")).toBe(true);
|
|
167
|
+
}, 120_000);
|
|
168
|
+
|
|
169
|
+
test("a run that stopped after the NOT VALID add resumes at VALIDATE", async () => {
|
|
170
|
+
await admin.query(`ALTER TABLE ${S}.items DROP CONSTRAINT items_n_small`);
|
|
171
|
+
// The run stopped after its first statement.
|
|
172
|
+
await admin.query(`ALTER TABLE ${S}.items ADD CONSTRAINT items_n_small CHECK (n < 1000000) NOT VALID`);
|
|
173
|
+
expect(await validated("items_n_small")).toBe(false);
|
|
174
|
+
const out = await apply(writeBuild("v3.json", [schema, parents, items("pos", "fk", "small")]));
|
|
175
|
+
expect(out.failed).toEqual([]);
|
|
176
|
+
expect(out.statements.filter((s) => s.sql.includes("items_n_small")).map((s) => [s.sql, s.class])).toEqual([[`ALTER TABLE ${S}.items VALIDATE CONSTRAINT items_n_small`, "validate"]]);
|
|
177
|
+
expect(await validated("items_n_small")).toBe(true);
|
|
178
|
+
}, 120_000);
|
|
179
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lock-safe SET NOT NULL and the pre-checks against a live server (#3686).
|
|
3
|
+
*
|
|
4
|
+
* - The applier makes SET NOT NULL as a NOT VALID check, its validation in a
|
|
5
|
+
* transaction of its own, SET NOT NULL and the check dropped, and leaves no
|
|
6
|
+
* check behind.
|
|
7
|
+
* - Step by step, each statement's transaction held open: a concurrent
|
|
8
|
+
* writer is not blocked while the validation reads the table, and SET NOT
|
|
9
|
+
* NULL is proven by the check without a scan (the server says so at
|
|
10
|
+
* DEBUG1).
|
|
11
|
+
* - Each kind of pre-check runs on the server and counts what its statement
|
|
12
|
+
* would fail on: NULLs, duplicates, rows a check refuses, orphans.
|
|
13
|
+
*
|
|
14
|
+
* The server: `CHANT_SQL_E2E_POSTGRES_URL` (with
|
|
15
|
+
* `CHANT_SQL_E2E_POSTGRES_PASSWORD`) names a running one, else a throwaway
|
|
16
|
+
* server at the pin, skipped when Docker is not available. The test writes
|
|
17
|
+
* only to the schema `chant_e2e_3686`, dropped afterwards.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import pg from "pg";
|
|
24
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
25
|
+
import { dockerAvailable, startTestPostgres, type TestPostgres } from "../testing/server";
|
|
26
|
+
import { connectPostgres, type PostgresClient, type PostgresEndpoint } from "../live/client";
|
|
27
|
+
import { postgresApply } from "../../op/activities/postgres-apply";
|
|
28
|
+
import { diffStatements, type StatementStep } from "../../migration-statements";
|
|
29
|
+
|
|
30
|
+
const S = "chant_e2e_3686";
|
|
31
|
+
const given = process.env.CHANT_SQL_E2E_POSTGRES_URL;
|
|
32
|
+
const enabled = given ? true : await dockerAvailable();
|
|
33
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-pg-safe-"));
|
|
34
|
+
let server: TestPostgres | undefined;
|
|
35
|
+
let endpoint: PostgresEndpoint;
|
|
36
|
+
let admin: PostgresClient;
|
|
37
|
+
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
if (!enabled) return;
|
|
40
|
+
if (given) endpoint = { url: given, password: process.env.CHANT_SQL_E2E_POSTGRES_PASSWORD ?? "" };
|
|
41
|
+
else {
|
|
42
|
+
server = await startTestPostgres();
|
|
43
|
+
endpoint = server.endpoint();
|
|
44
|
+
}
|
|
45
|
+
admin = await connectPostgres(endpoint);
|
|
46
|
+
await admin.query(`DROP SCHEMA IF EXISTS ${S} CASCADE`);
|
|
47
|
+
}, 600_000);
|
|
48
|
+
|
|
49
|
+
afterAll(async () => {
|
|
50
|
+
if (enabled) {
|
|
51
|
+
await admin?.query(`DROP SCHEMA IF EXISTS ${S} CASCADE`).catch(() => undefined);
|
|
52
|
+
await admin?.end();
|
|
53
|
+
}
|
|
54
|
+
await server?.stop();
|
|
55
|
+
rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
interface Obj {
|
|
59
|
+
export: string;
|
|
60
|
+
type: string;
|
|
61
|
+
ddl: string;
|
|
62
|
+
dependsOn?: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const doc = (objects: Obj[]) => ({ dialect: "postgres", postgresMajor: 18, objects: objects.map((o) => ({ dependsOn: [], ...o })) });
|
|
66
|
+
const schema: Obj = { export: "app", type: "Postgres::Schema", ddl: `CREATE SCHEMA ${S}` };
|
|
67
|
+
const items = (cols: { a?: boolean; b?: boolean }): Obj => ({
|
|
68
|
+
export: "items",
|
|
69
|
+
type: "Postgres::Table",
|
|
70
|
+
dependsOn: ["app"],
|
|
71
|
+
ddl: `CREATE TABLE ${S}.items (id bigint PRIMARY KEY, a text${cols.a ? " NOT NULL" : ""}, b text${cols.b ? " NOT NULL" : ""}, c text, parent bigint)`,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
function writeBuild(file: string, objects: Obj[]): string {
|
|
75
|
+
const path = join(dir, file);
|
|
76
|
+
writeFileSync(path, JSON.stringify(doc(objects)));
|
|
77
|
+
return path;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const profile = () => ({
|
|
81
|
+
config: { ownership: { stack: "e2e3686", env: "test" }, sql: { profiles: { e2e: { url: endpoint.url, password: { env: "PG_E2E_PASSWORD" }, schemas: [S] } } } },
|
|
82
|
+
env: { PG_E2E_PASSWORD: endpoint.password ?? "" },
|
|
83
|
+
});
|
|
84
|
+
const apply = (path: string) => postgresApply({ buildPath: path, environment: "e2e" }, undefined, { ...profile(), log: () => undefined });
|
|
85
|
+
|
|
86
|
+
/** A raw node-postgres session, for holding a transaction open and reading the server's notices. */
|
|
87
|
+
async function session(): Promise<pg.Client> {
|
|
88
|
+
const url = new URL(endpoint.url);
|
|
89
|
+
if (endpoint.password !== undefined) url.password = encodeURIComponent(endpoint.password);
|
|
90
|
+
const c = new pg.Client({ connectionString: url.toString() });
|
|
91
|
+
c.on("error", () => undefined);
|
|
92
|
+
await c.connect();
|
|
93
|
+
return c;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe.skipIf(!enabled)("lock-safe SET NOT NULL on a live server", () => {
|
|
97
|
+
beforeAll(async () => {
|
|
98
|
+
const first = await apply(writeBuild("v1.json", [schema, items({})]));
|
|
99
|
+
expect(first.failed).toEqual([]);
|
|
100
|
+
await admin.query(`INSERT INTO ${S}.items (id, a, b, c, parent) SELECT g, 'a', 'b', CASE WHEN g = 7 THEN NULL ELSE 'c' END, g FROM generate_series(1, 200000) g`);
|
|
101
|
+
}, 120_000);
|
|
102
|
+
|
|
103
|
+
test("the applier sends the four statements, validating in a transaction of its own, and leaves no check", async () => {
|
|
104
|
+
const out = await apply(writeBuild("v2.json", [schema, items({ a: true })]));
|
|
105
|
+
expect(out.failed).toEqual([]);
|
|
106
|
+
const mine = out.statements.filter((s) => s.sql.includes("a__chant_nn") || s.sql.includes("COLUMN a "));
|
|
107
|
+
expect(mine.map((s) => [s.sql, s.class])).toEqual([
|
|
108
|
+
[`ALTER TABLE ${S}.items ADD CONSTRAINT a__chant_nn CHECK (a IS NOT NULL) NOT VALID`, "metadata"],
|
|
109
|
+
[`ALTER TABLE ${S}.items VALIDATE CONSTRAINT a__chant_nn`, "validate"],
|
|
110
|
+
[`ALTER TABLE ${S}.items ALTER COLUMN a SET NOT NULL`, "metadata"],
|
|
111
|
+
[`ALTER TABLE ${S}.items DROP CONSTRAINT a__chant_nn`, "metadata"],
|
|
112
|
+
]);
|
|
113
|
+
const [add, validate, set] = mine.map((s) => s.transaction);
|
|
114
|
+
expect(validate).not.toBe(add);
|
|
115
|
+
expect(validate).not.toBe(set);
|
|
116
|
+
const [col] = await admin.query<{ notnull: boolean }>(`SELECT attnotnull AS notnull FROM pg_attribute WHERE attrelid = '${S}.items'::regclass AND attname = 'a'`);
|
|
117
|
+
expect(col?.notnull).toBe(true);
|
|
118
|
+
expect(await admin.query(`SELECT 1 FROM pg_constraint WHERE conrelid = '${S}.items'::regclass AND conname = 'a__chant_nn'`)).toEqual([]);
|
|
119
|
+
}, 120_000);
|
|
120
|
+
|
|
121
|
+
test("a concurrent writer is not blocked while the table is validated, and SET NOT NULL does not scan", async () => {
|
|
122
|
+
const d = diffStatements(doc([schema, items({ a: true })]), doc([schema, items({ a: true, b: true })]));
|
|
123
|
+
const steps = d.steps.filter((s): s is StatementStep => s.kind === "statement");
|
|
124
|
+
expect(steps.map((s) => s.class)).toEqual(["metadata", "validate", "metadata", "metadata"]);
|
|
125
|
+
expect((await admin.query<{ n: string }>(steps[0]!.precheck!.sql))[0]!.n).toBe("0");
|
|
126
|
+
|
|
127
|
+
const applier = await session();
|
|
128
|
+
const writer = await session();
|
|
129
|
+
const notices: string[] = [];
|
|
130
|
+
applier.on("notice", (n) => notices.push(n.message ?? ""));
|
|
131
|
+
try {
|
|
132
|
+
await writer.query("SET lock_timeout = '300ms'");
|
|
133
|
+
let id = 1_000_000;
|
|
134
|
+
for (const s of steps) {
|
|
135
|
+
await applier.query("BEGIN");
|
|
136
|
+
await applier.query(s.sql.includes("SET NOT NULL") ? "SET LOCAL client_min_messages = debug1" : "SET LOCAL client_min_messages = notice");
|
|
137
|
+
await applier.query(s.sql);
|
|
138
|
+
// The applier's transaction is open: does a write to the table wait for it?
|
|
139
|
+
const blocked = await writer.query(`INSERT INTO ${S}.items (id, a, b, c) VALUES (${id++}, 'a', 'b', 'c')`).then(
|
|
140
|
+
() => false,
|
|
141
|
+
(e: { code?: string }) => e.code === "55P03",
|
|
142
|
+
);
|
|
143
|
+
await applier.query("COMMIT");
|
|
144
|
+
if (s.class === "validate") expect(blocked, s.sql).toBe(false);
|
|
145
|
+
// The probe sees a lock: the brief catalog change does hold the writer while its transaction is open.
|
|
146
|
+
if (s.rule === "SQLPG217") expect(blocked, s.sql).toBe(true);
|
|
147
|
+
}
|
|
148
|
+
} finally {
|
|
149
|
+
await applier.end();
|
|
150
|
+
await writer.end();
|
|
151
|
+
}
|
|
152
|
+
expect(notices.some((m) => /sufficient to prove that it does not contain nulls/.test(m))).toBe(true);
|
|
153
|
+
}, 120_000);
|
|
154
|
+
|
|
155
|
+
test("each pre-check counts what its statement would fail on", async () => {
|
|
156
|
+
// c is NULL in one row; a holds one value in every row; one row has id 7; parent 1 is not a parent.
|
|
157
|
+
await admin.query(`CREATE TABLE ${S}.parents (id bigint PRIMARY KEY)`);
|
|
158
|
+
await admin.query(`INSERT INTO ${S}.parents SELECT g FROM generate_series(2, 200000) g`);
|
|
159
|
+
const after = (ddl: string, extra: Obj[] = []) => doc([schema, { ...items({ a: true, b: true }), ddl }, ...extra]);
|
|
160
|
+
const before = doc([schema, items({ a: true, b: true }), { export: "parents", type: "Postgres::Table", dependsOn: ["app"], ddl: `CREATE TABLE ${S}.parents (id bigint PRIMARY KEY)` }]);
|
|
161
|
+
const base = items({ a: true, b: true }).ddl;
|
|
162
|
+
const parents: Obj = { export: "parents", type: "Postgres::Table", dependsOn: ["app"], ddl: `CREATE TABLE ${S}.parents (id bigint PRIMARY KEY)` };
|
|
163
|
+
const cases: Array<[string, ReturnType<typeof doc>, string]> = [
|
|
164
|
+
["SET NOT NULL", after(base.replace("c text", "c text NOT NULL"), [parents]), "1"],
|
|
165
|
+
["UNIQUE", after(base.replace(", parent bigint)", ", parent bigint, CONSTRAINT items_a_key UNIQUE (a))"), [parents]), "1"],
|
|
166
|
+
["UNIQUE over id", after(base.replace(", parent bigint)", ", parent bigint, CONSTRAINT items_id_key UNIQUE (id))"), [parents]), "0"],
|
|
167
|
+
["CHECK", after(base.replace(", parent bigint)", ", parent bigint, CONSTRAINT not_seven CHECK (id <> 7))"), [parents]), "1"],
|
|
168
|
+
["FOREIGN KEY", after(base.replace("parent bigint", `parent bigint CONSTRAINT items_parent_fkey REFERENCES ${S}.parents (id)`), [parents]), "1"],
|
|
169
|
+
];
|
|
170
|
+
for (const [what, next, n] of cases) {
|
|
171
|
+
const step = diffStatements(before, next).steps.find((s): s is StatementStep => s.kind === "statement" && s.precheck !== undefined);
|
|
172
|
+
expect(step, what).toBeDefined();
|
|
173
|
+
expect((await admin.query<{ n: string }>(step!.precheck!.sql))[0]!.n, what).toBe(n);
|
|
174
|
+
}
|
|
175
|
+
}, 120_000);
|
|
176
|
+
});
|