@intentius/chant-lexicon-sql 0.124.46 → 0.124.49

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 (402) hide show
  1. package/dist/change-set.d.ts +1 -1
  2. package/dist/change-set.d.ts.map +1 -1
  3. package/dist/change-set.js +2 -1
  4. package/dist/clickhouse/apply/statements.d.ts +9 -0
  5. package/dist/clickhouse/apply/statements.d.ts.map +1 -1
  6. package/dist/clickhouse/apply/statements.js +24 -0
  7. package/dist/clickhouse/import/live-export.d.ts +7 -3
  8. package/dist/clickhouse/import/live-export.d.ts.map +1 -1
  9. package/dist/clickhouse/import/live-export.js +21 -5
  10. package/dist/clickhouse/live/named-collections.d.ts +27 -0
  11. package/dist/clickhouse/live/named-collections.d.ts.map +1 -0
  12. package/dist/clickhouse/live/named-collections.js +53 -0
  13. package/dist/clickhouse/live/undeclarable.d.ts +15 -3
  14. package/dist/clickhouse/live/undeclarable.d.ts.map +1 -1
  15. package/dist/clickhouse/live/undeclarable.js +18 -4
  16. package/dist/clickhouse/named-collections.d.ts +83 -0
  17. package/dist/clickhouse/named-collections.d.ts.map +1 -0
  18. package/dist/clickhouse/named-collections.js +203 -0
  19. package/dist/clickhouse/parser.d.ts.map +1 -1
  20. package/dist/clickhouse/parser.js +1 -1
  21. package/dist/clickhouse/plan/commands.d.ts +2 -1
  22. package/dist/clickhouse/plan/commands.d.ts.map +1 -1
  23. package/dist/clickhouse/plan/commands.js +17 -2
  24. package/dist/clickhouse/plan/diff.d.ts +15 -0
  25. package/dist/clickhouse/plan/diff.d.ts.map +1 -1
  26. package/dist/clickhouse/plan/diff.js +43 -1
  27. package/dist/clickhouse/plan/disruption.d.ts.map +1 -1
  28. package/dist/clickhouse/plan/disruption.js +5 -0
  29. package/dist/clickhouse/plan/rebuild-handoff.d.ts.map +1 -1
  30. package/dist/clickhouse/plan/rebuild-handoff.js +4 -0
  31. package/dist/clickhouse/plan/report.d.ts.map +1 -1
  32. package/dist/clickhouse/plan/report.js +6 -1
  33. package/dist/clickhouse/plan/rules.d.ts +5 -1
  34. package/dist/clickhouse/plan/rules.d.ts.map +1 -1
  35. package/dist/clickhouse/plan/rules.js +7 -3
  36. package/dist/clickhouse/plan/statements.d.ts +29 -0
  37. package/dist/clickhouse/plan/statements.d.ts.map +1 -0
  38. package/dist/clickhouse/plan/statements.js +334 -0
  39. package/dist/clickhouse/plan/stream.d.ts +40 -0
  40. package/dist/clickhouse/plan/stream.d.ts.map +1 -0
  41. package/dist/clickhouse/plan/stream.js +107 -0
  42. package/dist/clickhouse/rebuild/observe.d.ts +3 -1
  43. package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
  44. package/dist/clickhouse/rebuild/observe.js +14 -1
  45. package/dist/clickhouse/review-catalog.d.ts +213 -0
  46. package/dist/clickhouse/review-catalog.d.ts.map +1 -0
  47. package/dist/clickhouse/review-catalog.js +330 -0
  48. package/dist/codegen/classifier-page.d.ts.map +1 -1
  49. package/dist/codegen/classifier-page.js +44 -4
  50. package/dist/codegen/clickhouse-module.d.ts.map +1 -1
  51. package/dist/codegen/clickhouse-module.js +3 -0
  52. package/dist/codegen/coverage-page.d.ts.map +1 -1
  53. package/dist/codegen/coverage-page.js +5 -3
  54. package/dist/codegen/docs.js +1 -1
  55. package/dist/codegen/postgres-classifier-page.js +2 -2
  56. package/dist/codegen/registry.d.ts.map +1 -1
  57. package/dist/codegen/registry.js +10 -0
  58. package/dist/composites/catalog.d.ts.map +1 -1
  59. package/dist/composites/catalog.js +100 -0
  60. package/dist/composites/clickhouse.d.ts +2 -0
  61. package/dist/composites/clickhouse.d.ts.map +1 -1
  62. package/dist/composites/clickhouse.js +2 -0
  63. package/dist/composites/otel-tables.d.ts +100 -0
  64. package/dist/composites/otel-tables.d.ts.map +1 -0
  65. package/dist/composites/otel-tables.js +282 -0
  66. package/dist/composites/stream-ingest.d.ts +63 -0
  67. package/dist/composites/stream-ingest.d.ts.map +1 -0
  68. package/dist/composites/stream-ingest.js +39 -0
  69. package/dist/core/statement-class.d.ts +55 -0
  70. package/dist/core/statement-class.d.ts.map +1 -0
  71. package/dist/core/statement-class.js +43 -0
  72. package/dist/coverage/clickhouse.json +1 -1
  73. package/dist/coverage/postgres.json +12 -12
  74. package/dist/coverage.d.ts +16 -6
  75. package/dist/coverage.d.ts.map +1 -1
  76. package/dist/coverage.js +10 -5
  77. package/dist/drizzle-command.d.ts +17 -0
  78. package/dist/drizzle-command.d.ts.map +1 -0
  79. package/dist/drizzle-command.js +91 -0
  80. package/dist/drizzle.d.ts +70 -0
  81. package/dist/drizzle.d.ts.map +1 -0
  82. package/dist/drizzle.js +689 -0
  83. package/dist/files/clickhouse.d.ts.map +1 -1
  84. package/dist/files/clickhouse.js +8 -1
  85. package/dist/files/common.d.ts +13 -0
  86. package/dist/files/common.d.ts.map +1 -1
  87. package/dist/files/common.js +5 -0
  88. package/dist/files/postgres.d.ts.map +1 -1
  89. package/dist/files/postgres.js +35 -3
  90. package/dist/generated/clickhouse.d.ts +2 -0
  91. package/dist/generated/clickhouse.d.ts.map +1 -1
  92. package/dist/generated/clickhouse.js +2 -0
  93. package/dist/import-parser.d.ts +21 -1
  94. package/dist/import-parser.d.ts.map +1 -1
  95. package/dist/import-parser.js +79 -5
  96. package/dist/index.d.ts +1 -1
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +1 -1
  99. package/dist/integrity.json +7 -6
  100. package/dist/lint/post-synth/audit-entries.d.ts.map +1 -1
  101. package/dist/lint/post-synth/audit-entries.js +1 -0
  102. package/dist/lint/post-synth/index.d.ts.map +1 -1
  103. package/dist/lint/post-synth/index.js +2 -0
  104. package/dist/lint/post-synth/postgres-names.d.ts.map +1 -1
  105. package/dist/lint/post-synth/postgres-names.js +3 -0
  106. package/dist/lint/post-synth/sqlch128.d.ts +18 -0
  107. package/dist/lint/post-synth/sqlch128.d.ts.map +1 -0
  108. package/dist/lint/post-synth/sqlch128.js +222 -0
  109. package/dist/lint/rules/postgres-templates.js +2 -2
  110. package/dist/lsp/postgres-template.d.ts.map +1 -1
  111. package/dist/lsp/postgres-template.js +21 -0
  112. package/dist/manifest.json +1 -1
  113. package/dist/mcp/postgres.js +1 -1
  114. package/dist/meta.json +50 -0
  115. package/dist/migrate/from-postgres/detect.d.ts +4 -0
  116. package/dist/migrate/from-postgres/detect.d.ts.map +1 -0
  117. package/dist/migrate/from-postgres/detect.js +7 -0
  118. package/dist/migrate/from-postgres/emit.d.ts +26 -0
  119. package/dist/migrate/from-postgres/emit.d.ts.map +1 -0
  120. package/dist/migrate/from-postgres/emit.js +86 -0
  121. package/dist/migrate/from-postgres/index.d.ts +51 -0
  122. package/dist/migrate/from-postgres/index.d.ts.map +1 -0
  123. package/dist/migrate/from-postgres/index.js +115 -0
  124. package/dist/migrate/from-postgres/translate.d.ts +73 -0
  125. package/dist/migrate/from-postgres/translate.d.ts.map +1 -0
  126. package/dist/migrate/from-postgres/translate.js +261 -0
  127. package/dist/migrate/from-postgres/types.d.ts +51 -0
  128. package/dist/migrate/from-postgres/types.d.ts.map +1 -0
  129. package/dist/migrate/from-postgres/types.js +211 -0
  130. package/dist/migration-classify.d.ts +23 -0
  131. package/dist/migration-classify.d.ts.map +1 -0
  132. package/dist/migration-classify.js +44 -0
  133. package/dist/migration-replay.d.ts +73 -0
  134. package/dist/migration-replay.d.ts.map +1 -0
  135. package/dist/migration-replay.js +294 -0
  136. package/dist/okf/index.md +11 -0
  137. package/dist/okf/rules/SQLCH128.md +11 -0
  138. package/dist/okf/types/PostgresForeignDataWrapper.md +9 -0
  139. package/dist/okf/types/PostgresForeignServer.md +9 -0
  140. package/dist/okf/types/PostgresForeignTable.md +9 -0
  141. package/dist/okf/types/PostgresOperator.md +9 -0
  142. package/dist/okf/types/PostgresOperatorClass.md +9 -0
  143. package/dist/okf/types/PostgresOperatorFamily.md +9 -0
  144. package/dist/okf/types/PostgresPublication.md +9 -0
  145. package/dist/okf/types/PostgresStatistics.md +9 -0
  146. package/dist/okf/types/PostgresTextSearchConfiguration.md +9 -0
  147. package/dist/okf/types/PostgresTextSearchDictionary.md +9 -0
  148. package/dist/op/activities/postgres-migration.d.ts.map +1 -1
  149. package/dist/op/activities/postgres-migration.js +4 -0
  150. package/dist/op/activity-contracts.d.ts +20 -0
  151. package/dist/op/activity-contracts.d.ts.map +1 -1
  152. package/dist/op/activity-contracts.js +2 -0
  153. package/dist/plugin.d.ts.map +1 -1
  154. package/dist/plugin.js +49 -2
  155. package/dist/postgres/access/acl.js +1 -1
  156. package/dist/postgres/access/import.js +1 -1
  157. package/dist/postgres/apply/apply.js +13 -1
  158. package/dist/postgres/apply/statements.d.ts.map +1 -1
  159. package/dist/postgres/apply/statements.js +243 -4
  160. package/dist/postgres/constraint-names.d.ts +30 -0
  161. package/dist/postgres/constraint-names.d.ts.map +1 -0
  162. package/dist/postgres/constraint-names.js +72 -0
  163. package/dist/postgres/entities.d.ts +229 -2
  164. package/dist/postgres/entities.d.ts.map +1 -1
  165. package/dist/postgres/entities.js +326 -5
  166. package/dist/postgres/entity-types.d.ts +10 -0
  167. package/dist/postgres/entity-types.d.ts.map +1 -1
  168. package/dist/postgres/entity-types.js +10 -0
  169. package/dist/postgres/import/generator.d.ts.map +1 -1
  170. package/dist/postgres/import/generator.js +65 -2
  171. package/dist/postgres/import/live-export.d.ts.map +1 -1
  172. package/dist/postgres/import/live-export.js +2 -0
  173. package/dist/postgres/live/catalog-kinds.d.ts +20 -0
  174. package/dist/postgres/live/catalog-kinds.d.ts.map +1 -1
  175. package/dist/postgres/live/catalog-kinds.js +452 -0
  176. package/dist/postgres/live/catalog.d.ts +2 -0
  177. package/dist/postgres/live/catalog.d.ts.map +1 -1
  178. package/dist/postgres/live/catalog.js +3 -1
  179. package/dist/postgres/live/describe-resources.d.ts.map +1 -1
  180. package/dist/postgres/live/describe-resources.js +12 -2
  181. package/dist/postgres/migrate/backfill.d.ts +2 -0
  182. package/dist/postgres/migrate/backfill.d.ts.map +1 -1
  183. package/dist/postgres/migrate/backfill.js +7 -1
  184. package/dist/postgres/migrate/carry.d.ts +45 -1
  185. package/dist/postgres/migrate/carry.d.ts.map +1 -1
  186. package/dist/postgres/migrate/carry.js +72 -8
  187. package/dist/postgres/migrate/observe.d.ts +28 -4
  188. package/dist/postgres/migrate/observe.d.ts.map +1 -1
  189. package/dist/postgres/migrate/observe.js +45 -13
  190. package/dist/postgres/migrate/op.d.ts +26 -1
  191. package/dist/postgres/migrate/op.d.ts.map +1 -1
  192. package/dist/postgres/migrate/op.js +3 -1
  193. package/dist/postgres/migrate/replication.d.ts +42 -0
  194. package/dist/postgres/migrate/replication.d.ts.map +1 -1
  195. package/dist/postgres/migrate/replication.js +53 -0
  196. package/dist/postgres/migrate/steps.d.ts +35 -2
  197. package/dist/postgres/migrate/steps.d.ts.map +1 -1
  198. package/dist/postgres/migrate/steps.js +158 -12
  199. package/dist/postgres/parser.d.ts +144 -1
  200. package/dist/postgres/parser.d.ts.map +1 -1
  201. package/dist/postgres/parser.js +370 -4
  202. package/dist/postgres/plan/deep.d.ts.map +1 -1
  203. package/dist/postgres/plan/deep.js +43 -4
  204. package/dist/postgres/plan/diff.d.ts.map +1 -1
  205. package/dist/postgres/plan/diff.js +154 -1
  206. package/dist/postgres/plan/normalize.d.ts +28 -2
  207. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  208. package/dist/postgres/plan/normalize.js +260 -1
  209. package/dist/postgres/plan/rules.d.ts +4 -0
  210. package/dist/postgres/plan/rules.d.ts.map +1 -1
  211. package/dist/postgres/plan/rules.js +6 -2
  212. package/dist/postgres/plan/schema.js +1 -1
  213. package/dist/postgres/plan/statements.d.ts +26 -0
  214. package/dist/postgres/plan/statements.d.ts.map +1 -0
  215. package/dist/postgres/plan/statements.js +364 -0
  216. package/dist/postgres/secret-options.d.ts +10 -0
  217. package/dist/postgres/secret-options.d.ts.map +1 -0
  218. package/dist/postgres/secret-options.js +9 -0
  219. package/dist/postgres/testing/roundtrip-schema.d.ts +4 -2
  220. package/dist/postgres/testing/roundtrip-schema.d.ts.map +1 -1
  221. package/dist/postgres/testing/roundtrip-schema.js +24 -1
  222. package/dist/postgres.d.ts +1 -1
  223. package/dist/postgres.d.ts.map +1 -1
  224. package/dist/postgres.js +1 -1
  225. package/dist/rules/audit-entries.ts +1 -0
  226. package/dist/rules/postgres-names.ts +2 -0
  227. package/dist/rules/postgres-templates.ts +2 -2
  228. package/dist/rules/sqlch128.ts +230 -0
  229. package/dist/serializer.d.ts +5 -0
  230. package/dist/serializer.d.ts.map +1 -1
  231. package/dist/serializer.js +13 -2
  232. package/dist/sql-types-command.d.ts +13 -0
  233. package/dist/sql-types-command.d.ts.map +1 -1
  234. package/dist/sql-types-command.js +31 -16
  235. package/dist/sql-types.d.ts +22 -0
  236. package/dist/sql-types.d.ts.map +1 -1
  237. package/dist/sql-types.js +10 -6
  238. package/dist/sqlserver/lexer.d.ts +37 -0
  239. package/dist/sqlserver/lexer.d.ts.map +1 -0
  240. package/dist/sqlserver/lexer.js +254 -0
  241. package/dist/sqlserver/parse.d.ts +185 -0
  242. package/dist/sqlserver/parse.d.ts.map +1 -0
  243. package/dist/sqlserver/parse.js +767 -0
  244. package/dist/sqlserver/translate.d.ts +34 -0
  245. package/dist/sqlserver/translate.d.ts.map +1 -0
  246. package/dist/sqlserver/translate.js +495 -0
  247. package/dist/sqlserver/types.d.ts +44 -0
  248. package/dist/sqlserver/types.d.ts.map +1 -0
  249. package/dist/sqlserver/types.js +133 -0
  250. package/dist/sqlserver/validate.d.ts +31 -0
  251. package/dist/sqlserver/validate.d.ts.map +1 -0
  252. package/dist/sqlserver/validate.js +67 -0
  253. package/dist/validate.d.ts.map +1 -1
  254. package/dist/validate.js +10 -0
  255. package/package.json +2 -2
  256. package/src/change-set.ts +2 -1
  257. package/src/clickhouse/apply/statements.ts +25 -0
  258. package/src/clickhouse/import/live-export.ts +20 -5
  259. package/src/clickhouse/live/named-collections.e2e.test.ts +88 -0
  260. package/src/clickhouse/live/named-collections.ts +61 -0
  261. package/src/clickhouse/live/undeclarable.e2e.test.ts +5 -3
  262. package/src/clickhouse/live/undeclarable.ts +20 -4
  263. package/src/clickhouse/named-collections.test.ts +187 -0
  264. package/src/clickhouse/named-collections.ts +224 -0
  265. package/src/clickhouse/parser.ts +2 -1
  266. package/src/clickhouse/plan/commands.ts +17 -2
  267. package/src/clickhouse/plan/diff.ts +57 -1
  268. package/src/clickhouse/plan/disruption.ts +5 -0
  269. package/src/clickhouse/plan/rebuild-handoff.ts +3 -0
  270. package/src/clickhouse/plan/report.ts +6 -1
  271. package/src/clickhouse/plan/rules.ts +14 -4
  272. package/src/clickhouse/plan/statements.test.ts +61 -0
  273. package/src/clickhouse/plan/statements.ts +306 -0
  274. package/src/clickhouse/plan/stream-s3queue.e2e.test.ts +148 -0
  275. package/src/clickhouse/plan/stream.test.ts +237 -0
  276. package/src/clickhouse/plan/stream.ts +111 -0
  277. package/src/clickhouse/rebuild/observe.ts +15 -1
  278. package/src/clickhouse/review-catalog.test.ts +181 -0
  279. package/src/clickhouse/review-catalog.ts +489 -0
  280. package/src/codegen/classifier-page.ts +44 -4
  281. package/src/codegen/clickhouse-module.ts +3 -0
  282. package/src/codegen/coverage-page.ts +5 -3
  283. package/src/codegen/docs.ts +1 -1
  284. package/src/codegen/postgres-classifier-page.ts +2 -2
  285. package/src/codegen/registry.ts +10 -0
  286. package/src/composites/catalog.test.ts +6 -1
  287. package/src/composites/catalog.ts +100 -0
  288. package/src/composites/clickhouse.ts +2 -0
  289. package/src/composites/composites.test.ts +1 -1
  290. package/src/composites/otel-tables.test.ts +118 -0
  291. package/src/composites/otel-tables.ts +339 -0
  292. package/src/composites/stream-ingest.test.ts +114 -0
  293. package/src/composites/stream-ingest.ts +82 -0
  294. package/src/core/statement-class.ts +88 -0
  295. package/src/core/undeclarable.test.ts +6 -5
  296. package/src/coverage/clickhouse.json +1 -1
  297. package/src/coverage/postgres.json +12 -12
  298. package/src/coverage.test.ts +27 -2
  299. package/src/coverage.ts +18 -7
  300. package/src/drizzle-command.ts +94 -0
  301. package/src/drizzle-kit.e2e.test.ts +89 -0
  302. package/src/drizzle-types.e2e.test.ts +67 -0
  303. package/src/drizzle.test.ts +358 -0
  304. package/src/drizzle.ts +742 -0
  305. package/src/files/clickhouse.ts +8 -1
  306. package/src/files/common.ts +14 -1
  307. package/src/files/postgres.ts +34 -3
  308. package/src/generated/clickhouse.ts +3 -0
  309. package/src/generated/lexicon-sql.json +50 -0
  310. package/src/import-parser.sqlserver.test.ts +92 -0
  311. package/src/import-parser.ts +79 -5
  312. package/src/index.ts +1 -1
  313. package/src/lint/post-synth/audit-entries.ts +1 -0
  314. package/src/lint/post-synth/index.ts +2 -0
  315. package/src/lint/post-synth/postgres-checks.test.ts +8 -1
  316. package/src/lint/post-synth/postgres-names.ts +2 -0
  317. package/src/lint/post-synth/sqlch128.test.ts +107 -0
  318. package/src/lint/post-synth/sqlch128.ts +230 -0
  319. package/src/lint/rules/postgres-templates.ts +2 -2
  320. package/src/lsp/postgres-template.ts +20 -0
  321. package/src/mcp/postgres.ts +1 -1
  322. package/src/migrate/from-postgres/cli.test.ts +165 -0
  323. package/src/migrate/from-postgres/detect.ts +7 -0
  324. package/src/migrate/from-postgres/emit.ts +96 -0
  325. package/src/migrate/from-postgres/example.test.ts +205 -0
  326. package/src/migrate/from-postgres/index.ts +122 -0
  327. package/src/migrate/from-postgres/translate.test.ts +242 -0
  328. package/src/migrate/from-postgres/translate.ts +359 -0
  329. package/src/migrate/from-postgres/types.test.ts +116 -0
  330. package/src/migrate/from-postgres/types.ts +236 -0
  331. package/src/migrate/from-postgres/validate.e2e.test.ts +44 -0
  332. package/src/migrate/from-postgres/workspace.e2e.test.ts +114 -0
  333. package/src/migration-classify.test.ts +150 -0
  334. package/src/migration-classify.ts +45 -0
  335. package/src/migration-replay.e2e.test.ts +155 -0
  336. package/src/migration-replay.test.ts +173 -0
  337. package/src/migration-replay.ts +289 -0
  338. package/src/op/__snapshots__/watch-pipeline.test.ts.snap +2 -2
  339. package/src/op/activities/postgres-migration.ts +4 -0
  340. package/src/op/activity-contracts.ts +2 -0
  341. package/src/op/watch-pipeline.test.ts +8 -1
  342. package/src/plugin.ts +51 -2
  343. package/src/postgres/access/acl.ts +1 -1
  344. package/src/postgres/access/import.ts +1 -1
  345. package/src/postgres/apply/apply.ts +14 -1
  346. package/src/postgres/apply/statements.ts +225 -4
  347. package/src/postgres/constraint-names.ts +71 -0
  348. package/src/postgres/database-kinds.test.ts +865 -0
  349. package/src/postgres/entities.ts +541 -5
  350. package/src/postgres/entity-types.ts +10 -0
  351. package/src/postgres/import/generator.ts +54 -2
  352. package/src/postgres/import/live-export.ts +1 -0
  353. package/src/postgres/live/catalog-kinds.ts +494 -0
  354. package/src/postgres/live/catalog.ts +6 -1
  355. package/src/postgres/live/database-kinds.e2e.test.ts +164 -0
  356. package/src/postgres/live/describe-resources.ts +13 -2
  357. package/src/postgres/live/undeclarable.e2e.test.ts +13 -6
  358. package/src/postgres/migrate/backfill.ts +13 -1
  359. package/src/postgres/migrate/carry.ts +103 -8
  360. package/src/postgres/migrate/followups.e2e.test.ts +218 -0
  361. package/src/postgres/migrate/followups.test.ts +199 -0
  362. package/src/postgres/migrate/kinds.e2e.test.ts +5 -5
  363. package/src/postgres/migrate/observe.ts +56 -15
  364. package/src/postgres/migrate/op.ts +28 -1
  365. package/src/postgres/migrate/partition.e2e.test.ts +129 -0
  366. package/src/postgres/migrate/partition.test.ts +206 -0
  367. package/src/postgres/migrate/replication.ts +66 -0
  368. package/src/postgres/migrate/steps.ts +157 -12
  369. package/src/postgres/migrate/subscriber.e2e.test.ts +141 -0
  370. package/src/postgres/migrate/subscriber.test.ts +59 -0
  371. package/src/postgres/parser.ts +455 -4
  372. package/src/postgres/plan/deep.ts +42 -4
  373. package/src/postgres/plan/diff.ts +137 -1
  374. package/src/postgres/plan/normalize.ts +277 -1
  375. package/src/postgres/plan/rules.ts +32 -4
  376. package/src/postgres/plan/schema.ts +1 -1
  377. package/src/postgres/plan/statements.test.ts +78 -0
  378. package/src/postgres/plan/statements.ts +299 -0
  379. package/src/postgres/secret-options.ts +10 -0
  380. package/src/postgres/testing/roundtrip-schema.ts +24 -1
  381. package/src/postgres.ts +33 -0
  382. package/src/serializer.ts +13 -2
  383. package/src/skills/chant-sql-postgres-migration.md +4 -3
  384. package/src/skills/chant-sql-postgres.md +11 -1
  385. package/src/skills/chant-sql.md +2 -1
  386. package/src/sql-files.test.ts +33 -0
  387. package/src/sql-types-command.ts +38 -16
  388. package/src/sql-types.ts +11 -6
  389. package/src/sqlserver/fixture.test.ts +186 -0
  390. package/src/sqlserver/import-cli.test.ts +61 -0
  391. package/src/sqlserver/lexer.ts +260 -0
  392. package/src/sqlserver/parse.ts +865 -0
  393. package/src/sqlserver/translate.test.ts +396 -0
  394. package/src/sqlserver/translate.ts +558 -0
  395. package/src/sqlserver/types.test.ts +102 -0
  396. package/src/sqlserver/types.ts +154 -0
  397. package/src/sqlserver/validate.e2e.test.ts +32 -0
  398. package/src/sqlserver/validate.test.ts +49 -0
  399. package/src/sqlserver/validate.ts +76 -0
  400. package/src/validate.ts +10 -0
  401. package/src/workspace-kinds.test.ts +11 -0
  402. package/workspace-kinds.json +100 -2
@@ -0,0 +1,865 @@
1
+ /**
2
+ * Extended statistics, foreign data wrappers, foreign servers and
3
+ * publications (#3911), and foreign tables, operator classes and families, and
4
+ * text search configurations and dictionaries (#4406): what the tags parse and
5
+ * refuse, how a change to each is classified and made, how the catalog reader
6
+ * prints each, and the round trip: declare, build, import, plan with no change.
7
+ */
8
+
9
+ import { describe, expect, test } from "vitest";
10
+ import { foreignDataWrapper, foreignServer, foreignTable, func, operator, operatorClass, operatorFamily, publication, schema, SqlTemplateError, statistics, table, textSearchConfiguration, textSearchDictionary } from "./entities";
11
+ import { diffObject, keyedByQualifiedName, pgSchemaFromLive, type PgSchemaObject } from "./plan/schema";
12
+ import { diffPgSchemas, matchPgObjects } from "./plan/diff";
13
+ import { alterSteps, dropStatement } from "./apply/statements";
14
+ import { declaredObjects } from "./apply/apply";
15
+ import { readLiveSchema, type LivePgObject } from "./live/catalog";
16
+ import { parseDictionaryOptions, readDatabaseKinds, readOperators } from "./live/catalog-kinds";
17
+ import type { PostgresClient } from "./live/client";
18
+ import { objectsToIR, type ImportedPgObject } from "./import/ir";
19
+ import { PostgresGenerator, postgresTemplates } from "./import/generator";
20
+ import { canonicalPgObject } from "./plan/normalize";
21
+ import { liveProps } from "./plan/deep";
22
+ import { fakeClient } from "./testing/fake-client";
23
+ import { sqlSerializer } from "../serializer";
24
+ import type { SerializerResult } from "@intentius/chant/serializer";
25
+ import { kindRow } from "../coverage";
26
+
27
+ const app = schema`CREATE SCHEMA app`;
28
+ const props = (o: { props: unknown }) => o.props as Record<string, unknown>;
29
+
30
+ describe("the tags", () => {
31
+ test("operator: a symbol in a schema, identified by its operand types", () => {
32
+ const eq = func`CREATE FUNCTION ${app}.pair_eq(a int, b int) RETURNS boolean LANGUAGE sql AS 'SELECT a = b'`;
33
+ const op = operator`CREATE OPERATOR ${app}.=== (FUNCTION = ${eq}, LEFTARG = int, RIGHTARG = int, COMMUTATOR = ===, NEGATOR = OPERATOR(app.!==), RESTRICT = eqsel, JOIN = eqjoinsel, HASHES); COMMENT ON OPERATOR ${app}.===(int, int) IS 'Same pair'`;
34
+ expect(op.entityType).toBe("Postgres::Operator");
35
+ expect(op.sqlName).toBe("app.===");
36
+ expect(op.dependsOn).toContain(eq);
37
+ expect(op.props).toMatchObject({ schema: "app", name: "===", leftArg: "int", rightArg: "int", comment: "Same pair" });
38
+ const c = canonicalPgObject(op.entityType, props(op));
39
+ expect(c.signature).toBe("(integer,integer)");
40
+ expect(c.fields.definition).toEqual({ commutator: "app.===", function: "app.pair_eq", hashes: "true", negator: "app.!==", restrict: "eqsel", join: "eqjoinsel" });
41
+ const prefix = operator`CREATE OPERATOR app.@@ (PROCEDURE = app.abs_of, RIGHTARG = int)`;
42
+ expect(canonicalPgObject(prefix.entityType, props(prefix))).toMatchObject({ name: "@@", signature: "(NONE,integer)", fields: { definition: { function: "app.abs_of" } } });
43
+ });
44
+
45
+ test("statistics: a table's columns and expressions, the kinds the server gathers", () => {
46
+ const orders = table`CREATE TABLE ${app}.orders (id int, status text, total numeric)`;
47
+ const s = statistics`CREATE STATISTICS ${app}.orders_stats (dependencies, ndistinct) ON status, id, (total * 2) FROM ${orders}; COMMENT ON STATISTICS ${app}.orders_stats IS 'Correlated'`;
48
+ expect(s.entityType).toBe("Postgres::Statistics");
49
+ expect(s.dependsOn).toContain(orders);
50
+ expect(s.props).toMatchObject({ schema: "app", name: "orders_stats", kinds: ["dependencies", "ndistinct"], tableName: "app.orders", comment: "Correlated" });
51
+ // Sorted, and the kinds the server builds when none are written are no list at all.
52
+ const c = canonicalPgObject(s.entityType, props(s));
53
+ expect(c.fields).toMatchObject({ table: "app.orders", kinds: ["dependencies", "ndistinct"], targets: ["id", "status", "total * 2"] });
54
+ const all = statistics`CREATE STATISTICS app.s ON a, b FROM app.t`;
55
+ const listed = statistics`CREATE STATISTICS app.s (mcv, ndistinct, dependencies) ON b, a FROM app.t`;
56
+ expect(canonicalPgObject(all.entityType, props(all)).fields).toEqual(canonicalPgObject(listed.entityType, props(listed)).fields);
57
+ const lone = statistics`CREATE STATISTICS app.e ON (lower(email)) FROM app.t`;
58
+ expect(canonicalPgObject(lone.entityType, props(lone)).fields.kinds).toEqual(["none"]);
59
+ });
60
+
61
+ test("a foreign data wrapper and a foreign server, the database's and without a schema", () => {
62
+ const handler = func`CREATE FUNCTION ${app}.fdw_handler() RETURNS fdw_handler LANGUAGE c AS 'x', 'y'`;
63
+ const fdw = foreignDataWrapper`CREATE FOREIGN DATA WRAPPER app_fdw HANDLER ${handler} NO VALIDATOR OPTIONS (debug 'true'); COMMENT ON FOREIGN DATA WRAPPER app_fdw IS 'Remote'`;
64
+ expect(fdw.entityType).toBe("Postgres::ForeignDataWrapper");
65
+ expect(fdw.sqlName).toBe("app_fdw");
66
+ expect(fdw.dependsOn).toContain(handler);
67
+ expect(fdw.props).toMatchObject({ name: "app_fdw", handlerName: "app.fdw_handler", noValidator: true, options: { debug: "true" }, comment: "Remote" });
68
+ expect(canonicalPgObject(fdw.entityType, props(fdw))).toMatchObject({ schema: undefined, fields: { handler: "app.fdw_handler", options: { debug: "true" } } });
69
+ expect(canonicalPgObject(fdw.entityType, props(fdw)).fields).not.toHaveProperty("validator");
70
+
71
+ const server = foreignServer`CREATE SERVER IF NOT EXISTS app_remote TYPE 'pg' VERSION '16' FOREIGN DATA WRAPPER ${fdw} OPTIONS (host 'db.internal', port '5432'); COMMENT ON SERVER app_remote IS 'The replica'`;
72
+ expect(server.dependsOn).toContain(fdw);
73
+ expect(server.props).toMatchObject({ name: "app_remote", serverType: "pg", version: "16", wrapperName: "app_fdw", options: { host: "db.internal", port: "5432" }, comment: "The replica" });
74
+ expect(canonicalPgObject(server.entityType, props(server)).fields).toMatchObject({ serverType: "pg", version: "16", wrapper: "app_fdw", options: { host: "db.internal", port: "5432" } });
75
+ });
76
+
77
+ test("a publication: all tables, or tables with column lists and filters, or schemas, and its options", () => {
78
+ const orders = table`CREATE TABLE ${app}.orders (id int, status text)`;
79
+ const audit = schema`CREATE SCHEMA audit`;
80
+ const p = publication`CREATE PUBLICATION app_pub FOR TABLE ONLY ${orders} (status, id) WHERE (id > 0), app.users, TABLES IN SCHEMA ${audit} WITH (publish = 'update, insert', publish_via_partition_root = on)`;
81
+ expect(p.entityType).toBe("Postgres::Publication");
82
+ expect(p.dependsOn).toEqual(expect.arrayContaining([orders, audit]));
83
+ expect(canonicalPgObject(p.entityType, props(p)).fields).toMatchObject({
84
+ tables: [{ table: "app.orders", columns: ["id", "status"], where: "id > 0" }, { table: "app.users" }],
85
+ schemas: ["audit"],
86
+ publish: ["insert", "update"],
87
+ viaRoot: true,
88
+ });
89
+ const all = publication`CREATE PUBLICATION everything FOR ALL TABLES WITH (publish = 'insert, update, delete, truncate')`;
90
+ expect(canonicalPgObject(all.entityType, props(all)).fields).toEqual({ allTables: true });
91
+ expect(canonicalPgObject(all.entityType, props(all)).fields.publish).toBeUndefined();
92
+ const empty = publication`CREATE PUBLICATION nothing`;
93
+ expect(canonicalPgObject(empty.entityType, props(empty)).fields).not.toHaveProperty("tables");
94
+ });
95
+
96
+ test("a foreign table: its columns with options, CHECK constraints, server and options", () => {
97
+ const replica = foreignServer`CREATE SERVER app_replica FOREIGN DATA WRAPPER app_fdw`;
98
+ const ft = foreignTable`CREATE FOREIGN TABLE ${app}.remote_orders (id int OPTIONS (column_name 'order_id') NOT NULL, status text DEFAULT 'placed', CONSTRAINT status_known CHECK (status IS NOT NULL)) SERVER ${replica} OPTIONS (table_name 'orders'); COMMENT ON FOREIGN TABLE ${app}.remote_orders IS 'Orders'; COMMENT ON COLUMN ${app}.remote_orders.id IS 'The key'`;
99
+ expect(ft.entityType).toBe("Postgres::ForeignTable");
100
+ expect(ft.sqlName).toBe("app.remote_orders");
101
+ expect(ft.dependsOn).toEqual(expect.arrayContaining([app, replica]));
102
+ expect(ft.props).toMatchObject({ schema: "app", name: "remote_orders", serverName: "app_replica", options: { table_name: "orders" }, comment: "Orders" });
103
+ expect(ft.props.columns[0]).toMatchObject({ name: "id", type: "int", notNull: true, options: { column_name: "order_id" }, comment: "The key" });
104
+ expect((ft as unknown as { columns: Record<string, unknown> }).columns).toHaveProperty("status");
105
+ const c = canonicalPgObject(ft.entityType, props(ft));
106
+ expect(c.kind).toBe("foreignTable");
107
+ expect(c.schema).toBe("app");
108
+ expect(c.fields).toMatchObject({ server: "app_replica", options: { table_name: "orders" } });
109
+ expect(c.columns.map((x) => [x.name, x.type, x.notNull, x.default])).toEqual([["id", "integer", true, undefined], ["status", "text", false, "'placed'"]]);
110
+ expect(c.columns[0]!.options).toEqual({ column_name: "order_id" });
111
+ expect(c.constraints).toEqual([expect.objectContaining({ kind: "CHECK", name: "status_known", body: "check status is not null" })]);
112
+ });
113
+
114
+ test("operator classes and families: identified by their index method", () => {
115
+ const fam = operatorFamily`CREATE OPERATOR FAMILY ${app}.ints USING btree; COMMENT ON OPERATOR FAMILY ${app}.ints USING btree IS 'Integers'`;
116
+ expect(fam.entityType).toBe("Postgres::OperatorFamily");
117
+ expect(fam.props).toMatchObject({ schema: "app", name: "ints", method: "btree", comment: "Integers" });
118
+ expect(canonicalPgObject(fam.entityType, props(fam))).toMatchObject({ kind: "operatorFamily", schema: "app", name: "ints", signature: " USING btree" });
119
+ const cmp = func`CREATE FUNCTION ${app}.int_cmp(a int, b int) RETURNS int LANGUAGE sql AS 'SELECT 0'`;
120
+ const oc = operatorClass`CREATE OPERATOR CLASS ${app}.int_ops DEFAULT FOR TYPE int USING btree FAMILY ${fam} AS OPERATOR 1 <, OPERATOR 3 pg_catalog.= (int, int), FUNCTION 1 ${cmp}(int, int), OPERATOR 5 > FOR SEARCH`;
121
+ expect(oc.entityType).toBe("Postgres::OperatorClass");
122
+ expect(oc.sqlName).toBe("app.int_ops");
123
+ expect(oc.dependsOn).toEqual(expect.arrayContaining([fam, cmp]));
124
+ expect(oc.props).toMatchObject({ schema: "app", name: "int_ops", default: true, dataType: "int", method: "btree", family: "app.ints" });
125
+ const c = canonicalPgObject(oc.entityType, props(oc));
126
+ expect(c.signature).toBe(" USING btree");
127
+ // Operand types default to the class's type; a bare operator is pg_catalog's or the default schema's; the family is the class's own unless another is named.
128
+ expect(c.fields).toMatchObject({
129
+ default: true,
130
+ dataType: "integer",
131
+ family: "app.ints",
132
+ items: ["operator 1 < (integer,integer)", "operator 3 = (integer,integer)", "operator 5 > (integer,integer)", "function 1 (integer,integer) app.int_cmp(integer,integer)"],
133
+ });
134
+ // The server's printing of a class in a family of its own name is the declaration with no FAMILY.
135
+ const own = operatorClass`CREATE OPERATOR CLASS app.hash_ops FOR TYPE int USING hash AS OPERATOR 1 =, FUNCTION 1 hashint4(int)`;
136
+ const named = operatorClass`CREATE OPERATOR CLASS app.hash_ops FOR TYPE integer USING hash FAMILY app.hash_ops AS OPERATOR 1 pg_catalog.= (integer, integer), FUNCTION 1 (integer, integer) hashint4(integer)`;
137
+ expect(canonicalPgObject(own.entityType, props(own)).fields).toEqual(canonicalPgObject(named.entityType, props(named)).fields);
138
+ expect(canonicalPgObject(own.entityType, props(own)).fields).not.toHaveProperty("family");
139
+ // An ordering operator names the btree family it sorts by; a prefix operator's missing side is NONE; STORAGE is the stored type.
140
+ const gist = operatorClass`CREATE OPERATOR CLASS app.g FOR TYPE point USING gist AS OPERATOR 1 <-> (point, point) FOR ORDER BY pg_catalog.float_ops, OPERATOR 2 @@ (NONE, point), STORAGE box`;
141
+ expect(canonicalPgObject(gist.entityType, props(gist)).fields.items).toEqual(["operator 1 <-> (point,point) for order by float_ops", "operator 2 @@ (NONE,point)", "storage box"]);
142
+ });
143
+
144
+ test("a text search dictionary and configuration: options, a parser and the mappings after the CREATE", () => {
145
+ const dict = textSearchDictionary`CREATE TEXT SEARCH DICTIONARY ${app}.shop_stop (TEMPLATE = simple, StopWords = english, Accept = false); COMMENT ON TEXT SEARCH DICTIONARY ${app}.shop_stop IS 'Stop words'`;
146
+ expect(dict.entityType).toBe("Postgres::TextSearchDictionary");
147
+ expect(dict.props).toMatchObject({ schema: "app", name: "shop_stop", template: "simple", options: { stopwords: "english", accept: "false" }, comment: "Stop words" });
148
+ expect(canonicalPgObject(dict.entityType, props(dict)).fields).toMatchObject({ template: "simple", options: { accept: "false", stopwords: "english" } });
149
+ // A quoted value is the same option: the server keeps every value as a string.
150
+ const quoted = textSearchDictionary`CREATE TEXT SEARCH DICTIONARY app.shop_stop (template = pg_catalog.simple, accept = 'false', stopwords = 'english')`;
151
+ expect(canonicalPgObject(quoted.entityType, props(quoted)).fields).toEqual({ template: "simple", options: { accept: "false", stopwords: "english" } });
152
+
153
+ const cfg = textSearchConfiguration`CREATE TEXT SEARCH CONFIGURATION ${app}.shop (PARSER = pg_catalog."default");
154
+ ALTER TEXT SEARCH CONFIGURATION ${app}.shop ADD MAPPING FOR asciiword, word WITH ${dict}, english_stem;
155
+ ALTER TEXT SEARCH CONFIGURATION ${app}.shop ADD MAPPING FOR int WITH simple;
156
+ COMMENT ON TEXT SEARCH CONFIGURATION ${app}.shop IS 'Shop search'`;
157
+ expect(cfg.entityType).toBe("Postgres::TextSearchConfiguration");
158
+ expect(cfg.dependsOn).toEqual(expect.arrayContaining([app, dict]));
159
+ expect(cfg.props).toMatchObject({ schema: "app", name: "shop", parser: 'pg_catalog."default"', comment: "Shop search" });
160
+ expect(cfg.props.mappings).toEqual([
161
+ { tokens: ["asciiword", "word"], dictionaries: ["app.shop_stop", "english_stem"] },
162
+ { tokens: ["int"], dictionaries: ["simple"] },
163
+ ]);
164
+ expect(canonicalPgObject(cfg.entityType, props(cfg)).fields).toMatchObject({ parser: '"default"', mappings: { asciiword: ["shop_stop", "english_stem"].map((n, i) => (i === 0 ? "app.shop_stop" : n)), int: ["simple"], word: ["app.shop_stop", "english_stem"] } });
165
+ });
166
+
167
+ test("what is refused", () => {
168
+ expect(() => operator`CREATE OPERATOR app.=== (LEFTARG = int, RIGHTARG = int)`).toThrow(/FUNCTION/);
169
+ expect(() => operator`CREATE OPERATOR app.=== (FUNCTION = f, LEFTARG = int, MERGE = true)`).toThrow(/an option of an operator/);
170
+ expect(() => operator`CREATE OPERATOR CLASS c FOR TYPE int USING btree AS OPERATOR 1 <`).toThrow(/use the operatorClass tag/);
171
+ expect(() => operator`CREATE OPERATOR FAMILY f USING btree`).toThrow(/use the operatorFamily tag/);
172
+ expect(() => table`CREATE FOREIGN TABLE app.t (id int) SERVER s`).toThrow(/use the foreignTable tag/);
173
+ expect(() => operator`CREATE OPERATOR app.eq (FUNCTION = f)`).toThrow(/operator's symbol/);
174
+ expect(() => foreignServer`CREATE SERVER s FOREIGN DATA WRAPPER w OPTIONS (host 'x', password 'hunter2')`).toThrow(/holds a credential/);
175
+ expect(() => foreignDataWrapper`CREATE FOREIGN DATA WRAPPER w OPTIONS (api_key 'x')`).toThrow(SqlTemplateError);
176
+ expect(() => foreignServer`CREATE SERVER s OPTIONS (host 'x')`).toThrow(/FOREIGN DATA WRAPPER/);
177
+ expect(() => foreignServer`CREATE SERVER app.s FOREIGN DATA WRAPPER w`).toThrow(/without a schema/);
178
+ expect(() => foreignServer`CREATE SERVER s FOREIGN DATA WRAPPER w OPTIONS (host 'a', host 'b')`).toThrow(/given twice/);
179
+ expect(() => publication`CREATE PUBLICATION p WITH (publich = 'insert')`).toThrow(/an option of a publication/);
180
+ expect(() => publication`CREATE PUBLICATION p FOR`).toThrow();
181
+ expect(() => statistics`CREATE STATISTICS app.s (histogram) ON a, b FROM app.t`).toThrow(/statistics kind/);
182
+ expect(() => statistics`CREATE STATISTICS app.s ON a, b`).toThrow(/FROM/);
183
+ expect(() => statistics`CREATE USER MAPPING FOR u SERVER s`).toThrow(SqlTemplateError);
184
+ expect(() => publication`CREATE STATISTICS app.s ON a, b FROM app.t`).toThrow(/use the statistics tag/);
185
+ // #4406: what a foreign table, an operator class and a text search object refuse.
186
+ expect(() => foreignTable`CREATE FOREIGN TABLE app.t (id int PRIMARY KEY) SERVER s`).toThrow(/CHECK or NOT NULL, not PRIMARY KEY/);
187
+ expect(() => foreignTable`CREATE FOREIGN TABLE app.t (id int, UNIQUE (id)) SERVER s`).toThrow(/CHECK or NOT NULL, not UNIQUE/);
188
+ expect(() => foreignTable`CREATE FOREIGN TABLE app.t (id int, total int GENERATED ALWAYS AS (id * 2) STORED) SERVER s`).toThrow(/generated column/);
189
+ expect(() => foreignTable`CREATE FOREIGN TABLE app.t (id int, CHECK (id > 0) NOT ENFORCED) SERVER s`).toThrow(/NOT ENFORCED/);
190
+ expect(() => foreignTable`CREATE FOREIGN TABLE app.t (id int) INHERITS (app.p) SERVER s`).toThrow(/INHERITS/);
191
+ expect(() => foreignTable`CREATE FOREIGN TABLE app.t PARTITION OF app.p FOR VALUES IN (1) SERVER s`).toThrow(/PARTITION OF/);
192
+ expect(() => foreignTable`CREATE FOREIGN TABLE app.t (LIKE app.p) SERVER s`).toThrow(/LIKE/);
193
+ expect(() => foreignTable`CREATE FOREIGN TABLE app.t (id int)`).toThrow(/SERVER/);
194
+ expect(() => foreignTable`CREATE FOREIGN TABLE app.t (id int) SERVER s OPTIONS (password 'x')`).toThrow(/holds a credential/);
195
+ expect(() => foreignTable`CREATE FOREIGN TABLE app.t (id int OPTIONS (api_key 'x')) SERVER s`).toThrow(/holds a credential/);
196
+ expect(() => operatorClass`CREATE OPERATOR CLASS app.c FOR TYPE int USING btree AS`).toThrow();
197
+ expect(() => operatorClass`CREATE OPERATOR CLASS app.c FOR TYPE int USING btree AS OPERATOR x <`).toThrow(/strategy or support number/);
198
+ expect(() => operatorClass`CREATE OPERATOR CLASS app.c FOR TYPE int USING btree AS OPERATOR 1 < FOR EVERYTHING`).toThrow(/SEARCH or ORDER BY/);
199
+ expect(() => operatorClass`CREATE OPERATOR CLASS app.c FOR TYPE int USING btree AS FUNCTION 1 f`).toThrow(/argument types/);
200
+ expect(() => operatorClass`CREATE OPERATOR CLASS app.c FOR TYPE int USING btree AS OPERATOR 1 <; COMMENT ON OPERATOR CLASS app.c USING hash IS 'x'`).toThrow(/another index method/);
201
+ expect(() => operatorFamily`CREATE OPERATOR FAMILY app.f`).toThrow(/USING/);
202
+ expect(() => textSearchConfiguration`CREATE TEXT SEARCH CONFIGURATION app.c (COPY = english)`).toThrow(/COPY copies mappings/);
203
+ expect(() => textSearchConfiguration`CREATE TEXT SEARCH CONFIGURATION app.c ()`).toThrow(/PARSER/);
204
+ expect(() => textSearchConfiguration`CREATE TEXT SEARCH CONFIGURATION app.c (PARSER = p); ALTER TEXT SEARCH CONFIGURATION app.other ADD MAPPING FOR word WITH simple`).toThrow(/another configuration/);
205
+ expect(() => textSearchConfiguration`CREATE TEXT SEARCH CONFIGURATION app.c (PARSER = p); ALTER TEXT SEARCH CONFIGURATION app.c ADD MAPPING FOR word WITH simple; ALTER TEXT SEARCH CONFIGURATION app.c ADD MAPPING FOR word WITH english_stem`).toThrow(/mapped twice/);
206
+ expect(() => textSearchConfiguration`CREATE TEXT SEARCH CONFIGURATION app.c (PARSER = p); ALTER TEXT SEARCH CONFIGURATION app.c DROP MAPPING FOR word`).toThrow(/ADD MAPPING/);
207
+ expect(() => textSearchConfiguration`ALTER TEXT SEARCH CONFIGURATION app.c ADD MAPPING FOR word WITH simple`).toThrow(/before any CREATE/);
208
+ expect(() => textSearchDictionary`CREATE TEXT SEARCH DICTIONARY app.d (StopWords = english)`).toThrow(/TEMPLATE/);
209
+ expect(() => textSearchDictionary`CREATE TEXT SEARCH DICTIONARY app.d (TEMPLATE = simple, TEMPLATE = snowball)`).toThrow(/given twice/);
210
+ expect(() => textSearchDictionary`CREATE TEXT SEARCH PARSER app.p (START = f)`).toThrow(/DICTIONARY or CONFIGURATION/);
211
+ expect(() => textSearchDictionary`CREATE TEXT SEARCH CONFIGURATION app.c (PARSER = p)`).toThrow(/use the textSearchConfiguration tag/);
212
+ });
213
+ });
214
+
215
+ // ── Classifying and making changes ─────────────────────────────────────
216
+
217
+ const live = (type: string, statement: string, extra: Partial<LivePgObject> = {}) => pgSchemaFromLive([{ type, statement, name: "", oid: "1", ...extra } as LivePgObject], "public");
218
+ const declared = (type: string, ddl: string): PgSchemaObject[] => keyedByQualifiedName([{ key: "x", canonical: { ...diffObject(type, ddl, "public"), exportName: "x" } }]);
219
+ const rules = (type: string, before: string, after: string, extra: Partial<LivePgObject> = {}, major?: number) =>
220
+ diffPgSchemas(live(type, before, extra), declared(type, after), major ? { major } : {}).changes.map((c) => [c.field, c.rule]);
221
+
222
+ function steps(type: string, liveDdl: string, declaredDdl: string, extra: Partial<LivePgObject> = {}, major = 18): string[] {
223
+ const [obj] = declaredObjects(JSON.stringify({ dialect: "postgres", objects: [{ export: "x", type, ddl: declaredDdl, dependsOn: [] }] }));
224
+ const after = keyedByQualifiedName([{ key: "x", canonical: obj!.canonical }]);
225
+ const before = live(type, liveDdl, extra);
226
+ const m = matchPgObjects(before, after).find((x) => x.kind === "matched")!;
227
+ return alterSteps(obj!, m.before!.canonical, diffPgSchemas(before, after, { major }).changes, { major }).steps.map((s) => s.sql);
228
+ }
229
+
230
+ const OPR = "Postgres::Operator";
231
+ const STAT = "Postgres::Statistics";
232
+ const FDW = "Postgres::ForeignDataWrapper";
233
+ const SRV = "Postgres::ForeignServer";
234
+ const PUB = "Postgres::Publication";
235
+ const OPC = "Postgres::OperatorClass";
236
+ const OPF = "Postgres::OperatorFamily";
237
+ const FTB = "Postgres::ForeignTable";
238
+ const TSD = "Postgres::TextSearchDictionary";
239
+ const TSC = "Postgres::TextSearchConfiguration";
240
+
241
+ describe("the classifier and the statements", () => {
242
+ test("the server's printing is the declaration's", () => {
243
+ expect(rules(STAT, "CREATE STATISTICS app.s ON a, b FROM app.t", "CREATE STATISTICS app.s (ndistinct, dependencies, mcv) ON b, a FROM app.t")).toEqual([]);
244
+ expect(rules(STAT, "CREATE STATISTICS app.s (dependencies) ON a, (b + 1) FROM app.t", "CREATE STATISTICS app.s (dependencies) ON (b + 1), a FROM app.t")).toEqual([]);
245
+ expect(rules(FDW, "CREATE FOREIGN DATA WRAPPER w HANDLER app.h OPTIONS (a '1', b '2')", "CREATE FOREIGN DATA WRAPPER w HANDLER app.h NO VALIDATOR OPTIONS (b '2', a '1')")).toEqual([]);
246
+ expect(rules(SRV, "CREATE SERVER s TYPE 'pg' FOREIGN DATA WRAPPER w OPTIONS (host 'h')", "CREATE SERVER s TYPE 'pg' FOREIGN DATA WRAPPER w OPTIONS (host 'h')")).toEqual([]);
247
+ expect(
248
+ rules(PUB, "CREATE PUBLICATION p FOR TABLE app.a (id), app.b, TABLES IN SCHEMA s WITH (publish = 'insert, update')", "CREATE PUBLICATION p FOR TABLE app.b, app.a (id), TABLES IN SCHEMA s WITH (publish = 'update,insert')"),
249
+ ).toEqual([]);
250
+ // Options at their default are no change: the publication publishes every action, to its own tables, unless told otherwise.
251
+ expect(rules(PUB, "CREATE PUBLICATION p FOR ALL TABLES", "CREATE PUBLICATION p FOR ALL TABLES WITH (publish = 'insert, update, delete, truncate', publish_via_partition_root = false)")).toEqual([]);
252
+ });
253
+
254
+ test("an operator is dropped and created: it has no ALTER for its function or operands", () => {
255
+ const O1 = "CREATE OPERATOR app.=== (FUNCTION = app.pair_eq, LEFTARG = integer, RIGHTARG = integer, COMMUTATOR = OPERATOR(app.===), RESTRICT = eqsel)";
256
+ expect(rules(OPR, O1, "CREATE OPERATOR app.=== (LEFTARG = int, RIGHTARG = int, RESTRICT = eqsel, COMMUTATOR = ===, FUNCTION = app.pair_eq)")).toEqual([]);
257
+ // The function is not an attribute ALTER OPERATOR reaches, so a change of it is a drop and a create.
258
+ const F2 = O1.replace("app.pair_eq", "app.pair_eq2");
259
+ expect(rules(OPR, O1, F2)).toEqual([["definition", "SQLPG300"]]);
260
+ expect(rules(OPR, O1, F2, { dependents: ["operator class app.c for access method btree"] })).toEqual([["definition", "SQLPG301"]]);
261
+ const recreate = steps(OPR, O1, F2);
262
+ expect(recreate[0]).toBe("DROP OPERATOR app.===(integer,integer)");
263
+ expect(recreate[1]).toContain("pair_eq2");
264
+ expect(recreate[2]).toMatch(/^COMMENT ON OPERATOR app\.===\(integer,integer\) IS /);
265
+ // Another operand type is another operator, not a change.
266
+ expect(dropStatement("operator", "app", "===", "(NONE,integer)").sql).toBe("DROP OPERATOR app.===(NONE,integer)");
267
+ });
268
+
269
+ test("statistics are dropped and created: they have no ALTER for their definition", () => {
270
+ const S1 = "CREATE STATISTICS app.s ON a, b FROM app.t";
271
+ const S2 = "CREATE STATISTICS app.s (dependencies) ON a, b FROM app.t";
272
+ expect(rules(STAT, S1, S2)).toEqual([["kinds", "SQLPG300"]]);
273
+ expect(rules(STAT, S1, "CREATE STATISTICS app.s ON a, c FROM app.t")).toEqual([["targets", "SQLPG300"]]);
274
+ expect(rules(STAT, S1, "CREATE STATISTICS app.s ON a, b FROM app.u")).toEqual([["table", "SQLPG300"]]);
275
+ const recreate = steps(STAT, S1, S2);
276
+ expect(recreate[0]).toBe("DROP STATISTICS app.s");
277
+ expect(recreate[1]).toBe(S2);
278
+ expect(recreate[2]).toMatch(/^COMMENT ON STATISTICS app\.s IS /);
279
+ expect(dropStatement("statistics", "app", "s").sql).toBe("DROP STATISTICS app.s");
280
+ });
281
+
282
+ test("a wrapper is altered in place", () => {
283
+ const W1 = "CREATE FOREIGN DATA WRAPPER w HANDLER app.h OPTIONS (a '1', b '2')";
284
+ const W2 = "CREATE FOREIGN DATA WRAPPER w NO HANDLER VALIDATOR app.v OPTIONS (a '9', c '3')";
285
+ expect(rules(FDW, W1, W2)).toEqual([
286
+ ["handler", "SQLPG303"],
287
+ ["validator", "SQLPG303"],
288
+ ["options", "SQLPG303"],
289
+ ]);
290
+ expect(steps(FDW, W1, W2)).toEqual(["ALTER FOREIGN DATA WRAPPER w NO HANDLER VALIDATOR app.v OPTIONS (ADD c '3', SET a '9', DROP b)"]);
291
+ expect(dropStatement("foreignDataWrapper", undefined, "w").sql).toBe("DROP FOREIGN DATA WRAPPER w");
292
+ });
293
+
294
+ test("a server's version and options are altered in place; its type and wrapper are not", () => {
295
+ const V1 = "CREATE SERVER s TYPE 'pg' VERSION '15' FOREIGN DATA WRAPPER w OPTIONS (host 'a')";
296
+ expect(rules(SRV, V1, V1.replace("'15'", "'16'").replace("'a'", "'b'"))).toEqual([
297
+ ["version", "SQLPG303"],
298
+ ["options", "SQLPG303"],
299
+ ]);
300
+ expect(steps(SRV, V1, V1.replace("VERSION '15' ", "").replace("'a'", "'b'"))).toEqual(["ALTER SERVER s VERSION NULL OPTIONS (SET host 'b')"]);
301
+ expect(rules(SRV, V1, V1.replace("w OPTIONS", "w2 OPTIONS"))).toEqual([["wrapper", "SQLPG300"]]);
302
+ expect(rules(SRV, V1, V1.replace("w OPTIONS", "w2 OPTIONS"), { dependents: ["foreign table app.remote_orders"] })).toEqual([["wrapper", "SQLPG301"]]);
303
+ const recreate = steps(SRV, V1, V1.replace("'pg'", "'mysql'"));
304
+ expect(recreate[0]).toBe("DROP SERVER s");
305
+ expect(recreate[1]).toMatch(/^CREATE SERVER s TYPE 'mysql'/);
306
+ });
307
+
308
+ test("a publication is altered in place: options, tables and schemas", () => {
309
+ const P1 = "CREATE PUBLICATION p FOR TABLE app.a (id), app.b, TABLES IN SCHEMA s1";
310
+ const P2 = "CREATE PUBLICATION p FOR TABLE app.a (id, name) WHERE (id > 0), app.c, TABLES IN SCHEMA s2 WITH (publish = 'insert', publish_via_partition_root = true)";
311
+ expect(rules(PUB, P1, P2).map(([f]) => f)).toEqual(["tables", "schemas", "publish", "viaRoot"]);
312
+ expect(steps(PUB, P1, P2)).toEqual([
313
+ "ALTER PUBLICATION p SET (publish = 'insert', publish_via_partition_root = true)",
314
+ "ALTER PUBLICATION p DROP TABLE app.a",
315
+ "ALTER PUBLICATION p DROP TABLE app.b",
316
+ "ALTER PUBLICATION p ADD TABLE app.a (id, name) WHERE (id > 0)",
317
+ "ALTER PUBLICATION p ADD TABLE app.c",
318
+ "ALTER PUBLICATION p DROP TABLES IN SCHEMA s1",
319
+ "ALTER PUBLICATION p ADD TABLES IN SCHEMA s2",
320
+ ]);
321
+ // Back to the defaults is written out: the server has no RESET for a publication's options.
322
+ expect(steps(PUB, "CREATE PUBLICATION p WITH (publish = 'insert', publish_via_partition_root = true)", "CREATE PUBLICATION p")).toEqual([
323
+ "ALTER PUBLICATION p SET (publish = 'insert, update, delete, truncate', publish_via_partition_root = false)",
324
+ ]);
325
+ // FOR ALL TABLES cannot be altered into or out of.
326
+ expect(rules(PUB, "CREATE PUBLICATION p FOR ALL TABLES", "CREATE PUBLICATION p FOR TABLE app.a")).toEqual([["allTables", "SQLPG300"]]);
327
+ expect(steps(PUB, "CREATE PUBLICATION p FOR ALL TABLES", "CREATE PUBLICATION p FOR TABLE app.a")[0]).toBe("DROP PUBLICATION p");
328
+ });
329
+
330
+ test("an operator's estimators, and from 17 its commutator, negator and flags, are altered in place", () => {
331
+ const O1 = "CREATE OPERATOR app.=== (FUNCTION = app.pair_eq, LEFTARG = integer, RIGHTARG = integer, COMMUTATOR = OPERATOR(app.===), RESTRICT = eqsel)";
332
+ const alter = "ALTER OPERATOR app.===(integer,integer) SET";
333
+ // RESTRICT and JOIN, set, changed or removed, on every major.
334
+ for (const major of [14, 16, 18]) {
335
+ expect(rules(OPR, O1, O1.replace("eqsel", "scalarltsel"), {}, major)).toEqual([["definition.restrict", "SQLPG304"]]);
336
+ expect(rules(OPR, O1, `${O1.slice(0, -1)}, JOIN = eqjoinsel)`, {}, major)).toEqual([["definition.join", "SQLPG304"]]);
337
+ }
338
+ expect(steps(OPR, O1, O1.replace("eqsel", "scalarltsel"))).toEqual([`${alter} (RESTRICT = scalarltsel)`]);
339
+ expect(steps(OPR, O1, O1.replace(", RESTRICT = eqsel", ""))).toEqual([`${alter} (RESTRICT = NONE)`]);
340
+ // Every attribute that changed is one statement; the object's dependents do not matter to it.
341
+ const both = `${O1.replace("eqsel", "scalarltsel").slice(0, -1)}, JOIN = scalarltjoinsel)`;
342
+ expect(rules(OPR, O1, both, { dependents: ["operator class app.c for access method btree"] })).toEqual([["definition.restrict", "SQLPG304"], ["definition.join", "SQLPG304"]]);
343
+ expect(steps(OPR, O1, both)).toEqual([`${alter} (RESTRICT = scalarltsel, JOIN = scalarltjoinsel)`]);
344
+ // 17 added COMMUTATOR, NEGATOR, HASHES and MERGES to ALTER OPERATOR, each only to turn it on or fill it in.
345
+ const negated = `${O1.slice(0, -1)}, NEGATOR = OPERATOR(app.!==), HASHES, MERGES)`;
346
+ for (const major of [17, 18]) {
347
+ expect(rules(OPR, O1, negated, {}, major)).toEqual([["definition.negator", "SQLPG304"], ["definition.hashes", "SQLPG304"], ["definition.merges", "SQLPG304"]]);
348
+ }
349
+ expect(steps(OPR, O1, negated)).toEqual([`${alter} (NEGATOR = OPERATOR(app.!==), HASHES, MERGES)`]);
350
+ expect(steps(OPR, "CREATE OPERATOR app.=== (FUNCTION = app.pair_eq, LEFTARG = integer, RIGHTARG = integer)", O1, {}, 17)).toEqual([`${alter} (RESTRICT = eqsel, COMMUTATOR = OPERATOR(app.===))`]);
351
+ // Before 17 only RESTRICT and JOIN are alterable, so the rest is a drop and a create.
352
+ for (const major of [14, 15, 16]) expect(rules(OPR, O1, negated, {}, major)).toEqual([["definition", "SQLPG300"]]);
353
+ // Never in place: a commutator that is already set, a flag turned off, the function or an operand.
354
+ const other = O1.replace("COMMUTATOR = OPERATOR(app.===)", "COMMUTATOR = OPERATOR(app.====)");
355
+ expect(rules(OPR, O1, other)).toEqual([["definition", "SQLPG300"]]);
356
+ expect(rules(OPR, O1, O1.replace(", COMMUTATOR = OPERATOR(app.===)", ""))).toEqual([["definition", "SQLPG300"]]);
357
+ expect(rules(OPR, `${O1.slice(0, -1)}, HASHES)`, O1)).toEqual([["definition", "SQLPG300"]]);
358
+ expect(rules(OPR, O1, `${O1.replace("eqsel", "scalarltsel").slice(0, -1)}, HASHES)`.replace("app.pair_eq", "app.other_eq"))).toEqual([["definition", "SQLPG300"]]);
359
+ // A comment still changes in place beside them.
360
+ expect(rules(OPR, O1, `${O1}; COMMENT ON OPERATOR app.===(integer, integer) IS 'x'`)).toEqual([["comment", "SQLPG216"]]);
361
+ });
362
+
363
+ test("a text search dictionary's options change in place; its template does not", () => {
364
+ const D1 = "CREATE TEXT SEARCH DICTIONARY app.d (TEMPLATE = simple, StopWords = english)";
365
+ expect(rules(TSD, D1.replace("simple", "pg_catalog.simple").replace("StopWords", "stopwords").replace("english", "'english'"), D1)).toEqual([]);
366
+ const D2 = "CREATE TEXT SEARCH DICTIONARY app.d (TEMPLATE = simple, Accept = false)";
367
+ expect(rules(TSD, D1, D2)).toEqual([["options", "SQLPG305"]]);
368
+ // An option named with no value is removed.
369
+ expect(steps(TSD, D1, D2)).toEqual(["ALTER TEXT SEARCH DICTIONARY app.d (accept = 'false', stopwords)"]);
370
+ expect(steps(TSD, D1, D1.replace("english", "russian"))).toEqual(["ALTER TEXT SEARCH DICTIONARY app.d (stopwords = 'russian')"]);
371
+ const T2 = D1.replace("simple", "snowball");
372
+ expect(rules(TSD, D1, T2)).toEqual([["template", "SQLPG300"]]);
373
+ expect(rules(TSD, D1, T2, { dependents: ["text search configuration app.c"] })).toEqual([["template", "SQLPG301"]]);
374
+ const recreate = steps(TSD, D1, T2);
375
+ expect(recreate[0]).toBe("DROP TEXT SEARCH DICTIONARY app.d");
376
+ expect(recreate[1]).toBe(T2);
377
+ expect(recreate[2]).toMatch(/^COMMENT ON TEXT SEARCH DICTIONARY app\.d IS /);
378
+ expect(dropStatement("textSearchDictionary", "app", "d").sql).toBe("DROP TEXT SEARCH DICTIONARY app.d");
379
+ });
380
+
381
+ test("a text search configuration's mappings change in place; its parser does not", () => {
382
+ const add = (verb: string, tokens: string, dicts: string) => `ALTER TEXT SEARCH CONFIGURATION app.c ${verb} MAPPING FOR ${tokens} WITH ${dicts}`;
383
+ const C1 = `CREATE TEXT SEARCH CONFIGURATION app.c (PARSER = pg_catalog."default"); ${add("ADD", "asciiword, word", "english_stem")}; ${add("ADD", "int", "simple")}`;
384
+ // What the server prints: the parser without pg_catalog, the dictionaries as the catalog names them.
385
+ expect(rules(TSC, `CREATE TEXT SEARCH CONFIGURATION app.c (PARSER = "default");\n${add("ADD", "word, asciiword", "pg_catalog.english_stem")};\n${add("ADD", "int", "public.simple")}`, C1)).toEqual([]);
386
+ const C2 = `CREATE TEXT SEARCH CONFIGURATION app.c (PARSER = pg_catalog."default"); ${add("ADD", "asciiword", "english_stem")}; ${add("ADD", "word", "app.d, english_stem")}; ${add("ADD", "uint", "simple")}`;
387
+ expect(rules(TSC, C1, C2)).toEqual([["mappings", "SQLPG305"]]);
388
+ expect(steps(TSC, C1, C2)).toEqual([
389
+ 'ALTER TEXT SEARCH CONFIGURATION app.c DROP MAPPING FOR "int"',
390
+ "ALTER TEXT SEARCH CONFIGURATION app.c ALTER MAPPING FOR word WITH app.d, english_stem",
391
+ "ALTER TEXT SEARCH CONFIGURATION app.c ADD MAPPING FOR uint WITH simple",
392
+ ]);
393
+ // Token types that share their dictionaries share a statement.
394
+ expect(steps(TSC, "CREATE TEXT SEARCH CONFIGURATION app.c (PARSER = \"default\")", C1)).toEqual([
395
+ "ALTER TEXT SEARCH CONFIGURATION app.c ADD MAPPING FOR asciiword, word WITH english_stem",
396
+ 'ALTER TEXT SEARCH CONFIGURATION app.c ADD MAPPING FOR "int" WITH simple',
397
+ ]);
398
+ const P2 = C1.replace('pg_catalog."default"', "app.my_parser");
399
+ expect(rules(TSC, C1, P2)).toEqual([["parser", "SQLPG300"]]);
400
+ expect(rules(TSC, C1, P2, { dependents: ["index app.docs_idx"] })).toEqual([["parser", "SQLPG301"]]);
401
+ expect(steps(TSC, C1, P2).slice(0, 2)).toEqual(["DROP TEXT SEARCH CONFIGURATION app.c", `CREATE TEXT SEARCH CONFIGURATION app.c (PARSER = app.my_parser)`]);
402
+ expect(dropStatement("textSearchConfiguration", "app", "c").sql).toBe("DROP TEXT SEARCH CONFIGURATION app.c");
403
+ });
404
+
405
+ test("an operator class is dropped and created: ALTER OPERATOR CLASS renames and moves it only", () => {
406
+ const K1 = "CREATE OPERATOR CLASS app.c FOR TYPE integer USING hash AS OPERATOR 1 =, FUNCTION 1 hashint4(integer)";
407
+ // The server prints every operand type, the operator's schema and the family when it is not the class's own.
408
+ const printed = "CREATE OPERATOR CLASS app.c FOR TYPE integer USING hash AS\n OPERATOR 1 pg_catalog.= (integer, integer),\n FUNCTION 1 (integer, integer) hashint4(integer)";
409
+ expect(rules(OPC, printed, K1)).toEqual([]);
410
+ expect(rules(OPC, printed, K1.replace("CLASS app.c", "CLASS app.c DEFAULT"))).toEqual([["default", "SQLPG300"]]);
411
+ expect(rules(OPC, printed, K1.replace("AS OPERATOR 1 =", "FAMILY app.f AS OPERATOR 1 ="))).toEqual([["family", "SQLPG300"]]);
412
+ expect(rules(OPC, printed, K1.replace("hashint4(integer)", "hashint4extended(integer, bigint)"))).toEqual([["items", "SQLPG300"]]);
413
+ expect(rules(OPC, printed, K1.replace("hashint4(integer)", "hashint4extended(integer, bigint)"), { dependents: ["index app.docs_idx"] })).toEqual([["items", "SQLPG301"]]);
414
+ const recreate = steps(OPC, printed, K1.replace("CLASS app.c", "CLASS app.c DEFAULT"));
415
+ expect(recreate[0]).toBe("DROP OPERATOR CLASS app.c USING hash");
416
+ expect(recreate[1]).toContain("CREATE OPERATOR CLASS app.c DEFAULT FOR TYPE");
417
+ expect(recreate[2]).toMatch(/^COMMENT ON OPERATOR CLASS app\.c USING hash IS /);
418
+ expect(dropStatement("operatorClass", "app", "c", " USING hash").sql).toBe("DROP OPERATOR CLASS app.c USING hash");
419
+ });
420
+
421
+ test("an operator family has its name, method and comment", () => {
422
+ expect(rules(OPF, "CREATE OPERATOR FAMILY app.f USING btree", "CREATE OPERATOR FAMILY app.f USING btree")).toEqual([]);
423
+ expect(steps(OPF, "CREATE OPERATOR FAMILY app.f USING btree", "CREATE OPERATOR FAMILY app.f USING btree; COMMENT ON OPERATOR FAMILY app.f USING btree IS 'x'")).toEqual([expect.stringMatching(/^COMMENT ON OPERATOR FAMILY app\.f USING btree IS 'x/)]);
424
+ expect(dropStatement("operatorFamily", "app", "f", " USING btree").sql).toBe("DROP OPERATOR FAMILY app.f USING btree");
425
+ // The same name for another method is another family, not a change.
426
+ const [a] = declared(OPF, "CREATE OPERATOR FAMILY app.f USING btree");
427
+ const [b] = declared(OPF, "CREATE OPERATOR FAMILY app.f USING hash");
428
+ expect(a!.key).not.toBe(b!.key);
429
+ });
430
+
431
+ test("a foreign table is altered in place, except for its server", () => {
432
+ const F1 = "CREATE FOREIGN TABLE app.r (id integer NOT NULL, status text, old text) SERVER s OPTIONS (table_name 'orders')";
433
+ // The server prints the default cast and the doubled parentheses; a column is its type, options and NOT NULL.
434
+ expect(rules(FTB, "CREATE FOREIGN TABLE app.r (\n id integer NOT NULL,\n status text DEFAULT 'new'::text,\n CONSTRAINT k CHECK ((id > 0))\n) SERVER s", "CREATE FOREIGN TABLE app.r (id int NOT NULL, status text DEFAULT 'new', CHECK (id > 0)) SERVER s")).toEqual([]);
435
+ const F2 =
436
+ "CREATE FOREIGN TABLE app.r (id integer, status character varying(10) DEFAULT 'new' CHECK (status <> ''), total numeric OPTIONS (column_name 't')) SERVER s OPTIONS (table_name 'orders2', schema_name 'x')";
437
+ expect(rules(FTB, F1, F2)).toEqual([
438
+ ["columns.old", "SQLPG306"],
439
+ ["columns.id.notNull", "SQLPG306"],
440
+ ["columns.status.type", "SQLPG306"],
441
+ ["columns.status.default", "SQLPG306"],
442
+ ["columns.total", "SQLPG306"],
443
+ ["constraints.check", "SQLPG306"],
444
+ ["options", "SQLPG306"],
445
+ ]);
446
+ expect(steps(FTB, F1, F2)).toEqual([
447
+ "ALTER FOREIGN TABLE app.r DROP COLUMN old",
448
+ "ALTER FOREIGN TABLE app.r ALTER COLUMN id DROP NOT NULL",
449
+ "ALTER FOREIGN TABLE app.r ALTER COLUMN status TYPE character varying(10)",
450
+ "ALTER FOREIGN TABLE app.r ALTER COLUMN status SET DEFAULT 'new'",
451
+ "ALTER FOREIGN TABLE app.r ADD COLUMN total numeric",
452
+ "ALTER FOREIGN TABLE app.r ALTER COLUMN total OPTIONS (ADD column_name 't')",
453
+ "ALTER FOREIGN TABLE app.r ADD CHECK (status <> '')",
454
+ "ALTER FOREIGN TABLE app.r OPTIONS (ADD schema_name 'x', SET table_name 'orders2')",
455
+ ]);
456
+ // A column option, a dropped constraint and a dropped default.
457
+ const G1 = "CREATE FOREIGN TABLE app.r (id integer OPTIONS (column_name 'a') DEFAULT 1, CONSTRAINT pos CHECK (id > 0)) SERVER s";
458
+ const G2 = "CREATE FOREIGN TABLE app.r (id integer OPTIONS (column_name 'b')) SERVER s";
459
+ expect(steps(FTB, G1, G2)).toEqual([
460
+ "ALTER FOREIGN TABLE app.r ALTER COLUMN id SET DEFAULT 1".replace("SET DEFAULT 1", "DROP DEFAULT"),
461
+ "ALTER FOREIGN TABLE app.r ALTER COLUMN id OPTIONS (SET column_name 'b')",
462
+ "ALTER FOREIGN TABLE app.r DROP CONSTRAINT pos",
463
+ ]);
464
+ // ALTER FOREIGN TABLE has no SERVER action.
465
+ const S2 = F1.replace(" SERVER s ", " SERVER s2 ");
466
+ expect(rules(FTB, F1, S2)).toEqual([["server", "SQLPG300"]]);
467
+ expect(rules(FTB, F1, S2, { dependents: ["view app.v"] })).toEqual([["server", "SQLPG301"]]);
468
+ expect(steps(FTB, F1, S2).slice(0, 2)).toEqual(["DROP FOREIGN TABLE app.r", S2]);
469
+ expect(dropStatement("foreignTable", "app", "r").sql).toBe("DROP FOREIGN TABLE app.r");
470
+ // A column's comment and the table's go through COMMENT ON.
471
+ expect(steps(FTB, F1, `${F1}; COMMENT ON COLUMN app.r.status IS 'State'`)).toEqual(["COMMENT ON COLUMN app.r.status IS 'State'"]);
472
+ });
473
+
474
+ test("a comment changes in place", () => {
475
+ expect(steps(SRV, "CREATE SERVER s FOREIGN DATA WRAPPER w", "CREATE SERVER s FOREIGN DATA WRAPPER w; COMMENT ON SERVER s IS 'x'")).toEqual([expect.stringMatching(/^COMMENT ON SERVER s IS 'x/)]);
476
+ expect(steps(PUB, "CREATE PUBLICATION p", "CREATE PUBLICATION p; COMMENT ON PUBLICATION p IS 'x'")).toEqual([expect.stringMatching(/^COMMENT ON PUBLICATION p IS 'x/)]);
477
+ });
478
+ });
479
+
480
+ // ── Reading the catalog ────────────────────────────────────────────────
481
+
482
+ /** Answers the database-kind reader's queries from rows, by the catalog each reads. */
483
+ function catalogClient(rows: {
484
+ stats?: unknown[];
485
+ wrappers?: unknown[];
486
+ servers?: unknown[];
487
+ pubs?: unknown[];
488
+ pubTables?: unknown[];
489
+ pubSchemas?: unknown[];
490
+ version15?: boolean;
491
+ classes?: unknown[];
492
+ families?: unknown[];
493
+ dictionaries?: unknown[];
494
+ configurations?: unknown[];
495
+ foreignTables?: unknown[];
496
+ foreignColumns?: unknown[];
497
+ foreignChecks?: unknown[];
498
+ }): PostgresClient {
499
+ return {
500
+ async query<T>(sql: string): Promise<T[]> {
501
+ const r = (x: unknown[] | undefined) => (x ?? []) as T[];
502
+ if (sql.includes("pg_get_statisticsobjdef")) return r(rows.stats);
503
+ if (sql.includes("FROM pg_catalog.pg_foreign_data_wrapper w")) return r(rows.wrappers);
504
+ if (sql.includes("FROM pg_catalog.pg_foreign_server v")) return r(rows.servers);
505
+ if (sql.includes("FROM pg_catalog.pg_publication p")) return r(rows.pubs);
506
+ if (sql.includes("FROM pg_catalog.pg_publication_rel r")) return r(rows.pubTables);
507
+ if (sql.includes("FROM pg_catalog.pg_publication_namespace pn")) return r(rows.pubSchemas);
508
+ if (sql.includes("FROM pg_catalog.pg_opfamily f JOIN")) return r(rows.families);
509
+ if (sql.includes("FROM pg_catalog.pg_opclass c JOIN pg_catalog.pg_namespace n ON n.oid = c.opcnamespace")) return r(rows.classes);
510
+ if (sql.includes("FROM pg_catalog.pg_ts_config c JOIN pg_catalog.pg_namespace n ON n.oid = c.cfgnamespace")) return r(rows.configurations);
511
+ if (sql.includes("FROM pg_catalog.pg_ts_dict d JOIN pg_catalog.pg_namespace n ON n.oid = d.dictnamespace")) return r(rows.dictionaries);
512
+ if (sql.includes("JOIN pg_catalog.pg_foreign_table ft ON ft.ftrelid = c.oid")) return r(rows.foreignTables);
513
+ if (sql.includes("FROM pg_catalog.pg_attribute AS a JOIN pg_catalog.pg_type t ON t.oid = a.atttypid\n LEFT JOIN")) return r(rows.foreignColumns);
514
+ if (sql.includes("FROM pg_catalog.pg_constraint k")) return r(rows.foreignChecks);
515
+ // The 15 features: the column list, and schema members.
516
+ if (sql.includes("attname = 'prattrs'") || sql.includes("to_regclass('pg_catalog.pg_publication_namespace')")) return rows.version15 ? r([{ x: 1 }]) : [];
517
+ return [];
518
+ },
519
+ async end() {},
520
+ };
521
+ }
522
+
523
+ describe("reading the catalog", () => {
524
+ test("each kind is assembled from its rows, a credential left out and said so", async () => {
525
+ const objects = await readDatabaseKinds(
526
+ catalogClient({
527
+ version15: true,
528
+ stats: [{ oid: "1", schema: "app", name: "s", def: "CREATE STATISTICS app.s (dependencies) ON a, b FROM app.t", comment: "Linked" }],
529
+ wrappers: [{ oid: "2", name: "app_fdw", handler: "app.h", validator: null, options: ["debug=true"], comment: null, dependents: ["server app_remote"] }],
530
+ servers: [{ oid: "3", name: "app_remote", type: "pg", version: null, wrapper: "app_fdw", options: ["host=db", "sslpassword=x", "password=y"], comment: "It's remote", dependents: [] }],
531
+ pubs: [{ oid: "4", name: "app_pub", alltables: false, pubinsert: true, pubupdate: true, pubdelete: false, pubtruncate: false, def: { pubviaroot: true, pubgencols: "n" }, comment: null }],
532
+ pubTables: [{ schema: "app", name: "t", columns: ['"a"', '"b"'], filter: "a > 0" }, { schema: "app", name: "u", columns: [], filter: null }],
533
+ pubSchemas: [{ name: "audit" }],
534
+ }),
535
+ {},
536
+ );
537
+ expect(objects.map((o) => [o.type, o.name])).toEqual([
538
+ [STAT, "s"],
539
+ [FDW, "app_fdw"],
540
+ [SRV, "app_remote"],
541
+ [PUB, "app_pub"],
542
+ ]);
543
+ expect(objects[0]!.statement).toBe("CREATE STATISTICS app.s (dependencies) ON a, b FROM app.t;\nCOMMENT ON STATISTICS app.s IS 'Linked'");
544
+ expect(objects[1]).toMatchObject({ statement: "CREATE FOREIGN DATA WRAPPER app_fdw HANDLER app.h OPTIONS (debug 'true')", dependents: ["server app_remote"] });
545
+ expect(objects[2]!.statement).toBe("CREATE SERVER app_remote TYPE 'pg' FOREIGN DATA WRAPPER app_fdw OPTIONS (host 'db');\nCOMMENT ON SERVER app_remote IS 'It''s remote'");
546
+ expect(objects[2]!.warnings).toEqual(["the options sslpassword, password hold a credential and are left out; the environment supplies it"]);
547
+ expect(objects[3]!.statement).toBe(
548
+ "CREATE PUBLICATION app_pub FOR TABLE app.t (\"a\", \"b\") WHERE (a > 0), app.u, TABLES IN SCHEMA audit WITH (publish = 'insert, update', publish_via_partition_root = true)",
549
+ );
550
+ // Each parses back with its own tag.
551
+ for (const o of objects) expect(() => liveProps(o)).not.toThrow();
552
+ });
553
+
554
+ test("an operator: its operands, its commutator and its flags, as the catalog holds them", async () => {
555
+ const client: PostgresClient = {
556
+ async query<T>(sql: string): Promise<T[]> {
557
+ if (!sql.includes("FROM pg_catalog.pg_operator o")) return [];
558
+ return [
559
+ { oid: "1", schema: "app", name: "===", leftarg: "integer", rightarg: "integer", func: "app.pair_eq", commutator: "OPERATOR(app.===)", negator: null, restrict: "eqsel", join_func: null, hashes: true, merges: false, comment: "Same", dependents: ["operator class app.c"] },
560
+ { oid: "2", schema: "app", name: "@@", leftarg: null, rightarg: "integer", func: "app.abs_of", commutator: null, negator: null, restrict: null, join_func: null, hashes: false, merges: false, comment: null, dependents: [] },
561
+ ] as T[];
562
+ },
563
+ async end() {},
564
+ };
565
+ const ops = await readOperators(client, {});
566
+ expect(ops.map((o) => [o.schema, o.name, o.signature])).toEqual([["app", "===", "(integer,integer)"], ["app", "@@", "(NONE,integer)"]]);
567
+ expect(ops[0]!.statement).toBe(
568
+ "CREATE OPERATOR app.=== (FUNCTION = app.pair_eq, LEFTARG = integer, RIGHTARG = integer, COMMUTATOR = OPERATOR(app.===), RESTRICT = eqsel, HASHES);\nCOMMENT ON OPERATOR app.===(integer,integer) IS 'Same'",
569
+ );
570
+ expect(ops[0]!.dependents).toEqual(["operator class app.c"]);
571
+ expect(ops[1]!.statement).toBe("CREATE OPERATOR app.@@ (FUNCTION = app.abs_of, RIGHTARG = integer)");
572
+ for (const o of ops) expect(() => liveProps(o)).not.toThrow();
573
+ });
574
+
575
+ test("operator classes and families: a class carries its members, its own family is its own", async () => {
576
+ const objects = await readDatabaseKinds(
577
+ catalogClient({
578
+ classes: [
579
+ {
580
+ oid: "10", schema: "app", name: "int_hash_ops", method: "hash", isdefault: false, type: "integer", storage: null, family: null,
581
+ operators: ["OPERATOR 1 pg_catalog.= (integer, integer)"], functions: ["FUNCTION 1 (integer, integer) hashint4(integer)"], comment: "Hash by value", dependents: ["index app.users_idx"],
582
+ },
583
+ {
584
+ oid: "11", schema: "app", name: "box_ops", method: "gist", isdefault: true, type: "box", storage: "box", family: 'app."My Family"',
585
+ operators: ["OPERATOR 1 pg_catalog.<<(box, box)".replace("<<(box", "<< (box")], functions: [], comment: null, dependents: [],
586
+ },
587
+ { oid: "12", schema: "app", name: "empty_ops", method: "hash", isdefault: false, type: "integer", storage: null, family: null, operators: [], functions: [], comment: null, dependents: [] },
588
+ ],
589
+ families: [
590
+ { oid: "20", schema: "app", name: "spare_ops", method: "btree", loose: 2, comment: "Spare", dependents: [] },
591
+ { oid: "21", schema: "app", name: "plain_ops", method: "btree", loose: 0, comment: null, dependents: [] },
592
+ ],
593
+ }),
594
+ {},
595
+ );
596
+ const [hash, box, empty, spare, plain] = objects;
597
+ expect(objects.map((o) => [o.type, o.name, o.signature])).toEqual([
598
+ [OPC, "int_hash_ops", " USING hash"],
599
+ [OPC, "box_ops", " USING gist"],
600
+ [OPC, "empty_ops", " USING hash"],
601
+ [OPF, "spare_ops", " USING btree"],
602
+ [OPF, "plain_ops", " USING btree"],
603
+ ]);
604
+ expect(hash!.statement).toBe(
605
+ "CREATE OPERATOR CLASS app.int_hash_ops FOR TYPE integer USING hash AS\n OPERATOR 1 pg_catalog.= (integer, integer),\n FUNCTION 1 (integer, integer) hashint4(integer);\nCOMMENT ON OPERATOR CLASS app.int_hash_ops USING hash IS 'Hash by value'",
606
+ );
607
+ expect(hash!.dependents).toEqual(["index app.users_idx"]);
608
+ expect(box!.statement).toBe('CREATE OPERATOR CLASS app.box_ops DEFAULT FOR TYPE box USING gist FAMILY app."My Family" AS\n OPERATOR 1 pg_catalog.<< (box, box),\n STORAGE box');
609
+ // A class whose members could not be read is not declared half.
610
+ expect(empty!.unsupported).toMatch(/at least one/);
611
+ expect(spare!.statement).toBe("CREATE OPERATOR FAMILY app.spare_ops USING btree;\nCOMMENT ON OPERATOR FAMILY app.spare_ops USING btree IS 'Spare'");
612
+ expect(spare!.warnings).toEqual(["2 loose members added with ALTER OPERATOR FAMILY ... ADD are left out; a declaration holds the family and its classes' members"]);
613
+ expect(plain!.warnings).toBeUndefined();
614
+ for (const o of [hash!, box!, spare!, plain!]) expect(() => liveProps(o)).not.toThrow();
615
+ expect(liveProps(box!)).toMatchObject({ default: true, family: 'app."My Family"', items: [{ kind: "operator" }, { kind: "storage", type: "box" }] });
616
+ });
617
+
618
+ test("text search: a dictionary's options as the server writes them, a configuration's mappings grouped by dictionaries", async () => {
619
+ const objects = await readDatabaseKinds(
620
+ catalogClient({
621
+ dictionaries: [
622
+ { oid: "30", schema: "app", name: "shop_stop", template: "simple", init: "stopwords = 'english', accept = 'false'", comment: "It's stop words", dependents: ["text search configuration app.shop"] },
623
+ { oid: "31", schema: "app", name: "bare", template: "app.my_template", init: null, comment: null, dependents: [] },
624
+ { oid: "32", schema: "app", name: "odd", template: "simple", init: "this is not a list", comment: null, dependents: [] },
625
+ ],
626
+ configurations: [
627
+ {
628
+ oid: "40", schema: "app", name: "shop", parser: '"default"', comment: "Shop search", dependents: ["index app.docs_idx"],
629
+ mappings: ["asciiword\tapp.shop_stop, english_stem", "word\tapp.shop_stop, english_stem", "int\tsimple"],
630
+ },
631
+ { oid: "41", schema: "app", name: "nothing", parser: '"default"', comment: null, mappings: [], dependents: [] },
632
+ ],
633
+ }),
634
+ {},
635
+ );
636
+ const [stop, bare, odd, shop, nothing] = objects;
637
+ expect(objects.map((o) => [o.type, o.name])).toEqual([[TSD, "shop_stop"], [TSD, "bare"], [TSD, "odd"], [TSC, "shop"], [TSC, "nothing"]]);
638
+ expect(stop!.statement).toBe("CREATE TEXT SEARCH DICTIONARY app.shop_stop (TEMPLATE = simple, stopwords = 'english', accept = 'false');\nCOMMENT ON TEXT SEARCH DICTIONARY app.shop_stop IS 'It''s stop words'");
639
+ expect(stop!.dependents).toEqual(["text search configuration app.shop"]);
640
+ expect(bare!.statement).toBe("CREATE TEXT SEARCH DICTIONARY app.bare (TEMPLATE = app.my_template)");
641
+ expect(odd!.unsupported).toMatch(/not in the form the server writes/);
642
+ expect(shop!.statement).toBe(
643
+ [
644
+ 'CREATE TEXT SEARCH CONFIGURATION app.shop (PARSER = "default")',
645
+ "ALTER TEXT SEARCH CONFIGURATION app.shop ADD MAPPING FOR asciiword, word WITH app.shop_stop, english_stem",
646
+ 'ALTER TEXT SEARCH CONFIGURATION app.shop ADD MAPPING FOR "int" WITH simple',
647
+ "COMMENT ON TEXT SEARCH CONFIGURATION app.shop IS 'Shop search'",
648
+ ].join(";\n"),
649
+ );
650
+ expect(nothing!.statement).toBe('CREATE TEXT SEARCH CONFIGURATION app.nothing (PARSER = "default")');
651
+ for (const o of [stop!, bare!, shop!, nothing!]) expect(() => liveProps(o)).not.toThrow();
652
+ expect(liveProps(shop!).mappings).toEqual([{ tokens: ["asciiword", "word"], dictionaries: ["app.shop_stop", "english_stem"] }, { tokens: ["int"], dictionaries: ["simple"] }]);
653
+ expect(parseDictionaryOptions("Language = 'it''s', \"Odd Name\" = 'a\\\\b'")).toEqual([["Language", "it's"], ["Odd Name", "a\\b"]]);
654
+ expect(parseDictionaryOptions(undefined)).toEqual([]);
655
+ expect(parseDictionaryOptions("language")).toBeUndefined();
656
+ });
657
+
658
+ test("foreign tables: columns with their options, checks, server and options; a credential is left out; a partition, an inheriting table and a generated column are not declarable", async () => {
659
+ const objects = await readDatabaseKinds(
660
+ catalogClient({
661
+ foreignTables: [
662
+ { oid: "50", schema: "app", name: "remote_orders", ispartition: false, inherits: false, server: "app_replica", options: ["table_name=orders", "password=hunter2"], comment: "Orders", dependents: ["view app.order_view"] },
663
+ { oid: "51", schema: "app", name: "remote_part", ispartition: true, inherits: false, server: "app_replica", options: null, comment: null, dependents: [] },
664
+ { oid: "52", schema: "app", name: "remote_child", ispartition: false, inherits: true, server: "app_replica", options: null, comment: null, dependents: [] },
665
+ { oid: "53", schema: "app", name: "remote_gen", ispartition: false, inherits: false, server: "app_replica", options: null, comment: null, dependents: [] },
666
+ ],
667
+ foreignColumns: [
668
+ { rel: "50", name: "id", type: "integer", notnull: true, generated: "", dflt: null, collation: null, options: ["column_name=order_id", "sslpassword=x"], comment: "The key" },
669
+ { rel: "50", name: "status", type: "text", notnull: false, generated: "", dflt: "'placed'::text", collation: '"C"', options: null, comment: null },
670
+ { rel: "51", name: "id", type: "integer", notnull: false, generated: "", dflt: null, collation: null, options: null, comment: null },
671
+ { rel: "52", name: "id", type: "integer", notnull: false, generated: "", dflt: null, collation: null, options: null, comment: null },
672
+ { rel: "53", name: "id", type: "integer", notnull: false, generated: "s", dflt: "1", collation: null, options: null, comment: null },
673
+ ],
674
+ foreignChecks: [{ rel: "50", name: "status_known", def: "CHECK ((status IS NOT NULL))", comment: "Always set" }],
675
+ }),
676
+ {},
677
+ );
678
+ expect(objects.map((o) => [o.type, o.name])).toEqual([[FTB, "remote_orders"], [FTB, "remote_part"], [FTB, "remote_child"], [FTB, "remote_gen"]]);
679
+ const [orders, part, child, gen] = objects;
680
+ expect(orders!.statement).toBe(
681
+ [
682
+ "CREATE FOREIGN TABLE app.remote_orders (\n id integer OPTIONS (column_name 'order_id') NOT NULL,\n status text COLLATE \"C\" DEFAULT 'placed'::text,\n CONSTRAINT status_known CHECK ((status IS NOT NULL))\n) SERVER app_replica OPTIONS (table_name 'orders')",
683
+ "COMMENT ON FOREIGN TABLE app.remote_orders IS 'Orders'",
684
+ "COMMENT ON COLUMN app.remote_orders.id IS 'The key'",
685
+ "COMMENT ON CONSTRAINT status_known ON app.remote_orders IS 'Always set'",
686
+ ].join(";\n"),
687
+ );
688
+ expect(orders!.warnings).toEqual(["the options sslpassword, password hold a credential and are left out; the environment supplies it"]);
689
+ expect(orders!.dependents).toEqual(["view app.order_view"]);
690
+ expect(part!.unsupported).toMatch(/partition/);
691
+ expect(child!.unsupported).toMatch(/inherits/);
692
+ expect(gen!.unsupported).toMatch(/generated column/);
693
+ expect(liveProps(orders!)).toMatchObject({ serverName: "app_replica", options: { table_name: "orders" }, columns: [{ name: "id", options: { column_name: "order_id" } }, { name: "status", collate: '"C"' }], checks: [{ name: "status_known" }] });
694
+ });
695
+
696
+ test("a publication of every table, on a server before 15", async () => {
697
+ const [p] = await readDatabaseKinds(catalogClient({ pubs: [{ oid: "4", name: "all_of_it", alltables: true, pubinsert: true, pubupdate: true, pubdelete: true, pubtruncate: true, def: { pubviaroot: false }, comment: null }] }), {});
698
+ expect(p!.statement).toBe("CREATE PUBLICATION all_of_it FOR ALL TABLES");
699
+ });
700
+
701
+ test("the schema reader includes them, after the functions they call", async () => {
702
+ const log: string[] = [];
703
+ await readLiveSchema(fakeClient({}, log), { schemas: ["app"] });
704
+ expect(log.some((q) => q.includes("pg_get_statisticsobjdef"))).toBe(true);
705
+ expect(log.some((q) => q.includes("pg_foreign_server"))).toBe(true);
706
+ expect(log.some((q) => q.includes("FROM pg_catalog.pg_publication p"))).toBe(true);
707
+ });
708
+ });
709
+
710
+ // ── The round trip ─────────────────────────────────────────────────────
711
+
712
+ /** What the server prints for the objects below. */
713
+ const SERVER_PRINTS: Array<{ type: string; schema?: string; name: string; signature?: string; statement: string }> = [
714
+ { type: "Postgres::Schema", name: "app", statement: "CREATE SCHEMA app" },
715
+ { type: "Postgres::Schema", name: "audit", statement: "CREATE SCHEMA audit" },
716
+ { type: "Postgres::Table", schema: "app", name: "orders", statement: "CREATE TABLE app.orders (\n id integer,\n status text\n)" },
717
+ { type: "Postgres::Function", schema: "app", name: "pair_eq", signature: "(integer,integer)", statement: "CREATE FUNCTION app.pair_eq(a integer, b integer)\n RETURNS boolean\n LANGUAGE sql\nAS $function$SELECT a = b$function$" },
718
+ { type: OPR, schema: "app", name: "===", signature: "(integer,integer)", statement: "CREATE OPERATOR app.=== (FUNCTION = app.pair_eq, LEFTARG = integer, RIGHTARG = integer, COMMUTATOR = OPERATOR(app.===), HASHES);\nCOMMENT ON OPERATOR app.===(integer,integer) IS 'Same pair'" },
719
+ { type: STAT, schema: "app", name: "orders_stats", statement: "CREATE STATISTICS app.orders_stats (dependencies) ON id, status FROM app.orders;\nCOMMENT ON STATISTICS app.orders_stats IS 'Correlated'" },
720
+ { type: FDW, name: "app_fdw", statement: "CREATE FOREIGN DATA WRAPPER app_fdw OPTIONS (debug 'true');\nCOMMENT ON FOREIGN DATA WRAPPER app_fdw IS 'Remote'" },
721
+ { type: SRV, name: "app_remote", statement: "CREATE SERVER app_remote TYPE 'pg' VERSION '16' FOREIGN DATA WRAPPER app_fdw OPTIONS (host 'db.internal', port '5432')" },
722
+ { type: PUB, name: "app_pub", statement: "CREATE PUBLICATION app_pub FOR TABLE app.orders (id, status) WHERE (id > 0), TABLES IN SCHEMA audit WITH (publish = 'insert, update')" },
723
+ {
724
+ type: FTB,
725
+ schema: "app",
726
+ name: "remote_orders",
727
+ statement:
728
+ "CREATE FOREIGN TABLE app.remote_orders (\n id integer OPTIONS (column_name 'order_id') NOT NULL,\n status text DEFAULT 'placed'::text,\n CONSTRAINT status_known CHECK ((status IS NOT NULL))\n) SERVER app_remote OPTIONS (table_name 'orders');\nCOMMENT ON FOREIGN TABLE app.remote_orders IS 'Orders on the replica'",
729
+ },
730
+ {
731
+ type: OPC,
732
+ schema: "app",
733
+ name: "int_hash_ops",
734
+ signature: " USING hash",
735
+ statement:
736
+ "CREATE OPERATOR CLASS app.int_hash_ops FOR TYPE integer USING hash AS\n OPERATOR 1 pg_catalog.= (integer, integer),\n FUNCTION 1 (integer, integer) hashint4(integer);\nCOMMENT ON OPERATOR CLASS app.int_hash_ops USING hash IS 'Hash by value'",
737
+ },
738
+ { type: OPF, schema: "app", name: "spare_ops", signature: " USING btree", statement: "CREATE OPERATOR FAMILY app.spare_ops USING btree" },
739
+ { type: TSD, schema: "app", name: "shop_stop", statement: "CREATE TEXT SEARCH DICTIONARY app.shop_stop (TEMPLATE = simple, stopwords = 'english')" },
740
+ {
741
+ type: TSC,
742
+ schema: "app",
743
+ name: "shop",
744
+ statement:
745
+ 'CREATE TEXT SEARCH CONFIGURATION app.shop (PARSER = "default");\nALTER TEXT SEARCH CONFIGURATION app.shop ADD MAPPING FOR asciiword, word WITH app.shop_stop, english_stem;\nALTER TEXT SEARCH CONFIGURATION app.shop ADD MAPPING FOR "int" WITH simple;\nCOMMENT ON TEXT SEARCH CONFIGURATION app.shop IS \'Shop search\'',
746
+ },
747
+ ];
748
+
749
+ describe("declare, build, import, plan with no change", () => {
750
+ const liveObjects = (): LivePgObject[] => SERVER_PRINTS.map((o, i) => ({ oid: String(i), ...o }) as LivePgObject);
751
+
752
+ test("a declaration the server prints back plans with no change", () => {
753
+ const orders = table`CREATE TABLE ${app}.orders (id int, status text)`;
754
+ const audit = schema`CREATE SCHEMA audit`;
755
+ const fdw = foreignDataWrapper`CREATE FOREIGN DATA WRAPPER app_fdw OPTIONS (debug 'true'); COMMENT ON FOREIGN DATA WRAPPER app_fdw IS 'Remote'`;
756
+ const pairEq = func`CREATE FUNCTION ${app}.pair_eq(a integer, b integer) RETURNS boolean LANGUAGE sql AS 'SELECT a = b'`;
757
+ const remote = foreignServer`CREATE SERVER app_remote TYPE 'pg' VERSION '16' FOREIGN DATA WRAPPER ${fdw} OPTIONS (port '5432', host 'db.internal')`;
758
+ const shopStop = textSearchDictionary`CREATE TEXT SEARCH DICTIONARY ${app}.shop_stop (TEMPLATE = simple, StopWords = english)`;
759
+ const objects = [
760
+ app,
761
+ audit,
762
+ orders,
763
+ pairEq,
764
+ operator`CREATE OPERATOR ${app}.=== (LEFTARG = int, RIGHTARG = int, FUNCTION = ${pairEq}, HASHES, COMMUTATOR = ===); COMMENT ON OPERATOR ${app}.===(int, int) IS 'Same pair'`,
765
+ statistics`CREATE STATISTICS ${app}.orders_stats (dependencies) ON status, id FROM ${orders}; COMMENT ON STATISTICS ${app}.orders_stats IS 'Correlated'`,
766
+ fdw,
767
+ remote,
768
+ publication`CREATE PUBLICATION app_pub FOR TABLE ${orders} (status, id) WHERE (id > 0), TABLES IN SCHEMA ${audit} WITH (publish = 'update, insert')`,
769
+ foreignTable`CREATE FOREIGN TABLE ${app}.remote_orders (id int OPTIONS (column_name 'order_id') NOT NULL, status text DEFAULT 'placed', CONSTRAINT status_known CHECK (status IS NOT NULL)) SERVER ${remote} OPTIONS (table_name 'orders'); COMMENT ON FOREIGN TABLE ${app}.remote_orders IS 'Orders on the replica'`,
770
+ operatorClass`CREATE OPERATOR CLASS ${app}.int_hash_ops FOR TYPE int USING hash AS OPERATOR 1 =, FUNCTION 1 hashint4(int); COMMENT ON OPERATOR CLASS ${app}.int_hash_ops USING hash IS 'Hash by value'`,
771
+ operatorFamily`CREATE OPERATOR FAMILY ${app}.spare_ops USING btree`,
772
+ shopStop,
773
+ textSearchConfiguration`CREATE TEXT SEARCH CONFIGURATION ${app}.shop (PARSER = pg_catalog.default);
774
+ ALTER TEXT SEARCH CONFIGURATION ${app}.shop ADD MAPPING FOR word, asciiword WITH ${shopStop}, english_stem;
775
+ ALTER TEXT SEARCH CONFIGURATION ${app}.shop ADD MAPPING FOR int WITH simple;
776
+ COMMENT ON TEXT SEARCH CONFIGURATION ${app}.shop IS 'Shop search'`,
777
+ ];
778
+ // The build: each entity's DDL, rendered, as the build output holds it.
779
+ const build = JSON.stringify({ dialect: "postgres", objects: objects.map((o, i) => ({ export: `o${i}`, type: o.entityType, ddl: (o.props as { ddl: string }).ddl, dependsOn: [] })) });
780
+ const declaredSchema = keyedByQualifiedName(declaredObjects(build).map((d) => ({ key: d.exportName, canonical: d.canonical })));
781
+ expect(diffPgSchemas(pgSchemaFromLive(liveObjects(), "public"), declaredSchema).changes).toEqual([]);
782
+ });
783
+
784
+ test("import writes each as a declaration, which builds to the same objects", () => {
785
+ const ir = objectsToIR(SERVER_PRINTS.map((o) => ({ type: o.type, ...(o.schema ? { schema: o.schema } : {}), name: o.name, ddl: o.statement }) as ImportedPgObject));
786
+ const [file] = new PostgresGenerator().generate(ir);
787
+ const content = file!.content;
788
+ expect(content).toContain(
789
+ 'import { foreignDataWrapper, foreignServer, foreignTable, func, operator, operatorClass, operatorFamily, publication, schema, statistics, table, textSearchConfiguration, textSearchDictionary } from "@intentius/chant-lexicon-sql/postgres";',
790
+ );
791
+ expect(content).toContain("export const ordersStats = statistics`");
792
+ expect(content).toContain("export const pairEq = func`");
793
+ expect(content).toContain("CREATE OPERATOR ${appSchema}.=== (FUNCTION = ${pairEq}, LEFTARG = integer");
794
+ expect(content).toContain("CREATE STATISTICS ${appSchema}.orders_stats (dependencies) ON id, status FROM ${orders}");
795
+ expect(content).toContain("export const appFdw = foreignDataWrapper`");
796
+ expect(content).toContain("FOREIGN DATA WRAPPER ${appFdw} OPTIONS (host 'db.internal'");
797
+ expect(content).toContain("export const appPub = publication`");
798
+ expect(content).toContain("TABLES IN SCHEMA ${auditSchema} WITH (publish = 'insert, update')");
799
+ expect(content).toContain("FOR TABLE ${orders} (id, status) WHERE (id > 0)");
800
+ // #4406: a foreign table names its server, a configuration its dictionary, by export.
801
+ expect(content).toContain("export const remoteOrders = foreignTable`");
802
+ expect(content).toContain(") SERVER ${appRemote} OPTIONS (table_name 'orders')");
803
+ expect(content).toContain("export const intHashOps = operatorClass`");
804
+ expect(content).toContain("export const spareOps = operatorFamily`");
805
+ expect(content).toContain("export const shopStop = textSearchDictionary`");
806
+ expect(content).toContain("ADD MAPPING FOR asciiword, word WITH ${shopStop}, english_stem");
807
+ expect(content).toContain("CREATE TEXT SEARCH CONFIGURATION ${appSchema}.shop (PARSER");
808
+
809
+ // The imported statements, in the generator's order, as a build: no change against the objects they came from.
810
+ const items = ir.resources.map((r) => ({ exportName: r.logicalId, type: r.type, ...(r.properties.schema ? { schema: String(r.properties.schema) } : {}), name: String(r.properties.name), ddl: String(r.properties.ddl) }));
811
+ const { order } = postgresTemplates(items);
812
+ expect(order.map((o) => o.exportName).indexOf("appFdw")).toBeLessThan(order.map((o) => o.exportName).indexOf("appRemote"));
813
+ expect(order.map((o) => o.exportName).indexOf("auditSchema")).toBeLessThan(order.map((o) => o.exportName).indexOf("appPub"));
814
+ expect(order.map((o) => o.exportName).indexOf("appRemote")).toBeLessThan(order.map((o) => o.exportName).indexOf("remoteOrders"));
815
+ expect(order.map((o) => o.exportName).indexOf("shopStop")).toBeLessThan(order.map((o) => o.exportName).indexOf("shop"));
816
+ const build = JSON.stringify({ dialect: "postgres", objects: order.map((it) => ({ export: it.exportName, type: it.type, ddl: it.ddl, dependsOn: [] })) });
817
+ const declaredSchema = keyedByQualifiedName(declaredObjects(build).map((d) => ({ key: d.exportName, canonical: d.canonical })));
818
+ expect(diffPgSchemas(pgSchemaFromLive(liveObjects(), "public"), declaredSchema).changes).toEqual([]);
819
+ });
820
+ });
821
+
822
+ describe("the build output", () => {
823
+ test("each new kind is written under its entity type, with its own props beside it", () => {
824
+ const orders = foreignTable`CREATE FOREIGN TABLE app.remote_orders (id int) SERVER replica`;
825
+ const ops = operatorClass`CREATE OPERATOR CLASS app.int_hash_ops FOR TYPE integer USING hash AS OPERATOR 1 =, FUNCTION 1 hashint4(integer)`;
826
+ const family = operatorFamily`CREATE OPERATOR FAMILY app.spare_ops USING btree`;
827
+ const dict = textSearchDictionary`CREATE TEXT SEARCH DICTIONARY app.d (TEMPLATE = simple)`;
828
+ const cfg = textSearchConfiguration`CREATE TEXT SEARCH CONFIGURATION app.c (PARSER = pg_catalog.default); ALTER TEXT SEARCH CONFIGURATION app.c ADD MAPPING FOR word WITH ${dict}`;
829
+ const out = sqlSerializer.serialize(new Map(Object.entries({ orders, ops, family, dict, cfg })) as never, undefined, {}) as SerializerResult;
830
+ const doc = JSON.parse(out.primary) as { objects: Array<Record<string, unknown>> };
831
+ const byExport = Object.fromEntries(doc.objects.map((o) => [o.export, o]));
832
+ expect(byExport.orders).toMatchObject({ type: FTB, sqlName: "app.remote_orders", serverName: "replica" });
833
+ expect(byExport.ops).toMatchObject({ type: OPC, method: "hash", dataType: "integer" });
834
+ expect(byExport.family).toMatchObject({ type: OPF, method: "btree" });
835
+ expect(byExport.dict).toMatchObject({ type: TSD, template: "simple" });
836
+ expect(byExport.cfg).toMatchObject({ type: TSC, parser: "pg_catalog.default", dependsOn: ["dict"] });
837
+ // And the applier reads the objects back from that output.
838
+ expect(declaredObjects(out.primary).map((d) => [d.exportName, d.canonical.kind, d.canonical.signature]).sort((a, b) => String(a[0]).localeCompare(String(b[0])))).toEqual([
839
+ ["cfg", "textSearchConfiguration", undefined],
840
+ ["dict", "textSearchDictionary", undefined],
841
+ ["family", "operatorFamily", " USING btree"],
842
+ ["ops", "operatorClass", " USING hash"],
843
+ ["orders", "foreignTable", undefined],
844
+ ]);
845
+ });
846
+ });
847
+
848
+ describe("the coverage table", () => {
849
+ test("a foreign table, an operator class and family and the two text search kinds are declared; the parser and template stay refused", () => {
850
+ expect(kindRow("postgres", "foreign table")).toMatchObject({ status: "declared", entity: FTB });
851
+ expect(kindRow("postgres", "operator class")).toMatchObject({ status: "declared", entity: OPC });
852
+ expect(kindRow("postgres", "operator family")).toMatchObject({ status: "declared", entity: OPF });
853
+ expect(kindRow("postgres", "text search configuration")).toMatchObject({ status: "declared", entity: TSC });
854
+ expect(kindRow("postgres", "text search dictionary")).toMatchObject({ status: "declared", entity: TSD });
855
+ expect(kindRow("postgres", "text search parser")).toMatchObject({ status: "refused", reason: expect.stringContaining("C code") });
856
+ expect(kindRow("postgres", "text search template")).toMatchObject({ status: "refused", reason: expect.stringContaining("C code") });
857
+ });
858
+ });
859
+
860
+ describe("the kinds that stay refused", () => {
861
+ test("a user mapping and a subscription say why: they carry credentials", () => {
862
+ expect(kindRow("postgres", "user mapping")).toMatchObject({ status: "refused", reason: expect.stringContaining("password") });
863
+ expect(kindRow("postgres", "subscription")).toMatchObject({ status: "refused", reason: expect.stringContaining("credential") });
864
+ });
865
+ });