@intentius/chant-lexicon-sql 0.124.48 → 0.124.50

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 (138) hide show
  1. package/dist/clickhouse/plan/statements.d.ts +29 -0
  2. package/dist/clickhouse/plan/statements.d.ts.map +1 -0
  3. package/dist/clickhouse/plan/statements.js +334 -0
  4. package/dist/codegen/classifier-page.d.ts.map +1 -1
  5. package/dist/codegen/classifier-page.js +23 -0
  6. package/dist/codegen/postgres-classifier-page.js +2 -2
  7. package/dist/codegen/registry.d.ts.map +1 -1
  8. package/dist/codegen/registry.js +5 -0
  9. package/dist/core/statement-class.d.ts +55 -0
  10. package/dist/core/statement-class.d.ts.map +1 -0
  11. package/dist/core/statement-class.js +43 -0
  12. package/dist/coverage/postgres.json +6 -6
  13. package/dist/drizzle.d.ts.map +1 -1
  14. package/dist/drizzle.js +5 -0
  15. package/dist/files/postgres.d.ts.map +1 -1
  16. package/dist/files/postgres.js +23 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/integrity.json +5 -5
  21. package/dist/lint/post-synth/postgres-names.d.ts.map +1 -1
  22. package/dist/lint/post-synth/postgres-names.js +3 -0
  23. package/dist/lint/rules/postgres-templates.js +2 -2
  24. package/dist/lsp/postgres-template.d.ts.map +1 -1
  25. package/dist/lsp/postgres-template.js +10 -0
  26. package/dist/manifest.json +1 -1
  27. package/dist/mcp/postgres.js +1 -1
  28. package/dist/meta.json +25 -0
  29. package/dist/migration-classify.d.ts +23 -0
  30. package/dist/migration-classify.d.ts.map +1 -0
  31. package/dist/migration-classify.js +44 -0
  32. package/dist/okf/index.md +5 -0
  33. package/dist/okf/types/PostgresForeignTable.md +9 -0
  34. package/dist/okf/types/PostgresOperatorClass.md +9 -0
  35. package/dist/okf/types/PostgresOperatorFamily.md +9 -0
  36. package/dist/okf/types/PostgresTextSearchConfiguration.md +9 -0
  37. package/dist/okf/types/PostgresTextSearchDictionary.md +9 -0
  38. package/dist/plugin.d.ts.map +1 -1
  39. package/dist/plugin.js +13 -0
  40. package/dist/postgres/access/acl.js +1 -1
  41. package/dist/postgres/access/import.js +1 -1
  42. package/dist/postgres/apply/apply.js +5 -0
  43. package/dist/postgres/apply/statements.d.ts.map +1 -1
  44. package/dist/postgres/apply/statements.js +156 -2
  45. package/dist/postgres/entities.d.ts +120 -2
  46. package/dist/postgres/entities.d.ts.map +1 -1
  47. package/dist/postgres/entities.js +180 -5
  48. package/dist/postgres/entity-types.d.ts +5 -0
  49. package/dist/postgres/entity-types.d.ts.map +1 -1
  50. package/dist/postgres/entity-types.js +5 -0
  51. package/dist/postgres/import/generator.d.ts.map +1 -1
  52. package/dist/postgres/import/generator.js +16 -1
  53. package/dist/postgres/live/catalog-kinds.d.ts +6 -0
  54. package/dist/postgres/live/catalog-kinds.d.ts.map +1 -1
  55. package/dist/postgres/live/catalog-kinds.js +251 -3
  56. package/dist/postgres/live/describe-resources.d.ts.map +1 -1
  57. package/dist/postgres/live/describe-resources.js +2 -0
  58. package/dist/postgres/migrate/observe.d.ts.map +1 -1
  59. package/dist/postgres/migrate/observe.js +4 -1
  60. package/dist/postgres/migrate/replication.d.ts +42 -0
  61. package/dist/postgres/migrate/replication.d.ts.map +1 -1
  62. package/dist/postgres/migrate/replication.js +53 -0
  63. package/dist/postgres/parser.d.ts +73 -1
  64. package/dist/postgres/parser.d.ts.map +1 -1
  65. package/dist/postgres/parser.js +181 -7
  66. package/dist/postgres/plan/deep.d.ts.map +1 -1
  67. package/dist/postgres/plan/deep.js +15 -4
  68. package/dist/postgres/plan/diff.d.ts.map +1 -1
  69. package/dist/postgres/plan/diff.js +120 -5
  70. package/dist/postgres/plan/normalize.d.ts +12 -1
  71. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  72. package/dist/postgres/plan/normalize.js +151 -0
  73. package/dist/postgres/plan/rules.d.ts +3 -0
  74. package/dist/postgres/plan/rules.d.ts.map +1 -1
  75. package/dist/postgres/plan/rules.js +5 -2
  76. package/dist/postgres/plan/schema.js +1 -1
  77. package/dist/postgres/plan/statements.d.ts +26 -0
  78. package/dist/postgres/plan/statements.d.ts.map +1 -0
  79. package/dist/postgres/plan/statements.js +364 -0
  80. package/dist/postgres/testing/roundtrip-schema.d.ts +3 -2
  81. package/dist/postgres/testing/roundtrip-schema.d.ts.map +1 -1
  82. package/dist/postgres/testing/roundtrip-schema.js +12 -1
  83. package/dist/postgres.d.ts +1 -1
  84. package/dist/postgres.d.ts.map +1 -1
  85. package/dist/postgres.js +1 -1
  86. package/dist/rules/postgres-names.ts +2 -0
  87. package/dist/rules/postgres-templates.ts +2 -2
  88. package/dist/validate.d.ts.map +1 -1
  89. package/dist/validate.js +5 -0
  90. package/package.json +2 -2
  91. package/src/clickhouse/plan/statements.test.ts +61 -0
  92. package/src/clickhouse/plan/statements.ts +306 -0
  93. package/src/codegen/classifier-page.ts +23 -0
  94. package/src/codegen/postgres-classifier-page.ts +2 -2
  95. package/src/codegen/registry.ts +5 -0
  96. package/src/core/statement-class.ts +88 -0
  97. package/src/coverage/postgres.json +6 -6
  98. package/src/drizzle.ts +5 -0
  99. package/src/files/postgres.ts +22 -1
  100. package/src/generated/lexicon-sql.json +25 -0
  101. package/src/index.ts +1 -1
  102. package/src/lint/post-synth/postgres-checks.test.ts +8 -1
  103. package/src/lint/post-synth/postgres-names.ts +2 -0
  104. package/src/lint/rules/postgres-templates.ts +2 -2
  105. package/src/lsp/postgres-template.ts +9 -0
  106. package/src/mcp/postgres.ts +1 -1
  107. package/src/migration-classify.test.ts +150 -0
  108. package/src/migration-classify.ts +45 -0
  109. package/src/op/watch-pipeline.test.ts +8 -1
  110. package/src/plugin.ts +14 -0
  111. package/src/postgres/access/acl.ts +1 -1
  112. package/src/postgres/access/import.ts +1 -1
  113. package/src/postgres/apply/apply.ts +5 -0
  114. package/src/postgres/apply/statements.ts +151 -3
  115. package/src/postgres/database-kinds.test.ts +498 -17
  116. package/src/postgres/entities.ts +276 -5
  117. package/src/postgres/entity-types.ts +5 -0
  118. package/src/postgres/import/generator.ts +13 -1
  119. package/src/postgres/live/catalog-kinds.ts +275 -3
  120. package/src/postgres/live/describe-resources.ts +2 -0
  121. package/src/postgres/migrate/observe.ts +3 -1
  122. package/src/postgres/migrate/replication.ts +66 -0
  123. package/src/postgres/migrate/subscriber.e2e.test.ts +141 -0
  124. package/src/postgres/migrate/subscriber.test.ts +59 -0
  125. package/src/postgres/parser.ts +225 -6
  126. package/src/postgres/plan/deep.ts +15 -4
  127. package/src/postgres/plan/diff.ts +107 -6
  128. package/src/postgres/plan/normalize.ts +155 -0
  129. package/src/postgres/plan/rules.ts +25 -4
  130. package/src/postgres/plan/schema.ts +1 -1
  131. package/src/postgres/plan/statements.test.ts +78 -0
  132. package/src/postgres/plan/statements.ts +299 -0
  133. package/src/postgres/testing/roundtrip-schema.ts +12 -1
  134. package/src/postgres.ts +17 -0
  135. package/src/skills/chant-sql-postgres-migration.md +1 -0
  136. package/src/skills/chant-sql-postgres.md +5 -0
  137. package/src/sql-files.test.ts +33 -0
  138. package/src/validate.ts +5 -0
@@ -52,6 +52,11 @@ import { sequenceDefaults } from "../live/catalog";
52
52
  import type {
53
53
  AggregateArgDef,
54
54
  OperatorProps,
55
+ OperatorClassProps,
56
+ OperatorFamilyProps,
57
+ ForeignTableProps,
58
+ TextSearchConfigurationProps,
59
+ TextSearchDictionaryProps,
55
60
  AggregateProps,
56
61
  CastProps,
57
62
  CheckDef,
@@ -323,6 +328,8 @@ export interface CanonicalColumn {
323
328
  collate?: string;
324
329
  compression?: string;
325
330
  storage?: string;
331
+ /** A foreign table column's `OPTIONS`, sorted by name. */
332
+ options?: Record<string, string>;
326
333
  comment?: string;
327
334
  }
328
335
 
@@ -354,6 +361,11 @@ export type CanonicalKind =
354
361
  | "cast"
355
362
  | "eventTrigger"
356
363
  | "operator"
364
+ | "operatorClass"
365
+ | "operatorFamily"
366
+ | "foreignTable"
367
+ | "textSearchConfiguration"
368
+ | "textSearchDictionary"
357
369
  | "statistics"
358
370
  | "foreignDataWrapper"
359
371
  | "foreignServer"
@@ -404,6 +416,11 @@ const kindOf: Record<string, CanonicalKind> = {
404
416
  "Postgres::Cast": "cast",
405
417
  "Postgres::EventTrigger": "eventTrigger",
406
418
  "Postgres::Operator": "operator",
419
+ "Postgres::OperatorClass": "operatorClass",
420
+ "Postgres::OperatorFamily": "operatorFamily",
421
+ "Postgres::ForeignTable": "foreignTable",
422
+ "Postgres::TextSearchConfiguration": "textSearchConfiguration",
423
+ "Postgres::TextSearchDictionary": "textSearchDictionary",
407
424
  "Postgres::Statistics": "statistics",
408
425
  "Postgres::ForeignDataWrapper": "foreignDataWrapper",
409
426
  "Postgres::ForeignServer": "foreignServer",
@@ -745,6 +762,105 @@ function operatorOptions(p: OperatorProps, ownSchema: string, defaultSchema: str
745
762
  return Object.fromEntries(Object.entries(out).sort(([a], [b]) => (a < b ? -1 : 1)));
746
763
  }
747
764
 
765
+ /**
766
+ * A name where an object is named (a text search parser, template or
767
+ * dictionary, an operator family): the schema left off when it is
768
+ * `pg_catalog` or the default schema, since a bare name is looked for in both.
769
+ * The catalog prints every schema, so both sides are brought to this form.
770
+ */
771
+ export function canonicalRef(text: string, defaultSchema: string): string {
772
+ const pieces = canonicalName(text).split(/\.(?=(?:[^"]*"[^"]*")*[^"]*$)/);
773
+ if (pieces.length >= 2) {
774
+ const schema = identValue(pieces[0]!);
775
+ return schema === "pg_catalog" || schema === defaultSchema ? pieces.slice(1).join(".") : pieces.join(".");
776
+ }
777
+ return pieces[0]!;
778
+ }
779
+
780
+ /** An operator named in an operator class: `OPERATOR(app.<<)`, `app.<<` or a bare symbol, by `canonicalRef`'s rule for the schema. */
781
+ export function canonicalOperatorName(text: string, defaultSchema: string): string {
782
+ const inner = text.trim().replace(/^OPERATOR\s*\(\s*(.*?)\s*\)$/is, "$1").trim();
783
+ const m = /^("(?:[^"]|"")+"|[A-Za-z_][A-Za-z0-9_$]*)\s*\.\s*(\S.*)$/s.exec(inner);
784
+ if (!m) return inner;
785
+ const schema = identValue(m[1]!);
786
+ return schema === "pg_catalog" || schema === defaultSchema ? m[2]!.trim() : `${quoteIdent(schema)}.${m[2]!.trim()}`;
787
+ }
788
+
789
+ /** A comma list split at its top level: `numeric(12,2), text` is two items. */
790
+ function splitTopLevel(text: string): string[] {
791
+ const out: string[] = [];
792
+ let depth = 0;
793
+ let current = "";
794
+ for (const ch of text) {
795
+ if (ch === "(" || ch === "[") depth++;
796
+ else if (ch === ")" || ch === "]") depth--;
797
+ if (ch === "," && depth === 0) {
798
+ out.push(current.trim());
799
+ current = "";
800
+ } else current += ch;
801
+ }
802
+ if (current.trim() !== "") out.push(current.trim());
803
+ return out;
804
+ }
805
+
806
+ /**
807
+ * The operand types a support function of an operator class is registered
808
+ * for when `FUNCTION n (left, right)` does not say (sql-createopclass): a
809
+ * B-tree comparison function's (1) and a hash function's (1, 2) are the
810
+ * function's own input types, a B-tree `in_range` function's (3) its first
811
+ * and third, and every other support function's the class's type. The catalog
812
+ * always stores both, so a declaration is brought to them.
813
+ */
814
+ function supportTypes(method: string, number: number, classType: string, args: readonly string[]): [string, string] {
815
+ if (method === "btree" && number === 1) return [args[0] ?? classType, args[1] ?? args[0] ?? classType];
816
+ if (method === "btree" && number === 3) return [args[0] ?? classType, args[2] ?? classType];
817
+ if (method === "hash" && (number === 1 || number === 2)) return [args[0] ?? classType, args[0] ?? classType];
818
+ return [classType, classType];
819
+ }
820
+
821
+ /** An operator class as compared: its type, family, default flag and members, each member canonical and the list sorted. */
822
+ function operatorClassFields(p: OperatorClassProps, schema: string, defaultSchema: string, fields: Record<string, unknown>): void {
823
+ const type = canonicalType(p.dataType, defaultSchema)!;
824
+ fields.default = p.default === true;
825
+ fields.dataType = type;
826
+ // A class with no FAMILY is in a family of its own name; so is one that names that family.
827
+ const own = canonicalRef(`${quoteIdent(schema)}.${quoteIdent(p.name)}`, defaultSchema);
828
+ const named = p.family === undefined ? own : /^\s*("(?:[^"]|"")+"|[A-Za-z_][A-Za-z0-9_$]*)\s*$/.test(p.family) && identValue(p.family.trim()) === p.name ? own : canonicalRef(p.family, defaultSchema);
829
+ fields.family = named === own ? undefined : named;
830
+ const side = (t: string) => (t.trim().toUpperCase() === "NONE" ? "NONE" : canonicalArgType(t, defaultSchema));
831
+ const members = p.items.map((i): { rank: number; number: number; text: string } => {
832
+ if (i.kind === "storage") return { rank: 2, number: 0, text: `storage ${canonicalType(i.type, defaultSchema)}` };
833
+ const n = Number(i.number);
834
+ if (i.kind === "operator") {
835
+ const [l, r] = i.types !== undefined ? splitTopLevel(i.types) : [type, type];
836
+ const sorted = i.orderBy !== undefined ? ` for order by ${canonicalRef(i.orderBy, defaultSchema)}` : "";
837
+ return { rank: 0, number: n, text: `operator ${n} ${canonicalOperatorName(i.operator, defaultSchema)} (${side(l ?? type)},${side(r ?? l ?? type)})${sorted}` };
838
+ }
839
+ const args = splitTopLevel(i.args).map((a) => canonicalArgType(a, defaultSchema));
840
+ const given = i.types !== undefined ? splitTopLevel(i.types).map(side) : undefined;
841
+ const [l, r] = given ? [given[0]!, given[1] ?? given[0]!] : supportTypes(p.method, n, type, args);
842
+ return { rank: 1, number: n, text: `function ${n} (${l},${r}) ${canonicalFunctionName(i.function, defaultSchema)}(${args.join(",")})` };
843
+ });
844
+ members.sort((a, b) => a.rank - b.rank || a.number - b.number || (a.text < b.text ? -1 : 1));
845
+ fields.items = members.map((m) => m.text);
846
+ }
847
+
848
+ /** A dictionary option's value as compared: a string constant's value, a quoted name's, a bare word folded, a number as written. */
849
+ function dictionaryValue(v: string): string {
850
+ const t = v.trim();
851
+ if (/^([Ee]?'|\$)/.test(t)) return stringValue(t) ?? t;
852
+ if (/^"/.test(t)) return identValue(t);
853
+ return t.toLowerCase();
854
+ }
855
+
856
+ /** A text search configuration's mappings as compared: each token type to its dictionaries in order, sorted by token type. */
857
+ function mappingFields(p: TextSearchConfigurationProps, defaultSchema: string): Record<string, string[]> | undefined {
858
+ const out: Record<string, string[]> = {};
859
+ for (const m of p.mappings) for (const t of m.tokens) out[t.toLowerCase()] = m.dictionaries.map((d) => canonicalRef(d, defaultSchema));
860
+ const entries = Object.entries(out).sort(([a], [b]) => (a < b ? -1 : 1));
861
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
862
+ }
863
+
748
864
  /** An `OPTIONS (...)` list as compared: by name, none as nothing. */
749
865
  function sortedOptions(options: Record<string, string> | undefined): Record<string, string> | undefined {
750
866
  const entries = Object.entries(options ?? {}).sort(([a], [b]) => (a < b ? -1 : 1));
@@ -869,6 +985,45 @@ export function canonicalPgObject(entityType: string, props: Record<string, unkn
869
985
  fields.definition = operatorOptions(p, base.schema!, defaultSchema);
870
986
  break;
871
987
  }
988
+ case "operatorClass":
989
+ operatorClassFields(props as unknown as OperatorClassProps, base.schema!, defaultSchema, fields);
990
+ base.signature = ` USING ${(props as unknown as OperatorClassProps).method}`;
991
+ break;
992
+ case "operatorFamily":
993
+ base.signature = ` USING ${(props as unknown as OperatorFamilyProps).method}`;
994
+ break;
995
+ case "textSearchDictionary": {
996
+ const p = props as unknown as TextSearchDictionaryProps;
997
+ fields.template = canonicalRef(p.template, defaultSchema);
998
+ fields.options = sortedOptions(Object.fromEntries(Object.entries(p.options).map(([k, v]) => [k.toLowerCase(), dictionaryValue(v)])));
999
+ break;
1000
+ }
1001
+ case "textSearchConfiguration": {
1002
+ const p = props as unknown as TextSearchConfigurationProps;
1003
+ fields.parser = canonicalRef(p.parser, defaultSchema);
1004
+ fields.mappings = mappingFields(p, defaultSchema);
1005
+ break;
1006
+ }
1007
+ case "foreignTable": {
1008
+ const p = props as unknown as ForeignTableProps;
1009
+ p.columns.forEach((c: ColumnDef, position) => {
1010
+ const options = sortedOptions(c.options);
1011
+ columns.push({
1012
+ name: c.name,
1013
+ position,
1014
+ type: canonicalType(c.type, defaultSchema),
1015
+ notNull: c.notNull === true,
1016
+ default: canonicalExpr(c.default),
1017
+ collate: c.collate ? canonicalName(c.collate) : undefined,
1018
+ comment: comment(c.comment),
1019
+ ...(options ? { options } : {}),
1020
+ });
1021
+ });
1022
+ for (const c of p.checks) constraints.push({ kind: "CHECK", name: c.name, body: ["check", canonicalExpr(c.expr), c.noInherit ? "no inherit" : ""].filter(Boolean).join(" "), ...(c.notValid ? { notValid: true } : {}), comment: comment(c.comment) });
1023
+ fields.server = quoteIdent(p.serverName);
1024
+ fields.options = sortedOptions(p.options);
1025
+ break;
1026
+ }
872
1027
  case "statistics": {
873
1028
  const p = props as unknown as StatisticsProps;
874
1029
  fields.table = canonicalName(p.tableName, defaultSchema);
@@ -360,15 +360,15 @@ export const PG_CLASSIFIER_RULES = {
360
360
  SQLPG300: rule(
361
361
  "SQLPG300",
362
362
  "metadata",
363
- "Drop and create a type, aggregate, collation, cast, event trigger, operator, statistics object, foreign server or publication",
364
- "A composite or range type's definition, a collation's, a cast's, an event trigger's, an operator's function and operands and extended statistics' have no ALTER, an aggregate's argument types, state type and final function cannot be replaced, and neither can a foreign server's type or wrapper or a publication's FOR ALL TABLES: the object is dropped and created in one transaction. Nothing on the server depends on it (a column, a view, a function or a cast would make the DROP fail); the locks are on its catalog rows only. Dropped statistics are empty until the next ANALYZE, and a publication that is dropped stops its subscribers until it is created again.",
363
+ "Drop and create a type, aggregate, collation, cast, event trigger, operator, operator class or family, foreign table's server, text search object, statistics object, foreign server or publication",
364
+ "A composite or range type's definition, a collation's, a cast's, an event trigger's, an operator's function and operands, and any attribute of it that SQLPG304 does not alter, an operator class's members, type, family and default flag, an operator family's index method, and extended statistics' have no ALTER (ALTER OPERATOR CLASS and ALTER OPERATOR FAMILY change a name, an owner or a schema only), an aggregate's argument types, state type and final function cannot be replaced, and neither can a foreign server's type or wrapper, a foreign table's server, a text search configuration's parser, a text search dictionary's template or a publication's FOR ALL TABLES: the object is dropped and created in one transaction. Nothing on the server depends on it (a column, a view, an index, a function or a cast would make the DROP fail); the locks are on its catalog rows only. Dropped statistics are empty until the next ANALYZE, and a publication that is dropped stops its subscribers until it is created again.",
365
365
  `${DOCS}/sql-createtype.html`,
366
366
  ),
367
367
  SQLPG301: rule(
368
368
  "SQLPG301",
369
369
  "expand",
370
- "Change a type, aggregate, collation, cast, foreign server or publication that other objects depend on",
371
- "No ALTER makes the change, and DROP refuses while a column, a view, a function or another object depends on the object. Create the new definition under another name, move what depends on it, then drop the old.",
370
+ "Change a type, aggregate, collation, cast, operator, operator class, text search object, foreign table's server, foreign server or publication that other objects depend on",
371
+ "No ALTER makes the change, and DROP refuses while a column, a view, an index, a function or another object depends on the object. Create the new definition under another name, move what depends on it, then drop the old.",
372
372
  `${DOCS}/sql-droptype.html`,
373
373
  ),
374
374
  SQLPG302: rule(
@@ -385,6 +385,27 @@ export const PG_CLASSIFIER_RULES = {
385
385
  "ALTER FOREIGN DATA WRAPPER (handler, validator, options), ALTER SERVER (version, options) and ALTER PUBLICATION (SET options, ADD and DROP TABLE and TABLES IN SCHEMA) change the object's catalog rows under a brief lock; no table is rewritten. A publication's change reaches its subscribers as the walsender next reads it: a table added is not copied to a subscription until the subscriber runs REFRESH PUBLICATION.",
386
386
  `${DOCS}/sql-alterpublication.html`,
387
387
  ),
388
+ SQLPG304: rule(
389
+ "SQLPG304",
390
+ "metadata",
391
+ "Alter an operator in place",
392
+ "ALTER OPERATOR ... SET changes the operator's catalog row under a brief lock; no index or table is read. RESTRICT and JOIN can be set or removed (NONE) on every supported major. From 17, COMMUTATOR and NEGATOR can be set when the operator has none, and HASHES and MERGES turned on, never off (the Postgres 18 page: \"Can only be changed if the operator does not have an existing commutator\", \"Can only be enabled and not disabled\"); setting a commutator or negator also sets the other operator's. On 14 to 16 only RESTRICT and JOIN are alterable, so any other change drops and creates the operator (SQLPG300), as does a change of function or operands on any major.",
393
+ `${DOCS}/sql-alteroperator.html`,
394
+ ),
395
+ SQLPG305: rule(
396
+ "SQLPG305",
397
+ "metadata",
398
+ "Alter a text search configuration's mappings or a dictionary's options in place",
399
+ "ALTER TEXT SEARCH CONFIGURATION ADD MAPPING, ALTER MAPPING and DROP MAPPING change the token types mapped to dictionaries; ALTER TEXT SEARCH DICTIONARY sets a template's options, and an option named with no value is removed. Both change catalog rows under a brief lock and leave the parser or template as it is, so a parser or template change is dropped and created (SQLPG300). A tsvector already stored keeps the tokens it was built with: rebuild the columns and indexes that hold one when the mappings or options change what a word becomes. The same applies on 14 to 18.",
400
+ `${DOCS}/sql-altertsconfig.html`,
401
+ ),
402
+ SQLPG306: rule(
403
+ "SQLPG306",
404
+ "metadata",
405
+ "Alter a foreign table in place",
406
+ "ALTER FOREIGN TABLE adds and drops columns, changes a column's type, default, NOT NULL and OPTIONS, adds and drops CHECK and NOT NULL constraints, and sets the table's OPTIONS, all in the catalog. Nothing is checked against the remote side and no row is rewritten or scanned (\"It is the user's responsibility to ensure that the table definition matches the remote side\"), so a CHECK is added with no validation pass. A foreign table's server cannot be altered, so a change of server is dropped and created (SQLPG300).",
407
+ `${DOCS}/sql-alterforeigntable.html`,
408
+ ),
388
409
  SQLPG270: rule("SQLPG270", "drop", "Drop an object", "The object and, for a table, a materialized view or a sequence, its data are gone.", `${DOCS}/sql-droptable.html`),
389
410
  } as const satisfies Record<string, PgClassifierRule>;
390
411
 
@@ -106,7 +106,7 @@ export function pgSchemaFromBuildFile(path: string, defaultSchema = "public"): P
106
106
  */
107
107
  export function pgNamespace(kind: string): string {
108
108
  if (kind === "schema" || kind === "extension" || kind === "trigger" || kind === "policy" || kind === "role") return kind;
109
- if (kind === "collation" || kind === "cast" || kind === "eventTrigger" || kind === "operator" || kind === "statistics" || kind === "foreignDataWrapper" || kind === "foreignServer" || kind === "publication") return kind;
109
+ if (kind === "collation" || kind === "cast" || kind === "eventTrigger" || kind === "operator" || kind === "operatorClass" || kind === "operatorFamily" || kind === "textSearchConfiguration" || kind === "textSearchDictionary" || kind === "statistics" || kind === "foreignDataWrapper" || kind === "foreignServer" || kind === "publication") return kind;
110
110
  if (kind === "grant" || kind === "defaultPrivileges") return "grant";
111
111
  if (kind === "enum" || kind === "domain" || kind === "composite" || kind === "range") return "type";
112
112
  if (kind === "function" || kind === "procedure" || kind === "aggregate") return "routine";
@@ -0,0 +1,78 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { classifyPgStatements } from "./statements";
3
+
4
+ const one = (sql: string) => classifyPgStatements([sql])[0]!;
5
+ const rules = (sql: string) => one(sql).changes.map((c) => c.rule);
6
+
7
+ describe("Postgres statements", () => {
8
+ test("CREATE INDEX without CONCURRENTLY is SQLPG241 with its SHARE lock", () => {
9
+ const v = one("CREATE INDEX orders_status_idx ON app.orders (status)");
10
+ expect(v.outcome).toBe("classified");
11
+ expect(v.changes).toMatchObject([{ rule: "SQLPG241", class: "concurrently", lock: "SHARE" }]);
12
+ });
13
+
14
+ test("CREATE INDEX CONCURRENTLY is SQLPG240", () => {
15
+ expect(one("CREATE UNIQUE INDEX CONCURRENTLY i ON t (a)").changes).toMatchObject([{ rule: "SQLPG240", lock: "SHARE UPDATE EXCLUSIVE" }]);
16
+ });
17
+
18
+ test("an index on a table the file creates needs no CONCURRENTLY", () => {
19
+ const [t, i] = classifyPgStatements(["CREATE TABLE t (id bigint PRIMARY KEY, a int)", "CREATE INDEX i ON t (a)"]);
20
+ expect(t!.changes[0]!.rule).toBe("SQLPG200");
21
+ expect(i!.changes[0]!.rule).toBe("SQLPG200");
22
+ });
23
+
24
+ test("ADD COLUMN splits by what the table must compute", () => {
25
+ expect(rules("ALTER TABLE t ADD COLUMN a int")).toEqual(["SQLPG201"]);
26
+ expect(rules("ALTER TABLE t ADD COLUMN a int NOT NULL DEFAULT 0")).toEqual(["SQLPG201"]);
27
+ expect(rules("ALTER TABLE t ADD COLUMN a int NOT NULL")).toEqual(["SQLPG203"]);
28
+ expect(rules("ALTER TABLE t ADD COLUMN a uuid DEFAULT gen_random_uuid()")).toEqual(["SQLPG202"]);
29
+ expect(rules("ALTER TABLE t ADD COLUMN a int GENERATED ALWAYS AS (b + 1) STORED")).toEqual(["SQLPG202"]);
30
+ });
31
+
32
+ test("a column type change is undecided between SQLPG206 and SQLPG207", () => {
33
+ const v = one("ALTER TABLE t ALTER COLUMN a TYPE bigint");
34
+ expect(v.outcome).toBe("undecided");
35
+ expect(v.candidates!.map((c) => c.rule)).toEqual(["SQLPG206", "SQLPG207"]);
36
+ expect(one("ALTER TABLE t ALTER COLUMN a TYPE int USING a::int").candidates!.map((c) => c.rule)).toEqual(["SQLPG206", "SQLPG207", "SQLPG208"]);
37
+ });
38
+
39
+ test("several actions in one ALTER TABLE are each classified", () => {
40
+ expect(rules("ALTER TABLE t ADD COLUMN a int, DROP COLUMN b, ALTER COLUMN c SET NOT NULL")).toEqual(["SQLPG201", "SQLPG204", "SQLPG210"]);
41
+ expect(one("ALTER TABLE t DROP COLUMN b").changes[0]!.destructive).toBe(true);
42
+ });
43
+
44
+ test("constraints", () => {
45
+ expect(rules("ALTER TABLE t ADD CONSTRAINT fk FOREIGN KEY (a) REFERENCES u (id) NOT VALID")).toEqual(["SQLPG217"]);
46
+ expect(rules("ALTER TABLE t ADD CONSTRAINT fk FOREIGN KEY (a) REFERENCES u (id)")).toEqual(["SQLPG219"]);
47
+ expect(rules("ALTER TABLE t ADD CONSTRAINT c CHECK (a > 0)")).toEqual(["SQLPG218"]);
48
+ expect(rules("ALTER TABLE t ADD PRIMARY KEY (id)")).toEqual(["SQLPG221"]);
49
+ expect(rules("ALTER TABLE t VALIDATE CONSTRAINT fk")).toEqual(["SQLPG220"]);
50
+ expect(rules("ALTER TABLE t DROP CONSTRAINT fk")).toEqual(["SQLPG223"]);
51
+ });
52
+
53
+ test("renames", () => {
54
+ expect(rules("ALTER TABLE t RENAME COLUMN a TO b")).toEqual(["SQLPG205"]);
55
+ expect(rules("ALTER TABLE t RENAME TO u")).toEqual(["SQLPG228"]);
56
+ expect(rules("ALTER INDEX i RENAME TO j")).toEqual(["SQLPG229"]);
57
+ });
58
+
59
+ test("DROP, GRANT, COMMENT and row security go through the parser or the verb", () => {
60
+ expect(rules("DROP INDEX CONCURRENTLY i")).toEqual(["SQLPG242"]);
61
+ expect(rules("DROP TABLE t")).toEqual(["SQLPG270"]);
62
+ expect(rules("GRANT SELECT ON TABLE t TO reader")).toEqual(["SQLPG296"]);
63
+ expect(rules("REVOKE SELECT ON TABLE t FROM reader")).toEqual(["SQLPG297"]);
64
+ expect(rules("COMMENT ON TABLE t IS 'x'")).toEqual(["SQLPG216"]);
65
+ expect(rules("ALTER TABLE t ENABLE ROW LEVEL SECURITY")).toEqual(["SQLPG293"]);
66
+ });
67
+
68
+ test("functions and views", () => {
69
+ expect(rules("CREATE OR REPLACE FUNCTION f() RETURNS int LANGUAGE sql AS $$ select 1; $$")).toEqual(["SQLPG280"]);
70
+ expect(one("CREATE OR REPLACE VIEW v AS SELECT 1 AS a").candidates!.map((c) => c.rule)).toEqual(["SQLPG250", "SQLPG251"]);
71
+ expect(rules("CREATE VIEW v AS SELECT 1 AS a")).toEqual(["SQLPG200"]);
72
+ });
73
+
74
+ test("data statements and unknown ALTERs", () => {
75
+ expect(one("UPDATE t SET a = 1").outcome).toBe("data");
76
+ expect(one("ALTER TABLE t OWNER TO app").outcome).toBe("unclassified");
77
+ });
78
+ });
@@ -0,0 +1,299 @@
1
+ /**
2
+ * The Postgres statement classifier (#4407): a migration file's statements,
3
+ * each given the class and rule the schema-diff classifier (`./rules.ts`)
4
+ * gives the same change, with the lock the rule's restriction names. It reads
5
+ * text and contacts no server.
6
+ *
7
+ * `CREATE`, `GRANT`, `REVOKE`, `COMMENT ON` and `ALTER TABLE ... ROW LEVEL
8
+ * SECURITY` go through the declaration parser (`../parser.ts`). The parser
9
+ * has no `ALTER` grammar, so the other `ALTER` and `DROP` statements are read
10
+ * word by word from the same tokens.
11
+ *
12
+ * A statement alone cannot decide some changes. `ALTER COLUMN ... TYPE` is a
13
+ * catalog change or a table rewrite by the column's old type, which the file
14
+ * does not state: it is `undecided`, naming SQLPG206 and SQLPG207 (and
15
+ * SQLPG208 with a `USING` expression). `CREATE OR REPLACE VIEW` is SQLPG250
16
+ * or SQLPG251 by whether the columns stay. A statement no rule covers is
17
+ * `unclassified` with a note.
18
+ */
19
+
20
+ import { isTrivia, tokenizeText, type Token } from "../tokens";
21
+ import { parseStatements, type StatementNode } from "../parser";
22
+ import { classifiedOf, lockOf, splitTopLevel, sqlHead, type StatementClassified, type StatementVerdict } from "../../core/statement-class";
23
+ import { PG_CHANGE_CLASSES, PG_CLASSIFIER_RULES, type PgClassifierRuleId } from "./rules";
24
+
25
+ const classified = (id: PgClassifierRuleId, extra: Partial<StatementClassified> = {}): StatementClassified => {
26
+ const lock = lockOf(PG_CLASSIFIER_RULES[id].restriction);
27
+ return classifiedOf(PG_CLASSIFIER_RULES, PG_CHANGE_CLASSES.label, id, { ...(lock ? { lock } : {}), ...extra });
28
+ };
29
+
30
+ /** What one clause of a statement comes to. */
31
+ type Reading = { rules: Array<{ id: PgClassifierRuleId; extra?: Partial<StatementClassified> }> } | { undecided: PgClassifierRuleId[]; note: string } | { none: string } | { data: string };
32
+
33
+ const rule = (id: PgClassifierRuleId, extra?: Partial<StatementClassified>): Reading => ({ rules: [{ id, ...(extra ? { extra } : {}) }] });
34
+
35
+ const VOLATILE = /\b(?:clock_timestamp|random|gen_random_uuid|uuid_generate_v\d\w*|nextval|timeofday|txid_current|setseed)\s*\(/i;
36
+
37
+ /** The words from `i` that are one possibly qualified name, and the index after it. */
38
+ function nameAt(words: readonly string[], i: number): { name: string; next: number } {
39
+ let name = words[i] ?? "";
40
+ let j = i + 1;
41
+ while (words[j] === "." && words[j + 1] !== undefined) {
42
+ name += `.${words[j + 1]}`;
43
+ j += 2;
44
+ }
45
+ return { name, next: j };
46
+ }
47
+
48
+ const bare = (name: string): string => (name.split(".").pop() ?? name).replace(/^"|"$/g, "").toLowerCase();
49
+
50
+ const has = (u: readonly string[], ...seq: string[]): boolean => u.some((_, i) => seq.every((w, k) => u[i + k] === w));
51
+
52
+ /** One `ALTER TABLE` action (the words between top-level commas). */
53
+ function tableAction(a: readonly string[]): Reading {
54
+ const u = a.map((w) => w.toUpperCase());
55
+ const first = u[0];
56
+ if (first === "ADD") {
57
+ let i = 1;
58
+ if (u[i] === "COLUMN") i++;
59
+ if (u[i] === "CONSTRAINT" || ["PRIMARY", "UNIQUE", "CHECK", "FOREIGN", "EXCLUDE"].includes(u[i] ?? "")) {
60
+ let k = i;
61
+ if (u[k] === "CONSTRAINT") k += 2;
62
+ const kind = u[k];
63
+ const notValid = has(u, "NOT", "VALID");
64
+ if (kind === "NOT" && u[k + 1] === "NULL") return rule("SQLPG210");
65
+ if (notValid) return rule("SQLPG217", { note: `${kind ?? "constraint"} added NOT VALID; VALIDATE CONSTRAINT is SQLPG220` });
66
+ if (kind === "PRIMARY" || kind === "UNIQUE") return rule("SQLPG221", has(u, "USING", "INDEX") ? { note: "from an existing unique index, so no build under this lock" } : {});
67
+ if (kind === "CHECK") return rule("SQLPG218");
68
+ if (kind === "FOREIGN") return rule("SQLPG219");
69
+ if (kind === "EXCLUDE") return rule("SQLPG222");
70
+ return { none: "an ADD CONSTRAINT of a kind no rule names" };
71
+ }
72
+ if (u[i] === "IF" && u[i + 1] === "NOT" && u[i + 2] === "EXISTS") i += 3;
73
+ const hasDefault = u.includes("DEFAULT");
74
+ const defaultText = hasDefault ? a.slice(u.indexOf("DEFAULT") + 1).join(" ") : "";
75
+ const generated = has(u, "GENERATED");
76
+ const stored = u.includes("STORED") && generated;
77
+ const identity = u.includes("IDENTITY");
78
+ const notNull = has(u, "NOT", "NULL");
79
+ if (stored || identity) return rule("SQLPG202", { note: stored ? "a STORED generated column" : "an identity column" });
80
+ if (VOLATILE.test(defaultText)) return rule("SQLPG202", { note: "a volatile default" });
81
+ if (notNull && !hasDefault && !generated) return rule("SQLPG203");
82
+ return rule("SQLPG201");
83
+ }
84
+ if (first === "DROP") {
85
+ if (u[1] === "CONSTRAINT") return rule("SQLPG223");
86
+ return rule("SQLPG204", { destructive: true });
87
+ }
88
+ if (first === "RENAME") {
89
+ if (u[1] === "COLUMN" || (u[1] !== "CONSTRAINT" && u[1] !== "TO")) return rule("SQLPG205");
90
+ return rule("SQLPG228");
91
+ }
92
+ if (first === "ALTER") {
93
+ let i = 1;
94
+ if (u[i] === "COLUMN") i++;
95
+ if (u[i] === "CONSTRAINT") return { none: "ALTER CONSTRAINT (deferrability)" };
96
+ const rest = u.slice(i + 1);
97
+ const col = a[i] ?? "column";
98
+ if (rest[0] === "TYPE" || (rest[0] === "SET" && rest[1] === "DATA" && rest[2] === "TYPE")) {
99
+ const using = rest.includes("USING");
100
+ return {
101
+ undecided: using ? ["SQLPG206", "SQLPG207", "SQLPG208"] : ["SQLPG206", "SQLPG207"],
102
+ note: `the new type of ${col} is a catalog change or a table rewrite by its old type${using ? ", or a change across kinds with this USING expression," : ""}, which the file does not state`,
103
+ };
104
+ }
105
+ if (rest[0] === "SET" && rest[1] === "DEFAULT") return rule("SQLPG209");
106
+ if (rest[0] === "DROP" && rest[1] === "DEFAULT") return rule("SQLPG209");
107
+ if (rest[0] === "SET" && rest[1] === "NOT" && rest[2] === "NULL") return rule("SQLPG210");
108
+ if (rest[0] === "DROP" && rest[1] === "NOT" && rest[2] === "NULL") return rule("SQLPG211");
109
+ if (rest[0] === "SET" && rest[1] === "EXPRESSION") return rule("SQLPG212");
110
+ if ((rest[0] === "ADD" && rest[1] === "GENERATED") || (rest[0] === "SET" && (rest[1] === "GENERATED" || rest[1] === "INCREMENT" || rest[1] === "START" || rest[1] === "RESTART")) || rest[0] === "RESTART" || (rest[0] === "DROP" && rest[1] === "IDENTITY")) {
111
+ return rule("SQLPG213");
112
+ }
113
+ if (rest[0] === "SET" && (rest[1] === "STORAGE" || rest[1] === "COMPRESSION")) return rule("SQLPG215");
114
+ return { none: `ALTER COLUMN ${rest.slice(0, 2).join(" ")}` };
115
+ }
116
+ if (first === "VALIDATE" && u[1] === "CONSTRAINT") return rule("SQLPG220");
117
+ if (first === "SET" || first === "RESET") {
118
+ if (u[1] === "LOGGED" || u[1] === "UNLOGGED") return rule("SQLPG225");
119
+ if (u[1] === "ACCESS" || u[1] === "TABLESPACE") return rule("SQLPG226");
120
+ if (u[1] === "SCHEMA") return rule("SQLPG228");
121
+ if (u[1] === "(" || first === "RESET") return rule("SQLPG224");
122
+ return { none: `ALTER TABLE ${u.slice(0, 2).join(" ")}` };
123
+ }
124
+ if (first === "ATTACH" || first === "DETACH" || first === "INHERIT" || (first === "NO" && u[1] === "INHERIT") || first === "OF" || (first === "NOT" && u[1] === "OF")) return rule("SQLPG227");
125
+ if ((first === "ENABLE" || first === "DISABLE" || first === "FORCE" || (first === "NO" && u[1] === "FORCE")) && has(u, "ROW", "LEVEL", "SECURITY")) return rule("SQLPG293");
126
+ if ((first === "ENABLE" || first === "DISABLE") && (u[1] === "TRIGGER" || u[1] === "REPLICA" || u[1] === "ALWAYS")) return rule("SQLPG284");
127
+ return { none: `ALTER TABLE ${u.slice(0, 2).join(" ")}` };
128
+ }
129
+
130
+ /** `ALTER <object> ...` other than `TABLE`, by its second word. */
131
+ function alterOther(u: readonly string[], a: readonly string[]): Reading {
132
+ const obj = u[1];
133
+ const rename = u.includes("RENAME");
134
+ if (obj === "INDEX") return rename ? rule("SQLPG229") : rule("SQLPG243");
135
+ if (obj === "TYPE") {
136
+ if (has(u, "ADD", "VALUE")) return rule("SQLPG260");
137
+ if (has(u, "RENAME", "TO")) return rule("SQLPG228");
138
+ return { none: "ALTER TYPE other than ADD VALUE and RENAME TO" };
139
+ }
140
+ if (obj === "SEQUENCE") return rename || has(u, "SET", "SCHEMA") ? rule("SQLPG228") : has(u, "OWNER", "TO") ? { none: "ALTER SEQUENCE OWNER TO" } : rule("SQLPG265");
141
+ if (obj === "VIEW") return rename || has(u, "SET", "SCHEMA") ? rule("SQLPG228") : has(u, "OWNER", "TO") ? { none: "ALTER VIEW OWNER TO" } : rule("SQLPG253");
142
+ if (obj === "MATERIALIZED") return rename || has(u, "SET", "SCHEMA") ? rule("SQLPG228") : { none: "ALTER MATERIALIZED VIEW other than RENAME" };
143
+ if (obj === "SCHEMA") return rename ? rule("SQLPG228") : has(u, "OWNER", "TO") ? rule("SQLPG267") : { none: "ALTER SCHEMA" };
144
+ if (obj === "EXTENSION") return has(u, "UPDATE") || has(u, "SET", "SCHEMA") ? rule("SQLPG266") : { none: "ALTER EXTENSION ADD or DROP member" };
145
+ if (obj === "FUNCTION" || obj === "PROCEDURE" || obj === "ROUTINE") return rename || has(u, "SET", "SCHEMA") ? rule("SQLPG228") : { none: `ALTER ${obj} other than RENAME` };
146
+ if (obj === "TRIGGER") return rule("SQLPG284");
147
+ if (obj === "POLICY") return rename ? rule("SQLPG228") : rule("SQLPG291");
148
+ if (obj === "ROLE" || obj === "USER" || obj === "GROUP") return rule("SQLPG294");
149
+ if (obj === "DOMAIN") {
150
+ if (rename || has(u, "SET", "SCHEMA")) return rule("SQLPG228");
151
+ if (has(u, "ADD", "CONSTRAINT") || (u[3] === "ADD" && ["CHECK", "NOT"].includes(u[4] ?? ""))) return rule("SQLPG262");
152
+ if (has(u, "OWNER", "TO")) return { none: "ALTER DOMAIN OWNER TO" };
153
+ return rule("SQLPG263");
154
+ }
155
+ if (obj === "SERVER" || obj === "PUBLICATION" || (obj === "FOREIGN" && u[2] === "DATA")) return rename ? rule("SQLPG228") : rule("SQLPG303");
156
+ void a;
157
+ return { none: `ALTER ${obj ?? ""}`.trim() };
158
+ }
159
+
160
+ function dropStatement(u: readonly string[]): Reading {
161
+ const obj = u[1] === "MATERIALIZED" || u[1] === "FOREIGN" || u[1] === "EVENT" ? `${u[1]} ${u[2]}` : u[1];
162
+ if (obj === "INDEX") return rule("SQLPG242", u[2] === "CONCURRENTLY" ? { note: "DROP INDEX CONCURRENTLY waits for running queries instead of blocking them" } : {});
163
+ if (obj === "TRIGGER") return rule("SQLPG285", { destructive: false });
164
+ if (obj === "POLICY") return rule("SQLPG292", { destructive: false });
165
+ return rule("SQLPG270", { destructive: true });
166
+ }
167
+
168
+ /** How a `CREATE` that the parser could not read starts: its kind by key words. */
169
+ function createByWords(u: readonly string[]): Reading {
170
+ let i = 1;
171
+ const orReplace = u[i] === "OR" && u[i + 1] === "REPLACE";
172
+ if (orReplace) i += 2;
173
+ while (["GLOBAL", "LOCAL", "TEMPORARY", "TEMP", "UNLOGGED", "RECURSIVE", "UNIQUE", "CONSTRAINT"].includes(u[i] ?? "") && u[i + 1] !== "INDEX" && u[i] !== "UNIQUE") i++;
174
+ if (u[i] === "UNIQUE") i++;
175
+ if (u[i] === "INDEX") {
176
+ const concurrently = u[i + 1] === "CONCURRENTLY";
177
+ return concurrently ? rule("SQLPG240") : rule("SQLPG241");
178
+ }
179
+ if (orReplace && (u[i] === "FUNCTION" || u[i] === "PROCEDURE")) return rule("SQLPG280");
180
+ if (orReplace && u[i] === "VIEW") return { undecided: ["SQLPG250", "SQLPG251"], note: "whether the view keeps its columns depends on the view it replaces" };
181
+ if (orReplace && u[i] === "AGGREGATE") return rule("SQLPG302");
182
+ if (u[i] === "TRIGGER") return rule("SQLPG283");
183
+ if (u[i] === "POLICY") return rule("SQLPG290");
184
+ return rule("SQLPG200");
185
+ }
186
+
187
+ /** A statement the parser read: its rule, given the tables this file created earlier. */
188
+ function fromNode(n: StatementNode, created: Set<string>): Reading {
189
+ switch (n.statement) {
190
+ case "table":
191
+ created.add(bare(n.name.pieces.join(".")));
192
+ return rule("SQLPG200");
193
+ case "index":
194
+ if (n.concurrently) return rule("SQLPG240");
195
+ if (created.has(bare(n.table.pieces.join(".")))) return rule("SQLPG200", { note: "an index on a table this file creates, which has no rows to block" });
196
+ return rule("SQLPG241");
197
+ case "view":
198
+ if (n.orReplace && !n.materialized) return { undecided: ["SQLPG250", "SQLPG251"], note: "whether the view keeps its columns depends on the view it replaces" };
199
+ return rule("SQLPG200");
200
+ case "function":
201
+ case "procedure":
202
+ return n.orReplace ? rule("SQLPG280") : rule("SQLPG200");
203
+ case "aggregate":
204
+ return n.orReplace ? rule("SQLPG302") : rule("SQLPG200");
205
+ case "trigger":
206
+ return created.has(bare(n.table.pieces.join("."))) ? rule("SQLPG200", { note: "a trigger on a table this file creates" }) : rule("SQLPG283");
207
+ case "policy":
208
+ return created.has(bare(n.table.pieces.join("."))) ? rule("SQLPG200", { note: "a policy on a table this file creates" }) : rule("SQLPG290");
209
+ case "grant":
210
+ return n.defaults ? rule("SQLPG298") : n.revoke ? rule("SQLPG297") : rule("SQLPG296");
211
+ case "rowSecurity":
212
+ return rule("SQLPG293");
213
+ case "comment":
214
+ return rule("SQLPG216");
215
+ case "role":
216
+ return rule("SQLPG200");
217
+ default:
218
+ return rule("SQLPG200");
219
+ }
220
+ }
221
+
222
+ const DATA_WORDS = new Set(["INSERT", "UPDATE", "DELETE", "SELECT", "WITH", "COPY", "CALL", "DO", "SET", "BEGIN", "START", "COMMIT", "END", "ROLLBACK", "SAVEPOINT", "RELEASE", "ANALYZE", "ANALYSE", "VACUUM", "REINDEX", "LOCK", "NOTIFY", "LISTEN", "RESET", "SHOW", "EXPLAIN", "VALUES", "MERGE", "REFRESH", "CLUSTER", "CHECKPOINT", "DISCARD", "PREPARE", "EXECUTE", "DEALLOCATE"]);
223
+
224
+ /** Classify one statement. `created` is the tables the file's earlier statements created. */
225
+ function read(sql: string, created: Set<string>): Reading[] {
226
+ let tokens: Token[];
227
+ try {
228
+ tokens = tokenizeText(sql, 0);
229
+ } catch {
230
+ return [{ none: "text the tokenizer could not read" }];
231
+ }
232
+ const words = tokens.filter((t) => !isTrivia(t) && t.text !== ";").map((t) => t.text);
233
+ const u = words.map((w) => w.toUpperCase());
234
+ const head = u[0];
235
+ if (head === undefined) return [{ data: "an empty statement" }];
236
+
237
+ if (head === "CREATE" || head === "GRANT" || head === "REVOKE" || head === "COMMENT" || (head === "ALTER" && (u[1] === "DEFAULT" || (u[1] === "TABLE" && has(u, "ROW", "LEVEL", "SECURITY"))))) {
238
+ try {
239
+ const nodes = parseStatements(tokens);
240
+ return nodes.map((n) => fromNode(n, created));
241
+ } catch {
242
+ if (head === "CREATE") return [createByWords(u)];
243
+ if (head === "GRANT") return [rule("SQLPG296")];
244
+ if (head === "REVOKE") return [rule("SQLPG297")];
245
+ if (head === "COMMENT") return [rule("SQLPG216")];
246
+ if (head === "ALTER" && u[1] === "DEFAULT") return [rule("SQLPG298")];
247
+ return [rule("SQLPG293")];
248
+ }
249
+ }
250
+ if (head === "ALTER" && u[1] === "TABLE") {
251
+ let i = 2;
252
+ if (u[i] === "IF" && u[i + 1] === "EXISTS") i += 2;
253
+ if (u[i] === "ONLY") i++;
254
+ const { name, next } = nameAt(words, i);
255
+ if (u[next] === "*") i = next + 1;
256
+ else i = next;
257
+ const actions = splitTopLevel(words.slice(i));
258
+ if (actions.length === 0) return [{ none: `ALTER TABLE ${name} with no action` }];
259
+ void name;
260
+ return actions.map(tableAction);
261
+ }
262
+ if (head === "ALTER") return [alterOther(u, words)];
263
+ if (head === "DROP") return [dropStatement(u)];
264
+ if (head === "TRUNCATE") return [{ data: "TRUNCATE removes every row of the table" }];
265
+ if (DATA_WORDS.has(head)) return [{ data: `${head} reads or writes rows or the session, not the schema` }];
266
+ return [{ none: `${head} is not a statement the classifier reads` }];
267
+ }
268
+
269
+ /**
270
+ * Classify a migration's up statements, in order. The statements are the
271
+ * file's own text, one per entry (a Flyway file after placeholder
272
+ * substitution).
273
+ */
274
+ export function classifyPgStatements(statements: readonly string[]): StatementVerdict[] {
275
+ const created = new Set<string>();
276
+ return statements.map((sql, k) => {
277
+ const readings = read(sql, created);
278
+ const out: StatementVerdict = { index: k + 1, sql: sqlHead(sql), outcome: "classified", changes: [] };
279
+ const none: string[] = [];
280
+ for (const r of readings) {
281
+ if ("rules" in r) out.changes.push(...r.rules.map((x) => classified(x.id, x.extra)));
282
+ else if ("undecided" in r) {
283
+ out.candidates = r.undecided.map((id) => classified(id));
284
+ out.note = r.note;
285
+ } else if ("none" in r) none.push(r.none);
286
+ else out.note = r.data;
287
+ }
288
+ if (out.candidates && out.changes.length === 0) out.outcome = "undecided";
289
+ else if (out.candidates) {
290
+ // A statement with some decided clauses and one undecided: report undecided, and keep the decided ones.
291
+ out.outcome = "undecided";
292
+ } else if (out.changes.length === 0 && none.length === 0) out.outcome = "data";
293
+ else if (none.length > 0) {
294
+ out.outcome = out.changes.length > 0 ? "classified" : "unclassified";
295
+ out.note = [out.note, `no rule covers ${none.join("; ")}`].filter(Boolean).join("; ");
296
+ }
297
+ return out;
298
+ });
299
+ }