unmet = RequiresCheck.unmetFor(generatorImpls);
if (unmet.isEmpty()) return;
if (allowUnmetRequires) {
for (String line : unmet) getLog().warn("MetaData Mojo > " + line);
diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/RequiresCheck.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/RequiresCheck.java
index d9d2a7c1f..140b829ec 100644
--- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/RequiresCheck.java
+++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/RequiresCheck.java
@@ -1,6 +1,8 @@
package com.metaobjects.mojo;
+import com.metaobjects.generator.Generator;
import com.metaobjects.generator.GeneratorRegistry;
+import com.metaobjects.generator.kotlin.ConfiguredRequires;
import com.metaobjects.generator.kotlin.GeneratorInfo;
import com.metaobjects.generator.kotlin.GeneratorRegistryKt;
@@ -25,6 +27,10 @@
* copy {@code mvn metaobjects:eject} wrote, by its simple class name. A generator the registries
* do not know (the adopter's own) has no requirements, and does not satisfy one. Requirements
* are per port: a Kotlin controller is not satisfied by a Java DTO generator.
+ *
+ * A generator that needs less under some args ({@link ConfiguredRequires}: spring-config with
+ * {@code validatorEnabled=false} references no validator) is asked for its requirements after
+ * its args are set, so the check follows the configuration rather than the default.
*/
final class RequiresCheck {
@@ -64,11 +70,31 @@ private static Known resolve(List known, String classname) {
/**
* One line per declared generator whose requirements the same list does not declare, naming
* everything missing (the requirements of a missing requirement included) and the class to
- * add; empty when every requirement is met.
+ * add; empty when every requirement is met. Each generator needs what its registry entry
+ * says.
*
* @param classnames the {@code } of each {@code }, in declared order
*/
static List unmet(List classnames) {
+ return unmet(classnames, Map.of());
+ }
+
+ /**
+ * As {@link #unmet(List)} for the instances a run is about to execute, args set: a
+ * {@link ConfiguredRequires} generator is asked what its configuration needs.
+ */
+ static List unmetFor(List generators) {
+ List classnames = new ArrayList<>();
+ Map> configured = new LinkedHashMap<>();
+ for (Generator g : generators) {
+ String classname = g.getClass().getName();
+ classnames.add(classname);
+ if (g instanceof ConfiguredRequires c) configured.putIfAbsent(classname, c.configuredRequires());
+ }
+ return unmet(classnames, configured);
+ }
+
+ private static List unmet(List classnames, Map> configured) {
List known = known();
// One {@code port:stableName} index: membership and the "what class fixes this"
// lookup read the same structure, so adding a port cannot leave one behind.
@@ -90,7 +116,7 @@ static List unmet(List classnames) {
// declared requirement's own requirements are reported on its own line.
List missing = new ArrayList<>();
Set seen = new LinkedHashSet<>();
- List todo = new ArrayList<>(k.requires());
+ List todo = new ArrayList<>(configured.getOrDefault(d.getKey(), k.requires()));
while (!todo.isEmpty()) {
String r = todo.remove(0);
String key = k.port() + ":" + r;
diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/GeneratorRequiresCompileTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/GeneratorRequiresCompileTest.java
index 0ae908081..045651441 100644
--- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/GeneratorRequiresCompileTest.java
+++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/GeneratorRequiresCompileTest.java
@@ -167,6 +167,30 @@ public void theControllerAloneDoesNotCompileAndTheGoalRefusesIt() throws Excepti
mojo.refuseUnmetRequires(List.of(construct(controller)));
}
+ /** spring-config references the generated validator only when {@code validatorEnabled} is on,
+ * so the check follows the instance's args: a pom with it off built before the check existed. */
+ @Test
+ public void springConfigRequiresTheValidatorOnlyWhenTheValidatorIsEnabled() throws Exception {
+ String classname = "com.metaobjects.generator.kotlin.KotlinSpringConfigGenerator";
+ MetaDataGeneratorMojo mojo = new MetaDataGeneratorMojo();
+
+ Generator disabled = construct(classname).setArgs(Map.of("validatorEnabled", "false"));
+ mojo.refuseUnmetRequires(List.of(disabled));
+ assertEquals(List.of(), RequiresCheck.unmetFor(List.of(disabled)));
+
+ for (Generator enabled : List.of(construct(classname), construct(classname).setArgs(Map.of("validatorEnabled", "true")))) {
+ List unmet = RequiresCheck.unmetFor(List.of(enabled));
+ assertEquals(1, unmet.size());
+ assertTrue(unmet.get(0), unmet.get(0).contains("\"validator\""));
+ try {
+ mojo.refuseUnmetRequires(List.of(enabled));
+ fail("spring-config with the validator hook ran without the validator");
+ } catch (MetaDataException expected) {
+ assertTrue(expected.getMessage(), expected.getMessage().contains("\"validator\""));
+ }
+ }
+ }
+
@Test
public void anOwnedCopyIsMatchedByItsSimpleNameAndAnUnknownGeneratorRequiresNothing() {
assertEquals(1, RequiresCheck.unmet(List.of("com.acme.codegen.SpringControllerGenerator")).size());
diff --git a/server/python/src/metaobjects/codegen/generators/entity_model.py b/server/python/src/metaobjects/codegen/generators/entity_model.py
index 367139cef..42587b2ee 100644
--- a/server/python/src/metaobjects/codegen/generators/entity_model.py
+++ b/server/python/src/metaobjects/codegen/generators/entity_model.py
@@ -555,7 +555,13 @@ def _emit_field_lines(
else:
element = d.target_entity
imports.add(f"from .{d.target_entity} import {d.target_entity}")
- lines.append(f" {d.relation_name}: list[{element}] = []")
+ # default_factory, not `= []`: a class-level list literal is one shared object
+ # as far as a linter or a reader can tell (pydantic copies it, a dataclass-style
+ # reader would not).
+ lines.append(
+ f" {d.relation_name}: list[{element}] = Field(default_factory=list)"
+ )
+ uses_field = True
return lines, uses_field
def _emit_create_lines(
@@ -655,9 +661,9 @@ def render_entity_model(
When *object_index* is supplied, M:N navigations (``relationship.*``
``@cardinality:"many" + @through``) are emitted as nested Pydantic
- collections (``tags: list[Tag] = []``); a self-join uses a forward-ref string
- (``following: list["Person"] = []``). Without an index, only scalar/object
- fields are emitted (back-compat). *config* carries the FR-019 @provided-enum
+ collections (``tags: list[Tag] = Field(default_factory=list)``); a self-join uses a
+ forward-ref string (``following: list["Person"] = Field(default_factory=list)``).
+ Without an index, only scalar/object fields are emitted (back-compat). *config* carries the FR-019 @provided-enum
resolution; when omitted, only the non-provided shared-materialize path is reachable."""
imports: set[str] = set()
base_class = "BaseModel"
diff --git a/server/python/src/metaobjects/codegen/runtime/api_docs_builder.py b/server/python/src/metaobjects/codegen/runtime/api_docs_builder.py
index 0f3754c18..12087e470 100644
--- a/server/python/src/metaobjects/codegen/runtime/api_docs_builder.py
+++ b/server/python/src/metaobjects/codegen/runtime/api_docs_builder.py
@@ -38,6 +38,7 @@
FieldShape,
)
from metaobjects.codegen import type_map
+from metaobjects.codegen.model_walk import package_of
from .m2m_codegen import (
build_object_index,
resolve_m2m_descriptors,
@@ -210,7 +211,7 @@ def _build_object_unit(
if not symbols:
return None
- return ApiUnit(obj.name, _package_of(obj), unit_kind, symbols)
+ return ApiUnit(obj.name, package_of(obj), unit_kind, symbols)
def _build_report_unit(self, obj: MetaObject, root: MetaData) -> ApiUnit:
"""The unit for a SERVED report (FR-044 Table G): the row model plus the read-only
@@ -229,7 +230,7 @@ def _build_report_unit(self, obj: MetaObject, root: MetaData) -> ApiUnit:
),
*self._read_only_symbols(model_obj),
]
- return ApiUnit(obj.name, _package_of(obj), "report", symbols)
+ return ApiUnit(obj.name, package_of(obj), "report", symbols)
def _read_only_symbols(self, obj: MetaObject) -> list[ApiSymbol]:
"""What the READ-ONLY router (``is_read_only_routed``) actually mounts and emits,
@@ -431,7 +432,7 @@ def _build_template_unit(self, tmpl: MetaData, root: MetaData) -> ApiUnit:
)
)
- return ApiUnit(name, _package_of(tmpl), "template", symbols)
+ return ApiUnit(name, package_of(tmpl), "template", symbols)
# ----- field shapes -------------------------------------------------------
@@ -471,18 +472,6 @@ def _payload_fields(self, vo: MetaObject) -> list[FieldShape]:
return rows
-def _package_of(node: MetaData) -> str:
- """The unit's metadata package (``"acme::shop"``) — drives the doc-page layout
- path. Resolved from the nearest ancestor that carries one (objects inherit the
- file/root package)."""
- pkg = node.package
- parent = node.parent
- while pkg is None and parent is not None:
- pkg = parent.package
- parent = parent.parent
- return pkg or ""
-
-
def build_api_model(root: MetaData, project: str) -> ApiModel:
"""Module-level convenience: build the Python SDK api-surface IR."""
return PythonApiModelBuilder().build(root, project)
diff --git a/server/python/tests/codegen/test_m2m_codegen.py b/server/python/tests/codegen/test_m2m_codegen.py
index beee40949..a197d81ba 100644
--- a/server/python/tests/codegen/test_m2m_codegen.py
+++ b/server/python/tests/codegen/test_m2m_codegen.py
@@ -180,15 +180,22 @@ def test_entity_without_m2m_has_no_descriptors() -> None:
def test_pydantic_nested_collection_emitted() -> None:
src = render_entity_model(_ENTITIES["Post"], _INDEX)
- assert "tags: list[Tag] = []" in src
+ assert "tags: list[Tag] = Field(default_factory=list)" in src
assert "from .Tag import Tag" in src
+def test_pydantic_nested_collection_default_is_a_factory_and_imports_field() -> None:
+ for name in ("Post", "Person"):
+ src = render_entity_model(_ENTITIES[name], _INDEX)
+ assert "= []" not in src, src
+ assert "from pydantic import" in src and "Field" in src.split("from pydantic import", 1)[1].split("\n", 1)[0], src
+
+
def test_pydantic_self_referential_collection() -> None:
src = render_entity_model(_ENTITIES["Person"], _INDEX)
# Self-join: the element type is the entity itself (forward ref string).
- assert 'following: list["Person"] = []' in src
- assert 'friends: list["Person"] = []' in src
+ assert 'following: list["Person"] = Field(default_factory=list)' in src
+ assert 'friends: list["Person"] = Field(default_factory=list)' in src
# ---------------------------------------------------------------------------
diff --git a/server/python/tests/test_api_docs_packages.py b/server/python/tests/test_api_docs_packages.py
new file mode 100644
index 000000000..9ac1799cc
--- /dev/null
+++ b/server/python/tests/test_api_docs_packages.py
@@ -0,0 +1,64 @@
+"""Each API page lands under its own object's package, not the first loaded file's.
+
+A model loaded from several files has one root; ``root.package`` is the first file's. The
+builder used to read the package off the node and then its ancestors, so every object whose
+own file declared another package was filed (and back-linked) under the root's.
+"""
+from __future__ import annotations
+
+import tempfile
+from pathlib import Path
+
+from metaobjects import MetaDataLoader
+from metaobjects.apidocs import PythonApiModelBuilder
+from metaobjects.codegen.config import GenConfig
+from metaobjects.codegen.generator import GenContext
+from metaobjects.codegen.generators.api_docs_generator import ApiDocsGenerator
+from metaobjects.shared.base_types import TYPE_OBJECT
+
+
+def _entity_file(package: str, name: str) -> str:
+ return (
+ '{ "metadata.root": { "package": "%s", "children": [\n'
+ ' { "object.entity": { "name": "%s", "children": [\n'
+ ' { "source.rdb": { "@table": "t_%s" } },\n'
+ ' { "field.long": { "name": "id" } },\n'
+ ' { "identity.primary": { "name": "id", "@fields": ["id"], "@generation": "increment" } }\n'
+ " ]}}\n"
+ "]}}" % (package, name, name.lower())
+ )
+
+
+def _load_two_packages():
+ with tempfile.TemporaryDirectory() as d:
+ # Sorted load order makes "alpha" the file whose package the merged root keeps.
+ (Path(d) / "meta.alpha.json").write_text(_entity_file("alpha::lib", "Widget"), encoding="utf-8")
+ (Path(d) / "meta.beta.json").write_text(_entity_file("beta::app", "Order"), encoding="utf-8")
+ r = MetaDataLoader.from_directory(d)
+ assert not r.errors, r.errors
+ return r.root
+
+
+def test_every_unit_carries_its_own_package() -> None:
+ model = PythonApiModelBuilder().build(_load_two_packages(), "two-packages")
+ assert {u.node: u.package for u in model.units} == {"Widget": "alpha::lib", "Order": "beta::app"}
+
+
+def test_pages_and_back_links_are_filed_under_each_package() -> None:
+ root = _load_two_packages()
+ ctx = GenContext(
+ entities=[c for c in root.own_children() if c.type == TYPE_OBJECT],
+ loaded_root=root,
+ matches=lambda _e: True,
+ config=GenConfig(out_dir="/tmp/out"),
+ warn=lambda _m: None,
+ )
+ files = {f.path: f.content for f in ApiDocsGenerator(api_subdir="api").generate(ctx)}
+
+ assert "api/alpha/lib/Widget.md" in files
+ assert "api/beta/app/Order.md" in files
+ assert not [p for p in files if p.endswith("Order.md") and "alpha" in p], sorted(files)
+ # The back-link to the model page stays in the page's own package.
+ assert "alpha/lib/Widget.md" in files["api/alpha/lib/Widget.md"]
+ assert "beta/app/Order.md" in files["api/beta/app/Order.md"]
+ assert "alpha" not in files["api/beta/app/Order.md"]
diff --git a/server/typescript/packages/codegen-ts/src/overwrite-policy.ts b/server/typescript/packages/codegen-ts/src/overwrite-policy.ts
index 64c63d06d..ed3a29b58 100644
--- a/server/typescript/packages/codegen-ts/src/overwrite-policy.ts
+++ b/server/typescript/packages/codegen-ts/src/overwrite-policy.ts
@@ -503,13 +503,26 @@ function classifyWrite(
// File exists. Load the canonical snapshot if any.
const genStateDir = resolveGenStateDir(opts);
const relPath = opts.outputRelPath ?? defaultOutputRelPath(path);
- const snapshot = readSnapshotChecked(genStateDir, relPath);
+ const storedSnapshot = readSnapshotChecked(genStateDir, relPath);
const current = readFileSync(path, "utf-8");
+ // A snapshot body is a gitignored local cache, so it outlives a `git checkout` that
+ // moves the file to content it never described. `--baseline=fresh` is documented to
+ // overwrite a file the committed manifest has NO RECORD of; a stale body for such a path
+ // is not a merge base, and merging against it keeps the old content as a "hand edit".
+ // Only `fresh`, and only for a path with no recorded hash: every other mode still merges.
+ const snapshot =
+ storedSnapshot !== undefined &&
+ (opts.baseline ?? "default") === "fresh" &&
+ readGeneratedHash(genStateDir, relPath) === undefined
+ ? undefined
+ : storedSnapshot;
+
// First-time regen on a pre-existing file (no snapshot) — caveat 3. `baseline`
// only ever applies here: BaselineMode's own doc comment says so ("When
// .gen-state is absent but the file exists"), and a snapshot body — handled
- // below — always gets the real three-way merge regardless of baseline.
+ // below — always gets the real three-way merge, except the unrecorded-path
+ // case of `fresh` just above.
if (snapshot === undefined) {
if ((opts.baseline ?? "default") === "fresh") {
// Opt-in escape hatch: overwrite and seed the snapshot from fresh.
diff --git a/server/typescript/packages/codegen-ts/test/baseline-fresh-stale-snapshot.test.ts b/server/typescript/packages/codegen-ts/test/baseline-fresh-stale-snapshot.test.ts
new file mode 100644
index 000000000..ed27f3612
--- /dev/null
+++ b/server/typescript/packages/codegen-ts/test/baseline-fresh-stale-snapshot.test.ts
@@ -0,0 +1,89 @@
+// `--baseline=fresh` over a stale merge-base snapshot.
+//
+// The snapshot bodies under `.metaobjects/.gen-state/` are a gitignored local cache, so they
+// survive a `git checkout` that moves the output files to content they never described.
+// `--baseline=fresh` is documented to overwrite a file the committed manifest has no record of;
+// it used to three-way merge against such a body instead, keeping the old content as a "hand
+// edit" that `verify --codegen` then reported.
+
+import { describe, test, expect, beforeEach, afterEach } from "bun:test";
+import { mkdtempSync, rmSync, writeFileSync, readFileSync, readdirSync, statSync, existsSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { MetaDataLoader } from "@metaobjectsdev/metadata";
+import { FileSource } from "@metaobjectsdev/metadata/core";
+import { runGen } from "../src/runner.js";
+import { defineConfig } from "../src/metaobjects-config.js";
+import { entityFile } from "../src/generators/entity-file.js";
+import { queriesFile } from "../src/generators/queries-file.js";
+import type { BaselineMode } from "../src/overwrite-policy.js";
+
+const FIXTURE = resolve(import.meta.dir, "fixtures", "single-entity.json");
+
+let projectRoot: string;
+beforeEach(() => { projectRoot = mkdtempSync(join(tmpdir(), "adopt-baseline-")); });
+afterEach(() => { rmSync(projectRoot, { recursive: true, force: true }); });
+
+const genStateDir = (): string => join(projectRoot, ".metaobjects", ".gen-state");
+const outDir = (): string => join(projectRoot, "generated");
+const hashesFile = (): string => join(genStateDir(), ".hashes.json");
+
+const STALE = "STALE LINE WRITTEN BY AN OLDER ENGINE\n";
+
+async function gen(baseline?: BaselineMode) {
+ const { root } = await new MetaDataLoader().load([new FileSource(FIXTURE)]);
+ return runGen({
+ config: defineConfig({
+ outDir: outDir(),
+ extStyle: "none",
+ dbImport: "~/server/db",
+ dialect: "sqlite",
+ generators: [entityFile(), queriesFile()],
+ }),
+ metadata: root,
+ projectRoot,
+ ...(baseline !== undefined && { baseline }),
+ });
+}
+
+/** Append stale content to every generated file and forget its manifest record, keeping the snapshot bodies. */
+function staleAndUnrecorded(): string[] {
+ const names = readdirSync(outDir()).filter((e) => statSync(join(outDir(), e)).isFile());
+ for (const n of names) writeFileSync(join(outDir(), n), readFileSync(join(outDir(), n), "utf-8") + STALE);
+ writeFileSync(hashesFile(), "{}\n");
+ return names;
+}
+
+describe("--baseline=fresh with a stale snapshot body", () => {
+ test("overwrites a file the manifest has no record of, instead of merging the old content back", async () => {
+ await gen();
+ const names = staleAndUnrecorded();
+ // The cache the adopter still has: a body for each path, no manifest entry.
+ expect(existsSync(join(genStateDir(), "generated", names[0]!))).toBe(true);
+
+ const result = await gen("fresh");
+
+ for (const n of names) {
+ expect(readFileSync(join(outDir(), n), "utf-8")).not.toContain("STALE LINE");
+ }
+ expect(result.files.some((f) => f.status === "merged" || f.status === "conflict")).toBe(false);
+ expect(result.files.filter((f) => f.status === "overwrite").length).toBe(names.length);
+ });
+
+ test("without --baseline=fresh the same state still merges (the cache is only ignored by fresh)", async () => {
+ await gen();
+ const names = staleAndUnrecorded();
+ await gen();
+ // The stale line is a hand edit against the snapshot base, so the default flow keeps it.
+ expect(readFileSync(join(outDir(), names[0]!), "utf-8")).toContain("STALE LINE");
+ });
+
+ test("a recorded path still three-way merges under fresh", async () => {
+ await gen();
+ const names = readdirSync(outDir()).filter((e) => statSync(join(outDir(), e)).isFile());
+ const f = join(outDir(), names[0]!);
+ writeFileSync(f, readFileSync(f, "utf-8") + "// my hand edit\n");
+ await gen("fresh");
+ expect(readFileSync(f, "utf-8")).toContain("// my hand edit");
+ });
+});
diff --git a/server/typescript/packages/migrate-ts/src/check-expr-compare.ts b/server/typescript/packages/migrate-ts/src/check-expr-compare.ts
index de7d059b4..33c3b1e8a 100644
--- a/server/typescript/packages/migrate-ts/src/check-expr-compare.ts
+++ b/server/typescript/packages/migrate-ts/src/check-expr-compare.ts
@@ -28,13 +28,26 @@
// are fixed in the quote-aware walker below, which is the only place that can tell a real
// cast from a `::` inside a regex literal.
+/**
+ * `` `status` `` → `status`, OUTSIDE single-quoted literals. SQLite stores a CHECK exactly as
+ * it was written, and Drizzle-written migrations quote identifiers with backticks, so the
+ * stored text and the double-quoted one generated here would otherwise never compare equal.
+ * A backtick inside a literal (a regex pattern) is data and is left alone.
+ */
+function unwrapBacktickIdentifiers(expr: string): string {
+ return expr
+ .split(/('(?:[^']|'')*')/)
+ .map((part, i) => (i % 2 === 1 ? part : part.replace(/`(\w+)`/g, "$1")))
+ .join("");
+}
+
/**
* Canonical form: drop casts/brackets/parens, fold `= ANY (ARRAY[…])` back to `IN`,
* lower-case, collapse whitespace, canonicalize cast target types, and collapse spacing
* around commas, casts and SYMBOLIC operators (outside single-quoted literals).
*/
export function normalizeCheckExpr(expr: string): string {
- const stripped = expr
+ const stripped = unwrapBacktickIdentifiers(expr)
.toLowerCase()
// Drop `::text` / `::"MyType"` type casts PG adds to literals. The lookbehind
// restricts the strip to a cast that immediately follows a CLOSING single
diff --git a/server/typescript/packages/migrate-ts/src/introspect/sqlite-shared.ts b/server/typescript/packages/migrate-ts/src/introspect/sqlite-shared.ts
index 7d2c8ea5b..271e9fd4d 100644
--- a/server/typescript/packages/migrate-ts/src/introspect/sqlite-shared.ts
+++ b/server/typescript/packages/migrate-ts/src/introspect/sqlite-shared.ts
@@ -126,12 +126,17 @@ export function parseSqliteChecks(createSql: string | null | undefined): CheckDe
// constraint. Positions are 1:1, so the balanced expression is sliced from the
// ORIGINAL (with its real quotes intact).
const masked = maskCommentsAndStrings(createSql);
- // An optional `CONSTRAINT ` prefix then `CHECK (`. Named → group 1/2;
+ // An optional `CONSTRAINT ` prefix then `CHECK (`. Named → group 1-4;
// bare inline `CHECK (` → name defaults to "" (anonymous, expression-matched).
- const re = /(?:\bCONSTRAINT\s+(?:"((?:[^"]|"")+)"|([A-Za-z_][A-Za-z0-9_$]*))\s+)?\bCHECK\s*\(/gi;
+ // SQLite accepts four identifier quotings: "name", `name` (what Drizzle-written migrations
+ // use), [name] and a bare word. A quoting not read here leaves the name empty, and the
+ // declared check then never matches its own stored twin: a false add/drop pair.
+ const re = /(?:\bCONSTRAINT\s+(?:"((?:[^"]|"")+)"|`((?:[^`]|``)+)`|\[([^\]]+)\]|([A-Za-z_][A-Za-z0-9_$]*))\s+)?\bCHECK\s*\(/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(masked)) !== null) {
- const name = m[1] !== undefined ? m[1].replace(/""/g, '"') : (m[2] ?? "");
+ const name = m[1] !== undefined ? m[1].replace(/""/g, '"')
+ : m[2] !== undefined ? m[2].replace(/``/g, "`")
+ : (m[3] ?? m[4] ?? "");
const open = re.lastIndex - 1; // position of the "(" the regex just consumed
let depth = 0;
let inString = false;
diff --git a/server/typescript/packages/migrate-ts/test/check-evolution/normalize.test.ts b/server/typescript/packages/migrate-ts/test/check-evolution/normalize.test.ts
index 52927b452..24723d9d5 100644
--- a/server/typescript/packages/migrate-ts/test/check-evolution/normalize.test.ts
+++ b/server/typescript/packages/migrate-ts/test/check-evolution/normalize.test.ts
@@ -14,6 +14,10 @@ describe("normalizeCheckExpr", () => {
expect(checkExprEquals("length(code) >= 3", "(length(code) >= 3)")).toBe(true);
expect(checkExprEquals("status IN ('A', 'B')", "status in ('A', 'B')")).toBe(true);
});
+ test("a backtick-quoted identifier equals the double-quoted one, but a backtick in a literal is data", () => {
+ expect(checkExprEquals("`status` IN ('open', 'closed')", '"status" IN (\'open\', \'closed\')')).toBe(true);
+ expect(checkExprEquals("slug ~ 'a`b`'", "slug ~ 'ab'")).toBe(false);
+ });
test("PG `= ANY (ARRAY[...])` rewrite of an IN-list equals the generated IN form", () => {
// The crux idempotency case: PG stores `status IN ('OPEN','CLOSED')` and
// pg_get_constraintdef returns `status = ANY (ARRAY['OPEN'::text, 'CLOSED'::text])`.
diff --git a/server/typescript/packages/migrate-ts/test/unit/review-holes.test.ts b/server/typescript/packages/migrate-ts/test/unit/review-holes.test.ts
index 5e10551fd..3a08b6d39 100644
--- a/server/typescript/packages/migrate-ts/test/unit/review-holes.test.ts
+++ b/server/typescript/packages/migrate-ts/test/unit/review-holes.test.ts
@@ -70,6 +70,22 @@ describe("parseSqliteChecks — anonymous + comment/literal safety", () => {
});
});
+describe("parseSqliteChecks — every identifier quoting SQLite accepts names the constraint", () => {
+ // Drizzle-written migrations quote with backticks. An unread quoting left the name empty,
+ // so the declared check never matched its stored twin: a false add-check/drop-check pair.
+ test.each([
+ ['"items_status_chk"', "items_status_chk"],
+ ["`items_status_chk`", "items_status_chk"],
+ ["[items_status_chk]", "items_status_chk"],
+ ["items_status_chk", "items_status_chk"],
+ ["`odd``name`", "odd`name"],
+ ['"odd""name"', 'odd"name'],
+ ])("CONSTRAINT %s", (quoted, name) => {
+ const sql = `CREATE TABLE "items" ("id" INTEGER, \`status\` text CONSTRAINT ${quoted} CHECK (\`status\` IN ('open', 'closed')))`;
+ expect(parseSqliteChecks(sql)).toEqual([{ name, expression: "`status` IN ('open', 'closed')" }]);
+ });
+});
+
describe("hole 2 — drop-view exemption is keyed by (schema, name), not name", () => {
const view = (name: string, schema?: string): ViewDescriptor =>
({ name, sql: "SELECT 1", ...(schema !== undefined ? { schema } : {}) }) as ViewDescriptor;