Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/features/reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | `<name>Hour` | `timestamp` | same rule |
| `dimension.time` at `day`, `week`, `month`, `quarter`, `year` | `<name><Grain>` | `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 |
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand Down
8 changes: 4 additions & 4 deletions server/csharp/MetaObjects.Codegen/CSharpNaming.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1068,13 +1068,13 @@ public static string Pluralize(string name)
}

/// <summary>
/// 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.
/// </summary>
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);
}
Expand Down
8 changes: 4 additions & 4 deletions server/csharp/MetaObjects/Core/Reporting/ReportShape.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,19 +157,19 @@ private static bool IsPrimaryKeyField(MetaObject entity, MetaField field)

/// <summary>
/// Table C (<c>required</c> of a dimension). Without <c>@spine</c>: only a dimension with
/// no <c>@via</c> over an <c>@of</c> field whose effective <c>@required</c> is true. With
/// no <c>@via</c> over an <c>@of</c> field that is effectively required (<c>@required</c> or a <c>validator.required</c> child). With
/// <c>@spine</c>: only a dimension whose <c>@via</c> hops equal the spine's (a column of
/// the spine entity itself, whose rows are the report's rows) over an <c>@of</c> field that
/// is <c>@required</c> or a primary-key column of the entity <c>@of</c> names. A dimension
/// is required (same rule) or a primary-key column of the entity <c>@of</c> names. A dimension
/// beyond the spine is reached by a LEFT OUTER join.
/// </summary>
private static bool DimensionRequired(
MetaDimension dim, MetaObject named, MetaField of, MetaObject from, MetaRoot root,
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> 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<String> hops = reportingViaHops(via, memberOwner(dim, from), from, root);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -26,6 +28,7 @@ const NUMBER_VALUE_SUBTYPES = new Set<string>([
FIELD_SUBTYPE_LONG,
FIELD_SUBTYPE_DOUBLE,
FIELD_SUBTYPE_FLOAT,
FIELD_SUBTYPE_CURRENCY,
]);

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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(
Expand Down
29 changes: 29 additions & 0 deletions server/typescript/packages/metadata/test/report-shape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, unknown>>[];
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);
Expand Down
Loading