@intentius/chant-lexicon-sql 0.115.0 → 0.117.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/codegen/registry.d.ts.map +1 -1
- package/dist/config.d.ts +20 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +5 -5
- package/dist/lint/rules/sqlpg001.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 +35 -0
- package/dist/okf/index.md +7 -0
- package/dist/okf/types/PostgresDefaultPrivileges.md +9 -0
- package/dist/okf/types/PostgresFunction.md +9 -0
- package/dist/okf/types/PostgresGrant.md +9 -0
- package/dist/okf/types/PostgresPolicy.md +9 -0
- package/dist/okf/types/PostgresProcedure.md +9 -0
- package/dist/okf/types/PostgresRole.md +9 -0
- package/dist/okf/types/PostgresTrigger.md +9 -0
- package/dist/op/activities/postgres-migration.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +10 -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 +6 -1
- package/dist/postgres/apply/apply.d.ts.map +1 -1
- package/dist/postgres/apply/statements.d.ts +12 -2
- package/dist/postgres/apply/statements.d.ts.map +1 -1
- package/dist/postgres/entities.d.ts +212 -3
- package/dist/postgres/entities.d.ts.map +1 -1
- package/dist/postgres/entity-types.d.ts +7 -0
- package/dist/postgres/entity-types.d.ts.map +1 -1
- package/dist/postgres/import/generator.d.ts +4 -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 +25 -4
- package/dist/postgres/live/catalog.d.ts.map +1 -1
- package/dist/postgres/live/describe-resources.d.ts +15 -1
- 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 +155 -5
- 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 +11 -2
- package/dist/postgres/plan/diff.d.ts.map +1 -1
- package/dist/postgres/plan/normalize.d.ts +28 -2
- package/dist/postgres/plan/normalize.d.ts.map +1 -1
- package/dist/postgres/plan/rules.d.ts +14 -0
- package/dist/postgres/plan/rules.d.ts.map +1 -1
- package/dist/postgres/plan/schema.d.ts +12 -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/sqlpg001.ts +10 -4
- package/dist/validate.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/codegen/postgres-classifier-page.ts +1 -1
- package/src/codegen/registry.ts +7 -0
- package/src/config.ts +10 -0
- package/src/generated/lexicon-sql.json +35 -0
- package/src/index.ts +1 -1
- package/src/lint/rules/postgres-templates.ts +2 -2
- package/src/lint/rules/sqlpg001.ts +10 -4
- package/src/lsp/postgres-template.ts +8 -0
- package/src/mcp/postgres.ts +1 -1
- package/src/migration-statements.ts +7 -0
- package/src/op/activities/postgres-migration.ts +27 -2
- package/src/op/activity-contracts.ts +1 -0
- package/src/plugin.ts +6 -0
- 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 +135 -6
- package/src/postgres/apply/statements.ts +104 -16
- package/src/postgres/entities.ts +613 -14
- package/src/postgres/entity-types.ts +7 -0
- package/src/postgres/import/generator.ts +18 -5
- package/src/postgres/import/live-export.ts +16 -2
- package/src/postgres/live/bind.ts +3 -0
- package/src/postgres/live/catalog.ts +155 -5
- package/src/postgres/live/describe-resources.ts +43 -9
- package/src/postgres/live/routines.e2e.test.ts +173 -0
- package/src/postgres/migrate/batches.e2e.test.ts +2 -1
- package/src/postgres/migrate/migrate.e2e.test.ts +2 -1
- 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 +554 -9
- package/src/postgres/plan/commands.ts +61 -8
- package/src/postgres/plan/deep.ts +79 -18
- package/src/postgres/plan/diff.ts +114 -9
- package/src/postgres/plan/normalize.ts +272 -5
- package/src/postgres/plan/rules.ts +80 -0
- package/src/postgres/plan/schema.ts +27 -6
- package/src/postgres/plan/server-normalize.ts +53 -1
- package/src/postgres/routines.test.ts +150 -0
- package/src/postgres.ts +22 -0
- package/src/skill-defs.ts +1 -1
- package/src/skills/chant-sql-postgres-migration.md +3 -1
- package/src/skills/chant-sql-postgres.md +25 -2
- package/src/validate.ts +7 -0
|
@@ -13,6 +13,13 @@ export const POSTGRES_ENTITY_TYPES = {
|
|
|
13
13
|
enum: "Postgres::Enum",
|
|
14
14
|
domain: "Postgres::Domain",
|
|
15
15
|
extension: "Postgres::Extension",
|
|
16
|
+
function: "Postgres::Function",
|
|
17
|
+
procedure: "Postgres::Procedure",
|
|
18
|
+
trigger: "Postgres::Trigger",
|
|
19
|
+
policy: "Postgres::Policy",
|
|
20
|
+
role: "Postgres::Role",
|
|
21
|
+
grant: "Postgres::Grant",
|
|
22
|
+
defaultPrivileges: "Postgres::DefaultPrivileges",
|
|
16
23
|
} as const;
|
|
17
24
|
|
|
18
25
|
export type PostgresEntityType = (typeof POSTGRES_ENTITY_TYPES)[keyof typeof POSTGRES_ENTITY_TYPES];
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
* (`nextval('app.invoice_seq'::regclass)` is `nextval(${invoiceSeq})`);
|
|
12
12
|
* - the schema part of the object's own name, wherever the name appears,
|
|
13
13
|
* becomes the schema's export (`CREATE TABLE ${appSchema}.users`);
|
|
14
|
-
* - an extension's `WITH SCHEMA
|
|
14
|
+
* - an extension's `WITH SCHEMA`, a grant's `ON SCHEMA` and default
|
|
15
|
+
* privileges' `IN SCHEMA` name the schema's export;
|
|
16
|
+
* - a grant's object becomes the object's export (`GRANT SELECT ON TABLE
|
|
17
|
+
* ${orders} TO reader`), a policy's table its table's.
|
|
15
18
|
*
|
|
16
19
|
* Columns stay text, as they do for ClickHouse (#3236): which relation a bare
|
|
17
20
|
* column belongs to is the server's to resolve. One file, because the
|
|
@@ -42,6 +45,13 @@ const TAG: Record<string, string> = {
|
|
|
42
45
|
[POSTGRES_ENTITY_TYPES.enum]: "type",
|
|
43
46
|
[POSTGRES_ENTITY_TYPES.domain]: "domain",
|
|
44
47
|
[POSTGRES_ENTITY_TYPES.extension]: "extension",
|
|
48
|
+
[POSTGRES_ENTITY_TYPES.function]: "func",
|
|
49
|
+
[POSTGRES_ENTITY_TYPES.procedure]: "procedure",
|
|
50
|
+
[POSTGRES_ENTITY_TYPES.trigger]: "trigger",
|
|
51
|
+
[POSTGRES_ENTITY_TYPES.policy]: "policy",
|
|
52
|
+
[POSTGRES_ENTITY_TYPES.role]: "role",
|
|
53
|
+
[POSTGRES_ENTITY_TYPES.grant]: "grant",
|
|
54
|
+
[POSTGRES_ENTITY_TYPES.defaultPrivileges]: "grant",
|
|
45
55
|
};
|
|
46
56
|
|
|
47
57
|
/** Text that sits inside a template literal: a backquote and `${` escaped. */
|
|
@@ -82,8 +92,9 @@ function templateBody(item: Item, byQualified: Map<string, Item>, schemaExport:
|
|
|
82
92
|
const dot = tokens[sig[k + 1] ?? -1];
|
|
83
93
|
const b = tokens[sig[k + 2] ?? -1];
|
|
84
94
|
if (!isName(a) || dot?.kind !== "punct" || dot.text !== "." || !isName(b)) {
|
|
85
|
-
// An extension's `WITH SCHEMA app`.
|
|
86
|
-
|
|
95
|
+
// An extension's `WITH SCHEMA app`, a grant's `ON SCHEMA app`, default privileges' `IN SCHEMA app`.
|
|
96
|
+
const bySchemaWord = item.type === POSTGRES_ENTITY_TYPES.extension || item.type === POSTGRES_ENTITY_TYPES.grant || item.type === POSTGRES_ENTITY_TYPES.defaultPrivileges;
|
|
97
|
+
if (bySchemaWord && isName(a) && prev?.kind === "ident" && prev.text.toUpperCase() === "SCHEMA") {
|
|
87
98
|
const s = schemaExport.get(identValue(a));
|
|
88
99
|
if (s) replace.set(sig[k]!, { to: sig[k]! + 1, text: `\${${s}}`, ref: s });
|
|
89
100
|
}
|
|
@@ -144,8 +155,10 @@ export class PostgresGenerator implements TypeScriptGenerator {
|
|
|
144
155
|
const schemaExport = new Map<string, string>();
|
|
145
156
|
for (const it of items) {
|
|
146
157
|
if (it.type === POSTGRES_ENTITY_TYPES.schema) schemaExport.set(it.name, it.exportName);
|
|
147
|
-
// An index is never referenced by name in another object's DDL.
|
|
148
|
-
else if (it.schema && it.type !== POSTGRES_ENTITY_TYPES.index
|
|
158
|
+
// An index or a trigger is never referenced by name in another object's DDL.
|
|
159
|
+
else if (it.schema && it.type !== POSTGRES_ENTITY_TYPES.index && it.type !== POSTGRES_ENTITY_TYPES.trigger && it.type !== POSTGRES_ENTITY_TYPES.policy) {
|
|
160
|
+
byQualified.set(`${it.schema}.${it.name}`, it);
|
|
161
|
+
}
|
|
149
162
|
}
|
|
150
163
|
|
|
151
164
|
const bodies = new Map(items.map((it) => [it.exportName, templateBody(it, byQualified, schemaExport)]));
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* a declaration of them would have chant fight it.
|
|
12
12
|
* - `verbatim` changes nothing yet: the printers already leave out what the
|
|
13
13
|
* server would add by itself.
|
|
14
|
+
* - Where the profile manages access (`sql.profiles.<env>.access`), the
|
|
15
|
+
* policies and each table's row-level security come with the objects, and
|
|
16
|
+
* the privileges on them and the default privileges are written as `grant`
|
|
17
|
+
* declarations: the GRANT and REVOKE statements that take a new object's
|
|
18
|
+
* privileges to what the server holds. Roles are the environment's and are
|
|
19
|
+
* not imported.
|
|
14
20
|
*/
|
|
15
21
|
|
|
16
22
|
import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
|
|
@@ -18,6 +24,7 @@ import { bindPostgres, type BindOptions } from "../live/bind";
|
|
|
18
24
|
import { markProviderOwned, readLiveSchema } from "../live/catalog";
|
|
19
25
|
import { objectsToIR, type ImportedPgObject } from "./ir";
|
|
20
26
|
import { isChantManaged } from "../../core/ownership";
|
|
27
|
+
import { importedAccess } from "../access/import";
|
|
21
28
|
|
|
22
29
|
export interface ExportOptions extends Omit<BindOptions, "environment"> {
|
|
23
30
|
environment: string;
|
|
@@ -31,8 +38,11 @@ export interface ExportOptions extends Omit<BindOptions, "environment"> {
|
|
|
31
38
|
export async function exportResources(options: ExportOptions): Promise<ExportedTemplate> {
|
|
32
39
|
const { target, client } = await bindPostgres(options);
|
|
33
40
|
let live;
|
|
41
|
+
let access: ImportedPgObject[] = [];
|
|
34
42
|
try {
|
|
35
|
-
live = markProviderOwned(await readLiveSchema(client, { schemas: target.schemas }), target.provider);
|
|
43
|
+
live = markProviderOwned(await readLiveSchema(client, { schemas: target.schemas, access: target.access === true }), target.provider);
|
|
44
|
+
// Where the profile manages access, the privileges on what is imported, and the default privileges, as grant declarations.
|
|
45
|
+
if (target.access === true && !options.selector) access = await importedAccess(client, live.filter((o) => !o.foreign && !o.unsupported && (!options.owned || isChantManaged(o.comment))), target.schemas);
|
|
36
46
|
} finally {
|
|
37
47
|
await client.end();
|
|
38
48
|
}
|
|
@@ -43,11 +53,15 @@ export async function exportResources(options: ExportOptions): Promise<ExportedT
|
|
|
43
53
|
if (options.selector?.type && o.type !== options.selector.type) continue;
|
|
44
54
|
const name = options.selector?.name;
|
|
45
55
|
if (name !== undefined && name !== o.name && name !== `${o.schema}.${o.name}`) continue;
|
|
56
|
+
if (o.unsupported) {
|
|
57
|
+
warnings.push(`${o.schema ? `${o.schema}.` : ""}${o.name}${o.signature ?? ""} is left out: ${o.unsupported}`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
46
60
|
if (o.foreign) {
|
|
47
61
|
warnings.push(`${o.schema ? `${o.schema}.` : ""}${o.name} is kept by ${o.foreign}; left out, since declaring it would have chant change what that tool owns`);
|
|
48
62
|
continue;
|
|
49
63
|
}
|
|
50
64
|
objects.push({ type: o.type, ...(o.schema ? { schema: o.schema } : {}), name: o.name, ddl: o.statement });
|
|
51
65
|
}
|
|
52
|
-
return objectsToIR(objects, warnings) as ExportedTemplate;
|
|
66
|
+
return objectsToIR([...objects, ...access], warnings) as ExportedTemplate;
|
|
53
67
|
}
|
|
@@ -21,6 +21,8 @@ export interface PostgresTarget {
|
|
|
21
21
|
provider?: PostgresProvider;
|
|
22
22
|
/** The apply's timeouts the profile sets (`lockTimeoutMs`, `statementTimeoutMs`, `scanTimeoutMs`); the applier's defaults otherwise. */
|
|
23
23
|
timeouts?: { lockTimeoutMs?: number; statementTimeoutMs?: number; scanTimeoutMs?: number };
|
|
24
|
+
/** Whether chant manages access here (`sql.profiles.<env>.access`): policies, row-level security, the declared roles, grants and default privileges. */
|
|
25
|
+
access?: boolean;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
export interface UnresolvedTarget {
|
|
@@ -72,6 +74,7 @@ export function resolvePostgresTarget(input: {
|
|
|
72
74
|
...(profile.schemas ? { schemas: profile.schemas } : {}),
|
|
73
75
|
defaultSchema: profile.defaultSchema ?? "public",
|
|
74
76
|
...(Object.keys(timeouts).length > 0 ? { timeouts } : {}),
|
|
77
|
+
...(profile.access === true ? { access: true } : {}),
|
|
75
78
|
});
|
|
76
79
|
}
|
|
77
80
|
const url = env.POSTGRES_URL;
|
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
* - objects an extension owns (`pg_depend.deptype = 'e'`): the extension
|
|
19
19
|
* declares them, so the extension is what is imported;
|
|
20
20
|
* - `plpgsql`, installed in every database;
|
|
21
|
+
* - aggregates, and a trigger the server makes for itself (a foreign key's)
|
|
22
|
+
* or clones onto a partition from its parent's;
|
|
23
|
+
* - a routine with a SQL-standard body (`RETURN`, `BEGIN ATOMIC`): read, but
|
|
24
|
+
* marked `unsupported`, since the server prints it rewritten and a
|
|
25
|
+
* declaration cannot hold it;
|
|
21
26
|
* - a sequence an identity column owns (it is the column's), and a `serial`
|
|
22
27
|
* column's sequence, since the column prints as `serial`;
|
|
23
28
|
* - an index that backs a constraint (the constraint creates it), and an
|
|
@@ -76,12 +81,27 @@ export interface LivePgObject {
|
|
|
76
81
|
statement: string;
|
|
77
82
|
/** The tool whose bookkeeping this is, when it is another tool's (an ORM's revision table). */
|
|
78
83
|
foreign?: string;
|
|
84
|
+
/** A routine's input parameter types, `(integer,text)`; a trigger's table, ` ON app.users` (`../plan/normalize.ts`). */
|
|
85
|
+
signature?: string;
|
|
86
|
+
/** What depends on a routine (a view, a trigger, a column default), as the server describes each. */
|
|
87
|
+
dependents?: string[];
|
|
88
|
+
/** Why no declaration can hold this object; it is read so import can say it left it out. */
|
|
89
|
+
unsupported?: string;
|
|
79
90
|
}
|
|
80
91
|
|
|
81
92
|
/** What {@link readLiveSchema} reads. */
|
|
82
93
|
export interface SchemaScope {
|
|
83
94
|
/** The schemas in scope; undefined is every schema but the server's own and `public`'s own objects included. */
|
|
84
95
|
schemas?: readonly string[];
|
|
96
|
+
/**
|
|
97
|
+
* Whether access is read (`sql.profiles.<env>.access`): the policies on the
|
|
98
|
+
* tables in scope, each table's row-level security, and the roles named in
|
|
99
|
+
* `roles`. Off, none of it is read, so a project that manages access
|
|
100
|
+
* elsewhere sees none of it.
|
|
101
|
+
*/
|
|
102
|
+
access?: boolean;
|
|
103
|
+
/** The roles to read, when access is: the declared ones. A role is the cluster's, never read otherwise. */
|
|
104
|
+
roles?: readonly string[];
|
|
85
105
|
}
|
|
86
106
|
|
|
87
107
|
type Row = Record<string, unknown>;
|
|
@@ -181,8 +201,9 @@ const seqParams = (r: Row): SequenceParams => ({
|
|
|
181
201
|
|
|
182
202
|
/**
|
|
183
203
|
* Every object in scope, in a stable order: schemas, extensions, enums,
|
|
184
|
-
* domains, sequences, tables, views and materialized views, indexes
|
|
185
|
-
*
|
|
204
|
+
* domains, sequences, tables, views and materialized views, indexes,
|
|
205
|
+
* functions and procedures, triggers, and where access is read, policies and
|
|
206
|
+
* the declared roles; each kind by schema then name, in byte order.
|
|
186
207
|
*/
|
|
187
208
|
export async function readLiveSchema(client: PostgresClient, scope: SchemaScope = {}): Promise<LivePgObject[]> {
|
|
188
209
|
const out: LivePgObject[] = [];
|
|
@@ -314,6 +335,7 @@ export async function readLiveSchema(client: PostgresClient, scope: SchemaScope
|
|
|
314
335
|
FROM pg_catalog.pg_inherits i JOIN pg_catalog.pg_class pc ON pc.oid = i.inhparent JOIN pg_catalog.pg_namespace pn ON pn.oid = pc.relnamespace
|
|
315
336
|
WHERE i.inhrelid = c.oid ORDER BY i.inhseqno) AS parents,
|
|
316
337
|
c.reloptions AS reloptions, (SELECT a.amname FROM pg_catalog.pg_am a WHERE a.oid = c.relam) AS am,
|
|
338
|
+
c.relrowsecurity AS rowsecurity, c.relforcerowsecurity AS forcerowsecurity,
|
|
317
339
|
(SELECT ts.spcname FROM pg_catalog.pg_tablespace ts WHERE ts.oid = c.reltablespace) AS tablespace,
|
|
318
340
|
pg_catalog.obj_description(c.oid, 'pg_class') AS comment
|
|
319
341
|
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
@@ -480,7 +502,13 @@ export async function readLiveSchema(client: PostgresClient, scope: SchemaScope
|
|
|
480
502
|
oid: rel,
|
|
481
503
|
...(str(t.comment) ? { comment: str(t.comment) } : {}),
|
|
482
504
|
...(FOREIGN_TABLES[String(t.name)] ? { foreign: FOREIGN_TABLES[String(t.name)] } : {}),
|
|
483
|
-
statement: [
|
|
505
|
+
statement: [
|
|
506
|
+
create,
|
|
507
|
+
...(scope.access && t.rowsecurity === true ? [`ALTER TABLE ${q} ENABLE ROW LEVEL SECURITY`] : []),
|
|
508
|
+
...(scope.access && t.forcerowsecurity === true ? [`ALTER TABLE ${q} FORCE ROW LEVEL SECURITY`] : []),
|
|
509
|
+
...commentOn(`TABLE ${q}`, t.comment),
|
|
510
|
+
...commentLines,
|
|
511
|
+
].join(";\n"),
|
|
484
512
|
});
|
|
485
513
|
}
|
|
486
514
|
|
|
@@ -556,11 +584,133 @@ export async function readLiveSchema(client: PostgresClient, scope: SchemaScope
|
|
|
556
584
|
}
|
|
557
585
|
}
|
|
558
586
|
|
|
587
|
+
// Functions and procedures (not aggregates).
|
|
588
|
+
{
|
|
589
|
+
const rows = await client.query<Row>(
|
|
590
|
+
`SELECT p.oid::text AS oid, n.nspname AS schema, p.proname AS name, p.prokind AS kind, pg_catalog.pg_get_functiondef(p.oid) AS def,
|
|
591
|
+
'(' || pg_catalog.replace(pg_catalog.oidvectortypes(p.proargtypes), ', ', ',') || ')' AS signature,
|
|
592
|
+
p.prosqlbody IS NOT NULL AS sqlbody, pg_catalog.obj_description(p.oid, 'pg_proc') AS comment,
|
|
593
|
+
ARRAY(SELECT pg_catalog.pg_describe_object(d.classid, d.objid, d.objsubid) FROM pg_catalog.pg_depend d
|
|
594
|
+
WHERE d.refclassid = 'pg_catalog.pg_proc'::pg_catalog.regclass AND d.refobjid = p.oid AND d.deptype = 'n'
|
|
595
|
+
ORDER BY 1) AS dependents
|
|
596
|
+
FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
|
|
597
|
+
WHERE p.prokind IN ('f', 'p', 'w') AND ${s.sql} AND ${notExtensionOwned("pg_proc", "p.oid")}
|
|
598
|
+
ORDER BY n.nspname ${C}, p.proname ${C}, pg_catalog.oidvectortypes(p.proargtypes) ${C}`,
|
|
599
|
+
s.params,
|
|
600
|
+
);
|
|
601
|
+
for (const r of rows) {
|
|
602
|
+
// A migration's dual-write function, until its contract.
|
|
603
|
+
if (hasChantTrailerKey(str(r.comment), [MIGRATION_TRAILER_KEY])) continue;
|
|
604
|
+
const signature = String(r.signature);
|
|
605
|
+
const q = `${qname(String(r.schema), String(r.name))}${signature}`;
|
|
606
|
+
const procedure = r.kind === "p";
|
|
607
|
+
const dependents = (r.dependents as string[] | null) ?? [];
|
|
608
|
+
out.push({
|
|
609
|
+
type: procedure ? POSTGRES_ENTITY_TYPES.procedure : POSTGRES_ENTITY_TYPES.function,
|
|
610
|
+
schema: String(r.schema),
|
|
611
|
+
name: String(r.name),
|
|
612
|
+
oid: String(r.oid),
|
|
613
|
+
signature,
|
|
614
|
+
...(dependents.length > 0 ? { dependents } : {}),
|
|
615
|
+
...(str(r.comment) ? { comment: str(r.comment) } : {}),
|
|
616
|
+
...(r.sqlbody === true ? { unsupported: "its body is SQL-standard (RETURN or BEGIN ATOMIC), which the server prints back rewritten; declare it with a string body (AS $$ ... $$)" } : {}),
|
|
617
|
+
statement: [String(r.def).trim(), ...commentOn(`${procedure ? "PROCEDURE" : "FUNCTION"} ${q}`, r.comment)].join(";\n"),
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Triggers: the ones written by hand, not a foreign key's or a partition's clone of its parent's.
|
|
623
|
+
{
|
|
624
|
+
const rows = await client.query<Row>(
|
|
625
|
+
`SELECT t.oid::text AS oid, n.nspname AS schema, c.relname AS table_name, t.tgname AS name, pg_catalog.pg_get_triggerdef(t.oid, true) AS def,
|
|
626
|
+
pg_catalog.obj_description(t.oid, 'pg_trigger') AS comment
|
|
627
|
+
FROM pg_catalog.pg_trigger t JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
628
|
+
WHERE NOT t.tgisinternal AND t.tgparentid = 0 AND ${s.sql} AND ${notExtensionOwned("pg_class", "c.oid")}
|
|
629
|
+
ORDER BY n.nspname ${C}, c.relname ${C}, t.tgname ${C}`,
|
|
630
|
+
s.params,
|
|
631
|
+
);
|
|
632
|
+
for (const r of rows) {
|
|
633
|
+
// A migration's dual-write trigger, until its contract.
|
|
634
|
+
if (hasChantTrailerKey(str(r.comment), [MIGRATION_TRAILER_KEY])) continue;
|
|
635
|
+
const table = qname(String(r.schema), String(r.table_name));
|
|
636
|
+
out.push({
|
|
637
|
+
type: POSTGRES_ENTITY_TYPES.trigger,
|
|
638
|
+
schema: String(r.schema),
|
|
639
|
+
name: String(r.name),
|
|
640
|
+
oid: String(r.oid),
|
|
641
|
+
signature: ` ON ${table}`,
|
|
642
|
+
...(str(r.comment) ? { comment: str(r.comment) } : {}),
|
|
643
|
+
...(FOREIGN_TABLES[String(r.table_name)] ? { foreign: FOREIGN_TABLES[String(r.table_name)] } : {}),
|
|
644
|
+
statement: [String(r.def), ...commentOn(`TRIGGER ${quoteIdent(String(r.name))} ON ${table}`, r.comment)].join(";\n"),
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (scope.access) {
|
|
650
|
+
// Policies, on the tables in scope.
|
|
651
|
+
const policies = await client.query<Row>(
|
|
652
|
+
`SELECT p.oid::text AS oid, n.nspname AS schema, c.relname AS table_name, p.polname AS name, p.polpermissive AS permissive, p.polcmd AS cmd,
|
|
653
|
+
ARRAY(SELECT CASE WHEN r = 0 THEN 'PUBLIC' ELSE pg_catalog.quote_ident(pg_catalog.pg_get_userbyid(r)) END FROM pg_catalog.unnest(p.polroles) r ORDER BY 1) AS roles,
|
|
654
|
+
pg_catalog.pg_get_expr(p.polqual, p.polrelid, true) AS qual, pg_catalog.pg_get_expr(p.polwithcheck, p.polrelid, true) AS withcheck,
|
|
655
|
+
pg_catalog.obj_description(p.oid, 'pg_policy') AS comment
|
|
656
|
+
FROM pg_catalog.pg_policy p JOIN pg_catalog.pg_class c ON c.oid = p.polrelid JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
657
|
+
WHERE ${s.sql} AND ${notExtensionOwned("pg_class", "c.oid")}
|
|
658
|
+
ORDER BY n.nspname ${C}, c.relname ${C}, p.polname ${C}`,
|
|
659
|
+
s.params,
|
|
660
|
+
);
|
|
661
|
+
const COMMAND: Record<string, string> = { r: "SELECT", a: "INSERT", w: "UPDATE", d: "DELETE" };
|
|
662
|
+
for (const r of policies) {
|
|
663
|
+
const table = qname(String(r.schema), String(r.table_name));
|
|
664
|
+
const roles = (r.roles as string[] | null) ?? [];
|
|
665
|
+
const parts = [`CREATE POLICY ${quoteIdent(String(r.name))} ON ${table}`];
|
|
666
|
+
if (r.permissive === false) parts.push("AS RESTRICTIVE");
|
|
667
|
+
if (COMMAND[String(r.cmd)]) parts.push(`FOR ${COMMAND[String(r.cmd)]}`);
|
|
668
|
+
if (!(roles.length === 1 && roles[0] === "PUBLIC")) parts.push(`TO ${roles.join(", ")}`);
|
|
669
|
+
if (str(r.qual)) parts.push(`USING (${r.qual})`);
|
|
670
|
+
if (str(r.withcheck)) parts.push(`WITH CHECK (${r.withcheck})`);
|
|
671
|
+
out.push({
|
|
672
|
+
type: POSTGRES_ENTITY_TYPES.policy,
|
|
673
|
+
schema: String(r.schema),
|
|
674
|
+
name: String(r.name),
|
|
675
|
+
oid: String(r.oid),
|
|
676
|
+
signature: ` ON ${table}`,
|
|
677
|
+
...(str(r.comment) ? { comment: str(r.comment) } : {}),
|
|
678
|
+
statement: [parts.join(" "), ...commentOn(`POLICY ${quoteIdent(String(r.name))} ON ${table}`, r.comment)].join(";\n"),
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// The declared roles, by name: a role is the cluster's, and the ones the declarations do not name are the environment's.
|
|
683
|
+
if (scope.roles && scope.roles.length > 0) {
|
|
684
|
+
const roles = await client.query<Row>(
|
|
685
|
+
`SELECT r.oid::text AS oid, r.rolname AS name, r.rolsuper AS superuser, r.rolinherit AS inherit, r.rolcreaterole AS createrole, r.rolcreatedb AS createdb,
|
|
686
|
+
r.rolcanlogin AS login, r.rolreplication AS replication, r.rolbypassrls AS bypassrls, r.rolconnlimit AS connlimit,
|
|
687
|
+
pg_catalog.shobj_description(r.oid, 'pg_authid') AS comment
|
|
688
|
+
FROM pg_catalog.pg_roles r WHERE r.rolname = ANY($1::text[]) ORDER BY r.rolname ${C}`,
|
|
689
|
+
[[...scope.roles]],
|
|
690
|
+
);
|
|
691
|
+
for (const r of roles) {
|
|
692
|
+
const words: string[] = [];
|
|
693
|
+
for (const [k, dflt] of [["superuser", false], ["createdb", false], ["createrole", false], ["inherit", true], ["login", false], ["replication", false], ["bypassrls", false]] as const) {
|
|
694
|
+
if ((r[k] === true) !== dflt) words.push(r[k] === true ? k.toUpperCase() : `NO${k.toUpperCase()}`);
|
|
695
|
+
}
|
|
696
|
+
if (Number(r.connlimit) !== -1) words.push(`CONNECTION LIMIT ${Number(r.connlimit)}`);
|
|
697
|
+
const name = quoteIdent(String(r.name));
|
|
698
|
+
out.push({
|
|
699
|
+
type: POSTGRES_ENTITY_TYPES.role,
|
|
700
|
+
name: String(r.name),
|
|
701
|
+
oid: String(r.oid),
|
|
702
|
+
...(str(r.comment) ? { comment: str(r.comment) } : {}),
|
|
703
|
+
statement: [`CREATE ROLE ${name}${words.length > 0 ? ` WITH ${words.join(" ")}` : ""}`, ...commentOn(`ROLE ${name}`, r.comment)].join(";\n"),
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
559
709
|
return out;
|
|
560
710
|
}
|
|
561
711
|
|
|
562
|
-
/** The key an object is found by: its type and schema-qualified name. */
|
|
563
|
-
export const liveKey = (type: string, schema: string | undefined, name: string): string => `${type} ${schema ?? ""}.${name}`;
|
|
712
|
+
/** The key an object is found by: its type and schema-qualified name, and a routine's parameter types or a trigger's table. */
|
|
713
|
+
export const liveKey = (type: string, schema: string | undefined, name: string, signature?: string): string => `${type} ${schema ?? ""}.${name}${signature ?? ""}`;
|
|
564
714
|
|
|
565
715
|
/**
|
|
566
716
|
* Mark what the managed service owns (#3282) as foreign: its reserved
|
|
@@ -27,17 +27,42 @@ import { liveKey, markProviderOwned, readLiveSchema, type LivePgObject } from ".
|
|
|
27
27
|
import { POSTGRES_ENTITY_TYPES } from "../entity-types";
|
|
28
28
|
import { isChantManaged, readMarker, stripMarker } from "../../core/ownership";
|
|
29
29
|
import { identValue } from "../parser";
|
|
30
|
+
import { canonicalPgObject } from "../plan/normalize";
|
|
30
31
|
|
|
31
32
|
interface Bound {
|
|
32
33
|
target: PostgresTarget;
|
|
33
34
|
byKey: Map<string, LivePgObject>;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* The schema and name a declared entity is created as; a bare name is in the
|
|
39
|
+
* default schema. A routine's parameter types and a trigger's table tell it
|
|
40
|
+
* from another of the same name (`signature`).
|
|
41
|
+
*/
|
|
42
|
+
export function declaredAddress(entity: { type: string; props: Record<string, unknown> }, defaultSchema: string): { schema?: string; name: string; signature?: string } {
|
|
38
43
|
const name = String(entity.props.name ?? "");
|
|
39
|
-
if (entity.type === POSTGRES_ENTITY_TYPES.schema || entity.type === POSTGRES_ENTITY_TYPES.extension) return { name };
|
|
40
|
-
|
|
44
|
+
if (entity.type === POSTGRES_ENTITY_TYPES.schema || entity.type === POSTGRES_ENTITY_TYPES.extension || entity.type === POSTGRES_ENTITY_TYPES.role) return { name };
|
|
45
|
+
const schema = typeof entity.props.schema === "string" ? entity.props.schema : identValue(defaultSchema);
|
|
46
|
+
if (entity.type === POSTGRES_ENTITY_TYPES.function || entity.type === POSTGRES_ENTITY_TYPES.procedure || entity.type === POSTGRES_ENTITY_TYPES.trigger || entity.type === POSTGRES_ENTITY_TYPES.policy) {
|
|
47
|
+
const signature = canonicalPgObject(entity.type, entity.props, defaultSchema).signature;
|
|
48
|
+
return { schema, name, ...(signature !== undefined ? { signature } : {}) };
|
|
49
|
+
}
|
|
50
|
+
return { schema, name };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Why an access declaration is not observed one by one: a grant or default
|
|
55
|
+
* privileges, which `chant sql plan` compares as the access they add up to;
|
|
56
|
+
* a policy or role where the profile does not manage access.
|
|
57
|
+
*/
|
|
58
|
+
export function accessUnobserved(type: string, managed: boolean): { reason: "unsupported-kind" | "filtered"; detail: string } | undefined {
|
|
59
|
+
if (type === POSTGRES_ENTITY_TYPES.grant || type === POSTGRES_ENTITY_TYPES.defaultPrivileges) {
|
|
60
|
+
return { reason: "unsupported-kind", detail: "a grant is compared as the privileges the declarations add up to, by chant sql plan and the applier, not one statement at a time" };
|
|
61
|
+
}
|
|
62
|
+
if (!managed && (type === POSTGRES_ENTITY_TYPES.policy || type === POSTGRES_ENTITY_TYPES.role)) {
|
|
63
|
+
return { reason: "filtered", detail: "access is not managed in this environment (sql.profiles.<env>.access)" };
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
41
66
|
}
|
|
42
67
|
|
|
43
68
|
/** A schema scope for the catalog read: the profile's schemas, else every schema the declarations name and the default one. */
|
|
@@ -58,8 +83,15 @@ function adapter(options: BindOptions & { owned?: boolean; declared: DeclaredEnt
|
|
|
58
83
|
async bind() {
|
|
59
84
|
const { target, client } = await bindPostgres(options);
|
|
60
85
|
try {
|
|
61
|
-
const live = markProviderOwned(
|
|
62
|
-
|
|
86
|
+
const live = markProviderOwned(
|
|
87
|
+
await readLiveSchema(client, {
|
|
88
|
+
schemas: scopeFor(target, options.declared.filter((d) => d.type.startsWith("Postgres::"))),
|
|
89
|
+
access: target.access === true,
|
|
90
|
+
roles: options.declared.filter((d) => d.type === POSTGRES_ENTITY_TYPES.role).map((d) => String(d.props.name)),
|
|
91
|
+
}),
|
|
92
|
+
target.provider,
|
|
93
|
+
);
|
|
94
|
+
return { target, byKey: new Map(live.map((o) => [liveKey(o.type, o.schema, o.name, o.signature), o])) };
|
|
63
95
|
} finally {
|
|
64
96
|
await client.end();
|
|
65
97
|
}
|
|
@@ -67,9 +99,11 @@ function adapter(options: BindOptions & { owned?: boolean; declared: DeclaredEnt
|
|
|
67
99
|
classifyBindFailure: (err) => classifyPostgresFailure(err),
|
|
68
100
|
async read({ target, byKey }, entity): Promise<EntityObservation> {
|
|
69
101
|
if (!entity.type.startsWith("Postgres::")) return { unobserved: { reason: "unsupported-kind", detail: entity.type } };
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
const
|
|
102
|
+
const why = accessUnobserved(entity.type, target.access === true);
|
|
103
|
+
if (why) return { unobserved: why };
|
|
104
|
+
const { schema, name, signature } = declaredAddress(entity, target.defaultSchema);
|
|
105
|
+
const queried = `${redactUrl(target.endpoint.url)} ${schema ? `${schema}.` : ""}${name}${signature ?? ""}`;
|
|
106
|
+
const live = byKey.get(liveKey(entity.type, schema, name, signature));
|
|
73
107
|
if (!live) return { absent: true, queried };
|
|
74
108
|
const owned = !live.foreign && isChantManaged(live.comment);
|
|
75
109
|
if (options.owned && !owned) {
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Functions, procedures and triggers against a live server (#3680): applied
|
|
3
|
+
* as declared, they plan with no change and read back with no drift; a body,
|
|
4
|
+
* a result type and a trigger's condition changed in the declaration are
|
|
5
|
+
* planned with their rules and applied; a function replaced by hand is
|
|
6
|
+
* drift; and an import writes them back as `func`, `procedure` and
|
|
7
|
+
* `trigger` declarations that parse.
|
|
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_3680`, dropped afterwards.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
20
|
+
import { dockerAvailable, startTestPostgres, type TestPostgres } from "../testing/server";
|
|
21
|
+
import { connectPostgres, type PostgresClient, type PostgresEndpoint } from "./client";
|
|
22
|
+
import { readLiveSchema } from "./catalog";
|
|
23
|
+
import { planPgAgainstServer } from "../plan/commands";
|
|
24
|
+
import { observeResourcesDeep } from "../plan/deep";
|
|
25
|
+
import { postgresApply } from "../../op/activities/postgres-apply";
|
|
26
|
+
import { objectsToIR } from "../import/ir";
|
|
27
|
+
import { PostgresGenerator } from "../import/generator";
|
|
28
|
+
import { liveProps } from "../plan/deep";
|
|
29
|
+
import { declaredObjects } from "../apply/apply";
|
|
30
|
+
|
|
31
|
+
const SCHEMA = "chant_e2e_3680";
|
|
32
|
+
const given = process.env.CHANT_SQL_E2E_POSTGRES_URL;
|
|
33
|
+
const enabled = given ? true : await dockerAvailable();
|
|
34
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-pg-routines-"));
|
|
35
|
+
let server: TestPostgres | undefined;
|
|
36
|
+
let endpoint: PostgresEndpoint;
|
|
37
|
+
let admin: PostgresClient;
|
|
38
|
+
|
|
39
|
+
beforeAll(async () => {
|
|
40
|
+
if (!enabled) return;
|
|
41
|
+
if (given) endpoint = { url: given, password: process.env.CHANT_SQL_E2E_POSTGRES_PASSWORD ?? "" };
|
|
42
|
+
else {
|
|
43
|
+
server = await startTestPostgres();
|
|
44
|
+
endpoint = server.endpoint();
|
|
45
|
+
}
|
|
46
|
+
admin = await connectPostgres(endpoint);
|
|
47
|
+
await admin.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`);
|
|
48
|
+
}, 600_000);
|
|
49
|
+
|
|
50
|
+
afterAll(async () => {
|
|
51
|
+
if (enabled) {
|
|
52
|
+
await admin?.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => undefined);
|
|
53
|
+
await admin?.end();
|
|
54
|
+
}
|
|
55
|
+
await server?.stop();
|
|
56
|
+
rmSync(dir, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
interface Obj {
|
|
60
|
+
export: string;
|
|
61
|
+
type: string;
|
|
62
|
+
ddl: string;
|
|
63
|
+
dependsOn?: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const MARKER = { stack: "e2e3680", env: "test" };
|
|
67
|
+
const profile = () => ({
|
|
68
|
+
config: { ownership: MARKER, sql: { profiles: { e2e: { url: endpoint.url, password: { env: "PG_E2E_PASSWORD" }, schemas: [SCHEMA] } } } },
|
|
69
|
+
env: { PG_E2E_PASSWORD: endpoint.password ?? "" },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function writeBuild(file: string, objects: Obj[]): string {
|
|
73
|
+
const path = join(dir, file);
|
|
74
|
+
writeFileSync(path, JSON.stringify({ dialect: "postgres", postgresMajor: 18, objects: objects.map((o) => ({ dependsOn: [], ...o })) }));
|
|
75
|
+
return path;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const apply = (path: string) => postgresApply({ buildPath: path, environment: "e2e" }, undefined, { ...profile(), log: () => undefined });
|
|
79
|
+
const plan = (path: string) => planPgAgainstServer("e2e", path, profile());
|
|
80
|
+
|
|
81
|
+
const S = SCHEMA;
|
|
82
|
+
const objects = (v: { body: string; totalReturns: string; when: string }): Obj[] => [
|
|
83
|
+
{ export: "app", type: "Postgres::Schema", ddl: `CREATE SCHEMA ${S}` },
|
|
84
|
+
{
|
|
85
|
+
export: "orders",
|
|
86
|
+
type: "Postgres::Table",
|
|
87
|
+
dependsOn: ["app"],
|
|
88
|
+
ddl: `CREATE TABLE ${S}.orders (id bigint PRIMARY KEY, status text NOT NULL, amount numeric(12,2), note varchar(40), updated_at timestamptz)`,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
export: "touch",
|
|
92
|
+
type: "Postgres::Function",
|
|
93
|
+
dependsOn: ["app"],
|
|
94
|
+
ddl: `CREATE FUNCTION ${S}.touch() RETURNS trigger LANGUAGE plpgsql SET search_path = ${S} AS $$\nBEGIN\n ${v.body}\n RETURN NEW;\nEND\n$$;\nCOMMENT ON FUNCTION ${S}.touch() IS 'Stamps updated_at'`,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
export: "total",
|
|
98
|
+
type: "Postgres::Function",
|
|
99
|
+
dependsOn: ["orders"],
|
|
100
|
+
ddl: `CREATE FUNCTION ${S}.total(min_amount numeric DEFAULT 0, label varchar(10) = 'all') RETURNS ${v.totalReturns} LANGUAGE sql STABLE PARALLEL SAFE AS $$ SELECT coalesce(sum(amount), 0)::${v.totalReturns} FROM ${S}.orders WHERE amount >= min_amount $$`,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
export: "archive",
|
|
104
|
+
type: "Postgres::Procedure",
|
|
105
|
+
dependsOn: ["orders"],
|
|
106
|
+
ddl: `CREATE PROCEDURE ${S}.archive(IN before_id bigint, INOUT archived int DEFAULT NULL) LANGUAGE plpgsql AS $$ BEGIN DELETE FROM ${S}.orders WHERE id < before_id; GET DIAGNOSTICS archived = ROW_COUNT; END $$`,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
export: "ordersTouch",
|
|
110
|
+
type: "Postgres::Trigger",
|
|
111
|
+
dependsOn: ["orders", "touch"],
|
|
112
|
+
ddl: `CREATE TRIGGER orders_touch BEFORE INSERT OR UPDATE OF status, note ON ${S}.orders FOR EACH ROW WHEN (${v.when}) EXECUTE FUNCTION ${S}.touch('x', 1)`,
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
const V1 = { body: "NEW.updated_at := now();", totalReturns: "numeric", when: "NEW.note IS DISTINCT FROM 'skip'" };
|
|
117
|
+
const V2 = { body: "NEW.updated_at := clock_timestamp();", totalReturns: "double precision", when: "NEW.note <> 'skip' AND NEW.status = 'open'" };
|
|
118
|
+
|
|
119
|
+
describe.skipIf(!enabled)("functions, procedures and triggers on a live server", () => {
|
|
120
|
+
test("apply, plan, drift, change and import", async () => {
|
|
121
|
+
const v1 = writeBuild("v1.json", objects(V1));
|
|
122
|
+
const first = await apply(v1);
|
|
123
|
+
expect(first.failed).toEqual([]);
|
|
124
|
+
expect(first.applied.map((a) => [a.name, a.action])).toEqual([
|
|
125
|
+
[S, "created"],
|
|
126
|
+
[`${S}.orders`, "created"],
|
|
127
|
+
[`${S}.touch()`, "created"],
|
|
128
|
+
[`${S}.total(numeric,character varying)`, "created"],
|
|
129
|
+
[`${S}.archive(bigint,integer)`, "created"],
|
|
130
|
+
[`orders_touch ON ${S}.orders`, "created"],
|
|
131
|
+
]);
|
|
132
|
+
// The trigger fires, with its WHEN and its arguments.
|
|
133
|
+
await admin.query(`INSERT INTO ${S}.orders (id, status, amount) VALUES (1, 'open', 10), (2, 'open', 5)`);
|
|
134
|
+
expect((await admin.query<{ n: string }>(`SELECT count(*) AS n FROM ${S}.orders WHERE updated_at IS NOT NULL`))[0]!.n).toBe("2");
|
|
135
|
+
|
|
136
|
+
// As declared: no change, and no drift, the WHEN the server prints with its casts included.
|
|
137
|
+
expect((await plan(v1)).changes).toEqual([]);
|
|
138
|
+
const declared = declaredObjects(JSON.stringify({ dialect: "postgres", objects: objects(V1).map((o) => ({ dependsOn: [], ...o })) }));
|
|
139
|
+
const entities = new Map(declared.map((o) => [o.exportName, { entityType: o.type, props: o.props }]));
|
|
140
|
+
const deep = await observeResourcesDeep({ environment: "e2e", entityNames: [...entities.keys()], entities, ...profile() });
|
|
141
|
+
expect(deep.unobserved ?? {}).toEqual({});
|
|
142
|
+
const strip = (p: Record<string, unknown>) => Object.fromEntries(Object.entries(p).filter(([k]) => !["ddl", "source", "reads", "orReplace"].includes(k)));
|
|
143
|
+
for (const [name, e] of entities) expect(strip(deep.resources[name]!.properties), name).toEqual(strip(e.props));
|
|
144
|
+
|
|
145
|
+
// Replaced by hand: drift, in the plan and in the deep read.
|
|
146
|
+
await admin.query(`CREATE OR REPLACE FUNCTION ${S}.touch() RETURNS trigger LANGUAGE plpgsql SET search_path = ${S} AS $$ BEGIN RETURN NEW; END $$`);
|
|
147
|
+
expect((await plan(v1)).changes.map((c) => [c.object, c.field, c.rule])).toEqual([[`touch (${S}.touch())`, "body", "SQLPG280"]]);
|
|
148
|
+
const drifted = await observeResourcesDeep({ environment: "e2e", entityNames: ["touch"], entities, ...profile() });
|
|
149
|
+
expect(drifted.resources.touch!.properties.body).toBe(" BEGIN RETURN NEW; END ");
|
|
150
|
+
|
|
151
|
+
// Changed in the declaration: a body (OR REPLACE), a result type (drop and create), a trigger's condition (OR REPLACE).
|
|
152
|
+
const v2 = writeBuild("v2.json", objects(V2));
|
|
153
|
+
expect((await plan(v2)).changes.map((c) => [c.object, c.rule])).toEqual([
|
|
154
|
+
[`touch (${S}.touch())`, "SQLPG280"],
|
|
155
|
+
[`total (${S}.total(numeric,character varying))`, "SQLPG281"],
|
|
156
|
+
[`ordersTouch (${S}.orders_touch ON ${S}.orders)`, "SQLPG284"],
|
|
157
|
+
]);
|
|
158
|
+
const second = await apply(v2);
|
|
159
|
+
expect(second.failed).toEqual([]);
|
|
160
|
+
expect(second.applied.filter((a) => a.action === "updated").map((a) => a.name)).toEqual([`${S}.touch()`, `${S}.total(numeric,character varying)`, `orders_touch ON ${S}.orders`]);
|
|
161
|
+
expect((await plan(v2)).changes).toEqual([]);
|
|
162
|
+
expect((await admin.query<{ c: string }>(`SELECT obj_description('${S}.total(numeric,varchar)'::regprocedure, 'pg_proc') AS c`))[0]!.c).toContain("stack=e2e3680");
|
|
163
|
+
|
|
164
|
+
// Import: the server's own printing, written back as declarations that parse.
|
|
165
|
+
const live = await readLiveSchema(admin, { schemas: [S] });
|
|
166
|
+
for (const o of live) liveProps(o);
|
|
167
|
+
const [file] = new PostgresGenerator().generate(objectsToIR(live.map((o) => ({ type: o.type, schema: o.schema, name: o.name, ddl: o.statement }))));
|
|
168
|
+
expect(file!.content).toContain("export const touch = func`");
|
|
169
|
+
expect(file!.content).toContain("export const archive = procedure`");
|
|
170
|
+
expect(file!.content).toContain("export const ordersTouch = trigger`");
|
|
171
|
+
expect(file!.content).toContain("EXECUTE FUNCTION ${touch}('x', '1')");
|
|
172
|
+
}, 600_000);
|
|
173
|
+
});
|
|
@@ -96,7 +96,8 @@ describe.skipIf(!enabled)("a uuid primary key", () => {
|
|
|
96
96
|
await admin!.query("CREATE FUNCTION audit.count_update() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN INSERT INTO audit.updates VALUES (NEW.id); RETURN NEW; END $$");
|
|
97
97
|
await admin!.query("CREATE TRIGGER audit_update AFTER UPDATE ON uu.tokens FOR EACH ROW EXECUTE FUNCTION audit.count_update()");
|
|
98
98
|
writeBuild("tokens-v2.json", [UU, tokens("integer")]);
|
|
99
|
-
|
|
99
|
+
// The application's own trigger is not declared, so the plan also proposes its drop (SQLPG285), which only a prune makes.
|
|
100
|
+
expect((await plan("tokens-v2.json")).changes.map((c) => c.rule)).toEqual(["SQLPG208", "SQLPG285"]);
|
|
100
101
|
|
|
101
102
|
expect(await m.postgresMigrationPlan(args(), undefined, deps())).toMatchObject({ state: "migrate", summary: expect.stringContaining("batched by id") });
|
|
102
103
|
await m.postgresMigrationExpand(args(), undefined, deps());
|
|
@@ -307,7 +307,8 @@ describe.skipIf(!enabled)("an interrupted backfill resumes from its receipts", (
|
|
|
307
307
|
writeBuild("events-v2.json", [EV, V2]);
|
|
308
308
|
|
|
309
309
|
// varchar(20) to varchar(10) is a rewrite in place (SQLPG207), which the Op makes in batches instead.
|
|
310
|
-
|
|
310
|
+
// The application's own trigger is not declared, so the plan also proposes its drop (SQLPG285), which only a prune makes.
|
|
311
|
+
expect((await plan("events-v2.json")).changes.map((c) => c.rule)).toEqual(["SQLPG207", "SQLPG285"]);
|
|
311
312
|
expect(await m.postgresMigrationPlan(args(), undefined, deps())).toMatchObject({ state: "migrate", change: "type" });
|
|
312
313
|
await m.postgresMigrationExpand(args(), undefined, deps());
|
|
313
314
|
await m.postgresMigrationDualWrite(args(), undefined, deps());
|