From 2ae7a8bf4f4901763eb7b97b208abb3b5e9c384d Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 3 Nov 2026 21:50:36 -0500 Subject: [PATCH 01/10] fix(csharp-codegen): an extending entity carries its parent's properties; fix test timing flakes A C# entity that extends another skipped the inherited fields but its class declaration never named the base, so the parent's properties appeared nowhere. A concrete parent is flattened onto the child (each concrete entity owns a table with all its columns; class inheritance between mapped entities would be an EF TPH/TPT hierarchy). An emitted abstract shape is named as the base. Flakes fixed at the cause: DependencyCliTests changed the process-wide cwd outside the serial collection, racing GenCommandTests; codegen-ts compile tests run under a 30s timeout set in one helper instead of bun's 5s default. --- CHANGELOG.md | 17 +++ .../DependencyCliTests.cs | 6 + .../EntityExtendsParentTests.cs | 103 ++++++++++++++++++ .../Generators/EntityGenerator.cs | 31 ++++-- .../test/array-default-compile.test.ts | 1 + .../test/assigned-pk-insert-compile.test.ts | 1 + .../test/codegen-compile-conformance.test.ts | 1 + .../codegen-ts/test/compile-timeout.ts | 26 +++++ .../test/db-schema-generic-compile.test.ts | 1 + ...ct-optional-property-types-compile.test.ts | 1 + .../codegen-ts/test/extractor-codegen.test.ts | 1 + .../test/filter-type-enum-compile.test.ts | 1 + .../codegen-ts/test/fr004-verify-demo.test.ts | 1 + .../golden/api-docs-relative-imports.test.ts | 1 + .../extractor-render-payload-imports.test.ts | 1 + .../test/jsonb-vo-read-typed-compile.test.ts | 1 + .../test/names-extends-chain.test.ts | 1 + .../test/names-reference-compile.test.ts | 1 + .../test/nodenext-safe-imports.test.ts | 1 + .../test/owned-composition-imports.test.ts | 1 + .../test/owned-generator-skew-compile.test.ts | 1 + .../test/projection/compile.test.ts | 1 + .../int-enum-replica-view-no-dup.test.ts | 1 + .../projection/int-enum-view-compile.test.ts | 1 + .../issue-214-read-half-compile.test.ts | 1 + .../test/reference-binding-compile.test.ts | 1 + .../test/reference-byte-identical.test.ts | 1 + .../test/sourceless-entity-surface.test.ts | 1 + .../test/templates/names-consumption.test.ts | 1 + .../test/tph-read-schema-default.test.ts | 1 + .../test/tph-reference-target.test.ts | 1 + .../test/typed-create-compile.test.ts | 1 + .../verbatim-module-syntax-compile.test.ts | 1 + 33 files changed, 202 insertions(+), 9 deletions(-) create mode 100644 server/csharp/MetaObjects.Codegen.Tests/EntityExtendsParentTests.cs create mode 100644 server/typescript/packages/codegen-ts/test/compile-timeout.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 579ec52514..1c07c714f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,23 @@ here.** column, instead of suggesting a `@column` they already have. The error code is unchanged. New conformance fixtures: `column-name-shared-in-sql-view-ok`, `error-column-name-collision-derived-view` and `error-column-name-collision-table-with-sql-view`. +- **C# codegen: an entity that extends another no longer loses the parent's properties.** The member + loop skipped every inherited field but the class declaration never named the base, so the fields + appeared nowhere. A child of a **concrete** parent now declares the parent's properties itself and + names no base: each concrete entity owns a table with all its columns (what `meta migrate` creates + and the other ports' models carry), and `class Child : Parent` between two mapped entities is an EF + inheritance hierarchy (one shared table, or a child table holding only its own columns). A child of + an **abstract** parent with `EmitAbstractShapes` on now emits `class Child : Base`, as the + generator always meant to, and declares only its own members. Regenerating changes those two shapes + of C# output only. +- **Test flakes fixed at their cause.** `GenCommandTests.Gen_writes_the_hash_manifest_beside_the_metadata_not_in_cwd` + (C# Cli) failed about one run in five because `DependencyCliTests` runs `gen` in process, which + changes the process-wide working directory, without joining the serial collection every other + such class is in; the two classes ran in parallel and one read the other's directory as the cwd. + The codegen-ts tests that type-check generated output with the real TypeScript compiler (28 files, + the slowest case measured at 3.3s against bun's 5s default) now run under a 30s timeout set in one + place, `test/compile-timeout.ts`, so a loaded machine cannot report a timeout for correct code. + - **`meta migrate` refuses, at plan time, to drop a column a view it does not own still reads.** With `--from-db`, a declared-`@unmanaged` view or one outside the scoped schemas whose live body names the dropped column now blocks the `drop-column`, and the message names the view and the diff --git a/server/csharp/MetaObjects.Cli.Tests/DependencyCliTests.cs b/server/csharp/MetaObjects.Cli.Tests/DependencyCliTests.cs index cb98d46aff..e316c9ba31 100644 --- a/server/csharp/MetaObjects.Cli.Tests/DependencyCliTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/DependencyCliTests.cs @@ -5,6 +5,12 @@ namespace MetaObjects.Cli.Tests; +// OwnedRunner.Run changes the process-wide working directory and console for the length of a run. +// Outside the serial collection this class ran in parallel with every other class, so +// GenCommandTests.Gen_writes_the_hash_manifest_beside_the_metadata_not_in_cwd read THIS test's +// temp root as "the current directory", found the manifest gen had just written there, and failed +// about one run in five. +[Collection(OwnedRunnerCollection.Name)] public sealed class DependencyCliTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), "meta-dep-cli-" + Guid.NewGuid().ToString("N")); diff --git a/server/csharp/MetaObjects.Codegen.Tests/EntityExtendsParentTests.cs b/server/csharp/MetaObjects.Codegen.Tests/EntityExtendsParentTests.cs new file mode 100644 index 0000000000..c8244fa609 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/EntityExtendsParentTests.cs @@ -0,0 +1,103 @@ +// An entity that `extends` another must carry the parent's properties. +// +// The member loop skips every field the C# base class supplies, so the class declaration has +// to NAME that base — and a base is only right when it cannot become an EF inheritance +// hierarchy. A concrete parent is a mapped entity with its own table; migrate-ts gives the +// child a table with ALL the columns, which `class Child : Parent` (TPH or TPT) would +// contradict, so the child is flattened. An abstract parent is an unmapped shape (emitted +// only with EmitAbstractShapes), so inheritance is exact there. + +using MetaObjects.Codegen; +using MetaObjects.Codegen.Generators; +using MetaObjects.Loader; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +public class EntityExtendsParentTests +{ + private const string ConcreteParentModel = """ + { "metadata.root": { "package": "acme::demo", "children": [ + { "object.entity": { "name": "Parent", "children": [ + { "source.rdb": { "@table": "parents" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "title" } }, + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } } ] } }, + { "object.entity": { "name": "Child", "extends": "Parent", "children": [ + { "source.rdb": { "@table": "children" } }, + { "field.string": { "name": "extra" } } ] } } + ] } } + """; + + private const string AbstractChainModel = """ + { "metadata.root": { "package": "acme::demo", "children": [ + { "object.entity": { "name": "Base", "abstract": true, "children": [ + { "field.long": { "name": "id" } } ] } }, + { "object.entity": { "name": "Audited", "abstract": true, "extends": "Base", "children": [ + { "field.timestamp": { "name": "createdAt" } } ] } }, + { "object.entity": { "name": "Widget", "extends": "Audited", "children": [ + { "source.rdb": { "@table": "widgets" } }, + { "field.string": { "name": "label" } }, + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } } ] } } + ] } } + """; + + private static Dictionary Generate(string model, bool abstractShapes) + { + var r = new MetaDataLoader().Load([new InMemoryStringSource(model, id: "extends.json")]); + Assert.Empty(r.Errors); + var ctx = new GenContext + { + Entities = r.Root.Objects(), + Root = r.Root, + Config = new GenConfig { OutDir = "/unused", Namespace = "Acme.Generated", EmitAbstractShapes = abstractShapes }, + }; + return new EntityGenerator().Generate(ctx).ToDictionary(f => f.Path, f => f.Content); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void A_child_of_a_concrete_parent_declares_the_parents_properties_and_names_no_base(bool abstractShapes) + { + var child = Generate(ConcreteParentModel, abstractShapes)["Child.g.cs"]; + // Not an EF inheritance hierarchy: Child owns its table and every column. + Assert.Contains("public class Child", child); + Assert.DoesNotContain("class Child :", child); + Assert.Contains("public long Id { get; set; }", child); + Assert.Contains(" Title { get; set; }", child); + Assert.Contains(" Extra { get; set; }", child); + Assert.Contains("[Table(\"children\")]", child); + // The parent is untouched. + var parent = Generate(ConcreteParentModel, abstractShapes)["Parent.g.cs"]; + Assert.DoesNotContain("Extra", parent); + } + + [Fact] + public void An_entity_extending_an_emitted_abstract_shape_names_it_and_declares_only_its_own_members() + { + var files = Generate(AbstractChainModel, abstractShapes: true); + Assert.Contains("public abstract class Base", files["Base.g.cs"]); + Assert.DoesNotContain("class Base :", files["Base.g.cs"]); + var audited = files["Audited.g.cs"]; + Assert.Contains("public abstract class Audited : Base", audited); + Assert.Contains("CreatedAt", audited); + Assert.DoesNotContain(" Id ", audited); + var widget = files["Widget.g.cs"]; + Assert.Contains("public class Widget : Audited", widget); + Assert.Contains("Label", widget); + // Inherited through the base chain, not restated. + Assert.DoesNotContain("CreatedAt", widget); + Assert.DoesNotContain(" Id ", widget); + } + + [Fact] + public void Without_emitted_shapes_an_abstract_parents_fields_are_flattened_onto_the_child() + { + var widget = Generate(AbstractChainModel, abstractShapes: false)["Widget.g.cs"]; + Assert.DoesNotContain("class Widget :", widget); + Assert.Contains("public long Id { get; set; }", widget); + Assert.Contains("CreatedAt", widget); + Assert.Contains("Label", widget); + } +} diff --git a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs index 64c88f88d9..e382feff13 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs @@ -482,7 +482,7 @@ protected virtual void EmitClassDeclarationLine( // HasValue(...) fails model validation ("has a discriminator property, // but does not have a discriminator value configured"). var classKeyword = TphPlanBuilder.IsTphDiscriminatorBase(entity, ctx.Root) ? "abstract class" : "class"; - sb.AppendLine($"public {classKeyword} {className}"); + sb.AppendLine($"public {classKeyword} {className}{MappedBaseClause(entity, ctx)}"); break; case ClassDeclarationKind.TphSubtype: // Extend the nearest ancestor that actually GETS EMITTED as a class — the @@ -504,7 +504,7 @@ protected virtual void EmitClassDeclarationLine( sb.AppendLine($"public {tphKeyword} {className} : {baseClass}"); break; case ClassDeclarationKind.AbstractShape: - sb.AppendLine($"public abstract class {className}"); + sb.AppendLine($"public abstract class {className}{MappedBaseClause(entity, ctx)}"); break; case ClassDeclarationKind.ValueObject: sb.AppendLine($"public class {className}"); @@ -512,6 +512,12 @@ protected virtual void EmitClassDeclarationLine( } } + // ` : Base` when this non-TPH entity or abstract shape extends an emitted abstract shape + // (DirectMappedParent), else empty. The member loops skip the fields that base carries, + // so the declaration line MUST name it or those properties exist nowhere on the class. + private static string MappedBaseClause(MetaObject entity, GenContext ctx) => + DirectMappedParent(entity, ctx.Config) is { } parent ? $" : {CSharpNaming.Pascal(parent.Name)}" : ""; + // A M:N navigation collection property on the source entity. The collection is // the relationship name PascalCased; its element type is the target entity. // @@ -733,16 +739,23 @@ protected static MetaObject EmittedTphAncestor(MetaObject member, GenContext ctx // The directly-extended entity to emit as a C# base class for a NON-TPH entity (mapped // entity or abstract shape). The metadata `extends` chain becomes the C# inheritance - // chain: `class Physician : BaseAuditableEntity`, `abstract class BaseAuditableEntity : - // BaseEntity`. Returns the immediate `extends` target when it is an emitted entity — an - // abstract base (only when EmitAbstractShapes is on, since the base must exist as a type) - // or a concrete parent. Null when there is no such parent: the entity then flattens its - // (un-emitted) abstract ancestors' fields as before. TPH members use DirectTphParent. + // chain ONLY through abstract shapes: `class Physician : BaseAuditableEntity`, `abstract + // class BaseAuditableEntity : BaseEntity`. An abstract shape is not mapped, so EF folds + // its properties into each derived entity's own table — exactly what every other port + // and the migrate-ts schema give a child of an abstract parent. + // + // Returns null otherwise: the entity then declares every resolved field itself. That + // includes a CONCRETE parent. Each concrete entity owns a table carrying ALL its columns + // (the migrate-ts schema gives `Child` a table with the parent's columns and its own, + // and the other ports' child models carry both), but a C# `Child : Parent` between two + // MAPPED entities is an EF inheritance hierarchy — TPH (one shared table plus a + // discriminator) or TPT (child table holding only the child's own columns). Either + // contradicts that schema, so a concrete parent is flattened, never named as a base. + // TPH members use DirectTphParent. protected static MetaObject? DirectMappedParent(MetaObject entity, GenConfig config) { if (entity.SuperData is not MetaObject sup || !sup.IsEntity()) return null; - if (InstanceArtifacts.IsAbstract(sup)) return config.EmitAbstractShapes ? sup : null; - return sup; + return InstanceArtifacts.IsAbstract(sup) && config.EmitAbstractShapes ? sup : null; } // The nearest @discriminator-bearing ancestor of a TPH subtype (its TPH base). diff --git a/server/typescript/packages/codegen-ts/test/array-default-compile.test.ts b/server/typescript/packages/codegen-ts/test/array-default-compile.test.ts index 02c4fdef6a..470d6fcccd 100644 --- a/server/typescript/packages/codegen-ts/test/array-default-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/array-default-compile.test.ts @@ -14,6 +14,7 @@ // codegen-ts and can't be pushed into metadata. import { describe, test, expect } from "bun:test"; +import "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; diff --git a/server/typescript/packages/codegen-ts/test/assigned-pk-insert-compile.test.ts b/server/typescript/packages/codegen-ts/test/assigned-pk-insert-compile.test.ts index 02af8efea2..fb9cf3169f 100644 --- a/server/typescript/packages/codegen-ts/test/assigned-pk-insert-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/assigned-pk-insert-compile.test.ts @@ -19,6 +19,7 @@ // a mismatch BETWEEN those two files, so a single-file gate cannot see it. import { describe, test, expect } from "bun:test"; +import "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; diff --git a/server/typescript/packages/codegen-ts/test/codegen-compile-conformance.test.ts b/server/typescript/packages/codegen-ts/test/codegen-compile-conformance.test.ts index 53c0ebfbe2..45bbcd214d 100644 --- a/server/typescript/packages/codegen-ts/test/codegen-compile-conformance.test.ts +++ b/server/typescript/packages/codegen-ts/test/codegen-compile-conformance.test.ts @@ -30,6 +30,7 @@ // precisely the bug class this exists to catch — so a skip here is never "just this lane". import { describe, test, expect } from "bun:test"; +import "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join, resolve, dirname } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; diff --git a/server/typescript/packages/codegen-ts/test/compile-timeout.ts b/server/typescript/packages/codegen-ts/test/compile-timeout.ts new file mode 100644 index 0000000000..41011e0302 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/compile-timeout.ts @@ -0,0 +1,26 @@ +// Side-effect import for every test file that type-checks generated output with the real +// TypeScript compiler (`ts.createProgram`) or otherwise re-runs the whole generator suite. +// +// Why a timeout at all: bun's default is 5s. A compile test parses lib.d.ts, drizzle-orm and +// zod's declarations on every call, and that costs 0.5-3.3s on an idle machine (the slowest, +// codegen-compile-conformance, measured 2.7-3.3s per case; the full codegen-ts run measured +// 2.5 min for 3,293 tests). That is under 2x headroom against the default, so a loaded +// machine (a parallel lane, a gate run, another agent) pushes a correct test past 5s and it +// reports a timeout that says nothing about the code. +// +// 30s is ~9x the slowest measured case: loose enough that load cannot trip it, tight enough +// that a compile which really hangs still fails in half a minute. The default is restored when +// the importing file finishes, so other files keep bun's 5s hang detection. +// +// Usage: `import "./compile-timeout.js";` (relative to the test file) before the tests. + +import { afterAll, setDefaultTimeout } from "bun:test"; + +/** Bun's own default per-test timeout, restored after a file that raised it. */ +const BUN_DEFAULT_TIMEOUT_MS = 5_000; + +/** Per-test timeout for type-checking tests: ~9x the slowest measured case. */ +export const COMPILE_TEST_TIMEOUT_MS = 30_000; + +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); +afterAll(() => setDefaultTimeout(BUN_DEFAULT_TIMEOUT_MS)); diff --git a/server/typescript/packages/codegen-ts/test/db-schema-generic-compile.test.ts b/server/typescript/packages/codegen-ts/test/db-schema-generic-compile.test.ts index b568b79639..a88e26438e 100644 --- a/server/typescript/packages/codegen-ts/test/db-schema-generic-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/db-schema-generic-compile.test.ts @@ -33,6 +33,7 @@ // which is what an adopter actually does with generated Drizzle tables. import { describe, test, expect } from "bun:test"; +import "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; diff --git a/server/typescript/packages/codegen-ts/test/exact-optional-property-types-compile.test.ts b/server/typescript/packages/codegen-ts/test/exact-optional-property-types-compile.test.ts index eda718d743..86d7bdf2fe 100644 --- a/server/typescript/packages/codegen-ts/test/exact-optional-property-types-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/exact-optional-property-types-compile.test.ts @@ -15,6 +15,7 @@ // all-optional value object. import { describe, test, expect } from "bun:test"; +import "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; diff --git a/server/typescript/packages/codegen-ts/test/extractor-codegen.test.ts b/server/typescript/packages/codegen-ts/test/extractor-codegen.test.ts index 9328dbde6e..0a0ff79122 100644 --- a/server/typescript/packages/codegen-ts/test/extractor-codegen.test.ts +++ b/server/typescript/packages/codegen-ts/test/extractor-codegen.test.ts @@ -11,6 +11,7 @@ // emitted extractor module under bun, and CALL the generated functions. import { describe, test, expect, afterAll } from "bun:test"; +import "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; diff --git a/server/typescript/packages/codegen-ts/test/filter-type-enum-compile.test.ts b/server/typescript/packages/codegen-ts/test/filter-type-enum-compile.test.ts index 7fbf133e65..088a3f767f 100644 --- a/server/typescript/packages/codegen-ts/test/filter-type-enum-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/filter-type-enum-compile.test.ts @@ -13,6 +13,7 @@ // assignments must type-check. import { describe, test, expect } from "bun:test"; +import "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; diff --git a/server/typescript/packages/codegen-ts/test/fr004-verify-demo.test.ts b/server/typescript/packages/codegen-ts/test/fr004-verify-demo.test.ts index 164cf6c9cb..749ee267cd 100644 --- a/server/typescript/packages/codegen-ts/test/fr004-verify-demo.test.ts +++ b/server/typescript/packages/codegen-ts/test/fr004-verify-demo.test.ts @@ -15,6 +15,7 @@ // `bun run typecheck`) so it is hermetic + immune to workspace self-link noise. import { describe, test, expect } from "bun:test"; +import "./compile-timeout.js"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/server/typescript/packages/codegen-ts/test/golden/api-docs-relative-imports.test.ts b/server/typescript/packages/codegen-ts/test/golden/api-docs-relative-imports.test.ts index 9b0c02b336..e3eb78e881 100644 --- a/server/typescript/packages/codegen-ts/test/golden/api-docs-relative-imports.test.ts +++ b/server/typescript/packages/codegen-ts/test/golden/api-docs-relative-imports.test.ts @@ -22,6 +22,7 @@ // `routesFileHono` generator's `emitsHonoRoutes` marker). import { describe, test, expect, afterAll } from "bun:test"; +import "../compile-timeout.js"; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; diff --git a/server/typescript/packages/codegen-ts/test/golden/extractor-render-payload-imports.test.ts b/server/typescript/packages/codegen-ts/test/golden/extractor-render-payload-imports.test.ts index b88f82d2d2..6e83808a63 100644 --- a/server/typescript/packages/codegen-ts/test/golden/extractor-render-payload-imports.test.ts +++ b/server/typescript/packages/codegen-ts/test/golden/extractor-render-payload-imports.test.ts @@ -16,6 +16,7 @@ // The payload import must RESOLVE (no TS2307) and the payload type must be found. import { describe, test, expect, afterAll } from "bun:test"; +import "../compile-timeout.js"; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; diff --git a/server/typescript/packages/codegen-ts/test/jsonb-vo-read-typed-compile.test.ts b/server/typescript/packages/codegen-ts/test/jsonb-vo-read-typed-compile.test.ts index 5a57bb213d..81e5417816 100644 --- a/server/typescript/packages/codegen-ts/test/jsonb-vo-read-typed-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/jsonb-vo-read-typed-compile.test.ts @@ -9,6 +9,7 @@ // the generated finder, compiled against the generated tree with the real TS compiler. import { describe, test, expect } from "bun:test"; +import "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; diff --git a/server/typescript/packages/codegen-ts/test/names-extends-chain.test.ts b/server/typescript/packages/codegen-ts/test/names-extends-chain.test.ts index 9285c0ac80..d58ebdfd99 100644 --- a/server/typescript/packages/codegen-ts/test/names-extends-chain.test.ts +++ b/server/typescript/packages/codegen-ts/test/names-extends-chain.test.ts @@ -12,6 +12,7 @@ // pass just as well for a generator that emitted both the spread AND the restated literal, // which is the outcome this change exists to prevent. import { describe, test, expect } from "bun:test"; +import "./compile-timeout.js"; import { mkdtempSync, rmSync, readdirSync, readFileSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; diff --git a/server/typescript/packages/codegen-ts/test/names-reference-compile.test.ts b/server/typescript/packages/codegen-ts/test/names-reference-compile.test.ts index 2edd302589..6c236bd567 100644 --- a/server/typescript/packages/codegen-ts/test/names-reference-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/names-reference-compile.test.ts @@ -23,6 +23,7 @@ // the assigned-PK bug lived exactly there, between the entity file and the queries file. import { describe, test, expect } from "bun:test"; +import "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; diff --git a/server/typescript/packages/codegen-ts/test/nodenext-safe-imports.test.ts b/server/typescript/packages/codegen-ts/test/nodenext-safe-imports.test.ts index 3d552eb161..b94e947286 100644 --- a/server/typescript/packages/codegen-ts/test/nodenext-safe-imports.test.ts +++ b/server/typescript/packages/codegen-ts/test/nodenext-safe-imports.test.ts @@ -15,6 +15,7 @@ // only the extension errors this fix targets are asserted. import { describe, test, expect } from "bun:test"; +import "./compile-timeout.js"; import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; diff --git a/server/typescript/packages/codegen-ts/test/owned-composition-imports.test.ts b/server/typescript/packages/codegen-ts/test/owned-composition-imports.test.ts index b9b74ec8d1..9644caaf8b 100644 --- a/server/typescript/packages/codegen-ts/test/owned-composition-imports.test.ts +++ b/server/typescript/packages/codegen-ts/test/owned-composition-imports.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import "./compile-timeout.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; diff --git a/server/typescript/packages/codegen-ts/test/owned-generator-skew-compile.test.ts b/server/typescript/packages/codegen-ts/test/owned-generator-skew-compile.test.ts index 516a81d527..66e4aedfeb 100644 --- a/server/typescript/packages/codegen-ts/test/owned-generator-skew-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/owned-generator-skew-compile.test.ts @@ -14,6 +14,7 @@ // real TypeScript compiler. import { describe, test, expect } from "bun:test"; +import "./compile-timeout.js"; import { mkdtempSync, rmSync, readdirSync } from "node:fs"; import { join, resolve } from "node:path"; import ts from "typescript"; diff --git a/server/typescript/packages/codegen-ts/test/projection/compile.test.ts b/server/typescript/packages/codegen-ts/test/projection/compile.test.ts index 534c40ca85..5e5df1ca55 100644 --- a/server/typescript/packages/codegen-ts/test/projection/compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/projection/compile.test.ts @@ -8,6 +8,7 @@ // non-compiling output fails here, not just the one we already knew about. import { describe, test, expect } from "bun:test"; +import "../compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; diff --git a/server/typescript/packages/codegen-ts/test/projection/int-enum-replica-view-no-dup.test.ts b/server/typescript/packages/codegen-ts/test/projection/int-enum-replica-view-no-dup.test.ts index e96a901c60..4757b69b5b 100644 --- a/server/typescript/packages/codegen-ts/test/projection/int-enum-replica-view-no-dup.test.ts +++ b/server/typescript/packages/codegen-ts/test/projection/int-enum-replica-view-no-dup.test.ts @@ -6,6 +6,7 @@ // duplicate-identifier compile error, i.e. the same class of bug as emitting none. import { describe, test, expect } from "bun:test"; +import "../compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; diff --git a/server/typescript/packages/codegen-ts/test/projection/int-enum-view-compile.test.ts b/server/typescript/packages/codegen-ts/test/projection/int-enum-view-compile.test.ts index 20eb413ae9..2ac48c1b4d 100644 --- a/server/typescript/packages/codegen-ts/test/projection/int-enum-view-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/projection/int-enum-view-compile.test.ts @@ -17,6 +17,7 @@ // declare the codec, is covered by `int-enum-replica-view-no-dup.test.ts`. import { describe, test, expect } from "bun:test"; +import "../compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; diff --git a/server/typescript/packages/codegen-ts/test/projection/issue-214-read-half-compile.test.ts b/server/typescript/packages/codegen-ts/test/projection/issue-214-read-half-compile.test.ts index 7fec7c2a64..2b7e12df7a 100644 --- a/server/typescript/packages/codegen-ts/test/projection/issue-214-read-half-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/projection/issue-214-read-half-compile.test.ts @@ -8,6 +8,7 @@ // type + query code must type-check (`typeof view.$inferSelect`, the re-read, etc.). import { describe, test, expect } from "bun:test"; +import "../compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; diff --git a/server/typescript/packages/codegen-ts/test/reference-binding-compile.test.ts b/server/typescript/packages/codegen-ts/test/reference-binding-compile.test.ts index 88af9e562d..7414eb1226 100644 --- a/server/typescript/packages/codegen-ts/test/reference-binding-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/reference-binding-compile.test.ts @@ -13,6 +13,7 @@ // module that has none: the rows live in the discriminator base's table. import { describe, expect, test } from "bun:test"; +import "./compile-timeout.js"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; diff --git a/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts b/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts index 32f99156a7..e9cda93c8c 100644 --- a/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts +++ b/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts @@ -3,6 +3,7 @@ // The reference generators import only "@metaobjectsdev/codegen-ts" (the public engine); // if this passes, a consumer can copy them out and own them with no behavior change. import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import "./compile-timeout.js"; import { existsSync, mkdirSync, mkdtempSync, rmSync, readdirSync, readFileSync, cpSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; diff --git a/server/typescript/packages/codegen-ts/test/sourceless-entity-surface.test.ts b/server/typescript/packages/codegen-ts/test/sourceless-entity-surface.test.ts index 87885cbf6a..04768e5787 100644 --- a/server/typescript/packages/codegen-ts/test/sourceless-entity-surface.test.ts +++ b/server/typescript/packages/codegen-ts/test/sourceless-entity-surface.test.ts @@ -19,6 +19,7 @@ // document-store adopter would, with the real TypeScript compiler. import { describe, test, expect } from "bun:test"; +import "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; diff --git a/server/typescript/packages/codegen-ts/test/templates/names-consumption.test.ts b/server/typescript/packages/codegen-ts/test/templates/names-consumption.test.ts index b141107640..94d46dad23 100644 --- a/server/typescript/packages/codegen-ts/test/templates/names-consumption.test.ts +++ b/server/typescript/packages/codegen-ts/test/templates/names-consumption.test.ts @@ -11,6 +11,7 @@ // chain is under test: namesFile()'s `emitsNames` marker → the runner's aggregation → // ResolvedGenConfig.includeNames + RenderContext.includeNames → the template. import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import "../compile-timeout.js"; import { existsSync, mkdtempSync, rmSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; diff --git a/server/typescript/packages/codegen-ts/test/tph-read-schema-default.test.ts b/server/typescript/packages/codegen-ts/test/tph-read-schema-default.test.ts index 5b77635c8e..598fde24b1 100644 --- a/server/typescript/packages/codegen-ts/test/tph-read-schema-default.test.ts +++ b/server/typescript/packages/codegen-ts/test/tph-read-schema-default.test.ts @@ -16,6 +16,7 @@ // the field is declared at — and by compiling the tree, which is the symptom adopters saw. import { describe, test, expect } from "bun:test"; +import "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; import { pathToFileURL } from "node:url"; diff --git a/server/typescript/packages/codegen-ts/test/tph-reference-target.test.ts b/server/typescript/packages/codegen-ts/test/tph-reference-target.test.ts index e7aad98168..b36177e80f 100644 --- a/server/typescript/packages/codegen-ts/test/tph-reference-target.test.ts +++ b/server/typescript/packages/codegen-ts/test/tph-reference-target.test.ts @@ -17,6 +17,7 @@ // `expected-schema-tph.test.ts`, which asserts the same FKs from the DDL side. import { describe, test, expect } from "bun:test"; +import "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; import ts from "typescript"; diff --git a/server/typescript/packages/codegen-ts/test/typed-create-compile.test.ts b/server/typescript/packages/codegen-ts/test/typed-create-compile.test.ts index 91c5947a0b..ac9fdb1028 100644 --- a/server/typescript/packages/codegen-ts/test/typed-create-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/typed-create-compile.test.ts @@ -11,6 +11,7 @@ // clean, a misspelt or missing field is an error at the call site. import { describe, test, expect } from "bun:test"; +import "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; diff --git a/server/typescript/packages/codegen-ts/test/verbatim-module-syntax-compile.test.ts b/server/typescript/packages/codegen-ts/test/verbatim-module-syntax-compile.test.ts index c738e0f94d..43043fa66f 100644 --- a/server/typescript/packages/codegen-ts/test/verbatim-module-syntax-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/verbatim-module-syntax-compile.test.ts @@ -15,6 +15,7 @@ // annotation is actually emitted. import { describe, test, expect } from "bun:test"; +import "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; From b498d57e3c1980e4783975ff514ed0068e473480 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 4 Nov 2026 01:27:39 -0500 Subject: [PATCH 02/10] fix(codegen): skip cross-kind cross-field comparisons in every port; executed Patch check; TPH form rule test A validator.comparison between fields of different kinds (e.g. date vs timestamp) is now skipped in Java, Kotlin, TypeScript and Python as C# already did, instead of a 500 (ClassCastException), a string compare, or a TypeError. Adds the shared comparison-across-kinds-is-skipped conformance case, replaces the Java Patch source-text comparison with an executed check, and adds a TPH subtype form test for a cross-field rule. --- CHANGELOG.md | 14 +++++ fixtures/validation-conformance/README.md | 4 +- .../cross-field-cases.json | 15 +++++ .../meta.cross-field.json | 3 + .../CrossFieldValidationConformanceTests.cs | 2 +- .../CrossFieldValidatorGenerationTests.cs | 55 ++++++++++--------- .../Generators/EntityGenerator.cs | 30 ++-------- .../generator/kotlin/KotlinCrossFieldRules.kt | 41 ++++++++++++-- .../kotlin/ValidationConformanceTest.kt | 2 +- server/java/codegen-spring/pom.xml | 9 +++ .../spring/SpringGeneratorSupport.java | 40 ++++++++++++-- .../SpringCrossFieldValidationTest.java | 22 ++++++-- .../spring/ValidationConformanceTest.java | 2 +- .../codegen/generators/entity_model.py | 24 ++++++++ .../codegen-ts-react/test/tph-form.test.ts | 24 +++++++- .../src/templates/zod-validators.ts | 18 ++++++ 16 files changed, 232 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c07c714f9..1c85e8b87d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,20 @@ here.** The codegen-ts tests that type-check generated output with the real TypeScript compiler (28 files, the slowest case measured at 3.3s against bun's 5s default) now run under a 30s timeout set in one place, `test/compile-timeout.ts`, so a loaded machine cannot report a timeout for correct code. +- **A cross-field `validator.comparison` between fields of different kinds is skipped in every + port, never rejected and never a 500.** Comparing a `field.date` with a `field.timestamp` (or any + two strings or temporals of different subtype) has no ordering. Java and Kotlin cast both sides + to `Comparable` inside the class-level validity check, so the cast threw `ClassCastException` and + a create answered 500 instead of 400; TypeScript compared the two as strings and Python raised a + `TypeError` that surfaced as an invalid body. All five ports now follow one rule: numerics compare + with each other, strings and temporals only with their own subtype, boolean and enum fields test + equality only, and any other pair emits no rule. New shared case + `comparison-across-kinds-is-skipped` in `fixtures/validation-conformance/`, run by every port. +- **Test coverage for cross-field validators.** A subtype form on a TPH hierarchy that declares a + cross-field rule now has a test proving the generated form binds the refined body schema (the rule + itself was enforced by the cross-field build). The Java check that a Patch is not held to cross-field + validators now runs the generated classes (the violating body is rejected by the Dto and accepted + by the Patch) instead of comparing generated source text. - **`meta migrate` refuses, at plan time, to drop a column a view it does not own still reads.** With `--from-db`, a declared-`@unmanaged` view or one outside the scoped schemas whose live body diff --git a/fixtures/validation-conformance/README.md b/fixtures/validation-conformance/README.md index b6fc8ca33e..39f85af6fb 100644 --- a/fixtures/validation-conformance/README.md +++ b/fixtures/validation-conformance/README.md @@ -122,7 +122,9 @@ is optional, so absence is expressible in every port. The semantics mirror the DB `CHECK` derivation in `migrate-ts/src/expected-schema.ts`, with an absent or null value standing for SQL `NULL` (and `""` NOT null): -- `comparison` (`startsAt lte endsAt`): passes when either side is null. +- `comparison` (`startsAt lte endsAt`): passes when either side is null. Fields of different kinds + have no ordering and the rule is SKIPPED in every port, never an error (`eventDate lte loggedAt`, a + date against a timestamp, is declared on `Event` and must accept a body where the date is later). - `requiredWhen`: violated only when the gating field equals `@equals` and the target is null; a null gate is unconstrained. Both a string gate (`kind = BIRTH`) and a boolean gate (`flag = true`). - `presentIff`: the target is present exactly when the gate equals `@equals`; a null gate counts as diff --git a/fixtures/validation-conformance/cross-field-cases.json b/fixtures/validation-conformance/cross-field-cases.json index 57949d652f..4e3da6899d 100644 --- a/fixtures/validation-conformance/cross-field-cases.json +++ b/fixtures/validation-conformance/cross-field-cases.json @@ -258,6 +258,21 @@ "review": "DONE" }, "expectValid": true + }, + { + "name": "comparison-across-kinds-is-skipped", + "entity": "Event", + "payload": { + "startsAt": 1, + "endsAt": 2, + "kind": "BIRTH", + "subject": "p1", + "state": "OPEN", + "email": "a@b.c", + "eventDate": "2030-01-02", + "loggedAt": "2020-01-01T00:00:00Z" + }, + "expectValid": true } ] } diff --git a/fixtures/validation-conformance/meta.cross-field.json b/fixtures/validation-conformance/meta.cross-field.json index c2e4b65c98..ece94d3263 100644 --- a/fixtures/validation-conformance/meta.cross-field.json +++ b/fixtures/validation-conformance/meta.cross-field.json @@ -15,6 +15,8 @@ { "field.string": { "name": "closedNote" } }, { "field.boolean": { "name": "flag" } }, { "field.string": { "name": "flagNote" } }, + { "field.date": { "name": "eventDate" } }, + { "field.timestamp": { "name": "loggedAt" } }, { "field.string": { "name": "email" } }, { "field.string": { "name": "phone" } }, { "field.string": { "name": "review" } }, @@ -24,6 +26,7 @@ { "validator.requiredWhen": { "name": "birthNeedsSubject", "@field": "subject", "@when": "kind", "@equals": "BIRTH" } }, { "validator.requiredWhen": { "name": "flaggedNeedsNote", "@field": "flagNote", "@when": "flag", "@equals": "true" } }, { "validator.presentIff": { "name": "closedIffNote", "@field": "closedNote", "@when": "state", "@equals": "CLOSED" } }, + { "validator.comparison": { "name": "dateVsTimestamp", "@left": "eventDate", "@op": "lte", "@right": "loggedAt" } }, { "validator.atLeastOne": { "name": "reachable", "@fields": ["email", "phone"] } }, { "validator.requiredWhen": { "name": "reviewNeedsReviewer", "@field": "reviewedBy", "@when": "review", "@equals": "DONE" } } ] diff --git a/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidationConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidationConformanceTests.cs index 72f15b02a6..775f53b110 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidationConformanceTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidationConformanceTests.cs @@ -45,7 +45,7 @@ public static IEnumerable Cases() => AllCases().Select(c => new object[] { c.GetProperty("name").GetString()! }); [Fact] - public void Discovers_all_cross_field_cases() => Assert.Equal(20, AllCases().Length); + public void Discovers_all_cross_field_cases() => Assert.Equal(21, AllCases().Length); [Theory] [MemberData(nameof(Cases))] diff --git a/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs b/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs index 1bf6703669..40cd02413f 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs @@ -113,6 +113,21 @@ public class CrossFieldValidatorGenerationTests """; // A decimal compared with a double: C# has no implicit conversion between the two. + private const string AbstractParent = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Base", "abstract": true, "children": [ + { "field.int": { "name": "a" } }, + { "field.int": { "name": "b" } }, + { "validator.comparison": { "name": "ab", "@left": "a", "@op": "lte", "@right": "b" } } + ]}}, + { "object.entity": { "name": "Item", "extends": "Base", "children": [ + { "source.rdb": { "@table": "items" } }, + { "field.long": { "name": "id" } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """; + private const string MixedNumeric = """ { "metadata.root": { "package": "acme", "children": [ { "object.entity": { "name": "Quote", "children": [ @@ -126,20 +141,20 @@ public class CrossFieldValidatorGenerationTests ]}} """; - private static GenContext Ctx(string model) + private static GenContext Ctx(string model, bool abstractShapes = false) { var r = new MetaDataLoader().Load([new InMemoryStringSource(model, id: "xf.json")]); Assert.Empty(r.Errors); return new GenContext { Entities = r.Root.Objects(), Root = r.Root, - Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme" }, + Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme", EmitAbstractShapes = abstractShapes }, }; } - private static Assembly Compile(EntityGenerator generator, string model) + private static Assembly Compile(EntityGenerator generator, string model, bool abstractShapes = false) { - var trees = generator.Generate(Ctx(model)).Select(f => CSharpSyntaxTree.ParseText(f.Content)).ToArray(); + var trees = generator.Generate(Ctx(model, abstractShapes)).Select(f => CSharpSyntaxTree.ParseText(f.Content)).ToArray(); var refs = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) .Split(Path.PathSeparator).Where(p => p.Length > 0) .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)).ToList(); @@ -185,25 +200,26 @@ public void Entity_without_cross_field_validators_is_not_validatable() } [Fact] - public void Derived_entity_runs_the_inherited_rule_and_its_own() + public void A_child_of_a_concrete_parent_runs_the_inherited_rule_and_its_own() { - var asm = Compile(new BasedEntityGenerator(), Inherited); + // The child is flattened (it carries the parent's properties itself), so the parent's + // rule has members to read and runs beside the child's own, with no special case. + var asm = Compile(new EntityGenerator(), Inherited); Assert.Equal(2, Validate(asm, "Child", "{\"a\":3,\"b\":1,\"c\":1,\"d\":0}").Count); } [Fact] - public void Without_an_emitted_base_class_a_validator_on_an_unemitted_member_is_skipped() + public void A_child_of_an_emitted_abstract_shape_enforces_the_rule_declared_on_the_shape() { - // The stock mapped declaration names no C# base, so Child carries only its own fields; - // the inherited rule references members it does not have and is skipped. - var asm = Compile(new EntityGenerator(), Inherited); - Assert.Single(Validate(asm, "Child", "{\"a\":3,\"b\":1,\"c\":1,\"d\":0}")); + var asm = Compile(new EntityGenerator(), AbstractParent, abstractShapes: true); + Assert.Single(Validate(asm, "Item", "{\"a\":3,\"b\":1}")); + Assert.Empty(Validate(asm, "Item", "{\"a\":1,\"b\":3}")); } [Fact] - public void Derived_entity_compiles_when_every_base_rule_is_skipped() + public void A_child_compiles_when_every_inherited_rule_is_skipped() { - var asm = Compile(new BasedEntityGenerator(), SkippedParent); + var asm = Compile(new EntityGenerator(), SkippedParent); Assert.Single(Validate(asm, "Child", "{\"kind\":\"X\"}")); Assert.Empty(Validate(asm, "Child", "{\"kind\":\"X\",\"note\":\"n\"}")); } @@ -216,17 +232,4 @@ public void Tph_subtype_runs_the_rule_of_an_unemitted_abstract_level() Assert.Single(Validate(asm, "Dog", "{\"lo\":3,\"hi\":2,\"kind\":\"FURRY\",\"fur\":\"f\"}")); Assert.Empty(Validate(asm, "Dog", "{\"lo\":1,\"hi\":2,\"kind\":\"FURRY\",\"fur\":\"f\"}")); } - - // Emits the metadata `extends` chain as a C# base, the shape an adopter override produces. - private sealed class BasedEntityGenerator : EntityGenerator - { - protected override void EmitClassDeclarationLine( - System.Text.StringBuilder sb, MetaObject entity, string className, ClassDeclarationKind kind, GenContext ctx) - { - if (kind == ClassDeclarationKind.MappedEntity && DirectMappedParent(entity, ctx.Config) is { } p) - sb.AppendLine($"public class {className} : {CSharpNaming.Pascal(p.Name)}"); - else - base.EmitClassDeclarationLine(sb, entity, className, kind, ctx); - } - } } diff --git a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs index e382feff13..e5b2a43438 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs @@ -294,18 +294,11 @@ private EmittedFile EmitMappedClassImpl( EmitClassHeader(sb, entity, className, isProjection, pkFields, ctx); // Cross-field validators run on the full create record only (never a read-only view). - // The C# base chain is real only when the declaration line actually names a base; fields - // the class skips as "inherited" are otherwise absent as properties, so a validator that - // references one is skipped rather than emitted against a member that does not exist. - var mappedBase = DirectMappedParent(entity, ctx.Config); - var declaresBase = mappedBase is not null && LastLineHasBase(sb); - var validatingBase = declaresBase && !InstanceArtifacts.IsAbstract(mappedBase!) - && HasEffectiveCrossField(mappedBase!) ? mappedBase : null; - var absent = mappedBase is not null && !declaresBase - ? new HashSet(mappedBase.Fields().Select(f => f.Name), StringComparer.Ordinal) - : null; - var validateMethod = isProjection ? null : CrossFieldValidateMethod(entity, validatingBase, absent); - if (validateMethod is not null && validatingBase is null) AppendValidatableInterface(sb); + // A non-TPH entity never has a validating base: its only possible base is an abstract + // shape, which declares no Validate, and every property it carries is present on this + // class (own or inherited through the base chain), so every effective rule is emitted here. + var validateMethod = isProjection ? null : CrossFieldValidateMethod(entity, validatingBase: null); + if (validateMethod is not null) AppendValidatableInterface(sb); sb.AppendLine("{"); // Nested enum declarations (before the class properties, as C# enum members @@ -1067,15 +1060,6 @@ private static List CrossFieldValidatorsFor(MetaObject entity, Me private static bool HasEffectiveCrossField(MetaObject entity) => entity.Validators().Any(IsCrossFieldValidator); - private static bool LastLineHasBase(StringBuilder sb) - { - var len = sb.Length; - while (len > 0 && (sb[len - 1] == '\n' || sb[len - 1] == '\r')) len--; - var start = len; - while (start > 0 && sb[start - 1] != '\n') start--; - return sb.ToString(start, len - start).Contains(" : ", StringComparison.Ordinal); - } - // Appends `, IValidatableObject` (or ` : IValidatableObject`) to the just-written class // declaration line. Works on the builder so an overridden declaration seam still applies. private static void AppendValidatableInterface(StringBuilder sb) @@ -1090,8 +1074,7 @@ private static void AppendValidatableInterface(StringBuilder sb) } // Returns the emitted Validate method (4-space class-body indent), or null when nothing to emit. - private string? CrossFieldValidateMethod( - MetaObject entity, MetaObject? validatingBase, ISet? absentProps = null) + private string? CrossFieldValidateMethod(MetaObject entity, MetaObject? validatingBase) { var validators = CrossFieldValidatorsFor(entity, validatingBase); if (validators.Count == 0) return null; @@ -1111,7 +1094,6 @@ private static void AppendValidatableInterface(StringBuilder sb) var f = entity.Fields().FirstOrDefault(x => x.Name == n); if (f is null || f.ResolvedIsArray()) return null; if (f.IsReadOnlyMutability || f.IsDerived() || generatedKey.Contains(f.Name)) return null; - if (absentProps is not null && absentProps.Contains(f.Name)) return null; // not a property of this class return CSharpNaming.ScalarFor(f.SubType) is not null || f.SubType == FIELD_SUBTYPE_ENUM ? f : null; } diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldRules.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldRules.kt index b205c1db7a..33f7c28d9c 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldRules.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldRules.kt @@ -3,12 +3,17 @@ package com.metaobjects.generator.kotlin import com.metaobjects.MetaData import com.metaobjects.field.BooleanField import com.metaobjects.field.CurrencyField +import com.metaobjects.field.DateField import com.metaobjects.field.DecimalField import com.metaobjects.field.DoubleField import com.metaobjects.field.FloatField import com.metaobjects.field.IntegerField import com.metaobjects.field.LongField +import com.metaobjects.field.EnumField import com.metaobjects.field.MetaField +import com.metaobjects.field.StringField +import com.metaobjects.field.TimeField +import com.metaobjects.field.TimestampField import com.metaobjects.`object`.MetaObject import com.metaobjects.validator.AtLeastOneValidator import com.metaobjects.validator.ComparisonValidator @@ -27,6 +32,33 @@ object KotlinCrossFieldRules { f is IntegerField || f is LongField || f is DoubleField || f is FloatField || f is DecimalField || f is CurrencyField + private fun isTemporalCross(f: MetaField<*>): Boolean = + f is DateField || f is TimeField || f is TimestampField + + /** + * The comparison for two fields, or null when the pair has no sound one. Mirrors the C# + * generator: numerics compare with each other, strings and temporals only with their own + * subtype (a date has no ordering against a timestamp, and casting both to Comparable throws + * ClassCastException inside the validity property, turning a create into a 500), and + * boolean/enum fields only test equality. Anything else is skipped, like every other port. + */ + private fun comparison(l: MetaField<*>, r: MetaField<*>, op: String, kop: String, ref: (MetaField<*>) -> String): CodeBlock? { + val ordering = op == "gt" || op == "gte" || op == "lt" || op == "lte" + val same = l.subType == r.subType + if (isNumericCross(l) && isNumericCross(r)) { + return if (same) CodeBlock.of("(%L as Comparable).compareTo(%L) $kop 0", ref(l), ref(r)) + else CodeBlock.of("java.math.BigDecimal(%L.toString()).compareTo(java.math.BigDecimal(%L.toString())) $kop 0", ref(l), ref(r)) + } + if (!same) return null + if (l is StringField || isTemporalCross(l)) { + return CodeBlock.of("(%L as Comparable).compareTo(%L) $kop 0", ref(l), ref(r)) + } + if (!ordering && (l is BooleanField || l is EnumField)) { + return CodeBlock.of("(%L %L %L)", ref(l), if (op == "eq") "==" else "!=", ref(r)) + } + return null + } + private fun crossAttr(v: MetaData, attr: String): String? = if (v.hasMetaAttr(attr)) v.getMetaAttr(attr).value?.toString() else null @@ -63,16 +95,13 @@ object KotlinCrossFieldRules { is ComparisonValidator -> { val l = fields[crossAttr(v, ComparisonValidator.ATTR_LEFT)] ?: continue val r = fields[crossAttr(v, ComparisonValidator.ATTR_RIGHT)] ?: continue - val op = when (crossAttr(v, ComparisonValidator.ATTR_OP)) { + val opName = crossAttr(v, ComparisonValidator.ATTR_OP) + val op = when (opName) { "gt" -> ">"; "gte" -> ">="; "lt" -> "<"; "lte" -> "<="; "ne" -> "!="; "eq" -> "==" else -> continue } if (l.isArrayType || r.isArrayType) continue - val cmp = if (isNumericCross(l) && isNumericCross(r) && l.subType != r.subType) { - CodeBlock.of("java.math.BigDecimal(%L.toString()).compareTo(java.math.BigDecimal(%L.toString())) $op 0", ref(l), ref(r)) - } else { - CodeBlock.of("(%L as Comparable).compareTo(%L) $op 0", ref(l), ref(r)) - } + val cmp = comparison(l, r, opName!!, op, ref) ?: continue CodeBlock.of("%L == null || %L == null || %L", ref(l), ref(r), cmp) } is RequiredWhenValidator, is PresentIffValidator -> { diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ValidationConformanceTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ValidationConformanceTest.kt index 1192885046..23f56987e1 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ValidationConformanceTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ValidationConformanceTest.kt @@ -92,7 +92,7 @@ class ValidationConformanceTest { "generated Account.kt failed to compile:\n${compileResult.messages}") // --- validate each corpus payload through a jakarta Validator --- - val mapper = ObjectMapper().registerKotlinModule() + val mapper = ObjectMapper().findAndRegisterModules().registerKotlinModule() val validator: Validator = Validation.buildDefaultValidatorFactory().validator val casesJson = String(Files.readAllBytes(corpus.resolve(casesName)), StandardCharsets.UTF_8) diff --git a/server/java/codegen-spring/pom.xml b/server/java/codegen-spring/pom.xml index 239418be3f..4b4949f0ee 100644 --- a/server/java/codegen-spring/pom.xml +++ b/server/java/codegen-spring/pom.xml @@ -107,6 +107,15 @@ provided + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + 2.18.2 + test + + diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java index de68dd814a..0b09e239d2 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java @@ -15,6 +15,7 @@ import com.metaobjects.field.BooleanField; import com.metaobjects.field.CurrencyField; import com.metaobjects.field.DecimalField; +import com.metaobjects.field.DateField; import com.metaobjects.field.DoubleField; import com.metaobjects.field.EnumField; import com.metaobjects.field.FloatField; @@ -23,6 +24,8 @@ import com.metaobjects.field.MetaField; import com.metaobjects.field.ObjectField; import com.metaobjects.field.StringField; +import com.metaobjects.field.TimeField; +import com.metaobjects.field.TimestampField; import com.metaobjects.generator.util.RestSurfaceGate; import com.metaobjects.identity.MetaIdentity; import com.metaobjects.identity.PrimaryIdentity; @@ -652,6 +655,35 @@ private static boolean isNumericCrossField(MetaField f) { || f instanceof FloatField || f instanceof DecimalField || f instanceof CurrencyField; } + private static boolean isTemporalCrossField(MetaField f) { + return f instanceof DateField || f instanceof TimeField || f instanceof TimestampField; + } + + /** + * The comparison expression for two fields, or null when the pair has no sound one. Mirrors the + * C# generator: numerics compare with each other, strings and temporals only with their own + * subtype (a date has no ordering against a timestamp, and casting both to Comparable would + * throw ClassCastException inside the constraint method, turning a create into a 500), and + * boolean/enum fields only test equality. Anything else is skipped, like every other port. + */ + private static String crossComparison(MetaField l, MetaField r, String lc, String rc, String op, String jop) { + boolean ordering = op.equals("gt") || op.equals("gte") || op.equals("lt") || op.equals("lte"); + boolean sameSubType = l.getSubType().equals(r.getSubType()); + if (isNumericCrossField(l) && isNumericCrossField(r)) { + return sameSubType + ? "((Comparable) (Object) " + lc + ").compareTo(" + rc + ") " + jop + " 0" + : "new java.math.BigDecimal(String.valueOf(" + lc + ")).compareTo(new java.math.BigDecimal(String.valueOf(" + rc + "))) " + jop + " 0"; + } + if (!sameSubType) return null; + if (l instanceof StringField || isTemporalCrossField(l)) { + return "((Comparable) (Object) " + lc + ").compareTo(" + rc + ") " + jop + " 0"; + } + if (!ordering && (l instanceof BooleanField || l instanceof EnumField)) { + return (op.equals("eq") ? "" : "!") + "java.util.Objects.equals(" + lc + ", " + rc + ")"; + } + return null; + } + private static String crossAttr(MetaData v, String attr) { if (!v.hasMetaAttr(attr)) return null; Object raw = v.getMetaAttr(attr).getValue(); @@ -741,12 +773,8 @@ public static List> crossFieldRules( if (l == null || r == null || jop == null || l.isArrayType() || r.isArrayType()) continue; String lc = ref.apply(l); String rc = ref.apply(r); - String cmp; - if (isNumericCrossField(l) && isNumericCrossField(r) && !l.getSubType().equals(r.getSubType())) { - cmp = "new java.math.BigDecimal(String.valueOf(" + lc + ")).compareTo(new java.math.BigDecimal(String.valueOf(" + rc + "))) " + jop + " 0"; - } else { - cmp = "((Comparable) (Object) " + lc + ").compareTo(" + rc + ") " + jop + " 0"; - } + String cmp = crossComparison(l, r, lc, rc, op, jop); + if (cmp == null) continue; expr = lc + " == null || " + rc + " == null || " + cmp; } else if (v instanceof RequiredWhenValidator || v instanceof PresentIffValidator) { MetaField t = crossField(entity, crossAttr(v, RequiredWhenValidator.ATTR_FIELD), components); diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringCrossFieldValidationTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringCrossFieldValidationTest.java index 82ef7426de..e998f0c095 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringCrossFieldValidationTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringCrossFieldValidationTest.java @@ -83,11 +83,22 @@ private Path gen(boolean withValidators) throws Exception { /** Violation count Bean Validation reports for {@code json} bound to the compiled {@code EvDto}. */ private int violations(boolean withValidators, String json) throws Exception { + return violations(withValidators, "acme.ev.EvDto", json); + } + + /** Violation count Bean Validation reports for {@code json} bound to the compiled {@code className}. */ + private int violations(boolean withValidators, String className, String json) throws Exception { Path src = gen(withValidators); Path classes = tmp.newFolder().toPath(); compile(src, classes); try (URLClassLoader cl = new URLClassLoader(new URL[]{ classes.toUri().toURL() }, getClass().getClassLoader())) { - Object dto = MAPPER.readValue(json, cl.loadClass("acme.ev.EvDto")); + Class cls = cl.loadClass(className); + // A Patch is built through its presence-tracking fromJson, never bound field by field; + // a Dto binds as a record. + Object dto = className.endsWith("Patch") + ? cls.getMethod("fromJson", com.fasterxml.jackson.databind.JsonNode.class, ObjectMapper.class) + .invoke(null, MAPPER.readTree(json), MAPPER) + : MAPPER.readValue(json, cls); Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); return validator.validate(dto).size(); } @@ -106,10 +117,11 @@ public void entityWithoutCrossFieldValidatorsAcceptsTheSameBody() throws Excepti } @Test - public void patchIsUnchangedByCrossFieldValidators() throws Exception { - String withV = Files.readString(gen(true).resolve("acme/ev/EvPatch.java")); - String without = Files.readString(gen(false).resolve("acme/ev/EvPatch.java")); - assertEquals("the generated PATCH body must not change", without, withV); + public void patchBodyIsNotHeldToCrossFieldValidators() throws Exception { + // Executed, not compared as text: the same violating body is rejected by the full class + // and accepted by the partial one, which cannot see the merged row. + assertEquals(1, violations(true, "acme.ev.EvDto", VIOLATES_COMPARISON)); + assertEquals(0, violations(true, "acme.ev.EvPatch", VIOLATES_COMPARISON)); } private static void compile(Path srcDir, Path classesDir) throws Exception { diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ValidationConformanceTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ValidationConformanceTest.java index a68ab1606b..1b8dfc48cb 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ValidationConformanceTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ValidationConformanceTest.java @@ -51,7 +51,7 @@ public class ValidationConformanceTest extends SharedRegistryTestBase { @Rule public TemporaryFolder tmp = new TemporaryFolder(); - private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final ObjectMapper MAPPER = new ObjectMapper().findAndRegisterModules(); private static Path repoRoot() { return Path.of(System.getProperty("user.dir")).resolve("../../..").normalize(); diff --git a/server/python/src/metaobjects/codegen/generators/entity_model.py b/server/python/src/metaobjects/codegen/generators/entity_model.py index bd02e13b8c..c96e9c84b3 100644 --- a/server/python/src/metaobjects/codegen/generators/entity_model.py +++ b/server/python/src/metaobjects/codegen/generators/entity_model.py @@ -463,6 +463,28 @@ def _assigned_pk_field_names(entity: MetaObject) -> set[str]: # (None is SQL NULL; "" is NOT null). Enforced on the Create model only: a Patch body # cannot see the merged row, so the DB CHECK stays the enforcement point there. _COMPARISON_PY_OP = {"gt": ">", "gte": ">=", "lt": "<", "lte": "<=", "ne": "!=", "eq": "=="} +_NUMERIC_CROSS = ( + fc.FIELD_SUBTYPE_INT, fc.FIELD_SUBTYPE_LONG, fc.FIELD_SUBTYPE_DOUBLE, fc.FIELD_SUBTYPE_FLOAT, + fc.FIELD_SUBTYPE_DECIMAL, fc.FIELD_SUBTYPE_CURRENCY, +) +_ORDERED_CROSS = ( + fc.FIELD_SUBTYPE_STRING, fc.FIELD_SUBTYPE_DATE, fc.FIELD_SUBTYPE_TIME, fc.FIELD_SUBTYPE_TIMESTAMP, +) + + +def _comparable(left: MetaField, right: MetaField, op: str) -> bool: + """Whether a comparison between these two fields has a sound meaning, as in every port: numerics + compare with each other, strings and temporals only with their own subtype (a date has no + ordering against a timestamp, and Python raises TypeError on one), and boolean/enum fields + only test equality. Anything else is skipped, never rejected.""" + a, b = left.sub_type, right.sub_type + if a in _NUMERIC_CROSS and b in _NUMERIC_CROSS: + return True + if a != b: + return False + if a in _ORDERED_CROSS: + return True + return op in ("eq", "ne") and a in (fc.FIELD_SUBTYPE_BOOLEAN, fc.FIELD_SUBTYPE_ENUM) _CROSS_FIELD_SUBTYPES = ( vc.VALIDATOR_SUBTYPE_COMPARISON, vc.VALIDATOR_SUBTYPE_REQUIRED_WHEN, @@ -516,6 +538,8 @@ def attr(name: str, node: MetaData = v) -> object: continue if field_is_array(by_name[str(left)]) or field_is_array(by_name[str(right)]): continue + if not _comparable(by_name[str(left)], by_name[str(right)], str(attr(vc.VALIDATOR_ATTR_OP))): + continue checks.append( f" if self.{left} is not None and self.{right} is not None:\n" f" try:\n" diff --git a/server/typescript/packages/codegen-ts-react/test/tph-form.test.ts b/server/typescript/packages/codegen-ts-react/test/tph-form.test.ts index e1f8cf0f8c..a11e10655f 100644 --- a/server/typescript/packages/codegen-ts-react/test/tph-form.test.ts +++ b/server/typescript/packages/codegen-ts-react/test/tph-form.test.ts @@ -16,7 +16,7 @@ import { formFile } from "../src/form-file.js"; import { renderFormFile } from "../src/templates/form-file.js"; import { makeRenderContext, buildPkMap, buildRelationMap } from "@metaobjectsdev/codegen-ts"; -async function loadTph(): Promise<{ root: MetaRoot; base: MetaObject; bridge: MetaObject }> { +async function loadTph(withRule = false): Promise<{ root: MetaRoot; base: MetaObject; bridge: MetaObject }> { const loader = new MetaDataLoader(); const { root, errors } = await loader.load([ new InMemoryStringSource( @@ -41,7 +41,15 @@ async function loadTph(): Promise<{ root: MetaRoot; base: MetaObject; bridge: Me name: "BridgeAuth", extends: "Auth", "@discriminatorValue": "Bridge", - children: [{ "field.int": { name: "quantity", "@required": true } }], + children: [ + { "field.int": { name: "quantity", "@required": true } }, + ...(withRule + ? [ + { "field.int": { name: "minQty" } }, + { "validator.comparison": { name: "qtyOrder", "@left": "minQty", "@op": "lte", "@right": "quantity" } }, + ] + : []), + ], }, }, { @@ -96,4 +104,16 @@ describe("FR-017 Tier 3 — TPH React forms", () => { expect(out).toContain("BridgeAuth.quantity"); expect(out).not.toContain("BridgeAuth.type.name"); }); + + // A refined Zod schema has no `.omit()`, so the form must bind the schema codegen exports + // for exactly this case (refined, discriminator already omitted), never the unrefined base + // (which would drop the cross-field rule from the form) and never `.omit()` on the refined one. + // The schema's behaviour is executed in codegen-ts `tph-cross-field.test.ts`. + test("a subtype form with a cross-field rule binds the refined body schema", async () => { + const { root, bridge } = await loadTph(true); + const out = renderFormFile(bridge, ctxFor(root)); + expect(out).toContain("BridgeAuthInsertBodySchema"); + expect(out).not.toContain("BridgeAuthInsertBaseSchema"); + expect(out).not.toContain("BridgeAuthInsertSchema.omit"); + }); }); diff --git a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts index c4b93717c9..8c5a5ffab6 100644 --- a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts +++ b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts @@ -278,6 +278,23 @@ const NUMERIC_SUBTYPES: ReadonlySet = new Set([ FIELD_SUBTYPE_DECIMAL, FIELD_SUBTYPE_CURRENCY, ]); +const ORDERED_SUBTYPES: ReadonlySet = new Set([ + FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP, +]); + +/** Whether a comparison between these two fields has a sound meaning, as in every port: + * numerics compare with each other, strings and temporals only with their own subtype (a + * date against a timestamp compares two differently shaped strings, which is meaningless), + * and boolean/enum fields only test equality. Anything else is skipped, never rejected. */ +function crossComparable(left: MetaField, right: MetaField, opName: string): boolean { + const a = left.subType; + const b = right.subType; + if (NUMERIC_SUBTYPES.has(a) && NUMERIC_SUBTYPES.has(b)) return true; + if (a !== b) return false; + if (ORDERED_SUBTYPES.has(a)) return true; + return (opName === "eq" || opName === "ne") && (a === FIELD_SUBTYPE_BOOLEAN || a === FIELD_SUBTYPE_ENUM); +} + /** Names of the fields the INSERT-shaped schemas carry (same skip rules as the emitters). */ function insertSchemaFieldNameSet(obj: MetaObject): Set { const autoGen = autoGenPkFieldNames(obj); @@ -337,6 +354,7 @@ function crossFieldRefinementBody(obj: MetaObject): string[] { const opName = String(v.attr(VALIDATOR_ATTR_OP)); const op = CROSS_FIELD_OPS[opName]; if (!left || !right || !op || left.resolvedIsArray() || right.resolvedIsArray()) break; + if (!crossComparable(left, right, opName)) break; const numeric = NUMERIC_SUBTYPES.has(left.subType); const c = numeric ? "Number(l) - Number(r)" From ffbeda06a3f4c379e1bfa34cd7eef0b69ed08f95 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 4 Nov 2026 04:10:45 -0500 Subject: [PATCH 03/10] fix(codegen): eq/ne between same-subtype uuid, uri and inet fields stays enforced; review fixes Code review of the cross-kind skip found that equality on identifier-like fields was dropped along with ordering. Restores it in every port, adds shared uuid cases, corrects stale C# comments, and documents the EmitClassDeclarationLine seam change. --- CHANGELOG.md | 10 +++++-- .../metaobjects-codegen/references/csharp.md | 4 ++- fixtures/validation-conformance/README.md | 8 +++-- .../cross-field-cases.json | 30 +++++++++++++++++++ .../meta.cross-field.json | 3 ++ .../CrossFieldValidationConformanceTests.cs | 2 +- .../CrossFieldValidatorGenerationTests.cs | 3 +- .../Generators/EntityGenerator.cs | 8 +++-- .../generator/kotlin/KotlinCrossFieldRules.kt | 7 +++-- .../spring/SpringGeneratorSupport.java | 8 +++-- .../codegen/generators/entity_model.py | 9 ++++-- .../src/templates/zod-validators.ts | 11 +++++-- 12 files changed, 82 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c85e8b87d..9a7de48475 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,7 +56,8 @@ here.** inheritance hierarchy (one shared table, or a child table holding only its own columns). A child of an **abstract** parent with `EmitAbstractShapes` on now emits `class Child : Base`, as the generator always meant to, and declares only its own members. Regenerating changes those two shapes - of C# output only. + of C# output only. An adopter override of `EmitClassDeclarationLine` that names a concrete parent as + the base now gets that parent's properties twice; drop the base from the override. - **Test flakes fixed at their cause.** `GenCommandTests.Gen_writes_the_hash_manifest_beside_the_metadata_not_in_cwd` (C# Cli) failed about one run in five because `DependencyCliTests` runs `gen` in process, which changes the process-wide working directory, without joining the serial collection every other @@ -71,8 +72,11 @@ here.** a create answered 500 instead of 400; TypeScript compared the two as strings and Python raised a `TypeError` that surfaced as an invalid body. All five ports now follow one rule: numerics compare with each other, strings and temporals only with their own subtype, boolean and enum fields test - equality only, and any other pair emits no rule. New shared case - `comparison-across-kinds-is-skipped` in `fixtures/validation-conformance/`, run by every port. + `eq` / `ne` only (also on two `uuid`, `uri` or `inet` fields), and any other pair emits no rule, so an + ordering test on two booleans or two enums is no longer enforced by the generated validators (C# never + did). The database `CHECK` still covers the pair where the database can compare it. New shared cases + `comparison-across-kinds-is-skipped`, `uuid-ne-rejects-equal-ids` and `uuid-ne-accepts-different-ids` + in `fixtures/validation-conformance/`, run by every port. - **Test coverage for cross-field validators.** A subtype form on a TPH hierarchy that declares a cross-field rule now has a test proving the generated form binds the refined body schema (the rule itself was enforced by the cross-field build). The Java check that a Patch is not held to cross-field diff --git a/agent-context/skills/metaobjects-codegen/references/csharp.md b/agent-context/skills/metaobjects-codegen/references/csharp.md index e2a5371bb5..d86cf4cea2 100644 --- a/agent-context/skills/metaobjects-codegen/references/csharp.md +++ b/agent-context/skills/metaobjects-codegen/references/csharp.md @@ -307,7 +307,9 @@ plus finer hooks — `EmitClassHeader` / `EmitClassDeclarationLine` (class decla `partial`, marker interfaces), `EmitPropertyAttributes` (per-property C# attributes), `EmitFileUsings` (extra usings), `EmitClassBodyTrailer` (extra members). Subclass a generator and override only the seam you need rather than forking. `@default` on a -scalar emits a literal initializer; an `object.value`'s default storage is jsonb. +scalar emits a literal initializer; an override of `EmitClassDeclarationLine` must not name a +concrete parent as the C# base (the child already declares the parent's properties, so a base would +redeclare them; only an emitted abstract shape is a base); an `object.value`'s default storage is jsonb. **Shared + externally-provided enums (FR-019).** A package-level abstract `field.enum` (`abstract: true`, `@values`) extended by concrete entity fields is diff --git a/fixtures/validation-conformance/README.md b/fixtures/validation-conformance/README.md index 39f85af6fb..ddc9474ca0 100644 --- a/fixtures/validation-conformance/README.md +++ b/fixtures/validation-conformance/README.md @@ -120,11 +120,15 @@ so the run-time runners, which read `cases.json`, are unaffected. The entity is is optional, so absence is expressible in every port. The semantics mirror the DB `CHECK` derivation in `migrate-ts/src/expected-schema.ts`, with an absent -or null value standing for SQL `NULL` (and `""` NOT null): +or null value standing for SQL `NULL` (and `""` NOT null). One deliberate difference: a comparison the +ports skip (two fields of different kinds) still gets its `CHECK`, which the database enforces if it +can compare the two column types: - `comparison` (`startsAt lte endsAt`): passes when either side is null. Fields of different kinds have no ordering and the rule is SKIPPED in every port, never an error (`eventDate lte loggedAt`, a - date against a timestamp, is declared on `Event` and must accept a body where the date is later). + date against a timestamp, is declared on `Event` and must accept a body where the date is later). Ordering applies to numerics with each other and to + strings and temporals of the same subtype; `eq` / `ne` also apply to two fields of the same boolean, + enum, uuid, uri or inet subtype (`ownerId ne approverId` rejects two equal ids). - `requiredWhen`: violated only when the gating field equals `@equals` and the target is null; a null gate is unconstrained. Both a string gate (`kind = BIRTH`) and a boolean gate (`flag = true`). - `presentIff`: the target is present exactly when the gate equals `@equals`; a null gate counts as diff --git a/fixtures/validation-conformance/cross-field-cases.json b/fixtures/validation-conformance/cross-field-cases.json index 4e3da6899d..f287b29675 100644 --- a/fixtures/validation-conformance/cross-field-cases.json +++ b/fixtures/validation-conformance/cross-field-cases.json @@ -273,6 +273,36 @@ "loggedAt": "2020-01-01T00:00:00Z" }, "expectValid": true + }, + { + "name": "uuid-ne-rejects-equal-ids", + "entity": "Event", + "payload": { + "startsAt": 1, + "endsAt": 2, + "kind": "BIRTH", + "subject": "p1", + "state": "OPEN", + "email": "a@b.c", + "ownerId": "3f2b1c1e-8a64-4d0e-9a52-1f6c0d2b7a10", + "approverId": "3f2b1c1e-8a64-4d0e-9a52-1f6c0d2b7a10" + }, + "expectValid": false + }, + { + "name": "uuid-ne-accepts-different-ids", + "entity": "Event", + "payload": { + "startsAt": 1, + "endsAt": 2, + "kind": "BIRTH", + "subject": "p1", + "state": "OPEN", + "email": "a@b.c", + "ownerId": "3f2b1c1e-8a64-4d0e-9a52-1f6c0d2b7a10", + "approverId": "9b7d4e22-0c1a-4f3b-8d65-2a7e9c5f1b34" + }, + "expectValid": true } ] } diff --git a/fixtures/validation-conformance/meta.cross-field.json b/fixtures/validation-conformance/meta.cross-field.json index ece94d3263..636a1d163b 100644 --- a/fixtures/validation-conformance/meta.cross-field.json +++ b/fixtures/validation-conformance/meta.cross-field.json @@ -17,6 +17,8 @@ { "field.string": { "name": "flagNote" } }, { "field.date": { "name": "eventDate" } }, { "field.timestamp": { "name": "loggedAt" } }, + { "field.uuid": { "name": "ownerId" } }, + { "field.uuid": { "name": "approverId" } }, { "field.string": { "name": "email" } }, { "field.string": { "name": "phone" } }, { "field.string": { "name": "review" } }, @@ -26,6 +28,7 @@ { "validator.requiredWhen": { "name": "birthNeedsSubject", "@field": "subject", "@when": "kind", "@equals": "BIRTH" } }, { "validator.requiredWhen": { "name": "flaggedNeedsNote", "@field": "flagNote", "@when": "flag", "@equals": "true" } }, { "validator.presentIff": { "name": "closedIffNote", "@field": "closedNote", "@when": "state", "@equals": "CLOSED" } }, + { "validator.comparison": { "name": "ownerNotApprover", "@left": "ownerId", "@op": "ne", "@right": "approverId" } }, { "validator.comparison": { "name": "dateVsTimestamp", "@left": "eventDate", "@op": "lte", "@right": "loggedAt" } }, { "validator.atLeastOne": { "name": "reachable", "@fields": ["email", "phone"] } }, { "validator.requiredWhen": { "name": "reviewNeedsReviewer", "@field": "reviewedBy", "@when": "review", "@equals": "DONE" } } diff --git a/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidationConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidationConformanceTests.cs index 775f53b110..7a9bfff2a6 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidationConformanceTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidationConformanceTests.cs @@ -45,7 +45,7 @@ public static IEnumerable Cases() => AllCases().Select(c => new object[] { c.GetProperty("name").GetString()! }); [Fact] - public void Discovers_all_cross_field_cases() => Assert.Equal(21, AllCases().Length); + public void Discovers_all_cross_field_cases() => Assert.Equal(23, AllCases().Length); [Theory] [MemberData(nameof(Cases))] diff --git a/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs b/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs index 40cd02413f..5ad3a701da 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs @@ -112,7 +112,7 @@ public class CrossFieldValidatorGenerationTests ]}} """; - // A decimal compared with a double: C# has no implicit conversion between the two. + // A concrete entity extending an abstract shape that declares a rule. private const string AbstractParent = """ { "metadata.root": { "package": "acme", "children": [ { "object.entity": { "name": "Base", "abstract": true, "children": [ @@ -128,6 +128,7 @@ public class CrossFieldValidatorGenerationTests ]}} """; + // A decimal compared with a double: C# has no implicit conversion between the two. private const string MixedNumeric = """ { "metadata.root": { "package": "acme", "children": [ { "object.entity": { "name": "Quote", "children": [ diff --git a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs index e5b2a43438..10eddb8e3c 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs @@ -306,9 +306,10 @@ private EmittedFile EmitMappedClassImpl( var enumDecls = CollectEnumDecls(entity); // Members in declared order: scalar columns + enum-typed props + (entities only) owned-type navs. - // When this entity extends an emitted base class (DirectMappedParent), the base carries + // When this entity extends an emitted abstract shape (DirectMappedParent), the shape carries // the inherited fields — emit ONLY this entity's own fields and let C# inheritance supply - // the rest (mirrors the metadata `extends` chain; the PK lives on the declaring base). + // the rest. A concrete parent is never a C# base (two mapped entities in one hierarchy would + // be an EF TPH/TPT mapping, not the one-table-per-entity model), so its fields are declared here. var inheritedNames = DirectMappedParent(entity, ctx.Config) is { } mp ? new HashSet(mp.Fields().Select(f => f.Name), StringComparer.Ordinal) : new HashSet(StringComparer.Ordinal); @@ -1155,7 +1156,8 @@ string Gate(MetaField when, string raw) else if (lk == CrossKind.Temporal && rk == CrossKind.Temporal && CSharpNaming.ScalarForField(l) == CSharpNaming.ScalarForField(r)) cond = $"{a} {csOp} {b}"; - else if (!ordering && lk == rk && lk != CrossKind.Other) + else if (!ordering && ((lk == rk && lk != CrossKind.Other) + || (l.SubType == r.SubType && l.SubType is FIELD_SUBTYPE_UUID or FIELD_SUBTYPE_URI or FIELD_SUBTYPE_INET))) cond = op == "eq" ? $"Equals({a}, {b})" : $"!Equals({a}, {b})"; else break; var lp = PropertyName(l); var rp = PropertyName(r); diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldRules.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldRules.kt index 33f7c28d9c..ab58dcd12e 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldRules.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldRules.kt @@ -12,8 +12,11 @@ import com.metaobjects.field.LongField import com.metaobjects.field.EnumField import com.metaobjects.field.MetaField import com.metaobjects.field.StringField +import com.metaobjects.field.InetField import com.metaobjects.field.TimeField import com.metaobjects.field.TimestampField +import com.metaobjects.field.UriField +import com.metaobjects.field.UuidField import com.metaobjects.`object`.MetaObject import com.metaobjects.validator.AtLeastOneValidator import com.metaobjects.validator.ComparisonValidator @@ -40,7 +43,7 @@ object KotlinCrossFieldRules { * generator: numerics compare with each other, strings and temporals only with their own * subtype (a date has no ordering against a timestamp, and casting both to Comparable throws * ClassCastException inside the validity property, turning a create into a 500), and - * boolean/enum fields only test equality. Anything else is skipped, like every other port. + * boolean, enum, uuid, uri and inet fields only test equality. Anything else is skipped, like every other port. */ private fun comparison(l: MetaField<*>, r: MetaField<*>, op: String, kop: String, ref: (MetaField<*>) -> String): CodeBlock? { val ordering = op == "gt" || op == "gte" || op == "lt" || op == "lte" @@ -53,7 +56,7 @@ object KotlinCrossFieldRules { if (l is StringField || isTemporalCross(l)) { return CodeBlock.of("(%L as Comparable).compareTo(%L) $kop 0", ref(l), ref(r)) } - if (!ordering && (l is BooleanField || l is EnumField)) { + if (!ordering && (l is BooleanField || l is EnumField || l is UuidField || l is UriField || l is InetField)) { return CodeBlock.of("(%L %L %L)", ref(l), if (op == "eq") "==" else "!=", ref(r)) } return null diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java index 0b09e239d2..22fb99ddee 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java @@ -26,6 +26,9 @@ import com.metaobjects.field.StringField; import com.metaobjects.field.TimeField; import com.metaobjects.field.TimestampField; +import com.metaobjects.field.InetField; +import com.metaobjects.field.UriField; +import com.metaobjects.field.UuidField; import com.metaobjects.generator.util.RestSurfaceGate; import com.metaobjects.identity.MetaIdentity; import com.metaobjects.identity.PrimaryIdentity; @@ -664,7 +667,7 @@ private static boolean isTemporalCrossField(MetaField f) { * C# generator: numerics compare with each other, strings and temporals only with their own * subtype (a date has no ordering against a timestamp, and casting both to Comparable would * throw ClassCastException inside the constraint method, turning a create into a 500), and - * boolean/enum fields only test equality. Anything else is skipped, like every other port. + * boolean, enum, uuid, uri and inet fields only test equality. Anything else is skipped, like every other port. */ private static String crossComparison(MetaField l, MetaField r, String lc, String rc, String op, String jop) { boolean ordering = op.equals("gt") || op.equals("gte") || op.equals("lt") || op.equals("lte"); @@ -678,7 +681,8 @@ private static String crossComparison(MetaField l, MetaField r, String lc, if (l instanceof StringField || isTemporalCrossField(l)) { return "((Comparable) (Object) " + lc + ").compareTo(" + rc + ") " + jop + " 0"; } - if (!ordering && (l instanceof BooleanField || l instanceof EnumField)) { + if (!ordering && (l instanceof BooleanField || l instanceof EnumField + || l instanceof UuidField || l instanceof UriField || l instanceof InetField)) { return (op.equals("eq") ? "" : "!") + "java.util.Objects.equals(" + lc + ", " + rc + ")"; } return null; diff --git a/server/python/src/metaobjects/codegen/generators/entity_model.py b/server/python/src/metaobjects/codegen/generators/entity_model.py index c96e9c84b3..b95076fd40 100644 --- a/server/python/src/metaobjects/codegen/generators/entity_model.py +++ b/server/python/src/metaobjects/codegen/generators/entity_model.py @@ -475,8 +475,8 @@ def _assigned_pk_field_names(entity: MetaObject) -> set[str]: def _comparable(left: MetaField, right: MetaField, op: str) -> bool: """Whether a comparison between these two fields has a sound meaning, as in every port: numerics compare with each other, strings and temporals only with their own subtype (a date has no - ordering against a timestamp, and Python raises TypeError on one), and boolean/enum fields - only test equality. Anything else is skipped, never rejected.""" + ordering against a timestamp, and Python raises TypeError on one), and boolean, enum, uuid, uri + and inet fields only test equality. Anything else is skipped, never rejected.""" a, b = left.sub_type, right.sub_type if a in _NUMERIC_CROSS and b in _NUMERIC_CROSS: return True @@ -484,7 +484,10 @@ def _comparable(left: MetaField, right: MetaField, op: str) -> bool: return False if a in _ORDERED_CROSS: return True - return op in ("eq", "ne") and a in (fc.FIELD_SUBTYPE_BOOLEAN, fc.FIELD_SUBTYPE_ENUM) + return op in ("eq", "ne") and a in ( + fc.FIELD_SUBTYPE_BOOLEAN, fc.FIELD_SUBTYPE_ENUM, + fc.FIELD_SUBTYPE_UUID, fc.FIELD_SUBTYPE_URI, fc.FIELD_SUBTYPE_INET, + ) _CROSS_FIELD_SUBTYPES = ( vc.VALIDATOR_SUBTYPE_COMPARISON, vc.VALIDATOR_SUBTYPE_REQUIRED_WHEN, diff --git a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts index 8c5a5ffab6..a6318788a4 100644 --- a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts +++ b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts @@ -16,7 +16,7 @@ import { FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_CURRENCY, FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_DECIMAL, FIELD_ATTR_PRECISION, FIELD_ATTR_SCALE, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP, - FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_MAP, FIELD_SUBTYPE_UUID, + FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_MAP, FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, VALIDATOR_SUBTYPE_REQUIRED, VALIDATOR_SUBTYPE_LENGTH, VALIDATOR_SUBTYPE_REGEX, VALIDATOR_SUBTYPE_NUMERIC, VALIDATOR_SUBTYPE_ARRAY, @@ -282,17 +282,22 @@ const ORDERED_SUBTYPES: ReadonlySet = new Set([ FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP, ]); +/** Subtypes with equality but no ordering: a pair of the same one may be tested with eq / ne. */ +const EQUALITY_SUBTYPES: ReadonlySet = new Set([ + FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, +]); + /** Whether a comparison between these two fields has a sound meaning, as in every port: * numerics compare with each other, strings and temporals only with their own subtype (a * date against a timestamp compares two differently shaped strings, which is meaningless), - * and boolean/enum fields only test equality. Anything else is skipped, never rejected. */ + * and boolean, enum, uuid, uri and inet fields only test equality. Anything else is skipped, never rejected. */ function crossComparable(left: MetaField, right: MetaField, opName: string): boolean { const a = left.subType; const b = right.subType; if (NUMERIC_SUBTYPES.has(a) && NUMERIC_SUBTYPES.has(b)) return true; if (a !== b) return false; if (ORDERED_SUBTYPES.has(a)) return true; - return (opName === "eq" || opName === "ne") && (a === FIELD_SUBTYPE_BOOLEAN || a === FIELD_SUBTYPE_ENUM); + return (opName === "eq" || opName === "ne") && EQUALITY_SUBTYPES.has(a); } /** Names of the fields the INSERT-shaped schemas carry (same skip rules as the emitters). */ From 5c26ffa00d957a8087a26a9acc9e451fd2bbdd26 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 4 Nov 2026 04:29:21 -0500 Subject: [PATCH 04/10] chore: simplify pass; sync owned template copies; C# date eq no longer rejects across kinds --- .../codegen/templates/zod-validators.ts | 26 +++++++++++++++++-- .../codegen/templates/zod-validators.ts | 26 +++++++++++++++++-- .../CrossFieldValidatorGenerationTests.cs | 16 +++++++----- .../EntityExtendsParentTests.cs | 6 ++--- .../Generators/EntityGenerator.cs | 4 +-- .../generator/kotlin/KotlinCrossFieldRules.kt | 7 +++-- .../spring/SpringGeneratorSupport.java | 8 +++--- .../codegen/generators/entity_model.py | 11 +++----- .../src/templates/zod-validators.ts | 1 - .../codegen-ts/test/compile-timeout.ts | 2 +- .../src/templates/zod-validators.ts | 26 +++++++++++++++++-- 11 files changed, 98 insertions(+), 35 deletions(-) diff --git a/examples/advanced-modeling/codegen/templates/zod-validators.ts b/examples/advanced-modeling/codegen/templates/zod-validators.ts index 661966d8ea..5880f2af68 100644 --- a/examples/advanced-modeling/codegen/templates/zod-validators.ts +++ b/examples/advanced-modeling/codegen/templates/zod-validators.ts @@ -16,8 +16,7 @@ import { FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_CURRENCY, FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_DECIMAL, FIELD_ATTR_PRECISION, FIELD_ATTR_SCALE, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP, - FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_MAP, FIELD_SUBTYPE_UUID, - FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, + FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_MAP, FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, VALIDATOR_SUBTYPE_REQUIRED, VALIDATOR_SUBTYPE_LENGTH, VALIDATOR_SUBTYPE_REGEX, VALIDATOR_SUBTYPE_NUMERIC, VALIDATOR_SUBTYPE_ARRAY, IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION, @@ -278,6 +277,28 @@ const NUMERIC_SUBTYPES: ReadonlySet = new Set([ FIELD_SUBTYPE_DECIMAL, FIELD_SUBTYPE_CURRENCY, ]); +const ORDERED_SUBTYPES: ReadonlySet = new Set([ + FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP, +]); + +/** Subtypes with equality but no ordering: a pair of the same one may be tested with eq / ne. */ +const EQUALITY_SUBTYPES: ReadonlySet = new Set([ + FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, +]); + +/** Whether a comparison between these two fields has a sound meaning, as in every port: + * numerics compare with each other, strings and temporals only with their own subtype (a + * date against a timestamp compares two differently shaped strings, which is meaningless), + * and boolean, enum, uuid, uri and inet fields only test equality. Anything else is skipped, never rejected. */ +function crossComparable(left: MetaField, right: MetaField, opName: string): boolean { + const a = left.subType; + const b = right.subType; + if (NUMERIC_SUBTYPES.has(a) && NUMERIC_SUBTYPES.has(b)) return true; + if (a !== b) return false; + if (ORDERED_SUBTYPES.has(a)) return true; + return (opName === "eq" || opName === "ne") && EQUALITY_SUBTYPES.has(a); +} + /** Names of the fields the INSERT-shaped schemas carry (same skip rules as the emitters). */ function insertSchemaFieldNameSet(obj: MetaObject): Set { const autoGen = autoGenPkFieldNames(obj); @@ -337,6 +358,7 @@ function crossFieldRefinementBody(obj: MetaObject): string[] { const opName = String(v.attr(VALIDATOR_ATTR_OP)); const op = CROSS_FIELD_OPS[opName]; if (!left || !right || !op || left.resolvedIsArray() || right.resolvedIsArray()) break; + if (!crossComparable(left, right, opName)) break; const numeric = NUMERIC_SUBTYPES.has(left.subType); const c = numeric ? "Number(l) - Number(r)" diff --git a/examples/showcase/codegen/templates/zod-validators.ts b/examples/showcase/codegen/templates/zod-validators.ts index 661966d8ea..5880f2af68 100644 --- a/examples/showcase/codegen/templates/zod-validators.ts +++ b/examples/showcase/codegen/templates/zod-validators.ts @@ -16,8 +16,7 @@ import { FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_CURRENCY, FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_DECIMAL, FIELD_ATTR_PRECISION, FIELD_ATTR_SCALE, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP, - FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_MAP, FIELD_SUBTYPE_UUID, - FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, + FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_MAP, FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, VALIDATOR_SUBTYPE_REQUIRED, VALIDATOR_SUBTYPE_LENGTH, VALIDATOR_SUBTYPE_REGEX, VALIDATOR_SUBTYPE_NUMERIC, VALIDATOR_SUBTYPE_ARRAY, IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION, @@ -278,6 +277,28 @@ const NUMERIC_SUBTYPES: ReadonlySet = new Set([ FIELD_SUBTYPE_DECIMAL, FIELD_SUBTYPE_CURRENCY, ]); +const ORDERED_SUBTYPES: ReadonlySet = new Set([ + FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP, +]); + +/** Subtypes with equality but no ordering: a pair of the same one may be tested with eq / ne. */ +const EQUALITY_SUBTYPES: ReadonlySet = new Set([ + FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, +]); + +/** Whether a comparison between these two fields has a sound meaning, as in every port: + * numerics compare with each other, strings and temporals only with their own subtype (a + * date against a timestamp compares two differently shaped strings, which is meaningless), + * and boolean, enum, uuid, uri and inet fields only test equality. Anything else is skipped, never rejected. */ +function crossComparable(left: MetaField, right: MetaField, opName: string): boolean { + const a = left.subType; + const b = right.subType; + if (NUMERIC_SUBTYPES.has(a) && NUMERIC_SUBTYPES.has(b)) return true; + if (a !== b) return false; + if (ORDERED_SUBTYPES.has(a)) return true; + return (opName === "eq" || opName === "ne") && EQUALITY_SUBTYPES.has(a); +} + /** Names of the fields the INSERT-shaped schemas carry (same skip rules as the emitters). */ function insertSchemaFieldNameSet(obj: MetaObject): Set { const autoGen = autoGenPkFieldNames(obj); @@ -337,6 +358,7 @@ function crossFieldRefinementBody(obj: MetaObject): string[] { const opName = String(v.attr(VALIDATOR_ATTR_OP)); const op = CROSS_FIELD_OPS[opName]; if (!left || !right || !op || left.resolvedIsArray() || right.resolvedIsArray()) break; + if (!crossComparable(left, right, opName)) break; const numeric = NUMERIC_SUBTYPES.has(left.subType); const c = numeric ? "Number(l) - Number(r)" diff --git a/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs b/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs index 5ad3a701da..7d55763343 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs @@ -153,13 +153,16 @@ private static GenContext Ctx(string model, bool abstractShapes = false) }; } + // Built once per run: opening every trusted-platform assembly is the cost of each compile. + private static readonly Lazy> CompileReferences = new(() => + ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator).Where(p => p.Length > 0) + .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)).ToList()); + private static Assembly Compile(EntityGenerator generator, string model, bool abstractShapes = false) { var trees = generator.Generate(Ctx(model, abstractShapes)).Select(f => CSharpSyntaxTree.ParseText(f.Content)).ToArray(); - var refs = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) - .Split(Path.PathSeparator).Where(p => p.Length > 0) - .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)).ToList(); - var comp = CSharpCompilation.Create("xfi_" + Guid.NewGuid().ToString("N"), trees, refs, + var comp = CSharpCompilation.Create("xfi_" + Guid.NewGuid().ToString("N"), trees, CompileReferences.Value, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); using var ms = new MemoryStream(); var emit = comp.Emit(ms); @@ -167,10 +170,11 @@ private static Assembly Compile(EntityGenerator generator, string model, bool ab return Assembly.Load(ms.ToArray()); } + private static readonly JsonSerializerOptions ValidateJsonOptions = new() { PropertyNameCaseInsensitive = true }; + private static List Validate(Assembly asm, string type, string json) { - object inst = JsonSerializer.Deserialize(json, asm.GetType($"Acme.{type}")!, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + object inst = JsonSerializer.Deserialize(json, asm.GetType($"Acme.{type}")!, ValidateJsonOptions)!; var results = new List(); Validator.TryValidateObject(inst, new ValidationContext(inst), results, true); return results; diff --git a/server/csharp/MetaObjects.Codegen.Tests/EntityExtendsParentTests.cs b/server/csharp/MetaObjects.Codegen.Tests/EntityExtendsParentTests.cs index c8244fa609..76c968a04e 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/EntityExtendsParentTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/EntityExtendsParentTests.cs @@ -60,7 +60,8 @@ private static Dictionary Generate(string model, bool abstractSh [InlineData(true)] public void A_child_of_a_concrete_parent_declares_the_parents_properties_and_names_no_base(bool abstractShapes) { - var child = Generate(ConcreteParentModel, abstractShapes)["Child.g.cs"]; + var files = Generate(ConcreteParentModel, abstractShapes); + var child = files["Child.g.cs"]; // Not an EF inheritance hierarchy: Child owns its table and every column. Assert.Contains("public class Child", child); Assert.DoesNotContain("class Child :", child); @@ -69,8 +70,7 @@ public void A_child_of_a_concrete_parent_declares_the_parents_properties_and_nam Assert.Contains(" Extra { get; set; }", child); Assert.Contains("[Table(\"children\")]", child); // The parent is untouched. - var parent = Generate(ConcreteParentModel, abstractShapes)["Parent.g.cs"]; - Assert.DoesNotContain("Extra", parent); + Assert.DoesNotContain("Extra", files["Parent.g.cs"]); } [Fact] diff --git a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs index 10eddb8e3c..449176be43 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs @@ -1156,8 +1156,8 @@ string Gate(MetaField when, string raw) else if (lk == CrossKind.Temporal && rk == CrossKind.Temporal && CSharpNaming.ScalarForField(l) == CSharpNaming.ScalarForField(r)) cond = $"{a} {csOp} {b}"; - else if (!ordering && ((lk == rk && lk != CrossKind.Other) - || (l.SubType == r.SubType && l.SubType is FIELD_SUBTYPE_UUID or FIELD_SUBTYPE_URI or FIELD_SUBTYPE_INET))) + else if (!ordering && l.SubType == r.SubType + && (lk != CrossKind.Other || l.SubType is FIELD_SUBTYPE_UUID or FIELD_SUBTYPE_URI or FIELD_SUBTYPE_INET)) cond = op == "eq" ? $"Equals({a}, {b})" : $"!Equals({a}, {b})"; else break; var lp = PropertyName(l); var rp = PropertyName(r); diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldRules.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldRules.kt index ab58dcd12e..43bb588505 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldRules.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldRules.kt @@ -48,12 +48,11 @@ object KotlinCrossFieldRules { private fun comparison(l: MetaField<*>, r: MetaField<*>, op: String, kop: String, ref: (MetaField<*>) -> String): CodeBlock? { val ordering = op == "gt" || op == "gte" || op == "lt" || op == "lte" val same = l.subType == r.subType - if (isNumericCross(l) && isNumericCross(r)) { - return if (same) CodeBlock.of("(%L as Comparable).compareTo(%L) $kop 0", ref(l), ref(r)) - else CodeBlock.of("java.math.BigDecimal(%L.toString()).compareTo(java.math.BigDecimal(%L.toString())) $kop 0", ref(l), ref(r)) + if (isNumericCross(l) && isNumericCross(r) && !same) { + return CodeBlock.of("java.math.BigDecimal(%L.toString()).compareTo(java.math.BigDecimal(%L.toString())) $kop 0", ref(l), ref(r)) } if (!same) return null - if (l is StringField || isTemporalCross(l)) { + if (isNumericCross(l) || l is StringField || isTemporalCross(l)) { return CodeBlock.of("(%L as Comparable).compareTo(%L) $kop 0", ref(l), ref(r)) } if (!ordering && (l is BooleanField || l is EnumField || l is UuidField || l is UriField || l is InetField)) { diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java index 22fb99ddee..8752fc3316 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java @@ -672,13 +672,11 @@ private static boolean isTemporalCrossField(MetaField f) { private static String crossComparison(MetaField l, MetaField r, String lc, String rc, String op, String jop) { boolean ordering = op.equals("gt") || op.equals("gte") || op.equals("lt") || op.equals("lte"); boolean sameSubType = l.getSubType().equals(r.getSubType()); - if (isNumericCrossField(l) && isNumericCrossField(r)) { - return sameSubType - ? "((Comparable) (Object) " + lc + ").compareTo(" + rc + ") " + jop + " 0" - : "new java.math.BigDecimal(String.valueOf(" + lc + ")).compareTo(new java.math.BigDecimal(String.valueOf(" + rc + "))) " + jop + " 0"; + if (isNumericCrossField(l) && isNumericCrossField(r) && !sameSubType) { + return "new java.math.BigDecimal(String.valueOf(" + lc + ")).compareTo(new java.math.BigDecimal(String.valueOf(" + rc + "))) " + jop + " 0"; } if (!sameSubType) return null; - if (l instanceof StringField || isTemporalCrossField(l)) { + if (isNumericCrossField(l) || l instanceof StringField || isTemporalCrossField(l)) { return "((Comparable) (Object) " + lc + ").compareTo(" + rc + ") " + jop + " 0"; } if (!ordering && (l instanceof BooleanField || l instanceof EnumField diff --git a/server/python/src/metaobjects/codegen/generators/entity_model.py b/server/python/src/metaobjects/codegen/generators/entity_model.py index b95076fd40..e69c00ef45 100644 --- a/server/python/src/metaobjects/codegen/generators/entity_model.py +++ b/server/python/src/metaobjects/codegen/generators/entity_model.py @@ -463,10 +463,10 @@ def _assigned_pk_field_names(entity: MetaObject) -> set[str]: # (None is SQL NULL; "" is NOT null). Enforced on the Create model only: a Patch body # cannot see the merged row, so the DB CHECK stays the enforcement point there. _COMPARISON_PY_OP = {"gt": ">", "gte": ">=", "lt": "<", "lte": "<=", "ne": "!=", "eq": "=="} -_NUMERIC_CROSS = ( - fc.FIELD_SUBTYPE_INT, fc.FIELD_SUBTYPE_LONG, fc.FIELD_SUBTYPE_DOUBLE, fc.FIELD_SUBTYPE_FLOAT, - fc.FIELD_SUBTYPE_DECIMAL, fc.FIELD_SUBTYPE_CURRENCY, -) +_INT_SUBTYPES = (fc.FIELD_SUBTYPE_INT, fc.FIELD_SUBTYPE_LONG) +_FLOAT_SUBTYPES = (fc.FIELD_SUBTYPE_DOUBLE, fc.FIELD_SUBTYPE_FLOAT) +_DECIMAL_SUBTYPES = (fc.FIELD_SUBTYPE_DECIMAL, fc.FIELD_SUBTYPE_CURRENCY) +_NUMERIC_CROSS = _INT_SUBTYPES + _FLOAT_SUBTYPES + _DECIMAL_SUBTYPES _ORDERED_CROSS = ( fc.FIELD_SUBTYPE_STRING, fc.FIELD_SUBTYPE_DATE, fc.FIELD_SUBTYPE_TIME, fc.FIELD_SUBTYPE_TIMESTAMP, ) @@ -494,9 +494,6 @@ def _comparable(left: MetaField, right: MetaField, op: str) -> bool: vc.VALIDATOR_SUBTYPE_PRESENT_IFF, vc.VALIDATOR_SUBTYPE_AT_LEAST_ONE, ) -_INT_SUBTYPES = (fc.FIELD_SUBTYPE_INT, fc.FIELD_SUBTYPE_LONG) -_FLOAT_SUBTYPES = (fc.FIELD_SUBTYPE_DOUBLE, fc.FIELD_SUBTYPE_FLOAT) -_DECIMAL_SUBTYPES = (fc.FIELD_SUBTYPE_DECIMAL, fc.FIELD_SUBTYPE_CURRENCY) _NUMERIC_LITERAL = re.compile(r"^-?\d+(\.\d+)?$") diff --git a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts index a6318788a4..c063b8139e 100644 --- a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts +++ b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts @@ -17,7 +17,6 @@ import { FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_DECIMAL, FIELD_ATTR_PRECISION, FIELD_ATTR_SCALE, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP, FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_MAP, FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, - FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, VALIDATOR_SUBTYPE_REQUIRED, VALIDATOR_SUBTYPE_LENGTH, VALIDATOR_SUBTYPE_REGEX, VALIDATOR_SUBTYPE_NUMERIC, VALIDATOR_SUBTYPE_ARRAY, IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION, diff --git a/server/typescript/packages/codegen-ts/test/compile-timeout.ts b/server/typescript/packages/codegen-ts/test/compile-timeout.ts index 41011e0302..7b21094bd2 100644 --- a/server/typescript/packages/codegen-ts/test/compile-timeout.ts +++ b/server/typescript/packages/codegen-ts/test/compile-timeout.ts @@ -20,7 +20,7 @@ import { afterAll, setDefaultTimeout } from "bun:test"; const BUN_DEFAULT_TIMEOUT_MS = 5_000; /** Per-test timeout for type-checking tests: ~9x the slowest measured case. */ -export const COMPILE_TEST_TIMEOUT_MS = 30_000; +const COMPILE_TEST_TIMEOUT_MS = 30_000; setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); afterAll(() => setDefaultTimeout(BUN_DEFAULT_TIMEOUT_MS)); diff --git a/server/typescript/packages/test-generators/src/templates/zod-validators.ts b/server/typescript/packages/test-generators/src/templates/zod-validators.ts index 661966d8ea..5880f2af68 100644 --- a/server/typescript/packages/test-generators/src/templates/zod-validators.ts +++ b/server/typescript/packages/test-generators/src/templates/zod-validators.ts @@ -16,8 +16,7 @@ import { FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_CURRENCY, FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_DECIMAL, FIELD_ATTR_PRECISION, FIELD_ATTR_SCALE, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP, - FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_MAP, FIELD_SUBTYPE_UUID, - FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, + FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_MAP, FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, VALIDATOR_SUBTYPE_REQUIRED, VALIDATOR_SUBTYPE_LENGTH, VALIDATOR_SUBTYPE_REGEX, VALIDATOR_SUBTYPE_NUMERIC, VALIDATOR_SUBTYPE_ARRAY, IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION, @@ -278,6 +277,28 @@ const NUMERIC_SUBTYPES: ReadonlySet = new Set([ FIELD_SUBTYPE_DECIMAL, FIELD_SUBTYPE_CURRENCY, ]); +const ORDERED_SUBTYPES: ReadonlySet = new Set([ + FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP, +]); + +/** Subtypes with equality but no ordering: a pair of the same one may be tested with eq / ne. */ +const EQUALITY_SUBTYPES: ReadonlySet = new Set([ + FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, +]); + +/** Whether a comparison between these two fields has a sound meaning, as in every port: + * numerics compare with each other, strings and temporals only with their own subtype (a + * date against a timestamp compares two differently shaped strings, which is meaningless), + * and boolean, enum, uuid, uri and inet fields only test equality. Anything else is skipped, never rejected. */ +function crossComparable(left: MetaField, right: MetaField, opName: string): boolean { + const a = left.subType; + const b = right.subType; + if (NUMERIC_SUBTYPES.has(a) && NUMERIC_SUBTYPES.has(b)) return true; + if (a !== b) return false; + if (ORDERED_SUBTYPES.has(a)) return true; + return (opName === "eq" || opName === "ne") && EQUALITY_SUBTYPES.has(a); +} + /** Names of the fields the INSERT-shaped schemas carry (same skip rules as the emitters). */ function insertSchemaFieldNameSet(obj: MetaObject): Set { const autoGen = autoGenPkFieldNames(obj); @@ -337,6 +358,7 @@ function crossFieldRefinementBody(obj: MetaObject): string[] { const opName = String(v.attr(VALIDATOR_ATTR_OP)); const op = CROSS_FIELD_OPS[opName]; if (!left || !right || !op || left.resolvedIsArray() || right.resolvedIsArray()) break; + if (!crossComparable(left, right, opName)) break; const numeric = NUMERIC_SUBTYPES.has(left.subType); const c = numeric ? "Number(l) - Number(r)" From a25bd2b00e6a5bcb5498423af65f9918e61648de Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 4 Nov 2026 04:46:15 -0500 Subject: [PATCH 05/10] no-mistakes(review): Per-file compile timeout; @localTime temporals skip cross-field comparison --- CHANGELOG.md | 13 ++++++++---- .../codegen/templates/zod-validators.ts | 6 ++++-- .../codegen/templates/zod-validators.ts | 6 ++++-- fixtures/validation-conformance/README.md | 4 +++- .../cross-field-cases.json | 15 +++++++++++++ .../meta.cross-field.json | 2 ++ .../CrossFieldValidationConformanceTests.cs | 2 +- .../generator/kotlin/KotlinCrossFieldRules.kt | 14 +++++++------ .../spring/SpringGeneratorSupport.java | 16 +++++++------- .../codegen/generators/entity_model.py | 10 +++++++-- .../src/templates/zod-validators.ts | 6 ++++-- .../test/array-default-compile.test.ts | 6 ++++-- .../test/assigned-pk-insert-compile.test.ts | 6 ++++-- .../test/codegen-compile-conformance.test.ts | 6 ++++-- .../codegen-ts/test/compile-timeout.ts | 21 +++++++------------ .../test/db-schema-generic-compile.test.ts | 6 ++++-- ...ct-optional-property-types-compile.test.ts | 6 ++++-- .../codegen-ts/test/extractor-codegen.test.ts | 6 ++++-- .../test/filter-type-enum-compile.test.ts | 6 ++++-- .../codegen-ts/test/fr004-verify-demo.test.ts | 6 ++++-- .../golden/api-docs-relative-imports.test.ts | 6 ++++-- .../extractor-render-payload-imports.test.ts | 6 ++++-- .../test/jsonb-vo-read-typed-compile.test.ts | 6 ++++-- .../test/names-extends-chain.test.ts | 6 ++++-- .../test/names-reference-compile.test.ts | 6 ++++-- .../test/nodenext-safe-imports.test.ts | 6 ++++-- .../test/owned-composition-imports.test.ts | 6 ++++-- .../test/owned-generator-skew-compile.test.ts | 6 ++++-- .../test/projection/compile.test.ts | 6 ++++-- .../int-enum-replica-view-no-dup.test.ts | 6 ++++-- .../projection/int-enum-view-compile.test.ts | 6 ++++-- .../issue-214-read-half-compile.test.ts | 6 ++++-- .../test/reference-binding-compile.test.ts | 6 ++++-- .../test/reference-byte-identical.test.ts | 6 ++++-- .../test/sourceless-entity-surface.test.ts | 6 ++++-- .../test/templates/names-consumption.test.ts | 6 ++++-- .../test/tph-read-schema-default.test.ts | 6 ++++-- .../test/tph-reference-target.test.ts | 6 ++++-- .../test/typed-create-compile.test.ts | 6 ++++-- .../verbatim-module-syntax-compile.test.ts | 6 ++++-- .../src/templates/zod-validators.ts | 6 ++++-- 41 files changed, 191 insertions(+), 98 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a7de48475..9d09f79179 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,19 +63,24 @@ here.** changes the process-wide working directory, without joining the serial collection every other such class is in; the two classes ran in parallel and one read the other's directory as the cwd. The codegen-ts tests that type-check generated output with the real TypeScript compiler (28 files, - the slowest case measured at 3.3s against bun's 5s default) now run under a 30s timeout set in one - place, `test/compile-timeout.ts`, so a loaded machine cannot report a timeout for correct code. + the slowest case measured at 3.3s against bun's 5s default) now run under a 30s timeout whose value + lives in one place, `test/compile-timeout.ts`. Each of those files calls `setDefaultTimeout` with + it, because bun applies that call only to the file that makes it, so the timeout holds in a plain + `bun test` as well as in CI, and a loaded machine no longer reports a timeout for correct code. - **A cross-field `validator.comparison` between fields of different kinds is skipped in every port, never rejected and never a 500.** Comparing a `field.date` with a `field.timestamp` (or any two strings or temporals of different subtype) has no ordering. Java and Kotlin cast both sides to `Comparable` inside the class-level validity check, so the cast threw `ClassCastException` and a create answered 500 instead of 400; TypeScript compared the two as strings and Python raised a `TypeError` that surfaced as an invalid body. All five ports now follow one rule: numerics compare - with each other, strings and temporals only with their own subtype, boolean and enum fields test + with each other, strings and temporals only with their own subtype and `@localTime` flag (a + `field.timestamp` is an instant and a `@localTime` one a wall clock, which is a different native + type in every port), boolean and enum fields test `eq` / `ne` only (also on two `uuid`, `uri` or `inet` fields), and any other pair emits no rule, so an ordering test on two booleans or two enums is no longer enforced by the generated validators (C# never did). The database `CHECK` still covers the pair where the database can compare it. New shared cases - `comparison-across-kinds-is-skipped`, `uuid-ne-rejects-equal-ids` and `uuid-ne-accepts-different-ids` + `comparison-across-kinds-is-skipped`, `comparison-instant-vs-local-timestamp-is-skipped`, + `uuid-ne-rejects-equal-ids` and `uuid-ne-accepts-different-ids` in `fixtures/validation-conformance/`, run by every port. - **Test coverage for cross-field validators.** A subtype form on a TPH hierarchy that declares a cross-field rule now has a test proving the generated form binds the refined body schema (the rule diff --git a/examples/advanced-modeling/codegen/templates/zod-validators.ts b/examples/advanced-modeling/codegen/templates/zod-validators.ts index 5880f2af68..917a215da5 100644 --- a/examples/advanced-modeling/codegen/templates/zod-validators.ts +++ b/examples/advanced-modeling/codegen/templates/zod-validators.ts @@ -287,14 +287,16 @@ const EQUALITY_SUBTYPES: ReadonlySet = new Set([ ]); /** Whether a comparison between these two fields has a sound meaning, as in every port: - * numerics compare with each other, strings and temporals only with their own subtype (a - * date against a timestamp compares two differently shaped strings, which is meaningless), + * numerics compare with each other, strings and temporals only with their own subtype and + * @localTime flag (a date against a timestamp, or an instant against a wall clock, compares + * two differently shaped strings, which is meaningless), * and boolean, enum, uuid, uri and inet fields only test equality. Anything else is skipped, never rejected. */ function crossComparable(left: MetaField, right: MetaField, opName: string): boolean { const a = left.subType; const b = right.subType; if (NUMERIC_SUBTYPES.has(a) && NUMERIC_SUBTYPES.has(b)) return true; if (a !== b) return false; + if ((left.attr(FIELD_ATTR_LOCAL_TIME) === true) !== (right.attr(FIELD_ATTR_LOCAL_TIME) === true)) return false; if (ORDERED_SUBTYPES.has(a)) return true; return (opName === "eq" || opName === "ne") && EQUALITY_SUBTYPES.has(a); } diff --git a/examples/showcase/codegen/templates/zod-validators.ts b/examples/showcase/codegen/templates/zod-validators.ts index 5880f2af68..917a215da5 100644 --- a/examples/showcase/codegen/templates/zod-validators.ts +++ b/examples/showcase/codegen/templates/zod-validators.ts @@ -287,14 +287,16 @@ const EQUALITY_SUBTYPES: ReadonlySet = new Set([ ]); /** Whether a comparison between these two fields has a sound meaning, as in every port: - * numerics compare with each other, strings and temporals only with their own subtype (a - * date against a timestamp compares two differently shaped strings, which is meaningless), + * numerics compare with each other, strings and temporals only with their own subtype and + * @localTime flag (a date against a timestamp, or an instant against a wall clock, compares + * two differently shaped strings, which is meaningless), * and boolean, enum, uuid, uri and inet fields only test equality. Anything else is skipped, never rejected. */ function crossComparable(left: MetaField, right: MetaField, opName: string): boolean { const a = left.subType; const b = right.subType; if (NUMERIC_SUBTYPES.has(a) && NUMERIC_SUBTYPES.has(b)) return true; if (a !== b) return false; + if ((left.attr(FIELD_ATTR_LOCAL_TIME) === true) !== (right.attr(FIELD_ATTR_LOCAL_TIME) === true)) return false; if (ORDERED_SUBTYPES.has(a)) return true; return (opName === "eq" || opName === "ne") && EQUALITY_SUBTYPES.has(a); } diff --git a/fixtures/validation-conformance/README.md b/fixtures/validation-conformance/README.md index ddc9474ca0..0382d6982d 100644 --- a/fixtures/validation-conformance/README.md +++ b/fixtures/validation-conformance/README.md @@ -126,7 +126,9 @@ can compare the two column types: - `comparison` (`startsAt lte endsAt`): passes when either side is null. Fields of different kinds have no ordering and the rule is SKIPPED in every port, never an error (`eventDate lte loggedAt`, a - date against a timestamp, is declared on `Event` and must accept a body where the date is later). Ordering applies to numerics with each other and to + date against a timestamp, is declared on `Event` and must accept a body where the date is later). Two temporals of + the same subtype are still different kinds when only one carries `@localTime` (`loggedAt lte wallClock`, an + instant against a wall clock, is skipped the same way). Ordering applies to numerics with each other and to strings and temporals of the same subtype; `eq` / `ne` also apply to two fields of the same boolean, enum, uuid, uri or inet subtype (`ownerId ne approverId` rejects two equal ids). - `requiredWhen`: violated only when the gating field equals `@equals` and the target is null; a null diff --git a/fixtures/validation-conformance/cross-field-cases.json b/fixtures/validation-conformance/cross-field-cases.json index f287b29675..dae54a0eef 100644 --- a/fixtures/validation-conformance/cross-field-cases.json +++ b/fixtures/validation-conformance/cross-field-cases.json @@ -274,6 +274,21 @@ }, "expectValid": true }, + { + "name": "comparison-instant-vs-local-timestamp-is-skipped", + "entity": "Event", + "payload": { + "startsAt": 1, + "endsAt": 2, + "kind": "BIRTH", + "subject": "p1", + "state": "OPEN", + "email": "a@b.c", + "loggedAt": "2030-01-02T00:00:00Z", + "wallClock": "2020-01-01T00:00:00" + }, + "expectValid": true + }, { "name": "uuid-ne-rejects-equal-ids", "entity": "Event", diff --git a/fixtures/validation-conformance/meta.cross-field.json b/fixtures/validation-conformance/meta.cross-field.json index 636a1d163b..9759b923fa 100644 --- a/fixtures/validation-conformance/meta.cross-field.json +++ b/fixtures/validation-conformance/meta.cross-field.json @@ -17,6 +17,7 @@ { "field.string": { "name": "flagNote" } }, { "field.date": { "name": "eventDate" } }, { "field.timestamp": { "name": "loggedAt" } }, + { "field.timestamp": { "name": "wallClock", "@localTime": true } }, { "field.uuid": { "name": "ownerId" } }, { "field.uuid": { "name": "approverId" } }, { "field.string": { "name": "email" } }, @@ -30,6 +31,7 @@ { "validator.presentIff": { "name": "closedIffNote", "@field": "closedNote", "@when": "state", "@equals": "CLOSED" } }, { "validator.comparison": { "name": "ownerNotApprover", "@left": "ownerId", "@op": "ne", "@right": "approverId" } }, { "validator.comparison": { "name": "dateVsTimestamp", "@left": "eventDate", "@op": "lte", "@right": "loggedAt" } }, + { "validator.comparison": { "name": "instantVsWallClock", "@left": "loggedAt", "@op": "lte", "@right": "wallClock" } }, { "validator.atLeastOne": { "name": "reachable", "@fields": ["email", "phone"] } }, { "validator.requiredWhen": { "name": "reviewNeedsReviewer", "@field": "reviewedBy", "@when": "review", "@equals": "DONE" } } ] diff --git a/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidationConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidationConformanceTests.cs index 7a9bfff2a6..8ec4fd1b50 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidationConformanceTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidationConformanceTests.cs @@ -45,7 +45,7 @@ public static IEnumerable Cases() => AllCases().Select(c => new object[] { c.GetProperty("name").GetString()! }); [Fact] - public void Discovers_all_cross_field_cases() => Assert.Equal(23, AllCases().Length); + public void Discovers_all_cross_field_cases() => Assert.Equal(24, AllCases().Length); [Theory] [MemberData(nameof(Cases))] diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldRules.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldRules.kt index 43bb588505..1be75e4614 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldRules.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldRules.kt @@ -41,7 +41,8 @@ object KotlinCrossFieldRules { /** * The comparison for two fields, or null when the pair has no sound one. Mirrors the C# * generator: numerics compare with each other, strings and temporals only with their own - * subtype (a date has no ordering against a timestamp, and casting both to Comparable throws + * subtype and Kotlin type (a date has no ordering against a timestamp, nor an Instant against a + * `@localTime` LocalDateTime, and casting both to Comparable throws * ClassCastException inside the validity property, turning a create into a 500), and * boolean, enum, uuid, uri and inet fields only test equality. Anything else is skipped, like every other port. */ @@ -52,13 +53,14 @@ object KotlinCrossFieldRules { return CodeBlock.of("java.math.BigDecimal(%L.toString()).compareTo(java.math.BigDecimal(%L.toString())) $kop 0", ref(l), ref(r)) } if (!same) return null - if (isNumericCross(l) || l is StringField || isTemporalCross(l)) { + val comparable = isNumericCross(l) || l is StringField || isTemporalCross(l) + val equatable = !ordering && (l is BooleanField || l is EnumField || l is UuidField || l is UriField || l is InetField) + if (!comparable && !equatable) return null + if (KotlinTypeMapper.kotlinTypeName(l) != KotlinTypeMapper.kotlinTypeName(r)) return null + if (comparable) { return CodeBlock.of("(%L as Comparable).compareTo(%L) $kop 0", ref(l), ref(r)) } - if (!ordering && (l is BooleanField || l is EnumField || l is UuidField || l is UriField || l is InetField)) { - return CodeBlock.of("(%L %L %L)", ref(l), if (op == "eq") "==" else "!=", ref(r)) - } - return null + return CodeBlock.of("(%L %L %L)", ref(l), if (op == "eq") "==" else "!=", ref(r)) } private fun crossAttr(v: MetaData, attr: String): String? = diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java index 8752fc3316..ab308ab864 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java @@ -665,7 +665,8 @@ private static boolean isTemporalCrossField(MetaField f) { /** * The comparison expression for two fields, or null when the pair has no sound one. Mirrors the * C# generator: numerics compare with each other, strings and temporals only with their own - * subtype (a date has no ordering against a timestamp, and casting both to Comparable would + * subtype and Java type (a date has no ordering against a timestamp, nor an Instant against a + * {@code @localTime} LocalDateTime, and casting both to Comparable would * throw ClassCastException inside the constraint method, turning a create into a 500), and * boolean, enum, uuid, uri and inet fields only test equality. Anything else is skipped, like every other port. */ @@ -676,14 +677,15 @@ private static String crossComparison(MetaField l, MetaField r, String lc, return "new java.math.BigDecimal(String.valueOf(" + lc + ")).compareTo(new java.math.BigDecimal(String.valueOf(" + rc + "))) " + jop + " 0"; } if (!sameSubType) return null; - if (isNumericCrossField(l) || l instanceof StringField || isTemporalCrossField(l)) { + boolean comparable = isNumericCrossField(l) || l instanceof StringField || isTemporalCrossField(l); + boolean equatable = !ordering && (l instanceof BooleanField || l instanceof EnumField + || l instanceof UuidField || l instanceof UriField || l instanceof InetField); + if (!comparable && !equatable) return null; + if (!SpringTypeMapper.javaTypeName(l).equals(SpringTypeMapper.javaTypeName(r))) return null; + if (comparable) { return "((Comparable) (Object) " + lc + ").compareTo(" + rc + ") " + jop + " 0"; } - if (!ordering && (l instanceof BooleanField || l instanceof EnumField - || l instanceof UuidField || l instanceof UriField || l instanceof InetField)) { - return (op.equals("eq") ? "" : "!") + "java.util.Objects.equals(" + lc + ", " + rc + ")"; - } - return null; + return (op.equals("eq") ? "" : "!") + "java.util.Objects.equals(" + lc + ", " + rc + ")"; } private static String crossAttr(MetaData v, String attr) { diff --git a/server/python/src/metaobjects/codegen/generators/entity_model.py b/server/python/src/metaobjects/codegen/generators/entity_model.py index e69c00ef45..92eb5bc484 100644 --- a/server/python/src/metaobjects/codegen/generators/entity_model.py +++ b/server/python/src/metaobjects/codegen/generators/entity_model.py @@ -32,6 +32,7 @@ IDENTITY_ATTR_GENERATION, ) from metaobjects.meta.core.validator import validator_constants as vc +from metaobjects.meta.persistence.db import db_constants as dbc from metaobjects.meta.persistence.origin.origin_constants import ( AGG_ALL, AGG_ANY, @@ -474,14 +475,19 @@ def _assigned_pk_field_names(entity: MetaObject) -> set[str]: def _comparable(left: MetaField, right: MetaField, op: str) -> bool: """Whether a comparison between these two fields has a sound meaning, as in every port: numerics - compare with each other, strings and temporals only with their own subtype (a date has no - ordering against a timestamp, and Python raises TypeError on one), and boolean, enum, uuid, uri + compare with each other, strings and temporals only with their own subtype and @localTime flag (a + date has no ordering against a timestamp, nor an aware datetime against a naive one, and Python + raises TypeError on both), and boolean, enum, uuid, uri and inet fields only test equality. Anything else is skipped, never rejected.""" a, b = left.sub_type, right.sub_type if a in _NUMERIC_CROSS and b in _NUMERIC_CROSS: return True if a != b: return False + if (left.get_meta_attr(dbc.FIELD_ATTR_LOCAL_TIME) is True) != ( + right.get_meta_attr(dbc.FIELD_ATTR_LOCAL_TIME) is True + ): + return False if a in _ORDERED_CROSS: return True return op in ("eq", "ne") and a in ( diff --git a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts index c063b8139e..3cb10e53e9 100644 --- a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts +++ b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts @@ -287,14 +287,16 @@ const EQUALITY_SUBTYPES: ReadonlySet = new Set([ ]); /** Whether a comparison between these two fields has a sound meaning, as in every port: - * numerics compare with each other, strings and temporals only with their own subtype (a - * date against a timestamp compares two differently shaped strings, which is meaningless), + * numerics compare with each other, strings and temporals only with their own subtype and + * @localTime flag (a date against a timestamp, or an instant against a wall clock, compares + * two differently shaped strings, which is meaningless), * and boolean, enum, uuid, uri and inet fields only test equality. Anything else is skipped, never rejected. */ function crossComparable(left: MetaField, right: MetaField, opName: string): boolean { const a = left.subType; const b = right.subType; if (NUMERIC_SUBTYPES.has(a) && NUMERIC_SUBTYPES.has(b)) return true; if (a !== b) return false; + if ((left.attr(FIELD_ATTR_LOCAL_TIME) === true) !== (right.attr(FIELD_ATTR_LOCAL_TIME) === true)) return false; if (ORDERED_SUBTYPES.has(a)) return true; return (opName === "eq" || opName === "ne") && EQUALITY_SUBTYPES.has(a); } diff --git a/server/typescript/packages/codegen-ts/test/array-default-compile.test.ts b/server/typescript/packages/codegen-ts/test/array-default-compile.test.ts index 470d6fcccd..17ae3d42d7 100644 --- a/server/typescript/packages/codegen-ts/test/array-default-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/array-default-compile.test.ts @@ -13,8 +13,8 @@ // loader rejects a JSON-array default), which is exactly why the fix lives in // codegen-ts and can't be pushed into metadata. -import { describe, test, expect } from "bun:test"; -import "./compile-timeout.js"; +import { describe, test, expect, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; @@ -26,6 +26,8 @@ import { buildRelationMap } from "../src/relation-resolver.js"; import type { GenContext } from "../src/generator.js"; import type { Dialect } from "../src/metaobjects-config.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + async function loadRoot() { const json = JSON.stringify({ "metadata.root": { diff --git a/server/typescript/packages/codegen-ts/test/assigned-pk-insert-compile.test.ts b/server/typescript/packages/codegen-ts/test/assigned-pk-insert-compile.test.ts index fb9cf3169f..a0ea791134 100644 --- a/server/typescript/packages/codegen-ts/test/assigned-pk-insert-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/assigned-pk-insert-compile.test.ts @@ -18,8 +18,8 @@ // This compiles entity + queries TOGETHER with the real TS compiler — the bug is // a mismatch BETWEEN those two files, so a single-file gate cannot see it. -import { describe, test, expect } from "bun:test"; -import "./compile-timeout.js"; +import { describe, test, expect, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; @@ -32,6 +32,8 @@ import { buildRelationMap } from "../src/relation-resolver.js"; import type { GenContext } from "../src/generator.js"; import type { Dialect } from "../src/metaobjects-config.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + /** @param pkField the PK field node; @param identity the identity.primary node. */ async function loadRoot(pkField: unknown, identity: unknown) { const json = JSON.stringify({ diff --git a/server/typescript/packages/codegen-ts/test/codegen-compile-conformance.test.ts b/server/typescript/packages/codegen-ts/test/codegen-compile-conformance.test.ts index 45bbcd214d..688521fdad 100644 --- a/server/typescript/packages/codegen-ts/test/codegen-compile-conformance.test.ts +++ b/server/typescript/packages/codegen-ts/test/codegen-compile-conformance.test.ts @@ -29,8 +29,8 @@ // The peer lanes are the same test in each port. If one port drops out, that port keeps // precisely the bug class this exists to catch — so a skip here is never "just this lane". -import { describe, test, expect } from "bun:test"; -import "./compile-timeout.js"; +import { describe, test, expect, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join, resolve, dirname } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -50,6 +50,8 @@ import { buildPkMap } from "../src/pk-resolver.js"; import { buildRelationMap } from "../src/relation-resolver.js"; import type { GenContext } from "../src/generator.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + // test → codegen-ts → packages → typescript → server → repo root const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..", ".."); const CORPUS = resolve( diff --git a/server/typescript/packages/codegen-ts/test/compile-timeout.ts b/server/typescript/packages/codegen-ts/test/compile-timeout.ts index 7b21094bd2..0b7047ff1d 100644 --- a/server/typescript/packages/codegen-ts/test/compile-timeout.ts +++ b/server/typescript/packages/codegen-ts/test/compile-timeout.ts @@ -1,4 +1,4 @@ -// Side-effect import for every test file that type-checks generated output with the real +// Per-test timeout for every test file that type-checks generated output with the real // TypeScript compiler (`ts.createProgram`) or otherwise re-runs the whole generator suite. // // Why a timeout at all: bun's default is 5s. A compile test parses lib.d.ts, drizzle-orm and @@ -9,18 +9,13 @@ // reports a timeout that says nothing about the code. // // 30s is ~9x the slowest measured case: loose enough that load cannot trip it, tight enough -// that a compile which really hangs still fails in half a minute. The default is restored when -// the importing file finishes, so other files keep bun's 5s hang detection. +// that a compile which really hangs still fails in half a minute. // -// Usage: `import "./compile-timeout.js";` (relative to the test file) before the tests. - -import { afterAll, setDefaultTimeout } from "bun:test"; - -/** Bun's own default per-test timeout, restored after a file that raised it. */ -const BUN_DEFAULT_TIMEOUT_MS = 5_000; +// Usage: each file calls `setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS)` at its top level. It +// cannot be a side effect of importing this module: bun evaluates a module once per process, +// and `setDefaultTimeout` applies only to the file that calls it, so a side-effect import +// raises the timeout for the first importing file alone. Bun resets the default for each +// file, so files that do not call it keep the 5s hang detection. /** Per-test timeout for type-checking tests: ~9x the slowest measured case. */ -const COMPILE_TEST_TIMEOUT_MS = 30_000; - -setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); -afterAll(() => setDefaultTimeout(BUN_DEFAULT_TIMEOUT_MS)); +export const COMPILE_TEST_TIMEOUT_MS = 30_000; diff --git a/server/typescript/packages/codegen-ts/test/db-schema-generic-compile.test.ts b/server/typescript/packages/codegen-ts/test/db-schema-generic-compile.test.ts index a88e26438e..bbcd9aee59 100644 --- a/server/typescript/packages/codegen-ts/test/db-schema-generic-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/db-schema-generic-compile.test.ts @@ -32,8 +32,8 @@ // The `schema` passed here is the generated entity module itself (`import * as schema`), // which is what an adopter actually does with generated Drizzle tables. -import { describe, test, expect } from "bun:test"; -import "./compile-timeout.js"; +import { describe, test, expect, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; @@ -46,6 +46,8 @@ import { buildRelationMap } from "../src/relation-resolver.js"; import type { GenContext } from "../src/generator.js"; import type { Dialect } from "../src/metaobjects-config.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + async function loadRoot() { const json = JSON.stringify({ "metadata.root": { diff --git a/server/typescript/packages/codegen-ts/test/exact-optional-property-types-compile.test.ts b/server/typescript/packages/codegen-ts/test/exact-optional-property-types-compile.test.ts index 86d7bdf2fe..4d4100f29d 100644 --- a/server/typescript/packages/codegen-ts/test/exact-optional-property-types-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/exact-optional-property-types-compile.test.ts @@ -14,8 +14,8 @@ // each kind (scalar, enum, array, nested value object), plus a jsonb column typed by an // all-optional value object. -import { describe, test, expect } from "bun:test"; -import "./compile-timeout.js"; +import { describe, test, expect, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; @@ -31,6 +31,8 @@ import { buildRelationMap } from "../src/relation-resolver.js"; import type { GenContext } from "../src/generator.js"; import type { Dialect } from "../src/metaobjects-config.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + const MODEL = { "metadata.root": { package: "tracker", diff --git a/server/typescript/packages/codegen-ts/test/extractor-codegen.test.ts b/server/typescript/packages/codegen-ts/test/extractor-codegen.test.ts index 0a0ff79122..3fc58a160c 100644 --- a/server/typescript/packages/codegen-ts/test/extractor-codegen.test.ts +++ b/server/typescript/packages/codegen-ts/test/extractor-codegen.test.ts @@ -10,8 +10,8 @@ // render the payload + output + extractor sources to a temp dir, dynamically import() the // emitted extractor module under bun, and CALL the generated functions. -import { describe, test, expect, afterAll } from "bun:test"; -import "./compile-timeout.js"; +import { describe, test, expect, afterAll, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; @@ -24,6 +24,8 @@ import { buildPkMap } from "../src/pk-resolver.js"; import { buildRelationMap } from "../src/relation-resolver.js"; import type { GenContext } from "../src/generator.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + // Emit the REAL per-VO entity modules (`.ts` exporting `interface ` + // its enum union-aliases) the same way `meta gen` does, so the generated // extractor's `from "./.js"` payload imports RESOLVE against the modules diff --git a/server/typescript/packages/codegen-ts/test/filter-type-enum-compile.test.ts b/server/typescript/packages/codegen-ts/test/filter-type-enum-compile.test.ts index 088a3f767f..4f5af92488 100644 --- a/server/typescript/packages/codegen-ts/test/filter-type-enum-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/filter-type-enum-compile.test.ts @@ -12,8 +12,8 @@ // `@ts-expect-error` below must be USED (an unused one is TS2578), and the positive // assignments must type-check. -import { describe, test, expect } from "bun:test"; -import "./compile-timeout.js"; +import { describe, test, expect, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; @@ -24,6 +24,8 @@ import { buildPkMap } from "../src/pk-resolver.js"; import { buildRelationMap } from "../src/relation-resolver.js"; import type { GenContext } from "../src/generator.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + const MODEL = { "metadata.root": { package: "tracker", diff --git a/server/typescript/packages/codegen-ts/test/fr004-verify-demo.test.ts b/server/typescript/packages/codegen-ts/test/fr004-verify-demo.test.ts index 749ee267cd..f19643acd1 100644 --- a/server/typescript/packages/codegen-ts/test/fr004-verify-demo.test.ts +++ b/server/typescript/packages/codegen-ts/test/fr004-verify-demo.test.ts @@ -14,8 +14,8 @@ // (a) uses the TS compiler API (not `bun test`'s type system, and not a spawned // `bun run typecheck`) so it is hermetic + immune to workspace self-link noise. -import { describe, test, expect } from "bun:test"; -import "./compile-timeout.js"; +import { describe, test, expect, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "./compile-timeout.js"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -29,6 +29,8 @@ import { buildPkMap } from "../src/pk-resolver.js"; import { buildRelationMap } from "../src/relation-resolver.js"; import type { GenContext } from "../src/generator.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + // The AuthorBrief projection over the trainerWebsite test entities (Plan #3 T3/T4). const model = [ { "object.value": { name: "PostBrief", children: [{ "field.string": { name: "title", "@required": true } }] } }, diff --git a/server/typescript/packages/codegen-ts/test/golden/api-docs-relative-imports.test.ts b/server/typescript/packages/codegen-ts/test/golden/api-docs-relative-imports.test.ts index e3eb78e881..0b651a4a61 100644 --- a/server/typescript/packages/codegen-ts/test/golden/api-docs-relative-imports.test.ts +++ b/server/typescript/packages/codegen-ts/test/golden/api-docs-relative-imports.test.ts @@ -21,8 +21,8 @@ // (`ctx.config.includeHonoRoutes`, aggregated by the runner from a // `routesFileHono` generator's `emitsHonoRoutes` marker). -import { describe, test, expect, afterAll } from "bun:test"; -import "../compile-timeout.js"; +import { describe, test, expect, afterAll, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "../compile-timeout.js"; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; @@ -42,6 +42,8 @@ import { buildRelationMap } from "../../src/relation-resolver.js"; import { runGen, defineConfig } from "../../src/index.js"; import type { Generator, GenContext, EmittedFile } from "../../src/generator.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + const provider = frameworkTemplatesProvider; // A simple CRUD entity (PK `id`, one required string field) — enough to exercise diff --git a/server/typescript/packages/codegen-ts/test/golden/extractor-render-payload-imports.test.ts b/server/typescript/packages/codegen-ts/test/golden/extractor-render-payload-imports.test.ts index 6e83808a63..cc37b17e95 100644 --- a/server/typescript/packages/codegen-ts/test/golden/extractor-render-payload-imports.test.ts +++ b/server/typescript/packages/codegen-ts/test/golden/extractor-render-payload-imports.test.ts @@ -15,8 +15,8 @@ // files AGAINST the REAL generated entity modules — NO hand-written payloads.ts. // The payload import must RESOLVE (no TS2307) and the payload type must be found. -import { describe, test, expect, afterAll } from "bun:test"; -import "../compile-timeout.js"; +import { describe, test, expect, afterAll, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "../compile-timeout.js"; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; @@ -32,6 +32,8 @@ import { buildRelationMap } from "../../src/relation-resolver.js"; import type { GenContext, EmittedFile } from "../../src/generator.js"; import type { Provider } from "@metaobjectsdev/render"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + // A nested payload graph that exercises the multi-VO import grouping: // Order (root) -> Customer (single nested), Line (array-of-objects), // plus an inline enum `priority` (union alias OWNED BY Order's module). diff --git a/server/typescript/packages/codegen-ts/test/jsonb-vo-read-typed-compile.test.ts b/server/typescript/packages/codegen-ts/test/jsonb-vo-read-typed-compile.test.ts index 81e5417816..ecb2b1f003 100644 --- a/server/typescript/packages/codegen-ts/test/jsonb-vo-read-typed-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/jsonb-vo-read-typed-compile.test.ts @@ -8,8 +8,8 @@ // on both. Proven the only way that counts: a caller module reading nested members through // the generated finder, compiled against the generated tree with the real TS compiler. -import { describe, test, expect } from "bun:test"; -import "./compile-timeout.js"; +import { describe, test, expect, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; @@ -22,6 +22,8 @@ import { buildRelationMap } from "../src/relation-resolver.js"; import type { EmittedFile, GenContext } from "../src/generator.js"; import type { Dialect } from "../src/metaobjects-config.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + const MODEL = { "metadata.root": { package: "rentals", diff --git a/server/typescript/packages/codegen-ts/test/names-extends-chain.test.ts b/server/typescript/packages/codegen-ts/test/names-extends-chain.test.ts index d58ebdfd99..109a544397 100644 --- a/server/typescript/packages/codegen-ts/test/names-extends-chain.test.ts +++ b/server/typescript/packages/codegen-ts/test/names-extends-chain.test.ts @@ -11,8 +11,8 @@ // physical name must be ABSENT from the child's artifact. A positive-only assertion would // pass just as well for a generator that emitted both the spread AND the restated literal, // which is the outcome this change exists to prevent. -import { describe, test, expect } from "bun:test"; -import "./compile-timeout.js"; +import { describe, test, expect, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "./compile-timeout.js"; import { mkdtempSync, rmSync, readdirSync, readFileSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; @@ -22,6 +22,8 @@ import { runGen, defineConfig } from "../src/index.js"; import { namesFile } from "../src/generators/index.js"; import { entityFile } from "../src/generators/entity-file.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + const MODEL = { "metadata.root": { package: "acme", diff --git a/server/typescript/packages/codegen-ts/test/names-reference-compile.test.ts b/server/typescript/packages/codegen-ts/test/names-reference-compile.test.ts index 6c236bd567..87c73b2e77 100644 --- a/server/typescript/packages/codegen-ts/test/names-reference-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/names-reference-compile.test.ts @@ -22,8 +22,8 @@ // splitting them into separate fixtures is how a defect BETWEEN two emissions survives — // the assigned-PK bug lived exactly there, between the entity file and the queries file. -import { describe, test, expect } from "bun:test"; -import "./compile-timeout.js"; +import { describe, test, expect, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; @@ -37,6 +37,8 @@ import { buildRelationMap } from "../src/relation-resolver.js"; import type { GenContext } from "../src/generator.js"; import type { Dialect } from "../src/metaobjects-config.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + // Physical names deliberately unlike anything derivable, so a reading of the emitted // output can tell a reference from a re-derivation at a glance. const MODEL = { diff --git a/server/typescript/packages/codegen-ts/test/nodenext-safe-imports.test.ts b/server/typescript/packages/codegen-ts/test/nodenext-safe-imports.test.ts index b94e947286..81f69eca31 100644 --- a/server/typescript/packages/codegen-ts/test/nodenext-safe-imports.test.ts +++ b/server/typescript/packages/codegen-ts/test/nodenext-safe-imports.test.ts @@ -14,8 +14,8 @@ // unresolved `../db` stub or an uninstalled optional `fastify` peer) are ignored; // only the extension errors this fix targets are asserted. -import { describe, test, expect } from "bun:test"; -import "./compile-timeout.js"; +import { describe, test, expect, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "./compile-timeout.js"; import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; @@ -27,6 +27,8 @@ import { queriesFile } from "../src/generators/queries-file.js"; import { routesFile } from "../src/generators/routes-file.js"; import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + const META = JSON.stringify({ "metadata.root": { package: "acme::blog", diff --git a/server/typescript/packages/codegen-ts/test/owned-composition-imports.test.ts b/server/typescript/packages/codegen-ts/test/owned-composition-imports.test.ts index 9644caaf8b..22152aa45a 100644 --- a/server/typescript/packages/codegen-ts/test/owned-composition-imports.test.ts +++ b/server/typescript/packages/codegen-ts/test/owned-composition-imports.test.ts @@ -1,9 +1,11 @@ -import { describe, expect, test } from "bun:test"; -import "./compile-timeout.js"; +import { describe, expect, test, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "./compile-timeout.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + /** * A composition an adopter OWNS may only call what the package exports. * diff --git a/server/typescript/packages/codegen-ts/test/owned-generator-skew-compile.test.ts b/server/typescript/packages/codegen-ts/test/owned-generator-skew-compile.test.ts index 66e4aedfeb..73d850c2be 100644 --- a/server/typescript/packages/codegen-ts/test/owned-generator-skew-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/owned-generator-skew-compile.test.ts @@ -13,8 +13,8 @@ // Each mix of old/current entity and queries generators is generated and compiled with the // real TypeScript compiler. -import { describe, test, expect } from "bun:test"; -import "./compile-timeout.js"; +import { describe, test, expect, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "./compile-timeout.js"; import { mkdtempSync, rmSync, readdirSync } from "node:fs"; import { join, resolve } from "node:path"; import ts from "typescript"; @@ -27,6 +27,8 @@ import { queriesFile as currentQueries } from "../src/reference/queries.js"; import { entityFile as ownedEntity108 } from "./fixtures/owned-1.0.8/entity.js"; import { queriesFile as ownedQueries108 } from "./fixtures/owned-1.0.8/queries.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + const FIXTURES = ["autoset-timestamps.json", "extends-chain.json", "two-entities-fk.json", "cross-package-vo.json"]; const MIXES: Array<{ label: string; generators: () => Generator[] }> = [ diff --git a/server/typescript/packages/codegen-ts/test/projection/compile.test.ts b/server/typescript/packages/codegen-ts/test/projection/compile.test.ts index 5e5df1ca55..a1ee9330c7 100644 --- a/server/typescript/packages/codegen-ts/test/projection/compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/projection/compile.test.ts @@ -7,8 +7,8 @@ // DbContextCompileTests). Any read-only/writable inconsistency that produces // non-compiling output fails here, not just the one we already knew about. -import { describe, test, expect } from "bun:test"; -import "../compile-timeout.js"; +import { describe, test, expect, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "../compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; @@ -20,6 +20,8 @@ import { buildPkMap } from "../../src/pk-resolver.js"; import { buildRelationMap } from "../../src/relation-resolver.js"; import type { GenContext } from "../../src/generator.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + async function loadProjectionRoot() { const json = JSON.stringify({ "metadata.root": { diff --git a/server/typescript/packages/codegen-ts/test/projection/int-enum-replica-view-no-dup.test.ts b/server/typescript/packages/codegen-ts/test/projection/int-enum-replica-view-no-dup.test.ts index 4757b69b5b..923d3c8f93 100644 --- a/server/typescript/packages/codegen-ts/test/projection/int-enum-replica-view-no-dup.test.ts +++ b/server/typescript/packages/codegen-ts/test/projection/int-enum-replica-view-no-dup.test.ts @@ -5,8 +5,8 @@ // codec must be declared exactly once in that file. Declaring it on both sides is a // duplicate-identifier compile error, i.e. the same class of bug as emitting none. -import { describe, test, expect } from "bun:test"; -import "../compile-timeout.js"; +import { describe, test, expect, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "../compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; @@ -17,6 +17,8 @@ import { buildPkMap } from "../../src/pk-resolver.js"; import { buildRelationMap } from "../../src/relation-resolver.js"; import type { GenContext } from "../../src/generator.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + async function loadRoot() { const json = JSON.stringify({ "metadata.root": { diff --git a/server/typescript/packages/codegen-ts/test/projection/int-enum-view-compile.test.ts b/server/typescript/packages/codegen-ts/test/projection/int-enum-view-compile.test.ts index 2ac48c1b4d..7f42bbc592 100644 --- a/server/typescript/packages/codegen-ts/test/projection/int-enum-view-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/projection/int-enum-view-compile.test.ts @@ -16,8 +16,8 @@ // The read-view host, where the table and the view share one module and must not both // declare the codec, is covered by `int-enum-replica-view-no-dup.test.ts`. -import { describe, test, expect } from "bun:test"; -import "../compile-timeout.js"; +import { describe, test, expect, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "../compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; @@ -28,6 +28,8 @@ import { buildPkMap } from "../../src/pk-resolver.js"; import { buildRelationMap } from "../../src/relation-resolver.js"; import type { GenContext } from "../../src/generator.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + const STATUS = { "@values": ["DRAFT", "BOOKED", "DELIVERED", "CANCELLED"], "@intValueMap": { DRAFT: 0, BOOKED: 10, DELIVERED: 30, CANCELLED: 90 }, diff --git a/server/typescript/packages/codegen-ts/test/projection/issue-214-read-half-compile.test.ts b/server/typescript/packages/codegen-ts/test/projection/issue-214-read-half-compile.test.ts index 2b7e12df7a..d8eca4090e 100644 --- a/server/typescript/packages/codegen-ts/test/projection/issue-214-read-half-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/projection/issue-214-read-half-compile.test.ts @@ -7,8 +7,8 @@ // asserts the structure AND compiles the real generated output with tsc — the read // type + query code must type-check (`typeof view.$inferSelect`, the re-read, etc.). -import { describe, test, expect } from "bun:test"; -import "../compile-timeout.js"; +import { describe, test, expect, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "../compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; @@ -23,6 +23,8 @@ import { queriesFile as refQueriesFile } from "../../src/reference/queries.js"; import type { GenContext } from "../../src/generator.js"; import type { Dialect } from "../../src/metaobjects-config.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + // Customer + a write-through Order (replica view v_order_with_customer, derived // customerName from Customer.name via the customer join). const META = JSON.stringify({ diff --git a/server/typescript/packages/codegen-ts/test/reference-binding-compile.test.ts b/server/typescript/packages/codegen-ts/test/reference-binding-compile.test.ts index 7414eb1226..fd8b2e071e 100644 --- a/server/typescript/packages/codegen-ts/test/reference-binding-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/reference-binding-compile.test.ts @@ -12,8 +12,8 @@ // - a reference onto an ABSTRACT level of a TPH hierarchy imported a table const from a // module that has none: the rows live in the discriminator base's table. -import { describe, expect, test } from "bun:test"; -import "./compile-timeout.js"; +import { describe, expect, test, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "./compile-timeout.js"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; @@ -28,6 +28,8 @@ import { buildPkMap } from "../src/pk-resolver.js"; import { buildRelationMap } from "../src/relation-resolver.js"; import type { EmittedFile, GenContext } from "../src/generator.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + const pk = { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment" } }; const MODEL = { diff --git a/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts b/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts index e9cda93c8c..e33a1922c8 100644 --- a/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts +++ b/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts @@ -2,8 +2,8 @@ // produce BYTE-IDENTICAL output to the built-in generators they were relocated from. // The reference generators import only "@metaobjectsdev/codegen-ts" (the public engine); // if this passes, a consumer can copy them out and own them with no behavior change. -import { describe, test, expect, beforeEach, afterEach } from "bun:test"; -import "./compile-timeout.js"; +import { describe, test, expect, beforeEach, afterEach, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "./compile-timeout.js"; import { existsSync, mkdirSync, mkdtempSync, rmSync, readdirSync, readFileSync, cpSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -81,6 +81,8 @@ import type { RequirementTestArgs, RequirementTestsOpts } from "../src/index.js" import { MetaDataLoader, InMemoryStringSource, isMetaSource, isReadOnlySource, type MetaObject } from "@metaobjectsdev/metadata"; import { FileSource } from "@metaobjectsdev/metadata/core"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + const FIXTURE_DIR = resolve(import.meta.dir, "fixtures"); // test → codegen-ts → packages → typescript → server → repo root const REPO_FIXTURES = resolve(import.meta.dir, "..", "..", "..", "..", "..", "fixtures"); diff --git a/server/typescript/packages/codegen-ts/test/sourceless-entity-surface.test.ts b/server/typescript/packages/codegen-ts/test/sourceless-entity-surface.test.ts index 04768e5787..8468dbac8d 100644 --- a/server/typescript/packages/codegen-ts/test/sourceless-entity-surface.test.ts +++ b/server/typescript/packages/codegen-ts/test/sourceless-entity-surface.test.ts @@ -18,8 +18,8 @@ // The test compiles the output, plus a small hand-written repository that uses it the way a // document-store adopter would, with the real TypeScript compiler. -import { describe, test, expect } from "bun:test"; -import "./compile-timeout.js"; +import { describe, test, expect, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; @@ -31,6 +31,8 @@ import { buildRelationMap } from "../src/relation-resolver.js"; import { isSourcelessEntity } from "../src/index.js"; import type { GenContext } from "../src/generator.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + const MODEL = { "metadata.root": { package: "shop", diff --git a/server/typescript/packages/codegen-ts/test/templates/names-consumption.test.ts b/server/typescript/packages/codegen-ts/test/templates/names-consumption.test.ts index 94d46dad23..1daddff266 100644 --- a/server/typescript/packages/codegen-ts/test/templates/names-consumption.test.ts +++ b/server/typescript/packages/codegen-ts/test/templates/names-consumption.test.ts @@ -10,8 +10,8 @@ // Both arms run through `runGen`, not through a hand-built RenderContext, so the whole // chain is under test: namesFile()'s `emitsNames` marker → the runner's aggregation → // ResolvedGenConfig.includeNames + RenderContext.includeNames → the template. -import { describe, test, expect, beforeEach, afterEach } from "bun:test"; -import "../compile-timeout.js"; +import { describe, test, expect, beforeEach, afterEach, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "../compile-timeout.js"; import { existsSync, mkdtempSync, rmSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -24,6 +24,8 @@ import { entityFile } from "../../src/generators/entity-file.js"; import { queriesFile } from "../../src/generators/queries-file.js"; import type { ExtStyle } from "../../src/render-context.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + const FIXTURE = resolve(import.meta.dir, "../fixtures/trainer-website-shape.json"); const loader = new MetaDataLoader(); diff --git a/server/typescript/packages/codegen-ts/test/tph-read-schema-default.test.ts b/server/typescript/packages/codegen-ts/test/tph-read-schema-default.test.ts index 598fde24b1..0b4aed9155 100644 --- a/server/typescript/packages/codegen-ts/test/tph-read-schema-default.test.ts +++ b/server/typescript/packages/codegen-ts/test/tph-read-schema-default.test.ts @@ -15,8 +15,8 @@ // base-declared and a subtype-declared field — the defect is not specific to the level // the field is declared at — and by compiling the tree, which is the symptom adopters saw. -import { describe, test, expect } from "bun:test"; -import "./compile-timeout.js"; +import { describe, test, expect, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; import { pathToFileURL } from "node:url"; @@ -33,6 +33,8 @@ import { buildRelationMap } from "../src/relation-resolver.js"; import type { EmittedFile, GenContext } from "../src/generator.js"; import type { Dialect } from "../src/metaobjects-config.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + const MODEL = { "metadata.root": { package: "demo", diff --git a/server/typescript/packages/codegen-ts/test/tph-reference-target.test.ts b/server/typescript/packages/codegen-ts/test/tph-reference-target.test.ts index b36177e80f..1ad356f36e 100644 --- a/server/typescript/packages/codegen-ts/test/tph-reference-target.test.ts +++ b/server/typescript/packages/codegen-ts/test/tph-reference-target.test.ts @@ -16,8 +16,8 @@ // The model is TS-local on purpose, and mirrors migrate-ts's // `expected-schema-tph.test.ts`, which asserts the same FKs from the DDL side. -import { describe, test, expect } from "bun:test"; -import "./compile-timeout.js"; +import { describe, test, expect, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; import ts from "typescript"; @@ -33,6 +33,8 @@ import { buildRelationMap } from "../src/relation-resolver.js"; import type { EmittedFile, GenContext } from "../src/generator.js"; import type { Dialect } from "../src/metaobjects-config.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + const MODEL = { "metadata.root": { package: "demo", diff --git a/server/typescript/packages/codegen-ts/test/typed-create-compile.test.ts b/server/typescript/packages/codegen-ts/test/typed-create-compile.test.ts index ac9fdb1028..a997b0a855 100644 --- a/server/typescript/packages/codegen-ts/test/typed-create-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/typed-create-compile.test.ts @@ -10,8 +10,8 @@ // module compiled against the generated tree with the real TS compiler — a correct call is // clean, a misspelt or missing field is an error at the call site. -import { describe, test, expect } from "bun:test"; -import "./compile-timeout.js"; +import { describe, test, expect, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; @@ -24,6 +24,8 @@ import { buildRelationMap } from "../src/relation-resolver.js"; import type { EmittedFile, GenContext } from "../src/generator.js"; import type { Dialect } from "../src/metaobjects-config.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + const MODEL = { "metadata.root": { package: "brew", diff --git a/server/typescript/packages/codegen-ts/test/verbatim-module-syntax-compile.test.ts b/server/typescript/packages/codegen-ts/test/verbatim-module-syntax-compile.test.ts index 43043fa66f..cac127ae11 100644 --- a/server/typescript/packages/codegen-ts/test/verbatim-module-syntax-compile.test.ts +++ b/server/typescript/packages/codegen-ts/test/verbatim-module-syntax-compile.test.ts @@ -14,8 +14,8 @@ // by string-match. The entity carries a self-referential FK so the AnyPgColumn // annotation is actually emitted. -import { describe, test, expect } from "bun:test"; -import "./compile-timeout.js"; +import { describe, test, expect, setDefaultTimeout } from "bun:test"; +import { COMPILE_TEST_TIMEOUT_MS } from "./compile-timeout.js"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import ts from "typescript"; @@ -27,6 +27,8 @@ import { buildRelationMap } from "../src/relation-resolver.js"; import type { GenContext } from "../src/generator.js"; import type { Dialect } from "../src/metaobjects-config.js"; +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); + async function loadRoot() { const json = JSON.stringify({ "metadata.root": { diff --git a/server/typescript/packages/test-generators/src/templates/zod-validators.ts b/server/typescript/packages/test-generators/src/templates/zod-validators.ts index 5880f2af68..917a215da5 100644 --- a/server/typescript/packages/test-generators/src/templates/zod-validators.ts +++ b/server/typescript/packages/test-generators/src/templates/zod-validators.ts @@ -287,14 +287,16 @@ const EQUALITY_SUBTYPES: ReadonlySet = new Set([ ]); /** Whether a comparison between these two fields has a sound meaning, as in every port: - * numerics compare with each other, strings and temporals only with their own subtype (a - * date against a timestamp compares two differently shaped strings, which is meaningless), + * numerics compare with each other, strings and temporals only with their own subtype and + * @localTime flag (a date against a timestamp, or an instant against a wall clock, compares + * two differently shaped strings, which is meaningless), * and boolean, enum, uuid, uri and inet fields only test equality. Anything else is skipped, never rejected. */ function crossComparable(left: MetaField, right: MetaField, opName: string): boolean { const a = left.subType; const b = right.subType; if (NUMERIC_SUBTYPES.has(a) && NUMERIC_SUBTYPES.has(b)) return true; if (a !== b) return false; + if ((left.attr(FIELD_ATTR_LOCAL_TIME) === true) !== (right.attr(FIELD_ATTR_LOCAL_TIME) === true)) return false; if (ORDERED_SUBTYPES.has(a)) return true; return (opName === "eq" || opName === "ne") && EQUALITY_SUBTYPES.has(a); } From 3fedb738004d79fbbcb0c4eb463787a26601690f Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 4 Nov 2026 06:39:33 -0500 Subject: [PATCH 06/10] no-mistakes(review): Apply compile timeout to tph-cross-field test --- .../packages/codegen-ts/test/tph-cross-field.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/typescript/packages/codegen-ts/test/tph-cross-field.test.ts b/server/typescript/packages/codegen-ts/test/tph-cross-field.test.ts index ef262f2004..c04a8a8146 100644 --- a/server/typescript/packages/codegen-ts/test/tph-cross-field.test.ts +++ b/server/typescript/packages/codegen-ts/test/tph-cross-field.test.ts @@ -11,7 +11,7 @@ // kind = BIRTH) rejects a BIRTH row without a note and accepts every other row; a rule gated // on the discriminator reads the subtype's pinned value, so it holds on a body that omits it. -import { describe, test, expect, afterEach } from "bun:test"; +import { describe, test, expect, afterEach, setDefaultTimeout } from "bun:test"; import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; import { pathToFileURL } from "node:url"; @@ -25,6 +25,9 @@ import { makeRenderContext } from "../src/render-context.js"; import { buildPkMap } from "../src/pk-resolver.js"; import { buildRelationMap } from "../src/relation-resolver.js"; import type { EmittedFile, GenContext } from "../src/generator.js"; +import { COMPILE_TEST_TIMEOUT_MS } from "./compile-timeout.js"; + +setDefaultTimeout(COMPILE_TEST_TIMEOUT_MS); const MODEL = { "metadata.root": { From cc652753244f85fff3b19fe9952dcfced6b31984 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 4 Nov 2026 06:42:46 -0500 Subject: [PATCH 07/10] no-mistakes(review): Skip C# eq/ne across differing CLR types --- .../meta.cross-field.json | 1 + .../CrossFieldValidatorGenerationTests.cs | 25 +++++++++++++++++++ .../Generators/EntityGenerator.cs | 5 +++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/fixtures/validation-conformance/meta.cross-field.json b/fixtures/validation-conformance/meta.cross-field.json index 9759b923fa..a47faea675 100644 --- a/fixtures/validation-conformance/meta.cross-field.json +++ b/fixtures/validation-conformance/meta.cross-field.json @@ -32,6 +32,7 @@ { "validator.comparison": { "name": "ownerNotApprover", "@left": "ownerId", "@op": "ne", "@right": "approverId" } }, { "validator.comparison": { "name": "dateVsTimestamp", "@left": "eventDate", "@op": "lte", "@right": "loggedAt" } }, { "validator.comparison": { "name": "instantVsWallClock", "@left": "loggedAt", "@op": "lte", "@right": "wallClock" } }, + { "validator.comparison": { "name": "instantEqWallClock", "@left": "loggedAt", "@op": "eq", "@right": "wallClock" } }, { "validator.atLeastOne": { "name": "reachable", "@fields": ["email", "phone"] } }, { "validator.requiredWhen": { "name": "reviewNeedsReviewer", "@field": "reviewedBy", "@when": "review", "@equals": "DONE" } } ] diff --git a/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs b/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs index 7d55763343..4334b76ff4 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs @@ -37,6 +37,23 @@ public class CrossFieldValidatorGenerationTests ]}} """; + private const string EqAcrossClrTypes = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Stamp", "children": [ + { "source.rdb": { "@table": "stamps" } }, + { "field.long": { "name": "id" } }, + { "field.timestamp": { "name": "loggedAt" } }, + { "field.timestamp": { "name": "wallClock", "@localTime": true } }, + { "field.enum": { "name": "colour", "@values": ["RED", "BLUE"] } }, + { "field.enum": { "name": "shade", "@values": ["RED", "BLUE"] } }, + { "identity.primary": { "@fields": "id" } }, + { "validator.comparison": { "name": "sameInstant", "@left": "loggedAt", "@op": "eq", "@right": "wallClock" } }, + { "validator.comparison": { "name": "differentInstant", "@left": "loggedAt", "@op": "ne", "@right": "wallClock" } }, + { "validator.comparison": { "name": "sameColour", "@left": "colour", "@op": "eq", "@right": "shade" } } + ]}} + ]}} + """; + private const string Plain = """ { "metadata.root": { "package": "acme", "children": [ { "object.entity": { "name": "Widget", "children": [ @@ -204,6 +221,14 @@ public void Entity_without_cross_field_validators_is_not_validatable() Assert.False(typeof(IValidatableObject).IsAssignableFrom(asm.GetType("Acme.Widget")!)); } + [Fact] + public void Eq_and_ne_between_fields_of_different_clr_types_are_skipped_not_rejected() + { + var asm = Compile(new EntityGenerator(), EqAcrossClrTypes); + Assert.Empty(Validate(asm, "Stamp", "{\"loggedAt\":\"2030-01-02T00:00:00Z\",\"wallClock\":\"2020-01-01T00:00:00\",\"colour\":0,\"shade\":1}")); + Assert.Empty(Validate(asm, "Stamp", "{\"loggedAt\":\"2020-01-01T00:00:00Z\",\"wallClock\":\"2020-01-01T00:00:00\",\"colour\":0,\"shade\":0}")); + } + [Fact] public void A_child_of_a_concrete_parent_runs_the_inherited_rule_and_its_own() { diff --git a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs index 449176be43..4c40700d45 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs @@ -1157,7 +1157,10 @@ string Gate(MetaField when, string raw) && CSharpNaming.ScalarForField(l) == CSharpNaming.ScalarForField(r)) cond = $"{a} {csOp} {b}"; else if (!ordering && l.SubType == r.SubType - && (lk != CrossKind.Other || l.SubType is FIELD_SUBTYPE_UUID or FIELD_SUBTYPE_URI or FIELD_SUBTYPE_INET)) + && (lk != CrossKind.Other || l.SubType is FIELD_SUBTYPE_UUID or FIELD_SUBTYPE_URI or FIELD_SUBTYPE_INET) + && CSharpNaming.ScalarForField(l) == CSharpNaming.ScalarForField(r) + && (l.SubType != FIELD_SUBTYPE_ENUM + || ReferenceEquals(Fr019SharedEnum.ResolveSharedEnumDecl(l) ?? l, Fr019SharedEnum.ResolveSharedEnumDecl(r) ?? r))) cond = op == "eq" ? $"Equals({a}, {b})" : $"!Equals({a}, {b})"; else break; var lp = PropertyName(l); var rp = PropertyName(r); From 2861c0deba06f90c505ae040b1492251a7cb147a Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 4 Nov 2026 07:31:35 -0500 Subject: [PATCH 08/10] no-mistakes(document): Updated entities.md cross-field section for kind-based comparison rule; one follow-up left. --- docs/features/entities.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/features/entities.md b/docs/features/entities.md index 5247997235..d0f0d2f728 100644 --- a/docs/features/entities.md +++ b/docs/features/entities.md @@ -338,6 +338,13 @@ side is null; `requiredWhen` is violated only when the gate equals `@equals` and needs one non-null field. `@equals` is read per the gating field's subtype. The shared cases in `fixtures/validation-conformance/cross-field-cases.json` gate all five ports. +One difference: a `comparison` orders only fields of the same kind. Numerics compare with each other; +strings and temporals compare only with their own subtype and `@localTime` flag. `eq` and `ne` also +apply to two booleans, enums, `uuid`, `uri` or `inet` fields, and ordering on a boolean or enum is not +enforced. Any other pair is skipped in the generated model, never rejected, but the DB `CHECK` still +covers it where the database can compare the two columns. The full rule is in +`fixtures/validation-conformance/README.md`. + PATCH and partial-update models do **not** enforce these rules, because a partial body cannot see the merged row. The database `CHECK` is the enforcement there. From 72c0c8a3341470b66f852f158bf0f80048bedfa3 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 4 Nov 2026 07:33:34 -0500 Subject: [PATCH 09/10] no-mistakes(document): Document comparison kind rule in authoring skill --- agent-context/skills/metaobjects-authoring/SKILL.md | 2 +- .../expected/.claude/skills/metaobjects-authoring/SKILL.md | 2 +- .../expected/.claude/skills/metaobjects-authoring/SKILL.md | 2 +- .../expected/.claude/skills/metaobjects-authoring/SKILL.md | 2 +- .../expected/.claude/skills/metaobjects-authoring/SKILL.md | 2 +- .../expected/.claude/skills/metaobjects-authoring/SKILL.md | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/agent-context/skills/metaobjects-authoring/SKILL.md b/agent-context/skills/metaobjects-authoring/SKILL.md index a1a3fe6c0b..01fb198e0d 100644 --- a/agent-context/skills/metaobjects-authoring/SKILL.md +++ b/agent-context/skills/metaobjects-authoring/SKILL.md @@ -434,7 +434,7 @@ store with no `CHECK` support is still covered. PATCH and partial-update models | Subtype | Rule | Key attrs | |---|---|---| -| `validator.comparison` | two fields stand in a relational order (`@left @op @right`) | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` | +| `validator.comparison` | two fields stand in a relational order (`@left @op @right`). Numerics compare with each other; strings and temporals only with the same subtype and `@localTime`; boolean, enum, uuid, uri and inet only `eq`/`ne`. Any other pair is skipped, never rejected. | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` | | `validator.requiredWhen` | `@field` is required when `@when` equals `@equals` | `@field`, `@when`, `@equals` | | `validator.presentIff` | `@field` is present **iff** `@when` equals `@equals` (biconditional) | `@field`, `@when`, `@equals` | | `validator.atLeastOne` | at least one of `@fields` (2+) is present | `@fields` | diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md index a1a3fe6c0b..01fb198e0d 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -434,7 +434,7 @@ store with no `CHECK` support is still covered. PATCH and partial-update models | Subtype | Rule | Key attrs | |---|---|---| -| `validator.comparison` | two fields stand in a relational order (`@left @op @right`) | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` | +| `validator.comparison` | two fields stand in a relational order (`@left @op @right`). Numerics compare with each other; strings and temporals only with the same subtype and `@localTime`; boolean, enum, uuid, uri and inet only `eq`/`ne`. Any other pair is skipped, never rejected. | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` | | `validator.requiredWhen` | `@field` is required when `@when` equals `@equals` | `@field`, `@when`, `@equals` | | `validator.presentIff` | `@field` is present **iff** `@when` equals `@equals` (biconditional) | `@field`, `@when`, `@equals` | | `validator.atLeastOne` | at least one of `@fields` (2+) is present | `@fields` | diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md index a1a3fe6c0b..01fb198e0d 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -434,7 +434,7 @@ store with no `CHECK` support is still covered. PATCH and partial-update models | Subtype | Rule | Key attrs | |---|---|---| -| `validator.comparison` | two fields stand in a relational order (`@left @op @right`) | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` | +| `validator.comparison` | two fields stand in a relational order (`@left @op @right`). Numerics compare with each other; strings and temporals only with the same subtype and `@localTime`; boolean, enum, uuid, uri and inet only `eq`/`ne`. Any other pair is skipped, never rejected. | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` | | `validator.requiredWhen` | `@field` is required when `@when` equals `@equals` | `@field`, `@when`, `@equals` | | `validator.presentIff` | `@field` is present **iff** `@when` equals `@equals` (biconditional) | `@field`, `@when`, `@equals` | | `validator.atLeastOne` | at least one of `@fields` (2+) is present | `@fields` | diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md index a1a3fe6c0b..01fb198e0d 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -434,7 +434,7 @@ store with no `CHECK` support is still covered. PATCH and partial-update models | Subtype | Rule | Key attrs | |---|---|---| -| `validator.comparison` | two fields stand in a relational order (`@left @op @right`) | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` | +| `validator.comparison` | two fields stand in a relational order (`@left @op @right`). Numerics compare with each other; strings and temporals only with the same subtype and `@localTime`; boolean, enum, uuid, uri and inet only `eq`/`ne`. Any other pair is skipped, never rejected. | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` | | `validator.requiredWhen` | `@field` is required when `@when` equals `@equals` | `@field`, `@when`, `@equals` | | `validator.presentIff` | `@field` is present **iff** `@when` equals `@equals` (biconditional) | `@field`, `@when`, `@equals` | | `validator.atLeastOne` | at least one of `@fields` (2+) is present | `@fields` | diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md index a1a3fe6c0b..01fb198e0d 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -434,7 +434,7 @@ store with no `CHECK` support is still covered. PATCH and partial-update models | Subtype | Rule | Key attrs | |---|---|---| -| `validator.comparison` | two fields stand in a relational order (`@left @op @right`) | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` | +| `validator.comparison` | two fields stand in a relational order (`@left @op @right`). Numerics compare with each other; strings and temporals only with the same subtype and `@localTime`; boolean, enum, uuid, uri and inet only `eq`/`ne`. Any other pair is skipped, never rejected. | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` | | `validator.requiredWhen` | `@field` is required when `@when` equals `@equals` | `@field`, `@when`, `@equals` | | `validator.presentIff` | `@field` is present **iff** `@when` equals `@equals` (biconditional) | `@field`, `@when`, `@equals` | | `validator.atLeastOne` | at least one of `@fields` (2+) is present | `@fields` | diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md index a1a3fe6c0b..01fb198e0d 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -434,7 +434,7 @@ store with no `CHECK` support is still covered. PATCH and partial-update models | Subtype | Rule | Key attrs | |---|---|---| -| `validator.comparison` | two fields stand in a relational order (`@left @op @right`) | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` | +| `validator.comparison` | two fields stand in a relational order (`@left @op @right`). Numerics compare with each other; strings and temporals only with the same subtype and `@localTime`; boolean, enum, uuid, uri and inet only `eq`/`ne`. Any other pair is skipped, never rejected. | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` | | `validator.requiredWhen` | `@field` is required when `@when` equals `@equals` | `@field`, `@when`, `@equals` | | `validator.presentIff` | `@field` is present **iff** `@when` equals `@equals` (biconditional) | `@field`, `@when`, `@equals` | | `validator.atLeastOne` | at least one of `@fields` (2+) is present | `@fields` | From 3664e4485a7ad3e36936601adeb88e48f21088c9 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 4 Nov 2026 07:42:14 -0500 Subject: [PATCH 10/10] fix(codegen-kotlin): cross-field rules no longer depend on KotlinTypeMapper An ejected copy of KotlinCrossFieldRules compiles standalone, so it cannot reference KotlinTypeMapper. Decide the native shape from @localTime and @lenient directly. --- .../generator/kotlin/KotlinCrossFieldRules.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldRules.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldRules.kt index 1be75e4614..a3560c8a3e 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldRules.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldRules.kt @@ -1,6 +1,7 @@ package com.metaobjects.generator.kotlin import com.metaobjects.MetaData +import com.metaobjects.database.CoreDBMetaDataProvider import com.metaobjects.field.BooleanField import com.metaobjects.field.CurrencyField import com.metaobjects.field.DateField @@ -38,6 +39,18 @@ object KotlinCrossFieldRules { private fun isTemporalCross(f: MetaField<*>): Boolean = f is DateField || f is TimeField || f is TimestampField + private fun flag(f: MetaField<*>, attr: String): Boolean = + f.hasMetaAttr(attr, true) && + runCatching { f.getMetaAttr(attr, true).value }.getOrNull()?.toString()?.trim().toBoolean() + + /** + * What decides the Kotlin type of a field beyond its subtype: a timestamp is an Instant unless + * `@localTime`, and a uri/inet is a native type unless `@lenient` (then a String). Read from the + * attributes rather than the type mapper so an ejected copy of this file stands alone. + */ + private fun nativeShape(f: MetaField<*>): Pair = + Pair(f is TimestampField && flag(f, CoreDBMetaDataProvider.LOCAL_TIME), (f is UriField || f is InetField) && flag(f, UriField.ATTR_LENIENT)) + /** * The comparison for two fields, or null when the pair has no sound one. Mirrors the C# * generator: numerics compare with each other, strings and temporals only with their own @@ -56,7 +69,7 @@ object KotlinCrossFieldRules { val comparable = isNumericCross(l) || l is StringField || isTemporalCross(l) val equatable = !ordering && (l is BooleanField || l is EnumField || l is UuidField || l is UriField || l is InetField) if (!comparable && !equatable) return null - if (KotlinTypeMapper.kotlinTypeName(l) != KotlinTypeMapper.kotlinTypeName(r)) return null + if (nativeShape(l) != nativeShape(r)) return null if (comparable) { return CodeBlock.of("(%L as Comparable).compareTo(%L) $kop 0", ref(l), ref(r)) }