@intentius/chant-lexicon-sql 0.124.45 → 0.124.48

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 (360) 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/stream.d.ts +40 -0
  37. package/dist/clickhouse/plan/stream.d.ts.map +1 -0
  38. package/dist/clickhouse/plan/stream.js +107 -0
  39. package/dist/clickhouse/rebuild/observe.d.ts +3 -1
  40. package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
  41. package/dist/clickhouse/rebuild/observe.js +14 -1
  42. package/dist/clickhouse/review-catalog.d.ts +213 -0
  43. package/dist/clickhouse/review-catalog.d.ts.map +1 -0
  44. package/dist/clickhouse/review-catalog.js +330 -0
  45. package/dist/codegen/classifier-page.d.ts.map +1 -1
  46. package/dist/codegen/classifier-page.js +21 -4
  47. package/dist/codegen/clickhouse-module.d.ts.map +1 -1
  48. package/dist/codegen/clickhouse-module.js +3 -0
  49. package/dist/codegen/coverage-page.d.ts.map +1 -1
  50. package/dist/codegen/coverage-page.js +5 -3
  51. package/dist/codegen/docs.js +1 -1
  52. package/dist/codegen/postgres-classifier-page.js +1 -1
  53. package/dist/codegen/registry.d.ts.map +1 -1
  54. package/dist/codegen/registry.js +5 -0
  55. package/dist/composites/catalog.d.ts.map +1 -1
  56. package/dist/composites/catalog.js +100 -0
  57. package/dist/composites/clickhouse.d.ts +2 -0
  58. package/dist/composites/clickhouse.d.ts.map +1 -1
  59. package/dist/composites/clickhouse.js +2 -0
  60. package/dist/composites/otel-tables.d.ts +100 -0
  61. package/dist/composites/otel-tables.d.ts.map +1 -0
  62. package/dist/composites/otel-tables.js +282 -0
  63. package/dist/composites/stream-ingest.d.ts +63 -0
  64. package/dist/composites/stream-ingest.d.ts.map +1 -0
  65. package/dist/composites/stream-ingest.js +39 -0
  66. package/dist/coverage/clickhouse.json +1 -1
  67. package/dist/coverage/postgres.json +7 -7
  68. package/dist/coverage.d.ts +16 -6
  69. package/dist/coverage.d.ts.map +1 -1
  70. package/dist/coverage.js +10 -5
  71. package/dist/drizzle-command.d.ts +17 -0
  72. package/dist/drizzle-command.d.ts.map +1 -0
  73. package/dist/drizzle-command.js +91 -0
  74. package/dist/drizzle.d.ts +70 -0
  75. package/dist/drizzle.d.ts.map +1 -0
  76. package/dist/drizzle.js +684 -0
  77. package/dist/files/clickhouse.d.ts.map +1 -1
  78. package/dist/files/clickhouse.js +8 -1
  79. package/dist/files/common.d.ts +13 -0
  80. package/dist/files/common.d.ts.map +1 -1
  81. package/dist/files/common.js +5 -0
  82. package/dist/files/postgres.js +12 -2
  83. package/dist/generated/clickhouse.d.ts +2 -0
  84. package/dist/generated/clickhouse.d.ts.map +1 -1
  85. package/dist/generated/clickhouse.js +2 -0
  86. package/dist/import-parser.d.ts +21 -1
  87. package/dist/import-parser.d.ts.map +1 -1
  88. package/dist/import-parser.js +79 -5
  89. package/dist/index.d.ts +1 -1
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +1 -1
  92. package/dist/integrity.json +6 -5
  93. package/dist/lint/post-synth/audit-entries.d.ts.map +1 -1
  94. package/dist/lint/post-synth/audit-entries.js +1 -0
  95. package/dist/lint/post-synth/index.d.ts.map +1 -1
  96. package/dist/lint/post-synth/index.js +2 -0
  97. package/dist/lint/post-synth/sqlch128.d.ts +18 -0
  98. package/dist/lint/post-synth/sqlch128.d.ts.map +1 -0
  99. package/dist/lint/post-synth/sqlch128.js +222 -0
  100. package/dist/lint/rules/postgres-templates.js +2 -2
  101. package/dist/lsp/postgres-template.d.ts.map +1 -1
  102. package/dist/lsp/postgres-template.js +11 -0
  103. package/dist/manifest.json +1 -1
  104. package/dist/mcp/postgres.js +1 -1
  105. package/dist/meta.json +25 -0
  106. package/dist/migrate/from-postgres/detect.d.ts +4 -0
  107. package/dist/migrate/from-postgres/detect.d.ts.map +1 -0
  108. package/dist/migrate/from-postgres/detect.js +7 -0
  109. package/dist/migrate/from-postgres/emit.d.ts +26 -0
  110. package/dist/migrate/from-postgres/emit.d.ts.map +1 -0
  111. package/dist/migrate/from-postgres/emit.js +86 -0
  112. package/dist/migrate/from-postgres/index.d.ts +51 -0
  113. package/dist/migrate/from-postgres/index.d.ts.map +1 -0
  114. package/dist/migrate/from-postgres/index.js +115 -0
  115. package/dist/migrate/from-postgres/translate.d.ts +73 -0
  116. package/dist/migrate/from-postgres/translate.d.ts.map +1 -0
  117. package/dist/migrate/from-postgres/translate.js +261 -0
  118. package/dist/migrate/from-postgres/types.d.ts +51 -0
  119. package/dist/migrate/from-postgres/types.d.ts.map +1 -0
  120. package/dist/migrate/from-postgres/types.js +211 -0
  121. package/dist/migration-replay.d.ts +73 -0
  122. package/dist/migration-replay.d.ts.map +1 -0
  123. package/dist/migration-replay.js +294 -0
  124. package/dist/okf/index.md +6 -0
  125. package/dist/okf/rules/SQLCH128.md +11 -0
  126. package/dist/okf/types/PostgresForeignDataWrapper.md +9 -0
  127. package/dist/okf/types/PostgresForeignServer.md +9 -0
  128. package/dist/okf/types/PostgresOperator.md +9 -0
  129. package/dist/okf/types/PostgresPublication.md +9 -0
  130. package/dist/okf/types/PostgresStatistics.md +9 -0
  131. package/dist/op/activities/postgres-migration.d.ts.map +1 -1
  132. package/dist/op/activities/postgres-migration.js +4 -0
  133. package/dist/op/activity-contracts.d.ts +20 -0
  134. package/dist/op/activity-contracts.d.ts.map +1 -1
  135. package/dist/op/activity-contracts.js +2 -0
  136. package/dist/plugin.d.ts.map +1 -1
  137. package/dist/plugin.js +36 -2
  138. package/dist/postgres/apply/apply.js +8 -1
  139. package/dist/postgres/apply/statements.d.ts.map +1 -1
  140. package/dist/postgres/apply/statements.js +87 -2
  141. package/dist/postgres/constraint-names.d.ts +30 -0
  142. package/dist/postgres/constraint-names.d.ts.map +1 -0
  143. package/dist/postgres/constraint-names.js +72 -0
  144. package/dist/postgres/entities.d.ts +110 -1
  145. package/dist/postgres/entities.d.ts.map +1 -1
  146. package/dist/postgres/entities.js +146 -0
  147. package/dist/postgres/entity-types.d.ts +5 -0
  148. package/dist/postgres/entity-types.d.ts.map +1 -1
  149. package/dist/postgres/entity-types.js +5 -0
  150. package/dist/postgres/import/generator.d.ts.map +1 -1
  151. package/dist/postgres/import/generator.js +50 -2
  152. package/dist/postgres/import/live-export.d.ts.map +1 -1
  153. package/dist/postgres/import/live-export.js +2 -0
  154. package/dist/postgres/live/catalog-kinds.d.ts +14 -0
  155. package/dist/postgres/live/catalog-kinds.d.ts.map +1 -1
  156. package/dist/postgres/live/catalog-kinds.js +206 -0
  157. package/dist/postgres/live/catalog.d.ts +2 -0
  158. package/dist/postgres/live/catalog.d.ts.map +1 -1
  159. package/dist/postgres/live/catalog.js +3 -1
  160. package/dist/postgres/live/describe-resources.d.ts.map +1 -1
  161. package/dist/postgres/live/describe-resources.js +10 -2
  162. package/dist/postgres/migrate/backfill.d.ts +2 -0
  163. package/dist/postgres/migrate/backfill.d.ts.map +1 -1
  164. package/dist/postgres/migrate/backfill.js +7 -1
  165. package/dist/postgres/migrate/carry.d.ts +45 -1
  166. package/dist/postgres/migrate/carry.d.ts.map +1 -1
  167. package/dist/postgres/migrate/carry.js +72 -8
  168. package/dist/postgres/migrate/observe.d.ts +28 -4
  169. package/dist/postgres/migrate/observe.d.ts.map +1 -1
  170. package/dist/postgres/migrate/observe.js +41 -12
  171. package/dist/postgres/migrate/op.d.ts +26 -1
  172. package/dist/postgres/migrate/op.d.ts.map +1 -1
  173. package/dist/postgres/migrate/op.js +3 -1
  174. package/dist/postgres/migrate/steps.d.ts +35 -2
  175. package/dist/postgres/migrate/steps.d.ts.map +1 -1
  176. package/dist/postgres/migrate/steps.js +158 -12
  177. package/dist/postgres/parser.d.ts +72 -1
  178. package/dist/postgres/parser.d.ts.map +1 -1
  179. package/dist/postgres/parser.js +194 -2
  180. package/dist/postgres/plan/deep.d.ts.map +1 -1
  181. package/dist/postgres/plan/deep.js +29 -1
  182. package/dist/postgres/plan/diff.d.ts.map +1 -1
  183. package/dist/postgres/plan/diff.js +39 -1
  184. package/dist/postgres/plan/normalize.d.ts +17 -2
  185. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  186. package/dist/postgres/plan/normalize.js +109 -1
  187. package/dist/postgres/plan/rules.d.ts +1 -0
  188. package/dist/postgres/plan/rules.d.ts.map +1 -1
  189. package/dist/postgres/plan/rules.js +3 -2
  190. package/dist/postgres/plan/schema.js +1 -1
  191. package/dist/postgres/secret-options.d.ts +10 -0
  192. package/dist/postgres/secret-options.d.ts.map +1 -0
  193. package/dist/postgres/secret-options.js +9 -0
  194. package/dist/postgres/testing/roundtrip-schema.d.ts +3 -2
  195. package/dist/postgres/testing/roundtrip-schema.d.ts.map +1 -1
  196. package/dist/postgres/testing/roundtrip-schema.js +13 -1
  197. package/dist/postgres.d.ts +1 -1
  198. package/dist/postgres.d.ts.map +1 -1
  199. package/dist/postgres.js +1 -1
  200. package/dist/rules/audit-entries.ts +1 -0
  201. package/dist/rules/postgres-templates.ts +2 -2
  202. package/dist/rules/sqlch128.ts +230 -0
  203. package/dist/serializer.d.ts +5 -0
  204. package/dist/serializer.d.ts.map +1 -1
  205. package/dist/serializer.js +13 -2
  206. package/dist/sql-types-command.d.ts +13 -0
  207. package/dist/sql-types-command.d.ts.map +1 -1
  208. package/dist/sql-types-command.js +31 -16
  209. package/dist/sql-types.d.ts +22 -0
  210. package/dist/sql-types.d.ts.map +1 -1
  211. package/dist/sql-types.js +10 -6
  212. package/dist/sqlserver/lexer.d.ts +37 -0
  213. package/dist/sqlserver/lexer.d.ts.map +1 -0
  214. package/dist/sqlserver/lexer.js +254 -0
  215. package/dist/sqlserver/parse.d.ts +185 -0
  216. package/dist/sqlserver/parse.d.ts.map +1 -0
  217. package/dist/sqlserver/parse.js +767 -0
  218. package/dist/sqlserver/translate.d.ts +34 -0
  219. package/dist/sqlserver/translate.d.ts.map +1 -0
  220. package/dist/sqlserver/translate.js +495 -0
  221. package/dist/sqlserver/types.d.ts +44 -0
  222. package/dist/sqlserver/types.d.ts.map +1 -0
  223. package/dist/sqlserver/types.js +133 -0
  224. package/dist/sqlserver/validate.d.ts +31 -0
  225. package/dist/sqlserver/validate.d.ts.map +1 -0
  226. package/dist/sqlserver/validate.js +67 -0
  227. package/dist/validate.d.ts.map +1 -1
  228. package/dist/validate.js +5 -0
  229. package/package.json +2 -2
  230. package/src/change-set.ts +2 -1
  231. package/src/clickhouse/apply/statements.ts +25 -0
  232. package/src/clickhouse/import/live-export.ts +20 -5
  233. package/src/clickhouse/live/named-collections.e2e.test.ts +88 -0
  234. package/src/clickhouse/live/named-collections.ts +61 -0
  235. package/src/clickhouse/live/undeclarable.e2e.test.ts +5 -3
  236. package/src/clickhouse/live/undeclarable.ts +20 -4
  237. package/src/clickhouse/named-collections.test.ts +187 -0
  238. package/src/clickhouse/named-collections.ts +224 -0
  239. package/src/clickhouse/parser.ts +2 -1
  240. package/src/clickhouse/plan/commands.ts +17 -2
  241. package/src/clickhouse/plan/diff.ts +57 -1
  242. package/src/clickhouse/plan/disruption.ts +5 -0
  243. package/src/clickhouse/plan/rebuild-handoff.ts +3 -0
  244. package/src/clickhouse/plan/report.ts +6 -1
  245. package/src/clickhouse/plan/rules.ts +14 -4
  246. package/src/clickhouse/plan/stream-s3queue.e2e.test.ts +148 -0
  247. package/src/clickhouse/plan/stream.test.ts +237 -0
  248. package/src/clickhouse/plan/stream.ts +111 -0
  249. package/src/clickhouse/rebuild/observe.ts +15 -1
  250. package/src/clickhouse/review-catalog.test.ts +181 -0
  251. package/src/clickhouse/review-catalog.ts +489 -0
  252. package/src/codegen/classifier-page.ts +21 -4
  253. package/src/codegen/clickhouse-module.ts +3 -0
  254. package/src/codegen/coverage-page.ts +5 -3
  255. package/src/codegen/docs.ts +1 -1
  256. package/src/codegen/postgres-classifier-page.ts +1 -1
  257. package/src/codegen/registry.ts +5 -0
  258. package/src/composites/catalog.test.ts +6 -1
  259. package/src/composites/catalog.ts +100 -0
  260. package/src/composites/clickhouse.ts +2 -0
  261. package/src/composites/composites.test.ts +1 -1
  262. package/src/composites/otel-tables.test.ts +118 -0
  263. package/src/composites/otel-tables.ts +339 -0
  264. package/src/composites/stream-ingest.test.ts +114 -0
  265. package/src/composites/stream-ingest.ts +82 -0
  266. package/src/core/undeclarable.test.ts +6 -5
  267. package/src/coverage/clickhouse.json +1 -1
  268. package/src/coverage/postgres.json +7 -7
  269. package/src/coverage.test.ts +27 -2
  270. package/src/coverage.ts +18 -7
  271. package/src/drizzle-command.ts +94 -0
  272. package/src/drizzle-kit.e2e.test.ts +89 -0
  273. package/src/drizzle-types.e2e.test.ts +67 -0
  274. package/src/drizzle.test.ts +358 -0
  275. package/src/drizzle.ts +737 -0
  276. package/src/files/clickhouse.ts +8 -1
  277. package/src/files/common.ts +14 -1
  278. package/src/files/postgres.ts +12 -2
  279. package/src/generated/clickhouse.ts +3 -0
  280. package/src/generated/lexicon-sql.json +25 -0
  281. package/src/import-parser.sqlserver.test.ts +92 -0
  282. package/src/import-parser.ts +79 -5
  283. package/src/index.ts +1 -1
  284. package/src/lint/post-synth/audit-entries.ts +1 -0
  285. package/src/lint/post-synth/index.ts +2 -0
  286. package/src/lint/post-synth/sqlch128.test.ts +107 -0
  287. package/src/lint/post-synth/sqlch128.ts +230 -0
  288. package/src/lint/rules/postgres-templates.ts +2 -2
  289. package/src/lsp/postgres-template.ts +11 -0
  290. package/src/mcp/postgres.ts +1 -1
  291. package/src/migrate/from-postgres/cli.test.ts +165 -0
  292. package/src/migrate/from-postgres/detect.ts +7 -0
  293. package/src/migrate/from-postgres/emit.ts +96 -0
  294. package/src/migrate/from-postgres/example.test.ts +205 -0
  295. package/src/migrate/from-postgres/index.ts +122 -0
  296. package/src/migrate/from-postgres/translate.test.ts +242 -0
  297. package/src/migrate/from-postgres/translate.ts +359 -0
  298. package/src/migrate/from-postgres/types.test.ts +116 -0
  299. package/src/migrate/from-postgres/types.ts +236 -0
  300. package/src/migrate/from-postgres/validate.e2e.test.ts +44 -0
  301. package/src/migrate/from-postgres/workspace.e2e.test.ts +114 -0
  302. package/src/migration-replay.e2e.test.ts +155 -0
  303. package/src/migration-replay.test.ts +173 -0
  304. package/src/migration-replay.ts +289 -0
  305. package/src/op/__snapshots__/watch-pipeline.test.ts.snap +2 -2
  306. package/src/op/activities/postgres-migration.ts +4 -0
  307. package/src/op/activity-contracts.ts +2 -0
  308. package/src/plugin.ts +37 -2
  309. package/src/postgres/apply/apply.ts +9 -1
  310. package/src/postgres/apply/statements.ts +74 -1
  311. package/src/postgres/constraint-names.ts +71 -0
  312. package/src/postgres/database-kinds.test.ts +384 -0
  313. package/src/postgres/entities.ts +266 -1
  314. package/src/postgres/entity-types.ts +5 -0
  315. package/src/postgres/import/generator.ts +42 -2
  316. package/src/postgres/import/live-export.ts +1 -0
  317. package/src/postgres/live/catalog-kinds.ts +223 -0
  318. package/src/postgres/live/catalog.ts +6 -1
  319. package/src/postgres/live/database-kinds.e2e.test.ts +164 -0
  320. package/src/postgres/live/describe-resources.ts +11 -2
  321. package/src/postgres/live/undeclarable.e2e.test.ts +13 -6
  322. package/src/postgres/migrate/backfill.ts +13 -1
  323. package/src/postgres/migrate/carry.ts +103 -8
  324. package/src/postgres/migrate/followups.e2e.test.ts +218 -0
  325. package/src/postgres/migrate/followups.test.ts +199 -0
  326. package/src/postgres/migrate/kinds.e2e.test.ts +5 -5
  327. package/src/postgres/migrate/observe.ts +53 -14
  328. package/src/postgres/migrate/op.ts +28 -1
  329. package/src/postgres/migrate/partition.e2e.test.ts +129 -0
  330. package/src/postgres/migrate/partition.test.ts +206 -0
  331. package/src/postgres/migrate/steps.ts +157 -12
  332. package/src/postgres/parser.ts +234 -2
  333. package/src/postgres/plan/deep.ts +28 -1
  334. package/src/postgres/plan/diff.ts +36 -1
  335. package/src/postgres/plan/normalize.ts +122 -1
  336. package/src/postgres/plan/rules.ts +10 -3
  337. package/src/postgres/plan/schema.ts +1 -1
  338. package/src/postgres/secret-options.ts +10 -0
  339. package/src/postgres/testing/roundtrip-schema.ts +13 -1
  340. package/src/postgres.ts +16 -0
  341. package/src/serializer.ts +13 -2
  342. package/src/skills/chant-sql-postgres-migration.md +3 -3
  343. package/src/skills/chant-sql-postgres.md +6 -1
  344. package/src/skills/chant-sql.md +2 -1
  345. package/src/sql-types-command.ts +38 -16
  346. package/src/sql-types.ts +11 -6
  347. package/src/sqlserver/fixture.test.ts +186 -0
  348. package/src/sqlserver/import-cli.test.ts +61 -0
  349. package/src/sqlserver/lexer.ts +260 -0
  350. package/src/sqlserver/parse.ts +865 -0
  351. package/src/sqlserver/translate.test.ts +396 -0
  352. package/src/sqlserver/translate.ts +558 -0
  353. package/src/sqlserver/types.test.ts +102 -0
  354. package/src/sqlserver/types.ts +154 -0
  355. package/src/sqlserver/validate.e2e.test.ts +32 -0
  356. package/src/sqlserver/validate.test.ts +49 -0
  357. package/src/sqlserver/validate.ts +76 -0
  358. package/src/validate.ts +5 -0
  359. package/src/workspace-kinds.test.ts +11 -0
  360. package/workspace-kinds.json +100 -2
@@ -0,0 +1,181 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { MERGE_TREE_SETTINGS, TABLE_ENGINES, TYPE_FAMILIES } from "../generated/clickhouse";
3
+ import {
4
+ CDC_DELETED_COLUMN,
5
+ CDC_VERSION_COLUMN,
6
+ REVIEW_RULES,
7
+ constraintReview,
8
+ engineReview,
9
+ findingText,
10
+ lowCardinalityReview,
11
+ nullableReview,
12
+ sortKeyReview,
13
+ } from "./review-catalog";
14
+
15
+ const types = { id: "Int64", ts: "DateTime64(3)", tenant: "String", maybe: "Nullable(Int32)" };
16
+
17
+ describe("sortKeyReview", () => {
18
+ test("a columnstore ORDER outranks a clustering key, a primary key and a unique key", () => {
19
+ const r = sortKeyReview({
20
+ object: "t",
21
+ columnTypes: types,
22
+ candidates: [
23
+ { kind: "unique-key", columns: ["tenant"], source: "UNIQUE u" },
24
+ { kind: "primary-key", columns: ["id"], source: "PRIMARY KEY pk" },
25
+ { kind: "clustering-key", columns: ["ts"], source: "CLUSTERED INDEX cx" },
26
+ { kind: "explicit-order", columns: ["tenant", "ts"], source: "COLUMNSTORE ORDER" },
27
+ ],
28
+ });
29
+ expect(r.orderBy).toEqual(["tenant", "ts"]);
30
+ expect(r.finding.ruleId).toBe("SQLRV001");
31
+ expect(r.finding.evidence[0]).toBe("COLUMNSTORE ORDER (tenant, ts)");
32
+ // The three it passed over are named in the order of their rank.
33
+ expect(r.finding.evidence.slice(1)).toEqual(["passed over: CLUSTERED INDEX cx (ts)", "passed over: PRIMARY KEY pk (id)", "passed over: UNIQUE u (tenant)"]);
34
+ });
35
+
36
+ test("with no candidate the key is tuple() and the finding says why", () => {
37
+ const r = sortKeyReview({ object: "t", columnTypes: types, candidates: [] });
38
+ expect(r.orderBy).toEqual([]);
39
+ expect(r.finding.message).toContain("tuple()");
40
+ expect(r.finding.evidence).toEqual(["no clustering key, primary key or unique key in the source"]);
41
+ });
42
+
43
+ test("a Nullable key column sets allow_nullable_key, a setting the pinned server has", () => {
44
+ const r = sortKeyReview({ object: "t", columnTypes: types, candidates: [{ kind: "primary-key", columns: ["maybe"], source: "PRIMARY KEY pk" }] });
45
+ expect(r.settings).toEqual({ allow_nullable_key: "1" });
46
+ expect(Object.keys(MERGE_TREE_SETTINGS)).toContain("allow_nullable_key");
47
+ expect(r.finding.evidence.join(" ")).toContain("allow_nullable_key = 1");
48
+ });
49
+
50
+ test("a descending column is said to be ascending here", () => {
51
+ const r = sortKeyReview({ object: "t", columnTypes: types, candidates: [{ kind: "clustering-key", columns: ["ts"], descending: ["ts"], source: "CLUSTERED INDEX cx" }] });
52
+ expect(r.finding.evidence).toContain("descending in the source, ascending here: ts");
53
+ });
54
+ });
55
+
56
+ describe("engineReview", () => {
57
+ test("an append table is MergeTree and adds no column", () => {
58
+ const r = engineReview({ object: "t", mode: "append", uniqueKey: ["id"] });
59
+ expect(r.engine).toBe("MergeTree");
60
+ expect(r.extraColumns).toEqual([]);
61
+ expect(r.finding.evidence).toContain("the source enforces uniqueness on (id); MergeTree does not");
62
+ });
63
+
64
+ test("a CDC mirror is ReplacingMergeTree(_version, _deleted) with the two columns", () => {
65
+ const r = engineReview({ object: "t", mode: "cdc", uniqueKey: ["id"] });
66
+ expect(r.engine).toBe("ReplacingMergeTree(_version, _deleted)");
67
+ expect(r.extraColumns).toEqual([CDC_VERSION_COLUMN, CDC_DELETED_COLUMN]);
68
+ });
69
+
70
+ test("a CDC mirror takes the caller's names for the two columns, in the engine, the columns and the advice (#4399)", () => {
71
+ const r = engineReview({ object: "t", mode: "cdc", cdcColumns: { version: "_peerdb_version", deleted: "_peerdb_is_deleted" } });
72
+ expect(r.engine).toBe("ReplacingMergeTree(_peerdb_version, _peerdb_is_deleted)");
73
+ expect(r.extraColumns).toEqual([
74
+ { name: "_peerdb_version", type: CDC_VERSION_COLUMN.type },
75
+ { name: "_peerdb_is_deleted", type: CDC_DELETED_COLUMN.type },
76
+ ]);
77
+ expect(r.finding.message).toContain("_peerdb_version and _peerdb_is_deleted");
78
+ expect(r.finding.suggestion).toContain("highest _peerdb_version");
79
+ expect(r.finding.suggestion).toContain("filter on _peerdb_is_deleted,");
80
+ });
81
+
82
+ test("the engines it writes exist in the pinned catalog and take the arguments it gives", () => {
83
+ expect(Object.keys(TABLE_ENGINES)).toContain("MergeTree");
84
+ const replacing = TABLE_ENGINES.ReplacingMergeTree;
85
+ expect(replacing.args?.map((a) => a.name)).toEqual(["ver", "is_deleted"]);
86
+ expect(replacing.mergeTree).toBe(true);
87
+ expect(CDC_VERSION_COLUMN.type in TYPE_FAMILIES).toBe(true);
88
+ expect(CDC_DELETED_COLUMN.type in TYPE_FAMILIES).toBe(true);
89
+ });
90
+ });
91
+
92
+ describe("nullableReview", () => {
93
+ test("a NOT NULL column keeps its type and raises nothing", () => {
94
+ expect(nullableReview({ object: "t", column: "id", type: "Int64", sourceNullable: false })).toEqual({ type: "Int64" });
95
+ });
96
+
97
+ test("a nullable column with a default says the default could replace Nullable", () => {
98
+ const r = nullableReview({ object: "t", column: "n", type: "Int32", sourceNullable: true, defaultExpr: "0" });
99
+ expect(r.type).toBe("Nullable(Int32)");
100
+ expect(r.finding?.evidence).toEqual(["n allows NULL in the source", "it has the default 0"]);
101
+ expect(r.finding?.suggestion).toContain("Int32 DEFAULT 0");
102
+ });
103
+
104
+ test("a nullable column with no default says so", () => {
105
+ const r = nullableReview({ object: "t", column: "n", type: "String", sourceNullable: true });
106
+ expect(r.type).toBe("Nullable(String)");
107
+ expect(r.finding?.evidence).toEqual(["n allows NULL in the source", "it has no default"]);
108
+ });
109
+
110
+ test("Nullable is a family of the pinned catalog", () => {
111
+ expect("Nullable" in TYPE_FAMILIES && "LowCardinality" in TYPE_FAMILIES).toBe(true);
112
+ });
113
+ });
114
+
115
+ describe("lowCardinalityReview", () => {
116
+ const at = (column: string, type = "String", maxLength?: number) => lowCardinalityReview({ object: "t", column, type, ...(maxLength !== undefined ? { maxLength } : {}) });
117
+
118
+ test("a String column named for a small set is a candidate and keeps its type", () => {
119
+ for (const name of ["status", "order_status", "OrderStatus", "country_code", "currency"]) {
120
+ const f = at(name);
121
+ expect(f?.ruleId, name).toBe("SQLRV004");
122
+ expect(f?.message).toContain("stays String");
123
+ }
124
+ });
125
+
126
+ test("the declared length is evidence when it is short", () => {
127
+ expect(at("status", "String", 20)?.evidence).toContain("the declared length is 20");
128
+ expect(at("status", "String", 4000)?.evidence.join(" ")).not.toContain("declared length");
129
+ });
130
+
131
+ test("other names, other types and words that merely end the same way are not candidates", () => {
132
+ expect(at("name")).toBeUndefined();
133
+ expect(at("birthday")).toBeUndefined();
134
+ expect(at("prostatus")).toBeUndefined();
135
+ expect(at("status", "Int32")).toBeUndefined();
136
+ });
137
+
138
+ test("a Nullable(String) column is a candidate and the suggestion names the nested form", () => {
139
+ expect(at("status", "Nullable(String)")?.suggestion).toContain("LowCardinality(Nullable(String))");
140
+ });
141
+ });
142
+
143
+ describe("constraintReview", () => {
144
+ test("a foreign key, a unique constraint and a check are comments and findings", () => {
145
+ const r = constraintReview({
146
+ object: "t",
147
+ constraints: [
148
+ { kind: "foreign-key", name: "fk", columns: ["a"], references: { table: "u", columns: ["id"] } },
149
+ { kind: "unique", name: "uq", columns: ["b", "c"] },
150
+ { kind: "check", name: "ck", expression: "([x] > 0\n AND [x] < 10)" },
151
+ ],
152
+ });
153
+ expect(r.comments).toEqual(["-- FOREIGN KEY fk (a) REFERENCES u (id)", "-- UNIQUE uq (b, c)", "-- CHECK ck ([x] > 0 AND [x] < 10)"]);
154
+ expect(r.findings.map((f) => f.ruleId)).toEqual(["SQLRV005", "SQLRV005", "SQLRV005"]);
155
+ expect(r.findings[0]?.column).toBe("a");
156
+ expect(r.findings[1]?.column).toBeUndefined();
157
+ expect(r.comments.every((c) => !c.includes("\n"))).toBe(true);
158
+ });
159
+ });
160
+
161
+ describe("constraintReview: an exclusion constraint (#4399)", () => {
162
+ test("is a comment and a finding with no column", () => {
163
+ const r = constraintReview({ object: "t", constraints: [{ kind: "exclusion", name: "no_overlap", expression: "USING gist (during WITH &&)" }] });
164
+ expect(r.comments).toEqual(["-- EXCLUDE no_overlap USING gist (during WITH &&)"]);
165
+ expect(r.findings[0]).toMatchObject({ ruleId: "SQLRV005", severity: "warning" });
166
+ expect(r.findings[0]?.column).toBeUndefined();
167
+ expect(r.findings[0]?.message).toContain("Exclusion constraint no_overlap is not enforced");
168
+ });
169
+ });
170
+
171
+ describe("the rules", () => {
172
+ test("every rule has a unique id and a name", () => {
173
+ const names = Object.values(REVIEW_RULES).map((r) => r.name);
174
+ expect(new Set(names).size).toBe(names.length);
175
+ for (const [id, r] of Object.entries(REVIEW_RULES)) expect(r.id).toBe(id);
176
+ });
177
+
178
+ test("findingText leads with the object and column, then evidence and suggestion", () => {
179
+ expect(findingText({ ruleId: "SQLRV003", severity: "warning", object: "t", column: "c", message: "m.", evidence: ["a", "b"], suggestion: "s." })).toBe("t.c: m. Evidence: a; b. Suggestion: s.");
180
+ });
181
+ });
@@ -0,0 +1,489 @@
1
+ /**
2
+ * The needs-review catalog: the judgment calls that turn a table from another
3
+ * database into a ClickHouse table, each as a finding with the evidence it
4
+ * used. A source reader (`chant import` of SQL Server, #4398; `chant migrate`
5
+ * of Postgres, #4399) does the mechanical work, a type map and a rename,
6
+ * and asks this module for everything that is a decision.
7
+ *
8
+ * Nothing here knows a source dialect. A reader describes its table in the
9
+ * neutral facts below (a candidate sort key, a column's nullability, a
10
+ * constraint) and gets back the ClickHouse choice, a finding to report, and
11
+ * for a constraint the comment lines to keep in the DDL. The rules are the
12
+ * same whichever database the table came from.
13
+ *
14
+ * ## API
15
+ *
16
+ * - {@link sortKeyReview}: `ORDER BY` from the candidates the source offers,
17
+ * in the priority `explicit-order`, `clustering-key`, `primary-key`,
18
+ * `unique-key`, then `tuple()`. Returns `orderBy`, the `settings` the key
19
+ * needs (`allow_nullable_key` when a key column is `Nullable`), and a
20
+ * finding that names the chosen candidate and the others it passed over.
21
+ * - {@link engineReview}: `MergeTree` for an append-only table;
22
+ * `ReplacingMergeTree(_version, _deleted)` for a CDC mirror, with the two
23
+ * columns it adds in `extraColumns`. `cdcColumns` names them otherwise
24
+ * (the Postgres pair uses the `CdcMirror` composite's `_is_deleted`).
25
+ * - {@link nullableReview}: a column's final type. A `NOT NULL` column keeps
26
+ * its type with no finding. A nullable column becomes `Nullable(T)` with a
27
+ * finding; when it has a default the finding says the default could
28
+ * replace `Nullable`.
29
+ * - {@link lowCardinalityReview}: a finding for a `String` column whose name
30
+ * and declared length suggest few distinct values. It never changes the
31
+ * type; offline, the name and the type are all there is to read.
32
+ * - {@link constraintReview}: foreign keys, checks, unique and exclusion
33
+ * constraints, which ClickHouse does not enforce. Returns `comments` (SQL `--` lines to
34
+ * put before the `CREATE`) and one finding per constraint.
35
+ * - {@link REVIEW_RULES}: every rule id with its summary and the ClickHouse
36
+ * documentation it rests on, for report metadata.
37
+ * - {@link findingText}: a finding as one line (object, message, evidence,
38
+ * suggestion), the form a SARIF `message` takes.
39
+ *
40
+ * A finding's `severity` is `warning` for a decision the author must confirm
41
+ * and `info` for a note that changes nothing. The caller maps findings to its
42
+ * own diagnostic type; `at` carries the source line and column when the
43
+ * reader knows them.
44
+ *
45
+ * Every ClickHouse type and engine this module writes is checked against the
46
+ * pinned catalog (`src/generated/clickhouse.ts`, ClickHouse 26.8) in
47
+ * `review-catalog.test.ts`, so a pin move that drops one fails there.
48
+ *
49
+ * Sources: the MergeTree sorting key and `allow_nullable_key`
50
+ * (https://clickhouse.com/docs/engines/table-engines/mergetree-family/mergetree),
51
+ * `ReplacingMergeTree(ver, is_deleted)`
52
+ * (https://clickhouse.com/docs/engines/table-engines/mergetree-family/replacingmergetree),
53
+ * `Nullable` (https://clickhouse.com/docs/sql-reference/data-types/nullable),
54
+ * `LowCardinality`
55
+ * (https://clickhouse.com/docs/sql-reference/data-types/lowcardinality).
56
+ */
57
+
58
+ export type ReviewSeverity = "warning" | "info";
59
+
60
+ export type ReviewRuleId =
61
+ | "SQLRV001"
62
+ | "SQLRV002"
63
+ | "SQLRV003"
64
+ | "SQLRV004"
65
+ | "SQLRV005"
66
+ | "SQLRV006"
67
+ | "SQLRV007"
68
+ | "SQLRV008"
69
+ | "SQLRV009"
70
+ | "SQLRV010"
71
+ | "SQLRV011"
72
+ | "SQLRV012";
73
+
74
+ export interface ReviewRule {
75
+ id: ReviewRuleId;
76
+ name: string;
77
+ summary: string;
78
+ docs: readonly string[];
79
+ }
80
+
81
+ const CH_MERGETREE = "https://clickhouse.com/docs/engines/table-engines/mergetree-family/mergetree";
82
+
83
+ /**
84
+ * The rules. SQLRV001 to SQLRV005 are the shared ones; the rest are raised by
85
+ * a source reader for what only its dialect has, and live here so a report
86
+ * lists one catalog.
87
+ */
88
+ export const REVIEW_RULES: Readonly<Record<ReviewRuleId, ReviewRule>> = {
89
+ SQLRV001: {
90
+ id: "SQLRV001",
91
+ name: "sort-key",
92
+ summary: "The ORDER BY key is a suggestion from the source's indexes.",
93
+ docs: [CH_MERGETREE],
94
+ },
95
+ SQLRV002: {
96
+ id: "SQLRV002",
97
+ name: "engine",
98
+ summary: "The table engine is a choice the source does not make.",
99
+ docs: [CH_MERGETREE, "https://clickhouse.com/docs/engines/table-engines/mergetree-family/replacingmergetree"],
100
+ },
101
+ SQLRV003: {
102
+ id: "SQLRV003",
103
+ name: "nullable",
104
+ summary: "A nullable column becomes Nullable(T), which costs storage and speed; a default can replace it.",
105
+ docs: ["https://clickhouse.com/docs/sql-reference/data-types/nullable"],
106
+ },
107
+ SQLRV004: {
108
+ id: "SQLRV004",
109
+ name: "low-cardinality",
110
+ summary: "A String column that may hold few distinct values could be LowCardinality(String).",
111
+ docs: ["https://clickhouse.com/docs/sql-reference/data-types/lowcardinality"],
112
+ },
113
+ SQLRV005: {
114
+ id: "SQLRV005",
115
+ name: "constraint",
116
+ summary: "ClickHouse does not enforce this constraint; it is kept as a comment.",
117
+ docs: ["https://clickhouse.com/docs/sql-reference/statements/create/table"],
118
+ },
119
+ SQLRV006: {
120
+ id: "SQLRV006",
121
+ name: "generated-column",
122
+ summary: "An identity, sequence, computed or system-generated column has no ClickHouse equivalent.",
123
+ docs: ["https://clickhouse.com/docs/sql-reference/statements/create/table"],
124
+ },
125
+ SQLRV007: {
126
+ id: "SQLRV007",
127
+ name: "type-judgment",
128
+ summary: "The type has no exact ClickHouse match; the nearest one was chosen.",
129
+ docs: ["https://clickhouse.com/docs/sql-reference/data-types"],
130
+ },
131
+ SQLRV008: {
132
+ id: "SQLRV008",
133
+ name: "default-expression",
134
+ summary: "A default expression was translated or dropped.",
135
+ docs: ["https://clickhouse.com/docs/sql-reference/statements/create/table"],
136
+ },
137
+ SQLRV009: {
138
+ id: "SQLRV009",
139
+ name: "storage-option",
140
+ summary: "A storage option of the source (filegroup, partitioning, compression, versioning) has no counterpart here.",
141
+ docs: [CH_MERGETREE],
142
+ },
143
+ SQLRV010: {
144
+ id: "SQLRV010",
145
+ name: "query-text",
146
+ summary: "A view body is copied as written and is not translated.",
147
+ docs: ["https://clickhouse.com/docs/sql-reference/statements/create/view"],
148
+ },
149
+ SQLRV011: {
150
+ id: "SQLRV011",
151
+ name: "not-carried",
152
+ summary: "An object or statement of the source is not carried over, and is named here.",
153
+ docs: [],
154
+ },
155
+ SQLRV012: {
156
+ id: "SQLRV012",
157
+ name: "emulator-rejected",
158
+ summary: "The ClickHouse emulator refused a statement the import would write.",
159
+ docs: [],
160
+ },
161
+ };
162
+
163
+ export interface SourcePosition {
164
+ /** 1-based. */
165
+ line: number;
166
+ /** 1-based. */
167
+ column: number;
168
+ }
169
+
170
+ export interface ReviewFinding {
171
+ ruleId: ReviewRuleId;
172
+ severity: ReviewSeverity;
173
+ /** The table or view the finding is about, as the source names it. */
174
+ object: string;
175
+ /** The column, when the finding is about one. */
176
+ column?: string;
177
+ message: string;
178
+ /** What the decision was read from: each entry is a fact in the source. */
179
+ evidence: readonly string[];
180
+ suggestion?: string;
181
+ at?: SourcePosition;
182
+ }
183
+
184
+ /** A finding as one line. */
185
+ export function findingText(f: ReviewFinding): string {
186
+ const where = f.column ? `${f.object}.${f.column}` : f.object;
187
+ const parts = [`${where}: ${f.message}`];
188
+ if (f.evidence.length > 0) parts.push(`Evidence: ${f.evidence.join("; ")}.`);
189
+ if (f.suggestion) parts.push(`Suggestion: ${f.suggestion}`);
190
+ return parts.join(" ");
191
+ }
192
+
193
+ const q = (name: string): string => (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : `\`${name.replace(/`/g, "\\`")}\``);
194
+ const list = (names: readonly string[]): string => names.join(", ");
195
+
196
+ // --- sort key -------------------------------------------------------------
197
+
198
+ /**
199
+ * Where a candidate sort key came from, strongest first. `explicit-order` is
200
+ * an ordering the author declared for storage (a columnstore `ORDER`),
201
+ * `clustering-key` the key the source stores rows in (a clustered index),
202
+ * then the primary key and the first unique key.
203
+ */
204
+ export type SortKeyKind = "explicit-order" | "clustering-key" | "primary-key" | "unique-key";
205
+
206
+ const SORT_KEY_PRIORITY: readonly SortKeyKind[] = ["explicit-order", "clustering-key", "primary-key", "unique-key"];
207
+
208
+ export interface SortKeyCandidate {
209
+ kind: SortKeyKind;
210
+ /** The index or constraint name, for the evidence. */
211
+ name?: string;
212
+ columns: readonly string[];
213
+ /** Columns the source sorts descending; ClickHouse's `ORDER BY` drops the direction. */
214
+ descending?: readonly string[];
215
+ /** The fact this candidate was read from, e.g. `CREATE CLUSTERED INDEX CX_t`. */
216
+ source: string;
217
+ }
218
+
219
+ export interface SortKeyInput {
220
+ object: string;
221
+ candidates: readonly SortKeyCandidate[];
222
+ /** Each column's ClickHouse type, by name; `Nullable(...)` marks a nullable key column. */
223
+ columnTypes: Readonly<Record<string, string>>;
224
+ at?: SourcePosition;
225
+ }
226
+
227
+ export interface SortKeyResult {
228
+ orderBy: string[];
229
+ /** MergeTree settings the key needs. */
230
+ settings: Record<string, string>;
231
+ finding: ReviewFinding;
232
+ }
233
+
234
+ export function sortKeyReview(input: SortKeyInput): SortKeyResult {
235
+ const ranked = SORT_KEY_PRIORITY.flatMap((kind) => input.candidates.filter((c) => c.kind === kind && c.columns.length > 0));
236
+ const chosen = ranked[0];
237
+ const evidence: string[] = [];
238
+ const settings: Record<string, string> = {};
239
+ let orderBy: string[] = [];
240
+ let message: string;
241
+ let suggestion: string | undefined;
242
+ if (chosen) {
243
+ orderBy = [...chosen.columns];
244
+ evidence.push(`${chosen.source} (${list(chosen.columns)})`);
245
+ for (const other of ranked.slice(1)) evidence.push(`passed over: ${other.source} (${list(other.columns)})`);
246
+ if (chosen.descending && chosen.descending.length > 0) evidence.push(`descending in the source, ascending here: ${list(chosen.descending)}`);
247
+ const nullable = orderBy.filter((c) => (input.columnTypes[c] ?? "").startsWith("Nullable("));
248
+ if (nullable.length > 0) {
249
+ settings.allow_nullable_key = "1";
250
+ evidence.push(`Nullable key column${nullable.length > 1 ? "s" : ""}: ${list(nullable)}, so allow_nullable_key = 1 is set`);
251
+ }
252
+ message = `ORDER BY (${list(orderBy.map(q))}) suggested from the ${chosen.kind.replace("-", " ")}.`;
253
+ suggestion = "Put the columns your queries filter on first, and the one with the fewest distinct values before the one with the most.";
254
+ } else {
255
+ orderBy = [];
256
+ evidence.push("no clustering key, primary key or unique key in the source");
257
+ message = "ORDER BY tuple() used because the source gives no key to suggest.";
258
+ suggestion = "Choose the columns your queries filter on; a MergeTree table without a sort key cannot skip granules.";
259
+ }
260
+ return {
261
+ orderBy,
262
+ settings,
263
+ finding: { ruleId: "SQLRV001", severity: "warning", object: input.object, message, evidence, ...(suggestion ? { suggestion } : {}), ...(input.at ? { at: input.at } : {}) },
264
+ };
265
+ }
266
+
267
+ // --- engine ---------------------------------------------------------------
268
+
269
+ export interface EngineInput {
270
+ object: string;
271
+ /**
272
+ * `append` for a table that is loaded and queried, `cdc` for a mirror of a
273
+ * table that changes in place (#4399): rows arrive as new versions and a
274
+ * delete arrives as a marker row.
275
+ */
276
+ mode: "append" | "cdc";
277
+ /** Facts about the source table that bear on the choice, as plain sentences. */
278
+ facts?: readonly string[];
279
+ /** The columns the source enforces unique, when it does. */
280
+ uniqueKey?: readonly string[];
281
+ /**
282
+ * For `cdc`, the names of the version and deleted columns, when the pipeline
283
+ * or the `CdcMirror` composite names them other than `_version` and
284
+ * `_deleted`. The names are the caller's; the catalog only writes them.
285
+ */
286
+ cdcColumns?: { version: string; deleted: string };
287
+ at?: SourcePosition;
288
+ }
289
+
290
+ export interface EngineResult {
291
+ /** The text after `ENGINE =`. */
292
+ engine: string;
293
+ /** Columns the engine needs and the source does not have. */
294
+ extraColumns: Array<{ name: string; type: string }>;
295
+ finding: ReviewFinding;
296
+ }
297
+
298
+ export const CDC_VERSION_COLUMN = { name: "_version", type: "UInt64" } as const;
299
+ export const CDC_DELETED_COLUMN = { name: "_deleted", type: "UInt8" } as const;
300
+
301
+ export function engineReview(input: EngineInput): EngineResult {
302
+ const evidence = [...(input.facts ?? [])];
303
+ const unique = input.uniqueKey && input.uniqueKey.length > 0 ? list(input.uniqueKey) : undefined;
304
+ if (input.mode === "cdc") {
305
+ evidence.push(`the table is mirrored from a source that updates rows in place${unique ? `, keyed on (${unique})` : ""}`);
306
+ const version = input.cdcColumns?.version ?? CDC_VERSION_COLUMN.name;
307
+ const deleted = input.cdcColumns?.deleted ?? CDC_DELETED_COLUMN.name;
308
+ return {
309
+ engine: `ReplacingMergeTree(${version}, ${deleted})`,
310
+ extraColumns: [
311
+ { name: version, type: CDC_VERSION_COLUMN.type },
312
+ { name: deleted, type: CDC_DELETED_COLUMN.type },
313
+ ],
314
+ finding: {
315
+ ruleId: "SQLRV002",
316
+ severity: "warning",
317
+ object: input.object,
318
+ message: `ReplacingMergeTree(${version}, ${deleted}) chosen for a mirror; it adds the columns ${version} and ${deleted}.`,
319
+ evidence,
320
+ suggestion: `The sort key must be the source key, because rows with an equal sort key collapse to the highest ${version} on merge. Read with FINAL, or filter on ${deleted}, to see the collapsed rows before a merge.`,
321
+ ...(input.at ? { at: input.at } : {}),
322
+ },
323
+ };
324
+ }
325
+ if (unique) evidence.push(`the source enforces uniqueness on (${unique}); MergeTree does not`);
326
+ return {
327
+ engine: "MergeTree",
328
+ extraColumns: [],
329
+ finding: {
330
+ ruleId: "SQLRV002",
331
+ severity: "warning",
332
+ object: input.object,
333
+ message: "MergeTree chosen; the source has no engine to carry over.",
334
+ evidence,
335
+ suggestion: unique
336
+ ? "If rows can arrive twice, ReplacingMergeTree collapses duplicates of the sort key on merge, not on insert."
337
+ : "Pick another MergeTree variant for pre-aggregation (SummingMergeTree, AggregatingMergeTree) when the table is a rollup.",
338
+ ...(input.at ? { at: input.at } : {}),
339
+ },
340
+ };
341
+ }
342
+
343
+ // --- Nullable -------------------------------------------------------------
344
+
345
+ export interface NullableInput {
346
+ object: string;
347
+ column: string;
348
+ /** The column's ClickHouse type before `Nullable`. */
349
+ type: string;
350
+ sourceNullable: boolean;
351
+ /** The default as the ClickHouse expression it became, or the source text when it did not translate. */
352
+ defaultExpr?: string;
353
+ at?: SourcePosition;
354
+ }
355
+
356
+ export interface NullableResult {
357
+ type: string;
358
+ finding?: ReviewFinding;
359
+ }
360
+
361
+ export function nullableReview(input: NullableInput): NullableResult {
362
+ if (!input.sourceNullable) return { type: input.type };
363
+ const evidence = [`${input.column} allows NULL in the source`];
364
+ let suggestion: string;
365
+ if (input.defaultExpr !== undefined) {
366
+ evidence.push(`it has the default ${input.defaultExpr}`);
367
+ suggestion = `If NULL is never stored on purpose, declare ${input.type} DEFAULT ${input.defaultExpr} and load the default instead of NULL.`;
368
+ } else {
369
+ evidence.push("it has no default");
370
+ suggestion = `If a sentinel value can stand for missing, declare ${input.type} DEFAULT <sentinel>; a Nullable column keeps a separate null mask.`;
371
+ }
372
+ return {
373
+ type: `Nullable(${input.type})`,
374
+ finding: {
375
+ ruleId: "SQLRV003",
376
+ severity: "warning",
377
+ object: input.object,
378
+ column: input.column,
379
+ message: `Kept as Nullable(${input.type}) so no stored NULL is lost.`,
380
+ evidence,
381
+ suggestion,
382
+ ...(input.at ? { at: input.at } : {}),
383
+ },
384
+ };
385
+ }
386
+
387
+ // --- LowCardinality -------------------------------------------------------
388
+
389
+ /** Last words of a column name that usually mean a small set of values. */
390
+ const LOW_CARDINALITY_WORDS = new Set("status state type kind category country currency region level channel gender lang language locale unit code flag method mode grade tier priority".split(" "));
391
+
392
+ /** A column name's last word: `order_status` and `OrderStatus` both give `status`. */
393
+ function lastWord(name: string): string {
394
+ const words = name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").split(/[^A-Za-z0-9]+/).filter(Boolean);
395
+ return (words[words.length - 1] ?? "").toLowerCase();
396
+ }
397
+
398
+ export interface LowCardinalityInput {
399
+ object: string;
400
+ column: string;
401
+ /** The ClickHouse type as emitted. Only `String` and `Nullable(String)` are candidates. */
402
+ type: string;
403
+ /** The source's declared maximum length, when it has one. */
404
+ maxLength?: number;
405
+ at?: SourcePosition;
406
+ }
407
+
408
+ /** The longest declared length that still counts as supporting evidence. */
409
+ export const LOW_CARDINALITY_MAX_LENGTH = 50;
410
+
411
+ export function lowCardinalityReview(input: LowCardinalityInput): ReviewFinding | undefined {
412
+ if (input.type !== "String" && input.type !== "Nullable(String)") return undefined;
413
+ if (!LOW_CARDINALITY_WORDS.has(lastWord(input.column))) return undefined;
414
+ const evidence = [`the name ${input.column} ends in a word that usually names a small set of values`, `the type is ${input.type}`];
415
+ if (input.maxLength !== undefined && input.maxLength <= LOW_CARDINALITY_MAX_LENGTH) evidence.push(`the declared length is ${String(input.maxLength)}`);
416
+ evidence.push("no data was read, so the distinct count is unknown");
417
+ const wrapped = input.type.startsWith("Nullable(") ? "LowCardinality(Nullable(String))" : "LowCardinality(String)";
418
+ return {
419
+ ruleId: "SQLRV004",
420
+ severity: "warning",
421
+ object: input.object,
422
+ column: input.column,
423
+ message: `${input.column} is a LowCardinality candidate and stays ${input.type}.`,
424
+ evidence,
425
+ suggestion: `ClickHouse documents ${wrapped} as helpful below roughly ten thousand distinct values; count them with uniqExact(${q(input.column)}) before changing the type.`,
426
+ ...(input.at ? { at: input.at } : {}),
427
+ };
428
+ }
429
+
430
+ // --- constraints ----------------------------------------------------------
431
+
432
+ export interface ConstraintFact {
433
+ kind: "foreign-key" | "check" | "unique" | "exclusion";
434
+ name?: string;
435
+ columns?: readonly string[];
436
+ references?: { table: string; columns: readonly string[] };
437
+ /** A check's expression, or an exclusion's `USING method (element WITH operator, ...)`, as the source wrote it. */
438
+ expression?: string;
439
+ at?: SourcePosition;
440
+ }
441
+
442
+ export interface ConstraintResult {
443
+ /** `-- ` lines to put before the `CREATE`; never contain a line break. */
444
+ comments: string[];
445
+ findings: ReviewFinding[];
446
+ }
447
+
448
+ const oneLine = (s: string): string => s.replace(/\s+/g, " ").trim();
449
+
450
+ export function constraintReview(input: { object: string; constraints: readonly ConstraintFact[] }): ConstraintResult {
451
+ const comments: string[] = [];
452
+ const findings: ReviewFinding[] = [];
453
+ for (const c of input.constraints) {
454
+ const label = c.name ? ` ${c.name}` : "";
455
+ let text: string;
456
+ let message: string;
457
+ let suggestion: string;
458
+ if (c.kind === "foreign-key") {
459
+ const target = c.references ? `${c.references.table} (${list(c.references.columns)})` : "an unknown table";
460
+ text = `FOREIGN KEY${label} (${list(c.columns ?? [])}) REFERENCES ${target}`;
461
+ message = `Foreign key${label} is not enforced by ClickHouse and is kept as a comment.`;
462
+ suggestion = "Check referential integrity in the load pipeline, or join on the column and accept orphan rows.";
463
+ } else if (c.kind === "unique") {
464
+ text = `UNIQUE${label} (${list(c.columns ?? [])})`;
465
+ message = `Unique constraint${label} is not enforced by ClickHouse and is kept as a comment.`;
466
+ suggestion = "A MergeTree sort key does not make rows unique. ReplacingMergeTree collapses duplicates on merge only.";
467
+ } else if (c.kind === "exclusion") {
468
+ text = `EXCLUDE${label} ${oneLine(c.expression ?? "")}`;
469
+ message = `Exclusion constraint${label} is not enforced by ClickHouse and is kept as a comment.`;
470
+ suggestion = "ClickHouse has no constraint that rejects overlapping rows. Check for overlaps in the load, or in a query.";
471
+ } else {
472
+ text = `CHECK${label} ${oneLine(c.expression ?? "")}`;
473
+ message = `Check constraint${label} is not translated and is kept as a comment.`;
474
+ suggestion = "ClickHouse has CONSTRAINT name CHECK expr, enforced on INSERT. Rewrite the expression in ClickHouse SQL to use it.";
475
+ }
476
+ comments.push(`-- ${oneLine(text)}`);
477
+ findings.push({
478
+ ruleId: "SQLRV005",
479
+ severity: "warning",
480
+ object: input.object,
481
+ ...(c.kind !== "check" && c.kind !== "exclusion" && c.columns && c.columns.length === 1 ? { column: c.columns[0]! } : {}),
482
+ message,
483
+ evidence: [oneLine(text)],
484
+ suggestion,
485
+ ...(c.at ? { at: c.at } : {}),
486
+ });
487
+ }
488
+ return { comments, findings };
489
+ }