From f503b7c1817e224282e8b5a222887fbfff54c473 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 10 Oct 2026 10:16:10 -0400 Subject: [PATCH 1/2] fix(reporting): resolve validator.required for report dimensions; type currency filter operand as number A report dimension over a field required by a validator.required child was typed nullable in the report shape (all ports); C# entity generation ignored the child too. The generated filter type gave field.currency a string operand. --- CHANGELOG.md | 22 ++++++++++++-- .../canonical/meta.fitness.json | 2 +- .../MetaObjects.Codegen/CSharpNaming.cs | 8 ++--- .../MetaObjects/Core/Reporting/ReportShape.cs | 8 ++--- .../metaobjects/reporting/ReportShape.java | 6 ++-- .../meta/core/reporting/report_shape.py | 8 +++-- .../codegen-ts/src/templates/filter-type.ts | 3 ++ .../test/templates/filter-type.test.ts | 24 +++++++++++++++ .../src/core/reporting/report-shape.ts | 7 ++--- .../metadata/test/report-shape.test.ts | 29 +++++++++++++++++++ 10 files changed, 97 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0814381dc..4aed4c193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,8 +27,9 @@ every port's `verify`), the derived-key parity fix across the ports, and the lis nested-average fixes found by the first adopter to map real report pages onto the feature. **How much smaller the code gets.** The size reduction comes in two steps, and each is its own -number. The first landed on 1.0.x: 439 lines of handler code retired. The second is reporting: -with it, the same pages come to about 60 lines of SQL and about 180 lines of metadata. +number. The first landed on 1.0.x: 439 lines of handler code retired. The second is reporting, +and it is a SQL reduction only: in an adopter, hand-written SQL went from 75 lines to 15 (-80%). +Total hand-written lines did not fall, so do not read it as a total line reduction. **Two diffs to expect when you upgrade.** @@ -494,6 +495,23 @@ until you regenerate. flag, as it does when no table changes. Gated in `migrate-ts` unit and drift tests, the CLI's `verify-db-view-recreate` test, and new `integration-tests` lanes on a real SQLite (with the D1 diff) and a real Postgres. +- **A report dimension over a field required by a `validator.required` child is typed non-null + in every port (FR-044).** `reportShape` read only the inline `@required` attribute, so a + dimension over `Program.title` required through a `validator.required` child came out + nullable (`text(...)`, `z.string().nullable()`, `| null`) although the view column is never + null. It now resolves required-ness as the field's loader and codegen already do (the attr or + the child, own or inherited), in the TypeScript, C#, Java (and so Kotlin) and Python + metadata. C# entity generation had the same gap (a `validator.required` field came out + nullable) and is fixed in the same change; the TypeScript, Java, Kotlin and Python entity and + projection fields were already correct. A model that declares + `@required: true` generates what it did before; regenerate and review the diff if you use + the child form on a dimension's field. +- **TypeScript: the generated filter type gives a `field.currency` field a number operand + (FR-044).** `{ revenue: { gt: 0 } }` on a report list hook did not type-check, because the + filter type fell through to `string` while the allowlist and row schema treat currency as + integer minor units. The other ports have no typed filter operand and already treat currency + as a `long`. + - **TypeScript: a report's decimal fields reach the wire as strings on SQLite, as on Postgres (FR-044).** A ratio is typed `decimal`, the TypeScript read schema types a decimal as `string`, and SQLite has no decimal: the view computes a `REAL`, which the driver hands the diff --git a/fixtures/persistence-conformance/canonical/meta.fitness.json b/fixtures/persistence-conformance/canonical/meta.fitness.json index 8a8b0cf2d..8d35b31db 100644 --- a/fixtures/persistence-conformance/canonical/meta.fitness.json +++ b/fixtures/persistence-conformance/canonical/meta.fitness.json @@ -7,7 +7,7 @@ "children": [ { "source.rdb": { "@table": "programs" } }, { "field.long": { "name": "id" } }, - { "field.string": { "name": "title", "@required": true, "@maxLength": 200 } }, + { "field.string": { "name": "title", "@maxLength": 200, "children": [ { "validator.required": {} } ] } }, { "field.currency": { "name": "priceCents", "@required": true, "@currency": "USD" } }, { "field.enum": { "name": "status", "@required": true, "@values": ["DRAFT", "PUBLISHED", "ARCHIVED"] } }, { "field.timestamp": { "name": "createdAt", "@column": "created_ts", "@required": true, "@localTime": true } }, diff --git a/server/csharp/MetaObjects.Codegen/CSharpNaming.cs b/server/csharp/MetaObjects.Codegen/CSharpNaming.cs index 49ac6494d..4ac07c381 100644 --- a/server/csharp/MetaObjects.Codegen/CSharpNaming.cs +++ b/server/csharp/MetaObjects.Codegen/CSharpNaming.cs @@ -1068,13 +1068,13 @@ public static string Pluralize(string name) } /// - /// Whether a field is non-nullable in the generated entity: explicitly @required, - /// or part of the primary identity. + /// Whether a field is non-nullable in the generated entity: required (the @required attr + /// or a validator.required child), or part of the primary identity. /// public static bool IsRequired(MetaObject entity, MetaField field) { - // ADR-0039: resolving — @required may be inherited from an abstract base via extends. - if (field.Attr(FIELD_ATTR_REQUIRED) is true) return true; + // ADR-0039: resolving — the attr and the validator set both inherit via extends. + if (field.IsRequired) return true; var pk = entity.PrimaryIdentity(); return pk is not null && pk.Fields.Contains(field.Name); } diff --git a/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs b/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs index 901d00c4c..b181dd77a 100644 --- a/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs +++ b/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs @@ -157,10 +157,10 @@ private static bool IsPrimaryKeyField(MetaObject entity, MetaField field) /// /// Table C (required of a dimension). Without @spine: only a dimension with - /// no @via over an @of field whose effective @required is true. With + /// no @via over an @of field that is effectively required (@required or a validator.required child). With /// @spine: only a dimension whose @via hops equal the spine's (a column of /// the spine entity itself, whose rows are the report's rows) over an @of field that - /// is @required or a primary-key column of the entity @of names. A dimension + /// is required (same rule) or a primary-key column of the entity @of names. A dimension /// beyond the spine is reached by a LEFT OUTER join. /// private static bool DimensionRequired( @@ -168,8 +168,8 @@ private static bool DimensionRequired( string[]? spine, string reportName) { string? via = dim.Via(); - // The @required ATTR only, read resolving (ADR-0039); a validator.required child does not count. - bool ofRequired = of.Attr(FIELD_ATTR_REQUIRED) is true; + // Effective required-ness, resolving (ADR-0039): the @required attr OR a validator.required child. + bool ofRequired = of.IsRequired; if (spine is null) return via is null && ofRequired; if (via is null) return false; // a column of @from: null in a spine row with no facts var hops = ReportingViaHops(via, ReportingMemberOwner(dim, from), from, root) diff --git a/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportShape.java b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportShape.java index 3bea93ec9..c091d0692 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportShape.java +++ b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportShape.java @@ -32,6 +32,7 @@ import com.metaobjects.object.MetaObject; import com.metaobjects.reporting.ReportAccessors.ReportDimensionItem; import com.metaobjects.source.MetaSource; +import com.metaobjects.validator.RequiredValidator; import java.util.ArrayList; import java.util.Collections; @@ -346,8 +347,9 @@ private static boolean isPrimaryKeyField(MetaObject entity, MetaField field) private static boolean dimensionRequired(MetaDimension dim, MetaObject named, MetaField of, MetaObject from, MetaRoot root, List spine, MetaObject report) { String via = dim.getVia(); - // Attr only: a validator.required child does not make the column non-null. - boolean ofRequired = ReportingAttrs.isTrue(of, MetaField.ATTR_REQUIRED); + // Effective required-ness, resolving (ADR-0039): the @required attr OR a validator.required child. + boolean ofRequired = ReportingAttrs.isTrue(of, MetaField.ATTR_REQUIRED) + || of.getValidators().stream().anyMatch(v -> RequiredValidator.SUBTYPE_REQUIRED.equals(v.getSubType())); if (spine == null) return via == null && ofRequired; if (via == null) return false; // a column of @from: null in a spine row with no facts List hops = reportingViaHops(via, memberOwner(dim, from), from, root); diff --git a/server/python/src/metaobjects/meta/core/reporting/report_shape.py b/server/python/src/metaobjects/meta/core/reporting/report_shape.py index d6a19a66b..420227293 100644 --- a/server/python/src/metaobjects/meta/core/reporting/report_shape.py +++ b/server/python/src/metaobjects/meta/core/reporting/report_shape.py @@ -232,9 +232,11 @@ def _dimension_required( a primary-key column of the entity ``@of`` names. A dimension beyond the spine is reached by a LEFT OUTER join.""" via = dim.via() - # ADR-0039 resolving: the @of field's effective @required (the attr only; a - # validator.required child does not count). - of_required = of.get_meta_attr(FIELD_ATTR_REQUIRED) is True + # ADR-0039 resolving: the @of field's effective required-ness, the @required attr OR a + # validator.required child (children() is the effective set, own + inherited). + of_required = of.get_meta_attr(FIELD_ATTR_REQUIRED) is True or any( + c.type == "validator" and c.sub_type == "required" for c in of.children() + ) if spine is None: return via is None and of_required if via is None: diff --git a/server/typescript/packages/codegen-ts/src/templates/filter-type.ts b/server/typescript/packages/codegen-ts/src/templates/filter-type.ts index f7d40f57b..57e6bc7d6 100644 --- a/server/typescript/packages/codegen-ts/src/templates/filter-type.ts +++ b/server/typescript/packages/codegen-ts/src/templates/filter-type.ts @@ -11,12 +11,14 @@ import { FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, + FIELD_SUBTYPE_CURRENCY, FIELD_SUBTYPE_ENUM, opsForField, } from "@metaobjectsdev/metadata"; import { isSortableField } from "./filter-shared.js"; import { enumValues } from "../enum-meta.js"; +// currency is integer minor units (allowlist `integer: true`, row schema `z.number().int()`). // VALUE-type classification only (distinct from the OPERATOR band, which comes from // opsForSubType). decimal is deliberately NOT here: its operator band stays NUMERIC // (eq/ne/gt/gte/lt/lte/in/isNull, see OPS_BY_SUBTYPE) but its VALUE type is `string`, @@ -26,6 +28,7 @@ const NUMBER_VALUE_SUBTYPES = new Set([ FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, + FIELD_SUBTYPE_CURRENCY, ]); /** diff --git a/server/typescript/packages/codegen-ts/test/templates/filter-type.test.ts b/server/typescript/packages/codegen-ts/test/templates/filter-type.test.ts index 12785de7f..0de8e1b32 100644 --- a/server/typescript/packages/codegen-ts/test/templates/filter-type.test.ts +++ b/server/typescript/packages/codegen-ts/test/templates/filter-type.test.ts @@ -118,4 +118,28 @@ describe("renderFilterType", () => { // ...and the string-only `like` operator absent. expect(out).not.toMatch(/total\?:\s*string\s*\|\s*\{[^}]*like\?:/); }); + + // field.currency is integer minor units (the allowlist marks it integer, the row schema is + // z.number().int()), so its filter operand is a number, not a string. + test("currency filterable field has number value type", async () => { + const meta = { + "metadata.root": { + package: "shop::money", + children: [ + { + "object.entity": { + name: "Invoice", + children: [{ "field.currency": { name: "revenue", "@filterable": true, "@currency": "USD" } }], + }, + }, + ], + }, + }; + const { root } = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(meta))]); + const entity = root.objects().find((c) => c.name === "Invoice")!; + const out = renderFilterType(entity).toString(); + expect(out).toMatch(/revenue\?:\s*number\s*\|\s*\{/); + expect(out).toMatch(/revenue\?:[\s\S]*?gt\?:\s*number/); + expect(out).not.toMatch(/revenue\?:[\s\S]*?gt\?:\s*string/); + }); }); diff --git a/server/typescript/packages/metadata/src/core/reporting/report-shape.ts b/server/typescript/packages/metadata/src/core/reporting/report-shape.ts index 7935afd36..64a5b29ce 100644 --- a/server/typescript/packages/metadata/src/core/reporting/report-shape.ts +++ b/server/typescript/packages/metadata/src/core/reporting/report-shape.ts @@ -12,7 +12,6 @@ import { CHILD_REF_SEPARATOR, PACKAGE_SEPARATOR } from "../../shared/structural. import type { MetaObject } from "../object/meta-object.js"; import type { MetaField } from "../field/meta-field.js"; import { - FIELD_ATTR_REQUIRED, FIELD_SUBTYPE_CURRENCY, FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_DECIMAL, @@ -238,7 +237,7 @@ function isTimeGrain(grain: string | undefined): grain is TimeGrain { /** * Table C (`required` of a dimension). Without `@spine`: only a dimension with no `@via` - * over an `@of` field whose effective `@required` is true. With `@spine`: only a dimension + * over an `@of` field that is effectively required (`@required`, or a `validator.required` child). With `@spine`: only a dimension * whose `@via` hops equal the spine's (a column of the spine entity itself, whose rows are * the report's rows) over an `@of` field that is `@required` or a primary-key column of the * entity `@of` names. A dimension beyond the spine is reached by a LEFT OUTER join. @@ -253,12 +252,12 @@ function dimensionRequired( reportName: string, ): boolean { const via = dim.via(); - if (spine === undefined) return via === undefined && of.attr(FIELD_ATTR_REQUIRED) === true; + if (spine === undefined) return via === undefined && of.isRequired; if (via === undefined) return false; // a column of @from: null in a spine row with no facts const hops = reportingViaHops(via, reportingMemberOwner(dim, from), from, root); if (hops === undefined) throw unresolved(reportName, `dimension '${dim.name}' @via '${via}'`); const onSpine = hops.length === spine.length && hops.every((h, i) => h === spine[i]); - return onSpine && (of.attr(FIELD_ATTR_REQUIRED) === true || isPrimaryKeyField(named, of)); + return onSpine && (of.isRequired || isPrimaryKeyField(named, of)); } function dimensionField( diff --git a/server/typescript/packages/metadata/test/report-shape.test.ts b/server/typescript/packages/metadata/test/report-shape.test.ts index 0e415804e..e4cb78ffc 100644 --- a/server/typescript/packages/metadata/test/report-shape.test.ts +++ b/server/typescript/packages/metadata/test/report-shape.test.ts @@ -329,6 +329,35 @@ describe("reportShape Table C (FR-044 @spine and measure @default)", () => { expect(required["catalogName"]).toBe(false); // Catalog.name: @required, beyond the spine }); + test("a validator.required child counts as @required for a dimension (spine and plain)", async () => { + // Same model, but Program.title and Purchase.minutes are required by a validator child. + const viaValidator = (e: typeof program | typeof purchase, field: string) => { + const copy = structuredClone(e); + const kids = copy["object.entity"].children as Record>[]; + for (const k of kids) { + for (const body of Object.values(k)) { + if (body["name"] === field) { + delete body["@required"]; + body["children"] = [{ "validator.required": {} }]; + } + } + } + return copy; + }; + const root = await loadInline([ + file("acme", [ + catalog, + viaValidator(program, "title"), + viaValidator(purchase, "minutes"), + spineSales, + plainSales, + catalogSales, + ]), + ]); + expect(requiredOf(root, "SpineSales")["programTitle"]).toBe(true); + expect(requiredOf(root, "PlainSales")["minutes"]).toBe(true); + }); + test("a two-hop spine: a dimension whose @via equals the whole spine is on it", async () => { const required = requiredOf(await salesModel(), "CatalogSales"); expect(required["catalogId"]).toBe(true); From c8c149b515144d24de70d4f92abfd6d05b8db943 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 10 Oct 2026 11:08:37 -0400 Subject: [PATCH 2/2] no-mistakes(document): Updated reporting.md table rows to clarify required-ness includes validator.required child. --- docs/features/reporting.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/features/reporting.md b/docs/features/reporting.md index 9e79df68b..b42019979 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -249,7 +249,7 @@ physical column name is your naming strategy applied to the **derived field name | Item | Column | Type | Never null? | |---|---|---|---| -| `dimension.attribute` | the dimension's name | the `@of` field's type | only when the dimension has no `@via` and the `@of` field declares `@required: true` | +| `dimension.attribute` | the dimension's name | the `@of` field's type | only when the dimension has no `@via` and the `@of` field is required (`@required: true` or a `validator.required` child) | | `dimension.time` at `hour` | `Hour` | `timestamp` | same rule | | `dimension.time` at `day`, `week`, `month`, `quarter`, `year` | `` | `date`, the first day of the bucket | same rule | | `count` (with or without `@distinct`) | the measure's name | `long` | yes: a count is never null | @@ -262,7 +262,7 @@ physical column name is your naming strategy applied to the **derived field name | `min` / `max` | the measure's name | the `@of` field's type | no | | `measure.ratio` | the measure's name | `decimal` | no | | any measure above except `count`, declaring `@default` | the measure's name | as above | yes: it reads its `@default` instead of null | -| in a `@spine` report, a dimension over a column of the spine entity | the dimension's name | the `@of` field's type | yes when the `@of` field is `@required` or one of the spine entity's `identity.primary` `@fields`; no for a dimension reached beyond the spine entity | +| in a `@spine` report, a dimension over a column of the spine entity | the dimension's name | the `@of` field's type | yes when the `@of` field is required (`@required` or a `validator.required` child) or one of the spine entity's `identity.primary` `@fields`; no for a dimension reached beyond the spine entity | The last two rows are the only ways a measure other than `count`, or a dimension reached through `@via`, is never null. The key clause applies only under `@spine`, where the key is