@intentius/chant-lexicon-sql 0.124.18 → 0.124.19

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.
Files changed (60) hide show
  1. package/dist/clickhouse/import/live-export.d.ts +8 -0
  2. package/dist/clickhouse/import/live-export.d.ts.map +1 -1
  3. package/dist/clickhouse/live/undeclarable.d.ts +32 -0
  4. package/dist/clickhouse/live/undeclarable.d.ts.map +1 -0
  5. package/dist/clickhouse/testing/fake-server.d.ts +6 -0
  6. package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
  7. package/dist/codegen/coverage-page.d.ts +7 -0
  8. package/dist/codegen/coverage-page.d.ts.map +1 -0
  9. package/dist/codegen/docs.d.ts.map +1 -1
  10. package/dist/core/undeclarable.d.ts +38 -0
  11. package/dist/core/undeclarable.d.ts.map +1 -0
  12. package/dist/coverage/clickhouse-commands.json +24 -0
  13. package/dist/coverage/clickhouse.json +25 -0
  14. package/dist/coverage/postgres-commands.json +245 -0
  15. package/dist/coverage/postgres.json +51 -0
  16. package/dist/coverage.d.ts +68 -0
  17. package/dist/coverage.d.ts.map +1 -1
  18. package/dist/files/clickhouse.d.ts.map +1 -1
  19. package/dist/files/common.d.ts +12 -0
  20. package/dist/files/common.d.ts.map +1 -1
  21. package/dist/files/postgres.d.ts.map +1 -1
  22. package/dist/import-parser.d.ts +7 -2
  23. package/dist/import-parser.d.ts.map +1 -1
  24. package/dist/integrity.json +2 -2
  25. package/dist/manifest.json +1 -1
  26. package/dist/plugin.d.ts.map +1 -1
  27. package/dist/postgres/import/live-export.d.ts +8 -0
  28. package/dist/postgres/import/live-export.d.ts.map +1 -1
  29. package/dist/postgres/live/catalog.d.ts +12 -1
  30. package/dist/postgres/live/catalog.d.ts.map +1 -1
  31. package/dist/postgres/live/undeclarable.d.ts +42 -0
  32. package/dist/postgres/live/undeclarable.d.ts.map +1 -0
  33. package/dist/postgres/testing/fake-client.d.ts +6 -0
  34. package/dist/postgres/testing/fake-client.d.ts.map +1 -1
  35. package/package.json +2 -2
  36. package/src/clickhouse/import/live-export.ts +17 -2
  37. package/src/clickhouse/live/undeclarable.e2e.test.ts +64 -0
  38. package/src/clickhouse/live/undeclarable.ts +66 -0
  39. package/src/clickhouse/testing/fake-server.ts +5 -0
  40. package/src/codegen/classifier-page.test.ts +6 -0
  41. package/src/codegen/coverage-page.ts +77 -0
  42. package/src/codegen/docs.ts +5 -0
  43. package/src/core/undeclarable.test.ts +138 -0
  44. package/src/core/undeclarable.ts +66 -0
  45. package/src/coverage/clickhouse-commands.json +24 -0
  46. package/src/coverage/clickhouse.json +25 -0
  47. package/src/coverage/postgres-commands.json +245 -0
  48. package/src/coverage/postgres.json +51 -0
  49. package/src/coverage.test.ts +78 -0
  50. package/src/coverage.ts +142 -0
  51. package/src/files/clickhouse.ts +6 -1
  52. package/src/files/common.ts +18 -0
  53. package/src/files/postgres.ts +6 -1
  54. package/src/import-parser.ts +12 -4
  55. package/src/plugin.ts +6 -1
  56. package/src/postgres/import/live-export.ts +25 -2
  57. package/src/postgres/live/catalog.ts +7 -3
  58. package/src/postgres/live/undeclarable.e2e.test.ts +81 -0
  59. package/src/postgres/live/undeclarable.ts +141 -0
  60. package/src/postgres/testing/fake-client.ts +5 -0
package/src/coverage.ts CHANGED
@@ -1,4 +1,17 @@
1
1
  /**
2
+ * Coverage, two ways.
3
+ *
4
+ * Which kinds of object each dialect declares (#3911): every `CREATE` command
5
+ * a pinned server has (`./coverage/<dialect>-commands.json`, written by
6
+ * `scripts/sql-create-commands.ts` from the server's own sources) has a row
7
+ * in `./coverage/<dialect>.json` marking it declared, refused with a reason,
8
+ * or out of scope. A command with several forms that differ in what chant can
9
+ * hold (`CREATE TYPE ... AS ENUM` and `CREATE TYPE ... AS RANGE`) has a row
10
+ * per form. Import refuses an object of a refused kind by that row's `kind`
11
+ * (`./core/undeclarable.ts`), and the docs page is rendered from the rows
12
+ * (`./codegen/coverage-page.ts`). To declare a kind, flip its row to
13
+ * `declared` with the `entity` that holds it, and drop the reason.
14
+ *
2
15
  * How much of the pinned ClickHouse catalog the dialect types beyond its name.
3
16
  *
4
17
  * Every engine, type family, codec, skip index type and setting at the pin is
@@ -9,6 +22,135 @@
9
22
  */
10
23
 
11
24
  import { CODECS, SKIP_INDEX_TYPES, TABLE_ENGINES, CLICKHOUSE_VERSION } from "./generated/clickhouse";
25
+ import { POSTGRES_PINS } from "./spec/postgres-pin";
26
+ import type { SqlDialect } from "./dialects";
27
+ import postgresKinds from "./coverage/postgres.json" with { type: "json" };
28
+ import clickhouseKinds from "./coverage/clickhouse.json" with { type: "json" };
29
+ import postgresCommands from "./coverage/postgres-commands.json" with { type: "json" };
30
+ import clickhouseCommands from "./coverage/clickhouse-commands.json" with { type: "json" };
31
+
32
+ // ── Object kinds ───────────────────────────────────────────────────────
33
+
34
+ /** `declared`: a tag holds it. `refused`: import names it and stops. `out-of-scope`: the environment's, never imported. */
35
+ export type KindStatus = "declared" | "refused" | "out-of-scope";
36
+
37
+ /** One row of a dialect's coverage table: a `CREATE` command, or one form of it. */
38
+ export interface KindRow {
39
+ /** The command as the server's reference names it: `CREATE TYPE`. */
40
+ command: string;
41
+ /** The form of the command the row is about, when the command has forms chant treats apart: `AS RANGE`. */
42
+ form?: string;
43
+ /**
44
+ * How a statement of the command is told to be this form: a regular
45
+ * expression, case-insensitive, over the statement with its comments and
46
+ * string literals taken out. The command's row without one is the rest.
47
+ */
48
+ match?: string;
49
+ /** The object kind, unique in the dialect, as messages name it: `range type`. */
50
+ kind: string;
51
+ status: KindStatus;
52
+ /** The entity type that declares it, when declared. */
53
+ entity?: string;
54
+ /** Why it is refused or out of scope. */
55
+ reason?: string;
56
+ /** Anything else a reader should know. */
57
+ note?: string;
58
+ }
59
+
60
+ /** Each dialect's coverage table. */
61
+ export const KIND_COVERAGE: Record<SqlDialect, readonly KindRow[]> = {
62
+ postgres: postgresKinds.kinds as KindRow[],
63
+ clickhouse: clickhouseKinds.kinds as KindRow[],
64
+ };
65
+
66
+ /** The `CREATE` commands each pinned server has: per Postgres major, and the pinned ClickHouse release. */
67
+ export const PINNED_COMMANDS: Record<SqlDialect, ReadonlyArray<{ version: string; commands: readonly string[] }>> = {
68
+ postgres: Object.values(postgresCommands.majors),
69
+ clickhouse: [{ version: clickhouseCommands.version, commands: clickhouseCommands.commands }],
70
+ };
71
+
72
+ /** A dialect's row for `kind`; throws for a kind the table does not have, so a probe cannot name one that is not there. */
73
+ export function kindRow(dialect: SqlDialect, kind: string): KindRow {
74
+ const row = KIND_COVERAGE[dialect].find((r) => r.kind === kind);
75
+ if (!row) throw new Error(`no ${dialect} coverage row for the kind "${kind}" (src/coverage/${dialect}.json)`);
76
+ return row;
77
+ }
78
+
79
+ /**
80
+ * What is wrong with a dialect's coverage table, against its pinned servers'
81
+ * commands: a command a pinned server has and no row covers, a row for a
82
+ * command no pinned server has, a kind named twice, a declared row with no
83
+ * entity, a refused or out-of-scope row with no reason. Empty when complete.
84
+ */
85
+ export function kindCoverageProblems(dialect: SqlDialect, opts: { pins?: ReadonlyArray<{ version: string; commands: readonly string[] }>; rows?: readonly KindRow[] } = {}): string[] {
86
+ const rows = opts.rows ?? KIND_COVERAGE[dialect];
87
+ const pins = opts.pins ?? PINNED_COMMANDS[dialect];
88
+ const problems: string[] = [];
89
+ const covered = new Set(rows.map((r) => r.command));
90
+ const listed = new Set(pins.flatMap((p) => p.commands));
91
+ for (const p of pins) {
92
+ for (const c of p.commands) if (!covered.has(c)) problems.push(`${dialect} ${p.version} has ${c}, and no row in src/coverage/${dialect}.json covers it`);
93
+ }
94
+ for (const c of covered) if (!listed.has(c)) problems.push(`src/coverage/${dialect}.json has a row for ${c}, which no pinned ${dialect} server has`);
95
+ const seen = new Set<string>();
96
+ for (const r of rows) {
97
+ if (seen.has(r.kind)) problems.push(`the kind "${r.kind}" has two rows`);
98
+ seen.add(r.kind);
99
+ if (!["declared", "refused", "out-of-scope"].includes(r.status)) problems.push(`${r.kind}: status "${r.status}" is not declared, refused or out-of-scope`);
100
+ if (r.status === "declared" && !r.entity) problems.push(`${r.kind} is declared, and names no entity type`);
101
+ if (r.status !== "declared" && !r.reason) problems.push(`${r.kind} is ${r.status}, and gives no reason`);
102
+ }
103
+ return problems;
104
+ }
105
+
106
+ /** A statement's words, upper case, with comments and string literals (quoted and dollar-quoted) taken out. */
107
+ function bareStatement(sql: string): string {
108
+ return sql
109
+ .replace(/--[^\n]*/g, " ")
110
+ .replace(/\/\*[\s\S]*?\*\//g, " ")
111
+ .replace(/\$([A-Za-z_][A-Za-z0-9_]*)?\$[\s\S]*?\$\1\$/g, " '' ")
112
+ .replace(/'(?:[^'\\]|''|\\.)*'/g, " '' ")
113
+ .replace(/\s+/g, " ")
114
+ .trim()
115
+ .toUpperCase();
116
+ }
117
+
118
+ /** Words a `CREATE` can carry before the command's own: `CREATE OR REPLACE TRUSTED PROCEDURAL LANGUAGE`, `CREATE DEFAULT CONVERSION`. */
119
+ const MODIFIERS = new Set(["OR", "REPLACE", "TRUSTED", "PROCEDURAL", "DEFAULT", "UNIQUE", "UNLOGGED", "RECURSIVE", "CONSTRAINT", "GLOBAL", "LOCAL", "TEMP", "TEMPORARY"]);
120
+
121
+ /**
122
+ * The coverage row a `CREATE` statement is of, by its command (the longest
123
+ * that its words start with) and, among the command's forms, the first whose
124
+ * `match` it matches; undefined for a statement no row covers.
125
+ */
126
+ export function statementKind(dialect: SqlDialect, sql: string): KindRow | undefined {
127
+ const bare = bareStatement(sql);
128
+ const words = bare.split(/[\s(]+/);
129
+ if (words[0] !== "CREATE") return undefined;
130
+ const rest = words.slice(1).filter((w, i, all) => !MODIFIERS.has(w) || all.slice(0, i).some((x) => !MODIFIERS.has(x)));
131
+ const rows = KIND_COVERAGE[dialect];
132
+ let command: string | undefined;
133
+ for (const r of rows) {
134
+ const cmd = r.command.split(" ").slice(1);
135
+ if (cmd.every((w, i) => rest[i] === w) && (!command || cmd.length > command.split(" ").length - 1)) command = r.command;
136
+ }
137
+ if (!command) return undefined;
138
+ const forms = rows.filter((r) => r.command === command);
139
+ return forms.find((r) => r.match !== undefined && new RegExp(r.match, "i").test(bare)) ?? forms.find((r) => r.match === undefined);
140
+ }
141
+
142
+ /** Whether the commands file was written for the pinned servers: a list for each pinned version, and for no other. */
143
+ export function pinnedCommandsCurrent(dialect: SqlDialect): string[] {
144
+ const have = PINNED_COMMANDS[dialect].map((p) => p.version);
145
+ const want = dialect === "postgres" ? POSTGRES_PINS.map((p) => p.version) : [CLICKHOUSE_VERSION];
146
+ const run = `run npx tsx scripts/sql-create-commands.ts ${dialect}`;
147
+ return [
148
+ ...want.filter((v) => !have.includes(v)).map((v) => `${dialect} ${v} is pinned, and src/coverage/${dialect}-commands.json has no command list for it: ${run}`),
149
+ ...have.filter((v) => !want.includes(v)).map((v) => `src/coverage/${dialect}-commands.json lists ${dialect} ${v}, which is no longer pinned: ${run}`),
150
+ ];
151
+ }
152
+
153
+ // ── ClickHouse argument typing ─────────────────────────────────────────
12
154
 
13
155
  export interface CoverageSection {
14
156
  section: string;
@@ -20,7 +20,7 @@ import { isAccessStatement, parseAccess, type AccessNode } from "../clickhouse/a
20
20
  import type { Token } from "../clickhouse/tokens";
21
21
  import { CLICKHOUSE_TAG_OF } from "../clickhouse/import/generator";
22
22
  import { describeStatement, type ImportedObject } from "../clickhouse/import/ir";
23
- import { fileUnparsed, firstLine, lineAt, quoteBare, splitTokens, tokensText, type FileLine, type ReadOptions } from "./common";
23
+ import { fileUnparsed, firstLine, lineAt, quoteBare, splitTokens, tokensText, undeclarableStatement, type FileLine, type ReadOptions } from "./common";
24
24
  import { templateSafe, templateStrings } from "./postgres";
25
25
 
26
26
  type Tag = (strings: TemplateStringsArray, ...values: unknown[]) => Declarable;
@@ -87,6 +87,11 @@ export function readClickHouse(ddl: string, options: ReadOptions, problems: stri
87
87
  for (const stmt of splitTokens(tokenizeText(ddl, 0), isTrivia)) {
88
88
  let sql = tokensText(stmt);
89
89
  const line = lineAt(ddl, stmt[0]!.start);
90
+ const undeclarable = undeclarableStatement("clickhouse", sql);
91
+ if (undeclarable) {
92
+ (options.undeclarable ?? problems).push(undeclarable);
93
+ continue;
94
+ }
90
95
  if (isAccessStatement(sql)) {
91
96
  try {
92
97
  const read = readAccess(sql, options);
@@ -2,6 +2,8 @@
2
2
 
3
3
  import type { Token } from "../core/tokens";
4
4
  import type { UnknownClause } from "../core/cursor";
5
+ import type { SqlDialect } from "../dialects";
6
+ import { statementKind } from "../coverage";
5
7
 
6
8
  export interface ReadOptions {
7
9
  /** Names the DDL in errors: a file's path, a command. */
@@ -11,6 +13,22 @@ export interface ReadOptions {
11
13
  * unqualified, as an ORM does when the schema comes from its connection.
12
14
  */
13
15
  schema?: string;
16
+ /**
17
+ * Where a statement of a kind chant cannot declare goes (#3911), as a line
18
+ * naming it, instead of among the problems: `chant import
19
+ * --parser-option skipUndeclarable` lists them as warnings.
20
+ */
21
+ undeclarable?: string[];
22
+ }
23
+
24
+ /**
25
+ * A `CREATE` of a kind the coverage table does not mark declared, as the line
26
+ * that names it with why, or undefined for any other statement.
27
+ */
28
+ export function undeclarableStatement(dialect: SqlDialect, sql: string): string | undefined {
29
+ const row = statementKind(dialect, sql);
30
+ if (!row || row.status === "declared") return undefined;
31
+ return `${row.kind}: chant cannot declare it (${row.reason ?? row.status}): ${firstLine(sql)}`;
14
32
  }
15
33
 
16
34
  /** A file of DDL with statements that declare nothing chant can hold: each problem names its statement. */
@@ -26,7 +26,7 @@ import * as tags from "../postgres/entities";
26
26
  import { POSTGRES_ENTITY_TYPES, type PostgresEntityType } from "../postgres/entity-types";
27
27
  import { POSTGRES_TAG_OF } from "../postgres/import/generator";
28
28
  import type { ImportedPgObject } from "../postgres/import/ir";
29
- import { fileUnparsed, firstLine, lineAt, quoteBare, splitTokens, tokensText, type FileLine, type ReadOptions } from "./common";
29
+ import { fileUnparsed, firstLine, lineAt, quoteBare, splitTokens, tokensText, undeclarableStatement, type FileLine, type ReadOptions } from "./common";
30
30
 
31
31
  type Tag = (strings: TemplateStringsArray, ...values: unknown[]) => Declarable;
32
32
 
@@ -91,6 +91,11 @@ export function readPostgres(ddl: string, options: ReadOptions, problems: string
91
91
  problems.push(`not a statement that declares an object: ${firstLine(sql)}`);
92
92
  continue;
93
93
  }
94
+ const undeclarable = undeclarableStatement("postgres", sql);
95
+ if (undeclarable) {
96
+ (options.undeclarable ?? problems).push(undeclarable);
97
+ continue;
98
+ }
94
99
  try {
95
100
  const tokens = tokenizeText(sql, 0);
96
101
  for (const node of parseStatements(tokens)) parsed.push({ tokens, node, line: lineAt(ddl, stmt[0]!.start) });
@@ -9,7 +9,9 @@
9
9
  * anything else is ClickHouse.
10
10
  *
11
11
  * A statement that declares nothing, or does not parse, is an error naming
12
- * it (`./sql-files.ts`); none is left out. A clause the parser does not know
12
+ * it (`./sql-files.ts`); none is left out. So is a `CREATE` of a kind chant
13
+ * cannot declare (#3911, `./coverage.ts`): `--parser-option
14
+ * skipUndeclarable` imports the rest and lists those as warnings. A clause the parser does not know
13
15
  * (#3872) is kept in the template as written.
14
16
  */
15
17
 
@@ -52,13 +54,19 @@ export function dialectOption(options: ParserOptions | undefined): SqlDialect |
52
54
  }
53
55
 
54
56
  export class SqlFileParser implements TemplateParser {
55
- constructor(private readonly dialect?: SqlDialect) {}
57
+ constructor(
58
+ private readonly dialect?: SqlDialect,
59
+ private readonly options: { skipUndeclarable?: boolean } = {},
60
+ ) {}
56
61
 
57
62
  parse(content: string): TemplateIR {
58
63
  const dialect = this.dialect ?? guessDialect(content);
59
64
  const problems: string[] = [];
60
- const ir = dialect === "postgres" ? pgObjectsToIR(readPostgres(content, { origin: "the DDL" }, problems)) : chObjectsToIR(readClickHouse(content, { origin: "the DDL" }, problems));
65
+ const skipped: string[] = [];
66
+ const read = { origin: "the DDL", ...(this.options.skipUndeclarable ? { undeclarable: skipped } : {}) };
67
+ const ir = dialect === "postgres" ? pgObjectsToIR(readPostgres(content, read, problems)) : chObjectsToIR(readClickHouse(content, read, problems));
61
68
  if (problems.length > 0) throw new SqlFileError(`the ${dialect === "postgres" ? "Postgres" : "ClickHouse"} DDL`, problems);
62
- return ir;
69
+ if (skipped.length === 0) return ir;
70
+ return { ...ir, warnings: [...(ir.warnings ?? []), ...skipped.map((s) => `left out (skipUndeclarable): ${s}`)] };
63
71
  }
64
72
  }
package/src/plugin.ts CHANGED
@@ -159,7 +159,7 @@ export const sqlPlugin: LexiconPlugin = {
159
159
 
160
160
  /** `chant import schema.sql`: a file of Postgres or ClickHouse DDL (`./import-parser.ts`). */
161
161
  templateParser(options) {
162
- return new SqlFileParser(dialectOption(options));
162
+ return new SqlFileParser(dialectOption(options), { skipUndeclarable: options?.skipUndeclarable === true });
163
163
  },
164
164
 
165
165
  parserOptions() {
@@ -169,6 +169,11 @@ export const sqlPlugin: LexiconPlugin = {
169
169
  type: "string" as const,
170
170
  description: "the DDL's dialect, clickhouse or postgres; read off its statements when omitted",
171
171
  },
172
+ {
173
+ name: "skipUndeclarable",
174
+ type: "boolean" as const,
175
+ description: "import the rest of the file, and list each CREATE of a kind chant cannot declare (an aggregate, a cast, a quota) as a warning instead of failing",
176
+ },
172
177
  ];
173
178
  },
174
179
 
@@ -17,6 +17,12 @@
17
17
  * declarations: the GRANT and REVOKE statements that take a new object's
18
18
  * privileges to what the server holds. Roles are the environment's and are
19
19
  * not imported.
20
+ * - An object of a kind no declaration holds (an aggregate, a cast, a
21
+ * composite type, an event trigger, ...; `../live/undeclarable.ts`) fails
22
+ * the import, which names every one of them (#3911). `skipUndeclarable`
23
+ * (`--skip-undeclarable`) imports the rest and lists them as warnings. A
24
+ * selector or `owned` import asks for objects chant can hold, so it does
25
+ * not look.
20
26
  */
21
27
 
22
28
  import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
@@ -25,6 +31,9 @@ import { markProviderOwned, readLiveSchema } from "../live/catalog";
25
31
  import { objectsToIR, type ImportedPgObject } from "./ir";
26
32
  import { isChantManaged } from "../../core/ownership";
27
33
  import { importedAccess } from "../access/import";
34
+ import { readUndeclarable } from "../live/undeclarable";
35
+ import { describeUndeclarable, refuseUndeclarable, type UndeclarableObject } from "../../core/undeclarable";
36
+ import { isProviderOwned, providerData } from "../providers";
28
37
 
29
38
  export interface ExportOptions extends Omit<BindOptions, "environment"> {
30
39
  environment: string;
@@ -33,20 +42,33 @@ export interface ExportOptions extends Omit<BindOptions, "environment"> {
33
42
  selector?: ResourceSelector;
34
43
  owned?: boolean;
35
44
  verbatim?: boolean;
45
+ /** Import what can be declared, and list the objects of kinds no declaration holds as warnings instead of failing (#3911). */
46
+ skipUndeclarable?: boolean;
36
47
  }
37
48
 
38
49
  export async function exportResources(options: ExportOptions): Promise<ExportedTemplate> {
39
50
  const { target, client } = await bindPostgres(options);
40
51
  let live;
41
52
  let access: ImportedPgObject[] = [];
53
+ // A selector or an owned import asks only for what chant can hold.
54
+ const checked = !options.selector && !options.owned;
55
+ let undeclarable: { objects: UndeclarableObject[]; unread: string[] } = { objects: [], unread: [] };
42
56
  try {
57
+ if (checked) undeclarable = await readUndeclarable(client, { schemas: target.schemas });
43
58
  live = markProviderOwned(await readLiveSchema(client, { schemas: target.schemas, access: target.access === true }), target.provider);
44
59
  // Where the profile manages access, the privileges on what is imported, and the default privileges, as grant declarations.
45
60
  if (target.access === true && !options.selector) access = await importedAccess(client, live.filter((o) => !o.foreign && !o.unsupported && (!options.owned || isChantManaged(o.comment))), target.schemas);
46
61
  } finally {
47
62
  await client.end();
48
63
  }
49
- const warnings: string[] = [];
64
+ const warnings: string[] = undeclarable.unread.map((u) => `the server was not checked for objects of one kind chant cannot declare, so some may be left out: ${u}`);
65
+ // A managed provider's own (in its schemas, or owned by its roles) is the provider's, as for the objects read below.
66
+ const provider = target.provider;
67
+ const providers = (o: UndeclarableObject) =>
68
+ provider !== undefined &&
69
+ ((o.schema !== undefined && isProviderOwned(provider, { kind: "schema", name: o.schema })) || (o.owner !== undefined && isProviderOwned(provider, { kind: "role", name: o.owner })));
70
+ for (const o of undeclarable.objects.filter(providers)) warnings.push(`${describeUndeclarable(o)} is kept by ${providerData(provider!).label}; left out`);
71
+ refuseUndeclarable("postgres", undeclarable.objects.filter((o) => !providers(o)), options.skipUndeclarable, warnings);
50
72
  const objects: ImportedPgObject[] = [];
51
73
  for (const o of live) {
52
74
  if (options.owned && !isChantManaged(o.comment)) continue;
@@ -54,7 +76,8 @@ export async function exportResources(options: ExportOptions): Promise<ExportedT
54
76
  const name = options.selector?.name;
55
77
  if (name !== undefined && name !== o.name && name !== `${o.schema}.${o.name}`) continue;
56
78
  if (o.unsupported) {
57
- warnings.push(`${o.schema ? `${o.schema}.` : ""}${o.name}${o.signature ?? ""} is left out: ${o.unsupported}`);
79
+ // When checked, it is among the undeclarable objects above.
80
+ if (!checked) warnings.push(`${o.schema ? `${o.schema}.` : ""}${o.name}${o.signature ?? ""} is left out: ${o.unsupported}`);
58
81
  continue;
59
82
  }
60
83
  if (o.foreign) {
@@ -18,7 +18,7 @@
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)
21
+ * - a trigger the server makes for itself (a foreign key's)
22
22
  * or clones onto a partition from its parent's;
23
23
  * - a routine with a SQL-standard body (`RETURN`, `BEGIN ATOMIC`): read, but
24
24
  * marked `unsupported`, since the server prints it rewritten and a
@@ -32,6 +32,10 @@
32
32
  * progress (`../migrate/names.ts`, `../migrate/carry.ts`), so a table reads
33
33
  * as it was before the migration until its switch, and as declared after it.
34
34
  *
35
+ * Objects of the kinds no declaration holds (aggregates, casts, collations,
36
+ * composite and range types, event triggers, ...) are not read here: import
37
+ * finds them with `./undeclarable.ts` and refuses them by name (#3911).
38
+ *
35
39
  * Objects another tool owns, an ORM's or a migration runner's revision table
36
40
  * ({@link FOREIGN_TABLES}), are read and marked with the tool, so import
37
41
  * leaves them out with a warning and observation reports them foreign, never
@@ -112,7 +116,7 @@ const commentOn = (what: string, comment: unknown): string[] => {
112
116
  };
113
117
 
114
118
  /** The scope as a predicate on a schema name column, with its parameter. */
115
- function scopeSql(column: string, scope: SchemaScope): { sql: string; params: unknown[] } {
119
+ export function scopeSql(column: string, scope: SchemaScope): { sql: string; params: unknown[] } {
116
120
  if (scope.schemas) return { sql: `${column} = ANY($1::text[])`, params: [[...scope.schemas]] };
117
121
  return {
118
122
  sql: `${column} NOT IN ('pg_catalog', 'information_schema', 'pg_toast') AND ${column} NOT LIKE 'pg\\_temp\\_%' AND ${column} NOT LIKE 'pg\\_toast\\_temp\\_%'`,
@@ -121,7 +125,7 @@ function scopeSql(column: string, scope: SchemaScope): { sql: string; params: un
121
125
  }
122
126
 
123
127
  /** `NOT <object is owned by an extension>`, for an object of catalog `catalog` with oid column `oid`. */
124
- const notExtensionOwned = (catalog: string, oid: string): string =>
128
+ export const notExtensionOwned = (catalog: string, oid: string): string =>
125
129
  `NOT EXISTS (SELECT 1 FROM pg_catalog.pg_depend d WHERE d.classid = 'pg_catalog.${catalog}'::pg_catalog.regclass AND d.objid = ${oid} AND d.deptype = 'e')`;
126
130
 
127
131
  /** A collation name as a clause, `pg_catalog` names unqualified. */
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Import against a real Postgres server never leaves an object out without a
3
+ * word (#3911): a database holding a composite type, an aggregate, a
4
+ * collation, a cast and an event trigger beside a table fails the import,
5
+ * which names all five; with `skipUndeclarable` the table is imported and
6
+ * the five are warnings. What an extension brings (hstore's casts and
7
+ * operators) is the extension's, and is not refused.
8
+ *
9
+ * Needs Docker; skips cleanly without it. One throwaway `postgres` container
10
+ * at the pin, removed afterwards even on failure.
11
+ */
12
+
13
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
14
+ import { dockerAvailable, startTestPostgres, type TestPostgres } from "../testing/server";
15
+ import { exportResources } from "../import/live-export";
16
+ import { UndeclarableObjectsError } from "../../core/undeclarable";
17
+
18
+ const enabled = await dockerAvailable();
19
+ let server: TestPostgres | undefined;
20
+
21
+ const SEED = `
22
+ CREATE EXTENSION hstore WITH SCHEMA public;
23
+ CREATE SCHEMA app;
24
+ CREATE TABLE app.users (id bigint PRIMARY KEY, name text);
25
+ CREATE TYPE app.pair AS (a integer, b text);
26
+ CREATE FUNCTION app.add2(integer, integer) RETURNS integer LANGUAGE sql AS $$ SELECT $1 + $2 $$;
27
+ CREATE AGGREGATE app.total(integer) (sfunc = app.add2, stype = integer, initcond = '0');
28
+ CREATE COLLATION app.ci (provider = icu, locale = 'und-u-ks-level2', deterministic = false);
29
+ CREATE FUNCTION app.pair_text(app.pair) RETURNS text LANGUAGE sql AS $$ SELECT ($1).b $$;
30
+ CREATE CAST (app.pair AS text) WITH FUNCTION app.pair_text(app.pair);
31
+ CREATE FUNCTION app.on_ddl() RETURNS event_trigger LANGUAGE plpgsql AS $$ BEGIN END $$;
32
+ CREATE EVENT TRIGGER audit_ddl ON ddl_command_end EXECUTE FUNCTION app.on_ddl();
33
+ `;
34
+
35
+ beforeAll(async () => {
36
+ if (!enabled) return;
37
+ server = await startTestPostgres();
38
+ const admin = await server.connect();
39
+ await admin.query("CREATE DATABASE source");
40
+ await admin.end();
41
+ const source = await server.connect("source");
42
+ await source.query(SEED);
43
+ await source.end();
44
+ }, 600_000);
45
+
46
+ afterAll(async () => {
47
+ await server?.stop();
48
+ });
49
+
50
+ const profile = () => ({
51
+ config: { sql: { profiles: { src: { url: server!.endpoint("source").url, password: { env: "PG_UD_PASSWORD" } } } } },
52
+ env: { PG_UD_PASSWORD: server!.endpoint("source").password },
53
+ });
54
+
55
+ const NAMED = [
56
+ "aggregate app.total(integer)",
57
+ "cast (app.pair AS text)",
58
+ "collation app.ci",
59
+ "event trigger audit_ddl",
60
+ "composite type app.pair",
61
+ ];
62
+
63
+ describe.skipIf(!enabled)("importing a database with objects chant cannot declare", () => {
64
+ test("fails, naming every one of them", async () => {
65
+ const err = await exportResources({ environment: "src", ...profile() }).then(
66
+ () => undefined,
67
+ (e: unknown) => e,
68
+ );
69
+ expect(err).toBeInstanceOf(UndeclarableObjectsError);
70
+ const objects = (err as UndeclarableObjectsError).objects.map((o) => `${o.kind} ${o.schema ? `${o.schema}.` : ""}${o.name}`);
71
+ expect([...objects].sort()).toEqual([...NAMED].sort());
72
+ for (const line of NAMED) expect((err as Error).message).toContain(` - ${line}\n`);
73
+ }, 120_000);
74
+
75
+ test("with skipUndeclarable, imports the rest and lists them as warnings", async () => {
76
+ const ir = await exportResources({ environment: "src", skipUndeclarable: true, ...profile() });
77
+ expect(ir.resources.map((r) => r.properties.name)).toEqual(expect.arrayContaining(["users", "hstore", "add2", "pair_text", "on_ddl"]));
78
+ const left = (ir.warnings ?? []).filter((w) => w.includes("is left out (--skip-undeclarable)"));
79
+ expect(left.map((w) => w.split(" is left out")[0]).sort()).toEqual([...NAMED].sort());
80
+ }, 120_000);
81
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The objects in a live Postgres database of the kinds no declaration holds
3
+ * (#3911), found so import can refuse them by name instead of leaving them
4
+ * out (`../../core/undeclarable.ts`).
5
+ *
6
+ * One catalog query per kind the coverage table marks refused
7
+ * (`../../coverage/postgres.json`), keyed by the row's `kind`. A kind whose
8
+ * row is flipped to declared is no longer asked about: its objects are then
9
+ * the catalog reader's (`./catalog.ts`) to read.
10
+ *
11
+ * Left out, as the reader leaves them out: the server's own schemas, objects
12
+ * an extension owns (`pg_depend.deptype = 'e'`; the extension is declared),
13
+ * the objects `initdb` makes, whose oids are below 16384
14
+ * (`FirstNormalObjectId`), and an object that is part of another
15
+ * (`deptype = 'i'`: the casts a range type makes for itself). An object in a
16
+ * schema is looked for in the schemas in scope; one that belongs to the whole
17
+ * database (a cast, an event trigger, a publication, a foreign data wrapper)
18
+ * is always looked for.
19
+ */
20
+
21
+ import type { PostgresClient } from "./client";
22
+ import { notExtensionOwned, scopeSql, type SchemaScope } from "./catalog";
23
+ import { KIND_COVERAGE } from "../../coverage";
24
+ import type { UndeclarableObject } from "../../core/undeclarable";
25
+
26
+ /** `FirstNormalObjectId`: every oid below it is the server's own. */
27
+ const FIRST_USER_OID = 16384;
28
+
29
+ interface Probe {
30
+ /**
31
+ * A query returning `schema` (NULL for a database-wide object), `name`, and
32
+ * for a database-wide object the role that owns it (so a managed
33
+ * provider's own can be told apart); `scope` is the predicate on `n.nspname`.
34
+ */
35
+ sql(scope: string): string;
36
+ }
37
+
38
+ const C = 'COLLATE "C"';
39
+
40
+ /** Not part of another object (`pg_depend.deptype = 'i'`): the multirange cast a range type makes for itself is the range type's. */
41
+ const notInternal = (catalog: string, oid: string) =>
42
+ `NOT EXISTS (SELECT 1 FROM pg_catalog.pg_depend d WHERE d.classid = 'pg_catalog.${catalog}'::pg_catalog.regclass AND d.objid = ${oid} AND d.deptype = 'i')`;
43
+ const ns = (alias: string, column: string) => `JOIN pg_catalog.pg_namespace ${alias} ON ${alias}.oid = ${column}`;
44
+ const args = (oid: string) => `'(' || pg_catalog.replace(pg_catalog.oidvectortypes(${oid}), ', ', ',') || ')'`;
45
+
46
+ /** A probe for objects of a catalog in a schema, the catalog aliased `x`; `name` is the name expression. */
47
+ const inSchema = (catalog: string, nsColumn: string, name: string, where = "TRUE"): Probe => ({
48
+ sql: (scope) =>
49
+ `SELECT n.nspname AS schema, ${name} AS name, NULL::text AS owner FROM pg_catalog.${catalog} x ${ns("n", `x.${nsColumn}`)}
50
+ WHERE ${where} AND ${scope} AND x.oid >= ${FIRST_USER_OID} AND ${notExtensionOwned(catalog, "x.oid")} AND ${notInternal(catalog, "x.oid")}`,
51
+ });
52
+
53
+ /** A probe for objects that belong to the database, not a schema. */
54
+ const databaseWide = (catalog: string, name: string, owner?: string): Probe => ({
55
+ sql: () =>
56
+ `SELECT NULL::text AS schema, ${name} AS name, ${owner ? `pg_catalog.pg_get_userbyid(x.${owner})` : "NULL::text"} AS owner FROM pg_catalog.${catalog} x
57
+ WHERE x.oid >= ${FIRST_USER_OID} AND ${notExtensionOwned(catalog, "x.oid")} AND ${notInternal(catalog, "x.oid")}`,
58
+ });
59
+
60
+ /** The pg_type rows of one `typtype`. */
61
+ const type = (where: string) => inSchema("pg_type", "typnamespace", "x.typname", where);
62
+
63
+ export const POSTGRES_PROBES: Readonly<Record<string, Probe>> = {
64
+ "access method": databaseWide("pg_am", "x.amname"),
65
+ aggregate: inSchema("pg_proc", "pronamespace", `x.proname || ${args("x.proargtypes")}`, "x.prokind = 'a'"),
66
+ "routine with a SQL-standard body": inSchema("pg_proc", "pronamespace", `x.proname || ${args("x.proargtypes")}`, "x.prokind IN ('f', 'p', 'w') AND x.prosqlbody IS NOT NULL"),
67
+ cast: databaseWide("pg_cast", "'(' || pg_catalog.format_type(x.castsource, NULL) || ' AS ' || pg_catalog.format_type(x.casttarget, NULL) || ')'"),
68
+ collation: inSchema("pg_collation", "collnamespace", "x.collname"),
69
+ conversion: inSchema("pg_conversion", "connamespace", "x.conname"),
70
+ "event trigger": databaseWide("pg_event_trigger", "x.evtname", "evtowner"),
71
+ "foreign data wrapper": databaseWide("pg_foreign_data_wrapper", "x.fdwname", "fdwowner"),
72
+ "foreign server": databaseWide("pg_foreign_server", "x.srvname", "srvowner"),
73
+ "user mapping": {
74
+ // pg_user_mapping is the superuser's to read; the view shows every mapping without its options.
75
+ sql: () =>
76
+ `SELECT NULL::text AS schema, 'for ' || x.usename || ' on server ' || x.srvname AS name, pg_catalog.pg_get_userbyid(s.srvowner) AS owner FROM pg_catalog.pg_user_mappings x
77
+ JOIN pg_catalog.pg_foreign_server s ON s.oid = x.srvid
78
+ WHERE ${notExtensionOwned("pg_foreign_server", "s.oid")}`,
79
+ },
80
+ "foreign table": inSchema("pg_class", "relnamespace", "x.relname", "x.relkind = 'f'"),
81
+ "procedural language": databaseWide("pg_language", "x.lanname", "lanowner"),
82
+ operator: inSchema("pg_operator", "oprnamespace", "x.oprname || '(' || pg_catalog.format_type(x.oprleft, NULL) || ',' || pg_catalog.format_type(x.oprright, NULL) || ')'"),
83
+ "operator class": inSchema("pg_opclass", "opcnamespace", "x.opcname || ' USING ' || (SELECT a.amname FROM pg_catalog.pg_am a WHERE a.oid = x.opcmethod)"),
84
+ // A family CREATE OPERATOR CLASS made for itself, named as its class, is the class's.
85
+ "operator family": inSchema(
86
+ "pg_opfamily",
87
+ "opfnamespace",
88
+ "x.opfname || ' USING ' || (SELECT a.amname FROM pg_catalog.pg_am a WHERE a.oid = x.opfmethod)",
89
+ "NOT EXISTS (SELECT 1 FROM pg_catalog.pg_opclass c WHERE c.opcfamily = x.oid AND c.opcname = x.opfname AND c.opcnamespace = x.opfnamespace)",
90
+ ),
91
+ publication: databaseWide("pg_publication", "x.pubname", "pubowner"),
92
+ subscription: {
93
+ sql: () =>
94
+ `SELECT NULL::text AS schema, x.subname AS name, pg_catalog.pg_get_userbyid(x.subowner) AS owner FROM pg_catalog.pg_subscription x
95
+ WHERE x.subdbid = (SELECT d.oid FROM pg_catalog.pg_database d WHERE d.datname = pg_catalog.current_database())`,
96
+ },
97
+ rule: {
98
+ sql: (scope) =>
99
+ `SELECT n.nspname AS schema, r.rulename || ' ON ' || c.relname AS name, NULL::text AS owner
100
+ FROM pg_catalog.pg_rewrite r JOIN pg_catalog.pg_class c ON c.oid = r.ev_class ${ns("n", "c.relnamespace")}
101
+ WHERE r.rulename <> '_RETURN' AND ${scope} AND ${notExtensionOwned("pg_class", "c.oid")}`,
102
+ },
103
+ "extended statistics": inSchema("pg_statistic_ext", "stxnamespace", "x.stxname"),
104
+ "text search configuration": inSchema("pg_ts_config", "cfgnamespace", "x.cfgname"),
105
+ "text search dictionary": inSchema("pg_ts_dict", "dictnamespace", "x.dictname"),
106
+ "text search parser": inSchema("pg_ts_parser", "prsnamespace", "x.prsname"),
107
+ "text search template": inSchema("pg_ts_template", "tmplnamespace", "x.tmplname"),
108
+ transform: databaseWide(
109
+ "pg_transform",
110
+ "'FOR ' || pg_catalog.format_type(x.trftype, NULL) || ' LANGUAGE ' || (SELECT l.lanname FROM pg_catalog.pg_language l WHERE l.oid = x.trflang)",
111
+ ),
112
+ "composite type": type("x.typtype = 'c' AND EXISTS (SELECT 1 FROM pg_catalog.pg_class c WHERE c.oid = x.typrelid AND c.relkind = 'c')"),
113
+ "range type": type("x.typtype = 'r'"),
114
+ // A base type, or a shell; not the array type the server makes beside every type.
115
+ "base type": type("x.typtype IN ('b', 'p') AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_type e WHERE e.typarray = x.oid)"),
116
+ };
117
+
118
+ /**
119
+ * Every object in scope of a kind the coverage table refuses, kind by kind
120
+ * in the table's order, each by schema then name. A kind the session may not
121
+ * read (a role without the privilege) is a problem in `unread`, not a pass.
122
+ */
123
+ export async function readUndeclarable(client: PostgresClient, scope: SchemaScope = {}): Promise<{ objects: UndeclarableObject[]; unread: string[] }> {
124
+ const s = scopeSql("n.nspname", scope);
125
+ const objects: UndeclarableObject[] = [];
126
+ const unread: string[] = [];
127
+ for (const row of KIND_COVERAGE.postgres) {
128
+ if (row.status !== "refused") continue;
129
+ const probe = POSTGRES_PROBES[row.kind];
130
+ if (!probe) throw new Error(`no catalog query finds a Postgres ${row.kind} (src/postgres/live/undeclarable.ts)`);
131
+ // The comment names the kind, in the server's log and to a test's fake client.
132
+ const sql = `/* chant undeclarable: ${row.kind} */ SELECT q.schema, q.name, q.owner FROM (${probe.sql(s.sql)}) q ORDER BY q.schema ${C} NULLS FIRST, q.name ${C}`;
133
+ try {
134
+ const rows = await client.query<{ schema: string | null; name: string; owner: string | null }>(sql, sql.includes("$1") ? s.params : []);
135
+ for (const r of rows) objects.push({ kind: row.kind, ...(r.schema ? { schema: r.schema } : {}), name: r.name, ...(r.owner ? { owner: r.owner } : {}) });
136
+ } catch (err) {
137
+ unread.push(`${row.kind}: ${err instanceof Error ? err.message : String(err)}`);
138
+ }
139
+ }
140
+ return { objects, unread };
141
+ }
@@ -11,6 +11,8 @@ export interface FakeCatalog {
11
11
  tables?: Array<{ schema: string; name: string; comment?: string; columns?: Array<{ name: string; type: string; notnull?: boolean; dflt?: string }> }>;
12
12
  views?: Array<{ schema: string; name: string; def: string; materialized?: boolean; comment?: string }>;
13
13
  indexes?: Array<{ schema: string; name: string; table: string; def: string }>;
14
+ /** What the undeclarable-kind probes (`../live/undeclarable.ts`) find, by the coverage table's kind. Default: nothing. */
15
+ undeclarable?: Array<{ kind: string; schema?: string; name: string }>;
14
16
  }
15
17
 
16
18
  export function fakeClient(catalog: FakeCatalog, log: string[] = []): PostgresClient {
@@ -20,6 +22,9 @@ export function fakeClient(catalog: FakeCatalog, log: string[] = []): PostgresCl
20
22
  async query<T>(sql: string): Promise<T[]> {
21
23
  log.push(sql);
22
24
  const rows = (r: unknown[]) => r as T[];
25
+ // A probe for one undeclarable kind (`../live/undeclarable.ts`), which its comment names.
26
+ const probe = /^\/\* chant undeclarable: (.+?) \*\//.exec(sql);
27
+ if (probe) return rows((catalog.undeclarable ?? []).filter((u) => u.kind === probe[1]).map((u) => ({ schema: u.schema ?? null, name: u.name, owner: null })));
23
28
  if (sql.includes("FROM pg_catalog.pg_namespace n WHERE")) return rows((catalog.schemas ?? []).map((s, i) => ({ oid: String(100 + i), name: s.name, comment: s.comment ?? null })));
24
29
  if (sql.includes("c.relkind IN ('r', 'p')")) {
25
30
  return rows(tables.map((t) => ({ oid: t.oid, schema: t.schema, name: t.name, kind: "r", persistence: "p", ispartition: false, typed: false, partkey: null, bound: null, parents: [], reloptions: null, am: "heap", tablespace: null, comment: t.comment ?? null })));