@intentius/chant-lexicon-sql 0.115.0 → 0.116.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/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 +15 -0
- package/dist/okf/index.md +3 -0
- package/dist/okf/types/PostgresFunction.md +9 -0
- package/dist/okf/types/PostgresProcedure.md +9 -0
- package/dist/okf/types/PostgresTrigger.md +9 -0
- package/dist/plugin.d.ts.map +1 -1
- 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 +9 -2
- package/dist/postgres/apply/statements.d.ts.map +1 -1
- package/dist/postgres/entities.d.ts +110 -3
- package/dist/postgres/entities.d.ts.map +1 -1
- package/dist/postgres/entity-types.d.ts +3 -0
- package/dist/postgres/entity-types.d.ts.map +1 -1
- package/dist/postgres/import/generator.d.ts.map +1 -1
- package/dist/postgres/import/live-export.d.ts.map +1 -1
- package/dist/postgres/live/catalog.d.ts +16 -4
- package/dist/postgres/live/catalog.d.ts.map +1 -1
- package/dist/postgres/live/describe-resources.d.ts +6 -1
- package/dist/postgres/live/describe-resources.d.ts.map +1 -1
- package/dist/postgres/parser.d.ts +82 -5
- package/dist/postgres/parser.d.ts.map +1 -1
- package/dist/postgres/plan/deep.d.ts.map +1 -1
- package/dist/postgres/plan/diff.d.ts +1 -1
- package/dist/postgres/plan/diff.d.ts.map +1 -1
- package/dist/postgres/plan/normalize.d.ts +21 -2
- package/dist/postgres/plan/normalize.d.ts.map +1 -1
- package/dist/postgres/plan/rules.d.ts +6 -0
- package/dist/postgres/plan/rules.d.ts.map +1 -1
- package/dist/postgres/plan/schema.d.ts +11 -1
- package/dist/postgres/plan/schema.d.ts.map +1 -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 +3 -0
- 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 +3 -0
- package/src/generated/lexicon-sql.json +15 -0
- package/src/index.ts +1 -1
- package/src/lint/rules/postgres-templates.ts +2 -2
- package/src/lint/rules/sqlpg001.ts +3 -0
- package/src/lsp/postgres-template.ts +6 -0
- package/src/mcp/postgres.ts +1 -1
- package/src/plugin.ts +3 -0
- package/src/postgres/apply/apply.ts +21 -5
- package/src/postgres/apply/statements.ts +53 -11
- package/src/postgres/entities.ts +332 -9
- package/src/postgres/entity-types.ts +3 -0
- package/src/postgres/import/generator.ts +5 -2
- package/src/postgres/import/live-export.ts +4 -0
- package/src/postgres/live/catalog.ts +78 -4
- package/src/postgres/live/describe-resources.ts +17 -7
- 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/parser.ts +314 -8
- package/src/postgres/plan/commands.ts +2 -2
- package/src/postgres/plan/deep.ts +57 -17
- package/src/postgres/plan/diff.ts +46 -6
- package/src/postgres/plan/normalize.ts +158 -4
- package/src/postgres/plan/rules.ts +36 -0
- package/src/postgres/plan/schema.ts +25 -6
- package/src/postgres/plan/server-normalize.ts +24 -0
- package/src/postgres/routines.test.ts +150 -0
- package/src/postgres.ts +11 -0
- package/src/skill-defs.ts +1 -1
- package/src/skills/chant-sql-postgres.md +18 -2
- package/src/validate.ts +3 -0
|
@@ -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,6 +81,12 @@ 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. */
|
|
@@ -181,8 +192,9 @@ const seqParams = (r: Row): SequenceParams => ({
|
|
|
181
192
|
|
|
182
193
|
/**
|
|
183
194
|
* Every object in scope, in a stable order: schemas, extensions, enums,
|
|
184
|
-
* domains, sequences, tables, views and materialized views, indexes
|
|
185
|
-
* kind by schema then name, in byte
|
|
195
|
+
* domains, sequences, tables, views and materialized views, indexes,
|
|
196
|
+
* functions and procedures, triggers; each kind by schema then name, in byte
|
|
197
|
+
* order.
|
|
186
198
|
*/
|
|
187
199
|
export async function readLiveSchema(client: PostgresClient, scope: SchemaScope = {}): Promise<LivePgObject[]> {
|
|
188
200
|
const out: LivePgObject[] = [];
|
|
@@ -556,11 +568,73 @@ export async function readLiveSchema(client: PostgresClient, scope: SchemaScope
|
|
|
556
568
|
}
|
|
557
569
|
}
|
|
558
570
|
|
|
571
|
+
// Functions and procedures (not aggregates).
|
|
572
|
+
{
|
|
573
|
+
const rows = await client.query<Row>(
|
|
574
|
+
`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,
|
|
575
|
+
'(' || pg_catalog.replace(pg_catalog.oidvectortypes(p.proargtypes), ', ', ',') || ')' AS signature,
|
|
576
|
+
p.prosqlbody IS NOT NULL AS sqlbody, pg_catalog.obj_description(p.oid, 'pg_proc') AS comment,
|
|
577
|
+
ARRAY(SELECT pg_catalog.pg_describe_object(d.classid, d.objid, d.objsubid) FROM pg_catalog.pg_depend d
|
|
578
|
+
WHERE d.refclassid = 'pg_catalog.pg_proc'::pg_catalog.regclass AND d.refobjid = p.oid AND d.deptype = 'n'
|
|
579
|
+
ORDER BY 1) AS dependents
|
|
580
|
+
FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
|
|
581
|
+
WHERE p.prokind IN ('f', 'p', 'w') AND ${s.sql} AND ${notExtensionOwned("pg_proc", "p.oid")}
|
|
582
|
+
ORDER BY n.nspname ${C}, p.proname ${C}, pg_catalog.oidvectortypes(p.proargtypes) ${C}`,
|
|
583
|
+
s.params,
|
|
584
|
+
);
|
|
585
|
+
for (const r of rows) {
|
|
586
|
+
// A migration's dual-write function, until its contract.
|
|
587
|
+
if (hasChantTrailerKey(str(r.comment), [MIGRATION_TRAILER_KEY])) continue;
|
|
588
|
+
const signature = String(r.signature);
|
|
589
|
+
const q = `${qname(String(r.schema), String(r.name))}${signature}`;
|
|
590
|
+
const procedure = r.kind === "p";
|
|
591
|
+
const dependents = (r.dependents as string[] | null) ?? [];
|
|
592
|
+
out.push({
|
|
593
|
+
type: procedure ? POSTGRES_ENTITY_TYPES.procedure : POSTGRES_ENTITY_TYPES.function,
|
|
594
|
+
schema: String(r.schema),
|
|
595
|
+
name: String(r.name),
|
|
596
|
+
oid: String(r.oid),
|
|
597
|
+
signature,
|
|
598
|
+
...(dependents.length > 0 ? { dependents } : {}),
|
|
599
|
+
...(str(r.comment) ? { comment: str(r.comment) } : {}),
|
|
600
|
+
...(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 $$ ... $$)" } : {}),
|
|
601
|
+
statement: [String(r.def).trim(), ...commentOn(`${procedure ? "PROCEDURE" : "FUNCTION"} ${q}`, r.comment)].join(";\n"),
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Triggers: the ones written by hand, not a foreign key's or a partition's clone of its parent's.
|
|
607
|
+
{
|
|
608
|
+
const rows = await client.query<Row>(
|
|
609
|
+
`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,
|
|
610
|
+
pg_catalog.obj_description(t.oid, 'pg_trigger') AS comment
|
|
611
|
+
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
|
|
612
|
+
WHERE NOT t.tgisinternal AND t.tgparentid = 0 AND ${s.sql} AND ${notExtensionOwned("pg_class", "c.oid")}
|
|
613
|
+
ORDER BY n.nspname ${C}, c.relname ${C}, t.tgname ${C}`,
|
|
614
|
+
s.params,
|
|
615
|
+
);
|
|
616
|
+
for (const r of rows) {
|
|
617
|
+
// A migration's dual-write trigger, until its contract.
|
|
618
|
+
if (hasChantTrailerKey(str(r.comment), [MIGRATION_TRAILER_KEY])) continue;
|
|
619
|
+
const table = qname(String(r.schema), String(r.table_name));
|
|
620
|
+
out.push({
|
|
621
|
+
type: POSTGRES_ENTITY_TYPES.trigger,
|
|
622
|
+
schema: String(r.schema),
|
|
623
|
+
name: String(r.name),
|
|
624
|
+
oid: String(r.oid),
|
|
625
|
+
signature: ` ON ${table}`,
|
|
626
|
+
...(str(r.comment) ? { comment: str(r.comment) } : {}),
|
|
627
|
+
...(FOREIGN_TABLES[String(r.table_name)] ? { foreign: FOREIGN_TABLES[String(r.table_name)] } : {}),
|
|
628
|
+
statement: [String(r.def), ...commentOn(`TRIGGER ${quoteIdent(String(r.name))} ON ${table}`, r.comment)].join(";\n"),
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
559
633
|
return out;
|
|
560
634
|
}
|
|
561
635
|
|
|
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}`;
|
|
636
|
+
/** The key an object is found by: its type and schema-qualified name, and a routine's parameter types or a trigger's table. */
|
|
637
|
+
export const liveKey = (type: string, schema: string | undefined, name: string, signature?: string): string => `${type} ${schema ?? ""}.${name}${signature ?? ""}`;
|
|
564
638
|
|
|
565
639
|
/**
|
|
566
640
|
* Mark what the managed service owns (#3282) as foreign: its reserved
|
|
@@ -27,17 +27,27 @@ 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
44
|
if (entity.type === POSTGRES_ENTITY_TYPES.schema || entity.type === POSTGRES_ENTITY_TYPES.extension) return { name };
|
|
40
|
-
|
|
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) {
|
|
47
|
+
const signature = canonicalPgObject(entity.type, entity.props, defaultSchema).signature;
|
|
48
|
+
return { schema, name, ...(signature !== undefined ? { signature } : {}) };
|
|
49
|
+
}
|
|
50
|
+
return { schema, name };
|
|
41
51
|
}
|
|
42
52
|
|
|
43
53
|
/** A schema scope for the catalog read: the profile's schemas, else every schema the declarations name and the default one. */
|
|
@@ -59,7 +69,7 @@ function adapter(options: BindOptions & { owned?: boolean; declared: DeclaredEnt
|
|
|
59
69
|
const { target, client } = await bindPostgres(options);
|
|
60
70
|
try {
|
|
61
71
|
const live = markProviderOwned(await readLiveSchema(client, { schemas: scopeFor(target, options.declared.filter((d) => d.type.startsWith("Postgres::"))) }), target.provider);
|
|
62
|
-
return { target, byKey: new Map(live.map((o) => [liveKey(o.type, o.schema, o.name), o])) };
|
|
72
|
+
return { target, byKey: new Map(live.map((o) => [liveKey(o.type, o.schema, o.name, o.signature), o])) };
|
|
63
73
|
} finally {
|
|
64
74
|
await client.end();
|
|
65
75
|
}
|
|
@@ -67,9 +77,9 @@ function adapter(options: BindOptions & { owned?: boolean; declared: DeclaredEnt
|
|
|
67
77
|
classifyBindFailure: (err) => classifyPostgresFailure(err),
|
|
68
78
|
async read({ target, byKey }, entity): Promise<EntityObservation> {
|
|
69
79
|
if (!entity.type.startsWith("Postgres::")) return { unobserved: { reason: "unsupported-kind", detail: entity.type } };
|
|
70
|
-
const { schema, name } = declaredAddress(entity, target.defaultSchema);
|
|
71
|
-
const queried = `${redactUrl(target.endpoint.url)} ${schema ? `${schema}.` : ""}${name}`;
|
|
72
|
-
const live = byKey.get(liveKey(entity.type, schema, name));
|
|
80
|
+
const { schema, name, signature } = declaredAddress(entity, target.defaultSchema);
|
|
81
|
+
const queried = `${redactUrl(target.endpoint.url)} ${schema ? `${schema}.` : ""}${name}${signature ?? ""}`;
|
|
82
|
+
const live = byKey.get(liveKey(entity.type, schema, name, signature));
|
|
73
83
|
if (!live) return { absent: true, queried };
|
|
74
84
|
const owned = !live.foreign && isChantManaged(live.comment);
|
|
75
85
|
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());
|