@mandujs/core 0.53.3 → 0.54.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +654 -654
- package/package.json +1 -1
- package/src/a11y/__tests__/run-audit.test.ts +333 -333
- package/src/a11y/fix-hints.ts +76 -76
- package/src/a11y/index.ts +18 -18
- package/src/a11y/run-audit.ts +394 -394
- package/src/a11y/types.ts +125 -125
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +122 -122
- package/src/auth/__tests__/tokens.test.ts +274 -274
- package/src/auth/__tests__/verification.test.ts +274 -274
- package/src/auth/index.ts +76 -76
- package/src/auth/login.ts +225 -225
- package/src/auth/password.ts +120 -120
- package/src/auth/reset.ts +243 -243
- package/src/auth/tokens.ts +612 -612
- package/src/auth/verification.ts +253 -253
- package/src/brain/__tests__/redactor.test.ts +94 -94
- package/src/brain/adapters/__tests__/_helpers.ts +83 -83
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
- package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
- package/src/brain/adapters/anthropic-oauth.ts +1 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -300
- package/src/brain/adapters/index.ts +319 -319
- package/src/brain/adapters/oauth-flow.ts +439 -439
- package/src/brain/consent.ts +240 -240
- package/src/brain/credentials.ts +396 -396
- package/src/bundler/__tests__/build-runner.ts +113 -113
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
- package/src/bundler/__tests__/extended-watch.test.ts +711 -711
- package/src/bundler/__tests__/fast-refresh.test.ts +10 -10
- package/src/bundler/__tests__/hdr.test.ts +353 -353
- package/src/bundler/__tests__/hmr-client.test.ts +532 -532
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -519
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +179 -179
- package/src/bundler/build.ts +55 -55
- package/src/bundler/dev.ts +42 -42
- package/src/bundler/manifest-schema.ts +301 -301
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +263 -263
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
- package/src/bundler/plugins/block-generated-imports.ts +155 -155
- package/src/bundler/plugins/index.ts +83 -83
- package/src/bundler/plugins/react-compiler-config.ts +108 -108
- package/src/bundler/plugins/react-compiler-lint.ts +253 -253
- package/src/bundler/plugins/react-compiler.ts +162 -162
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +201 -201
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +10 -10
- package/src/bundler/vendor-cache-types.ts +130 -130
- package/src/bundler/vendor-cache.ts +526 -526
- package/src/change/snapshot.ts +18 -18
- package/src/client/Form.tsx +105 -105
- package/src/client/Link.tsx +9 -9
- package/src/client/__tests__/use-sse.test.ts +153 -153
- package/src/client/globals.ts +1 -1
- package/src/client/hooks.ts +362 -362
- package/src/client/hydrate.ts +340 -340
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/router.ts +11 -11
- package/src/client/runtime.ts +47 -47
- package/src/client/serialize.ts +404 -404
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-head.ts +197 -197
- package/src/client/use-sse.ts +378 -378
- package/src/client/window-state.ts +101 -101
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +3 -3
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +8 -8
- package/src/content/content-layer.ts +7 -7
- package/src/content/data-store.ts +245 -245
- package/src/content/frontmatter.ts +189 -189
- package/src/content/loader-context.ts +171 -171
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +172 -172
- package/src/content/loaders/glob.ts +253 -253
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/prebuild.test.ts +571 -571
- package/src/content/prebuild.ts +636 -636
- package/src/content/schema.ts +20 -20
- package/src/content/sidebar.ts +630 -630
- package/src/content/slug.ts +110 -110
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/define.ts +11 -11
- package/src/contract/index.ts +1 -1
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +410 -410
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/route-helpers.ts +1 -1
- package/src/contract/rpc.ts +443 -443
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +482 -482
- package/src/db/index.ts +138 -138
- package/src/db/migrations/history-table.ts +345 -345
- package/src/db/migrations/index.ts +3 -3
- package/src/db/migrations/lock.ts +324 -324
- package/src/db/migrations/runner.ts +650 -650
- package/src/deploy/cache.ts +140 -140
- package/src/deploy/compile/vercel.ts +344 -344
- package/src/deploy/index.ts +87 -87
- package/src/deploy/inference/brain.ts +268 -268
- package/src/deploy/inference/context.ts +182 -182
- package/src/deploy/inference/filling-extract.ts +245 -245
- package/src/deploy/inference/heuristic.ts +182 -182
- package/src/deploy/intent.ts +173 -173
- package/src/deploy/plan.ts +178 -178
- package/src/design/__tests__/agents-link.test.ts +109 -109
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
- package/src/design/__tests__/lint.test.ts +110 -110
- package/src/design/__tests__/parser.test.ts +195 -195
- package/src/design/__tests__/tailwind-theme.test.ts +229 -229
- package/src/design/agents-link.ts +165 -165
- package/src/design/diff.ts +138 -138
- package/src/design/extract.ts +285 -285
- package/src/design/index.ts +102 -102
- package/src/design/lint.ts +209 -209
- package/src/design/parser.ts +555 -555
- package/src/design/patch.ts +241 -241
- package/src/design/scaffold.ts +147 -147
- package/src/design/tailwind-theme.ts +441 -441
- package/src/design/types.ts +210 -210
- package/src/desktop/__tests__/webview-fallback.test.ts +254 -254
- package/src/desktop/__tests__/window.test.ts +248 -248
- package/src/desktop/__tests__/worker.test.ts +266 -266
- package/src/desktop/index.ts +43 -43
- package/src/desktop/types.ts +158 -158
- package/src/desktop/worker.ts +180 -180
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
- package/src/dev-error-overlay/index.ts +30 -30
- package/src/dev-error-overlay/overlay-injector.ts +243 -243
- package/src/dev-error-overlay/overlay-styles.ts +52 -52
- package/src/dev-error-overlay/types.ts +66 -66
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +25 -25
- package/src/devtools/client/catchers/error-catcher.ts +344 -344
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/mandu-character.tsx +331 -331
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
- package/src/devtools/client/components/panel/islands-panel.tsx +320 -320
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +500 -500
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +265 -265
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +450 -450
- package/src/devtools/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/devtools/worker/worker-manager.ts +410 -410
- package/src/diagnose/__tests__/checks.test.ts +451 -451
- package/src/diagnose/checks.ts +832 -832
- package/src/diagnose/index.ts +17 -17
- package/src/diagnose/run.ts +93 -93
- package/src/diagnose/types.ts +53 -53
- package/src/email/__tests__/email.test.ts +355 -355
- package/src/email/index.ts +282 -282
- package/src/email/smtp.ts +64 -64
- package/src/error/domains.ts +265 -265
- package/src/error/result.ts +60 -60
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/__tests__/session-sqlite.test.ts +454 -454
- package/src/filling/auth.ts +308 -308
- package/src/filling/cookie-codec.ts +299 -299
- package/src/filling/deps.ts +265 -265
- package/src/filling/session-sqlite.ts +617 -617
- package/src/filling/sse.ts +5 -5
- package/src/filling/ws.ts +78 -78
- package/src/generator/index.ts +3 -3
- package/src/guard/__tests__/design-inline-class.test.ts +219 -219
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/define-rule.ts +243 -243
- package/src/guard/design-inline-class.ts +393 -393
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/reporter.ts +442 -442
- package/src/guard/rule-presets.ts +379 -379
- package/src/guard/semantic-slots.ts +1 -1
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/tsgolint-bridge.ts +512 -512
- package/src/guard/types.ts +348 -348
- package/src/guard/watcher.ts +405 -405
- package/src/i18n/define.ts +126 -126
- package/src/i18n/index.ts +52 -52
- package/src/i18n/message-registry.ts +173 -173
- package/src/i18n/types.ts +112 -112
- package/src/id/index.ts +105 -105
- package/src/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -544
- package/src/kitchen/api/errors-grouping.ts +126 -0
- package/src/kitchen/kitchen-handler.ts +192 -63
- package/src/kitchen/kitchen-ui.ts +842 -464
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/middleware/bridge.ts +147 -147
- package/src/middleware/compose.ts +134 -134
- package/src/middleware/compress.ts +62 -62
- package/src/middleware/cors.ts +47 -47
- package/src/middleware/csrf.ts +328 -328
- package/src/middleware/define.ts +132 -132
- package/src/middleware/jwt.ts +134 -134
- package/src/middleware/logger.ts +58 -58
- package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
- package/src/middleware/oauth/index.ts +505 -505
- package/src/middleware/oauth/providers.ts +115 -115
- package/src/middleware/rate-limit/index.ts +522 -522
- package/src/middleware/rate-limit/sqlite-store.ts +382 -382
- package/src/middleware/scheduler-cron.ts +96 -96
- package/src/middleware/secure/__tests__/secure.test.ts +360 -360
- package/src/middleware/secure/csp.ts +193 -193
- package/src/middleware/session.ts +174 -174
- package/src/middleware/timeout.ts +55 -55
- package/src/observability/logger-adapter.ts +36 -36
- package/src/observability/sqlite-store.ts +254 -254
- package/src/openapi/generator.ts +1 -1
- package/src/openapi/openapi.test.ts +43 -43
- package/src/perf/__tests__/user-marks.test.ts +354 -354
- package/src/perf/index.ts +133 -133
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
- package/src/plugins/__tests__/runner.test.ts +409 -409
- package/src/plugins/define.ts +124 -124
- package/src/plugins/examples/dep-check-plugin.ts +80 -80
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
- package/src/plugins/examples/sitemap-plugin.ts +65 -65
- package/src/plugins/runner.ts +361 -361
- package/src/plugins/types.ts +368 -368
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +32 -32
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +823 -823
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +559 -559
- package/src/resource/ddl/persistence-types.ts +218 -218
- package/src/resource/ddl/type-map.ts +223 -223
- package/src/resource/ddl/types.ts +232 -232
- package/src/resource/generator-repo.ts +630 -630
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.ts +6 -6
- package/src/routes/index.ts +74 -74
- package/src/routes/metadata-routes.ts +427 -427
- package/src/routes/types.ts +341 -341
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
- package/src/runtime/__tests__/hdr-client.test.ts +223 -223
- package/src/runtime/__tests__/http-errors.test.ts +117 -117
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/adapter.ts +47 -47
- package/src/runtime/boundary.tsx +252 -252
- package/src/runtime/cache.ts +494 -494
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +300 -300
- package/src/runtime/http-errors.ts +113 -113
- package/src/runtime/image-handler.ts +1 -1
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/middleware.ts +264 -264
- package/src/runtime/not-found.ts +93 -93
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/router.ts +105 -105
- package/src/runtime/server.ts +67 -67
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/validate.ts +169 -169
- package/src/seo/index.ts +219 -219
- package/src/seo/integration/ssr.ts +306 -306
- package/src/seo/render/basic.ts +435 -435
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +197 -197
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +127 -127
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +104 -104
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +588 -588
- package/src/slot/validator.ts +39 -39
- package/src/storage/s3/__tests__/s3.test.ts +479 -479
- package/src/storage/s3/index.ts +412 -412
- package/src/testing/__tests__/assertions.test.ts +632 -632
- package/src/testing/__tests__/reporter.test.ts +454 -454
- package/src/testing/assertions.ts +986 -986
- package/src/testing/db.ts +157 -157
- package/src/testing/mocks.ts +203 -203
- package/src/testing/session.ts +190 -190
- package/src/types/branded.ts +56 -56
- package/src/types/index.ts +1 -1
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/watcher.ts +18 -18
|
@@ -1,823 +1,823 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Phase 4c — DDL emit engine tests.
|
|
3
|
-
*
|
|
4
|
-
* Structure:
|
|
5
|
-
* 1. `quoteIdent` — identifier quoting + rejection rules (security).
|
|
6
|
-
* 2. `resolveColumnType` — dialect-matrix over 10 field types.
|
|
7
|
-
* 3. `resolveDefault` + `nowExpr` — default literal + NOW() parity.
|
|
8
|
-
* 4. `emitCreateTable` — column + constraint + index emission.
|
|
9
|
-
* 5. `emitChange` — one section per `Change.kind`.
|
|
10
|
-
* 6. `emitSchema` / `emitChanges` — multi-resource + empty-input cases.
|
|
11
|
-
* 7. Injection-resistance regression (Agent G will cross-check).
|
|
12
|
-
*
|
|
13
|
-
* Each test that varies by dialect uses `describe.each([...providers])`
|
|
14
|
-
* to guarantee parity without manual duplication.
|
|
15
|
-
*/
|
|
16
|
-
import { describe, expect, test } from "bun:test";
|
|
17
|
-
|
|
18
|
-
import {
|
|
19
|
-
emitChange,
|
|
20
|
-
emitChanges,
|
|
21
|
-
emitCreateTable,
|
|
22
|
-
emitDropTable,
|
|
23
|
-
emitSchema,
|
|
24
|
-
quoteIdent,
|
|
25
|
-
} from "../emit";
|
|
26
|
-
import { nowExpr, resolveColumnType, resolveDefault } from "../type-map";
|
|
27
|
-
import type {
|
|
28
|
-
Change,
|
|
29
|
-
DdlFieldDef,
|
|
30
|
-
DdlFieldType,
|
|
31
|
-
DdlIndex,
|
|
32
|
-
DdlResource,
|
|
33
|
-
SqlProvider,
|
|
34
|
-
} from "../types";
|
|
35
|
-
|
|
36
|
-
// ---------------------------------------------------------------------
|
|
37
|
-
// Shared fixtures / helpers
|
|
38
|
-
// ---------------------------------------------------------------------
|
|
39
|
-
|
|
40
|
-
const PROVIDERS: readonly SqlProvider[] = ["postgres", "mysql", "sqlite"];
|
|
41
|
-
|
|
42
|
-
function field(partial: Partial<DdlFieldDef> & { name: string; type: DdlFieldType }): DdlFieldDef {
|
|
43
|
-
return {
|
|
44
|
-
nullable: false,
|
|
45
|
-
primary: false,
|
|
46
|
-
unique: false,
|
|
47
|
-
indexed: false,
|
|
48
|
-
...partial,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function resource(name: string, fields: DdlFieldDef[], indexes: DdlIndex[] = []): DdlResource {
|
|
53
|
-
return { name, fields, indexes };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const USER_RESOURCE: DdlResource = resource("users", [
|
|
57
|
-
field({ name: "id", type: "uuid", primary: true }),
|
|
58
|
-
field({ name: "email", type: "email", unique: true }),
|
|
59
|
-
field({ name: "name", type: "string", maxLength: 120 }),
|
|
60
|
-
]);
|
|
61
|
-
|
|
62
|
-
// ---------------------------------------------------------------------
|
|
63
|
-
// 1. quoteIdent
|
|
64
|
-
// ---------------------------------------------------------------------
|
|
65
|
-
|
|
66
|
-
describe("quoteIdent", () => {
|
|
67
|
-
test("postgres wraps in double quotes", () => {
|
|
68
|
-
expect(quoteIdent("users", "postgres")).toBe('"users"');
|
|
69
|
-
});
|
|
70
|
-
test("mysql wraps in backticks", () => {
|
|
71
|
-
expect(quoteIdent("users", "mysql")).toBe("`users`");
|
|
72
|
-
});
|
|
73
|
-
test("sqlite wraps in double quotes", () => {
|
|
74
|
-
expect(quoteIdent("users", "sqlite")).toBe('"users"');
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("rejects names containing a double quote (postgres)", () => {
|
|
78
|
-
expect(() => quoteIdent('bad"name', "postgres")).toThrow(/unquotable character/);
|
|
79
|
-
});
|
|
80
|
-
test("rejects names containing a double quote (sqlite)", () => {
|
|
81
|
-
expect(() => quoteIdent('bad"name', "sqlite")).toThrow(/unquotable character/);
|
|
82
|
-
});
|
|
83
|
-
test("rejects names containing a backtick (mysql)", () => {
|
|
84
|
-
expect(() => quoteIdent("bad`name", "mysql")).toThrow(/unquotable character/);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("rejects empty string on every provider", () => {
|
|
88
|
-
for (const p of PROVIDERS) {
|
|
89
|
-
expect(() => quoteIdent("", p)).toThrow(/must not be empty/);
|
|
90
|
-
}
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test("rejects identifiers longer than 63 chars", () => {
|
|
94
|
-
const long = "x".repeat(70);
|
|
95
|
-
for (const p of PROVIDERS) {
|
|
96
|
-
expect(() => quoteIdent(long, p)).toThrow(/too long/);
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("accepts identifier exactly 63 chars", () => {
|
|
101
|
-
const exact = "a".repeat(63);
|
|
102
|
-
expect(quoteIdent(exact, "postgres")).toBe(`"${exact}"`);
|
|
103
|
-
expect(quoteIdent(exact, "mysql")).toBe(`\`${exact}\``);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
test("rejects NUL byte", () => {
|
|
107
|
-
expect(() => quoteIdent("bad\0name", "postgres")).toThrow(/NUL byte/);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
test("rejects non-string input", () => {
|
|
111
|
-
// @ts-expect-error — deliberate type violation
|
|
112
|
-
expect(() => quoteIdent(42, "postgres")).toThrow(/must be a string/);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
test("allows underscores and digits", () => {
|
|
116
|
-
expect(quoteIdent("user_sessions_v2", "postgres")).toBe('"user_sessions_v2"');
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
// ---------------------------------------------------------------------
|
|
121
|
-
// 2. resolveColumnType — dialect matrix
|
|
122
|
-
// ---------------------------------------------------------------------
|
|
123
|
-
|
|
124
|
-
describe("resolveColumnType", () => {
|
|
125
|
-
test("string with maxLength → VARCHAR(N) on postgres", () => {
|
|
126
|
-
expect(resolveColumnType(field({ name: "n", type: "string", maxLength: 100 }), "postgres"))
|
|
127
|
-
.toBe("VARCHAR(100)");
|
|
128
|
-
});
|
|
129
|
-
test("string without maxLength → TEXT on postgres", () => {
|
|
130
|
-
expect(resolveColumnType(field({ name: "n", type: "string" }), "postgres")).toBe("TEXT");
|
|
131
|
-
});
|
|
132
|
-
test("string without maxLength → VARCHAR(255) on mysql", () => {
|
|
133
|
-
expect(resolveColumnType(field({ name: "n", type: "string" }), "mysql")).toBe("VARCHAR(255)");
|
|
134
|
-
});
|
|
135
|
-
test("string with maxLength → VARCHAR(N) on mysql", () => {
|
|
136
|
-
expect(resolveColumnType(field({ name: "n", type: "string", maxLength: 40 }), "mysql"))
|
|
137
|
-
.toBe("VARCHAR(40)");
|
|
138
|
-
});
|
|
139
|
-
test("string on sqlite is always TEXT (ignores maxLength)", () => {
|
|
140
|
-
expect(resolveColumnType(field({ name: "n", type: "string", maxLength: 10 }), "sqlite"))
|
|
141
|
-
.toBe("TEXT");
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
test("uuid → UUID/CHAR(36)/TEXT", () => {
|
|
145
|
-
expect(resolveColumnType(field({ name: "id", type: "uuid" }), "postgres")).toBe("UUID");
|
|
146
|
-
expect(resolveColumnType(field({ name: "id", type: "uuid" }), "mysql")).toBe("CHAR(36)");
|
|
147
|
-
expect(resolveColumnType(field({ name: "id", type: "uuid" }), "sqlite")).toBe("TEXT");
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
test("boolean → BOOLEAN / TINYINT(1) / INTEGER", () => {
|
|
151
|
-
expect(resolveColumnType(field({ name: "b", type: "boolean" }), "postgres")).toBe("BOOLEAN");
|
|
152
|
-
expect(resolveColumnType(field({ name: "b", type: "boolean" }), "mysql")).toBe("TINYINT(1)");
|
|
153
|
-
expect(resolveColumnType(field({ name: "b", type: "boolean" }), "sqlite")).toBe("INTEGER");
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
test("number → DOUBLE PRECISION / DOUBLE / REAL", () => {
|
|
157
|
-
expect(resolveColumnType(field({ name: "n", type: "number" }), "postgres")).toBe("DOUBLE PRECISION");
|
|
158
|
-
expect(resolveColumnType(field({ name: "n", type: "number" }), "mysql")).toBe("DOUBLE");
|
|
159
|
-
expect(resolveColumnType(field({ name: "n", type: "number" }), "sqlite")).toBe("REAL");
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test("date → TIMESTAMPTZ / DATETIME(6) / TEXT", () => {
|
|
163
|
-
expect(resolveColumnType(field({ name: "d", type: "date" }), "postgres")).toBe("TIMESTAMPTZ");
|
|
164
|
-
expect(resolveColumnType(field({ name: "d", type: "date" }), "mysql")).toBe("DATETIME(6)");
|
|
165
|
-
expect(resolveColumnType(field({ name: "d", type: "date" }), "sqlite")).toBe("TEXT");
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
test("json → JSONB / JSON / TEXT", () => {
|
|
169
|
-
expect(resolveColumnType(field({ name: "j", type: "json" }), "postgres")).toBe("JSONB");
|
|
170
|
-
expect(resolveColumnType(field({ name: "j", type: "json" }), "mysql")).toBe("JSON");
|
|
171
|
-
expect(resolveColumnType(field({ name: "j", type: "json" }), "sqlite")).toBe("TEXT");
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
test("array + object persist as JSON on postgres/mysql, TEXT on sqlite", () => {
|
|
175
|
-
for (const t of ["array", "object"] as const) {
|
|
176
|
-
expect(resolveColumnType(field({ name: "x", type: t }), "postgres")).toBe("JSONB");
|
|
177
|
-
expect(resolveColumnType(field({ name: "x", type: t }), "mysql")).toBe("JSON");
|
|
178
|
-
expect(resolveColumnType(field({ name: "x", type: t }), "sqlite")).toBe("TEXT");
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
test("email → VARCHAR(320) / VARCHAR(320) / TEXT", () => {
|
|
183
|
-
expect(resolveColumnType(field({ name: "e", type: "email" }), "postgres")).toBe("VARCHAR(320)");
|
|
184
|
-
expect(resolveColumnType(field({ name: "e", type: "email" }), "mysql")).toBe("VARCHAR(320)");
|
|
185
|
-
expect(resolveColumnType(field({ name: "e", type: "email" }), "sqlite")).toBe("TEXT");
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
test("url → VARCHAR(2048) / VARCHAR(2048) / TEXT", () => {
|
|
189
|
-
expect(resolveColumnType(field({ name: "u", type: "url" }), "postgres")).toBe("VARCHAR(2048)");
|
|
190
|
-
expect(resolveColumnType(field({ name: "u", type: "url" }), "mysql")).toBe("VARCHAR(2048)");
|
|
191
|
-
expect(resolveColumnType(field({ name: "u", type: "url" }), "sqlite")).toBe("TEXT");
|
|
192
|
-
});
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
// ---------------------------------------------------------------------
|
|
196
|
-
// 3. resolveDefault + nowExpr
|
|
197
|
-
// ---------------------------------------------------------------------
|
|
198
|
-
|
|
199
|
-
describe("resolveDefault", () => {
|
|
200
|
-
test("kind:now returns NOW() on postgres", () => {
|
|
201
|
-
expect(resolveDefault({ kind: "now" }, "postgres")).toBe("NOW()");
|
|
202
|
-
});
|
|
203
|
-
test("kind:now returns NOW() on mysql", () => {
|
|
204
|
-
expect(resolveDefault({ kind: "now" }, "mysql")).toBe("NOW()");
|
|
205
|
-
});
|
|
206
|
-
test("kind:now returns CURRENT_TIMESTAMP on sqlite", () => {
|
|
207
|
-
expect(resolveDefault({ kind: "now" }, "sqlite")).toBe("CURRENT_TIMESTAMP");
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
test("kind:null returns NULL everywhere", () => {
|
|
211
|
-
for (const p of PROVIDERS) {
|
|
212
|
-
expect(resolveDefault({ kind: "null" }, p)).toBe("NULL");
|
|
213
|
-
}
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
test("string literal is ANSI-escaped (single quote doubling)", () => {
|
|
217
|
-
expect(resolveDefault({ kind: "literal", value: "O'Brien" }, "postgres"))
|
|
218
|
-
.toBe("'O''Brien'");
|
|
219
|
-
expect(resolveDefault({ kind: "literal", value: "O'Brien" }, "mysql"))
|
|
220
|
-
.toBe("'O''Brien'");
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
test("number literal is emitted verbatim", () => {
|
|
224
|
-
expect(resolveDefault({ kind: "literal", value: 42 }, "mysql")).toBe("42");
|
|
225
|
-
expect(resolveDefault({ kind: "literal", value: -1.5 }, "postgres")).toBe("-1.5");
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
test("non-finite number throws", () => {
|
|
229
|
-
expect(() => resolveDefault({ kind: "literal", value: Number.NaN }, "postgres"))
|
|
230
|
-
.toThrow(/finite number/);
|
|
231
|
-
expect(() => resolveDefault({ kind: "literal", value: Number.POSITIVE_INFINITY }, "postgres"))
|
|
232
|
-
.toThrow(/finite number/);
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
test("boolean literal → TRUE/FALSE on postgres, mysql; 1/0 on sqlite", () => {
|
|
236
|
-
expect(resolveDefault({ kind: "literal", value: true }, "postgres")).toBe("TRUE");
|
|
237
|
-
expect(resolveDefault({ kind: "literal", value: false }, "postgres")).toBe("FALSE");
|
|
238
|
-
expect(resolveDefault({ kind: "literal", value: true }, "mysql")).toBe("TRUE");
|
|
239
|
-
expect(resolveDefault({ kind: "literal", value: true }, "sqlite")).toBe("1");
|
|
240
|
-
expect(resolveDefault({ kind: "literal", value: false }, "sqlite")).toBe("0");
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
test("sql expression is passed through verbatim", () => {
|
|
244
|
-
expect(resolveDefault({ kind: "sql", expr: "gen_random_uuid()" }, "postgres"))
|
|
245
|
-
.toBe("gen_random_uuid()");
|
|
246
|
-
});
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
describe("nowExpr", () => {
|
|
250
|
-
test.each([["postgres", "NOW()"], ["mysql", "NOW()"], ["sqlite", "CURRENT_TIMESTAMP"]] as const)(
|
|
251
|
-
"%s → %s",
|
|
252
|
-
(p, expected) => {
|
|
253
|
-
expect(nowExpr(p)).toBe(expected);
|
|
254
|
-
},
|
|
255
|
-
);
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
// ---------------------------------------------------------------------
|
|
259
|
-
// 4. emitCreateTable
|
|
260
|
-
// ---------------------------------------------------------------------
|
|
261
|
-
|
|
262
|
-
describe("emitCreateTable", () => {
|
|
263
|
-
test("emits 3-field table on postgres", () => {
|
|
264
|
-
const sql = emitCreateTable(USER_RESOURCE, "postgres");
|
|
265
|
-
expect(sql).toBe(
|
|
266
|
-
[
|
|
267
|
-
'CREATE TABLE "users" (',
|
|
268
|
-
' "id" UUID PRIMARY KEY,',
|
|
269
|
-
' "email" VARCHAR(320) NOT NULL UNIQUE,',
|
|
270
|
-
' "name" VARCHAR(120) NOT NULL',
|
|
271
|
-
");",
|
|
272
|
-
].join("\n"),
|
|
273
|
-
);
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
test("emits 3-field table on mysql", () => {
|
|
277
|
-
const sql = emitCreateTable(USER_RESOURCE, "mysql");
|
|
278
|
-
expect(sql).toBe(
|
|
279
|
-
[
|
|
280
|
-
"CREATE TABLE `users` (",
|
|
281
|
-
" `id` CHAR(36) PRIMARY KEY,",
|
|
282
|
-
" `email` VARCHAR(320) NOT NULL UNIQUE,",
|
|
283
|
-
" `name` VARCHAR(120) NOT NULL",
|
|
284
|
-
");",
|
|
285
|
-
].join("\n"),
|
|
286
|
-
);
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
test("emits 3-field table on sqlite", () => {
|
|
290
|
-
const sql = emitCreateTable(USER_RESOURCE, "sqlite");
|
|
291
|
-
expect(sql).toBe(
|
|
292
|
-
[
|
|
293
|
-
'CREATE TABLE "users" (',
|
|
294
|
-
' "id" TEXT PRIMARY KEY,',
|
|
295
|
-
' "email" TEXT NOT NULL UNIQUE,',
|
|
296
|
-
' "name" TEXT NOT NULL',
|
|
297
|
-
");",
|
|
298
|
-
].join("\n"),
|
|
299
|
-
);
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
test("PRIMARY KEY column does not emit NOT NULL (implied)", () => {
|
|
303
|
-
const sql = emitCreateTable(USER_RESOURCE, "postgres");
|
|
304
|
-
// The id column should be `"id" UUID PRIMARY KEY` — no NOT NULL.
|
|
305
|
-
expect(sql).toContain('"id" UUID PRIMARY KEY,');
|
|
306
|
-
expect(sql).not.toContain('"id" UUID PRIMARY KEY NOT NULL');
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
test("inline UNIQUE emitted for non-primary field", () => {
|
|
310
|
-
const sql = emitCreateTable(USER_RESOURCE, "postgres");
|
|
311
|
-
expect(sql).toContain('"email" VARCHAR(320) NOT NULL UNIQUE');
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
test("emits DEFAULT clause per dialect for a date field", () => {
|
|
315
|
-
const r = resource("events", [
|
|
316
|
-
field({ name: "id", type: "uuid", primary: true }),
|
|
317
|
-
field({ name: "created_at", type: "date", default: { kind: "now" } }),
|
|
318
|
-
]);
|
|
319
|
-
expect(emitCreateTable(r, "postgres")).toContain(
|
|
320
|
-
'"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()',
|
|
321
|
-
);
|
|
322
|
-
expect(emitCreateTable(r, "mysql")).toContain(
|
|
323
|
-
"`created_at` DATETIME(6) NOT NULL DEFAULT NOW()",
|
|
324
|
-
);
|
|
325
|
-
expect(emitCreateTable(r, "sqlite")).toContain(
|
|
326
|
-
'"created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP',
|
|
327
|
-
);
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
test("emits CREATE INDEX for each indexed:true field (auto-named)", () => {
|
|
331
|
-
const r = resource("posts", [
|
|
332
|
-
field({ name: "id", type: "uuid", primary: true }),
|
|
333
|
-
field({ name: "author_id", type: "uuid", indexed: true }),
|
|
334
|
-
field({ name: "published_at", type: "date", indexed: true }),
|
|
335
|
-
]);
|
|
336
|
-
const sql = emitCreateTable(r, "postgres");
|
|
337
|
-
expect(sql).toContain('CREATE INDEX "idx_posts_author_id" ON "posts" ("author_id");');
|
|
338
|
-
expect(sql).toContain('CREATE INDEX "idx_posts_published_at" ON "posts" ("published_at");');
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
test("does NOT auto-index unique / primary columns (implicit)", () => {
|
|
342
|
-
const r = resource("foo", [
|
|
343
|
-
field({ name: "id", type: "uuid", primary: true, indexed: true }),
|
|
344
|
-
field({ name: "slug", type: "string", unique: true, indexed: true }),
|
|
345
|
-
]);
|
|
346
|
-
const sql = emitCreateTable(r, "postgres");
|
|
347
|
-
expect(sql).not.toContain("idx_foo_id");
|
|
348
|
-
expect(sql).not.toContain("idx_foo_slug");
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
test("emits CREATE UNIQUE INDEX for multi-column unique DdlIndex", () => {
|
|
352
|
-
const r = resource("memberships", [
|
|
353
|
-
field({ name: "id", type: "uuid", primary: true }),
|
|
354
|
-
field({ name: "user_id", type: "uuid" }),
|
|
355
|
-
field({ name: "org_id", type: "uuid" }),
|
|
356
|
-
], [{ name: "uniq_membership_user_org", fields: ["user_id", "org_id"], unique: true }]);
|
|
357
|
-
|
|
358
|
-
const sql = emitCreateTable(r, "postgres");
|
|
359
|
-
expect(sql).toContain(
|
|
360
|
-
'CREATE UNIQUE INDEX "uniq_membership_user_org" ON "memberships" ("user_id", "org_id");',
|
|
361
|
-
);
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
test("non-unique multi-column index emits plain CREATE INDEX", () => {
|
|
365
|
-
const r = resource("logs", [
|
|
366
|
-
field({ name: "id", type: "uuid", primary: true }),
|
|
367
|
-
field({ name: "ts", type: "date" }),
|
|
368
|
-
field({ name: "level", type: "string" }),
|
|
369
|
-
], [{ name: "idx_logs_ts_level", fields: ["ts", "level"], unique: false }]);
|
|
370
|
-
|
|
371
|
-
expect(emitCreateTable(r, "postgres")).toContain(
|
|
372
|
-
'CREATE INDEX "idx_logs_ts_level" ON "logs" ("ts", "level");',
|
|
373
|
-
);
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
test("validates that DdlIndex references existing fields", () => {
|
|
377
|
-
const r = resource("x", [
|
|
378
|
-
field({ name: "id", type: "uuid", primary: true }),
|
|
379
|
-
], [{ name: "bad_idx", fields: ["missing"], unique: false }]);
|
|
380
|
-
expect(() => emitCreateTable(r, "postgres")).toThrow(/references unknown field/);
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
test("throws for empty field list", () => {
|
|
384
|
-
const r = resource("empty", []);
|
|
385
|
-
expect(() => emitCreateTable(r, "postgres")).toThrow(/no fields/);
|
|
386
|
-
});
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
describe("emitDropTable", () => {
|
|
390
|
-
test.each([...PROVIDERS] as SqlProvider[])("%s emits DROP TABLE IF EXISTS", (p) => {
|
|
391
|
-
const sql = emitDropTable("widgets", p);
|
|
392
|
-
expect(sql).toContain("DROP TABLE IF EXISTS");
|
|
393
|
-
expect(sql.endsWith(";")).toBe(true);
|
|
394
|
-
if (p === "mysql") expect(sql).toContain("`widgets`");
|
|
395
|
-
else expect(sql).toContain('"widgets"');
|
|
396
|
-
});
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
// ---------------------------------------------------------------------
|
|
400
|
-
// 5. emitChange — one section per Change.kind
|
|
401
|
-
// ---------------------------------------------------------------------
|
|
402
|
-
|
|
403
|
-
describe("emitChange — add-column", () => {
|
|
404
|
-
const change: Change = {
|
|
405
|
-
kind: "add-column",
|
|
406
|
-
resourceName: "users",
|
|
407
|
-
field: field({ name: "age", type: "number", nullable: true }),
|
|
408
|
-
};
|
|
409
|
-
|
|
410
|
-
test("postgres", () => {
|
|
411
|
-
expect(emitChange(change, "postgres"))
|
|
412
|
-
.toBe('ALTER TABLE "users" ADD COLUMN "age" DOUBLE PRECISION;');
|
|
413
|
-
});
|
|
414
|
-
test("mysql", () => {
|
|
415
|
-
expect(emitChange(change, "mysql"))
|
|
416
|
-
.toBe("ALTER TABLE `users` ADD COLUMN `age` DOUBLE;");
|
|
417
|
-
});
|
|
418
|
-
test("sqlite", () => {
|
|
419
|
-
expect(emitChange(change, "sqlite"))
|
|
420
|
-
.toBe('ALTER TABLE "users" ADD COLUMN "age" REAL;');
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
test("indexed:true field emits ADD COLUMN plus auto CREATE INDEX", () => {
|
|
424
|
-
const indexed: Change = {
|
|
425
|
-
kind: "add-column",
|
|
426
|
-
resourceName: "users",
|
|
427
|
-
field: field({ name: "role", type: "string", indexed: true }),
|
|
428
|
-
};
|
|
429
|
-
|
|
430
|
-
expect(emitChange(indexed, "postgres")).toBe(
|
|
431
|
-
[
|
|
432
|
-
'ALTER TABLE "users" ADD COLUMN "role" TEXT NOT NULL;',
|
|
433
|
-
'CREATE INDEX "idx_users_role" ON "users" ("role");',
|
|
434
|
-
].join("\n"),
|
|
435
|
-
);
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
test("indexed:true unique or primary add-column does not create redundant index", () => {
|
|
439
|
-
const unique: Change = {
|
|
440
|
-
kind: "add-column",
|
|
441
|
-
resourceName: "users",
|
|
442
|
-
field: field({ name: "slug", type: "string", indexed: true, unique: true }),
|
|
443
|
-
};
|
|
444
|
-
expect(emitChange(unique, "postgres")).not.toContain("idx_users_slug");
|
|
445
|
-
});
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
describe("emitChange — drop-column", () => {
|
|
449
|
-
const change: Change = {
|
|
450
|
-
kind: "drop-column",
|
|
451
|
-
resourceName: "users",
|
|
452
|
-
fieldName: "age",
|
|
453
|
-
};
|
|
454
|
-
|
|
455
|
-
test("postgres", () => {
|
|
456
|
-
expect(emitChange(change, "postgres"))
|
|
457
|
-
.toBe('ALTER TABLE "users" DROP COLUMN "age";');
|
|
458
|
-
});
|
|
459
|
-
test("mysql", () => {
|
|
460
|
-
expect(emitChange(change, "mysql"))
|
|
461
|
-
.toBe("ALTER TABLE `users` DROP COLUMN `age`;");
|
|
462
|
-
});
|
|
463
|
-
test("sqlite (assumes SQLite >= 3.35)", () => {
|
|
464
|
-
// Documented assumption inline in emit.ts — Bun ships a modern SQLite.
|
|
465
|
-
expect(emitChange(change, "sqlite"))
|
|
466
|
-
.toBe('ALTER TABLE "users" DROP COLUMN "age";');
|
|
467
|
-
});
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
describe("emitChange — alter-column-type stub", () => {
|
|
471
|
-
const change: Change = {
|
|
472
|
-
kind: "alter-column-type",
|
|
473
|
-
resourceName: "users",
|
|
474
|
-
fieldName: "age",
|
|
475
|
-
fromType: "number",
|
|
476
|
-
toType: "string",
|
|
477
|
-
stub: true,
|
|
478
|
-
};
|
|
479
|
-
|
|
480
|
-
test("emits comment block with required TODO text", () => {
|
|
481
|
-
for (const p of PROVIDERS) {
|
|
482
|
-
const sql = emitChange(change, p);
|
|
483
|
-
expect(sql).toContain("-- TODO: Mandu does not auto-generate ALTER COLUMN TYPE in v1.");
|
|
484
|
-
expect(sql).toContain("mandu db apply");
|
|
485
|
-
expect(sql).toContain("Column type change detected: users.age");
|
|
486
|
-
expect(sql).toContain("from: number");
|
|
487
|
-
expect(sql).toContain("to: string");
|
|
488
|
-
}
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
test("includes SELECT 1 no-op statement", () => {
|
|
492
|
-
const sql = emitChange(change, "postgres");
|
|
493
|
-
expect(sql).toContain("SELECT 1;");
|
|
494
|
-
});
|
|
495
|
-
});
|
|
496
|
-
|
|
497
|
-
describe("emitChange — alter-column-nullable", () => {
|
|
498
|
-
test("postgres SET NOT NULL / DROP NOT NULL", () => {
|
|
499
|
-
expect(
|
|
500
|
-
emitChange(
|
|
501
|
-
{ kind: "alter-column-nullable", resourceName: "users", fieldName: "email", nullable: false },
|
|
502
|
-
"postgres",
|
|
503
|
-
),
|
|
504
|
-
).toBe('ALTER TABLE "users" ALTER COLUMN "email" SET NOT NULL;');
|
|
505
|
-
expect(
|
|
506
|
-
emitChange(
|
|
507
|
-
{ kind: "alter-column-nullable", resourceName: "users", fieldName: "email", nullable: true },
|
|
508
|
-
"postgres",
|
|
509
|
-
),
|
|
510
|
-
).toBe('ALTER TABLE "users" ALTER COLUMN "email" DROP NOT NULL;');
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
test("sqlite emits a stub (cannot toggle NOT NULL in place)", () => {
|
|
514
|
-
const sql = emitChange(
|
|
515
|
-
{ kind: "alter-column-nullable", resourceName: "users", fieldName: "email", nullable: true },
|
|
516
|
-
"sqlite",
|
|
517
|
-
);
|
|
518
|
-
expect(sql).toContain("TODO: SQLite cannot toggle NOT NULL in place");
|
|
519
|
-
expect(sql).toContain("SELECT 1;");
|
|
520
|
-
});
|
|
521
|
-
|
|
522
|
-
test("mysql emits a stub (MODIFY COLUMN needs full type)", () => {
|
|
523
|
-
const sql = emitChange(
|
|
524
|
-
{ kind: "alter-column-nullable", resourceName: "users", fieldName: "email", nullable: false },
|
|
525
|
-
"mysql",
|
|
526
|
-
);
|
|
527
|
-
expect(sql).toContain("MySQL MODIFY COLUMN requires");
|
|
528
|
-
expect(sql).toContain("SELECT 1;");
|
|
529
|
-
});
|
|
530
|
-
});
|
|
531
|
-
|
|
532
|
-
describe("emitChange — alter-column-default", () => {
|
|
533
|
-
test("postgres SET DEFAULT", () => {
|
|
534
|
-
const sql = emitChange(
|
|
535
|
-
{
|
|
536
|
-
kind: "alter-column-default",
|
|
537
|
-
resourceName: "users",
|
|
538
|
-
fieldName: "status",
|
|
539
|
-
default: { kind: "literal", value: "active" },
|
|
540
|
-
},
|
|
541
|
-
"postgres",
|
|
542
|
-
);
|
|
543
|
-
expect(sql).toBe('ALTER TABLE "users" ALTER COLUMN "status" SET DEFAULT \'active\';');
|
|
544
|
-
});
|
|
545
|
-
|
|
546
|
-
test("postgres DROP DEFAULT when default is undefined", () => {
|
|
547
|
-
const sql = emitChange(
|
|
548
|
-
{ kind: "alter-column-default", resourceName: "users", fieldName: "status" },
|
|
549
|
-
"postgres",
|
|
550
|
-
);
|
|
551
|
-
expect(sql).toBe('ALTER TABLE "users" ALTER COLUMN "status" DROP DEFAULT;');
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
test("mysql supports SET/DROP DEFAULT directly", () => {
|
|
555
|
-
expect(
|
|
556
|
-
emitChange(
|
|
557
|
-
{
|
|
558
|
-
kind: "alter-column-default",
|
|
559
|
-
resourceName: "u",
|
|
560
|
-
fieldName: "f",
|
|
561
|
-
default: { kind: "literal", value: 1 },
|
|
562
|
-
},
|
|
563
|
-
"mysql",
|
|
564
|
-
),
|
|
565
|
-
).toBe("ALTER TABLE `u` ALTER COLUMN `f` SET DEFAULT 1;");
|
|
566
|
-
});
|
|
567
|
-
});
|
|
568
|
-
|
|
569
|
-
describe("emitChange — indexes", () => {
|
|
570
|
-
test("add-index single column, non-unique", () => {
|
|
571
|
-
for (const p of PROVIDERS) {
|
|
572
|
-
const sql = emitChange(
|
|
573
|
-
{
|
|
574
|
-
kind: "add-index",
|
|
575
|
-
resourceName: "users",
|
|
576
|
-
index: { name: "idx_users_email", fields: ["email"], unique: false },
|
|
577
|
-
},
|
|
578
|
-
p,
|
|
579
|
-
);
|
|
580
|
-
expect(sql).toContain("CREATE INDEX");
|
|
581
|
-
expect(sql).not.toContain("UNIQUE");
|
|
582
|
-
}
|
|
583
|
-
});
|
|
584
|
-
|
|
585
|
-
test("add-index multi-column, unique", () => {
|
|
586
|
-
const sql = emitChange(
|
|
587
|
-
{
|
|
588
|
-
kind: "add-index",
|
|
589
|
-
resourceName: "memberships",
|
|
590
|
-
index: { name: "uniq_user_org", fields: ["user_id", "org_id"], unique: true },
|
|
591
|
-
},
|
|
592
|
-
"postgres",
|
|
593
|
-
);
|
|
594
|
-
expect(sql).toBe('CREATE UNIQUE INDEX "uniq_user_org" ON "memberships" ("user_id", "org_id");');
|
|
595
|
-
});
|
|
596
|
-
|
|
597
|
-
test("drop-index — postgres + sqlite standalone, mysql with ON table", () => {
|
|
598
|
-
expect(
|
|
599
|
-
emitChange({ kind: "drop-index", resourceName: "users", indexName: "idx_users_email" }, "postgres"),
|
|
600
|
-
).toBe('DROP INDEX "idx_users_email";');
|
|
601
|
-
expect(
|
|
602
|
-
emitChange({ kind: "drop-index", resourceName: "users", indexName: "idx_users_email" }, "sqlite"),
|
|
603
|
-
).toBe('DROP INDEX "idx_users_email";');
|
|
604
|
-
expect(
|
|
605
|
-
emitChange({ kind: "drop-index", resourceName: "users", indexName: "idx_users_email" }, "mysql"),
|
|
606
|
-
).toBe("DROP INDEX `idx_users_email` ON `users`;");
|
|
607
|
-
});
|
|
608
|
-
|
|
609
|
-
test("add-index with no fields throws", () => {
|
|
610
|
-
expect(() =>
|
|
611
|
-
emitChange(
|
|
612
|
-
{
|
|
613
|
-
kind: "add-index",
|
|
614
|
-
resourceName: "users",
|
|
615
|
-
index: { name: "x", fields: [], unique: false },
|
|
616
|
-
},
|
|
617
|
-
"postgres",
|
|
618
|
-
),
|
|
619
|
-
).toThrow(/no fields/);
|
|
620
|
-
});
|
|
621
|
-
});
|
|
622
|
-
|
|
623
|
-
describe("emitChange — rename", () => {
|
|
624
|
-
test("rename-table (user-confirmed) per dialect", () => {
|
|
625
|
-
for (const p of PROVIDERS) {
|
|
626
|
-
const sql = emitChange(
|
|
627
|
-
{ kind: "rename-table", oldName: "old_users", newName: "users", origin: "user-confirmed" },
|
|
628
|
-
p,
|
|
629
|
-
);
|
|
630
|
-
expect(sql).toContain("RENAME TO");
|
|
631
|
-
if (p === "mysql") {
|
|
632
|
-
expect(sql).toBe("ALTER TABLE `old_users` RENAME TO `users`;");
|
|
633
|
-
} else {
|
|
634
|
-
expect(sql).toBe(`ALTER TABLE "old_users" RENAME TO "users";`);
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
});
|
|
638
|
-
|
|
639
|
-
test("rename-column (user-confirmed) per dialect", () => {
|
|
640
|
-
expect(
|
|
641
|
-
emitChange(
|
|
642
|
-
{
|
|
643
|
-
kind: "rename-column",
|
|
644
|
-
resourceName: "users",
|
|
645
|
-
oldName: "e_mail",
|
|
646
|
-
newName: "email",
|
|
647
|
-
origin: "user-confirmed",
|
|
648
|
-
},
|
|
649
|
-
"postgres",
|
|
650
|
-
),
|
|
651
|
-
).toBe('ALTER TABLE "users" RENAME COLUMN "e_mail" TO "email";');
|
|
652
|
-
|
|
653
|
-
expect(
|
|
654
|
-
emitChange(
|
|
655
|
-
{
|
|
656
|
-
kind: "rename-column",
|
|
657
|
-
resourceName: "users",
|
|
658
|
-
oldName: "e_mail",
|
|
659
|
-
newName: "email",
|
|
660
|
-
origin: "user-confirmed",
|
|
661
|
-
},
|
|
662
|
-
"mysql",
|
|
663
|
-
),
|
|
664
|
-
).toBe("ALTER TABLE `users` RENAME COLUMN `e_mail` TO `email`;");
|
|
665
|
-
|
|
666
|
-
expect(
|
|
667
|
-
emitChange(
|
|
668
|
-
{
|
|
669
|
-
kind: "rename-column",
|
|
670
|
-
resourceName: "users",
|
|
671
|
-
oldName: "e_mail",
|
|
672
|
-
newName: "email",
|
|
673
|
-
origin: "user-confirmed",
|
|
674
|
-
},
|
|
675
|
-
"sqlite",
|
|
676
|
-
),
|
|
677
|
-
).toBe('ALTER TABLE "users" RENAME COLUMN "e_mail" TO "email";');
|
|
678
|
-
});
|
|
679
|
-
});
|
|
680
|
-
|
|
681
|
-
describe("emitChange — create-table / drop-table dispatch", () => {
|
|
682
|
-
test("create-table dispatch emits full CREATE TABLE", () => {
|
|
683
|
-
const sql = emitChange({ kind: "create-table", resource: USER_RESOURCE }, "postgres");
|
|
684
|
-
expect(sql).toContain('CREATE TABLE "users"');
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
test("drop-table dispatch emits DROP TABLE IF EXISTS", () => {
|
|
688
|
-
const sql = emitChange({ kind: "drop-table", resourceName: "users" }, "mysql");
|
|
689
|
-
expect(sql).toBe("DROP TABLE IF EXISTS `users`;");
|
|
690
|
-
});
|
|
691
|
-
|
|
692
|
-
test("unknown kind throws", () => {
|
|
693
|
-
// @ts-expect-error — deliberate invalid input
|
|
694
|
-
expect(() => emitChange({ kind: "unsupported" }, "postgres")).toThrow(/unknown Change\.kind/);
|
|
695
|
-
});
|
|
696
|
-
});
|
|
697
|
-
|
|
698
|
-
// ---------------------------------------------------------------------
|
|
699
|
-
// 6. emitSchema / emitChanges — multi-statement composition
|
|
700
|
-
// ---------------------------------------------------------------------
|
|
701
|
-
|
|
702
|
-
describe("emitSchema", () => {
|
|
703
|
-
test("emits CREATE TABLE for each resource in order", () => {
|
|
704
|
-
const rs = [
|
|
705
|
-
resource("a", [field({ name: "id", type: "uuid", primary: true })]),
|
|
706
|
-
resource("b", [field({ name: "id", type: "uuid", primary: true })]),
|
|
707
|
-
resource("c", [field({ name: "id", type: "uuid", primary: true })]),
|
|
708
|
-
];
|
|
709
|
-
const sql = emitSchema(rs, "postgres");
|
|
710
|
-
const idxA = sql.indexOf('CREATE TABLE "a"');
|
|
711
|
-
const idxB = sql.indexOf('CREATE TABLE "b"');
|
|
712
|
-
const idxC = sql.indexOf('CREATE TABLE "c"');
|
|
713
|
-
expect(idxA).toBeGreaterThanOrEqual(0);
|
|
714
|
-
expect(idxB).toBeGreaterThan(idxA);
|
|
715
|
-
expect(idxC).toBeGreaterThan(idxB);
|
|
716
|
-
});
|
|
717
|
-
|
|
718
|
-
test("empty array → empty string", () => {
|
|
719
|
-
expect(emitSchema([], "postgres")).toBe("");
|
|
720
|
-
});
|
|
721
|
-
|
|
722
|
-
test("separates resources with a blank line", () => {
|
|
723
|
-
const rs = [
|
|
724
|
-
resource("a", [field({ name: "id", type: "uuid", primary: true })]),
|
|
725
|
-
resource("b", [field({ name: "id", type: "uuid", primary: true })]),
|
|
726
|
-
];
|
|
727
|
-
const sql = emitSchema(rs, "sqlite");
|
|
728
|
-
expect(sql).toMatch(/\);\n\nCREATE TABLE "b"/);
|
|
729
|
-
});
|
|
730
|
-
});
|
|
731
|
-
|
|
732
|
-
describe("emitChanges", () => {
|
|
733
|
-
test("empty array returns empty string (not whitespace)", () => {
|
|
734
|
-
expect(emitChanges([], "postgres")).toBe("");
|
|
735
|
-
});
|
|
736
|
-
|
|
737
|
-
test("joins change outputs with newlines", () => {
|
|
738
|
-
const changes: Change[] = [
|
|
739
|
-
{ kind: "drop-table", resourceName: "x" },
|
|
740
|
-
{ kind: "drop-table", resourceName: "y" },
|
|
741
|
-
];
|
|
742
|
-
expect(emitChanges(changes, "postgres")).toBe(
|
|
743
|
-
[
|
|
744
|
-
'DROP TABLE IF EXISTS "x";',
|
|
745
|
-
'DROP TABLE IF EXISTS "y";',
|
|
746
|
-
].join("\n"),
|
|
747
|
-
);
|
|
748
|
-
});
|
|
749
|
-
|
|
750
|
-
test("preserves order across heterogeneous change kinds", () => {
|
|
751
|
-
const changes: Change[] = [
|
|
752
|
-
{
|
|
753
|
-
kind: "create-table",
|
|
754
|
-
resource: resource("new_table", [field({ name: "id", type: "uuid", primary: true })]),
|
|
755
|
-
},
|
|
756
|
-
{
|
|
757
|
-
kind: "add-column",
|
|
758
|
-
resourceName: "users",
|
|
759
|
-
field: field({ name: "age", type: "number", nullable: true }),
|
|
760
|
-
},
|
|
761
|
-
];
|
|
762
|
-
const sql = emitChanges(changes, "postgres");
|
|
763
|
-
expect(sql.indexOf("CREATE TABLE")).toBeLessThan(sql.indexOf("ADD COLUMN"));
|
|
764
|
-
});
|
|
765
|
-
});
|
|
766
|
-
|
|
767
|
-
// ---------------------------------------------------------------------
|
|
768
|
-
// 7. Injection-resistance / security regression
|
|
769
|
-
// ---------------------------------------------------------------------
|
|
770
|
-
|
|
771
|
-
describe("security — SQL injection resistance", () => {
|
|
772
|
-
test("malicious resource name with closing quote + DROP is rejected", () => {
|
|
773
|
-
const malicious: DdlResource = {
|
|
774
|
-
name: 'x"; DROP TABLE users; --',
|
|
775
|
-
fields: [field({ name: "id", type: "uuid", primary: true })],
|
|
776
|
-
indexes: [],
|
|
777
|
-
};
|
|
778
|
-
expect(() => emitCreateTable(malicious, "postgres")).toThrow(/unquotable character/);
|
|
779
|
-
});
|
|
780
|
-
|
|
781
|
-
test("malicious field name with backtick is rejected on mysql", () => {
|
|
782
|
-
const bad: DdlResource = {
|
|
783
|
-
name: "users",
|
|
784
|
-
fields: [field({ name: "id`; DROP;", type: "uuid", primary: true })],
|
|
785
|
-
indexes: [],
|
|
786
|
-
};
|
|
787
|
-
expect(() => emitCreateTable(bad, "mysql")).toThrow(/unquotable character/);
|
|
788
|
-
});
|
|
789
|
-
|
|
790
|
-
test("malicious index name is rejected", () => {
|
|
791
|
-
expect(() =>
|
|
792
|
-
emitChange(
|
|
793
|
-
{
|
|
794
|
-
kind: "add-index",
|
|
795
|
-
resourceName: "users",
|
|
796
|
-
index: { name: 'bad"idx', fields: ["id"], unique: false },
|
|
797
|
-
},
|
|
798
|
-
"postgres",
|
|
799
|
-
),
|
|
800
|
-
).toThrow(/unquotable character/);
|
|
801
|
-
});
|
|
802
|
-
|
|
803
|
-
test("string literal DEFAULT with quote is escaped, not rejected", () => {
|
|
804
|
-
// Literals are data, not identifiers — they escape, not throw.
|
|
805
|
-
const r = resource("t", [
|
|
806
|
-
field({ name: "id", type: "uuid", primary: true }),
|
|
807
|
-
field({
|
|
808
|
-
name: "note",
|
|
809
|
-
type: "string",
|
|
810
|
-
default: { kind: "literal", value: "'; DROP TABLE; --" },
|
|
811
|
-
}),
|
|
812
|
-
]);
|
|
813
|
-
const sql = emitCreateTable(r, "postgres");
|
|
814
|
-
// Single quotes in the value are escaped to ''. The DROP is inside
|
|
815
|
-
// a balanced pair of single quotes, i.e. data not SQL.
|
|
816
|
-
expect(sql).toContain("DEFAULT '''; DROP TABLE; --'");
|
|
817
|
-
// The leading quote in the user input was escaped (''') — the token
|
|
818
|
-
// that follows is inside the string literal, not executable SQL.
|
|
819
|
-
const defaultMatch = sql.match(/DEFAULT ('(?:''|[^'])*')/);
|
|
820
|
-
expect(defaultMatch).not.toBeNull();
|
|
821
|
-
expect(defaultMatch?.[1]).toBe("'''; DROP TABLE; --'");
|
|
822
|
-
});
|
|
823
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* Phase 4c — DDL emit engine tests.
|
|
3
|
+
*
|
|
4
|
+
* Structure:
|
|
5
|
+
* 1. `quoteIdent` — identifier quoting + rejection rules (security).
|
|
6
|
+
* 2. `resolveColumnType` — dialect-matrix over 10 field types.
|
|
7
|
+
* 3. `resolveDefault` + `nowExpr` — default literal + NOW() parity.
|
|
8
|
+
* 4. `emitCreateTable` — column + constraint + index emission.
|
|
9
|
+
* 5. `emitChange` — one section per `Change.kind`.
|
|
10
|
+
* 6. `emitSchema` / `emitChanges` — multi-resource + empty-input cases.
|
|
11
|
+
* 7. Injection-resistance regression (Agent G will cross-check).
|
|
12
|
+
*
|
|
13
|
+
* Each test that varies by dialect uses `describe.each([...providers])`
|
|
14
|
+
* to guarantee parity without manual duplication.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, test } from "bun:test";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
emitChange,
|
|
20
|
+
emitChanges,
|
|
21
|
+
emitCreateTable,
|
|
22
|
+
emitDropTable,
|
|
23
|
+
emitSchema,
|
|
24
|
+
quoteIdent,
|
|
25
|
+
} from "../emit";
|
|
26
|
+
import { nowExpr, resolveColumnType, resolveDefault } from "../type-map";
|
|
27
|
+
import type {
|
|
28
|
+
Change,
|
|
29
|
+
DdlFieldDef,
|
|
30
|
+
DdlFieldType,
|
|
31
|
+
DdlIndex,
|
|
32
|
+
DdlResource,
|
|
33
|
+
SqlProvider,
|
|
34
|
+
} from "../types";
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------
|
|
37
|
+
// Shared fixtures / helpers
|
|
38
|
+
// ---------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
const PROVIDERS: readonly SqlProvider[] = ["postgres", "mysql", "sqlite"];
|
|
41
|
+
|
|
42
|
+
function field(partial: Partial<DdlFieldDef> & { name: string; type: DdlFieldType }): DdlFieldDef {
|
|
43
|
+
return {
|
|
44
|
+
nullable: false,
|
|
45
|
+
primary: false,
|
|
46
|
+
unique: false,
|
|
47
|
+
indexed: false,
|
|
48
|
+
...partial,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resource(name: string, fields: DdlFieldDef[], indexes: DdlIndex[] = []): DdlResource {
|
|
53
|
+
return { name, fields, indexes };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const USER_RESOURCE: DdlResource = resource("users", [
|
|
57
|
+
field({ name: "id", type: "uuid", primary: true }),
|
|
58
|
+
field({ name: "email", type: "email", unique: true }),
|
|
59
|
+
field({ name: "name", type: "string", maxLength: 120 }),
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------
|
|
63
|
+
// 1. quoteIdent
|
|
64
|
+
// ---------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
describe("quoteIdent", () => {
|
|
67
|
+
test("postgres wraps in double quotes", () => {
|
|
68
|
+
expect(quoteIdent("users", "postgres")).toBe('"users"');
|
|
69
|
+
});
|
|
70
|
+
test("mysql wraps in backticks", () => {
|
|
71
|
+
expect(quoteIdent("users", "mysql")).toBe("`users`");
|
|
72
|
+
});
|
|
73
|
+
test("sqlite wraps in double quotes", () => {
|
|
74
|
+
expect(quoteIdent("users", "sqlite")).toBe('"users"');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("rejects names containing a double quote (postgres)", () => {
|
|
78
|
+
expect(() => quoteIdent('bad"name', "postgres")).toThrow(/unquotable character/);
|
|
79
|
+
});
|
|
80
|
+
test("rejects names containing a double quote (sqlite)", () => {
|
|
81
|
+
expect(() => quoteIdent('bad"name', "sqlite")).toThrow(/unquotable character/);
|
|
82
|
+
});
|
|
83
|
+
test("rejects names containing a backtick (mysql)", () => {
|
|
84
|
+
expect(() => quoteIdent("bad`name", "mysql")).toThrow(/unquotable character/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("rejects empty string on every provider", () => {
|
|
88
|
+
for (const p of PROVIDERS) {
|
|
89
|
+
expect(() => quoteIdent("", p)).toThrow(/must not be empty/);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("rejects identifiers longer than 63 chars", () => {
|
|
94
|
+
const long = "x".repeat(70);
|
|
95
|
+
for (const p of PROVIDERS) {
|
|
96
|
+
expect(() => quoteIdent(long, p)).toThrow(/too long/);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("accepts identifier exactly 63 chars", () => {
|
|
101
|
+
const exact = "a".repeat(63);
|
|
102
|
+
expect(quoteIdent(exact, "postgres")).toBe(`"${exact}"`);
|
|
103
|
+
expect(quoteIdent(exact, "mysql")).toBe(`\`${exact}\``);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("rejects NUL byte", () => {
|
|
107
|
+
expect(() => quoteIdent("bad\0name", "postgres")).toThrow(/NUL byte/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("rejects non-string input", () => {
|
|
111
|
+
// @ts-expect-error — deliberate type violation
|
|
112
|
+
expect(() => quoteIdent(42, "postgres")).toThrow(/must be a string/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("allows underscores and digits", () => {
|
|
116
|
+
expect(quoteIdent("user_sessions_v2", "postgres")).toBe('"user_sessions_v2"');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------
|
|
121
|
+
// 2. resolveColumnType — dialect matrix
|
|
122
|
+
// ---------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
describe("resolveColumnType", () => {
|
|
125
|
+
test("string with maxLength → VARCHAR(N) on postgres", () => {
|
|
126
|
+
expect(resolveColumnType(field({ name: "n", type: "string", maxLength: 100 }), "postgres"))
|
|
127
|
+
.toBe("VARCHAR(100)");
|
|
128
|
+
});
|
|
129
|
+
test("string without maxLength → TEXT on postgres", () => {
|
|
130
|
+
expect(resolveColumnType(field({ name: "n", type: "string" }), "postgres")).toBe("TEXT");
|
|
131
|
+
});
|
|
132
|
+
test("string without maxLength → VARCHAR(255) on mysql", () => {
|
|
133
|
+
expect(resolveColumnType(field({ name: "n", type: "string" }), "mysql")).toBe("VARCHAR(255)");
|
|
134
|
+
});
|
|
135
|
+
test("string with maxLength → VARCHAR(N) on mysql", () => {
|
|
136
|
+
expect(resolveColumnType(field({ name: "n", type: "string", maxLength: 40 }), "mysql"))
|
|
137
|
+
.toBe("VARCHAR(40)");
|
|
138
|
+
});
|
|
139
|
+
test("string on sqlite is always TEXT (ignores maxLength)", () => {
|
|
140
|
+
expect(resolveColumnType(field({ name: "n", type: "string", maxLength: 10 }), "sqlite"))
|
|
141
|
+
.toBe("TEXT");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("uuid → UUID/CHAR(36)/TEXT", () => {
|
|
145
|
+
expect(resolveColumnType(field({ name: "id", type: "uuid" }), "postgres")).toBe("UUID");
|
|
146
|
+
expect(resolveColumnType(field({ name: "id", type: "uuid" }), "mysql")).toBe("CHAR(36)");
|
|
147
|
+
expect(resolveColumnType(field({ name: "id", type: "uuid" }), "sqlite")).toBe("TEXT");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("boolean → BOOLEAN / TINYINT(1) / INTEGER", () => {
|
|
151
|
+
expect(resolveColumnType(field({ name: "b", type: "boolean" }), "postgres")).toBe("BOOLEAN");
|
|
152
|
+
expect(resolveColumnType(field({ name: "b", type: "boolean" }), "mysql")).toBe("TINYINT(1)");
|
|
153
|
+
expect(resolveColumnType(field({ name: "b", type: "boolean" }), "sqlite")).toBe("INTEGER");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("number → DOUBLE PRECISION / DOUBLE / REAL", () => {
|
|
157
|
+
expect(resolveColumnType(field({ name: "n", type: "number" }), "postgres")).toBe("DOUBLE PRECISION");
|
|
158
|
+
expect(resolveColumnType(field({ name: "n", type: "number" }), "mysql")).toBe("DOUBLE");
|
|
159
|
+
expect(resolveColumnType(field({ name: "n", type: "number" }), "sqlite")).toBe("REAL");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("date → TIMESTAMPTZ / DATETIME(6) / TEXT", () => {
|
|
163
|
+
expect(resolveColumnType(field({ name: "d", type: "date" }), "postgres")).toBe("TIMESTAMPTZ");
|
|
164
|
+
expect(resolveColumnType(field({ name: "d", type: "date" }), "mysql")).toBe("DATETIME(6)");
|
|
165
|
+
expect(resolveColumnType(field({ name: "d", type: "date" }), "sqlite")).toBe("TEXT");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("json → JSONB / JSON / TEXT", () => {
|
|
169
|
+
expect(resolveColumnType(field({ name: "j", type: "json" }), "postgres")).toBe("JSONB");
|
|
170
|
+
expect(resolveColumnType(field({ name: "j", type: "json" }), "mysql")).toBe("JSON");
|
|
171
|
+
expect(resolveColumnType(field({ name: "j", type: "json" }), "sqlite")).toBe("TEXT");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("array + object persist as JSON on postgres/mysql, TEXT on sqlite", () => {
|
|
175
|
+
for (const t of ["array", "object"] as const) {
|
|
176
|
+
expect(resolveColumnType(field({ name: "x", type: t }), "postgres")).toBe("JSONB");
|
|
177
|
+
expect(resolveColumnType(field({ name: "x", type: t }), "mysql")).toBe("JSON");
|
|
178
|
+
expect(resolveColumnType(field({ name: "x", type: t }), "sqlite")).toBe("TEXT");
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("email → VARCHAR(320) / VARCHAR(320) / TEXT", () => {
|
|
183
|
+
expect(resolveColumnType(field({ name: "e", type: "email" }), "postgres")).toBe("VARCHAR(320)");
|
|
184
|
+
expect(resolveColumnType(field({ name: "e", type: "email" }), "mysql")).toBe("VARCHAR(320)");
|
|
185
|
+
expect(resolveColumnType(field({ name: "e", type: "email" }), "sqlite")).toBe("TEXT");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("url → VARCHAR(2048) / VARCHAR(2048) / TEXT", () => {
|
|
189
|
+
expect(resolveColumnType(field({ name: "u", type: "url" }), "postgres")).toBe("VARCHAR(2048)");
|
|
190
|
+
expect(resolveColumnType(field({ name: "u", type: "url" }), "mysql")).toBe("VARCHAR(2048)");
|
|
191
|
+
expect(resolveColumnType(field({ name: "u", type: "url" }), "sqlite")).toBe("TEXT");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------
|
|
196
|
+
// 3. resolveDefault + nowExpr
|
|
197
|
+
// ---------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
describe("resolveDefault", () => {
|
|
200
|
+
test("kind:now returns NOW() on postgres", () => {
|
|
201
|
+
expect(resolveDefault({ kind: "now" }, "postgres")).toBe("NOW()");
|
|
202
|
+
});
|
|
203
|
+
test("kind:now returns NOW() on mysql", () => {
|
|
204
|
+
expect(resolveDefault({ kind: "now" }, "mysql")).toBe("NOW()");
|
|
205
|
+
});
|
|
206
|
+
test("kind:now returns CURRENT_TIMESTAMP on sqlite", () => {
|
|
207
|
+
expect(resolveDefault({ kind: "now" }, "sqlite")).toBe("CURRENT_TIMESTAMP");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("kind:null returns NULL everywhere", () => {
|
|
211
|
+
for (const p of PROVIDERS) {
|
|
212
|
+
expect(resolveDefault({ kind: "null" }, p)).toBe("NULL");
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("string literal is ANSI-escaped (single quote doubling)", () => {
|
|
217
|
+
expect(resolveDefault({ kind: "literal", value: "O'Brien" }, "postgres"))
|
|
218
|
+
.toBe("'O''Brien'");
|
|
219
|
+
expect(resolveDefault({ kind: "literal", value: "O'Brien" }, "mysql"))
|
|
220
|
+
.toBe("'O''Brien'");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("number literal is emitted verbatim", () => {
|
|
224
|
+
expect(resolveDefault({ kind: "literal", value: 42 }, "mysql")).toBe("42");
|
|
225
|
+
expect(resolveDefault({ kind: "literal", value: -1.5 }, "postgres")).toBe("-1.5");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("non-finite number throws", () => {
|
|
229
|
+
expect(() => resolveDefault({ kind: "literal", value: Number.NaN }, "postgres"))
|
|
230
|
+
.toThrow(/finite number/);
|
|
231
|
+
expect(() => resolveDefault({ kind: "literal", value: Number.POSITIVE_INFINITY }, "postgres"))
|
|
232
|
+
.toThrow(/finite number/);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("boolean literal → TRUE/FALSE on postgres, mysql; 1/0 on sqlite", () => {
|
|
236
|
+
expect(resolveDefault({ kind: "literal", value: true }, "postgres")).toBe("TRUE");
|
|
237
|
+
expect(resolveDefault({ kind: "literal", value: false }, "postgres")).toBe("FALSE");
|
|
238
|
+
expect(resolveDefault({ kind: "literal", value: true }, "mysql")).toBe("TRUE");
|
|
239
|
+
expect(resolveDefault({ kind: "literal", value: true }, "sqlite")).toBe("1");
|
|
240
|
+
expect(resolveDefault({ kind: "literal", value: false }, "sqlite")).toBe("0");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("sql expression is passed through verbatim", () => {
|
|
244
|
+
expect(resolveDefault({ kind: "sql", expr: "gen_random_uuid()" }, "postgres"))
|
|
245
|
+
.toBe("gen_random_uuid()");
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("nowExpr", () => {
|
|
250
|
+
test.each([["postgres", "NOW()"], ["mysql", "NOW()"], ["sqlite", "CURRENT_TIMESTAMP"]] as const)(
|
|
251
|
+
"%s → %s",
|
|
252
|
+
(p, expected) => {
|
|
253
|
+
expect(nowExpr(p)).toBe(expected);
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ---------------------------------------------------------------------
|
|
259
|
+
// 4. emitCreateTable
|
|
260
|
+
// ---------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
describe("emitCreateTable", () => {
|
|
263
|
+
test("emits 3-field table on postgres", () => {
|
|
264
|
+
const sql = emitCreateTable(USER_RESOURCE, "postgres");
|
|
265
|
+
expect(sql).toBe(
|
|
266
|
+
[
|
|
267
|
+
'CREATE TABLE "users" (',
|
|
268
|
+
' "id" UUID PRIMARY KEY,',
|
|
269
|
+
' "email" VARCHAR(320) NOT NULL UNIQUE,',
|
|
270
|
+
' "name" VARCHAR(120) NOT NULL',
|
|
271
|
+
");",
|
|
272
|
+
].join("\n"),
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("emits 3-field table on mysql", () => {
|
|
277
|
+
const sql = emitCreateTable(USER_RESOURCE, "mysql");
|
|
278
|
+
expect(sql).toBe(
|
|
279
|
+
[
|
|
280
|
+
"CREATE TABLE `users` (",
|
|
281
|
+
" `id` CHAR(36) PRIMARY KEY,",
|
|
282
|
+
" `email` VARCHAR(320) NOT NULL UNIQUE,",
|
|
283
|
+
" `name` VARCHAR(120) NOT NULL",
|
|
284
|
+
");",
|
|
285
|
+
].join("\n"),
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("emits 3-field table on sqlite", () => {
|
|
290
|
+
const sql = emitCreateTable(USER_RESOURCE, "sqlite");
|
|
291
|
+
expect(sql).toBe(
|
|
292
|
+
[
|
|
293
|
+
'CREATE TABLE "users" (',
|
|
294
|
+
' "id" TEXT PRIMARY KEY,',
|
|
295
|
+
' "email" TEXT NOT NULL UNIQUE,',
|
|
296
|
+
' "name" TEXT NOT NULL',
|
|
297
|
+
");",
|
|
298
|
+
].join("\n"),
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("PRIMARY KEY column does not emit NOT NULL (implied)", () => {
|
|
303
|
+
const sql = emitCreateTable(USER_RESOURCE, "postgres");
|
|
304
|
+
// The id column should be `"id" UUID PRIMARY KEY` — no NOT NULL.
|
|
305
|
+
expect(sql).toContain('"id" UUID PRIMARY KEY,');
|
|
306
|
+
expect(sql).not.toContain('"id" UUID PRIMARY KEY NOT NULL');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("inline UNIQUE emitted for non-primary field", () => {
|
|
310
|
+
const sql = emitCreateTable(USER_RESOURCE, "postgres");
|
|
311
|
+
expect(sql).toContain('"email" VARCHAR(320) NOT NULL UNIQUE');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("emits DEFAULT clause per dialect for a date field", () => {
|
|
315
|
+
const r = resource("events", [
|
|
316
|
+
field({ name: "id", type: "uuid", primary: true }),
|
|
317
|
+
field({ name: "created_at", type: "date", default: { kind: "now" } }),
|
|
318
|
+
]);
|
|
319
|
+
expect(emitCreateTable(r, "postgres")).toContain(
|
|
320
|
+
'"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()',
|
|
321
|
+
);
|
|
322
|
+
expect(emitCreateTable(r, "mysql")).toContain(
|
|
323
|
+
"`created_at` DATETIME(6) NOT NULL DEFAULT NOW()",
|
|
324
|
+
);
|
|
325
|
+
expect(emitCreateTable(r, "sqlite")).toContain(
|
|
326
|
+
'"created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP',
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("emits CREATE INDEX for each indexed:true field (auto-named)", () => {
|
|
331
|
+
const r = resource("posts", [
|
|
332
|
+
field({ name: "id", type: "uuid", primary: true }),
|
|
333
|
+
field({ name: "author_id", type: "uuid", indexed: true }),
|
|
334
|
+
field({ name: "published_at", type: "date", indexed: true }),
|
|
335
|
+
]);
|
|
336
|
+
const sql = emitCreateTable(r, "postgres");
|
|
337
|
+
expect(sql).toContain('CREATE INDEX "idx_posts_author_id" ON "posts" ("author_id");');
|
|
338
|
+
expect(sql).toContain('CREATE INDEX "idx_posts_published_at" ON "posts" ("published_at");');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("does NOT auto-index unique / primary columns (implicit)", () => {
|
|
342
|
+
const r = resource("foo", [
|
|
343
|
+
field({ name: "id", type: "uuid", primary: true, indexed: true }),
|
|
344
|
+
field({ name: "slug", type: "string", unique: true, indexed: true }),
|
|
345
|
+
]);
|
|
346
|
+
const sql = emitCreateTable(r, "postgres");
|
|
347
|
+
expect(sql).not.toContain("idx_foo_id");
|
|
348
|
+
expect(sql).not.toContain("idx_foo_slug");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("emits CREATE UNIQUE INDEX for multi-column unique DdlIndex", () => {
|
|
352
|
+
const r = resource("memberships", [
|
|
353
|
+
field({ name: "id", type: "uuid", primary: true }),
|
|
354
|
+
field({ name: "user_id", type: "uuid" }),
|
|
355
|
+
field({ name: "org_id", type: "uuid" }),
|
|
356
|
+
], [{ name: "uniq_membership_user_org", fields: ["user_id", "org_id"], unique: true }]);
|
|
357
|
+
|
|
358
|
+
const sql = emitCreateTable(r, "postgres");
|
|
359
|
+
expect(sql).toContain(
|
|
360
|
+
'CREATE UNIQUE INDEX "uniq_membership_user_org" ON "memberships" ("user_id", "org_id");',
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("non-unique multi-column index emits plain CREATE INDEX", () => {
|
|
365
|
+
const r = resource("logs", [
|
|
366
|
+
field({ name: "id", type: "uuid", primary: true }),
|
|
367
|
+
field({ name: "ts", type: "date" }),
|
|
368
|
+
field({ name: "level", type: "string" }),
|
|
369
|
+
], [{ name: "idx_logs_ts_level", fields: ["ts", "level"], unique: false }]);
|
|
370
|
+
|
|
371
|
+
expect(emitCreateTable(r, "postgres")).toContain(
|
|
372
|
+
'CREATE INDEX "idx_logs_ts_level" ON "logs" ("ts", "level");',
|
|
373
|
+
);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("validates that DdlIndex references existing fields", () => {
|
|
377
|
+
const r = resource("x", [
|
|
378
|
+
field({ name: "id", type: "uuid", primary: true }),
|
|
379
|
+
], [{ name: "bad_idx", fields: ["missing"], unique: false }]);
|
|
380
|
+
expect(() => emitCreateTable(r, "postgres")).toThrow(/references unknown field/);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("throws for empty field list", () => {
|
|
384
|
+
const r = resource("empty", []);
|
|
385
|
+
expect(() => emitCreateTable(r, "postgres")).toThrow(/no fields/);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
describe("emitDropTable", () => {
|
|
390
|
+
test.each([...PROVIDERS] as SqlProvider[])("%s emits DROP TABLE IF EXISTS", (p) => {
|
|
391
|
+
const sql = emitDropTable("widgets", p);
|
|
392
|
+
expect(sql).toContain("DROP TABLE IF EXISTS");
|
|
393
|
+
expect(sql.endsWith(";")).toBe(true);
|
|
394
|
+
if (p === "mysql") expect(sql).toContain("`widgets`");
|
|
395
|
+
else expect(sql).toContain('"widgets"');
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------------
|
|
400
|
+
// 5. emitChange — one section per Change.kind
|
|
401
|
+
// ---------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
describe("emitChange — add-column", () => {
|
|
404
|
+
const change: Change = {
|
|
405
|
+
kind: "add-column",
|
|
406
|
+
resourceName: "users",
|
|
407
|
+
field: field({ name: "age", type: "number", nullable: true }),
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
test("postgres", () => {
|
|
411
|
+
expect(emitChange(change, "postgres"))
|
|
412
|
+
.toBe('ALTER TABLE "users" ADD COLUMN "age" DOUBLE PRECISION;');
|
|
413
|
+
});
|
|
414
|
+
test("mysql", () => {
|
|
415
|
+
expect(emitChange(change, "mysql"))
|
|
416
|
+
.toBe("ALTER TABLE `users` ADD COLUMN `age` DOUBLE;");
|
|
417
|
+
});
|
|
418
|
+
test("sqlite", () => {
|
|
419
|
+
expect(emitChange(change, "sqlite"))
|
|
420
|
+
.toBe('ALTER TABLE "users" ADD COLUMN "age" REAL;');
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("indexed:true field emits ADD COLUMN plus auto CREATE INDEX", () => {
|
|
424
|
+
const indexed: Change = {
|
|
425
|
+
kind: "add-column",
|
|
426
|
+
resourceName: "users",
|
|
427
|
+
field: field({ name: "role", type: "string", indexed: true }),
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
expect(emitChange(indexed, "postgres")).toBe(
|
|
431
|
+
[
|
|
432
|
+
'ALTER TABLE "users" ADD COLUMN "role" TEXT NOT NULL;',
|
|
433
|
+
'CREATE INDEX "idx_users_role" ON "users" ("role");',
|
|
434
|
+
].join("\n"),
|
|
435
|
+
);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("indexed:true unique or primary add-column does not create redundant index", () => {
|
|
439
|
+
const unique: Change = {
|
|
440
|
+
kind: "add-column",
|
|
441
|
+
resourceName: "users",
|
|
442
|
+
field: field({ name: "slug", type: "string", indexed: true, unique: true }),
|
|
443
|
+
};
|
|
444
|
+
expect(emitChange(unique, "postgres")).not.toContain("idx_users_slug");
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
describe("emitChange — drop-column", () => {
|
|
449
|
+
const change: Change = {
|
|
450
|
+
kind: "drop-column",
|
|
451
|
+
resourceName: "users",
|
|
452
|
+
fieldName: "age",
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
test("postgres", () => {
|
|
456
|
+
expect(emitChange(change, "postgres"))
|
|
457
|
+
.toBe('ALTER TABLE "users" DROP COLUMN "age";');
|
|
458
|
+
});
|
|
459
|
+
test("mysql", () => {
|
|
460
|
+
expect(emitChange(change, "mysql"))
|
|
461
|
+
.toBe("ALTER TABLE `users` DROP COLUMN `age`;");
|
|
462
|
+
});
|
|
463
|
+
test("sqlite (assumes SQLite >= 3.35)", () => {
|
|
464
|
+
// Documented assumption inline in emit.ts — Bun ships a modern SQLite.
|
|
465
|
+
expect(emitChange(change, "sqlite"))
|
|
466
|
+
.toBe('ALTER TABLE "users" DROP COLUMN "age";');
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
describe("emitChange — alter-column-type stub", () => {
|
|
471
|
+
const change: Change = {
|
|
472
|
+
kind: "alter-column-type",
|
|
473
|
+
resourceName: "users",
|
|
474
|
+
fieldName: "age",
|
|
475
|
+
fromType: "number",
|
|
476
|
+
toType: "string",
|
|
477
|
+
stub: true,
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
test("emits comment block with required TODO text", () => {
|
|
481
|
+
for (const p of PROVIDERS) {
|
|
482
|
+
const sql = emitChange(change, p);
|
|
483
|
+
expect(sql).toContain("-- TODO: Mandu does not auto-generate ALTER COLUMN TYPE in v1.");
|
|
484
|
+
expect(sql).toContain("mandu db apply");
|
|
485
|
+
expect(sql).toContain("Column type change detected: users.age");
|
|
486
|
+
expect(sql).toContain("from: number");
|
|
487
|
+
expect(sql).toContain("to: string");
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("includes SELECT 1 no-op statement", () => {
|
|
492
|
+
const sql = emitChange(change, "postgres");
|
|
493
|
+
expect(sql).toContain("SELECT 1;");
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
describe("emitChange — alter-column-nullable", () => {
|
|
498
|
+
test("postgres SET NOT NULL / DROP NOT NULL", () => {
|
|
499
|
+
expect(
|
|
500
|
+
emitChange(
|
|
501
|
+
{ kind: "alter-column-nullable", resourceName: "users", fieldName: "email", nullable: false },
|
|
502
|
+
"postgres",
|
|
503
|
+
),
|
|
504
|
+
).toBe('ALTER TABLE "users" ALTER COLUMN "email" SET NOT NULL;');
|
|
505
|
+
expect(
|
|
506
|
+
emitChange(
|
|
507
|
+
{ kind: "alter-column-nullable", resourceName: "users", fieldName: "email", nullable: true },
|
|
508
|
+
"postgres",
|
|
509
|
+
),
|
|
510
|
+
).toBe('ALTER TABLE "users" ALTER COLUMN "email" DROP NOT NULL;');
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("sqlite emits a stub (cannot toggle NOT NULL in place)", () => {
|
|
514
|
+
const sql = emitChange(
|
|
515
|
+
{ kind: "alter-column-nullable", resourceName: "users", fieldName: "email", nullable: true },
|
|
516
|
+
"sqlite",
|
|
517
|
+
);
|
|
518
|
+
expect(sql).toContain("TODO: SQLite cannot toggle NOT NULL in place");
|
|
519
|
+
expect(sql).toContain("SELECT 1;");
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test("mysql emits a stub (MODIFY COLUMN needs full type)", () => {
|
|
523
|
+
const sql = emitChange(
|
|
524
|
+
{ kind: "alter-column-nullable", resourceName: "users", fieldName: "email", nullable: false },
|
|
525
|
+
"mysql",
|
|
526
|
+
);
|
|
527
|
+
expect(sql).toContain("MySQL MODIFY COLUMN requires");
|
|
528
|
+
expect(sql).toContain("SELECT 1;");
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
describe("emitChange — alter-column-default", () => {
|
|
533
|
+
test("postgres SET DEFAULT", () => {
|
|
534
|
+
const sql = emitChange(
|
|
535
|
+
{
|
|
536
|
+
kind: "alter-column-default",
|
|
537
|
+
resourceName: "users",
|
|
538
|
+
fieldName: "status",
|
|
539
|
+
default: { kind: "literal", value: "active" },
|
|
540
|
+
},
|
|
541
|
+
"postgres",
|
|
542
|
+
);
|
|
543
|
+
expect(sql).toBe('ALTER TABLE "users" ALTER COLUMN "status" SET DEFAULT \'active\';');
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test("postgres DROP DEFAULT when default is undefined", () => {
|
|
547
|
+
const sql = emitChange(
|
|
548
|
+
{ kind: "alter-column-default", resourceName: "users", fieldName: "status" },
|
|
549
|
+
"postgres",
|
|
550
|
+
);
|
|
551
|
+
expect(sql).toBe('ALTER TABLE "users" ALTER COLUMN "status" DROP DEFAULT;');
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("mysql supports SET/DROP DEFAULT directly", () => {
|
|
555
|
+
expect(
|
|
556
|
+
emitChange(
|
|
557
|
+
{
|
|
558
|
+
kind: "alter-column-default",
|
|
559
|
+
resourceName: "u",
|
|
560
|
+
fieldName: "f",
|
|
561
|
+
default: { kind: "literal", value: 1 },
|
|
562
|
+
},
|
|
563
|
+
"mysql",
|
|
564
|
+
),
|
|
565
|
+
).toBe("ALTER TABLE `u` ALTER COLUMN `f` SET DEFAULT 1;");
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
describe("emitChange — indexes", () => {
|
|
570
|
+
test("add-index single column, non-unique", () => {
|
|
571
|
+
for (const p of PROVIDERS) {
|
|
572
|
+
const sql = emitChange(
|
|
573
|
+
{
|
|
574
|
+
kind: "add-index",
|
|
575
|
+
resourceName: "users",
|
|
576
|
+
index: { name: "idx_users_email", fields: ["email"], unique: false },
|
|
577
|
+
},
|
|
578
|
+
p,
|
|
579
|
+
);
|
|
580
|
+
expect(sql).toContain("CREATE INDEX");
|
|
581
|
+
expect(sql).not.toContain("UNIQUE");
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test("add-index multi-column, unique", () => {
|
|
586
|
+
const sql = emitChange(
|
|
587
|
+
{
|
|
588
|
+
kind: "add-index",
|
|
589
|
+
resourceName: "memberships",
|
|
590
|
+
index: { name: "uniq_user_org", fields: ["user_id", "org_id"], unique: true },
|
|
591
|
+
},
|
|
592
|
+
"postgres",
|
|
593
|
+
);
|
|
594
|
+
expect(sql).toBe('CREATE UNIQUE INDEX "uniq_user_org" ON "memberships" ("user_id", "org_id");');
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test("drop-index — postgres + sqlite standalone, mysql with ON table", () => {
|
|
598
|
+
expect(
|
|
599
|
+
emitChange({ kind: "drop-index", resourceName: "users", indexName: "idx_users_email" }, "postgres"),
|
|
600
|
+
).toBe('DROP INDEX "idx_users_email";');
|
|
601
|
+
expect(
|
|
602
|
+
emitChange({ kind: "drop-index", resourceName: "users", indexName: "idx_users_email" }, "sqlite"),
|
|
603
|
+
).toBe('DROP INDEX "idx_users_email";');
|
|
604
|
+
expect(
|
|
605
|
+
emitChange({ kind: "drop-index", resourceName: "users", indexName: "idx_users_email" }, "mysql"),
|
|
606
|
+
).toBe("DROP INDEX `idx_users_email` ON `users`;");
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("add-index with no fields throws", () => {
|
|
610
|
+
expect(() =>
|
|
611
|
+
emitChange(
|
|
612
|
+
{
|
|
613
|
+
kind: "add-index",
|
|
614
|
+
resourceName: "users",
|
|
615
|
+
index: { name: "x", fields: [], unique: false },
|
|
616
|
+
},
|
|
617
|
+
"postgres",
|
|
618
|
+
),
|
|
619
|
+
).toThrow(/no fields/);
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
describe("emitChange — rename", () => {
|
|
624
|
+
test("rename-table (user-confirmed) per dialect", () => {
|
|
625
|
+
for (const p of PROVIDERS) {
|
|
626
|
+
const sql = emitChange(
|
|
627
|
+
{ kind: "rename-table", oldName: "old_users", newName: "users", origin: "user-confirmed" },
|
|
628
|
+
p,
|
|
629
|
+
);
|
|
630
|
+
expect(sql).toContain("RENAME TO");
|
|
631
|
+
if (p === "mysql") {
|
|
632
|
+
expect(sql).toBe("ALTER TABLE `old_users` RENAME TO `users`;");
|
|
633
|
+
} else {
|
|
634
|
+
expect(sql).toBe(`ALTER TABLE "old_users" RENAME TO "users";`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test("rename-column (user-confirmed) per dialect", () => {
|
|
640
|
+
expect(
|
|
641
|
+
emitChange(
|
|
642
|
+
{
|
|
643
|
+
kind: "rename-column",
|
|
644
|
+
resourceName: "users",
|
|
645
|
+
oldName: "e_mail",
|
|
646
|
+
newName: "email",
|
|
647
|
+
origin: "user-confirmed",
|
|
648
|
+
},
|
|
649
|
+
"postgres",
|
|
650
|
+
),
|
|
651
|
+
).toBe('ALTER TABLE "users" RENAME COLUMN "e_mail" TO "email";');
|
|
652
|
+
|
|
653
|
+
expect(
|
|
654
|
+
emitChange(
|
|
655
|
+
{
|
|
656
|
+
kind: "rename-column",
|
|
657
|
+
resourceName: "users",
|
|
658
|
+
oldName: "e_mail",
|
|
659
|
+
newName: "email",
|
|
660
|
+
origin: "user-confirmed",
|
|
661
|
+
},
|
|
662
|
+
"mysql",
|
|
663
|
+
),
|
|
664
|
+
).toBe("ALTER TABLE `users` RENAME COLUMN `e_mail` TO `email`;");
|
|
665
|
+
|
|
666
|
+
expect(
|
|
667
|
+
emitChange(
|
|
668
|
+
{
|
|
669
|
+
kind: "rename-column",
|
|
670
|
+
resourceName: "users",
|
|
671
|
+
oldName: "e_mail",
|
|
672
|
+
newName: "email",
|
|
673
|
+
origin: "user-confirmed",
|
|
674
|
+
},
|
|
675
|
+
"sqlite",
|
|
676
|
+
),
|
|
677
|
+
).toBe('ALTER TABLE "users" RENAME COLUMN "e_mail" TO "email";');
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
describe("emitChange — create-table / drop-table dispatch", () => {
|
|
682
|
+
test("create-table dispatch emits full CREATE TABLE", () => {
|
|
683
|
+
const sql = emitChange({ kind: "create-table", resource: USER_RESOURCE }, "postgres");
|
|
684
|
+
expect(sql).toContain('CREATE TABLE "users"');
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
test("drop-table dispatch emits DROP TABLE IF EXISTS", () => {
|
|
688
|
+
const sql = emitChange({ kind: "drop-table", resourceName: "users" }, "mysql");
|
|
689
|
+
expect(sql).toBe("DROP TABLE IF EXISTS `users`;");
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test("unknown kind throws", () => {
|
|
693
|
+
// @ts-expect-error — deliberate invalid input
|
|
694
|
+
expect(() => emitChange({ kind: "unsupported" }, "postgres")).toThrow(/unknown Change\.kind/);
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
// ---------------------------------------------------------------------
|
|
699
|
+
// 6. emitSchema / emitChanges — multi-statement composition
|
|
700
|
+
// ---------------------------------------------------------------------
|
|
701
|
+
|
|
702
|
+
describe("emitSchema", () => {
|
|
703
|
+
test("emits CREATE TABLE for each resource in order", () => {
|
|
704
|
+
const rs = [
|
|
705
|
+
resource("a", [field({ name: "id", type: "uuid", primary: true })]),
|
|
706
|
+
resource("b", [field({ name: "id", type: "uuid", primary: true })]),
|
|
707
|
+
resource("c", [field({ name: "id", type: "uuid", primary: true })]),
|
|
708
|
+
];
|
|
709
|
+
const sql = emitSchema(rs, "postgres");
|
|
710
|
+
const idxA = sql.indexOf('CREATE TABLE "a"');
|
|
711
|
+
const idxB = sql.indexOf('CREATE TABLE "b"');
|
|
712
|
+
const idxC = sql.indexOf('CREATE TABLE "c"');
|
|
713
|
+
expect(idxA).toBeGreaterThanOrEqual(0);
|
|
714
|
+
expect(idxB).toBeGreaterThan(idxA);
|
|
715
|
+
expect(idxC).toBeGreaterThan(idxB);
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
test("empty array → empty string", () => {
|
|
719
|
+
expect(emitSchema([], "postgres")).toBe("");
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test("separates resources with a blank line", () => {
|
|
723
|
+
const rs = [
|
|
724
|
+
resource("a", [field({ name: "id", type: "uuid", primary: true })]),
|
|
725
|
+
resource("b", [field({ name: "id", type: "uuid", primary: true })]),
|
|
726
|
+
];
|
|
727
|
+
const sql = emitSchema(rs, "sqlite");
|
|
728
|
+
expect(sql).toMatch(/\);\n\nCREATE TABLE "b"/);
|
|
729
|
+
});
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
describe("emitChanges", () => {
|
|
733
|
+
test("empty array returns empty string (not whitespace)", () => {
|
|
734
|
+
expect(emitChanges([], "postgres")).toBe("");
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test("joins change outputs with newlines", () => {
|
|
738
|
+
const changes: Change[] = [
|
|
739
|
+
{ kind: "drop-table", resourceName: "x" },
|
|
740
|
+
{ kind: "drop-table", resourceName: "y" },
|
|
741
|
+
];
|
|
742
|
+
expect(emitChanges(changes, "postgres")).toBe(
|
|
743
|
+
[
|
|
744
|
+
'DROP TABLE IF EXISTS "x";',
|
|
745
|
+
'DROP TABLE IF EXISTS "y";',
|
|
746
|
+
].join("\n"),
|
|
747
|
+
);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
test("preserves order across heterogeneous change kinds", () => {
|
|
751
|
+
const changes: Change[] = [
|
|
752
|
+
{
|
|
753
|
+
kind: "create-table",
|
|
754
|
+
resource: resource("new_table", [field({ name: "id", type: "uuid", primary: true })]),
|
|
755
|
+
},
|
|
756
|
+
{
|
|
757
|
+
kind: "add-column",
|
|
758
|
+
resourceName: "users",
|
|
759
|
+
field: field({ name: "age", type: "number", nullable: true }),
|
|
760
|
+
},
|
|
761
|
+
];
|
|
762
|
+
const sql = emitChanges(changes, "postgres");
|
|
763
|
+
expect(sql.indexOf("CREATE TABLE")).toBeLessThan(sql.indexOf("ADD COLUMN"));
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
// ---------------------------------------------------------------------
|
|
768
|
+
// 7. Injection-resistance / security regression
|
|
769
|
+
// ---------------------------------------------------------------------
|
|
770
|
+
|
|
771
|
+
describe("security — SQL injection resistance", () => {
|
|
772
|
+
test("malicious resource name with closing quote + DROP is rejected", () => {
|
|
773
|
+
const malicious: DdlResource = {
|
|
774
|
+
name: 'x"; DROP TABLE users; --',
|
|
775
|
+
fields: [field({ name: "id", type: "uuid", primary: true })],
|
|
776
|
+
indexes: [],
|
|
777
|
+
};
|
|
778
|
+
expect(() => emitCreateTable(malicious, "postgres")).toThrow(/unquotable character/);
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
test("malicious field name with backtick is rejected on mysql", () => {
|
|
782
|
+
const bad: DdlResource = {
|
|
783
|
+
name: "users",
|
|
784
|
+
fields: [field({ name: "id`; DROP;", type: "uuid", primary: true })],
|
|
785
|
+
indexes: [],
|
|
786
|
+
};
|
|
787
|
+
expect(() => emitCreateTable(bad, "mysql")).toThrow(/unquotable character/);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
test("malicious index name is rejected", () => {
|
|
791
|
+
expect(() =>
|
|
792
|
+
emitChange(
|
|
793
|
+
{
|
|
794
|
+
kind: "add-index",
|
|
795
|
+
resourceName: "users",
|
|
796
|
+
index: { name: 'bad"idx', fields: ["id"], unique: false },
|
|
797
|
+
},
|
|
798
|
+
"postgres",
|
|
799
|
+
),
|
|
800
|
+
).toThrow(/unquotable character/);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
test("string literal DEFAULT with quote is escaped, not rejected", () => {
|
|
804
|
+
// Literals are data, not identifiers — they escape, not throw.
|
|
805
|
+
const r = resource("t", [
|
|
806
|
+
field({ name: "id", type: "uuid", primary: true }),
|
|
807
|
+
field({
|
|
808
|
+
name: "note",
|
|
809
|
+
type: "string",
|
|
810
|
+
default: { kind: "literal", value: "'; DROP TABLE; --" },
|
|
811
|
+
}),
|
|
812
|
+
]);
|
|
813
|
+
const sql = emitCreateTable(r, "postgres");
|
|
814
|
+
// Single quotes in the value are escaped to ''. The DROP is inside
|
|
815
|
+
// a balanced pair of single quotes, i.e. data not SQL.
|
|
816
|
+
expect(sql).toContain("DEFAULT '''; DROP TABLE; --'");
|
|
817
|
+
// The leading quote in the user input was escaped (''') — the token
|
|
818
|
+
// that follows is inside the string literal, not executable SQL.
|
|
819
|
+
const defaultMatch = sql.match(/DEFAULT ('(?:''|[^'])*')/);
|
|
820
|
+
expect(defaultMatch).not.toBeNull();
|
|
821
|
+
expect(defaultMatch?.[1]).toBe("'''; DROP TABLE; --'");
|
|
822
|
+
});
|
|
823
|
+
});
|