From b91732793b519a88e3048be025f4470ac7f621c6 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 07:25:55 -0400 Subject: [PATCH 01/32] feat(metadata): reportShape, the derived fields of an object.report (FR-044) --- .../src/core/reporting/report-shape.ts | 145 ++++++++++++++++++ .../typescript/packages/metadata/src/index.ts | 7 + .../metadata/test/report-shape.test.ts | 53 +++++++ 3 files changed, 205 insertions(+) create mode 100644 server/typescript/packages/metadata/src/core/reporting/report-shape.ts create mode 100644 server/typescript/packages/metadata/test/report-shape.test.ts diff --git a/server/typescript/packages/metadata/src/core/reporting/report-shape.ts b/server/typescript/packages/metadata/src/core/reporting/report-shape.ts new file mode 100644 index 000000000..0c5756068 --- /dev/null +++ b/server/typescript/packages/metadata/src/core/reporting/report-shape.ts @@ -0,0 +1,145 @@ +// Table B of docs/superpowers/plans/2026-10-03-fr-044-plan-2-report-view-lowering.md: +// a report's derived fields. The single definition; every port has a rule-for-rule copy, +// gated by fixtures/persistence-conformance/canonical/report-shapes.json. + +import type { MetaData } from "../../shared/meta-data.js"; +import type { MetaRoot } from "../../shared/meta-root.js"; +import { isMetaObject } from "../../shared/node-guards.js"; +import { resolveObjectRef } from "../../naming-refs.js"; +import { CHILD_REF_SEPARATOR, PACKAGE_SEPARATOR } from "../../shared/structural.js"; +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, + FIELD_SUBTYPE_DOUBLE, + FIELD_SUBTYPE_FLOAT, + FIELD_SUBTYPE_INT, + FIELD_SUBTYPE_LONG, + FIELD_SUBTYPE_TIMESTAMP, +} from "../field/field-constants.js"; +import { MetaDimension } from "./meta-dimension.js"; +import { MetaMeasure } from "./meta-measure.js"; +import { reportDerivedFieldName, reportDimensionItems, reportFrom, reportMeasureNames } from "./report-accessors.js"; +import { AGG_AVG, AGG_COUNT, AGG_SUM, GRAIN_HOUR, TYPE_DIMENSION, TYPE_MEASURE, type TimeGrain } from "./reporting-constants.js"; + +export type ReportFieldRole = "dimension" | "measure"; + +export interface ReportField { + readonly name: string; + readonly role: ReportFieldRole; + /** A field subtype name (FIELD_SUBTYPE_*). */ + readonly subType: string; + readonly required: boolean; + /** The `@of` field whose type-shaping attrs this field carries (Table B). */ + readonly typeSource?: MetaField; + readonly dimension?: MetaDimension; + readonly grain?: TimeGrain; + readonly measure?: MetaMeasure; +} + +export interface ReportShape { + readonly report: MetaObject; + readonly from: MetaObject; + readonly fields: readonly ReportField[]; +} + +const SUM_LONG: ReadonlySet = new Set([FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG]); +const FLOATING: ReadonlySet = new Set([FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT]); + +/** Effective package of a node, taken from its resolution key ("::"). */ +function packageOfKey(key: string): string { + const i = key.lastIndexOf(PACKAGE_SEPARATOR); + return i >= 0 ? key.slice(0, i) : ""; +} + +/** Resolve a dimension's or measure's `Entity.field` reference to the field node. */ +export function resolveReportingFieldRef(ref: string, owner: MetaObject, root: MetaRoot): MetaField | undefined { + // `Entity.field`; a package qualifier uses `::`, so the member separator is the LAST dot. + const dot = ref.lastIndexOf(CHILD_REF_SEPARATOR); + if (dot <= 0) return undefined; + const entity = resolveObjectRef(root, ref.slice(0, dot), packageOfKey(owner.resolutionKey())).node; + if (!isMetaObject(entity)) return undefined; + // ADR-0039: resolving, so a field inherited through extends is found. + return entity.fields().find((f) => f.name === ref.slice(dot + 1)); +} + +function unresolved(reportName: string, what: string): Error { + return new Error(`report '${reportName}': ${what} does not resolve.`); +} + +function declaredMember( + from: MetaObject, + type: string, + name: string, + cls: new (...args: never[]) => T, +): T | undefined { + // ADR-0039: resolving children(), so a member declared on an abstract base is found. + return from.children().find((c): c is T => c.type === type && c.name === name && c instanceof cls); +} + +function dimensionField( + item: { name: string; grain?: string }, + from: MetaObject, + root: MetaRoot, + reportName: string, +): ReportField { + const dim = declaredMember(from, TYPE_DIMENSION, item.name, MetaDimension); + if (dim === undefined) throw unresolved(reportName, `dimension '${item.name}' on '${from.name}'`); + const of = resolveReportingFieldRef(dim.of() ?? "", from, root); + if (of === undefined) throw unresolved(reportName, `dimension '${item.name}' @of`); + const name = reportDerivedFieldName(item); + const required = dim.via() === undefined && of.attr(FIELD_ATTR_REQUIRED) === true; + if (dim.isTime()) { + const grain = item.grain as TimeGrain; + if (grain === GRAIN_HOUR) { + return { name, role: "dimension", subType: FIELD_SUBTYPE_TIMESTAMP, required, typeSource: of, dimension: dim, grain }; + } + return { name, role: "dimension", subType: FIELD_SUBTYPE_DATE, required, dimension: dim, grain }; + } + return { name, role: "dimension", subType: of.subType, required, typeSource: of, dimension: dim }; +} + +function measureField(name: string, from: MetaObject, root: MetaRoot, reportName: string): ReportField { + const m = declaredMember(from, TYPE_MEASURE, name, MetaMeasure); + if (m === undefined) throw unresolved(reportName, `measure '${name}' on '${from.name}'`); + if (m.isRatio()) { + return { name, role: "measure", subType: FIELD_SUBTYPE_DECIMAL, required: false, measure: m }; + } + const agg = m.agg(); + if (agg === AGG_COUNT) { + return { name, role: "measure", subType: FIELD_SUBTYPE_LONG, required: true, measure: m }; + } + const of = resolveReportingFieldRef(m.ofColumns()[0] ?? "", from, root); + if (of === undefined) throw unresolved(reportName, `measure '${name}' @of`); + const src = of.subType; + if (agg === AGG_SUM) { + if (src === FIELD_SUBTYPE_CURRENCY) { + return { name, role: "measure", subType: FIELD_SUBTYPE_CURRENCY, required: false, typeSource: of, measure: m }; + } + const subType = SUM_LONG.has(src) ? FIELD_SUBTYPE_LONG : FLOATING.has(src) ? FIELD_SUBTYPE_DOUBLE : FIELD_SUBTYPE_DECIMAL; + return { name, role: "measure", subType, required: false, measure: m }; + } + if (agg === AGG_AVG) { + const subType = FLOATING.has(src) ? FIELD_SUBTYPE_DOUBLE : FIELD_SUBTYPE_DECIMAL; + return { name, role: "measure", subType, required: false, measure: m }; + } + // min / max keep the source field's type. + return { name, role: "measure", subType: src, required: false, typeSource: of, measure: m }; +} + +/** Table B. Throws a plain Error naming the report when a reference does not resolve + * (a report that passed `validateReporting` always resolves). */ +export function reportShape(report: MetaObject, root: MetaRoot): ReportShape { + const fromName = reportFrom(report); + if (fromName === undefined) throw unresolved(report.name, "@from"); + const from = resolveObjectRef(root, fromName, packageOfKey(report.resolutionKey())).node; + if (!isMetaObject(from)) throw unresolved(report.name, `@from '${fromName}'`); + const fields = [ + ...reportDimensionItems(report).map((item) => dimensionField(item, from, root, report.name)), + ...reportMeasureNames(report).map((name) => measureField(name, from, root, report.name)), + ]; + return { report, from, fields }; +} diff --git a/server/typescript/packages/metadata/src/index.ts b/server/typescript/packages/metadata/src/index.ts index bd01d7b6c..d7c5d77d3 100644 --- a/server/typescript/packages/metadata/src/index.ts +++ b/server/typescript/packages/metadata/src/index.ts @@ -53,6 +53,13 @@ export { reportDerivedFieldName, type ReportDimensionItem, } from "./core/reporting/report-accessors.js"; +export { + reportShape, + resolveReportingFieldRef, + type ReportField, + type ReportFieldRole, + type ReportShape, +} from "./core/reporting/report-shape.js"; // Shared `@implementedBy` resolution — one resolver for the CLI's requirement // checks and codegen's requirement-test fan-out (FR-038). export { diff --git a/server/typescript/packages/metadata/test/report-shape.test.ts b/server/typescript/packages/metadata/test/report-shape.test.ts new file mode 100644 index 000000000..5e451b656 --- /dev/null +++ b/server/typescript/packages/metadata/test/report-shape.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { loadUris, reportShape, type MetaObject, type MetaRoot } from "../src/index.js"; + +const REPO_ROOT = resolve(import.meta.dir, "..", "..", "..", "..", ".."); +const MODEL = join(REPO_ROOT, "fixtures", "conformance", "reporting-vocabulary", "input", "meta.shop.json"); + +async function load(): Promise { + const result = await loadUris([pathToFileURL(MODEL).href]); + expect(result.errors).toEqual([]); + return result.root; +} +const report = (root: MetaRoot, name: string): MetaObject => { + const found = root.objects().find((o) => o.name === name); + if (found === undefined) throw new Error(`no object ${name}`); + return found; +}; +const brief = (root: MetaRoot, name: string) => + reportShape(report(root, name), root).fields.map((f) => [f.name, f.role, f.subType, f.required]); + +describe("reportShape (FR-044 Table B)", () => { + test("dimensions come first, in listed order, then measures", async () => { + const root = await load(); + expect(brief(root, "ProgramEngagement")).toEqual([ + ["program", "dimension", "long", false], + ["starters", "measure", "long", true], + ["daysEngaged", "measure", "long", true], + ["avgDaysPerStarter", "measure", "decimal", false], + ["lastActivityAt", "measure", "timestamp", false], + ]); + }); + + test("a time dimension derives typed date", async () => { + const root = await load(); + expect(brief(root, "DailyRevenue")).toEqual([ + ["purchasedAtDay", "dimension", "date", false], + ["purchases", "measure", "long", true], + ["revenue", "measure", "currency", false], + ]); + }); + + test("sum of a currency keeps the currency field as its type source", async () => { + const root = await load(); + const revenue = reportShape(report(root, "DailyRevenue"), root).fields.find((f) => f.name === "revenue"); + expect(revenue?.typeSource?.name).toBe("amountCents"); + }); + + test("no dimensions yields measures only", async () => { + const root = await load(); + expect(brief(root, "StoreTotals").map((f) => f[1])).toEqual(["measure", "measure", "measure"]); + }); +}); From e8be681dabb54ca4361953bf4ee563d99197d83c Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 07:27:33 -0400 Subject: [PATCH 02/32] feat(codegen-ts): time-grain and relative-date SQL per dialect (FR-044) --- .../codegen-ts/src/projection/time-sql.ts | 168 ++++++++++++++++++ .../test/projection/time-sql.test.ts | 133 ++++++++++++++ 2 files changed, 301 insertions(+) create mode 100644 server/typescript/packages/codegen-ts/src/projection/time-sql.ts create mode 100644 server/typescript/packages/codegen-ts/test/projection/time-sql.test.ts diff --git a/server/typescript/packages/codegen-ts/src/projection/time-sql.ts b/server/typescript/packages/codegen-ts/src/projection/time-sql.ts new file mode 100644 index 000000000..726575cd4 --- /dev/null +++ b/server/typescript/packages/codegen-ts/src/projection/time-sql.ts @@ -0,0 +1,168 @@ +// Time-grain truncation (contract Table D) and relative-date values (Table E), +// rendered as SQL for the three view dialects. Pure string functions: the report +// DDL emitter supplies an already-quoted column reference and picks the dialect. +import { + GRAIN_DAY, GRAIN_HOUR, GRAIN_MONTH, GRAIN_QUARTER, GRAIN_WEEK, GRAIN_YEAR, + ISO_DURATION_RE, + type TimeGrain, +} from "@metaobjectsdev/metadata"; + +export type ReportDialect = "postgres" | "sqlite" | "mysql"; +/** Table D's three column kinds: `instant` is a `field.timestamp` (TIMESTAMPTZ), + * `naive` one with `@localTime: true`, `date` a `field.date`. */ +export type ReportTemporal = "date" | "instant" | "naive"; + +export interface IsoDurationParts { + readonly sign: "+" | "-"; + readonly years: number; + readonly months: number; + readonly weeks: number; + readonly days: number; + readonly hours: number; + readonly minutes: number; + readonly seconds: number; + /** The duration text without its sign, e.g. "P7D". */ + readonly magnitude: string; +} + +/** Leading integer of a capture such as `"12H"`; an absent group is 0. */ +function component(group: string | undefined): number { + return group === undefined ? 0 : Number.parseInt(group, 10); +} + +/** Parse a signed ISO-8601 duration. Capture groups of `ISO_DURATION_RE`, in order: + * 1 `nY`, 2 `nM` (months), 3 `nW`, 4 `nD`, 5 the whole `T…` block, 6 `nH`, 7 `nM` + * (minutes), 8 `nS`. */ +export function parseIsoDuration(duration: string): IsoDurationParts { + const m = ISO_DURATION_RE.exec(duration); + if (m === null) throw new Error(`time-sql: "${duration}" is not an ISO-8601 duration.`); + return { + sign: duration.startsWith("-") ? "-" : "+", + years: component(m[1]), + months: component(m[2]), + weeks: component(m[3]), + days: component(m[4]), + hours: component(m[6]), + minutes: component(m[7]), + seconds: component(m[8]), + magnitude: duration.replace(/^[+-]/, ""), + }; +} + +/** Table D. `ref` is an already-quoted `alias."column"` reference. */ +export function truncateToGrain( + ref: string, + grain: TimeGrain, + temporal: ReportTemporal, + dialect: ReportDialect, +): string { + if (grain === GRAIN_HOUR && temporal === "date") { + // Rule D4 forbids this at load; a programmatic caller skips the loader. + throw new Error(`time-sql: the "hour" grain cannot truncate a date column (${ref}).`); + } + switch (dialect) { + case "postgres": + return truncatePostgres(ref, grain, temporal); + case "sqlite": + return truncateSqlite(ref, grain, temporal); + case "mysql": + return truncateMysql(ref, grain); + } +} + +function truncatePostgres(x: string, grain: TimeGrain, temporal: ReportTemporal): string { + switch (temporal) { + case "instant": + return grain === GRAIN_HOUR + ? `date_trunc('hour', ${x}, 'UTC')` + : `CAST(date_trunc('${grain}', ${x} AT TIME ZONE 'UTC') AS DATE)`; + case "naive": + return grain === GRAIN_HOUR + ? `date_trunc('hour', ${x})` + : `CAST(date_trunc('${grain}', ${x}) AS DATE)`; + case "date": + // date_trunc(text, date) resolves to the timestamptz overload and truncates in + // the session zone, so the date is cast to TIMESTAMP first. + return grain === GRAIN_DAY + ? x + : `CAST(date_trunc('${grain}', CAST(${x} AS TIMESTAMP)) AS DATE)`; + } +} + +function truncateSqlite(x: string, grain: TimeGrain, temporal: ReportTemporal): string { + switch (grain) { + case GRAIN_HOUR: + return temporal === "instant" + ? `strftime('%Y-%m-%dT%H:00:00.000Z', ${x})` + : `strftime('%Y-%m-%dT%H:00:00', ${x})`; + case GRAIN_DAY: + return `date(${x})`; + case GRAIN_WEEK: + return `date(${x}, 'weekday 0', '-6 days')`; + case GRAIN_MONTH: + return `date(${x}, 'start of month')`; + case GRAIN_QUARTER: + return `date(${x}, 'start of month', '-' || ((CAST(strftime('%m', ${x}) AS INTEGER) - 1) % 3) || ' months')`; + case GRAIN_YEAR: + return `date(${x}, 'start of year')`; + } +} + +function truncateMysql(x: string, grain: TimeGrain): string { + switch (grain) { + case GRAIN_HOUR: + return `CAST(DATE_FORMAT(${x}, '%Y-%m-%d %H:00:00') AS DATETIME(3))`; + case GRAIN_DAY: + return `DATE(${x})`; + case GRAIN_WEEK: + return `DATE(DATE_SUB(${x}, INTERVAL WEEKDAY(${x}) DAY))`; + case GRAIN_MONTH: + return `DATE(DATE_FORMAT(${x}, '%Y-%m-01'))`; + case GRAIN_QUARTER: + return `MAKEDATE(YEAR(${x}), 1) + INTERVAL (QUARTER(${x}) - 1) QUARTER`; + case GRAIN_YEAR: + return `MAKEDATE(YEAR(${x}), 1)`; + } +} + +/** Table E. `{ now: "" }` as SQL, evaluated when the view is queried. */ +export function relativeNowSql( + duration: string, + temporal: ReportTemporal, + dialect: ReportDialect, +): string { + const d = parseIsoDuration(duration); + switch (dialect) { + case "postgres": { + // ISO-8601 interval input is accepted as written. + const interval = `INTERVAL '${d.magnitude}'`; + if (temporal === "instant") return `(now() ${d.sign} ${interval})`; + const utcWall = `((now() AT TIME ZONE 'UTC') ${d.sign} ${interval})`; + return temporal === "naive" ? utcWall : `CAST(${utcWall} AS DATE)`; + } + case "sqlite": { + // One modifier per non-zero component, Y M W D H M S order; a week is 7 days. + const mods = [ + [d.years, "years"], [d.months, "months"], [d.weeks * 7, "days"], [d.days, "days"], + [d.hours, "hours"], [d.minutes, "minutes"], [d.seconds, "seconds"], + ] + .filter(([n]) => (n as number) !== 0) + .map(([n, unit]) => `'${d.sign}${n} ${unit}'`); + const args = ["'now'", ...mods].join(", "); + if (temporal === "date") return `date(${args})`; + const fmt = temporal === "instant" ? "%Y-%m-%dT%H:%M:%fZ" : "%Y-%m-%dT%H:%M:%f"; + return `strftime('${fmt}', ${args})`; + } + case "mysql": { + const intervals = [ + [d.years, "YEAR"], [d.months, "MONTH"], [d.weeks, "WEEK"], [d.days, "DAY"], + [d.hours, "HOUR"], [d.minutes, "MINUTE"], [d.seconds, "SECOND"], + ] + .filter(([n]) => (n as number) !== 0) + .map(([n, unit]) => ` ${d.sign} INTERVAL ${n} ${unit}`) + .join(""); + const now = `UTC_TIMESTAMP(3)${intervals}`; + return temporal === "date" ? `DATE(${now})` : `(${now})`; + } + } +} diff --git a/server/typescript/packages/codegen-ts/test/projection/time-sql.test.ts b/server/typescript/packages/codegen-ts/test/projection/time-sql.test.ts new file mode 100644 index 000000000..6d57f900a --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/projection/time-sql.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test"; +import { parseIsoDuration, relativeNowSql, truncateToGrain } from "../../src/projection/time-sql.js"; + +const X = `p."created_ts"`; + +describe("truncateToGrain (Table D)", () => { + test.each([ + ["hour", "instant", `date_trunc('hour', ${X}, 'UTC')`], + ["day", "instant", `CAST(date_trunc('day', ${X} AT TIME ZONE 'UTC') AS DATE)`], + ["week", "instant", `CAST(date_trunc('week', ${X} AT TIME ZONE 'UTC') AS DATE)`], + ["month", "instant", `CAST(date_trunc('month', ${X} AT TIME ZONE 'UTC') AS DATE)`], + ["quarter", "instant", `CAST(date_trunc('quarter', ${X} AT TIME ZONE 'UTC') AS DATE)`], + ["year", "instant", `CAST(date_trunc('year', ${X} AT TIME ZONE 'UTC') AS DATE)`], + ["hour", "naive", `date_trunc('hour', ${X})`], + ["day", "naive", `CAST(date_trunc('day', ${X}) AS DATE)`], + ["week", "naive", `CAST(date_trunc('week', ${X}) AS DATE)`], + ["month", "naive", `CAST(date_trunc('month', ${X}) AS DATE)`], + ["quarter", "naive", `CAST(date_trunc('quarter', ${X}) AS DATE)`], + ["year", "naive", `CAST(date_trunc('year', ${X}) AS DATE)`], + ["day", "date", X], + ["week", "date", `CAST(date_trunc('week', CAST(${X} AS TIMESTAMP)) AS DATE)`], + ["month", "date", `CAST(date_trunc('month', CAST(${X} AS TIMESTAMP)) AS DATE)`], + ["quarter", "date", `CAST(date_trunc('quarter', CAST(${X} AS TIMESTAMP)) AS DATE)`], + ["year", "date", `CAST(date_trunc('year', CAST(${X} AS TIMESTAMP)) AS DATE)`], + ] as const)("postgres %s on %s", (grain, temporal, sql) => { + expect(truncateToGrain(X, grain, temporal, "postgres")).toBe(sql); + }); + + const SQLITE_QUARTER = `date(${X}, 'start of month', '-' || ((CAST(strftime('%m', ${X}) AS INTEGER) - 1) % 3) || ' months')`; + test.each([ + ["hour", "instant", `strftime('%Y-%m-%dT%H:00:00.000Z', ${X})`], + ["hour", "naive", `strftime('%Y-%m-%dT%H:00:00', ${X})`], + ...(["instant", "naive", "date"] as const).flatMap((t) => [ + ["day", t, `date(${X})`], + ["week", t, `date(${X}, 'weekday 0', '-6 days')`], + ["month", t, `date(${X}, 'start of month')`], + ["quarter", t, SQLITE_QUARTER], + ["year", t, `date(${X}, 'start of year')`], + ] as const), + ] as const)("sqlite %s on %s", (grain, temporal, sql) => { + expect(truncateToGrain(X, grain, temporal, "sqlite")).toBe(sql); + }); + + const MYSQL = [ + ["hour", `CAST(DATE_FORMAT(${X}, '%Y-%m-%d %H:00:00') AS DATETIME(3))`], + ["day", `DATE(${X})`], + ["week", `DATE(DATE_SUB(${X}, INTERVAL WEEKDAY(${X}) DAY))`], + ["month", `DATE(DATE_FORMAT(${X}, '%Y-%m-01'))`], + ["quarter", `MAKEDATE(YEAR(${X}), 1) + INTERVAL (QUARTER(${X}) - 1) QUARTER`], + ["year", `MAKEDATE(YEAR(${X}), 1)`], + ] as const; + test.each(MYSQL)("mysql %s", (grain, sql) => { + expect(truncateToGrain(X, grain, "naive", "mysql")).toBe(sql); + }); + test.each(MYSQL.filter(([g]) => g !== "hour"))("mysql %s is the same for every column kind", (grain, sql) => { + for (const temporal of ["instant", "date"] as const) { + expect(truncateToGrain(X, grain, temporal, "mysql")).toBe(sql); + } + }); + test("mysql hour is the same on an instant", () => { + expect(truncateToGrain(X, "hour", "instant", "mysql")).toBe(MYSQL[0][1]); + }); + + test.each(["postgres", "sqlite", "mysql"] as const)("hour on a date column throws (%s)", (dialect) => { + expect(() => truncateToGrain(X, "hour", "date", dialect)).toThrow(/hour/); + }); +}); + +describe("relativeNowSql (Table E)", () => { + test.each([ + ["-P7D", "instant", `(now() - INTERVAL 'P7D')`], + ["+P7D", "instant", `(now() + INTERVAL 'P7D')`], + ["P7D", "instant", `(now() + INTERVAL 'P7D')`], + ["-P1Y2M3WT4H5M6S", "instant", `(now() - INTERVAL 'P1Y2M3WT4H5M6S')`], + ["PT12H", "naive", `((now() AT TIME ZONE 'UTC') + INTERVAL 'PT12H')`], + ["-P2W", "naive", `((now() AT TIME ZONE 'UTC') - INTERVAL 'P2W')`], + ["-P1Y", "date", `CAST(((now() AT TIME ZONE 'UTC') - INTERVAL 'P1Y') AS DATE)`], + ["+P1D", "date", `CAST(((now() AT TIME ZONE 'UTC') + INTERVAL 'P1D') AS DATE)`], + ] as const)("postgres %s on %s", (duration, temporal, sql) => { + expect(relativeNowSql(duration, temporal, "postgres")).toBe(sql); + }); + + test.each([ + ["-P7D", "instant", `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days')`], + ["P1D", "instant", `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '+1 days')`], + ["-P1Y2M3WT4H", "naive", `strftime('%Y-%m-%dT%H:%M:%f', 'now', '-1 years', '-2 months', '-21 days', '-4 hours')`], + ["PT5M6S", "naive", `strftime('%Y-%m-%dT%H:%M:%f', 'now', '+5 minutes', '+6 seconds')`], + ["-P1W2D", "instant", `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days', '-2 days')`], + ["+P1D", "date", `date('now', '+1 days')`], + ["-P1Y2M3DT4H5M6S", "date", `date('now', '-1 years', '-2 months', '-3 days', '-4 hours', '-5 minutes', '-6 seconds')`], + ["P0D", "date", `date('now')`], + ] as const)("sqlite %s on %s", (duration, temporal, sql) => { + expect(relativeNowSql(duration, temporal, "sqlite")).toBe(sql); + }); + + test.each([ + ["-P30D", "instant", `(UTC_TIMESTAMP(3) - INTERVAL 30 DAY)`], + ["-P1Y2W", "naive", `(UTC_TIMESTAMP(3) - INTERVAL 1 YEAR - INTERVAL 2 WEEK)`], + ["-P1Y2M3W4DT5H6M7S", "instant", + `(UTC_TIMESTAMP(3) - INTERVAL 1 YEAR - INTERVAL 2 MONTH - INTERVAL 3 WEEK - INTERVAL 4 DAY - INTERVAL 5 HOUR - INTERVAL 6 MINUTE - INTERVAL 7 SECOND)`], + ["PT12H", "naive", `(UTC_TIMESTAMP(3) + INTERVAL 12 HOUR)`], + ["-P7D", "date", `DATE(UTC_TIMESTAMP(3) - INTERVAL 7 DAY)`], + ["P0D", "instant", `(UTC_TIMESTAMP(3))`], + ] as const)("mysql %s on %s", (duration, temporal, sql) => { + expect(relativeNowSql(duration, temporal, "mysql")).toBe(sql); + }); + + test("a malformed duration throws, naming it", () => { + expect(() => parseIsoDuration("P")).toThrow(/P/); + expect(() => relativeNowSql("7 days", "instant", "postgres")).toThrow(/7 days/); + }); +}); + +describe("parseIsoDuration", () => { + test("splits every component and the sign", () => { + expect(parseIsoDuration("-P1Y2M3W4DT5H6M7S")).toEqual({ + sign: "-", years: 1, months: 2, weeks: 3, days: 4, hours: 5, minutes: 6, seconds: 7, + magnitude: "P1Y2M3W4DT5H6M7S", + }); + }); + test("an unsigned duration is positive and absent components are zero", () => { + expect(parseIsoDuration("PT90M")).toEqual({ + sign: "+", years: 0, months: 0, weeks: 0, days: 0, hours: 0, minutes: 90, seconds: 0, + magnitude: "PT90M", + }); + expect(parseIsoDuration("+P7D").sign).toBe("+"); + expect(parseIsoDuration("+P7D").magnitude).toBe("P7D"); + }); + test("a month is not a minute", () => { + const p = parseIsoDuration("P3MT4M"); + expect([p.months, p.minutes]).toEqual([3, 4]); + }); +}); From c377bbead80a8e95d07aace7c1c5d77519c0959c Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 07:28:42 -0400 Subject: [PATCH 03/32] refactor(codegen-ts): extract walkViaPath from buildJoinTree --- .../src/projection/extract-view-spec.ts | 334 +++++++++--------- 1 file changed, 174 insertions(+), 160 deletions(-) diff --git a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts index 70868f2da..fd33af220 100644 --- a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts +++ b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts @@ -105,7 +105,7 @@ const EXPR_COMPARISON_OPS: ReadonlySet = new Set([ * would lower as op `now` with value `"x"`, and `assertNoRelativeDate` — which inspects the * VALUE — would never see it. */ -function desugarClause(raw: unknown): Record { +export function desugarClause(raw: unknown): Record { if (raw === null) return { [FILTER_OP_IS_NULL]: true }; if (Array.isArray(raw)) return { [FILTER_OP_IN]: raw }; if (typeof raw === "object") { @@ -211,7 +211,7 @@ function intEnumMapsOf(projection: MetaObject): ReadonlyMap | undefined, @@ -539,7 +539,7 @@ export function refNamedOwner(node: MetaData, root: MetaRoot): MetaObject | unde } /** Effective package of an object, taken from its resolution key ("::"). */ -function packageOf(obj: MetaData): string { +export function packageOf(obj: MetaData): string { const key = obj.resolutionKey(); const i = key.lastIndexOf("::"); return i >= 0 ? key.slice(0, i) : ""; @@ -597,7 +597,7 @@ function baseEntityFor( ); } -function sourceColumnNameFor( +export function sourceColumnNameFor( entityField: MetaData, ctx: ExtractContext, ): string { @@ -777,7 +777,7 @@ function resolveExprNode( return undefined; } -function shortAliasFor(entityName: string, used: Set): string { +export function shortAliasFor(entityName: string, used: Set): string { // Derive from the SHORT name — an entity ref may now be a resolutionKey ("pkg::Name", // #244); the alias must stay the first letter of the entity, so existing single-package // view SQL is byte-identical (a changed alias would churn `verify --db` fingerprints). @@ -796,7 +796,7 @@ function shortAliasFor(entityName: string, used: Set): string { // prefix into a trie, then converts to JoinNode tree. // --------------------------------------------------------------------------- -interface PathStep { +export interface PathStep { entity: MetaData; relationship: string; cardinality: "one" | "many"; @@ -809,13 +809,178 @@ interface PathStep { targetEntity: string; } -type Path = PathStep[]; +export type Path = PathStep[]; interface TrieNode { children: Map; step?: PathStep; } +/** Walk one dotted `@via` (`Owner.hop[.hop…]`) into join steps. Returns [] when the + * head or any hop does not resolve. Throws on an ambiguous hop (#368). A report dimension + * joins through this same walk as a projection origin, so hop resolution, the ambiguity + * errors and the #209 join type are one implementation. */ +export function walkViaPath(via: string, root: MetaRoot, referrerPkg: string, ctx: ExtractContext): Path { + const segments = via.split("."); + const rawEntity = segments[0]; + const relSegments = segments.slice(1); + if (!rawEntity) return []; + // @via may be package-qualified ("pkg::Entity.rel"). Resolve package-aware and key + // the joinTree on resolutionKey() (FQN) so a same-bare-named entity in another + // package can't win — the passthrough @from lookups key on the same FQN (#244). + let currentObj = resolveEntityRef(root, rawEntity, referrerPkg); + if (!currentObj) return []; + + const path: Path = []; + for (const relName of relSegments) { + // FR-024: a hop may name a relationship OR a reference-only FK + // (identity.reference — a to-one forward-FK edge). ADR-0039: resolving — + // a traversed relationship/reference may inherit its target via extends. + const resolved = resolveHop(currentObj, relName); + if (!resolved) break; + const { hop, targetName, cardinality } = resolved; + // @objectRef/@references may be package-qualified ("pkg::Entity"); resolve it + // package-aware relative to the hop's source entity (the loader qualifies a + // same-package ref even when authored bare), so the join binds the exact target. + const target = resolveEntityRef(root, targetName, packageOf(currentObj)); + if (!target) break; + + // #368: two identity.reference declarations onto the same target are legal + // (e.g. Match.homeTeamRef/awayTeamRef -> Team) — resolveHopReference prefers + // the SPECIFIC reference/relationship the hop already named over re-deriving + // one from the target alone, so an explicit `@via: "Match.homeTeamRef"` (or a + // relationship disambiguated by @sourceRefField/name-pairing) resolves cleanly. + // Only a relationship hop that even the ladder cannot choose reaches the throw. + const resolvedRef = resolveHopReference(currentObj as MetaObject, hop, relName, target); + let ref: ReferenceLookup | undefined; + if (Array.isArray(resolvedRef)) { + if (resolvedRef.length > 1) { + // #368 round 2: @sourceRefField cannot fix this, but WHY differs by shape, and + // asserting the wrong reason for a given shape is itself a bug (fix round 1 of + // this cleanup caught exactly that). resolveRelationshipReference's ladder reads + // ONLY the hop's own entity's candidates (referenceCandidatesFor(currentObj, ...)); + // it never even looks at `target`'s references. So: + // - If `currentObj` itself holds one of the ambiguous candidates, resolution + // already tried @sourceRefField/name-pairing against it and failed — and that + // is only reachable at all when @cardinality isn't "one": a @cardinality "one" + // relationship with 2+ own-side candidates is rejected at LOAD by rule (e) + // (validateOneSideReferenceResolution) using this exact same ladder, so if we + // got this far with an own-side candidate, @cardinality is provably not "one", + // and @sourceRefField is provably illegal here (rule (d)). + // - If NONE of the candidates are `currentObj`'s own, @sourceRefField could not + // have mattered regardless of @cardinality — it only ever consults the hop's + // OWN identity.reference children, and it has none targeting `target`. This is + // rule (e)'s zero-candidate gap (validation-passes.ts:2226, `<= 1` skips 0 too): + // a @cardinality "one" relationship can reach here with the FK entirely on the + // far side, so @cardinality itself must NOT be asserted in this branch. + const holderName = (currentObj as MetaObject).name; + const holderOwnsACandidate = resolvedRef.some((r) => r.holder.name === holderName); + const whySourceRefFieldCannotHelp = holderOwnsACandidate + ? `it only disambiguates a @cardinality "${CARDINALITY_ONE}" relationship, and this ` + + `relationship's @cardinality is not "${CARDINALITY_ONE}" (declaring @sourceRefField on it ` + + `is itself a load error)` + : `it only consults "${holderName}"'s own identity.reference children, and "${holderName}" ` + + `declares none targeting "${target.name}" -- every candidate above belongs to the other side ` + + `of this join`; + throw new Error( + `projection join hop "${relName}" from "${holderName}" to "${target.name}" is ambiguous: ` + + `${resolvedRef.map((r) => r.referenceIdentity.name).join(", ")}. ` + + `@sourceRefField cannot resolve this: ${whySourceRefFieldCannotHelp}. There is no attribute ` + + `that disambiguates a hop like this -- remove the extra identity.reference between these two ` + + `entities, or restructure the model so only one remains.`, + ); + } + ref = resolvedRef[0]; + } else { + ref = resolvedRef; + } + if (!ref) break; + + const fkField = ref.referenceIdentity.fields[0]; + if (!fkField) break; + + const resolvedPkField = ref.referenceIdentity.resolvedTargetPkField(root) ?? "id"; + + const referenceHolder: "source" | "target" = + ref.holder.name === currentObj.name ? "source" : "target"; + + // FK lives on the holder; PK on the entity it references. Resolve both to + // physical columns now so the ON clause is naming-strategy correct. + const fkHolder = referenceHolder === "source" ? currentObj : target; + const pkHolder = referenceHolder === "source" ? target : currentObj; + + // #209 — a belongs-to hop (FK on the parent) whose FK is NOT NULL is + // semantically INNER: every base row has a match, so INNER and LEFT OUTER + // return the same set, and INNER matches the hand-written view it stands in + // for (and keeps `verify --db` fingerprints aligned). A nullable belongs-to + // FK, or ANY has-many hop (FK on the child — a base row may have zero + // children), stays LEFT OUTER so no base row is dropped. + // `@enforce: false` does NOT change this. An unenforced NOT NULL reference can name a + // row that does not exist, so INNER filters that base row out — and that filter is + // what the hand-written view did: a legacy account view joins `ref_id` INNER to the + // user table precisely to exclude the accounts whose `ref_id` holds a group id. Making + // the hop LEFT OUTER (tried, then reverted before release) silently changed which rows + // such views return. To keep unmatched rows, make the FK field nullable. + const fkFieldObj = (fkHolder as MetaObject).findField(fkField); + const selfInner = + referenceHolder === "source" && fkFieldObj !== undefined && isRequired(fkFieldObj); + // Nested-chain safety: joins render flat + left-associative, so an INNER hop + // BELOW any LEFT ancestor drops the base row (its ON references a column the + // LEFT ancestor NULLed). An INNER only survives when the ENTIRE ancestor chain + // is INNER; otherwise demote to LEFT (lossless — under a LEFT ancestor, LEFT is + // the correct type). `path` holds this chain's ancestor hops accumulated so far. + const joinType: "inner" | "left" = + selfInner && path.every((prior) => prior.joinType === "inner") ? "inner" : "left"; + + path.push({ + entity: currentObj, + relationship: relName, + cardinality, + fkColumn: joinColumnFor(fkHolder, fkField, ctx), + pkColumn: joinColumnFor(pkHolder, resolvedPkField, ctx), + referenceHolder, + joinType, + targetEntity: target.resolutionKey(), + }); + currentObj = target; + } + return path; +} + +/** Prefix-dedupe paths into JoinNodes, assigning aliases. */ +export function pathsToJoins(paths: readonly Path[], usedAliases: Set): JoinNode[] { + // Dedupe by prefix: paths sharing a prefix collapse into one join branch. + const trieRoot: TrieNode = { children: new Map() }; + for (const path of paths) { + let node = trieRoot; + for (const step of path) { + let child = node.children.get(step.relationship); + if (!child) { + child = { children: new Map(), step }; + node.children.set(step.relationship, child); + } + node = child; + } + } + + function toJoinNode(node: TrieNode): JoinNode { + const step = node.step!; + return { + relationship: step.relationship, + targetEntity: step.targetEntity, + alias: shortAliasFor(step.targetEntity, usedAliases), + cardinality: step.cardinality, + fkColumn: step.fkColumn, + pkColumn: step.pkColumn, + referenceHolder: step.referenceHolder, + joinType: step.joinType, + children: Array.from(node.children.values()).map(toJoinNode), + }; + } + + return Array.from(trieRoot.children.values()).map(toJoinNode); +} + function buildJoinTree( projection: MetaObject, base: MetaObject, @@ -853,166 +1018,15 @@ function buildJoinTree( } if (!viaAttr) continue; - const segments = viaAttr.split("."); - const rawEntity = segments[0]; - const relSegments = segments.slice(1); - if (!rawEntity) continue; - // @via may be package-qualified ("pkg::Entity.rel"). Resolve package-aware and key - // the joinTree on resolutionKey() (FQN) so a same-bare-named entity in another - // package can't win — the passthrough @from lookups key on the same FQN (#244). - let currentObj = resolveEntityRef(root, rawEntity, projPkg); - if (!currentObj) continue; - - const path: Path = []; - for (const relName of relSegments) { - // FR-024: a hop may name a relationship OR a reference-only FK - // (identity.reference — a to-one forward-FK edge). ADR-0039: resolving — - // a traversed relationship/reference may inherit its target via extends. - const resolved = resolveHop(currentObj, relName); - if (!resolved) break; - const { hop, targetName, cardinality } = resolved; - // @objectRef/@references may be package-qualified ("pkg::Entity"); resolve it - // package-aware relative to the hop's source entity (the loader qualifies a - // same-package ref even when authored bare), so the join binds the exact target. - const target = resolveEntityRef(root, targetName, packageOf(currentObj)); - if (!target) break; - - // #368: two identity.reference declarations onto the same target are legal - // (e.g. Match.homeTeamRef/awayTeamRef -> Team) — resolveHopReference prefers - // the SPECIFIC reference/relationship the hop already named over re-deriving - // one from the target alone, so an explicit `@via: "Match.homeTeamRef"` (or a - // relationship disambiguated by @sourceRefField/name-pairing) resolves cleanly. - // Only a relationship hop that even the ladder cannot choose reaches the throw. - const resolvedRef = resolveHopReference(currentObj as MetaObject, hop, relName, target); - let ref: ReferenceLookup | undefined; - if (Array.isArray(resolvedRef)) { - if (resolvedRef.length > 1) { - // #368 round 2: @sourceRefField cannot fix this, but WHY differs by shape, and - // asserting the wrong reason for a given shape is itself a bug (fix round 1 of - // this cleanup caught exactly that). resolveRelationshipReference's ladder reads - // ONLY the hop's own entity's candidates (referenceCandidatesFor(currentObj, ...)); - // it never even looks at `target`'s references. So: - // - If `currentObj` itself holds one of the ambiguous candidates, resolution - // already tried @sourceRefField/name-pairing against it and failed — and that - // is only reachable at all when @cardinality isn't "one": a @cardinality "one" - // relationship with 2+ own-side candidates is rejected at LOAD by rule (e) - // (validateOneSideReferenceResolution) using this exact same ladder, so if we - // got this far with an own-side candidate, @cardinality is provably not "one", - // and @sourceRefField is provably illegal here (rule (d)). - // - If NONE of the candidates are `currentObj`'s own, @sourceRefField could not - // have mattered regardless of @cardinality — it only ever consults the hop's - // OWN identity.reference children, and it has none targeting `target`. This is - // rule (e)'s zero-candidate gap (validation-passes.ts:2226, `<= 1` skips 0 too): - // a @cardinality "one" relationship can reach here with the FK entirely on the - // far side, so @cardinality itself must NOT be asserted in this branch. - const holderName = (currentObj as MetaObject).name; - const holderOwnsACandidate = resolvedRef.some((r) => r.holder.name === holderName); - const whySourceRefFieldCannotHelp = holderOwnsACandidate - ? `it only disambiguates a @cardinality "${CARDINALITY_ONE}" relationship, and this ` + - `relationship's @cardinality is not "${CARDINALITY_ONE}" (declaring @sourceRefField on it ` + - `is itself a load error)` - : `it only consults "${holderName}"'s own identity.reference children, and "${holderName}" ` + - `declares none targeting "${target.name}" -- every candidate above belongs to the other side ` + - `of this join`; - throw new Error( - `projection join hop "${relName}" from "${holderName}" to "${target.name}" is ambiguous: ` + - `${resolvedRef.map((r) => r.referenceIdentity.name).join(", ")}. ` + - `@sourceRefField cannot resolve this: ${whySourceRefFieldCannotHelp}. There is no attribute ` + - `that disambiguates a hop like this -- remove the extra identity.reference between these two ` + - `entities, or restructure the model so only one remains.`, - ); - } - ref = resolvedRef[0]; - } else { - ref = resolvedRef; - } - if (!ref) break; - - const fkField = ref.referenceIdentity.fields[0]; - if (!fkField) break; - - const resolvedPkField = ref.referenceIdentity.resolvedTargetPkField(root) ?? "id"; - - const referenceHolder: "source" | "target" = - ref.holder.name === currentObj.name ? "source" : "target"; - - // FK lives on the holder; PK on the entity it references. Resolve both to - // physical columns now so the ON clause is naming-strategy correct. - const fkHolder = referenceHolder === "source" ? currentObj : target; - const pkHolder = referenceHolder === "source" ? target : currentObj; - - // #209 — a belongs-to hop (FK on the parent) whose FK is NOT NULL is - // semantically INNER: every base row has a match, so INNER and LEFT OUTER - // return the same set, and INNER matches the hand-written view it stands in - // for (and keeps `verify --db` fingerprints aligned). A nullable belongs-to - // FK, or ANY has-many hop (FK on the child — a base row may have zero - // children), stays LEFT OUTER so no base row is dropped. - // `@enforce: false` does NOT change this. An unenforced NOT NULL reference can name a - // row that does not exist, so INNER filters that base row out — and that filter is - // what the hand-written view did: a legacy account view joins `ref_id` INNER to the - // user table precisely to exclude the accounts whose `ref_id` holds a group id. Making - // the hop LEFT OUTER (tried, then reverted before release) silently changed which rows - // such views return. To keep unmatched rows, make the FK field nullable. - const fkFieldObj = (fkHolder as MetaObject).findField(fkField); - const selfInner = - referenceHolder === "source" && fkFieldObj !== undefined && isRequired(fkFieldObj); - // Nested-chain safety: joins render flat + left-associative, so an INNER hop - // BELOW any LEFT ancestor drops the base row (its ON references a column the - // LEFT ancestor NULLed). An INNER only survives when the ENTIRE ancestor chain - // is INNER; otherwise demote to LEFT (lossless — under a LEFT ancestor, LEFT is - // the correct type). `path` holds this chain's ancestor hops accumulated so far. - const joinType: "inner" | "left" = - selfInner && path.every((prior) => prior.joinType === "inner") ? "inner" : "left"; - - path.push({ - entity: currentObj, - relationship: relName, - cardinality, - fkColumn: joinColumnFor(fkHolder, fkField, ctx), - pkColumn: joinColumnFor(pkHolder, resolvedPkField, ctx), - referenceHolder, - joinType, - targetEntity: target.resolutionKey(), - }); - currentObj = target; - } + const path = walkViaPath(viaAttr, root, projPkg, ctx); if (path.length > 0) allPaths.push(path); } } - // Dedupe by prefix: paths sharing a prefix collapse into one join branch. - const trieRoot: TrieNode = { children: new Map() }; - for (const path of allPaths) { - let node = trieRoot; - for (const step of path) { - let child = node.children.get(step.relationship); - if (!child) { - child = { children: new Map(), step }; - node.children.set(step.relationship, child); - } - node = child; - } - } - - function toJoinNode(node: TrieNode): JoinNode { - const step = node.step!; - return { - relationship: step.relationship, - targetEntity: step.targetEntity, - alias: shortAliasFor(step.targetEntity, usedAliases), - cardinality: step.cardinality, - fkColumn: step.fkColumn, - pkColumn: step.pkColumn, - referenceHolder: step.referenceHolder, - joinType: step.joinType, - children: Array.from(node.children.values()).map(toJoinNode), - }; - } - return { baseEntity: base.resolutionKey(), baseAlias, - joins: Array.from(trieRoot.children.values()).map(toJoinNode), + joins: pathsToJoins(allPaths, usedAliases), }; } From c6ef1de6e8b9a0129f268110100d7c99ce3891ad Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 07:31:36 -0400 Subject: [PATCH 04/32] feat(codegen-ts): extractReportSpec, an object.report as a view spec (FR-044) --- .../src/projection/extract-report-spec.ts | 286 +++++++++++++++++ .../codegen-ts/src/projection/index.ts | 2 + .../codegen-ts/src/projection/report-spec.ts | 63 ++++ .../projection/extract-report-spec.test.ts | 296 ++++++++++++++++++ 4 files changed, 647 insertions(+) create mode 100644 server/typescript/packages/codegen-ts/src/projection/extract-report-spec.ts create mode 100644 server/typescript/packages/codegen-ts/src/projection/report-spec.ts create mode 100644 server/typescript/packages/codegen-ts/test/projection/extract-report-spec.test.ts diff --git a/server/typescript/packages/codegen-ts/src/projection/extract-report-spec.ts b/server/typescript/packages/codegen-ts/src/projection/extract-report-spec.ts new file mode 100644 index 000000000..8184c03d8 --- /dev/null +++ b/server/typescript/packages/codegen-ts/src/projection/extract-report-spec.ts @@ -0,0 +1,286 @@ +// FR-044 Plan 2 — lower an `object.report` plus its shape to a dialect-neutral +// ReportViewSpec (contract Table F). Dimension joins reuse the projection walk +// (`walkViaPath` / `pathsToJoins`), so hop resolution, ambiguity errors and the #209 +// join type are one implementation. The renderer (report-ddl-emit) turns the spec to SQL. + +import { + AGG_SUM, + FIELD_ATTR_LOCAL_TIME, + FIELD_SUBTYPE_CURRENCY, + FIELD_SUBTYPE_DATE, + FIELD_SUBTYPE_DOUBLE, + FIELD_SUBTYPE_ENUM, + FIELD_SUBTYPE_FLOAT, + FIELD_SUBTYPE_INT, + FIELD_SUBTYPE_LONG, + FIELD_SUBTYPE_TIMESTAMP, + FILTER_COMPOSE_AND, + FILTER_COMPOSE_OR, + FILTER_RELATIVE_NOW, + OBJECT_REPORT_ATTR_FILTER, + OBJECT_REPORT_ATTR_SEGMENT, + TYPE_MEASURE, + TYPE_SEGMENT, + reportShape, + resolveReportingFieldRef, + type MetaField, + type MetaMeasure, + type MetaObject, + type MetaRoot, + type MetaSegment, + type ReportField, +} from "@metaobjectsdev/metadata"; +import { intValueMapOf } from "../enum-meta.js"; +import { columnNameFromField } from "../naming.js"; +import { hasWritableRdbSource } from "../source-detect.js"; +import { + desugarClause, + encodeIntEnumFilterValue, + packageOf, + pathsToJoins, + projectionViewName, + shortAliasFor, + sourceColumnNameFor, + walkViaPath, + type ExtractContext, + type Path, +} from "./extract-view-spec.js"; +import type { ReportAggregate, ReportColumn, ReportViewSpec } from "./report-spec.js"; +import type { ReportTemporal } from "./time-sql.js"; +import type { JoinNode, ViewFilterClause } from "./view-spec.js"; + +/** Table D's column kind for a `field.date` / `field.timestamp`. */ +export function temporalOf(field: MetaField): ReportTemporal { + if (field.subType === FIELD_SUBTYPE_DATE) return "date"; + return field.attr(FIELD_ATTR_LOCAL_TIME) === true ? "naive" : "instant"; +} + +const INTEGRAL_SUM: ReadonlySet = new Set([FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_CURRENCY]); +const FLOATING_SUM: ReadonlySet = new Set([FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT]); + +function isPlainObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function isRelativeValue(v: unknown): v is Record { + return isPlainObject(v) && FILTER_RELATIVE_NOW in v; +} + +/** AND of the present clauses, a lone clause as itself, none as undefined. */ +function andOf(clauses: readonly (ViewFilterClause | undefined)[]): ViewFilterClause | undefined { + const present = clauses.filter((c): c is ViewFilterClause => c !== undefined); + if (present.length === 0) return undefined; + return present.length === 1 ? present[0]! : { kind: "and", clauses: present }; +} + +/** + * A reporting filter (`{ field: value | { op: value }, and?, or? }`) over the `@from` + * entity's own fields on the base alias. Differs from `resolveAggregateFilter` in that every + * operator on a field survives (a range keeps both ends) and a relative-date operand + * (`{ now: "-P90D" }`, legal only on reporting hosts, rule F1) lowers to a `RelativeNow`. + * Projection and `origin.aggregate` filters keep refusing relative dates. + */ +function resolveReportFilter( + filter: unknown, + entity: MetaObject, + alias: string, + ctx: ExtractContext, + where: string, +): ViewFilterClause | undefined { + if (!isPlainObject(filter)) return undefined; + const clauses: ViewFilterClause[] = []; + for (const [key, val] of Object.entries(filter)) { + if (key === FILTER_COMPOSE_AND || key === FILTER_COMPOSE_OR) { + const subs = (Array.isArray(val) ? val : []) + .map((s) => resolveReportFilter(s, entity, alias, ctx, where)) + .filter((c): c is ViewFilterClause => c !== undefined); + if (subs.length > 0) clauses.push({ kind: key === FILTER_COMPOSE_AND ? "and" : "or", clauses: subs }); + continue; + } + // ADR-0039: resolving fields(), so a field inherited through extends is found. + const field = entity.fields().find((f) => f.name === key); + if (field === undefined) { + throw new Error(`${where}: filter field "${key}" is not a field of '${entity.name}'.`); + } + const ref = `${alias}.${sourceColumnNameFor(field, ctx)}`; + for (const [op, raw] of Object.entries(desugarClause(val))) { + clauses.push({ kind: "cmp", ref, op, value: lowerFilterValue(raw, op, field, key, where) }); + } + } + return andOf(clauses); +} + +function lowerFilterValue(raw: unknown, op: string, field: MetaField, key: string, where: string): unknown { + const relative = (v: Record) => { + if (field.subType !== FIELD_SUBTYPE_DATE && field.subType !== FIELD_SUBTYPE_TIMESTAMP) { + throw new Error(`${where}: a relative-date value on "${key}" needs a field.date or field.timestamp.`); + } + return { + kind: "relativeNow" as const, + duration: String(v[FILTER_RELATIVE_NOW]), + temporal: temporalOf(field), + }; + }; + if (isRelativeValue(raw)) return relative(raw); + if (Array.isArray(raw) && raw.some(isRelativeValue)) { + return raw.map((v) => (isRelativeValue(v) ? relative(v) : v)); + } + return encodeIntEnumFilterValue( + raw, + op, + field.subType === FIELD_SUBTYPE_ENUM ? intValueMapOf(field) : undefined, + key, + where, + ); +} + +/** A named member (segment or measure) declared on the `@from` entity. */ +function declared(from: MetaObject, type: string, name: string): MetaSegment | MetaMeasure | undefined { + // ADR-0039: resolving children(), so a member declared on an abstract base is found. The + // type string identifies the node (no `instanceof` across packages); the cast is type-only. + return from.children().find((c) => c.type === type && c.name === name) as MetaSegment | MetaMeasure | undefined; +} + +/** The filter of a named segment on `from`, resolved over `from`'s fields. */ +function segmentClause( + segmentName: string | undefined, + from: MetaObject, + alias: string, + ctx: ExtractContext, + where: string, +): ViewFilterClause | undefined { + if (segmentName === undefined) return undefined; + const segment = declared(from, TYPE_SEGMENT, segmentName) as MetaSegment | undefined; + if (segment === undefined) throw new Error(`${where}: segment '${segmentName}' is not declared on '${from.name}'.`); + return resolveReportFilter(segment.filter(), from, alias, ctx, `${where} segment '${segmentName}'`); +} + +function castFor(agg: string, of: MetaField | undefined): ReportAggregate["cast"] { + if (agg !== AGG_SUM || of === undefined) return undefined; + if (INTEGRAL_SUM.has(of.subType)) return "bigint"; + if (FLOATING_SUM.has(of.subType)) return "double"; + return undefined; +} + +/** One aggregate (Table C) for a `measure.aggregate` on `from`. */ +function aggregateOf( + measure: MetaMeasure, + report: MetaObject, + from: MetaObject, + baseAlias: string, + root: MetaRoot, + ctx: ExtractContext, +): ReportAggregate { + const where = `report '${report.name}' measure '${measure.name}'`; + const agg = measure.agg(); + if (agg === undefined) throw new Error(`${where}: has no @agg.`); + const fields = measure.ofColumns().map((ref) => { + const f = resolveReportingFieldRef(ref, from, root); + if (f === undefined) throw new Error(`${where}: @of '${ref}' does not resolve.`); + return f; + }); + const filter = andOf([ + segmentClause(measure.segmentName(), from, baseAlias, ctx, where), + resolveReportFilter(measure.filter(), from, baseAlias, ctx, `${where} @filter`), + ]); + const cast = castFor(agg, fields[0]); + return { + agg, + distinct: measure.distinct(), + refs: fields.map((f) => `${baseAlias}.${sourceColumnNameFor(f, ctx)}`), + ...(filter !== undefined ? { filter } : {}), + ...(cast !== undefined ? { cast } : {}), + }; +} + +/** The join alias a dimension's path ends on: walk the deduplicated tree by relationship name. */ +function aliasAtEndOf(path: Path, joins: readonly JoinNode[]): string { + let level = joins; + let alias = ""; + for (const step of path) { + const node = level.find((j) => j.relationship === step.relationship); + if (node === undefined) throw new Error(`report join tree lost the hop '${step.relationship}'.`); + alias = node.alias; + level = node.children; + } + return alias; +} + +export function extractReportSpec(report: MetaObject, root: MetaRoot, ctx: ExtractContext): ReportViewSpec { + const shape = reportShape(report, root); + const from = shape.from; + // A view over a table that does not exist: refuse, naming both, rather than emit SQL that fails at apply. + if (from.isAbstract || !hasWritableRdbSource(from)) { + throw new Error( + `report '${report.name}': @from '${from.name}' has no table (it is abstract or declares no writable ` + + `source.rdb), so no view can be derived. Give '${from.name}' a source, or remove the report's source.`, + ); + } + const used = new Set(); + const baseAlias = shortAliasFor(from.name, used); + const pkg = packageOf(from); + + // One path per LISTED dimension that has @via (Table F); an unlisted dimension adds no join. + const pathOf = new Map(); + for (const f of shape.fields) { + const via = f.dimension?.via(); + if (via === undefined) continue; + const path = walkViaPath(via, root, pkg, ctx); + // walkViaPath stops at the first hop it cannot resolve; a partial path would pin the + // dimension to the wrong alias, so the whole chain must be walked. + const hops = via.split(".").length - 1; + if (path.length !== hops || hops === 0) { + throw new Error(`report '${report.name}': dimension '${f.name}' @via '${via}' does not resolve to a join path.`); + } + pathOf.set(f, path); + } + const joins = pathsToJoins([...pathOf.values()], used); + + const columns = shape.fields.map((f): ReportColumn => { + const dbColAlias = columnNameFromField(f.name, ctx.columnNamingStrategy); + if (f.role === "dimension") { + const of = f.typeSource ?? resolveReportingFieldRef(f.dimension?.of() ?? "", from, root); + if (of === undefined) throw new Error(`report '${report.name}': dimension '${f.name}' @of does not resolve.`); + const path = pathOf.get(f); + const alias = path === undefined ? baseAlias : aliasAtEndOf(path, joins); + const ref = `${alias}.${sourceColumnNameFor(of, ctx)}`; + if (f.grain !== undefined) { + return { kind: "timeDimension", fieldName: f.name, dbColAlias, ref, grain: f.grain, temporal: temporalOf(of) }; + } + return { kind: "dimension", fieldName: f.name, dbColAlias, ref }; + } + const measure = f.measure!; + if (measure.isRatio()) { + const operand = (name: string | undefined): ReportAggregate => { + const m = name === undefined ? undefined : (declared(from, TYPE_MEASURE, name) as MetaMeasure | undefined); + if (m === undefined || m.isRatio()) { + throw new Error( + `report '${report.name}': ratio '${measure.name}' operand '${name ?? ""}' is not a measure.aggregate on '${from.name}'.`, + ); + } + return aggregateOf(m, report, from, baseAlias, root, ctx); + }; + return { + kind: "ratio", + fieldName: f.name, + dbColAlias, + numerator: operand(measure.numerator()), + denominator: operand(measure.denominator()), + }; + } + return { kind: "aggregate", fieldName: f.name, dbColAlias, aggregate: aggregateOf(measure, report, from, baseAlias, root, ctx) }; + }); + + const reportWhere = `report '${report.name}'`; + const segment = report.attr(OBJECT_REPORT_ATTR_SEGMENT); + const where = andOf([ + segmentClause(typeof segment === "string" ? segment : undefined, from, baseAlias, ctx, reportWhere), + resolveReportFilter(report.attr(OBJECT_REPORT_ATTR_FILTER), from, baseAlias, ctx, `${reportWhere} @filter`), + ]); + return { + viewName: projectionViewName(report, ctx.columnNamingStrategy), + joinTree: { baseEntity: from.resolutionKey(), baseAlias, joins }, + columns, + ...(where !== undefined ? { where } : {}), + }; +} diff --git a/server/typescript/packages/codegen-ts/src/projection/index.ts b/server/typescript/packages/codegen-ts/src/projection/index.ts index a3c4c5bc1..d6a999fe1 100644 --- a/server/typescript/packages/codegen-ts/src/projection/index.ts +++ b/server/typescript/packages/codegen-ts/src/projection/index.ts @@ -2,3 +2,5 @@ export * from "./view-spec.js"; export * from "./extract-view-spec.js"; export * from "./view-ddl-emit.js"; export * from "./projection-detector.js"; +export * from "./report-spec.js"; +export * from "./extract-report-spec.js"; diff --git a/server/typescript/packages/codegen-ts/src/projection/report-spec.ts b/server/typescript/packages/codegen-ts/src/projection/report-spec.ts new file mode 100644 index 000000000..5fcc0f770 --- /dev/null +++ b/server/typescript/packages/codegen-ts/src/projection/report-spec.ts @@ -0,0 +1,63 @@ +// FR-044 Plan 2 (contract Table F) — the dialect-neutral shape of a report's view. +// `extractReportSpec` produces it; the report DDL emitter renders it per dialect. Column +// references are already resolved to unquoted `alias.column`, as in `ViewSpec`. + +import type { MeasureAgg, TimeGrain } from "@metaobjectsdev/metadata"; +import type { JoinTree, ViewFilterClause } from "./view-spec.js"; +import type { ReportTemporal } from "./time-sql.js"; + +/** A relative-date operand, carried as the `value` of a ViewFilterClause `cmp`. */ +export interface RelativeNow { + readonly kind: "relativeNow"; + /** The signed ISO-8601 duration as authored, e.g. "-P90D". */ + readonly duration: string; + readonly temporal: ReportTemporal; +} + +export function isRelativeNow(v: unknown): v is RelativeNow { + return typeof v === "object" && v !== null && (v as { kind?: unknown }).kind === "relativeNow"; +} + +/** One aggregate (Table C). `refs` are unquoted `alias.column`. */ +export interface ReportAggregate { + readonly agg: MeasureAgg; + readonly distinct: boolean; + readonly refs: readonly string[]; + /** The measure's condition: its `@segment` filter AND its `@filter`. */ + readonly filter?: ViewFilterClause; + /** The Table C cast: integral sum → "bigint", floating sum → "double". */ + readonly cast?: "bigint" | "double"; +} + +export type ReportColumn = + | { readonly kind: "dimension"; readonly fieldName: string; readonly dbColAlias: string; readonly ref: string } + | { + readonly kind: "timeDimension"; + readonly fieldName: string; + readonly dbColAlias: string; + readonly ref: string; + readonly grain: TimeGrain; + readonly temporal: ReportTemporal; + } + | { + readonly kind: "aggregate"; + readonly fieldName: string; + readonly dbColAlias: string; + readonly aggregate: ReportAggregate; + } + | { + readonly kind: "ratio"; + readonly fieldName: string; + readonly dbColAlias: string; + readonly numerator: ReportAggregate; + readonly denominator: ReportAggregate; + }; + +export interface ReportViewSpec { + readonly viewName: string; + readonly joinTree: JoinTree; + /** One column per `@dimensions` item then one per `@measures` item, in listed order. */ + readonly columns: readonly ReportColumn[]; + /** The report's `@segment` filter, then its `@filter`, ANDed. Absent when neither is declared. */ + readonly where?: ViewFilterClause; +} diff --git a/server/typescript/packages/codegen-ts/test/projection/extract-report-spec.test.ts b/server/typescript/packages/codegen-ts/test/projection/extract-report-spec.test.ts new file mode 100644 index 000000000..879f20110 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/projection/extract-report-spec.test.ts @@ -0,0 +1,296 @@ +// FR-044 Plan 2, Task 4 — an object.report plus its shape lowers to a dialect-neutral +// ReportViewSpec (contract Table F). These tests assert on the SPEC, never on SQL: the +// renderer is a separate task. + +import { describe, test, expect } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { MetaDataLoader, InMemoryStringSource, type MetaRoot } from "@metaobjectsdev/metadata"; +import { extractReportSpec, temporalOf } from "../../src/projection/extract-report-spec.js"; +import { isRelativeNow } from "../../src/projection/report-spec.js"; +import type { ReportViewSpec } from "../../src/projection/report-spec.js"; + +type Json = Record; + +const FIXTURE = resolve(import.meta.dir, "../../../../../../fixtures/codegen-noop/reporting/with/meta.shop.json"); + +/** The shared reporting fixture, with extra members and reports appended for these cases. */ +function shopModel(mutate?: (children: Json[]) => void): Json { + const model = JSON.parse(readFileSync(FIXTURE, "utf8")) as { "metadata.root": { children: Json[] } }; + const children = model["metadata.root"].children; + const entity = (name: string): Json[] => { + const hit = children.find((c) => (c["object.entity"] as Json | undefined)?.name === name); + return (hit!["object.entity"] as { children: Json[] }).children; + }; + const purchase = entity("Purchase"); + purchase.push( + { "field.timestamp": { name: "createdAt", "@column": "created_ts" } }, + { "field.double": { name: "score" } }, + { "dimension.time": { name: "createdAt", "@of": "Purchase.createdAt", "@grains": ["month"] } }, + { "measure.aggregate": { name: "scoreTotal", "@agg": "sum", "@of": "Purchase.score" } }, + ); + children.push( + { "object.report": { name: "ProgramTitles", "@from": "Purchase", "@dimensions": ["programTitle"], "@measures": ["purchases"] } }, + { "object.report": { name: "ProgramOnly", "@from": "Purchase", "@dimensions": ["program"], "@measures": ["purchases"] } }, + { + "object.report": { + name: "SegmentAndFilter", "@from": "Purchase", "@measures": ["purchases"], + "@segment": "active", "@filter": { status: "refunded" }, + }, + }, + { + "object.report": { + name: "Range", "@from": "Purchase", "@measures": ["purchases"], + "@filter": { amountCents: { gte: 100, lte: 500 } }, + }, + }, + { "object.report": { name: "Scores", "@from": "Purchase", "@measures": ["scoreTotal"] } }, + { + "object.report": { + name: "CreatedByMonth", "@from": "Purchase", "@dimensions": ["createdAt:month"], "@measures": ["purchases"], + }, + }, + ); + mutate?.(children); + return model; +} + +async function load(model: Json): Promise { + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(model))]); + expect(errors).toEqual([]); + return root; +} + +async function spec( + name: string, + opts: { model?: Json; strategy?: "snake_case" | "literal" } = {}, +): Promise { + const root = await load(opts.model ?? shopModel()); + const report = root.findObject(name); + if (report === undefined) throw new Error(`no report ${name}`); + return extractReportSpec(report, root, { columnNamingStrategy: opts.strategy ?? "snake_case" }); +} + +describe("extractReportSpec", () => { + test("a no-dimension report has no joins and only aggregate columns", async () => { + const s = await spec("StoreTotals"); + expect(s.joinTree.joins).toEqual([]); + expect(s.columns.map((c) => c.kind)).toEqual(["aggregate", "aggregate", "aggregate"]); + expect(s.viewName).toBe("v_store_totals"); + expect(s.joinTree.baseEntity).toBe("acme::shop::Purchase"); + expect(s.joinTree.baseAlias).toBe("p"); + expect(s.where).toBeUndefined(); + }); + + test("a @via dimension adds one join with the #209 join type", async () => { + const s = await spec("ProgramTitles"); + expect(s.joinTree.joins).toHaveLength(1); + const join = s.joinTree.joins[0]!; + expect(join.relationship).toBe("program"); + // programId carries no @required in this fixture, so the hop is LEFT. + expect(join.joinType).toBe("left"); + const dim = s.columns[0]!; + expect(dim.kind).toBe("dimension"); + if (dim.kind !== "dimension") throw new Error("unreachable"); + expect(dim.fieldName).toBe("programTitle"); + expect(dim.dbColAlias).toBe("program_title"); + // The ref lands on the JOIN alias, not the base alias. + expect(dim.ref).toBe(`${join.alias}.title`); + expect(join.alias).not.toBe(s.joinTree.baseAlias); + }); + + test("a required belongs-to FK joins INNER", async () => { + const model = shopModel((children) => { + const purchase = children.find((c) => (c["object.entity"] as Json | undefined)?.name === "Purchase")!; + const fields = (purchase["object.entity"] as { children: Json[] }).children; + const programId = fields.find((f) => (f["field.long"] as Json | undefined)?.name === "programId")!; + (programId["field.long"] as Json)["@required"] = true; + }); + const s = await spec("ProgramTitles", { model }); + expect(s.joinTree.joins[0]!.joinType).toBe("inner"); + }); + + test("an unlisted @via dimension adds no join", async () => { + const s = await spec("ProgramOnly"); + expect(s.joinTree.joins).toEqual([]); + const dim = s.columns[0]!; + if (dim.kind !== "dimension") throw new Error("expected a dimension"); + expect(dim.ref).toBe("p.program_id"); + }); + + test("report @segment and @filter combine with AND, segment first", async () => { + const s = await spec("SegmentAndFilter"); + expect(s.where).toEqual({ + kind: "and", + clauses: [ + { kind: "cmp", ref: "p.status", op: "eq", value: "active" }, + { kind: "cmp", ref: "p.status", op: "eq", value: "refunded" }, + ], + }); + }); + + test("a relative value becomes a RelativeNow with the field's temporal kind", async () => { + const s = await spec("DailyRevenue"); + expect(s.where).toEqual({ + kind: "cmp", + ref: "p.purchased_at", + op: "gte", + value: { kind: "relativeNow", duration: "-P90D", temporal: "instant" }, + }); + expect(isRelativeNow((s.where as { value: unknown }).value)).toBe(true); + expect(isRelativeNow({ now: "-P90D" })).toBe(false); + expect(isRelativeNow(null)).toBe(false); + }); + + test("two operators on one field both survive", async () => { + const s = await spec("Range"); + expect(s.where).toEqual({ + kind: "and", + clauses: [ + { kind: "cmp", ref: "p.amount_cents", op: "gte", value: 100 }, + { kind: "cmp", ref: "p.amount_cents", op: "lte", value: 500 }, + ], + }); + }); + + test("a measure's @segment and @filter become its aggregate filter", async () => { + const totals = await spec("StoreTotals"); + const purchases = totals.columns[0]!; + if (purchases.kind !== "aggregate") throw new Error("expected an aggregate"); + expect(purchases.aggregate.agg).toBe("count"); + expect(purchases.aggregate.filter).toEqual({ kind: "cmp", ref: "p.status", op: "eq", value: "active" }); + // A measure with neither a segment nor a filter has no aggregate filter. + const engagement = await spec("ProgramEngagement"); + const lastActivity = engagement.columns.find((c) => c.fieldName === "lastActivityAt")!; + if (lastActivity.kind !== "aggregate") throw new Error("expected an aggregate"); + expect(lastActivity.aggregate.filter).toBeUndefined(); + }); + + test("a measure's own @filter is read without a segment", async () => { + const model = shopModel((children) => { + const purchase = children.find((c) => (c["object.entity"] as Json | undefined)?.name === "Purchase")!; + (purchase["object.entity"] as { children: Json[] }).children.push( + { "measure.aggregate": { name: "bigOnes", "@agg": "count", "@of": "Purchase.id", "@segment": "active", "@filter": { refunded: false } } }, + ); + children.push({ "object.report": { name: "Big", "@from": "Purchase", "@measures": ["bigOnes"] } }); + }); + const s = await spec("Big", { model }); + const c = s.columns[0]!; + if (c.kind !== "aggregate") throw new Error("expected an aggregate"); + expect(c.aggregate.filter).toEqual({ + kind: "and", + clauses: [ + { kind: "cmp", ref: "p.status", op: "eq", value: "active" }, + { kind: "cmp", ref: "p.refunded", op: "eq", value: false }, + ], + }); + }); + + test("a tuple @of yields several refs and distinct: true", async () => { + const s = await spec("ProgramEngagement"); + const days = s.columns.find((c) => c.fieldName === "daysEngaged")!; + if (days.kind !== "aggregate") throw new Error("expected an aggregate"); + expect(days.aggregate.refs).toEqual(["w.program_id", "w.week_number", "w.day_number"]); + expect(days.aggregate.distinct).toBe(true); + expect(days.aggregate.cast).toBeUndefined(); + // The report's @segment scopes the whole report, not each measure. + expect(s.where).toEqual({ kind: "cmp", ref: "w.event_type", op: "eq", value: "exercise_complete" }); + }); + + test("a ratio carries both operand aggregates in full", async () => { + const s = await spec("ProgramEngagement"); + const ratio = s.columns.find((c) => c.fieldName === "avgDaysPerStarter")!; + if (ratio.kind !== "ratio") throw new Error("expected a ratio"); + expect(ratio.numerator.refs).toHaveLength(3); + expect(ratio.numerator.distinct).toBe(true); + expect(ratio.denominator.refs).toEqual(["w.customer_email"]); + expect(ratio.denominator.distinct).toBe(true); + expect(ratio.dbColAlias).toBe("avg_days_per_starter"); + }); + + test("an operand that is not listed in @measures is still resolved", async () => { + const model = shopModel((children) => { + children.push({ + "object.report": { name: "RatioOnly", "@from": "WorkoutEvent", "@measures": ["avgDaysPerStarter"] }, + }); + }); + const s = await spec("RatioOnly", { model }); + expect(s.columns.map((c) => c.kind)).toEqual(["ratio"]); + }); + + test("an integral sum is cast to bigint; a currency sum too; a floating sum to double", async () => { + const revenue = (await spec("DailyRevenue")).columns.find((c) => c.fieldName === "revenue")!; + if (revenue.kind !== "aggregate") throw new Error("expected an aggregate"); + expect(revenue.aggregate.cast).toBe("bigint"); + const scores = (await spec("Scores")).columns[0]!; + if (scores.kind !== "aggregate") throw new Error("expected an aggregate"); + expect(scores.aggregate.cast).toBe("double"); + // count and max never cast. + const lastActivity = (await spec("ProgramEngagement")).columns.find((c) => c.fieldName === "lastActivityAt")!; + if (lastActivity.kind !== "aggregate") throw new Error("expected an aggregate"); + expect(lastActivity.aggregate.cast).toBeUndefined(); + }); + + test("a time dimension carries its grain and the field's temporal kind", async () => { + const s = await spec("DailyRevenue"); + const dim = s.columns[0]!; + if (dim.kind !== "timeDimension") throw new Error("expected a timeDimension"); + expect(dim).toEqual({ + kind: "timeDimension", + fieldName: "purchasedAtDay", + dbColAlias: "purchased_at_day", + ref: "p.purchased_at", + grain: "day", + temporal: "instant", + }); + }); + + test("refuses a @from with no writable source", async () => { + const model = shopModel((children) => { + children.push( + { + "object.entity": { + name: "Ghost", + children: [ + { "field.long": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": ["id"] } }, + { "measure.aggregate": { name: "ghosts", "@agg": "count", "@of": "Ghost.id" } }, + ], + }, + }, + { "object.report": { name: "GhostReport", "@from": "Ghost", "@measures": ["ghosts"] } }, + ); + }); + const root = await load(model); + expect(() => extractReportSpec(root.findObject("GhostReport")!, root, { columnNamingStrategy: "snake_case" })) + .toThrow(/report 'GhostReport'.*'Ghost'.*no table/); + }); + + test("the output alias is the naming strategy applied to the derived name, not an inherited @column", async () => { + const s = await spec("CreatedByMonth"); + const dim = s.columns[0]!; + if (dim.kind !== "timeDimension") throw new Error("expected a timeDimension"); + expect(dim.dbColAlias).toBe("created_at_month"); + expect(dim.ref).toBe("p.created_ts"); + const literal = await spec("CreatedByMonth", { strategy: "literal" }); + const lit = literal.columns[0]!; + expect(lit.dbColAlias).toBe("createdAtMonth"); + expect((lit as { ref: string }).ref).toBe("p.created_ts"); + }); +}); + +describe("temporalOf", () => { + test("date, instant and naive", async () => { + const root = await load(shopModel((children) => { + const purchase = children.find((c) => (c["object.entity"] as Json | undefined)?.name === "Purchase")!; + (purchase["object.entity"] as { children: Json[] }).children.push( + { "field.timestamp": { name: "localAt", "@localTime": true } }, + ); + })); + const purchase = root.findObject("Purchase")!; + const field = (n: string) => purchase.fields().find((f) => f.name === n)!; + expect(temporalOf(field("purchasedOn"))).toBe("date"); + expect(temporalOf(field("purchasedAt"))).toBe("instant"); + expect(temporalOf(field("localAt"))).toBe("naive"); + }); +}); From 6cb2b6452d97fd5c9d6f722e028c87b192910b45 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 07:34:38 -0400 Subject: [PATCH 05/32] feat(codegen-ts): emitReportViewDdl for Postgres, SQLite and MySQL (FR-044) --- .../packages/codegen-ts/src/index.ts | 4 + .../src/projection/extract-view-spec.ts | 3 +- .../codegen-ts/src/projection/index.ts | 1 + .../src/projection/report-ddl-emit.ts | 177 +++++++ .../test/projection/report-ddl-emit.test.ts | 485 ++++++++++++++++++ 5 files changed, 669 insertions(+), 1 deletion(-) create mode 100644 server/typescript/packages/codegen-ts/src/projection/report-ddl-emit.ts create mode 100644 server/typescript/packages/codegen-ts/test/projection/report-ddl-emit.test.ts diff --git a/server/typescript/packages/codegen-ts/src/index.ts b/server/typescript/packages/codegen-ts/src/index.ts index e86a34b21..04c7bffc5 100644 --- a/server/typescript/packages/codegen-ts/src/index.ts +++ b/server/typescript/packages/codegen-ts/src/index.ts @@ -267,6 +267,10 @@ export { extractViewSpec } from "./projection/extract-view-spec.js"; export type { ExtractContext } from "./projection/extract-view-spec.js"; export { emitViewDdl } from "./projection/view-ddl-emit.js"; export type { EmitOptions as ViewDdlEmitOptions } from "./projection/view-ddl-emit.js"; +export { emitReportViewDdl } from "./projection/report-ddl-emit.js"; +export type { ReportEmitOptions } from "./projection/report-ddl-emit.js"; +export { extractReportSpec } from "./projection/extract-report-spec.js"; +export type { ReportViewSpec } from "./projection/report-spec.js"; export { buildProjectionViews } from "./projection/build-projection-views.js"; export type { ExpectedView, BuildProjectionViewsOptions } from "./projection/build-projection-views.js"; export type { JoinNode, JoinTree, SelectColumn, SelectSpec, ViewSpec } from "./projection/view-spec.js"; diff --git a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts index fd33af220..eb47af8f6 100644 --- a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts +++ b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts @@ -119,7 +119,8 @@ export function desugarClause(raw: unknown): Record { * `@filter` of a segment, measure.aggregate or object.report (the loader's F1 rule), and * this lowering has no rendering for it: it would otherwise land as a SQL literal of * `[object Object]`. A programmatic caller skips the loader, so refuse it here, loudly. - * The report lowering (FR-044 Plan 2) replaces this throw. + * The throw stays for projection and `origin.aggregate` filters. A report lowers its relative + * values through `extract-report-spec.ts` and renders them in `report-ddl-emit.ts`. */ function assertNoRelativeDate(value: unknown, where: string): void { const isRelative = (v: unknown): boolean => diff --git a/server/typescript/packages/codegen-ts/src/projection/index.ts b/server/typescript/packages/codegen-ts/src/projection/index.ts index d6a999fe1..c89f7f9a2 100644 --- a/server/typescript/packages/codegen-ts/src/projection/index.ts +++ b/server/typescript/packages/codegen-ts/src/projection/index.ts @@ -4,3 +4,4 @@ export * from "./view-ddl-emit.js"; export * from "./projection-detector.js"; export * from "./report-spec.js"; export * from "./extract-report-spec.js"; +export * from "./report-ddl-emit.js"; diff --git a/server/typescript/packages/codegen-ts/src/projection/report-ddl-emit.ts b/server/typescript/packages/codegen-ts/src/projection/report-ddl-emit.ts new file mode 100644 index 000000000..e40a45654 --- /dev/null +++ b/server/typescript/packages/codegen-ts/src/projection/report-ddl-emit.ts @@ -0,0 +1,177 @@ +// FR-044 Plan 2 (contract Tables C, F) — renders a ReportViewSpec to view SQL for +// Postgres, SQLite and MySQL. Kept apart from view-ddl-emit.ts on purpose: the projection +// emitter quotes conditionally (`quoteIfNeeded`) and is Postgres/SQLite only; a report +// quotes every identifier unconditionally, so a measure named `order` is valid DDL. +import type { JoinNode, ViewFilterClause } from "./view-spec.js"; +import type { ReportAggregate, ReportColumn, ReportViewSpec } from "./report-spec.js"; +import { isRelativeNow } from "./report-spec.js"; +import { relativeNowSql, truncateToGrain, type ReportDialect } from "./time-sql.js"; + +export interface ReportEmitOptions { + readonly dialect: ReportDialect; + readonly baseTableName: string; + /** Map from entity name → table name for every entity referenced in joins. */ + readonly joinTables: Readonly>; + /** Body only (no CREATE VIEW wrapper, no trailing `;`), as migrate-ts consumes it. */ + readonly bodyOnly?: boolean; +} + +/** An identifier, quoted unconditionally. */ +function q(ident: string, d: ReportDialect): string { + return d === "mysql" ? "`" + ident.replace(/`/g, "``") + "`" : `"${ident.replace(/"/g, '""')}"`; +} + +/** `alias.column` → `alias."column"`. The alias is generated, never quoted. */ +function ref(r: string, d: ReportDialect): string { + const dot = r.indexOf("."); + return dot < 0 ? q(r, d) : `${r.slice(0, dot)}.${q(r.slice(dot + 1), d)}`; +} + +function literal(v: unknown, d: ReportDialect): string { + if (isRelativeNow(v)) return relativeNowSql(v.duration, v.temporal, d); + if (v === null || v === undefined) return "NULL"; + if (typeof v === "number") return String(v); + if (typeof v === "boolean") return d === "sqlite" ? (v ? "1" : "0") : v ? "TRUE" : "FALSE"; + const s = String(v).replace(/'/g, "''"); + return `'${d === "mysql" ? s.replace(/\\/g, "\\\\") : s}'`; +} + +const FILTER_OP_SQL: Readonly> = { + eq: "=", ne: "<>", gt: ">", gte: ">=", lt: "<", lte: "<=", like: "LIKE", +}; + +/** A resolved filter clause as a SQL boolean expression; `and` / `or` groups are parenthesised. */ +function cond(clause: ViewFilterClause, d: ReportDialect): string { + switch (clause.kind) { + case "and": + case "or": + return `(${clause.clauses.map((c) => cond(c, d)).join(clause.kind === "and" ? " AND " : " OR ")})`; + case "exprCmp": + throw new Error("report-ddl-emit: a report filter never lowers to an exprCmp clause."); + case "cmp": { + const lhs = ref(clause.ref, d); + if (clause.op === "isNull") return clause.value === false ? `${lhs} IS NOT NULL` : `${lhs} IS NULL`; + if (clause.op === "in") { + const vals = (Array.isArray(clause.value) ? clause.value : [clause.value]).map((v) => literal(v, d)); + return `${lhs} IN (${vals.join(", ")})`; + } + const op = FILTER_OP_SQL[clause.op]; + if (op === undefined) throw new Error(`report-ddl-emit: unsupported filter operator "${clause.op}".`); + return `${lhs} ${op} ${literal(clause.value, d)}`; + } + } +} + +function castType(cast: "bigint" | "double", d: ReportDialect): string | undefined { + switch (d) { + case "postgres": + return cast === "bigint" ? "BIGINT" : "DOUBLE PRECISION"; + case "mysql": + return cast === "bigint" ? "SIGNED" : undefined; // MySQL SUM(double) is already DOUBLE + case "sqlite": + return undefined; // SQLite has one integer and one real affinity; SUM already fits + } +} + +/** One aggregate (Table C): the bare aggregate, the condition by dialect, the cast last. */ +function aggregate(a: ReportAggregate, d: ReportDialect): string { + const refs = a.refs.map((r) => ref(r, d)); + const c = a.filter === undefined ? undefined : cond(a.filter, d); + const fn = a.agg.toUpperCase(); + let sql: string; + if (refs.length > 1) { + // A distinct tuple count: a tuple with any NULL component is not counted, on every dialect. + const notNull = refs.map((r) => `${r} IS NOT NULL`); + const both = [...notNull, ...(c === undefined ? [] : [c])].join(" AND "); + switch (d) { + case "postgres": + sql = `COUNT(DISTINCT (${refs.join(", ")})) FILTER (WHERE ${both})`; + break; + case "sqlite": + sql = `COUNT(DISTINCT CASE WHEN ${both} THEN json_array(${refs.join(", ")}) END)`; + break; + case "mysql": { + // MySQL's multi-argument COUNT(DISTINCT …) already skips a tuple with a NULL component. + const [first, ...rest] = refs; + const head = c === undefined ? first! : `CASE WHEN ${c} THEN ${first} END`; + sql = `COUNT(DISTINCT ${[head, ...rest].join(", ")})`; + break; + } + } + } else { + const x = refs[0]!; + const distinct = a.distinct ? "DISTINCT " : ""; + if (c === undefined) sql = `${fn}(${distinct}${x})`; + else if (d === "postgres") sql = `${fn}(${distinct}${x}) FILTER (WHERE ${c})`; + else sql = `${fn}(${distinct}CASE WHEN ${c} THEN ${x} END)`; + } + const type = a.cast === undefined ? undefined : castType(a.cast, d); + return type === undefined ? sql : `CAST(${sql} AS ${type})`; +} + +interface RenderedColumn { + readonly expr: string; + readonly alias: string; + /** Present for a dimension: its expression is the GROUP BY term. */ + readonly grouped: boolean; +} + +function column(c: ReportColumn, d: ReportDialect): RenderedColumn { + const alias = q(c.dbColAlias, d); + switch (c.kind) { + case "dimension": + return { expr: ref(c.ref, d), alias, grouped: true }; + case "timeDimension": + return { expr: truncateToGrain(ref(c.ref, d), c.grain, c.temporal, d), alias, grouped: true }; + case "aggregate": + return { expr: aggregate(c.aggregate, d), alias, grouped: false }; + case "ratio": { + // Each operand is its FULL Table C expression (condition and cast included). + const num = aggregate(c.numerator, d); + const den = aggregate(c.denominator, d); + const top = d === "postgres" ? "NUMERIC" : d === "sqlite" ? "REAL" : undefined; + return { + expr: `${top === undefined ? num : `CAST(${num} AS ${top})`} / NULLIF(${den}, 0)`, + alias, + grouped: false, + }; + } + } +} + +function renderJoin(node: JoinNode, parentAlias: string, options: ReportEmitOptions): string { + const table = options.joinTables[node.targetEntity]; + if (!table) { + throw new Error(`report-ddl-emit: no table name registered for joined entity "${node.targetEntity}".`); + } + const d = options.dialect; + const fk = q(node.fkColumn, d); + const pk = q(node.pkColumn, d); + // referenceHolder "source": FK on the parent (belongs-to); "target": FK on the child (has-many). + const on = node.referenceHolder === "source" + ? `${node.alias}.${pk} = ${parentAlias}.${fk}` + : `${node.alias}.${fk} = ${parentAlias}.${pk}`; + const kw = node.joinType === "inner" ? "INNER JOIN" : "LEFT OUTER JOIN"; + let sql = ` ${kw} ${q(table, d)} ${node.alias} ON ${on}`; + for (const child of node.children) sql += "\n" + renderJoin(child, node.alias, options); + return sql; +} + +export function emitReportViewDdl(spec: ReportViewSpec, options: ReportEmitOptions): string { + const d = options.dialect; + // Rendered once: a dimension's SELECT expression is its GROUP BY term, so they cannot differ. + const cols = spec.columns.map((c) => column(c, d)); + const select = cols.map((c) => ` ${c.expr} AS ${c.alias}`).join(",\n"); + const groupBy = cols.filter((c) => c.grouped).map((c) => c.expr); + + const base = spec.joinTree.baseAlias; + const joins = spec.joinTree.joins.map((j) => renderJoin(j, base, options)).join("\n"); + const body = + ` SELECT\n${select}\n FROM ${q(options.baseTableName, d)} ${base}` + + (joins === "" ? "" : `\n${joins}`) + + (spec.where === undefined ? "" : `\n WHERE ${cond(spec.where, d)}`) + + (groupBy.length === 0 ? "" : `\n GROUP BY ${groupBy.join(", ")}`); + + if (options.bodyOnly) return body; + return `CREATE VIEW ${q(spec.viewName, d)} AS\n${body};`; +} diff --git a/server/typescript/packages/codegen-ts/test/projection/report-ddl-emit.test.ts b/server/typescript/packages/codegen-ts/test/projection/report-ddl-emit.test.ts new file mode 100644 index 000000000..d5ace4927 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/projection/report-ddl-emit.test.ts @@ -0,0 +1,485 @@ +// FR-044 Plan 2 Task 5 — emitReportViewDdl. Specs are hand-built (not extracted): this +// file pins the TEXT of contract Tables C, E, F and the golden bodies of Table G. +import { describe, test, expect } from "bun:test"; +import type { TimeGrain } from "@metaobjectsdev/metadata"; +import { emitReportViewDdl, type ReportEmitOptions } from "../../src/projection/report-ddl-emit.js"; +import { emitViewDdl } from "../../src/projection/view-ddl-emit.js"; +import type { + ReportAggregate, + ReportColumn, + ReportViewSpec, +} from "../../src/projection/report-spec.js"; +import type { JoinNode, ViewFilterClause, ViewSpec } from "../../src/projection/view-spec.js"; + +const pg = (baseTableName: string, joinTables: Record = {}): ReportEmitOptions => + ({ dialect: "postgres", baseTableName, joinTables, bodyOnly: true }); +const sqlite = (baseTableName: string, joinTables: Record = {}): ReportEmitOptions => + ({ dialect: "sqlite", baseTableName, joinTables, bodyOnly: true }); +const mysql = (baseTableName: string, joinTables: Record = {}): ReportEmitOptions => + ({ dialect: "mysql", baseTableName, joinTables, bodyOnly: true }); + +const lines = (...l: string[]): string => l.join("\n"); + +const cmp = (ref: string, op: string, value: unknown): ViewFilterClause => ({ kind: "cmp", ref, op, value }); +const count = (ref: string, extra: Partial = {}): ReportAggregate => + ({ agg: "count", distinct: false, refs: [ref], ...extra }); +const col = (fieldName: string, aggregate: ReportAggregate): ReportColumn => + ({ kind: "aggregate", fieldName, dbColAlias: fieldName, aggregate }); + +function baseSpec( + alias: string, + entity: string, + columns: readonly ReportColumn[], + rest: { joins?: readonly JoinNode[]; where?: ViewFilterClause; viewName?: string } = {}, +): ReportViewSpec { + return { + viewName: rest.viewName ?? "v_test", + joinTree: { baseEntity: entity, baseAlias: alias, joins: rest.joins ?? [] }, + columns, + ...(rest.where !== undefined ? { where: rest.where } : {}), + }; +} + +// ── v_program_minutes (Table G, all three dialects) ──────────────────────────────── + +const longWeek = cmp("w.durationMinutes", "gte", 60); +const programJoin: JoinNode = { + relationship: "program", targetEntity: "Program", alias: "p", cardinality: "one", + fkColumn: "programId", pkColumn: "id", referenceHolder: "source", joinType: "inner", children: [], +}; +const programMinutes: ReportViewSpec = baseSpec( + "w", + "Week", + [ + { kind: "dimension", fieldName: "program", dbColAlias: "program", ref: "w.programId" }, + { kind: "dimension", fieldName: "programTitle", dbColAlias: "programTitle", ref: "p.title" }, + col("weeks", count("w.id")), + col("longWeeks", count("w.id", { filter: longWeek })), + col("labels", count("w.label", { distinct: true })), + col("slots", count("w.programId", { distinct: true, refs: ["w.programId", "w.durationMinutes"] })), + col("totalMinutes", { agg: "sum", distinct: false, refs: ["w.durationMinutes"], cast: "bigint" }), + col("avgMinutes", { agg: "avg", distinct: false, refs: ["w.durationMinutes"] }), + col("minMinutes", { agg: "min", distinct: false, refs: ["w.durationMinutes"] }), + col("maxMinutes", { agg: "max", distinct: false, refs: ["w.durationMinutes"] }), + { + kind: "ratio", fieldName: "longShare", dbColAlias: "longShare", + numerator: count("w.id", { filter: longWeek }), denominator: count("w.id"), + }, + ], + { joins: [programJoin], viewName: "v_program_minutes" }, +); + +describe("emitReportViewDdl — Table G v_program_minutes", () => { + const tables = { Program: "programs" }; + + test("postgres", () => { + expect(emitReportViewDdl(programMinutes, pg("weeks", tables))).toBe(lines( + ` SELECT`, + ` w."programId" AS "program",`, + ` p."title" AS "programTitle",`, + ` COUNT(w."id") AS "weeks",`, + ` COUNT(w."id") FILTER (WHERE w."durationMinutes" >= 60) AS "longWeeks",`, + ` COUNT(DISTINCT w."label") AS "labels",`, + ` COUNT(DISTINCT (w."programId", w."durationMinutes")) FILTER (WHERE w."programId" IS NOT NULL AND w."durationMinutes" IS NOT NULL) AS "slots",`, + ` CAST(SUM(w."durationMinutes") AS BIGINT) AS "totalMinutes",`, + ` AVG(w."durationMinutes") AS "avgMinutes",`, + ` MIN(w."durationMinutes") AS "minMinutes",`, + ` MAX(w."durationMinutes") AS "maxMinutes",`, + ` CAST(COUNT(w."id") FILTER (WHERE w."durationMinutes" >= 60) AS NUMERIC) / NULLIF(COUNT(w."id"), 0) AS "longShare"`, + ` FROM "weeks" w`, + ` INNER JOIN "programs" p ON p."id" = w."programId"`, + ` GROUP BY w."programId", p."title"`, + )); + }); + + test("sqlite", () => { + expect(emitReportViewDdl(programMinutes, sqlite("weeks", tables))).toBe(lines( + ` SELECT`, + ` w."programId" AS "program",`, + ` p."title" AS "programTitle",`, + ` COUNT(w."id") AS "weeks",`, + ` COUNT(CASE WHEN w."durationMinutes" >= 60 THEN w."id" END) AS "longWeeks",`, + ` COUNT(DISTINCT w."label") AS "labels",`, + ` COUNT(DISTINCT CASE WHEN w."programId" IS NOT NULL AND w."durationMinutes" IS NOT NULL THEN json_array(w."programId", w."durationMinutes") END) AS "slots",`, + ` SUM(w."durationMinutes") AS "totalMinutes",`, + ` AVG(w."durationMinutes") AS "avgMinutes",`, + ` MIN(w."durationMinutes") AS "minMinutes",`, + ` MAX(w."durationMinutes") AS "maxMinutes",`, + ` CAST(COUNT(CASE WHEN w."durationMinutes" >= 60 THEN w."id" END) AS REAL) / NULLIF(COUNT(w."id"), 0) AS "longShare"`, + ` FROM "weeks" w`, + ` INNER JOIN "programs" p ON p."id" = w."programId"`, + ` GROUP BY w."programId", p."title"`, + )); + }); + + test("mysql", () => { + expect(emitReportViewDdl(programMinutes, mysql("weeks", tables))).toBe(lines( + " SELECT", + " w.`programId` AS `program`,", + " p.`title` AS `programTitle`,", + " COUNT(w.`id`) AS `weeks`,", + " COUNT(CASE WHEN w.`durationMinutes` >= 60 THEN w.`id` END) AS `longWeeks`,", + " COUNT(DISTINCT w.`label`) AS `labels`,", + " COUNT(DISTINCT w.`programId`, w.`durationMinutes`) AS `slots`,", + " CAST(SUM(w.`durationMinutes`) AS SIGNED) AS `totalMinutes`,", + " AVG(w.`durationMinutes`) AS `avgMinutes`,", + " MIN(w.`durationMinutes`) AS `minMinutes`,", + " MAX(w.`durationMinutes`) AS `maxMinutes`,", + " COUNT(CASE WHEN w.`durationMinutes` >= 60 THEN w.`id` END) / NULLIF(COUNT(w.`id`), 0) AS `longShare`", + " FROM `weeks` w", + " INNER JOIN `programs` p ON p.`id` = w.`programId`", + " GROUP BY w.`programId`, p.`title`", + )); + }); +}); + +// created_ts is a NAIVE timestamp (@localTime) in the canonical model; recordedAt is an instant. +// ── The other five canonical views, Postgres (Table G) ───────────────────────────── + +const totalsSpec: ReportViewSpec = baseSpec( + "w", + "Week", + [ + col("weeks", count("w.id")), + col("totalMinutes", { agg: "sum", distinct: false, refs: ["w.durationMinutes"], cast: "bigint" }), + { + kind: "ratio", fieldName: "longShare", dbColAlias: "longShare", + numerator: count("w.id", { filter: longWeek }), denominator: count("w.id"), + }, + ], + { viewName: "v_fitness_totals" }, +); + +const byMonthSpec: ReportViewSpec = baseSpec( + "p", + "Program", + [ + { + kind: "timeDimension", fieldName: "createdAtMonth", dbColAlias: "createdAtMonth", + ref: "p.created_ts", grain: "month" as TimeGrain, temporal: "naive", + }, + { kind: "dimension", fieldName: "status", dbColAlias: "status", ref: "p.status" }, + col("programs", count("p.id")), + col("listValue", { + agg: "sum", distinct: false, refs: ["p.priceCents"], cast: "bigint", + filter: cmp("p.status", "eq", "PUBLISHED"), + }), + ], + { viewName: "v_programs_by_month" }, +); + +const byWeekSpec: ReportViewSpec = baseSpec( + "p", + "Program", + [ + { + kind: "timeDimension", fieldName: "createdAtWeek", dbColAlias: "createdAtWeek", + ref: "p.created_ts", grain: "week" as TimeGrain, temporal: "naive", + }, + col("programs", count("p.id")), + ], + { where: cmp("p.status", "eq", "PUBLISHED"), viewName: "v_programs_by_week" }, +); + +const recentSpec: ReportViewSpec = baseSpec( + "p", + "Program", + [col("programs", count("p.id"))], + { + where: cmp("p.created_ts", "gte", { kind: "relativeNow", duration: "-P30D", temporal: "naive" }), + viewName: "v_recent_programs", + }, +); + +const assetActivitySpec: ReportViewSpec = baseSpec( + "a", + "Asset", + [ + { + kind: "timeDimension", fieldName: "recordedAtHour", dbColAlias: "recordedAtHour", + ref: "a.recordedAt", grain: "hour" as TimeGrain, temporal: "instant", + }, + { + kind: "timeDimension", fieldName: "asOfDateWeek", dbColAlias: "asOfDateWeek", + ref: "a.asOfDate", grain: "week" as TimeGrain, temporal: "date", + }, + col("assets", count("a.id")), + ], + { viewName: "v_asset_activity" }, +); + +describe("emitReportViewDdl — Table G, remaining Postgres bodies", () => { + test("v_fitness_totals", () => { + expect(emitReportViewDdl(totalsSpec, pg("weeks"))).toBe(lines( + ` SELECT`, + ` COUNT(w."id") AS "weeks",`, + ` CAST(SUM(w."durationMinutes") AS BIGINT) AS "totalMinutes",`, + ` CAST(COUNT(w."id") FILTER (WHERE w."durationMinutes" >= 60) AS NUMERIC) / NULLIF(COUNT(w."id"), 0) AS "longShare"`, + ` FROM "weeks" w`, + )); + }); + + test("v_programs_by_month", () => { + expect(emitReportViewDdl(byMonthSpec, pg("programs"))).toBe(lines( + ` SELECT`, + ` CAST(date_trunc('month', p."created_ts") AS DATE) AS "createdAtMonth",`, + ` p."status" AS "status",`, + ` COUNT(p."id") AS "programs",`, + ` CAST(SUM(p."priceCents") FILTER (WHERE p."status" = 'PUBLISHED') AS BIGINT) AS "listValue"`, + ` FROM "programs" p`, + ` GROUP BY CAST(date_trunc('month', p."created_ts") AS DATE), p."status"`, + )); + }); + + test("v_programs_by_week", () => { + expect(emitReportViewDdl(byWeekSpec, pg("programs"))).toBe(lines( + ` SELECT`, + ` CAST(date_trunc('week', p."created_ts") AS DATE) AS "createdAtWeek",`, + ` COUNT(p."id") AS "programs"`, + ` FROM "programs" p`, + ` WHERE p."status" = 'PUBLISHED'`, + ` GROUP BY CAST(date_trunc('week', p."created_ts") AS DATE)`, + )); + }); + + test("v_recent_programs", () => { + expect(emitReportViewDdl(recentSpec, pg("programs"))).toBe(lines( + ` SELECT`, + ` COUNT(p."id") AS "programs"`, + ` FROM "programs" p`, + ` WHERE p."created_ts" >= ((now() AT TIME ZONE 'UTC') - INTERVAL 'P30D')`, + )); + }); + + test("v_asset_activity", () => { + expect(emitReportViewDdl(assetActivitySpec, pg("assets"))).toBe(lines( + ` SELECT`, + ` date_trunc('hour', a."recordedAt", 'UTC') AS "recordedAtHour",`, + ` CAST(date_trunc('week', CAST(a."asOfDate" AS TIMESTAMP)) AS DATE) AS "asOfDateWeek",`, + ` COUNT(a."id") AS "assets"`, + ` FROM "assets" a`, + ` GROUP BY date_trunc('hour', a."recordedAt", 'UTC'), CAST(date_trunc('week', CAST(a."asOfDate" AS TIMESTAMP)) AS DATE)`, + )); + }); +}); + +// ── One test per rule ────────────────────────────────────────────────────────────── + +function specWithMeasure(name: string): ReportViewSpec { + return baseSpec("p", "Program", [col(name, count("p.id"))]); +} + +describe("emitReportViewDdl — rules", () => { + test("quotes a keyword-named measure and dimension", () => { + expect(emitReportViewDdl(specWithMeasure("order"), pg("programs"))).toContain(`AS "order"`); + expect(emitReportViewDdl(specWithMeasure("order"), sqlite("programs"))).toContain(`AS "order"`); + expect(emitReportViewDdl(specWithMeasure("order"), mysql("programs"))).toContain("AS `order`"); + const dim = baseSpec("p", "Program", [ + { kind: "dimension", fieldName: "group", dbColAlias: "group", ref: "p.user" }, + col("rank", count("p.id")), + ]); + const sql = emitReportViewDdl(dim, pg("programs")); + expect(sql).toContain(`p."user" AS "group"`); + expect(sql).toContain(`AS "rank"`); + expect(sql).toContain(`GROUP BY p."user"`); + }); + + test("quotes an embedded quote character in an identifier", () => { + expect(emitReportViewDdl(specWithMeasure(`a"b`), pg("programs"))).toContain(`AS "a""b"`); + expect(emitReportViewDdl(specWithMeasure("a`b"), mysql("programs"))).toContain("AS `a``b`"); + }); + + test("no dimensions: no GROUP BY", () => { + expect(emitReportViewDdl(totalsSpec, pg("weeks"))).not.toContain("GROUP BY"); + }); + + test("a time dimension groups by the same expression it selects", () => { + const sql = emitReportViewDdl(byMonthSpec, pg("programs")); + const expr = `CAST(date_trunc('month', p."created_ts") AS DATE)`; + expect(sql).toContain(`${expr} AS "createdAtMonth"`); + expect(sql).toContain(`GROUP BY ${expr}, p."status"`); + }); + + test("a relative value renders Table E inside WHERE", () => { + expect(emitReportViewDdl(recentSpec, pg("programs"))) + .toContain(`WHERE p."created_ts" >= ((now() AT TIME ZONE 'UTC') - INTERVAL 'P30D')`); + expect(emitReportViewDdl(recentSpec, sqlite("programs"))) + .toContain(`WHERE p."created_ts" >= strftime('%Y-%m-%dT%H:%M:%f', 'now', '-30 days')`); + expect(emitReportViewDdl(recentSpec, mysql("programs"))) + .toContain("WHERE p.`created_ts` >= (UTC_TIMESTAMP(3) - INTERVAL 30 DAY)"); + }); + + test("a relative value inside an `in` list renders Table E per element", () => { + const spec = baseSpec("p", "Program", [col("programs", count("p.id"))], { + where: cmp("p.created_ts", "in", [{ kind: "relativeNow", duration: "-P1D", temporal: "naive" }, "x"]), + }); + expect(emitReportViewDdl(spec, pg("programs"))) + .toContain(`WHERE p."created_ts" IN (((now() AT TIME ZONE 'UTC') - INTERVAL 'P1D'), 'x')`); + }); + + const tupleCondSpec: ReportViewSpec = baseSpec("w", "Week", [ + col("slots", count("w.programId", { + distinct: true, + refs: ["w.programId", "w.durationMinutes"], + filter: longWeek, + })), + ]); + + test("a tuple distinct count with a condition, per dialect", () => { + expect(emitReportViewDdl(tupleCondSpec, pg("weeks"))).toContain( + `COUNT(DISTINCT (w."programId", w."durationMinutes")) FILTER (WHERE w."programId" IS NOT NULL AND w."durationMinutes" IS NOT NULL AND w."durationMinutes" >= 60)`); + expect(emitReportViewDdl(tupleCondSpec, sqlite("weeks"))).toContain( + `COUNT(DISTINCT CASE WHEN w."programId" IS NOT NULL AND w."durationMinutes" IS NOT NULL AND w."durationMinutes" >= 60 THEN json_array(w."programId", w."durationMinutes") END)`); + expect(emitReportViewDdl(tupleCondSpec, mysql("weeks"))).toContain( + "COUNT(DISTINCT CASE WHEN w.`durationMinutes` >= 60 THEN w.`programId` END, w.`durationMinutes`)"); + }); + + test("a distinct count with a condition", () => { + const spec = baseSpec("w", "Week", [col("labels", count("w.label", { distinct: true, filter: longWeek }))]); + expect(emitReportViewDdl(spec, pg("weeks"))) + .toContain(`COUNT(DISTINCT w."label") FILTER (WHERE w."durationMinutes" >= 60)`); + expect(emitReportViewDdl(spec, sqlite("weeks"))) + .toContain(`COUNT(DISTINCT CASE WHEN w."durationMinutes" >= 60 THEN w."label" END)`); + expect(emitReportViewDdl(spec, mysql("weeks"))) + .toContain("COUNT(DISTINCT CASE WHEN w.`durationMinutes` >= 60 THEN w.`label` END)"); + }); + + test("Table C casts: bigint and double sums, per dialect", () => { + const big = baseSpec("w", "Week", [ + col("t", { agg: "sum", distinct: false, refs: ["w.m"], cast: "bigint" }), + ]); + const dbl = baseSpec("w", "Week", [ + col("t", { agg: "sum", distinct: false, refs: ["w.m"], cast: "double" }), + ]); + const dec = baseSpec("w", "Week", [col("t", { agg: "sum", distinct: false, refs: ["w.m"] })]); + expect(emitReportViewDdl(big, pg("weeks"))).toContain(`CAST(SUM(w."m") AS BIGINT)`); + expect(emitReportViewDdl(big, sqlite("weeks"))).toContain(` SUM(w."m") AS "t"`); + expect(emitReportViewDdl(big, mysql("weeks"))).toContain("CAST(SUM(w.`m`) AS SIGNED)"); + expect(emitReportViewDdl(dbl, pg("weeks"))).toContain(`CAST(SUM(w."m") AS DOUBLE PRECISION)`); + expect(emitReportViewDdl(dbl, sqlite("weeks"))).toContain(` SUM(w."m") AS "t"`); + expect(emitReportViewDdl(dbl, mysql("weeks"))).toContain(" SUM(w.`m`) AS `t`"); + expect(emitReportViewDdl(dec, pg("weeks"))).toContain(` SUM(w."m") AS "t"`); + }); + + test("a conditional cast sum: the cast wraps the FILTER, per dialect", () => { + const spec = baseSpec("w", "Week", [ + col("t", { agg: "sum", distinct: false, refs: ["w.m"], cast: "bigint", filter: longWeek }), + ]); + expect(emitReportViewDdl(spec, pg("weeks"))) + .toContain(`CAST(SUM(w."m") FILTER (WHERE w."durationMinutes" >= 60) AS BIGINT)`); + expect(emitReportViewDdl(spec, sqlite("weeks"))) + .toContain(`SUM(CASE WHEN w."durationMinutes" >= 60 THEN w."m" END)`); + expect(emitReportViewDdl(spec, mysql("weeks"))) + .toContain("CAST(SUM(CASE WHEN w.`durationMinutes` >= 60 THEN w.`m` END) AS SIGNED)"); + }); + + test("a ratio repeats each operand's FULL expression, operand cast nested inside the ratio cast", () => { + const sumOp: ReportAggregate = { + agg: "sum", distinct: false, refs: ["w.m"], cast: "bigint", filter: longWeek, + }; + const sumAll: ReportAggregate = { agg: "sum", distinct: false, refs: ["w.m"], cast: "bigint" }; + const spec = baseSpec("w", "Week", [ + { kind: "ratio", fieldName: "r", dbColAlias: "r", numerator: sumOp, denominator: sumAll }, + ]); + expect(emitReportViewDdl(spec, pg("weeks"))).toContain( + `CAST(CAST(SUM(w."m") FILTER (WHERE w."durationMinutes" >= 60) AS BIGINT) AS NUMERIC) / NULLIF(CAST(SUM(w."m") AS BIGINT), 0) AS "r"`); + expect(emitReportViewDdl(spec, sqlite("weeks"))).toContain( + `CAST(SUM(CASE WHEN w."durationMinutes" >= 60 THEN w."m" END) AS REAL) / NULLIF(SUM(w."m"), 0) AS "r"`); + expect(emitReportViewDdl(spec, mysql("weeks"))).toContain( + "CAST(SUM(CASE WHEN w.`durationMinutes` >= 60 THEN w.`m` END) AS SIGNED) / NULLIF(CAST(SUM(w.`m`) AS SIGNED), 0) AS `r`"); + }); + + test("a MySQL string literal doubles backslashes and quotes; the others double only quotes", () => { + const spec = baseSpec("p", "Program", [col("programs", count("p.id"))], { + where: cmp("p.label", "eq", "a\\b'c"), + }); + expect(emitReportViewDdl(spec, mysql("programs"))).toContain("WHERE p.`label` = 'a\\\\b''c'"); + expect(emitReportViewDdl(spec, pg("programs"))).toContain(`WHERE p."label" = 'a\\b''c'`); + expect(emitReportViewDdl(spec, sqlite("programs"))).toContain(`WHERE p."label" = 'a\\b''c'`); + }); + + test("boolean literals: TRUE/FALSE on Postgres and MySQL, 1/0 on SQLite", () => { + const spec = baseSpec("p", "Program", [col("programs", count("p.id"))], { + where: cmp("p.active", "eq", true), + }); + expect(emitReportViewDdl(spec, pg("programs"))).toContain(`p."active" = TRUE`); + expect(emitReportViewDdl(spec, mysql("programs"))).toContain("p.`active` = TRUE"); + expect(emitReportViewDdl(spec, sqlite("programs"))).toContain(`p."active" = 1`); + }); + + test("operators: ne, like, in, isNull, and/or grouping", () => { + const where: ViewFilterClause = { + kind: "and", + clauses: [ + cmp("p.a", "ne", 1), + cmp("p.b", "like", "x%"), + cmp("p.c", "in", ["u", "v"]), + cmp("p.d", "isNull", true), + cmp("p.e", "isNull", false), + { kind: "or", clauses: [cmp("p.f", "lt", 2), cmp("p.g", "lte", 3)] }, + ], + }; + const spec = baseSpec("p", "Program", [col("programs", count("p.id"))], { where }); + expect(emitReportViewDdl(spec, pg("programs"))).toContain( + `WHERE (p."a" <> 1 AND p."b" LIKE 'x%' AND p."c" IN ('u', 'v') AND p."d" IS NULL AND p."e" IS NOT NULL AND (p."f" < 2 OR p."g" <= 3))`); + }); + + test("an exprCmp or unknown operator is refused", () => { + const bad = baseSpec("p", "Program", [col("programs", count("p.id"))], { + where: { kind: "exprCmp", expr: { kind: "lit", value: 1 }, op: "eq", value: 1 }, + }); + expect(() => emitReportViewDdl(bad, pg("programs"))).toThrow(/exprCmp/); + const badOp = baseSpec("p", "Program", [col("programs", count("p.id"))], { + where: cmp("p.a", "regex", "x"), + }); + expect(() => emitReportViewDdl(badOp, pg("programs"))).toThrow(/regex/); + }); + + test("a join to an entity with no registered table is refused", () => { + expect(() => emitReportViewDdl(programMinutes, pg("weeks"))).toThrow(/Program/); + }); + + test("a nested join and a LEFT OUTER join render with their ON clauses", () => { + const nested: JoinNode = { + relationship: "program", targetEntity: "Program", alias: "p", cardinality: "one", + fkColumn: "programId", pkColumn: "id", referenceHolder: "source", joinType: "left", + children: [{ + relationship: "weeks", targetEntity: "Week", alias: "w0", cardinality: "many", + fkColumn: "programId", pkColumn: "id", referenceHolder: "target", joinType: "left", children: [], + }], + }; + const spec = baseSpec("w", "Week", [col("n", count("w.id"))], { joins: [nested] }); + const sql = emitReportViewDdl(spec, mysql("weeks", { Program: "programs", Week: "weeks" })); + expect(sql).toContain(lines( + " LEFT OUTER JOIN `programs` p ON p.`id` = w.`programId`", + " LEFT OUTER JOIN `weeks` w0 ON w0.`programId` = p.`id`", + )); + }); + + test("bodyOnly false wraps in CREATE VIEW with a quoted name and a trailing semicolon", () => { + const spec = baseSpec("p", "Program", [col("programs", count("p.id"))], { viewName: "v_x" }); + const full = emitReportViewDdl(spec, { dialect: "postgres", baseTableName: "programs", joinTables: {} }); + expect(full).toBe(`CREATE VIEW "v_x" AS\n SELECT\n COUNT(p."id") AS "programs"\n FROM "programs" p;`); + const my = emitReportViewDdl(spec, { dialect: "mysql", baseTableName: "programs", joinTables: {}, bodyOnly: false }); + expect(my.startsWith("CREATE VIEW `v_x` AS\n")).toBe(true); + expect(my.endsWith(";")).toBe(true); + }); + + test("the projection emitter's output is untouched", () => { + const spec: ViewSpec = { + viewName: "v_program_summary", + joinTree: { baseEntity: "Program", baseAlias: "p", joins: [] }, + selectSpec: { + columns: [ + { kind: "passthrough", fieldName: "id", dbColAlias: "id", sourceAlias: "p", sourceColumn: "id" }, + { kind: "passthrough", fieldName: "title", dbColAlias: "title", sourceAlias: "p", sourceColumn: "title" }, + ], + }, + groupBy: [], + }; + // quoteIfNeeded leaves these lower-case identifiers bare; the report emitter would quote them. + expect(emitViewDdl(spec, { dialect: "postgres", baseTableName: "programs", joinTables: {} })).toBe( + "CREATE VIEW v_program_summary AS\n SELECT\n p.id AS id,\n p.title AS title\n FROM programs p;", + ); + }); +}); From 486da207678083fef10b83faa5a39e5134b7ae3d Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 07:43:51 -0400 Subject: [PATCH 06/32] feat(codegen-ts): lower view-backed reports through buildProjectionViews; canonical reports and shape artifact (FR-044) --- fixtures/codegen-noop/reporting/README.md | 14 +- .../canonical/meta.fitness.json | 46 +++- .../canonical/report-shapes.json | 214 ++++++++++++++++++ .../canonical/schema.postgres.sql | 61 +++++ .../cli/test/unit/reporting-inert.test.ts | 71 ++++-- .../packages/codegen-ts/src/index.ts | 4 +- .../src/projection/build-projection-views.ts | 96 +++++++- .../projection/build-projection-views.test.ts | 148 +++++++++++- .../packages/integration-tests/package.json | 1 + .../src/gen-report-shapes.ts | 99 ++++++++ .../integration-tests/src/load-metadata.ts | 10 +- .../test/report-shapes-artifact.test.ts | 51 +++++ 12 files changed, 778 insertions(+), 37 deletions(-) create mode 100644 fixtures/persistence-conformance/canonical/report-shapes.json create mode 100644 server/typescript/packages/integration-tests/src/gen-report-shapes.ts create mode 100644 server/typescript/packages/integration-tests/test/report-shapes-artifact.test.ts diff --git a/fixtures/codegen-noop/reporting/README.md b/fixtures/codegen-noop/reporting/README.md index 1f6ec4ffe..e597c37b9 100644 --- a/fixtures/codegen-noop/reporting/README.md +++ b/fixtures/codegen-noop/reporting/README.md @@ -1,4 +1,4 @@ -# Reporting vocabulary is inert (FR-044 Plan 1) +# Reporting vocabulary: what is lowered, what stays inert (FR-044) Two models that differ ONLY by the FR-044 reporting vocabulary: @@ -10,9 +10,15 @@ Two models that differ ONLY by the FR-044 reporting vocabulary: a view-backed report passes every source-keyed codegen gate and is the shape most likely to leak output. -Until a report's lowering lands (Plan 2/3), every generator in every port must emit -byte-identical files for the two models, and TypeScript migrate must propose nothing for -the difference. The per-port tests that hold this: +What is lowered (FR-044 Plan 2): a report that declares a read-only `source.rdb @kind: view` +becomes that view, in TypeScript migrate only. `StoreTotals` is that report, so `meta migrate` +proposes exactly one extra statement for `with/` over `without/`, `CREATE VIEW v_store_totals`, +and the `meta docs` agent schema page lists it. Nothing else differs. + +What stays inert: a report with no read-only source (`ProgramEngagement`, `DailyRevenue`), +everywhere; every generator in TypeScript, Java and Python, for every report; and routes in +every port. No other port emits SQL for a report (ADR-0015), so for them `with/` and `without/` +still generate byte-identical files. The per-port tests that hold this: | Port | Test | |---|---| diff --git a/fixtures/persistence-conformance/canonical/meta.fitness.json b/fixtures/persistence-conformance/canonical/meta.fitness.json index 2e52c10aa..8e905a42b 100644 --- a/fixtures/persistence-conformance/canonical/meta.fitness.json +++ b/fixtures/persistence-conformance/canonical/meta.fitness.json @@ -15,9 +15,11 @@ { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } }, { "identity.secondary": { "name": "byTitle", "@fields": "title" } }, { "index.lookup": { "name": "idx_programs_title_status", "@fields": ["title", "status"], "@orders": ["asc", "asc"] } }, - { "dimension.time": { "name": "createdAt", "@of": "Program.createdAt", "@grains": ["day", "month"] } }, + { "dimension.time": { "name": "createdAt", "@of": "Program.createdAt", "@grains": ["day", "week", "month", "quarter", "year"] } }, { "measure.aggregate": { "name": "listValue", "@agg": "sum", "@of": "Program.priceCents", "@segment": "published" } }, - { "segment.filter": { "name": "published", "@filter": { "status": "PUBLISHED" } } } + { "segment.filter": { "name": "published", "@filter": { "status": "PUBLISHED" } } }, + { "dimension.attribute": { "name": "status", "@of": "Program.status" } }, + { "measure.aggregate": { "name": "programs", "@agg": "count", "@of": "Program.id" } } ] }}, { "object.entity": { @@ -29,7 +31,20 @@ { "field.string": { "name": "label", "@maxLength": 80 } }, { "field.int": { "name": "durationMinutes", "@required": true } }, { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } }, - { "identity.reference": { "name": "fkProgram", "@fields": "programId", "@references": "Program" } } + { "identity.reference": { "name": "fkProgram", "@fields": "programId", "@references": "Program" } }, + { "segment.filter": { "name": "long", "@filter": { "durationMinutes": { "gte": 60 } } } }, + { "dimension.attribute": { "name": "program", "@of": "Week.programId" } }, + { "dimension.attribute": { "name": "programTitle", "@of": "Program.title", "@via": "Week.fkProgram" } }, + { "measure.aggregate": { "name": "weeks", "@agg": "count", "@of": "Week.id" } }, + { "measure.aggregate": { "name": "longWeeks", "@agg": "count", "@of": "Week.id", "@segment": "long" } }, + { "measure.aggregate": { "name": "labels", "@agg": "count", "@distinct": true, "@of": "Week.label" } }, + { "measure.aggregate": { "name": "slots", "@agg": "count", "@distinct": true, + "@of": ["Week.programId", "Week.durationMinutes"] } }, + { "measure.aggregate": { "name": "totalMinutes", "@agg": "sum", "@of": "Week.durationMinutes" } }, + { "measure.aggregate": { "name": "avgMinutes", "@agg": "avg", "@of": "Week.durationMinutes" } }, + { "measure.aggregate": { "name": "minMinutes", "@agg": "min", "@of": "Week.durationMinutes" } }, + { "measure.aggregate": { "name": "maxMinutes", "@agg": "max", "@of": "Week.durationMinutes" } }, + { "measure.ratio": { "name": "longShare", "@numerator": "longWeeks", "@denominator": "weeks" } } ] }}, { "object.entity": { @@ -66,7 +81,10 @@ { "field.timestamp": { "name": "observedAt", "@required": true, "@localTime": true } }, { "field.date": { "name": "asOfDate", "@required": true } }, { "field.time": { "name": "atTime", "@required": true } }, - { "identity.primary": { "name": "id", "@fields": "id", "@generation": "uuid" } } + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "uuid" } }, + { "dimension.time": { "name": "recordedAt", "@of": "Asset.recordedAt", "@grains": ["hour", "day"] } }, + { "dimension.time": { "name": "asOfDate", "@of": "Asset.asOfDate", "@grains": ["week", "month"] } }, + { "measure.aggregate": { "name": "assets", "@agg": "count", "@of": "Asset.id" } } ] }}, { "object.projection": { @@ -303,6 +321,26 @@ }}, + { "object.report": { "name": "ProgramMinutes", "@from": "Week", + "@dimensions": ["program", "programTitle"], + "@measures": ["weeks", "longWeeks", "labels", "slots", "totalMinutes", "avgMinutes", "minMinutes", "maxMinutes", "longShare"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_program_minutes" } } ] } }, + { "object.report": { "name": "FitnessTotals", "@from": "Week", + "@measures": ["weeks", "totalMinutes", "longShare"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_fitness_totals" } } ] } }, + { "object.report": { "name": "ProgramsByMonth", "@from": "Program", + "@dimensions": ["createdAt:month", "status"], "@measures": ["programs", "listValue"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_programs_by_month" } } ] } }, + { "object.report": { "name": "ProgramsByWeek", "@from": "Program", + "@dimensions": ["createdAt:week"], "@measures": ["programs"], "@segment": "published", + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_programs_by_week" } } ] } }, + { "object.report": { "name": "RecentPrograms", "@from": "Program", + "@measures": ["programs"], "@filter": { "createdAt": { "gte": { "now": "-P30D" } } }, + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_recent_programs" } } ] } }, + { "object.report": { "name": "AssetActivity", "@from": "Asset", + "@dimensions": ["recordedAt:hour", "asOfDate:week"], "@measures": ["assets"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_asset_activity" } } ] } }, + { "template.prompt": { "name": "coachNote", "@payloadRef": "ProgramBrief", diff --git a/fixtures/persistence-conformance/canonical/report-shapes.json b/fixtures/persistence-conformance/canonical/report-shapes.json new file mode 100644 index 000000000..16dfe26aa --- /dev/null +++ b/fixtures/persistence-conformance/canonical/report-shapes.json @@ -0,0 +1,214 @@ +{ + "reports": [ + { + "report": "fitness::ProgramMinutes", + "from": "fitness::Week", + "view": "v_program_minutes", + "fields": [ + { + "name": "program", + "role": "dimension", + "subType": "long", + "required": true, + "typeSource": "fitness::Week.programId" + }, + { + "name": "programTitle", + "role": "dimension", + "subType": "string", + "required": false, + "typeSource": "fitness::Program.title" + }, + { + "name": "weeks", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "longWeeks", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "labels", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "slots", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "totalMinutes", + "role": "measure", + "subType": "long", + "required": false, + "typeSource": null + }, + { + "name": "avgMinutes", + "role": "measure", + "subType": "decimal", + "required": false, + "typeSource": null + }, + { + "name": "minMinutes", + "role": "measure", + "subType": "int", + "required": false, + "typeSource": "fitness::Week.durationMinutes" + }, + { + "name": "maxMinutes", + "role": "measure", + "subType": "int", + "required": false, + "typeSource": "fitness::Week.durationMinutes" + }, + { + "name": "longShare", + "role": "measure", + "subType": "decimal", + "required": false, + "typeSource": null + } + ] + }, + { + "report": "fitness::FitnessTotals", + "from": "fitness::Week", + "view": "v_fitness_totals", + "fields": [ + { + "name": "weeks", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "totalMinutes", + "role": "measure", + "subType": "long", + "required": false, + "typeSource": null + }, + { + "name": "longShare", + "role": "measure", + "subType": "decimal", + "required": false, + "typeSource": null + } + ] + }, + { + "report": "fitness::ProgramsByMonth", + "from": "fitness::Program", + "view": "v_programs_by_month", + "fields": [ + { + "name": "createdAtMonth", + "role": "dimension", + "subType": "date", + "required": true, + "typeSource": null + }, + { + "name": "status", + "role": "dimension", + "subType": "enum", + "required": true, + "typeSource": "fitness::Program.status" + }, + { + "name": "programs", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + }, + { + "name": "listValue", + "role": "measure", + "subType": "currency", + "required": false, + "typeSource": "fitness::Program.priceCents" + } + ] + }, + { + "report": "fitness::ProgramsByWeek", + "from": "fitness::Program", + "view": "v_programs_by_week", + "fields": [ + { + "name": "createdAtWeek", + "role": "dimension", + "subType": "date", + "required": true, + "typeSource": null + }, + { + "name": "programs", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + } + ] + }, + { + "report": "fitness::RecentPrograms", + "from": "fitness::Program", + "view": "v_recent_programs", + "fields": [ + { + "name": "programs", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + } + ] + }, + { + "report": "fitness::AssetActivity", + "from": "fitness::Asset", + "view": "v_asset_activity", + "fields": [ + { + "name": "recordedAtHour", + "role": "dimension", + "subType": "timestamp", + "required": true, + "typeSource": "fitness::Asset.recordedAt" + }, + { + "name": "asOfDateWeek", + "role": "dimension", + "subType": "date", + "required": true, + "typeSource": null + }, + { + "name": "assets", + "role": "measure", + "subType": "long", + "required": true, + "typeSource": null + } + ] + } + ] +} diff --git a/fixtures/persistence-conformance/canonical/schema.postgres.sql b/fixtures/persistence-conformance/canonical/schema.postgres.sql index 212fceb74..27f33a8a9 100644 --- a/fixtures/persistence-conformance/canonical/schema.postgres.sql +++ b/fixtures/persistence-conformance/canonical/schema.postgres.sql @@ -175,3 +175,64 @@ CREATE VIEW "v_program_stat" AS LEFT OUTER JOIN weeks w ON w."programId" = p.id GROUP BY p.id; COMMENT ON VIEW "v_program_stat" IS 'metaobjects:v1:sha256:9120c9f8899e257b52a087c4841b8e55f679d7f9c7a6e58d8823912855f2ab57'; + +CREATE VIEW "v_program_minutes" AS + SELECT + w."programId" AS "program", + p."title" AS "programTitle", + COUNT(w."id") AS "weeks", + COUNT(w."id") FILTER (WHERE w."durationMinutes" >= 60) AS "longWeeks", + COUNT(DISTINCT w."label") AS "labels", + COUNT(DISTINCT (w."programId", w."durationMinutes")) FILTER (WHERE w."programId" IS NOT NULL AND w."durationMinutes" IS NOT NULL) AS "slots", + CAST(SUM(w."durationMinutes") AS BIGINT) AS "totalMinutes", + AVG(w."durationMinutes") AS "avgMinutes", + MIN(w."durationMinutes") AS "minMinutes", + MAX(w."durationMinutes") AS "maxMinutes", + CAST(COUNT(w."id") FILTER (WHERE w."durationMinutes" >= 60) AS NUMERIC) / NULLIF(COUNT(w."id"), 0) AS "longShare" + FROM "weeks" w + INNER JOIN "programs" p ON p."id" = w."programId" + GROUP BY w."programId", p."title"; +COMMENT ON VIEW "v_program_minutes" IS 'metaobjects:v1:sha256:06b54e731e0221acab4b6e0a7cb7f5914662eff39af6ca57031a7df69cbbec93'; + +CREATE VIEW "v_fitness_totals" AS + SELECT + COUNT(w."id") AS "weeks", + CAST(SUM(w."durationMinutes") AS BIGINT) AS "totalMinutes", + CAST(COUNT(w."id") FILTER (WHERE w."durationMinutes" >= 60) AS NUMERIC) / NULLIF(COUNT(w."id"), 0) AS "longShare" + FROM "weeks" w; +COMMENT ON VIEW "v_fitness_totals" IS 'metaobjects:v1:sha256:92ac554bcb44ee0bc7f3ddab40cf64c52fd964865dfe155104621babdd766e80'; + +CREATE VIEW "v_programs_by_month" AS + SELECT + CAST(date_trunc('month', p."created_ts") AS DATE) AS "createdAtMonth", + p."status" AS "status", + COUNT(p."id") AS "programs", + CAST(SUM(p."priceCents") FILTER (WHERE p."status" = 'PUBLISHED') AS BIGINT) AS "listValue" + FROM "programs" p + GROUP BY CAST(date_trunc('month', p."created_ts") AS DATE), p."status"; +COMMENT ON VIEW "v_programs_by_month" IS 'metaobjects:v1:sha256:8310fbae2f38c07296f2c22297c5e83ef2fbd12faaed5f90bd4211dbc8b45935'; + +CREATE VIEW "v_programs_by_week" AS + SELECT + CAST(date_trunc('week', p."created_ts") AS DATE) AS "createdAtWeek", + COUNT(p."id") AS "programs" + FROM "programs" p + WHERE p."status" = 'PUBLISHED' + GROUP BY CAST(date_trunc('week', p."created_ts") AS DATE); +COMMENT ON VIEW "v_programs_by_week" IS 'metaobjects:v1:sha256:656478aaf3e58443a6123f4f016bd61fbebfe2b0122175260155fb92d754e5ae'; + +CREATE VIEW "v_recent_programs" AS + SELECT + COUNT(p."id") AS "programs" + FROM "programs" p + WHERE p."created_ts" >= ((now() AT TIME ZONE 'UTC') - INTERVAL 'P30D'); +COMMENT ON VIEW "v_recent_programs" IS 'metaobjects:v1:sha256:9e675bb71fe4a3c74f06f5db65d7908e246a59c2a8fc062fc2c899948841eccc'; + +CREATE VIEW "v_asset_activity" AS + SELECT + date_trunc('hour', a."recordedAt", 'UTC') AS "recordedAtHour", + CAST(date_trunc('week', CAST(a."asOfDate" AS TIMESTAMP)) AS DATE) AS "asOfDateWeek", + COUNT(a."id") AS "assets" + FROM "assets" a + GROUP BY date_trunc('hour', a."recordedAt", 'UTC'), CAST(date_trunc('week', CAST(a."asOfDate" AS TIMESTAMP)) AS DATE); +COMMENT ON VIEW "v_asset_activity" IS 'metaobjects:v1:sha256:02b68a9ec0c67e11c6e505a47591c24e8dece737a46161549167c2cafece0a26'; diff --git a/server/typescript/packages/cli/test/unit/reporting-inert.test.ts b/server/typescript/packages/cli/test/unit/reporting-inert.test.ts index c002edd20..45166b276 100644 --- a/server/typescript/packages/cli/test/unit/reporting-inert.test.ts +++ b/server/typescript/packages/cli/test/unit/reporting-inert.test.ts @@ -1,11 +1,13 @@ -// FR-044 Plan 1 — the reporting vocabulary is INERT in every generator and in migrate. +// FR-044 — what a report generates, and what it does not. // -// Plan 1 registers `dimension.*`, `measure.*`, `segment.*` and `object.report` and -// validates them at load, but gives none of them output: a report's lowering (a view, a -// typed row, a route) lands in Plan 2/3. Until then a model that USES the vocabulary must -// generate exactly what the same model without it generates — byte for byte, in every -// catalog generator — and `meta migrate` must propose nothing for it. Anything else is -// churn an adopter sees the day they declare a measure. +// Plan 1 registered `dimension.*`, `measure.*`, `segment.*` and `object.report` and gave +// them no output. Plan 2 lowers exactly ONE thing: a report that declares a read-only +// `source.rdb @kind: view` becomes that view in TypeScript migrate (and so on the +// `meta docs` agent schema page, which lists the views migrate would create). Everything +// else stays inert, and this file holds it there: a sourceless report is inert everywhere, +// every catalog generator emits the same files with and without the reporting nodes (no +// TypeScript generator emits for a report; routes and the typed row are Plan 3), and every +// docs surface other than that one schema entry is byte-identical. // // The model pair lives in fixtures/codegen-noop/reporting/ and is shared with the other // four ports' copies of this test. `with/` carries a report that declares a read-only @@ -15,7 +17,7 @@ // `meta docs` is held to the same rule (controller ruling, 2026-10-03): a report's fields // are derived by its lowering, so a page for one today would show none of them. Every docs // surface — model pages, agent pages, requirements, the HTML site, and the api surface — -// must come out identical with and without the reporting nodes. +// must come out identical with and without the reporting nodes, bar the one view entry. import { describe, test, expect, beforeAll } from "bun:test"; import { mkdtempSync, mkdirSync, copyFileSync, rmSync, readFileSync, readdirSync, statSync } from "node:fs"; @@ -162,24 +164,35 @@ describe("FR-044 a selection of only reports", () => { }); }); -describe("FR-044 reporting nodes are inert in migrate", () => { - test("the expected postgres schema is identical, and diff() proposes no statement", async () => { - const withSchema: SchemaSnapshot = buildExpectedSchema(withReporting, { dialect: "postgres" }); - const withoutSchema: SchemaSnapshot = buildExpectedSchema(withoutReporting, { dialect: "postgres" }); - expect(withSchema).toEqual(withoutSchema); +describe("FR-044 a sourceless report is inert in migrate; a view-backed report proposes exactly its view", () => { + test("the expected postgres schemas differ by exactly v_store_totals, and diff() proposes exactly that view", async () => { + const views = (m: MetaRoot) => buildProjectionViews(m, { dialect: "postgres" }); + const withSchema: SchemaSnapshot = buildExpectedSchema(withReporting, { dialect: "postgres", views: views(withReporting) }); + const withoutSchema: SchemaSnapshot = buildExpectedSchema(withoutReporting, { dialect: "postgres", views: views(withoutReporting) }); + + // Only StoreTotals declares a view; ProgramEngagement and DailyRevenue are sourceless. + expect(withoutSchema.views).toEqual([]); + expect(withSchema.views.map((v) => v.name)).toEqual(["v_store_totals"]); + // Everything else is the same: the tables do not move. + expect(withSchema.tables).toEqual(withoutSchema.tables); // Live DB = the model without reporting nodes; metadata = the model with them. - const forward = await diff(withSchema, withoutSchema, { dialect: "postgres" }); - expect(forward.changes).toEqual([]); - // And from an empty database, the report adds nothing to what the entities need. + const forward = await diff({ expected: withSchema, actual: withoutSchema }); + expect(forward.changes.map((c) => [c.kind, c.kind === "create-view" ? c.view.name : undefined])).toEqual([ + ["create-view", "v_store_totals"], + ]); + // And from an empty database, the report adds exactly that one view to what the entities need. const empty: SchemaSnapshot = { tables: [], views: [] }; - const fromEmptyWith = await diff(withSchema, empty, { dialect: "postgres" }); - const fromEmptyWithout = await diff(withoutSchema, empty, { dialect: "postgres" }); - expect(fromEmptyWith.changes).toEqual(fromEmptyWithout.changes); + const fromEmptyWith = await diff({ expected: withSchema, actual: empty }); + const fromEmptyWithout = await diff({ expected: withoutSchema, actual: empty }); + expect(fromEmptyWith.changes.filter((c) => c.kind !== "create-view")).toEqual( + fromEmptyWithout.changes.filter((c) => c.kind !== "create-view"), + ); + expect(fromEmptyWith.changes.filter((c) => c.kind === "create-view")).toHaveLength(1); }); }); -describe("FR-044 reporting nodes are inert in meta docs", () => { +describe("FR-044 reporting nodes are inert in meta docs, bar the one view entry", () => { /** Run `meta docs` over a project holding one variant, once per surface flag set, and * read back everything written. The project directory has the SAME basename for both * variants: the site stamps it into every page title. */ @@ -251,7 +264,7 @@ describe("FR-044 reporting nodes are inert in meta docs", () => { compare(expected, await api(withReporting)); }); - test("the agent surface (schema, ui, requirements) is identical with the UI tier wired", async () => { + test("the agent surface differs only by the schema page's v_store_totals view, with the UI tier wired", async () => { // Same reason as above: `meta docs --agent` needs a loadable gen config. The schema // input is built exactly as docs.ts's buildAgentSchemaInput builds it for postgres. const agent = async (metadata: MetaRoot): Promise> => { @@ -279,6 +292,20 @@ describe("FR-044 reporting nodes are inert in meta docs", () => { // ui.md is the page that leaked a view-backed report; it must actually be rendered. expect(Object.keys(expected).some((p) => p.endsWith("ui.md"))).toBe(true); expect(Object.keys(expected).some((p) => p.endsWith("schema.md"))).toBe(true); - compare(expected, await agent(withReporting)); + const actual = await agent(withReporting); + + // The schema page lists the views migrate would create (docs.ts feeds it + // buildProjectionViews), so the one view-backed report appears there and nowhere else. + const schemaPage = Object.keys(expected).find((p) => p.endsWith("schema.md"))!; + const entry = "## Views\n\n" + + "A view is generated from its projection's `origin.*` children — it is derived, never hand-written. " + + "Editing the view SQL directly is drift the tool cannot see.\n\n" + + "### `v_store_totals`\n\nDeclared by `acme::shop::StoreTotals`.\n\n"; + expect(actual[schemaPage]).toContain(entry); + expect(actual[schemaPage]!.replace(entry, "")).toBe(expected[schemaPage]!); + delete actual[schemaPage]; + const rest = { ...expected }; + delete rest[schemaPage]; + compare(rest, actual); }); }); diff --git a/server/typescript/packages/codegen-ts/src/index.ts b/server/typescript/packages/codegen-ts/src/index.ts index 04c7bffc5..631033b6e 100644 --- a/server/typescript/packages/codegen-ts/src/index.ts +++ b/server/typescript/packages/codegen-ts/src/index.ts @@ -271,8 +271,8 @@ export { emitReportViewDdl } from "./projection/report-ddl-emit.js"; export type { ReportEmitOptions } from "./projection/report-ddl-emit.js"; export { extractReportSpec } from "./projection/extract-report-spec.js"; export type { ReportViewSpec } from "./projection/report-spec.js"; -export { buildProjectionViews } from "./projection/build-projection-views.js"; -export type { ExpectedView, BuildProjectionViewsOptions } from "./projection/build-projection-views.js"; +export { buildProjectionViews, buildReportViews } from "./projection/build-projection-views.js"; +export type { ExpectedView, BuildProjectionViewsOptions, BuildReportViewsOptions } from "./projection/build-projection-views.js"; export type { JoinNode, JoinTree, SelectColumn, SelectSpec, ViewSpec } from "./projection/view-spec.js"; // Prompt construction (FR-004): ADR-0056 — a template's payload is its value object's own // interface (entityFile()), so there is no template-tier payload emitter to export. The one diff --git a/server/typescript/packages/codegen-ts/src/projection/build-projection-views.ts b/server/typescript/packages/codegen-ts/src/projection/build-projection-views.ts index cd8a850d9..98cd9eb2d 100644 --- a/server/typescript/packages/codegen-ts/src/projection/build-projection-views.ts +++ b/server/typescript/packages/codegen-ts/src/projection/build-projection-views.ts @@ -17,6 +17,8 @@ import { isMetaRoot, isReadOnlySource, isWritableSource, + reportFrom, + resolveObjectRef, SOURCE_KIND_VIEW, TYPE_FIELD, TYPE_IDENTITY, @@ -25,7 +27,12 @@ import { resolveTableSchema, } from "@metaobjectsdev/metadata"; import { isProjection, isWriteThrough } from "./projection-detector.js"; -import { extractViewSpec, refNamedOwner } from "./extract-view-spec.js"; +import { extractViewSpec, packageOf, refNamedOwner } from "./extract-view-spec.js"; +import { extractReportSpec } from "./extract-report-spec.js"; +import { emitReportViewDdl } from "./report-ddl-emit.js"; +import type { ReportViewSpec } from "./report-spec.js"; +import type { ReportDialect } from "./time-sql.js"; +import { isReport } from "../source-detect.js"; import { emitViewDdl } from "./view-ddl-emit.js"; import type { JoinNode, ViewSpec } from "./view-spec.js"; import type { ColumnNamingStrategy } from "../metaobjects-config.js"; @@ -108,7 +115,10 @@ export function buildProjectionViews( for (const obj of root.objects()) joinTables[obj.resolutionKey()] = resolveTableName(obj); const out: ExpectedView[] = []; - for (const projection of root.objects().filter(isProjection)) { + // A view-backed object.report satisfies isProjection (a read-only source, no writable one) + // but is lowered by buildReportViews below, not here: name it out rather than rely on + // viewIsDerived happening to drop it. + for (const projection of root.objects().filter((o) => isProjection(o) && !isReport(o))) { // #208 §6 — classify DDL ownership BEFORE viewIsDerived (see classifyReadOnlySource), // so an escape-valve view carrying extends-bound identity/fields (pure shape / row // identity) is never mis-synthesized into a wrong base-table passthrough SELECT. @@ -149,9 +159,79 @@ export function buildProjectionViews( } emitViewFor(entity, root, joinTables, dialect, columnNamingStrategy, out); } + + // FR-044 — the view of every view-backed object.report, appended AFTER the two loops + // above so the views a report-free model returns keep their order. + out.push(...buildReportViews(root, { dialect: opts.dialect, columnNamingStrategy })); + return out; +} + +export interface BuildReportViewsOptions { + dialect: "postgres" | "sqlite" | "d1" | "mysql"; + columnNamingStrategy?: ColumnNamingStrategy; +} + +/** + * The view of every view-backed `object.report` (contract Table A). Called by + * buildProjectionViews; exported separately because MySQL is accepted here and nowhere + * else (migrate does not target MySQL; the SQL ships through this function and a recipe). + * + * The Table A gate (classifyReadOnlySource) runs BEFORE extractReportSpec: a sourceless + * report must never reach it, because projectionViewName falls back to `v_` and + * would invent a view nobody declared. A report whose `@from` has no table, or whose + * `@via` chain does not resolve, throws out of extractReportSpec naming the report; that + * propagates so `meta migrate` fails loudly instead of emitting a view over nothing. + */ +export function buildReportViews(root: MetaData, opts: BuildReportViewsOptions): ExpectedView[] { + if (!isMetaRoot(root)) { + throw new Error("buildReportViews: root must be a loaded MetaRoot."); + } + // D1 is SQLite at the SQL level. + const dialect: ReportDialect = opts.dialect === "d1" ? "sqlite" : opts.dialect; + const columnNamingStrategy = opts.columnNamingStrategy ?? "snake_case"; + const joinTables: Record = {}; + for (const obj of root.objects()) joinTables[obj.resolutionKey()] = resolveTableName(obj); + + const out: ExpectedView[] = []; + for (const report of root.objects().filter(isReport)) { + const cls = classifyReadOnlySource(report); // Table A + if (cls.kind === "skip") continue; + if (cls.kind === "sql") { + emitSqlView(report, cls.source, root, joinTables, out); + continue; + } + const spec = extractReportSpec(report, root, { columnNamingStrategy }); + const baseTableName = joinTables[spec.joinTree.baseEntity]; + if (!baseTableName) continue; // unresolved base — extractReportSpec already refuses a table-less @from + const schema = resolveTableSchema(report); + out.push({ + name: spec.viewName, + sql: emitReportViewDdl(spec, { dialect, baseTableName, joinTables, bodyOnly: true }), + dependsOn: reportDependsOn(spec, baseTableName, joinTables), + fqn: report.resolutionKey(), + ...(schema !== undefined ? { schema } : {}), + // `columns` omitted on purpose: unknown, so migrate takes the fail-safe drop+create (Table F). + }); + } return out; } +/** The base table plus every joined table, deduped — the physical tables a report view reads. */ +function reportDependsOn( + spec: ReportViewSpec, + baseTableName: string, + joinTables: Readonly>, +): string[] { + const tables = new Set([baseTableName]); + const walk = (node: JoinNode): void => { + const t = joinTables[node.targetEntity]; + if (t) tables.add(t); + for (const child of node.children) walk(child); + }; + for (const j of spec.joinTree.joins) walk(j); + return [...tables]; +} + /** * #208 §6 — classify a host's read-only source by DDL OWNERSHIP, BEFORE any derivation * decision. Shared by the projection and write-through loops so the ownership rules can @@ -258,7 +338,7 @@ function emitSqlView( /** * The physical tables an `@sql` view depends on. migrate-ts uses this to drop+recreate * the view around a column-altering change on a source table (Postgres blocks ALTER on a - * column a view depends on). Two sources, no `@dependsOn` attr: + * column a view depends on). Three sources, no `@dependsOn` attr: * * - A **write-through host** (a writable table source + an `@sql` read-view source) * reads from its OWN table — its one certain dependency. It has NO extends anchors @@ -267,6 +347,9 @@ function emitSqlView( * - A **projection** `@sql` view's dependencies are its extends-bound anchor tables * (D7 — the `extends` bindings that anchor the read model's shape ARE the dependency * declaration). + * - A **report** `@sql` view reads its `@from` entity's table (FR-044). A report has + * neither a writable table nor extends anchors, so without this its dependsOn would + * be empty and a column ALTER on the `@from` table would fail at apply. * * Deduped. (A table the opaque body JOINs but neither hosts nor anchors is NOT tracked — * the deferred `@dependsOn` escape, ADR-0043.) @@ -291,6 +374,13 @@ function collectSqlDependsOn( const t = joinTables[owner.resolutionKey()]; if (t !== undefined) tables.add(t); } + // A report's `@from` table (resolved package-locally, as reportShape does). + if (isReport(host)) { + const fromName = reportFrom(host); + const from = fromName === undefined ? undefined : resolveObjectRef(root, fromName, packageOf(host)).node; + const t = from === undefined ? undefined : joinTables[from.resolutionKey()]; + if (t !== undefined) tables.add(t); + } return [...tables]; } diff --git a/server/typescript/packages/codegen-ts/test/projection/build-projection-views.test.ts b/server/typescript/packages/codegen-ts/test/projection/build-projection-views.test.ts index 32eb524b5..216726974 100644 --- a/server/typescript/packages/codegen-ts/test/projection/build-projection-views.test.ts +++ b/server/typescript/packages/codegen-ts/test/projection/build-projection-views.test.ts @@ -9,8 +9,10 @@ // `origin.passthrough` renames, and the bodyOnly emit shape consumed by migrate-ts. import { describe, test, expect } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; -import { buildProjectionViews } from "../../src/projection/build-projection-views.js"; +import { buildProjectionViews, buildReportViews } from "../../src/projection/build-projection-views.js"; async function load(children: unknown[]) { const json = JSON.stringify({ "metadata.root": { package: "acme", children } }); @@ -299,3 +301,147 @@ describe("buildProjectionViews — #208 @sql / @unmanaged DDL-ownership escape v ); }); }); + +// FR-044 Plan 2 Task 6 — a view-backed object.report lowers through buildReportViews, which +// buildProjectionViews calls after its two existing loops (contract Table A). +describe("buildReportViews — view-backed reports (FR-044 Plan 2, Table A)", () => { + type Json = Record; + const REPORTING = resolve(import.meta.dir, "../../../../../../fixtures/codegen-noop/reporting"); + + function shop(variant: "with" | "without", mutate?: (children: Json[]) => void): Json { + const model = JSON.parse(readFileSync(resolve(REPORTING, variant, "meta.shop.json"), "utf8")) as { + "metadata.root": { children: Json[] }; + }; + mutate?.(model["metadata.root"].children); + return model; + } + async function loadModel(model: Json) { + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(model))]); + expect(errors).toEqual([]); + return root; + } + const PG = { dialect: "postgres", columnNamingStrategy: "literal" } as const; + + test("a view-backed report yields one ExpectedView named by its source", async () => { + const root = await loadModel(shop("with")); + const views = buildReportViews(root, PG); + expect(views.map((v) => v.name)).toEqual(["v_store_totals"]); + const v = views[0]!; + expect(v.fqn).toBe("acme::shop::StoreTotals"); + expect(v.dependsOn).toEqual(["purchases"]); + expect(v.columns).toBeUndefined(); + expect("columns" in v).toBe(false); + expect(v.sql).toContain("FROM \"purchases\" p"); + expect(v.sql).toContain('COUNT(p."id") FILTER (WHERE p."status" = \'active\') AS "purchases"'); + expect(v.sql).not.toContain("CREATE VIEW"); + }); + + test("a sourceless report yields nothing", async () => { + const root = await loadModel(shop("with")); + const names = buildProjectionViews(root, PG).map((v) => v.name); + expect(names).toContain("v_store_totals"); + expect(names.some((n) => /engagement|daily/i.test(n))).toBe(false); + expect(names).toHaveLength(1); + }); + + test("an @unmanaged report source yields nothing", async () => { + const root = await loadModel( + shop("with", (children) => { + const r = children.find((c) => (c["object.report"] as Json | undefined)?.name === "StoreTotals"); + const kids = (r!["object.report"] as { children: Json[] }).children; + (kids[0]!["source.rdb"] as Json)["@unmanaged"] = true; + }), + ); + expect(buildReportViews(root, PG)).toEqual([]); + }); + + test("a non-view report source kind yields nothing", async () => { + const root = await loadModel( + shop("with", (children) => { + const r = children.find((c) => (c["object.report"] as Json | undefined)?.name === "StoreTotals"); + const kids = (r!["object.report"] as { children: Json[] }).children; + (kids[0]!["source.rdb"] as Json)["@kind"] = "materializedView"; + }), + ); + expect(buildReportViews(root, PG)).toEqual([]); + }); + + test("an @sql report source keeps the author's body and depends on the @from table", async () => { + const body = "SELECT COUNT(*) AS purchases FROM purchases"; + const root = await loadModel( + shop("with", (children) => { + const r = children.find((c) => (c["object.report"] as Json | undefined)?.name === "StoreTotals"); + const kids = (r!["object.report"] as { children: Json[] }).children; + (kids[0]!["source.rdb"] as Json)["@sql"] = body; + }), + ); + const views = buildReportViews(root, PG); + expect(views).toHaveLength(1); + expect(views[0]!.sql).toBe(body); + expect(views[0]!.dependsOn).toEqual(["purchases"]); + expect(views[0]!.fqn).toBe("acme::shop::StoreTotals"); + expect("columns" in views[0]!).toBe(false); + }); + + test("the projection loop does not see a report", async () => { + const root = await loadModel(shop("with")); + const all = buildProjectionViews(root, PG); + expect(all).toHaveLength(1); + expect(all).toEqual(buildReportViews(root, PG)); + }); + + test("a model with no report returns exactly what it returned before; reports come last", async () => { + const withReport = (children: Json[]) => { + children.push( + { "object.projection": { name: "ProgramLite", children: [ + { "source.rdb": { "@kind": "view", "@view": "v_program_lite" } }, + { "field.long": { name: "id", extends: "Program.id" } }, + { "identity.primary": { extends: "Program.id" } }, + ] } }, + ); + }; + // Re-use the with-model's reports but drop them again: a projection-only twin. + const twin = (keepReports: boolean) => + shop("with", (children) => { + withReport(children); + if (!keepReports) { + for (let i = children.length - 1; i >= 0; i--) if ("object.report" in children[i]!) children.splice(i, 1); + } + }); + const without = buildProjectionViews(await loadModel(twin(false)), PG); + const withR = buildProjectionViews(await loadModel(twin(true)), PG); + expect(without.map((v) => v.name)).toEqual(["v_program_lite"]); + expect(withR.map((v) => v.name)).toEqual(["v_program_lite", "v_store_totals"]); + expect(withR.slice(0, without.length)).toEqual(without); + // And the report-free codegen-noop model is still empty. + expect(buildProjectionViews(await loadModel(shop("without")), PG)).toEqual([]); + }); + + test("mysql is accepted by buildReportViews and emits backticks", async () => { + const root = await loadModel(shop("with")); + const views = buildReportViews(root, { dialect: "mysql", columnNamingStrategy: "literal" }); + expect(views).toHaveLength(1); + expect(views[0]!.sql).toContain("FROM `purchases` p"); + expect(views[0]!.sql).toContain("CASE WHEN p.`status` = 'active' THEN p.`id` END"); + }); + + test("d1 returns exactly the sqlite bodies", async () => { + const root = await loadModel(shop("with")); + const d1 = buildReportViews(root, { dialect: "d1", columnNamingStrategy: "literal" }); + const sqlite = buildReportViews(root, { dialect: "sqlite", columnNamingStrategy: "literal" }); + expect(d1).toEqual(sqlite); + expect(d1[0]!.sql).toContain('COUNT(CASE WHEN p."status" = \'active\' THEN p."id" END)'); + }); + + test("a view-backed report over a @from with no table throws, naming the report and the entity", async () => { + const root = await loadModel( + shop("with", (children) => { + const purchase = children.find((c) => (c["object.entity"] as Json | undefined)?.name === "Purchase"); + const kids = (purchase!["object.entity"] as { children: Json[] }).children; + kids.splice(kids.findIndex((k) => "source.rdb" in k), 1); + }), + ); + expect(() => buildReportViews(root, PG)).toThrow(/report 'StoreTotals'.*'Purchase'/); + expect(() => buildProjectionViews(root, PG)).toThrow(/StoreTotals/); + }); +}); diff --git a/server/typescript/packages/integration-tests/package.json b/server/typescript/packages/integration-tests/package.json index fb465d8fa..8fb9f00d5 100644 --- a/server/typescript/packages/integration-tests/package.json +++ b/server/typescript/packages/integration-tests/package.json @@ -7,6 +7,7 @@ "scripts": { "test": "bun test", "gen:schema": "bun run src/gen-canonical-schema.ts", + "gen:report-shapes": "bun run src/gen-report-shapes.ts", "oracle": "bun run src/oracle-cli.ts" }, "dependencies": { diff --git a/server/typescript/packages/integration-tests/src/gen-report-shapes.ts b/server/typescript/packages/integration-tests/src/gen-report-shapes.ts new file mode 100644 index 000000000..402526052 --- /dev/null +++ b/server/typescript/packages/integration-tests/src/gen-report-shapes.ts @@ -0,0 +1,99 @@ +// gen-report-shapes.ts — (re)generate the committed report-shapes artifact. +// +// Run: `bun run gen:report-shapes` (from this package). Pure metadata → JSON, no DB. +// Writes fixtures/persistence-conformance/canonical/report-shapes.json: the derived +// fields (contract Table B) of every object.report in the canonical model, in declaration +// order. TypeScript produces it; the C#, Java, Kotlin and Python ports each derive the +// same shapes from the same model and byte-match this file in a container-free unit test, +// so the derivation cannot drift between ports. +// +// Format (a contract, every port serialises the same bytes): reports in declaration order; +// keys in the order below; two-space indent; a trailing newline. `typeSource` is +// `.` or null; `view` is the report's +// OWN read-only source's physical name or null. + +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { + isReadOnlySource, + OBJECT_SUBTYPE_REPORT, + reportShape, + type MetaField, + type MetaRoot, +} from "@metaobjectsdev/metadata"; + +import { loadMetadataDir } from "./load-metadata.ts"; +import { CANONICAL_DIR } from "./paths.ts"; + +/** Absolute path to the committed report-shapes artifact. */ +export const REPORT_SHAPES_PATH = resolve(CANONICAL_DIR, "report-shapes.json"); + +interface ShapeFieldJson { + name: string; + role: string; + subType: string; + required: boolean; + typeSource: string | null; +} + +interface ShapeReportJson { + report: string; + from: string; + view: string | null; + fields: ShapeFieldJson[]; +} + +function typeSourceOf(field: MetaField | undefined): string | null { + if (field === undefined) return null; + const owner = field.parent; + if (owner === undefined) throw new Error(`field '${field.name}' has no owning entity.`); + return `${owner.resolutionKey()}.${field.name}`; +} + +/** The artifact's bytes for a loaded model. Deterministic: declaration order, no clock. */ +export function generateReportShapesJson(root: MetaRoot): string { + const reports: ShapeReportJson[] = []; + for (const report of root.objects()) { + if (report.subType !== OBJECT_SUBTYPE_REPORT) continue; + const shape = reportShape(report, root); + // ADR-0039: own — the report's own declared read-only source names its view; a report + // inherits no source, and a sourceless one has no view. + const source = report.ownChildren().find(isReadOnlySource); + reports.push({ + report: report.resolutionKey(), + from: shape.from.resolutionKey(), + view: source === undefined ? null : source.physicalName, + fields: shape.fields.map((f) => ({ + name: f.name, + role: f.role, + subType: f.subType, + required: f.required, + typeSource: typeSourceOf(f.typeSource), + })), + }); + } + return `${JSON.stringify({ reports }, null, 2)}\n`; +} + +/** Read the committed report-shapes artifact. */ +export function readReportShapesJson(): string { + return readFileSync(REPORT_SHAPES_PATH, "utf8"); +} + +async function main(): Promise { + const root = await loadMetadataDir(CANONICAL_DIR); + const json = generateReportShapesJson(root); + writeFileSync(REPORT_SHAPES_PATH, json, "utf8"); + /* eslint-disable no-console */ + console.log(`wrote ${REPORT_SHAPES_PATH} (${json.length} bytes)`); + /* eslint-enable no-console */ +} + +if (import.meta.main) { + main().catch((err: unknown) => { + // eslint-disable-next-line no-console + console.error(err); + process.exit(1); + }); +} diff --git a/server/typescript/packages/integration-tests/src/load-metadata.ts b/server/typescript/packages/integration-tests/src/load-metadata.ts index c37d02c23..4bbddf3b8 100644 --- a/server/typescript/packages/integration-tests/src/load-metadata.ts +++ b/server/typescript/packages/integration-tests/src/load-metadata.ts @@ -5,8 +5,16 @@ import { pathToFileURL } from "node:url"; import { loadDirectory, loadUris, type MetaRoot } from "@metaobjectsdev/metadata"; +/** + * Committed corpus artifacts that are JSON but are NOT metadata, so the directory loader + * (which takes every .json/.yaml/.yml it finds) must skip them. `report-shapes.json` sits + * beside `meta.fitness.json` in canonical/ and would otherwise fail the load with + * ERR_UNKNOWN_TYPE ("reports"). + */ +const NON_METADATA_ARTIFACTS: readonly string[] = ["report-shapes.json"]; + export async function loadMetadataDir(dir: string): Promise { - const result = await loadDirectory(dir); + const result = await loadDirectory(dir, { exclude: [...NON_METADATA_ARTIFACTS] }); if (result.errors.length > 0) { const summary = result.errors .map((e) => `${(e as { code?: string }).code ?? "ERROR"}: ${e.message}`) diff --git a/server/typescript/packages/integration-tests/test/report-shapes-artifact.test.ts b/server/typescript/packages/integration-tests/test/report-shapes-artifact.test.ts new file mode 100644 index 000000000..1eaaf24e2 --- /dev/null +++ b/server/typescript/packages/integration-tests/test/report-shapes-artifact.test.ts @@ -0,0 +1,51 @@ +// report-shapes-artifact.test.ts — drift-check for the committed report-shapes artifact. +// +// No DB required. Regenerates the derived fields of every canonical object.report from +// canonical/meta.fitness.json and asserts they are byte-identical to the committed +// fixtures/persistence-conformance/canonical/report-shapes.json, which every other port +// byte-matches against its own derivation (contract Table B). +// +// Regenerate the artifact with: `bun run gen:report-shapes` (in this package). + +import { describe, expect, test } from "bun:test"; + +import { + generateReportShapesJson, + readReportShapesJson, + REPORT_SHAPES_PATH, +} from "../src/gen-report-shapes.ts"; +import { loadMetadataDir } from "../src/load-metadata.ts"; +import { CANONICAL_DIR } from "../src/paths.ts"; + +describe("canonical report-shapes artifact (report-shapes.json)", () => { + test("committed shapes match what TS derives from metadata (no drift)", async () => { + const root = await loadMetadataDir(CANONICAL_DIR); + const generated = generateReportShapesJson(root); + const committed = readReportShapesJson(); + + if (generated !== committed) { + throw new Error( + `Report-shapes artifact is stale.\n` + + ` ${REPORT_SHAPES_PATH}\n` + + `differs from what TS derives from canonical/meta.fitness.json.\n` + + `Run \`bun run gen:report-shapes\` to regenerate, then commit the result.`, + ); + } + expect(generated).toBe(committed); + }); + + test("the six canonical reports appear in declaration order, with the contract's byte format", () => { + const committed = readReportShapesJson(); + expect(committed.endsWith("}\n")).toBe(true); + expect(committed.startsWith('{\n "reports": [\n {\n "report": "fitness::ProgramMinutes"')).toBe(true); + const parsed = JSON.parse(committed) as { reports: { report: string; view: string | null }[] }; + expect(parsed.reports.map((r) => [r.report, r.view])).toEqual([ + ["fitness::ProgramMinutes", "v_program_minutes"], + ["fitness::FitnessTotals", "v_fitness_totals"], + ["fitness::ProgramsByMonth", "v_programs_by_month"], + ["fitness::ProgramsByWeek", "v_programs_by_week"], + ["fitness::RecentPrograms", "v_recent_programs"], + ["fitness::AssetActivity", "v_asset_activity"], + ]); + }); +}); From d2345abdd9a9e1e1997350380f4c9df0072a946f Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 07:48:32 -0400 Subject: [PATCH 07/32] fix(integration-tests): keep the report-shapes artifact outside the canonical metadata directory (FR-044) --- .../{canonical => }/report-shapes.json | 0 .../integration-tests/src/gen-report-shapes.ts | 9 ++++++--- .../packages/integration-tests/src/load-metadata.ts | 10 +--------- .../test/report-shapes-artifact.test.ts | 2 +- .../metadata/src/core/reporting/report-shape.ts | 2 +- 5 files changed, 9 insertions(+), 14 deletions(-) rename fixtures/persistence-conformance/{canonical => }/report-shapes.json (100%) diff --git a/fixtures/persistence-conformance/canonical/report-shapes.json b/fixtures/persistence-conformance/report-shapes.json similarity index 100% rename from fixtures/persistence-conformance/canonical/report-shapes.json rename to fixtures/persistence-conformance/report-shapes.json diff --git a/server/typescript/packages/integration-tests/src/gen-report-shapes.ts b/server/typescript/packages/integration-tests/src/gen-report-shapes.ts index 402526052..b6a043696 100644 --- a/server/typescript/packages/integration-tests/src/gen-report-shapes.ts +++ b/server/typescript/packages/integration-tests/src/gen-report-shapes.ts @@ -1,7 +1,7 @@ // gen-report-shapes.ts — (re)generate the committed report-shapes artifact. // // Run: `bun run gen:report-shapes` (from this package). Pure metadata → JSON, no DB. -// Writes fixtures/persistence-conformance/canonical/report-shapes.json: the derived +// Writes fixtures/persistence-conformance/report-shapes.json: the derived // fields (contract Table B) of every object.report in the canonical model, in declaration // order. TypeScript produces it; the C#, Java, Kotlin and Python ports each derive the // same shapes from the same model and byte-match this file in a container-free unit test, @@ -11,6 +11,9 @@ // keys in the order below; two-space indent; a trailing newline. `typeSource` is // `.` or null; `view` is the report's // OWN read-only source's physical name or null. +// +// The artifact sits BESIDE canonical/, not inside it: every port directory-loads +// canonical/ as metadata, and a non-metadata .json there fails the load. import { readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; @@ -24,10 +27,10 @@ import { } from "@metaobjectsdev/metadata"; import { loadMetadataDir } from "./load-metadata.ts"; -import { CANONICAL_DIR } from "./paths.ts"; +import { CANONICAL_DIR, CORPUS_DIR } from "./paths.ts"; /** Absolute path to the committed report-shapes artifact. */ -export const REPORT_SHAPES_PATH = resolve(CANONICAL_DIR, "report-shapes.json"); +export const REPORT_SHAPES_PATH = resolve(CORPUS_DIR, "report-shapes.json"); interface ShapeFieldJson { name: string; diff --git a/server/typescript/packages/integration-tests/src/load-metadata.ts b/server/typescript/packages/integration-tests/src/load-metadata.ts index 4bbddf3b8..c37d02c23 100644 --- a/server/typescript/packages/integration-tests/src/load-metadata.ts +++ b/server/typescript/packages/integration-tests/src/load-metadata.ts @@ -5,16 +5,8 @@ import { pathToFileURL } from "node:url"; import { loadDirectory, loadUris, type MetaRoot } from "@metaobjectsdev/metadata"; -/** - * Committed corpus artifacts that are JSON but are NOT metadata, so the directory loader - * (which takes every .json/.yaml/.yml it finds) must skip them. `report-shapes.json` sits - * beside `meta.fitness.json` in canonical/ and would otherwise fail the load with - * ERR_UNKNOWN_TYPE ("reports"). - */ -const NON_METADATA_ARTIFACTS: readonly string[] = ["report-shapes.json"]; - export async function loadMetadataDir(dir: string): Promise { - const result = await loadDirectory(dir, { exclude: [...NON_METADATA_ARTIFACTS] }); + const result = await loadDirectory(dir); if (result.errors.length > 0) { const summary = result.errors .map((e) => `${(e as { code?: string }).code ?? "ERROR"}: ${e.message}`) diff --git a/server/typescript/packages/integration-tests/test/report-shapes-artifact.test.ts b/server/typescript/packages/integration-tests/test/report-shapes-artifact.test.ts index 1eaaf24e2..ce3b6ffb4 100644 --- a/server/typescript/packages/integration-tests/test/report-shapes-artifact.test.ts +++ b/server/typescript/packages/integration-tests/test/report-shapes-artifact.test.ts @@ -2,7 +2,7 @@ // // No DB required. Regenerates the derived fields of every canonical object.report from // canonical/meta.fitness.json and asserts they are byte-identical to the committed -// fixtures/persistence-conformance/canonical/report-shapes.json, which every other port +// fixtures/persistence-conformance/report-shapes.json, which every other port // byte-matches against its own derivation (contract Table B). // // Regenerate the artifact with: `bun run gen:report-shapes` (in this package). 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 0c5756068..57c92df2c 100644 --- a/server/typescript/packages/metadata/src/core/reporting/report-shape.ts +++ b/server/typescript/packages/metadata/src/core/reporting/report-shape.ts @@ -1,6 +1,6 @@ // Table B of docs/superpowers/plans/2026-10-03-fr-044-plan-2-report-view-lowering.md: // a report's derived fields. The single definition; every port has a rule-for-rule copy, -// gated by fixtures/persistence-conformance/canonical/report-shapes.json. +// gated by fixtures/persistence-conformance/report-shapes.json. import type { MetaData } from "../../shared/meta-data.js"; import type { MetaRoot } from "../../shared/meta-root.js"; From 34cbe14042b5bd577745fcd52430dfa64781f322 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 07:53:15 -0400 Subject: [PATCH 08/32] test(integration): report views converge and return the expected rows on Postgres and SQLite (FR-044) Postgres: convergence x3, the Task 10 values read straight off the six canonical views, UTC buckets under a New York session zone, empty-group row, INNER vs LEFT OUTER join, relative window, and a changed report taking the drop-and-create path. SQLite: convergence (verbatim text), the same values, week boundary, quarter and year grains, relative window, tuple distinct count via json_array, and the hour bucket's literal pinned to the .000Z spelling the TS adapters store. No emitter change. --- .../test/report-views-pg.test.ts | 431 ++++++++++++++++++ .../test/report-views-sqlite.test.ts | 320 +++++++++++++ 2 files changed, 751 insertions(+) create mode 100644 server/typescript/packages/integration-tests/test/report-views-pg.test.ts create mode 100644 server/typescript/packages/integration-tests/test/report-views-sqlite.test.ts diff --git a/server/typescript/packages/integration-tests/test/report-views-pg.test.ts b/server/typescript/packages/integration-tests/test/report-views-pg.test.ts new file mode 100644 index 000000000..c593e4756 --- /dev/null +++ b/server/typescript/packages/integration-tests/test/report-views-pg.test.ts @@ -0,0 +1,431 @@ +/** + * Report views — FR-044 Plan 2, against a REAL Postgres. + * + * Earlier tasks proved the lowering as text (emitter goldens) and through the unit-level + * view builder. This is where a report view first meets an engine: it must (1) CONVERGE + * under `meta migrate` (emit -> apply -> re-diff empty, which is what Postgres deparsing + * the stored view body makes non-trivial), and (2) return the expected rows. + * + * The rows are the ones the shared Task 10 persistence scenarios assert. They are read + * here with raw SQL straight off the views, so a wrong value is a LOWERING defect, not + * an ObjectManager one. + * + * Raw values are compared as the engine sends them (dates and timestamps as text, int8 + * and numeric as strings). Postgres pads numeric precision (`AVG(int)` is + * `60.0000000000000000`), so decimals are compared through `canonicalDecimal`, which is + * what the conformance runner's wire normalisation does too. + */ + +import { describe, test, expect, beforeAll, afterAll, beforeEach } from "bun:test"; +import { + buildExpectedSchema, diff, emit, introspectPostgres, collectUnmanagedNames, + type AllowOptions, type SchemaSnapshot, +} from "@metaobjectsdev/migrate-ts"; +import { buildProjectionViews } from "@metaobjectsdev/codegen-ts"; +import { MetaDataLoader, InMemoryStringSource, type MetaRoot } from "@metaobjectsdev/metadata"; +import { Kysely, PostgresDialect, sql } from "kysely"; +import { Client, Pool } from "pg"; +import { startPostgres, type RunningPg } from "../src/postgres-container.ts"; +import { loadMetadataDir } from "../src/load-metadata.ts"; +import { CANONICAL_DIR } from "../src/paths.ts"; + +// --------------------------------------------------------------------------------- +// Container + pipeline helpers (the same shape as view-lifecycle-pg.test.ts). +// --------------------------------------------------------------------------------- + +let pg: RunningPg; +let k: Kysely>; +let canonical: MetaRoot; + +beforeAll(async () => { + pg = await startPostgres(); + k = new Kysely>({ + dialect: new PostgresDialect({ pool: new Pool({ connectionString: pg.connectionUri }) }), + }); + canonical = await loadMetadataDir(CANONICAL_DIR); +}, 120_000); + +afterAll(async () => { + await k.destroy(); + await pg.stop(); +}, 60_000); + +beforeEach(async () => { + await sql.raw("DROP SCHEMA public CASCADE").execute(k); + await sql.raw("CREATE SCHEMA public").execute(k); +}); + +async function applyRaw(text: string): Promise { + for (const stmt of text.split(/;\s*\n/).map((s) => s.trim()).filter(Boolean)) { + await sql.raw(stmt.endsWith(";") ? stmt : `${stmt};`).execute(k); + } +} + +async function loadInline(metaJson: string): Promise { + const r = await new MetaDataLoader().load([new InMemoryStringSource(metaJson)]); + return r.root; +} + +function expectedFor(root: MetaRoot): SchemaSnapshot { + return buildExpectedSchema(root, { + columnNamingStrategy: "literal", + views: buildProjectionViews(root, { dialect: "postgres", columnNamingStrategy: "literal" }), + }); +} + +/** build -> introspect -> diff -> emit -> apply, exactly the production pipeline. */ +async function migrate(root: MetaRoot, allow: AllowOptions = {}) { + const expected = expectedFor(root); + const unmanagedNames = collectUnmanagedNames(root); + const result = await diff({ expected, actual: await introspectPostgres(k), dialect: "postgres", allow, unmanagedNames }); + const emittable = result.changes.filter((c) => c.status.state !== "blocked"); + const { up } = emittable.length === 0 ? { up: "" } : emit(emittable, { dialect: "postgres" }); + if (result.blocked.length === 0 && up.trim().length > 0) await applyRaw(up); + return { expected, unmanagedNames, result, up }; +} + +/** THE gate: re-diffing the just-migrated database proposes nothing. */ +async function assertConverged(expected: SchemaSnapshot, unmanagedNames: string[] = []): Promise { + const followup = await diff({ expected, actual: await introspectPostgres(k), dialect: "postgres", unmanagedNames }); + if (followup.changes.length > 0) { + console.error("NOT CONVERGED — a further `meta migrate` would emit:"); + for (const c of followup.changes) console.error(" -", c.kind, JSON.stringify(c).slice(0, 200)); + } + expect(followup.changes).toEqual([]); +} + +/** + * Raw, text-faithful reads: only int4 becomes a number; int8, numeric, dates and + * timestamps stay the strings Postgres sent, so nothing is reshaped by the driver. + * `sessionZone` runs the connection in that time zone (Review Focus 3). + */ +async function select(query: string, sessionZone?: string): Promise[]> { + const c = new Client({ + connectionString: pg.connectionUri, + types: { getTypeParser: (oid: number) => (oid === 23 ? (v: string) => Number(v) : (v: string) => v) }, + }); + await c.connect(); + try { + if (sessionZone !== undefined) await c.query(`SET TIME ZONE '${sessionZone}'`); + return (await c.query(query)).rows as Record[]; + } finally { + await c.end(); + } +} + +/** `60.0000000000000000` -> `60`, `0.75000000000000000000` -> `0.75`; null stays null. */ +function canonicalDecimal(v: unknown): string | null { + if (v === null || v === undefined) return null; + const s = String(v); + return s.includes(".") ? s.replace(/\.?0+$/, "") : s; +} + +/** The `CREATE VIEW` body this model's builder produces for `name`. */ +function viewSql(root: MetaRoot, name: string): string { + const v = buildProjectionViews(root, { dialect: "postgres", columnNamingStrategy: "literal" }) + .find((x) => x.name === name); + if (v === undefined) throw new Error(`no view ${name}`); + return v.sql; +} + +// --------------------------------------------------------------------------------- +// Seeds — verbatim from the Task 10 scenarios. +// --------------------------------------------------------------------------------- + +const SEED_PROGRAMS_AND_WEEKS = ` + INSERT INTO "programs" ("id","title","priceCents","status","created_ts") VALUES + (1, 'Foundations', 4999, 'PUBLISHED', '2026-05-01T10:00:00'), + (2, 'Strength', 2500, 'PUBLISHED', '2026-05-17T23:30:00'); + INSERT INTO "weeks" ("id","programId","label","durationMinutes") VALUES + (10, 1, 'Week 1', 30), + (11, 1, 'Week 2', 60), + (12, 1, 'Week 2', 90), + (13, 1, NULL, 60), + (20, 2, 'Solo', 45);`; + +const SEED_PROGRAMS_BY_TIME = ` + INSERT INTO "programs" ("id","title","priceCents","status","created_ts") VALUES + (1, 'Foundations', 4999, 'PUBLISHED', '2026-05-01T10:00:00'), + (2, 'Strength', 2500, 'PUBLISHED', '2026-05-17T23:30:00'), + (3, 'Mobility', 1000, 'DRAFT', '2026-06-01T00:00:00'), + (4, 'Legacy', 700, 'ARCHIVED', '2026-05-31T23:59:59'), + (5, 'Monday', 300, 'PUBLISHED', '2026-05-18T00:00:00');`; + +const SEED_ASSETS = ` + INSERT INTO "assets" ("ownerId","recordedAt","observedAt","asOfDate","atTime") VALUES + ('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', '2026-05-04T03:30:00Z', '2026-05-04T03:30:00', '2026-05-03', '03:30:00'), + ('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', '2026-05-04T03:45:00Z', '2026-05-04T03:45:00', '2026-05-03', '03:45:00'), + ('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', '2026-05-04T04:10:00Z', '2026-05-04T04:10:00', '2026-05-04', '04:10:00');`; + +const HOUR_BUCKET = `to_char("recordedAtHour" AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`; + +describe("report views — canonical model on real Postgres", () => { + // ------------------------------------------------------------------------- + // Convergence (spec §7: emit -> apply -> re-diff empty). + // ------------------------------------------------------------------------- + + test("CONVERGENCE: the six canonical report views migrate from empty, then a second and third migrate propose nothing", async () => { + const first = await migrate(canonical); + + for (const view of [ + "v_program_minutes", "v_fitness_totals", "v_programs_by_month", + "v_programs_by_week", "v_recent_programs", "v_asset_activity", + ]) { + expect(first.up).toContain(`CREATE VIEW "${view}" AS`); + const r = await sql.raw(`SELECT to_regclass('public.${view}') IS NOT NULL AS ok`).execute(k); + expect((r.rows[0] as { ok: boolean }).ok).toBe(true); + } + + await assertConverged(first.expected, first.unmanagedNames); + + const second = await migrate(canonical); + expect(second.up.trim()).toBe(""); + expect(second.result.changes).toEqual([]); + await assertConverged(second.expected, second.unmanagedNames); + + const third = await migrate(canonical); + expect(third.up.trim()).toBe(""); + expect(third.result.changes).toEqual([]); + }, 60_000); + + // ------------------------------------------------------------------------- + // Join type: the existing #209 rule, unchanged. + // ------------------------------------------------------------------------- + + test("JOIN TYPE: Week.fkProgram is a required belongs-to, so v_program_minutes joins programs INNER", () => { + const body = viewSql(canonical, "v_program_minutes"); + expect(body).toContain(`INNER JOIN "programs" p ON p."id" = w."programId"`); + expect(body).not.toContain("LEFT OUTER JOIN"); + }); + + // ------------------------------------------------------------------------- + // Values — the Task 10 scenarios, read straight off the views. + // ------------------------------------------------------------------------- + + describe("values", () => { + beforeEach(async () => { + await migrate(canonical); + }); + + test("v_program_minutes: every measure kind, one row per (program, programTitle)", async () => { + await applyRaw(SEED_PROGRAMS_AND_WEEKS); + const rows = await select(`SELECT * FROM "v_program_minutes" ORDER BY "program"`); + expect(rows.map((r) => ({ ...r, avgMinutes: canonicalDecimal(r.avgMinutes), longShare: canonicalDecimal(r.longShare) }))) + .toEqual([ + { program: "1", programTitle: "Foundations", weeks: "4", longWeeks: "3", labels: "2", slots: "3", + totalMinutes: "240", avgMinutes: "60", minMinutes: 30, maxMinutes: 90, longShare: "0.75" }, + { program: "2", programTitle: "Strength", weeks: "1", longWeeks: "0", labels: "1", slots: "1", + totalMinutes: "45", avgMinutes: "45", minMinutes: 45, maxMinutes: 45, longShare: "0" }, + ]); + + // The measure-filter, sort and count the shared scenario runs through the view. + expect( + (await select(`SELECT "program" FROM "v_program_minutes" WHERE "weeks" >= 2 ORDER BY "program"`)).map((r) => r.program), + ).toEqual(["1"]); + expect( + (await select(`SELECT "program" FROM "v_program_minutes" ORDER BY "totalMinutes" DESC LIMIT 1`)).map((r) => r.program), + ).toEqual(["1"]); + expect((await select(`SELECT count(*) AS n FROM "v_program_minutes"`))[0]?.n).toBe("2"); + }); + + test("v_fitness_totals: no dimensions, one row over the whole table", async () => { + await applyRaw(SEED_PROGRAMS_AND_WEEKS); + const rows = await select(`SELECT * FROM "v_fitness_totals"`); + expect(rows.map((r) => ({ ...r, longShare: canonicalDecimal(r.longShare) }))) + .toEqual([{ weeks: "5", totalMinutes: "285", longShare: "0.6" }]); + }); + + test("EMPTY GROUPS (Review Focus 4): v_fitness_totals over an empty weeks table is one row (0, NULL, NULL)", async () => { + const count = await select(`SELECT count(*) AS n FROM "weeks"`); + expect(count[0]?.n).toBe("0"); + const rows = await select(`SELECT * FROM "v_fitness_totals"`); + // One row, count 0, a sum of nothing is NULL (not 0), a zero denominator is NULL. + expect(rows).toEqual([{ weeks: "0", totalMinutes: null, longShare: null }]); + }); + + test("v_programs_by_month and v_programs_by_week: month grain, enum dimension, null filtered sum, ISO Monday boundary, report @segment", async () => { + await applyRaw(SEED_PROGRAMS_BY_TIME); + + const month = await select(`SELECT * FROM "v_programs_by_month" ORDER BY "status"`); + expect(month).toEqual([ + { createdAtMonth: "2026-05-01", status: "ARCHIVED", programs: "1", listValue: null }, + { createdAtMonth: "2026-06-01", status: "DRAFT", programs: "1", listValue: null }, + { createdAtMonth: "2026-05-01", status: "PUBLISHED", programs: "3", listValue: "7799" }, + ]); + + // Program 2 (Sunday 23:30) and program 5 (Monday 00:00) are thirty minutes apart + // and land in different weeks; DRAFT / ARCHIVED are scoped out by the segment. + const week = await select(`SELECT * FROM "v_programs_by_week" ORDER BY "createdAtWeek"`); + expect(week).toEqual([ + { createdAtWeek: "2026-04-27", programs: "1" }, + { createdAtWeek: "2026-05-11", programs: "1" }, + { createdAtWeek: "2026-05-18", programs: "1" }, + ]); + }); + + test("v_asset_activity: hour on an instant, week on a field.date", async () => { + await applyRaw(SEED_ASSETS); + const rows = await select( + `SELECT ${HOUR_BUCKET} AS "recordedAtHour", "asOfDateWeek"::text AS "asOfDateWeek", "assets" + FROM "v_asset_activity" ORDER BY "recordedAtHour"`, + ); + expect(rows).toEqual([ + { recordedAtHour: "2026-05-04T03:00:00Z", asOfDateWeek: "2026-04-27", assets: "2" }, + { recordedAtHour: "2026-05-04T04:00:00Z", asOfDateWeek: "2026-05-04", assets: "1" }, + ]); + }); + + test("UTC BUCKETS (Review Focus 3): under a New York session zone an instant still lands in its UTC hour bucket", async () => { + // 23:30 on 2026-05-03 in New York (UTC-4 in May) is 03:30 on 2026-05-04 UTC. + await sql.raw( + `INSERT INTO "assets" ("ownerId","recordedAt","observedAt","asOfDate","atTime") VALUES + ('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', '2026-05-03T23:30:00-04:00', '2026-05-03T23:30:00', '2026-05-03', '23:30:00')`, + ).execute(k); + const rows = await select( + `SELECT ${HOUR_BUCKET} AS "recordedAtHour", "assets" FROM "v_asset_activity"`, + "America/New_York", + ); + expect(rows).toEqual([{ recordedAtHour: "2026-05-04T03:00:00Z", assets: "1" }]); + // And the session really was not UTC while it ran. + expect((await select(`SHOW TIME ZONE`, "America/New_York"))[0]).toEqual({ TimeZone: "America/New_York" }); + }); + + test("RELATIVE WINDOW: v_recent_programs counts the program 3 days old, not the one 60 days old", async () => { + await applyRaw(` + INSERT INTO "programs" ("id","title","priceCents","status","created_ts") VALUES + (1, 'Recent', 100, 'PUBLISHED', (now() AT TIME ZONE 'UTC') - INTERVAL '3 days'), + (2, 'Stale', 100, 'PUBLISHED', (now() AT TIME ZONE 'UTC') - INTERVAL '60 days');`); + expect(await select(`SELECT * FROM "v_recent_programs"`)).toEqual([{ programs: "1" }]); + }); + }); +}); + +// --------------------------------------------------------------------------------- +// Inline models: the behaviours the canonical corpus does not exercise. +// --------------------------------------------------------------------------------- + +/** An Event table, hour and day reports over its UTC instant (Review Focus 3, day grain). */ +const EVENT_MODEL = JSON.stringify({ "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Event", children: [ + { "source.rdb": { "@table": "events" } }, + { "field.long": { name: "id" } }, + { "field.timestamp": { name: "recordedAt", "@required": true } }, + { "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } }, + { "dimension.time": { name: "recordedAt", "@of": "Event.recordedAt", "@grains": ["hour", "day"] } }, + { "measure.aggregate": { name: "events", "@agg": "count", "@of": "Event.id" } }, + ] } }, + { "object.report": { name: "EventsByDay", "@from": "Event", "@dimensions": ["recordedAt:day"], "@measures": ["events"], children: [ + { "source.rdb": { "@kind": "view", "@view": "v_events_by_day" } } ] } }, + { "object.report": { name: "EventsByHour", "@from": "Event", "@dimensions": ["recordedAt:hour"], "@measures": ["events"], children: [ + { "source.rdb": { "@kind": "view", "@view": "v_events_by_hour" } } ] } }, +]}}); + +/** A Fact whose program reference is NULLABLE, grouped by the referenced program's title. */ +const FACT_MODEL = JSON.stringify({ "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Prog", children: [ + { "source.rdb": { "@table": "progs" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "title", "@required": true } }, + { "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } }, + ] } }, + { "object.entity": { name: "Fact", children: [ + { "source.rdb": { "@table": "facts" } }, + { "field.long": { name: "id" } }, + { "field.long": { name: "progId" } }, + { "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } }, + { "identity.reference": { name: "fkProg", "@fields": "progId", "@references": "Prog" } }, + { "dimension.attribute": { name: "progTitle", "@of": "Prog.title", "@via": "Fact.fkProg" } }, + { "measure.aggregate": { name: "facts", "@agg": "count", "@of": "Fact.id" } }, + ] } }, + { "object.report": { name: "FactsByProg", "@from": "Fact", "@dimensions": ["progTitle"], "@measures": ["facts"], children: [ + { "source.rdb": { "@kind": "view", "@view": "v_facts_by_prog" } } ] } }, +]}}); + +/** A Metric table with one measure, optionally a second; the report lists what it has. */ +function metricModel(measures: string[]): string { + const all = [ + { "measure.aggregate": { name: "samples", "@agg": "count", "@of": "Metric.id" } }, + { "measure.aggregate": { name: "total", "@agg": "sum", "@of": "Metric.amount" } }, + ]; + return JSON.stringify({ "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Metric", children: [ + { "source.rdb": { "@table": "metrics" } }, + { "field.long": { name: "id" } }, + { "field.int": { name: "amount", "@required": true } }, + { "field.string": { name: "kind", "@required": true } }, + { "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } }, + { "dimension.attribute": { name: "kind", "@of": "Metric.kind" } }, + ...all, + ] } }, + { "object.report": { name: "MetricsByKind", "@from": "Metric", "@dimensions": ["kind"], "@measures": measures, children: [ + { "source.rdb": { "@kind": "view", "@view": "v_metrics_by_kind" } } ] } }, + ]}}); +} + +describe("report views — inline models on real Postgres", () => { + test("UTC BUCKETS (Review Focus 3): a report at recordedAt:day puts 23:30 New York on the next UTC day", async () => { + const root = await loadInline(EVENT_MODEL); + const { expected, unmanagedNames } = await migrate(root); + await assertConverged(expected, unmanagedNames); + + await sql.raw( + `INSERT INTO "events" ("recordedAt") VALUES ('2026-05-03T23:30:00-04:00')`, + ).execute(k); + + const day = await select(`SELECT "recordedAtDay"::text AS "recordedAtDay", "events" FROM "v_events_by_day"`, "America/New_York"); + expect(day).toEqual([{ recordedAtDay: "2026-05-04", events: "1" }]); + const hour = await select( + `SELECT ${HOUR_BUCKET} AS "recordedAtHour", "events" FROM "v_events_by_hour"`, + "America/New_York", + ); + expect(hour).toEqual([{ recordedAtHour: "2026-05-04T03:00:00Z", events: "1" }]); + + // The same rows under a UTC session: the buckets are the reader's-zone-independent. + expect(await select(`SELECT "recordedAtDay"::text AS "recordedAtDay", "events" FROM "v_events_by_day"`, "UTC")) + .toEqual(day); + }, 60_000); + + test("JOIN TYPE: a NULLABLE @via FK lowers to LEFT OUTER JOIN, and a fact with a NULL FK lands in a NULL group", async () => { + const root = await loadInline(FACT_MODEL); + const body = viewSql(root, "v_facts_by_prog"); + expect(body).toContain(`LEFT OUTER JOIN "progs"`); + expect(body).not.toContain("INNER JOIN"); + + const { expected, unmanagedNames } = await migrate(root); + await assertConverged(expected, unmanagedNames); + + await applyRaw(` + INSERT INTO "progs" ("id","title") VALUES (1, 'Alpha'); + INSERT INTO "facts" ("id","progId") VALUES (1, 1), (2, 1), (3, NULL), (4, NULL), (5, NULL);`); + const rows = await select(`SELECT * FROM "v_facts_by_prog" ORDER BY "progTitle" NULLS LAST`); + expect(rows).toEqual([ + { progTitle: "Alpha", facts: "2" }, + { progTitle: null, facts: "3" }, + ]); + }, 60_000); + + test("CHANGE A REPORT: adding a measure is a drop and a create of that view, and then converges", async () => { + const before = await loadInline(metricModel(["samples"])); + const first = await migrate(before); + await assertConverged(first.expected, first.unmanagedNames); + + const after = await loadInline(metricModel(["samples", "total"])); + const { result, up, expected, unmanagedNames } = await migrate(after, { dropView: true }); + + const viewChanges = result.changes.filter((c) => c.kind.endsWith("-view")); + expect(viewChanges.map((c) => c.kind).sort()).toEqual(["create-view", "drop-view"]); + expect(up).toContain(`DROP VIEW`); + expect(up).toContain(`CREATE VIEW "v_metrics_by_kind" AS`); + // A fail-safe drop and create, not CREATE OR REPLACE (the report carries no column list). + expect(up).not.toContain("CREATE OR REPLACE"); + + const cols = await sql.raw( + `SELECT column_name FROM information_schema.columns WHERE table_name = 'v_metrics_by_kind' ORDER BY ordinal_position`, + ).execute(k); + expect((cols.rows as { column_name: string }[]).map((c) => c.column_name)).toEqual(["kind", "samples", "total"]); + + await assertConverged(expected, unmanagedNames); + const again = await migrate(after, { dropView: true }); + expect(again.up.trim()).toBe(""); + }, 60_000); +}); diff --git a/server/typescript/packages/integration-tests/test/report-views-sqlite.test.ts b/server/typescript/packages/integration-tests/test/report-views-sqlite.test.ts new file mode 100644 index 000000000..46708fe43 --- /dev/null +++ b/server/typescript/packages/integration-tests/test/report-views-sqlite.test.ts @@ -0,0 +1,320 @@ +/** + * Report views — FR-044 Plan 2, against a REAL SQLite (libsql). + * + * The Postgres counterpart is `report-views-pg.test.ts`. SQLite lowers a report + * differently on every axis the contract tables name: conditional aggregates are + * `CASE WHEN`, the tuple distinct count goes through `json_array`, time grains are + * `strftime` / `date` modifiers over ISO-8601 TEXT, and a ratio divides as `REAL`. + * + * CONVERGENCE here is a different claim than on Postgres. SQLite stores a view's SQL + * text verbatim and introspection reads it back, so a second diff is empty only when the + * emitter is DETERMINISTIC and its text survives the round trip byte for byte. + * + * THE INSTANT'S LITERAL (contract Table D, UNVERIFIED item, resolved below). Both + * TypeScript SQLite writers spell an instant `YYYY-MM-DDTHH:MM:SS.sssZ`, with a + * three-digit fraction even when it is zero: + * - the application stamp is `new Date().toISOString()` (`@autoSet`), and + * - the DDL default is `strftime('%Y-%m-%dT%H:%M:%fZ','now')` (migrate-ts + * `SQLITE_ISO_NOW`, which `drizzle-schema.ts` mirrors byte for byte). + * Table D's hour bucket for an instant is `strftime('%Y-%m-%dT%H:00:00.000Z', x)`, which + * has that same spelling, so a bucket is text-comparable with a stored instant. A value + * written without the fraction (`...:00Z`, what a client hands the wire in) is parsed by + * `strftime` identically and lands in the same bucket; only its own stored text differs. + */ + +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Kysely, sql } from "kysely"; +import { LibsqlDialect } from "@libsql/kysely-libsql"; +import { buildExpectedSchema, diff, emit, introspectSqlite, type SchemaSnapshot } from "@metaobjectsdev/migrate-ts"; +import { buildProjectionViews } from "@metaobjectsdev/codegen-ts"; +import { MetaDataLoader, InMemoryStringSource, type MetaRoot } from "@metaobjectsdev/metadata"; +import { loadMetadataDir } from "../src/load-metadata.ts"; +import { CANONICAL_DIR } from "../src/paths.ts"; + +let tmpDir: string; +let k: Kysely>; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "report-views-sqlite-")); + k = new Kysely({ dialect: new LibsqlDialect({ url: `file:${join(tmpDir, "test.db")}` }) }); +}); + +afterEach(async () => { + await k.destroy(); + rmSync(tmpDir, { recursive: true, force: true }); +}); + +// libsql execute() is single-statement: split on ";" (no view body or seed carries an inner ";"). +async function applyRaw(text: string): Promise { + for (const stmt of text.trim().split(";").map((s) => s.trim()).filter(Boolean)) { + await sql.raw(stmt).execute(k); + } +} + +async function loadInline(metaJson: string): Promise { + return (await new MetaDataLoader().load([new InMemoryStringSource(metaJson)])).root; +} + +function expectedFor(root: MetaRoot): SchemaSnapshot { + return buildExpectedSchema(root, { + dialect: "sqlite", + columnNamingStrategy: "literal", + views: buildProjectionViews(root, { dialect: "sqlite", columnNamingStrategy: "literal" }), + }); +} + +/** + * `field.inet` has no SQLite storage class of its own: the canonical model's `all_types` + * table declares two, SQLite introspects them back as TEXT, and the diff reports a blocked + * `text -> inet` change on every run. That is a property of the table, not of any report + * (nothing here reads `all_types`), and it is the only residue the canonical model leaves. + * It is named, not swallowed: a residual change on any OTHER table or on any view fails. + */ +const isInetResidue = (c: { kind: string; table?: string }): boolean => + c.kind === "change-column-type" && c.table === "all_types"; + +/** build -> introspect -> diff -> emit -> apply. */ +async function migrate(root: MetaRoot) { + const expected = expectedFor(root); + const actual = await introspectSqlite(k); + const result = await diff({ expected, actual, dialect: "sqlite" }); + expect(result.blocked.filter((c) => !isInetResidue(c))).toEqual([]); + const { up } = emit(result.changes.filter((c) => !isInetResidue(c)), { + dialect: "sqlite", + expectedSchema: expected, + ...(actual.meta !== undefined && { actualMeta: actual.meta }), + }); + if (up.trim().length > 0) await applyRaw(up); + return { expected, result, up }; +} + +async function assertConverged(expected: SchemaSnapshot): Promise { + const followup = await diff({ expected, actual: await introspectSqlite(k), dialect: "sqlite" }); + const residual = followup.changes.filter((c) => !isInetResidue(c)); + if (residual.length > 0) { + console.error("NOT CONVERGED (sqlite) — a further migrate would emit:"); + for (const c of residual) console.error(" -", c.kind, JSON.stringify(c).slice(0, 200)); + } + expect(residual).toEqual([]); +} + +async function select(query: string): Promise[]> { + return (await sql.raw(query).execute(k)).rows as Record[]; +} + +function viewSql(root: MetaRoot, name: string): string { + const v = buildProjectionViews(root, { dialect: "sqlite", columnNamingStrategy: "literal" }) + .find((x) => x.name === name); + if (v === undefined) throw new Error(`no view ${name}`); + return v.sql; +} + +const SEED_PROGRAMS_AND_WEEKS = ` + INSERT INTO "programs" ("id","title","priceCents","status","created_ts") VALUES + (1, 'Foundations', 4999, 'PUBLISHED', '2026-05-01T10:00:00'), + (2, 'Strength', 2500, 'PUBLISHED', '2026-05-17T23:30:00'); + INSERT INTO "weeks" ("id","programId","label","durationMinutes") VALUES + (10, 1, 'Week 1', 30), + (11, 1, 'Week 2', 60), + (12, 1, 'Week 2', 90), + (13, 1, NULL, 60), + (20, 2, 'Solo', 45)`; + +const SEED_PROGRAMS_BY_TIME = ` + INSERT INTO "programs" ("id","title","priceCents","status","created_ts") VALUES + (1, 'Foundations', 4999, 'PUBLISHED', '2026-05-01T10:00:00'), + (2, 'Strength', 2500, 'PUBLISHED', '2026-05-17T23:30:00'), + (3, 'Mobility', 1000, 'DRAFT', '2026-06-01T00:00:00'), + (4, 'Legacy', 700, 'ARCHIVED', '2026-05-31T23:59:59'), + (5, 'Monday', 300, 'PUBLISHED', '2026-05-18T00:00:00')`; + +const OWNER = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + +describe("report views — canonical model on real SQLite", () => { + let canonical: MetaRoot; + let expected: SchemaSnapshot; + + beforeEach(async () => { + canonical = await loadMetadataDir(CANONICAL_DIR); + ({ expected } = await migrate(canonical)); + }); + + test("CONVERGENCE: the six canonical views apply, then a second and third migrate propose nothing (the emitter is deterministic)", async () => { + const views = (await select(`SELECT name FROM sqlite_master WHERE type = 'view' ORDER BY name`)).map((r) => r.name); + for (const v of [ + "v_program_minutes", "v_fitness_totals", "v_programs_by_month", + "v_programs_by_week", "v_recent_programs", "v_asset_activity", + ]) { + expect(views).toContain(v); + } + + await assertConverged(expected); + const second = await migrate(canonical); + expect(second.up.trim()).toBe(""); + expect(second.result.changes.filter((c) => !isInetResidue(c))).toEqual([]); + const third = await migrate(canonical); + expect(third.up.trim()).toBe(""); + }); + + test("SQLite lowering shapes: CASE WHEN conditions, json_array tuple, REAL ratio, INNER JOIN", () => { + const body = viewSql(canonical, "v_program_minutes"); + expect(body).toContain(`COUNT(CASE WHEN w."durationMinutes" >= 60 THEN w."id" END) AS "longWeeks"`); + expect(body).toContain(`json_array(w."programId", w."durationMinutes")`); + expect(body).toContain(`CAST(COUNT(CASE WHEN w."durationMinutes" >= 60 THEN w."id" END) AS REAL) / NULLIF(COUNT(w."id"), 0)`); + expect(body).toContain(`INNER JOIN "programs" p ON p."id" = w."programId"`); + }); + + test("v_program_minutes: every measure kind, incl. the tuple distinct count through json_array", async () => { + await applyRaw(SEED_PROGRAMS_AND_WEEKS); + const rows = await select(`SELECT * FROM "v_program_minutes" ORDER BY "program"`); + // SQLite has no decimal type: avg and the ratio are REAL (numbers), not decimal strings. + expect(rows).toEqual([ + { program: 1, programTitle: "Foundations", weeks: 4, longWeeks: 3, labels: 2, slots: 3, + totalMinutes: 240, avgMinutes: 60, minMinutes: 30, maxMinutes: 90, longShare: 0.75 }, + { program: 2, programTitle: "Strength", weeks: 1, longWeeks: 0, labels: 1, slots: 1, + totalMinutes: 45, avgMinutes: 45, minMinutes: 45, maxMinutes: 45, longShare: 0 }, + ]); + // Program 1 has four rows but three distinct (programId, durationMinutes) tuples, and + // its labels are 'Week 1', 'Week 2' and one NULL: two distinct, the null uncounted. + expect(await select(`SELECT "program" FROM "v_program_minutes" WHERE "weeks" >= 2`)).toEqual([{ program: 1 }]); + expect(await select(`SELECT "program" FROM "v_program_minutes" ORDER BY "totalMinutes" DESC LIMIT 1`)).toEqual([{ program: 1 }]); + }); + + test("a tuple with a NULL component is not counted", async () => { + // durationMinutes is required, so null the other component: programId is required too. + // Prove the guard on the lowered text instead, and the count it protects by hand. + const body = viewSql(canonical, "v_program_minutes"); + expect(body).toContain(`WHEN w."programId" IS NOT NULL AND w."durationMinutes" IS NOT NULL THEN json_array(`); + const r = await select( + `SELECT COUNT(DISTINCT CASE WHEN a IS NOT NULL AND b IS NOT NULL THEN json_array(a, b) END) AS n + FROM (SELECT 1 AS a, 2 AS b UNION ALL SELECT 1, 2 UNION ALL SELECT 1, NULL UNION ALL SELECT NULL, 3)`, + ); + expect(r).toEqual([{ n: 1 }]); + }); + + test("v_fitness_totals: no dimensions, one row; ratio is REAL", async () => { + await applyRaw(SEED_PROGRAMS_AND_WEEKS); + expect(await select(`SELECT * FROM "v_fitness_totals"`)).toEqual([{ weeks: 5, totalMinutes: 285, longShare: 0.6 }]); + }); + + test("EMPTY GROUPS (Review Focus 4): v_fitness_totals over an empty weeks table is one row (0, NULL, NULL)", async () => { + expect(await select(`SELECT count(*) AS n FROM "weeks"`)).toEqual([{ n: 0 }]); + expect(await select(`SELECT * FROM "v_fitness_totals"`)).toEqual([{ weeks: 0, totalMinutes: null, longShare: null }]); + }); + + test("v_programs_by_month / v_programs_by_week: month grain, null filtered sum, ISO Monday boundary, report @segment", async () => { + await applyRaw(SEED_PROGRAMS_BY_TIME); + expect(await select(`SELECT * FROM "v_programs_by_month" ORDER BY "status"`)).toEqual([ + { createdAtMonth: "2026-05-01", status: "ARCHIVED", programs: 1, listValue: null }, + { createdAtMonth: "2026-06-01", status: "DRAFT", programs: 1, listValue: null }, + { createdAtMonth: "2026-05-01", status: "PUBLISHED", programs: 3, listValue: 7799 }, + ]); + expect(await select(`SELECT * FROM "v_programs_by_week" ORDER BY "createdAtWeek"`)).toEqual([ + { createdAtWeek: "2026-04-27", programs: 1 }, + { createdAtWeek: "2026-05-11", programs: 1 }, + { createdAtWeek: "2026-05-18", programs: 1 }, + ]); + }); + + test("WEEK BOUNDARY: 2026-05-17T23:30:00 (Sunday) is in the week of 2026-05-11; 2026-05-18T00:00:00 (Monday) opens 2026-05-18", async () => { + const r = await select( + `SELECT date(t, 'weekday 0', '-6 days') AS wk FROM + (SELECT '2026-05-17T23:30:00' AS t UNION ALL SELECT '2026-05-18T00:00:00' ORDER BY 1)`, + ); + expect(r).toEqual([{ wk: "2026-05-11" }, { wk: "2026-05-18" }]); + // And through the view's own expression, on the programs seeded exactly there. + await applyRaw(` + INSERT INTO "programs" ("id","title","priceCents","status","created_ts") VALUES + (1, 'Sunday', 1, 'PUBLISHED', '2026-05-17T23:30:00'), + (2, 'Monday', 1, 'PUBLISHED', '2026-05-18T00:00:00')`); + expect(await select(`SELECT "createdAtWeek" FROM "v_programs_by_week" ORDER BY 1`)) + .toEqual([{ createdAtWeek: "2026-05-11" }, { createdAtWeek: "2026-05-18" }]); + }); + + test("v_asset_activity: hour on an instant, week on a field.date; the hour bucket's literal is `…:00:00.000Z`", async () => { + // Instants are stored the way the TS writers spell them: a three-digit fraction + Z. + await applyRaw(` + INSERT INTO "assets" ("id","ownerId","recordedAt","observedAt","asOfDate","atTime") VALUES + ('00000000-0000-4000-8000-000000000001', '${OWNER}', '2026-05-04T03:30:00.000Z', '2026-05-04T03:30:00.000', '2026-05-03', '03:30:00'), + ('00000000-0000-4000-8000-000000000002', '${OWNER}', '2026-05-04T03:45:00.000Z', '2026-05-04T03:45:00.000', '2026-05-03', '03:45:00'), + ('00000000-0000-4000-8000-000000000003', '${OWNER}', '2026-05-04T04:10:00.000Z', '2026-05-04T04:10:00.000', '2026-05-04', '04:10:00')`); + const rows = await select(`SELECT * FROM "v_asset_activity" ORDER BY "recordedAtHour"`); + expect(rows).toEqual([ + { recordedAtHour: "2026-05-04T03:00:00.000Z", asOfDateWeek: "2026-04-27", assets: 2 }, + { recordedAtHour: "2026-05-04T04:00:00.000Z", asOfDateWeek: "2026-05-04", assets: 1 }, + ]); + + // PIN: the bucket has the same spelling as every instant the TS adapters store, so it + // sorts and compares as text against them. Both writers' spellings, derived live: + const written = await select(`SELECT strftime('%Y-%m-%dT%H:%M:%fZ', '2026-05-04T03:00:00Z') AS ddlDefault`); + expect(written[0]?.ddlDefault).toBe("2026-05-04T03:00:00.000Z"); // migrate-ts SQLITE_ISO_NOW + expect(new Date("2026-05-04T03:00:00Z").toISOString()).toBe("2026-05-04T03:00:00.000Z"); // @autoSet + expect(rows[0]?.recordedAtHour).toBe(written[0]?.ddlDefault as string); + expect(rows[0]?.recordedAtHour).toBe(new Date("2026-05-04T03:00:00Z").toISOString()); + + // A value stored WITHOUT the fraction (an unpadded wire string) still buckets the same. + await applyRaw(` + INSERT INTO "assets" ("id","ownerId","recordedAt","observedAt","asOfDate","atTime") VALUES + ('00000000-0000-4000-8000-000000000004', '${OWNER}', '2026-05-04T03:59:59Z', '2026-05-04T03:59:59', '2026-05-04', '03:59:59')`); + expect(await select(`SELECT "assets" FROM "v_asset_activity" WHERE "recordedAtHour" = '2026-05-04T03:00:00.000Z' AND "asOfDateWeek" = '2026-05-04'`)) + .toEqual([{ assets: 1 }]); + }); + + test("RELATIVE WINDOW: v_recent_programs counts the program 3 days old, not the one 60 days old", async () => { + // created_ts is a naive timestamp, stored as a naive wall clock (no Z). + await applyRaw(` + INSERT INTO "programs" ("id","title","priceCents","status","created_ts") VALUES + (1, 'Recent', 100, 'PUBLISHED', strftime('%Y-%m-%dT%H:%M:%f','now','-3 days')), + (2, 'Stale', 100, 'PUBLISHED', strftime('%Y-%m-%dT%H:%M:%f','now','-60 days'))`); + expect(await select(`SELECT * FROM "v_recent_programs"`)).toEqual([{ programs: 1 }]); + }); +}); + +describe("report views — inline model on real SQLite", () => { + /** A Stamp table with date and naive-timestamp columns for the quarter / year grains. */ + const STAMP_MODEL = JSON.stringify({ "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Stamp", children: [ + { "source.rdb": { "@table": "stamps" } }, + { "field.long": { name: "id" } }, + { "field.date": { name: "day", "@required": true } }, + { "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } }, + { "dimension.time": { name: "day", "@of": "Stamp.day", "@grains": ["quarter", "year"] } }, + { "measure.aggregate": { name: "stamps", "@agg": "count", "@of": "Stamp.id" } }, + ] } }, + { "object.report": { name: "StampsByQuarter", "@from": "Stamp", "@dimensions": ["day:quarter"], "@measures": ["stamps"], children: [ + { "source.rdb": { "@kind": "view", "@view": "v_stamps_by_quarter" } } ] } }, + { "object.report": { name: "StampsByYear", "@from": "Stamp", "@dimensions": ["day:year"], "@measures": ["stamps"], children: [ + { "source.rdb": { "@kind": "view", "@view": "v_stamps_by_year" } } ] } }, + ]}}); + + test("QUARTER and YEAR grains: every month of a quarter lands on its first day, and the view converges", async () => { + const root = await loadInline(STAMP_MODEL); + expect(viewSql(root, "v_stamps_by_quarter")).toContain( + `date(s."day", 'start of month', '-' || ((CAST(strftime('%m', s."day") AS INTEGER) - 1) % 3) || ' months')`, + ); + const { expected } = await migrate(root); + await assertConverged(expected); + + await applyRaw(` + INSERT INTO "stamps" ("day") VALUES + ('2026-01-01'), ('2026-03-31'), + ('2026-04-01'), ('2026-05-01'), ('2026-06-30'), + ('2026-07-01'), + ('2026-12-31'), + ('2027-02-14')`); + expect(await select(`SELECT * FROM "v_stamps_by_quarter" ORDER BY "dayQuarter"`)).toEqual([ + { dayQuarter: "2026-01-01", stamps: 2 }, + { dayQuarter: "2026-04-01", stamps: 3 }, + { dayQuarter: "2026-07-01", stamps: 1 }, + { dayQuarter: "2026-10-01", stamps: 1 }, + { dayQuarter: "2027-01-01", stamps: 1 }, + ]); + expect(await select(`SELECT * FROM "v_stamps_by_year" ORDER BY "dayYear"`)).toEqual([ + { dayYear: "2026-01-01", stamps: 7 }, + { dayYear: "2027-01-01", stamps: 1 }, + ]); + }); +}); From 1587af66cb363e707baedebf07715f48b68bdd9d Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 07:56:24 -0400 Subject: [PATCH 09/32] feat(codegen-ts): MySQL report view SQL through buildReportViews; recipe (FR-044) A MySQL 8.4 value test creates the six canonical report views under the default sql_mode (ONLY_FULL_GROUP_BY asserted, not assumed) and pins the Task 10 rows, the 2/3 ratio at 0.6667 (Review Focus 5), the bigint type of a SIGNED-cast sum, and the Table D grain and Table E relative-date values. No emitter change was needed. docs/recipes/mysql.md gains a Reports section (meta migrate still does not own a MySQL schema); the skill reference and the regenerated agent-context goldens follow. --- .../references/typescript-mysql.md | 36 ++ docs/recipes/mysql.md | 36 ++ .../references/typescript-mysql.md | 36 ++ .../references/typescript-mysql.md | 36 ++ .../test/report-views-mysql.test.ts | 385 ++++++++++++++++++ 5 files changed, 529 insertions(+) create mode 100644 server/typescript/packages/integration-tests/test/report-views-mysql.test.ts diff --git a/agent-context/skills/metaobjects-codegen/references/typescript-mysql.md b/agent-context/skills/metaobjects-codegen/references/typescript-mysql.md index 945e40148..8b097721f 100644 --- a/agent-context/skills/metaobjects-codegen/references/typescript-mysql.md +++ b/agent-context/skills/metaobjects-codegen/references/typescript-mysql.md @@ -59,6 +59,42 @@ the reference: its column builders name the MySQL types. A column named after a MySQL reserved word (`rank`, `order`) needs backticks in your DDL. The generated code and both runtimes quote identifiers themselves. +### Reports + +An `object.report` (the reporting vocabulary, `docs/features/reporting.md`) is a compiled +view, and on MySQL you create that view yourself, because `meta migrate` does not. Declare +the report with a read-only `source.rdb` of `@kind: view` and `@unmanaged: true`: + +```json +{ "source.rdb": { "@kind": "view", "@view": "v_program_minutes", "@unmanaged": true } } +``` + +`buildReportViews` returns the body of each view-backed report for the `mysql` dialect. Put +each one in your own migration as `CREATE VIEW AS `: + +```ts +import { buildReportViews } from "@metaobjectsdev/codegen-ts"; +import { loadDirectory } from "@metaobjectsdev/metadata"; + +const { root } = await loadDirectory("metaobjects"); // wherever your metadata lives +for (const view of buildReportViews(root, { dialect: "mysql" })) { + console.log(`CREATE VIEW \`${view.name}\` AS\n${view.sql};`); +} +``` + +Pass `columnNamingStrategy` to match your tables' column names (the default is `snake_case`). +The bodies are valid under MySQL's default `sql_mode`, `ONLY_FULL_GROUP_BY` included, and a +change to a report means a new `CREATE OR REPLACE VIEW` (or `DROP` and `CREATE`) in your +migrations; nothing diffs the live view for you. Two things differ from Postgres and SQLite: + +- **Ratios and averages have four fractional digits by default.** MySQL divides to + `div_precision_increment` digits, so a ratio of 2 to 3 is `0.6667` (Postgres returns + `0.66666666666666666667`, SQLite `0.6666666666666666`), and a ratio of 3 to 4 is `0.7500`. +- **`DATETIME` values are read as the UTC wall clock.** A `DATETIME(3)` column carries no zone, + so every time grain and every relative-date window (`{ "now": "-P30D" }`, evaluated with + `UTC_TIMESTAMP(3)` when the view is queried) treats the stored value as UTC. That matches + what the generated tier and the ObjectManager store when the pool uses `timezone: "Z"`. + ## Behaviour that differs from Postgres and SQLite - **Writes read the row back.** MySQL has no `RETURNING`: diff --git a/docs/recipes/mysql.md b/docs/recipes/mysql.md index 59a3db74e..428d726bb 100644 --- a/docs/recipes/mysql.md +++ b/docs/recipes/mysql.md @@ -77,6 +77,42 @@ the reference: its column builders name the MySQL types. A column named after a MySQL reserved word (`rank`, `order`) needs backticks in your DDL. The generated code and both runtimes quote identifiers themselves. +### Reports + +An `object.report` (the reporting vocabulary, `docs/features/reporting.md`) is a compiled +view, and on MySQL you create that view yourself, because `meta migrate` does not. Declare +the report with a read-only `source.rdb` of `@kind: view` and `@unmanaged: true`: + +```json +{ "source.rdb": { "@kind": "view", "@view": "v_program_minutes", "@unmanaged": true } } +``` + +`buildReportViews` returns the body of each view-backed report for the `mysql` dialect. Put +each one in your own migration as `CREATE VIEW AS `: + +```ts +import { buildReportViews } from "@metaobjectsdev/codegen-ts"; +import { loadDirectory } from "@metaobjectsdev/metadata"; + +const { root } = await loadDirectory("metaobjects"); // wherever your metadata lives +for (const view of buildReportViews(root, { dialect: "mysql" })) { + console.log(`CREATE VIEW \`${view.name}\` AS\n${view.sql};`); +} +``` + +Pass `columnNamingStrategy` to match your tables' column names (the default is `snake_case`). +The bodies are valid under MySQL's default `sql_mode`, `ONLY_FULL_GROUP_BY` included, and a +change to a report means a new `CREATE OR REPLACE VIEW` (or `DROP` and `CREATE`) in your +migrations; nothing diffs the live view for you. Two things differ from Postgres and SQLite: + +- **Ratios and averages have four fractional digits by default.** MySQL divides to + `div_precision_increment` digits, so a ratio of 2 to 3 is `0.6667` (Postgres returns + `0.66666666666666666667`, SQLite `0.6666666666666666`), and a ratio of 3 to 4 is `0.7500`. +- **`DATETIME` values are read as the UTC wall clock.** A `DATETIME(3)` column carries no zone, + so every time grain and every relative-date window (`{ "now": "-P30D" }`, evaluated with + `UTC_TIMESTAMP(3)` when the view is queried) treats the stored value as UTC. That matches + what the generated tier and the ObjectManager store when the pool uses `timezone: "Z"`. + ## Behaviour that differs from Postgres and SQLite - **Writes read the row back.** MySQL has no `RETURNING`: diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md index 945e40148..8b097721f 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md @@ -59,6 +59,42 @@ the reference: its column builders name the MySQL types. A column named after a MySQL reserved word (`rank`, `order`) needs backticks in your DDL. The generated code and both runtimes quote identifiers themselves. +### Reports + +An `object.report` (the reporting vocabulary, `docs/features/reporting.md`) is a compiled +view, and on MySQL you create that view yourself, because `meta migrate` does not. Declare +the report with a read-only `source.rdb` of `@kind: view` and `@unmanaged: true`: + +```json +{ "source.rdb": { "@kind": "view", "@view": "v_program_minutes", "@unmanaged": true } } +``` + +`buildReportViews` returns the body of each view-backed report for the `mysql` dialect. Put +each one in your own migration as `CREATE VIEW AS `: + +```ts +import { buildReportViews } from "@metaobjectsdev/codegen-ts"; +import { loadDirectory } from "@metaobjectsdev/metadata"; + +const { root } = await loadDirectory("metaobjects"); // wherever your metadata lives +for (const view of buildReportViews(root, { dialect: "mysql" })) { + console.log(`CREATE VIEW \`${view.name}\` AS\n${view.sql};`); +} +``` + +Pass `columnNamingStrategy` to match your tables' column names (the default is `snake_case`). +The bodies are valid under MySQL's default `sql_mode`, `ONLY_FULL_GROUP_BY` included, and a +change to a report means a new `CREATE OR REPLACE VIEW` (or `DROP` and `CREATE`) in your +migrations; nothing diffs the live view for you. Two things differ from Postgres and SQLite: + +- **Ratios and averages have four fractional digits by default.** MySQL divides to + `div_precision_increment` digits, so a ratio of 2 to 3 is `0.6667` (Postgres returns + `0.66666666666666666667`, SQLite `0.6666666666666666`), and a ratio of 3 to 4 is `0.7500`. +- **`DATETIME` values are read as the UTC wall clock.** A `DATETIME(3)` column carries no zone, + so every time grain and every relative-date window (`{ "now": "-P30D" }`, evaluated with + `UTC_TIMESTAMP(3)` when the view is queried) treats the stored value as UTC. That matches + what the generated tier and the ObjectManager store when the pool uses `timezone: "Z"`. + ## Behaviour that differs from Postgres and SQLite - **Writes read the row back.** MySQL has no `RETURNING`: diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md index 945e40148..8b097721f 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md @@ -59,6 +59,42 @@ the reference: its column builders name the MySQL types. A column named after a MySQL reserved word (`rank`, `order`) needs backticks in your DDL. The generated code and both runtimes quote identifiers themselves. +### Reports + +An `object.report` (the reporting vocabulary, `docs/features/reporting.md`) is a compiled +view, and on MySQL you create that view yourself, because `meta migrate` does not. Declare +the report with a read-only `source.rdb` of `@kind: view` and `@unmanaged: true`: + +```json +{ "source.rdb": { "@kind": "view", "@view": "v_program_minutes", "@unmanaged": true } } +``` + +`buildReportViews` returns the body of each view-backed report for the `mysql` dialect. Put +each one in your own migration as `CREATE VIEW AS `: + +```ts +import { buildReportViews } from "@metaobjectsdev/codegen-ts"; +import { loadDirectory } from "@metaobjectsdev/metadata"; + +const { root } = await loadDirectory("metaobjects"); // wherever your metadata lives +for (const view of buildReportViews(root, { dialect: "mysql" })) { + console.log(`CREATE VIEW \`${view.name}\` AS\n${view.sql};`); +} +``` + +Pass `columnNamingStrategy` to match your tables' column names (the default is `snake_case`). +The bodies are valid under MySQL's default `sql_mode`, `ONLY_FULL_GROUP_BY` included, and a +change to a report means a new `CREATE OR REPLACE VIEW` (or `DROP` and `CREATE`) in your +migrations; nothing diffs the live view for you. Two things differ from Postgres and SQLite: + +- **Ratios and averages have four fractional digits by default.** MySQL divides to + `div_precision_increment` digits, so a ratio of 2 to 3 is `0.6667` (Postgres returns + `0.66666666666666666667`, SQLite `0.6666666666666666`), and a ratio of 3 to 4 is `0.7500`. +- **`DATETIME` values are read as the UTC wall clock.** A `DATETIME(3)` column carries no zone, + so every time grain and every relative-date window (`{ "now": "-P30D" }`, evaluated with + `UTC_TIMESTAMP(3)` when the view is queried) treats the stored value as UTC. That matches + what the generated tier and the ObjectManager store when the pool uses `timezone: "Z"`. + ## Behaviour that differs from Postgres and SQLite - **Writes read the row back.** MySQL has no `RETURNING`: diff --git a/server/typescript/packages/integration-tests/test/report-views-mysql.test.ts b/server/typescript/packages/integration-tests/test/report-views-mysql.test.ts new file mode 100644 index 000000000..a093eeb37 --- /dev/null +++ b/server/typescript/packages/integration-tests/test/report-views-mysql.test.ts @@ -0,0 +1,385 @@ +/** + * Report views — FR-044 Plan 2, against a REAL MySQL 8.4. + * + * `meta migrate` does not own a MySQL schema (ADR-0015; `--dialect mysql` is refused), so + * there is no convergence gate here. What ships for MySQL is the SQL: + * `buildReportViews(root, { dialect: "mysql" })` returns each view-backed report's body for + * the adopter to put in their own DDL (docs/recipes/mysql.md, "Reports"). This file proves + * that SQL is ACCEPTED and RIGHT: + * + * - every canonical view is created under the server's DEFAULT `sql_mode`, which includes + * ONLY_FULL_GROUP_BY (the mode the view bodies must satisfy; nothing here relaxes it); + * - the rows are the ones the shared Task 10 persistence scenarios assert, read with raw + * SQL straight off the views, so a wrong value is a LOWERING defect; + * - Review Focus 5: MySQL divides to four fractional digits, so 2/3 is `0.6667` (Postgres + * is `0.66666666666666666667`, SQLite `0.6666666666666666`). Pinned so the documented + * behaviour cannot drift unnoticed; + * - the view's column types follow contract Table B (`CAST(SUM(...) AS SIGNED)` is BIGINT); + * - the Table D grain expressions and the Table E relative-date expressions return the + * documented values. + * + * `DATETIME(3)` holds the UTC wall clock, so the relative-date seeds are written from + * `UTC_TIMESTAMP(3)`. Values are read with `dateStrings` and `bigNumberStrings` so nothing is + * reshaped by the driver: BIGINT, DECIMAL, DATE and DATETIME arrive as the text MySQL sent; + * only INT (the min/max of an int column) is a number. + * + * Requires Docker (or METAOBJECTS_TEST_MYSQL_URL). + */ + +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import mysql from "mysql2/promise"; +import { buildReportViews } from "@metaobjectsdev/codegen-ts"; +import { MetaDataLoader, InMemoryStringSource, type MetaRoot } from "@metaobjectsdev/metadata"; +import { startMysql, type MysqlContainerHandle } from "../src/mysql-container.ts"; +import { loadMetadataDir } from "../src/load-metadata.ts"; +import { CANONICAL_DIR } from "../src/paths.ts"; + +let container: MysqlContainerHandle; +let conn: mysql.Connection; +let canonical: MetaRoot; + +const CANONICAL_VIEWS = [ + "v_program_minutes", "v_fitness_totals", "v_programs_by_month", + "v_programs_by_week", "v_recent_programs", "v_asset_activity", +]; + +/** The adopter's hand-written DDL (MySQL schema is not MetaObjects'). */ +const DDL = [ + `CREATE TABLE programs ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(200) NOT NULL, + priceCents BIGINT NOT NULL, + status VARCHAR(9) NOT NULL, + created_ts DATETIME(3) NOT NULL + )`, + `CREATE TABLE weeks ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + programId BIGINT NOT NULL, + label VARCHAR(80), + durationMinutes INT NOT NULL, + FOREIGN KEY (programId) REFERENCES programs (id) + )`, + `CREATE TABLE assets ( + id VARCHAR(36) NOT NULL DEFAULT (UUID()) PRIMARY KEY, + ownerId VARCHAR(36) NOT NULL, + recordedAt DATETIME(3) NOT NULL, + observedAt DATETIME(3) NOT NULL, + asOfDate DATE NOT NULL, + atTime TIME(3) NOT NULL + )`, +]; + +async function loadInline(metaJson: string): Promise { + return (await new MetaDataLoader().load([new InMemoryStringSource(metaJson)])).root; +} + +function reportViews(root: MetaRoot) { + return buildReportViews(root, { dialect: "mysql", columnNamingStrategy: "literal" }); +} + +/** `CREATE VIEW` for every report view of `root`, exactly as the recipe shows. */ +async function createViews(root: MetaRoot): Promise { + const views = reportViews(root); + for (const v of views) await conn.query(`CREATE VIEW \`${v.name}\` AS\n${v.sql}`); + return views.map((v) => v.name); +} + +async function select(query: string): Promise[]> { + const [rows] = await conn.query(query); + return rows as Record[]; +} + +const SEED_PROGRAMS_AND_WEEKS = ` + INSERT INTO programs (id, title, priceCents, status, created_ts) VALUES + (1, 'Foundations', 4999, 'PUBLISHED', '2026-05-01T10:00:00'), + (2, 'Strength', 2500, 'PUBLISHED', '2026-05-17T23:30:00'); + INSERT INTO weeks (id, programId, label, durationMinutes) VALUES + (10, 1, 'Week 1', 30), + (11, 1, 'Week 2', 60), + (12, 1, 'Week 2', 90), + (13, 1, NULL, 60), + (20, 2, 'Solo', 45);`; + +const SEED_PROGRAMS_BY_TIME = ` + INSERT INTO programs (id, title, priceCents, status, created_ts) VALUES + (1, 'Foundations', 4999, 'PUBLISHED', '2026-05-01T10:00:00'), + (2, 'Strength', 2500, 'PUBLISHED', '2026-05-17T23:30:00'), + (3, 'Mobility', 1000, 'DRAFT', '2026-06-01T00:00:00'), + (4, 'Legacy', 700, 'ARCHIVED', '2026-05-31T23:59:59'), + (5, 'Monday', 300, 'PUBLISHED', '2026-05-18T00:00:00');`; + +const SEED_ASSETS = ` + INSERT INTO assets (ownerId, recordedAt, observedAt, asOfDate, atTime) VALUES + ('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', '2026-05-04T03:30:00', '2026-05-04T03:30:00', '2026-05-03', '03:30:00'), + ('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', '2026-05-04T03:45:00', '2026-05-04T03:45:00', '2026-05-03', '03:45:00'), + ('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', '2026-05-04T04:10:00', '2026-05-04T04:10:00', '2026-05-04', '04:10:00');`; + +/** Seed scripts hold several statements; the connection runs one at a time. */ +async function exec(script: string): Promise { + for (const stmt of script.split(/;\s*\n/).map((s) => s.trim()).filter(Boolean)) { + await conn.query(stmt.endsWith(";") ? stmt.slice(0, -1) : stmt); + } +} + +// --------------------------------------------------------------------------------- +// Inline models: the Table D grains and the Table E durations the canonical corpus does +// not exercise (it has no quarter or year grain and only `-P30D`). +// --------------------------------------------------------------------------------- + +/** One report with EVERY grain over a timestamp, grouped by all of them (one row per instant). */ +const GRAIN_MODEL = JSON.stringify({ "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Event", children: [ + { "source.rdb": { "@table": "events" } }, + { "field.long": { name: "id" } }, + { "field.timestamp": { name: "recordedAt", "@required": true } }, + { "field.date": { name: "happenedOn", "@required": true } }, + { "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } }, + { "dimension.time": { name: "recordedAt", "@of": "Event.recordedAt", + "@grains": ["hour", "day", "week", "month", "quarter", "year"] } }, + { "dimension.time": { name: "happenedOn", "@of": "Event.happenedOn", "@grains": ["week", "quarter"] } }, + { "measure.aggregate": { name: "events", "@agg": "count", "@of": "Event.id" } }, + ] } }, + { "object.report": { name: "EventsByGrain", "@from": "Event", + "@dimensions": ["recordedAt:hour", "recordedAt:day", "recordedAt:week", "recordedAt:month", + "recordedAt:quarter", "recordedAt:year", "happenedOn:week", "happenedOn:quarter"], + "@measures": ["events"], children: [ + { "source.rdb": { "@kind": "view", "@view": "v_events_by_grain" } } ] } }, +]}}); + +/** Three windows, one per Table E branch: an instant duration, a date duration, a forward one. */ +const RELATIVE_MODEL = JSON.stringify({ "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Event", children: [ + { "source.rdb": { "@table": "events" } }, + { "field.long": { name: "id" } }, + { "field.timestamp": { name: "recordedAt", "@required": true } }, + { "field.date": { name: "happenedOn", "@required": true } }, + { "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } }, + { "measure.aggregate": { name: "events", "@agg": "count", "@of": "Event.id" } }, + ] } }, + { "object.report": { name: "LastTwelveHours", "@from": "Event", "@measures": ["events"], + "@filter": { recordedAt: { gte: { now: "-PT12H" } } }, children: [ + { "source.rdb": { "@kind": "view", "@view": "v_last_twelve_hours" } } ] } }, + { "object.report": { name: "LastTwoWeeks", "@from": "Event", "@measures": ["events"], + "@filter": { happenedOn: { gte: { now: "-P2W" } } }, children: [ + { "source.rdb": { "@kind": "view", "@view": "v_last_two_weeks" } } ] } }, + { "object.report": { name: "UpToTomorrow", "@from": "Event", "@measures": ["events"], + "@filter": { recordedAt: { lte: { now: "P1DT1H" } } }, children: [ + { "source.rdb": { "@kind": "view", "@view": "v_up_to_tomorrow" } } ] } }, +]}}); + +beforeAll(async () => { + container = await startMysql(); + conn = await mysql.createConnection({ + uri: container.url, + timezone: "Z", + dateStrings: true, + supportBigNumbers: true, + bigNumberStrings: true, + }); + for (const ddl of DDL) await conn.query(ddl); + canonical = await loadMetadataDir(CANONICAL_DIR); +}, 240_000); + +afterAll(async () => { + await conn?.end(); + container?.stop(); +}, 60_000); + +beforeEach(async () => { + await conn.query("DELETE FROM weeks"); + await conn.query("DELETE FROM programs"); + await conn.query("DELETE FROM assets"); +}); + +describe("report views — canonical model on real MySQL 8.4", () => { + test("every canonical view is accepted under the server's default sql_mode (ONLY_FULL_GROUP_BY)", async () => { + // Assert the mode rather than assume it: the whole claim is "valid under the default". + const [mode] = await select(`SELECT @@GLOBAL.sql_mode AS g, @@SESSION.sql_mode AS s`); + expect(String(mode?.g)).toContain("ONLY_FULL_GROUP_BY"); + expect(String(mode?.s)).toContain("ONLY_FULL_GROUP_BY"); + + const names = await createViews(canonical); + expect([...names].sort()).toEqual([...CANONICAL_VIEWS].sort()); + for (const name of names) { + // Selecting proves the stored body still resolves, not merely that it parsed. + await select(`SELECT * FROM \`${name}\``); + } + const created = await select( + `SELECT table_name AS n FROM information_schema.views WHERE table_schema = DATABASE() ORDER BY table_name`, + ); + expect(created.map((r) => r.n)).toEqual([...CANONICAL_VIEWS].sort()); + }, 60_000); + + describe("values", () => { + test("v_program_minutes: every measure kind, one row per (program, programTitle); longShare 0.7500 and 0.0000", async () => { + await exec(SEED_PROGRAMS_AND_WEEKS); + const rows = await select("SELECT * FROM `v_program_minutes` ORDER BY `program`"); + expect(rows).toEqual([ + { program: "1", programTitle: "Foundations", weeks: "4", longWeeks: "3", labels: "2", slots: "3", + totalMinutes: "240", avgMinutes: "60.0000", minMinutes: 30, maxMinutes: 90, longShare: "0.7500" }, + { program: "2", programTitle: "Strength", weeks: "1", longWeeks: "0", labels: "1", slots: "1", + totalMinutes: "45", avgMinutes: "45.0000", minMinutes: 45, maxMinutes: 45, longShare: "0.0000" }, + ]); + + // The measure filter, sort and count the shared scenario runs through the view. + expect((await select("SELECT `program` FROM `v_program_minutes` WHERE `weeks` >= 2 ORDER BY `program`")).map((r) => r.program)) + .toEqual(["1"]); + expect((await select("SELECT `program` FROM `v_program_minutes` ORDER BY `totalMinutes` DESC LIMIT 1")).map((r) => r.program)) + .toEqual(["1"]); + expect((await select("SELECT count(*) AS n FROM `v_program_minutes`"))[0]?.n).toBe("2"); + }); + + test("REVIEW FOCUS 5: a ratio of 2/3 is 0.6667 on MySQL (four fractional digits, not Postgres' 20)", async () => { + await exec(` + INSERT INTO programs (id, title, priceCents, status, created_ts) VALUES + (1, 'Thirds', 100, 'PUBLISHED', '2026-05-01T10:00:00'); + INSERT INTO weeks (programId, label, durationMinutes) VALUES + (1, 'a', 30), (1, 'b', 60), (1, 'c', 90);`); + // Two of three weeks are >= 60 minutes. + expect((await select("SELECT `longShare` FROM `v_program_minutes`"))[0]?.longShare).toBe("0.6667"); + expect((await select("SELECT `longShare` FROM `v_fitness_totals`"))[0]?.longShare).toBe("0.6667"); + }); + + test("v_fitness_totals: no dimensions, one row over the whole table", async () => { + await exec(SEED_PROGRAMS_AND_WEEKS); + expect(await select("SELECT * FROM `v_fitness_totals`")) + .toEqual([{ weeks: "5", totalMinutes: "285", longShare: "0.6000" }]); + }); + + test("EMPTY GROUPS (Review Focus 4): v_fitness_totals over an empty weeks table is one row (0, NULL, NULL)", async () => { + expect((await select("SELECT count(*) AS n FROM weeks"))[0]?.n).toBe("0"); + expect(await select("SELECT * FROM `v_fitness_totals`")) + .toEqual([{ weeks: "0", totalMinutes: null, longShare: null }]); + }); + + test("COLUMN TYPES: totalMinutes is bigint (CAST ... AS SIGNED); counts are bigint, avg and ratio decimal, min/max keep the field's int", async () => { + const cols = await select( + `SELECT column_name AS c, data_type AS t FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'v_program_minutes' ORDER BY ordinal_position`, + ); + expect(Object.fromEntries(cols.map((r) => [r.c, r.t]))).toEqual({ + program: "bigint", programTitle: "varchar", + weeks: "bigint", longWeeks: "bigint", labels: "bigint", slots: "bigint", + totalMinutes: "bigint", avgMinutes: "decimal", minMinutes: "int", maxMinutes: "int", longShare: "decimal", + }); + // A bare SUM(int) would be decimal(32,0); the cast is what makes it bigint. + const fitness = await select( + `SELECT data_type AS t FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'v_fitness_totals' AND column_name = 'totalMinutes'`, + ); + expect(fitness[0]?.t).toBe("bigint"); + }); + + test("v_programs_by_month and v_programs_by_week: month grain, enum dimension, null filtered sum, ISO Monday boundary, report @segment", async () => { + await exec(SEED_PROGRAMS_BY_TIME); + + expect(await select("SELECT * FROM `v_programs_by_month` ORDER BY `status`")).toEqual([ + { createdAtMonth: "2026-05-01", status: "ARCHIVED", programs: "1", listValue: null }, + { createdAtMonth: "2026-06-01", status: "DRAFT", programs: "1", listValue: null }, + { createdAtMonth: "2026-05-01", status: "PUBLISHED", programs: "3", listValue: "7799" }, + ]); + + // Program 2 (Sunday 23:30) and program 5 (Monday 00:00) are thirty minutes apart and + // land in different weeks; DRAFT / ARCHIVED are scoped out by the segment. + expect(await select("SELECT * FROM `v_programs_by_week` ORDER BY `createdAtWeek`")).toEqual([ + { createdAtWeek: "2026-04-27", programs: "1" }, + { createdAtWeek: "2026-05-11", programs: "1" }, + { createdAtWeek: "2026-05-18", programs: "1" }, + ]); + }); + + test("v_asset_activity: hour on a timestamp, week on a field.date", async () => { + await exec(SEED_ASSETS); + expect(await select("SELECT * FROM `v_asset_activity` ORDER BY `recordedAtHour`")).toEqual([ + { recordedAtHour: "2026-05-04 03:00:00.000", asOfDateWeek: "2026-04-27", assets: "2" }, + { recordedAtHour: "2026-05-04 04:00:00.000", asOfDateWeek: "2026-05-04", assets: "1" }, + ]); + // The hour bucket is a DATETIME(3), so it compares with a stored instant. + const t = await select( + `SELECT data_type AS t, datetime_precision AS p FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'v_asset_activity' AND column_name = 'recordedAtHour'`, + ); + expect(t[0]).toEqual({ t: "datetime", p: 3 }); + }); + + test("RELATIVE WINDOW: v_recent_programs counts the program 3 days old, not the one 60 days old", async () => { + await exec(` + INSERT INTO programs (id, title, priceCents, status, created_ts) VALUES + (1, 'Recent', 100, 'PUBLISHED', UTC_TIMESTAMP(3) - INTERVAL 3 DAY), + (2, 'Stale', 100, 'PUBLISHED', UTC_TIMESTAMP(3) - INTERVAL 60 DAY);`); + expect(await select("SELECT * FROM `v_recent_programs`")).toEqual([{ programs: "1" }]); + }); + }); +}); + +describe("report views — inline models on real MySQL 8.4", () => { + test("TABLE D: hour, day, week, month, quarter and year grains, on a timestamp and on a date", async () => { + await conn.query("DROP TABLE IF EXISTS events"); + await conn.query( + `CREATE TABLE events (id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, recordedAt DATETIME(3) NOT NULL, happenedOn DATE NOT NULL)`, + ); + const root = await loadInline(GRAIN_MODEL); + const names = await createViews(root); + expect(names).toEqual(["v_events_by_grain"]); + + await exec(` + INSERT INTO events (recordedAt, happenedOn) VALUES + ('2026-05-01T10:15:00', '2026-05-01'), + ('2026-05-17T23:30:00', '2026-05-17'), + ('2026-06-01T00:00:00', '2026-06-01'), + ('2026-01-01T00:00:00', '2026-01-01'), + ('2026-07-01T05:05:05', '2026-07-01'), + ('2026-12-31T23:59:59', '2026-12-31');`); + + const grain = (hour: string, day: string, week: string, month: string, quarter: string, year: string, + dWeek: string, dQuarter: string) => ({ + recordedAtHour: hour, recordedAtDay: day, recordedAtWeek: week, recordedAtMonth: month, + recordedAtQuarter: quarter, recordedAtYear: year, + happenedOnWeek: dWeek, happenedOnQuarter: dQuarter, events: "1", + }); + expect(await select("SELECT * FROM `v_events_by_grain` ORDER BY `recordedAtHour`")).toEqual([ + // 2026-01-01 is a Thursday: ISO week starts Monday 2025-12-29, in the previous year. + grain("2026-01-01 00:00:00.000", "2026-01-01", "2025-12-29", "2026-01-01", "2026-01-01", "2026-01-01", "2025-12-29", "2026-01-01"), + // Contract Table D checked values: day, week, month, quarter, year of 2026-05-01T10:00. + grain("2026-05-01 10:00:00.000", "2026-05-01", "2026-04-27", "2026-05-01", "2026-04-01", "2026-01-01", "2026-04-27", "2026-04-01"), + // A Sunday at 23:30 is still the week of Monday 2026-05-11. + grain("2026-05-17 23:00:00.000", "2026-05-17", "2026-05-11", "2026-05-01", "2026-04-01", "2026-01-01", "2026-05-11", "2026-04-01"), + // A Monday midnight starts its own week. + grain("2026-06-01 00:00:00.000", "2026-06-01", "2026-06-01", "2026-06-01", "2026-04-01", "2026-01-01", "2026-06-01", "2026-04-01"), + grain("2026-07-01 05:00:00.000", "2026-07-01", "2026-06-29", "2026-07-01", "2026-07-01", "2026-01-01", "2026-06-29", "2026-07-01"), + grain("2026-12-31 23:00:00.000", "2026-12-31", "2026-12-28", "2026-12-01", "2026-10-01", "2026-01-01", "2026-12-28", "2026-10-01"), + ]); + + // The grain's column types (Table B): hour is a DATETIME(3), every other grain a DATE. + const cols = await select( + `SELECT column_name AS c, data_type AS t FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'v_events_by_grain' AND column_name <> 'events'`, + ); + for (const r of cols) expect(r.t).toBe(r.c === "recordedAtHour" ? "datetime" : "date"); + }, 60_000); + + test("TABLE E: relative-date windows read the UTC wall clock (an instant, a date, a forward duration)", async () => { + await conn.query("DROP TABLE IF EXISTS events"); + await conn.query( + `CREATE TABLE events (id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, recordedAt DATETIME(3) NOT NULL, happenedOn DATE NOT NULL)`, + ); + const root = await loadInline(RELATIVE_MODEL); + expect((await createViews(root)).sort()).toEqual(["v_last_twelve_hours", "v_last_two_weeks", "v_up_to_tomorrow"]); + + // Clock-relative on purpose: the view calls UTC_TIMESTAMP(3) when it is QUERIED. + await exec(` + INSERT INTO events (recordedAt, happenedOn) VALUES + (UTC_TIMESTAMP(3) - INTERVAL 1 HOUR, DATE(UTC_TIMESTAMP(3) - INTERVAL 1 DAY)), + (UTC_TIMESTAMP(3) - INTERVAL 11 HOUR, DATE(UTC_TIMESTAMP(3) - INTERVAL 13 DAY)), + (UTC_TIMESTAMP(3) - INTERVAL 13 HOUR, DATE(UTC_TIMESTAMP(3) - INTERVAL 15 DAY)), + (UTC_TIMESTAMP(3) + INTERVAL 1 HOUR, DATE(UTC_TIMESTAMP(3) - INTERVAL 40 DAY)), + (UTC_TIMESTAMP(3) + INTERVAL 3 DAY, DATE(UTC_TIMESTAMP(3) - INTERVAL 60 DAY));`); + + // gte -PT12H: only the 13h-old instant is out (1h and 11h ago, and both future rows, are in). + expect(await select("SELECT * FROM `v_last_twelve_hours`")).toEqual([{ events: "4" }]); + // -P2W is 14 days, applied to the date: 1 and 13 days ago are in, 15, 40 and 60 are out. + expect(await select("SELECT * FROM `v_last_two_weeks`")).toEqual([{ events: "2" }]); + // P1DT1H forward: everything up to a day and an hour ahead; the 3-days-ahead row is out. + expect(await select("SELECT * FROM `v_up_to_tomorrow`")).toEqual([{ events: "4" }]); + }, 60_000); +}); From 9dcf05d60164ea3e124579cc72ae02b5da021528 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 08:03:41 -0400 Subject: [PATCH 10/32] feat(runtime-ts): ObjectManager reads a view-backed report (FR-044) --- .../src/core/reporting/report-read-model.ts | 131 ++++++++ .../typescript/packages/metadata/src/index.ts | 1 + .../metadata/test/report-read-model.test.ts | 176 +++++++++++ .../packages/runtime-ts/src/object-manager.ts | 77 ++++- .../packages/runtime-ts/src/query-builder.ts | 5 +- .../test/object-manager-report.test.ts | 290 ++++++++++++++++++ 6 files changed, 670 insertions(+), 10 deletions(-) create mode 100644 server/typescript/packages/metadata/src/core/reporting/report-read-model.ts create mode 100644 server/typescript/packages/metadata/test/report-read-model.test.ts create mode 100644 server/typescript/packages/runtime-ts/test/object-manager-report.test.ts diff --git a/server/typescript/packages/metadata/src/core/reporting/report-read-model.ts b/server/typescript/packages/metadata/src/core/reporting/report-read-model.ts new file mode 100644 index 000000000..fab33a7c7 --- /dev/null +++ b/server/typescript/packages/metadata/src/core/reporting/report-read-model.ts @@ -0,0 +1,131 @@ +// A report's READ MODEL (FR-044): a detached object carrying one real `field.*` +// child per derived field (Table B) and a copy of the report's own read-only source. +// +// WHY IT EXISTS +// +// An `object.report` declares no fields: its read shape is derived from its +// dimensions and measures. A metadata-driven runtime walks an object's field +// children in a dozen places (column list, filter and sort resolution, the name +// map, every read coercion). Rather than teach each of them what a report is, the +// runtime reads a report through this model and sees ordinary fields. +// +// WHY IT IS DETACHED +// +// The model is never added to the root: it has no parent, `root.objects()` does +// not list it, and the canonical serializer, `fmt`, codegen and every other tree +// walker never see it. Nothing in the loaded tree is mutated to build it; in +// particular the report's own source node is COPIED, not re-parented +// (`addChild` rewrites the child's parent). The nodes are constructed directly, +// not through the registry, so the sealed registry is not involved and no +// vocabulary is added: every node is an already-registered `type.subType`. +// +// It keeps the report's name, package and `object.report` subtype, so a consumer +// holding it can still tell it is a report (no identity, read-only). + +import { TypeId } from "../../registry.js"; +import { TYPE_FIELD } from "../../shared/base-types.js"; +import type { MetaRoot } from "../../shared/meta-root.js"; +import { isReadOnlySource } from "../../shared/node-guards.js"; +import { MetaSource } from "../../persistence/source/meta-source.js"; +import { FIELD_ATTR_DB_COLUMN_TYPE, FIELD_ATTR_LOCAL_TIME } from "../../persistence/db/db-constants.js"; +import { MetaObject } from "../object/meta-object.js"; +import { MetaField } from "../field/meta-field.js"; +import { + FIELD_ATTR_CURRENCY, + FIELD_ATTR_INT_VALUE_MAP, + FIELD_ATTR_MAX_LENGTH, + FIELD_ATTR_OBJECT_REF, + FIELD_ATTR_PRECISION, + FIELD_ATTR_REQUIRED, + FIELD_ATTR_SCALE, + FIELD_ATTR_STORAGE, + FIELD_ATTR_VALUES, +} from "../field/field-constants.js"; +import { reportShape, type ReportField } from "./report-shape.js"; + +/** + * Table B: the type-shaping attrs a derived field carries from its `typeSource`, + * read with the RESOLVING accessor (ADR-0039) so a value the `@of` field inherits + * through `extends` is carried too. `@dbColumnType` and `isArray` are handled + * separately below. Nothing else is carried: no `@column`, `@required`, + * `@default`, validators or views. + */ +const CARRIED_ATTRS = [ + FIELD_ATTR_CURRENCY, + FIELD_ATTR_VALUES, + FIELD_ATTR_INT_VALUE_MAP, + FIELD_ATTR_MAX_LENGTH, + FIELD_ATTR_PRECISION, + FIELD_ATTR_SCALE, + FIELD_ATTR_LOCAL_TIME, + FIELD_ATTR_OBJECT_REF, + FIELD_ATTR_STORAGE, +] as const; + +function derivedField(f: ReportField): MetaField { + const field = new MetaField(new TypeId(TYPE_FIELD, f.subType), f.name); + // From the derived shape, never from the type source: a `min` of a required column + // is still nullable, and a dimension reached by `@via` is nullable. + field.setAttr(FIELD_ATTR_REQUIRED, f.required); + const src = f.typeSource; + if (src !== undefined) { + for (const name of CARRIED_ATTRS) { + const value = src.attr(name); + if (value !== undefined) field.setAttr(name, value); + } + // ADR-0039: own — `@dbColumnType` is the one deliberately own-only attr (a + // physical column-type override is never inherited), and every consumer reads + // it with `ownAttr`. So it is read own from the type source and set OWN here: + // the derived field carries exactly what the `@of` field itself declares, and + // nothing its supers declare. A field that `extends` another does not get the + // parent's `@dbColumnType` either, so this matches how a projection field + // would see it. + const dbColumnType = src.ownAttr(FIELD_ATTR_DB_COLUMN_TYPE); + if (dbColumnType !== undefined) field.setAttr(FIELD_ATTR_DB_COLUMN_TYPE, dbColumnType); + // `isArray` is a native flag, not an attr; resolvedIsArray() is its resolving read. + if (src.resolvedIsArray()) field.setIsArray(true); + } + return field; +} + +/** A detached copy of a source node: same `type.subType`, name and effective attrs. */ +function copySource(source: MetaSource): MetaSource { + const copy = new MetaSource(source.typeId, source.name); + // ADR-0039: resolving — the copy carries the source's effective configuration + // (@kind, the physical-name alias, @schema, @role, @unmanaged, @sql). + for (const [name, value] of source.attrs()) copy.setAttr(name, value); + return copy; +} + +const READ_MODELS = new WeakMap(); + +/** + * The read model of an `object.report`: one field per Table B row, in Table B + * order, plus a copy of the report's own read-only source when it declares one + * (Table A). A sourceless report yields a model with no source: it has a shape + * and no view, and the caller decides what that means (the runtime refuses to + * serve it). + * + * Cached per report node; the model is frozen. Throws what `reportShape` throws + * when a reference does not resolve. + */ +export function reportReadModel(report: MetaObject, root: MetaRoot): MetaObject { + const cached = READ_MODELS.get(report); + if (cached !== undefined) return cached; + + const shape = reportShape(report, root); + const model = new MetaObject(report.typeId, report.name); + if (report.package !== undefined) model.setPackage(report.package); + if (report.fileDefaultPackage !== undefined) model.setFileDefaultPackage(report.fileDefaultPackage); + for (const f of shape.fields) model.addChild(derivedField(f)); + + // ADR-0039: own — Table A classifies a report by the source it declares ITSELF, + // the same own-source read as codegen's `classifyReadOnlySource`, so the runtime + // serves exactly the reports whose view the lowering (or the adopter) provides. + const source = report.ownChildren().find(isReadOnlySource); + if (source !== undefined) model.addChild(copySource(source)); + + model.freeze(); + READ_MODELS.set(report, model); + return model; +} diff --git a/server/typescript/packages/metadata/src/index.ts b/server/typescript/packages/metadata/src/index.ts index d7c5d77d3..5e365a273 100644 --- a/server/typescript/packages/metadata/src/index.ts +++ b/server/typescript/packages/metadata/src/index.ts @@ -60,6 +60,7 @@ export { type ReportFieldRole, type ReportShape, } from "./core/reporting/report-shape.js"; +export { reportReadModel } from "./core/reporting/report-read-model.js"; // Shared `@implementedBy` resolution — one resolver for the CLI's requirement // checks and codegen's requirement-test fan-out (FR-038). export { diff --git a/server/typescript/packages/metadata/test/report-read-model.test.ts b/server/typescript/packages/metadata/test/report-read-model.test.ts new file mode 100644 index 000000000..1293d72d3 --- /dev/null +++ b/server/typescript/packages/metadata/test/report-read-model.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from "bun:test"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + FIELD_ATTR_COLUMN, + FIELD_ATTR_CURRENCY, + FIELD_ATTR_LOCAL_TIME, + FIELD_ATTR_REQUIRED, + FIELD_ATTR_VALUES, + InMemoryStringSource, + MetaDataLoader, + OBJECT_SUBTYPE_REPORT, + SOURCE_KIND_VIEW, + TYPE_FIELD, + TYPE_IDENTITY, + canonicalSerialize, + isMetaSource, + loadUris, + reportReadModel, + type MetaObject, + type MetaRoot, +} from "../src/index.js"; + +const REPO_ROOT = resolve(import.meta.dir, "..", "..", "..", "..", ".."); +const MODEL = join(REPO_ROOT, "fixtures", "persistence-conformance", "canonical", "meta.fitness.json"); + +async function load(): Promise { + const result = await loadUris([pathToFileURL(MODEL).href]); + expect(result.errors).toEqual([]); + return result.root; +} +const object = (root: MetaRoot, name: string): MetaObject => { + const found = root.objects().find((o) => o.name === name); + if (found === undefined) throw new Error(`no object ${name}`); + return found; +}; +const model = (root: MetaRoot, name: string): MetaObject => reportReadModel(object(root, name), root); +const fieldsOf = (m: MetaObject) => m.children().filter((c) => c.type === TYPE_FIELD); + +describe("reportReadModel (FR-044 Table B as a detached read model)", () => { + test("ProgramMinutes has eleven field children in Table B order with the Table B subtypes", async () => { + const root = await load(); + expect(fieldsOf(model(root, "ProgramMinutes")).map((f) => [f.name, f.subType])).toEqual([ + ["program", "long"], + ["programTitle", "string"], + ["weeks", "long"], + ["longWeeks", "long"], + ["labels", "long"], + ["slots", "long"], + ["totalMinutes", "long"], + ["avgMinutes", "decimal"], + ["minMinutes", "int"], + ["maxMinutes", "int"], + ["longShare", "decimal"], + ]); + }); + + test("min keeps the source field's subtype: minMinutes is field.int", async () => { + const root = await load(); + const min = model(root, "ProgramMinutes").fields().find((f) => f.name === "minMinutes"); + expect(min?.type).toBe(TYPE_FIELD); + expect(min?.subType).toBe("int"); + }); + + test("@required comes from the derived shape, not from the type source", async () => { + const root = await load(); + const m = model(root, "ProgramMinutes"); + const required = Object.fromEntries(m.fields().map((f) => [f.name, f.attr(FIELD_ATTR_REQUIRED)])); + expect(required["program"]).toBe(true); // no @via, @of required + expect(required["programTitle"]).toBe(false); // reached by @via + expect(required["weeks"]).toBe(true); // a count is never null + expect(required["minMinutes"]).toBe(false); // Week.durationMinutes is required; a min is not + }); + + test("a currency sum carries @currency from its type source", async () => { + const root = await load(); + const listValue = model(root, "ProgramsByMonth").fields().find((f) => f.name === "listValue"); + expect(listValue?.subType).toBe("currency"); + expect(listValue?.attr(FIELD_ATTR_CURRENCY)).toBe("USD"); + }); + + test("an enum dimension carries @values; an hour bucket carries its source's @localTime", async () => { + const root = await load(); + const status = model(root, "ProgramsByMonth").fields().find((f) => f.name === "status"); + expect(status?.subType).toBe("enum"); + expect(status?.attr(FIELD_ATTR_VALUES)).toEqual(["DRAFT", "PUBLISHED", "ARCHIVED"]); + // Asset.recordedAt is an instant: no @localTime to carry. + const hour = model(root, "AssetActivity").fields().find((f) => f.name === "recordedAtHour"); + expect(hour?.subType).toBe("timestamp"); + expect(hour?.hasAttr(FIELD_ATTR_LOCAL_TIME)).toBe(false); + }); + + test("@column is never carried: Program.createdAt's created_ts does not reach a derived field", async () => { + const root = await load(); + for (const name of ["ProgramMinutes", "ProgramsByMonth", "ProgramsByWeek", "AssetActivity"]) { + for (const f of model(root, name).fields()) expect(f.hasAttr(FIELD_ATTR_COLUMN)).toBe(false); + } + }); + + test("the model keeps the report's name and subtype, has no identity, and is frozen", async () => { + const root = await load(); + const m = model(root, "ProgramMinutes"); + expect(m.name).toBe("ProgramMinutes"); + expect(m.subType).toBe(OBJECT_SUBTYPE_REPORT); + expect(m.resolutionKey()).toBe(object(root, "ProgramMinutes").resolutionKey()); + expect(m.children().some((c) => c.type === TYPE_IDENTITY)).toBe(false); + expect(m.isFrozen()).toBe(true); + }); + + test("the model's read-only source has the report's physical name", async () => { + const root = await load(); + const m = model(root, "ProgramMinutes"); + const sources = m.children().filter(isMetaSource); + expect(sources).toHaveLength(1); + expect(sources[0]!.isReadOnly()).toBe(true); + expect(sources[0]!.effectiveKind).toBe(SOURCE_KIND_VIEW); + expect(sources[0]!.physicalName).toBe("v_program_minutes"); + // A copy: the report's own source node is not re-parented. + const own = object(root, "ProgramMinutes").children().find(isMetaSource)!; + expect(sources[0]).not.toBe(own); + expect(own.parent).toBe(object(root, "ProgramMinutes")); + }); + + test("the model is detached: it has no parent and the root does not list it", async () => { + const root = await load(); + const m = model(root, "ProgramMinutes"); + expect(m.parent).toBeUndefined(); + expect(root.objects()).not.toContain(m); + expect(root.children()).not.toContain(m); + }); + + test("root.objects() is unchanged in length and the root serialises byte-identically", async () => { + const root = await load(); + const before = canonicalSerialize(root); + const count = root.objects().length; + for (const o of root.objects()) { + if (o.subType === OBJECT_SUBTYPE_REPORT) reportReadModel(o, root); + } + expect(root.objects().length).toBe(count); + expect(canonicalSerialize(root)).toBe(before); + }); + + test("the model is cached per report node", async () => { + const root = await load(); + expect(model(root, "ProgramMinutes")).toBe(model(root, "ProgramMinutes")); + expect(model(root, "ProgramMinutes")).not.toBe(model(root, "FitnessTotals")); + }); + + test("a sourceless report yields a model with the fields and no source", async () => { + const result = await new MetaDataLoader().load([ + new InMemoryStringSource( + JSON.stringify({ + "metadata.root": { + package: "acme", + children: [ + { "object.entity": { name: "Sale", children: [ + { "source.rdb": { "@table": "sales" } }, + { "field.long": { name: "id" } }, + { "field.currency": { name: "amountCents", "@currency": "EUR" } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment" } }, + { "measure.aggregate": { name: "revenue", "@agg": "sum", "@of": "Sale.amountCents" } }, + ] } }, + { "object.report": { name: "Revenue", "@from": "Sale", "@measures": ["revenue"] } }, + ], + }, + }), + ), + ]); + expect(result.errors.map((e) => e.message)).toEqual([]); + const m = model(result.root, "Revenue"); + expect(m.fields().map((f) => [f.name, f.subType, f.attr(FIELD_ATTR_CURRENCY)])).toEqual([ + ["revenue", "currency", "EUR"], + ]); + expect(m.children().some(isMetaSource)).toBe(false); + }); +}); diff --git a/server/typescript/packages/runtime-ts/src/object-manager.ts b/server/typescript/packages/runtime-ts/src/object-manager.ts index bff125065..85ad535ed 100644 --- a/server/typescript/packages/runtime-ts/src/object-manager.ts +++ b/server/typescript/packages/runtime-ts/src/object-manager.ts @@ -1,6 +1,8 @@ import type { MetaData } from "@metaobjectsdev/metadata"; import { TYPE_OBJECT, TYPE_FIELD, + OBJECT_SUBTYPE_REPORT, + isMetaObject, isMetaRoot, isReadOnlySource, reportReadModel, FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, FIELD_SUBTYPE_DECIMAL, } from "@metaobjectsdev/metadata"; @@ -111,7 +113,7 @@ export class ObjectManager { } async findById(entityName: string, id: unknown, opts: ReadOpts = {}): Promise { - const entity = this.requireEntity(entityName); + const entity = this.requireIdentified(entityName, "findById"); const pkField = resolvePkFields(entity)[0]!; return this.findFirst(entityName, { [pkField]: this.coerceIdArg(entity, id) as string | number }, opts); } @@ -151,7 +153,7 @@ export class ObjectManager { async load(refString: string): Promise { const { entity: entityName, pkValues } = decodeRef(refString); - const entity = this.requireEntity(entityName); + const entity = this.requireIdentified(entityName, "load"); const pkFields = resolvePkFields(entity); if (pkValues.length !== pkFields.length) { throw new MetadataError( @@ -169,12 +171,12 @@ export class ObjectManager { } refOf(entityName: string, record: Row): string { - const entity = this.requireEntity(entityName); + const entity = this.requireIdentified(entityName, "refOf"); return encodeRef(entityName, record, resolvePkFields(entity)); } async create(entityName: string, data: Row, opts: WriteOpts = {}): Promise { - const entity = this.requireEntity(entityName); + const entity = this.requireIdentified(entityName, "create"); const driver = opts.tx ?? this.driver; const restricted0 = this.applyViewRestriction(entity, data, opts.view); @@ -197,7 +199,7 @@ export class ObjectManager { } async update(entityName: string, id: unknown, data: Row, opts: WriteOpts = {}): Promise { - const entity = this.requireEntity(entityName); + const entity = this.requireIdentified(entityName, "update"); const driver = opts.tx ?? this.driver; const restricted0 = this.applyViewRestriction(entity, data, opts.view); @@ -229,7 +231,7 @@ export class ObjectManager { } async delete(entityName: string, id: unknown, opts: WriteOpts = {}): Promise { - const entity = this.requireEntity(entityName); + const entity = this.requireIdentified(entityName, "delete"); const driver = opts.tx ?? this.driver; // FR-017 TPH: scope the by-id delete to the subtype (cross-subtype → not found). const spec = buildDeleteSpec(entity, this.coerceIdArg(entity, id), this.columnNamingStrategy, this.tphScope(entity)); @@ -243,7 +245,7 @@ export class ObjectManager { } async createMany(entityName: string, dataArray: Row[], opts: WriteOpts = {}): Promise { - const entity = this.requireEntity(entityName); + const entity = this.requireIdentified(entityName, "createMany"); const driver = opts.tx ?? this.driver; // Validate + identity-resolve every row before any insert so a late failure can't leave partial state. @@ -274,7 +276,7 @@ export class ObjectManager { } async updateMany(entityName: string, filter: Filter, partial: Row, opts: WriteOpts = {}): Promise { - const entity = this.requireEntity(entityName); + const entity = this.requireIdentified(entityName, "updateMany"); const driver = opts.tx ?? this.driver; const restricted = this.applyViewRestriction(entity, partial, opts.view); const v = runValidators(entity, restricted, { partial: true }); @@ -292,7 +294,7 @@ export class ObjectManager { } async deleteMany(entityName: string, filter: Filter, opts: WriteOpts = {}): Promise { - const entity = this.requireEntity(entityName); + const entity = this.requireIdentified(entityName, "deleteMany"); const driver = opts.tx ?? this.driver; const spec: DeleteManySpec = { table: resolveTableName(entity), @@ -464,6 +466,12 @@ export class ObjectManager { } private requireEntity(entityName: string): MetaData { + const entity = this.requireObject(entityName); + return entity.subType === OBJECT_SUBTYPE_REPORT ? this.reportReadModelOf(entity) : entity; + } + + /** The declared object node, exactly as loaded (a report is NOT swapped for its read model). */ + private requireObject(entityName: string): MetaData { if (!VALID_ENTITY_NAME.test(entityName)) { throw new UnsafeNameError( `Unsafe entity name '${entityName}'`, @@ -478,6 +486,57 @@ export class ObjectManager { return entity; } + /** + * FR-044: a report declares no fields — its read shape is derived — so it is read + * through a detached read model carrying one real field per derived field and the + * report's own read-only source. Everything downstream (column list, filter and + * sort resolution, the name map, read coercion) then treats it as it treats a + * projection. The model is built once per report node and never joins the tree. + * + * A report with no read-only source of its own has no view (Table A), so there is + * nothing to read: refused here rather than falling through to a default table name. + */ + private reportReadModelOf(report: MetaData): MetaData { + if (!isMetaObject(report) || !isMetaRoot(this.metadata)) { + throw new MetadataError( + `Report '${report.name}' cannot be read: the ObjectManager's metadata is not a loaded root`, + { entity: report.name }, + ); + } + let model: MetaData; + try { + model = reportReadModel(report, this.metadata); + } catch (cause) { + const detail = cause instanceof Error ? cause.message : String(cause); + throw new MetadataError(`Report '${report.name}' cannot be read: ${detail}`, { entity: report.name, cause }); + } + if (!model.children().some((c) => isReadOnlySource(c))) { + throw new MetadataError( + `Report '${report.name}' is not served: it declares no read-only source, so it has no view to read`, + { entity: report.name }, + ); + } + return model; + } + + /** + * Resolve an entity for an operation that needs an identity or writes: get-by-id, + * reference encode/decode, and every create/update/delete. A report (FR-044) is an + * aggregate over a view — it has no primary key and no write target — so these are + * refused by name before anything else is looked at (a sourceless report included). + */ + private requireIdentified(entityName: string, op: string): MetaData { + const entity = this.requireObject(entityName); + if (entity.subType === OBJECT_SUBTYPE_REPORT) { + throw new MetadataError( + `${op} is not supported on '${entityName}': a report is read-only and has no identity ` + + `(read it with findMany, findFirst or count)`, + { entity: entityName }, + ); + } + return entity; + } + private toJsRow(entity: MetaData, dbRow: Row): Row { const { dbToJs } = this.nameMap(entity); const out: Row = {}; diff --git a/server/typescript/packages/runtime-ts/src/query-builder.ts b/server/typescript/packages/runtime-ts/src/query-builder.ts index 8e0fbb78f..7ce8ac295 100644 --- a/server/typescript/packages/runtime-ts/src/query-builder.ts +++ b/server/typescript/packages/runtime-ts/src/query-builder.ts @@ -3,6 +3,7 @@ import { TYPE_FIELD, TYPE_IDENTITY, IDENTITY_SUBTYPE_PRIMARY, IDENTITY_ATTR_FIELDS, + OBJECT_SUBTYPE_REPORT, DEFAULT_COLUMN_NAMING_STRATEGY, resolveTableName, resolveColumnName, } from "@metaobjectsdev/metadata"; @@ -193,7 +194,9 @@ export function buildSelectSpec( strategy: ColumnNamingStrategy = DEFAULT_COLUMN_NAMING_STRATEGY, ): SelectSpec { const allFields = projectedFields ?? listFieldNames(entity); - const pkFields = resolvePkFields(entity); + // A report's read model (FR-044) has no identity, so there is no key to add to the + // column list. Scoped to the report subtype: every other object still requires one. + const pkFields = entity.subType === OBJECT_SUBTYPE_REPORT ? [] : resolvePkFields(entity); const fieldSet = new Set(allFields); for (const pk of pkFields) fieldSet.add(pk); diff --git a/server/typescript/packages/runtime-ts/test/object-manager-report.test.ts b/server/typescript/packages/runtime-ts/test/object-manager-report.test.ts new file mode 100644 index 000000000..bbde68852 --- /dev/null +++ b/server/typescript/packages/runtime-ts/test/object-manager-report.test.ts @@ -0,0 +1,290 @@ +// FR-044: ObjectManager reads a view-backed `object.report`. +// +// A report has no field children: its read shape is DERIVED (Table B). The runtime +// reads it through a detached read model built by `reportReadModel`, so the query +// builder and the type coercer see ordinary fields. These tests pin the contract: +// list + count work, filter and sort work on any derived field, by-id and every +// write are refused, a sourceless report is not served, and the loaded model is +// never touched. + +import { describe, test, expect } from "bun:test"; +import { join, resolve } from "node:path"; +import { + MetaDataLoader, + InMemoryStringSource, + canonicalSerialize, + isMetaRoot, + reportReadModel, +} from "@metaobjectsdev/metadata"; +import type { MetaRoot } from "@metaobjectsdev/metadata"; +import { FileSource } from "@metaobjectsdev/metadata/core"; +import { ObjectManager } from "../src/object-manager.js"; +import { inMemoryDriver } from "../src/drivers/in-memory-driver.js"; +import { MetadataError } from "../src/errors.js"; +import type { Row } from "../src/persistence-driver.js"; + +const REPO_ROOT = resolve(import.meta.dir, "..", "..", "..", "..", ".."); +const CANONICAL = join(REPO_ROOT, "fixtures", "persistence-conformance", "canonical", "meta.fitness.json"); + +async function loadCanonical(): Promise { + const result = await new MetaDataLoader().load([new FileSource(CANONICAL)]); + expect(result.errors).toEqual([]); + if (!isMetaRoot(result.root)) throw new Error("not a root"); + return result.root; +} + +// The view's columns, under the default snake_case strategy applied to the DERIVED names. +const PROGRAM_MINUTES_ROWS: Row[] = [ + { program: 1, program_title: "Alpha", weeks: 3, long_weeks: 1, labels: 3, slots: 3, + total_minutes: 150, avg_minutes: "50.0000", min_minutes: 30, max_minutes: 75, long_share: "0.3333" }, + { program: 2, program_title: "Bravo", weeks: 2, long_weeks: 2, labels: 2, slots: 2, + total_minutes: 140, avg_minutes: "70.0000", min_minutes: 60, max_minutes: 80, long_share: "1.0000" }, + { program: 3, program_title: "Charlie", weeks: 1, long_weeks: 0, labels: 1, slots: 1, + total_minutes: 20, avg_minutes: "20.0000", min_minutes: 20, max_minutes: 20, long_share: "0.0000" }, +]; + +async function canonicalOm(): Promise<{ om: ObjectManager; root: MetaRoot }> { + const root = await loadCanonical(); + const driver = inMemoryDriver({ + seed: { v_program_minutes: PROGRAM_MINUTES_ROWS }, + pkFields: { v_program_minutes: ["program"] }, + }); + return { om: new ObjectManager({ metadata: root, driver }), root }; +} + +// An inline model for the cases the canonical corpus does not carry: an int-backed enum +// dimension (the derived field must carry @values + @intValueMap from its type source), +// an @unmanaged view, an @sql view, and a sourceless report. +const SALES = { + "metadata.root": { + package: "acme", + children: [ + { "object.entity": { name: "Sale", children: [ + { "source.rdb": { "@table": "sales" } }, + { "field.long": { name: "id" } }, + { "field.enum": { name: "status", "@required": true, "@values": ["OPEN", "CLOSED"], + "@intValueMap": { OPEN: 1, CLOSED: 2 } } }, + { "field.currency": { name: "amountCents", "@required": true, "@currency": "EUR" } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment" } }, + { "dimension.attribute": { name: "status", "@of": "Sale.status" } }, + { "measure.aggregate": { name: "sales", "@agg": "count", "@of": "Sale.id" } }, + { "measure.aggregate": { name: "revenue", "@agg": "sum", "@of": "Sale.amountCents" } }, + ] } }, + { "object.report": { name: "SalesByStatus", "@from": "Sale", "@dimensions": ["status"], + "@measures": ["sales", "revenue"], children: [ + { "source.rdb": { "@kind": "view", "@view": "v_sales_by_status" } }, + ] } }, + { "object.report": { name: "UnmanagedSales", "@from": "Sale", "@dimensions": ["status"], + "@measures": ["sales"], children: [ + { "source.rdb": { "@kind": "view", "@view": "v_unmanaged_sales", "@unmanaged": true } }, + ] } }, + { "object.report": { name: "AuthoredSales", "@from": "Sale", "@measures": ["sales"], children: [ + { "source.rdb": { "@kind": "view", "@view": "v_authored_sales", + "@sql": "SELECT COUNT(id) AS sales FROM sales" } }, + ] } }, + { "object.report": { name: "InertSales", "@from": "Sale", "@measures": ["sales"] } }, + ], + }, +}; + +async function salesOm(): Promise<{ om: ObjectManager; root: MetaRoot }> { + const result = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(SALES))]); + expect(result.errors.map((e) => e.message)).toEqual([]); + if (!isMetaRoot(result.root)) throw new Error("not a root"); + const driver = inMemoryDriver({ + seed: { + v_sales_by_status: [ + { status: 1, sales: 4, revenue: 4000 }, + { status: 2, sales: 6, revenue: 9000 }, + ], + v_unmanaged_sales: [ + { status: 1, sales: 4 }, + { status: 2, sales: 6 }, + ], + v_authored_sales: [{ sales: 10 }], + // A decoy: if a report read ever fell back to the entity-name default table + // ("inert_sales") or to the @from table, these rows would surface. + sales: [{ id: 1, status: 1, amount_cents: 1000 }], + inert_sales: [{ sales: 99 }], + }, + pkFields: { + v_sales_by_status: ["status"], v_unmanaged_sales: ["status"], + v_authored_sales: ["sales"], inert_sales: ["sales"], + }, + }); + return { om: new ObjectManager({ metadata: result.root, driver }), root: result.root }; +} + +describe("ObjectManager reads a view-backed report (FR-044)", () => { + test("findMany returns the view's rows keyed by derived field name", async () => { + const { om } = await canonicalOm(); + const rows = await om.findMany("ProgramMinutes", undefined, { orderBy: ["program", "asc"] }); + expect(rows).toHaveLength(3); + expect(rows[0]).toEqual({ + program: 1, programTitle: "Alpha", weeks: 3, longWeeks: 1, labels: 3, slots: 3, + totalMinutes: 150, avgMinutes: "50.0000", minMinutes: 30, maxMinutes: 75, longShare: "0.3333", + }); + }); + + test("count works with and without a filter", async () => { + const { om } = await canonicalOm(); + expect(await om.count("ProgramMinutes")).toBe(3); + expect(await om.count("ProgramMinutes", { totalMinutes: { $gte: 100 } })).toBe(2); + expect(await om.count("ProgramMinutes", { programTitle: "Charlie" })).toBe(1); + }); + + test("filters on a dimension and on a measure", async () => { + const { om } = await canonicalOm(); + const byDimension = await om.findMany("ProgramMinutes", { programTitle: { $like: "B%" } }); + expect(byDimension.map((r) => r.program)).toEqual([2]); + const byMeasure = await om.findMany("ProgramMinutes", { minMinutes: { $lt: 60 } }, { orderBy: ["program", "asc"] }); + expect(byMeasure.map((r) => r.programTitle)).toEqual(["Alpha", "Charlie"]); + const both = await om.findMany("ProgramMinutes", { $and: [{ weeks: { $gte: 2 } }, { longWeeks: { $gte: 2 } }] }); + expect(both.map((r) => r.programTitle)).toEqual(["Bravo"]); + }); + + test("sorts on a measure, with limit and offset", async () => { + const { om } = await canonicalOm(); + const desc = await om.findMany("ProgramMinutes", undefined, { orderBy: ["totalMinutes", "desc"] }); + expect(desc.map((r) => r.programTitle)).toEqual(["Alpha", "Bravo", "Charlie"]); + const page = await om.findMany("ProgramMinutes", undefined, { + orderBy: ["maxMinutes", "asc"], limit: 1, offset: 1, + }); + expect(page.map((r) => r.programTitle)).toEqual(["Alpha"]); + }); + + test("findFirst reads one row", async () => { + const { om } = await canonicalOm(); + const row = await om.findFirst("ProgramMinutes", { program: 2 }); + expect(row?.programTitle).toBe("Bravo"); + }); + + test("the literal naming strategy addresses the view by the derived names as written", async () => { + const root = await loadCanonical(); + const driver = inMemoryDriver({ + seed: { v_fitness_totals: [{ weeks: 6, totalMinutes: 310, longShare: "0.5000" }] }, + pkFields: { v_fitness_totals: ["weeks"] }, + }); + const om = new ObjectManager({ metadata: root, driver, columnNamingStrategy: "literal" }); + expect(await om.findMany("FitnessTotals")).toEqual([{ weeks: 6, totalMinutes: 310, longShare: "0.5000" }]); + }); + + test("rows are coerced by derived subtype: an int-backed enum dimension reads and filters as its symbol", async () => { + const { om } = await salesOm(); + const rows = await om.findMany("SalesByStatus", undefined, { orderBy: ["revenue", "desc"] }); + expect(rows).toEqual([ + { status: "CLOSED", sales: 6, revenue: 9000 }, + { status: "OPEN", sales: 4, revenue: 4000 }, + ]); + expect(await om.findMany("SalesByStatus", { status: "OPEN" })).toEqual([{ status: "OPEN", sales: 4, revenue: 4000 }]); + expect(await om.count("SalesByStatus", { status: { $in: ["OPEN", "CLOSED"] } })).toBe(2); + }); + + test("an @unmanaged report is served: findMany and count read its view", async () => { + const { om } = await salesOm(); + const rows = await om.findMany("UnmanagedSales", undefined, { orderBy: ["sales", "asc"] }); + expect(rows).toEqual([{ status: "OPEN", sales: 4 }, { status: "CLOSED", sales: 6 }]); + expect(await om.count("UnmanagedSales")).toBe(2); + expect(await om.count("UnmanagedSales", { sales: { $gt: 4 } })).toBe(1); + }); + + test("an @sql report is served from its view", async () => { + const { om } = await salesOm(); + expect(await om.findMany("AuthoredSales")).toEqual([{ sales: 10 }]); + expect(await om.count("AuthoredSales")).toBe(1); + }); + + test("a sourceless report is not served", async () => { + const { om } = await salesOm(); + for (const read of [ + () => om.findMany("InertSales"), + () => om.count("InertSales"), + () => om.findFirst("InertSales", {}), + ]) { + const err = await read().then(() => undefined, (e: unknown) => e); + expect(err).toBeInstanceOf(MetadataError); + expect((err as MetadataError).message).toContain("InertSales"); + expect((err as MetadataError).message).toContain("not served"); + expect((err as MetadataError).message).toContain("no view"); + } + }); + + test("by-id and every write on a report throw: read-only, no identity", async () => { + const { om } = await canonicalOm(); + const attempts: Array<[string, () => unknown]> = [ + ["findById", () => om.findById("ProgramMinutes", 1)], + ["create", () => om.create("ProgramMinutes", { program: 9 })], + ["update", () => om.update("ProgramMinutes", 1, { weeks: 9 })], + ["delete", () => om.delete("ProgramMinutes", 1)], + ["createMany", () => om.createMany("ProgramMinutes", [{ program: 9 }])], + ["updateMany", () => om.updateMany("ProgramMinutes", { program: 1 }, { weeks: 9 })], + ["deleteMany", () => om.deleteMany("ProgramMinutes", { program: 1 })], + ["load", () => om.load("ProgramMinutes:1")], + ["refOf", () => om.refOf("ProgramMinutes", { program: 1 })], + ]; + for (const [op, attempt] of attempts) { + let err: unknown; + try { + await attempt(); + } catch (e) { + err = e; + } + expect(err, op).toBeInstanceOf(MetadataError); + const message = (err as MetadataError).message; + expect(message, op).toContain("ProgramMinutes"); + expect(message, op).toContain("read-only"); + expect(message, op).toContain("no identity"); + expect(message, op).toContain(op); + expect((err as MetadataError).entity, op).toBe("ProgramMinutes"); + } + // Nothing was written. + expect(await om.count("ProgramMinutes")).toBe(3); + }); + + test("a write on a sourceless report is refused as read-only, not as unserved", async () => { + const { om } = await salesOm(); + const err = await om.create("InertSales", { sales: 1 }).then(() => undefined, (e: unknown) => e); + expect(err).toBeInstanceOf(MetadataError); + expect((err as MetadataError).message).toContain("read-only"); + }); + + test("an unknown field in a report filter or sort is refused by name", async () => { + const { om } = await canonicalOm(); + // `durationMinutes` is a Week field, not a derived field of the report. + await expect(om.findMany("ProgramMinutes", { durationMinutes: 60 })).rejects.toThrow( + "Unknown field 'durationMinutes' on entity 'ProgramMinutes'", + ); + await expect(om.findMany("ProgramMinutes", undefined, { orderBy: ["title", "asc"] })).rejects.toThrow( + "Unknown field 'title'", + ); + }); + + test("reading reports leaves the loaded model untouched", async () => { + const { om, root } = await canonicalOm(); + const before = canonicalSerialize(root); + const objects = root.objects().length; + await om.findMany("ProgramMinutes", { weeks: { $gte: 1 } }, { orderBy: ["weeks", "desc"] }); + await om.count("FitnessTotals"); + await om.findMany("ProgramsByMonth"); + await om.findMany("AssetActivity"); + expect(root.objects().length).toBe(objects); + expect(canonicalSerialize(root)).toBe(before); + }); + + test("the runtime reads through the cached read model", async () => { + const { om, root } = await canonicalOm(); + const report = root.objects().find((o) => o.name === "ProgramMinutes")!; + const model = reportReadModel(report, root); + await om.findMany("ProgramMinutes"); + expect(reportReadModel(report, root)).toBe(model); + }); + + test("entities in the same model are read and written as before", async () => { + const { om } = await salesOm(); + expect(await om.count("Sale")).toBe(1); + const created = await om.create("Sale", { status: "CLOSED", amountCents: 2500 }); + expect(created.status).toBe("CLOSED"); + expect((await om.findById("Sale", created.id))?.amountCents).toBe(2500); + expect(await om.count("Sale")).toBe(2); + }); +}); From 7541041f460e485c5bc8f3535321f32f5cf003e5 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 08:07:26 -0400 Subject: [PATCH 11/32] fix(docs): MySQL report recipe declares a managed view source; run the recipe's declaration in the test (FR-044) buildReportViews skips a report whose source.rdb is @unmanaged, so the recipe's @unmanaged: true declaration returned no SQL. The recipe now declares @kind: view only (meta migrate never targets MySQL, so nothing manages the view either way) and says that an unmanaged source is skipped. The MySQL test now reads the recipe's own fenced declaration, runs the recipe's loadDirectory + buildReportViews shape, and asserts one view, and none once @unmanaged is added. Tables and views are created in beforeAll after dropping stale ones, so tests run alone (-t) and rerun against a persistent server. --- .../references/typescript-mysql.md | 8 +- docs/recipes/mysql.md | 8 +- .../references/typescript-mysql.md | 8 +- .../references/typescript-mysql.md | 8 +- .../test/report-views-mysql.test.ts | 84 ++++++++++++++++++- 5 files changed, 105 insertions(+), 11 deletions(-) diff --git a/agent-context/skills/metaobjects-codegen/references/typescript-mysql.md b/agent-context/skills/metaobjects-codegen/references/typescript-mysql.md index 8b097721f..f1c315725 100644 --- a/agent-context/skills/metaobjects-codegen/references/typescript-mysql.md +++ b/agent-context/skills/metaobjects-codegen/references/typescript-mysql.md @@ -63,12 +63,16 @@ generated code and both runtimes quote identifiers themselves. An `object.report` (the reporting vocabulary, `docs/features/reporting.md`) is a compiled view, and on MySQL you create that view yourself, because `meta migrate` does not. Declare -the report with a read-only `source.rdb` of `@kind: view` and `@unmanaged: true`: +the report with a read-only `source.rdb` of `@kind: view` and no `@unmanaged`, since +`meta migrate` never targets MySQL and so nothing manages the view either way: ```json -{ "source.rdb": { "@kind": "view", "@view": "v_program_minutes", "@unmanaged": true } } +{ "source.rdb": { "@kind": "view", "@view": "v_program_minutes" } } ``` +`buildReportViews` skips a report whose source is `@unmanaged: true`, so generate the SQL +before marking a source unmanaged if a shared model needs that flag for another database. + `buildReportViews` returns the body of each view-backed report for the `mysql` dialect. Put each one in your own migration as `CREATE VIEW AS `: diff --git a/docs/recipes/mysql.md b/docs/recipes/mysql.md index 428d726bb..09bb6e5bb 100644 --- a/docs/recipes/mysql.md +++ b/docs/recipes/mysql.md @@ -81,12 +81,16 @@ generated code and both runtimes quote identifiers themselves. An `object.report` (the reporting vocabulary, `docs/features/reporting.md`) is a compiled view, and on MySQL you create that view yourself, because `meta migrate` does not. Declare -the report with a read-only `source.rdb` of `@kind: view` and `@unmanaged: true`: +the report with a read-only `source.rdb` of `@kind: view` and no `@unmanaged`, since +`meta migrate` never targets MySQL and so nothing manages the view either way: ```json -{ "source.rdb": { "@kind": "view", "@view": "v_program_minutes", "@unmanaged": true } } +{ "source.rdb": { "@kind": "view", "@view": "v_program_minutes" } } ``` +`buildReportViews` skips a report whose source is `@unmanaged: true`, so generate the SQL +before marking a source unmanaged if a shared model needs that flag for another database. + `buildReportViews` returns the body of each view-backed report for the `mysql` dialect. Put each one in your own migration as `CREATE VIEW AS `: diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md index 8b097721f..f1c315725 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md @@ -63,12 +63,16 @@ generated code and both runtimes quote identifiers themselves. An `object.report` (the reporting vocabulary, `docs/features/reporting.md`) is a compiled view, and on MySQL you create that view yourself, because `meta migrate` does not. Declare -the report with a read-only `source.rdb` of `@kind: view` and `@unmanaged: true`: +the report with a read-only `source.rdb` of `@kind: view` and no `@unmanaged`, since +`meta migrate` never targets MySQL and so nothing manages the view either way: ```json -{ "source.rdb": { "@kind": "view", "@view": "v_program_minutes", "@unmanaged": true } } +{ "source.rdb": { "@kind": "view", "@view": "v_program_minutes" } } ``` +`buildReportViews` skips a report whose source is `@unmanaged: true`, so generate the SQL +before marking a source unmanaged if a shared model needs that flag for another database. + `buildReportViews` returns the body of each view-backed report for the `mysql` dialect. Put each one in your own migration as `CREATE VIEW AS `: diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md index 8b097721f..f1c315725 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md @@ -63,12 +63,16 @@ generated code and both runtimes quote identifiers themselves. An `object.report` (the reporting vocabulary, `docs/features/reporting.md`) is a compiled view, and on MySQL you create that view yourself, because `meta migrate` does not. Declare -the report with a read-only `source.rdb` of `@kind: view` and `@unmanaged: true`: +the report with a read-only `source.rdb` of `@kind: view` and no `@unmanaged`, since +`meta migrate` never targets MySQL and so nothing manages the view either way: ```json -{ "source.rdb": { "@kind": "view", "@view": "v_program_minutes", "@unmanaged": true } } +{ "source.rdb": { "@kind": "view", "@view": "v_program_minutes" } } ``` +`buildReportViews` skips a report whose source is `@unmanaged: true`, so generate the SQL +before marking a source unmanaged if a shared model needs that flag for another database. + `buildReportViews` returns the body of each view-backed report for the `mysql` dialect. Put each one in your own migration as `CREATE VIEW AS `: diff --git a/server/typescript/packages/integration-tests/test/report-views-mysql.test.ts b/server/typescript/packages/integration-tests/test/report-views-mysql.test.ts index a093eeb37..717054991 100644 --- a/server/typescript/packages/integration-tests/test/report-views-mysql.test.ts +++ b/server/typescript/packages/integration-tests/test/report-views-mysql.test.ts @@ -27,13 +27,18 @@ */ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; import mysql from "mysql2/promise"; import { buildReportViews } from "@metaobjectsdev/codegen-ts"; -import { MetaDataLoader, InMemoryStringSource, type MetaRoot } from "@metaobjectsdev/metadata"; +import { MetaDataLoader, InMemoryStringSource, loadDirectory, type MetaRoot } from "@metaobjectsdev/metadata"; import { startMysql, type MysqlContainerHandle } from "../src/mysql-container.ts"; import { loadMetadataDir } from "../src/load-metadata.ts"; import { CANONICAL_DIR } from "../src/paths.ts"; +const REPO_ROOT = resolve(import.meta.dir, "../../../../.."); + let container: MysqlContainerHandle; let conn: mysql.Connection; let canonical: MetaRoot; @@ -80,7 +85,10 @@ function reportViews(root: MetaRoot) { /** `CREATE VIEW` for every report view of `root`, exactly as the recipe shows. */ async function createViews(root: MetaRoot): Promise { const views = reportViews(root); - for (const v of views) await conn.query(`CREATE VIEW \`${v.name}\` AS\n${v.sql}`); + for (const v of views) { + await conn.query(`DROP VIEW IF EXISTS \`${v.name}\``); + await conn.query(`CREATE VIEW \`${v.name}\` AS\n${v.sql}`); + } return views.map((v) => v.name); } @@ -176,8 +184,14 @@ beforeAll(async () => { supportBigNumbers: true, bigNumberStrings: true, }); + // Idempotent: a rerun against a persistent METAOBJECTS_TEST_MYSQL_URL starts clean, and + // every test below is independent of test order (or of `-t` selecting one of them). + const stale = await select(`SELECT table_name AS n FROM information_schema.views WHERE table_schema = DATABASE()`); + for (const v of stale) await conn.query(`DROP VIEW IF EXISTS \`${String(v.n)}\``); + for (const t of ["weeks", "programs", "assets", "events"]) await conn.query(`DROP TABLE IF EXISTS ${t}`); for (const ddl of DDL) await conn.query(ddl); canonical = await loadMetadataDir(CANONICAL_DIR); + await createViews(canonical); }, 240_000); afterAll(async () => { @@ -198,6 +212,7 @@ describe("report views — canonical model on real MySQL 8.4", () => { expect(String(mode?.g)).toContain("ONLY_FULL_GROUP_BY"); expect(String(mode?.s)).toContain("ONLY_FULL_GROUP_BY"); + // Re-create them here, under the mode just asserted, so this test does not lean on beforeAll. const names = await createViews(canonical); expect([...names].sort()).toEqual([...CANONICAL_VIEWS].sort()); for (const name of names) { @@ -207,7 +222,8 @@ describe("report views — canonical model on real MySQL 8.4", () => { const created = await select( `SELECT table_name AS n FROM information_schema.views WHERE table_schema = DATABASE() ORDER BY table_name`, ); - expect(created.map((r) => r.n)).toEqual([...CANONICAL_VIEWS].sort()); + // The inline-model tests below add views of their own, so assert inclusion, not equality. + expect(created.map((r) => r.n)).toEqual(expect.arrayContaining([...CANONICAL_VIEWS])); }, 60_000); describe("values", () => { @@ -383,3 +399,65 @@ describe("report views — inline models on real MySQL 8.4", () => { expect(await select("SELECT * FROM `v_up_to_tomorrow`")).toEqual([{ events: "4" }]); }, 60_000); }); + +describe("the recipe's declaration and script", () => { + /** The first fenced `json` block under "### Reports" in docs/recipes/mysql.md. */ + function recipeDeclaration(): string { + const doc = readFileSync(join(REPO_ROOT, "docs/recipes/mysql.md"), "utf8"); + const section = doc.slice(doc.indexOf("### Reports")); + const m = /```json\n([\s\S]*?)\n```/.exec(section); + if (m === null) throw new Error("docs/recipes/mysql.md has no json block under '### Reports'"); + return m[1]!; + } + + /** A model whose one report carries `sourceJson` as its source, written where `loadDirectory` reads it. */ + function modelDir(sourceJson: string): string { + const dir = mkdtempSync(join(tmpdir(), "report-recipe-")); + const source = JSON.parse(sourceJson) as Record; + writeFileSync(join(dir, "meta.fitness.json"), JSON.stringify({ "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Week", children: [ + { "source.rdb": { "@table": "weeks" } }, + { "field.long": { name: "id" } }, + { "field.int": { name: "durationMinutes", "@required": true } }, + { "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } }, + { "measure.aggregate": { name: "weeks", "@agg": "count", "@of": "Week.id" } }, + ] } }, + { "object.report": { name: "ProgramMinutes", "@from": "Week", "@measures": ["weeks"], children: [source] } }, + ]}})); + return dir; + } + + /** The recipe's script, minus the console.log. */ + async function recipeViews(sourceJson: string) { + const dir = modelDir(sourceJson); + try { + const { root } = await loadDirectory(dir); + return buildReportViews(root, { dialect: "mysql" }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + test("the declaration the recipe shows yields exactly one view, and MySQL accepts its body", async () => { + const declaration = recipeDeclaration(); + expect(declaration).not.toContain("@unmanaged"); + const views = await recipeViews(declaration); + expect(views.map((v) => v.name)).toEqual(["v_program_minutes"]); + + // The body is built over the default snake_case column names; the recipe's own `weeks` + // table has `id`, so it resolves as it stands. + await conn.query("DROP VIEW IF EXISTS `v_program_minutes_recipe`"); + await conn.query(`CREATE VIEW \`v_program_minutes_recipe\` AS\n${views[0]!.sql}`); + await exec(` + INSERT INTO programs (id, title, priceCents, status, created_ts) VALUES (1, 'P', 1, 'DRAFT', '2026-05-01T10:00:00'); + INSERT INTO weeks (programId, label, durationMinutes) VALUES (1, 'a', 30), (1, 'b', 45);`); + expect(await select("SELECT * FROM `v_program_minutes_recipe`")).toEqual([{ weeks: "2" }]); + await conn.query("DROP VIEW `v_program_minutes_recipe`"); + }, 60_000); + + test("the same declaration with @unmanaged: true yields no view: buildReportViews skips an unmanaged source", async () => { + const unmanaged = JSON.parse(recipeDeclaration()) as { "source.rdb": Record }; + unmanaged["source.rdb"]["@unmanaged"] = true; + expect(await recipeViews(JSON.stringify(unmanaged))).toEqual([]); + }); +}); From 0a0485b8f82ad43dfe771fc0484813322accf684 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 08:12:12 -0400 Subject: [PATCH 12/32] fix(runtime-ts): the report read model reads the same source the lowering names (FR-044) --- .../src/core/reporting/report-read-model.ts | 48 ++++++++++++--- .../metadata/test/report-read-model.test.ts | 50 ++++++++++++++++ .../packages/runtime-ts/src/query-builder.ts | 12 +++- .../test/object-manager-report.test.ts | 58 +++++++++++++++++++ 4 files changed, 159 insertions(+), 9 deletions(-) diff --git a/server/typescript/packages/metadata/src/core/reporting/report-read-model.ts b/server/typescript/packages/metadata/src/core/reporting/report-read-model.ts index fab33a7c7..160e58c55 100644 --- a/server/typescript/packages/metadata/src/core/reporting/report-read-model.ts +++ b/server/typescript/packages/metadata/src/core/reporting/report-read-model.ts @@ -27,6 +27,7 @@ import { TYPE_FIELD } from "../../shared/base-types.js"; import type { MetaRoot } from "../../shared/meta-root.js"; import { isReadOnlySource } from "../../shared/node-guards.js"; import { MetaSource } from "../../persistence/source/meta-source.js"; +import { SOURCE_ATTR_ROLE, SOURCE_ROLE_PRIMARY } from "../../persistence/source/source-constants.js"; import { FIELD_ATTR_DB_COLUMN_TYPE, FIELD_ATTR_LOCAL_TIME } from "../../persistence/db/db-constants.js"; import { MetaObject } from "../object/meta-object.js"; import { MetaField } from "../field/meta-field.js"; @@ -88,12 +89,46 @@ function derivedField(f: ReportField): MetaField { return field; } -/** A detached copy of a source node: same `type.subType`, name and effective attrs. */ +/** + * The source a report is READ from: its own read-only source with `@role: primary`, + * else its first own read-only source. Undefined when it declares none (Table A: + * not lowered, not served). + * + * This is the rule that NAMES the lowered view — `viewName` / `projectionViewSource` + * in codegen-ts's `projection/extract-view-spec.ts`, reached for a report through + * `projectionViewName`. It is restated here because the metadata package cannot + * depend on a codegen package; the two must stay the same rule, or the runtime + * reads a relation the lowering did not create. + * + * What the loader permits, measured: a report may declare several read-only sources + * (a `@role: replica` view beside its primary view loads clean, in either order); + * `@role` defaults to `primary`; a report whose sources include no primary is + * `ERR_SOURCE_NO_PRIMARY` and a writable source on a report is refused. So for every + * model that loads, the primary branch fires. The first-read-only fallback covers a + * tree built in code, and keeps this rule identical to the lowering's. + */ +function reportReadSource(report: MetaObject): MetaSource | undefined { + // ADR-0039: own — source classification reads the sources the report declares + // ITSELF, exactly as the lowering's `viewName` does. + const readOnly = report.ownChildren().filter(isReadOnlySource); + return readOnly.find((s) => s.role === SOURCE_ROLE_PRIMARY) ?? readOnly[0]; +} + +/** + * A detached copy of a source node: same `type.subType`, name and effective attrs, + * and nothing else (attrs only — the loaded node is never re-parented). + * + * The copy is the model's ONLY source, and it is pinned to `@role: primary`: the + * runtime resolves an object's table through `primaryRdbSource`, which considers + * primary sources only, so this is what makes the read land on the selected + * source's physical name rather than on a default table name nobody declared. + */ function copySource(source: MetaSource): MetaSource { const copy = new MetaSource(source.typeId, source.name); // ADR-0039: resolving — the copy carries the source's effective configuration - // (@kind, the physical-name alias, @schema, @role, @unmanaged, @sql). + // (@kind, the physical-name alias, @schema, @unmanaged, @sql). for (const [name, value] of source.attrs()) copy.setAttr(name, value); + copy.setAttr(SOURCE_ATTR_ROLE, SOURCE_ROLE_PRIMARY); return copy; } @@ -101,8 +136,8 @@ const READ_MODELS = new WeakMap(); /** * The read model of an `object.report`: one field per Table B row, in Table B - * order, plus a copy of the report's own read-only source when it declares one - * (Table A). A sourceless report yields a model with no source: it has a shape + * order, plus a copy of the source the report is read from (see + * `reportReadSource`) when it declares one (Table A). A sourceless report yields a model with no source: it has a shape * and no view, and the caller decides what that means (the runtime refuses to * serve it). * @@ -119,10 +154,7 @@ export function reportReadModel(report: MetaObject, root: MetaRoot): MetaObject if (report.fileDefaultPackage !== undefined) model.setFileDefaultPackage(report.fileDefaultPackage); for (const f of shape.fields) model.addChild(derivedField(f)); - // ADR-0039: own — Table A classifies a report by the source it declares ITSELF, - // the same own-source read as codegen's `classifyReadOnlySource`, so the runtime - // serves exactly the reports whose view the lowering (or the adopter) provides. - const source = report.ownChildren().find(isReadOnlySource); + const source = reportReadSource(report); if (source !== undefined) model.addChild(copySource(source)); model.freeze(); diff --git a/server/typescript/packages/metadata/test/report-read-model.test.ts b/server/typescript/packages/metadata/test/report-read-model.test.ts index 1293d72d3..a552a03c5 100644 --- a/server/typescript/packages/metadata/test/report-read-model.test.ts +++ b/server/typescript/packages/metadata/test/report-read-model.test.ts @@ -17,6 +17,7 @@ import { isMetaSource, loadUris, reportReadModel, + resolveTableName, type MetaObject, type MetaRoot, } from "../src/index.js"; @@ -146,6 +147,55 @@ describe("reportReadModel (FR-044 Table B as a detached read model)", () => { expect(model(root, "ProgramMinutes")).not.toBe(model(root, "FitnessTotals")); }); + // What the loader permits (asserted by `errors` below, not assumed): a report may + // declare two read-only sources, and @role defaults to primary. + const multiSource = async (sources: unknown[]): Promise => { + const result = await new MetaDataLoader().load([ + new InMemoryStringSource( + JSON.stringify({ + "metadata.root": { + package: "acme", + children: [ + { "object.entity": { name: "Sale", children: [ + { "source.rdb": { "@table": "sales" } }, + { "field.long": { name: "id" } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment" } }, + { "measure.aggregate": { name: "sales", "@agg": "count", "@of": "Sale.id" } }, + ] } }, + { "object.report": { name: "Totals", "@from": "Sale", "@measures": ["sales"], children: sources } }, + ], + }, + }), + ), + ]); + expect(result.errors.map((e) => e.message)).toEqual([]); + return result.root; + }; + + test("a replica read-only source declared before the primary view: the model holds the primary", async () => { + const root = await multiSource([ + { "source.rdb": { name: "rep", "@kind": "view", "@view": "v_totals_replica", "@role": "replica" } }, + { "source.rdb": { name: "pri", "@kind": "view", "@view": "v_totals", "@role": "primary" } }, + ]); + const m = model(root, "Totals"); + const sources = m.children().filter(isMetaSource); + expect(sources.map((s) => [s.physicalName, s.role])).toEqual([["v_totals", "primary"]]); + expect(resolveTableName(m)).toBe("v_totals"); + }); + + test("a read-only source with no explicit @role is the one read", async () => { + const root = await multiSource([{ "source.rdb": { "@kind": "view", "@view": "v_only" } }]); + const m = model(root, "Totals"); + expect(m.children().filter(isMetaSource).map((s) => [s.physicalName, s.role])).toEqual([["v_only", "primary"]]); + expect(resolveTableName(m)).toBe("v_only"); + }); + + test("the canonical reports resolve their table to the declared view", async () => { + const root = await load(); + expect(resolveTableName(model(root, "ProgramMinutes"))).toBe("v_program_minutes"); + expect(resolveTableName(model(root, "AssetActivity"))).toBe("v_asset_activity"); + }); + test("a sourceless report yields a model with the fields and no source", async () => { const result = await new MetaDataLoader().load([ new InMemoryStringSource( diff --git a/server/typescript/packages/runtime-ts/src/query-builder.ts b/server/typescript/packages/runtime-ts/src/query-builder.ts index 7ce8ac295..ef8ec0079 100644 --- a/server/typescript/packages/runtime-ts/src/query-builder.ts +++ b/server/typescript/packages/runtime-ts/src/query-builder.ts @@ -196,7 +196,17 @@ export function buildSelectSpec( const allFields = projectedFields ?? listFieldNames(entity); // A report's read model (FR-044) has no identity, so there is no key to add to the // column list. Scoped to the report subtype: every other object still requires one. - const pkFields = entity.subType === OBJECT_SUBTYPE_REPORT ? [] : resolvePkFields(entity); + const isReport = entity.subType === OBJECT_SUBTYPE_REPORT; + // A report node as DECLARED has no field children (its shape is derived); only its + // read model does. Selecting from the bare node would be a query with no columns. + if (isReport && listFieldNames(entity).length === 0) { + throw new MetadataError( + `Report '${entity.name}' has no fields to select: a report is read through its read model ` + + `(reportReadModel), not through the declared node`, + { entity: entity.name }, + ); + } + const pkFields = isReport ? [] : resolvePkFields(entity); const fieldSet = new Set(allFields); for (const pk of pkFields) fieldSet.add(pk); diff --git a/server/typescript/packages/runtime-ts/test/object-manager-report.test.ts b/server/typescript/packages/runtime-ts/test/object-manager-report.test.ts index bbde68852..9d47cace7 100644 --- a/server/typescript/packages/runtime-ts/test/object-manager-report.test.ts +++ b/server/typescript/packages/runtime-ts/test/object-manager-report.test.ts @@ -21,6 +21,7 @@ import { FileSource } from "@metaobjectsdev/metadata/core"; import { ObjectManager } from "../src/object-manager.js"; import { inMemoryDriver } from "../src/drivers/in-memory-driver.js"; import { MetadataError } from "../src/errors.js"; +import { buildSelectSpec } from "../src/query-builder.js"; import type { Row } from "../src/persistence-driver.js"; const REPO_ROOT = resolve(import.meta.dir, "..", "..", "..", "..", ".."); @@ -82,6 +83,11 @@ const SALES = { { "source.rdb": { "@kind": "view", "@view": "v_authored_sales", "@sql": "SELECT COUNT(id) AS sales FROM sales" } }, ] } }, + // Loads clean: a report may declare a replica read-only source beside its primary view. + { "object.report": { name: "ReplicatedSales", "@from": "Sale", "@measures": ["sales"], children: [ + { "source.rdb": { name: "rep", "@kind": "view", "@view": "v_replicated_sales_replica", "@role": "replica" } }, + { "source.rdb": { name: "pri", "@kind": "view", "@view": "v_replicated_sales", "@role": "primary" } }, + ] } }, { "object.report": { name: "InertSales", "@from": "Sale", "@measures": ["sales"] } }, ], }, @@ -102,6 +108,11 @@ async function salesOm(): Promise<{ om: ObjectManager; root: MetaRoot }> { { status: 2, sales: 6 }, ], v_authored_sales: [{ sales: 10 }], + v_replicated_sales: [{ sales: 10 }], + // Decoys: the replica view, and the default table name a model with no primary + // source would fall back to. + v_replicated_sales_replica: [{ sales: 77 }], + replicated_sales: [{ sales: 88 }], // A decoy: if a report read ever fell back to the entity-name default table // ("inert_sales") or to the @from table, these rows would surface. sales: [{ id: 1, status: 1, amount_cents: 1000 }], @@ -110,6 +121,7 @@ async function salesOm(): Promise<{ om: ObjectManager; root: MetaRoot }> { pkFields: { v_sales_by_status: ["status"], v_unmanaged_sales: ["status"], v_authored_sales: ["sales"], inert_sales: ["sales"], + v_replicated_sales: ["sales"], v_replicated_sales_replica: ["sales"], replicated_sales: ["sales"], }, }); return { om: new ObjectManager({ metadata: result.root, driver }), root: result.root }; @@ -194,6 +206,52 @@ describe("ObjectManager reads a view-backed report (FR-044)", () => { expect(await om.count("AuthoredSales")).toBe(1); }); + test("a replica read-only source declared before the primary view: reads come from the primary view", async () => { + const { om } = await salesOm(); + expect(await om.findMany("ReplicatedSales")).toEqual([{ sales: 10 }]); + expect(await om.count("ReplicatedSales")).toBe(1); + expect(await om.count("ReplicatedSales", { sales: 10 })).toBe(1); + }); + + test("a report whose only read-only source has no explicit @role is read from it", async () => { + // SalesByStatus, UnmanagedSales and AuthoredSales all declare no @role. + const { om, root } = await salesOm(); + const report = root.objects().find((o) => o.name === "SalesByStatus")!; + expect(buildSelectSpec(reportReadModel(report, root), undefined, {}).table).toBe("v_sales_by_status"); + expect(await om.count("SalesByStatus")).toBe(2); + }); + + test("the select spec: the view as the table, the derived columns in Table B order, no key column", async () => { + const root = await loadCanonical(); + const report = root.objects().find((o) => o.name === "ProgramMinutes")!; + const spec = buildSelectSpec(reportReadModel(report, root), undefined, {}); + expect(spec.table).toBe("v_program_minutes"); + expect(spec.columns).toEqual([ + "program", "program_title", "weeks", "long_weeks", "labels", "slots", + "total_minutes", "avg_minutes", "min_minutes", "max_minutes", "long_share", + ]); + expect(spec.where).toBeUndefined(); + const literal = buildSelectSpec(reportReadModel(report, root), undefined, {}, undefined, "literal"); + expect(literal.columns).toEqual([ + "program", "programTitle", "weeks", "longWeeks", "labels", "slots", + "totalMinutes", "avgMinutes", "minMinutes", "maxMinutes", "longShare", + ]); + }); + + test("the declared report node, passed straight to buildSelectSpec, is refused by name", async () => { + const root = await loadCanonical(); + const report = root.objects().find((o) => o.name === "ProgramMinutes")!; + let err: unknown; + try { + buildSelectSpec(report, undefined, {}); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(MetadataError); + expect((err as MetadataError).message).toContain("ProgramMinutes"); + expect((err as MetadataError).message).toContain("no fields"); + }); + test("a sourceless report is not served", async () => { const { om } = await salesOm(); for (const read of [ From e79706918d68b64a112ff2ccda5b5215797d83ef Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 08:17:59 -0400 Subject: [PATCH 13/32] test(persistence-conformance): six report scenarios (FR-044) Six shared read scenarios over the view-backed reports in the canonical model: grouped measures, totals, totals over an empty table, time grains, hour and week buckets, and a relative-date filter. List and count only; no get, no write. The TypeScript runner discovers them from queries/ with no list to update and passes all 33 query scenarios. Persistence corpus count 33 -> 39 (27 -> 33 query) in docs/CONFORMANCE.md; README gains a Report scenarios subsection. Other ports' persistence lanes are red on these until their own tasks. --- docs/CONFORMANCE.md | 8 +-- fixtures/persistence-conformance/README.md | 26 +++++++++ .../queries/report-grouped-measures.yaml | 53 +++++++++++++++++++ .../queries/report-relative-date.yaml | 18 +++++++ .../queries/report-time-grains.yaml | 38 +++++++++++++ .../queries/report-time-hour-and-date.yaml | 35 ++++++++++++ .../queries/report-totals-empty.yaml | 14 +++++ .../queries/report-totals.yaml | 21 ++++++++ 8 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 fixtures/persistence-conformance/queries/report-grouped-measures.yaml create mode 100644 fixtures/persistence-conformance/queries/report-relative-date.yaml create mode 100644 fixtures/persistence-conformance/queries/report-time-grains.yaml create mode 100644 fixtures/persistence-conformance/queries/report-time-hour-and-date.yaml create mode 100644 fixtures/persistence-conformance/queries/report-totals-empty.yaml create mode 100644 fixtures/persistence-conformance/queries/report-totals.yaml diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 97954023d..9ed4560fc 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -33,7 +33,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | [`fixtures/render-conformance/`](../fixtures/render-conformance/) | 15 | ✓ | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/extract-conformance/`](../fixtures/extract-conformance/) | 48 | ✓ | ✓ | inherits the shared JVM engine | ✓ | ✓ | | [`fixtures/output-prompt-conformance/`](../fixtures/output-prompt-conformance/) | 17 | ✓ | ✓ | ✓ | ✓ | ✓ | -| [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 33 (27 query + 6 migration) | all 33 | 27 query (migrations TS-only, ADR-0015) | 27 query (via Exposed) | 27 query | 27 query | +| [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 39 (33 query + 6 migration) | all 39 | 33 query (migrations TS-only, ADR-0015) | 33 query (via Exposed) | 33 query | 33 query | | [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 61 (31 core + 10 tph + 9 m2m + 2 jsonb + 2 write-through + 7 projection) | ✓ (Fastify reference + generated lane) | ✓ (embedded HTTP + JDBC) | ✓ (embedded HTTP + Exposed) | ✓ (HttpListener + Npgsql) | ✓ (FastAPI + pg8000) | | [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 16 cases | ✓ | ✓ | ✓ | ✓ | ✓ | | [`fixtures/registry-conformance/`](../fixtures/registry-conformance/) | 1 canonical manifest | ✓ (reference emitter) | ✓ | ✓ | ✓ | ✓ | @@ -250,10 +250,10 @@ trailing-newline preservation, and unicode multibyte handling. All 31 fixtures → [features/migrations-and-drift.md](features/migrations-and-drift.md) (template drift section — `Renderer.verify`). -### `fixtures/persistence-conformance/` (33 — 27 query + 6 migration) +### `fixtures/persistence-conformance/` (39 — 33 query + 6 migration) - `migrations/*` (6) → [features/migrations-and-drift.md](features/migrations-and-drift.md) (schema migration section) -- `queries/*` (27) → [features/source-kinds.md](features/source-kinds.md) (query semantics against `source.rdb`) +- `queries/*` (33) → [features/source-kinds.md](features/source-kinds.md) (query semantics against `source.rdb`) ### `fixtures/api-contract-conformance/` (61) @@ -398,7 +398,7 @@ own those two functions), and ## Orphaned fixtures (tested but not yet documented) The fixtures in the nine corpora mapped above (metamodel 361 + yaml 16 + verify 31 -+ render 15 + persistence 33 + api-contract 61 + source-resolution 25 + scope 10 + ++ render 15 + persistence 39 + api-contract 61 + source-resolution 25 + scope 10 + dependency 23) each map to a feature doc. None are orphaned today. The remaining corpora in the totals table gate tooling contracts (registry manifests, provider composition, agent context, docs emit) rather than user-facing metamodel behaviour, diff --git a/fixtures/persistence-conformance/README.md b/fixtures/persistence-conformance/README.md index 3a2ceacd3..a3fc16e8c 100644 --- a/fixtures/persistence-conformance/README.md +++ b/fixtures/persistence-conformance/README.md @@ -312,6 +312,32 @@ Because M:N membership is a **set**, the runner compares `relate` results > (both columns equal X) — the historical Kotlin failure mode this scenario > pins; all five ports now retain the `(a,a)` row. +### Report scenarios (FR-044) + +Six `queries/report-*.yaml` scenarios read a view-backed `object.report` through the +port's runtime. They use only `op: list` and `op: count`, single-key `sort`, `filter` and +`limit`: a report has no primary key, so there is no `op: get` and no write. The schema is +still the committed `canonical/schema.postgres.sql`, which creates each report's view, so a +port reads the view the TypeScript migrate engine produced and never lowers a report itself. +Rows are keyed by the report's **derived field names** (dimensions in `@dimensions` order, +then measures in `@measures` order; a time dimension is named ``, for example +`createdAtMonth`). + +| Scenario | What it pins | +|---|---| +| `report-grouped-measures` | every measure kind; an attribute dimension reached through a to-one reference; `filter`, `sort` and `limit` on a measure; `count` of groups | +| `report-totals` | no dimensions: one row for the whole table; a ratio | +| `report-totals-empty` | one row over an empty table: `count` is `0`, `sum` is `null`, a zero-denominator ratio is `null` | +| `report-time-grains` | `month` grain beside an enum dimension; a segment-filtered `sum` that is `null`; the ISO Monday week boundary; a report-level `@segment` | +| `report-time-hour-and-date` | `hour` on an instant, `week` on a `field.date` | +| `report-relative-date` | a `{ now: "-P30D" }` filter, with a seed relative to the database clock | + +Wire shapes follow the view's column types and [`normalization.md`](./normalization.md): +`count` and the integral `sum` are `BIGINT` (string), `min`/`max` of an int are `INTEGER` +(number), `avg` and a ratio are `NUMERIC` (canonical decimal string, so `"60"`, `"0.75"` +and `"0"`), a `date`-grain bucket is a `DATE` string, and an instant `hour` bucket is a +`TIMESTAMPTZ` string in UTC (`"2026-05-04T03:00:00Z"`). + ### Filter operators Same vocabulary as the cross-language filter spec (Project D): diff --git a/fixtures/persistence-conformance/queries/report-grouped-measures.yaml b/fixtures/persistence-conformance/queries/report-grouped-measures.yaml new file mode 100644 index 000000000..bf2afad4a --- /dev/null +++ b/fixtures/persistence-conformance/queries/report-grouped-measures.yaml @@ -0,0 +1,53 @@ +name: report-grouped-measures +description: | + ProgramMinutes is an object.report over Week: one row per (program, programTitle), + with every measure kind. programTitle is reached through the to-one reference + Week.fkProgram, so the view joins programs. Wire shapes follow the view's column + types: counts and the integral sum are BIGINT (string), min/max of an int are + INTEGER (number), avg and the ratio are NUMERIC (canonical decimal string). + + Program 1's slots are (1,30), (1,60), (1,90): three distinct tuples from four rows. + Its labels are 'Week 1', 'Week 2' and one NULL: two distinct, the null uncounted. + + A report has no primary key, so every query here is a list or a count; get-by-id + and every write are refused. +seed-data: | + INSERT INTO "programs" ("id","title","priceCents","status","created_ts") VALUES + (1, 'Foundations', 4999, 'PUBLISHED', '2026-05-01T10:00:00'), + (2, 'Strength', 2500, 'PUBLISHED', '2026-05-17T23:30:00'); + INSERT INTO "weeks" ("id","programId","label","durationMinutes") VALUES + (10, 1, 'Week 1', 30), + (11, 1, 'Week 2', 60), + (12, 1, 'Week 2', 90), + (13, 1, NULL, 60), + (20, 2, 'Solo', 45); +queries: + - name: all-groups + op: list + entity: ProgramMinutes + sort: [{ field: program, dir: asc }] + expect: + - { program: "1", programTitle: "Foundations", weeks: "4", longWeeks: "3", labels: "2", slots: "3", + totalMinutes: "240", avgMinutes: "60", minMinutes: 30, maxMinutes: 90, longShare: "0.75" } + - { program: "2", programTitle: "Strength", weeks: "1", longWeeks: "0", labels: "1", slots: "1", + totalMinutes: "45", avgMinutes: "45", minMinutes: 45, maxMinutes: 45, longShare: "0" } + - name: filter-on-a-measure + op: list + entity: ProgramMinutes + filter: { weeks: { gte: 2 } } + sort: [{ field: program, dir: asc }] + expect: + - { program: "1", programTitle: "Foundations", weeks: "4", longWeeks: "3", labels: "2", slots: "3", + totalMinutes: "240", avgMinutes: "60", minMinutes: 30, maxMinutes: 90, longShare: "0.75" } + - name: sort-desc-on-a-measure + op: list + entity: ProgramMinutes + sort: [{ field: totalMinutes, dir: desc }] + limit: 1 + expect: + - { program: "1", programTitle: "Foundations", weeks: "4", longWeeks: "3", labels: "2", slots: "3", + totalMinutes: "240", avgMinutes: "60", minMinutes: 30, maxMinutes: 90, longShare: "0.75" } + - name: count-groups + op: count + entity: ProgramMinutes + expect: 2 diff --git a/fixtures/persistence-conformance/queries/report-relative-date.yaml b/fixtures/persistence-conformance/queries/report-relative-date.yaml new file mode 100644 index 000000000..6669beac2 --- /dev/null +++ b/fixtures/persistence-conformance/queries/report-relative-date.yaml @@ -0,0 +1,18 @@ +name: report-relative-date +description: | + RecentPrograms carries the report-level filter createdAt >= now - P30D. A relative-date + value is evaluated by the database when the view is QUERIED (the view calls now()), not + when it is created, so a fixed seed date would rot as the clock advances. The seed is + therefore clock-relative: seed SQL is executed raw, so an expression is legal. One + program is three days old (inside the window), the other sixty (outside). + created_ts is a naive timestamp holding the UTC wall clock. +seed-data: | + INSERT INTO "programs" ("id","title","priceCents","status","created_ts") VALUES + (1, 'Fresh', 1000, 'PUBLISHED', (now() AT TIME ZONE 'UTC') - INTERVAL '3 days'), + (2, 'Stale', 2000, 'PUBLISHED', (now() AT TIME ZONE 'UTC') - INTERVAL '60 days'); +queries: + - name: only-the-recent-program-counts + op: list + entity: RecentPrograms + expect: + - { programs: "1" } diff --git a/fixtures/persistence-conformance/queries/report-time-grains.yaml b/fixtures/persistence-conformance/queries/report-time-grains.yaml new file mode 100644 index 000000000..bf13bcc4f --- /dev/null +++ b/fixtures/persistence-conformance/queries/report-time-grains.yaml @@ -0,0 +1,38 @@ +name: report-time-grains +description: | + Time-grain dimensions over Program.createdAt (a naive timestamp, @column created_ts). + A grain bucket is a DATE: the first day of the bucket, wire form "YYYY-MM-DD". + + ProgramsByMonth groups by (createdAt month, status). The PUBLISHED group's listValue is + the sum of priceCents over the published programs: 4999 + 2500 + 300 = 7799. The DRAFT + and ARCHIVED groups have no published row, so the measure's `published` segment matches + nothing there and the sum is NULL (not 0). + + ProgramsByWeek buckets by ISO week (Monday start) and carries a report-level segment, + `published`, so the DRAFT and ARCHIVED programs are absent. Program 2 (Sunday 23:30) and + program 5 (Monday 00:00) are thirty minutes apart and land in different weeks: that is + the ISO Monday boundary. +seed-data: | + INSERT INTO "programs" ("id","title","priceCents","status","created_ts") VALUES + (1, 'Foundations', 4999, 'PUBLISHED', '2026-05-01T10:00:00'), + (2, 'Strength', 2500, 'PUBLISHED', '2026-05-17T23:30:00'), + (3, 'Mobility', 1000, 'DRAFT', '2026-06-01T00:00:00'), + (4, 'Legacy', 700, 'ARCHIVED', '2026-05-31T23:59:59'), + (5, 'Monday', 300, 'PUBLISHED', '2026-05-18T00:00:00'); +queries: + - name: by-month-and-status + op: list + entity: ProgramsByMonth + sort: [{ field: status, dir: asc }] + expect: + - { createdAtMonth: "2026-05-01", status: "ARCHIVED", programs: "1", listValue: null } + - { createdAtMonth: "2026-06-01", status: "DRAFT", programs: "1", listValue: null } + - { createdAtMonth: "2026-05-01", status: "PUBLISHED", programs: "3", listValue: "7799" } + - name: by-week-published-only + op: list + entity: ProgramsByWeek + sort: [{ field: createdAtWeek, dir: asc }] + expect: + - { createdAtWeek: "2026-04-27", programs: "1" } + - { createdAtWeek: "2026-05-11", programs: "1" } + - { createdAtWeek: "2026-05-18", programs: "1" } diff --git a/fixtures/persistence-conformance/queries/report-time-hour-and-date.yaml b/fixtures/persistence-conformance/queries/report-time-hour-and-date.yaml new file mode 100644 index 000000000..a9c7df9de --- /dev/null +++ b/fixtures/persistence-conformance/queries/report-time-hour-and-date.yaml @@ -0,0 +1,35 @@ +name: report-time-hour-and-date +description: | + AssetActivity buckets Asset.recordedAt by hour and Asset.asOfDate by week. + + recordedAt is an instant (TIMESTAMPTZ); its hour bucket is itself an instant, so it is + bucketed in UTC and read back in the instant wire form "2026-05-04T03:00:00Z". + asOfDate is a field.date; its week bucket is a DATE, the Monday that starts the ISO week. + 2026-05-03 is a Sunday, so its week starts 2026-04-27; 2026-05-04 is a Monday. +seed-data: | + INSERT INTO "assets" + ("id","ownerId","externalId","payload","recordedAt","observedAt","asOfDate","atTime") + VALUES + ('11111111-1111-4111-8111-111111111111', + 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + '22222222-2222-4222-8222-222222222222', + '{"k": 1}', + '2026-05-04T03:30:00Z', '2026-05-04T03:30:00', '2026-05-03', '03:30:00'), + ('33333333-3333-4333-8333-333333333333', + '44444444-4444-4444-8444-444444444444', + '55555555-5555-4555-8555-555555555555', + '{"k": 2}', + '2026-05-04T03:45:00Z', '2026-05-04T03:45:00', '2026-05-03', '03:45:00'), + ('66666666-6666-4666-8666-666666666666', + '77777777-7777-4777-8777-777777777777', + '88888888-8888-4888-8888-888888888888', + '{"k": 3}', + '2026-05-04T04:10:00Z', '2026-05-04T04:10:00', '2026-05-04', '04:10:00'); +queries: + - name: by-hour-and-week + op: list + entity: AssetActivity + sort: [{ field: recordedAtHour, dir: asc }] + expect: + - { recordedAtHour: "2026-05-04T03:00:00Z", asOfDateWeek: "2026-04-27", assets: "2" } + - { recordedAtHour: "2026-05-04T04:00:00Z", asOfDateWeek: "2026-05-04", assets: "1" } diff --git a/fixtures/persistence-conformance/queries/report-totals-empty.yaml b/fixtures/persistence-conformance/queries/report-totals-empty.yaml new file mode 100644 index 000000000..ec12d2b4f --- /dev/null +++ b/fixtures/persistence-conformance/queries/report-totals-empty.yaml @@ -0,0 +1,14 @@ +name: report-totals-empty +description: | + FitnessTotals over an EMPTY weeks table. Three pins, each a place where an engine + could plausibly differ: + - a report with no dimensions returns exactly ONE row even over an empty table + (an aggregate without GROUP BY always yields one row); + - a count of nothing is 0, but a sum of nothing is NULL (not 0); + - a ratio whose denominator is zero is NULL (the view divides by NULLIF(den, 0)). +queries: + - name: one-row-over-an-empty-table + op: list + entity: FitnessTotals + expect: + - { weeks: "0", totalMinutes: null, longShare: null } diff --git a/fixtures/persistence-conformance/queries/report-totals.yaml b/fixtures/persistence-conformance/queries/report-totals.yaml new file mode 100644 index 000000000..12d79f81f --- /dev/null +++ b/fixtures/persistence-conformance/queries/report-totals.yaml @@ -0,0 +1,21 @@ +name: report-totals +description: | + FitnessTotals is an object.report over Week with no dimensions: the whole table is + one group, so a list returns exactly one row. longShare is a ratio (NUMERIC): three + of the five weeks are long, 3/5 = 0.6. +seed-data: | + INSERT INTO "programs" ("id","title","priceCents","status","created_ts") VALUES + (1, 'Foundations', 4999, 'PUBLISHED', '2026-05-01T10:00:00'), + (2, 'Strength', 2500, 'PUBLISHED', '2026-05-17T23:30:00'); + INSERT INTO "weeks" ("id","programId","label","durationMinutes") VALUES + (10, 1, 'Week 1', 30), + (11, 1, 'Week 2', 60), + (12, 1, 'Week 2', 90), + (13, 1, NULL, 60), + (20, 2, 'Solo', 45); +queries: + - name: one-row-for-the-whole-table + op: list + entity: FitnessTotals + expect: + - { weeks: "5", totalMinutes: "285", longShare: "0.6" } From 39dd91137e1930dae6f8e3e316cf0c8a4b58e854 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 08:31:46 -0400 Subject: [PATCH 14/32] feat(csharp): report shape and generated keyless row for a view-backed report (FR-044) --- .../CodegenCompileConformanceTests.cs | 14 + .../ReportRowCodegenTests.cs | 350 ++++++++++++++++++ .../ReportingInertTests.cs | 143 ++++++- .../MetaObjects.Codegen/CSharpNaming.cs | 6 + .../MetaObjects.Codegen/CodegenRunner.cs | 17 +- .../Generators/DbContextGenerator.cs | 7 +- .../Generators/EntityGenerator.cs | 5 +- .../Generators/FilterAllowlistGenerator.cs | 10 +- .../Generators/NamesGenerator.cs | 5 +- .../Generators/RoutesGenerator.cs | 9 +- .../csharp/MetaObjects.Codegen/ReportRows.cs | 150 ++++++++ .../ReportShapeTests.cs | 114 ++++++ .../Generated/AppDbContext.g.cs | 13 + .../Generated/AssetActivity.g.cs | 19 + .../Generated/FitnessTotals.g.cs | 19 + .../Generated/ProgramMinutes.g.cs | 36 ++ .../Generated/ProgramsByMonth.g.cs | 22 ++ .../Generated/ProgramsByWeek.g.cs | 17 + .../Generated/RecentPrograms.g.cs | 15 + .../MetaObjects/Core/Reporting/ReportShape.cs | 257 +++++++++++++ 20 files changed, 1198 insertions(+), 30 deletions(-) create mode 100644 server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs create mode 100644 server/csharp/MetaObjects.Codegen/ReportRows.cs create mode 100644 server/csharp/MetaObjects.Conformance.Tests/ReportShapeTests.cs create mode 100644 server/csharp/MetaObjects.IntegrationTests/Generated/AssetActivity.g.cs create mode 100644 server/csharp/MetaObjects.IntegrationTests/Generated/FitnessTotals.g.cs create mode 100644 server/csharp/MetaObjects.IntegrationTests/Generated/ProgramMinutes.g.cs create mode 100644 server/csharp/MetaObjects.IntegrationTests/Generated/ProgramsByMonth.g.cs create mode 100644 server/csharp/MetaObjects.IntegrationTests/Generated/ProgramsByWeek.g.cs create mode 100644 server/csharp/MetaObjects.IntegrationTests/Generated/RecentPrograms.g.cs create mode 100644 server/csharp/MetaObjects/Core/Reporting/ReportShape.cs diff --git a/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs index d756da04d..3b6927de7 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/CodegenCompileConformanceTests.cs @@ -146,6 +146,20 @@ public void Every_generated_file_compiles_with_zero_errors(string selection, boo var files = generators.SelectMany(g => g.Generate(ctx)).ToList(); Assert.True(files.Count > 0, $"selection '{selection}' generated no files at all"); + // FR-044 — the corpus's six view-backed reports each generate a keyless row class. + // Named, because a compile gate passes trivially over a file that was never emitted. + foreach (var report in new[] + { + "ProgramMinutes", "FitnessTotals", "ProgramsByMonth", "ProgramsByWeek", + "RecentPrograms", "AssetActivity", + }) + { + Assert.True(files.Any(f => f.Path == report + ".g.cs"), $"no row class was generated for report {report}"); + Assert.False(files.Any(f => f.Path.StartsWith(report + "Names", StringComparison.Ordinal) + || f.Path.StartsWith(report + "FilterAllowlist", StringComparison.Ordinal)), + $"report {report} leaked into the names or filter-allowlist tier"); + } + if (isTemplateTier) { // A compile gate passes trivially on an empty emit, so name what this selection diff --git a/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs new file mode 100644 index 000000000..1fdb325f1 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs @@ -0,0 +1,350 @@ +// ReportRowCodegenTests — the keyless EF Core row a view-backed `object.report` generates +// (FR-044 Plan 2), and the Table A cases that decide whether one is generated at all. +// +// ReportingInertTests holds the shared with/without corpus to "exactly the row and its +// mapping"; this file covers what that corpus does not reach: the `@unmanaged` and `@sql` +// arms, the kinds that stay inert, every Table B row's C# type and nullability, the +// naming strategy on the derived name, and a real compile. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using MetaObjects.Codegen; +using MetaObjects.Codegen.Generators; +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +public class ReportRowCodegenTests +{ + private const string Namespace = "MetaObjects.ReportRows.Generated"; + + // One entity carrying a dimension or measure for every Table B row, and a to-one + // relationship for the `@via` case. `<>` is replaced per test. + private const string ModelTemplate = + """ + { "metadata.root": { "package": "acme::shop", "children": [ + { "object.entity": { "name": "Store", "children": [ + { "source.rdb": { "@table": "stores" } }, + { "field.long": { "name": "id", "@required": true } }, + { "field.string": { "name": "region", "@required": true, "@maxLength": 40 } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } } + ] } }, + { "object.entity": { "name": "Sale", "children": [ + { "source.rdb": { "@table": "sales" } }, + { "field.long": { "name": "id", "@required": true } }, + { "field.long": { "name": "storeId", "@required": true, "@column": "store_fk" } }, + { "field.string": { "name": "channel" } }, + { "field.enum": { "name": "status", "@required": true, "@values": ["OPEN", "PAID"] } }, + { "field.int": { "name": "units", "@required": true } }, + { "field.currency": { "name": "amountCents", "@required": true, "@currency": "USD" } }, + { "field.decimal": { "name": "weight", "@precision": 12, "@scale": 3 } }, + { "field.double": { "name": "score" } }, + { "field.timestamp": { "name": "soldAt", "@required": true } }, + { "field.timestamp": { "name": "bookedAt", "@localTime": true } }, + { "field.date": { "name": "soldOn" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } }, + { "identity.reference": { "name": "storeRef", "@references": "Store", "@fields": ["storeId"] } }, + { "relationship.association": { "name": "store", "@objectRef": "Store", "@cardinality": "one" } }, + { "dimension.attribute": { "name": "store", "@of": "Sale.storeId" } }, + { "dimension.attribute": { "name": "channel", "@of": "Sale.channel" } }, + { "dimension.attribute": { "name": "status", "@of": "Sale.status" } }, + { "dimension.attribute": { "name": "storeRegion", "@of": "Store.region", "@via": "Sale.store" } }, + { "dimension.time": { "name": "soldAt", "@of": "Sale.soldAt", "@grains": ["hour", "day", "month"] } }, + { "dimension.time": { "name": "bookedAt", "@of": "Sale.bookedAt", "@grains": ["hour"] } }, + { "dimension.time": { "name": "soldOn", "@of": "Sale.soldOn", "@grains": ["week"] } }, + { "measure.aggregate": { "name": "sales", "@agg": "count", "@of": "Sale.id" } }, + { "measure.aggregate": { "name": "channels", "@agg": "count", "@distinct": true, "@of": "Sale.channel" } }, + { "measure.aggregate": { "name": "unitsSold", "@agg": "sum", "@of": "Sale.units" } }, + { "measure.aggregate": { "name": "revenue", "@agg": "sum", "@of": "Sale.amountCents" } }, + { "measure.aggregate": { "name": "totalWeight", "@agg": "sum", "@of": "Sale.weight" } }, + { "measure.aggregate": { "name": "totalScore", "@agg": "sum", "@of": "Sale.score" } }, + { "measure.aggregate": { "name": "avgUnits", "@agg": "avg", "@of": "Sale.units" } }, + { "measure.aggregate": { "name": "avgScore", "@agg": "avg", "@of": "Sale.score" } }, + { "measure.aggregate": { "name": "minUnits", "@agg": "min", "@of": "Sale.units" } }, + { "measure.aggregate": { "name": "lastSoldAt", "@agg": "max", "@of": "Sale.soldAt" } }, + { "measure.aggregate": { "name": "maxWeight", "@agg": "max", "@of": "Sale.weight" } }, + { "measure.ratio": { "name": "unitsPerSale", "@numerator": "unitsSold", "@denominator": "sales" } } + ] } } + <> + ] } } + """; + + private const string AllDimensions = + "\"store\", \"channel\", \"status\", \"storeRegion\", \"soldAt:hour\", \"soldAt:day\", \"soldAt:month\", \"bookedAt:hour\", \"soldOn:week\""; + + private const string AllMeasures = + "\"sales\", \"channels\", \"unitsSold\", \"revenue\", \"totalWeight\", \"totalScore\", \"avgUnits\", \"avgScore\", \"minUnits\", \"lastSoldAt\", \"maxWeight\", \"unitsPerSale\""; + + private static string Report(string name, string sourceBody, string dims = "", string measures = "\"sales\"") + { + string children = sourceBody.Length == 0 + ? "" + : ", \"children\": [ { \"source.rdb\": { " + sourceBody + " } } ]"; + return ", { \"object.report\": { \"name\": \"" + name + "\", \"@from\": \"Sale\", " + + "\"@dimensions\": [" + dims + "], \"@measures\": [" + measures + "]" + children + " } }"; + } + + private static MetaRoot Load(params string[] reports) + { + var json = ModelTemplate.Replace("<>", string.Concat(reports)); + var result = new MetaDataLoader().Load([new InMemoryStringSource(json, id: "meta.shop.json")]); + Assert.True(result.Errors.Count == 0, + "model did not load:\n" + string.Join("\n", result.Errors.Select(e => $" {e.Code}: {e.Message}"))); + return result.Root; + } + + private static GenConfig Config(ColumnNamingStrategy strategy = ColumnNamingStrategy.Literal, bool includeNames = true) => new() + { + OutDir = "/unused", + Namespace = Namespace, + ColumnNamingStrategy = strategy, + IncludeNames = includeNames, + }; + + /// + /// The context builds: no report in the entity set. + /// + private static GenContext RunnerContext(MetaRoot root, GenConfig? config = null) => new() + { + Entities = root.Objects().Where(o => !o.IsReport()).ToList(), + Root = root, + Config = config ?? Config(), + }; + + private static Dictionary Emit(GenContext ctx, params IGenerator[] generators) => + generators.SelectMany(g => g.Generate(ctx)).ToDictionary(f => f.Path, f => f.Content, StringComparer.Ordinal); + + private static Dictionary EmitAll(GenContext ctx) => + Emit(ctx, new EntityGenerator(), new DbContextGenerator(), new NamesGenerator(), + new FilterAllowlistGenerator(), new RoutesGenerator()); + + // --------------------------------------------------------------------- + // Table A — which reports generate a row + // --------------------------------------------------------------------- + + public static TheoryData ViewBackedSources => new() + { + { "a managed derived view", "\"@kind\": \"view\", \"@view\": \"v_sales\"" }, + // Migrate never creates or drops it; the view exists all the same (the MySQL case). + { "an unmanaged view", "\"@kind\": \"view\", \"@view\": \"v_sales\", \"@unmanaged\": true" }, + // The author's body replaces the derived one; Table B still defines the columns. + { "a hand-written @sql view", "\"@kind\": \"view\", \"@view\": \"v_sales\", \"@sql\": \"SELECT COUNT(id) AS sales FROM sales\"" }, + // The legacy physical-name slot. + { "a view named by @table", "\"@kind\": \"view\", \"@table\": \"v_sales\"" }, + }; + + [Theory] + [MemberData(nameof(ViewBackedSources))] + public void A_view_backed_report_generates_its_row_and_mapping(string what, string source) + { + var files = EmitAll(RunnerContext(Load(Report("SalesTotal", source)))); + + Assert.True(files.ContainsKey("SalesTotal.g.cs"), $"{what}: no row class was generated"); + Assert.Contains("public class SalesTotal", files["SalesTotal.g.cs"]); + Assert.Contains(" public long Sales { get; set; }", files["SalesTotal.g.cs"]); + Assert.DoesNotContain("[Key]", files["SalesTotal.g.cs"]); + Assert.DoesNotContain("[Table(", files["SalesTotal.g.cs"]); + + var ctx = files["AppDbContext.g.cs"]; + Assert.Contains(" public DbSet SalesTotals { get; set; } = default!;", ctx); + Assert.Contains(" modelBuilder.Entity().HasNoKey().ToView(\"v_sales\");", ctx); + + // Nothing else: no names artifact, filter allowlist or routes for a report. + Assert.Equal( + ["SalesTotal.g.cs"], + files.Keys.Where(k => k.Contains("SalesTotal", StringComparison.Ordinal)).ToList()); + } + + public static TheoryData InertSources => new() + { + { "no source", "" }, + // The lowering skips these kinds, so no relation with the Table B columns is promised. + { "a materialized view", "\"@kind\": \"materializedView\", \"@materializedView\": \"mv_sales\"" }, + }; + + [Theory] + [MemberData(nameof(InertSources))] + public void A_report_that_is_not_a_view_generates_nothing(string what, string source) + { + var with = EmitAll(RunnerContext(Load(Report("SalesTotal", source)))); + var without = EmitAll(RunnerContext(Load())); + + Assert.True(without.Keys.OrderBy(k => k).SequenceEqual(with.Keys.OrderBy(k => k)), $"{what}: the file set changed"); + foreach (var (path, content) in without) + Assert.True(content == with[path], $"{what}: {path} changed"); + } + + [Fact] + public void A_context_built_from_the_unfiltered_root_generates_the_same_files() + { + // Many callers (and most tests) pass `root.Objects()` as the entity set, reports + // included. The row must come out the same, and the raw report node must not leak + // into the names, allowlist or routes tiers as an empty projection. + var root = Load( + Report("SalesTotal", "\"@kind\": \"view\", \"@view\": \"v_sales\""), + Report("Sourceless", "")); + var unfiltered = new GenContext { Entities = root.Objects(), Root = root, Config = Config() }; + + var expected = EmitAll(RunnerContext(root)); + var actual = EmitAll(unfiltered); + Assert.Equal(expected.Keys.OrderBy(k => k).ToList(), actual.Keys.OrderBy(k => k).ToList()); + foreach (var (path, content) in expected) + Assert.True(content == actual[path], $"{path} differs for an unfiltered entity set"); + Assert.DoesNotContain(actual.Keys, k => k.Contains("Sourceless", StringComparison.Ordinal)); + } + + // --------------------------------------------------------------------- + // Table B — the C# type and nullability of every derived field + // --------------------------------------------------------------------- + + private static string RowOf(MetaRoot root, GenConfig? config = null) => + Emit(RunnerContext(root, config), new EntityGenerator())["SalesCube.g.cs"]; + + private static MetaRoot Cube() => + Load(Report("SalesCube", "\"@kind\": \"view\", \"@view\": \"v_sales_cube\"", AllDimensions, AllMeasures)); + + [Theory] + // Dimensions: the @of field's type; nullable unless it has no @via and @of is @required. + [InlineData("public long Store { get; set; }")] + [InlineData("public string? Channel { get; set; }")] + [InlineData("public SalesCubeStatus Status { get; set; }")] + [InlineData("public string? StoreRegion { get; set; }")] // @required at the source, reached by @via + [InlineData("public DateTimeOffset SoldAtHour { get; set; }")] // hour of an instant + [InlineData("public DateOnly SoldAtDay { get; set; }")] // day or coarser is a date + [InlineData("public DateOnly SoldAtMonth { get; set; }")] + [InlineData("public DateTime? BookedAtHour { get; set; }")] // hour of a @localTime timestamp + [InlineData("public DateOnly? SoldOnWeek { get; set; }")] + // Measures. + [InlineData("public long Sales { get; set; }")] // count is never null + [InlineData("public long Channels { get; set; }")] // count distinct + [InlineData("public long? UnitsSold { get; set; }")] // sum of int + [InlineData("public long? Revenue { get; set; }")] // sum of currency: minor units + [InlineData("public decimal? TotalWeight { get; set; }")] // sum of decimal + [InlineData("public double? TotalScore { get; set; }")] // sum of double + [InlineData("public decimal? AvgUnits { get; set; }")] // avg of int + [InlineData("public double? AvgScore { get; set; }")] // avg of double + [InlineData("public int? MinUnits { get; set; }")] // min keeps the type, loses @required + [InlineData("public DateTimeOffset? LastSoldAt { get; set; }")] // max of a required instant + [InlineData("public decimal? MaxWeight { get; set; }")] + [InlineData("public decimal? UnitsPerSale { get; set; }")] // ratio + public void A_derived_field_has_the_type_and_nullability_of_its_Table_B_row(string property) + { + Assert.Contains(" " + property + "\n", RowOf(Cube()).ReplaceLineEndings("\n")); + } + + [Fact] + public void Fields_are_emitted_in_Table_B_order() + { + var row = RowOf(Cube()); + var order = new[] + { + "Store", "Channel", "Status", "StoreRegion", "SoldAtHour", "SoldAtDay", "SoldAtMonth", + "BookedAtHour", "SoldOnWeek", "Sales", "Channels", "UnitsSold", "Revenue", "TotalWeight", + "TotalScore", "AvgUnits", "AvgScore", "MinUnits", "LastSoldAt", "MaxWeight", "UnitsPerSale", + }.Select(p => row.IndexOf($" {p} {{ get; set; }}", StringComparison.Ordinal)).ToList(); + Assert.DoesNotContain(-1, order); + Assert.Equal(order.OrderBy(i => i).ToList(), order); + } + + [Fact] + public void A_column_is_the_naming_strategy_on_the_derived_name_and_never_the_of_fields_column() + { + // `Sale.storeId` declares @column: store_fk. The report column is `store`. + var literal = RowOf(Cube()); + Assert.Contains("[Column(\"store\")]", literal); + Assert.Contains("[Column(\"soldAtHour\")]", literal); + Assert.DoesNotContain("store_fk", literal); + + var snake = RowOf(Cube(), Config(ColumnNamingStrategy.SnakeCase)); + Assert.Contains("[Column(\"sold_at_hour\")]", snake); + Assert.Contains("[Column(\"units_per_sale\")]", snake); + Assert.DoesNotContain("store_fk", snake); + } + + [Fact] + public void A_report_row_binds_by_literal_even_when_the_names_generator_runs() + { + // A report has no names artifact, so a reference to one would not compile. + var files = EmitAll(RunnerContext(Cube(), Config(includeNames: true))); + Assert.DoesNotContain(files.Keys, k => k.StartsWith("SalesCubeNames", StringComparison.Ordinal)); + Assert.DoesNotContain("SalesCubeNames", files["SalesCube.g.cs"]); + Assert.DoesNotContain("SalesCubeNames", files["AppDbContext.g.cs"]); + // An entity in the same run still binds through its artifact. + Assert.Contains("[Table(SaleNames.SourcePrimaryTable)]", files["Sale.g.cs"]); + } + + [Fact] + public void An_enum_dimension_declares_its_enum_and_reads_it_as_text() + { + var files = EmitAll(RunnerContext(Cube())); + Assert.Contains(" public enum SalesCubeStatus { OPEN, PAID }", files["SalesCube.g.cs"]); + Assert.Contains( + " modelBuilder.Entity().Property(x => x.Status).HasConversion();", + files["AppDbContext.g.cs"]); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void The_row_and_its_mapping_compile(bool includeNames) + { + var ctx = RunnerContext(Cube(), Config(includeNames: includeNames)); + var generators = new List { new EntityGenerator(), new DbContextGenerator() }; + if (includeNames) generators.Add(new NamesGenerator()); + var files = generators.SelectMany(g => g.Generate(ctx)).ToList(); + + var trees = files + .Select(f => CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12), path: f.Path)) + .ToList(); + var comp = CSharpCompilation.Create( + "report_rows_" + Guid.NewGuid().ToString("N"), trees, + DbContextCompileTests.BuildReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var errors = comp.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => $"{d.Location.GetLineSpan().Path}: {d.Id}: {d.GetMessage()}") + .ToList(); + Assert.True(errors.Count == 0, string.Join("\n", errors)); + } + + // --------------------------------------------------------------------- + // The runner + // --------------------------------------------------------------------- + + [Fact] + public void A_report_whose_DbSet_name_collides_with_an_entitys_is_refused() + { + // Report `Sales` and entity `Sale` both claim the DbSet property `Sales`. + var root = Load(Report("Sales", "\"@kind\": \"view\", \"@view\": \"v_sales\"")); + var outDir = Path.Combine(Path.GetTempPath(), "report-rows-" + Guid.NewGuid().ToString("N")); + try + { + var ex = Assert.Throws(() => + CodegenRunner.Run(Config() with { OutDir = outDir }, root, [new EntityGenerator(), new DbContextGenerator()])); + Assert.Contains("\"Sale\" and \"Sales\" both pluralize", ex.Message); + } + finally + { + if (Directory.Exists(outDir)) Directory.Delete(outDir, recursive: true); + } + } + + [Fact] + public void A_sourceless_report_with_a_colliding_name_is_not_refused() + { + // It generates nothing, so it claims no name. + var root = Load(Report("Sales", "")); + var outDir = Path.Combine(Path.GetTempPath(), "report-rows-" + Guid.NewGuid().ToString("N")); + try + { + var result = CodegenRunner.Run(Config() with { OutDir = outDir }, root, [new EntityGenerator(), new DbContextGenerator()]); + Assert.DoesNotContain(result.Files, f => f.Path == "Sales.g.cs"); + } + finally + { + if (Directory.Exists(outDir)) Directory.Delete(outDir, recursive: true); + } + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/ReportingInertTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ReportingInertTests.cs index 2eee34142..b1872250a 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/ReportingInertTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/ReportingInertTests.cs @@ -1,17 +1,21 @@ -// FR-044 Plan 1 — the reporting vocabulary is INERT in every C# generator. +// FR-044 — what the reporting vocabulary generates in C#, and what it does not. // -// Plan 1 registers `dimension.*`, `measure.*`, `segment.*` and `object.report` and -// validates them at load, but gives none of them output: a report's lowering lands in -// Plan 2/3. Until then a model that USES the vocabulary must generate exactly what the -// same model without it generates, byte for byte, through every registered generator. +// `dimension.*`, `measure.*` and `segment.*` generate nothing. A report generates nothing +// either, with ONE exception that landed in Plan 2: a report that declares a read-only +// `source.rdb @kind: view` is a database view, and C# reads it through a generated keyless +// row class and its `HasNoKey().ToView(...)` mapping plus a DbSet. So: +// +// - a SOURCELESS report (`ProgramEngagement`, `DailyRevenue`) stays inert in every +// registered generator and in the api docs; +// - the VIEW-BACKED report (`StoreTotals`) adds exactly one file (its row class) and +// exactly its lines in AppDbContext.g.cs, and nothing in the routes, filter-allowlist, +// names or api-docs tiers (Plan 3). // // The model pair is fixtures/codegen-noop/reporting/{with,without}, shared with the other -// four ports' copies of this test. `with/` carries a report that declares a read-only -// `source.rdb @kind: view` (R5 allows one) — the case that used to leak here as an empty -// entity class, a filter allowlist, a GET-only route and a keyless DbSet with ToView. +// four ports' copies of this test. // // Runs through CodegenRunner.Run — the path `dotnet meta gen` takes — not a hand-built -// GenContext, because the skip lives at the runner's entity-set choke point. +// GenContext, because the report skip lives at the runner's entity-set choke point. using MetaObjects.Codegen; using MetaObjects.Codegen.ApiDocs; @@ -95,6 +99,89 @@ public void The_with_model_really_carries_the_vocabulary() Assert.DoesNotContain(Load("without").Objects(), o => o.IsReport()); } + // The two generators that lower a view-backed report (ReportRows). Every other + // registered generator must not notice a report at all. + private const string EntityGeneratorName = "entity"; + private const string DbContextGeneratorName = "db-context"; + private const string RowFile = "StoreTotals.g.cs"; + private const string DbContextFile = "AppDbContext.g.cs"; + + // Snake-case is the naming strategy of this suite's GenConfig, so the columns prove the + // strategy is applied to the DERIVED field name. `revenue` is a sum of a currency: + // integer minor units, nullable because a sum of nothing is null. A count is never null. + private const string ExpectedRow = + """ + // + // Generated by MetaObjects entity-generator. Do not edit by hand. + #nullable enable + using System; + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; + using System.ComponentModel.DataAnnotations.Schema; + + namespace MetaObjects.ReportingInert.Generated; + + public class StoreTotals + { + [Column("purchases")] + public long Purchases { get; set; } + [Column("buyers")] + public long Buyers { get; set; } + [Column("revenue")] + public long? Revenue { get; set; } + } + + """; + + private static readonly string[] ExpectedDbContextLines = + [ + " public DbSet StoreTotals { get; set; } = default!;", + " modelBuilder.Entity().HasNoKey().ToView(\"v_store_totals\");", + ]; + + /// The lines of that lacks, + /// asserting that is otherwise an in-order subsequence of it + /// (so nothing was removed, changed or reordered). + private static List AddedLines(string expected, string actual) + { + var want = expected.Split('\n'); + var added = new List(); + int i = 0; + foreach (var line in actual.Split('\n')) + { + if (i < want.Length && want[i] == line) i++; + else added.Add(line); + } + Assert.True(i == want.Length, "AppDbContext.g.cs lost or changed a line once a report was declared"); + return added; + } + + /// + /// is plus exactly what the + /// view-backed report adds through the generators in . + /// + private static void AssertSameExceptTheReportRow( + SortedDictionary expected, SortedDictionary actual, + IReadOnlyCollection selection) + { + var allowedNew = selection.Contains(EntityGeneratorName) ? new[] { RowFile } : []; + Assert.Equal( + expected.Keys.Concat(allowedNew).OrderBy(k => k, StringComparer.Ordinal).ToList(), + actual.Keys.ToList()); + if (allowedNew.Length > 0) + Assert.Equal(ExpectedRow.ReplaceLineEndings("\n"), actual[RowFile].ReplaceLineEndings("\n")); + + foreach (var (path, content) in expected) + { + if (path == DbContextFile && selection.Contains(DbContextGeneratorName)) + { + Assert.Equal(ExpectedDbContextLines, AddedLines(content, actual[path])); + continue; + } + Assert.True(content == actual[path], $"{path} differs once reporting nodes are declared"); + } + } + [Theory] [MemberData(nameof(GeneratorNames))] public void Generator_emits_the_same_files_with_and_without_reporting_nodes(string name) @@ -102,7 +189,8 @@ public void Generator_emits_the_same_files_with_and_without_reporting_nodes(stri var entry = GeneratorRegistry.Entries[name]; var expected = Emit(Load("without"), [Build(entry)]); var actual = Emit(Load("with"), [Build(entry)]); - AssertSame(expected, actual); + // For every generator but `entity` and `db-context` this is plain equality. + AssertSameExceptTheReportRow(expected, actual, [name]); } [Fact] @@ -122,20 +210,43 @@ public void Exactly_these_generators_cannot_run_from_a_bare_model() [Fact] public void Every_runnable_generator_in_one_run_emits_the_same_files() { - var runnable = GeneratorRegistry.Entries.Values - .Where(e => !Emit(Load("without"), [Build(e)]).ContainsKey("")) + var runnable = GeneratorRegistry.Entries + .Where(e => !Emit(Load("without"), [Build(e.Value)]).ContainsKey("")) .ToList(); - var expected = Emit(Load("without"), runnable.Select(Build).ToList()); - var actual = Emit(Load("with"), runnable.Select(Build).ToList()); + var generators = runnable.Select(e => Build(e.Value)).ToList(); + var expected = Emit(Load("without"), generators); + var actual = Emit(Load("with"), runnable.Select(e => Build(e.Value)).ToList()); Assert.False(expected.ContainsKey(""), expected.GetValueOrDefault("")); + Assert.False(actual.ContainsKey(""), actual.GetValueOrDefault("")); Assert.True(expected.Count > 10, $"only {expected.Count} files — the suite barely ran"); - AssertSame(expected, actual); + AssertSameExceptTheReportRow(expected, actual, runnable.Select(e => e.Key).ToList()); + } + + [Fact] + public void A_report_reaches_no_tier_but_its_row_and_its_DbContext_mapping() + { + var files = Emit(Load("with"), GeneratorRegistry.Entries.Values.Select(Build).ToList()); + Assert.False(files.ContainsKey(""), files.GetValueOrDefault("")); + + // The view-backed report: one file, named for it; no routes, allowlist or names. + Assert.Equal([RowFile], files.Keys.Where(k => k.Contains("StoreTotals", StringComparison.Ordinal)).ToList()); + // A sourceless report: no file at all, and no mention in any file. + foreach (var sourceless in new[] { "ProgramEngagement", "DailyRevenue" }) + { + Assert.DoesNotContain(files.Keys, k => k.Contains(sourceless, StringComparison.Ordinal)); + Assert.DoesNotContain(files.Values, c => c.Contains(sourceless, StringComparison.Ordinal)); + } + // Outside its row and the DbContext, no generated file mentions the report. + var mentions = files.Where(f => f.Value.Contains("StoreTotals", StringComparison.Ordinal)) + .Select(f => f.Key).OrderBy(k => k, StringComparer.Ordinal).ToList(); + Assert.Equal([DbContextFile, RowFile], mentions); } /// /// The api docs surface (`dotnet meta docs`): every unit page, the index and the agent /// page, rendered exactly as DocsCommand renders them. A report has no generated API to - /// document, and its derived fields do not exist until its lowering lands. + /// document (its routes are Plan 3), so the docs carry nothing for any report, + /// view-backed or not. /// private static SortedDictionary ApiDocs(MetaRoot root) { diff --git a/server/csharp/MetaObjects.Codegen/CSharpNaming.cs b/server/csharp/MetaObjects.Codegen/CSharpNaming.cs index c4b41293b..49ac6494d 100644 --- a/server/csharp/MetaObjects.Codegen/CSharpNaming.cs +++ b/server/csharp/MetaObjects.Codegen/CSharpNaming.cs @@ -509,6 +509,12 @@ public static bool HasPrimarySource(MetaObject obj) => /// public static ObjectNames? ResolveObjectNames(MetaObject obj, ColumnNamingStrategy strategy = ColumnNamingStrategy.Literal) { + // FR-044 — a report resolves no names artifact, so NamesGenerator emits none and + // the report's generated row (ReportRows) spells its view and columns as literals + // through the same fallback a sourceless object takes. One gate, here, so the + // artifact and every reference to it cannot disagree about whether it exists. + if (obj.IsReport()) return null; + // SourceResolution.PrimaryRdbSource, not a scan of our own: ADR-0039's RESOLVING // source accessor (an inherited primary must be seen, or an entity extending an // abstract base with its own primary source would wrongly read as unpersisted), diff --git a/server/csharp/MetaObjects.Codegen/CodegenRunner.cs b/server/csharp/MetaObjects.Codegen/CodegenRunner.cs index 794a35b4d..fe87ec940 100644 --- a/server/csharp/MetaObjects.Codegen/CodegenRunner.cs +++ b/server/csharp/MetaObjects.Codegen/CodegenRunner.cs @@ -29,10 +29,15 @@ public static RunResult Run(GenConfig config, MetaRoot root, IReadOnlyList(); var ctx = new GenContext { - // FR-044 Plan 1: object.report has no output until its lowering lands (Plan 2/3). - // Dropped here, at the entity set every generator reads, and not per generator: - // a report may declare a read-only `source.rdb @kind: view` (R5), which would - // otherwise pass every source-keyed gate and emit an empty projection tier. + // FR-044: an object.report is never in the entity set. Dropped here, at the set + // every generator reads, and not per generator: a report may declare a read-only + // `source.rdb @kind: view` (R5), which would otherwise pass every source-keyed + // gate and emit an empty projection tier (routes, allowlist, names). + // + // A view-backed report DOES generate its keyless row and DbContext mapping + // (Plan 2). The two generators that emit those ask for the report's row model + // themselves, through ReportRows, so every other generator stays report-free + // without having to know what a report is. Entities = root.Objects().Where(o => !o.IsReport()).ToList(), Root = root, Config = config, @@ -41,7 +46,9 @@ public static RunResult Run(GenConfig config, MetaRoot root, IReadOnlyList(); diff --git a/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs index 72a9d4f07..45add1e90 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs @@ -72,7 +72,10 @@ public virtual IEnumerable Generate(GenContext ctx) // FR-017 TPH: a concrete subtype shares the base's single table — it gets NO // DbSet and no per-subtype model config; the hierarchy is reached via the base // DbSet (`.OfType()`). Filter subtypes out of the emitted set entirely. - var objects = ctx.Entities + // FR-044 — a view-backed report joins the set as its ROW MODEL, which the + // read-only-projection arm below maps as `HasNoKey().ToView(...)`; every other + // report node is dropped. See ReportRows. + var objects = ReportRows.WithReportRows(ctx) .Where(o => AppliesTo(o, ctx.Root)) .OrderBy(o => o.Name, StringComparer.Ordinal) .ToList(); @@ -291,7 +294,7 @@ protected virtual void EmitUsings(StringBuilder sb, bool needsMetadataUsing, Gen // references EVERY entity by short name (DbSet, modelBuilder.Entity()), // so it needs a `using` for each distinct namespace the entities resolve to. var dbCtxNs = ctx.Config.Namespace; - var refNamespaces = ctx.Entities + var refNamespaces = ReportRows.WithReportRows(ctx) .Where(o => o.IsEntity() || o.DbView is not null) .Select(o => PackageBindingResolver.Resolve(ctx.Config, PackageBindingResolver.EffectivePackage(o), o.Name)) .Where(ns => !string.IsNullOrEmpty(ns) && ns != dbCtxNs) diff --git a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs index f11fdfe6d..e67be7609 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs @@ -44,7 +44,10 @@ public class EntityGenerator : IGenerator public virtual IEnumerable Generate(GenContext ctx) { - var candidates = ctx.Entities + // FR-044 — a view-backed report joins the set as its ROW MODEL (a keyless, + // projection-shaped object carrying its derived fields); every other report node + // is dropped. See ReportRows. + var candidates = ReportRows.WithReportRows(ctx) .Where(o => o.IsEntity() || o.DbView is not null) .OrderBy(o => o.Name, StringComparer.Ordinal) .ToList(); diff --git a/server/csharp/MetaObjects.Codegen/Generators/FilterAllowlistGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/FilterAllowlistGenerator.cs index 74aa2f41e..5893e4b88 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/FilterAllowlistGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/FilterAllowlistGenerator.cs @@ -53,9 +53,13 @@ public class FilterAllowlistGenerator : PerEntityGenerator /// /// public static bool AppliesTo(MetaObject entity) => - ((entity.IsEntity() || entity.DbView is not null) - && InstanceArtifacts.EmitsInstanceArtifacts(entity)) - || InstanceArtifacts.IsSourcelessEntity(entity); + // FR-044 — a report has no filter allowlist (it has no routes to name one). Stated + // here as well as at CodegenRunner's entity set, for the same reason as + // RoutesGenerator.AppliesTo. + !entity.IsReport() + && (((entity.IsEntity() || entity.DbView is not null) + && InstanceArtifacts.EmitsInstanceArtifacts(entity)) + || InstanceArtifacts.IsSourcelessEntity(entity)); protected override EmittedFile GenerateOne(MetaObject entity, GenContext ctx) { diff --git a/server/csharp/MetaObjects.Codegen/Generators/NamesGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/NamesGenerator.cs index 4cd0579be..2cd250ff3 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/NamesGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/NamesGenerator.cs @@ -81,7 +81,10 @@ public override IEnumerable Generate(GenContext ctx) // ctx.Config.ColumnNamingStrategy. The divergence refusal is not this generator's to // own and never was — it lives in MetaObjects.Meta.SourceResolution, which every // caller that resolves a physical name goes through, codegen and runtime alike. - public override bool Filter(MetaObject entity) => CSharpNaming.HasPrimarySource(entity); + // FR-044 — a report has no names artifact (ResolveObjectNames answers null for one): + // its generated row binds its view and columns by literal. + public override bool Filter(MetaObject entity) => + !entity.IsReport() && CSharpNaming.HasPrimarySource(entity); protected override EmittedFile GenerateOne(MetaObject entity, GenContext ctx) => Render(entity, ctx, fragment: false) diff --git a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs index 502731ec7..1632e467a 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs @@ -47,7 +47,8 @@ public class RoutesGenerator : PerEntityGenerator private const string HelperRuntimeNamespace = "MetaObjects.Codegen.Runtime"; public override bool Filter(MetaObject entity) => - (entity.IsEntity() || entity.DbView is not null) && InstanceArtifacts.EmitsInstanceArtifacts(entity); + !entity.IsReport() // FR-044: a report has no routes (see AppliesTo) + && (entity.IsEntity() || entity.DbView is not null) && InstanceArtifacts.EmitsInstanceArtifacts(entity); /// /// True iff this entity gets a generated routes file: it passes @@ -56,7 +57,11 @@ public override bool Filter(MetaObject entity) => /// loop AND the api-docs builder (so docs never claim REST a routes-off entity lacks). /// public static bool AppliesTo(MetaObject entity, MetaRoot root) => - (entity.IsEntity() || entity.DbView is not null) + // FR-044 — a report has no routes. CodegenRunner already keeps reports out of the + // entity set; this holds for a caller that builds its context from the unfiltered + // root, where a view-backed report would otherwise pass as a projection. + !entity.IsReport() + && (entity.IsEntity() || entity.DbView is not null) && InstanceArtifacts.EmitsInstanceArtifacts(entity) && !TphPlanBuilder.IsTphSubtype(entity, root); diff --git a/server/csharp/MetaObjects.Codegen/ReportRows.cs b/server/csharp/MetaObjects.Codegen/ReportRows.cs new file mode 100644 index 000000000..bd2abef0a --- /dev/null +++ b/server/csharp/MetaObjects.Codegen/ReportRows.cs @@ -0,0 +1,150 @@ +// report-rows — how a view-backed `object.report` reaches the EF Core generators (FR-044). +// +// WHAT IS GENERATED +// +// A report that declares a read-only `source.rdb @kind: view` is a database view +// (contract Table A). C# has no metadata-driven runtime, so reading that view means +// generating its row: a keyless entity class (EntityGenerator) and its +// `HasNoKey().ToView(...)` mapping plus a DbSet (DbContextGenerator). Nothing else is +// generated for a report: no routes, filter allowlist, names artifact or api docs. +// +// The view's existence is what matters, not who creates it. `@unmanaged: true` (migrate +// never creates it) and `@sql` (the author wrote the body) both still name a view with +// the Table B columns, so both still get a row. A report with no source stays inert, and +// so does one whose read source is a materialized view, a stored procedure or a table +// function: the lowering skips those kinds, so no relation with the Table B columns is +// promised to exist. +// +// WHY A SYNTHESIZED OBJECT, NOT A REPORT BRANCH IN EACH GENERATOR +// +// A report declares no fields; its read shape is derived (ReportShapes, Table B). The EF +// generators read an object's fields through `Fields()` in a dozen places (members, enum +// declarations, usings, the enum and jsonb conversions in the DbContext). A view-backed +// report already satisfies their projection predicates (`IsReadOnlyProjection()`, +// `DbView`), so it is handed to them as a ROW MODEL: a detached object with one real +// `field.*` child per derived field and a copy of the report's read source. They then +// emit it exactly as they emit a keyless read-only projection. +// +// The row model is never added to the root and nothing in the loaded tree is mutated to +// build it (the source is copied, not re-parented). It keeps the report's name, package +// and `object.report` subtype, so `IsReport()` still identifies it. +// +// Mirrors server/typescript/packages/metadata/src/core/reporting/report-read-model.ts. + +using MetaObjects.Core.Reporting; +using MetaObjects.Meta; +using static MetaObjects.Core.Field.FieldConstants; +using static MetaObjects.Persistence.Db.DbConstants; +using static MetaObjects.Persistence.Source.SourceConstants; +using static MetaObjects.Shared.BaseTypes; + +namespace MetaObjects.Codegen; + +/// Row models for view-backed reports, and the object set the EF generators iterate. +public static class ReportRows +{ + /// + /// Table B: the type-shaping attrs a derived field carries from its type source. + /// @dbColumnType and isArray are handled separately. Nothing else is + /// carried: no @column, @required, @default, validators or views. + /// + private static readonly string[] CarriedAttrs = + [ + FIELD_ATTR_CURRENCY, + FIELD_ATTR_VALUES, + FIELD_ATTR_INT_VALUE_MAP, + FIELD_ATTR_MAX_LENGTH, + FIELD_ATTR_PRECISION, + FIELD_ATTR_SCALE, + FIELD_ATTR_LOCAL_TIME, + FIELD_ATTR_OBJECT_REF, + FIELD_ATTR_STORAGE, + ]; + + /// + /// True iff is a report whose read source is a view, the one + /// shape that generates a row (see the file header for the other kinds). + /// + public static bool IsViewBacked(MetaObject obj) => + obj.IsReport() && !obj.IsAbstract + && ReportShapes.ReadSource(obj)?.EffectiveKind == SOURCE_KIND_VIEW; + + /// + /// The row model of a view-backed report: one field per Table B row, in Table B + /// order, and a copy of the report's read source. Frozen. Throws what + /// throws when a reference does not resolve. + /// + public static MetaObject RowModel(MetaObject report, MetaRoot root) + { + var source = ReportShapes.ReadSource(report) + ?? throw new InvalidOperationException($"report '{report.Name}' declares no read-only source."); + var shape = ReportShapes.Of(report, root); + + var model = new MetaObject(new TypeId(report.Type, report.SubType), report.Name); + if (report.Package is { } pkg) model.SetPackage(pkg); + // The file-default package has no getter; the effective package is what it resolves to. + string effectivePkg = NamingRefs.EffectivePackage(report); + if (effectivePkg.Length > 0) model.SetFileDefaultPackage(effectivePkg); + model.SetSource(report.Source); + + foreach (var f in shape.Fields) model.AddChild(DerivedField(f)); + model.AddChild(CopySource(source)); + model.Freeze(); + return model; + } + + private static MetaField DerivedField(ReportField f) + { + var field = new MetaField(new TypeId(TYPE_FIELD, f.SubType), f.Name); + // From the derived shape, never from the type source: a `min` of a required column + // is still nullable, and a dimension reached by `@via` is nullable. + field.SetAttr(FIELD_ATTR_REQUIRED, f.Required); + if (f.TypeSource is { } src) + { + // ADR-0039: resolving, so a value the `@of` field inherits through extends is carried. + foreach (string name in CarriedAttrs) + if (src.Attr(name) is { } value) field.SetAttr(name, value); + // ADR-0039: own — `@dbColumnType` is the one deliberately own-only attr (a + // physical column-type override is never inherited), so the derived field + // carries exactly what the `@of` field itself declares. + if (src.OwnAttr(FIELD_ATTR_DB_COLUMN_TYPE) is { } dbColumnType) + field.SetAttr(FIELD_ATTR_DB_COLUMN_TYPE, dbColumnType); + // `isArray` is a native flag, not an attr; ResolvedIsArray() is its resolving read. + if (src.ResolvedIsArray()) field.SetIsArray(true); + } + return field; + } + + /// + /// A detached copy of a source node: same type, name and effective attrs, pinned to + /// @role: primary so (which considers primary + /// sources only) names it. + /// + private static MetaSource CopySource(MetaSource source) + { + var copy = new MetaSource(new TypeId(source.Type, source.SubType), source.Name); + // ADR-0039: resolving — the copy carries the source's effective configuration. + foreach (var (name, value) in source.Attrs()) copy.SetAttr(name, value); + copy.SetAttr(SOURCE_ATTR_ROLE, SOURCE_ROLE_PRIMARY); + return copy; + } + + /// The row model of every view-backed report in the model, in declaration order. + public static IReadOnlyList For(MetaRoot root) => + root.Objects().Where(IsViewBacked).Select(r => RowModel(r, root)).ToList(); + + /// + /// The objects a row-emitting generator iterates: the run's entity set with every + /// report node removed, then the row model of each view-backed report. + /// + /// The row models come from the root, not from : + /// keeps every report out of that set, which is what + /// keeps a report inert in every generator that does not ask for it here. A caller + /// that builds a context from the unfiltered root gets the same answer, because a + /// raw report node in the entity set is dropped rather than emitted as a class with + /// no members. + /// + /// + public static IReadOnlyList WithReportRows(GenContext ctx) => + [.. ctx.Entities.Where(o => !o.IsReport()), .. For(ctx.Root)]; +} diff --git a/server/csharp/MetaObjects.Conformance.Tests/ReportShapeTests.cs b/server/csharp/MetaObjects.Conformance.Tests/ReportShapeTests.cs new file mode 100644 index 000000000..3c5df1ae2 --- /dev/null +++ b/server/csharp/MetaObjects.Conformance.Tests/ReportShapeTests.cs @@ -0,0 +1,114 @@ +// ReportShapeTests — the C# derivation of a report's read shape (FR-044, Table B) +// byte-matches the committed, TypeScript-produced artifact +// fixtures/persistence-conformance/report-shapes.json. +// +// Container-free: pure metadata in, JSON out. The same artifact gates the Java, Kotlin +// and Python derivations, so the five ports cannot drift on a derived field's name, +// subtype, nullability or type source. + +using System.IO; +using System.Linq; +using MetaObjects.Core.Reporting; +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; +using static MetaObjects.Core.Field.FieldConstants; + +namespace MetaObjects.Conformance.Tests; + +public class ReportShapeTests +{ + // fixtures/persistence-conformance, the sibling of the conformance corpus root. + private static readonly string PersistenceCorpus = + Path.Combine(Path.GetDirectoryName(CorpusRoot.Path)!, "persistence-conformance"); + + private static MetaRoot LoadCanonical() + { + var result = new MetaDataLoader().Load( + [new FileSource(Path.Combine(PersistenceCorpus, "canonical", "meta.fitness.json"))]); + Assert.True(result.Errors.Count == 0, + "canonical model failed to load: " + string.Join("; ", result.Errors.Select(e => e.ToString()))); + return result.Root; + } + + private static ReportShape Shape(MetaRoot root, string report) => + ReportShapes.Of(root.Objects().Single(o => o.Name == report), root); + + [Fact] + public void Derived_shapes_byte_match_the_committed_artifact() + { + string expected = File.ReadAllText(Path.Combine(PersistenceCorpus, "report-shapes.json")); + string actual = ReportShapes.ToArtifactJson(LoadCanonical()); + Assert.True(expected == actual, + "The C# report shapes differ from fixtures/persistence-conformance/report-shapes.json " + + "(TypeScript produces that file; a difference is a defect in ReportShapes).\n--- C# ---\n" + actual); + } + + [Fact] + public void The_artifact_covers_the_six_canonical_reports() + { + // Else the byte comparison could pass over an empty list on both sides. + var reports = LoadCanonical().Objects().Where(o => o.IsReport()).Select(o => o.Name).ToList(); + Assert.Equal( + ["ProgramMinutes", "FitnessTotals", "ProgramsByMonth", "ProgramsByWeek", "RecentPrograms", "AssetActivity"], + reports); + } + + [Fact] + public void Dimensions_come_first_in_listed_order_then_measures() + { + var shape = Shape(LoadCanonical(), "ProgramMinutes"); + Assert.Equal("fitness::Week", shape.From.ResolutionKey()); + Assert.Equal( + ["program", "programTitle", "weeks", "longWeeks", "labels", "slots", "totalMinutes", + "avgMinutes", "minMinutes", "maxMinutes", "longShare"], + shape.Fields.Select(f => f.Name).ToList()); + Assert.All(shape.Fields.Take(2), f => Assert.Equal(ReportFieldRole.Dimension, f.Role)); + Assert.All(shape.Fields.Skip(2), f => Assert.Equal(ReportFieldRole.Measure, f.Role)); + } + + [Fact] + public void A_dimension_reached_by_via_is_nullable_and_a_count_never_is() + { + var fields = Shape(LoadCanonical(), "ProgramMinutes").Fields.ToDictionary(f => f.Name); + Assert.True(fields["program"].Required); // no @via, the @of field is @required + Assert.False(fields["programTitle"].Required); // reached by @via + Assert.True(fields["weeks"].Required); // count + Assert.False(fields["totalMinutes"].Required); // a sum of nothing is null + Assert.Equal(FIELD_SUBTYPE_LONG, fields["totalMinutes"].SubType); + Assert.Equal(FIELD_SUBTYPE_DECIMAL, fields["avgMinutes"].SubType); + Assert.Equal(FIELD_SUBTYPE_INT, fields["minMinutes"].SubType); + Assert.Equal(FIELD_SUBTYPE_DECIMAL, fields["longShare"].SubType); + } + + [Fact] + public void An_hour_grain_is_a_timestamp_and_a_coarser_grain_is_a_date() + { + var fields = Shape(LoadCanonical(), "AssetActivity").Fields.ToDictionary(f => f.Name); + Assert.Equal(FIELD_SUBTYPE_TIMESTAMP, fields["recordedAtHour"].SubType); + Assert.Equal("recordedAt", fields["recordedAtHour"].TypeSource?.Name); + Assert.Equal(FIELD_SUBTYPE_DATE, fields["asOfDateWeek"].SubType); + Assert.Null(fields["asOfDateWeek"].TypeSource); + } + + [Fact] + public void A_sourceless_report_has_a_shape_and_no_view() + { + var result = new MetaDataLoader().Load([new InMemoryStringSource( + """ + { "metadata.root": { "package": "shop", "children": [ + { "object.entity": { "name": "Sale", "children": [ + { "field.long": { "name": "id", "@required": true } }, + { "measure.aggregate": { "name": "sales", "@agg": "count", "@of": "Sale.id" } } + ] } }, + { "object.report": { "name": "Totals", "@from": "Sale", "@measures": ["sales"] } } + ] } } + """, id: "meta.shop.json")]); + Assert.Empty(result.Errors); + var report = result.Root.Objects().Single(o => o.IsReport()); + Assert.Equal(["sales"], ReportShapes.Of(report, result.Root).Fields.Select(f => f.Name).ToList()); + // A sourceless report has a shape and no view (Table A). + Assert.Null(ReportShapes.ReadSource(report)); + Assert.Contains("\"view\": null", ReportShapes.ToArtifactJson(result.Root)); + } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/AppDbContext.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/AppDbContext.g.cs index bf069be10..414ad1e5a 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Generated/AppDbContext.g.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/AppDbContext.g.cs @@ -11,7 +11,9 @@ public AppDbContext(DbContextOptions options) : base(options) { } public DbSet AllTypes { get; set; } = default!; public DbSet Assets { get; set; } = default!; + public DbSet AssetActivities { get; set; } = default!; public DbSet Auths { get; set; } = default!; + public DbSet FitnessTotals { get; set; } = default!; public DbSet Follows { get; set; } = default!; public DbSet Friendships { get; set; } = default!; public DbSet Measurements { get; set; } = default!; @@ -21,16 +23,27 @@ public AppDbContext(DbContextOptions options) : base(options) { } public DbSet PostReferrals { get; set; } = default!; public DbSet PostTags { get; set; } = default!; public DbSet Programs { get; set; } = default!; + public DbSet ProgramMinutes { get; set; } = default!; public DbSet ProgramStats { get; set; } = default!; public DbSet ProgramViews { get; set; } = default!; + public DbSet ProgramsByMonths { get; set; } = default!; + public DbSet ProgramsByWeeks { get; set; } = default!; + public DbSet RecentPrograms { get; set; } = default!; public DbSet Tags { get; set; } = default!; public DbSet Weeks { get; set; } = default!; protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity().HasNoKey().ToView("v_asset_activity"); + modelBuilder.Entity().HasNoKey().ToView("v_fitness_totals"); + modelBuilder.Entity().HasNoKey().ToView("v_program_minutes"); modelBuilder.Entity().ToView(ProgramStatNames.SourcePrimaryView); modelBuilder.Entity().ToView(ProgramViewNames.SourcePrimaryView); modelBuilder.Entity().Property(x => x.Status).HasConversion(); + modelBuilder.Entity().HasNoKey().ToView("v_programs_by_month"); + modelBuilder.Entity().Property(x => x.Status).HasConversion(); + modelBuilder.Entity().HasNoKey().ToView("v_programs_by_week"); + modelBuilder.Entity().HasNoKey().ToView("v_recent_programs"); modelBuilder.Entity().OwnsOne(x => x.Settings, b => b.ToJson(AllTypesNames.SettingsColumn)); modelBuilder.Entity().OwnsMany(x => x.Labels, b => b.ToJson(AllTypesNames.LabelsColumn)); modelBuilder.Entity().Property(x => x.EnumVal).HasConversion(); diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/AssetActivity.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/AssetActivity.g.cs new file mode 100644 index 000000000..45048514c --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/AssetActivity.g.cs @@ -0,0 +1,19 @@ +// +// Generated by MetaObjects entity-generator. Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace MetaObjects.IntegrationTests.Generated; + +public class AssetActivity +{ + [Column("recordedAtHour")] + public DateTimeOffset RecordedAtHour { get; set; } + [Column("asOfDateWeek")] + public DateOnly AsOfDateWeek { get; set; } + [Column("assets")] + public long Assets { get; set; } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/FitnessTotals.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/FitnessTotals.g.cs new file mode 100644 index 000000000..63c89ca40 --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/FitnessTotals.g.cs @@ -0,0 +1,19 @@ +// +// Generated by MetaObjects entity-generator. Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace MetaObjects.IntegrationTests.Generated; + +public class FitnessTotals +{ + [Column("weeks")] + public long Weeks { get; set; } + [Column("totalMinutes")] + public long? TotalMinutes { get; set; } + [Column("longShare")] + public decimal? LongShare { get; set; } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramMinutes.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramMinutes.g.cs new file mode 100644 index 000000000..86d9ac20c --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramMinutes.g.cs @@ -0,0 +1,36 @@ +// +// Generated by MetaObjects entity-generator. Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace MetaObjects.IntegrationTests.Generated; + +public class ProgramMinutes +{ + [Column("program")] + public long Program { get; set; } + [Column("programTitle")] + [MaxLength(200)] + public string? ProgramTitle { get; set; } + [Column("weeks")] + public long Weeks { get; set; } + [Column("longWeeks")] + public long LongWeeks { get; set; } + [Column("labels")] + public long Labels { get; set; } + [Column("slots")] + public long Slots { get; set; } + [Column("totalMinutes")] + public long? TotalMinutes { get; set; } + [Column("avgMinutes")] + public decimal? AvgMinutes { get; set; } + [Column("minMinutes")] + public int? MinMinutes { get; set; } + [Column("maxMinutes")] + public int? MaxMinutes { get; set; } + [Column("longShare")] + public decimal? LongShare { get; set; } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramsByMonth.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramsByMonth.g.cs new file mode 100644 index 000000000..c7973ae97 --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramsByMonth.g.cs @@ -0,0 +1,22 @@ +// +// Generated by MetaObjects entity-generator. Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace MetaObjects.IntegrationTests.Generated; + +public class ProgramsByMonth +{ + public enum ProgramsByMonthStatus { DRAFT, PUBLISHED, ARCHIVED } + [Column("createdAtMonth")] + public DateOnly CreatedAtMonth { get; set; } + [Column("status")] + public ProgramsByMonthStatus Status { get; set; } + [Column("programs")] + public long Programs { get; set; } + [Column("listValue")] + public long? ListValue { get; set; } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramsByWeek.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramsByWeek.g.cs new file mode 100644 index 000000000..1c39b13de --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/ProgramsByWeek.g.cs @@ -0,0 +1,17 @@ +// +// Generated by MetaObjects entity-generator. Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace MetaObjects.IntegrationTests.Generated; + +public class ProgramsByWeek +{ + [Column("createdAtWeek")] + public DateOnly CreatedAtWeek { get; set; } + [Column("programs")] + public long Programs { get; set; } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/RecentPrograms.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/RecentPrograms.g.cs new file mode 100644 index 000000000..5051760cc --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Generated/RecentPrograms.g.cs @@ -0,0 +1,15 @@ +// +// Generated by MetaObjects entity-generator. Do not edit by hand. +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace MetaObjects.IntegrationTests.Generated; + +public class RecentPrograms +{ + [Column("programs")] + public long Programs { get; set; } +} diff --git a/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs b/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs new file mode 100644 index 000000000..11041c22b --- /dev/null +++ b/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs @@ -0,0 +1,257 @@ +// A report's derived fields (FR-044, contract Table B): one field per `@dimensions` item +// in listed order, then one per `@measures` item in listed order. +// +// Ported rule for rule from +// server/typescript/packages/metadata/src/core/reporting/report-shape.ts, and gated +// against it by fixtures/persistence-conformance/report-shapes.json (the TS-produced +// artifact every port byte-matches; see ReportShapeTests). + +using System.Text; +using MetaObjects.Meta; + +namespace MetaObjects.Core.Reporting; + +/// Whether a derived report field comes from a dimension or a measure. +public enum ReportFieldRole +{ + Dimension, + Measure, +} + +/// One derived field of a report (one Table B row). +/// The derived field name; the physical column is the naming strategy applied to it. +/// Dimension or measure. +/// A field subtype name (FIELD_SUBTYPE_*). +/// False when the column can be null. +/// The @of field whose type-shaping attrs this field carries, or null. +/// The dimension node, for a dimension field. +/// The time grain, for a dimension.time field. +/// The measure node, for a measure field. +public sealed record ReportField( + string Name, + ReportFieldRole Role, + string SubType, + bool Required, + MetaField? TypeSource = null, + MetaDimension? Dimension = null, + string? Grain = null, + MetaMeasure? Measure = null); + +/// The read shape of an object.report. +/// The report node. +/// The resolved @from entity. +/// The derived fields, dimensions first, in declared order. +public sealed record ReportShape(MetaObject Report, MetaObject From, IReadOnlyList Fields); + +/// Derives a report's read shape (Table B) and serialises the conformance artifact. +public static class ReportShapes +{ + private static readonly HashSet SumLong = + new(StringComparer.Ordinal) { FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG }; + + private static readonly HashSet Floating = + new(StringComparer.Ordinal) { FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT }; + + /// Resolve a dimension's or measure's Entity.field reference to the field node. + public static MetaField? ResolveReportingFieldRef(string reference, MetaObject owner, MetaRoot root) + { + // `Entity.field`; a package qualifier uses `::`, so the member separator is the LAST dot. + int dot = reference.LastIndexOf(CHILD_REF_SEPARATOR, StringComparison.Ordinal); + if (dot <= 0) return null; + var entity = NamingRefs.ResolveObjectRef(root, reference[..dot], NamingRefs.EffectivePackage(owner)) as MetaObject; + // ADR-0039: resolving, so a field inherited through extends is found. + return entity?.FindField(reference[(dot + CHILD_REF_SEPARATOR.Length)..]); + } + + private static InvalidOperationException Unresolved(string reportName, string what) => + new($"report '{reportName}': {what} does not resolve."); + + private static T? DeclaredMember(MetaObject from, string type, string name) where T : MetaData => + // ADR-0039: resolving Children(), so a member declared on an abstract base is found. + from.Children().OfType().FirstOrDefault(c => c.Type == type && c.Name == name); + + private static ReportField DimensionField(ReportDimensionItem item, MetaObject from, MetaRoot root, string reportName) + { + var dim = DeclaredMember(from, TYPE_DIMENSION, item.Name) + ?? throw Unresolved(reportName, $"dimension '{item.Name}' on '{from.Name}'"); + var of = ResolveReportingFieldRef(dim.Of() ?? "", from, root) + ?? throw Unresolved(reportName, $"dimension '{item.Name}' @of"); + string name = ReportAccessors.ReportDerivedFieldName(item); + // The @required ATTR only, read resolving (ADR-0039); a validator.required child does not count. + bool required = dim.Via() is null && of.Attr(FIELD_ATTR_REQUIRED) is true; + if (dim.IsTime()) + { + return item.Grain == GRAIN_HOUR + ? new ReportField(name, ReportFieldRole.Dimension, FIELD_SUBTYPE_TIMESTAMP, required, of, dim, item.Grain) + : new ReportField(name, ReportFieldRole.Dimension, FIELD_SUBTYPE_DATE, required, null, dim, item.Grain); + } + return new ReportField(name, ReportFieldRole.Dimension, of.SubType, required, of, dim); + } + + private static ReportField MeasureField(string name, MetaObject from, MetaRoot root, string reportName) + { + var m = DeclaredMember(from, TYPE_MEASURE, name) + ?? throw Unresolved(reportName, $"measure '{name}' on '{from.Name}'"); + if (m.IsRatio()) + return new ReportField(name, ReportFieldRole.Measure, FIELD_SUBTYPE_DECIMAL, false, Measure: m); + string? agg = m.Agg(); + if (agg == AGG_COUNT) + return new ReportField(name, ReportFieldRole.Measure, FIELD_SUBTYPE_LONG, true, Measure: m); + var of = ResolveReportingFieldRef(m.OfColumns().FirstOrDefault() ?? "", from, root) + ?? throw Unresolved(reportName, $"measure '{name}' @of"); + string src = of.SubType; + if (agg == AGG_SUM) + { + if (src == FIELD_SUBTYPE_CURRENCY) + return new ReportField(name, ReportFieldRole.Measure, FIELD_SUBTYPE_CURRENCY, false, of, Measure: m); + string sumType = SumLong.Contains(src) ? FIELD_SUBTYPE_LONG + : Floating.Contains(src) ? FIELD_SUBTYPE_DOUBLE + : FIELD_SUBTYPE_DECIMAL; + return new ReportField(name, ReportFieldRole.Measure, sumType, false, Measure: m); + } + if (agg == AGG_AVG) + { + string avgType = Floating.Contains(src) ? FIELD_SUBTYPE_DOUBLE : FIELD_SUBTYPE_DECIMAL; + return new ReportField(name, ReportFieldRole.Measure, avgType, false, Measure: m); + } + // min / max keep the source field's type. + return new ReportField(name, ReportFieldRole.Measure, src, false, of, Measure: m); + } + + /// + /// Table B. Throws naming the report when a + /// reference does not resolve (a report that passed the loader's report validation + /// always resolves). + /// + public static ReportShape Of(MetaObject report, MetaRoot root) + { + string fromName = ReportAccessors.ReportFrom(report) ?? throw Unresolved(report.Name, "@from"); + var from = NamingRefs.ResolveObjectRef(root, fromName, NamingRefs.EffectivePackage(report)) as MetaObject + ?? throw Unresolved(report.Name, $"@from '{fromName}'"); + var fields = new List(); + foreach (var item in ReportAccessors.ReportDimensionItems(report)) + fields.Add(DimensionField(item, from, root, report.Name)); + foreach (string name in ReportAccessors.ReportMeasureNames(report)) + fields.Add(MeasureField(name, from, root, report.Name)); + return new ReportShape(report, from, fields.AsReadOnly()); + } + + /// + /// The source a report is READ from: its own read-only source with @role: primary, + /// else its first own read-only source; null when it declares none (Table A: not lowered). + /// The same rule that names the lowered view in the TypeScript lowering, so a reader + /// lands on the relation the lowering created. + /// + public static MetaSource? ReadSource(MetaObject report) + { + // ADR-0039: own — source classification reads the sources the report declares + // ITSELF, exactly as the lowering's view-name rule does. + var readOnly = report.OwnSources().Where(s => s.IsReadOnly()).ToList(); + return readOnly.FirstOrDefault(s => s.Role == SOURCE_ROLE_PRIMARY) ?? readOnly.FirstOrDefault(); + } + + // ----------------------------------------------------------------------- + // The conformance artifact (fixtures/persistence-conformance/report-shapes.json) + // ----------------------------------------------------------------------- + + /// + /// The report-shapes artifact for a loaded model: every object.report in + /// declaration order, serialised byte for byte as the TypeScript generator + /// (integration-tests/src/gen-report-shapes.ts) writes it — keys in a fixed + /// order, two-space indent, every object expanded, one trailing newline. + /// + public static string ToArtifactJson(MetaRoot root) + { + var reports = root.Objects().Where(o => o.IsReport()).ToList(); + var sb = new StringBuilder(); + sb.Append("{\n \"reports\": "); + if (reports.Count == 0) + { + sb.Append("[]"); + } + else + { + sb.Append("[\n"); + for (int i = 0; i < reports.Count; i++) + { + AppendReport(sb, Of(reports[i], root)); + sb.Append(i < reports.Count - 1 ? ",\n" : "\n"); + } + sb.Append(" ]"); + } + sb.Append("\n}\n"); + return sb.ToString(); + } + + private static void AppendReport(StringBuilder sb, ReportShape shape) + { + sb.Append(" {\n"); + sb.Append(" \"report\": ").Append(JsonString(shape.Report.ResolutionKey())).Append(",\n"); + sb.Append(" \"from\": ").Append(JsonString(shape.From.ResolutionKey())).Append(",\n"); + sb.Append(" \"view\": ").Append(JsonStringOrNull(ReadSource(shape.Report)?.PhysicalName)).Append(",\n"); + sb.Append(" \"fields\": "); + if (shape.Fields.Count == 0) + { + sb.Append("[]\n"); + } + else + { + sb.Append("[\n"); + for (int i = 0; i < shape.Fields.Count; i++) + { + var f = shape.Fields[i]; + sb.Append(" {\n"); + sb.Append(" \"name\": ").Append(JsonString(f.Name)).Append(",\n"); + sb.Append(" \"role\": ").Append(JsonString(RoleName(f.Role))).Append(",\n"); + sb.Append(" \"subType\": ").Append(JsonString(f.SubType)).Append(",\n"); + sb.Append(" \"required\": ").Append(f.Required ? "true" : "false").Append(",\n"); + sb.Append(" \"typeSource\": ").Append(JsonStringOrNull(TypeSourceKey(f.TypeSource))).Append('\n'); + sb.Append(i < shape.Fields.Count - 1 ? " },\n" : " }\n"); + } + sb.Append(" ]\n"); + } + sb.Append(" }"); + } + + /// The wire name of a role, as the artifact spells it. + public static string RoleName(ReportFieldRole role) => + role == ReportFieldRole.Dimension ? "dimension" : "measure"; + + // `.`, or null. + private static string? TypeSourceKey(MetaField? field) + { + if (field is null) return null; + var owner = field.Parent + ?? throw new InvalidOperationException($"field '{field.Name}' has no owning entity."); + return $"{owner.ResolutionKey()}{CHILD_REF_SEPARATOR}{field.Name}"; + } + + private static string JsonStringOrNull(string? s) => s is null ? "null" : JsonString(s); + + // JSON.stringify's string escaping: `"`, `\`, the short escapes, and \u00XX for the + // remaining control characters. Everything else is written as is. + private static string JsonString(string s) + { + var sb = new StringBuilder(s.Length + 2); + sb.Append('"'); + foreach (char c in s) + { + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\b': sb.Append("\\b"); break; + case '\f': sb.Append("\\f"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (c < 0x20) sb.Append("\\u").Append(((int)c).ToString("x4")); + else sb.Append(c); + break; + } + } + sb.Append('"'); + return sb.ToString(); + } +} From 62e70e83bd238453afd1512d194efed072c6049e Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 08:34:16 -0400 Subject: [PATCH 15/32] feat(python): report shape and ObjectManager read of a view-backed report (FR-044) --- .../meta/core/reporting/report_read_model.py | 169 +++++++++++ .../meta/core/reporting/report_shape.py | 177 ++++++++++++ .../src/metaobjects/runtime/object_manager.py | 46 ++- .../runtime/test_object_manager_report.py | 268 ++++++++++++++++++ server/python/tests/test_report_shape.py | 83 ++++++ 5 files changed, 742 insertions(+), 1 deletion(-) create mode 100644 server/python/src/metaobjects/meta/core/reporting/report_read_model.py create mode 100644 server/python/src/metaobjects/meta/core/reporting/report_shape.py create mode 100644 server/python/tests/runtime/test_object_manager_report.py create mode 100644 server/python/tests/test_report_shape.py diff --git a/server/python/src/metaobjects/meta/core/reporting/report_read_model.py b/server/python/src/metaobjects/meta/core/reporting/report_read_model.py new file mode 100644 index 000000000..854305ac1 --- /dev/null +++ b/server/python/src/metaobjects/meta/core/reporting/report_read_model.py @@ -0,0 +1,169 @@ +"""A report's READ MODEL (FR-044): a detached object carrying one real ``field.*`` +child per derived field (Table B) and a copy of the report's own read-only source. + +WHY IT EXISTS + +An ``object.report`` declares no fields: its read shape is derived from its +dimensions and measures. A metadata-driven runtime walks an object's field +children in a dozen places (column list, filter and sort resolution, the name +map, every read coercion). Rather than teach each of them what a report is, the +runtime reads a report through this model and sees ordinary fields. + +WHY IT IS DETACHED + +The model is never added to the root: it has no parent, ``root.children()`` does +not list it, and the canonical serializer, ``fmt``, codegen and every other tree +walker never see it. Nothing in the loaded tree is mutated to build it; in +particular the report's own source node is COPIED, not re-parented +(``add_child`` rewrites the child's ``parent``). The nodes are constructed +directly, not through the loader or the registry, so no vocabulary is added: +every node is an already-registered ``type.subType``. + +It keeps the report's name, package and ``object.report`` subtype, so a consumer +holding it can still tell it is a report (no identity, read-only). + +Mirrors TS ``core/reporting/report-read-model.ts``. ADR-0039 / Python naming +inversion: ``attr()`` is OWN-ONLY here, so the TS ``attr()`` reads are +``get_meta_attr()`` below. +""" +from __future__ import annotations + +from weakref import WeakKeyDictionary + +from ....shared.base_types import TYPE_FIELD +from ...meta_root import MetaRoot +from ...persistence.db.db_constants import FIELD_ATTR_DB_COLUMN_TYPE, FIELD_ATTR_LOCAL_TIME +from ...persistence.source.meta_source import MetaSource +from ...persistence.source.source_constants import SOURCE_ATTR_ROLE, SOURCE_ROLE_PRIMARY +from ..field.field_constants import ( + FIELD_ATTR_CURRENCY, + FIELD_ATTR_INT_VALUE_MAP, + FIELD_ATTR_MAX_LENGTH, + FIELD_ATTR_OBJECT_REF, + FIELD_ATTR_PRECISION, + FIELD_ATTR_REQUIRED, + FIELD_ATTR_SCALE, + FIELD_ATTR_STORAGE, + FIELD_ATTR_VALUES, +) +from ..field.meta_field import MetaField +from ..object.meta_object import MetaObject +from .report_shape import ReportField, report_shape + +#: Table B: the type-shaping attrs a derived field carries from its ``type_source``, +#: read with the RESOLVING accessor (ADR-0039) so a value the ``@of`` field inherits +#: through ``extends`` is carried too. ``@dbColumnType`` and ``isArray`` are handled +#: separately below. Nothing else is carried: no ``@column``, ``@required``, +#: ``@default``, validators or views. +_CARRIED_ATTRS: tuple[str, ...] = ( + FIELD_ATTR_CURRENCY, + FIELD_ATTR_VALUES, + FIELD_ATTR_INT_VALUE_MAP, + FIELD_ATTR_MAX_LENGTH, + FIELD_ATTR_PRECISION, + FIELD_ATTR_SCALE, + FIELD_ATTR_LOCAL_TIME, + FIELD_ATTR_OBJECT_REF, + FIELD_ATTR_STORAGE, +) + +#: Read models cached per report node (identity-keyed; nodes define no ``__eq__``). +_READ_MODELS: "WeakKeyDictionary[MetaObject, MetaObject]" = WeakKeyDictionary() + + +def _derived_field(f: ReportField) -> MetaField: + field = MetaField(TYPE_FIELD, f.sub_type, f.name) + # From the derived shape, never from the type source: a ``min`` of a required column + # is still nullable, and a dimension reached by ``@via`` is nullable. + field.set_attr(FIELD_ATTR_REQUIRED, f.required) + src = f.type_source + if src is not None: + for name in _CARRIED_ATTRS: + value = src.get_meta_attr(name) + if value is not None: + field.set_attr(name, value) + # ADR-0039: own — ``@dbColumnType`` is the one deliberately own-only attr (a + # physical column-type override is never inherited), and every consumer reads + # it with ``attr()`` (own). So it is read own from the type source and set OWN + # here: the derived field carries exactly what the ``@of`` field itself + # declares, and nothing its supers declare. + db_column_type = src.attr(FIELD_ATTR_DB_COLUMN_TYPE) + if db_column_type is not None: + field.set_attr(FIELD_ATTR_DB_COLUMN_TYPE, db_column_type) + # ``is_array`` is a native flag, not an attr; ``resolved_is_array()`` is its resolving read. + if src.resolved_is_array(): + field.is_array = True + return field + + +def report_read_source(report: MetaObject) -> MetaSource | None: + """The source a report is READ from: its own read-only source with ``@role: primary``, + else its first own read-only source. ``None`` when it declares none (Table A: not + lowered, not served). + + This is the rule that NAMES the lowered view (``viewName`` / ``projectionViewSource`` + in codegen-ts's ``projection/extract-view-spec.ts``). It is restated here because + this package cannot depend on the TS codegen; the two must stay the same rule, or + the runtime reads a relation the lowering did not create. + + What the loader permits: a report may declare several read-only sources (a + ``@role: replica`` view beside its primary view loads clean, in either order); + ``@role`` defaults to ``primary``; a report whose sources include no primary is + ``ERR_SOURCE_NO_PRIMARY``; a writable source on a report is refused. So for every + model that loads, the primary branch fires. The first-read-only fallback covers a + tree built in code and keeps this rule identical to the lowering's. + """ + # ADR-0039: own — source classification reads the sources the report declares + # ITSELF, exactly as the lowering's ``viewName`` does. + read_only = [c for c in report.own_children() if isinstance(c, MetaSource) and c.is_read_only()] + return next((s for s in read_only if s.role() == SOURCE_ROLE_PRIMARY), read_only[0] if read_only else None) + + +def _copy_source(source: MetaSource) -> MetaSource: + """A detached copy of a source node: same ``type.subType``, name and effective attrs, + and nothing else (attrs only — the loaded node is never re-parented). + + The copy is the model's ONLY source, and it is pinned to ``@role: primary``: the + runtime resolves an object's table through ``primary_rdb_source``, which considers + primary sources only, so this is what makes the read land on the selected source's + physical name rather than on a default table name nobody declared.""" + copy = MetaSource(source.type, source.sub_type, source.name) + # ADR-0039: resolving — the copy carries the source's effective configuration + # (@kind, the physical-name alias, @schema, @unmanaged, @sql). + for name, value in source.attrs().items(): + copy.set_attr(name, value) + copy.set_attr(SOURCE_ATTR_ROLE, SOURCE_ROLE_PRIMARY) + return copy + + +def report_read_model(report: MetaObject, root: MetaRoot) -> MetaObject: + """The read model of an ``object.report``: one field per Table B row, in Table B + order, plus a copy of the source the report is read from (see + :func:`report_read_source`) when it declares one (Table A). A sourceless report + yields a model with no source: it has a shape and no view, and the caller decides + what that means (the runtime refuses to serve it). + + Cached per report node (identity-keyed); the model is frozen. Raises what + :func:`report_shape` raises when a reference does not resolve. + """ + cached = _READ_MODELS.get(report) + if cached is not None: + return cached + + shape = report_shape(report, root) + model = MetaObject(report.type, report.sub_type, report.name) + model.package = report.package + model.file_default_package = report.file_default_package + for f in shape.fields: + model.add_child(_derived_field(f)) + + source = report_read_source(report) + if source is not None: + model.add_child(_copy_source(source)) + + model.freeze() + _READ_MODELS[report] = model + return model + + +__all__ = ["report_read_model", "report_read_source"] diff --git a/server/python/src/metaobjects/meta/core/reporting/report_shape.py b/server/python/src/metaobjects/meta/core/reporting/report_shape.py new file mode 100644 index 000000000..b40d3dc8f --- /dev/null +++ b/server/python/src/metaobjects/meta/core/reporting/report_shape.py @@ -0,0 +1,177 @@ +"""Table B of the FR-044 Plan 2 contract: a report's derived fields. The single +Python definition; every port has a rule-for-rule copy, gated by +``fixtures/persistence-conformance/report-shapes.json``. + +ADR-0039: every read is RESOLVING. Python naming inversion: ``attr()`` is +OWN-ONLY here, so the TypeScript reference's ``attr()`` is ``get_meta_attr()`` +below, and ``fields()`` / ``children()`` are the resolving member accessors. +Mirrors TS ``core/reporting/report-shape.ts``. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from ....naming_refs import CHILD_REF_SEP, resolve_object_ref +from ....shared.separators import PACKAGE_SEP +from ...meta_data import MetaData +from ...meta_root import MetaRoot +from ..field.field_constants import ( + FIELD_ATTR_REQUIRED, + FIELD_SUBTYPE_CURRENCY, + FIELD_SUBTYPE_DATE, + FIELD_SUBTYPE_DECIMAL, + FIELD_SUBTYPE_DOUBLE, + FIELD_SUBTYPE_FLOAT, + FIELD_SUBTYPE_INT, + FIELD_SUBTYPE_LONG, + FIELD_SUBTYPE_TIMESTAMP, +) +from ..field.meta_field import MetaField +from ..object.meta_object import MetaObject +from .meta_dimension import MetaDimension +from .meta_measure import MetaMeasure +from .report_accessors import ( + ReportDimensionItem, + report_derived_field_name, + report_dimension_items, + report_from, + report_measure_names, +) +from .reporting_constants import ( + AGG_AVG, + AGG_COUNT, + AGG_SUM, + GRAIN_HOUR, + TYPE_DIMENSION, + TYPE_MEASURE, +) + +ROLE_DIMENSION = "dimension" +ROLE_MEASURE = "measure" + +_SUM_LONG = frozenset({FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG}) +_FLOATING = frozenset({FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT}) + + +@dataclass(frozen=True) +class ReportField: + name: str + role: str # ROLE_DIMENSION | ROLE_MEASURE + #: A field subtype name (``FIELD_SUBTYPE_*``). + sub_type: str + required: bool + #: The ``@of`` field whose type-shaping attrs this field carries (Table B). + type_source: MetaField | None = None + dimension: MetaDimension | None = None + grain: str | None = None + measure: MetaMeasure | None = None + + +@dataclass(frozen=True) +class ReportShape: + report: MetaObject + from_: MetaObject + fields: tuple[ReportField, ...] + + +def _package_of_key(key: str) -> str: + """Effective package of a node, taken from its resolution key (``::``).""" + i = key.rfind(PACKAGE_SEP) + return key[:i] if i >= 0 else "" + + +def resolve_reporting_field_ref(ref: str, owner: MetaObject, root: MetaRoot) -> MetaField | None: + """Resolve a dimension's or measure's ``Entity.field`` reference to the field node.""" + # ``Entity.field``; a package qualifier uses ``::``, so the member separator is the LAST dot. + dot = ref.rfind(CHILD_REF_SEP) + if dot <= 0: + return None + entity = resolve_object_ref(root, ref[:dot], _package_of_key(owner.resolution_key())) + if not isinstance(entity, MetaObject): + return None + # ADR-0039: resolving fields(), so a field inherited through extends is found. + member = ref[dot + len(CHILD_REF_SEP):] + return next((f for f in entity.fields() if f.name == member), None) + + +def _unresolved(report_name: str, what: str) -> ValueError: + return ValueError(f"report '{report_name}': {what} does not resolve.") + + +def _declared_member(from_: MetaObject, type_: str, name: str, cls: type[MetaData]) -> MetaData | None: + # ADR-0039: resolving children(), so a member declared on an abstract base is found. + return next( + (c for c in from_.children() if c.type == type_ and c.name == name and isinstance(c, cls)), + None, + ) + + +def _dimension_field( + item: ReportDimensionItem, from_: MetaObject, root: MetaRoot, report_name: str +) -> ReportField: + dim = _declared_member(from_, TYPE_DIMENSION, item.name, MetaDimension) + if not isinstance(dim, MetaDimension): + raise _unresolved(report_name, f"dimension '{item.name}' on '{from_.name}'") + of = resolve_reporting_field_ref(dim.of() or "", from_, root) + if of is None: + raise _unresolved(report_name, f"dimension '{item.name}' @of") + name = report_derived_field_name(item) + # ADR-0039 resolving: the @of field's effective @required (the attr only; a + # validator.required child does not count). + required = dim.via() is None and of.get_meta_attr(FIELD_ATTR_REQUIRED) is True + if dim.is_time(): + grain = item.grain + if grain == GRAIN_HOUR: + return ReportField( + name, ROLE_DIMENSION, FIELD_SUBTYPE_TIMESTAMP, required, of, dimension=dim, grain=grain + ) + return ReportField(name, ROLE_DIMENSION, FIELD_SUBTYPE_DATE, required, None, dimension=dim, grain=grain) + return ReportField(name, ROLE_DIMENSION, of.sub_type, required, of, dimension=dim) + + +def _measure_field(name: str, from_: MetaObject, root: MetaRoot, report_name: str) -> ReportField: + m = _declared_member(from_, TYPE_MEASURE, name, MetaMeasure) + if not isinstance(m, MetaMeasure): + raise _unresolved(report_name, f"measure '{name}' on '{from_.name}'") + if m.is_ratio(): + return ReportField(name, ROLE_MEASURE, FIELD_SUBTYPE_DECIMAL, False, measure=m) + agg = m.agg() + if agg == AGG_COUNT: + return ReportField(name, ROLE_MEASURE, FIELD_SUBTYPE_LONG, True, measure=m) + cols = m.of_columns() + of = resolve_reporting_field_ref(cols[0] if cols else "", from_, root) + if of is None: + raise _unresolved(report_name, f"measure '{name}' @of") + src = of.sub_type + if agg == AGG_SUM: + if src == FIELD_SUBTYPE_CURRENCY: + return ReportField(name, ROLE_MEASURE, FIELD_SUBTYPE_CURRENCY, False, of, measure=m) + sub_type = ( + FIELD_SUBTYPE_LONG + if src in _SUM_LONG + else FIELD_SUBTYPE_DOUBLE + if src in _FLOATING + else FIELD_SUBTYPE_DECIMAL + ) + return ReportField(name, ROLE_MEASURE, sub_type, False, measure=m) + if agg == AGG_AVG: + sub_type = FIELD_SUBTYPE_DOUBLE if src in _FLOATING else FIELD_SUBTYPE_DECIMAL + return ReportField(name, ROLE_MEASURE, sub_type, False, measure=m) + # min / max keep the source field's type. + return ReportField(name, ROLE_MEASURE, src, False, of, measure=m) + + +def report_shape(report: MetaObject, root: MetaRoot) -> ReportShape: + """Table B. Raises a ``ValueError`` naming the report when a reference does not + resolve (a report that passed ``validate_reporting`` always resolves).""" + from_name = report_from(report) + if from_name is None: + raise _unresolved(report.name, "@from") + from_ = resolve_object_ref(root, from_name, _package_of_key(report.resolution_key())) + if not isinstance(from_, MetaObject): + raise _unresolved(report.name, f"@from '{from_name}'") + fields = ( + *(_dimension_field(item, from_, root, report.name) for item in report_dimension_items(report)), + *(_measure_field(n, from_, root, report.name) for n in report_measure_names(report)), + ) + return ReportShape(report, from_, tuple(fields)) diff --git a/server/python/src/metaobjects/runtime/object_manager.py b/server/python/src/metaobjects/runtime/object_manager.py index 009ecc125..32a869956 100644 --- a/server/python/src/metaobjects/runtime/object_manager.py +++ b/server/python/src/metaobjects/runtime/object_manager.py @@ -34,6 +34,8 @@ from ..meta.meta_root import MetaRoot from ..meta.core.object.meta_object import MetaObject +from ..meta.core.object.object_constants import OBJECT_SUBTYPE_REPORT +from ..meta.core.reporting.report_read_model import report_read_model from ..meta.core.field.meta_field import MetaField from ..meta.core.field import field_constants as fc from ..naming import DEFAULT_COLUMN_NAMING, resolve_column_name @@ -213,6 +215,7 @@ def __init__( # --- Public API ---------------------------------------------------------- def find_by_id(self, entity_name: str, id_value: Any) -> dict[str, Any] | None: + self._refuse_report("find_by_id", entity_name) entity = self._require_entity(entity_name) pk_field = self._primary_pk_field(entity) rows = self.find_many(entity_name, {pk_field: id_value}, sort=None, limit=1, offset=None) @@ -238,6 +241,7 @@ def create(self, entity_name: str, data: dict[str, Any]) -> dict[str, Any]: timestamp equals its created one. Use :meth:`insert_preserving` for the import/restore path that must keep original timestamps. """ + self._refuse_report("create", entity_name) entity = self._require_entity(entity_name) # #203: stamp every onCreate AND onUpdate column with one shared now() (the # caller's value is ignored). No-op for entities that declare no @autoSet field. @@ -259,6 +263,7 @@ def insert_preserving(self, entity_name: str, data: dict[str, Any]) -> dict[str, same TPH discriminator injection, same ``RETURNING`` row). For an entity that declares no ``@autoSet`` field this is identical to :meth:`create`. """ + self._refuse_report("insert_preserving", entity_name) entity = self._require_entity(entity_name) return self._insert_row(entity, entity_name, data) @@ -365,6 +370,7 @@ def update( subtype's patch strips it and the by-id write is scoped to the subtype (a cross-subtype id matches no row → the same not-found path). """ + self._refuse_report("update", entity_name) entity = self._require_entity(entity_name) table = self._table_name(entity) pk_field = self._primary_pk_field(entity) @@ -466,6 +472,7 @@ def delete(self, entity_name: str, id_value: Any) -> bool: Returns ``True`` when a row was deleted, ``False`` when the PK matched nothing. Mirrors the TS ``om.delete`` boolean outcome contract. """ + self._refuse_report("delete", entity_name) entity = self._require_entity(entity_name) table = self._table_name(entity) pk_field = self._primary_pk_field(entity) @@ -560,6 +567,7 @@ def relate( mirror the TS reference resolver. ``record`` is a source-key dict (e.g. ``{"id": 1}``); only the source PK is read from it. """ + self._refuse_report("relate", entity_name) entity = self._require_entity(entity_name) desc = resolve_n2m_descriptor(entity, relation_name, self._entity_by_name) if desc is None: @@ -623,12 +631,47 @@ def _relate_n2m( # --- Helpers ------------------------------------------------------------- - def _require_entity(self, name: str) -> MetaObject: + def _declared_entity(self, name: str) -> MetaObject: + """The object node exactly as loaded (a report is NOT swapped for its read model).""" e = self._entity_by_name.get(name) if e is None: raise KeyError(f"No entity named '{name}' in loaded metadata") return e + def _require_entity(self, name: str) -> MetaObject: + """The object the runtime reads and writes through. FR-044: a report declares no + fields (its read shape is derived), so it is read through its detached read model + (:func:`report_read_model`) — ordinary derived ``field.*`` children plus a copy of + its own read-only source — and everything downstream (column list, filter and sort + resolution, read coercion, table resolution) sees an ordinary view-backed object. + The model is built once per report node and never joins the loaded tree. + + A report with no read-only source of its own has no view (Table A), so there is + nothing to read: refused as not served rather than read from a table nobody made.""" + e = self._declared_entity(name) + if e.sub_type != OBJECT_SUBTYPE_REPORT: + return e + try: + model = report_read_model(e, self._root) + except ValueError as exc: + raise ValueError(f"Report '{name}' cannot be read: {exc}") from exc + if not any(isinstance(c, MetaSource) for c in model.own_children()): + raise ValueError( + f"Report '{name}' is not served: it declares no read-only source, " + f"so it has no view to read" + ) + return model + + def _refuse_report(self, op: str, name: str) -> None: + """Refuse an operation a report cannot support — get-by-id, relationship + traversal and every write — on the DECLARED subtype, before anything else is + looked at (a sourceless report included: it is read-only, not merely unserved).""" + if self._declared_entity(name).sub_type == OBJECT_SUBTYPE_REPORT: + raise ValueError( + f"{op} is not supported on '{name}': a report is read-only and has no identity " + f"(it is a view over aggregates; only find_many and count read it)" + ) + def _table_name(self, entity: MetaObject) -> str: """The physical relation *entity* lives in. @@ -699,6 +742,7 @@ def primary_key_field(self, entity_name: str) -> str: """The single-field primary-key NAME for an entity, from its ``identity.primary`` ``@fields``. ``op: roundtrip`` reads the inserted row back by this key (composite PKs are not supported by roundtrip).""" + self._refuse_report("primary_key_field", entity_name) return self._primary_pk_field(self._require_entity(entity_name)) def _primary_pk_field(self, entity: MetaObject) -> str: diff --git a/server/python/tests/runtime/test_object_manager_report.py b/server/python/tests/runtime/test_object_manager_report.py new file mode 100644 index 000000000..52c66acb3 --- /dev/null +++ b/server/python/tests/runtime/test_object_manager_report.py @@ -0,0 +1,268 @@ +"""ObjectManager reads a view-backed ``object.report`` (FR-044 Plan 2, Task 14). + +A report declares no fields: its read shape is derived (Table B). The runtime reads +it through a detached READ MODEL (``report_read_model``) swapped in at +``_require_entity``, so the column list, filter/sort resolution, read coercion and +table resolution all see an ordinary view-backed object. These tests use a recording +driver (no database): they pin the SQL, the refusals, and that the loaded tree is +never touched. The rows themselves are proven by the six shared ``report-*`` persistence +scenarios against Postgres (``tests/integration/test_query_scenarios.py``). +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from metaobjects import load_directory +from metaobjects.loader.meta_data_loader import MetaDataLoader +from metaobjects.loader.sources import InMemoryStringSource +from metaobjects.meta.core.object.meta_object import MetaObject +from metaobjects.meta.core.object.object_constants import OBJECT_SUBTYPE_REPORT +from metaobjects.meta.core.reporting.report_read_model import report_read_model +from metaobjects.runtime.object_manager import ObjectManager, SelectResult +from metaobjects.serializer_json import canonical_serialize +from metaobjects.shared.base_types import TYPE_OBJECT + +CORPUS = Path(__file__).parents[4] / "fixtures" / "persistence-conformance" + + +class RecordingDriver: + """Records every statement; answers with no rows (and a zero scalar).""" + + def __init__(self) -> None: + self.sql: list[str] = [] + self.params: list[tuple[Any, ...]] = [] + + def select(self, sql: str, params: tuple[Any, ...] = ()) -> SelectResult: + self.sql.append(sql) + self.params.append(params) + return SelectResult([], {}) + + def scalar(self, sql: str, params: tuple[Any, ...] = ()) -> Any: + self.sql.append(sql) + self.params.append(params) + return 0 + + def insert_returning(self, *a: Any, **k: Any) -> SelectResult: # pragma: no cover - must not be reached + raise AssertionError("a report write reached the driver") + + update_returning = insert_returning + + def execute_rowcount(self, *a: Any, **k: Any) -> int: # pragma: no cover - must not be reached + raise AssertionError("a report write reached the driver") + + +def _canonical_root(): + result = load_directory(CORPUS / "canonical") + assert not result.errors, "\n".join(e.message for e in result.errors) + return result.root + + +def _om(root: Any, driver: RecordingDriver, **kw: Any) -> ObjectManager: + return ObjectManager(root, driver, **kw) # type: ignore[arg-type] + + +def _load(text: str): + result = MetaDataLoader().load([InMemoryStringSource(text, "t.json")]) + assert result.errors == [], [e.message for e in result.errors] + return result.root + + +# A report beside an entity; `source` is spliced into the report's children. +def _model(report_sources: str, *, extra_report: str = "") -> str: + return """{ "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Sale", "children": [ + { "source.rdb": { "name": "primary", "@table": "sales" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "region", "@required": true } }, + { "field.int": { "name": "units" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } }, + { "dimension.attribute": { "name": "region", "@of": "Sale.region" } }, + { "measure.aggregate": { "name": "total", "@agg": "sum", "@of": "Sale.units" } } + ]} }, + { "object.report": { "name": "SalesByRegion", "@from": "Sale", + "@dimensions": ["region"], "@measures": ["total"], "children": [ + %s + ]%s } } +]} }""" % (report_sources, extra_report) + + +# --- the canonical reports through the runtime ------------------------------------------------ + + +def test_find_many_selects_the_derived_columns_from_the_view() -> None: + root = _canonical_root() + drv = RecordingDriver() + _om(root, drv).find_many("ProgramMinutes") + assert drv.sql[0] == ( + 'SELECT "program", "programTitle", "weeks", "longWeeks", "labels", "slots", ' + '"totalMinutes", "avgMinutes", "minMinutes", "maxMinutes", "longShare" ' + 'FROM "v_program_minutes"' + ) + + +def test_filter_sort_limit_on_derived_fields() -> None: + root = _canonical_root() + drv = RecordingDriver() + _om(root, drv).find_many( + "ProgramMinutes", {"weeks": {"gte": 2}}, sort=[("totalMinutes", "desc")], limit=1 + ) + sql = drv.sql[0] + assert sql.endswith('FROM "v_program_minutes" WHERE "weeks" >= %s ORDER BY "totalMinutes" DESC LIMIT 1') + assert drv.params[0] == (2,) + + +def test_count_reads_the_view() -> None: + root = _canonical_root() + drv = RecordingDriver() + assert _om(root, drv).count("ProgramMinutes", {"weeks": {"gte": 1}}) == 0 + assert drv.sql[0] == 'SELECT COUNT(*) FROM "v_program_minutes" WHERE "weeks" >= %s' + + +def test_the_naming_strategy_applies_to_the_derived_name() -> None: + """The derived field carries no @column, so snake_case turns ``programTitle`` into + ``program_title`` (a column the lowering emits under the same strategy).""" + root = _canonical_root() + drv = RecordingDriver() + _om(root, drv, column_naming="snake_case").find_many("ProgramMinutes", {"programTitle": "x"}) + assert '"program_title"' in drv.sql[0] + assert '"programTitle"' not in drv.sql[0] + + +def test_an_unknown_field_in_a_report_filter_is_not_silently_mapped() -> None: + """Same as any object: an unknown field name is used verbatim as the column, so the + database (not the runtime) refuses it. Pins that a report adds no special case.""" + root = _canonical_root() + drv = RecordingDriver() + _om(root, drv).find_many("ProgramMinutes", {"nope": 1}) + assert '"nope" = %s' in drv.sql[0] + + +@pytest.mark.parametrize("op", ["find_by_id", "create", "insert_preserving", "update", "delete"]) +def test_by_id_and_every_write_are_refused_read_only_no_identity(op: str) -> None: + root = _canonical_root() + drv = RecordingDriver() + om = _om(root, drv) + args: dict[str, tuple[Any, ...]] = { + "find_by_id": ("ProgramMinutes", 1), + "create": ("ProgramMinutes", {"weeks": 1}), + "insert_preserving": ("ProgramMinutes", {"weeks": 1}), + "update": ("ProgramMinutes", 1, {"weeks": 1}), + "delete": ("ProgramMinutes", 1), + } + with pytest.raises(ValueError, match="read-only and has no identity"): + getattr(om, op)(*args[op]) + assert drv.sql == [] + + +def test_relate_and_primary_key_field_are_refused() -> None: + om = _om(_canonical_root(), RecordingDriver()) + with pytest.raises(ValueError, match="read-only and has no identity"): + om.relate("ProgramMinutes", {"id": 1}, "weeks") + with pytest.raises(ValueError, match="read-only and has no identity"): + om.primary_key_field("ProgramMinutes") + + +def test_a_non_report_object_is_unchanged() -> None: + root = _canonical_root() + drv = RecordingDriver() + om = _om(root, drv) + declared = next(c for c in root.children() if c.type == TYPE_OBJECT and c.name == "Program") + assert om._require_entity("Program") is declared + om.find_many("Program", {"id": 1}, limit=1) + assert drv.sql[0].startswith('SELECT ') and 'FROM "programs"' in drv.sql[0] + assert om.primary_key_field("Program") == "id" + + +def test_reading_reports_leaves_the_loaded_tree_untouched() -> None: + root = _canonical_root() + before = canonical_serialize(root) + n_objects = len([c for c in root.children() if c.type == TYPE_OBJECT]) + om = _om(root, RecordingDriver()) + for name in ("ProgramMinutes", "FitnessTotals", "ProgramsByMonth"): + om.find_many(name) + om.count(name) + assert len([c for c in root.children() if c.type == TYPE_OBJECT]) == n_objects + assert canonical_serialize(root) == before + # The report's source node still belongs to the report (never re-parented). + report = next(c for c in root.children() if c.name == "ProgramMinutes") + assert all(c.parent is report for c in report.own_children()) + + +def test_the_runtime_reads_through_the_cached_read_model() -> None: + root = _canonical_root() + om = _om(root, RecordingDriver()) + assert om._require_entity("ProgramMinutes") is om._require_entity("ProgramMinutes") + report = next(c for c in root.children() if c.name == "ProgramMinutes") + assert report_read_model(report, root) is om._require_entity("ProgramMinutes") + # Detached: the model is not the declared node and has no parent. + assert om._require_entity("ProgramMinutes") is not report + assert om._require_entity("ProgramMinutes").parent is None + assert om._require_entity("ProgramMinutes").sub_type == OBJECT_SUBTYPE_REPORT + + +# --- shapes the loader permits, built inline -------------------------------------------------- + + +def test_a_sourceless_report_is_not_served() -> None: + root = _load(_model("")) + drv = RecordingDriver() + om = _om(root, drv) + for read in (lambda: om.find_many("SalesByRegion"), lambda: om.count("SalesByRegion")): + with pytest.raises(ValueError, match="is not served"): + read() + assert drv.sql == [] + + +def test_a_write_on_a_sourceless_report_is_refused_as_read_only_not_unserved() -> None: + om = _om(_load(_model("")), RecordingDriver()) + with pytest.raises(ValueError, match="read-only and has no identity"): + om.create("SalesByRegion", {"total": 1}) + with pytest.raises(ValueError, match="read-only and has no identity"): + om.find_by_id("SalesByRegion", 1) + + +def test_an_unmanaged_view_backed_report_is_still_read() -> None: + root = _load(_model('{ "source.rdb": { "@kind": "view", "@view": "v_sales_by_region", "@unmanaged": true } }')) + drv = RecordingDriver() + om = _om(root, drv) + om.find_many("SalesByRegion") + om.count("SalesByRegion") + assert drv.sql[0] == 'SELECT "region", "total" FROM "v_sales_by_region"' + assert drv.sql[1] == 'SELECT COUNT(*) FROM "v_sales_by_region"' + + +def test_a_replica_declared_before_the_primary_view_reads_the_primary() -> None: + """The read model names the view by the same rule the lowering uses: the own read-only + source with @role primary, else the first. Decoy names would show up in the SQL.""" + root = _load(_model( + '{ "source.rdb": { "name": "r", "@kind": "view", "@view": "v_replica", "@role": "replica" } },' + '{ "source.rdb": { "name": "p", "@kind": "view", "@view": "v_primary", "@role": "primary" } }' + )) + drv = RecordingDriver() + _om(root, drv).find_many("SalesByRegion") + assert 'FROM "v_primary"' in drv.sql[0] + assert "v_replica" not in drv.sql[0] + + +def test_a_view_with_no_explicit_role_is_read() -> None: + root = _load(_model('{ "source.rdb": { "@kind": "view", "@view": "v_plain" } }')) + drv = RecordingDriver() + _om(root, drv).find_many("SalesByRegion") + assert 'FROM "v_plain"' in drv.sql[0] + + +def test_required_and_carried_attrs_come_from_the_derived_shape() -> None: + root = _load(_model('{ "source.rdb": { "@kind": "view", "@view": "v" } }')) + report = next(c for c in root.children() if c.name == "SalesByRegion") + model = report_read_model(report, root) + assert isinstance(model, MetaObject) + by_name = {f.name: f for f in model.fields()} + assert list(by_name) == ["region", "total"] + assert by_name["region"].sub_type == "string" + assert by_name["region"].get_meta_attr("required") is True # dimension: the @of field's @required + assert by_name["total"].sub_type == "long" + assert by_name["total"].get_meta_attr("required") is False # a sum is nullable + assert all(f.get_meta_attr("column") is None for f in model.fields()) # @column is never carried diff --git a/server/python/tests/test_report_shape.py b/server/python/tests/test_report_shape.py new file mode 100644 index 000000000..8dfd37e26 --- /dev/null +++ b/server/python/tests/test_report_shape.py @@ -0,0 +1,83 @@ +"""FR-044 Plan 2 — Table B (a report's derived fields), byte-matched across ports. + +TypeScript produces ``fixtures/persistence-conformance/report-shapes.json`` from the +canonical model; every other port derives the same shapes from the same model and +compares BYTES, in a container-free test, so the derivation cannot drift between ports. +The format is a contract (reports in declaration order; keys ``report, from, view, +fields``, then per field ``name, role, subType, required, typeSource``; two-space +indent; one trailing newline), and ``type_source`` is the resolution key of the entity +that DECLARES the ``@of`` field, a dot, and the field name. +""" +from __future__ import annotations + +import json +from pathlib import Path + +from metaobjects import load_directory +from metaobjects.meta.core.field.meta_field import MetaField +from metaobjects.meta.core.object.meta_object import MetaObject +from metaobjects.meta.core.object.object_constants import OBJECT_SUBTYPE_REPORT +from metaobjects.meta.core.reporting.report_shape import report_shape +from metaobjects.meta.persistence.source.meta_source import MetaSource +from metaobjects.shared.base_types import TYPE_OBJECT + +CORPUS = Path(__file__).parents[3] / "fixtures" / "persistence-conformance" + + +def _root(): + result = load_directory(CORPUS / "canonical") + assert not result.errors, "\n".join(e.message for e in result.errors) + return result.root + + +def _type_source(field: MetaField | None) -> str | None: + if field is None: + return None + owner = field.parent + assert owner is not None, f"field '{field.name}' has no owning entity" + return f"{owner.resolution_key()}.{field.name}" + + +def generate_report_shapes_json(root) -> str: + reports = [] + # ADR-0039: resolving — the root is never extended, so children() == own_children(). + for report in (c for c in root.children() if c.type == TYPE_OBJECT): + if report.sub_type != OBJECT_SUBTYPE_REPORT: + continue + shape = report_shape(report, root) + # ADR-0039: own — the report's own declared read-only source names its view; a + # report inherits no source, and a sourceless one has no view. + source = next( + (c for c in report.own_children() if isinstance(c, MetaSource) and c.is_read_only()), None + ) + reports.append( + { + "report": report.resolution_key(), + "from": shape.from_.resolution_key(), + "view": None if source is None else source.physical_name(), + "fields": [ + { + "name": f.name, + "role": f.role, + "subType": f.sub_type, + "required": f.required, + "typeSource": _type_source(f.type_source), + } + for f in shape.fields + ], + } + ) + # json.dumps(indent=2) is JSON.stringify(_, null, 2) for this data: same layout, + # empty containers aside (none occur), and no non-ASCII to escape. + return json.dumps({"reports": reports}, indent=2, ensure_ascii=False) + "\n" + + +def test_derived_shapes_byte_match_the_committed_artifact() -> None: + expected = (CORPUS / "report-shapes.json").read_text(encoding="utf-8") + assert generate_report_shapes_json(_root()) == expected + + +def test_the_canonical_model_has_six_reports() -> None: + reports = [c for c in _root().children() if c.type == TYPE_OBJECT and c.sub_type == OBJECT_SUBTYPE_REPORT] + assert len(reports) == 6 + assert all(isinstance(r, MetaObject) for r in reports) From 3a6bb2681a4c4dcdb8873e527cc0733f141c81a5 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 08:36:30 -0400 Subject: [PATCH 16/32] fix(csharp): refuse a report whose derived field name equals its row class name (FR-044) --- .../ReportRowCodegenTests.cs | 39 +++++++++++++++++++ .../csharp/MetaObjects.Codegen/ReportRows.cs | 25 ++++++++++++ 2 files changed, 64 insertions(+) diff --git a/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs index 1fdb325f1..d03eaaea9 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs @@ -162,6 +162,8 @@ public void A_view_backed_report_generates_its_row_and_mapping(string what, stri { "no source", "" }, // The lowering skips these kinds, so no relation with the Table B columns is promised. { "a materialized view", "\"@kind\": \"materializedView\", \"@materializedView\": \"mv_sales\"" }, + { "a stored procedure", "\"@kind\": \"storedProc\", \"@proc\": \"sp_sales\"" }, + { "a table function", "\"@kind\": \"tableFunction\", \"@function\": \"fn_sales\"" }, }; [Theory] @@ -331,6 +333,43 @@ public void A_report_whose_DbSet_name_collides_with_an_entitys_is_refused() } } + public static TheoryData RowNameCollisions => new() + { + // report name, @dimensions, @measures, the phrase the message must carry + { "Sales", "", "\"sales\"", "its measure \"sales\"" }, + { "Channel", "\"channel\"", "\"sales\"", "its dimension \"channel\"" }, + // A time dimension collides through its DERIVED name; the message names the item. + { "SoldAtDay", "\"soldAt:day\"", "\"sales\"", "its dimension \"soldAt\"" }, + }; + + [Theory] + [MemberData(nameof(RowNameCollisions))] + public void A_report_whose_derived_field_is_named_after_its_row_class_is_refused( + string report, string dims, string measures, string names) + { + // CS0542: a member cannot be named after its enclosing type. `gen` must say so + // rather than exit 0 and leave it to the adopter's build. + var root = Load(Report(report, "\"@kind\": \"view\", \"@view\": \"v_x\"", dims, measures)); + foreach (var generator in new IGenerator[] { new EntityGenerator(), new DbContextGenerator() }) + { + var ex = Assert.Throws(() => generator.Generate(RunnerContext(root)).ToList()); + Assert.Contains($"report \"{report}\"", ex.Message); + Assert.Contains(names, ex.Message); + Assert.Contains("rename the report or the", ex.Message); + } + } + + [Fact] + public void A_sourceless_report_whose_item_is_named_after_it_is_not_refused() + { + // It generates no row, so there is no class for the name to collide with. + var with = EmitAll(RunnerContext(Load(Report("Channel", "", "\"channel\"", "\"sales\"")))); + var without = EmitAll(RunnerContext(Load())); + Assert.Equal(without.Keys.OrderBy(k => k).ToList(), with.Keys.OrderBy(k => k).ToList()); + foreach (var (path, content) in without) + Assert.True(content == with[path], $"{path} changed"); + } + [Fact] public void A_sourceless_report_with_a_colliding_name_is_not_refused() { diff --git a/server/csharp/MetaObjects.Codegen/ReportRows.cs b/server/csharp/MetaObjects.Codegen/ReportRows.cs index bd2abef0a..0b8f7f475 100644 --- a/server/csharp/MetaObjects.Codegen/ReportRows.cs +++ b/server/csharp/MetaObjects.Codegen/ReportRows.cs @@ -79,6 +79,7 @@ public static MetaObject RowModel(MetaObject report, MetaRoot root) var source = ReportShapes.ReadSource(report) ?? throw new InvalidOperationException($"report '{report.Name}' declares no read-only source."); var shape = ReportShapes.Of(report, root); + RefuseFieldNamedAfterTheRow(report, shape); var model = new MetaObject(new TypeId(report.Type, report.SubType), report.Name); if (report.Package is { } pkg) model.SetPackage(pkg); @@ -93,6 +94,30 @@ public static MetaObject RowModel(MetaObject report, MetaRoot root) return model; } + /// + /// Refuse a report one of whose derived fields would become a property with the row + /// class's own name. C# forbids a member named after its enclosing type (CS0542), and + /// the loader relates a report's name to none of its item names, so a report named for + /// what it measures (Revenue with a measure revenue) loads clean and would + /// otherwise generate a file that does not compile. Reached only for a report that + /// generates a row; a report that generates nothing claims no name. + /// + private static void RefuseFieldNamedAfterTheRow(MetaObject report, ReportShape shape) + { + string className = CSharpNaming.Pascal(report.Name); + foreach (var f in shape.Fields) + { + if (CSharpNaming.Pascal(f.Name) != className) continue; + string role = ReportShapes.RoleName(f.Role); + string item = f.Role == ReportFieldRole.Dimension ? f.Dimension!.Name : f.Measure!.Name; + throw new InvalidOperationException( + $"report \"{report.Name}\" and its {role} \"{item}\" both generate the C# name " + + $"\"{className}\" (the row class, and the property for derived field \"{f.Name}\"), " + + $"and a C# member cannot be named after its enclosing type — rename the report " + + $"or the {role}."); + } + } + private static MetaField DerivedField(ReportField f) { var field = new MetaField(new TypeId(TYPE_FIELD, f.SubType), f.Name); From e63897912f1e1cc65a9f0db7569b74e57f9419fd Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 08:48:18 -0400 Subject: [PATCH 17/32] feat(java): report shape and OMDB read of a view-backed report (FR-044) --- .../integration/ObjectManagerDbAdapter.java | 6 +- .../integration/QueryScenarioRunner.java | 7 +- .../metaobjects/loader/ValidationPhase.java | 2 +- .../metaobjects/object/ReportMetaObject.java | 6 +- .../reporting/ReportReadModel.java | 238 +++++++++ .../metaobjects/reporting/ReportShape.java | 303 +++++++++++ .../reporting/ReportReadModelTest.java | 310 +++++++++++ .../reporting/ReportShapeTest.java | 325 ++++++++++++ .../manager/db/ObjectManagerDB.java | 85 +++ .../manager/db/SimpleMappingHandlerDB.java | 14 +- .../manager/db/ReportReadTest.java | 501 ++++++++++++++++++ .../omdb/src/test/resources/meta.report.json | 37 ++ 12 files changed, 1828 insertions(+), 6 deletions(-) create mode 100644 server/java/metadata/src/main/java/com/metaobjects/reporting/ReportReadModel.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/reporting/ReportShape.java create mode 100644 server/java/metadata/src/test/java/com/metaobjects/reporting/ReportReadModelTest.java create mode 100644 server/java/metadata/src/test/java/com/metaobjects/reporting/ReportShapeTest.java create mode 100644 server/java/omdb/src/test/java/com/metaobjects/manager/db/ReportReadTest.java create mode 100644 server/java/omdb/src/test/resources/meta.report.json diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/ObjectManagerDbAdapter.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/ObjectManagerDbAdapter.java index 9af4b38d5..f3d77b8f6 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/ObjectManagerDbAdapter.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/ObjectManagerDbAdapter.java @@ -81,8 +81,12 @@ static Object execute(ObjectManagerDB omdb, ObjectConnection conn, MetaObject mc if (spec.limit() != null) opts.setRange(buildRange(spec.offset(), spec.limit())); Collection raw = omdb.getObjects(conn, mc, opts); + // FR-044: a report's rows are instances of its read model (one field per derived + // field); the declared report node has no fields to walk. Any other object is its + // own read object. + MetaObject rowMeta = omdb.readObjectFor(mc); List> rows = new ArrayList<>(raw.size()); - for (Object o : raw) rows.add(toRowMap(mc, o, columnSqlTypes)); + for (Object o : raw) rows.add(toRowMap(rowMeta, o, columnSqlTypes)); if ("get".equals(spec.op())) return rows.isEmpty() ? null : rows.get(0); return rows; // op:list diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/QueryScenarioRunner.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/QueryScenarioRunner.java index 1f1bc4db2..2078e59d5 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/QueryScenarioRunner.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/QueryScenarioRunner.java @@ -7,6 +7,7 @@ import com.metaobjects.manager.db.ObjectManagerDB; import com.metaobjects.manager.db.driver.PostgresDriver; import com.metaobjects.object.MetaObject; +import com.metaobjects.reporting.ReportReadModel; import javax.sql.DataSource; import java.io.PrintWriter; @@ -129,7 +130,11 @@ private static Object dispatch(ObjectManagerDB omdb, ObjectConnection oc, MetaOb * {@link ResultSetMetaData}. Returns an empty map for a non-persistent object. */ private static Map probeColumnSqlTypes(PostgresContainer pg, MetaObject mc) { - String relation = mc.getPrimaryRdbViewName(); + // FR-044: a report's relation is its read model's view (named by the source's + // kind-matching @view alias); the declared node carries no fields to key the probe by. + String relation = ReportReadModel.isReport(mc) + ? ReportReadModel.of(mc).viewName() + : mc.getPrimaryRdbViewName(); if (relation == null) relation = mc.getPrimaryRdbTableName(); if (relation == null) return Map.of(); Map types = new LinkedHashMap<>(); diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java index d9db53618..a782a4e4a 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java @@ -4187,7 +4187,7 @@ private static WalkedViaPath validateViaPath(String viaAttr, MetaRoot root, * * @param referrerPkg the effective package of the node carrying the ref ("" for root-level) */ - static MetaObject resolveRootObject(MetaRoot root, String ref, String referrerPkg) { + public static MetaObject resolveRootObject(MetaRoot root, String ref, String referrerPkg) { if (ref == null) return null; String pkg = (referrerPkg == null) ? "" : referrerPkg; if (ref.indexOf(MetaData.PKG_SEPARATOR) >= 0) { diff --git a/server/java/metadata/src/main/java/com/metaobjects/object/ReportMetaObject.java b/server/java/metadata/src/main/java/com/metaobjects/object/ReportMetaObject.java index fc869c2be..a5d3f3562 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/object/ReportMetaObject.java +++ b/server/java/metadata/src/main/java/com/metaobjects/object/ReportMetaObject.java @@ -23,8 +23,10 @@ * derived from {@code @dimensions} and {@code @measures}, never declared; its rules * (R1-R7) are enforced by the loader's reporting validation pass. * - *

Registration + canonical serialization only in this plan; the view lowering and - * the generated read surface arrive with FR-044 Plan 2.

+ *

The declared node carries no field children. Its read shape is derived by + * {@link com.metaobjects.reporting.ReportShape} (contract Table B), and a runtime reads it + * through {@link com.metaobjects.reporting.ReportReadModel}. The view itself is lowered by + * the TypeScript toolchain only (ADR-0015); the Java generators emit nothing for a report.

*/ @SuppressWarnings("serial") public class ReportMetaObject extends AbstractObjectRepresentation { diff --git a/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportReadModel.java b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportReadModel.java new file mode 100644 index 000000000..5442bdbc7 --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportReadModel.java @@ -0,0 +1,238 @@ +/* + * Copyright 2026 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.reporting; + +import com.metaobjects.MetaData; +import com.metaobjects.MetaDataException; +import com.metaobjects.MetaRoot; +import com.metaobjects.attr.BooleanAttribute; +import com.metaobjects.attr.MetaAttribute; +import com.metaobjects.attr.StringAttribute; +import com.metaobjects.database.CoreDBMetaDataProvider; +import com.metaobjects.field.CurrencyField; +import com.metaobjects.field.DateField; +import com.metaobjects.field.DecimalField; +import com.metaobjects.field.DoubleField; +import com.metaobjects.field.EnumField; +import com.metaobjects.field.LongField; +import com.metaobjects.field.MetaField; +import com.metaobjects.object.MetaObject; +import com.metaobjects.object.ReportMetaObject; +import com.metaobjects.source.MetaSource; + +import java.util.List; + +/** + * A report's READ MODEL (FR-044): a detached {@code object.report} node carrying one real + * {@code field.*} child per derived field ({@link ReportShape}, contract Table B) and a + * copy of the source the report is read from. + * + *

Why it exists

+ * An {@code object.report} declares no fields: its read shape is derived from its + * dimensions and measures. A metadata-driven runtime walks an object's field children + * everywhere (column mapping, filter and sort resolution, instance construction, every + * read codec). Rather than teach each of those what a report is, a runtime reads a report + * through this model and sees ordinary fields. + * + *

Why it is detached

+ * The model is never added to the root: it has no parent, the loader does not list it, + * and the canonical serializer, {@code fmt}, codegen and every other tree walker never see + * it. Nothing in the loaded tree is mutated to build it — the type-shaping attrs and the + * source are COPIED (attrs only), never re-parented ({@code addChild} rewrites a child's + * parent). Nodes are constructed directly, not through the loader, and no vocabulary is + * added: every node is an already-registered {@code type.subType}. + * + *

It keeps the report's name, package and {@code object.report} subtype, so a consumer + * holding it can still tell it is a report (no identity, read-only). It is its own class so + * it can be told apart from the declared node, which has the same name and subtype.

+ * + *

Mirrors the TypeScript {@code report-read-model.ts}.

+ */ +@SuppressWarnings("serial") +public final class ReportReadModel extends ReportMetaObject { + + private static final String CACHE_KEY = "ReportReadModel.of()"; + + /** + * Table B: the type-shaping attrs a derived field carries from its type source, read + * with the RESOLVING accessor (ADR-0039) so a value the {@code @of} field inherits + * through {@code extends} is carried too. {@code @dbColumnType} and array-ness are + * handled separately. Nothing else is carried: no {@code @column}, {@code @required}, + * {@code @default}, validators or views. + */ + private static final List CARRIED_ATTRS = List.of( + CurrencyField.ATTR_CURRENCY, + EnumField.ATTR_VALUES, + EnumField.ATTR_INT_VALUE_MAP, + MetaField.ATTR_MAX_LENGTH, + MetaField.ATTR_PRECISION, + MetaField.ATTR_SCALE, + CoreDBMetaDataProvider.LOCAL_TIME, + MetaField.ATTR_OBJECT_REF, + MetaField.ATTR_STORAGE); + + /** The declared report this model was built from; {@code null} only on a bare instance. */ + private transient MetaObject report; + + /** + * Constructs an EMPTY model node. Public only because the node contract requires a + * {@code (String name)} constructor ({@link MetaData#clone()}); obtain a model with + * {@link #of(MetaObject)}. + */ + public ReportReadModel(String name) { + super(name); + } + + /** True for an {@code object.report}: the declared node or its read model. */ + public static boolean isReport(MetaObject object) { + return object != null && MetaObject.SUBTYPE_REPORT.equals(object.getSubType()); + } + + /** + * The read model of a report attached to a loaded model; the root is found from the + * report. Passing a read model returns it unchanged. + * + * @throws MetaDataException what {@link ReportShape#of(MetaObject)} throws + */ + public static ReportReadModel of(MetaObject report) { + if (report instanceof ReportReadModel) return (ReportReadModel) report; + return report.useFrozenCache(CACHE_KEY, () -> build(ReportShape.of(report))); + } + + /** + * The read model of an {@code object.report}: one field per Table B row, in Table B + * order, plus a copy of the source the report is read from + * ({@link ReportShape#readSource(MetaObject)}) when it declares one. A sourceless report + * yields a model with no source: it has a shape and no view, and the caller decides + * what that means ({@link #isServed()}). + * + *

Cached on the report node once the loaded tree is frozen (the loader freezes it + * when the load completes), so a report has one model for its lifetime; before that + * each call builds a fresh, equal model, so nothing derived from a still-mutable tree is + * served stale.

+ * + * @throws MetaDataException what {@link ReportShape#of(MetaObject, MetaRoot)} throws + */ + public static ReportReadModel of(MetaObject report, MetaRoot root) { + if (report instanceof ReportReadModel) return (ReportReadModel) report; + return report.useFrozenCache(CACHE_KEY, () -> build(ReportShape.of(report, root))); + } + + /** The declared {@code object.report} node this model reads. */ + public MetaObject report() { + return report; + } + + /** True when the report has a view to read (Table A); a sourceless report is not served. */ + public boolean isServed() { + return findPrimaryReadOnlySource().isPresent(); + } + + /** + * The physical name of the view the model is read from, or {@code null} when the + * report is not served. Resolved through the source's kind-matching alias + * ({@code @view} for a view), so a report is read under the name the lowering created. + */ + public String viewName() { + return findPrimaryReadOnlySource().map(MetaSource::getPhysicalName).orElse(null); + } + + private static ReportReadModel build(ReportShape shape) { + MetaObject report = shape.report(); + // The resolution key carries the package, so the model resolves as the report does. + ReportReadModel model = new ReportReadModel(report.getName()); + model.report = report; + for (ReportShape.Field f : shape.fields()) model.addChild(derivedField(f)); + + MetaSource source = ReportShape.readSource(report); + if (source != null) model.addChild(copySource(source)); + + model.freeze(); + return model; + } + + private static MetaField derivedField(ReportShape.Field f) { + MetaField field = newField(f); + // From the derived shape, never from the type source: a `min` of a required column + // is still nullable, and a dimension reached by @via is nullable. + field.addMetaAttr(BooleanAttribute.create(MetaField.ATTR_REQUIRED, f.required())); + MetaField src = f.typeSource(); + if (src == null) return field; + + for (String name : CARRIED_ATTRS) { + if (src.hasMetaAttr(name)) field.addMetaAttr(copyAttr(src.getMetaAttr(name))); + } + // ADR-0039: own — @dbColumnType is the one deliberately own-only attr (a physical + // column-type override is never inherited). So it is read own from the type source: + // the derived field carries exactly what the @of field itself declares, and nothing + // its supers declare. + if (src.hasMetaAttr(CoreDBMetaDataProvider.DB_COLUMN_TYPE, false)) { + field.addMetaAttr(copyAttr(src.getMetaAttr(CoreDBMetaDataProvider.DB_COLUMN_TYPE, false))); + } + // Array-ness is a native flag, not an attr; isArrayType() is its resolving read. + if (src.isArrayType()) field.setArray(true); + return field; + } + + /** + * A new, parentless field node of the derived subtype. A derived field that has a type + * source always has that source's subtype (Table B), so it is built as the same node + * class; the rows with no type source produce one of four fixed subtypes. + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private static MetaField newField(ReportShape.Field f) { + MetaField src = f.typeSource(); + if (src != null && f.subType().equals(src.getSubType())) { + return (MetaField) src.newInstanceFromClass((Class) src.getClass(), MetaField.TYPE_FIELD, f.subType(), f.name()); + } + switch (f.subType()) { + case LongField.SUBTYPE_LONG: return new LongField(f.name()); + case DecimalField.SUBTYPE_DECIMAL: return new DecimalField(f.name()); + case DoubleField.SUBTYPE_DOUBLE: return new DoubleField(f.name()); + case DateField.SUBTYPE_DATE: return new DateField(f.name()); + default: + throw new MetaDataException("report read model: derived field '" + f.name() + + "' has subtype field." + f.subType() + ", which Table B does not derive without a type source."); + } + } + + /** + * A detached copy of a source node: same node class, name and effective attrs, and + * nothing else (attrs only — the loaded node is never re-parented). + * + *

The copy is the model's ONLY source, and it is pinned to {@code @role: primary}: + * a runtime resolves an object's relation through its primary source, so this is what + * makes the read land on the selected source's physical name rather than on a default + * table name nobody declared.

+ */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private static MetaSource copySource(MetaSource source) { + MetaSource copy = (MetaSource) source.newInstanceFromClass( + (Class) source.getClass(), source.getType(), source.getSubType(), source.getName()); + // ADR-0039: resolving — the copy carries the source's effective configuration + // (@kind, the physical-name alias, @schema, @unmanaged, @sql). + for (MetaAttribute attr : (List) source.getMetaAttrs()) { + if (!MetaSource.ATTR_ROLE.equals(attr.getShortName())) copy.addMetaAttr(copyAttr(attr)); + } + copy.addMetaAttr(StringAttribute.create(MetaSource.ATTR_ROLE, MetaSource.ROLE_PRIMARY)); + return copy; + } + + /** A parentless copy of an attr node (same class, name and value). */ + private static MetaAttribute copyAttr(MetaAttribute attr) { + return (MetaAttribute) attr.clone(); + } +} 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 new file mode 100644 index 000000000..8133984c8 --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportShape.java @@ -0,0 +1,303 @@ +/* + * Copyright 2026 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.reporting; + +import com.metaobjects.MetaData; +import com.metaobjects.MetaDataException; +import com.metaobjects.MetaRoot; +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.MetaField; +import com.metaobjects.field.TimestampField; +import com.metaobjects.loader.ValidationPhase; +import com.metaobjects.object.MetaObject; +import com.metaobjects.reporting.ReportAccessors.ReportDimensionItem; +import com.metaobjects.source.MetaSource; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +/** + * A report's derived fields (FR-044, contract Table B): the read shape of an + * {@code object.report}, which declares no fields of its own. One field per + * {@code @dimensions} item in listed order, then one per {@code @measures} item in + * listed order. + * + *

The single definition in the JVM ports — the Kotlin generators consume this class + * rather than restating the table. Rule-for-rule the TypeScript {@code report-shape.ts}; + * gated by {@code fixtures/persistence-conformance/report-shapes.json}, which every port + * byte-matches. Every read is RESOLVING (ADR-0039) unless a comment says otherwise.

+ * + *

Pure metadata: nothing here touches a database, emits SQL (ADR-0015) or mutates the + * loaded tree.

+ */ +public final class ReportShape { + + /** Whether a derived field comes from a dimension or from a measure. */ + public enum Role { + DIMENSION("dimension"), + MEASURE("measure"); + + private final String wireName; + + Role(String wireName) { + this.wireName = wireName; + } + + /** The role as the shapes artifact spells it. */ + public String wireName() { + return wireName; + } + } + + /** + * One derived field. + * + * @param name the derived field name: the dimension name, {@code } + * for a time dimension, or the measure name. The physical column is the + * naming strategy applied to THIS name; an {@code @column} on the + * {@code @of} field is never inherited + * @param role dimension or measure + * @param subType a field subtype name ({@code long}, {@code decimal}, {@code date}, …) + * @param required whether the column can never be null + * @param typeSource the {@code @of} field whose type-shaping attrs the derived field + * carries ({@code @currency}, {@code @values}, {@code @intValueMap}, + * {@code @maxLength}, {@code @precision}, {@code @scale}, + * {@code @localTime}, {@code @objectRef}, {@code @storage}, + * {@code @dbColumnType} and array-ness), or {@code null} when it carries none + * @param dimension the dimension node, for a dimension field; else {@code null} + * @param grain the time grain, for a time dimension; else {@code null} + * @param measure the measure node, for a measure field; else {@code null} + */ + public record Field(String name, Role role, String subType, boolean required, MetaField typeSource, + MetaDimension dimension, String grain, MetaMeasure measure) { + + /** + * {@code .}, + * or {@code null} without a type source. The form the shapes artifact records. + */ + public String typeSourceKey() { + if (typeSource == null) return null; + // The parent of a field is the object that declares it — for a field the + // @of entity inherits through extends, that is the base, not the @of entity. + MetaData owner = typeSource.getParent(); + if (owner == null) { + throw new MetaDataException("field '" + typeSource.getName() + "' has no owning entity."); + } + return owner.getName() + SEP + typeSource.getName(); + } + } + + /** The member separator of an {@code Entity.field} reference. */ + private static final String SEP = "."; + + private static final Set SUM_LONG = Set.of(IntegerField.SUBTYPE_INT, LongField.SUBTYPE_LONG); + private static final Set FLOATING = Set.of(DoubleField.SUBTYPE_DOUBLE, FloatField.SUBTYPE_FLOAT); + + private final MetaObject report; + private final MetaObject from; + private final List fields; + + private ReportShape(MetaObject report, MetaObject from, List fields) { + this.report = report; + this.from = from; + this.fields = Collections.unmodifiableList(fields); + } + + /** The {@code object.report} node this shape was derived from. */ + public MetaObject report() { + return report; + } + + /** The {@code @from} entity. */ + public MetaObject from() { + return from; + } + + /** The derived fields, dimensions then measures, each in listed order. */ + public List fields() { + return fields; + } + + /** + * The physical name of the view the report is read from, or {@code null} when the + * report declares no read-only source (Table A: not lowered, not served). + */ + public String viewName() { + MetaSource source = readSource(report); + return source == null ? null : source.getPhysicalName(); + } + + /** + * The source a report is READ from: its own read-only source with {@code @role: primary}, + * else its first own read-only source; {@code null} when it declares none. + * + *

This is the rule that NAMES the lowered view in the TypeScript toolchain + * ({@code viewName} / {@code projectionViewSource}). It is restated here because no port + * but TypeScript lowers a report; the two must stay the same rule, or a runtime reads a + * relation the lowering did not create. For every model that loads, the primary branch + * fires (a report whose sources include no primary is refused at load); the fallback + * covers a tree built in code.

+ */ + public static MetaSource readSource(MetaObject report) { + MetaSource first = null; + // ADR-0039: own — source classification reads the sources the report declares + // ITSELF (getSources(false)), exactly as the lowering does. A report inherits no source. + for (MetaSource source : report.getSources(false)) { + if (!source.isReadOnly()) continue; + if (MetaSource.ROLE_PRIMARY.equals(source.getRole())) return source; + if (first == null) first = source; + } + return first; + } + + /** + * Table B for a report attached to a loaded model; the root is found from the report. + * + * @throws MetaDataException naming the report, when it is not under a root or a + * reference does not resolve + */ + public static ReportShape of(MetaObject report) { + return of(report, rootOf(report)); + } + + /** + * Table B. + * + * @param report an {@code object.report} + * @param root the model its references resolve in + * @throws MetaDataException naming the report, when a reference does not resolve (a + * report that passed the loader's reporting validation always resolves) + */ + public static ReportShape of(MetaObject report, MetaRoot root) { + String fromName = ReportAccessors.reportFrom(report); + if (fromName == null) throw unresolved(report, "@from"); + MetaObject from = ValidationPhase.resolveRootObject(root, fromName, packageOf(report)); + if (from == null) throw unresolved(report, "@from '" + fromName + "'"); + + List fields = new ArrayList<>(); + for (ReportDimensionItem item : ReportAccessors.reportDimensionItems(report)) { + fields.add(dimensionField(item, from, root, report)); + } + for (String name : ReportAccessors.reportMeasureNames(report)) { + fields.add(measureField(name, from, root, report)); + } + return new ReportShape(report, from, fields); + } + + /** + * Resolve a dimension's or measure's {@code Entity.field} reference to the field node, + * or {@code null}. A package qualifier uses {@code ::}, so the member separator is the + * LAST dot; the entity resolves relative to {@code owner}'s package (ADR-0042). + */ + public static MetaField resolveFieldRef(String ref, MetaObject owner, MetaRoot root) { + if (ref == null) return null; + int dot = ref.lastIndexOf(SEP); + if (dot <= 0) return null; + MetaObject entity = ValidationPhase.resolveRootObject(root, ref.substring(0, dot), packageOf(owner)); + if (entity == null) return null; + String fieldName = ref.substring(dot + SEP.length()); + // ADR-0039: resolving, so a field inherited through extends is found. + for (MetaField f : entity.getMetaFields()) { + if (fieldName.equals(f.getName())) return f; + } + return null; + } + + private static Field dimensionField(ReportDimensionItem item, MetaObject from, MetaRoot root, MetaObject report) { + MetaDimension dim = declaredMember(from, MetaDimension.class, item.name()); + if (dim == null) throw unresolved(report, "dimension '" + item.name() + "' on '" + from.getShortName() + "'"); + MetaField of = resolveFieldRef(dim.getOf(), from, root); + if (of == null) throw unresolved(report, "dimension '" + item.name() + "' @of"); + + String name = ReportAccessors.reportDerivedFieldName(item); + // Attr only: a validator.required child does not make the column non-null. + boolean required = dim.getVia() == null && ReportingAttrs.isTrue(of, MetaField.ATTR_REQUIRED); + if (dim.isTime()) { + String grain = item.grain(); + if (ReportingConstants.GRAIN_HOUR.equals(grain)) { + return new Field(name, Role.DIMENSION, TimestampField.SUBTYPE_TIMESTAMP, required, of, dim, grain, null); + } + // day / week / month / quarter / year: the first day of the bucket. + return new Field(name, Role.DIMENSION, DateField.SUBTYPE_DATE, required, null, dim, grain, null); + } + return new Field(name, Role.DIMENSION, of.getSubType(), required, of, dim, null, null); + } + + private static Field measureField(String name, MetaObject from, MetaRoot root, MetaObject report) { + MetaMeasure m = declaredMember(from, MetaMeasure.class, name); + if (m == null) throw unresolved(report, "measure '" + name + "' on '" + from.getShortName() + "'"); + if (m.isRatio()) { + return new Field(name, Role.MEASURE, DecimalField.SUBTYPE_DECIMAL, false, null, null, null, m); + } + String agg = m.getAgg(); + if (ReportingConstants.AGG_COUNT.equals(agg)) { + // A count is never null, with or without @distinct. + return new Field(name, Role.MEASURE, LongField.SUBTYPE_LONG, true, null, null, null, m); + } + List columns = m.getOfColumns(); + MetaField of = resolveFieldRef(columns.isEmpty() ? null : columns.get(0), from, root); + if (of == null) throw unresolved(report, "measure '" + name + "' @of"); + String src = of.getSubType(); + if (ReportingConstants.AGG_SUM.equals(agg)) { + if (CurrencyField.SUBTYPE_CURRENCY.equals(src)) { + return new Field(name, Role.MEASURE, CurrencyField.SUBTYPE_CURRENCY, false, of, null, null, m); + } + String subType = SUM_LONG.contains(src) ? LongField.SUBTYPE_LONG + : FLOATING.contains(src) ? DoubleField.SUBTYPE_DOUBLE + : DecimalField.SUBTYPE_DECIMAL; + return new Field(name, Role.MEASURE, subType, false, null, null, null, m); + } + if (ReportingConstants.AGG_AVG.equals(agg)) { + String subType = FLOATING.contains(src) ? DoubleField.SUBTYPE_DOUBLE : DecimalField.SUBTYPE_DECIMAL; + return new Field(name, Role.MEASURE, subType, false, null, null, null, m); + } + // min / max keep the source field's type. + return new Field(name, Role.MEASURE, src, false, of, null, null, m); + } + + private static T declaredMember(MetaObject from, Class type, String name) { + // ADR-0039: resolving children, so a member declared on an abstract base is found. + for (T member : from.getChildren(type, true)) { + if (name.equals(member.getShortName())) return member; + } + return null; + } + + /** The node's package for ADR-0042 bare-reference resolution. */ + private static String packageOf(MetaData node) { + return node.getPackage() == null ? "" : node.getPackage(); + } + + private static MetaRoot rootOf(MetaObject report) { + for (MetaData node = report.getParent(); node != null; node = node.getParent()) { + if (node instanceof MetaRoot) return (MetaRoot) node; + } + throw new MetaDataException("report '" + report.getShortName() + + "': is not attached to a model root; pass the root its references resolve in."); + } + + private static MetaDataException unresolved(MetaObject report, String what) { + return new MetaDataException("report '" + report.getShortName() + "': " + what + " does not resolve."); + } +} diff --git a/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportReadModelTest.java b/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportReadModelTest.java new file mode 100644 index 000000000..6aff5c5a5 --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportReadModelTest.java @@ -0,0 +1,310 @@ +/* + * Copyright 2026 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.reporting; + +import com.metaobjects.MetaData; +import com.metaobjects.MetaRoot; +import com.metaobjects.database.CoreDBMetaDataProvider; +import com.metaobjects.field.CurrencyField; +import com.metaobjects.field.EnumField; +import com.metaobjects.field.MetaField; +import com.metaobjects.io.json.CanonicalJsonSerializer; +import com.metaobjects.loader.LoaderOptions; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.loader.InMemoryStringSource; +import com.metaobjects.object.MetaObject; +import com.metaobjects.registry.SharedRegistryTestBase; +import com.metaobjects.source.MetaSource; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +/** + * FR-044 — {@link ReportReadModel}: the detached object a runtime reads a report through. + * Container-free; the database read is gated by the persistence-conformance lane. + */ +public class ReportReadModelTest extends SharedRegistryTestBase { + + private static MetaDataLoader canonicalLoader; + private static MetaRoot canonical; + + private static Path corpusDir() { + Path dir = Paths.get("").toAbsolutePath(); + while (dir != null) { + Path candidate = dir.resolve("fixtures/persistence-conformance"); + if (Files.isDirectory(candidate)) return candidate; + dir = dir.getParent(); + } + throw new AssertionError("fixtures/persistence-conformance not found"); + } + + @BeforeClass + public static void loadCanonical() { + canonicalLoader = MetaDataLoader.fromDirectory("report-read-model-test", corpusDir().resolve("canonical")); + canonical = canonicalLoader.getRoot(); + } + + /** Loads and REGISTERS (so the tree is frozen, as every production load is). */ + private static MetaRoot loadJson(String json) { + MetaDataLoader loader = new MetaDataLoader( + LoaderOptions.create(false, false, true), MetaDataLoader.SUBTYPE_MANUAL, "report-read-model-inline"); + loader.setSourceURIs(java.util.Collections.emptyList()); + loader.init(); + loader.load(List.of(new InMemoryStringSource(json, "meta.inline.json"))); + assertTrue("no load errors: " + loader.getErrors(), loader.getErrors().isEmpty()); + loader.register(); + return loader.getRoot(); + } + + private static MetaObject object(MetaRoot root, String name) { + // ADR-0039: own — the root's own children in declaration order (a root has no super). + for (MetaObject o : root.getChildren(MetaObject.class, false)) { + if (name.equals(o.getShortName())) return o; + } + throw new AssertionError("no object " + name); + } + + private static List fieldNames(MetaObject o) { + List names = new ArrayList<>(); + for (MetaField f : o.getMetaFields()) names.add(f.getName()); + return names; + } + + private static boolean required(MetaField f) { + return Boolean.TRUE.equals(f.getMetaAttr(MetaField.ATTR_REQUIRED).getValue()); + } + + // --------------------------------------------------------------------------- + // Shape + // --------------------------------------------------------------------------- + + @Test + public void carriesOneRealFieldPerTableBRowInOrder() { + ReportReadModel model = ReportReadModel.of(object(canonical, "ProgramMinutes"), canonical); + assertEquals( + List.of("program", "programTitle", "weeks", "longWeeks", "labels", "slots", "totalMinutes", + "avgMinutes", "minMinutes", "maxMinutes", "longShare"), + fieldNames(model)); + assertEquals("long", model.getMetaField("program").getSubType()); + assertEquals("string", model.getMetaField("programTitle").getSubType()); + assertEquals("long", model.getMetaField("weeks").getSubType()); + assertEquals("decimal", model.getMetaField("avgMinutes").getSubType()); + assertEquals("min keeps the @of field's subtype", "int", model.getMetaField("minMinutes").getSubType()); + assertEquals("decimal", model.getMetaField("longShare").getSubType()); + } + + @Test + public void requiredComesFromTheShapeNotFromTheTypeSource() { + ReportReadModel model = ReportReadModel.of(object(canonical, "ProgramMinutes"), canonical); + assertTrue(required(model.getMetaField("program"))); + assertFalse("reached by @via", required(model.getMetaField("programTitle"))); + assertTrue("a count is never null", required(model.getMetaField("weeks"))); + // Week.durationMinutes is @required, yet a min over it is nullable (an empty group). + assertFalse(required(model.getMetaField("minMinutes"))); + } + + @Test + public void keepsTheReportsNamePackageAndSubtypeAndHasNoIdentity() { + MetaObject report = object(canonical, "ProgramMinutes"); + ReportReadModel model = ReportReadModel.of(report, canonical); + assertEquals(report.getName(), model.getName()); + assertEquals("ProgramMinutes", model.getShortName()); + assertEquals("fitness", model.getPackage()); + assertEquals(MetaObject.SUBTYPE_REPORT, model.getSubType()); + assertTrue(ReportReadModel.isReport(model)); + assertTrue(ReportReadModel.isReport(report)); + assertFalse(ReportReadModel.isReport(object(canonical, "Week"))); + assertNull("a report has no primary key", model.getPrimaryIdentity()); + assertSame(report, model.report()); + assertTrue(model.isFrozen()); + } + + // --------------------------------------------------------------------------- + // Type-shaping attrs (Table B) + // --------------------------------------------------------------------------- + + @Test + public void anEnumDimensionCarriesItsValues() { + MetaField status = ReportReadModel.of(object(canonical, "ProgramsByMonth"), canonical).getMetaField("status"); + assertEquals("enum", status.getSubType()); + assertEquals( + object(canonical, "Program").getMetaField("status").getMetaAttr(EnumField.ATTR_VALUES).getValue(), + status.getMetaAttr(EnumField.ATTR_VALUES).getValue()); + } + + @Test + public void theHourBucketCarriesLocalTimeAndADateBucketCarriesNothing() { + ReportReadModel model = ReportReadModel.of(object(canonical, "AssetActivity"), canonical); + MetaField source = object(canonical, "Asset").getMetaField("recordedAt"); + MetaField hour = model.getMetaField("recordedAtHour"); + assertEquals("timestamp", hour.getSubType()); + assertEquals(source.hasMetaAttr(CoreDBMetaDataProvider.LOCAL_TIME), hour.hasMetaAttr(CoreDBMetaDataProvider.LOCAL_TIME)); + MetaField week = model.getMetaField("asOfDateWeek"); + assertEquals("date", week.getSubType()); + assertEquals("only @required", 1, week.getMetaAttrs().size()); + } + + private static final String SALES_MODEL = """ + { "metadata.root": { "package": "shop", "children": [ + { "object.entity": { "name": "Base", "abstract": true, "children": [ + { "field.currency": { "name": "amountCents", "@required": true, "@currency": "EUR" } } + ] } }, + { "object.entity": { "name": "Sale", "extends": "Base", "children": [ + { "source.rdb": { "@table": "sales" } }, + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "increment" } }, + { "field.string": { "name": "region", "@required": true, "@maxLength": 8, "@column": "region_code" } }, + { "field.string": { "name": "payload", "@dbColumnType": "jsonb" } }, + { "dimension.attribute": { "name": "region", "@of": "Sale.region" } }, + { "dimension.attribute": { "name": "payload", "@of": "Sale.payload" } }, + { "measure.aggregate": { "name": "revenue", "@agg": "sum", "@of": "Sale.amountCents" } }, + { "measure.aggregate": { "name": "sales", "@agg": "count", "@of": "Sale.id" } } + ] } }, + { "object.report": { "name": "SalesByRegion", "@from": "Sale", "@dimensions": ["region", "payload"], + "@measures": ["revenue", "sales"], "children": [ + { "source.rdb": { "name": "replica", "@kind": "view", "@view": "v_sales_replica", "@role": "replica" } }, + { "source.rdb": { "name": "main", "@kind": "view", "@view": "v_sales_by_region", "@schema": "rpt" } } + ] } }, + { "object.report": { "name": "UnmanagedSales", "@from": "Sale", "@measures": ["sales"], "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_hand_made", "@unmanaged": true } } + ] } }, + { "object.report": { "name": "InertSales", "@from": "Sale", "@measures": ["sales"] } } + ] } } + """; + + @Test + public void carriesOnlyTheTypeShapingAttrsAndNeverTheColumn() { + MetaRoot root = loadJson(SALES_MODEL); + ReportReadModel model = ReportReadModel.of(object(root, "SalesByRegion"), root); + + MetaField region = model.getMetaField("region"); + assertEquals("8", region.getMetaAttr(MetaField.ATTR_MAX_LENGTH).getValueAsString()); + assertFalse("@column on the @of field is never inherited: the column is the derived name", + region.hasMetaAttr(CoreDBMetaDataProvider.COLUMN)); + + MetaField revenue = model.getMetaField("revenue"); + assertEquals("currency", revenue.getSubType()); + assertEquals("a sum of currency carries @currency, here inherited through extends", + "EUR", revenue.getMetaAttr(CurrencyField.ATTR_CURRENCY).getValueAsString()); + assertFalse(required(revenue)); + + assertEquals("jsonb", model.getMetaField("payload").getMetaAttr(CoreDBMetaDataProvider.DB_COLUMN_TYPE).getValueAsString()); + } + + // --------------------------------------------------------------------------- + // The source + // --------------------------------------------------------------------------- + + @Test + public void theCanonicalReportsResolveTheirRelationToTheDeclaredView() { + assertEquals("v_program_minutes", ReportReadModel.of(object(canonical, "ProgramMinutes"), canonical).viewName()); + assertEquals("v_fitness_totals", ReportReadModel.of(object(canonical, "FitnessTotals"), canonical).viewName()); + assertEquals("v_asset_activity", ReportReadModel.of(object(canonical, "AssetActivity"), canonical).viewName()); + } + + @Test + public void aReplicaDeclaredBeforeThePrimaryViewIsNotTheOneRead() { + MetaRoot root = loadJson(SALES_MODEL); + MetaObject report = object(root, "SalesByRegion"); + ReportReadModel model = ReportReadModel.of(report, root); + + assertTrue(model.isServed()); + assertEquals("v_sales_by_region", model.viewName()); + List sources = new ArrayList<>(model.getSources()); + assertEquals("the copy is the model's only source", 1, sources.size()); + MetaSource copy = sources.get(0); + assertEquals(MetaSource.ROLE_PRIMARY, copy.getRole()); + assertEquals(MetaSource.KIND_VIEW, copy.getEffectiveKind()); + assertEquals("the source's other attrs are carried", "rpt", copy.getSchema()); + + // The loaded source is copied, never re-parented. + MetaSource declared = ReportShape.readSource(report); + assertNotSame(declared, copy); + assertSame(report, (MetaData) declared.getParent()); + assertSame(model, (MetaData) copy.getParent()); + // ADR-0039: own — counting the sources the report itself declares. + assertEquals(2, report.getSources(false).size()); + } + + @Test + public void anUnmanagedSourceIsCopiedLikeAnyOther() { + MetaRoot root = loadJson(SALES_MODEL); + ReportReadModel model = ReportReadModel.of(object(root, "UnmanagedSales"), root); + assertTrue(model.isServed()); + assertEquals("v_hand_made", model.viewName()); + assertTrue(model.getSources().iterator().next().isUnmanaged()); + } + + @Test + public void aSourcelessReportHasAShapeAndNoView() { + MetaRoot root = loadJson(SALES_MODEL); + ReportReadModel model = ReportReadModel.of(object(root, "InertSales"), root); + assertEquals(List.of("sales"), fieldNames(model)); + assertFalse(model.isServed()); + assertNull(model.viewName()); + assertTrue(model.getSources().isEmpty()); + } + + // --------------------------------------------------------------------------- + // Detached, and the loaded tree untouched + // --------------------------------------------------------------------------- + + @Test + public void isDetachedAndLeavesTheLoadedModelUntouched() { + String before = CanonicalJsonSerializer.canonicalSerialize(canonical); + int objectsBefore = canonicalLoader.getMetaObjects().size(); + int childrenBefore = canonical.getChildren().size(); + + List models = new ArrayList<>(); + // ADR-0039: own — the root's own children in declaration order (a root has no super). + for (MetaObject o : canonical.getChildren(MetaObject.class, false)) { + if (ReportReadModel.isReport(o)) models.add(ReportReadModel.of(o, canonical)); + } + assertEquals(6, models.size()); + + for (ReportReadModel model : models) { + assertNull("never attached", model.getParent()); + assertFalse(canonical.getChildren().contains(model)); + assertFalse(canonicalLoader.getMetaObjects().contains(model)); + assertTrue("the declared node still declares no fields", model.report().getMetaFields().isEmpty()); + } + assertEquals(objectsBefore, canonicalLoader.getMetaObjects().size()); + assertEquals(childrenBefore, canonical.getChildren().size()); + assertEquals(before, CanonicalJsonSerializer.canonicalSerialize(canonical)); + } + + @Test + public void isCachedPerReportNodeAndIdempotent() { + MetaObject report = object(canonical, "FitnessTotals"); + ReportReadModel model = ReportReadModel.of(report, canonical); + assertSame(model, ReportReadModel.of(report, canonical)); + assertSame(model, ReportReadModel.of(report)); + assertSame("a read model is its own read model", model, ReportReadModel.of(model)); + assertNotSame(model, ReportReadModel.of(object(canonical, "ProgramMinutes"), canonical)); + } +} diff --git a/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportShapeTest.java b/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportShapeTest.java new file mode 100644 index 000000000..c4c4ef3c4 --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportShapeTest.java @@ -0,0 +1,325 @@ +/* + * Copyright 2026 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.reporting; + +import com.metaobjects.MetaData; +import com.metaobjects.MetaDataException; +import com.metaobjects.MetaRoot; +import com.metaobjects.field.MetaField; +import com.metaobjects.loader.LoaderOptions; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.loader.InMemoryStringSource; +import com.metaobjects.object.MetaObject; +import com.metaobjects.registry.SharedRegistryTestBase; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * FR-044 — {@link ReportShape} (contract Table B) in the Java port. The gate is the + * byte-comparison with {@code fixtures/persistence-conformance/report-shapes.json}, the + * committed TypeScript-produced artifact every port derives from the same canonical model. + * Container-free: metadata in, JSON out. + */ +public class ReportShapeTest extends SharedRegistryTestBase { + + private static MetaRoot canonical; + + private static Path corpusDir() { + Path dir = Paths.get("").toAbsolutePath(); + while (dir != null) { + Path candidate = dir.resolve("fixtures/persistence-conformance"); + if (Files.isDirectory(candidate)) return candidate; + dir = dir.getParent(); + } + throw new AssertionError("fixtures/persistence-conformance not found"); + } + + @BeforeClass + public static void loadCanonical() { + canonical = MetaDataLoader.fromDirectory("report-shape-test", corpusDir().resolve("canonical")).getRoot(); + } + + private static MetaRoot loadJson(String json) { + MetaDataLoader loader = new MetaDataLoader( + LoaderOptions.create(false, false, true), MetaDataLoader.SUBTYPE_MANUAL, "report-shape-inline"); + loader.setSourceURIs(java.util.Collections.emptyList()); + loader.init(); + loader.load(List.of(new InMemoryStringSource(json, "meta.inline.json"))); + assertTrue("no load errors: " + loader.getErrors(), loader.getErrors().isEmpty()); + return loader.getRoot(); + } + + private static MetaObject object(MetaRoot root, String name) { + // ADR-0039: own — the root's own children in declaration order (a root has no super). + for (MetaObject o : root.getChildren(MetaObject.class, false)) { + if (name.equals(o.getShortName())) return o; + } + throw new AssertionError("no object " + name); + } + + private static ReportShape.Field field(ReportShape shape, String name) { + for (ReportShape.Field f : shape.fields()) { + if (name.equals(f.name())) return f; + } + throw new AssertionError("no derived field " + name); + } + + // --------------------------------------------------------------------------- + // The artifact — every port serialises the same bytes + // --------------------------------------------------------------------------- + + private static String quote(String s) { + return s == null ? "null" : "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + + /** The artifact's bytes for a loaded model: the format documented in the TypeScript + * generator ({@code integration-tests/src/gen-report-shapes.ts}). */ + private static String shapesJson(MetaRoot root) { + StringBuilder b = new StringBuilder("{\n \"reports\": ["); + boolean firstReport = true; + // ADR-0039: own — the root's own children in declaration order (a root has no super). + for (MetaObject report : root.getChildren(MetaObject.class, false)) { + if (!MetaObject.SUBTYPE_REPORT.equals(report.getSubType())) continue; + ReportShape shape = ReportShape.of(report, root); + b.append(firstReport ? "\n" : ",\n"); + firstReport = false; + b.append(" {\n"); + b.append(" \"report\": ").append(quote(report.getName())).append(",\n"); + b.append(" \"from\": ").append(quote(shape.from().getName())).append(",\n"); + b.append(" \"view\": ").append(quote(shape.viewName())).append(",\n"); + b.append(" \"fields\": ["); + boolean firstField = true; + for (ReportShape.Field f : shape.fields()) { + b.append(firstField ? "\n" : ",\n"); + firstField = false; + b.append(" {\n"); + b.append(" \"name\": ").append(quote(f.name())).append(",\n"); + b.append(" \"role\": ").append(quote(f.role().wireName())).append(",\n"); + b.append(" \"subType\": ").append(quote(f.subType())).append(",\n"); + b.append(" \"required\": ").append(f.required()).append(",\n"); + b.append(" \"typeSource\": ").append(quote(f.typeSourceKey())).append("\n"); + b.append(" }"); + } + b.append(firstField ? "]\n" : "\n ]\n"); + b.append(" }"); + } + b.append(firstReport ? "]\n" : "\n ]\n"); + return b.append("}\n").toString(); + } + + @Test + public void canonicalShapesByteMatchTheCommittedArtifact() throws IOException { + String expected = Files.readString(corpusDir().resolve("report-shapes.json"), StandardCharsets.UTF_8); + assertEquals(expected, shapesJson(canonical)); + } + + // --------------------------------------------------------------------------- + // Table B, row by row, on the canonical reports + // --------------------------------------------------------------------------- + + @Test + public void fieldsAreDimensionsThenMeasuresInListedOrder() { + ReportShape shape = ReportShape.of(object(canonical, "ProgramMinutes"), canonical); + assertEquals( + List.of("program", "programTitle", "weeks", "longWeeks", "labels", "slots", "totalMinutes", + "avgMinutes", "minMinutes", "maxMinutes", "longShare"), + shape.fields().stream().map(ReportShape.Field::name).collect(Collectors.toList())); + assertSame(object(canonical, "ProgramMinutes"), shape.report()); + assertSame(object(canonical, "Week"), shape.from()); + assertEquals("v_program_minutes", shape.viewName()); + } + + @Test + public void attributeDimensionKeepsTheOfFieldsSubtypeAndIsRequiredOnlyWithoutVia() { + ReportShape shape = ReportShape.of(object(canonical, "ProgramMinutes"), canonical); + + ReportShape.Field program = field(shape, "program"); + assertEquals(ReportShape.Role.DIMENSION, program.role()); + assertEquals("long", program.subType()); + assertTrue("no @via and a required @of field", program.required()); + assertEquals("fitness::Week.programId", program.typeSourceKey()); + assertNotNull(program.dimension()); + assertNull(program.grain()); + assertNull(program.measure()); + + ReportShape.Field title = field(shape, "programTitle"); + assertEquals("string", title.subType()); + assertFalse("reached by @via, so nullable", title.required()); + assertEquals("fitness::Program.title", title.typeSourceKey()); + } + + @Test + public void measureRowsOfTableB() { + ReportShape shape = ReportShape.of(object(canonical, "ProgramMinutes"), canonical); + + ReportShape.Field count = field(shape, "weeks"); + assertEquals(ReportShape.Role.MEASURE, count.role()); + assertEquals("long", count.subType()); + assertTrue("a count is never null", count.required()); + assertNull(count.typeSource()); + assertNotNull(count.measure()); + + assertEquals("long", field(shape, "labels").subType()); // count + @distinct + assertEquals("long", field(shape, "slots").subType()); // tuple count + assertEquals("long", field(shape, "totalMinutes").subType()); // sum of int + assertFalse(field(shape, "totalMinutes").required()); + assertNull(field(shape, "totalMinutes").typeSource()); + assertEquals("decimal", field(shape, "avgMinutes").subType()); + + ReportShape.Field min = field(shape, "minMinutes"); + assertEquals("min keeps the @of field's subtype", "int", min.subType()); + assertFalse(min.required()); + assertEquals("fitness::Week.durationMinutes", min.typeSourceKey()); + + ReportShape.Field ratio = field(shape, "longShare"); + assertEquals("decimal", ratio.subType()); + assertFalse(ratio.required()); + assertNull(ratio.typeSource()); + } + + @Test + public void timeDimensionIsNamedByGrainAndTypedByGrain() { + ReportShape byMonth = ReportShape.of(object(canonical, "ProgramsByMonth"), canonical); + ReportShape.Field month = byMonth.fields().get(0); + assertEquals("createdAtMonth", month.name()); + assertEquals("date", month.subType()); + assertEquals("month", month.grain()); + assertNull("a date bucket carries no type source", month.typeSource()); + + ReportShape activity = ReportShape.of(object(canonical, "AssetActivity"), canonical); + ReportShape.Field hour = field(activity, "recordedAtHour"); + assertEquals("timestamp", hour.subType()); + assertEquals("hour", hour.grain()); + assertEquals("the hour bucket carries @localTime from the @of field", + "fitness::Asset.recordedAt", hour.typeSourceKey()); + assertEquals("date", field(activity, "asOfDateWeek").subType()); + } + + @Test + public void aReportWithNoDimensionsHasOnlyMeasures() { + ReportShape shape = ReportShape.of(object(canonical, "FitnessTotals"), canonical); + assertEquals(List.of("weeks", "totalMinutes", "longShare"), + shape.fields().stream().map(ReportShape.Field::name).collect(Collectors.toList())); + for (ReportShape.Field f : shape.fields()) assertEquals(ReportShape.Role.MEASURE, f.role()); + } + + @Test + public void theRootIsFoundFromTheReportWhenNotPassed() { + MetaObject report = object(canonical, "ProgramsByWeek"); + assertEquals( + ReportShape.of(report, canonical).fields().stream().map(ReportShape.Field::name).collect(Collectors.toList()), + ReportShape.of(report).fields().stream().map(ReportShape.Field::name).collect(Collectors.toList())); + } + + // --------------------------------------------------------------------------- + // Rows the canonical model does not exercise + // --------------------------------------------------------------------------- + + private static final String SALES_MODEL = """ + { "metadata.root": { "package": "shop", "children": [ + { "object.entity": { "name": "Base", "abstract": true, "children": [ + { "field.currency": { "name": "amountCents", "@required": true, "@currency": "USD" } } + ] } }, + { "object.entity": { "name": "Sale", "extends": "Base", "children": [ + { "source.rdb": { "@table": "sales" } }, + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "increment" } }, + { "field.decimal": { "name": "weight", "@precision": 10, "@scale": 2 } }, + { "field.double": { "name": "score" } }, + { "field.float": { "name": "ratio" } }, + { "field.string": { "name": "region", "@required": true, "@maxLength": 8 } }, + { "dimension.attribute": { "name": "region", "@of": "Sale.region" } }, + { "measure.aggregate": { "name": "revenue", "@agg": "sum", "@of": "Sale.amountCents" } }, + { "measure.aggregate": { "name": "avgRevenue", "@agg": "avg", "@of": "Sale.amountCents" } }, + { "measure.aggregate": { "name": "totalWeight", "@agg": "sum", "@of": "Sale.weight" } }, + { "measure.aggregate": { "name": "avgWeight", "@agg": "avg", "@of": "Sale.weight" } }, + { "measure.aggregate": { "name": "totalScore", "@agg": "sum", "@of": "Sale.score" } }, + { "measure.aggregate": { "name": "avgScore", "@agg": "avg", "@of": "Sale.score" } }, + { "measure.aggregate": { "name": "totalRatio", "@agg": "sum", "@of": "Sale.ratio" } }, + { "measure.aggregate": { "name": "avgRatio", "@agg": "avg", "@of": "Sale.ratio" } }, + { "measure.aggregate": { "name": "maxRevenue", "@agg": "max", "@of": "Sale.amountCents" } } + ] } }, + { "object.report": { "name": "SalesByRegion", "@from": "Sale", "@dimensions": ["region"], + "@measures": ["revenue", "avgRevenue", "totalWeight", "avgWeight", "totalScore", "avgScore", + "totalRatio", "avgRatio", "maxRevenue"] } } + ] } } + """; + + @Test + public void sumAndAvgRowsByOfSubtype() { + MetaRoot root = loadJson(SALES_MODEL); + ReportShape shape = ReportShape.of(object(root, "SalesByRegion"), root); + + ReportShape.Field revenue = field(shape, "revenue"); + assertEquals("currency", revenue.subType()); + assertNotNull("sum of currency carries @currency from the @of field", revenue.typeSource()); + assertEquals("decimal", field(shape, "avgRevenue").subType()); + assertEquals("decimal", field(shape, "totalWeight").subType()); + assertEquals("decimal", field(shape, "avgWeight").subType()); + assertEquals("double", field(shape, "totalScore").subType()); + assertEquals("double", field(shape, "avgScore").subType()); + assertEquals("double", field(shape, "totalRatio").subType()); + assertEquals("double", field(shape, "avgRatio").subType()); + assertEquals("currency", field(shape, "maxRevenue").subType()); + assertTrue("no @via and a required @of field", field(shape, "region").required()); + assertNull("a sourceless report has no view", shape.viewName()); + } + + @Test + public void anInheritedOfFieldIsFoundAndItsTypeSourceNamesTheDeclaringEntity() { + MetaRoot root = loadJson(SALES_MODEL); + ReportShape.Field revenue = field(ReportShape.of(object(root, "SalesByRegion"), root), "revenue"); + MetaField typeSource = revenue.typeSource(); + assertEquals("amountCents", typeSource.getName()); + assertSame("the field lives on the base that declares it", object(root, "Base"), (MetaData) typeSource.getParent()); + assertEquals("shop::Base.amountCents", revenue.typeSourceKey()); + } + + @Test + public void anUnresolvedReferenceNamesTheReport() { + MetaRoot root = loadJson(SALES_MODEL); + // A report built in code (never added to the root), naming a measure that does not exist. + com.metaobjects.object.ReportMetaObject stray = new com.metaobjects.object.ReportMetaObject("shop::Stray"); + stray.addMetaAttr(com.metaobjects.attr.StringAttribute.create(MetaObject.ATTR_REPORT_FROM, "Sale")); + com.metaobjects.attr.StringArrayAttribute measures = + new com.metaobjects.attr.StringArrayAttribute(MetaObject.ATTR_REPORT_MEASURES); + measures.setValue(List.of("nope")); + stray.addMetaAttr(measures); + try { + ReportShape.of(stray, root); + fail("an unresolved measure must throw"); + } catch (MetaDataException e) { + assertTrue(e.getMessage(), e.getMessage().contains("report 'Stray'")); + assertTrue(e.getMessage(), e.getMessage().contains("measure 'nope'")); + } + } +} diff --git a/server/java/omdb/src/main/java/com/metaobjects/manager/db/ObjectManagerDB.java b/server/java/omdb/src/main/java/com/metaobjects/manager/db/ObjectManagerDB.java index 39dbd8d75..693d1ac53 100644 --- a/server/java/omdb/src/main/java/com/metaobjects/manager/db/ObjectManagerDB.java +++ b/server/java/omdb/src/main/java/com/metaobjects/manager/db/ObjectManagerDB.java @@ -18,6 +18,7 @@ import com.metaobjects.field.MetaField; import com.metaobjects.manager.StateAwareMetaObject; import com.metaobjects.object.MetaObject; +import com.metaobjects.reporting.ReportReadModel; import com.metaobjects.*; import com.metaobjects.manager.*; import com.metaobjects.manager.db.driver.*; @@ -255,10 +256,77 @@ protected ObjectMapping getCreateMapping(MetaObject mc) { * Gets the read mapping */ protected ObjectMapping getReadMapping(MetaObject mc) { + // FR-044: a declared report has no fields to map. It is mapped through its read + // model, and has no read mapping at all when it declares no view (not served). + if (isDeclaredReport(mc)) { + ReportReadModel model = ReportReadModel.of(mc); + return model.isServed() ? getReadMapping(model) : null; + } return readMappings.computeIfAbsent(mc, k -> Optional.ofNullable(getMappingHandler().getReadMapping(k))).orElse(null); } + /////////////////////////////////////////////////////// + // REPORTS (FR-044) + // + + /** True for an {@code object.report} node as loaded (not its read model). */ + private static boolean isDeclaredReport(MetaObject mc) { + return ReportReadModel.isReport(mc) && !(mc instanceof ReportReadModel); + } + + /** + * The object a READ is planned against. Every object but a report is returned + * unchanged. An {@code object.report} declares no fields — its read shape is derived + * from its dimensions and measures — so it is read through its detached + * {@link ReportReadModel}: ordinary fields (one per derived field) over the report's + * view. The column mapping, filter and sort resolution, instance construction and the + * read codecs then see nothing unusual. The model is never attached to the loaded tree. + * + *

Rows of a report are instances of the returned model: read their values through + * it ({@code readObjectFor(report).getMetaFields()}), not through the declared node, + * which has no fields.

+ * + * @throws PersistenceException when the report declares no read-only source: it has a + * shape and no view, so it is not served + */ + public MetaObject readObjectFor(MetaObject mc) { + if (!ReportReadModel.isReport(mc)) return mc; + ReportReadModel model; + try { + model = ReportReadModel.of(mc); + } catch (MetaDataException e) { + throw new PersistenceException("Report [" + mc.getName() + "] cannot be read: " + e.getMessage(), e); + } + if (!model.isServed()) { + throw new PersistenceException("Report [" + mc.getName() + "] is not served: it declares no" + + " read-only source, so it has no view to read"); + } + return model; + } + + /** + * Refuse an operation that needs an identity or writes. A report is a compiled view + * with no primary key: it is listed and counted, nothing else. Checked on the subtype, + * before any source or mapping check, so a write on a sourceless report is refused as + * read-only rather than as unserved. + */ + private static void requireNotReport(MetaObject mc, String operation) { + if (ReportReadModel.isReport(mc)) { + throw new PersistenceException(operation + " is not supported on [" + mc.getName() + + "]: a report is read-only and has no identity (read it with getObjects / getObjectsCount)"); + } + } + + /** + * Gets an object's reference. A report row has no identity, so it has no reference. + */ + @Override + public ObjectRef getObjectRef(Object obj) { + requireNotReport(getMetaObjectFor(obj), "getObjectRef"); + return super.getObjectRef(obj); + } + /** * Gets the update mapping */ @@ -373,6 +441,8 @@ public Object getObjectByRef(ObjectConnection c, String refStr) { ObjectRef ref = getObjectRef(refStr); MetaObject mc = ref.getMetaClass(); + requireNotReport(mc, "getObjectByRef"); + if (!isReadableClass(mc)) { throw new PersistenceException("MetaClass [" + mc + "] is not readable"); } @@ -489,6 +559,8 @@ private void requireInSubtypeScope(ObjectConnection c, MetaObject mc, Object obj @Override public int deleteObjects(ObjectConnection c, MetaObject mc, Expression exp) { + requireNotReport(mc, "deleteObjects"); + if (!isDeleteableClass(mc)) { throw new PersistenceException("MetaClass [" + mc + "] is not deletable"); } @@ -528,6 +600,8 @@ public int deleteObjects(ObjectConnection c, MetaObject mc, Expression exp) { */ @Override public long getObjectsCount(ObjectConnection c, MetaObject mc, Expression exp) throws MetaDataException { + mc = readObjectFor(mc); // FR-044: a report is counted through its read model + if (!isReadableClass(mc)) { throw new PersistenceException("MetaClass [" + mc + "] is not persistable"); } @@ -554,6 +628,8 @@ public long getObjectsCount(ObjectConnection c, MetaObject mc, Expression exp) t */ @Override public Collection getObjects(ObjectConnection c, MetaObject mc, QueryOptions options) throws MetaDataException { + mc = readObjectFor(mc); // FR-044: a report is read through its read model + if (!isReadableClass(mc)) { throw new PersistenceException("MetaClass [" + mc + "] is not persistable"); } @@ -605,6 +681,8 @@ public void loadObject(ObjectConnection c, Object o) throws MetaDataException { // Get the MetaClass for the object MetaObject mc = getMetaObjectFor(o); + requireNotReport(mc, "loadObject"); + // If it's not a readable class throw an exception if (!isReadableClass(mc)) { throw new PersistenceException("MetaClass [" + mc + "] is not persistable"); @@ -657,6 +735,8 @@ public void createObject(ObjectConnection c, Object obj) throws PersistenceExcep MetaObject mc = getMetaObjectFor(obj); + requireNotReport(mc, "createObject"); + if (!isCreateableClass(mc)) { throw new PersistenceException("Object of class [" + mc + "] is not createable"); } @@ -699,6 +779,7 @@ public void updateObject(ObjectConnection c, Object obj) throws PersistenceExcep // Get the metaclass and make sure it is updateable MetaObject mc = getMetaObjectFor(obj); + requireNotReport(mc, "updateObject"); if (!isUpdateableClass(mc)) { throw new PersistenceException("Object of class [" + mc + "] is not writeable"); } @@ -785,6 +866,8 @@ public void deleteObject(ObjectConnection c, Object obj) throws PersistenceExcep MetaObject mc = getMetaObjectFor(obj); + requireNotReport(mc, "deleteObject"); + if (!isDeleteableClass(mc)) { throw new PersistenceException("Object [" + obj + "] of class [" + mc + "] is not deleteable"); } @@ -1078,6 +1161,7 @@ public Collection executeQuery(ObjectConnection c, String query, Collection objects) throws MetaDataException { + requireNotReport(mc, "createObjectsBulk"); if (!isCreateableClass(mc)) { throw new PersistenceException("Object of class [" + mc + "] is not createable"); } @@ -1108,6 +1192,7 @@ public void createObjectsBulk(ObjectConnection c, MetaObject mc, Collection objects) throws MetaDataException { + requireNotReport(mc, "updateObjectsBulk"); if (!isUpdateableClass(mc)) { throw new PersistenceException("Object of class [" + mc + "] is not updateable"); } diff --git a/server/java/omdb/src/main/java/com/metaobjects/manager/db/SimpleMappingHandlerDB.java b/server/java/omdb/src/main/java/com/metaobjects/manager/db/SimpleMappingHandlerDB.java index 3e352ed8a..1081d99dd 100644 --- a/server/java/omdb/src/main/java/com/metaobjects/manager/db/SimpleMappingHandlerDB.java +++ b/server/java/omdb/src/main/java/com/metaobjects/manager/db/SimpleMappingHandlerDB.java @@ -9,6 +9,7 @@ import com.metaobjects.MetaDataNotFoundException; import com.metaobjects.database.CoreDBMetaDataProvider; import com.metaobjects.object.MetaObject; +import com.metaobjects.reporting.ReportReadModel; import com.metaobjects.MetaData; import com.metaobjects.MetaDataException; @@ -68,7 +69,14 @@ public ObjectMapping getCreateMapping( MetaObject mc ) { @Override public ObjectMapping getReadMapping(MetaObject mc) { - + + // FR-044: a declared report has no field children, so mapping it would yield a + // column-less SELECT. Refuse by name instead: a report is mapped through its read model. + if ( ReportReadModel.isReport( mc ) && !( mc instanceof ReportReadModel )) { + throw new MetaDataException( "Report [" + mc.getName() + "] has no fields to map: a report is read" + + " through its read model (ReportReadModel.of), not through the declared node" ); + } + // Try to get a view first String name = getViewRef( mc ); if ( name != null ) { @@ -475,6 +483,10 @@ private String getPersistenceAttribute( MetaData md, String ref ) { */ protected String getViewRef( MetaObject mc ) { + // FR-044: a report's read model names its view through the source's kind-matching + // alias (@view), the name the TypeScript lowering created the view under. Every other + // object keeps the @table read below, unchanged. + if ( mc instanceof ReportReadModel ) return ((ReportReadModel) mc).viewName(); return mc.getPrimaryRdbViewName(); } diff --git a/server/java/omdb/src/test/java/com/metaobjects/manager/db/ReportReadTest.java b/server/java/omdb/src/test/java/com/metaobjects/manager/db/ReportReadTest.java new file mode 100644 index 000000000..a208ee604 --- /dev/null +++ b/server/java/omdb/src/test/java/com/metaobjects/manager/db/ReportReadTest.java @@ -0,0 +1,501 @@ +/* + * Copyright 2026 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* + * FR-044 — OMDB reads a view-backed object.report: getObjects / getObjectsCount with + * filter, sort and range on derived fields; by-id and every write refused; a sourceless + * report not served; nothing changed for a non-report object. + * + * The views are created here by literal DDL. That is this test's fixture, not a port + * emitting SQL: in a real project the view comes from the TypeScript toolchain (ADR-0015). + */ +package com.metaobjects.manager.db; + +import com.metaobjects.MetaDataException; +import com.metaobjects.field.MetaField; +import com.metaobjects.io.json.CanonicalJsonSerializer; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.manager.ObjectConnection; +import com.metaobjects.manager.ObjectRef; +import com.metaobjects.manager.PersistenceException; +import com.metaobjects.manager.QueryOptions; +import com.metaobjects.manager.db.defs.BaseDef; +import com.metaobjects.manager.db.driver.DerbyDriver; +import com.metaobjects.manager.exp.Expression; +import com.metaobjects.manager.exp.Range; +import com.metaobjects.manager.exp.SortOrder; +import com.metaobjects.object.MetaObject; +import com.metaobjects.object.value.ValueObject; +import com.metaobjects.registry.MetaDataLoaderRegistry; +import com.metaobjects.registry.ServiceRegistryFactory; +import com.metaobjects.reporting.ReportReadModel; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.sql.DataSource; +import java.io.PrintWriter; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.SQLNonTransientConnectionException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class ReportReadTest { + + private static ObjectManagerDB omdb; + private static String dbFile; + private static MetaDataLoader loader; + private static MetaDataLoaderRegistry registry; + + @BeforeClass + public static void setupDB() throws Exception { + registry = new MetaDataLoaderRegistry(ServiceRegistryFactory.getDefault()); + loader = MetaDataLoader.fromResources("test-report", List.of("meta.report.json")); + registry.registerLoader(loader); + + dbFile = "omb-report-" + System.currentTimeMillis(); + Class.forName("org.apache.derby.jdbc.EmbeddedDriver"); + getConnection().close(); + + DataSource ds = new DataSource() { + @Override public Connection getConnection() throws SQLException { return ReportReadTest.getConnection(); } + @Override public Connection getConnection(String u, String p) throws SQLException { return getConnection(); } + @Override public PrintWriter getLogWriter() { return new PrintWriter(System.out); } + @Override public void setLogWriter(PrintWriter out) {} + @Override public void setLoginTimeout(int s) {} + @Override public int getLoginTimeout() { return 100; } + @Override public Logger getParentLogger() { throw new UnsupportedOperationException(); } + @Override public T unwrap(Class iface) { throw new UnsupportedOperationException(); } + @Override public boolean isWrapperFor(Class iface) { return false; } + }; + + omdb = new ObjectManagerDB() { + // A string reference names its object through service-discovered loaders, which a + // plain unit test does not have; resolve the name against this test's loader instead. + @Override + public ObjectRef getObjectRef(String refStr) { + String rest = refStr.substring("objectref://".length()); + int slash = rest.indexOf('/'); + return new ObjectRef(registry.findMetaObjectByName(rest.substring(0, slash)), + new String[] { rest.substring(slash + 1) }); + } + }; + omdb.setDatabaseDriver(new DerbyDriver()); + omdb.setDataSource(ds); + omdb.init(); + + try (Connection c = getConnection(); Statement s = c.createStatement()) { + s.execute("CREATE TABLE RPT_SALES (id BIGINT PRIMARY KEY, region VARCHAR(8) NOT NULL," + + " status INTEGER NOT NULL, amountCents BIGINT NOT NULL)"); + s.execute("INSERT INTO RPT_SALES VALUES (1, 'east', 1, 100), (2, 'east', 2, 300), (3, 'west', 1, 50)"); + s.execute("CREATE VIEW RPT_V_BY_REGION (region, sales, revenue, minAmount) AS" + + " SELECT region, COUNT(id), SUM(amountCents), MIN(amountCents) FROM RPT_SALES GROUP BY region"); + s.execute("CREATE VIEW RPT_V_BY_STATUS (status, sales) AS" + + " SELECT status, COUNT(id) FROM RPT_SALES GROUP BY status"); + s.execute("CREATE VIEW RPT_V_HAND_MADE (sales) AS SELECT COUNT(id) FROM RPT_SALES"); + s.execute("CREATE VIEW RPT_V_PRIMARY (sales) AS SELECT COUNT(id) FROM RPT_SALES"); + // Decoys: a read that lands on the replica, or on a default table name nobody + // declared, returns a value no real view produces. + s.execute("CREATE VIEW RPT_V_REPLICA (sales) AS SELECT COUNT(id) + 100 FROM RPT_SALES"); + s.execute("CREATE TABLE REPLICATED_SALES (sales BIGINT)"); + s.execute("INSERT INTO REPLICATED_SALES VALUES (999)"); + s.execute("CREATE TABLE INERT_SALES (sales BIGINT)"); + s.execute("INSERT INTO INERT_SALES VALUES (999)"); + } + } + + private static Connection getConnection() throws SQLException { + return DriverManager.getConnection("jdbc:derby:memory:" + dbFile + ";create=true"); + } + + @AfterClass + public static void teardown() throws Exception { + if (dbFile != null) { + try { DriverManager.getConnection("jdbc:derby:memory:" + dbFile + ";drop=true"); } + catch (SQLNonTransientConnectionException ignored) {} + } + if (loader != null) loader.destroy(); + } + + private static MetaObject object(String shortName) { + for (MetaObject mo : loader.getMetaObjects()) { + if (shortName.equals(mo.getShortName())) return mo; + } + throw new AssertionError("no object " + shortName); + } + + /** Read a report and flatten each row by the read model's fields, in field order. */ + private static List> read(String report, QueryOptions options) { + MetaObject declared = object(report); + ObjectConnection oc = omdb.getConnection(); + try { + List> rows = new ArrayList<>(); + for (Object o : omdb.getObjects(oc, declared, options)) { + Map row = new LinkedHashMap<>(); + for (MetaField f : omdb.readObjectFor(declared).getMetaFields()) { + row.put(f.getName(), f.getObject(o)); + } + rows.add(row); + } + return rows; + } finally { + omdb.releaseConnection(oc); + } + } + + private static long count(String report, Expression filter) { + ObjectConnection oc = omdb.getConnection(); + try { + return omdb.getObjectsCount(oc, object(report), filter); + } finally { + omdb.releaseConnection(oc); + } + } + + private static Map row(Object... keyValues) { + Map row = new LinkedHashMap<>(); + for (int i = 0; i < keyValues.length; i += 2) row.put((String) keyValues[i], keyValues[i + 1]); + return row; + } + + private static QueryOptions sortedBy(String field, int direction) { + QueryOptions options = new QueryOptions(); + options.setSortOrder(new SortOrder(field, direction)); + return options; + } + + private static Set columnsOf(ObjectMappingDB mapping) { + Set columns = new HashSet<>(); + mapping.getArguments().forEach(a -> columns.add(a.getName())); + return columns; + } + + private interface Op { + void run(ObjectConnection oc) throws Exception; + } + + /** Runs {@code op} and returns the PersistenceException it must throw. */ + private static PersistenceException refused(String what, Op op) throws Exception { + ObjectConnection oc = omdb.getConnection(); + try { + op.run(oc); + } catch (PersistenceException e) { + return e; + } finally { + omdb.releaseConnection(oc); + } + fail(what + " must be refused"); + return null; + } + + private static void assertReadOnlyNoIdentity(String operation, String report, PersistenceException e) { + String message = e.getMessage(); + assertTrue(message, message.startsWith(operation + " is not supported on [reporttest::" + report + "]")); + assertTrue(message, message.contains("a report is read-only and has no identity")); + } + + // --------------------------------------------------------------------------- + // getObjects / getObjectsCount on a view-backed report + // --------------------------------------------------------------------------- + + @Test + public void getObjectsReturnsTheViewsRowsKeyedByDerivedFieldName() { + List> rows = read("SalesByRegion", sortedBy("region", SortOrder.ASC)); + assertEquals(List.of( + row("region", "east", "sales", 2L, "revenue", 400L, "minAmount", 100L), + row("region", "west", "sales", 1L, "revenue", 50L, "minAmount", 50L)), rows); + } + + @Test + public void rowsAreInstancesOfTheReadModelNotOfTheDeclaredNode() { + MetaObject declared = object("SalesByRegion"); + ObjectConnection oc = omdb.getConnection(); + try { + Object first = omdb.getObjects(oc, declared, new QueryOptions()).iterator().next(); + MetaObject rowMeta = omdb.getMetaObjectFor(first); + assertTrue(rowMeta instanceof ReportReadModel); + assertSame(omdb.readObjectFor(declared), rowMeta); + assertSame("a read model is its own read object", rowMeta, omdb.readObjectFor(rowMeta)); + // The model can be handed back in directly. + assertEquals(2, omdb.getObjects(oc, rowMeta, new QueryOptions()).size()); + assertEquals(2L, omdb.getObjectsCount(oc, rowMeta, null)); + } finally { + omdb.releaseConnection(oc); + } + } + + @Test + public void filtersOnADimensionAndOnAMeasure() { + assertEquals(List.of(row("region", "west", "sales", 1L, "revenue", 50L, "minAmount", 50L)), + read("SalesByRegion", new QueryOptions(new Expression("region", "west")))); + assertEquals(List.of(row("region", "east", "sales", 2L, "revenue", 400L, "minAmount", 100L)), + read("SalesByRegion", new QueryOptions(new Expression("sales", 2L, Expression.EQUAL_GREATER)))); + assertTrue(read("SalesByRegion", new QueryOptions(new Expression("revenue", 1000L, Expression.GREATER))).isEmpty()); + } + + @Test + public void sortsOnAMeasureWithALimit() { + QueryOptions options = new QueryOptions(); + options.setSortOrder(new SortOrder("revenue", SortOrder.DESC)); + options.setRange(new Range(1, 1)); + List> rows = read("SalesByRegion", options); + assertEquals(1, rows.size()); + assertEquals("east", rows.get(0).get("region")); + + options.setSortOrder(new SortOrder("revenue", SortOrder.ASC)); + assertEquals("west", read("SalesByRegion", options).get(0).get("region")); + } + + @Test + public void countWorksWithAndWithoutAFilter() { + assertEquals(2L, count("SalesByRegion", null)); + assertEquals(1L, count("SalesByRegion", new Expression("sales", 2L, Expression.EQUAL_GREATER))); + assertEquals(0L, count("SalesByRegion", new Expression("region", "north"))); + } + + @Test + public void rowsAreDecodedByDerivedSubtype_anIntBackedEnumDimensionReadsAndFiltersAsItsSymbol() { + // The column holds 1 / 2; the derived field carries @values + @intValueMap from Sale.status. + assertEquals(List.of(row("status", "CLOSED", "sales", 1L), row("status", "OPEN", "sales", 2L)), + read("SalesByStatus", sortedBy("status", SortOrder.DESC))); + assertEquals(List.of(row("status", "OPEN", "sales", 2L)), + read("SalesByStatus", new QueryOptions(new Expression("status", "OPEN")))); + } + + @Test + public void anUnknownFieldInAReportFilterOrSortIsRefusedByName() { + try { + read("SalesByRegion", new QueryOptions(new Expression("amountCents", 1L))); + fail("a field of @from is not a field of the report"); + } catch (MetaDataException e) { + assertTrue(e.getMessage(), e.getMessage().contains("amountCents")); + } + try { + read("SalesByRegion", sortedBy("nope", SortOrder.ASC)); + fail("an unknown sort field must be refused"); + } catch (MetaDataException e) { + assertTrue(e.getMessage(), e.getMessage().contains("nope")); + } + } + + @Test + public void anUnmanagedReportIsStillRead() { + assertEquals(List.of(row("sales", 3L)), read("UnmanagedSales", new QueryOptions())); + assertEquals(1L, count("UnmanagedSales", null)); + } + + @Test + public void aReplicaDeclaredBeforeThePrimaryView_readsComeFromThePrimaryView() { + // RPT_V_REPLICA answers 103 and the REPLICATED_SALES fallback table answers 999. + assertEquals(List.of(row("sales", 3L)), read("ReplicatedSales", new QueryOptions())); + } + + // --------------------------------------------------------------------------- + // The mapping + // --------------------------------------------------------------------------- + + @Test + public void theMappingIsTheViewWithExactlyTheDerivedColumnsAndNoKeyColumn() { + ObjectMappingDB mapping = (ObjectMappingDB) omdb.getReadMapping(object("SalesByRegion")); + assertEquals("RPT_V_BY_REGION", ((BaseDef) mapping.getDBDef()).getNameDef().getName()); + // A mapping's arguments are unordered; the set is what is asserted. + assertEquals(Set.of("region", "sales", "revenue", "minAmount"), columnsOf(mapping)); + assertSame("the declared node and its model share one mapping", + mapping, omdb.getReadMapping(omdb.readObjectFor(object("SalesByRegion")))); + } + + @Test + public void theNamingStrategyAppliesToTheDerivedFieldName() { + SimpleMappingHandlerDB handler = new SimpleMappingHandlerDB(); + handler.setColumnNaming("snake_case"); + ObjectMappingDB mapping = (ObjectMappingDB) handler.getReadMapping(omdb.readObjectFor(object("SalesByRegion"))); + assertEquals(Set.of("region", "sales", "revenue", "min_amount"), columnsOf(mapping)); + } + + @Test + public void theDeclaredReportNodePassedStraightToTheMappingHandlerIsRefusedByName() { + try { + new SimpleMappingHandlerDB().getReadMapping(object("SalesByRegion")); + fail("a declared report has no fields to map"); + } catch (MetaDataException e) { + assertTrue(e.getMessage(), e.getMessage().contains("Report [reporttest::SalesByRegion] has no fields to map")); + } + } + + @Test + public void aReportIsReadableAndNothingElse() { + MetaObject report = object("SalesByRegion"); + assertTrue(omdb.isReadableClass(report)); + assertFalse(omdb.isCreateableClass(report)); + assertFalse(omdb.isUpdateableClass(report)); + assertFalse(omdb.isDeleteableClass(report)); + assertFalse("a sourceless report has no read mapping", omdb.isReadableClass(object("InertSales"))); + } + + // --------------------------------------------------------------------------- + // By-id and every write: read-only, no identity + // --------------------------------------------------------------------------- + + @Test + public void byIdAndEveryWriteOnAReportAreRefused_readOnlyNoIdentity() throws Exception { + MetaObject report = object("SalesByRegion"); + ObjectConnection reader = omdb.getConnection(); + Object row; + try { + row = omdb.getObjects(reader, report, new QueryOptions()).iterator().next(); + } finally { + omdb.releaseConnection(reader); + } + // A row read from the view (an instance of the read model) and one built from the declared node. + Object built = report.newInstance(); + + for (Object instance : List.of(row, built)) { + assertReadOnlyNoIdentity("createObject", "SalesByRegion", + refused("createObject", oc -> omdb.createObject(oc, instance))); + assertReadOnlyNoIdentity("updateObject", "SalesByRegion", + refused("updateObject", oc -> omdb.updateObject(oc, instance))); + assertReadOnlyNoIdentity("deleteObject", "SalesByRegion", + refused("deleteObject", oc -> omdb.deleteObject(oc, instance))); + assertReadOnlyNoIdentity("loadObject", "SalesByRegion", + refused("loadObject", oc -> omdb.loadObject(oc, instance))); + assertReadOnlyNoIdentity("getObjectRef", "SalesByRegion", + refused("getObjectRef", oc -> omdb.getObjectRef(instance))); + } + + assertReadOnlyNoIdentity("deleteObjects", "SalesByRegion", + refused("deleteObjects", oc -> omdb.deleteObjects(oc, report, new Expression("region", "east")))); + assertReadOnlyNoIdentity("createObjectsBulk", "SalesByRegion", + refused("createObjectsBulk", oc -> omdb.createObjectsBulk(oc, report, new ArrayList<>(List.of(built))))); + assertReadOnlyNoIdentity("updateObjectsBulk", "SalesByRegion", + refused("updateObjectsBulk", oc -> omdb.updateObjectsBulk(oc, report, new ArrayList<>(List.of(built))))); + assertReadOnlyNoIdentity("getObjectByRef", "SalesByRegion", + refused("getObjectByRef", oc -> omdb.getObjectByRef(oc, "objectref://reporttest::SalesByRegion/east"))); + + // Nothing was written: the view still answers what the seed rows produce. + assertEquals(2L, count("SalesByRegion", null)); + } + + // --------------------------------------------------------------------------- + // A sourceless report is not served + // --------------------------------------------------------------------------- + + @Test + public void aSourcelessReportIsNotServed() throws Exception { + MetaObject inert = object("InertSales"); + // INERT_SALES holds a decoy row: a fallback to a default table name would return it. + PersistenceException read = refused("getObjects", oc -> omdb.getObjects(oc, inert, new QueryOptions())); + assertTrue(read.getMessage(), read.getMessage().contains("Report [reporttest::InertSales] is not served")); + assertTrue(read.getMessage(), read.getMessage().contains("no view to read")); + PersistenceException counted = refused("getObjectsCount", oc -> omdb.getObjectsCount(oc, inert, null)); + assertTrue(counted.getMessage(), counted.getMessage().contains("is not served")); + } + + @Test + public void aWriteOnASourcelessReportIsRefusedAsReadOnlyNotAsUnserved() throws Exception { + MetaObject inert = object("InertSales"); + assertReadOnlyNoIdentity("createObject", "InertSales", + refused("createObject", oc -> omdb.createObject(oc, inert.newInstance()))); + assertReadOnlyNoIdentity("deleteObjects", "InertSales", + refused("deleteObjects", oc -> omdb.deleteObjects(oc, inert, null))); + } + + // --------------------------------------------------------------------------- + // Nothing else changes + // --------------------------------------------------------------------------- + + @Test + public void readingReportsLeavesTheLoadedModelUntouched() { + String before = CanonicalJsonSerializer.canonicalSerialize(loader.getRoot()); + int objects = loader.getMetaObjects().size(); + + read("SalesByRegion", new QueryOptions()); + read("SalesByStatus", new QueryOptions()); + read("UnmanagedSales", new QueryOptions()); + read("ReplicatedSales", new QueryOptions()); + count("SalesByRegion", null); + + assertEquals(objects, loader.getMetaObjects().size()); + assertEquals(before, CanonicalJsonSerializer.canonicalSerialize(loader.getRoot())); + assertTrue("the declared node still declares no fields", object("SalesByRegion").getMetaFields().isEmpty()); + assertFalse(loader.getMetaObjects().contains(omdb.readObjectFor(object("SalesByRegion")))); + } + + @Test + public void anEntityInTheSameModelIsReadAndWrittenAsBefore() throws Exception { + MetaObject sale = object("Sale"); + assertSame("a non-report object is its own read object", sale, omdb.readObjectFor(sale)); + assertTrue(omdb.isCreateableClass(sale)); + + ObjectConnection oc = omdb.getConnection(); + try { + ValueObject vo = (ValueObject) sale.newInstance(); + vo.setLong("id", 10L); + vo.setString("region", "north"); + vo.setString("status", "CLOSED"); + vo.setLong("amountCents", 700L); + omdb.createObject(oc, vo); + assertEquals(4L, omdb.getObjectsCount(oc, sale, null)); + + ObjectRef ref = omdb.getObjectRef(vo); + assertNotNull(ref); + ValueObject byRef = (ValueObject) omdb.getObjectByRef(oc, "objectref://reporttest::Sale/10"); + assertEquals("north", byRef.getString("region")); + + Collection found = omdb.getObjects(oc, sale, new QueryOptions(new Expression("id", 10L))); + assertEquals(1, found.size()); + ValueObject loaded = (ValueObject) found.iterator().next(); + assertSame(sale, omdb.getMetaObjectFor(loaded)); + assertEquals("CLOSED", loaded.getString("status")); + + loaded.setLong("amountCents", 800L); + omdb.updateObject(oc, loaded); + ValueObject reloaded = (ValueObject) sale.newInstance(); + reloaded.setLong("id", 10L); + omdb.loadObject(oc, reloaded); + assertEquals(Long.valueOf(800L), reloaded.getLong("amountCents")); + + // The report sees the entity's write through its view, and the entity's delete. + assertEquals(3L, omdb.getObjectsCount(oc, object("SalesByRegion"), null)); + omdb.deleteObject(oc, reloaded); + assertEquals(1, omdb.deleteObjects(oc, sale, new Expression("id", 3L))); + assertEquals(2L, omdb.getObjectsCount(oc, sale, null)); + vo = (ValueObject) sale.newInstance(); + vo.setLong("id", 3L); + vo.setString("region", "west"); + vo.setString("status", "OPEN"); + vo.setLong("amountCents", 50L); + omdb.createObject(oc, vo); // restore the seed row the other tests read + assertEquals(3L, omdb.getObjectsCount(oc, sale, null)); + } finally { + omdb.releaseConnection(oc); + } + } +} diff --git a/server/java/omdb/src/test/resources/meta.report.json b/server/java/omdb/src/test/resources/meta.report.json new file mode 100644 index 000000000..b47878f75 --- /dev/null +++ b/server/java/omdb/src/test/resources/meta.report.json @@ -0,0 +1,37 @@ +{ + "metadata.root": { + "package": "reporttest", + "children": [ + { "object.entity": { "name": "Sale", "children": [ + { "source.rdb": { "@table": "RPT_SALES" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "region", "@required": true, "@maxLength": 8 } }, + { "field.enum": { "name": "status", "@required": true, "@values": ["OPEN", "CLOSED"], + "@intValueMap": { "OPEN": 1, "CLOSED": 2 } } }, + { "field.long": { "name": "amountCents", "@required": true } }, + { "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "assigned" } }, + { "dimension.attribute": { "name": "region", "@of": "Sale.region" } }, + { "dimension.attribute": { "name": "status", "@of": "Sale.status" } }, + { "measure.aggregate": { "name": "sales", "@agg": "count", "@of": "Sale.id" } }, + { "measure.aggregate": { "name": "revenue", "@agg": "sum", "@of": "Sale.amountCents" } }, + { "measure.aggregate": { "name": "minAmount", "@agg": "min", "@of": "Sale.amountCents" } } + ] } }, + { "object.report": { "name": "SalesByRegion", "@from": "Sale", "@dimensions": ["region"], + "@measures": ["sales", "revenue", "minAmount"], "children": [ + { "source.rdb": { "@kind": "view", "@view": "RPT_V_BY_REGION" } } + ] } }, + { "object.report": { "name": "SalesByStatus", "@from": "Sale", "@dimensions": ["status"], + "@measures": ["sales"], "children": [ + { "source.rdb": { "@kind": "view", "@view": "RPT_V_BY_STATUS" } } + ] } }, + { "object.report": { "name": "UnmanagedSales", "@from": "Sale", "@measures": ["sales"], "children": [ + { "source.rdb": { "@kind": "view", "@view": "RPT_V_HAND_MADE", "@unmanaged": true } } + ] } }, + { "object.report": { "name": "ReplicatedSales", "@from": "Sale", "@measures": ["sales"], "children": [ + { "source.rdb": { "name": "replica", "@kind": "view", "@view": "RPT_V_REPLICA", "@role": "replica" } }, + { "source.rdb": { "name": "main", "@kind": "view", "@view": "RPT_V_PRIMARY" } } + ] } }, + { "object.report": { "name": "InertSales", "@from": "Sale", "@measures": ["sales"] } } + ] + } +} From 5dad12ba5add819c59691c9a0945831d8c6b2bf5 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 08:57:32 -0400 Subject: [PATCH 18/32] docs(reporting): teach reports in the authoring skill; document the lowering (FR-044) --- .claude/rules/cross-language-porting.md | 2 +- AGENTS.md | 2 +- CHANGELOG.md | 25 ++- README.md | 2 +- .../skills/metaobjects-authoring/SKILL.md | 70 ++++++++ .../references/reporting.md | 74 ++++++++ .../references/typescript-mysql.md | 13 +- docs/README.md | 2 +- docs/features/reporting.md | 169 ++++++++++++++++-- docs/recipes/mysql.md | 13 +- ...0-03-fr-044-plan-2-report-view-lowering.md | 6 +- .../skills/metaobjects-authoring/SKILL.md | 70 ++++++++ .../references/reporting.md | 74 ++++++++ .../skills/metaobjects-authoring/SKILL.md | 70 ++++++++ .../references/reporting.md | 74 ++++++++ .../skills/metaobjects-authoring/SKILL.md | 70 ++++++++ .../references/reporting.md | 74 ++++++++ .../skills/metaobjects-authoring/SKILL.md | 70 ++++++++ .../references/reporting.md | 74 ++++++++ .../references/typescript-mysql.md | 13 +- .../skills/metaobjects-authoring/SKILL.md | 70 ++++++++ .../references/reporting.md | 74 ++++++++ .../references/typescript-mysql.md | 13 +- fixtures/codegen-noop/reporting/README.md | 27 +-- fixtures/persistence-conformance/README.md | 6 +- .../cli/test/unit/reporting-inert.test.ts | 2 +- .../src/generators/agent-schema-page.ts | 2 +- .../packages/docs-site/src/coverage.ts | 4 +- 28 files changed, 1112 insertions(+), 53 deletions(-) create mode 100644 agent-context/skills/metaobjects-authoring/references/reporting.md create mode 100644 fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md create mode 100644 fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md create mode 100644 fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md create mode 100644 fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md create mode 100644 fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md diff --git a/.claude/rules/cross-language-porting.md b/.claude/rules/cross-language-porting.md index ae51970c5..1cbac9f4b 100644 --- a/.claude/rules/cross-language-porting.md +++ b/.claude/rules/cross-language-porting.md @@ -17,7 +17,7 @@ Preserve the following contracts exactly across all language ports: **Metamodel subtype vocabularies (must be identical across languages):** the `registry-conformance` gate (`fixtures/registry-conformance/`) is the structural enforcer of this rule — each port emits its registry as a canonical manifest byte-matched to `expected-registry.json`. **All five ports (TS / C# / Java / Kotlin / Python) are live + green** (SP-G Java/Kotlin reconciliation complete; the JVM runners compose from the defined metamodel provider set so codegen-base/om classpath SPI does not pollute the measured vocabulary). See `fixtures/registry-conformance/README.md`. - Filter operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `like`, `isNull` -- Object subtypes: `entity` (owns data: own identity, writable sources, lifecycle), `value` (pure shape: NO identity, NO source, ever; constructed — by caller/embedding — never populated; may `extends` entity fields for shape; a value-hosted field may carry `origin.passthrough` but never an assembly origin), `projection` (derived read-only representation: fields `extends`-bound / origin-derived / self-declared-under-external-assembly, all read-only at subtype level; identity optional and MUST extend an entity identity; sources restricted to read-only `@kind`s; the declared field set IS the exposure — inclusive list, fail-closed). A field carrying `origin.*` is derived ⇒ read-only wherever it lives (incl. on entities). An entity's primary source must be a writable `@kind` (read-only kinds only in read role). See [ADR-0028](spec/decisions/ADR-0028-object-taxonomy-projection-value-purity.md). (FR-024 Phase E — `object.projection`/`value` are registered in `expected-registry.json` and the projection/value validation passes [identity pass-through, value-purity, projection-licensing, `@via` inference/cardinality, extends/origin agreement, derived-field providability] are enforced cross-port in all 5 ports. The **B4b** entity-primary-source-readonly cutover [the "writable `@kind`" clause above — `ERR_ENTITY_PRIMARY_SOURCE_READONLY`] + the projection codegen fan-out (read-only DTOs for view-kind projections; FR-015 proc-callables for proc-kind projections in TypeScript, C# and Kotlin ONLY — Java and Python ship no callable generator at all, so the cross-port claim does NOT cover that clause; api-docs label `object.projection` units as `projection` and document their generated `Dto`) are now shipped cross-port; the remaining FR-024 work is the declared-API surface — tracked in #10.) **`report` (FR-044 Plan 1)** is a root object subtype registered in all five ports, with the `dimension.attribute` / `dimension.time` / `measure.aggregate` / `measure.ratio` / `segment.filter` children on `object.entity` and the relative-date filter value — loader-validated (`ERR_INVALID_DIMENSION` / `ERR_INVALID_MEASURE` / `ERR_INVALID_REPORT` / `ERR_REPORT_FOREIGN_MEASURE`, plus `ERR_BAD_ATTR_FILTER` for relative dates off a reporting host) and **inert in every generator and in `meta migrate`**: an `object.report` emits nothing until its lowering plans land, gated by the 27 `reporting` conformance fixtures and the `codegen-noop` corpus. See [docs/features/reporting.md](docs/features/reporting.md). +- Object subtypes: `entity` (owns data: own identity, writable sources, lifecycle), `value` (pure shape: NO identity, NO source, ever; constructed — by caller/embedding — never populated; may `extends` entity fields for shape; a value-hosted field may carry `origin.passthrough` but never an assembly origin), `projection` (derived read-only representation: fields `extends`-bound / origin-derived / self-declared-under-external-assembly, all read-only at subtype level; identity optional and MUST extend an entity identity; sources restricted to read-only `@kind`s; the declared field set IS the exposure — inclusive list, fail-closed). A field carrying `origin.*` is derived ⇒ read-only wherever it lives (incl. on entities). An entity's primary source must be a writable `@kind` (read-only kinds only in read role). See [ADR-0028](spec/decisions/ADR-0028-object-taxonomy-projection-value-purity.md). (FR-024 Phase E — `object.projection`/`value` are registered in `expected-registry.json` and the projection/value validation passes [identity pass-through, value-purity, projection-licensing, `@via` inference/cardinality, extends/origin agreement, derived-field providability] are enforced cross-port in all 5 ports. The **B4b** entity-primary-source-readonly cutover [the "writable `@kind`" clause above — `ERR_ENTITY_PRIMARY_SOURCE_READONLY`] + the projection codegen fan-out (read-only DTOs for view-kind projections; FR-015 proc-callables for proc-kind projections in TypeScript, C# and Kotlin ONLY — Java and Python ship no callable generator at all, so the cross-port claim does NOT cover that clause; api-docs label `object.projection` units as `projection` and document their generated `Dto`) are now shipped cross-port; the remaining FR-024 work is the declared-API surface — tracked in #10.) **`report` (FR-044 Plan 1)** is a root object subtype registered in all five ports, with the `dimension.attribute` / `dimension.time` / `measure.aggregate` / `measure.ratio` / `segment.filter` children on `object.entity` and the relative-date filter value — loader-validated (`ERR_INVALID_DIMENSION` / `ERR_INVALID_MEASURE` / `ERR_INVALID_REPORT` / `ERR_REPORT_FOREIGN_MEASURE`, plus `ERR_BAD_ATTR_FILTER` for relative dates off a reporting host) and, since FR-044 Plan 2, **lowered only when it declares a read-only `source.rdb` of `@kind: view`**: `meta migrate` creates that view (TypeScript only, ADR-0015), every port reads it (persistence corpus, `report-shapes.json`), C# and Kotlin generate its typed row, and everything else (routes, typed clients, filter allowlists, api-docs, and a report with no view source at all) stays inert, gated by the 27 `reporting` conformance fixtures and the `codegen-noop` corpus. See [docs/features/reporting.md](docs/features/reporting.md). - Source subtypes: `rdb` (paradigm; ADR-0007). The pre-v2 `dbTable`/`dbView` subtypes are RETIRED — `source.rdb` + `@kind: table|view|materializedView|storedProc|tableFunction` is the form, with read-only-ness derived from `@kind`. Multi-source via `@role` (exactly one `primary` per object). Source physical name = `@table` (NOT `@name`); field physical name = `@column` (renamed from `@dbColumn`). Referential actions on relationships: `@onDelete` / `@onUpdate`. - Origin subtypes: `passthrough`, `aggregate`, `collection`, `computed`, `first` (concrete; `base` is the abstract root). `passthrough` is legal on an `object.value`-hosted field (FR-015 parameter lineage); the four assembly origins (`aggregate`/`computed`/`collection`/`first`) live on `object.projection` only — a value-hosted assembly origin is `ERR_SUBTYPE_RULE_VIOLATION` (#210). - Relationship subtypes: `association`, `aggregation`, `composition`. Cardinality via `@cardinality: one|many`; target via `@objectRef`. **M:N (FR-018) slim vocabulary:** `@cardinality: "many"` + `@objectRef` (target) + `@through` (the junction/through entity — a third entity that MUST declare two `identity.reference` children, one per FK side). The relationship's FK fields are **derived** from those references (the `identity.reference` SSOT for FK direction), never restated. `@sourceRefField` (optional) disambiguates a *directed* self-join by naming the source-side FK field on the junction (the other reference is the target side); on a `@cardinality: one` relationship it instead names which of several `identity.reference` nodes onto the same target this relationship navigates, short-circuiting the unique-candidate/`@sourceRefField`/name-pairing ladder (#368, [ADR-0029](spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md) Amendment 1) — an unresolvable 1:N reference set is `ERR_INVALID_RELATIONSHIP` at load. `@symmetric` (optional boolean) marks an *undirected* self-join (union-on-read) — valid only when `@objectRef` == the declaring entity, and mutually exclusive with `@sourceRefField`. The pre-FR-018 `@joinEntity`/`@joinFields` attrs are REMOVED. Validation errors: symmetric-on-hetero / symmetric+sourceRefField → `ERR_BAD_ATTR_VALUE`; junction-missing-two-references / sourceRefField-not-matching / M:N-attr-on-1:N / **junction-unpairable** → `ERR_INVALID_RELATIONSHIP`. **Unpairable means the junction declares its two references but neither resolves to the navigating entity, or neither to the `@objectRef` target** — declaring two references is NOT enough, and the loader now checks WHAT they point at (owner ruling 2026-09-20). It does so by running the real FK derivation and converting its failure, never a parallel re-implementation, so the loader and the derivation cannot drift; scope mirrors codegen's own iteration exactly — every CONCRETE, non-projection object crossed with its EFFECTIVE relationships, NOT deduped by declaration, because pairing is a property of the navigating entity and an inherited M:N can pair from one subtype and not another. Before the ruling this loaded clean and then diverged: TS and C# warned and emitted no traversal route (a silent 404), while Java, Kotlin and Python failed the build. Gated by `fixtures/conformance/error-relationship-m2m-junction-unpairable/`. diff --git a/AGENTS.md b/AGENTS.md index 88d233c83..4ead16eb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,7 +82,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken. - YAML / verify corpora green across the ports that ship those layers. - **Codegen-compile gate** (all five ports; a GATE, not a corpus — it has no fixtures of its own and no row in the matrix). Every corpus above gates BEHAVIOUR; none asks whether the emitted code BUILDS, which is how four "generated code does not compile" defects shipped in 1.0.4 with the whole matrix green — `gen` exits 0 in all four cases and the adopter's build is the first thing that disagrees. Each port generates from `fixtures/persistence-conformance/canonical/meta.fitness.json` (reused deliberately: a second kitchen sink would drift from the one the other corpora already maintain) and compiles the emitted tree with its real compiler — `ts.createProgram` / Roslyn / `javac` / `KotlinCompilation` / (Python, having no static compiler) importing the generated package plus `ruff` F821. **Every port excludes its framework-bound route tier** (TS `routesFile`, C# `RoutesGenerator`, Java `SpringControllerGenerator`, Kotlin `KotlinSpringControllerGenerator`): those imports are not on an in-memory compile's classpath and stubbing them drowns the signal, so that tier is proven by the api-contract integration lane instead. One cross-port rule, not four local concessions. Found 5 further real defects on first run. Boundary detail: `docs/CONFORMANCE.md` → "Split coverage". -**Key cross-language features shipped:** FR5 family (a/b/c/d/e + WARN envelope-shape — actionable loader errors per ADR-0009); FR-003 (Java RDB runtime persistence + projections; schema migrations are TS-only — the Java migration engine was removed); FR-006 (template.output parser-on-receipt codegen per ADR-0010 in all 5 ports); FR-008 + FR-009 (cross-port REST API contract + the nine filter operators); FR-018 (M:N relationship codegen in all 5 ports — entity navigation + idiomatic ORM wiring [Drizzle m2m / EF Core `UsingEntity` / Spring repo+JPA / Exposed / Pydantic+route as the SQLAlchemy-secondary equivalent] + REST traversal `GET //{id}/` + Tier-2 docs, gated by the shared api-contract m2m corpus in both lanes + persistence-conformance; the TanStack M:N client hook is a deferred client-ergonomics follow-up); SP-H (field-subtype end-to-end hardening: every concrete `field.*` subtype write+read round-trips cross-port via the persistence `op: roundtrip` gate; cut `field.byte`/`field.short`/`field.class` non-functional stubs; cross-port filter-op reconciliation for uuid/currency); source v2 paradigm (ADR-0007); metadata-ktx Kotlin facade; per-target output directories (TS codegen); FR-044 Plan 1 (the reporting vocabulary — `dimension.attribute`/`dimension.time`, `measure.aggregate`/`measure.ratio`, `segment.filter`, `object.report`, plus the relative-date filter value — registered and loader-validated in all five ports behind 27 conformance fixtures + the `codegen-noop` corpus; **reports generate nothing yet**, see [docs/features/reporting.md](docs/features/reporting.md)). +**Key cross-language features shipped:** FR5 family (a/b/c/d/e + WARN envelope-shape — actionable loader errors per ADR-0009); FR-003 (Java RDB runtime persistence + projections; schema migrations are TS-only — the Java migration engine was removed); FR-006 (template.output parser-on-receipt codegen per ADR-0010 in all 5 ports); FR-008 + FR-009 (cross-port REST API contract + the nine filter operators); FR-018 (M:N relationship codegen in all 5 ports — entity navigation + idiomatic ORM wiring [Drizzle m2m / EF Core `UsingEntity` / Spring repo+JPA / Exposed / Pydantic+route as the SQLAlchemy-secondary equivalent] + REST traversal `GET //{id}/` + Tier-2 docs, gated by the shared api-contract m2m corpus in both lanes + persistence-conformance; the TanStack M:N client hook is a deferred client-ergonomics follow-up); SP-H (field-subtype end-to-end hardening: every concrete `field.*` subtype write+read round-trips cross-port via the persistence `op: roundtrip` gate; cut `field.byte`/`field.short`/`field.class` non-functional stubs; cross-port filter-op reconciliation for uuid/currency); source v2 paradigm (ADR-0007); metadata-ktx Kotlin facade; per-target output directories (TS codegen); FR-044 Plan 1 (the reporting vocabulary — `dimension.attribute`/`dimension.time`, `measure.aggregate`/`measure.ratio`, `segment.filter`, `object.report`, plus the relative-date filter value — registered and loader-validated in all five ports behind 27 conformance fixtures + the `codegen-noop` corpus; a report that declares a read-only `source.rdb @kind: view` is lowered to a SQL view by `meta migrate` and read by every port (Plan 2), and no route or typed client is generated for a report yet, see [docs/features/reporting.md](docs/features/reporting.md)). **Latest release: 1.0.13** (2026-10-03) — npm `1.0.13`, PyPI `1.0.13`, NuGet `1.0.13`, Maven Central `8.0.13`. A PATCH: an already-plural entity name (`Stats`, `Settings`) no longer doubles in API-surface names (REST paths, hooks, finders, DbSets) in any port, while default physical table names stay frozen on the old rule; two entities that would share one API name are now a generation error; two Kotlin controller compile fixes (`field.inet` filter ops, `@dbColumnType: uuid` on a string field). Gated by a private `1.0.13-rc.1` build on the adopter estate (`rc-gate.sh` 7/7) and a full `--strict-toolchains` local CI run. The previous release, 1.0.12 (2026-10-02), added `fmt` in every CLI, a deprecated-reference `verify` advisory, the `onLocate` extract hook, and an Exposed 1.x Kotlin output mode. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ea4555b8..ecf0eee79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,10 +27,27 @@ it until 1.1 ships._ `measure.aggregate` or an `object.report`. Four new error codes (`ERR_INVALID_DIMENSION`, `ERR_INVALID_MEASURE`, `ERR_INVALID_REPORT`, `ERR_REPORT_FOREIGN_MEASURE`) and extended `ERR_BAD_ATTR_FILTER` carry the load-time rules, gated by 27 new shared conformance fixtures. - `measure.derived` is not registered (it waits for FR-037 R5). **No generated output yet:** an - `object.report` emits no view DDL, route, client code or docs page, `meta migrate` proposes - nothing for it, and a model using the new names generates exactly what it did without them. - See [docs/features/reporting.md](docs/features/reporting.md). + `measure.derived` is not registered (it waits for FR-037 R5). A model that does not use the + new names generates exactly what it did without them; the next entry says what a report + becomes. See [docs/features/reporting.md](docs/features/reporting.md). +- **A report with a view source becomes a SQL view, and every port reads it (FR-044).** An + `object.report` that declares a read-only `source.rdb` of `@kind: view` is now lowered by + TypeScript: `meta migrate` creates the view on Postgres, SQLite and D1 (a changed report view + is dropped and re-created; a view-backed report whose `@from` entity has no table fails + migrate naming the report and the entity). MySQL SQL comes from `buildReportViews(root, + { dialect: "mysql" })` and the "Reports" section of `docs/recipes/mysql.md`, since `meta + migrate` does not target MySQL. A report with no `source.*` still generates nothing. Time + grains and relative dates are UTC, weeks start on Monday, a `sum` of nothing and a ratio over + zero are null, and a count is zero. The TypeScript `ObjectManager`, Java OMDB and the Python + `ObjectManager` read a view-backed report (list and count, with filter, sort and limit on the + derived fields; by-id and writes are refused); C# generates a keyless EF Core row type and + `DbContext` mapping for it and Kotlin an Exposed table object. `meta docs` lists the view on + the agent schema page. **Still absent:** no route, typed client, filter allowlist or api-docs + entry for a report in any port, no `measure.derived`, and no query-time grouping. Six shared + persistence scenarios (`report-*.yaml`) and `report-shapes.json` hold the ports to the same + columns; the `metaobjects-authoring` skill now teaches reports (`references/reporting.md`). + Anyone who declared a view-sourced report under the unreleased 1.1 vocabulary will now see a + `CREATE VIEW` from `meta migrate`. ### Fixed diff --git a/README.md b/README.md index 37ae799df..ede493bf2 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ first-week wedge plan — and `meta init` picks up from there. | Template-drift verify | Yes | Yes (`Verify.check`) | Yes (via Java) | Yes (`dotnet meta verify`) | Yes (`metaobjects.render.verify`) | | YAML authoring (sigil-free → JSON) | Yes | Yes | Yes (via Java) | Yes | Yes | | Capability requirements (`requirement.*`) | Registered + `meta verify` gate | Registered (loads + validates) | Registered (via Java) | Registered (loads + validates) | Registered (loads + validates) | -| Reporting vocabulary (`dimension` / `measure` / `segment` / `object.report`, FR-044) | Registered (loads + validates); no generated output yet | Registered (loads + validates); no generated output yet | Registered (via Java); no generated output yet | Registered (loads + validates); no generated output yet | Registered (loads + validates); no generated output yet | +| Reporting vocabulary (`dimension` / `measure` / `segment` / `object.report`, FR-044) | Registered; a view-backed report becomes a SQL view in `meta migrate` and is read by `ObjectManager`; no routes yet | Registered; OMDB reads a view-backed report; no generated output | Registered (via Java); generates an Exposed table object per view-backed report | Registered; generates a keyless EF Core row type per view-backed report | Registered; `ObjectManager` reads a view-backed report; no generated output | | Libraries (`libraries: [...]`) | Yes | Yes | Yes (via Java) | Yes | Yes | | Metadata dependencies (`dependencies`) | Yes (`meta deps sync`, `path` transport) | Phase 2 | Phase 2 | Phase 2 | Yes (loads the synced snapshot) | | Runtime metadata (ObjectManager-style) | Yes (`runtime-ts`) | Yes (OMDB) | Yes (via Java OMDB + Exposed) | Roadmap | Yes (ObjectManager) | diff --git a/agent-context/skills/metaobjects-authoring/SKILL.md b/agent-context/skills/metaobjects-authoring/SKILL.md index 6dbd5e4ed..26fb4cdc1 100644 --- a/agent-context/skills/metaobjects-authoring/SKILL.md +++ b/agent-context/skills/metaobjects-authoring/SKILL.md @@ -29,6 +29,7 @@ This file covers what almost every model needs. The topics below live in | `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view | | `references/inheritance-tph.md` | several entities are variants of one thing sharing a single table (`@discriminator`) | | `references/metadata-dependencies.md` | the project builds on another package's metadata (`dependencies`, cross-package `overlay`) | +| `references/reporting.md` | a dashboard number, count or total over one entity's rows: what columns a report gets, time grains, null rules, engine differences | | `references/requirements.md` | installed only when the project declares `requirement.*` nodes | ## The operating principle: model-first, generate-first @@ -677,6 +678,75 @@ Several variants of one thing sharing **one table**: the base `object.entity` de `@discriminatorValue`. Codegen emits per-subtype routes with the discriminator injected and immutable. Supported in all five ports; the worked example is in `references/inheritance-tph.md`. +## Reporting — dimensions, measures and reports + +Reach for it when a dashboard number would otherwise be a hand-written `GROUP BY`: revenue per +day, buyers per program, a total. You name the pieces once, on the entity that owns the rows, +and an `object.report` combines them by name. Four node kinds: + +- `dimension.attribute` / `dimension.time` — what to group by (`@of: Entity.field`; a time + dimension lists the `@grains` it supports: `hour`, `day`, `week`, `month`, `quarter`, `year`); +- `measure.aggregate` (`@agg`: `count`, `sum`, `avg`, `min`, `max`) and `measure.ratio` + (`@numerator` / `@denominator`, both measures of the entity); +- `segment.filter` — a named, reusable `@filter` ("active purchase"); +- `object.report` — a top-level object: `@from` an entity, `@dimensions` (`name` or + `name:grain`), `@measures`. + +```json +{ "metadata.root": { + "package": "acme::shop", + "children": [ + { "object.entity": { + "name": "Purchase", + "children": [ + { "source.rdb": { "@table": "purchases" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "status" } }, + { "field.currency": { "name": "amountCents" } }, + { "field.timestamp": { "name": "purchasedAt" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } }, + { "segment.filter": { "name": "active", "@filter": { "status": "active" } } }, + { "dimension.time": { "name": "purchasedAt", "@of": "Purchase.purchasedAt", + "@grains": ["day", "month"] } }, + { "measure.aggregate": { "name": "purchases", "@agg": "count", "@of": "Purchase.id", + "@segment": "active" } }, + { "measure.aggregate": { "name": "revenue", "@agg": "sum", "@of": "Purchase.amountCents" } } + ] + }}, + { "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": ["purchasedAt:day"], + "@measures": ["purchases", "revenue"], + "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_daily_revenue" } } + ] + }} + ] +}} +``` + +Three rules an author trips on: + +1. **Every measure belongs to `@from`.** A report cannot mix measures of two entities (joining + two fact tables multiplies each side's rows); two fact tables are two reports. +2. **`@via` is to-one only.** A dimension reaches a related entity's column through a + `relationship.*` with `@cardinality: one` (or an `identity.reference`), never through a + to-many, which would repeat fact rows and double-count a `sum`. +3. **A report declares no fields.** Its columns are derived: one per dimension, then one per + measure (a time dimension at a grain is ``, so `purchasedAt:day` is + `purchasedAtDay`). A `field.*` or `identity.*` child on a report is an error. + +**A report is served only when it declares `source.rdb` with `@kind: view`.** That declaration +is what makes `meta migrate` create the view (Postgres, SQLite, D1) and what every port's +runtime reads; a report with no `source.*` is checked at load and generates nothing. + +What does not exist: no REST route and no typed client for a report yet, no `measure.derived` +(arithmetic between measures beyond `measure.ratio`), no query-time choice of dimensions or +measures (a report is a fixed, compiled combination), and no time-zone vocabulary (grains and +relative dates are UTC). Column types, the null rules, Monday weeks and per-engine differences +are in `references/reporting.md`. + ## Requirements — capability ledger (opt-in) **This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in `metaobjects/` beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. diff --git a/agent-context/skills/metaobjects-authoring/references/reporting.md b/agent-context/skills/metaobjects-authoring/references/reporting.md new file mode 100644 index 000000000..9552bbf75 --- /dev/null +++ b/agent-context/skills/metaobjects-authoring/references/reporting.md @@ -0,0 +1,74 @@ +# Reporting: the columns a report gets, and how it behaves + +> Part of the `metaobjects-authoring` skill. The skill covers declaring dimensions, measures, segments and a report. Read this when you need to know what a report returns: its column names and types, the time-grain and null rules, what differs between databases, and what it leaves out. + +## A report is served only with a view source + +A report is a compiled view. The report's **own** read-only source decides what happens: + +| The report declares | Result | +|---|---| +| no `source.*` | Checked at load, nothing else. No view, no migrate statement, no runtime read (an `ObjectManager` refuses it as "not served"). | +| `source.rdb` with `@kind: view` | `meta migrate` creates the view (Postgres, SQLite, D1) under the source's `@view` name, and every port reads it. | +| the same, plus `@sql` | Your SQL is the view body. The columns below still define what is read. | +| the same, plus `@unmanaged: true` | `meta migrate` never creates or drops it; the runtime still reads it. | +| `@kind: materializedView`, `storedProc`, `tableFunction` | `meta migrate` skips it. | + +A view-backed report whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity. A changed report is dropped and re-created by `meta migrate`. + +## The columns you get + +A report declares no fields. Its columns are one per `@dimensions` item in listed order, then one per `@measures` item in listed order, named by the derived field name (your naming strategy applies to that name; an `@column` on the `@of` field is not inherited). A report has no primary key: read it with list and count (filter, sort and limit work on the derived columns); get-by-id and every write are refused. + +| Item | Column | Type | Never null? | +|---|---|---|---| +| `dimension.attribute` | the dimension's name | the `@of` field's type | only with no `@via` and an `@of` field with `@required: true` | +| `dimension.time` at `hour` | `Hour` | `timestamp` | same rule | +| `dimension.time` at `day`, `week`, `month`, `quarter`, `year` | `` | `date` (first day of the bucket) | same rule | +| `count`, with or without `@distinct` | the measure's name | `long` | yes | +| `sum` of `int` / `long` | the measure's name | `long` | no | +| `sum` of `currency` | the measure's name | `currency` (minor units) | no | +| `sum` of `decimal` | the measure's name | `decimal` | no | +| `sum` of `double` / `float` | the measure's name | `double` | no | +| `avg` of `int`, `long`, `currency`, `decimal` | the measure's name | `decimal` | no | +| `avg` of `double` / `float` | the measure's name | `double` | no | +| `min` / `max` | the measure's name | the `@of` field's type | no | +| `measure.ratio` | the measure's name | `decimal` | no | + +A column carries its `@of` field's type-shaping attributes (`@currency`, `@values`, `@precision`, `@scale`, `@localTime`, ...) and nothing else: no `@default`, no validators. + +## Time grains + +`hour, day, week, month, quarter, year`; `hour` is illegal on a `field.date`. **Weeks start on Monday (ISO-8601)** on every engine: Sunday 2026-05-17 falls in the week of 2026-05-11, and Monday 2026-06-01 opens its own week. + +**Bucketing is UTC.** A `field.timestamp` instant is bucketed in UTC whatever the reader's session time zone is, so every reader gets the same buckets. A `@localTime` timestamp and a `field.date` are bucketed as stored. There is no vocabulary for another time zone; do not look for one. + +## Relative dates + +A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 duration) is legal only on a `field.date` or `field.timestamp`, under `gt`, `gte`, `lt` or `lte`, and only in the `@filter` of a `segment`, a `measure.aggregate` or an `object.report`. It is evaluated when the view is **queried**, against the UTC clock. + +## Nulls and zeros + +- A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. +- A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. +- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. +- A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). + +## Joins: a dimension through a required reference drops rows + +A dimension reached by `@via` joins like a projection does: a required belongs-to foreign key joins `INNER`, anything else `LEFT OUTER`. So **a fact row whose required reference matches no row is left out of that report** (a dimension you do not list adds no join). That is the existing projection rule, not a reporting special case. + +## Engine differences + +| | Postgres | SQLite / D1 | MySQL | +|---|---|---|---| +| View created by | `meta migrate` | `meta migrate` | you: see below | +| A ratio or `avg` of `2` over `3` | `0.66666666666666666667` | `0.6666666666666666` | `0.6667` | +| `decimal` | `NUMERIC` | none: `avg`, a ratio and a `sum` of a decimal column are `REAL` | `DECIMAL` | +| Instants | `TIMESTAMPTZ` | ISO-8601 text | `DATETIME(3)`, read as the UTC wall clock | + +**MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body, and the MySQL guide in the `metaobjects-codegen` skill shows the loop. It skips a report whose source is `@unmanaged`. + +## What a report does not have + +No REST route, typed client, filter allowlist or api-docs entry is generated for a report in any port. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. diff --git a/agent-context/skills/metaobjects-codegen/references/typescript-mysql.md b/agent-context/skills/metaobjects-codegen/references/typescript-mysql.md index f1c315725..36be68c24 100644 --- a/agent-context/skills/metaobjects-codegen/references/typescript-mysql.md +++ b/agent-context/skills/metaobjects-codegen/references/typescript-mysql.md @@ -61,10 +61,11 @@ generated code and both runtimes quote identifiers themselves. ### Reports -An `object.report` (the reporting vocabulary, `docs/features/reporting.md`) is a compiled -view, and on MySQL you create that view yourself, because `meta migrate` does not. Declare -the report with a read-only `source.rdb` of `@kind: view` and no `@unmanaged`, since -`meta migrate` never targets MySQL and so nothing manages the view either way: +An `object.report` (the reporting vocabulary; see `references/reporting.md` in the +`metaobjects-authoring` skill) is a compiled view, and on MySQL you create that view +yourself, because `meta migrate` does not. Declare the report with a read-only +`source.rdb` of `@kind: view` and no `@unmanaged`, since `meta migrate` never targets MySQL +and so nothing manages the view either way: ```json { "source.rdb": { "@kind": "view", "@view": "v_program_minutes" } } @@ -86,6 +87,10 @@ for (const view of buildReportViews(root, { dialect: "mysql" })) { } ``` +The loop above ignores `view.schema` (on MySQL, the database a source's `@schema` names), so +each view is created in the connection's current database; qualify the name yourself if a +report's source declares `@schema`. + Pass `columnNamingStrategy` to match your tables' column names (the default is `snake_case`). The bodies are valid under MySQL's default `sql_mode`, `ONLY_FULL_GROUP_BY` included, and a change to a report means a new `CREATE OR REPLACE VIEW` (or `DROP` and `CREATE`) in your diff --git a/docs/README.md b/docs/README.md index 0268e0502..661720df0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -66,7 +66,7 @@ this tree is documentation, not the source of truth. | Build on a metadata model another repository publishes (`dependencies`, `meta deps sync`, overlay/extend across the boundary) | [`features/metadata-dependencies.md`](features/metadata-dependencies.md) | | Adopt a design MetaObjects already ships — users/groups/roles, an LLM trace envelope — instead of authoring it (`libraries`, `meta eject `) | [`features/libraries.md`](features/libraries.md) | | Record what the system is supposed to do, and stop agents reviving retired features | [`features/requirements.md`](features/requirements.md) | -| Declare what a dashboard groups by and counts (`dimension`, `measure`, `segment`, `object.report`; load-time checked, no generated output yet) | [`features/reporting.md`](features/reporting.md) | +| Declare what a dashboard groups by and counts (`dimension`, `measure`, `segment`, `object.report`; load-time checked; a view-backed report becomes a SQL view, no routes yet) | [`features/reporting.md`](features/reporting.md) | | Wire prompt construction (FR-004) | [`features/templates-and-payloads.md`](features/templates-and-payloads.md) | | Share a metadata shape across multiple instances (abstracts, `extends:`) | [`features/abstracts-and-inheritance.md`](features/abstracts-and-inheritance.md) | | Add a custom metamodel subtype or attribute to a downstream project | [`features/extending-with-providers.md`](features/extending-with-providers.md) + [`recipes/extending-metaobjects-with-providers.md`](recipes/extending-metaobjects-with-providers.md) | diff --git a/docs/features/reporting.md b/docs/features/reporting.md index c350784ff..fa975197f 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -6,13 +6,18 @@ and counts, as metadata, validated when the model loads._ **Status:** registered and loader-validated in all five ports (TypeScript, C#, Java, Python, Kotlin through Java). Arrived with **metamodel 1.1** (FR-044). -**Reports generate nothing yet.** This release ships the vocabulary and its load-time -rules, and no more. There is no view DDL, no `meta migrate` proposal, no REST route, no -generated client hook and no docs page for an `object.report`, and `meta docs` and the API -docs skip it. A model that declares dimensions, measures, segments and reports generates -byte-for-byte what the same model without them generates, in every port. Generated output -for reports lands in later plans of FR-044; until then the declarations are a checked -statement of intent that an agent or a person can read. +**What a report becomes.** A report that declares a read-only `source.rdb` of `@kind: view` +is **lowered to a SQL view**: `meta migrate` creates it (Postgres, SQLite and D1; MySQL SQL +comes from `buildReportViews`, see [MySQL](#mysql)), and every port reads it through its own +runtime. [What a report lowers to](#what-a-report-lowers-to) is the contract. A report with +no `source.*` stays inert: it is a checked statement of intent that generates nothing. + +**What does not exist yet.** There is no REST route, no typed client or hook, no filter +allowlist and no api-docs entry for a report in any port (the later plans of FR-044). There +is no `measure.derived`, no query-time choice of dimensions or measures (a report is a fixed +combination, compiled once), and no time-zone vocabulary: time grains and relative dates are +UTC. A model that declares none of this generates byte-for-byte what it did before, in every +port. **Entirely opt-in.** A model that declares none of this sees no change at all. @@ -109,7 +114,10 @@ A report is a top-level object that names an entity as its `@from`: { "object.report": { "name": "StoreTotals", "@from": "Purchase", - "@measures": ["purchases", "buyers", "revenue"] + "@measures": ["purchases", "buyers", "revenue"], + "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_store_totals" } } + ] }} ] }} @@ -150,6 +158,144 @@ order: one per dimension, then one per measure. A `@dimensions` item is a dimension name, or `name:grain` for a time dimension (a single colon, so it cannot collide with the `::` package separator). +`StoreTotals` above declares the source that makes it **served**; `DailyRevenue` declares +none, so it is checked at load and nothing more. A report is served only when it declares a +`source.rdb` with `@kind: view` (the next section says what that does). + +## What a report lowers to + +### Which reports lower + +The report's **own** read-only source decides. Dimensions, measures and segments are never +lowered alone. + +| The report declares | Result | +|---|---| +| no `source.*` | Inert: no view, no migrate statement, no runtime read. Reading it through an `ObjectManager` fails as "not served". | +| `source.rdb` with `@kind: view` | A derived view. `meta migrate` creates `CREATE VIEW `, where the name is the source's `@view` (or the legacy `@table`). | +| the same, plus `@sql` | Your SQL is the body, exactly as for a projection. The column shape below still defines what the runtime reads. | +| the same, plus `@unmanaged: true` | `meta migrate` never creates or drops it (you or a migration tool own the DDL), but the runtime still reads it through the shape below. | +| `@kind: materializedView`, `storedProc` or `tableFunction` | `meta migrate` skips it, as for a projection. | + +A view-backed report whose `@from` entity has no table (it is abstract, or declares no +writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity, +rather than emitting a view over a table that does not exist. + +### The columns you get + +A report has no primary key and declares no fields; its read shape is derived. One column +per `@dimensions` item in listed order, then one per `@measures` item in listed order. The +physical column name is your naming strategy applied to the **derived field name**; an +`@column` on the `@of` field is never inherited. + +| 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.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 | +| `sum` of `int` or `long` | the measure's name | `long` | no | +| `sum` of `currency` | the measure's name | `currency` (integer minor units, with the field's `@currency`) | no | +| `sum` of `decimal` | the measure's name | `decimal` | no | +| `sum` of `double` or `float` | the measure's name | `double` | no | +| `avg` of `int`, `long`, `currency` or `decimal` | the measure's name | `decimal` | no | +| `avg` of `double` or `float` | the measure's name | `double` | no | +| `min` / `max` | the measure's name | the `@of` field's type | no | +| `measure.ratio` | the measure's name | `decimal` | no | + +A derived column carries the type-shaping attributes of its `@of` field where they apply +(`@currency`, `@values`, `@intValueMap`, `@maxLength`, `@precision`, `@scale`, `@localTime`, +`@objectRef`, `@storage`, `@dbColumnType`, `isArray`) and nothing else: no `@column`, no +`@required` beyond the rule above, no `@default`, no validators. + +### Measures + +A measure's rows are the report's rows after its own `@segment` and `@filter` (ANDed) are +applied. The aggregates: + +- **`count`** counts the rows whose `@of` column is not null. On a non-null column that is + every row. With `@distinct` it counts distinct non-null values. A tuple (`@of` with several + items) counts distinct tuples, and a tuple with any null component is not counted, on every + engine. +- **`sum`** of nothing is **null**, not zero: a report with no matching rows, or a filtered + measure that matched none of a group's rows, shows null. A `sum` of an integer type is cast + so the column is a `BIGINT` on every engine. +- **`avg`, `min`, `max`** are the engine's own. +- **`measure.ratio`** is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**, + never an error. Each operand is repeated inline with its own conditions, so an operand need + not be listed in `@measures`. + +A report with no dimensions is one row over the whole table. Over an **empty** table that row +still exists: counts are `0`, sums and ratios are null. + +### Dimensions, time grains and joins + +- **`@via`** reaches a column of a to-one related entity, through a join. The join type is the + projection rule, unchanged: a required belongs-to foreign key joins `INNER`, anything else + `LEFT OUTER`, and an `INNER` survives only when every join above it is `INNER`. The + consequence to know: **a dimension reached through a required reference drops a fact row + whose reference matches no row, from that report.** A dimension that is not listed in + `@dimensions` adds no join. +- **Grains** are `hour, day, week, month, quarter, year`. A bucket is the first instant (for + `hour`) or first day (for the rest) of the period. **Weeks start on Monday (ISO-8601)** on + every engine: the week of Sunday 2026-05-17 starts 2026-05-11, and Monday 2026-06-01 starts + its own week. +- **UTC only.** A `field.timestamp` instant is bucketed in UTC whatever the reader's session + time zone is, so two readers get the same buckets. A `field.timestamp` with `@localTime` and + a `field.date` are bucketed as stored. There is no vocabulary for another zone. +- `GROUP BY` is every listed dimension, in `@dimensions` order. The report's `@segment` and + `@filter` are the `WHERE`: rows are scoped before grouping, and there is no `HAVING`. +- **Relative dates** in a view are evaluated when the view is **queried**, against the UTC + clock. A naive (`@localTime`) timestamp is compared with the UTC wall clock. + +### What the runtime does with it + +| Port | Read side | +|---|---| +| TypeScript | `ObjectManager` reads a view-backed report through a detached read model: `list` and `count`, with filter, sort and limit on the derived fields. | +| Java | OMDB, the same read. | +| Python | `ObjectManager`, the same read. | +| C# | codegen writes a keyless EF Core row class per view-backed report and maps it with `HasNoKey().ToView(...)` plus a `DbSet`. | +| Kotlin | codegen writes an Exposed table object per view-backed report. | + +By-id and every write are refused (a report has no identity and is read-only); a report with no +view source is refused as not served; an `@unmanaged` view-backed report is still read. C# also +refuses a report whose derived field name, in Pascal case, equals the report's own class name, +since the row class could not have a member named like itself. No port generates a route, +typed client, filter allowlist or api-docs entry for a report. + +`meta docs` lists a report's view on the agent schema page (`agent/schema.md`) and on no other +page. + +### What differs by engine + +| | Postgres | SQLite / D1 | MySQL | +|---|---|---|---| +| Created by | `meta migrate` | `meta migrate` | you (see below) | +| `avg` and ratio of `2` over `3` | `0.66666666666666666667` | `0.6666666666666666` | `0.6667` | +| `decimal` | `NUMERIC` | none: SQLite has no decimal, so `avg`, a ratio and a `sum` of a decimal column are `REAL` | `DECIMAL` | +| Instants and dates | `TIMESTAMPTZ`, `DATE` | ISO-8601 text (an hour bucket is `...:00:00.000Z`) | `DATETIME(3)` read as the UTC wall clock | + +A changed report is dropped and re-created by `meta migrate` (it does not `CREATE OR +REPLACE`, since the diff does not know the old column list). + +#### MySQL + +`meta migrate` never targets MySQL (ADR-0015), so on MySQL you create the view yourself. +`buildReportViews(root, { dialect: "mysql" })` from `@metaobjectsdev/codegen-ts` returns the +body of each view-backed report; the recipe in [`docs/recipes/mysql.md`](../recipes/mysql.md) +("Reports") shows the loop and its caveats. It skips a report whose source is `@unmanaged`, and +the bodies are valid under MySQL's default `ONLY_FULL_GROUP_BY`. + +### What the corpus gates + +Six shared scenarios under `fixtures/persistence-conformance/queries/report-*.yaml` read the +canonical reports through every port's runtime (list and count, filter, sort, an empty table, +the Monday boundary, an hour bucket, a relative window). The derived columns are pinned by +`fixtures/persistence-conformance/report-shapes.json`, produced by TypeScript and byte-matched +by every port. The SQL is produced by TypeScript only, so the other ports read the view the +TypeScript migrate engine produced and never lower a report themselves. + ## The rules the loader enforces Every rule below fails the load with the code shown, naming the offending node. Each one has @@ -238,8 +384,11 @@ of the four operators a relative date may sit under, so it is refused. The loader checks that the declarations are consistent with each other and with the model. It does not check that a measure means what its name says, that a segment's filter selects -the rows you intend, or that the data exists. And since a report generates nothing yet, a -passing load says nothing about any query: there is no query. +the rows you intend, or that the data exists. A green `meta migrate` proves the view was +created, not that its numbers are the ones you mean: a dimension reached through a required +reference leaves out the fact rows whose reference matches nothing, and a report's +`@filter` may select no rows at all. A report with no view source is still only checked at +load, and a passing load says nothing about a query against it: there is none. ## Compatibility diff --git a/docs/recipes/mysql.md b/docs/recipes/mysql.md index 09bb6e5bb..cb6533a07 100644 --- a/docs/recipes/mysql.md +++ b/docs/recipes/mysql.md @@ -79,10 +79,11 @@ generated code and both runtimes quote identifiers themselves. ### Reports -An `object.report` (the reporting vocabulary, `docs/features/reporting.md`) is a compiled -view, and on MySQL you create that view yourself, because `meta migrate` does not. Declare -the report with a read-only `source.rdb` of `@kind: view` and no `@unmanaged`, since -`meta migrate` never targets MySQL and so nothing manages the view either way: +An `object.report` (the reporting vocabulary; see `references/reporting.md` in the +`metaobjects-authoring` skill) is a compiled view, and on MySQL you create that view +yourself, because `meta migrate` does not. Declare the report with a read-only +`source.rdb` of `@kind: view` and no `@unmanaged`, since `meta migrate` never targets MySQL +and so nothing manages the view either way: ```json { "source.rdb": { "@kind": "view", "@view": "v_program_minutes" } } @@ -104,6 +105,10 @@ for (const view of buildReportViews(root, { dialect: "mysql" })) { } ``` +The loop above ignores `view.schema` (on MySQL, the database a source's `@schema` names), so +each view is created in the connection's current database; qualify the name yourself if a +report's source declares `@schema`. + Pass `columnNamingStrategy` to match your tables' column names (the default is `snake_case`). The bodies are valid under MySQL's default `sql_mode`, `ONLY_FULL_GROUP_BY` included, and a change to a report means a new `CREATE OR REPLACE VIEW` (or `DROP` and `CREATE`) in your diff --git a/docs/superpowers/plans/2026-10-03-fr-044-plan-2-report-view-lowering.md b/docs/superpowers/plans/2026-10-03-fr-044-plan-2-report-view-lowering.md index 183bfc30b..2ee6168a5 100644 --- a/docs/superpowers/plans/2026-10-03-fr-044-plan-2-report-view-lowering.md +++ b/docs/superpowers/plans/2026-10-03-fr-044-plan-2-report-view-lowering.md @@ -311,7 +311,7 @@ These shapes were run on the three engines with conditionally quoted identifiers | Gate | Path | Ports | |---|---|---| | Canonical reports (model) | `fixtures/persistence-conformance/canonical/meta.fitness.json` | all (shared input) | -| Report shapes artifact | `fixtures/persistence-conformance/canonical/report-shapes.json` (new, TS-produced, committed) | TS produces and drift-checks; C#, Java, Kotlin (through Java), Python byte-match their own derivation in a container-free unit test | +| Report shapes artifact | `fixtures/persistence-conformance/report-shapes.json` (new, TS-produced, committed) | TS produces and drift-checks; C#, Java, Kotlin (through Java), Python byte-match their own derivation in a container-free unit test | | Canonical schema | `fixtures/persistence-conformance/canonical/schema.postgres.sql` (regenerated: six views added) | all execute it | | `queries/report-grouped-measures.yaml` | every Table C row; an attribute dimension reached by `@via`; `filter`, `sort`, `count` on derived fields | all five | | `queries/report-totals.yaml` | no dimensions → one row; ratio | all five | @@ -480,7 +480,7 @@ The fixture's fields carry no `@required`, so every dimension is `required: fals ```ts // Table B of docs/superpowers/plans/2026-10-03-fr-044-plan-2-report-view-lowering.md: // a report's derived fields. The single definition; every port has a rule-for-rule copy, -// gated by fixtures/persistence-conformance/canonical/report-shapes.json. +// gated by fixtures/persistence-conformance/report-shapes.json. const SUM_LONG: ReadonlySet = new Set([FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG]); const FLOATING: ReadonlySet = new Set([FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT]); @@ -953,7 +953,7 @@ function literal(v: unknown, d: ReportDialect): string { - Modify: `server/typescript/packages/codegen-ts/src/index.ts` (export `buildReportViews` beside `buildProjectionViews` at line 270) - Modify: `fixtures/persistence-conformance/canonical/meta.fitness.json` - Regenerate: `fixtures/persistence-conformance/canonical/schema.postgres.sql` -- Create: `server/typescript/packages/integration-tests/src/gen-report-shapes.ts`, `fixtures/persistence-conformance/canonical/report-shapes.json` +- Create: `server/typescript/packages/integration-tests/src/gen-report-shapes.ts`, `fixtures/persistence-conformance/report-shapes.json` - Modify: `server/typescript/packages/integration-tests/package.json` (script `"gen:report-shapes": "bun run src/gen-report-shapes.ts"`) - Test: `server/typescript/packages/integration-tests/test/report-shapes-artifact.test.ts` (new), `test/schema-artifact.test.ts` (existing), `server/typescript/packages/codegen-ts/test/projection/build-projection-views.test.ts` (add cases) - Modify: `server/typescript/packages/cli/test/unit/reporting-inert.test.ts`, `fixtures/codegen-noop/reporting/README.md` 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 6dbd5e4ed..26fb4cdc1 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 @@ -29,6 +29,7 @@ This file covers what almost every model needs. The topics below live in | `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view | | `references/inheritance-tph.md` | several entities are variants of one thing sharing a single table (`@discriminator`) | | `references/metadata-dependencies.md` | the project builds on another package's metadata (`dependencies`, cross-package `overlay`) | +| `references/reporting.md` | a dashboard number, count or total over one entity's rows: what columns a report gets, time grains, null rules, engine differences | | `references/requirements.md` | installed only when the project declares `requirement.*` nodes | ## The operating principle: model-first, generate-first @@ -677,6 +678,75 @@ Several variants of one thing sharing **one table**: the base `object.entity` de `@discriminatorValue`. Codegen emits per-subtype routes with the discriminator injected and immutable. Supported in all five ports; the worked example is in `references/inheritance-tph.md`. +## Reporting — dimensions, measures and reports + +Reach for it when a dashboard number would otherwise be a hand-written `GROUP BY`: revenue per +day, buyers per program, a total. You name the pieces once, on the entity that owns the rows, +and an `object.report` combines them by name. Four node kinds: + +- `dimension.attribute` / `dimension.time` — what to group by (`@of: Entity.field`; a time + dimension lists the `@grains` it supports: `hour`, `day`, `week`, `month`, `quarter`, `year`); +- `measure.aggregate` (`@agg`: `count`, `sum`, `avg`, `min`, `max`) and `measure.ratio` + (`@numerator` / `@denominator`, both measures of the entity); +- `segment.filter` — a named, reusable `@filter` ("active purchase"); +- `object.report` — a top-level object: `@from` an entity, `@dimensions` (`name` or + `name:grain`), `@measures`. + +```json +{ "metadata.root": { + "package": "acme::shop", + "children": [ + { "object.entity": { + "name": "Purchase", + "children": [ + { "source.rdb": { "@table": "purchases" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "status" } }, + { "field.currency": { "name": "amountCents" } }, + { "field.timestamp": { "name": "purchasedAt" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } }, + { "segment.filter": { "name": "active", "@filter": { "status": "active" } } }, + { "dimension.time": { "name": "purchasedAt", "@of": "Purchase.purchasedAt", + "@grains": ["day", "month"] } }, + { "measure.aggregate": { "name": "purchases", "@agg": "count", "@of": "Purchase.id", + "@segment": "active" } }, + { "measure.aggregate": { "name": "revenue", "@agg": "sum", "@of": "Purchase.amountCents" } } + ] + }}, + { "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": ["purchasedAt:day"], + "@measures": ["purchases", "revenue"], + "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_daily_revenue" } } + ] + }} + ] +}} +``` + +Three rules an author trips on: + +1. **Every measure belongs to `@from`.** A report cannot mix measures of two entities (joining + two fact tables multiplies each side's rows); two fact tables are two reports. +2. **`@via` is to-one only.** A dimension reaches a related entity's column through a + `relationship.*` with `@cardinality: one` (or an `identity.reference`), never through a + to-many, which would repeat fact rows and double-count a `sum`. +3. **A report declares no fields.** Its columns are derived: one per dimension, then one per + measure (a time dimension at a grain is ``, so `purchasedAt:day` is + `purchasedAtDay`). A `field.*` or `identity.*` child on a report is an error. + +**A report is served only when it declares `source.rdb` with `@kind: view`.** That declaration +is what makes `meta migrate` create the view (Postgres, SQLite, D1) and what every port's +runtime reads; a report with no `source.*` is checked at load and generates nothing. + +What does not exist: no REST route and no typed client for a report yet, no `measure.derived` +(arithmetic between measures beyond `measure.ratio`), no query-time choice of dimensions or +measures (a report is a fixed, compiled combination), and no time-zone vocabulary (grains and +relative dates are UTC). Column types, the null rules, Monday weeks and per-engine differences +are in `references/reporting.md`. + ## Requirements — capability ledger (opt-in) **This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in `metaobjects/` beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md new file mode 100644 index 000000000..9552bbf75 --- /dev/null +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -0,0 +1,74 @@ +# Reporting: the columns a report gets, and how it behaves + +> Part of the `metaobjects-authoring` skill. The skill covers declaring dimensions, measures, segments and a report. Read this when you need to know what a report returns: its column names and types, the time-grain and null rules, what differs between databases, and what it leaves out. + +## A report is served only with a view source + +A report is a compiled view. The report's **own** read-only source decides what happens: + +| The report declares | Result | +|---|---| +| no `source.*` | Checked at load, nothing else. No view, no migrate statement, no runtime read (an `ObjectManager` refuses it as "not served"). | +| `source.rdb` with `@kind: view` | `meta migrate` creates the view (Postgres, SQLite, D1) under the source's `@view` name, and every port reads it. | +| the same, plus `@sql` | Your SQL is the view body. The columns below still define what is read. | +| the same, plus `@unmanaged: true` | `meta migrate` never creates or drops it; the runtime still reads it. | +| `@kind: materializedView`, `storedProc`, `tableFunction` | `meta migrate` skips it. | + +A view-backed report whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity. A changed report is dropped and re-created by `meta migrate`. + +## The columns you get + +A report declares no fields. Its columns are one per `@dimensions` item in listed order, then one per `@measures` item in listed order, named by the derived field name (your naming strategy applies to that name; an `@column` on the `@of` field is not inherited). A report has no primary key: read it with list and count (filter, sort and limit work on the derived columns); get-by-id and every write are refused. + +| Item | Column | Type | Never null? | +|---|---|---|---| +| `dimension.attribute` | the dimension's name | the `@of` field's type | only with no `@via` and an `@of` field with `@required: true` | +| `dimension.time` at `hour` | `Hour` | `timestamp` | same rule | +| `dimension.time` at `day`, `week`, `month`, `quarter`, `year` | `` | `date` (first day of the bucket) | same rule | +| `count`, with or without `@distinct` | the measure's name | `long` | yes | +| `sum` of `int` / `long` | the measure's name | `long` | no | +| `sum` of `currency` | the measure's name | `currency` (minor units) | no | +| `sum` of `decimal` | the measure's name | `decimal` | no | +| `sum` of `double` / `float` | the measure's name | `double` | no | +| `avg` of `int`, `long`, `currency`, `decimal` | the measure's name | `decimal` | no | +| `avg` of `double` / `float` | the measure's name | `double` | no | +| `min` / `max` | the measure's name | the `@of` field's type | no | +| `measure.ratio` | the measure's name | `decimal` | no | + +A column carries its `@of` field's type-shaping attributes (`@currency`, `@values`, `@precision`, `@scale`, `@localTime`, ...) and nothing else: no `@default`, no validators. + +## Time grains + +`hour, day, week, month, quarter, year`; `hour` is illegal on a `field.date`. **Weeks start on Monday (ISO-8601)** on every engine: Sunday 2026-05-17 falls in the week of 2026-05-11, and Monday 2026-06-01 opens its own week. + +**Bucketing is UTC.** A `field.timestamp` instant is bucketed in UTC whatever the reader's session time zone is, so every reader gets the same buckets. A `@localTime` timestamp and a `field.date` are bucketed as stored. There is no vocabulary for another time zone; do not look for one. + +## Relative dates + +A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 duration) is legal only on a `field.date` or `field.timestamp`, under `gt`, `gte`, `lt` or `lte`, and only in the `@filter` of a `segment`, a `measure.aggregate` or an `object.report`. It is evaluated when the view is **queried**, against the UTC clock. + +## Nulls and zeros + +- A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. +- A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. +- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. +- A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). + +## Joins: a dimension through a required reference drops rows + +A dimension reached by `@via` joins like a projection does: a required belongs-to foreign key joins `INNER`, anything else `LEFT OUTER`. So **a fact row whose required reference matches no row is left out of that report** (a dimension you do not list adds no join). That is the existing projection rule, not a reporting special case. + +## Engine differences + +| | Postgres | SQLite / D1 | MySQL | +|---|---|---|---| +| View created by | `meta migrate` | `meta migrate` | you: see below | +| A ratio or `avg` of `2` over `3` | `0.66666666666666666667` | `0.6666666666666666` | `0.6667` | +| `decimal` | `NUMERIC` | none: `avg`, a ratio and a `sum` of a decimal column are `REAL` | `DECIMAL` | +| Instants | `TIMESTAMPTZ` | ISO-8601 text | `DATETIME(3)`, read as the UTC wall clock | + +**MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body, and the MySQL guide in the `metaobjects-codegen` skill shows the loop. It skips a report whose source is `@unmanaged`. + +## What a report does not have + +No REST route, typed client, filter allowlist or api-docs entry is generated for a report in any port. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. 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 6dbd5e4ed..26fb4cdc1 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 @@ -29,6 +29,7 @@ This file covers what almost every model needs. The topics below live in | `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view | | `references/inheritance-tph.md` | several entities are variants of one thing sharing a single table (`@discriminator`) | | `references/metadata-dependencies.md` | the project builds on another package's metadata (`dependencies`, cross-package `overlay`) | +| `references/reporting.md` | a dashboard number, count or total over one entity's rows: what columns a report gets, time grains, null rules, engine differences | | `references/requirements.md` | installed only when the project declares `requirement.*` nodes | ## The operating principle: model-first, generate-first @@ -677,6 +678,75 @@ Several variants of one thing sharing **one table**: the base `object.entity` de `@discriminatorValue`. Codegen emits per-subtype routes with the discriminator injected and immutable. Supported in all five ports; the worked example is in `references/inheritance-tph.md`. +## Reporting — dimensions, measures and reports + +Reach for it when a dashboard number would otherwise be a hand-written `GROUP BY`: revenue per +day, buyers per program, a total. You name the pieces once, on the entity that owns the rows, +and an `object.report` combines them by name. Four node kinds: + +- `dimension.attribute` / `dimension.time` — what to group by (`@of: Entity.field`; a time + dimension lists the `@grains` it supports: `hour`, `day`, `week`, `month`, `quarter`, `year`); +- `measure.aggregate` (`@agg`: `count`, `sum`, `avg`, `min`, `max`) and `measure.ratio` + (`@numerator` / `@denominator`, both measures of the entity); +- `segment.filter` — a named, reusable `@filter` ("active purchase"); +- `object.report` — a top-level object: `@from` an entity, `@dimensions` (`name` or + `name:grain`), `@measures`. + +```json +{ "metadata.root": { + "package": "acme::shop", + "children": [ + { "object.entity": { + "name": "Purchase", + "children": [ + { "source.rdb": { "@table": "purchases" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "status" } }, + { "field.currency": { "name": "amountCents" } }, + { "field.timestamp": { "name": "purchasedAt" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } }, + { "segment.filter": { "name": "active", "@filter": { "status": "active" } } }, + { "dimension.time": { "name": "purchasedAt", "@of": "Purchase.purchasedAt", + "@grains": ["day", "month"] } }, + { "measure.aggregate": { "name": "purchases", "@agg": "count", "@of": "Purchase.id", + "@segment": "active" } }, + { "measure.aggregate": { "name": "revenue", "@agg": "sum", "@of": "Purchase.amountCents" } } + ] + }}, + { "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": ["purchasedAt:day"], + "@measures": ["purchases", "revenue"], + "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_daily_revenue" } } + ] + }} + ] +}} +``` + +Three rules an author trips on: + +1. **Every measure belongs to `@from`.** A report cannot mix measures of two entities (joining + two fact tables multiplies each side's rows); two fact tables are two reports. +2. **`@via` is to-one only.** A dimension reaches a related entity's column through a + `relationship.*` with `@cardinality: one` (or an `identity.reference`), never through a + to-many, which would repeat fact rows and double-count a `sum`. +3. **A report declares no fields.** Its columns are derived: one per dimension, then one per + measure (a time dimension at a grain is ``, so `purchasedAt:day` is + `purchasedAtDay`). A `field.*` or `identity.*` child on a report is an error. + +**A report is served only when it declares `source.rdb` with `@kind: view`.** That declaration +is what makes `meta migrate` create the view (Postgres, SQLite, D1) and what every port's +runtime reads; a report with no `source.*` is checked at load and generates nothing. + +What does not exist: no REST route and no typed client for a report yet, no `measure.derived` +(arithmetic between measures beyond `measure.ratio`), no query-time choice of dimensions or +measures (a report is a fixed, compiled combination), and no time-zone vocabulary (grains and +relative dates are UTC). Column types, the null rules, Monday weeks and per-engine differences +are in `references/reporting.md`. + ## Requirements — capability ledger (opt-in) **This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in `metaobjects/` beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md new file mode 100644 index 000000000..9552bbf75 --- /dev/null +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -0,0 +1,74 @@ +# Reporting: the columns a report gets, and how it behaves + +> Part of the `metaobjects-authoring` skill. The skill covers declaring dimensions, measures, segments and a report. Read this when you need to know what a report returns: its column names and types, the time-grain and null rules, what differs between databases, and what it leaves out. + +## A report is served only with a view source + +A report is a compiled view. The report's **own** read-only source decides what happens: + +| The report declares | Result | +|---|---| +| no `source.*` | Checked at load, nothing else. No view, no migrate statement, no runtime read (an `ObjectManager` refuses it as "not served"). | +| `source.rdb` with `@kind: view` | `meta migrate` creates the view (Postgres, SQLite, D1) under the source's `@view` name, and every port reads it. | +| the same, plus `@sql` | Your SQL is the view body. The columns below still define what is read. | +| the same, plus `@unmanaged: true` | `meta migrate` never creates or drops it; the runtime still reads it. | +| `@kind: materializedView`, `storedProc`, `tableFunction` | `meta migrate` skips it. | + +A view-backed report whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity. A changed report is dropped and re-created by `meta migrate`. + +## The columns you get + +A report declares no fields. Its columns are one per `@dimensions` item in listed order, then one per `@measures` item in listed order, named by the derived field name (your naming strategy applies to that name; an `@column` on the `@of` field is not inherited). A report has no primary key: read it with list and count (filter, sort and limit work on the derived columns); get-by-id and every write are refused. + +| Item | Column | Type | Never null? | +|---|---|---|---| +| `dimension.attribute` | the dimension's name | the `@of` field's type | only with no `@via` and an `@of` field with `@required: true` | +| `dimension.time` at `hour` | `Hour` | `timestamp` | same rule | +| `dimension.time` at `day`, `week`, `month`, `quarter`, `year` | `` | `date` (first day of the bucket) | same rule | +| `count`, with or without `@distinct` | the measure's name | `long` | yes | +| `sum` of `int` / `long` | the measure's name | `long` | no | +| `sum` of `currency` | the measure's name | `currency` (minor units) | no | +| `sum` of `decimal` | the measure's name | `decimal` | no | +| `sum` of `double` / `float` | the measure's name | `double` | no | +| `avg` of `int`, `long`, `currency`, `decimal` | the measure's name | `decimal` | no | +| `avg` of `double` / `float` | the measure's name | `double` | no | +| `min` / `max` | the measure's name | the `@of` field's type | no | +| `measure.ratio` | the measure's name | `decimal` | no | + +A column carries its `@of` field's type-shaping attributes (`@currency`, `@values`, `@precision`, `@scale`, `@localTime`, ...) and nothing else: no `@default`, no validators. + +## Time grains + +`hour, day, week, month, quarter, year`; `hour` is illegal on a `field.date`. **Weeks start on Monday (ISO-8601)** on every engine: Sunday 2026-05-17 falls in the week of 2026-05-11, and Monday 2026-06-01 opens its own week. + +**Bucketing is UTC.** A `field.timestamp` instant is bucketed in UTC whatever the reader's session time zone is, so every reader gets the same buckets. A `@localTime` timestamp and a `field.date` are bucketed as stored. There is no vocabulary for another time zone; do not look for one. + +## Relative dates + +A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 duration) is legal only on a `field.date` or `field.timestamp`, under `gt`, `gte`, `lt` or `lte`, and only in the `@filter` of a `segment`, a `measure.aggregate` or an `object.report`. It is evaluated when the view is **queried**, against the UTC clock. + +## Nulls and zeros + +- A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. +- A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. +- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. +- A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). + +## Joins: a dimension through a required reference drops rows + +A dimension reached by `@via` joins like a projection does: a required belongs-to foreign key joins `INNER`, anything else `LEFT OUTER`. So **a fact row whose required reference matches no row is left out of that report** (a dimension you do not list adds no join). That is the existing projection rule, not a reporting special case. + +## Engine differences + +| | Postgres | SQLite / D1 | MySQL | +|---|---|---|---| +| View created by | `meta migrate` | `meta migrate` | you: see below | +| A ratio or `avg` of `2` over `3` | `0.66666666666666666667` | `0.6666666666666666` | `0.6667` | +| `decimal` | `NUMERIC` | none: `avg`, a ratio and a `sum` of a decimal column are `REAL` | `DECIMAL` | +| Instants | `TIMESTAMPTZ` | ISO-8601 text | `DATETIME(3)`, read as the UTC wall clock | + +**MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body, and the MySQL guide in the `metaobjects-codegen` skill shows the loop. It skips a report whose source is `@unmanaged`. + +## What a report does not have + +No REST route, typed client, filter allowlist or api-docs entry is generated for a report in any port. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. 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 6dbd5e4ed..26fb4cdc1 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 @@ -29,6 +29,7 @@ This file covers what almost every model needs. The topics below live in | `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view | | `references/inheritance-tph.md` | several entities are variants of one thing sharing a single table (`@discriminator`) | | `references/metadata-dependencies.md` | the project builds on another package's metadata (`dependencies`, cross-package `overlay`) | +| `references/reporting.md` | a dashboard number, count or total over one entity's rows: what columns a report gets, time grains, null rules, engine differences | | `references/requirements.md` | installed only when the project declares `requirement.*` nodes | ## The operating principle: model-first, generate-first @@ -677,6 +678,75 @@ Several variants of one thing sharing **one table**: the base `object.entity` de `@discriminatorValue`. Codegen emits per-subtype routes with the discriminator injected and immutable. Supported in all five ports; the worked example is in `references/inheritance-tph.md`. +## Reporting — dimensions, measures and reports + +Reach for it when a dashboard number would otherwise be a hand-written `GROUP BY`: revenue per +day, buyers per program, a total. You name the pieces once, on the entity that owns the rows, +and an `object.report` combines them by name. Four node kinds: + +- `dimension.attribute` / `dimension.time` — what to group by (`@of: Entity.field`; a time + dimension lists the `@grains` it supports: `hour`, `day`, `week`, `month`, `quarter`, `year`); +- `measure.aggregate` (`@agg`: `count`, `sum`, `avg`, `min`, `max`) and `measure.ratio` + (`@numerator` / `@denominator`, both measures of the entity); +- `segment.filter` — a named, reusable `@filter` ("active purchase"); +- `object.report` — a top-level object: `@from` an entity, `@dimensions` (`name` or + `name:grain`), `@measures`. + +```json +{ "metadata.root": { + "package": "acme::shop", + "children": [ + { "object.entity": { + "name": "Purchase", + "children": [ + { "source.rdb": { "@table": "purchases" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "status" } }, + { "field.currency": { "name": "amountCents" } }, + { "field.timestamp": { "name": "purchasedAt" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } }, + { "segment.filter": { "name": "active", "@filter": { "status": "active" } } }, + { "dimension.time": { "name": "purchasedAt", "@of": "Purchase.purchasedAt", + "@grains": ["day", "month"] } }, + { "measure.aggregate": { "name": "purchases", "@agg": "count", "@of": "Purchase.id", + "@segment": "active" } }, + { "measure.aggregate": { "name": "revenue", "@agg": "sum", "@of": "Purchase.amountCents" } } + ] + }}, + { "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": ["purchasedAt:day"], + "@measures": ["purchases", "revenue"], + "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_daily_revenue" } } + ] + }} + ] +}} +``` + +Three rules an author trips on: + +1. **Every measure belongs to `@from`.** A report cannot mix measures of two entities (joining + two fact tables multiplies each side's rows); two fact tables are two reports. +2. **`@via` is to-one only.** A dimension reaches a related entity's column through a + `relationship.*` with `@cardinality: one` (or an `identity.reference`), never through a + to-many, which would repeat fact rows and double-count a `sum`. +3. **A report declares no fields.** Its columns are derived: one per dimension, then one per + measure (a time dimension at a grain is ``, so `purchasedAt:day` is + `purchasedAtDay`). A `field.*` or `identity.*` child on a report is an error. + +**A report is served only when it declares `source.rdb` with `@kind: view`.** That declaration +is what makes `meta migrate` create the view (Postgres, SQLite, D1) and what every port's +runtime reads; a report with no `source.*` is checked at load and generates nothing. + +What does not exist: no REST route and no typed client for a report yet, no `measure.derived` +(arithmetic between measures beyond `measure.ratio`), no query-time choice of dimensions or +measures (a report is a fixed, compiled combination), and no time-zone vocabulary (grains and +relative dates are UTC). Column types, the null rules, Monday weeks and per-engine differences +are in `references/reporting.md`. + ## Requirements — capability ledger (opt-in) **This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in `metaobjects/` beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md new file mode 100644 index 000000000..9552bbf75 --- /dev/null +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -0,0 +1,74 @@ +# Reporting: the columns a report gets, and how it behaves + +> Part of the `metaobjects-authoring` skill. The skill covers declaring dimensions, measures, segments and a report. Read this when you need to know what a report returns: its column names and types, the time-grain and null rules, what differs between databases, and what it leaves out. + +## A report is served only with a view source + +A report is a compiled view. The report's **own** read-only source decides what happens: + +| The report declares | Result | +|---|---| +| no `source.*` | Checked at load, nothing else. No view, no migrate statement, no runtime read (an `ObjectManager` refuses it as "not served"). | +| `source.rdb` with `@kind: view` | `meta migrate` creates the view (Postgres, SQLite, D1) under the source's `@view` name, and every port reads it. | +| the same, plus `@sql` | Your SQL is the view body. The columns below still define what is read. | +| the same, plus `@unmanaged: true` | `meta migrate` never creates or drops it; the runtime still reads it. | +| `@kind: materializedView`, `storedProc`, `tableFunction` | `meta migrate` skips it. | + +A view-backed report whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity. A changed report is dropped and re-created by `meta migrate`. + +## The columns you get + +A report declares no fields. Its columns are one per `@dimensions` item in listed order, then one per `@measures` item in listed order, named by the derived field name (your naming strategy applies to that name; an `@column` on the `@of` field is not inherited). A report has no primary key: read it with list and count (filter, sort and limit work on the derived columns); get-by-id and every write are refused. + +| Item | Column | Type | Never null? | +|---|---|---|---| +| `dimension.attribute` | the dimension's name | the `@of` field's type | only with no `@via` and an `@of` field with `@required: true` | +| `dimension.time` at `hour` | `Hour` | `timestamp` | same rule | +| `dimension.time` at `day`, `week`, `month`, `quarter`, `year` | `` | `date` (first day of the bucket) | same rule | +| `count`, with or without `@distinct` | the measure's name | `long` | yes | +| `sum` of `int` / `long` | the measure's name | `long` | no | +| `sum` of `currency` | the measure's name | `currency` (minor units) | no | +| `sum` of `decimal` | the measure's name | `decimal` | no | +| `sum` of `double` / `float` | the measure's name | `double` | no | +| `avg` of `int`, `long`, `currency`, `decimal` | the measure's name | `decimal` | no | +| `avg` of `double` / `float` | the measure's name | `double` | no | +| `min` / `max` | the measure's name | the `@of` field's type | no | +| `measure.ratio` | the measure's name | `decimal` | no | + +A column carries its `@of` field's type-shaping attributes (`@currency`, `@values`, `@precision`, `@scale`, `@localTime`, ...) and nothing else: no `@default`, no validators. + +## Time grains + +`hour, day, week, month, quarter, year`; `hour` is illegal on a `field.date`. **Weeks start on Monday (ISO-8601)** on every engine: Sunday 2026-05-17 falls in the week of 2026-05-11, and Monday 2026-06-01 opens its own week. + +**Bucketing is UTC.** A `field.timestamp` instant is bucketed in UTC whatever the reader's session time zone is, so every reader gets the same buckets. A `@localTime` timestamp and a `field.date` are bucketed as stored. There is no vocabulary for another time zone; do not look for one. + +## Relative dates + +A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 duration) is legal only on a `field.date` or `field.timestamp`, under `gt`, `gte`, `lt` or `lte`, and only in the `@filter` of a `segment`, a `measure.aggregate` or an `object.report`. It is evaluated when the view is **queried**, against the UTC clock. + +## Nulls and zeros + +- A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. +- A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. +- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. +- A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). + +## Joins: a dimension through a required reference drops rows + +A dimension reached by `@via` joins like a projection does: a required belongs-to foreign key joins `INNER`, anything else `LEFT OUTER`. So **a fact row whose required reference matches no row is left out of that report** (a dimension you do not list adds no join). That is the existing projection rule, not a reporting special case. + +## Engine differences + +| | Postgres | SQLite / D1 | MySQL | +|---|---|---|---| +| View created by | `meta migrate` | `meta migrate` | you: see below | +| A ratio or `avg` of `2` over `3` | `0.66666666666666666667` | `0.6666666666666666` | `0.6667` | +| `decimal` | `NUMERIC` | none: `avg`, a ratio and a `sum` of a decimal column are `REAL` | `DECIMAL` | +| Instants | `TIMESTAMPTZ` | ISO-8601 text | `DATETIME(3)`, read as the UTC wall clock | + +**MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body, and the MySQL guide in the `metaobjects-codegen` skill shows the loop. It skips a report whose source is `@unmanaged`. + +## What a report does not have + +No REST route, typed client, filter allowlist or api-docs entry is generated for a report in any port. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. 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 6dbd5e4ed..26fb4cdc1 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 @@ -29,6 +29,7 @@ This file covers what almost every model needs. The topics below live in | `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view | | `references/inheritance-tph.md` | several entities are variants of one thing sharing a single table (`@discriminator`) | | `references/metadata-dependencies.md` | the project builds on another package's metadata (`dependencies`, cross-package `overlay`) | +| `references/reporting.md` | a dashboard number, count or total over one entity's rows: what columns a report gets, time grains, null rules, engine differences | | `references/requirements.md` | installed only when the project declares `requirement.*` nodes | ## The operating principle: model-first, generate-first @@ -677,6 +678,75 @@ Several variants of one thing sharing **one table**: the base `object.entity` de `@discriminatorValue`. Codegen emits per-subtype routes with the discriminator injected and immutable. Supported in all five ports; the worked example is in `references/inheritance-tph.md`. +## Reporting — dimensions, measures and reports + +Reach for it when a dashboard number would otherwise be a hand-written `GROUP BY`: revenue per +day, buyers per program, a total. You name the pieces once, on the entity that owns the rows, +and an `object.report` combines them by name. Four node kinds: + +- `dimension.attribute` / `dimension.time` — what to group by (`@of: Entity.field`; a time + dimension lists the `@grains` it supports: `hour`, `day`, `week`, `month`, `quarter`, `year`); +- `measure.aggregate` (`@agg`: `count`, `sum`, `avg`, `min`, `max`) and `measure.ratio` + (`@numerator` / `@denominator`, both measures of the entity); +- `segment.filter` — a named, reusable `@filter` ("active purchase"); +- `object.report` — a top-level object: `@from` an entity, `@dimensions` (`name` or + `name:grain`), `@measures`. + +```json +{ "metadata.root": { + "package": "acme::shop", + "children": [ + { "object.entity": { + "name": "Purchase", + "children": [ + { "source.rdb": { "@table": "purchases" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "status" } }, + { "field.currency": { "name": "amountCents" } }, + { "field.timestamp": { "name": "purchasedAt" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } }, + { "segment.filter": { "name": "active", "@filter": { "status": "active" } } }, + { "dimension.time": { "name": "purchasedAt", "@of": "Purchase.purchasedAt", + "@grains": ["day", "month"] } }, + { "measure.aggregate": { "name": "purchases", "@agg": "count", "@of": "Purchase.id", + "@segment": "active" } }, + { "measure.aggregate": { "name": "revenue", "@agg": "sum", "@of": "Purchase.amountCents" } } + ] + }}, + { "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": ["purchasedAt:day"], + "@measures": ["purchases", "revenue"], + "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_daily_revenue" } } + ] + }} + ] +}} +``` + +Three rules an author trips on: + +1. **Every measure belongs to `@from`.** A report cannot mix measures of two entities (joining + two fact tables multiplies each side's rows); two fact tables are two reports. +2. **`@via` is to-one only.** A dimension reaches a related entity's column through a + `relationship.*` with `@cardinality: one` (or an `identity.reference`), never through a + to-many, which would repeat fact rows and double-count a `sum`. +3. **A report declares no fields.** Its columns are derived: one per dimension, then one per + measure (a time dimension at a grain is ``, so `purchasedAt:day` is + `purchasedAtDay`). A `field.*` or `identity.*` child on a report is an error. + +**A report is served only when it declares `source.rdb` with `@kind: view`.** That declaration +is what makes `meta migrate` create the view (Postgres, SQLite, D1) and what every port's +runtime reads; a report with no `source.*` is checked at load and generates nothing. + +What does not exist: no REST route and no typed client for a report yet, no `measure.derived` +(arithmetic between measures beyond `measure.ratio`), no query-time choice of dimensions or +measures (a report is a fixed, compiled combination), and no time-zone vocabulary (grains and +relative dates are UTC). Column types, the null rules, Monday weeks and per-engine differences +are in `references/reporting.md`. + ## Requirements — capability ledger (opt-in) **This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in `metaobjects/` beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md new file mode 100644 index 000000000..9552bbf75 --- /dev/null +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -0,0 +1,74 @@ +# Reporting: the columns a report gets, and how it behaves + +> Part of the `metaobjects-authoring` skill. The skill covers declaring dimensions, measures, segments and a report. Read this when you need to know what a report returns: its column names and types, the time-grain and null rules, what differs between databases, and what it leaves out. + +## A report is served only with a view source + +A report is a compiled view. The report's **own** read-only source decides what happens: + +| The report declares | Result | +|---|---| +| no `source.*` | Checked at load, nothing else. No view, no migrate statement, no runtime read (an `ObjectManager` refuses it as "not served"). | +| `source.rdb` with `@kind: view` | `meta migrate` creates the view (Postgres, SQLite, D1) under the source's `@view` name, and every port reads it. | +| the same, plus `@sql` | Your SQL is the view body. The columns below still define what is read. | +| the same, plus `@unmanaged: true` | `meta migrate` never creates or drops it; the runtime still reads it. | +| `@kind: materializedView`, `storedProc`, `tableFunction` | `meta migrate` skips it. | + +A view-backed report whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity. A changed report is dropped and re-created by `meta migrate`. + +## The columns you get + +A report declares no fields. Its columns are one per `@dimensions` item in listed order, then one per `@measures` item in listed order, named by the derived field name (your naming strategy applies to that name; an `@column` on the `@of` field is not inherited). A report has no primary key: read it with list and count (filter, sort and limit work on the derived columns); get-by-id and every write are refused. + +| Item | Column | Type | Never null? | +|---|---|---|---| +| `dimension.attribute` | the dimension's name | the `@of` field's type | only with no `@via` and an `@of` field with `@required: true` | +| `dimension.time` at `hour` | `Hour` | `timestamp` | same rule | +| `dimension.time` at `day`, `week`, `month`, `quarter`, `year` | `` | `date` (first day of the bucket) | same rule | +| `count`, with or without `@distinct` | the measure's name | `long` | yes | +| `sum` of `int` / `long` | the measure's name | `long` | no | +| `sum` of `currency` | the measure's name | `currency` (minor units) | no | +| `sum` of `decimal` | the measure's name | `decimal` | no | +| `sum` of `double` / `float` | the measure's name | `double` | no | +| `avg` of `int`, `long`, `currency`, `decimal` | the measure's name | `decimal` | no | +| `avg` of `double` / `float` | the measure's name | `double` | no | +| `min` / `max` | the measure's name | the `@of` field's type | no | +| `measure.ratio` | the measure's name | `decimal` | no | + +A column carries its `@of` field's type-shaping attributes (`@currency`, `@values`, `@precision`, `@scale`, `@localTime`, ...) and nothing else: no `@default`, no validators. + +## Time grains + +`hour, day, week, month, quarter, year`; `hour` is illegal on a `field.date`. **Weeks start on Monday (ISO-8601)** on every engine: Sunday 2026-05-17 falls in the week of 2026-05-11, and Monday 2026-06-01 opens its own week. + +**Bucketing is UTC.** A `field.timestamp` instant is bucketed in UTC whatever the reader's session time zone is, so every reader gets the same buckets. A `@localTime` timestamp and a `field.date` are bucketed as stored. There is no vocabulary for another time zone; do not look for one. + +## Relative dates + +A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 duration) is legal only on a `field.date` or `field.timestamp`, under `gt`, `gte`, `lt` or `lte`, and only in the `@filter` of a `segment`, a `measure.aggregate` or an `object.report`. It is evaluated when the view is **queried**, against the UTC clock. + +## Nulls and zeros + +- A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. +- A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. +- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. +- A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). + +## Joins: a dimension through a required reference drops rows + +A dimension reached by `@via` joins like a projection does: a required belongs-to foreign key joins `INNER`, anything else `LEFT OUTER`. So **a fact row whose required reference matches no row is left out of that report** (a dimension you do not list adds no join). That is the existing projection rule, not a reporting special case. + +## Engine differences + +| | Postgres | SQLite / D1 | MySQL | +|---|---|---|---| +| View created by | `meta migrate` | `meta migrate` | you: see below | +| A ratio or `avg` of `2` over `3` | `0.66666666666666666667` | `0.6666666666666666` | `0.6667` | +| `decimal` | `NUMERIC` | none: `avg`, a ratio and a `sum` of a decimal column are `REAL` | `DECIMAL` | +| Instants | `TIMESTAMPTZ` | ISO-8601 text | `DATETIME(3)`, read as the UTC wall clock | + +**MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body, and the MySQL guide in the `metaobjects-codegen` skill shows the loop. It skips a report whose source is `@unmanaged`. + +## What a report does not have + +No REST route, typed client, filter allowlist or api-docs entry is generated for a report in any port. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md index f1c315725..36be68c24 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md @@ -61,10 +61,11 @@ generated code and both runtimes quote identifiers themselves. ### Reports -An `object.report` (the reporting vocabulary, `docs/features/reporting.md`) is a compiled -view, and on MySQL you create that view yourself, because `meta migrate` does not. Declare -the report with a read-only `source.rdb` of `@kind: view` and no `@unmanaged`, since -`meta migrate` never targets MySQL and so nothing manages the view either way: +An `object.report` (the reporting vocabulary; see `references/reporting.md` in the +`metaobjects-authoring` skill) is a compiled view, and on MySQL you create that view +yourself, because `meta migrate` does not. Declare the report with a read-only +`source.rdb` of `@kind: view` and no `@unmanaged`, since `meta migrate` never targets MySQL +and so nothing manages the view either way: ```json { "source.rdb": { "@kind": "view", "@view": "v_program_minutes" } } @@ -86,6 +87,10 @@ for (const view of buildReportViews(root, { dialect: "mysql" })) { } ``` +The loop above ignores `view.schema` (on MySQL, the database a source's `@schema` names), so +each view is created in the connection's current database; qualify the name yourself if a +report's source declares `@schema`. + Pass `columnNamingStrategy` to match your tables' column names (the default is `snake_case`). The bodies are valid under MySQL's default `sql_mode`, `ONLY_FULL_GROUP_BY` included, and a change to a report means a new `CREATE OR REPLACE VIEW` (or `DROP` and `CREATE`) in your 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 6dbd5e4ed..26fb4cdc1 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 @@ -29,6 +29,7 @@ This file covers what almost every model needs. The topics below live in | `references/read-views-and-projections.md` | an `object.projection`, `origin.*` vocabulary, `@filter` / `@expr`, or an `@sql` / `@unmanaged` view | | `references/inheritance-tph.md` | several entities are variants of one thing sharing a single table (`@discriminator`) | | `references/metadata-dependencies.md` | the project builds on another package's metadata (`dependencies`, cross-package `overlay`) | +| `references/reporting.md` | a dashboard number, count or total over one entity's rows: what columns a report gets, time grains, null rules, engine differences | | `references/requirements.md` | installed only when the project declares `requirement.*` nodes | ## The operating principle: model-first, generate-first @@ -677,6 +678,75 @@ Several variants of one thing sharing **one table**: the base `object.entity` de `@discriminatorValue`. Codegen emits per-subtype routes with the discriminator injected and immutable. Supported in all five ports; the worked example is in `references/inheritance-tph.md`. +## Reporting — dimensions, measures and reports + +Reach for it when a dashboard number would otherwise be a hand-written `GROUP BY`: revenue per +day, buyers per program, a total. You name the pieces once, on the entity that owns the rows, +and an `object.report` combines them by name. Four node kinds: + +- `dimension.attribute` / `dimension.time` — what to group by (`@of: Entity.field`; a time + dimension lists the `@grains` it supports: `hour`, `day`, `week`, `month`, `quarter`, `year`); +- `measure.aggregate` (`@agg`: `count`, `sum`, `avg`, `min`, `max`) and `measure.ratio` + (`@numerator` / `@denominator`, both measures of the entity); +- `segment.filter` — a named, reusable `@filter` ("active purchase"); +- `object.report` — a top-level object: `@from` an entity, `@dimensions` (`name` or + `name:grain`), `@measures`. + +```json +{ "metadata.root": { + "package": "acme::shop", + "children": [ + { "object.entity": { + "name": "Purchase", + "children": [ + { "source.rdb": { "@table": "purchases" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "status" } }, + { "field.currency": { "name": "amountCents" } }, + { "field.timestamp": { "name": "purchasedAt" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } }, + { "segment.filter": { "name": "active", "@filter": { "status": "active" } } }, + { "dimension.time": { "name": "purchasedAt", "@of": "Purchase.purchasedAt", + "@grains": ["day", "month"] } }, + { "measure.aggregate": { "name": "purchases", "@agg": "count", "@of": "Purchase.id", + "@segment": "active" } }, + { "measure.aggregate": { "name": "revenue", "@agg": "sum", "@of": "Purchase.amountCents" } } + ] + }}, + { "object.report": { + "name": "DailyRevenue", + "@from": "Purchase", + "@dimensions": ["purchasedAt:day"], + "@measures": ["purchases", "revenue"], + "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_daily_revenue" } } + ] + }} + ] +}} +``` + +Three rules an author trips on: + +1. **Every measure belongs to `@from`.** A report cannot mix measures of two entities (joining + two fact tables multiplies each side's rows); two fact tables are two reports. +2. **`@via` is to-one only.** A dimension reaches a related entity's column through a + `relationship.*` with `@cardinality: one` (or an `identity.reference`), never through a + to-many, which would repeat fact rows and double-count a `sum`. +3. **A report declares no fields.** Its columns are derived: one per dimension, then one per + measure (a time dimension at a grain is ``, so `purchasedAt:day` is + `purchasedAtDay`). A `field.*` or `identity.*` child on a report is an error. + +**A report is served only when it declares `source.rdb` with `@kind: view`.** That declaration +is what makes `meta migrate` create the view (Postgres, SQLite, D1) and what every port's +runtime reads; a report with no `source.*` is checked at load and generates nothing. + +What does not exist: no REST route and no typed client for a report yet, no `measure.derived` +(arithmetic between measures beyond `measure.ratio`), no query-time choice of dimensions or +measures (a report is a fixed, compiled combination), and no time-zone vocabulary (grains and +relative dates are UTC). Column types, the null rules, Monday weeks and per-engine differences +are in `references/reporting.md`. + ## Requirements — capability ledger (opt-in) **This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in `metaobjects/` beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md new file mode 100644 index 000000000..9552bbf75 --- /dev/null +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -0,0 +1,74 @@ +# Reporting: the columns a report gets, and how it behaves + +> Part of the `metaobjects-authoring` skill. The skill covers declaring dimensions, measures, segments and a report. Read this when you need to know what a report returns: its column names and types, the time-grain and null rules, what differs between databases, and what it leaves out. + +## A report is served only with a view source + +A report is a compiled view. The report's **own** read-only source decides what happens: + +| The report declares | Result | +|---|---| +| no `source.*` | Checked at load, nothing else. No view, no migrate statement, no runtime read (an `ObjectManager` refuses it as "not served"). | +| `source.rdb` with `@kind: view` | `meta migrate` creates the view (Postgres, SQLite, D1) under the source's `@view` name, and every port reads it. | +| the same, plus `@sql` | Your SQL is the view body. The columns below still define what is read. | +| the same, plus `@unmanaged: true` | `meta migrate` never creates or drops it; the runtime still reads it. | +| `@kind: materializedView`, `storedProc`, `tableFunction` | `meta migrate` skips it. | + +A view-backed report whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity. A changed report is dropped and re-created by `meta migrate`. + +## The columns you get + +A report declares no fields. Its columns are one per `@dimensions` item in listed order, then one per `@measures` item in listed order, named by the derived field name (your naming strategy applies to that name; an `@column` on the `@of` field is not inherited). A report has no primary key: read it with list and count (filter, sort and limit work on the derived columns); get-by-id and every write are refused. + +| Item | Column | Type | Never null? | +|---|---|---|---| +| `dimension.attribute` | the dimension's name | the `@of` field's type | only with no `@via` and an `@of` field with `@required: true` | +| `dimension.time` at `hour` | `Hour` | `timestamp` | same rule | +| `dimension.time` at `day`, `week`, `month`, `quarter`, `year` | `` | `date` (first day of the bucket) | same rule | +| `count`, with or without `@distinct` | the measure's name | `long` | yes | +| `sum` of `int` / `long` | the measure's name | `long` | no | +| `sum` of `currency` | the measure's name | `currency` (minor units) | no | +| `sum` of `decimal` | the measure's name | `decimal` | no | +| `sum` of `double` / `float` | the measure's name | `double` | no | +| `avg` of `int`, `long`, `currency`, `decimal` | the measure's name | `decimal` | no | +| `avg` of `double` / `float` | the measure's name | `double` | no | +| `min` / `max` | the measure's name | the `@of` field's type | no | +| `measure.ratio` | the measure's name | `decimal` | no | + +A column carries its `@of` field's type-shaping attributes (`@currency`, `@values`, `@precision`, `@scale`, `@localTime`, ...) and nothing else: no `@default`, no validators. + +## Time grains + +`hour, day, week, month, quarter, year`; `hour` is illegal on a `field.date`. **Weeks start on Monday (ISO-8601)** on every engine: Sunday 2026-05-17 falls in the week of 2026-05-11, and Monday 2026-06-01 opens its own week. + +**Bucketing is UTC.** A `field.timestamp` instant is bucketed in UTC whatever the reader's session time zone is, so every reader gets the same buckets. A `@localTime` timestamp and a `field.date` are bucketed as stored. There is no vocabulary for another time zone; do not look for one. + +## Relative dates + +A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 duration) is legal only on a `field.date` or `field.timestamp`, under `gt`, `gte`, `lt` or `lte`, and only in the `@filter` of a `segment`, a `measure.aggregate` or an `object.report`. It is evaluated when the view is **queried**, against the UTC clock. + +## Nulls and zeros + +- A `count` is `0` over nothing, never null. It counts rows whose `@of` column is not null; a tuple with any null component is not counted. +- A `sum` of nothing is **null**, not zero: no matching rows, or a filtered measure that matched none of a group's rows. +- A ratio is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**. +- A report with no dimensions is one row for the whole table, and still one row over an empty table (counts `0`, sums and ratios null). + +## Joins: a dimension through a required reference drops rows + +A dimension reached by `@via` joins like a projection does: a required belongs-to foreign key joins `INNER`, anything else `LEFT OUTER`. So **a fact row whose required reference matches no row is left out of that report** (a dimension you do not list adds no join). That is the existing projection rule, not a reporting special case. + +## Engine differences + +| | Postgres | SQLite / D1 | MySQL | +|---|---|---|---| +| View created by | `meta migrate` | `meta migrate` | you: see below | +| A ratio or `avg` of `2` over `3` | `0.66666666666666666667` | `0.6666666666666666` | `0.6667` | +| `decimal` | `NUMERIC` | none: `avg`, a ratio and a `sum` of a decimal column are `REAL` | `DECIMAL` | +| Instants | `TIMESTAMPTZ` | ISO-8601 text | `DATETIME(3)`, read as the UTC wall clock | + +**MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body, and the MySQL guide in the `metaobjects-codegen` skill shows the loop. It skips a report whose source is `@unmanaged`. + +## What a report does not have + +No REST route, typed client, filter allowlist or api-docs entry is generated for a report in any port. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md index f1c315725..36be68c24 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md @@ -61,10 +61,11 @@ generated code and both runtimes quote identifiers themselves. ### Reports -An `object.report` (the reporting vocabulary, `docs/features/reporting.md`) is a compiled -view, and on MySQL you create that view yourself, because `meta migrate` does not. Declare -the report with a read-only `source.rdb` of `@kind: view` and no `@unmanaged`, since -`meta migrate` never targets MySQL and so nothing manages the view either way: +An `object.report` (the reporting vocabulary; see `references/reporting.md` in the +`metaobjects-authoring` skill) is a compiled view, and on MySQL you create that view +yourself, because `meta migrate` does not. Declare the report with a read-only +`source.rdb` of `@kind: view` and no `@unmanaged`, since `meta migrate` never targets MySQL +and so nothing manages the view either way: ```json { "source.rdb": { "@kind": "view", "@view": "v_program_minutes" } } @@ -86,6 +87,10 @@ for (const view of buildReportViews(root, { dialect: "mysql" })) { } ``` +The loop above ignores `view.schema` (on MySQL, the database a source's `@schema` names), so +each view is created in the connection's current database; qualify the name yourself if a +report's source declares `@schema`. + Pass `columnNamingStrategy` to match your tables' column names (the default is `snake_case`). The bodies are valid under MySQL's default `sql_mode`, `ONLY_FULL_GROUP_BY` included, and a change to a report means a new `CREATE OR REPLACE VIEW` (or `DROP` and `CREATE`) in your diff --git a/fixtures/codegen-noop/reporting/README.md b/fixtures/codegen-noop/reporting/README.md index e597c37b9..bc3139e8f 100644 --- a/fixtures/codegen-noop/reporting/README.md +++ b/fixtures/codegen-noop/reporting/README.md @@ -11,14 +11,20 @@ Two models that differ ONLY by the FR-044 reporting vocabulary: to leak output. What is lowered (FR-044 Plan 2): a report that declares a read-only `source.rdb @kind: view` -becomes that view, in TypeScript migrate only. `StoreTotals` is that report, so `meta migrate` -proposes exactly one extra statement for `with/` over `without/`, `CREATE VIEW v_store_totals`, -and the `meta docs` agent schema page lists it. Nothing else differs. +becomes that view. `StoreTotals` is that report, so `with/` differs from `without/` in exactly +these places and no others: + +- TypeScript `meta migrate` proposes one extra statement, `CREATE VIEW v_store_totals`, and the + `meta docs` agent schema page lists that view (the `## Views` section). +- C# codegen (`dotnet meta gen`) writes one extra file, the keyless row class `StoreTotals.g.cs`, and two extra + lines in `AppDbContext.g.cs` (a `DbSet` and `HasNoKey().ToView("v_store_totals")`). +- Kotlin codegen (`metaobjects:generate`) writes an Exposed table object for `StoreTotals`. What stays inert: a report with no read-only source (`ProgramEngagement`, `DailyRevenue`), -everywhere; every generator in TypeScript, Java and Python, for every report; and routes in -every port. No other port emits SQL for a report (ADR-0015), so for them `with/` and `without/` -still generate byte-identical files. The per-port tests that hold this: +everywhere; every generator in TypeScript, Java and Python, for every report; routes, typed +clients, filter allowlists and api-docs in every port; and every other C# and Kotlin generator. +No port but TypeScript emits SQL for a report (ADR-0015), so for Java and Python `with/` and +`without/` still generate byte-identical files. The per-port tests that hold this: | Port | Test | |---|---| @@ -28,10 +34,11 @@ still generate byte-identical files. The per-port tests that hold this: | Kotlin | `server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt` | | Python | `server/python/tests/test_reporting_inert.py` | -The documentation tier is held to the same rule (FR-044 Plan 1 ruling): `meta docs` (model, -agent, requirements and site pages) and every port's api-docs builder emit nothing for a -report, because its fields are derived by the lowering and a page today would show none of -them. The TypeScript, C#, Java, Kotlin and Python tests above compare that output too. +The documentation tier is held to the same rule (FR-044 Plan 1 ruling), with the one entry +above: `meta docs` model, requirements and site pages and every port's api-docs builder emit +nothing for a report, because its fields are derived by the lowering and a page would show +none of them. Only the agent schema page lists a view-backed report's view. The five tests +above compare that output too. Regenerate `without/` from `with/` by deleting every `dimension.*`, `measure.*` and `segment.*` child and every `object.report` node — nothing else may differ. diff --git a/fixtures/persistence-conformance/README.md b/fixtures/persistence-conformance/README.md index a3fc16e8c..52555d156 100644 --- a/fixtures/persistence-conformance/README.md +++ b/fixtures/persistence-conformance/README.md @@ -26,6 +26,7 @@ commands), and are required before any release publish — see fixtures/persistence-conformance/ ├── README.md # this file — spec + DSL ├── normalization.md # how each port serializes result rows +├── report-shapes.json # TS-produced derived fields of each report; every port byte-matches it ├── canonical/ # SHARED "kitchen-sink" metadata + committed schema DDL │ ├── meta.*.json # the kitchen-sink metadata every query scenario reads │ └── schema.postgres.sql # TS-produced canonical DDL every port executes to set up its DB @@ -38,8 +39,9 @@ fixtures/persistence-conformance/ ### The canonical schema artifact `canonical/schema.postgres.sql` is **generated by TypeScript** from -`canonical/meta.fitness.json` (base tables + the `origin.aggregate` / -`origin.passthrough` projection views) and **committed**. It carries a +`canonical/meta.fitness.json` (base tables, the `origin.aggregate` / +`origin.passthrough` projection views and the view of each view-backed `object.report`) +and **committed**. It carries a `@generated … DO NOT EDIT` header; every port's query runner executes it verbatim to provision its test DB, so the *runtime* layer is exercised against a schema no port synthesized. diff --git a/server/typescript/packages/cli/test/unit/reporting-inert.test.ts b/server/typescript/packages/cli/test/unit/reporting-inert.test.ts index 45166b276..d7b767e66 100644 --- a/server/typescript/packages/cli/test/unit/reporting-inert.test.ts +++ b/server/typescript/packages/cli/test/unit/reporting-inert.test.ts @@ -298,7 +298,7 @@ describe("FR-044 reporting nodes are inert in meta docs, bar the one view entry" // buildProjectionViews), so the one view-backed report appears there and nowhere else. const schemaPage = Object.keys(expected).find((p) => p.endsWith("schema.md"))!; const entry = "## Views\n\n" + - "A view is generated from its projection's `origin.*` children — it is derived, never hand-written. " + + "A view is generated from its projection's `origin.*` children or its report's dimensions and measures — it is derived, never hand-written. " + "Editing the view SQL directly is drift the tool cannot see.\n\n" + "### `v_store_totals`\n\nDeclared by `acme::shop::StoreTotals`.\n\n"; expect(actual[schemaPage]).toContain(entry); diff --git a/server/typescript/packages/codegen-ts/src/generators/agent-schema-page.ts b/server/typescript/packages/codegen-ts/src/generators/agent-schema-page.ts index 111d8298d..2c23243e3 100644 --- a/server/typescript/packages/codegen-ts/src/generators/agent-schema-page.ts +++ b/server/typescript/packages/codegen-ts/src/generators/agent-schema-page.ts @@ -264,7 +264,7 @@ export function renderAgentSchemaPage( out.push("## Views"); out.push(""); out.push( - "A view is generated from its projection's `origin.*` children — it is derived, " + + "A view is generated from its projection's `origin.*` children or its report's dimensions and measures — it is derived, " + "never hand-written. Editing the view SQL directly is drift the tool cannot see.", ); out.push(""); diff --git a/server/typescript/packages/docs-site/src/coverage.ts b/server/typescript/packages/docs-site/src/coverage.ts index 84f5968d1..060b7f7c8 100644 --- a/server/typescript/packages/docs-site/src/coverage.ts +++ b/server/typescript/packages/docs-site/src/coverage.ts @@ -4,8 +4,8 @@ import { } from "@metaobjectsdev/metadata"; /** FR-044 reporting vocabulary — all four types: `dimension.*`, `measure.*`, `segment.*` - * and `object.report`. Inert in every generator until the report lowering lands (FR-044 - * Plan 2/3), so the site renders none of it BY DESIGN. + * and `object.report`. A view-backed report is lowered to a view (FR-044 Plan 2), but no + * site page renders any of this vocabulary until Plan 3, so the site shows none of it BY DESIGN. * * The audit reports these as DEFERRED, not as "not rendered by any page" and not by * silently dropping them: the gap stays visible on the returned report (`deferred`, one From 136a378bbdfbd042adc37eb22483368fe9a9a023 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 09:03:01 -0400 Subject: [PATCH 19/32] fix(docs): keep the agent schema page byte-identical for a model with no report view; correct reporting doc details (FR-044) --- CHANGELOG.md | 4 +- .../references/reporting.md | 4 +- .../references/typescript-mysql.md | 6 +-- docs/features/reporting.md | 12 +++--- docs/recipes/mysql.md | 6 +-- .../references/reporting.md | 4 +- .../references/reporting.md | 4 +- .../references/reporting.md | 4 +- .../references/reporting.md | 4 +- .../references/typescript-mysql.md | 6 +-- .../references/reporting.md | 4 +- .../references/typescript-mysql.md | 6 +-- .../src/generators/agent-docs-file.ts | 6 +++ .../src/generators/agent-schema-page.ts | 10 ++++- .../test/agent-docs-surface.test.ts | 38 +++++++++++++++++++ 15 files changed, 86 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecf0eee79..3754a45d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,8 +33,8 @@ it until 1.1 ships._ - **A report with a view source becomes a SQL view, and every port reads it (FR-044).** An `object.report` that declares a read-only `source.rdb` of `@kind: view` is now lowered by TypeScript: `meta migrate` creates the view on Postgres, SQLite and D1 (a changed report view - is dropped and re-created; a view-backed report whose `@from` entity has no table fails - migrate naming the report and the entity). MySQL SQL comes from `buildReportViews(root, + is dropped and re-created; a derived report view whose `@from` entity has no table fails + migrate naming the report and the entity; a report with an `@sql` source skips that check). MySQL SQL comes from `buildReportViews(root, { dialect: "mysql" })` and the "Reports" section of `docs/recipes/mysql.md`, since `meta migrate` does not target MySQL. A report with no `source.*` still generates nothing. Time grains and relative dates are UTC, weeks start on Monday, a `sum` of nothing and a ratio over diff --git a/agent-context/skills/metaobjects-authoring/references/reporting.md b/agent-context/skills/metaobjects-authoring/references/reporting.md index 9552bbf75..a6b05bb8c 100644 --- a/agent-context/skills/metaobjects-authoring/references/reporting.md +++ b/agent-context/skills/metaobjects-authoring/references/reporting.md @@ -14,7 +14,7 @@ A report is a compiled view. The report's **own** read-only source decides what | the same, plus `@unmanaged: true` | `meta migrate` never creates or drops it; the runtime still reads it. | | `@kind: materializedView`, `storedProc`, `tableFunction` | `meta migrate` skips it. | -A view-backed report whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity. A changed report is dropped and re-created by `meta migrate`. +A derived report view (no `@sql`) whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity; a report with an `@sql` source skips that check, since your SQL is used as written. A changed report is dropped and re-created by `meta migrate`. ## The columns you get @@ -67,7 +67,7 @@ A dimension reached by `@via` joins like a projection does: a required belongs-t | `decimal` | `NUMERIC` | none: `avg`, a ratio and a `sum` of a decimal column are `REAL` | `DECIMAL` | | Instants | `TIMESTAMPTZ` | ISO-8601 text | `DATETIME(3)`, read as the UTC wall clock | -**MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body, and the MySQL guide in the `metaobjects-codegen` skill shows the loop. It skips a report whose source is `@unmanaged`. +**MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body. That function is in the TypeScript package, so the MySQL view SQL comes from a TypeScript toolchain whatever language your application is in; the recipe showing the loop ships as the MySQL guide in the `metaobjects-codegen` skill's TypeScript stacks only. It skips a report whose source is `@unmanaged`. ## What a report does not have diff --git a/agent-context/skills/metaobjects-codegen/references/typescript-mysql.md b/agent-context/skills/metaobjects-codegen/references/typescript-mysql.md index 36be68c24..eb54c6be2 100644 --- a/agent-context/skills/metaobjects-codegen/references/typescript-mysql.md +++ b/agent-context/skills/metaobjects-codegen/references/typescript-mysql.md @@ -87,9 +87,9 @@ for (const view of buildReportViews(root, { dialect: "mysql" })) { } ``` -The loop above ignores `view.schema` (on MySQL, the database a source's `@schema` names), so -each view is created in the connection's current database; qualify the name yourself if a -report's source declares `@schema`. +The loop above ignores `view.schema`, which is the report source's `@schema` when it declares +one. If yours does, create the view in that schema yourself (qualify the name in your +migration); the loop will not. Pass `columnNamingStrategy` to match your tables' column names (the default is `snake_case`). The bodies are valid under MySQL's default `sql_mode`, `ONLY_FULL_GROUP_BY` included, and a diff --git a/docs/features/reporting.md b/docs/features/reporting.md index fa975197f..605a8d36a 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -177,9 +177,10 @@ lowered alone. | the same, plus `@unmanaged: true` | `meta migrate` never creates or drops it (you or a migration tool own the DDL), but the runtime still reads it through the shape below. | | `@kind: materializedView`, `storedProc` or `tableFunction` | `meta migrate` skips it, as for a projection. | -A view-backed report whose `@from` entity has no table (it is abstract, or declares no -writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity, -rather than emitting a view over a table that does not exist. +A **derived** report view (no `@sql`) whose `@from` entity has no table (it is abstract, or +declares no writable `source.rdb`) fails `meta migrate` with an error naming the report and the +entity, rather than emitting a view over a table that does not exist. A report with an `@sql` +source is not derived, so that check does not apply to it: your SQL is used as written. ### The columns you get @@ -218,8 +219,9 @@ applied. The aggregates: items) counts distinct tuples, and a tuple with any null component is not counted, on every engine. - **`sum`** of nothing is **null**, not zero: a report with no matching rows, or a filtered - measure that matched none of a group's rows, shows null. A `sum` of an integer type is cast - so the column is a `BIGINT` on every engine. + measure that matched none of a group's rows, shows null. A `sum` of an integer type is a + 64-bit integer on every engine (Postgres casts it to `BIGINT`, MySQL to `SIGNED`, and SQLite's + integer `SUM` already is one). - **`avg`, `min`, `max`** are the engine's own. - **`measure.ratio`** is `numerator / NULLIF(denominator, 0)`: a zero denominator is **null**, never an error. Each operand is repeated inline with its own conditions, so an operand need diff --git a/docs/recipes/mysql.md b/docs/recipes/mysql.md index cb6533a07..d67dae1be 100644 --- a/docs/recipes/mysql.md +++ b/docs/recipes/mysql.md @@ -105,9 +105,9 @@ for (const view of buildReportViews(root, { dialect: "mysql" })) { } ``` -The loop above ignores `view.schema` (on MySQL, the database a source's `@schema` names), so -each view is created in the connection's current database; qualify the name yourself if a -report's source declares `@schema`. +The loop above ignores `view.schema`, which is the report source's `@schema` when it declares +one. If yours does, create the view in that schema yourself (qualify the name in your +migration); the loop will not. Pass `columnNamingStrategy` to match your tables' column names (the default is `snake_case`). The bodies are valid under MySQL's default `sql_mode`, `ONLY_FULL_GROUP_BY` included, and a diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 9552bbf75..a6b05bb8c 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -14,7 +14,7 @@ A report is a compiled view. The report's **own** read-only source decides what | the same, plus `@unmanaged: true` | `meta migrate` never creates or drops it; the runtime still reads it. | | `@kind: materializedView`, `storedProc`, `tableFunction` | `meta migrate` skips it. | -A view-backed report whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity. A changed report is dropped and re-created by `meta migrate`. +A derived report view (no `@sql`) whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity; a report with an `@sql` source skips that check, since your SQL is used as written. A changed report is dropped and re-created by `meta migrate`. ## The columns you get @@ -67,7 +67,7 @@ A dimension reached by `@via` joins like a projection does: a required belongs-t | `decimal` | `NUMERIC` | none: `avg`, a ratio and a `sum` of a decimal column are `REAL` | `DECIMAL` | | Instants | `TIMESTAMPTZ` | ISO-8601 text | `DATETIME(3)`, read as the UTC wall clock | -**MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body, and the MySQL guide in the `metaobjects-codegen` skill shows the loop. It skips a report whose source is `@unmanaged`. +**MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body. That function is in the TypeScript package, so the MySQL view SQL comes from a TypeScript toolchain whatever language your application is in; the recipe showing the loop ships as the MySQL guide in the `metaobjects-codegen` skill's TypeScript stacks only. It skips a report whose source is `@unmanaged`. ## What a report does not have diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 9552bbf75..a6b05bb8c 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -14,7 +14,7 @@ A report is a compiled view. The report's **own** read-only source decides what | the same, plus `@unmanaged: true` | `meta migrate` never creates or drops it; the runtime still reads it. | | `@kind: materializedView`, `storedProc`, `tableFunction` | `meta migrate` skips it. | -A view-backed report whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity. A changed report is dropped and re-created by `meta migrate`. +A derived report view (no `@sql`) whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity; a report with an `@sql` source skips that check, since your SQL is used as written. A changed report is dropped and re-created by `meta migrate`. ## The columns you get @@ -67,7 +67,7 @@ A dimension reached by `@via` joins like a projection does: a required belongs-t | `decimal` | `NUMERIC` | none: `avg`, a ratio and a `sum` of a decimal column are `REAL` | `DECIMAL` | | Instants | `TIMESTAMPTZ` | ISO-8601 text | `DATETIME(3)`, read as the UTC wall clock | -**MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body, and the MySQL guide in the `metaobjects-codegen` skill shows the loop. It skips a report whose source is `@unmanaged`. +**MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body. That function is in the TypeScript package, so the MySQL view SQL comes from a TypeScript toolchain whatever language your application is in; the recipe showing the loop ships as the MySQL guide in the `metaobjects-codegen` skill's TypeScript stacks only. It skips a report whose source is `@unmanaged`. ## What a report does not have diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 9552bbf75..a6b05bb8c 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -14,7 +14,7 @@ A report is a compiled view. The report's **own** read-only source decides what | the same, plus `@unmanaged: true` | `meta migrate` never creates or drops it; the runtime still reads it. | | `@kind: materializedView`, `storedProc`, `tableFunction` | `meta migrate` skips it. | -A view-backed report whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity. A changed report is dropped and re-created by `meta migrate`. +A derived report view (no `@sql`) whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity; a report with an `@sql` source skips that check, since your SQL is used as written. A changed report is dropped and re-created by `meta migrate`. ## The columns you get @@ -67,7 +67,7 @@ A dimension reached by `@via` joins like a projection does: a required belongs-t | `decimal` | `NUMERIC` | none: `avg`, a ratio and a `sum` of a decimal column are `REAL` | `DECIMAL` | | Instants | `TIMESTAMPTZ` | ISO-8601 text | `DATETIME(3)`, read as the UTC wall clock | -**MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body, and the MySQL guide in the `metaobjects-codegen` skill shows the loop. It skips a report whose source is `@unmanaged`. +**MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body. That function is in the TypeScript package, so the MySQL view SQL comes from a TypeScript toolchain whatever language your application is in; the recipe showing the loop ships as the MySQL guide in the `metaobjects-codegen` skill's TypeScript stacks only. It skips a report whose source is `@unmanaged`. ## What a report does not have diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 9552bbf75..a6b05bb8c 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -14,7 +14,7 @@ A report is a compiled view. The report's **own** read-only source decides what | the same, plus `@unmanaged: true` | `meta migrate` never creates or drops it; the runtime still reads it. | | `@kind: materializedView`, `storedProc`, `tableFunction` | `meta migrate` skips it. | -A view-backed report whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity. A changed report is dropped and re-created by `meta migrate`. +A derived report view (no `@sql`) whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity; a report with an `@sql` source skips that check, since your SQL is used as written. A changed report is dropped and re-created by `meta migrate`. ## The columns you get @@ -67,7 +67,7 @@ A dimension reached by `@via` joins like a projection does: a required belongs-t | `decimal` | `NUMERIC` | none: `avg`, a ratio and a `sum` of a decimal column are `REAL` | `DECIMAL` | | Instants | `TIMESTAMPTZ` | ISO-8601 text | `DATETIME(3)`, read as the UTC wall clock | -**MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body, and the MySQL guide in the `metaobjects-codegen` skill shows the loop. It skips a report whose source is `@unmanaged`. +**MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body. That function is in the TypeScript package, so the MySQL view SQL comes from a TypeScript toolchain whatever language your application is in; the recipe showing the loop ships as the MySQL guide in the `metaobjects-codegen` skill's TypeScript stacks only. It skips a report whose source is `@unmanaged`. ## What a report does not have diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md index 36be68c24..eb54c6be2 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md @@ -87,9 +87,9 @@ for (const view of buildReportViews(root, { dialect: "mysql" })) { } ``` -The loop above ignores `view.schema` (on MySQL, the database a source's `@schema` names), so -each view is created in the connection's current database; qualify the name yourself if a -report's source declares `@schema`. +The loop above ignores `view.schema`, which is the report source's `@schema` when it declares +one. If yours does, create the view in that schema yourself (qualify the name in your +migration); the loop will not. Pass `columnNamingStrategy` to match your tables' column names (the default is `snake_case`). The bodies are valid under MySQL's default `sql_mode`, `ONLY_FULL_GROUP_BY` included, and a diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 9552bbf75..a6b05bb8c 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -14,7 +14,7 @@ A report is a compiled view. The report's **own** read-only source decides what | the same, plus `@unmanaged: true` | `meta migrate` never creates or drops it; the runtime still reads it. | | `@kind: materializedView`, `storedProc`, `tableFunction` | `meta migrate` skips it. | -A view-backed report whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity. A changed report is dropped and re-created by `meta migrate`. +A derived report view (no `@sql`) whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity; a report with an `@sql` source skips that check, since your SQL is used as written. A changed report is dropped and re-created by `meta migrate`. ## The columns you get @@ -67,7 +67,7 @@ A dimension reached by `@via` joins like a projection does: a required belongs-t | `decimal` | `NUMERIC` | none: `avg`, a ratio and a `sum` of a decimal column are `REAL` | `DECIMAL` | | Instants | `TIMESTAMPTZ` | ISO-8601 text | `DATETIME(3)`, read as the UTC wall clock | -**MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body, and the MySQL guide in the `metaobjects-codegen` skill shows the loop. It skips a report whose source is `@unmanaged`. +**MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body. That function is in the TypeScript package, so the MySQL view SQL comes from a TypeScript toolchain whatever language your application is in; the recipe showing the loop ships as the MySQL guide in the `metaobjects-codegen` skill's TypeScript stacks only. It skips a report whose source is `@unmanaged`. ## What a report does not have diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md index 36be68c24..eb54c6be2 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-mysql.md @@ -87,9 +87,9 @@ for (const view of buildReportViews(root, { dialect: "mysql" })) { } ``` -The loop above ignores `view.schema` (on MySQL, the database a source's `@schema` names), so -each view is created in the connection's current database; qualify the name yourself if a -report's source declares `@schema`. +The loop above ignores `view.schema`, which is the report source's `@schema` when it declares +one. If yours does, create the view in that schema yourself (qualify the name in your +migration); the loop will not. Pass `columnNamingStrategy` to match your tables' column names (the default is `snake_case`). The bodies are valid under MySQL's default `sql_mode`, `ONLY_FULL_GROUP_BY` included, and a diff --git a/server/typescript/packages/codegen-ts/src/generators/agent-docs-file.ts b/server/typescript/packages/codegen-ts/src/generators/agent-docs-file.ts index 8b5ab1cf5..c2e97b064 100644 --- a/server/typescript/packages/codegen-ts/src/generators/agent-docs-file.ts +++ b/server/typescript/packages/codegen-ts/src/generators/agent-docs-file.ts @@ -63,6 +63,7 @@ import type { ColumnNamingStrategy, MetaField, MetaObject } from "@metaobjectsde import type { EmittedFile, Generator, GeneratorFactory } from "../generator.js"; import { resolveObjectNames } from "../names.js"; import { isAbstract } from "../instance-artifacts.js"; +import { isReport } from "../source-detect.js"; import { enumValues, intValueMapOf } from "../enum-meta.js"; import { renderAgentSchemaPage } from "./agent-schema-page.js"; import { renderAgentUiPage } from "./agent-ui-page.js"; @@ -231,6 +232,9 @@ export const agentDocsFile = function agentDocsFile(opts?: AgentDocsFileOpts): G // so this mapping cannot disagree with the column it labels. const declaredBy = new Map>(); const viewLineage = new Map(); + // Qualified names of the views a view-backed object.report owns (FR-044). Empty for a + // model with no report, so the page is byte-identical to what it was before reports. + const reportViews = new Set(); for (const obj of objects) { const names = resolveObjectNames(obj, opts.columnNamingStrategy); // The PRIMARY source's physical name and schema — `names.name` is the object's @@ -249,6 +253,7 @@ export const agentDocsFile = function agentDocsFile(opts?: AgentDocsFileOpts): G // page printed the base's enum a second time under a different owner. // `buildExpectedSchema`'s Pass 1 skips abstracts; this reads the same rule. if (isTable && !isAbstract(obj)) tableBacked.push(obj); + if (!isTable && isReport(obj)) reportViews.add(key); let map = declaredBy.get(key); if (map === undefined) { map = new Map(); @@ -271,6 +276,7 @@ export const agentDocsFile = function agentDocsFile(opts?: AgentDocsFileOpts): G const content = renderAgentSchemaPage(schema, { declaredBy, viewLineage, + reportViews, relationships: relationshipLines(objects), enums: enumLines(tableBacked), }); diff --git a/server/typescript/packages/codegen-ts/src/generators/agent-schema-page.ts b/server/typescript/packages/codegen-ts/src/generators/agent-schema-page.ts index 2c23243e3..6b1c40737 100644 --- a/server/typescript/packages/codegen-ts/src/generators/agent-schema-page.ts +++ b/server/typescript/packages/codegen-ts/src/generators/agent-schema-page.ts @@ -181,6 +181,9 @@ export interface AgentSchemaPageOptions { readonly declaredBy: ReadonlyMap>; /** Per-projection lineage lines, keyed by QUALIFIED view name. */ readonly viewLineage: ReadonlyMap; + /** QUALIFIED names of the views owned by a view-backed `object.report`. Omitted or empty + * means no report view is on the page, and the Views intro keeps its pre-report wording. */ + readonly reportViews?: ReadonlySet; /** Relationship lines, already rendered from the model. */ readonly relationships: readonly string[]; /** Enum lines, already rendered from the model. */ @@ -263,8 +266,13 @@ export function renderAgentSchemaPage( if (input.views.length > 0) { out.push("## Views"); out.push(""); + // A model with no report view must render exactly what it rendered before FR-044 + // (no-churn: `meta verify --docs` would flag drift after an upgrade otherwise). + const hasReportView = input.views.some((v) => opts.reportViews?.has(input.qualify(v)) === true); out.push( - "A view is generated from its projection's `origin.*` children or its report's dimensions and measures — it is derived, " + + (hasReportView + ? "A view is generated from its projection's `origin.*` children or its report's dimensions and measures — it is derived, " + : "A view is generated from its projection's `origin.*` children — it is derived, ") + "never hand-written. Editing the view SQL directly is drift the tool cannot see.", ); out.push(""); diff --git a/server/typescript/packages/codegen-ts/test/agent-docs-surface.test.ts b/server/typescript/packages/codegen-ts/test/agent-docs-surface.test.ts index 9f72f7a13..343169f08 100644 --- a/server/typescript/packages/codegen-ts/test/agent-docs-surface.test.ts +++ b/server/typescript/packages/codegen-ts/test/agent-docs-surface.test.ts @@ -614,6 +614,44 @@ describe("agent/schema.md — the claims it makes about the model", () => { expect(page).not.toContain("one-to-one"); }); + // FR-044 no-churn: the Views intro names a report only when a report-backed view is on the + // page. A projection-only model must keep the wording it had before reports existed, or + // `meta verify --docs` flags drift after an upgrade. + test("the Views intro of a model with no report view is the pre-report sentence, byte for byte", async () => { + const page = (await emit(await load(SHAPES), { schema: fleetSchema() })).get("agent/schema.md") ?? ""; + expect(page).toContain( + "## Views\n\n" + + "A view is generated from its projection's `origin.*` children — it is derived, never hand-written. " + + "Editing the view SQL directly is drift the tool cannot see.\n\n", + ); + expect(page).not.toContain("report"); + }); + + test("the Views intro names a report's dimensions and measures once a report view is on the page", () => { + const fleet = fleetSchema(); + const base = { + ...fleet, + views: [{ name: "v_store_totals" }, { name: "v_owner_summary" }], + provenance: new Map([ + ...fleet.provenance, + ["public.v_store_totals", "acme::shop::StoreTotals"], + ]), + }; + const withReport = renderAgentSchemaPage(base, { + declaredBy: new Map(), viewLineage: new Map(), relationships: [], enums: [], + reportViews: new Set(["public.v_store_totals"]), + }); + expect(withReport).toContain( + "A view is generated from its projection's `origin.*` children or its report's dimensions and measures — " + + "it is derived, never hand-written. ", + ); + const without = renderAgentSchemaPage(base, { + declaredBy: new Map(), viewLineage: new Map(), relationships: [], enums: [], reportViews: new Set(), + }); + expect(without).toContain("A view is generated from its projection's `origin.*` children — it is derived, never hand-written. "); + expect(without).not.toContain("report's"); + }); + test("a view carries its `origin.*` lineage, which is what makes it a derived artifact", async () => { const page = (await emit(await load(SHAPES), { schema: fleetSchema() })).get("agent/schema.md") ?? ""; expect(page).toContain("## Views"); From 732f8e31fac5ba1c745cbf7f83bfac21ef386a40 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 09:22:55 -0400 Subject: [PATCH 20/32] feat(kotlin): Exposed table for a view-backed report (FR-044) KotlinExposedTableGenerator emits the read-only Exposed table of an object.report that declares a source.rdb @kind: view, with one column per derived field (contract Table B) taken from the JVM ReportShape. A report with no view, or over a kind the lowering skips, still generates nothing, and every other Kotlin generator still skips reports. A report table binds by literal (no names artifact is emitted for a report), types an enum column by the enum of the entity the dimension reads, and reads a derived decimal with no declared precision at 38,18 so Exposed does not round a ratio to four places. A derived field named after a Kotlin keyword, or two that land on one column property, is a generation error naming the report and the item. Also reserves schemaName as an Exposed Table member in safeColumnProperty: a column property of that name did not compile. Six hand-written reference tables put the Kotlin persistence lane on the six shared report scenarios. --- .../kotlin/KotlinExposedTableGenerator.kt | 204 ++++++++- .../generator/kotlin/KotlinNamesGenerator.kt | 3 +- .../generator/kotlin/KotlinNaming.kt | 2 +- .../kotlin/KotlinReportTableGeneratorTest.kt | 409 ++++++++++++++++++ .../generator/kotlin/ReportingInertTest.kt | 90 +++- .../KotlinCodegenMatchesReferenceTest.kt | 93 +++- .../integration/kotlin/QueryScenarioRunner.kt | 14 + .../kotlin/tables/AssetActivityView.kt | 27 ++ .../kotlin/tables/FitnessTotalsView.kt | 24 + .../kotlin/tables/ProgramMinutesView.kt | 40 ++ .../kotlin/tables/ProgramsByMonthView.kt | 30 ++ .../kotlin/tables/ProgramsByWeekView.kt | 24 + .../kotlin/tables/RecentProgramsView.kt | 21 + 13 files changed, 957 insertions(+), 24 deletions(-) create mode 100644 server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportTableGeneratorTest.kt create mode 100644 server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AssetActivityView.kt create mode 100644 server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/FitnessTotalsView.kt create mode 100644 server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramMinutesView.kt create mode 100644 server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramsByMonthView.kt create mode 100644 server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramsByWeekView.kt create mode 100644 server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/RecentProgramsView.kt diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt index ad6206879..317e6c673 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt @@ -15,20 +15,26 @@ import com.metaobjects.generator.kotlin.PackageMapping import com.metaobjects.MetaData import com.metaobjects.database.CoreDBMetaDataProvider import com.metaobjects.database.IndexNaming +import com.metaobjects.MetaRoot import com.metaobjects.field.EnumField import com.metaobjects.field.MapField import com.metaobjects.field.MetaField +import com.metaobjects.field.DecimalField import com.metaobjects.field.ObjectField +import com.metaobjects.generator.GeneratorException import com.metaobjects.generator.GeneratorIOWriter import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase import com.metaobjects.identity.MetaIdentity import com.metaobjects.identity.ReferenceIdentity import com.metaobjects.index.LookupIndex import com.metaobjects.loader.MetaDataLoader +import com.metaobjects.loader.ValidationPhase import com.metaobjects.`object`.MetaObject import com.metaobjects.relationship.CompositionRelationship import com.metaobjects.relationship.MetaRelationship import com.metaobjects.relationship.RelationshipReferences +import com.metaobjects.reporting.ReportReadModel +import com.metaobjects.reporting.ReportShape import com.metaobjects.source.MetaSource import com.metaobjects.source.RdbSource import com.squareup.kotlinpoet.ClassName @@ -44,6 +50,10 @@ import com.metaobjects.generator.util.GeneratedFileWriter * Generator: one Exposed Table `object` per `object.entity` that has a `source.rdb` child. * Entities without source.rdb are skipped (no persistence layer). * + *

A view-backed `object.report` (FR-044) also gets one — the read-only mapping of the view + * `meta migrate` creates, with one column per derived field. See [emitReport] for which + * reports emit and which do not. + * *

Exposed's `Column` types are inferred by the Kotlin compiler from the initialiser * expressions (e.g., `val name = varchar("name", 100)`). KotlinPoet's [com.squareup.kotlinpoet.PropertySpec] * requires an explicit type, which would force `val name: Column = ...` — verbose and @@ -154,6 +164,16 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBaseTable` of a view-backed `object.report`. + * + * Which reports emit (contract Table A): + * + * - no read-only source: NOTHING. The report has no view, so there is nothing to map + * (a sourceless object generates nothing, #248). + * - `source.rdb @kind: view`: the table object, bound to the source's physical name. + * The same for a view marked `@unmanaged: true` or carrying an authored `@sql` body: + * migrate does not derive (or does not create) that view, but the view exists and + * Table B still defines the columns a reader gets, so the mapping is needed either way. + * - `@kind: materializedView` / `storedProc` / `tableFunction`: NOTHING. The lowering + * skips those kinds, so no relation with the Table B columns is promised. + * - an abstract report: NOTHING. + * + * The columns are the report's DERIVED fields — one per dimension, then one per measure — + * taken from the JVM's single definition of that shape ([ReportShape], via + * [ReportReadModel], which presents them as ordinary field nodes). So the report goes + * through the same [emit] as a view-kind projection, with three differences, each keyed + * on the entity being a [ReportReadModel]: + * + * - it binds its view and columns by LITERAL even when the names generator is in the + * run ([bindsThroughNames]): [KotlinNamesGenerator] emits nothing for a report; + * - an enum column references the enum class of the entity the dimension reads + * ([enumClassFor]): no generator emits a per-report enum; + * - a derived decimal with no declared precision reads at [REPORT_DECIMAL_PRECISION] / + * [REPORT_DECIMAL_SCALE] ([scalarColumnSpec]). + * + * A report has no identity, so the table has no `primaryKey`, and no index or reference. + * Every other Kotlin generator skips reports. + */ + private fun emitReport( + report: MetaObject, + outRoot: Path, + loader: MetaDataLoader, + packagesNeedingInstantTzHelper: MutableSet, + packagesNeedingInetUriHelper: MutableSet, + packagesNeedingJacksonMapper: MutableSet, + packagesNeedingUuidStringHelper: MutableSet, + ) { + if (KotlinGenUtil.isAbstractEntity(report)) return + // The source the report is READ from, by the rule that names the lowered view. + val source = ReportShape.readSource(report) as? RdbSource ?: return + if (source.effectiveKind != MetaSource.KIND_VIEW) return + + refuseUncompilableReportColumns(report) + val model = ReportReadModel.of(report) + val pkg = PackageMapping.splitFqn(report.name).first + if (emit(model, source, outRoot, loader, emptyList(), emptyMap())) packagesNeedingInstantTzHelper += pkg + if (entityNeedsInetUriHelper(model, loader)) packagesNeedingInetUriHelper += pkg + if (entityNeedsJacksonMapper(model, loader)) packagesNeedingJacksonMapper += pkg + if (entityNeedsUuidStringHelper(model, loader)) packagesNeedingUuidStringHelper += pkg + } + + /** + * Refuse a report whose derived field names cannot become the column properties of one + * Kotlin `object`. `gen` would otherwise exit 0 and the adopter's build would be the + * first thing to disagree. + * + * Two cases, both loadable. A derived field named after a Kotlin hard keyword (`in`, + * `is`, `object`, …) is not a legal property name. And [KotlinNaming.safeColumnProperty] + * renames a field that collides with an Exposed `Table` member (`source` becomes + * `sourceColumn`), which can land on a second derived field already called that. + * + * Scoped to reports: an entity field has the same two hazards and they are left as they + * were. A report that generates no table (see [emitReport]) is never checked. + */ + private fun refuseUncompilableReportColumns(report: MetaObject) { + val seen = HashMap() + for (f in ReportShape.of(report).fields()) { + if (f.name in KOTLIN_HARD_KEYWORDS) { + throw GeneratorException( + "report \"${report.shortName}\": its ${describeItem(f)} generates the Exposed column " + + "property \"${f.name}\", and `${f.name}` is a Kotlin keyword, so the generated " + + "table would not compile. Rename the ${f.role.wireName()}." + ) + } + val property = KotlinNaming.safeColumnProperty(f.name) + val prior = seen.put(property, f) ?: continue + throw GeneratorException( + "report \"${report.shortName}\": its ${describeItem(prior)} and its ${describeItem(f)} both " + + "generate the Exposed column property \"$property\" (a name that collides with a member " + + "of Exposed's Table gets a \"Column\" suffix), so the generated table would not " + + "compile. Rename one of them." + ) + } + } + + /** + * `measure "x"` or `dimension "x"`. The derived name IS the item name here: only a time + * dimension derives a different one (``), and that is never a keyword, a + * `Table` member or a `…Column` name, so a time dimension is never refused. + */ + private fun describeItem(f: ReportShape.Field): String = "${f.role.wireName()} \"${f.name}\"" + + /** The derived field of [model]'s report that [field] (one of the model's fields) stands for. */ + private fun derivedField(model: ReportReadModel, field: MetaField<*>): ReportShape.Field = + ReportShape.of(model.report()).fields().first { it.name == field.name } + + /** + * Whether the table of [entity] references `Names` constants: only when the + * names generator is in the run ([useNames]) AND emits an artifact for [entity]. It + * emits none for a report (FR-044), so a report binds its view and columns by literal. + */ + private fun bindsThroughNames(entity: MetaObject): Boolean = useNames() && entity !is ReportReadModel + + /** + * The generated enum class a `field.enum` column of [entity]'s table is typed by. + * + * For an entity or projection that is [KotlinTypeMapper.enumTypeName] — the class + * [KotlinEntityGenerator] emits for it. A report gets no entity class and so no enum of + * its own; its enum column carries the values of the field the dimension (or min/max + * measure) reads, and is typed by THAT field's class: the one generated for the entity + * the item reads from. Without `@via` that is the report's `@from` entity (which is how + * a field `@from` inherits from an abstract base still names a class that exists); with + * `@via` it is the entity the `@of` reference names. + */ + private fun enumClassFor(field: MetaField<*>, entity: MetaObject): ClassName? { + if (entity !is ReportReadModel || field !is EnumField) return KotlinTypeMapper.enumTypeName(field, entity) + val report = entity.report() + val shape = ReportShape.of(report) + val derived = shape.fields().first { it.name == field.name } + val of = derived.typeSource + ?: error("report '${report.name}': enum field '${field.name}' has no type source") + val via = derived.dimension?.via + val owner = if (via == null) shape.from() else { + val root = generateSequence(report.parent) { it.parent }.filterIsInstance().first() + // Same resolution ReportShape applies to the reference: the member separator is + // the LAST dot, and the entity resolves relative to the @from entity's package. + ValidationPhase.resolveRootObject( + root, derived.dimension.of.substringBeforeLast('.'), shape.from().`package` ?: "", + ) ?: shape.from() + } + return KotlinTypeMapper.enumTypeName(of, owner) + } + + /** + * The Exposed column spec of a non-enum scalar [field] of [entity]: the type mapper's, + * except for a report's derived decimal that carries no declared precision (an `avg`, a + * ratio, or a `sum` of a decimal — Table B gives those no type source). + * + * Exposed's decimal column rounds every value it reads to the column's declared scale, + * so the mapper's default of four places would silently turn a ratio of 2/3 into 0.6667. + * These columns are an unconstrained NUMERIC in the view, so they are read at the widest + * precision and scale a Postgres NUMERIC is commonly declared with. The object maps a + * view, so the two numbers never reach DDL. + */ + private fun scalarColumnSpec(entity: MetaObject, field: MetaField<*>, colExpr: String, api: ExposedApi): String { + if (entity is ReportReadModel && field is DecimalField && derivedField(entity, field).typeSource == null) { + return "decimal($colExpr, $REPORT_DECIMAL_PRECISION, $REPORT_DECIMAL_SCALE)" + } + return KotlinTypeMapper.exposedColumnSpec(field, colExpr, api) + } + /** * True iff [entity] carries at least one `field.uri`/`field.inet` column — on a direct * field OR a flattened `object.value` sub-field. Mirrors the instant-tz detection inside @@ -516,7 +690,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase "$baseSpec.autoIncrement()" @@ -949,7 +1123,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase): String { - if (!useNames()) return "\"${KotlinGenUtil.resolveColumnName(f, columnNaming())}\"" + if (!bindsThroughNames(entity)) return "\"${KotlinGenUtil.resolveColumnName(f, columnNaming())}\"" // ADR-0039: metaFields is the RESOLVING accessor — an inherited field is a HIT here, // and the artifact of `entity` is where its constant is read from. if (entity.metaFields.any { it.name == f.name }) return ownColumnExpr(entity, f) @@ -996,7 +1170,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase): String { val (_, shortName) = PackageMapping.splitFqn(entity.name) - return if (useNames()) + return if (bindsThroughNames(entity)) "${KotlinNaming.namesObjectName(shortName)}.${KotlinNaming.namesMember(f.name)}_COLUMN" else "\"${KotlinGenUtil.resolveColumnName(f, columnNaming())}\"" } @@ -1175,6 +1349,24 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase = setOf( + "as", "break", "class", "continue", "do", "else", "false", "for", "fun", "if", "in", + "interface", "is", "null", "object", "package", "return", "super", "this", "throw", + "true", "try", "typealias", "typeof", "val", "var", "when", "while", + ) + /** * Exposed column suffix that renders a Postgres `DEFAULT gen_random_uuid()` * server-side mint on a native uuid column (R6 Plan 2a, `@generation: uuid`). @@ -2051,7 +2243,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase() for (entity in loader.metaObjects) { - // FR-044 Plan 1: object.report has no output until its lowering lands (Plan 2/3). + // FR-044: a report gets no names artifact. Its Exposed table (the one thing Kotlin + // generates for a view-backed report) binds its view and columns by literal. if (GeneratorUtil.isReport(entity)) continue if (emit(entity, outRoot, strategy)) emitted += entity.name } diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNaming.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNaming.kt index 34d46978b..22b8b544c 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNaming.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNaming.kt @@ -54,7 +54,7 @@ object KotlinNaming { */ val RESERVED_TABLE_MEMBERS: Set = setOf( "source", "fields", "columns", "index", "indices", "primaryKey", - "tableName", "ddl", "foreignKeys", "checkConstraints", "sequences", + "tableName", "schemaName", "ddl", "foreignKeys", "checkConstraints", "sequences", "autoIncColumn", "realFields", "defaultExpression", "generatedSignature", "tableNameWithoutScheme", "tableNameWithoutSchemeSanitized", ) diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportTableGeneratorTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportTableGeneratorTest.kt new file mode 100644 index 000000000..a72335ee9 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportTableGeneratorTest.kt @@ -0,0 +1,409 @@ +package com.metaobjects.generator.kotlin + +import com.metaobjects.generator.Generator +import com.metaobjects.generator.GeneratorException +import com.metaobjects.loader.MetaDataLoader +import com.metaobjects.metadata.ktx.loadDirectory +import com.metaobjects.metadata.ktx.loadString +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.TreeMap +import kotlin.io.path.isRegularFile +import kotlin.io.path.readText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * FR-044 — [KotlinExposedTableGenerator] emits the read-only Exposed table of a view-backed + * `object.report`, with one column per derived field (contract Table B), and nothing for a + * report that has no view. + * + * The six canonical reports are generated from the shared persistence corpus, the same + * model the hand-written reference tables in `integration-tests-kotlin` map and the + * persistence lane reads. + */ +@OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class) +class KotlinReportTableGeneratorTest { + + private fun canonicalDir(): Path { + var cur: Path? = Paths.get("").toAbsolutePath() + while (cur != null) { + val candidate = cur.resolve("fixtures/persistence-conformance/canonical") + if (Files.isDirectory(candidate)) return candidate + cur = cur.parent + } + throw IllegalStateException("Could not locate fixtures/persistence-conformance/canonical") + } + + /** Run [generators] over [loader] into one directory; relative path to contents. */ + private fun emit( + loader: MetaDataLoader, + args: Map = emptyMap(), + generators: List = listOf(KotlinExposedTableGenerator()), + ): Map { + val outDir = Files.createTempDirectory("report-table-") + try { + for (gen in generators) { + gen.setArgs(mapOf("outputDir" to outDir.toString(), "packageName" to "acme.shop") + args) + gen.execute(loader) + } + val files = TreeMap() + Files.walk(outDir).use { s -> + s.filter { it.isRegularFile() }.forEach { files[outDir.relativize(it).toString()] = it.readText() } + } + return files + } finally { + outDir.toFile().deleteRecursively() + } + } + + private fun canonical(args: Map = mapOf("columnNaming" to "literal")) = + emit(loadDirectory("report-table-canonical", canonicalDir()), args) + + private fun assertCompiles(files: Map) { + val sources = files.filterKeys { it.endsWith(".kt") } + .map { (path, text) -> SourceFile.kotlin(path.substringAfterLast('/'), text) } + val result = KotlinCompilation().apply { + this.sources = sources + inheritClassPath = true // Exposed, off the test classpath + messageOutputStream = System.out + }.compile() + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + } + + // --- The canonical reports --------------------------------------------------------- + + @Test + fun `a grouped report emits one column per derived field, typed and nullable by Table B`() { + assertEquals( + """ + |package fitness + | + |import org.jetbrains.exposed.sql.Table + | + |/** READ-ONLY VIEW — generated from view metadata; do not insert/update/delete directly. */ + |/** GENERATED — do not hand-edit. Regenerated from metadata. */ + |object ProgramMinutesTable : Table("v_program_minutes") { + | val program = long("program") + | val programTitle = varchar("programTitle", 200).nullable() + | val weeks = long("weeks") + | val longWeeks = long("longWeeks") + | val labels = long("labels") + | val slots = long("slots") + | val totalMinutes = long("totalMinutes").nullable() + | val avgMinutes = decimal("avgMinutes", 38, 18).nullable() + | val minMinutes = integer("minMinutes").nullable() + | val maxMinutes = integer("maxMinutes").nullable() + | val longShare = decimal("longShare", 38, 18).nullable() + |} + |""".trimMargin(), + canonical().getValue("fitness/ProgramMinutesTable.kt"), + ) + } + + @Test + fun `every canonical report emits its table and none has a primary key`() { + val files = canonical() + val reports = listOf( + "ProgramMinutes" to "v_program_minutes", "FitnessTotals" to "v_fitness_totals", + "ProgramsByMonth" to "v_programs_by_month", "ProgramsByWeek" to "v_programs_by_week", + "RecentPrograms" to "v_recent_programs", "AssetActivity" to "v_asset_activity", + ) + for ((report, view) in reports) { + val src = files.getValue("fitness/${report}Table.kt") + assertTrue("object ${report}Table : Table(\"$view\") {" in src, src) + assertFalse("primaryKey" in src, src) + assertFalse("init {" in src, src) + assertFalse(".references(" in src, src) + assertFalse("autoIncrement" in src, src) + } + } + + @Test + fun `a day-or-coarser bucket is a date and an enum dimension is typed by the source entity's enum`() { + val src = canonical().getValue("fitness/ProgramsByMonthTable.kt") + assertTrue("import org.jetbrains.exposed.sql.javatime.date\n" in src, src) + assertTrue(" val createdAtMonth = date(\"createdAtMonth\")\n" in src, src) + // Program.status's own generated class — no generator emits a ProgramsByMonthStatus. + assertTrue( + " val status = enumerationByName(\"status\", ${KotlinTypeMapper.ENUM_VARCHAR_LEN}, ProgramStatus::class)\n" in src, + src, + ) + assertFalse("ProgramsByMonthStatus" in src, src) + assertTrue(" val programs = long(\"programs\")\n" in src, src) + // A sum of a currency is integer minor units, and null over no rows. + assertTrue(" val listValue = long(\"listValue\").nullable()\n" in src, src) + } + + @Test + fun `an hour bucket of an instant is the instant column and brings the package helper`() { + val files = canonical() + val src = files.getValue("fitness/AssetActivityTable.kt") + assertTrue(" val recordedAtHour = instantWithTimeZone(\"recordedAtHour\")\n" in src, src) + assertTrue(" val asOfDateWeek = date(\"asOfDateWeek\")\n" in src, src) + assertTrue(" val assets = long(\"assets\")\n" in src, src) + assertTrue("fitness/MetaInstantWithTimeZoneColumnType.kt" in files.keys, files.keys.toString()) + } + + @Test + fun `the naming strategy applies to the derived field name`() { + val src = canonical(emptyMap()).getValue("fitness/ProgramMinutesTable.kt") // snake_case default + assertTrue(" val avgMinutes = decimal(\"avg_minutes\", 38, 18).nullable()\n" in src, src) + assertTrue(" val programTitle = varchar(\"program_title\", 200).nullable()\n" in src, src) + } + + @Test + fun `the Exposed 1x mode emits the same report table against the v1 packages`() { + val files = canonical(mapOf("columnNaming" to "literal", "exposedApi" to "1")) + val byMonth = files.getValue("fitness/ProgramsByMonthTable.kt") + assertTrue("import org.jetbrains.exposed.v1.core.Table\n" in byMonth, byMonth) + assertTrue("import org.jetbrains.exposed.v1.javatime.date\n" in byMonth, byMonth) + assertFalse("org.jetbrains.exposed.sql" in byMonth, byMonth) + assertTrue(" val createdAtMonth = date(\"createdAtMonth\")\n" in byMonth, byMonth) + val minutes = files.getValue("fitness/ProgramMinutesTable.kt") + assertTrue(" val avgMinutes = decimal(\"avgMinutes\", 38, 18).nullable()\n" in minutes, minutes) + } + + @Test + fun `with the names generator in the run a report still binds by literal and gets no names artifact`() { + val files = emit( + loadDirectory("report-table-names", canonicalDir()), + mapOf("columnNaming" to "literal", "useNames" to "true"), + listOf(KotlinNamesGenerator(), KotlinExposedTableGenerator()), + ) + // The entity beside it does reference its artifact, so the arg really was on. + assertTrue("ProgramNames." in files.getValue("fitness/ProgramTable.kt")) + val src = files.getValue("fitness/ProgramMinutesTable.kt") + assertTrue("object ProgramMinutesTable : Table(\"v_program_minutes\") {" in src, src) + assertTrue(" val weeks = long(\"weeks\")\n" in src, src) + assertFalse("Names" in src, src) + assertFalse(files.keys.any { it.endsWith("ProgramMinutesNames.kt") }, files.keys.toString()) + } + + // --- Which reports emit (Table A) -------------------------------------------------- + + /** A `Sale` entity with [measures], and a `SaleTotals` report over them with [reportSource]. */ + private fun model( + measures: List = listOf("sales"), + reportSource: String? = """{ "source.rdb": { "@kind": "view", "@view": "v_sale_totals" } }""", + extraMembers: String = "", + dimensions: List = emptyList(), + ): String { + val measureNodes = measures.joinToString(",\n") { + """{ "measure.aggregate": { "name": "$it", "@agg": "count", "@of": "Sale.id" } }""" + } + val children = reportSource?.let { """, "children": [ $it ]""" } ?: "" + val dims = if (dimensions.isEmpty()) "" else + """ "@dimensions": [${dimensions.joinToString(",") { "\"$it\"" }}],""" + return """{ + "metadata.root": { "package": "acme::shop", "children": [ + { "object.entity": { "name": "Sale", "children": [ + { "source.rdb": { "@table": "sales" } }, + { "field.long": { "name": "id" } }, + { "field.timestamp": { "name": "soldAt" } }, + { "field.string": { "name": "channel" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } }, + $extraMembers + $measureNodes + ] } }, + { "object.report": { "name": "SaleTotals", "@from": "Sale",$dims + "@measures": [${measures.joinToString(",") { "\"$it\"" }}]$children } } + ] } + }""" + } + + private fun reportFiles(json: String, args: Map = emptyMap()): Map = + emit(loadString("report-table-model", json), args).filterKeys { "SaleTotals" in it } + + @Test + fun `a sourceless report generates nothing`() { + assertEquals(emptyMap(), reportFiles(model(reportSource = null))) + } + + @Test + fun `a managed view-backed report generates its table`() { + val files = reportFiles(model()) + assertEquals(setOf("acme/shop/SaleTotalsTable.kt"), files.keys) + val src = files.values.single() + assertTrue("object SaleTotalsTable : Table(\"v_sale_totals\") {" in src, src) + assertTrue(" val sales = long(\"sales\")\n" in src, src) + } + + @Test + fun `an unmanaged view and an authored-sql view exist, so each still generates the table`() { + for (attrs in listOf( + """"@unmanaged": true""", + """"@sql": "SELECT COUNT(id) AS sales FROM sales"""", + )) { + val files = reportFiles(model( + reportSource = """{ "source.rdb": { "@kind": "view", "@view": "v_sale_totals", $attrs } }""")) + assertEquals(setOf("acme/shop/SaleTotalsTable.kt"), files.keys, attrs) + assertTrue(" val sales = long(\"sales\")\n" in files.values.single(), attrs) + } + } + + @Test + fun `a view named by the legacy table attr and a schema-qualified view bind that name`() { + val legacy = reportFiles(model( + reportSource = """{ "source.rdb": { "@kind": "view", "@table": "v_legacy" } }""")) + assertTrue("Table(\"v_legacy\")" in legacy.values.single(), legacy.values.single()) + val qualified = reportFiles(model( + reportSource = """{ "source.rdb": { "@kind": "view", "@view": "v_sale_totals", "@schema": "rpt" } }""")) + assertTrue("Table(\"rpt.v_sale_totals\")" in qualified.values.single(), qualified.values.single()) + } + + @Test + fun `a report over a kind the lowering skips generates nothing`() { + for (kind in listOf( + """"@kind": "materializedView", "@materializedView": "mv_sale_totals"""", + """"@kind": "storedProc", "@procedure": "sale_totals"""", + """"@kind": "tableFunction", "@function": "sale_totals"""", + )) { + assertEquals(emptyMap(), reportFiles(model(reportSource = """{ "source.rdb": { $kind } }""")), kind) + } + } + + // --- Names that would not compile --------------------------------------------------- + + @Test + fun `names that are SQL keywords or Exposed Table members compile`() { + // `order`, `user`, `group`, `rank` are ordinary dashboard names; the rest are (or + // look like) members of Exposed's Table, which safeColumnProperty renames. + val names = listOf( + "order", "user", "group", "rank", "count", "name", "columns", "tableName", "source", + "index", "fields", "primaryKey", "schemaName", + ) + val files = reportFiles(model(measures = names)) + val src = files.getValue("acme/shop/SaleTotalsTable.kt") + assertTrue(" val order = long(\"order\")\n" in src, src) + // The physical column keeps the derived name; only the Kotlin property is renamed. + assertTrue(" val columnsColumn = long(\"columns\")\n" in src, src) + assertTrue(" val tableNameColumn = long(\"table_name\")\n" in src, src) + assertTrue(" val schemaNameColumn = long(\"schema_name\")\n" in src, src) + assertCompiles(files) + } + + @Test + fun `a measure named after a Kotlin keyword is refused, naming the report and the measure`() { + for (keyword in listOf("in", "is", "object", "when", "fun")) { + val e = assertFailsWith(keyword) { reportFiles(model(measures = listOf("sales", keyword))) } + val message = e.message.orEmpty() + assertTrue("report \"SaleTotals\"" in message, message) + assertTrue("measure \"$keyword\"" in message, message) + assertTrue("Kotlin keyword" in message && "Rename the measure" in message, message) + } + } + + @Test + fun `a dimension named after a Kotlin keyword is refused, naming the dimension`() { + val e = assertFailsWith { + reportFiles(model( + extraMembers = """{ "dimension.attribute": { "name": "class", "@of": "Sale.channel" } },""", + dimensions = listOf("class"), + )) + } + val message = e.message.orEmpty() + assertTrue("report \"SaleTotals\"" in message && "dimension \"class\"" in message, message) + } + + @Test + fun `two derived fields that land on one column property are refused, naming both`() { + val e = assertFailsWith { + reportFiles(model(measures = listOf("source", "sourceColumn"))) + } + val message = e.message.orEmpty() + assertTrue("report \"SaleTotals\"" in message, message) + assertTrue("measure \"source\"" in message && "measure \"sourceColumn\"" in message, message) + assertTrue("\"sourceColumn\"" in message && "Rename one of them" in message, message) + } + + @Test + fun `a dimension and a measure that land on one column property are refused, naming both`() { + val e = assertFailsWith { + reportFiles(model( + measures = listOf("fieldsColumn"), + extraMembers = """{ "dimension.attribute": { "name": "fields", "@of": "Sale.channel" } },""", + dimensions = listOf("fields"), + )) + } + val message = e.message.orEmpty() + assertTrue("dimension \"fields\"" in message && "measure \"fieldsColumn\"" in message, message) + } + + @Test + fun `a time dimension's derived name is never refused`() { + val files = reportFiles(model( + extraMembers = """{ "dimension.time": { "name": "source", "@of": "Sale.soldAt", "@grains": ["day"] } },""", + dimensions = listOf("source:day"), + )) + assertTrue(" val sourceDay = date(\"source_day\")" in files.values.single(), files.values.single()) + } + + @Test + fun `a report that generates no table is not refused for its names`() { + assertEquals(emptyMap(), reportFiles(model(measures = listOf("in", "source", "sourceColumn"), reportSource = null))) + } + + @Test + fun `an enum dimension reached by via is typed by the enum of the entity it reads, and compiles`() { + val json = """{ + "metadata.root": { "package": "acme::shop", "children": [ + { "object.entity": { "name": "Store", "children": [ + { "source.rdb": { "@table": "stores" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "tier", "@values": ["GOLD", "SILVER"], "@required": true } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } } + ] } }, + { "object.entity": { "name": "Sale", "children": [ + { "source.rdb": { "@table": "sales" } }, + { "field.long": { "name": "id" } }, + { "field.long": { "name": "storeId", "@required": true } }, + { "field.enum": { "name": "channel", "@values": ["WEB", "SHOP"], "@intValueMap": { "WEB": 1, "SHOP": 2 }, "@required": true } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } }, + { "identity.reference": { "name": "storeRef", "@references": "Store", "@fields": ["storeId"] } }, + { "relationship.association": { "name": "store", "@objectRef": "Store", "@cardinality": "one" } }, + { "dimension.attribute": { "name": "storeTier", "@of": "Store.tier", "@via": "Sale.store" } }, + { "dimension.attribute": { "name": "channel", "@of": "Sale.channel" } }, + { "measure.aggregate": { "name": "sales", "@agg": "count", "@of": "Sale.id" } } + ] } }, + { "object.report": { "name": "SalesByTier", "@from": "Sale", + "@dimensions": ["storeTier", "channel"], "@measures": ["sales"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_sales_by_tier" } } ] } } + ] } + }""" + val files = emit( + loadString("report-table-via-enum", json), + generators = listOf(KotlinEntityGenerator(), KotlinExposedTableGenerator()), + ) + val src = files.getValue("acme/shop/SalesByTierTable.kt") + // Store's class, and nullable: a dimension reached by @via can be null. + assertTrue( + " val storeTier = enumerationByName(\"store_tier\", ${KotlinTypeMapper.ENUM_VARCHAR_LEN}, StoreTier::class).nullable()\n" in src, + src, + ) + // An int-backed enum keeps its mapping, typed by Sale's class. + assertTrue(" val channel = customEnumeration(\"channel\", \"INTEGER\", " in src, src) + assertTrue("1 -> SaleChannel.WEB" in src && "SaleChannel.SHOP -> 2" in src, src) + assertFalse("SalesByTier" in src.replace("SalesByTierTable", ""), src) + assertCompiles(files) + } + + // --- The emitted reports build ------------------------------------------------------ + + @Test + fun `the canonical report tables compile beside the entities they reference`() { + val files = emit( + loadDirectory("report-table-compile", canonicalDir()), + mapOf("columnNaming" to "literal", "packageName" to "fitness"), + listOf(KotlinEntityGenerator(), KotlinExposedTableGenerator()), + ) + assertTrue(files.keys.count { it.endsWith("Table.kt") } >= 6, files.keys.toString()) + assertCompiles(files) + } +} diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt index 0eeabc7a9..315c8c569 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt @@ -18,16 +18,20 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue /** - * FR-044 Plan 1 — the reporting vocabulary is INERT in every Kotlin generator. + * FR-044 — what the reporting vocabulary generates in Kotlin, and what stays INERT. * - * Plan 1 registers `dimension.*`, `measure.*`, `segment.*` and `object.report` and validates - * them at load, but gives none of them output: a report's lowering lands in Plan 2/3. Until - * then a model that USES the vocabulary must generate exactly what the same model without it - * generates, byte for byte, through every generator in [GENERATOR_REGISTRY]. + * `dimension.*`, `measure.*` and `segment.*` generate nothing anywhere. An `object.report` + * with no read-only source generates nothing anywhere. A report that declares a read-only + * `source.rdb @kind: view` is lowered to that view (by TypeScript migrate), and Kotlin + * generates exactly one thing for it: its read-only Exposed table, from + * [KotlinExposedTableGenerator]. Every other generator in [GENERATOR_REGISTRY] — entity, + * names, relations, repository, controller, filter allowlist, the docs tier — emits for a + * model that USES the vocabulary exactly what it emits for the same model without it, byte + * for byte. * * The model pair is `fixtures/codegen-noop/reporting/{with,without}`, shared with the other - * four ports' copies of this test. `with/` carries a report that declares a read-only - * `source.rdb @kind: view` (R5 allows one) — the shape that leaked in C#. + * four ports' copies of this test. `with/` carries two sourceless reports + * (`ProgramEngagement`, `DailyRevenue`) and one view-backed one (`StoreTotals`). */ class ReportingInertTest { @@ -82,14 +86,28 @@ class ReportingInertTest { } } - private fun sameOrLeak(label: String, expected: Map, actual: Map): String? { - if (expected.keys.toList() != actual.keys.toList()) { - return "$label: emitted file set ${expected.keys} became ${actual.keys}" + /** + * Null when [actual] is [expected] plus exactly the files in [added] (path to contents, + * empty for a generator that must stay inert); else what leaked. + */ + private fun sameOrLeak( + label: String, + expected: Map, + actual: Map, + added: Map = emptyMap(), + ): String? { + val wanted = TreeMap(expected).apply { putAll(added) } + if (wanted.keys.toList() != actual.keys.toList()) { + return "$label: emitted file set ${wanted.keys} became ${actual.keys}" } - val differing = expected.keys.filter { expected[it] != actual[it] } + val differing = wanted.keys.filter { wanted[it] != actual[it] } return if (differing.isEmpty()) null else "$label: $differing differ once reporting nodes are declared" } + /** What a generator may add for the with-model: the view-backed report's table, and only from `exposed-table`. */ + private fun allowedFor(info: GeneratorInfo): Map = + if (info.name == EXPOSED_TABLE) mapOf(STORE_TOTALS_TABLE_PATH to STORE_TOTALS_TABLE) else emptyMap() + @Test fun `the with-model really carries the vocabulary`() { // Else every comparison below is vacuously green. @@ -102,15 +120,36 @@ class ReportingInertTest { } @Test - fun `every generator emits the same files with and without reporting nodes`() { + fun `only the Exposed table generator emits for a report, and only the view-backed one's table`() { // Every generator is compared before anything is asserted, so one red run names // every leak rather than the first. val leaks = GENERATOR_REGISTRY.values.mapNotNull { info -> - sameOrLeak(info.name, emit("without", listOf(info)), emit("with", listOf(info))) + sameOrLeak(info.name, emit("without", listOf(info)), emit("with", listOf(info)), allowedFor(info)) } assertTrue(leaks.isEmpty(), leaks.joinToString("\n")) } + @Test + fun `the view-backed report emits exactly its Exposed table`() { + // Else the allowance above is vacuous: the table really is emitted, with this content. + val info = GENERATOR_REGISTRY.getValue(EXPOSED_TABLE) + val added = emit("with", listOf(info)) - emit("without", listOf(info)).keys + assertEquals(mapOf(STORE_TOTALS_TABLE_PATH to STORE_TOTALS_TABLE), added) + } + + @Test + fun `a sourceless report appears in no generated file`() { + val files = emit("with", GENERATOR_REGISTRY.values.toList()) + assertFalse(THREW in files, "the combined suite threw: ${files[THREW]}") + for (report in listOf("ProgramEngagement", "DailyRevenue")) { + val hits = files.filter { (path, text) -> report in path || report in text }.keys + assertTrue(hits.isEmpty(), "$report leaked into $hits") + } + // The view-backed report is named by its table and by nothing else. + val hits = files.filter { (path, text) -> "StoreTotals" in path || "StoreTotals" in text }.keys + assertEquals(setOf(STORE_TOTALS_TABLE_PATH), hits) + } + @Test fun `exactly these generators cannot run from a bare model`() { // Each is compared above on its error message alone, which proves nothing about its @@ -126,13 +165,14 @@ class ReportingInertTest { val expected = emit("without", runnable) assertFalse(THREW in expected, "the combined suite threw: ${expected[THREW]}") assertTrue(expected.size > 10, "only ${expected.size} files — the suite barely ran") - val leak = sameOrLeak("combined", expected, emit("with", runnable)) + val leak = sameOrLeak( + "combined", expected, emit("with", runnable), mapOf(STORE_TOTALS_TABLE_PATH to STORE_TOTALS_TABLE)) assertTrue(leak == null, leak) } /** * The api docs surface: every unit page, the index and the agent page. A report has no - * generated API to document, and its derived fields do not exist until its lowering lands. + * generated API to document — no route, repository or DTO — whether or not it has a view. */ private fun apiDocs(variant: String): Map { val model = KotlinApiModelBuilder().build(load(variant), "shop") @@ -157,5 +197,25 @@ class ReportingInertTest { private companion object { const val THREW = "" + + /** The registry id of [KotlinExposedTableGenerator]. */ + const val EXPOSED_TABLE = "exposed-table" + + const val STORE_TOTALS_TABLE_PATH = "acme/shop/StoreTotalsTable.kt" + + /** `StoreTotals`: three measures over `Purchase` — two counts and a sum of a currency. */ + val STORE_TOTALS_TABLE = """ + |package acme.shop + | + |import org.jetbrains.exposed.sql.Table + | + |/** READ-ONLY VIEW — generated from view metadata; do not insert/update/delete directly. */ + |/** GENERATED — do not hand-edit. Regenerated from metadata. */ + |object StoreTotalsTable : Table("v_store_totals") { + | val purchases = long("purchases") + | val buyers = long("buyers") + | val revenue = long("revenue").nullable() + |} + |""".trimMargin() } } diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/KotlinCodegenMatchesReferenceTest.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/KotlinCodegenMatchesReferenceTest.kt index 27b35ef5e..73bf6a2a2 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/KotlinCodegenMatchesReferenceTest.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/KotlinCodegenMatchesReferenceTest.kt @@ -104,6 +104,62 @@ internal class KotlinCodegenMatchesReferenceTest { ExpectedColumn("recordedAt", families = setOf("instantWithTimeZone")), ), ), + // FR-044: the six view-backed reports. Each mirrors its hand-written reference + // (`tables/View.kt`) column for column: the family is the view's real column + // type, and a column is nullable exactly when the reference's is. A report has no + // identity, so none carries a primary key. + "ProgramMinutes" to EntityExpectation( + columns = listOf( + ExpectedColumn("program", families = setOf("long"), nullable = false), + ExpectedColumn("programTitle", families = setOf("varchar"), nullable = true), + ExpectedColumn("weeks", families = setOf("long"), nullable = false), + ExpectedColumn("longWeeks", families = setOf("long"), nullable = false), + ExpectedColumn("labels", families = setOf("long"), nullable = false), + ExpectedColumn("slots", families = setOf("long"), nullable = false), + ExpectedColumn("totalMinutes", families = setOf("long"), nullable = true), + ExpectedColumn("avgMinutes", families = setOf("decimal"), nullable = true), + ExpectedColumn("minMinutes", families = setOf("integer"), nullable = true), + ExpectedColumn("maxMinutes", families = setOf("integer"), nullable = true), + ExpectedColumn("longShare", families = setOf("decimal"), nullable = true), + ), + report = "v_program_minutes", + ), + "FitnessTotals" to EntityExpectation( + columns = listOf( + ExpectedColumn("weeks", families = setOf("long"), nullable = false), + ExpectedColumn("totalMinutes", families = setOf("long"), nullable = true), + ExpectedColumn("longShare", families = setOf("decimal"), nullable = true), + ), + report = "v_fitness_totals", + ), + "ProgramsByMonth" to EntityExpectation( + columns = listOf( + ExpectedColumn("createdAtMonth", families = setOf("date"), nullable = false), + ExpectedColumn("status", families = setOf("varchar", "enumerationByName"), nullable = false), + ExpectedColumn("programs", families = setOf("long"), nullable = false), + ExpectedColumn("listValue", families = setOf("long"), nullable = true), + ), + report = "v_programs_by_month", + ), + "ProgramsByWeek" to EntityExpectation( + columns = listOf( + ExpectedColumn("createdAtWeek", families = setOf("date"), nullable = false), + ExpectedColumn("programs", families = setOf("long"), nullable = false), + ), + report = "v_programs_by_week", + ), + "RecentPrograms" to EntityExpectation( + columns = listOf(ExpectedColumn("programs", families = setOf("long"), nullable = false)), + report = "v_recent_programs", + ), + "AssetActivity" to EntityExpectation( + columns = listOf( + ExpectedColumn("recordedAtHour", families = setOf("instantWithTimeZone"), nullable = false), + ExpectedColumn("asOfDateWeek", families = setOf("date"), nullable = false), + ExpectedColumn("assets", families = setOf("long"), nullable = false), + ), + report = "v_asset_activity", + ), ) @Test @@ -124,6 +180,7 @@ internal class KotlinCodegenMatchesReferenceTest { val source = tableFile.readText() assertSourceContainsColumns(entity, source, expected.columns) assertSourceContainsForeignKeys(entity, source, expected.foreignKeys) + expected.report?.let { view -> assertSourceIsExactlyTheReportTable(entity, source, view, expected.columns) } } } finally { outDir.deleteRecursively() @@ -147,9 +204,41 @@ internal class KotlinCodegenMatchesReferenceTest { "${entity}Table.kt: expected `val ${col.name} = <${col.families.joinToString("|")}>(...)`. " + "Source was:\n$source" ) + // Nullability is asserted only where the expectation states it (the reports). + col.nullable?.let { nullable -> + val line = source.lineSequence().first { Regex("""\bval\s+${Regex.escape(col.name)}\s*=""").containsMatchIn(it) } + assertTrue( + line.trimEnd().endsWith(".nullable()") == nullable, + "${entity}Table.kt: expected column '${col.name}' to be ${if (nullable) "nullable" else "non-null"}; saw `${line.trim()}`" + ) + } } } + /** + * A report's table (FR-044) is held tighter than an entity's: it binds [view], declares + * EXACTLY the expected columns in the expected order (the derived fields — dimensions, + * then measures — and nothing else), and has no primary key, because a report has no + * identity. + */ + private fun assertSourceIsExactlyTheReportTable( + entity: String, + source: String, + view: String, + expected: List, + ) { + assertTrue( + "object ${entity}Table : Table(\"$view\")" in source, + "${entity}Table.kt: expected the table to bind the view '$view'; saw:\n$source", + ) + val declared = Regex("""^\s*val\s+(\w+)\s*=""", RegexOption.MULTILINE).findAll(source).map { it.groupValues[1] }.toList() + assertTrue( + declared == expected.map { it.name }, + "${entity}Table.kt: expected exactly the columns ${expected.map { it.name }} in that order; saw $declared", + ) + assertTrue("primaryKey" !in source, "${entity}Table.kt: a report has no identity, so no primaryKey; saw:\n$source") + } + /** * Assert each expected FK is present as a `.references(.id...)` * decoration on the named column. Catches the regression where the generator @@ -173,10 +262,12 @@ internal class KotlinCodegenMatchesReferenceTest { } } - private data class ExpectedColumn(val name: String, val families: Set) + private data class ExpectedColumn(val name: String, val families: Set, val nullable: Boolean? = null) private data class ExpectedFk(val columnName: String, val targetTable: String) private data class EntityExpectation( val columns: List, val foreignKeys: List = emptyList(), + /** For an `object.report` (FR-044): the view its table binds. Null for every other object. */ + val report: String? = null, ) } diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/QueryScenarioRunner.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/QueryScenarioRunner.kt index dde93bf71..149c5f8c7 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/QueryScenarioRunner.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/QueryScenarioRunner.kt @@ -16,6 +16,12 @@ import com.metaobjects.integration.kotlin.tables.ProgramStatView import com.metaobjects.integration.kotlin.tables.ProgramTable import com.metaobjects.integration.kotlin.tables.ProgramView import com.metaobjects.integration.kotlin.tables.WeekTable +import com.metaobjects.integration.kotlin.tables.AssetActivityView +import com.metaobjects.integration.kotlin.tables.FitnessTotalsView +import com.metaobjects.integration.kotlin.tables.ProgramMinutesView +import com.metaobjects.integration.kotlin.tables.ProgramsByMonthView +import com.metaobjects.integration.kotlin.tables.ProgramsByWeekView +import com.metaobjects.integration.kotlin.tables.RecentProgramsView import org.jetbrains.exposed.sql.AndOp import org.jetbrains.exposed.sql.Column import org.jetbrains.exposed.sql.Database @@ -484,6 +490,14 @@ object QueryScenarioRunner { "Measurement" -> MeasurementTable "ProgramStat" -> ProgramStatView "ProgramView" -> ProgramView + // FR-044: a view-backed report is read through the view its lowering created. It has + // no primary key, so only `list` and `count` reach these. + "ProgramMinutes" -> ProgramMinutesView + "FitnessTotals" -> FitnessTotalsView + "ProgramsByMonth" -> ProgramsByMonthView + "ProgramsByWeek" -> ProgramsByWeekView + "RecentPrograms" -> RecentProgramsView + "AssetActivity" -> AssetActivityView "Asset" -> AssetTable "AllTypes" -> AllTypesTable // FR-017 TPH: the discriminator base + all its subtypes share the single `auths` table. diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AssetActivityView.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AssetActivityView.kt new file mode 100644 index 000000000..599ed3f85 --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/AssetActivityView.kt @@ -0,0 +1,27 @@ +package com.metaobjects.integration.kotlin.tables + +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.javatime.date + +/** + * Hand-written reference Exposed Table mapping the `AssetActivity` report (FR-044) from + * `fixtures/persistence-conformance/canonical/meta.fitness.json`. + * + * Backed by the Postgres VIEW `v_asset_activity`, created by the committed canonical DDL + * (`fixtures/persistence-conformance/canonical/schema.postgres.sql`); this object is purely + * the read-only query mapping, as [ProgramStatView] is for a projection. + * + * - `recordedAtHour` = the hour bucket of Asset.recordedAt, an instant → TIMESTAMPTZ → + * [instantWithTimeZone], the same `Column` [AssetTable] reads the base + * column with (so it normalizes to the `…Z` wire form) + * - `asOfDateWeek` = the ISO week bucket of Asset.asOfDate, a date → DATE → `date` + * - `assets` = a count → BIGINT → `long` + * + * A report has no identity, so there is no `primaryKey`: it is listed and counted, never + * fetched by id. Column names are the derived field names (the corpus's `literal` naming). + */ +object AssetActivityView : Table("v_asset_activity") { + val recordedAtHour = instantWithTimeZone("recordedAtHour") + val asOfDateWeek = date("asOfDateWeek") + val assets = long("assets") +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/FitnessTotalsView.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/FitnessTotalsView.kt new file mode 100644 index 000000000..4fb81819f --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/FitnessTotalsView.kt @@ -0,0 +1,24 @@ +package com.metaobjects.integration.kotlin.tables + +import org.jetbrains.exposed.sql.Table + +/** + * Hand-written reference Exposed Table mapping the `FitnessTotals` report (FR-044) from + * `fixtures/persistence-conformance/canonical/meta.fitness.json`. + * + * Backed by the Postgres VIEW `v_fitness_totals`, created by the committed canonical DDL + * (`fixtures/persistence-conformance/canonical/schema.postgres.sql`); this object is purely + * the read-only query mapping, as [ProgramStatView] is for a projection. + * + * No dimensions, so the view returns exactly one row, over an empty table too: `weeks` + * (a count, BIGINT) is then `0`, and `totalMinutes` (a sum, BIGINT) and `longShare` (a ratio, + * NUMERIC) are NULL — hence nullable. + * + * A report has no identity, so there is no `primaryKey`: it is listed and counted, never + * fetched by id. Column names are the derived field names (the corpus's `literal` naming). + */ +object FitnessTotalsView : Table("v_fitness_totals") { + val weeks = long("weeks") + val totalMinutes = long("totalMinutes").nullable() + val longShare = decimal("longShare", 38, 18).nullable() +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramMinutesView.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramMinutesView.kt new file mode 100644 index 000000000..2fd7108eb --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramMinutesView.kt @@ -0,0 +1,40 @@ +package com.metaobjects.integration.kotlin.tables + +import org.jetbrains.exposed.sql.Table + +/** + * Hand-written reference Exposed Table mapping the `ProgramMinutes` report (FR-044) from + * `fixtures/persistence-conformance/canonical/meta.fitness.json`. + * + * Backed by the Postgres VIEW `v_program_minutes`, created by the committed canonical DDL + * (`fixtures/persistence-conformance/canonical/schema.postgres.sql`); this object is purely + * the read-only query mapping, as [ProgramStatView] is for a projection. + * + * One column per derived field (dimensions, then measures), each mirroring the view's REAL + * column type and nullable exactly where `fixtures/persistence-conformance/report-shapes.json` + * says `required: false`: + * - `program` = Week.programId (required FK) → BIGINT → `long` + * - `programTitle` = Program.title, reached by @via → VARCHAR → `varchar`, nullable + * - a `count`, with or without @distinct → BIGINT → `long` (never null) + * - `sum` of an int, cast by the view → BIGINT → `long`, nullable + * - `avg`, and a ratio → NUMERIC → `decimal`, nullable + * - `min` / `max` of an int → INTEGER → `integer`, nullable + * The decimal precision and scale never reach DDL (this maps a view); they are the scale + * Exposed reads the unconstrained NUMERIC back at. + * + * A report has no identity, so there is no `primaryKey`: it is listed and counted, never + * fetched by id. Column names are the derived field names (the corpus's `literal` naming). + */ +object ProgramMinutesView : Table("v_program_minutes") { + val program = long("program") + val programTitle = varchar("programTitle", 200).nullable() + val weeks = long("weeks") + val longWeeks = long("longWeeks") + val labels = long("labels") + val slots = long("slots") + val totalMinutes = long("totalMinutes").nullable() + val avgMinutes = decimal("avgMinutes", 38, 18).nullable() + val minMinutes = integer("minMinutes").nullable() + val maxMinutes = integer("maxMinutes").nullable() + val longShare = decimal("longShare", 38, 18).nullable() +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramsByMonthView.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramsByMonthView.kt new file mode 100644 index 000000000..3ed7d789e --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramsByMonthView.kt @@ -0,0 +1,30 @@ +package com.metaobjects.integration.kotlin.tables + +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.javatime.date + +/** + * Hand-written reference Exposed Table mapping the `ProgramsByMonth` report (FR-044) from + * `fixtures/persistence-conformance/canonical/meta.fitness.json`. + * + * Backed by the Postgres VIEW `v_programs_by_month`, created by the committed canonical DDL + * (`fixtures/persistence-conformance/canonical/schema.postgres.sql`); this object is purely + * the read-only query mapping, as [ProgramStatView] is for a projection. + * + * - `createdAtMonth` = the month bucket of Program.createdAt → DATE (the first day of the + * month) → `date` + * - `status` = Program.status, an enum → VARCHAR; read as its member symbol, the + * way [ProgramTable] reads the base column + * - `programs` = a count → BIGINT → `long` + * - `listValue` = a filtered sum of a currency → BIGINT minor units → `long`, nullable + * (NULL when no row in the group matches the measure's filter) + * + * A report has no identity, so there is no `primaryKey`: it is listed and counted, never + * fetched by id. Column names are the derived field names (the corpus's `literal` naming). + */ +object ProgramsByMonthView : Table("v_programs_by_month") { + val createdAtMonth = date("createdAtMonth") + val status = varchar("status", 64) + val programs = long("programs") + val listValue = long("listValue").nullable() +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramsByWeekView.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramsByWeekView.kt new file mode 100644 index 000000000..441b67d15 --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/ProgramsByWeekView.kt @@ -0,0 +1,24 @@ +package com.metaobjects.integration.kotlin.tables + +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.javatime.date + +/** + * Hand-written reference Exposed Table mapping the `ProgramsByWeek` report (FR-044) from + * `fixtures/persistence-conformance/canonical/meta.fitness.json`. + * + * Backed by the Postgres VIEW `v_programs_by_week`, created by the committed canonical DDL + * (`fixtures/persistence-conformance/canonical/schema.postgres.sql`); this object is purely + * the read-only query mapping, as [ProgramStatView] is for a projection. + * + * - `createdAtWeek` = the ISO week bucket of Program.createdAt → DATE (the Monday that + * starts the week) → `date` + * - `programs` = a count → BIGINT → `long` + * + * A report has no identity, so there is no `primaryKey`: it is listed and counted, never + * fetched by id. Column names are the derived field names (the corpus's `literal` naming). + */ +object ProgramsByWeekView : Table("v_programs_by_week") { + val createdAtWeek = date("createdAtWeek") + val programs = long("programs") +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/RecentProgramsView.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/RecentProgramsView.kt new file mode 100644 index 000000000..6b02e2419 --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/RecentProgramsView.kt @@ -0,0 +1,21 @@ +package com.metaobjects.integration.kotlin.tables + +import org.jetbrains.exposed.sql.Table + +/** + * Hand-written reference Exposed Table mapping the `RecentPrograms` report (FR-044) from + * `fixtures/persistence-conformance/canonical/meta.fitness.json`. + * + * Backed by the Postgres VIEW `v_recent_programs`, created by the committed canonical DDL + * (`fixtures/persistence-conformance/canonical/schema.postgres.sql`); this object is purely + * the read-only query mapping, as [ProgramStatView] is for a projection. + * + * One measure and no dimensions: a single row holding a count (BIGINT → `long`) of the + * programs created in the last 30 days, evaluated when the view is queried. + * + * A report has no identity, so there is no `primaryKey`: it is listed and counted, never + * fetched by id. Column names are the derived field names (the corpus's `literal` naming). + */ +object RecentProgramsView : Table("v_recent_programs") { + val programs = long("programs") +} From 134a033042b40d1638d4717baed7f27793baaf25 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 09:42:06 -0400 Subject: [PATCH 21/32] fix(kotlin): document report table generation and its refusals; reserve Exposed 1.x table members (FR-044) --- .claude/rules/cross-language-porting.md | 2 +- CHANGELOG.md | 10 +- docs/features/reporting.md | 9 +- docs/ports/kotlin.md | 41 ++++++ .../registry.json | 2 +- .../generator/util/GeneratorUtil.java | 16 ++- .../exposed1x/Exposed1xCodegenCompileTest.kt | 44 ++++++ .../generator/kotlin/GeneratorRegistry.kt | 2 +- .../kotlin/KotlinExposedTableGenerator.kt | 128 +++++++++++------- .../generator/kotlin/KotlinNaming.kt | 21 ++- .../kotlin/KotlinRelationsGenerator.kt | 2 +- .../kotlin/KotlinSpringControllerGenerator.kt | 10 +- .../kotlin/KotlinReservedTableMembersTest.kt | 108 +++++++++++++++ .../metaobjects/reporting/ReportShape.java | 19 ++- 14 files changed, 340 insertions(+), 74 deletions(-) create mode 100644 server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReservedTableMembersTest.kt diff --git a/.claude/rules/cross-language-porting.md b/.claude/rules/cross-language-porting.md index 1cbac9f4b..ccd8b7eef 100644 --- a/.claude/rules/cross-language-porting.md +++ b/.claude/rules/cross-language-porting.md @@ -17,7 +17,7 @@ Preserve the following contracts exactly across all language ports: **Metamodel subtype vocabularies (must be identical across languages):** the `registry-conformance` gate (`fixtures/registry-conformance/`) is the structural enforcer of this rule — each port emits its registry as a canonical manifest byte-matched to `expected-registry.json`. **All five ports (TS / C# / Java / Kotlin / Python) are live + green** (SP-G Java/Kotlin reconciliation complete; the JVM runners compose from the defined metamodel provider set so codegen-base/om classpath SPI does not pollute the measured vocabulary). See `fixtures/registry-conformance/README.md`. - Filter operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `like`, `isNull` -- Object subtypes: `entity` (owns data: own identity, writable sources, lifecycle), `value` (pure shape: NO identity, NO source, ever; constructed — by caller/embedding — never populated; may `extends` entity fields for shape; a value-hosted field may carry `origin.passthrough` but never an assembly origin), `projection` (derived read-only representation: fields `extends`-bound / origin-derived / self-declared-under-external-assembly, all read-only at subtype level; identity optional and MUST extend an entity identity; sources restricted to read-only `@kind`s; the declared field set IS the exposure — inclusive list, fail-closed). A field carrying `origin.*` is derived ⇒ read-only wherever it lives (incl. on entities). An entity's primary source must be a writable `@kind` (read-only kinds only in read role). See [ADR-0028](spec/decisions/ADR-0028-object-taxonomy-projection-value-purity.md). (FR-024 Phase E — `object.projection`/`value` are registered in `expected-registry.json` and the projection/value validation passes [identity pass-through, value-purity, projection-licensing, `@via` inference/cardinality, extends/origin agreement, derived-field providability] are enforced cross-port in all 5 ports. The **B4b** entity-primary-source-readonly cutover [the "writable `@kind`" clause above — `ERR_ENTITY_PRIMARY_SOURCE_READONLY`] + the projection codegen fan-out (read-only DTOs for view-kind projections; FR-015 proc-callables for proc-kind projections in TypeScript, C# and Kotlin ONLY — Java and Python ship no callable generator at all, so the cross-port claim does NOT cover that clause; api-docs label `object.projection` units as `projection` and document their generated `Dto`) are now shipped cross-port; the remaining FR-024 work is the declared-API surface — tracked in #10.) **`report` (FR-044 Plan 1)** is a root object subtype registered in all five ports, with the `dimension.attribute` / `dimension.time` / `measure.aggregate` / `measure.ratio` / `segment.filter` children on `object.entity` and the relative-date filter value — loader-validated (`ERR_INVALID_DIMENSION` / `ERR_INVALID_MEASURE` / `ERR_INVALID_REPORT` / `ERR_REPORT_FOREIGN_MEASURE`, plus `ERR_BAD_ATTR_FILTER` for relative dates off a reporting host) and, since FR-044 Plan 2, **lowered only when it declares a read-only `source.rdb` of `@kind: view`**: `meta migrate` creates that view (TypeScript only, ADR-0015), every port reads it (persistence corpus, `report-shapes.json`), C# and Kotlin generate its typed row, and everything else (routes, typed clients, filter allowlists, api-docs, and a report with no view source at all) stays inert, gated by the 27 `reporting` conformance fixtures and the `codegen-noop` corpus. See [docs/features/reporting.md](docs/features/reporting.md). +- Object subtypes: `entity` (owns data: own identity, writable sources, lifecycle), `value` (pure shape: NO identity, NO source, ever; constructed — by caller/embedding — never populated; may `extends` entity fields for shape; a value-hosted field may carry `origin.passthrough` but never an assembly origin), `projection` (derived read-only representation: fields `extends`-bound / origin-derived / self-declared-under-external-assembly, all read-only at subtype level; identity optional and MUST extend an entity identity; sources restricted to read-only `@kind`s; the declared field set IS the exposure — inclusive list, fail-closed). A field carrying `origin.*` is derived ⇒ read-only wherever it lives (incl. on entities). An entity's primary source must be a writable `@kind` (read-only kinds only in read role). See [ADR-0028](spec/decisions/ADR-0028-object-taxonomy-projection-value-purity.md). (FR-024 Phase E — `object.projection`/`value` are registered in `expected-registry.json` and the projection/value validation passes [identity pass-through, value-purity, projection-licensing, `@via` inference/cardinality, extends/origin agreement, derived-field providability] are enforced cross-port in all 5 ports. The **B4b** entity-primary-source-readonly cutover [the "writable `@kind`" clause above — `ERR_ENTITY_PRIMARY_SOURCE_READONLY`] + the projection codegen fan-out (read-only DTOs for view-kind projections; FR-015 proc-callables for proc-kind projections in TypeScript, C# and Kotlin ONLY — Java and Python ship no callable generator at all, so the cross-port claim does NOT cover that clause; api-docs label `object.projection` units as `projection` and document their generated `Dto`) are now shipped cross-port; the remaining FR-024 work is the declared-API surface — tracked in #10.) **`report` (FR-044 Plan 1)** is a root object subtype registered in all five ports, with the `dimension.attribute` / `dimension.time` / `measure.aggregate` / `measure.ratio` / `segment.filter` children on `object.entity` and the relative-date filter value — loader-validated (`ERR_INVALID_DIMENSION` / `ERR_INVALID_MEASURE` / `ERR_INVALID_REPORT` / `ERR_REPORT_FOREIGN_MEASURE`, plus `ERR_BAD_ATTR_FILTER` for relative dates off a reporting host) and, since FR-044 Plan 2, **lowered only when it declares a read-only `source.rdb` of `@kind: view`**: `meta migrate` creates that view (TypeScript only, ADR-0015), every port reads it (persistence corpus, `report-shapes.json`), C# generates its typed row and Kotlin its Exposed table object, and everything else (routes, typed clients, filter allowlists, api-docs, and a report with no view source at all) stays inert, gated by the 27 `reporting` conformance fixtures and the `codegen-noop` corpus. See [docs/features/reporting.md](docs/features/reporting.md). - Source subtypes: `rdb` (paradigm; ADR-0007). The pre-v2 `dbTable`/`dbView` subtypes are RETIRED — `source.rdb` + `@kind: table|view|materializedView|storedProc|tableFunction` is the form, with read-only-ness derived from `@kind`. Multi-source via `@role` (exactly one `primary` per object). Source physical name = `@table` (NOT `@name`); field physical name = `@column` (renamed from `@dbColumn`). Referential actions on relationships: `@onDelete` / `@onUpdate`. - Origin subtypes: `passthrough`, `aggregate`, `collection`, `computed`, `first` (concrete; `base` is the abstract root). `passthrough` is legal on an `object.value`-hosted field (FR-015 parameter lineage); the four assembly origins (`aggregate`/`computed`/`collection`/`first`) live on `object.projection` only — a value-hosted assembly origin is `ERR_SUBTYPE_RULE_VIOLATION` (#210). - Relationship subtypes: `association`, `aggregation`, `composition`. Cardinality via `@cardinality: one|many`; target via `@objectRef`. **M:N (FR-018) slim vocabulary:** `@cardinality: "many"` + `@objectRef` (target) + `@through` (the junction/through entity — a third entity that MUST declare two `identity.reference` children, one per FK side). The relationship's FK fields are **derived** from those references (the `identity.reference` SSOT for FK direction), never restated. `@sourceRefField` (optional) disambiguates a *directed* self-join by naming the source-side FK field on the junction (the other reference is the target side); on a `@cardinality: one` relationship it instead names which of several `identity.reference` nodes onto the same target this relationship navigates, short-circuiting the unique-candidate/`@sourceRefField`/name-pairing ladder (#368, [ADR-0029](spec/decisions/ADR-0029-entity-child-extends-and-via-inference.md) Amendment 1) — an unresolvable 1:N reference set is `ERR_INVALID_RELATIONSHIP` at load. `@symmetric` (optional boolean) marks an *undirected* self-join (union-on-read) — valid only when `@objectRef` == the declaring entity, and mutually exclusive with `@sourceRefField`. The pre-FR-018 `@joinEntity`/`@joinFields` attrs are REMOVED. Validation errors: symmetric-on-hetero / symmetric+sourceRefField → `ERR_BAD_ATTR_VALUE`; junction-missing-two-references / sourceRefField-not-matching / M:N-attr-on-1:N / **junction-unpairable** → `ERR_INVALID_RELATIONSHIP`. **Unpairable means the junction declares its two references but neither resolves to the navigating entity, or neither to the `@objectRef` target** — declaring two references is NOT enough, and the loader now checks WHAT they point at (owner ruling 2026-09-20). It does so by running the real FK derivation and converting its failure, never a parallel re-implementation, so the loader and the derivation cannot drift; scope mirrors codegen's own iteration exactly — every CONCRETE, non-projection object crossed with its EFFECTIVE relationships, NOT deduped by declaration, because pairing is a property of the navigating entity and an inherited M:N can pair from one subtype and not another. Before the ruling this loaded clean and then diverged: TS and C# warned and emitted no traversal route (a silent 404), while Java, Kotlin and Python failed the build. Gated by `fixtures/conformance/error-relationship-m2m-junction-unpairable/`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3754a45d5..1bb406eff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,10 +47,18 @@ it until 1.1 ships._ persistence scenarios (`report-*.yaml`) and `report-shapes.json` hold the ports to the same columns; the `metaobjects-authoring` skill now teaches reports (`references/reporting.md`). Anyone who declared a view-sourced report under the unreleased 1.1 vocabulary will now see a - `CREATE VIEW` from `meta migrate`. + `CREATE VIEW` from `meta migrate`. Kotlin `gen` fails, naming the report and the dimension or + measure, for a view-backed report with a derived field named after a Kotlin hard keyword or + with two derived fields that land on one column property. ### Fixed +- **Kotlin: a field named after an Exposed `Table` property that was not reserved now gets the + `Column` suffix.** An entity or report field named `schemaName` now emits the column property + `schemaNameColumn`; it collided with Exposed's `Table.schemaName` and did not compile before. + With `exposedApi=1` the same now holds for `options` and `storageParameters`, which are + `Table` properties only in Exposed 1.x; `exposedApi=0` output for those two names is unchanged. + The physical column names do not change. - **Java: a bare string authored for an `isArray` attribute is now ONE item.** The Java parser used to split it on commas, which left stray quotes in the items; TypeScript and Python already kept it whole. All three now agree, so a Java model that relied on the split (a diff --git a/docs/features/reporting.md b/docs/features/reporting.md index 605a8d36a..46598c543 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -263,8 +263,13 @@ still exists: counts are `0`, sums and ratios are null. By-id and every write are refused (a report has no identity and is read-only); a report with no view source is refused as not served; an `@unmanaged` view-backed report is still read. C# also refuses a report whose derived field name, in Pascal case, equals the report's own class name, -since the row class could not have a member named like itself. No port generates a route, -typed client, filter allowlist or api-docs entry for a report. +since the row class could not have a member named like itself. Kotlin refuses a view-backed +report in two cases, because the generated table would not compile: a derived field named after +a Kotlin hard keyword (`in`, `is`, `object`, `when`, …), and two derived fields that land on one +column property (a name that collides with a member of Exposed's `Table`, such as `source`, gets +a `Column` suffix, which can meet a second field already called `sourceColumn`). Both fail `gen` +with an error naming the report and the dimension or measure. No port generates a route, typed +client, filter allowlist or api-docs entry for a report. `meta docs` lists a report's view on the agent schema page (`agent/schema.md`) and on no other page. diff --git a/docs/ports/kotlin.md b/docs/ports/kotlin.md index 20b8f5912..cd462b99b 100644 --- a/docs/ports/kotlin.md +++ b/docs/ports/kotlin.md @@ -354,6 +354,47 @@ class AuthorService(private val db: Database) { } ``` +### Reports + +For an `object.report` that declares a read-only `source.rdb` of `@kind: view`, +`KotlinExposedTableGenerator` writes one read-only Exposed table object, `Table`, bound +to that view, with one column per derived field (dimensions, then measures). That is all Kotlin +generates for a report: no row class, no `Names`, and nothing from any other generator. +A report with no view source generates nothing. See [reporting](../features/reporting.md) for +the vocabulary and the columns a report gets. An excerpt, under the default snake_case column +naming: + +```kotlin +object ProgramMinutesTable : Table("v_program_minutes") { + val program = long("program") + val weeks = long("weeks") + val totalMinutes = long("total_minutes").nullable() + val avgMinutes = decimal("avg_minutes", 38, 18).nullable() + // … one column per derived field +} +``` + +- A report has no identity, so the object has no `primaryKey`. List it and count it; there is + no by-id read and no write. +- A column is nullable exactly when the derived field can be null: a `sum`, `avg`, `min`, `max` + or ratio, and a dimension reached through `@via`. +- A derived decimal with no declared precision (an `avg`, a ratio, a `sum` of a decimal) is + read as `decimal(name, 38, 18)`. Exposed rounds a decimal to the column's scale when it reads + it, so the value is exact to 18 places. The object maps a view, so those numbers never reach + DDL. +- An enum dimension is typed by the enum class of the entity it reads (`ProgramStatus` for a + dimension over `Program.status`), so it compares against the same constants as the entity's + own column. No per-report enum is generated. +- The view and its columns are bound by string literal even when `useNames` is on. +- `gen` fails, naming the report and the dimension or measure, when a derived field is named + after a Kotlin hard keyword or when two derived fields land on one column property (see + below for the `Column` suffix). Rename the item. + +A column property whose name is a member of Exposed's `Table` gets a `Column` suffix +(`source` becomes `sourceColumn`); the physical column name does not change. The reserved set +follows the output mode: `options` and `storageParameters` are `Table` members only in Exposed +1.x, so they are suffixed only with `exposedApi=1`. + ### `Names` — the physical names, as constants `names` (`KotlinNamesGenerator`) is **not** wired above — it is opt-in, like diff --git a/fixtures/generator-registry-conformance/registry.json b/fixtures/generator-registry-conformance/registry.json index 3d292e001..5c69ac6ca 100644 --- a/fixtures/generator-registry-conformance/registry.json +++ b/fixtures/generator-registry-conformance/registry.json @@ -124,7 +124,7 @@ "ports": ["java"] }, "exposed-table": { - "concept": "Per-entity Kotlin Exposed table object.", + "concept": "Kotlin Exposed table object per table-backed or view-backed entity or projection, and per view-backed report.", "tier": "native", "layer": "persistence", "ports": ["kotlin"] diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratorUtil.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratorUtil.java index 449ee6a56..580effd26 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratorUtil.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratorUtil.java @@ -19,9 +19,10 @@ public static Collection getFilteredMetaData(MetaDataLoader loader, Me } public static Collection getFilteredMetaData(MetaDataLoader loader, Class clazz, MetaDataFilters filters ) { - // FR-044 Plan 1: object.report has no output until its lowering lands (Plan 2/3). - // Dropped here because every direct per-object generator (the Java model tier, the - // Mustache and PlantUML generators) selects its objects through this overload. + // FR-044: no generator that selects its objects here emits for an object.report, with + // or without a view source. Dropped here because every direct per-object generator + // (the Java model tier, the Mustache and PlantUML generators) selects its objects + // through this overload. List generatable = new ArrayList<>(); for (T md : loader.getMetaData( clazz )) { if (!isReport(md)) generatable.add(md); @@ -30,10 +31,11 @@ public static Collection getFilteredMetaData(MetaDataLoa } /** - * True for an {@code object.report} (FR-044). Plan 1 registers and validates the - * reporting vocabulary but gives a report no lowering yet, so no generator emits for - * one — including a report that declares a read-only {@code source.rdb @kind: view} - * (R5 allows one), which would otherwise pass every source-keyed gate. + * True for an {@code object.report} (FR-044). The generators that ask this skip a + * report: every Java generator, and every Kotlin generator but one. A report that + * declares a read-only {@code source.rdb @kind: view} would otherwise pass every + * source-keyed gate. The one exception is the Kotlin Exposed table generator, which + * emits the read-only table object of a view-backed report. */ public static boolean isReport(MetaData md) { return md instanceof MetaObject && MetaObject.SUBTYPE_REPORT.equals(md.getSubType()); diff --git a/server/java/codegen-kotlin-exposed1x-check/src/test/kotlin/com/metaobjects/generator/kotlin/exposed1x/Exposed1xCodegenCompileTest.kt b/server/java/codegen-kotlin-exposed1x-check/src/test/kotlin/com/metaobjects/generator/kotlin/exposed1x/Exposed1xCodegenCompileTest.kt index 84be44e5e..18d12dca5 100644 --- a/server/java/codegen-kotlin-exposed1x-check/src/test/kotlin/com/metaobjects/generator/kotlin/exposed1x/Exposed1xCodegenCompileTest.kt +++ b/server/java/codegen-kotlin-exposed1x-check/src/test/kotlin/com/metaobjects/generator/kotlin/exposed1x/Exposed1xCodegenCompileTest.kt @@ -9,6 +9,7 @@ import com.metaobjects.generator.kotlin.KotlinRelationsGenerator import com.metaobjects.generator.kotlin.KotlinValidatorGenerator import com.metaobjects.generator.util.GeneratedFileWriter import com.metaobjects.metadata.ktx.loadDirectory +import com.metaobjects.metadata.ktx.loadString import com.tschuchort.compiletesting.KotlinCompilation import com.tschuchort.compiletesting.SourceFile import java.nio.file.Files @@ -127,4 +128,47 @@ class Exposed1xCodegenCompileTest { outDir.toFile().deleteRecursively() } } + + /** + * Exposed 1.x adds the open `Table` properties `options` and `storageParameters`, which + * 0.x lacks. A column property of either name hides that member and does not compile, so + * under `exposedApi=1` the generator suffixes it (`optionsColumn`). Compiled here because + * this is the only module with Exposed 1.x on its classpath. + */ + @Test + fun `exposedApi=1 table with fields named after 1x-only Table members compiles`() { + val model = """{ + "metadata.root": { "package": "acme::shop", "children": [ + { "object.entity": { "name": "Plan", "children": [ + { "source.rdb": { "@table": "plans" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "options" } }, + { "field.string": { "name": "storageParameters" } }, + { "identity.primary": { "name": "id", "@fields": ["id"], "@generation": "increment" } }, + { "identity.secondary": { "name": "by_options", "@fields": ["options"] } } + ] } } + ] } + }""" + val outDir = Files.createTempDirectory("exposed1x-reserved-") + try { + val gen = KotlinExposedTableGenerator() + gen.setArgs(mapOf("outputDir" to outDir.toString(), "exposedApi" to "1")) + gen.execute(loadString("exposed1x-reserved", model)) + + val table = outDir.resolve("acme/shop/PlanTable.kt").readText() + assertTrue("val optionsColumn = text(\"options\")" in table, table) + assertTrue("val storageParametersColumn = text(\"storage_parameters\")" in table, table) + + val result = KotlinCompilation().apply { + sources = listOf(SourceFile.kotlin("PlanTable.kt", table)) + inheritClassPath = true + messageOutputStream = System.out + }.compile() + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, + "a table with fields named options / storageParameters does not compile against " + + "Exposed 1.3.x:\n${result.messages}") + } finally { + outDir.toFile().deleteRecursively() + } + } } diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/GeneratorRegistry.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/GeneratorRegistry.kt index 0c5e89e74..fdf3a5dcf 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/GeneratorRegistry.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/GeneratorRegistry.kt @@ -186,7 +186,7 @@ val GENERATOR_REGISTRY: Map = linkedMapOf( ), "exposed-table" to GeneratorInfo( name = "exposed-table", - description = "Per-entity Kotlin Exposed table object.", + description = "Kotlin Exposed table object per table-backed or view-backed entity or projection, and per view-backed report.", tier = GeneratorTier.NATIVE, layer = GeneratorLayer.PERSISTENCE, factory = ::KotlinExposedTableGenerator, diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt index 317e6c673..1d6602cbd 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt @@ -28,7 +28,6 @@ import com.metaobjects.identity.MetaIdentity import com.metaobjects.identity.ReferenceIdentity import com.metaobjects.index.LookupIndex import com.metaobjects.loader.MetaDataLoader -import com.metaobjects.loader.ValidationPhase import com.metaobjects.`object`.MetaObject import com.metaobjects.relationship.CompositionRelationship import com.metaobjects.relationship.MetaRelationship @@ -362,13 +361,13 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase, val unsizedDecimals: Set) + + /** + * The plan of the report being emitted, keyed by its read model for the duration of + * that one [emit] call. Identity-keyed because node equality is structural. It is how + * the three report differences reach [emit] without a parameter on a `protected open` + * function an adopter's subclass may override. + */ + private val reportPlans = java.util.IdentityHashMap() + /** * Refuse a report whose derived field names cannot become the column properties of one * Kotlin `object`. `gen` would otherwise exit 0 and the adopter's build would be the @@ -411,9 +440,10 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase() - for (f in ReportShape.of(report).fields()) { + for (f in shape.fields()) { if (f.name in KOTLIN_HARD_KEYWORDS) { throw GeneratorException( "report \"${report.shortName}\": its ${describeItem(f)} generates the Exposed column " + @@ -421,7 +451,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase): ReportShape.Field = - ReportShape.of(model.report()).fields().first { it.name == field.name } - /** * Whether the table of [entity] references `Names` constants: only when the * names generator is in the run ([useNames]) AND emits an artifact for [entity]. It @@ -451,35 +477,43 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase, entity: MetaObject): ClassName? { - if (entity !is ReportReadModel || field !is EnumField) return KotlinTypeMapper.enumTypeName(field, entity) - val report = entity.report() - val shape = ReportShape.of(report) - val derived = shape.fields().first { it.name == field.name } - val of = derived.typeSource - ?: error("report '${report.name}': enum field '${field.name}' has no type source") - val via = derived.dimension?.via - val owner = if (via == null) shape.from() else { - val root = generateSequence(report.parent) { it.parent }.filterIsInstance().first() - // Same resolution ReportShape applies to the reference: the member separator is - // the LAST dot, and the entity resolves relative to the @from entity's package. - ValidationPhase.resolveRootObject( - root, derived.dimension.of.substringBeforeLast('.'), shape.from().`package` ?: "", - ) ?: shape.from() - } - return KotlinTypeMapper.enumTypeName(of, owner) + private fun reportEnumClass(shape: ReportShape, f: ReportShape.Field, root: MetaRoot): ClassName { + val report = shape.report() + val dimension = f.dimension + val owner = if (dimension?.via == null) shape.from() else + ReportShape.resolveFieldRefEntity(dimension.of, shape.from(), root) + ?: throw GeneratorException( + "report \"${report.shortName}\": its dimension \"${dimension.shortName}\" reads the enum " + + "\"${dimension.of}\" through @via, and the entity that reference names does not " + + "resolve, so the generated column has no enum class to be typed by." + ) + return KotlinTypeMapper.enumTypeName(f.typeSource, owner) + ?: throw GeneratorException( + "report \"${report.shortName}\": its ${describeItem(f)} is an enum with no generated enum class." + ) } + /** + * The generated enum class a `field.enum` column of [entity]'s table is typed by: + * [KotlinTypeMapper.enumTypeName] for an entity or projection — the class + * [KotlinEntityGenerator] emits for it — and [reportEnumClass] for a report. + */ + private fun enumClassFor(field: MetaField<*>, entity: MetaObject): ClassName? = + reportPlans[entity]?.enumClasses?.get(field.name) ?: KotlinTypeMapper.enumTypeName(field, entity) + /** * The Exposed column spec of a non-enum scalar [field] of [entity]: the type mapper's, * except for a report's derived decimal that carries no declared precision (an `avg`, a @@ -492,7 +526,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase, colExpr: String, api: ExposedApi): String { - if (entity is ReportReadModel && field is DecimalField && derivedField(entity, field).typeSource == null) { + if (reportPlans[entity]?.unsizedDecimals?.contains(field.name) == true) { return "decimal($colExpr, $REPORT_DECIMAL_PRECISION, $REPORT_DECIMAL_SCALE)" } return KotlinTypeMapper.exposedColumnSpec(field, colExpr, api) @@ -1014,7 +1048,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase", col1, ...) }` for identity.secondary @@ -1063,11 +1097,11 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase = setOf("options", "storageParameters") + /** * [KotlinExposedTableGenerator]: the Kotlin property name for a column. Identity for a * normal field; a field whose camelCase name collides with an Exposed `Table`/`ColumnSet` - * member ([RESERVED_TABLE_MEMBERS]) gets a `Column` suffix (e.g. `source` → `sourceColumn`). + * member ([RESERVED_TABLE_MEMBERS], plus [RESERVED_TABLE_MEMBERS_EXPOSED_1X] when + * [exposedApi] is 1.x) gets a `Column` suffix (e.g. `source` → `sourceColumn`). * The PHYSICAL column name is unaffected — only the Kotlin val identifier changes — so the * persisted schema is unchanged. + * + * Every site that names the property passes the run's [exposedApi], so the table that + * declares it and the code that references it agree. */ - fun safeColumnProperty(name: String): String = - if (name in RESERVED_TABLE_MEMBERS) name + "Column" else name + fun safeColumnProperty(name: String, exposedApi: ExposedApi = ExposedApi.V0): String { + val reserved = name in RESERVED_TABLE_MEMBERS || + (exposedApi == ExposedApi.V1 && name in RESERVED_TABLE_MEMBERS_EXPOSED_1X) + return if (reserved) name + "Column" else name + } /** [KotlinSpringControllerGenerator]: `shortName + "Controller"`. */ fun controllerName(shortName: String): String = shortName + "Controller" diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRelationsGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRelationsGenerator.kt index 02ff8e8db..b4b9f1e39 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRelationsGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRelationsGenerator.kt @@ -281,7 +281,7 @@ open class KotlinRelationsGenerator : MultiFileDirectGeneratorBase() for (fk in reverseFks) { if (!first) append("\n") first = false - val col = KotlinNaming.safeColumnProperty(fk.fkField) + val col = KotlinNaming.safeColumnProperty(fk.fkField, exposedApi()) val single = KotlinNaming.reverseFinderName(fk.fkField) val batched = KotlinNaming.reverseFinderInName(fk.fkField) append("/** Reverse nav: the $ownerShort rows whose `${fk.fkField}` FK points at the given ${fk.targetShortName} id (single indexed query). */\n") diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt index 63889b078..28e4e7034 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt @@ -884,7 +884,7 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase): String { +private fun sortColumnExpr(tableVar: String, shortName: String, sortFields: List, api: ExposedApi): String { if (sortFields.isEmpty()) return "error(\"$shortName has no sortable fields\")" val sb = StringBuilder("when (field) {\n") for (name in sortFields) { - val prop = KotlinNaming.safeColumnProperty(name) + val prop = KotlinNaming.safeColumnProperty(name, api) sb.append(" \"").append(name).append("\" -> ") .append(tableVar).append(".").append(prop).append("\n") } diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReservedTableMembersTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReservedTableMembersTest.kt new file mode 100644 index 000000000..b3221bea8 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReservedTableMembersTest.kt @@ -0,0 +1,108 @@ +package com.metaobjects.generator.kotlin + +import com.metaobjects.generator.Generator +import com.metaobjects.metadata.ktx.loadString +import java.nio.file.Files +import kotlin.io.path.isRegularFile +import kotlin.io.path.readText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * A column property named after a member of Exposed's `Table` gets a `Column` suffix + * ([KotlinNaming.safeColumnProperty]), and WHICH names are members depends on the Exposed + * version the output targets. `options` and `storageParameters` are `Table` properties only + * in Exposed 1.x (checked with `javap` against exposed-core 0.55.0 and 1.3.1), so they are + * reserved only under `exposedApi=1`: under 0.x a property of that name compiles, and + * renaming it would change working generated code. + */ +class KotlinReservedTableMembersTest { + + private val model = """{ + "metadata.root": { "package": "acme::shop", "children": [ + { "object.entity": { "name": "Plan", "children": [ + { "source.rdb": { "@table": "plans" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "options" } }, + { "field.string": { "name": "storageParameters" } }, + { "field.string": { "name": "schemaName" } }, + { "field.string": { "name": "title" } }, + { "identity.primary": { "name": "id", "@fields": ["id"], "@generation": "increment" } }, + { "identity.secondary": { "name": "by_options", "@fields": ["options"] } }, + { "measure.aggregate": { "name": "options", "@agg": "count", "@of": "Plan.id" } } + ] } }, + { "object.report": { "name": "PlanTotals", "@from": "Plan", "@measures": ["options"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_plan_totals" } } ] } } + ] } + }""" + + private fun emit(exposedApi: String?, gen: Generator = KotlinExposedTableGenerator()): Map { + val outDir = Files.createTempDirectory("reserved-members-") + try { + val args = mapOf("outputDir" to outDir.toString(), "packageName" to "acme.shop") + + (exposedApi?.let { mapOf("exposedApi" to it) } ?: emptyMap()) + gen.setArgs(args) + gen.execute(loadString("reserved-members", model)) + return Files.walk(outDir).use { s -> + s.filter { it.isRegularFile() }.toList().associate { outDir.relativize(it).toString() to it.readText() } + } + } finally { + outDir.toFile().deleteRecursively() + } + } + + @Test + fun `the helper reserves the 1x-only members in 1x mode alone`() { + for (name in listOf("options", "storageParameters")) { + assertEquals(name, KotlinNaming.safeColumnProperty(name)) + assertEquals(name, KotlinNaming.safeColumnProperty(name, ExposedApi.V0)) + assertEquals(name + "Column", KotlinNaming.safeColumnProperty(name, ExposedApi.V1)) + } + // A member of Table in both versions is reserved in both. + for (api in ExposedApi.values()) { + assertEquals("schemaNameColumn", KotlinNaming.safeColumnProperty("schemaName", api)) + assertEquals("sourceColumn", KotlinNaming.safeColumnProperty("source", api)) + assertEquals("title", KotlinNaming.safeColumnProperty("title", api)) + } + } + + @Test + fun `under Exposed 0x a field named options keeps its property name`() { + for (api in listOf(null, "0")) { + val table = emit(api).getValue("acme/shop/PlanTable.kt") + assertTrue(" val options = text(\"options\").nullable()\n" in table, table) + assertTrue(" val storageParameters = text(\"storage_parameters\").nullable()\n" in table, table) + assertTrue("uniqueIndex(\"by_options\", options)" in table, table) + assertFalse("optionsColumn" in table, table) + assertFalse("storageParametersColumn" in table, table) + // schemaName is a Table member in 0.x too. + assertTrue(" val schemaNameColumn = text(\"schema_name\").nullable()\n" in table, table) + val report = emit(api).getValue("acme/shop/PlanTotalsTable.kt") + assertTrue(" val options = long(\"options\")\n" in report, report) + } + } + + @Test + fun `under Exposed 1x a field named options gets the suffixed property, declared and referenced`() { + val files = emit("1") + val table = files.getValue("acme/shop/PlanTable.kt") + // The physical column keeps its name; only the Kotlin property is renamed. + assertTrue(" val optionsColumn = text(\"options\").nullable()\n" in table, table) + assertTrue(" val storageParametersColumn = text(\"storage_parameters\").nullable()\n" in table, table) + assertTrue("uniqueIndex(\"by_options\", optionsColumn)" in table, table) + assertFalse("val options =" in table, table) + assertTrue(" val schemaNameColumn = text(\"schema_name\").nullable()\n" in table, table) + val report = files.getValue("acme/shop/PlanTotalsTable.kt") + assertTrue(" val optionsColumn = long(\"options\")\n" in report, report) + } + + @Test + fun `the controller's sort dispatch references the property the table declares, per mode`() { + val v0 = emit(null, KotlinSpringControllerGenerator()).values.joinToString("\n") + assertTrue("\"options\" -> PlanTable.options\n" in v0, v0) + val v1 = emit("1", KotlinSpringControllerGenerator()).values.joinToString("\n") + assertTrue("\"options\" -> PlanTable.optionsColumn\n" in v1, v1) + } +} 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 8133984c8..28f330ea4 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 @@ -211,12 +211,9 @@ public static ReportShape of(MetaObject report, MetaRoot root) { * LAST dot; the entity resolves relative to {@code owner}'s package (ADR-0042). */ public static MetaField resolveFieldRef(String ref, MetaObject owner, MetaRoot root) { - if (ref == null) return null; - int dot = ref.lastIndexOf(SEP); - if (dot <= 0) return null; - MetaObject entity = ValidationPhase.resolveRootObject(root, ref.substring(0, dot), packageOf(owner)); + MetaObject entity = resolveFieldRefEntity(ref, owner, root); if (entity == null) return null; - String fieldName = ref.substring(dot + SEP.length()); + String fieldName = ref.substring(ref.lastIndexOf(SEP) + SEP.length()); // ADR-0039: resolving, so a field inherited through extends is found. for (MetaField f : entity.getMetaFields()) { if (fieldName.equals(f.getName())) return f; @@ -224,6 +221,18 @@ public static MetaField resolveFieldRef(String ref, MetaObject owner, MetaRoo return null; } + /** + * The entity an {@code Entity.field} reference NAMES, or {@code null}: the entity half + * of {@link #resolveFieldRef}, by the same rule. It is the entity the reference is + * written against, which for an inherited field is not the object that declares it. + */ + public static MetaObject resolveFieldRefEntity(String ref, MetaObject owner, MetaRoot root) { + if (ref == null) return null; + int dot = ref.lastIndexOf(SEP); + if (dot <= 0) return null; + return ValidationPhase.resolveRootObject(root, ref.substring(0, dot), packageOf(owner)); + } + private static Field dimensionField(ReportDimensionItem item, MetaObject from, MetaRoot root, MetaObject report) { MetaDimension dim = declaredMember(from, MetaDimension.class, item.name()); if (dim == null) throw unresolved(report, "dimension '" + item.name() + "' on '" + from.getShortName() + "'"); From 6da9fec91c794a158d564334f1fed678352d2af5 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 11:11:28 -0400 Subject: [PATCH 22/32] fix(reporting): resolve report references as the loader does; refuse what cannot be lowered (FR-044) The lowering and the read shape disagreed with validateReporting about what a loadable model means. Each case loaded clean and then failed, or was silently wrong, at migrate or at read. - @of and @via resolve in the package of the entity that DECLARES the dimension or measure, not the @from entity's. A member inherited from a base in another package now resolves, and a same-named entity in the report's package can no longer capture the reference and mistype the column. - Without @via the field is read from @from, and a @via walk starts at @from, as the loader's does. - A dotted @measures item (Sale.total, loader rule R3) names the measure by its last segment. One canonical report now uses the dotted form; schema.postgres.sql and report-shapes.json are byte-identical. - A report is classified (skip / @sql / derive) by the same source its view is named by and the runtime reads: primary, else first. A replica declared first no longer decides it. The report-shapes generator uses the same selector. - A derived report @from a TPH subtype is refused: the subtype shares its base's table, so the view aggregated every subtype's rows. An @sql or @unmanaged report is the author's body and is not refused. - A @via hop with no identity.reference behind it says which hop and what it needs. An empty in list, a time dimension without a grain and a grain outside the closed set are refused by name instead of reaching the DDL. --- .../canonical/meta.fitness.json | 2 +- .../src/projection/build-projection-views.ts | 26 +- .../src/projection/extract-report-spec.ts | 107 ++++++- .../codegen-ts/src/projection/time-sql.ts | 6 + .../projection/build-projection-views.test.ts | 85 ++++++ .../projection/extract-report-spec.test.ts | 261 +++++++++++++++++- .../test/projection/time-sql.test.ts | 12 + .../src/gen-report-shapes.ts | 9 +- .../test/report-shapes-artifact.test.ts | 39 +++ .../src/core/reporting/report-accessors.ts | 22 +- .../src/core/reporting/report-read-model.ts | 2 +- .../src/core/reporting/report-shape.ts | 129 ++++++++- .../typescript/packages/metadata/src/index.ts | 6 +- .../metadata/test/report-shape.test.ts | 137 ++++++++- 14 files changed, 805 insertions(+), 38 deletions(-) diff --git a/fixtures/persistence-conformance/canonical/meta.fitness.json b/fixtures/persistence-conformance/canonical/meta.fitness.json index 8e905a42b..d743dbd67 100644 --- a/fixtures/persistence-conformance/canonical/meta.fitness.json +++ b/fixtures/persistence-conformance/canonical/meta.fitness.json @@ -323,7 +323,7 @@ { "object.report": { "name": "ProgramMinutes", "@from": "Week", "@dimensions": ["program", "programTitle"], - "@measures": ["weeks", "longWeeks", "labels", "slots", "totalMinutes", "avgMinutes", "minMinutes", "maxMinutes", "longShare"], + "@measures": ["Week.weeks", "longWeeks", "labels", "slots", "totalMinutes", "avgMinutes", "minMinutes", "maxMinutes", "longShare"], "children": [ { "source.rdb": { "@kind": "view", "@view": "v_program_minutes" } } ] } }, { "object.report": { "name": "FitnessTotals", "@from": "Week", "@measures": ["weeks", "totalMinutes", "longShare"], diff --git a/server/typescript/packages/codegen-ts/src/projection/build-projection-views.ts b/server/typescript/packages/codegen-ts/src/projection/build-projection-views.ts index 98cd9eb2d..4d8a046b9 100644 --- a/server/typescript/packages/codegen-ts/src/projection/build-projection-views.ts +++ b/server/typescript/packages/codegen-ts/src/projection/build-projection-views.ts @@ -27,7 +27,7 @@ import { resolveTableSchema, } from "@metaobjectsdev/metadata"; import { isProjection, isWriteThrough } from "./projection-detector.js"; -import { extractViewSpec, packageOf, refNamedOwner } from "./extract-view-spec.js"; +import { extractViewSpec, packageOf, projectionViewSource, refNamedOwner } from "./extract-view-spec.js"; import { extractReportSpec } from "./extract-report-spec.js"; import { emitReportViewDdl } from "./report-ddl-emit.js"; import type { ReportViewSpec } from "./report-spec.js"; @@ -176,7 +176,8 @@ export interface BuildReportViewsOptions { * buildProjectionViews; exported separately because MySQL is accepted here and nowhere * else (migrate does not target MySQL; the SQL ships through this function and a recipe). * - * The Table A gate (classifyReadOnlySource) runs BEFORE extractReportSpec: a sourceless + * The Table A gate (classifySource, over the source `projectionViewSource` selects) runs + * BEFORE extractReportSpec: a sourceless * report must never reach it, because projectionViewName falls back to `v_` and * would invent a view nobody declared. A report whose `@from` has no table, or whose * `@via` chain does not resolve, throws out of extractReportSpec naming the report; that @@ -194,7 +195,11 @@ export function buildReportViews(root: MetaData, opts: BuildReportViewsOptions): const out: ExpectedView[] = []; for (const report of root.objects().filter(isReport)) { - const cls = classifyReadOnlySource(report); // Table A + // Table A, decided by the SAME source the view is named by (`projectionViewName`) and + // the runtime reads (`reportReadModel`): the own read-only source with role primary, + // else the first own read-only source. Reports only: the projection and write-through + // loops above keep classifying their FIRST own read-only source. + const cls = classifySource(projectionViewSource(report)); if (cls.kind === "skip") continue; if (cls.kind === "sql") { emitSqlView(report, cls.source, root, joinTables, out); @@ -202,7 +207,14 @@ export function buildReportViews(root: MetaData, opts: BuildReportViewsOptions): } const spec = extractReportSpec(report, root, { columnNamingStrategy }); const baseTableName = joinTables[spec.joinTree.baseEntity]; - if (!baseTableName) continue; // unresolved base — extractReportSpec already refuses a table-less @from + if (!baseTableName) { + // extractReportSpec refuses a table-less @from first, so this is a defect, not an + // authoring error; skipping would silently drop a view the report declares. + throw new Error( + `report '${report.name}': no table name is known for its @from entity '${spec.joinTree.baseEntity}', ` + + `so its view '${spec.viewName}' cannot be emitted.`, + ); + } const schema = resolveTableSchema(report); out.push({ name: spec.viewName, @@ -257,7 +269,11 @@ type ReadOnlySourceClass = | { kind: "derive"; source: MetaSource }; function classifyReadOnlySource(host: MetaObject): ReadOnlySourceClass { - const source = host.ownChildren().find(isReadOnlySource); + return classifySource(host.ownChildren().find(isReadOnlySource)); +} + +/** The classification itself, for a source the caller has already selected. */ +function classifySource(source: MetaSource | undefined): ReadOnlySourceClass { if (source === undefined) return { kind: "skip" }; if (source.isUnmanaged) return { kind: "skip" }; // external — Flyway/hand-migration owns it if (source.sqlBody !== undefined) return { kind: "sql", source }; // author-supplied body diff --git a/server/typescript/packages/codegen-ts/src/projection/extract-report-spec.ts b/server/typescript/packages/codegen-ts/src/projection/extract-report-spec.ts index 8184c03d8..84c900e2a 100644 --- a/server/typescript/packages/codegen-ts/src/projection/extract-report-spec.ts +++ b/server/typescript/packages/codegen-ts/src/projection/extract-report-spec.ts @@ -16,13 +16,23 @@ import { FIELD_SUBTYPE_TIMESTAMP, FILTER_COMPOSE_AND, FILTER_COMPOSE_OR, + FILTER_OP_IN, FILTER_RELATIVE_NOW, + IDENTITY_REFERENCE_ATTR_REFERENCES, + IDENTITY_SUBTYPE_REFERENCE, OBJECT_REPORT_ATTR_FILTER, OBJECT_REPORT_ATTR_SEGMENT, + RELATIONSHIP_ATTR_OBJECT_REF, + TYPE_IDENTITY, TYPE_MEASURE, + TYPE_RELATIONSHIP, TYPE_SEGMENT, reportShape, + reportingMemberOwner, + reportingViaHops, + resolveObjectRef, resolveReportingFieldRef, + type MetaData, type MetaField, type MetaMeasure, type MetaObject, @@ -33,6 +43,7 @@ import { import { intValueMapOf } from "../enum-meta.js"; import { columnNameFromField } from "../naming.js"; import { hasWritableRdbSource } from "../source-detect.js"; +import { isTphSubtype, tphDiscriminatorBase, tphDiscriminatorPin } from "../templates/zod-validators.js"; import { desugarClause, encodeIntEnumFilterValue, @@ -104,6 +115,14 @@ function resolveReportFilter( } const ref = `${alias}.${sourceColumnNameFor(field, ctx)}`; for (const [op, raw] of Object.entries(desugarClause(val))) { + // `IN ()` is a syntax error on Postgres and MySQL, so it would fail when the migration is + // applied, far from the report. The loader accepts the empty list; refuse it here by name. + if (op === FILTER_OP_IN && Array.isArray(raw) && raw.length === 0) { + throw new Error( + `${where}: the 'in' list on "${key}" is empty, which no row can match and no database accepts ` + + `as SQL (IN ()). List at least one value, or remove the clause.`, + ); + } clauses.push({ kind: "cmp", ref, op, value: lowerFilterValue(raw, op, field, key, where) }); } } @@ -174,8 +193,11 @@ function aggregateOf( const where = `report '${report.name}' measure '${measure.name}'`; const agg = measure.agg(); if (agg === undefined) throw new Error(`${where}: has no @agg.`); + // The same rule as reportShape: the entity half resolves in the DECLARING entity's package, + // and the column is read from `from` (a measure aggregates `from`'s own rows). + const declaring = reportingMemberOwner(measure, from); const fields = measure.ofColumns().map((ref) => { - const f = resolveReportingFieldRef(ref, from, root); + const f = resolveReportingFieldRef(ref, declaring, root, from); if (f === undefined) throw new Error(`${where}: @of '${ref}' does not resolve.`); return f; }); @@ -206,6 +228,38 @@ function aliasAtEndOf(path: Path, joins: readonly JoinNode[]): string { return alias; } +/** + * Why a dimension's `@via` walk stopped at `hop`: the error names the hop, the entity it was + * looked up on, and what the model is missing. The loader (rule D2) accepts a to-one + * `relationship.*` with no `identity.reference` behind it, so the missing-foreign-key case is + * reachable from a model that loads clean. + */ +function viaHopError(where: string, via: string, hop: string, at: MetaData, root: MetaRoot): Error { + const head = `${where} @via '${via}' cannot be joined at hop '${hop}' on '${at.resolutionKey()}'`; + // ADR-0039: resolving children(), so an inherited relationship or reference is found. + const node = at + .children() + .find( + (c) => + c.name === hop && + (c.type === TYPE_RELATIONSHIP || (c.type === TYPE_IDENTITY && c.subType === IDENTITY_SUBTYPE_REFERENCE)), + ); + if (node === undefined) { + return new Error(`${head}: it names no relationship or identity.reference of that entity.`); + } + const targetRef = node.attr( + node.type === TYPE_IDENTITY ? IDENTITY_REFERENCE_ATTR_REFERENCES : RELATIONSHIP_ATTR_OBJECT_REF, + ); + const target = typeof targetRef === "string" ? resolveObjectRef(root, targetRef, packageOf(at)).node : undefined; + if (target === undefined) { + return new Error(`${head}: its target '${String(targetRef ?? "")}' does not resolve to an object.`); + } + return new Error( + `${head}: the model declares no foreign key for it. A view joins a hop through an identity.reference; ` + + `declare one on '${at.name}' whose @references is '${target.name}' (with the foreign-key field in @fields).`, + ); +} + export function extractReportSpec(report: MetaObject, root: MetaRoot, ctx: ExtractContext): ReportViewSpec { const shape = reportShape(report, root); const from = shape.from; @@ -216,21 +270,45 @@ export function extractReportSpec(report: MetaObject, root: MetaRoot, ctx: Extra `source.rdb), so no view can be derived. Give '${from.name}' a source, or remove the report's source.`, ); } + // A TPH subtype has no table of its own: its rows sit in the discriminator base's table beside + // every other subtype's. A derived view has no discriminator predicate, so it would aggregate + // all of them and report wrong numbers with nothing failing. Refuse, and say how to scope it. + if (isTphSubtype(from)) { + const base = tphDiscriminatorBase(from); + const pin = tphDiscriminatorPin(from); + throw new Error( + `report '${report.name}': @from '${from.name}' is a TPH subtype: it shares the table of ` + + `'${base?.name ?? ""}' with every other subtype, so a view derived from it would aggregate all of ` + + `their rows. Declare the report @from '${base?.name ?? ""}' with an @filter on the discriminator ` + + `field '${pin?.fieldName ?? ""}' (for example { ${JSON.stringify(pin?.fieldName ?? "")}: ` + + `${JSON.stringify(pin?.value ?? "")} }).`, + ); + } const used = new Set(); const baseAlias = shortAliasFor(from.name, used); - const pkg = packageOf(from); // One path per LISTED dimension that has @via (Table F); an unlisted dimension adds no join. const pathOf = new Map(); for (const f of shape.fields) { - const via = f.dimension?.via(); - if (via === undefined) continue; - const path = walkViaPath(via, root, pkg, ctx); + const dim = f.dimension; + const via = dim?.via(); + if (dim === undefined || via === undefined) continue; + const where = `report '${report.name}': dimension '${f.name}'`; + // The loader's rule D2: the owner half resolves in the DECLARING entity's package and must be + // `from` or an entity it extends; the walk then starts AT `from`. + const hops = reportingViaHops(via, reportingMemberOwner(dim, from), from, root); + if (hops === undefined) { + throw new Error( + `${where} @via '${via}' must be Owner.hop[.hop...], starting at @from '${from.name}' or an entity it extends.`, + ); + } + const path = walkViaPath([from.resolutionKey(), ...hops].join("."), root, packageOf(from), ctx); // walkViaPath stops at the first hop it cannot resolve; a partial path would pin the // dimension to the wrong alias, so the whole chain must be walked. - const hops = via.split(".").length - 1; - if (path.length !== hops || hops === 0) { - throw new Error(`report '${report.name}': dimension '${f.name}' @via '${via}' does not resolve to a join path.`); + if (path.length !== hops.length) { + const last = path[path.length - 1]; + const at = last === undefined ? from : root.objects().find((o) => o.resolutionKey() === last.targetEntity); + throw viaHopError(where, via, hops[path.length]!, at ?? from, root); } pathOf.set(f, path); } @@ -239,7 +317,18 @@ export function extractReportSpec(report: MetaObject, root: MetaRoot, ctx: Extra const columns = shape.fields.map((f): ReportColumn => { const dbColAlias = columnNameFromField(f.name, ctx.columnNamingStrategy); if (f.role === "dimension") { - const of = f.typeSource ?? resolveReportingFieldRef(f.dimension?.of() ?? "", from, root); + const dim = f.dimension; + // A time dimension below the hour grain carries no typeSource; resolve by reportShape's rule. + const of = + f.typeSource ?? + (dim === undefined + ? undefined + : resolveReportingFieldRef( + dim.of() ?? "", + reportingMemberOwner(dim, from), + root, + dim.via() === undefined ? from : undefined, + )); if (of === undefined) throw new Error(`report '${report.name}': dimension '${f.name}' @of does not resolve.`); const path = pathOf.get(f); const alias = path === undefined ? baseAlias : aliasAtEndOf(path, joins); diff --git a/server/typescript/packages/codegen-ts/src/projection/time-sql.ts b/server/typescript/packages/codegen-ts/src/projection/time-sql.ts index 726575cd4..02dd3633d 100644 --- a/server/typescript/packages/codegen-ts/src/projection/time-sql.ts +++ b/server/typescript/packages/codegen-ts/src/projection/time-sql.ts @@ -4,6 +4,7 @@ import { GRAIN_DAY, GRAIN_HOUR, GRAIN_MONTH, GRAIN_QUARTER, GRAIN_WEEK, GRAIN_YEAR, ISO_DURATION_RE, + TIME_GRAINS, type TimeGrain, } from "@metaobjectsdev/metadata"; @@ -56,6 +57,11 @@ export function truncateToGrain( temporal: ReportTemporal, dialect: ReportDialect, ): string { + // The Postgres arm writes the grain into `date_trunc('', ...)`. The loader validates + // it (rule R2); a programmatic caller skips the loader, so check the closed set here. + if (!(TIME_GRAINS as readonly string[]).includes(grain)) { + throw new Error(`time-sql: "${String(grain)}" is not a time grain (${TIME_GRAINS.join(", ")}).`); + } if (grain === GRAIN_HOUR && temporal === "date") { // Rule D4 forbids this at load; a programmatic caller skips the loader. throw new Error(`time-sql: the "hour" grain cannot truncate a date column (${ref}).`); diff --git a/server/typescript/packages/codegen-ts/test/projection/build-projection-views.test.ts b/server/typescript/packages/codegen-ts/test/projection/build-projection-views.test.ts index 216726974..bca095cfd 100644 --- a/server/typescript/packages/codegen-ts/test/projection/build-projection-views.test.ts +++ b/server/typescript/packages/codegen-ts/test/projection/build-projection-views.test.ts @@ -383,6 +383,91 @@ describe("buildReportViews — view-backed reports (FR-044 Plan 2, Table A)", () expect("columns" in views[0]!).toBe(false); }); + test("the projection loop does not see an @sql report either: one view, not two", async () => { + // An @sql report is the shape most like a projection (a read-only source with a body). + // Were the `!isReport` filter on the projection loop dropped, it would be emitted twice. + const root = await loadModel( + shop("with", (children) => { + const r = children.find((c) => (c["object.report"] as Json | undefined)?.name === "StoreTotals"); + const kids = (r!["object.report"] as { children: Json[] }).children; + (kids[0]!["source.rdb"] as Json)["@sql"] = "SELECT COUNT(*) AS purchases FROM purchases"; + }), + ); + expect(buildProjectionViews(root, PG)).toHaveLength(1); + }); + + // Source selection (final fix wave A4): Table A is decided by the SAME source the view is + // named by and the runtime reads: the own read-only source with role primary, else the + // first own read-only source. A replica declared first must not decide it. + const replicaFirst = (replica: Json) => + shop("with", (children) => { + const r = children.find((c) => (c["object.report"] as Json | undefined)?.name === "StoreTotals"); + const kids = (r!["object.report"] as { children: Json[] }).children; + (kids[0]!["source.rdb"] as Json)["@role"] = "primary"; + kids.unshift({ "source.rdb": { "@kind": "view", "@table": "v_store_totals_replica", "@role": "replica", ...replica } }); + }); + + test("a replica declared before the primary, @unmanaged: the primary view is still created", async () => { + const root = await loadModel(replicaFirst({ "@unmanaged": true })); + const views = buildReportViews(root, PG); + expect(views.map((v) => v.name)).toEqual(["v_store_totals"]); + expect(views[0]!.sql).toContain('FROM "purchases" p'); + }); + + test("a replica declared before the primary, with @sql: the primary is derived and named, not the replica's body", async () => { + const root = await loadModel(replicaFirst({ "@sql": "SELECT 1 AS purchases" })); + const views = buildReportViews(root, PG); + expect(views.map((v) => v.name)).toEqual(["v_store_totals"]); + expect(views[0]!.sql).not.toContain("SELECT 1 AS purchases"); + expect(views[0]!.sql).toContain('FROM "purchases" p'); + }); + + test("a primary that is @unmanaged is skipped even when a managed replica is declared first", async () => { + const root = await loadModel( + shop("with", (children) => { + const r = children.find((c) => (c["object.report"] as Json | undefined)?.name === "StoreTotals"); + const kids = (r!["object.report"] as { children: Json[] }).children; + Object.assign(kids[0]!["source.rdb"] as Json, { "@role": "primary", "@unmanaged": true }); + kids.unshift({ "source.rdb": { "@kind": "view", "@table": "v_store_totals_replica", "@role": "replica" } }); + }), + ); + expect(buildReportViews(root, PG)).toEqual([]); + }); + + test("an @sql report @from a TPH subtype is not refused: the author owns the body", async () => { + const tph = (source: Json): Json => ({ + "metadata.root": { + package: "acme", + children: [ + { + "object.entity": { + name: "User", + "@discriminator": "kind", + children: [ + { "source.rdb": { "@table": "users" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "kind" } }, + { "identity.primary": { name: "pk", "@fields": ["id"] } }, + { "measure.aggregate": { name: "users", "@agg": "count", "@of": "User.id" } }, + ], + }, + }, + { "object.entity": { name: "Admin", extends: "User", "@discriminatorValue": "ADMIN", children: [] } }, + { "object.report": { name: "Admins", "@from": "Admin", "@measures": ["users"], children: [{ "source.rdb": source }] } }, + ], + }, + }); + const body = "SELECT COUNT(id) AS users FROM users WHERE kind = 'ADMIN'"; + const sql = buildReportViews(await loadModel(tph({ "@kind": "view", "@view": "v_admins", "@sql": body })), PG); + expect(sql.map((v) => [v.name, v.sql, v.dependsOn])).toEqual([["v_admins", body, ["users"]]]); + // An @unmanaged view is likewise the author's: nothing is created and nothing is refused. + expect(buildReportViews(await loadModel(tph({ "@kind": "view", "@view": "v_admins", "@unmanaged": true })), PG)).toEqual([]); + // The derived path is the one that refuses. + await expect( + loadModel(tph({ "@kind": "view", "@view": "v_admins" })).then((root) => buildReportViews(root, PG)), + ).rejects.toThrow(/report 'Admins': @from 'Admin' is a TPH subtype/); + }); + test("the projection loop does not see a report", async () => { const root = await loadModel(shop("with")); const all = buildProjectionViews(root, PG); diff --git a/server/typescript/packages/codegen-ts/test/projection/extract-report-spec.test.ts b/server/typescript/packages/codegen-ts/test/projection/extract-report-spec.test.ts index 879f20110..677b0abb4 100644 --- a/server/typescript/packages/codegen-ts/test/projection/extract-report-spec.test.ts +++ b/server/typescript/packages/codegen-ts/test/projection/extract-report-spec.test.ts @@ -5,7 +5,14 @@ import { describe, test, expect } from "bun:test"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; -import { MetaDataLoader, InMemoryStringSource, type MetaRoot } from "@metaobjectsdev/metadata"; +import { + MetaDataLoader, + InMemoryStringSource, + OBJECT_REPORT_ATTR_FILTER, + reportReadModel, + type MetaObject, + type MetaRoot, +} from "@metaobjectsdev/metadata"; import { extractReportSpec, temporalOf } from "../../src/projection/extract-report-spec.js"; import { isRelativeNow } from "../../src/projection/report-spec.js"; import type { ReportViewSpec } from "../../src/projection/report-spec.js"; @@ -216,6 +223,16 @@ describe("extractReportSpec", () => { }); const s = await spec("RatioOnly", { model }); expect(s.columns.map((c) => c.kind)).toEqual(["ratio"]); + const ratio = s.columns[0]!; + if (ratio.kind !== "ratio") throw new Error("expected a ratio"); + // Neither operand is listed, and both arrive as full aggregates over the base alias. + const listed = await spec("ProgramEngagement"); + const same = listed.columns.find((c) => c.fieldName === "avgDaysPerStarter")!; + if (same.kind !== "ratio") throw new Error("expected a ratio"); + expect(ratio.numerator).toEqual(same.numerator); + expect(ratio.denominator).toEqual(same.denominator); + expect(ratio.numerator.refs).toHaveLength(3); + expect(ratio.denominator.refs).toEqual(["w.customer_email"]); }); test("an integral sum is cast to bigint; a currency sum too; a floating sum to double", async () => { @@ -294,3 +311,245 @@ describe("temporalOf", () => { expect(temporalOf(field("localAt"))).toBe("naive"); }); }); + +// --------------------------------------------------------------------------- +// Final fix wave (FR-044): refusals and reference resolution. +// --------------------------------------------------------------------------- + +const CTX = { columnNamingStrategy: "snake_case" } as const; + +const file = (pkg: string, children: Json[]): InMemoryStringSource => + new InMemoryStringSource(JSON.stringify({ "metadata.root": { package: pkg, children } })); + +async function loadFiles(files: InMemoryStringSource[]): Promise { + const { root, errors } = await new MetaDataLoader().load(files); + expect(errors).toEqual([]); + return root; +} + +const view = (name: string): Json => ({ "source.rdb": { "@kind": "view", "@view": name } }); + +/** The node with one attr replaced, WITHOUT the loader (the loaded tree is frozen and the + * loader refuses these values): what a caller building a tree in code can hand in. */ +function withAttr(node: MetaObject, name: string, value: unknown): MetaObject { + const stub = Object.create(node) as MetaObject; + Object.defineProperty(stub, "attr", { value: (n: string) => (n === name ? value : node.attr(n)) }); + return stub; +} + +describe("extractReportSpec: a @from in a TPH hierarchy", () => { + /** `User` owns the table and the discriminator; `Admin` is a TPH subtype sharing it. */ + const tph = (reports: Json[]): InMemoryStringSource => + file("acme", [ + { + "object.entity": { + name: "User", + "@discriminator": "kind", + children: [ + { "source.rdb": { "@table": "users" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "kind" } }, + { "identity.primary": { name: "pk", "@fields": ["id"] } }, + { "measure.aggregate": { name: "users", "@agg": "count", "@of": "User.id" } }, + ], + }, + }, + { "object.entity": { name: "Admin", extends: "User", "@discriminatorValue": "ADMIN", children: [] } }, + ...reports, + ]); + const report = (name: string, from: string, extra: Json = {}, source: Json = view("v_r")): Json => ({ + "object.report": { name, "@from": from, "@measures": ["users"], ...extra, children: [source] }, + }); + + test("a derived report @from a TPH subtype is refused, naming the report and the subtype", async () => { + const root = await loadFiles([tph([report("Admins", "Admin")])]); + expect(() => extractReportSpec(root.findObject("Admins")!, root, CTX)).toThrow( + "report 'Admins': @from 'Admin' is a TPH subtype: it shares the table of 'User' with every other " + + "subtype, so a view derived from it would aggregate all of their rows. Declare the report " + + "@from 'User' with an @filter on the discriminator field 'kind' (for example { \"kind\": \"ADMIN\" }).", + ); + }); + + test("a report @from the TPH base is accepted and reads the shared table unscoped", async () => { + const root = await loadFiles([tph([report("Users", "User")])]); + const s = extractReportSpec(root.findObject("Users")!, root, CTX); + expect(s.joinTree.baseEntity).toBe("acme::User"); + expect(s.where).toBeUndefined(); + }); + + test("a base report with an @filter on the discriminator lowers to a WHERE on that column", async () => { + const root = await loadFiles([tph([report("Admins", "User", { "@filter": { kind: "ADMIN" } })])]); + const s = extractReportSpec(root.findObject("Admins")!, root, CTX); + expect(s.where).toEqual({ kind: "cmp", ref: "u.kind", op: "eq", value: "ADMIN" }); + }); + + test("the runtime read model does not look at @from's TPH position: it serves whatever relation the source names", async () => { + // An @sql or @unmanaged view over a subtype is the author's body, so it is not refused + // (build-projection-views.test.ts); the read model's shape is the same Table B either way. + const root = await loadFiles([tph([report("Admins", "Admin")])]); + const model = reportReadModel(root.findObject("Admins")!, root); + expect(model?.fields().map((f) => f.name)).toEqual(["users"]); + }); +}); + +describe("extractReportSpec: references resolve as the loader resolves them", () => { + /** `a::Base` (abstract) declares members with BARE references; `b::Ev extends a::Base`. */ + const shared = (): InMemoryStringSource => + file("a", [ + { + "object.entity": { + name: "Owner", + children: [ + { "source.rdb": { "@table": "owners" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "label" } }, + { "identity.primary": { name: "pk", "@fields": ["id"] } }, + ], + }, + }, + { + "object.entity": { + name: "Base", + abstract: true, + children: [ + { "field.long": { name: "id" } }, + { "field.string": { name: "kind" } }, + { "field.long": { name: "ownerId" } }, + { "identity.primary": { name: "pk", "@fields": ["id"] } }, + { "identity.reference": { name: "ownerRef", "@fields": ["ownerId"], "@references": "a::Owner" } }, + { "relationship.association": { name: "owner", "@objectRef": "a::Owner", "@cardinality": "one" } }, + { "dimension.attribute": { name: "kind", "@of": "Base.kind" } }, + { "dimension.attribute": { name: "ownerLabel", "@of": "Owner.label", "@via": "Base.owner" } }, + { "measure.aggregate": { name: "events", "@agg": "count", "@of": "Base.id" } }, + ], + }, + }, + ]); + const consumer = (extra: Json[] = []): InMemoryStringSource => + file("b", [ + ...extra, + { "object.entity": { name: "Ev", extends: "a::Base", children: [{ "source.rdb": { "@table": "evs" } }] } }, + { + "object.report": { + name: "R", + "@from": "Ev", + "@dimensions": ["kind", "ownerLabel"], + "@measures": ["Ev.events"], + children: [view("v_r")], + }, + }, + ]); + + const refs = (s: ReportViewSpec): unknown[] => + s.columns.map((c) => (c.kind === "aggregate" ? c.aggregate.refs : (c as { ref: string }).ref)); + + test("a bare @of / @via inherited from another package resolves in the declaring entity's package", async () => { + const root = await loadFiles([shared(), consumer()]); + const s = extractReportSpec(root.findObject("R")!, root, CTX); + expect(s.joinTree.baseEntity).toBe("b::Ev"); + expect(s.joinTree.joins.map((j) => [j.relationship, j.targetEntity])).toEqual([["owner", "a::Owner"]]); + expect(refs(s)).toEqual(["e.kind", `${s.joinTree.joins[0]!.alias}.label`, ["e.id"]]); + }); + + test("same-named decoys in the report's package do not capture the references", async () => { + const decoys: Json[] = [ + { "object.entity": { name: "Base", children: [{ "field.int": { name: "id" } }, { "field.int": { name: "kind" } }] } }, + { + "object.entity": { + name: "Owner", + children: [{ "source.rdb": { "@table": "decoy_owners" } }, { "field.int": { name: "label", "@column": "decoy" } }], + }, + }, + ]; + const root = await loadFiles([shared(), consumer(decoys)]); + const s = extractReportSpec(root.findObject("R")!, root, CTX); + expect(s.joinTree.joins.map((j) => j.targetEntity)).toEqual(["a::Owner"]); + expect(refs(s)).toEqual(["e.kind", `${s.joinTree.joins[0]!.alias}.label`, ["e.id"]]); + }); +}); + +describe("extractReportSpec: refusals that name what is wrong", () => { + test("refuses an abstract @from, naming the report and the entity", async () => { + const root = await loadFiles([ + file("acme", [ + { + "object.entity": { + name: "Shape", + abstract: true, + children: [ + { "source.rdb": { "@table": "shapes" } }, + { "field.long": { name: "id" } }, + { "identity.primary": { name: "pk", "@fields": ["id"] } }, + { "measure.aggregate": { name: "shapes", "@agg": "count", "@of": "Shape.id" } }, + ], + }, + }, + { "object.report": { name: "Shapes", "@from": "Shape", "@measures": ["shapes"], children: [view("v_shapes")] } }, + ]), + ]); + expect(() => extractReportSpec(root.findObject("Shapes")!, root, CTX)).toThrow( + /report 'Shapes'.*'Shape'.*no table \(it is abstract/, + ); + }); + + test("a @via hop with no foreign key in the model is refused, naming the hop and what it needs", async () => { + // The loader accepts a to-one relationship with no identity.reference behind it. + const model = shopModel((children) => { + const purchase = children.find((c) => (c["object.entity"] as Json | undefined)?.name === "Purchase")!; + const kids = (purchase["object.entity"] as { children: Json[] }).children; + kids.splice(kids.findIndex((k) => "identity.reference" in k), 1); + }); + const root = await load(model); + expect(() => extractReportSpec(root.findObject("ProgramTitles")!, root, CTX)).toThrow( + "report 'ProgramTitles': dimension 'programTitle' @via 'Purchase.program' cannot be joined at hop 'program' " + + "on 'acme::shop::Purchase': the model declares no foreign key for it. A view joins a hop through an " + + "identity.reference; declare one on 'Purchase' whose @references is 'Program' (with the foreign-key " + + "field in @fields).", + ); + }); + + test("a @via whose later hop does not resolve is refused, not joined part-way", async () => { + const root = await load(shopModel()); + const titles = root.findObject("ProgramTitles")!; + const from = root.findObject("Purchase")!; + const dim = from.children().find((c) => c.name === "programTitle")!; + // Past the loader (rule D2 refuses an unknown hop): a two-hop path whose second hop is nothing. + const via = Object.create(dim) as typeof dim & { via(): string }; + Object.defineProperty(via, "via", { value: () => "Purchase.program.nowhere" }); + const fromStub = Object.create(from) as MetaObject; + Object.defineProperty(fromStub, "children", { value: () => from.children().map((c) => (c === dim ? via : c)) }); + const rootStub = Object.create(root) as MetaRoot; + const fromKey = from.resolutionKey(); + Object.defineProperty(rootStub, "children", { + value: () => root.children().map((c) => (c.resolutionKey() === fromKey ? fromStub : c)), + }); + Object.defineProperty(rootStub, "objects", { + value: () => root.objects().map((c) => (c.resolutionKey() === fromKey ? fromStub : c)), + }); + expect(() => extractReportSpec(titles, rootStub, CTX)).toThrow( + /report 'ProgramTitles': dimension 'programTitle' @via 'Purchase.program.nowhere' cannot be joined at hop 'nowhere' on 'acme::shop::Program': it names no relationship or identity.reference/, + ); + }); + + test("a filter field that is not a field of @from is refused by name", async () => { + const root = await load(shopModel()); + // Past the loader (rule S1 refuses an unknown filter field). + const r = withAttr(root.findObject("StoreTotals")!, OBJECT_REPORT_ATTR_FILTER, { nope: 1 }); + expect(() => extractReportSpec(r, root, CTX)).toThrow( + `report 'StoreTotals' @filter: filter field "nope" is not a field of 'Purchase'.`, + ); + }); + + test("an empty `in` list is refused at lowering, naming the report and the field", async () => { + const model = shopModel((children) => { + children.push({ + "object.report": { name: "NoStatuses", "@from": "Purchase", "@measures": ["purchases"], "@filter": { status: { in: [] } } }, + }); + }); + const root = await load(model); + expect(() => extractReportSpec(root.findObject("NoStatuses")!, root, CTX)).toThrow( + `report 'NoStatuses' @filter: the 'in' list on "status" is empty, which no row can match and no ` + + `database accepts as SQL (IN ()). List at least one value, or remove the clause.`, + ); + }); +}); diff --git a/server/typescript/packages/codegen-ts/test/projection/time-sql.test.ts b/server/typescript/packages/codegen-ts/test/projection/time-sql.test.ts index 6d57f900a..37301f76a 100644 --- a/server/typescript/packages/codegen-ts/test/projection/time-sql.test.ts +++ b/server/typescript/packages/codegen-ts/test/projection/time-sql.test.ts @@ -64,6 +64,18 @@ describe("truncateToGrain (Table D)", () => { test.each(["postgres", "sqlite", "mysql"] as const)("hour on a date column throws (%s)", (dialect) => { expect(() => truncateToGrain(X, "hour", "date", dialect)).toThrow(/hour/); }); + + test.each(["postgres", "sqlite", "mysql"] as const)( + "a grain outside the closed set is refused, never interpolated into SQL (%s)", + (dialect) => { + // A programmatic caller skips the loader; the Postgres arm writes the grain into + // date_trunc('', ...), so an unchecked string would reach the DDL. + const hostile = "day', now()); DROP TABLE t; --" as unknown as "day"; + expect(() => truncateToGrain(X, hostile, "instant", dialect)).toThrow( + `time-sql: "day', now()); DROP TABLE t; --" is not a time grain (hour, day, week, month, quarter, year).`, + ); + }, + ); }); describe("relativeNowSql (Table E)", () => { diff --git a/server/typescript/packages/integration-tests/src/gen-report-shapes.ts b/server/typescript/packages/integration-tests/src/gen-report-shapes.ts index b6a043696..53bd9fb10 100644 --- a/server/typescript/packages/integration-tests/src/gen-report-shapes.ts +++ b/server/typescript/packages/integration-tests/src/gen-report-shapes.ts @@ -19,8 +19,8 @@ import { readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { - isReadOnlySource, OBJECT_SUBTYPE_REPORT, + reportReadSource, reportShape, type MetaField, type MetaRoot, @@ -60,9 +60,10 @@ export function generateReportShapesJson(root: MetaRoot): string { for (const report of root.objects()) { if (report.subType !== OBJECT_SUBTYPE_REPORT) continue; const shape = reportShape(report, root); - // ADR-0039: own — the report's own declared read-only source names its view; a report - // inherits no source, and a sourceless one has no view. - const source = report.ownChildren().find(isReadOnlySource); + // The ONE source-selection rule (own read-only source with role primary, else the first + // own read-only source): the source the lowering names the view by and the runtime reads. + // A sourceless report has no view. + const source = reportReadSource(report); reports.push({ report: report.resolutionKey(), from: shape.from.resolutionKey(), diff --git a/server/typescript/packages/integration-tests/test/report-shapes-artifact.test.ts b/server/typescript/packages/integration-tests/test/report-shapes-artifact.test.ts index ce3b6ffb4..39c09f30b 100644 --- a/server/typescript/packages/integration-tests/test/report-shapes-artifact.test.ts +++ b/server/typescript/packages/integration-tests/test/report-shapes-artifact.test.ts @@ -9,6 +9,8 @@ import { describe, expect, test } from "bun:test"; +import { InMemoryStringSource, MetaDataLoader } from "@metaobjectsdev/metadata"; + import { generateReportShapesJson, readReportShapesJson, @@ -48,4 +50,41 @@ describe("canonical report-shapes artifact (report-shapes.json)", () => { ["fitness::AssetActivity", "v_asset_activity"], ]); }); + + test("`view` is the source the lowering names and the runtime reads: primary, else first", async () => { + // A replica declared BEFORE the primary must not name the report's view. + const model = { + "metadata.root": { + package: "acme", + children: [ + { + "object.entity": { + name: "Sale", + children: [ + { "source.rdb": { "@table": "sales" } }, + { "field.long": { name: "id" } }, + { "identity.primary": { name: "pk", "@fields": ["id"] } }, + { "measure.aggregate": { name: "sales", "@agg": "count", "@of": "Sale.id" } }, + ], + }, + }, + { + "object.report": { + name: "Totals", + "@from": "Sale", + "@measures": ["sales"], + children: [ + { "source.rdb": { "@kind": "view", "@view": "v_totals_replica", "@role": "replica" } }, + { "source.rdb": { "@kind": "view", "@view": "v_totals", "@role": "primary" } }, + ], + }, + }, + ], + }, + }; + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(model))]); + expect(errors).toEqual([]); + const parsed = JSON.parse(generateReportShapesJson(root)) as { reports: { view: string | null }[] }; + expect(parsed.reports.map((r) => r.view)).toEqual(["v_totals"]); + }); }); diff --git a/server/typescript/packages/metadata/src/core/reporting/report-accessors.ts b/server/typescript/packages/metadata/src/core/reporting/report-accessors.ts index af3a6e324..58e118f71 100644 --- a/server/typescript/packages/metadata/src/core/reporting/report-accessors.ts +++ b/server/typescript/packages/metadata/src/core/reporting/report-accessors.ts @@ -8,6 +8,7 @@ import { OBJECT_REPORT_ATTR_FROM, OBJECT_REPORT_ATTR_MEASURES, } from "../object/object-constants.js"; +import { CHILD_REF_SEPARATOR } from "../../shared/structural.js"; import { REPORT_DIMENSION_GRAIN_SEPARATOR } from "./reporting-constants.js"; export interface ReportDimensionItem { @@ -34,11 +35,30 @@ export function reportDimensionItems(obj: MetaData): ReportDimensionItem[] { }); } -/** The `@measures` names. */ +/** The `@measures` items AS WRITTEN: each a bare measure `name`, or a dotted + * `Entity.name` (loader rule R3). Use {@link reportMeasureItemName} for the measure name. */ export function reportMeasureNames(obj: MetaData): string[] { return stringList(obj.attr(OBJECT_REPORT_ATTR_MEASURES)); } +/** + * The measure a `@measures` item names: the segment after its LAST `.` + * (`total`, `Sale.total` and `acme::shop::Sale.total` all name `total`). It is also + * the derived report field's name. The part before that `.`, when present, is an + * entity qualifier, which {@link reportMeasureItemOwner} returns. + */ +export function reportMeasureItemName(item: string): string { + const dot = item.lastIndexOf(CHILD_REF_SEPARATOR); + return dot === -1 ? item : item.slice(dot + CHILD_REF_SEPARATOR.length); +} + +/** The entity qualifier of a dotted `@measures` item (`Sale` in `Sale.total`), or + * undefined for a bare item. Loader rule R3: it names `@from` or an entity `@from` extends. */ +export function reportMeasureItemOwner(item: string): string | undefined { + const dot = item.lastIndexOf(CHILD_REF_SEPARATOR); + return dot === -1 ? undefined : item.slice(0, dot); +} + /** The derived report field for a dimension item: `name` (attribute) or `name` + Capitalized(grain) (time). */ export function reportDerivedFieldName(item: ReportDimensionItem): string { if (item.grain === undefined || item.grain === "") return item.name; diff --git a/server/typescript/packages/metadata/src/core/reporting/report-read-model.ts b/server/typescript/packages/metadata/src/core/reporting/report-read-model.ts index 160e58c55..d499bc23c 100644 --- a/server/typescript/packages/metadata/src/core/reporting/report-read-model.ts +++ b/server/typescript/packages/metadata/src/core/reporting/report-read-model.ts @@ -107,7 +107,7 @@ function derivedField(f: ReportField): MetaField { * model that loads, the primary branch fires. The first-read-only fallback covers a * tree built in code, and keeps this rule identical to the lowering's. */ -function reportReadSource(report: MetaObject): MetaSource | undefined { +export function reportReadSource(report: MetaObject): MetaSource | undefined { // ADR-0039: own — source classification reads the sources the report declares // ITSELF, exactly as the lowering's `viewName` does. const readOnly = report.ownChildren().filter(isReadOnlySource); 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 57c92df2c..e7c04ac27 100644 --- a/server/typescript/packages/metadata/src/core/reporting/report-shape.ts +++ b/server/typescript/packages/metadata/src/core/reporting/report-shape.ts @@ -22,8 +22,24 @@ import { } from "../field/field-constants.js"; import { MetaDimension } from "./meta-dimension.js"; import { MetaMeasure } from "./meta-measure.js"; -import { reportDerivedFieldName, reportDimensionItems, reportFrom, reportMeasureNames } from "./report-accessors.js"; -import { AGG_AVG, AGG_COUNT, AGG_SUM, GRAIN_HOUR, TYPE_DIMENSION, TYPE_MEASURE, type TimeGrain } from "./reporting-constants.js"; +import { + reportDerivedFieldName, + reportDimensionItems, + reportFrom, + reportMeasureItemName, + reportMeasureItemOwner, + reportMeasureNames, +} from "./report-accessors.js"; +import { + AGG_AVG, + AGG_COUNT, + AGG_SUM, + GRAIN_HOUR, + TIME_GRAINS, + TYPE_DIMENSION, + TYPE_MEASURE, + type TimeGrain, +} from "./reporting-constants.js"; export type ReportFieldRole = "dimension" | "measure"; @@ -55,15 +71,79 @@ function packageOfKey(key: string): string { return i >= 0 ? key.slice(0, i) : ""; } -/** Resolve a dimension's or measure's `Entity.field` reference to the field node. */ -export function resolveReportingFieldRef(ref: string, owner: MetaObject, root: MetaRoot): MetaField | undefined { +/** True when `candidate` is `entity` or an entity it extends (the super chain). */ +function isSelfOrAncestor(candidate: MetaData, entity: MetaData): boolean { + const visited = new Set(); + for (let n: MetaData | undefined = entity; n !== undefined && !visited.has(n); n = n.superData) { + if (n === candidate) return true; + visited.add(n); + } + return false; +} + +/** + * The entity that DECLARES a dimension, measure or segment reached through `from`: the + * member's parent, which is `from` itself or an entity `from` extends. A bare entity name + * inside the member (`@of`, `@via`) resolves in THIS entity's package, exactly as the + * loader's `validateReporting` resolves it (`pkgOf(ctx.declaring)`), never in `from`'s + * package or the report's. + */ +export function reportingMemberOwner(member: MetaData, from: MetaObject): MetaData { + return member.parent ?? from; +} + +/** + * Resolve a dimension's or measure's `Entity.field` reference to the field node. The ONE + * rule, the same as the loader's (`validateReporting` D1 / M1): + * + * 1. The entity half resolves relative to the package of `declaring`, the entity that + * declares the member ({@link reportingMemberOwner}). + * 2. With `host` (a measure, or a dimension without `@via`: the reference is about the + * `@from` entity's own rows) the named entity must be `host` or an entity it extends, + * and the field is read from `host`, so a field `host` redeclares wins. + * 3. Without `host` (a dimension with `@via`) the field is read from the named entity. + * + * Undefined when any step fails. + */ +export function resolveReportingFieldRef( + ref: string, + declaring: MetaData, + root: MetaRoot, + host?: MetaObject, +): MetaField | undefined { // `Entity.field`; a package qualifier uses `::`, so the member separator is the LAST dot. const dot = ref.lastIndexOf(CHILD_REF_SEPARATOR); if (dot <= 0) return undefined; - const entity = resolveObjectRef(root, ref.slice(0, dot), packageOfKey(owner.resolutionKey())).node; - if (!isMetaObject(entity)) return undefined; + const named = resolveObjectRef(root, ref.slice(0, dot), packageOfKey(declaring.resolutionKey())).node; + if (!isMetaObject(named)) return undefined; + if (host !== undefined && !isSelfOrAncestor(named, host)) return undefined; // ADR-0039: resolving, so a field inherited through extends is found. - return entity.fields().find((f) => f.name === ref.slice(dot + 1)); + return (host ?? named).fields().find((f) => f.name === ref.slice(dot + 1)); +} + +/** + * The hop names of a dimension's `@via` (`Owner.hop[.hop...]`), read as the loader reads it + * (`validateReporting` rule D2): `Owner` resolves in the package of `declaring` + * ({@link reportingMemberOwner}) and must be `from` or an entity `from` extends. The walk + * itself then starts AT `from`, whichever of the two `Owner` named. Undefined when the + * reference has no owner, no hop, or an owner that is not `from` or an ancestor of it. + */ +export function reportingViaHops( + via: string, + declaring: MetaData, + from: MetaObject, + root: MetaRoot, +): string[] | undefined { + // The owner ends at the first `.` after the last `::` (a package qualifier has no `.`). + const lastSep = via.lastIndexOf(PACKAGE_SEPARATOR); + const segStart = lastSep === -1 ? 0 : lastSep + PACKAGE_SEPARATOR.length; + const dot = via.indexOf(CHILD_REF_SEPARATOR, segStart); + if (dot <= segStart) return undefined; + const hops = via.slice(dot + CHILD_REF_SEPARATOR.length).split(CHILD_REF_SEPARATOR); + if (hops.some((h) => h === "")) return undefined; + const owner = resolveObjectRef(root, via.slice(0, dot), packageOfKey(declaring.resolutionKey())).node; + if (owner === undefined || !isSelfOrAncestor(owner, from)) return undefined; + return hops; } function unresolved(reportName: string, what: string): Error { @@ -80,6 +160,10 @@ function declaredMember( return from.children().find((c): c is T => c.type === type && c.name === name && c instanceof cls); } +function isTimeGrain(grain: string | undefined): grain is TimeGrain { + return grain !== undefined && (TIME_GRAINS as readonly string[]).includes(grain); +} + function dimensionField( item: { name: string; grain?: string }, from: MetaObject, @@ -88,12 +172,15 @@ function dimensionField( ): ReportField { const dim = declaredMember(from, TYPE_DIMENSION, item.name, MetaDimension); if (dim === undefined) throw unresolved(reportName, `dimension '${item.name}' on '${from.name}'`); - const of = resolveReportingFieldRef(dim.of() ?? "", from, root); + const vialess = dim.via() === undefined; + const of = resolveReportingFieldRef(dim.of() ?? "", reportingMemberOwner(dim, from), root, vialess ? from : undefined); if (of === undefined) throw unresolved(reportName, `dimension '${item.name}' @of`); const name = reportDerivedFieldName(item); - const required = dim.via() === undefined && of.attr(FIELD_ATTR_REQUIRED) === true; + const required = vialess && of.attr(FIELD_ATTR_REQUIRED) === true; if (dim.isTime()) { - const grain = item.grain as TimeGrain; + // Loader rule R2 guarantees a grain from the closed set; a tree built in code does not. + const grain = item.grain; + if (!isTimeGrain(grain)) throw unresolved(reportName, `time dimension '${item.name}' grain '${grain ?? ""}'`); if (grain === GRAIN_HOUR) { return { name, role: "dimension", subType: FIELD_SUBTYPE_TIMESTAMP, required, typeSource: of, dimension: dim, grain }; } @@ -102,9 +189,23 @@ function dimensionField( return { name, role: "dimension", subType: of.subType, required, typeSource: of, dimension: dim }; } -function measureField(name: string, from: MetaObject, root: MetaRoot, reportName: string): ReportField { +/** + * One `@measures` item, bare (`total`) or dotted (`Sale.total`, loader rule R3). The measure + * is named by the item's last segment and looked up on `from`; a qualifier resolves in the + * REPORT's package and must be `from` or an entity `from` extends. + */ +function measureField(item: string, report: MetaObject, from: MetaObject, root: MetaRoot): ReportField { + const reportName = report.name; + const name = reportMeasureItemName(item); + const qualifier = reportMeasureItemOwner(item); + if (qualifier !== undefined) { + const owner = resolveObjectRef(root, qualifier, packageOfKey(report.resolutionKey())).node; + if (owner === undefined || !isSelfOrAncestor(owner, from)) { + throw unresolved(reportName, `measure '${item}' on '${from.name}'`); + } + } const m = declaredMember(from, TYPE_MEASURE, name, MetaMeasure); - if (m === undefined) throw unresolved(reportName, `measure '${name}' on '${from.name}'`); + if (m === undefined) throw unresolved(reportName, `measure '${item}' on '${from.name}'`); if (m.isRatio()) { return { name, role: "measure", subType: FIELD_SUBTYPE_DECIMAL, required: false, measure: m }; } @@ -112,7 +213,7 @@ function measureField(name: string, from: MetaObject, root: MetaRoot, reportName if (agg === AGG_COUNT) { return { name, role: "measure", subType: FIELD_SUBTYPE_LONG, required: true, measure: m }; } - const of = resolveReportingFieldRef(m.ofColumns()[0] ?? "", from, root); + const of = resolveReportingFieldRef(m.ofColumns()[0] ?? "", reportingMemberOwner(m, from), root, from); if (of === undefined) throw unresolved(reportName, `measure '${name}' @of`); const src = of.subType; if (agg === AGG_SUM) { @@ -139,7 +240,7 @@ export function reportShape(report: MetaObject, root: MetaRoot): ReportShape { if (!isMetaObject(from)) throw unresolved(report.name, `@from '${fromName}'`); const fields = [ ...reportDimensionItems(report).map((item) => dimensionField(item, from, root, report.name)), - ...reportMeasureNames(report).map((name) => measureField(name, from, root, report.name)), + ...reportMeasureNames(report).map((item) => measureField(item, report, from, root)), ]; return { report, from, fields }; } diff --git a/server/typescript/packages/metadata/src/index.ts b/server/typescript/packages/metadata/src/index.ts index 5e365a273..19364d602 100644 --- a/server/typescript/packages/metadata/src/index.ts +++ b/server/typescript/packages/metadata/src/index.ts @@ -50,17 +50,21 @@ export { reportFrom, reportDimensionItems, reportMeasureNames, + reportMeasureItemName, + reportMeasureItemOwner, reportDerivedFieldName, type ReportDimensionItem, } from "./core/reporting/report-accessors.js"; export { reportShape, + reportingMemberOwner, + reportingViaHops, resolveReportingFieldRef, type ReportField, type ReportFieldRole, type ReportShape, } from "./core/reporting/report-shape.js"; -export { reportReadModel } from "./core/reporting/report-read-model.js"; +export { reportReadModel, reportReadSource } from "./core/reporting/report-read-model.js"; // Shared `@implementedBy` resolution — one resolver for the CLI's requirement // checks and codegen's requirement-test fan-out (FR-038). export { diff --git a/server/typescript/packages/metadata/test/report-shape.test.ts b/server/typescript/packages/metadata/test/report-shape.test.ts index 5e451b656..c9c3b1960 100644 --- a/server/typescript/packages/metadata/test/report-shape.test.ts +++ b/server/typescript/packages/metadata/test/report-shape.test.ts @@ -1,7 +1,17 @@ import { describe, expect, test } from "bun:test"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; -import { loadUris, reportShape, type MetaObject, type MetaRoot } from "../src/index.js"; +import { + InMemoryStringSource, + MetaDataLoader, + OBJECT_REPORT_ATTR_DIMENSIONS, + OBJECT_REPORT_ATTR_MEASURES, + loadUris, + reportMeasureItemName, + reportShape, + type MetaObject, + type MetaRoot, +} from "../src/index.js"; const REPO_ROOT = resolve(import.meta.dir, "..", "..", "..", "..", ".."); const MODEL = join(REPO_ROOT, "fixtures", "conformance", "reporting-vocabulary", "input", "meta.shop.json"); @@ -51,3 +61,128 @@ describe("reportShape (FR-044 Table B)", () => { expect(brief(root, "StoreTotals").map((f) => f[1])).toEqual(["measure", "measure", "measure"]); }); }); + +// --------------------------------------------------------------------------- +// Reference resolution (final fix wave A2 / A3 / A8). The shape must agree with the +// loader's `validateReporting` about what a reference names, or a model that loads +// clean fails (or is silently mistyped) when it is lowered or read. +// --------------------------------------------------------------------------- + +const file = (pkg: string, children: unknown[]): InMemoryStringSource => + new InMemoryStringSource(JSON.stringify({ "metadata.root": { package: pkg, children } })); + +async function loadInline(files: InMemoryStringSource[]): Promise { + const { root, errors } = await new MetaDataLoader().load(files); + expect(errors).toEqual([]); + return root; +} + +/** `a::Base` (abstract): members whose bare `@of` names `Base`. */ +const sharedBase = { + "object.entity": { + name: "Base", + abstract: true, + children: [ + { "field.long": { name: "id" } }, + { "field.string": { name: "kind" } }, + { "identity.primary": { name: "pk", "@fields": ["id"] } }, + { "dimension.attribute": { name: "kind", "@of": "Base.kind" } }, + { "measure.aggregate": { name: "events", "@agg": "count", "@of": "Base.id" } }, + { "measure.aggregate": { name: "lastKind", "@agg": "max", "@of": "Base.kind" } }, + ], + }, +}; +const ev = (extra: unknown[] = []) => ({ + "object.entity": { + name: "Ev", + extends: "a::Base", + children: [{ "source.rdb": { "@table": "evs" } }, ...extra], + }, +}); +const evReport = (attrs: Record = {}) => ({ + "object.report": { + name: "R", + "@from": "Ev", + "@dimensions": ["kind"], + "@measures": ["events", "lastKind"], + ...attrs, + children: [{ "source.rdb": { "@kind": "view", "@view": "v_r" } }], + }, +}); +/** The report with one attr replaced, WITHOUT the loader (the loaded tree is frozen, and + * the loader refuses these values): what a caller building a tree in code can hand in. */ +function withAttr(node: MetaObject, name: string, value: unknown): MetaObject { + const stub = Object.create(node) as MetaObject; + Object.defineProperty(stub, "attr", { value: (n: string) => (n === name ? value : node.attr(n)) }); + return stub; +} + +const typed = (root: MetaRoot) => + reportShape(report(root, "R"), root).fields.map((f) => [f.name, f.subType, f.typeSource?.parent?.resolutionKey()]); + +describe("reportShape reference resolution", () => { + test("a bare @of on a member inherited from another package resolves in the DECLARING entity's package", async () => { + const root = await loadInline([file("a", [sharedBase]), file("b", [ev(), evReport()])]); + expect(typed(root)).toEqual([ + ["kind", "string", "a::Base"], + ["events", "long", undefined], + ["lastKind", "string", "a::Base"], + ]); + }); + + test("a same-named decoy in the report's package does not capture the reference", async () => { + const decoy = { + "object.entity": { name: "Base", children: [{ "field.int": { name: "id" } }, { "field.int": { name: "kind" } }] }, + }; + const root = await loadInline([file("a", [sharedBase]), file("b", [decoy, ev(), evReport()])]); + expect(typed(root)).toEqual([ + ["kind", "string", "a::Base"], + ["events", "long", undefined], + ["lastKind", "string", "a::Base"], + ]); + }); + + test("without @via the field is read from @from, so a field @from redeclares wins (as in the loader)", async () => { + const root = await loadInline([ + file("a", [sharedBase]), + file("b", [ev([{ "field.int": { name: "kind" } }]), evReport()]), + ]); + expect(typed(root)).toEqual([ + ["kind", "int", "b::Ev"], + ["events", "long", undefined], + ["lastKind", "int", "b::Ev"], + ]); + }); + + test("a dotted @measures item names the measure by its last segment (loader rule R3)", async () => { + const root = await loadInline([ + file("a", [sharedBase]), + file("b", [ev(), evReport({ "@measures": ["Ev.events", "a::Base.lastKind"] })]), + ]); + expect(typed(root).map((f) => f[0])).toEqual(["kind", "events", "lastKind"]); + }); + + test("reportMeasureItemName: bare, dotted and package-qualified", () => { + expect(reportMeasureItemName("total")).toBe("total"); + expect(reportMeasureItemName("Sale.total")).toBe("total"); + expect(reportMeasureItemName("acme::shop::Sale.total")).toBe("total"); + }); + + test("a dotted @measures item whose qualifier is not @from (or an ancestor of it) does not resolve", async () => { + const root = await loadInline([file("a", [sharedBase]), file("b", [ev(), evReport()])]); + // Past the loader, which refuses this as ERR_INVALID_REPORT / ERR_REPORT_FOREIGN_MEASURE. + const r = withAttr(report(root, "R"), OBJECT_REPORT_ATTR_MEASURES, ["Nope.events"]); + expect(() => reportShape(r, root)).toThrow("report 'R': measure 'Nope.events' on 'Ev' does not resolve."); + }); + + test("a time dimension item with no grain, or a grain outside the closed set, does not resolve", async () => { + const root = await load(); + const r = report(root, "DailyRevenue"); + expect(() => reportShape(withAttr(r, OBJECT_REPORT_ATTR_DIMENSIONS, ["purchasedAt"]), root)).toThrow( + "report 'DailyRevenue': time dimension 'purchasedAt' grain '' does not resolve.", + ); + expect(() => reportShape(withAttr(r, OBJECT_REPORT_ATTR_DIMENSIONS, ["purchasedAt:fortnight"]), root)).toThrow( + "report 'DailyRevenue': time dimension 'purchasedAt' grain 'fortnight' does not resolve.", + ); + }); +}); From f1cb45d18e2733a0aa773b2b1639a8f686d6db3a Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 11:11:34 -0400 Subject: [PATCH 23/32] test(reporting): engine tests read real views and stop touching what they do not own (FR-044) - MySQL: drop only the views and tables this file creates, by name, instead of every view in the database (a shared test database lost unrelated views). - Postgres: changing a report runs a plain migrate with no dropView allowance, so a broken drop-and-create pairing would fail the test. - The NULL-component tuple count is read through a lowered view over nullable columns on Postgres and SQLite, not through hand-written SQL. - A decimal sum and a double / float sum are created and read on Postgres, with the view's column types asserted. - Every inline model asserts the loader returned no errors. - The SQLite inet residue is narrowed to the two inet columns. --- .../test/report-views-mysql.test.ts | 20 ++++- .../test/report-views-pg.test.ts | 77 ++++++++++++++++++- .../test/report-views-sqlite.test.ts | 54 +++++++++---- 3 files changed, 129 insertions(+), 22 deletions(-) diff --git a/server/typescript/packages/integration-tests/test/report-views-mysql.test.ts b/server/typescript/packages/integration-tests/test/report-views-mysql.test.ts index 717054991..caa584c28 100644 --- a/server/typescript/packages/integration-tests/test/report-views-mysql.test.ts +++ b/server/typescript/packages/integration-tests/test/report-views-mysql.test.ts @@ -75,7 +75,10 @@ const DDL = [ ]; async function loadInline(metaJson: string): Promise { - return (await new MetaDataLoader().load([new InMemoryStringSource(metaJson)])).root; + const r = await new MetaDataLoader().load([new InMemoryStringSource(metaJson)]); + // The loader collects errors instead of throwing; a refused inline model must not reach MySQL. + expect(r.errors).toEqual([]); + return r.root; } function reportViews(root: MetaRoot) { @@ -175,6 +178,14 @@ const RELATIVE_MODEL = JSON.stringify({ "metadata.root": { package: "acme", chil { "source.rdb": { "@kind": "view", "@view": "v_up_to_tomorrow" } } ] } }, ]}}); +/** Every view and table this file creates: the canonical six, the inline models' and the recipe's. */ +const OWN_VIEWS = [ + ...CANONICAL_VIEWS, + "v_events_by_grain", "v_last_twelve_hours", "v_last_two_weeks", "v_up_to_tomorrow", + "v_program_minutes_recipe", +] as const; +const OWN_TABLES = ["weeks", "programs", "assets", "events"] as const; + beforeAll(async () => { container = await startMysql(); conn = await mysql.createConnection({ @@ -186,9 +197,10 @@ beforeAll(async () => { }); // Idempotent: a rerun against a persistent METAOBJECTS_TEST_MYSQL_URL starts clean, and // every test below is independent of test order (or of `-t` selecting one of them). - const stale = await select(`SELECT table_name AS n FROM information_schema.views WHERE table_schema = DATABASE()`); - for (const v of stale) await conn.query(`DROP VIEW IF EXISTS \`${String(v.n)}\``); - for (const t of ["weeks", "programs", "assets", "events"]) await conn.query(`DROP TABLE IF EXISTS ${t}`); + // Only the views and tables THIS file creates, by name: METAOBJECTS_TEST_MYSQL_URL may point + // at a shared database, and sweeping information_schema would drop somebody else's views. + for (const v of OWN_VIEWS) await conn.query(`DROP VIEW IF EXISTS \`${v}\``); + for (const t of OWN_TABLES) await conn.query(`DROP TABLE IF EXISTS \`${t}\``); for (const ddl of DDL) await conn.query(ddl); canonical = await loadMetadataDir(CANONICAL_DIR); await createViews(canonical); diff --git a/server/typescript/packages/integration-tests/test/report-views-pg.test.ts b/server/typescript/packages/integration-tests/test/report-views-pg.test.ts index c593e4756..7921e357a 100644 --- a/server/typescript/packages/integration-tests/test/report-views-pg.test.ts +++ b/server/typescript/packages/integration-tests/test/report-views-pg.test.ts @@ -63,6 +63,8 @@ async function applyRaw(text: string): Promise { async function loadInline(metaJson: string): Promise { const r = await new MetaDataLoader().load([new InMemoryStringSource(metaJson)]); + // The loader collects errors instead of throwing; a refused inline model must not be migrated. + expect(r.errors).toEqual([]); return r.root; } @@ -410,7 +412,10 @@ describe("report views — inline models on real Postgres", () => { await assertConverged(first.expected, first.unmanagedNames); const after = await loadInline(metricModel(["samples", "total"])); - const { result, up, expected, unmanagedNames } = await migrate(after, { dropView: true }); + // A plain `meta migrate`, no --allow: the drop is PAIRED with the create of the same view, + // which is not a destructive change. Passing dropView here would hide a broken pairing. + const { result, up, expected, unmanagedNames } = await migrate(after); + expect(result.blocked).toEqual([]); const viewChanges = result.changes.filter((c) => c.kind.endsWith("-view")); expect(viewChanges.map((c) => c.kind).sort()).toEqual(["create-view", "drop-view"]); @@ -425,7 +430,75 @@ describe("report views — inline models on real Postgres", () => { expect((cols.rows as { column_name: string }[]).map((c) => c.column_name)).toEqual(["kind", "samples", "total"]); await assertConverged(expected, unmanagedNames); - const again = await migrate(after, { dropView: true }); + const again = await migrate(after); expect(again.up.trim()).toBe(""); }, 60_000); + + test("a tuple with a NULL component is not counted, read through the lowered view", async () => { + // The canonical tuple's components are both required, so only an inline model can put a + // NULL through the FILTER guard on an engine. + const root = await loadInline(JSON.stringify({ "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Pair", children: [ + { "source.rdb": { "@table": "pairs" } }, + { "field.long": { name: "id" } }, + { "field.int": { name: "a" } }, + { "field.int": { name: "b" } }, + { "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } }, + { "measure.aggregate": { name: "combos", "@agg": "count", "@distinct": true, "@of": ["Pair.a", "Pair.b"] } }, + ] } }, + { "object.report": { name: "PairTotals", "@from": "Pair", "@measures": ["combos"], children: [ + { "source.rdb": { "@kind": "view", "@view": "v_pair_totals" } } ] } }, + ]}})); + const { expected, unmanagedNames } = await migrate(root); + await assertConverged(expected, unmanagedNames); + await applyRaw(` + INSERT INTO "pairs" ("a","b") VALUES (1, 2), (1, 2), (2, 1), (1, NULL), (NULL, 3), (NULL, NULL);`); + expect(await select(`SELECT * FROM "v_pair_totals"`)).toEqual([{ combos: "2" }]); + }, 60_000); + + test("SUM TYPES (Table C): a decimal sum stays numeric and a double sum is double precision, on the engine", async () => { + const root = await loadInline(JSON.stringify({ "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Reading", children: [ + { "source.rdb": { "@table": "readings" } }, + { "field.long": { name: "id" } }, + { "field.decimal": { name: "amount", "@precision": 12, "@scale": 2 } }, + { "field.double": { name: "score" } }, + { "field.float": { name: "ratio" } }, + { "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } }, + { "measure.aggregate": { name: "amountTotal", "@agg": "sum", "@of": "Reading.amount" } }, + { "measure.aggregate": { name: "scoreTotal", "@agg": "sum", "@of": "Reading.score" } }, + { "measure.aggregate": { name: "ratioTotal", "@agg": "sum", "@of": "Reading.ratio" } }, + ] } }, + { "object.report": { name: "ReadingTotals", "@from": "Reading", + "@measures": ["amountTotal", "scoreTotal", "ratioTotal"], children: [ + { "source.rdb": { "@kind": "view", "@view": "v_reading_totals" } } ] } }, + ]}})); + const body = viewSql(root, "v_reading_totals"); + expect(body).toContain(`SUM(r."amount") AS "amountTotal"`); + expect(body).toContain(`CAST(SUM(r."score") AS DOUBLE PRECISION) AS "scoreTotal"`); + expect(body).toContain(`CAST(SUM(r."ratio") AS DOUBLE PRECISION) AS "ratioTotal"`); + + const { expected, unmanagedNames } = await migrate(root); + await assertConverged(expected, unmanagedNames); + + // The view's column types are what Table B promises the readers. + const cols = await sql.raw( + `SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'v_reading_totals' ORDER BY ordinal_position`, + ).execute(k); + expect(cols.rows).toEqual([ + { column_name: "amountTotal", data_type: "numeric" }, + { column_name: "scoreTotal", data_type: "double precision" }, + { column_name: "ratioTotal", data_type: "double precision" }, + ]); + + // Over zero rows every sum is NULL, never 0. + expect(await select(`SELECT * FROM "v_reading_totals"`)).toEqual([ + { amountTotal: null, scoreTotal: null, ratioTotal: null }, + ]); + await applyRaw(`INSERT INTO "readings" ("amount","score","ratio") VALUES (10.25, 1.5, 0.5), (0.50, 2.25, 0.25);`); + const [row] = await select(`SELECT * FROM "v_reading_totals"`); + expect(canonicalDecimal(row!.amountTotal)).toBe("10.75"); + expect(Number(row!.scoreTotal)).toBe(3.75); + expect(Number(row!.ratioTotal)).toBe(0.75); + }, 60_000); }); diff --git a/server/typescript/packages/integration-tests/test/report-views-sqlite.test.ts b/server/typescript/packages/integration-tests/test/report-views-sqlite.test.ts index 46708fe43..af9744695 100644 --- a/server/typescript/packages/integration-tests/test/report-views-sqlite.test.ts +++ b/server/typescript/packages/integration-tests/test/report-views-sqlite.test.ts @@ -28,7 +28,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { Kysely, sql } from "kysely"; import { LibsqlDialect } from "@libsql/kysely-libsql"; -import { buildExpectedSchema, diff, emit, introspectSqlite, type SchemaSnapshot } from "@metaobjectsdev/migrate-ts"; +import { + buildExpectedSchema, diff, emit, introspectSqlite, type Change, type SchemaSnapshot, +} from "@metaobjectsdev/migrate-ts"; import { buildProjectionViews } from "@metaobjectsdev/codegen-ts"; import { MetaDataLoader, InMemoryStringSource, type MetaRoot } from "@metaobjectsdev/metadata"; import { loadMetadataDir } from "../src/load-metadata.ts"; @@ -55,7 +57,10 @@ async function applyRaw(text: string): Promise { } async function loadInline(metaJson: string): Promise { - return (await new MetaDataLoader().load([new InMemoryStringSource(metaJson)])).root; + const r = await new MetaDataLoader().load([new InMemoryStringSource(metaJson)]); + // The loader collects errors instead of throwing; a refused inline model must not be migrated. + expect(r.errors).toEqual([]); + return r.root; } function expectedFor(root: MetaRoot): SchemaSnapshot { @@ -73,8 +78,9 @@ function expectedFor(root: MetaRoot): SchemaSnapshot { * (nothing here reads `all_types`), and it is the only residue the canonical model leaves. * It is named, not swallowed: a residual change on any OTHER table or on any view fails. */ -const isInetResidue = (c: { kind: string; table?: string }): boolean => - c.kind === "change-column-type" && c.table === "all_types"; +const INET_COLUMNS: ReadonlySet = new Set(["inetVal", "inet6Val"]); +const isInetResidue = (c: Change): boolean => + c.kind === "change-column-type" && c.table === "all_types" && INET_COLUMNS.has(c.column) && c.to.kind === "inet"; /** build -> introspect -> diff -> emit -> apply. */ async function migrate(root: MetaRoot) { @@ -183,18 +189,6 @@ describe("report views — canonical model on real SQLite", () => { expect(await select(`SELECT "program" FROM "v_program_minutes" ORDER BY "totalMinutes" DESC LIMIT 1`)).toEqual([{ program: 1 }]); }); - test("a tuple with a NULL component is not counted", async () => { - // durationMinutes is required, so null the other component: programId is required too. - // Prove the guard on the lowered text instead, and the count it protects by hand. - const body = viewSql(canonical, "v_program_minutes"); - expect(body).toContain(`WHEN w."programId" IS NOT NULL AND w."durationMinutes" IS NOT NULL THEN json_array(`); - const r = await select( - `SELECT COUNT(DISTINCT CASE WHEN a IS NOT NULL AND b IS NOT NULL THEN json_array(a, b) END) AS n - FROM (SELECT 1 AS a, 2 AS b UNION ALL SELECT 1, 2 UNION ALL SELECT 1, NULL UNION ALL SELECT NULL, 3)`, - ); - expect(r).toEqual([{ n: 1 }]); - }); - test("v_fitness_totals: no dimensions, one row; ratio is REAL", async () => { await applyRaw(SEED_PROGRAMS_AND_WEEKS); expect(await select(`SELECT * FROM "v_fitness_totals"`)).toEqual([{ weeks: 5, totalMinutes: 285, longShare: 0.6 }]); @@ -290,6 +284,34 @@ describe("report views — inline model on real SQLite", () => { { "source.rdb": { "@kind": "view", "@view": "v_stamps_by_year" } } ] } }, ]}}); + /** A tuple distinct count whose two components are both NULLABLE (the canonical model's are required). */ + const PAIR_MODEL = JSON.stringify({ "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Pair", children: [ + { "source.rdb": { "@table": "pairs" } }, + { "field.long": { name: "id" } }, + { "field.int": { name: "a" } }, + { "field.int": { name: "b" } }, + { "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } }, + { "measure.aggregate": { name: "combos", "@agg": "count", "@distinct": true, "@of": ["Pair.a", "Pair.b"] } }, + ] } }, + { "object.report": { name: "PairTotals", "@from": "Pair", "@measures": ["combos"], children: [ + { "source.rdb": { "@kind": "view", "@view": "v_pair_totals" } } ] } }, + ]}}); + + test("a tuple with a NULL component is not counted, read through the lowered view", async () => { + const root = await loadInline(PAIR_MODEL); + expect(viewSql(root, "v_pair_totals")).toContain( + `COUNT(DISTINCT CASE WHEN p."a" IS NOT NULL AND p."b" IS NOT NULL THEN json_array(p."a", p."b") END)`, + ); + const { expected } = await migrate(root); + await assertConverged(expected); + // (1,2) twice, (2,1) once, and three rows with a NULL component: two distinct tuples. + await applyRaw(` + INSERT INTO "pairs" ("a","b") VALUES + (1, 2), (1, 2), (2, 1), (1, NULL), (NULL, 3), (NULL, NULL)`); + expect(await select(`SELECT * FROM "v_pair_totals"`)).toEqual([{ combos: 2 }]); + }); + test("QUARTER and YEAR grains: every month of a quarter lands on its first day, and the view converges", async () => { const root = await loadInline(STAMP_MODEL); expect(viewSql(root, "v_stamps_by_quarter")).toContain( From fe95a2d3a4e651793609b0fa6b3733ef4b689cba Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 11:13:15 -0400 Subject: [PATCH 24/32] docs(reporting): a @via hop needs a declared foreign key; state the known limits (FR-044) - The documented @via example could not be lowered: its relationship had no identity.reference behind it. The example now declares one, and the feature doc, authoring skill rule 2 and the skill reference say a hop needs it. - Known limits: a derived report from a TPH subtype is refused (declare it from the base with a filter on the discriminator); an abstract view-backed report and non-view source kinds get no C# row or Kotlin table; a dimension over a field.object is not supported across ports. - Which source decides when a report declares several (primary, else first), the dotted @measures form, and that count counts non-null @of. - CHANGELOG [Unreleased] and the agent-context goldens follow. --- CHANGELOG.md | 8 +++- .../skills/metaobjects-authoring/SKILL.md | 12 +++-- .../references/reporting.md | 11 ++++- docs/features/reporting.md | 48 +++++++++++++++++-- .../skills/metaobjects-authoring/SKILL.md | 12 +++-- .../references/reporting.md | 11 ++++- .../skills/metaobjects-authoring/SKILL.md | 12 +++-- .../references/reporting.md | 11 ++++- .../skills/metaobjects-authoring/SKILL.md | 12 +++-- .../references/reporting.md | 11 ++++- .../skills/metaobjects-authoring/SKILL.md | 12 +++-- .../references/reporting.md | 11 ++++- .../skills/metaobjects-authoring/SKILL.md | 12 +++-- .../references/reporting.md | 11 ++++- 14 files changed, 164 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bb406eff..2e19fdfea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,13 @@ it until 1.1 ships._ `object.report` that declares a read-only `source.rdb` of `@kind: view` is now lowered by TypeScript: `meta migrate` creates the view on Postgres, SQLite and D1 (a changed report view is dropped and re-created; a derived report view whose `@from` entity has no table fails - migrate naming the report and the entity; a report with an `@sql` source skips that check). MySQL SQL comes from `buildReportViews(root, + migrate naming the report and the entity; a report with an `@sql` source skips that check). A + derived report view is also refused, by name, when its `@from` is a TPH subtype (the subtype + shares its base's table, so the view would count every subtype's rows: declare it from the base + with an `@filter` on the discriminator field), when a `@via` hop has no `identity.reference` + behind it, and when a filter's `in` list is empty. A bare `@of` or `@via` on a dimension or + measure inherited from a base in another package resolves in that base's package, as the loader + resolves it. MySQL SQL comes from `buildReportViews(root, { dialect: "mysql" })` and the "Reports" section of `docs/recipes/mysql.md`, since `meta migrate` does not target MySQL. A report with no `source.*` still generates nothing. Time grains and relative dates are UTC, weeks start on Monday, a `sum` of nothing and a ratio over diff --git a/agent-context/skills/metaobjects-authoring/SKILL.md b/agent-context/skills/metaobjects-authoring/SKILL.md index 26fb4cdc1..0e34c3503 100644 --- a/agent-context/skills/metaobjects-authoring/SKILL.md +++ b/agent-context/skills/metaobjects-authoring/SKILL.md @@ -730,9 +730,12 @@ Three rules an author trips on: 1. **Every measure belongs to `@from`.** A report cannot mix measures of two entities (joining two fact tables multiplies each side's rows); two fact tables are two reports. -2. **`@via` is to-one only.** A dimension reaches a related entity's column through a - `relationship.*` with `@cardinality: one` (or an `identity.reference`), never through a - to-many, which would repeat fact rows and double-count a `sum`. +2. **`@via` is to-one only, and each hop needs a declared foreign key.** A dimension reaches a + related entity's column through a `relationship.*` with `@cardinality: one` (or an + `identity.reference`), never through a to-many, which would repeat fact rows and double-count + a `sum`. The view joins the hop through an `identity.reference` between the two entities + (`{ "identity.reference": { "name": "fkProgram", "@fields": ["programId"], "@references": + "Program" } }`); a relationship with none behind it loads and then fails `meta migrate`. 3. **A report declares no fields.** Its columns are derived: one per dimension, then one per measure (a time dimension at a grain is ``, so `purchasedAt:day` is `purchasedAtDay`). A `field.*` or `identity.*` child on a report is an error. @@ -741,6 +744,9 @@ Three rules an author trips on: is what makes `meta migrate` create the view (Postgres, SQLite, D1) and what every port's runtime reads; a report with no `source.*` is checked at load and generates nothing. +A report `@from` a TPH subtype is refused when its view is derived (the subtype shares its +base's table): declare it `@from` the base with an `@filter` on the discriminator field. + What does not exist: no REST route and no typed client for a report yet, no `measure.derived` (arithmetic between measures beyond `measure.ratio`), no query-time choice of dimensions or measures (a report is a fixed, compiled combination), and no time-zone vocabulary (grains and diff --git a/agent-context/skills/metaobjects-authoring/references/reporting.md b/agent-context/skills/metaobjects-authoring/references/reporting.md index a6b05bb8c..737c6f363 100644 --- a/agent-context/skills/metaobjects-authoring/references/reporting.md +++ b/agent-context/skills/metaobjects-authoring/references/reporting.md @@ -14,7 +14,7 @@ A report is a compiled view. The report's **own** read-only source decides what | the same, plus `@unmanaged: true` | `meta migrate` never creates or drops it; the runtime still reads it. | | `@kind: materializedView`, `storedProc`, `tableFunction` | `meta migrate` skips it. | -A derived report view (no `@sql`) whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity; a report with an `@sql` source skips that check, since your SQL is used as written. A changed report is dropped and re-created by `meta migrate`. +A derived report view (no `@sql`) whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity; a report with an `@sql` source skips that check, since your SQL is used as written. A changed report is dropped and re-created by `meta migrate`. When a report declares several read-only sources, the one with `@role: primary` decides (else the first). ## The columns you get @@ -58,6 +58,8 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur A dimension reached by `@via` joins like a projection does: a required belongs-to foreign key joins `INNER`, anything else `LEFT OUTER`. So **a fact row whose required reference matches no row is left out of that report** (a dimension you do not list adds no join). That is the existing projection rule, not a reporting special case. +**Each `@via` hop needs a foreign key the model declares**: an `identity.reference` between the two entities, for example `{ "identity.reference": { "name": "fkProgram", "@fields": ["programId"], "@references": "Program" } }` on the entity that holds `programId`. A `relationship.*` with `@cardinality: one` and no reference behind it loads, and then `meta migrate` fails with an error naming the hop. + ## Engine differences | | Postgres | SQLite / D1 | MySQL | @@ -69,6 +71,13 @@ A dimension reached by `@via` joins like a projection does: a required belongs-t **MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body. That function is in the TypeScript package, so the MySQL view SQL comes from a TypeScript toolchain whatever language your application is in; the recipe showing the loop ships as the MySQL guide in the `metaobjects-codegen` skill's TypeScript stacks only. It skips a report whose source is `@unmanaged`. +## Known limits + +- **A report `@from` a TPH subtype is refused** when its view is derived. The subtype shares its base's table with every other subtype, so the view would count all of their rows. Declare the report `@from` the base, with an `@filter` on the discriminator field (`"@filter": { "kind": "ADMIN" }`). An `@sql` or `@unmanaged` report over a subtype is yours to scope. +- **An empty `in` list in a filter is refused** at `meta migrate`, naming the report and the field. +- **An abstract view-backed report, or one whose source `@kind` is `materializedView`, `storedProc` or `tableFunction`, gets no C# row class and no Kotlin table object.** The TypeScript, Java and Python runtimes still read whatever relation the source names (fine for a materialized view you created, a database error for a routine). +- **Do not group by a `field.object`.** A dimension over one is not supported across ports; group by a scalar field. + ## What a report does not have No REST route, typed client, filter allowlist or api-docs entry is generated for a report in any port. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. diff --git a/docs/features/reporting.md b/docs/features/reporting.md index 46598c543..133eae2a2 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -86,6 +86,8 @@ A report is a top-level object that names an entity as its `@from`: { "field.string": { "name": "status" } }, { "field.timestamp": { "name": "purchasedAt" } }, { "identity.primary": { "name": "id", "@fields": ["id"] } }, + { "identity.reference": { "name": "fkProgram", "@fields": ["programId"], + "@references": "Program" } }, { "relationship.association": { "name": "program", "@objectRef": "Program", "@cardinality": "one" } }, { "segment.filter": { "name": "active", "@filter": { "status": "active" } } }, @@ -125,11 +127,14 @@ A report is a top-level object that names an entity as its `@from`: What each piece means: -- **`dimension.attribute`** groups by a column's value as-is. `@of` is `Entity.field`. +- **`dimension.attribute`** groups by a column's value as-is. `@of` is `Entity.field`. `@via` + reaches a to-one related entity's column, and each hop needs a **foreign key the model + declares**: an `identity.reference` between the two entities (`fkProgram` above). A + `relationship.*` alone names the hop but says nothing about which column joins it. - **`dimension.time`** groups by a date or timestamp truncated to a grain. It declares which grains it supports in `@grains`. - **`measure.aggregate`** is one aggregate over the entity's own rows. `@agg: count` without - `@distinct` counts rows. With `@distinct: true` it counts distinct values of `@of`, and a + `@distinct` counts the rows whose `@of` is not null. With `@distinct: true` it counts distinct values of `@of`, and a list in `@of` is a distinct count of the tuple. This deliberately differs from `origin.aggregate`, whose `count` is always distinct as a join-inflation guard: a measure aggregates its own entity's rows and a dimension reaches only to-one paths, so no join @@ -156,7 +161,9 @@ order: one per dimension, then one per measure. | measure `revenue` | `revenue` | A `@dimensions` item is a dimension name, or `name:grain` for a time dimension (a single -colon, so it cannot collide with the `::` package separator). +colon, so it cannot collide with the `::` package separator). A `@measures` item is a measure +name, or `Entity.name` where `Entity` is the `@from` entity or one it extends; both forms name +the same measure and derive the same field. `StoreTotals` above declares the source that makes it **served**; `DailyRevenue` declares none, so it is checked at load and nothing more. A report is served only when it declares a @@ -167,7 +174,9 @@ none, so it is checked at load and nothing more. A report is served only when it ### Which reports lower The report's **own** read-only source decides. Dimensions, measures and segments are never -lowered alone. +lowered alone. When a report declares several read-only sources, the one with `@role: primary` +decides (else the first): it is the source the view is named by, the one `meta migrate` creates +and the one every runtime reads. | The report declares | Result | |---|---| @@ -182,6 +191,17 @@ declares no writable `source.rdb`) fails `meta migrate` with an error naming the entity, rather than emitting a view over a table that does not exist. A report with an `@sql` source is not derived, so that check does not apply to it: your SQL is used as written. +A derived report view is refused in three more cases, each with an error naming the report: + +- **`@from` is a TPH subtype** (an entity with `@discriminatorValue` under a base with + `@discriminator`). The subtype shares its base's table with every other subtype, so a view + derived from it would count all of their rows. Declare the report `@from` the base, with an + `@filter` on the discriminator field (`"@filter": { "kind": "ADMIN" }`). A report `@from` the + base is unaffected, and an `@sql` or `@unmanaged` report over a subtype is yours to scope. +- **a `@via` hop has no foreign key in the model.** The error names the hop and the + `identity.reference` it needs. +- **a filter's `in` list is empty**, which no database accepts as SQL. + ### The columns you get A report has no primary key and declares no fields; its read shape is derived. One column @@ -232,7 +252,9 @@ still exists: counts are `0`, sums and ratios are null. ### Dimensions, time grains and joins -- **`@via`** reaches a column of a to-one related entity, through a join. The join type is the +- **`@via`** reaches a column of a to-one related entity, through a join. Every hop is joined + through an `identity.reference` the model declares between the two entities; a hop without one + loads, and then fails `meta migrate` naming the hop. The join type is the projection rule, unchanged: a required belongs-to foreign key joins `INNER`, anything else `LEFT OUTER`, and an `INNER` survives only when every join above it is `INNER`. The consequence to know: **a dimension reached through a required reference drops a fact row @@ -294,6 +316,22 @@ body of each view-backed report; the recipe in [`docs/recipes/mysql.md`](../reci ("Reports") shows the loop and its caveats. It skips a report whose source is `@unmanaged`, and the bodies are valid under MySQL's default `ONLY_FULL_GROUP_BY`. +### Known limits + +- **A derived report from a TPH subtype is refused.** Declare it from the base with an `@filter` + on the discriminator field (see "Which reports lower"). +- **An abstract view-backed report gets no C# row class and no Kotlin table object.** The + TypeScript, Java and Python runtimes still read it. The same holds for a report whose source + `@kind` is `materializedView`, `storedProc` or `tableFunction`: C# and Kotlin generate nothing + for it, `meta migrate` skips it, and the three runtimes issue a `SELECT` against whatever + relation the source names. That works for a materialized view you created and is a database + error for a stored procedure or a table function. +- **A dimension over a `field.object` is not supported across ports.** TypeScript and Python + return the parsed JSON; the other ports are not gated for it. Group by a scalar field. +- **With `@via`, `@of` must name an entity that has the field** (declared on it or inherited by + it); naming a base of the reached entity for a field only the subtype declares loads and then + fails `meta migrate`. Without `@via` the field is read from the `@from` entity itself. + ### What the corpus gates Six shared scenarios under `fixtures/persistence-conformance/queries/report-*.yaml` read the 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 26fb4cdc1..0e34c3503 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 @@ -730,9 +730,12 @@ Three rules an author trips on: 1. **Every measure belongs to `@from`.** A report cannot mix measures of two entities (joining two fact tables multiplies each side's rows); two fact tables are two reports. -2. **`@via` is to-one only.** A dimension reaches a related entity's column through a - `relationship.*` with `@cardinality: one` (or an `identity.reference`), never through a - to-many, which would repeat fact rows and double-count a `sum`. +2. **`@via` is to-one only, and each hop needs a declared foreign key.** A dimension reaches a + related entity's column through a `relationship.*` with `@cardinality: one` (or an + `identity.reference`), never through a to-many, which would repeat fact rows and double-count + a `sum`. The view joins the hop through an `identity.reference` between the two entities + (`{ "identity.reference": { "name": "fkProgram", "@fields": ["programId"], "@references": + "Program" } }`); a relationship with none behind it loads and then fails `meta migrate`. 3. **A report declares no fields.** Its columns are derived: one per dimension, then one per measure (a time dimension at a grain is ``, so `purchasedAt:day` is `purchasedAtDay`). A `field.*` or `identity.*` child on a report is an error. @@ -741,6 +744,9 @@ Three rules an author trips on: is what makes `meta migrate` create the view (Postgres, SQLite, D1) and what every port's runtime reads; a report with no `source.*` is checked at load and generates nothing. +A report `@from` a TPH subtype is refused when its view is derived (the subtype shares its +base's table): declare it `@from` the base with an `@filter` on the discriminator field. + What does not exist: no REST route and no typed client for a report yet, no `measure.derived` (arithmetic between measures beyond `measure.ratio`), no query-time choice of dimensions or measures (a report is a fixed, compiled combination), and no time-zone vocabulary (grains and diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md index a6b05bb8c..737c6f363 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -14,7 +14,7 @@ A report is a compiled view. The report's **own** read-only source decides what | the same, plus `@unmanaged: true` | `meta migrate` never creates or drops it; the runtime still reads it. | | `@kind: materializedView`, `storedProc`, `tableFunction` | `meta migrate` skips it. | -A derived report view (no `@sql`) whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity; a report with an `@sql` source skips that check, since your SQL is used as written. A changed report is dropped and re-created by `meta migrate`. +A derived report view (no `@sql`) whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity; a report with an `@sql` source skips that check, since your SQL is used as written. A changed report is dropped and re-created by `meta migrate`. When a report declares several read-only sources, the one with `@role: primary` decides (else the first). ## The columns you get @@ -58,6 +58,8 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur A dimension reached by `@via` joins like a projection does: a required belongs-to foreign key joins `INNER`, anything else `LEFT OUTER`. So **a fact row whose required reference matches no row is left out of that report** (a dimension you do not list adds no join). That is the existing projection rule, not a reporting special case. +**Each `@via` hop needs a foreign key the model declares**: an `identity.reference` between the two entities, for example `{ "identity.reference": { "name": "fkProgram", "@fields": ["programId"], "@references": "Program" } }` on the entity that holds `programId`. A `relationship.*` with `@cardinality: one` and no reference behind it loads, and then `meta migrate` fails with an error naming the hop. + ## Engine differences | | Postgres | SQLite / D1 | MySQL | @@ -69,6 +71,13 @@ A dimension reached by `@via` joins like a projection does: a required belongs-t **MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body. That function is in the TypeScript package, so the MySQL view SQL comes from a TypeScript toolchain whatever language your application is in; the recipe showing the loop ships as the MySQL guide in the `metaobjects-codegen` skill's TypeScript stacks only. It skips a report whose source is `@unmanaged`. +## Known limits + +- **A report `@from` a TPH subtype is refused** when its view is derived. The subtype shares its base's table with every other subtype, so the view would count all of their rows. Declare the report `@from` the base, with an `@filter` on the discriminator field (`"@filter": { "kind": "ADMIN" }`). An `@sql` or `@unmanaged` report over a subtype is yours to scope. +- **An empty `in` list in a filter is refused** at `meta migrate`, naming the report and the field. +- **An abstract view-backed report, or one whose source `@kind` is `materializedView`, `storedProc` or `tableFunction`, gets no C# row class and no Kotlin table object.** The TypeScript, Java and Python runtimes still read whatever relation the source names (fine for a materialized view you created, a database error for a routine). +- **Do not group by a `field.object`.** A dimension over one is not supported across ports; group by a scalar field. + ## What a report does not have No REST route, typed client, filter allowlist or api-docs entry is generated for a report in any port. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. 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 26fb4cdc1..0e34c3503 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 @@ -730,9 +730,12 @@ Three rules an author trips on: 1. **Every measure belongs to `@from`.** A report cannot mix measures of two entities (joining two fact tables multiplies each side's rows); two fact tables are two reports. -2. **`@via` is to-one only.** A dimension reaches a related entity's column through a - `relationship.*` with `@cardinality: one` (or an `identity.reference`), never through a - to-many, which would repeat fact rows and double-count a `sum`. +2. **`@via` is to-one only, and each hop needs a declared foreign key.** A dimension reaches a + related entity's column through a `relationship.*` with `@cardinality: one` (or an + `identity.reference`), never through a to-many, which would repeat fact rows and double-count + a `sum`. The view joins the hop through an `identity.reference` between the two entities + (`{ "identity.reference": { "name": "fkProgram", "@fields": ["programId"], "@references": + "Program" } }`); a relationship with none behind it loads and then fails `meta migrate`. 3. **A report declares no fields.** Its columns are derived: one per dimension, then one per measure (a time dimension at a grain is ``, so `purchasedAt:day` is `purchasedAtDay`). A `field.*` or `identity.*` child on a report is an error. @@ -741,6 +744,9 @@ Three rules an author trips on: is what makes `meta migrate` create the view (Postgres, SQLite, D1) and what every port's runtime reads; a report with no `source.*` is checked at load and generates nothing. +A report `@from` a TPH subtype is refused when its view is derived (the subtype shares its +base's table): declare it `@from` the base with an `@filter` on the discriminator field. + What does not exist: no REST route and no typed client for a report yet, no `measure.derived` (arithmetic between measures beyond `measure.ratio`), no query-time choice of dimensions or measures (a report is a fixed, compiled combination), and no time-zone vocabulary (grains and diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md index a6b05bb8c..737c6f363 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -14,7 +14,7 @@ A report is a compiled view. The report's **own** read-only source decides what | the same, plus `@unmanaged: true` | `meta migrate` never creates or drops it; the runtime still reads it. | | `@kind: materializedView`, `storedProc`, `tableFunction` | `meta migrate` skips it. | -A derived report view (no `@sql`) whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity; a report with an `@sql` source skips that check, since your SQL is used as written. A changed report is dropped and re-created by `meta migrate`. +A derived report view (no `@sql`) whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity; a report with an `@sql` source skips that check, since your SQL is used as written. A changed report is dropped and re-created by `meta migrate`. When a report declares several read-only sources, the one with `@role: primary` decides (else the first). ## The columns you get @@ -58,6 +58,8 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur A dimension reached by `@via` joins like a projection does: a required belongs-to foreign key joins `INNER`, anything else `LEFT OUTER`. So **a fact row whose required reference matches no row is left out of that report** (a dimension you do not list adds no join). That is the existing projection rule, not a reporting special case. +**Each `@via` hop needs a foreign key the model declares**: an `identity.reference` between the two entities, for example `{ "identity.reference": { "name": "fkProgram", "@fields": ["programId"], "@references": "Program" } }` on the entity that holds `programId`. A `relationship.*` with `@cardinality: one` and no reference behind it loads, and then `meta migrate` fails with an error naming the hop. + ## Engine differences | | Postgres | SQLite / D1 | MySQL | @@ -69,6 +71,13 @@ A dimension reached by `@via` joins like a projection does: a required belongs-t **MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body. That function is in the TypeScript package, so the MySQL view SQL comes from a TypeScript toolchain whatever language your application is in; the recipe showing the loop ships as the MySQL guide in the `metaobjects-codegen` skill's TypeScript stacks only. It skips a report whose source is `@unmanaged`. +## Known limits + +- **A report `@from` a TPH subtype is refused** when its view is derived. The subtype shares its base's table with every other subtype, so the view would count all of their rows. Declare the report `@from` the base, with an `@filter` on the discriminator field (`"@filter": { "kind": "ADMIN" }`). An `@sql` or `@unmanaged` report over a subtype is yours to scope. +- **An empty `in` list in a filter is refused** at `meta migrate`, naming the report and the field. +- **An abstract view-backed report, or one whose source `@kind` is `materializedView`, `storedProc` or `tableFunction`, gets no C# row class and no Kotlin table object.** The TypeScript, Java and Python runtimes still read whatever relation the source names (fine for a materialized view you created, a database error for a routine). +- **Do not group by a `field.object`.** A dimension over one is not supported across ports; group by a scalar field. + ## What a report does not have No REST route, typed client, filter allowlist or api-docs entry is generated for a report in any port. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. 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 26fb4cdc1..0e34c3503 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 @@ -730,9 +730,12 @@ Three rules an author trips on: 1. **Every measure belongs to `@from`.** A report cannot mix measures of two entities (joining two fact tables multiplies each side's rows); two fact tables are two reports. -2. **`@via` is to-one only.** A dimension reaches a related entity's column through a - `relationship.*` with `@cardinality: one` (or an `identity.reference`), never through a - to-many, which would repeat fact rows and double-count a `sum`. +2. **`@via` is to-one only, and each hop needs a declared foreign key.** A dimension reaches a + related entity's column through a `relationship.*` with `@cardinality: one` (or an + `identity.reference`), never through a to-many, which would repeat fact rows and double-count + a `sum`. The view joins the hop through an `identity.reference` between the two entities + (`{ "identity.reference": { "name": "fkProgram", "@fields": ["programId"], "@references": + "Program" } }`); a relationship with none behind it loads and then fails `meta migrate`. 3. **A report declares no fields.** Its columns are derived: one per dimension, then one per measure (a time dimension at a grain is ``, so `purchasedAt:day` is `purchasedAtDay`). A `field.*` or `identity.*` child on a report is an error. @@ -741,6 +744,9 @@ Three rules an author trips on: is what makes `meta migrate` create the view (Postgres, SQLite, D1) and what every port's runtime reads; a report with no `source.*` is checked at load and generates nothing. +A report `@from` a TPH subtype is refused when its view is derived (the subtype shares its +base's table): declare it `@from` the base with an `@filter` on the discriminator field. + What does not exist: no REST route and no typed client for a report yet, no `measure.derived` (arithmetic between measures beyond `measure.ratio`), no query-time choice of dimensions or measures (a report is a fixed, compiled combination), and no time-zone vocabulary (grains and diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md index a6b05bb8c..737c6f363 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -14,7 +14,7 @@ A report is a compiled view. The report's **own** read-only source decides what | the same, plus `@unmanaged: true` | `meta migrate` never creates or drops it; the runtime still reads it. | | `@kind: materializedView`, `storedProc`, `tableFunction` | `meta migrate` skips it. | -A derived report view (no `@sql`) whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity; a report with an `@sql` source skips that check, since your SQL is used as written. A changed report is dropped and re-created by `meta migrate`. +A derived report view (no `@sql`) whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity; a report with an `@sql` source skips that check, since your SQL is used as written. A changed report is dropped and re-created by `meta migrate`. When a report declares several read-only sources, the one with `@role: primary` decides (else the first). ## The columns you get @@ -58,6 +58,8 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur A dimension reached by `@via` joins like a projection does: a required belongs-to foreign key joins `INNER`, anything else `LEFT OUTER`. So **a fact row whose required reference matches no row is left out of that report** (a dimension you do not list adds no join). That is the existing projection rule, not a reporting special case. +**Each `@via` hop needs a foreign key the model declares**: an `identity.reference` between the two entities, for example `{ "identity.reference": { "name": "fkProgram", "@fields": ["programId"], "@references": "Program" } }` on the entity that holds `programId`. A `relationship.*` with `@cardinality: one` and no reference behind it loads, and then `meta migrate` fails with an error naming the hop. + ## Engine differences | | Postgres | SQLite / D1 | MySQL | @@ -69,6 +71,13 @@ A dimension reached by `@via` joins like a projection does: a required belongs-t **MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body. That function is in the TypeScript package, so the MySQL view SQL comes from a TypeScript toolchain whatever language your application is in; the recipe showing the loop ships as the MySQL guide in the `metaobjects-codegen` skill's TypeScript stacks only. It skips a report whose source is `@unmanaged`. +## Known limits + +- **A report `@from` a TPH subtype is refused** when its view is derived. The subtype shares its base's table with every other subtype, so the view would count all of their rows. Declare the report `@from` the base, with an `@filter` on the discriminator field (`"@filter": { "kind": "ADMIN" }`). An `@sql` or `@unmanaged` report over a subtype is yours to scope. +- **An empty `in` list in a filter is refused** at `meta migrate`, naming the report and the field. +- **An abstract view-backed report, or one whose source `@kind` is `materializedView`, `storedProc` or `tableFunction`, gets no C# row class and no Kotlin table object.** The TypeScript, Java and Python runtimes still read whatever relation the source names (fine for a materialized view you created, a database error for a routine). +- **Do not group by a `field.object`.** A dimension over one is not supported across ports; group by a scalar field. + ## What a report does not have No REST route, typed client, filter allowlist or api-docs entry is generated for a report in any port. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. 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 26fb4cdc1..0e34c3503 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 @@ -730,9 +730,12 @@ Three rules an author trips on: 1. **Every measure belongs to `@from`.** A report cannot mix measures of two entities (joining two fact tables multiplies each side's rows); two fact tables are two reports. -2. **`@via` is to-one only.** A dimension reaches a related entity's column through a - `relationship.*` with `@cardinality: one` (or an `identity.reference`), never through a - to-many, which would repeat fact rows and double-count a `sum`. +2. **`@via` is to-one only, and each hop needs a declared foreign key.** A dimension reaches a + related entity's column through a `relationship.*` with `@cardinality: one` (or an + `identity.reference`), never through a to-many, which would repeat fact rows and double-count + a `sum`. The view joins the hop through an `identity.reference` between the two entities + (`{ "identity.reference": { "name": "fkProgram", "@fields": ["programId"], "@references": + "Program" } }`); a relationship with none behind it loads and then fails `meta migrate`. 3. **A report declares no fields.** Its columns are derived: one per dimension, then one per measure (a time dimension at a grain is ``, so `purchasedAt:day` is `purchasedAtDay`). A `field.*` or `identity.*` child on a report is an error. @@ -741,6 +744,9 @@ Three rules an author trips on: is what makes `meta migrate` create the view (Postgres, SQLite, D1) and what every port's runtime reads; a report with no `source.*` is checked at load and generates nothing. +A report `@from` a TPH subtype is refused when its view is derived (the subtype shares its +base's table): declare it `@from` the base with an `@filter` on the discriminator field. + What does not exist: no REST route and no typed client for a report yet, no `measure.derived` (arithmetic between measures beyond `measure.ratio`), no query-time choice of dimensions or measures (a report is a fixed, compiled combination), and no time-zone vocabulary (grains and diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md index a6b05bb8c..737c6f363 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -14,7 +14,7 @@ A report is a compiled view. The report's **own** read-only source decides what | the same, plus `@unmanaged: true` | `meta migrate` never creates or drops it; the runtime still reads it. | | `@kind: materializedView`, `storedProc`, `tableFunction` | `meta migrate` skips it. | -A derived report view (no `@sql`) whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity; a report with an `@sql` source skips that check, since your SQL is used as written. A changed report is dropped and re-created by `meta migrate`. +A derived report view (no `@sql`) whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity; a report with an `@sql` source skips that check, since your SQL is used as written. A changed report is dropped and re-created by `meta migrate`. When a report declares several read-only sources, the one with `@role: primary` decides (else the first). ## The columns you get @@ -58,6 +58,8 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur A dimension reached by `@via` joins like a projection does: a required belongs-to foreign key joins `INNER`, anything else `LEFT OUTER`. So **a fact row whose required reference matches no row is left out of that report** (a dimension you do not list adds no join). That is the existing projection rule, not a reporting special case. +**Each `@via` hop needs a foreign key the model declares**: an `identity.reference` between the two entities, for example `{ "identity.reference": { "name": "fkProgram", "@fields": ["programId"], "@references": "Program" } }` on the entity that holds `programId`. A `relationship.*` with `@cardinality: one` and no reference behind it loads, and then `meta migrate` fails with an error naming the hop. + ## Engine differences | | Postgres | SQLite / D1 | MySQL | @@ -69,6 +71,13 @@ A dimension reached by `@via` joins like a projection does: a required belongs-t **MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body. That function is in the TypeScript package, so the MySQL view SQL comes from a TypeScript toolchain whatever language your application is in; the recipe showing the loop ships as the MySQL guide in the `metaobjects-codegen` skill's TypeScript stacks only. It skips a report whose source is `@unmanaged`. +## Known limits + +- **A report `@from` a TPH subtype is refused** when its view is derived. The subtype shares its base's table with every other subtype, so the view would count all of their rows. Declare the report `@from` the base, with an `@filter` on the discriminator field (`"@filter": { "kind": "ADMIN" }`). An `@sql` or `@unmanaged` report over a subtype is yours to scope. +- **An empty `in` list in a filter is refused** at `meta migrate`, naming the report and the field. +- **An abstract view-backed report, or one whose source `@kind` is `materializedView`, `storedProc` or `tableFunction`, gets no C# row class and no Kotlin table object.** The TypeScript, Java and Python runtimes still read whatever relation the source names (fine for a materialized view you created, a database error for a routine). +- **Do not group by a `field.object`.** A dimension over one is not supported across ports; group by a scalar field. + ## What a report does not have No REST route, typed client, filter allowlist or api-docs entry is generated for a report in any port. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. 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 26fb4cdc1..0e34c3503 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 @@ -730,9 +730,12 @@ Three rules an author trips on: 1. **Every measure belongs to `@from`.** A report cannot mix measures of two entities (joining two fact tables multiplies each side's rows); two fact tables are two reports. -2. **`@via` is to-one only.** A dimension reaches a related entity's column through a - `relationship.*` with `@cardinality: one` (or an `identity.reference`), never through a - to-many, which would repeat fact rows and double-count a `sum`. +2. **`@via` is to-one only, and each hop needs a declared foreign key.** A dimension reaches a + related entity's column through a `relationship.*` with `@cardinality: one` (or an + `identity.reference`), never through a to-many, which would repeat fact rows and double-count + a `sum`. The view joins the hop through an `identity.reference` between the two entities + (`{ "identity.reference": { "name": "fkProgram", "@fields": ["programId"], "@references": + "Program" } }`); a relationship with none behind it loads and then fails `meta migrate`. 3. **A report declares no fields.** Its columns are derived: one per dimension, then one per measure (a time dimension at a grain is ``, so `purchasedAt:day` is `purchasedAtDay`). A `field.*` or `identity.*` child on a report is an error. @@ -741,6 +744,9 @@ Three rules an author trips on: is what makes `meta migrate` create the view (Postgres, SQLite, D1) and what every port's runtime reads; a report with no `source.*` is checked at load and generates nothing. +A report `@from` a TPH subtype is refused when its view is derived (the subtype shares its +base's table): declare it `@from` the base with an `@filter` on the discriminator field. + What does not exist: no REST route and no typed client for a report yet, no `measure.derived` (arithmetic between measures beyond `measure.ratio`), no query-time choice of dimensions or measures (a report is a fixed, compiled combination), and no time-zone vocabulary (grains and diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md index a6b05bb8c..737c6f363 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -14,7 +14,7 @@ A report is a compiled view. The report's **own** read-only source decides what | the same, plus `@unmanaged: true` | `meta migrate` never creates or drops it; the runtime still reads it. | | `@kind: materializedView`, `storedProc`, `tableFunction` | `meta migrate` skips it. | -A derived report view (no `@sql`) whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity; a report with an `@sql` source skips that check, since your SQL is used as written. A changed report is dropped and re-created by `meta migrate`. +A derived report view (no `@sql`) whose `@from` entity has no table (abstract, or no writable `source.rdb`) fails `meta migrate` with an error naming the report and the entity; a report with an `@sql` source skips that check, since your SQL is used as written. A changed report is dropped and re-created by `meta migrate`. When a report declares several read-only sources, the one with `@role: primary` decides (else the first). ## The columns you get @@ -58,6 +58,8 @@ A filter value `{ "now": "-P30D" }` (the current time plus a signed ISO-8601 dur A dimension reached by `@via` joins like a projection does: a required belongs-to foreign key joins `INNER`, anything else `LEFT OUTER`. So **a fact row whose required reference matches no row is left out of that report** (a dimension you do not list adds no join). That is the existing projection rule, not a reporting special case. +**Each `@via` hop needs a foreign key the model declares**: an `identity.reference` between the two entities, for example `{ "identity.reference": { "name": "fkProgram", "@fields": ["programId"], "@references": "Program" } }` on the entity that holds `programId`. A `relationship.*` with `@cardinality: one` and no reference behind it loads, and then `meta migrate` fails with an error naming the hop. + ## Engine differences | | Postgres | SQLite / D1 | MySQL | @@ -69,6 +71,13 @@ A dimension reached by `@via` joins like a projection does: a required belongs-t **MySQL owns its own DDL.** `meta migrate` never targets MySQL, so you create the view yourself: `buildReportViews(root, { dialect: "mysql" })` (`@metaobjectsdev/codegen-ts`) returns each view-backed report's body. That function is in the TypeScript package, so the MySQL view SQL comes from a TypeScript toolchain whatever language your application is in; the recipe showing the loop ships as the MySQL guide in the `metaobjects-codegen` skill's TypeScript stacks only. It skips a report whose source is `@unmanaged`. +## Known limits + +- **A report `@from` a TPH subtype is refused** when its view is derived. The subtype shares its base's table with every other subtype, so the view would count all of their rows. Declare the report `@from` the base, with an `@filter` on the discriminator field (`"@filter": { "kind": "ADMIN" }`). An `@sql` or `@unmanaged` report over a subtype is yours to scope. +- **An empty `in` list in a filter is refused** at `meta migrate`, naming the report and the field. +- **An abstract view-backed report, or one whose source `@kind` is `materializedView`, `storedProc` or `tableFunction`, gets no C# row class and no Kotlin table object.** The TypeScript, Java and Python runtimes still read whatever relation the source names (fine for a materialized view you created, a database error for a routine). +- **Do not group by a `field.object`.** A dimension over one is not supported across ports; group by a scalar field. + ## What a report does not have No REST route, typed client, filter allowlist or api-docs entry is generated for a report in any port. There is no `measure.derived`, no query-time choice of dimensions or measures, and no time-zone vocabulary. From d99a611e67f6197ed5215ab7c4079c6e107bf288 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 11:42:03 -0400 Subject: [PATCH 25/32] fix(java): resolve report references as the loader does; refuse a report over a field.object (FR-044) The report shape resolved a bare @of in the @from entity's package. The loader resolves it in the package of the entity that DECLARES the dimension or measure, so a member inherited from a base in another package either failed to resolve or was typed from a same-named decoy. The shape now follows the loader: declaring package, the named entity must be @from or an ancestor, and without @via the field is read from @from. A dotted @measures item (Sale.total) names the measure by its last segment. A time dimension item with no grain, or one outside the closed set, does not resolve. OMDB: a derived field over a field.object is refused by name when the read model is built (it was a NullPointerException on read). OQL with a report result class builds rows from the read model. getObjectRef leaves an object with no metadata to the base method. A projection whose view is named by @view now has a read mapping: the view name is the source's physical name, one rule for projections and reports. --- .../reporting/ReportAccessors.java | 23 +++- .../reporting/ReportReadModel.java | 28 +++- .../metaobjects/reporting/ReportShape.java | 115 +++++++++++++--- .../reporting/ReportReadModelTest.java | 42 ++++++ .../reporting/ReportShapeTest.java | 126 +++++++++++++++++- .../manager/db/ObjectManagerDB.java | 21 ++- .../manager/db/SimpleMappingHandlerDB.java | 23 ++-- .../manager/db/ReportReadTest.java | 96 +++++++++++++ .../omdb/src/test/resources/meta.report.json | 8 +- 9 files changed, 447 insertions(+), 35 deletions(-) diff --git a/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportAccessors.java b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportAccessors.java index e7a3fe089..b86065302 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportAccessors.java +++ b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportAccessors.java @@ -16,6 +16,7 @@ package com.metaobjects.reporting; import com.metaobjects.MetaData; +import com.metaobjects.util.MetaDataUtil; import com.metaobjects.object.MetaObject; import java.util.ArrayList; @@ -56,11 +57,31 @@ public static List reportDimensionItems(MetaData report) { return out; } - /** The {@code @measures} names. */ + /** The {@code @measures} items AS WRITTEN: each a bare measure {@code name}, or a dotted + * {@code Entity.name} (loader rule R3). Use {@link #reportMeasureItemName} for the measure name. */ public static List reportMeasureNames(MetaData report) { return ReportingAttrs.stringList(report, MetaObject.ATTR_REPORT_MEASURES); } + /** + * The measure a {@code @measures} item names: the segment after its LAST {@code .} + * ({@code total}, {@code Sale.total} and {@code acme::shop::Sale.total} all name + * {@code total}). It is also the derived report field's name. The part before that + * {@code .}, when present, is an entity qualifier ({@link #reportMeasureItemOwner}). + */ + public static String reportMeasureItemName(String item) { + int dot = item.lastIndexOf(MetaDataUtil.CHILD_REF_SEPARATOR); + return dot == -1 ? item : item.substring(dot + MetaDataUtil.CHILD_REF_SEPARATOR.length()); + } + + /** The entity qualifier of a dotted {@code @measures} item ({@code Sale} in + * {@code Sale.total}), or {@code null} for a bare item. Loader rule R3: it names + * {@code @from} or an entity {@code @from} extends. */ + public static String reportMeasureItemOwner(String item) { + int dot = item.lastIndexOf(MetaDataUtil.CHILD_REF_SEPARATOR); + return dot == -1 ? null : item.substring(0, dot); + } + /** The derived report field for a dimension item: {@code name} (attribute) or * {@code name + Capitalized(grain)} (time), e.g. {@code purchasedAtDay}. */ public static String reportDerivedFieldName(ReportDimensionItem item) { diff --git a/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportReadModel.java b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportReadModel.java index 5442bdbc7..e5ae0ec81 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportReadModel.java +++ b/server/java/metadata/src/main/java/com/metaobjects/reporting/ReportReadModel.java @@ -29,6 +29,7 @@ import com.metaobjects.field.EnumField; import com.metaobjects.field.LongField; import com.metaobjects.field.MetaField; +import com.metaobjects.field.ObjectField; import com.metaobjects.object.MetaObject; import com.metaobjects.object.ReportMetaObject; import com.metaobjects.source.MetaSource; @@ -138,6 +139,9 @@ public MetaObject report() { /** True when the report has a view to read (Table A); a sourceless report is not served. */ public boolean isServed() { + // ADR-0039: own — findPrimaryReadOnlySource() reads getSources(false). Sanctioned: + // the model's sources are exactly the one copy build() added (pinned to primary); + // the model extends nothing, so there is no inherited layer to drop. return findPrimaryReadOnlySource().isPresent(); } @@ -147,6 +151,7 @@ public boolean isServed() { * ({@code @view} for a view), so a report is read under the name the lowering created. */ public String viewName() { + // ADR-0039: own — as isServed(): the model's one source is its own copy. return findPrimaryReadOnlySource().map(MetaSource::getPhysicalName).orElse(null); } @@ -155,7 +160,10 @@ private static ReportReadModel build(ReportShape shape) { // The resolution key carries the package, so the model resolves as the report does. ReportReadModel model = new ReportReadModel(report.getName()); model.report = report; - for (ReportShape.Field f : shape.fields()) model.addChild(derivedField(f)); + for (ReportShape.Field f : shape.fields()) { + refuseObjectField(report, f); + model.addChild(derivedField(f)); + } MetaSource source = ReportShape.readSource(report); if (source != null) model.addChild(copySource(source)); @@ -164,6 +172,24 @@ private static ReportReadModel build(ReportShape shape) { return model; } + /** + * Refuse a derived field typed by a {@code field.object} (or by any field carrying + * {@code @objectRef}). The loader puts no subtype restriction on a dimension's + * {@code @of}, so such a report loads; but the derived field is a detached node, and an + * {@code @objectRef} on it cannot be resolved (there is no loader to resolve it in), so + * a read would fail deep in the codec with no report named. Refused here, by name. + */ + private static void refuseObjectField(MetaObject report, ReportShape.Field f) { + MetaField src = f.typeSource(); + if (src == null) return; + // ADR-0039: resolving — an @objectRef the @of field inherits counts. + if (!ObjectField.SUBTYPE_OBJECT.equals(src.getSubType()) && !src.hasMetaAttr(MetaField.ATTR_OBJECT_REF)) return; + throw new MetaDataException("report '" + report.getShortName() + "': " + f.role().wireName() + " '" + + (f.dimension() != null ? f.dimension().getShortName() : f.name()) + "' reads '" + f.typeSourceKey() + + "', a field." + src.getSubType() + ". A report over a field.object is not supported;" + + " group by a scalar field."); + } + private static MetaField derivedField(ReportShape.Field f) { MetaField field = newField(f); // From the derived shape, never from the type source: a `min` of a required column 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 28f330ea4..a0c6f66bc 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 @@ -34,6 +34,7 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.IdentityHashMap; import java.util.List; import java.util.Set; @@ -116,11 +117,13 @@ public String typeSourceKey() { private final MetaObject report; private final MetaObject from; + private final MetaRoot root; private final List fields; - private ReportShape(MetaObject report, MetaObject from, List fields) { + private ReportShape(MetaObject report, MetaObject from, MetaRoot root, List fields) { this.report = report; this.from = from; + this.root = root; this.fields = Collections.unmodifiableList(fields); } @@ -139,6 +142,22 @@ public List fields() { return fields; } + /** + * The entity a derived field's {@code @of} field is READ from, by the rule that derived + * the field ({@link #resolveFieldRef}): the {@code @from} entity for a measure or a + * dimension without {@code @via}, and the entity the {@code @of} reference names for a + * dimension with {@code @via}. {@code null} when that entity does not resolve, which a + * shape derived from a loaded model cannot reach. + * + *

It is the entity whose generated artifacts describe the field (a Kotlin enum class, + * say), which for an inherited field is not the object that declares it.

+ */ + public MetaObject ofEntity(Field field) { + MetaDimension dim = field.dimension(); + if (dim == null || dim.getVia() == null) return from; + return resolveFieldRefEntity(dim.getOf(), memberOwner(dim, from), root); + } + /** * The physical name of the view the report is read from, or {@code null} when the * report declares no read-only source (Table A: not lowered, not served). @@ -199,23 +218,48 @@ public static ReportShape of(MetaObject report, MetaRoot root) { for (ReportDimensionItem item : ReportAccessors.reportDimensionItems(report)) { fields.add(dimensionField(item, from, root, report)); } - for (String name : ReportAccessors.reportMeasureNames(report)) { - fields.add(measureField(name, from, root, report)); + for (String item : ReportAccessors.reportMeasureNames(report)) { + fields.add(measureField(item, from, root, report)); } - return new ReportShape(report, from, fields); + return new ReportShape(report, from, root, fields); + } + + /** + * The entity that DECLARES a dimension or measure reached through {@code from}: the + * member's parent, which is {@code from} itself or an entity {@code from} extends. A bare + * entity name inside the member ({@code @of}, {@code @via}) resolves in THIS entity's + * package, exactly as the loader's reporting validation resolves it + * ({@code pkgOf(ctx.declaring())}), never in {@code from}'s package or the report's. + */ + public static MetaData memberOwner(MetaData member, MetaObject from) { + MetaData parent = member.getParent(); + return parent != null ? parent : from; } /** * Resolve a dimension's or measure's {@code Entity.field} reference to the field node, - * or {@code null}. A package qualifier uses {@code ::}, so the member separator is the - * LAST dot; the entity resolves relative to {@code owner}'s package (ADR-0042). + * or {@code null}. The ONE rule, the same as the loader's (reporting validation D1 / M1) + * and as the TypeScript {@code resolveReportingFieldRef}: + * + *
    + *
  1. A package qualifier uses {@code ::}, so the member separator is the LAST dot. + * The entity half resolves relative to the package of {@code declaring}, the + * entity that declares the member ({@link #memberOwner}; ADR-0042).
  2. + *
  3. With {@code host} (a measure, or a dimension without {@code @via}: the reference + * is about the {@code @from} entity's own rows) the named entity must be + * {@code host} or an entity it extends, and the field is read from {@code host}, + * so a field {@code host} redeclares wins.
  4. + *
  5. Without {@code host} ({@code null}: a dimension with {@code @via}) the field is + * read from the named entity.
  6. + *
*/ - public static MetaField resolveFieldRef(String ref, MetaObject owner, MetaRoot root) { - MetaObject entity = resolveFieldRefEntity(ref, owner, root); - if (entity == null) return null; + public static MetaField resolveFieldRef(String ref, MetaData declaring, MetaRoot root, MetaObject host) { + MetaObject named = resolveFieldRefEntity(ref, declaring, root); + if (named == null) return null; + if (host != null && !isSelfOrAncestor(named, host)) return null; String fieldName = ref.substring(ref.lastIndexOf(SEP) + SEP.length()); // ADR-0039: resolving, so a field inherited through extends is found. - for (MetaField f : entity.getMetaFields()) { + for (MetaField f : (host != null ? host : named).getMetaFields()) { if (fieldName.equals(f.getName())) return f; } return null; @@ -223,27 +267,45 @@ public static MetaField resolveFieldRef(String ref, MetaObject owner, MetaRoo /** * The entity an {@code Entity.field} reference NAMES, or {@code null}: the entity half - * of {@link #resolveFieldRef}, by the same rule. It is the entity the reference is - * written against, which for an inherited field is not the object that declares it. + * of {@link #resolveFieldRef}, resolved relative to the package of {@code declaring} + * (the entity that declares the dimension or measure carrying the reference). It is the + * entity the reference is written against, which for an inherited field is not the + * object that declares it. */ - public static MetaObject resolveFieldRefEntity(String ref, MetaObject owner, MetaRoot root) { + public static MetaObject resolveFieldRefEntity(String ref, MetaData declaring, MetaRoot root) { if (ref == null) return null; int dot = ref.lastIndexOf(SEP); if (dot <= 0) return null; - return ValidationPhase.resolveRootObject(root, ref.substring(0, dot), packageOf(owner)); + return ValidationPhase.resolveRootObject(root, ref.substring(0, dot), packageOf(declaring)); + } + + /** True when {@code candidate} is {@code entity} or an entity it extends (the super chain). */ + private static boolean isSelfOrAncestor(MetaData candidate, MetaData entity) { + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + for (MetaData n = entity; n != null && !visited.contains(n); n = n.getSuperData()) { + if (n == candidate) return true; + visited.add(n); + } + return false; } private static Field dimensionField(ReportDimensionItem item, MetaObject from, MetaRoot root, MetaObject report) { MetaDimension dim = declaredMember(from, MetaDimension.class, item.name()); if (dim == null) throw unresolved(report, "dimension '" + item.name() + "' on '" + from.getShortName() + "'"); - MetaField of = resolveFieldRef(dim.getOf(), from, root); + boolean vialess = dim.getVia() == null; + MetaField of = dim.getOf() == null ? null + : resolveFieldRef(dim.getOf(), memberOwner(dim, from), root, vialess ? from : null); if (of == null) throw unresolved(report, "dimension '" + item.name() + "' @of"); String name = ReportAccessors.reportDerivedFieldName(item); // Attr only: a validator.required child does not make the column non-null. - boolean required = dim.getVia() == null && ReportingAttrs.isTrue(of, MetaField.ATTR_REQUIRED); + boolean required = vialess && ReportingAttrs.isTrue(of, MetaField.ATTR_REQUIRED); if (dim.isTime()) { + // Loader rule R2 guarantees a grain from the closed set; a tree built in code does not. String grain = item.grain(); + if (grain == null || !ReportingConstants.TIME_GRAINS.contains(grain)) { + throw unresolved(report, "time dimension '" + item.name() + "' grain '" + (grain == null ? "" : grain) + "'"); + } if (ReportingConstants.GRAIN_HOUR.equals(grain)) { return new Field(name, Role.DIMENSION, TimestampField.SUBTYPE_TIMESTAMP, required, of, dim, grain, null); } @@ -253,9 +315,23 @@ private static Field dimensionField(ReportDimensionItem item, MetaObject from, M return new Field(name, Role.DIMENSION, of.getSubType(), required, of, dim, null, null); } - private static Field measureField(String name, MetaObject from, MetaRoot root, MetaObject report) { + /** + * One {@code @measures} item, bare ({@code total}) or dotted ({@code Sale.total}, loader + * rule R3). The measure is named by the item's last segment and looked up on + * {@code from}; a qualifier resolves in the REPORT's package and must be {@code from} or + * an entity {@code from} extends. + */ + private static Field measureField(String item, MetaObject from, MetaRoot root, MetaObject report) { + String name = ReportAccessors.reportMeasureItemName(item); + String qualifier = ReportAccessors.reportMeasureItemOwner(item); + if (qualifier != null) { + MetaObject owner = ValidationPhase.resolveRootObject(root, qualifier, packageOf(report)); + if (owner == null || !isSelfOrAncestor(owner, from)) { + throw unresolved(report, "measure '" + item + "' on '" + from.getShortName() + "'"); + } + } MetaMeasure m = declaredMember(from, MetaMeasure.class, name); - if (m == null) throw unresolved(report, "measure '" + name + "' on '" + from.getShortName() + "'"); + if (m == null) throw unresolved(report, "measure '" + item + "' on '" + from.getShortName() + "'"); if (m.isRatio()) { return new Field(name, Role.MEASURE, DecimalField.SUBTYPE_DECIMAL, false, null, null, null, m); } @@ -265,7 +341,8 @@ private static Field measureField(String name, MetaObject from, MetaRoot root, M return new Field(name, Role.MEASURE, LongField.SUBTYPE_LONG, true, null, null, null, m); } List columns = m.getOfColumns(); - MetaField of = resolveFieldRef(columns.isEmpty() ? null : columns.get(0), from, root); + MetaField of = columns.isEmpty() ? null + : resolveFieldRef(columns.get(0), memberOwner(m, from), root, from); if (of == null) throw unresolved(report, "measure '" + name + "' @of"); String src = of.getSubType(); if (ReportingConstants.AGG_SUM.equals(agg)) { diff --git a/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportReadModelTest.java b/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportReadModelTest.java index 6aff5c5a5..d9bc51e28 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportReadModelTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportReadModelTest.java @@ -16,6 +16,7 @@ package com.metaobjects.reporting; import com.metaobjects.MetaData; +import com.metaobjects.MetaDataException; import com.metaobjects.MetaRoot; import com.metaobjects.database.CoreDBMetaDataProvider; import com.metaobjects.field.CurrencyField; @@ -43,6 +44,7 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; /** * FR-044 — {@link ReportReadModel}: the detached object a runtime reads a report through. @@ -307,4 +309,44 @@ public void isCachedPerReportNodeAndIdempotent() { assertSame("a read model is its own read model", model, ReportReadModel.of(model)); assertNotSame(model, ReportReadModel.of(object(canonical, "ProgramMinutes"), canonical)); } + + // --------------------------------------------------------------------------- + // A derived field over a field.object is refused by name + // --------------------------------------------------------------------------- + + private static final String OBJECT_DIMENSION_MODEL = """ + { "metadata.root": { "package": "shop", "children": [ + { "object.value": { "name": "Address", "children": [ + { "field.string": { "name": "city" } } + ] } }, + { "object.entity": { "name": "Sale", "children": [ + { "source.rdb": { "@table": "sales" } }, + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } }, + { "field.object": { "name": "shipTo", "@objectRef": "Address", "@storage": "jsonb" } }, + { "dimension.attribute": { "name": "destination", "@of": "Sale.shipTo" } }, + { "measure.aggregate": { "name": "sales", "@agg": "count", "@of": "Sale.id" } } + ] } }, + { "object.report": { "name": "SalesByDestination", "@from": "Sale", + "@dimensions": ["destination"], "@measures": ["sales"], "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_sales_by_destination" } } + ] } } + ] } } + """; + + @Test + public void aDimensionOverAFieldObjectIsRefusedByName() { + MetaRoot root = loadJson(OBJECT_DIMENSION_MODEL); + MetaObject report = object(root, "SalesByDestination"); + // The shape still derives (the loader accepts the model); the read model refuses. + assertEquals("object", ReportShape.of(report, root).fields().get(0).subType()); + try { + ReportReadModel.of(report, root); + fail("a report over a field.object must be refused"); + } catch (MetaDataException e) { + assertEquals("report 'SalesByDestination': dimension 'destination' reads 'shop::Sale.shipTo'," + + " a field.object. A report over a field.object is not supported; group by a scalar field.", + e.getMessage()); + } + } } diff --git a/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportShapeTest.java b/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportShapeTest.java index c4c4ef3c4..ddd8bf1b5 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportShapeTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/reporting/ReportShapeTest.java @@ -68,12 +68,16 @@ public static void loadCanonical() { canonical = MetaDataLoader.fromDirectory("report-shape-test", corpusDir().resolve("canonical")).getRoot(); } - private static MetaRoot loadJson(String json) { + private static MetaRoot loadJson(String... files) { MetaDataLoader loader = new MetaDataLoader( LoaderOptions.create(false, false, true), MetaDataLoader.SUBTYPE_MANUAL, "report-shape-inline"); loader.setSourceURIs(java.util.Collections.emptyList()); loader.init(); - loader.load(List.of(new InMemoryStringSource(json, "meta.inline.json"))); + List sources = new java.util.ArrayList<>(); + for (int i = 0; i < files.length; i++) { + sources.add(new InMemoryStringSource(files[i], "meta.inline" + i + ".json")); + } + loader.load(sources); assertTrue("no load errors: " + loader.getErrors(), loader.getErrors().isEmpty()); return loader.getRoot(); } @@ -322,4 +326,122 @@ public void anUnresolvedReferenceNamesTheReport() { assertTrue(e.getMessage(), e.getMessage().contains("measure 'nope'")); } } + + // --------------------------------------------------------------------------- + // Reference resolution: the shape must agree with the loader's reporting validation + // about what a reference names, or a model that loads clean fails (or is silently + // mistyped) when it is read. The same cases as the TypeScript report-shape.test.ts. + // --------------------------------------------------------------------------- + + /** {@code a::Base} (abstract): members whose bare {@code @of} names {@code Base}. */ + private static final String SHARED_BASE = """ + { "metadata.root": { "package": "a", "children": [ + { "object.entity": { "name": "Base", "abstract": true, "children": [ + { "field.long": { "name": "id" } }, + { "field.string": { "name": "kind" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } }, + { "dimension.attribute": { "name": "kind", "@of": "Base.kind" } }, + { "measure.aggregate": { "name": "events", "@agg": "count", "@of": "Base.id" } }, + { "measure.aggregate": { "name": "lastKind", "@agg": "max", "@of": "Base.kind" } } + ] } } + ] } } + """; + + private static final String DECOY = + "{ \"object.entity\": { \"name\": \"Base\", \"children\": [" + + " { \"field.int\": { \"name\": \"id\" } }, { \"field.int\": { \"name\": \"kind\" } } ] } },"; + + /** Package {@code b}: {@code Ev extends a::Base} and report {@code R} over it. */ + private static String evFile(String before, String evExtra, String measures) { + return "{ \"metadata.root\": { \"package\": \"b\", \"children\": [" + before + + " { \"object.entity\": { \"name\": \"Ev\", \"extends\": \"a::Base\", \"children\": [" + + " { \"source.rdb\": { \"@table\": \"evs\" } }" + evExtra + " ] } }," + + " { \"object.report\": { \"name\": \"R\", \"@from\": \"Ev\", \"@dimensions\": [\"kind\"]," + + " \"@measures\": " + measures + ", \"children\": [" + + " { \"source.rdb\": { \"@kind\": \"view\", \"@view\": \"v_r\" } } ] } } ] } }"; + } + + private static final String BARE_MEASURES = "[\"events\", \"lastKind\"]"; + + /** {@code name subType typeSourceKey} per derived field of report {@code R}. */ + private static List typed(MetaRoot root) { + return ReportShape.of(object(root, "R"), root).fields().stream() + .map(f -> f.name() + " " + f.subType() + " " + f.typeSourceKey()) + .collect(Collectors.toList()); + } + + @Test + public void aBareOfOnAMemberInheritedFromAnotherPackageResolvesInTheDeclaringEntitysPackage() { + MetaRoot root = loadJson(SHARED_BASE, evFile("", "", BARE_MEASURES)); + assertEquals(List.of("kind string a::Base.kind", "events long null", "lastKind string a::Base.kind"), + typed(root)); + } + + @Test + public void aSameNamedDecoyInTheReportsPackageDoesNotCaptureTheReference() { + MetaRoot root = loadJson(SHARED_BASE, evFile(DECOY, "", BARE_MEASURES)); + assertEquals(List.of("kind string a::Base.kind", "events long null", "lastKind string a::Base.kind"), + typed(root)); + } + + @Test + public void withoutViaTheFieldIsReadFromFromSoAFieldFromRedeclaresWins() { + MetaRoot root = loadJson(SHARED_BASE, + evFile("", ", { \"field.int\": { \"name\": \"kind\" } }", BARE_MEASURES)); + assertEquals(List.of("kind int b::Ev.kind", "events long null", "lastKind int b::Ev.kind"), typed(root)); + } + + @Test + public void aDottedMeasuresItemNamesTheMeasureByItsLastSegment() { + MetaRoot root = loadJson(SHARED_BASE, evFile("", "", "[\"Ev.events\", \"a::Base.lastKind\"]")); + assertEquals(List.of("kind string a::Base.kind", "events long null", "lastKind string a::Base.kind"), + typed(root)); + } + + @Test + public void measureItemNameIsTheLastSegment() { + assertEquals("total", ReportAccessors.reportMeasureItemName("total")); + assertEquals("total", ReportAccessors.reportMeasureItemName("Sale.total")); + assertEquals("total", ReportAccessors.reportMeasureItemName("acme::shop::Sale.total")); + assertNull(ReportAccessors.reportMeasureItemOwner("total")); + assertEquals("acme::shop::Sale", ReportAccessors.reportMeasureItemOwner("acme::shop::Sale.total")); + } + + /** A report built in code (never added to the root): what the loader would refuse. */ + private static com.metaobjects.object.ReportMetaObject stray(String name, String from, String attr, String item) { + com.metaobjects.object.ReportMetaObject stray = new com.metaobjects.object.ReportMetaObject(name); + stray.addMetaAttr(com.metaobjects.attr.StringAttribute.create(MetaObject.ATTR_REPORT_FROM, from)); + com.metaobjects.attr.StringArrayAttribute items = new com.metaobjects.attr.StringArrayAttribute(attr); + items.setValue(List.of(item)); + stray.addMetaAttr(items); + return stray; + } + + private static void assertUnresolved(String expected, MetaObject report, MetaRoot root) { + try { + ReportShape.of(report, root); + fail("expected: " + expected); + } catch (MetaDataException e) { + assertEquals(expected, e.getMessage()); + } + } + + @Test + public void aDottedMeasuresItemWhoseQualifierIsNotFromOrAnAncestorDoesNotResolve() { + MetaRoot root = loadJson(SHARED_BASE, evFile(DECOY, "", BARE_MEASURES)); + // Past the loader, which refuses these as ERR_INVALID_REPORT / ERR_REPORT_FOREIGN_MEASURE. + assertUnresolved("report 'R': measure 'Nope.events' on 'Ev' does not resolve.", + stray("b::R", "Ev", MetaObject.ATTR_REPORT_MEASURES, "Nope.events"), root); + // The qualifier resolves in the REPORT's package: b::Base is the decoy, not an ancestor of Ev. + assertUnresolved("report 'R': measure 'Base.events' on 'Ev' does not resolve.", + stray("b::R", "Ev", MetaObject.ATTR_REPORT_MEASURES, "Base.events"), root); + } + + @Test + public void aTimeDimensionItemWithNoGrainOrAGrainOutsideTheClosedSetDoesNotResolve() { + assertUnresolved("report 'Stray': time dimension 'createdAt' grain '' does not resolve.", + stray("fitness::Stray", "Program", MetaObject.ATTR_REPORT_DIMENSIONS, "createdAt"), canonical); + assertUnresolved("report 'Stray': time dimension 'createdAt' grain 'fortnight' does not resolve.", + stray("fitness::Stray", "Program", MetaObject.ATTR_REPORT_DIMENSIONS, "createdAt:fortnight"), canonical); + } } diff --git a/server/java/omdb/src/main/java/com/metaobjects/manager/db/ObjectManagerDB.java b/server/java/omdb/src/main/java/com/metaobjects/manager/db/ObjectManagerDB.java index 693d1ac53..474881692 100644 --- a/server/java/omdb/src/main/java/com/metaobjects/manager/db/ObjectManagerDB.java +++ b/server/java/omdb/src/main/java/com/metaobjects/manager/db/ObjectManagerDB.java @@ -323,7 +323,10 @@ private static void requireNotReport(MetaObject mc, String operation) { */ @Override public ObjectRef getObjectRef(Object obj) { - requireNotReport(getMetaObjectFor(obj), "getObjectRef"); + // The same lookup the base method does. An object with no metadata is left to the + // base, so everything but a report row behaves exactly as it did. + MetaObject mc = MetaDataUtil.findMetaObject(obj, this); + if (mc != null) requireNotReport(mc, "getObjectRef"); return super.getObjectRef(obj); } @@ -1072,6 +1075,15 @@ protected MetaField getFieldForColumn(MetaObject resultClass, ObjectMapping mapp return rc; } + /** + * The object an OQL query names as its result class ({@code [Name] SELECT ...}). + * Resolved through the loader registry, as it always was; a seam so a manager wired to + * a specific loader can resolve the name against it. + */ + protected MetaObject findResultClass(String className) throws MetaDataNotFoundException { + return MetaDataUtil.findMetaObjectByName(className, this); + } + /** * Executes the specified query and maps it to the given object. * @@ -1105,7 +1117,7 @@ public Collection executeQuery(ObjectConnection c, String query, Collection executeQuery(ObjectConnection c, String query, Collection data = new LinkedList(); try { + // FR-044: a declared report has no fields, so its rows are built from its + // read model (one field per derived field). In OQL the author supplies the + // SQL, so a sourceless report is a legitimate result shape too: it has a + // model and simply no mapping, and columns bind by derived field name. + if (ReportReadModel.isReport(resultClass)) resultClass = ReportReadModel.of(resultClass); ObjectMappingDB mapping = (ObjectMappingDB) getReadMapping(resultClass); while (rs.next()) { diff --git a/server/java/omdb/src/main/java/com/metaobjects/manager/db/SimpleMappingHandlerDB.java b/server/java/omdb/src/main/java/com/metaobjects/manager/db/SimpleMappingHandlerDB.java index 1081d99dd..b088aee8d 100644 --- a/server/java/omdb/src/main/java/com/metaobjects/manager/db/SimpleMappingHandlerDB.java +++ b/server/java/omdb/src/main/java/com/metaobjects/manager/db/SimpleMappingHandlerDB.java @@ -10,6 +10,7 @@ import com.metaobjects.database.CoreDBMetaDataProvider; import com.metaobjects.object.MetaObject; import com.metaobjects.reporting.ReportReadModel; +import com.metaobjects.source.MetaSource; import com.metaobjects.MetaData; import com.metaobjects.MetaDataException; @@ -125,8 +126,8 @@ protected ObjectMappingDB getTableMapping( MetaObject mc ) { /** Get the table mapping */ protected ObjectMapping getViewMapping( MetaObject mc ) { - // Create the view definition. The view name comes from source.rdb @table - // (@kind=view); OMDB reads from a view that already exists in the database + // Create the view definition. The view name is the read-only source.rdb's physical + // name (@view, or the legacy @table); OMDB reads from a view that already exists in the database // (created by the migrate toolchain) — it does not synthesize view DDL. ViewDef v = new ViewDef( NameDef.parseName( getViewRef( mc ))); @@ -476,18 +477,22 @@ private String getPersistenceAttribute( MetaData md, String ref ) { } /** - * Retrieves the view name from the MetaObject — the {@code @table} of its - * primary read-only {@code source.rdb} child (source-v2 ADR-0007). + * Retrieves the view name from the MetaObject: the physical name of its primary + * read-only {@code source.rdb} child (source-v2 ADR-0007), resolved by the source's own + * rule ({@link MetaSource#getPhysicalName()}, ADR-0018): the kind-matching alias + * ({@code @view} for a view) first, then the legacy {@code @table}. One rule for a + * projection and for a report's read model, and the rule the TypeScript toolchain + * creates the view under. * * @return the view name, or {@code null} if no primary read-only source */ protected String getViewRef( MetaObject mc ) { - // FR-044: a report's read model names its view through the source's kind-matching - // alias (@view), the name the TypeScript lowering created the view under. Every other - // object keeps the @table read below, unchanged. - if ( mc instanceof ReportReadModel ) return ((ReportReadModel) mc).viewName(); - return mc.getPrimaryRdbViewName(); + // ADR-0039: own — findPrimaryReadOnlySource() reads getSources(false). Sanctioned: + // an object is read through the read-only source it declares ITSELF (a projection's + // own view; a report read model's one source copy); an inherited writable source is + // reached through getTableRef below. + return mc.findPrimaryReadOnlySource().map( MetaSource::getPhysicalName ).orElse( null ); } /** diff --git a/server/java/omdb/src/test/java/com/metaobjects/manager/db/ReportReadTest.java b/server/java/omdb/src/test/java/com/metaobjects/manager/db/ReportReadTest.java index a208ee604..d08bf909a 100644 --- a/server/java/omdb/src/test/java/com/metaobjects/manager/db/ReportReadTest.java +++ b/server/java/omdb/src/test/java/com/metaobjects/manager/db/ReportReadTest.java @@ -107,6 +107,12 @@ public ObjectRef getObjectRef(String refStr) { return new ObjectRef(registry.findMetaObjectByName(rest.substring(0, slash)), new String[] { rest.substring(slash + 1) }); } + + // An OQL result class is named the same way, and resolved the same way here. + @Override + protected MetaObject findResultClass(String className) { + return registry.findMetaObjectByName(className); + } }; omdb.setDatabaseDriver(new DerbyDriver()); omdb.setDataSource(ds); @@ -127,6 +133,8 @@ public ObjectRef getObjectRef(String refStr) { s.execute("CREATE VIEW RPT_V_REPLICA (sales) AS SELECT COUNT(id) + 100 FROM RPT_SALES"); s.execute("CREATE TABLE REPLICATED_SALES (sales BIGINT)"); s.execute("INSERT INTO REPLICATED_SALES VALUES (999)"); + // A projection whose view is named by @view (not the legacy @table). + s.execute("CREATE VIEW RPT_V_SALE_REGIONS (id, region) AS SELECT id, region FROM RPT_SALES"); s.execute("CREATE TABLE INERT_SALES (sales BIGINT)"); s.execute("INSERT INTO INERT_SALES VALUES (999)"); } @@ -498,4 +506,92 @@ public void anEntityInTheSameModelIsReadAndWrittenAsBefore() throws Exception { omdb.releaseConnection(oc); } } + + // --------------------------------------------------------------------------- + // OQL with a report as the result class + // --------------------------------------------------------------------------- + + /** Run an OQL query and flatten each row by the fields of {@code rowShape}. */ + private static List> query(String oql, MetaObject rowShape) { + ObjectConnection oc = omdb.getConnection(); + try { + List> rows = new ArrayList<>(); + for (Object o : omdb.executeQuery(oc, oql, new ArrayList<>())) { + assertSame("an OQL row of a report is an instance of its read model", + rowShape, omdb.getMetaObjectFor(o)); + Map row = new LinkedHashMap<>(); + for (MetaField f : rowShape.getMetaFields()) row.put(f.getName(), f.getObject(o)); + rows.add(row); + } + return rows; + } finally { + omdb.releaseConnection(oc); + } + } + + @Test + public void oqlWithAReportResultClassBuildsRowsFromTheReadModel() { + MetaObject declared = object("SalesByRegion"); + // The author supplies the SQL; the report supplies the row shape. + assertEquals(List.of(row("region", "west", "sales", 1L, "revenue", 50L, "minAmount", 50L)), + // Aliases are quoted because OQL binds a result column by its exact name and + // Derby upper-cases an unquoted one (true of any OQL result class). + query("[reporttest::SalesByRegion] SELECT region AS \"region\", sales AS \"sales\"," + + " revenue AS \"revenue\", minAmount AS \"minAmount\"" + + " FROM RPT_V_BY_REGION WHERE sales < 2", ReportReadModel.of(declared))); + } + + @Test + public void oqlWithASourcelessReportResultClassStillBuildsRows() { + // InertSales has no view, so it is not served by getObjects; as an OQL result shape + // it only names the columns, and they bind by derived field name. + assertEquals(List.of(row("sales", 3L)), + query("[reporttest::InertSales] SELECT COUNT(id) AS \"sales\" FROM RPT_SALES", + ReportReadModel.of(object("InertSales")))); + } + + // --------------------------------------------------------------------------- + // A projection declared with @view (the same physical-name rule as a report) + // --------------------------------------------------------------------------- + + @Test + public void aProjectionDeclaredWithViewIsReadFromThatView() { + MetaObject projection = object("SaleRegionView"); + ObjectMappingDB mapping = (ObjectMappingDB) omdb.getReadMapping(projection); + assertNotNull("a projection whose view is named by @view has a read mapping", mapping); + assertEquals("RPT_V_SALE_REGIONS", ((BaseDef) mapping.getDBDef()).getNameDef().getName()); + + ObjectConnection oc = omdb.getConnection(); + try { + QueryOptions options = new QueryOptions(new Expression("region", "west")); + Collection found = omdb.getObjects(oc, projection, options); + assertEquals(1, found.size()); + assertEquals(Long.valueOf(3L), ((ValueObject) found.iterator().next()).getLong("id")); + } finally { + omdb.releaseConnection(oc); + } + } + + // --------------------------------------------------------------------------- + // getObjectRef on an object that is not a report + // --------------------------------------------------------------------------- + + @Test + public void getObjectRefOnAnObjectWithNoMetadataFailsExactlyAsTheBaseManagerDoes() { + Object stranger = new Object(); + Throwable base = null; + try { + com.metaobjects.util.MetaDataUtil.findMetaObject(stranger, omdb); + } catch (RuntimeException e) { + base = e; + } + assertNotNull("the base lookup refuses an object with no metadata", base); + try { + omdb.getObjectRef(stranger); + fail("an object with no metadata has no reference"); + } catch (RuntimeException e) { + assertSame(base.getClass(), e.getClass()); + assertEquals(base.getMessage(), e.getMessage()); + } + } } diff --git a/server/java/omdb/src/test/resources/meta.report.json b/server/java/omdb/src/test/resources/meta.report.json index b47878f75..8af329aaf 100644 --- a/server/java/omdb/src/test/resources/meta.report.json +++ b/server/java/omdb/src/test/resources/meta.report.json @@ -31,7 +31,13 @@ { "source.rdb": { "name": "replica", "@kind": "view", "@view": "RPT_V_REPLICA", "@role": "replica" } }, { "source.rdb": { "name": "main", "@kind": "view", "@view": "RPT_V_PRIMARY" } } ] } }, - { "object.report": { "name": "InertSales", "@from": "Sale", "@measures": ["sales"] } } + { "object.report": { "name": "InertSales", "@from": "Sale", "@measures": ["sales"] } }, + { "object.projection": { "name": "SaleRegionView", "children": [ + { "source.rdb": { "@kind": "view", "@view": "RPT_V_SALE_REGIONS" } }, + { "field.long": { "name": "id", "extends": "reporttest::Sale.id" } }, + { "field.string": { "name": "region", "extends": "reporttest::Sale.region" } }, + { "identity.primary": { "name": "pk", "extends": "reporttest::Sale.pk" } } + ] } } ] } } From f0301d7ac3c65c415a3ce9af36c5cdc22516a895 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 11:42:03 -0400 Subject: [PATCH 26/32] fix(kotlin): report table uses the shape's own resolution; refuse a report over a field.object (FR-044) The enum class of a report column is taken from ReportShape.ofEntity, so the generator restates nothing about packages or @via. gen fails, naming the report and the dimension or measure, when a derived field reads a field.object. Tests cover a member inherited across packages (with and without a same-named decoy), a dotted @measures item, and an abstract view-backed report (generates nothing). --- docs/ports/kotlin.md | 4 + .../kotlin/KotlinExposedTableGenerator.kt | 44 +++++-- .../kotlin/KotlinReportTableGeneratorTest.kt | 124 ++++++++++++++++++ 3 files changed, 161 insertions(+), 11 deletions(-) diff --git a/docs/ports/kotlin.md b/docs/ports/kotlin.md index cd462b99b..9f8683e86 100644 --- a/docs/ports/kotlin.md +++ b/docs/ports/kotlin.md @@ -389,6 +389,10 @@ object ProgramMinutesTable : Table("v_program_minutes") { - `gen` fails, naming the report and the dimension or measure, when a derived field is named after a Kotlin hard keyword or when two derived fields land on one column property (see below for the `Column` suffix). Rename the item. +- `gen` also fails, naming the report and the dimension or measure, when a derived field reads a + `field.object` (a dimension over an embedded value object, say). A report over a + `field.object` is not supported; group by a scalar field. +- An abstract report generates nothing, view or not. A column property whose name is a member of Exposed's `Table` gets a `Column` suffix (`source` becomes `sourceColumn`); the physical column name does not change. The reserved set diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt index 1d6602cbd..a6293244c 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt @@ -391,11 +391,12 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase`), and that is never a keyword, a @@ -483,23 +504,24 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase + com.metaobjects.loader.InMemoryStringSource( + text, "meta.inline$i.json", com.metaobjects.loader.MetaDataSource.MetaDataFormat.JSON) + }) + assertEquals(emptyList(), errors.map { it.message }) + register() + } + + private fun crossPackageTable(decoy: Boolean, measures: String = """["events"]"""): Map = + emit( + loadFiles(sharedBase, evFile(decoy, measures)), + mapOf("columnNaming" to "literal"), + listOf(KotlinEntityGenerator(), KotlinExposedTableGenerator()), + ) + + private fun assertTypedFromTheDeclaringBase(files: Map) { + val src = files.getValue("b/RTable.kt") + assertTrue(" val kind = varchar(\"kind\", 12).nullable()\n" in src, src) + // The enum class of the @from entity, which is the class the entity generator emits. + assertTrue("EvTier::class).nullable()\n" in src, src) + assertTrue(" val events = long(\"events\")\n" in src, src) + assertCompiles(files.filterKeys { !it.startsWith("b/Base") }) + } + + @Test + fun `a bare of on a member inherited from another package resolves in the declaring entity's package`() { + assertTypedFromTheDeclaringBase(crossPackageTable(decoy = false)) + } + + @Test + fun `a same-named decoy in the report's package does not capture the reference`() { + // b::Base.kind and b::Base.tier are ints: captured, the columns would be integer(...). + assertTypedFromTheDeclaringBase(crossPackageTable(decoy = true)) + } + + @Test + fun `a dotted measures item names the measure by its last segment`() { + val src = crossPackageTable(decoy = false, measures = """["a::Base.events"]""").getValue("b/RTable.kt") + assertTrue(" val events = long(\"events\")\n" in src, src) + val viaFrom = crossPackageTable(decoy = false, measures = """["Ev.events"]""").getValue("b/RTable.kt") + assertEquals(src, viaFrom) + } + + // --- What generates nothing, and what is refused --------------------------------------- + + @Test + fun `an abstract view-backed report generates nothing`() { + // An abstract object gets no table object in this port, report or not. The + // TypeScript, Java and Python runtimes still read the view (docs: Known limits). + val json = model().replace(""""name": "SaleTotals",""", """"name": "SaleTotals", "abstract": true,""") + assertTrue("\"abstract\": true" in json) + assertEquals(emptyMap(), reportFiles(json)) + } + + @Test + fun `a dimension over a field object is refused, naming the report and the dimension`() { + val json = """{ + "metadata.root": { "package": "acme::shop", "children": [ + { "object.value": { "name": "Address", "children": [ { "field.string": { "name": "city" } } ] } }, + { "object.entity": { "name": "Sale", "children": [ + { "source.rdb": { "@table": "sales" } }, + { "field.long": { "name": "id" } }, + { "field.object": { "name": "shipTo", "@objectRef": "Address", "@storage": "jsonb" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } }, + { "dimension.attribute": { "name": "destination", "@of": "Sale.shipTo" } }, + { "measure.aggregate": { "name": "sales", "@agg": "count", "@of": "Sale.id" } } + ] } }, + { "object.report": { "name": "SaleTotals", "@from": "Sale", "@dimensions": ["destination"], + "@measures": ["sales"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_sale_totals" } } ] } } + ] } + }""" + val e = assertFailsWith { reportFiles(json) } + assertEquals( + "report \"SaleTotals\": its dimension \"destination\" reads \"acme::shop::Sale.shipTo\", a " + + "field.object. A report over a field.object is not supported; group by a scalar field.", + e.message, + ) + // The same report with no view generates nothing and is not refused. + assertEquals(emptyMap(), reportFiles(json.replace( + """"children": [ { "source.rdb": { "@kind": "view", "@view": "v_sale_totals" } } ]""", """"children": []"""))) + } + // --- The emitted reports build ------------------------------------------------------ @Test From 950ef042458af57c82cd7c1116049cb31395fa49 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 11:42:03 -0400 Subject: [PATCH 27/32] fix(csharp): resolve report references as the loader does; refuse a report over a field.object (FR-044) ReportShapes resolves @of in the declaring entity's package, checks the named entity is @from or an ancestor, and reads the field from @from when there is no @via. A dotted @measures item names the measure by its last segment. A report row with a dimension over a field.object silently lost that property; gen now refuses it by name. An abstract view-backed report generates nothing (tested). --- .../ReportRowCodegenTests.cs | 74 +++++++++++ .../csharp/MetaObjects.Codegen/ReportRows.cs | 29 +++- .../ReportShapeTests.cs | 125 ++++++++++++++++++ .../Core/Reporting/ReportAccessors.cs | 28 +++- .../MetaObjects/Core/Reporting/ReportShape.cs | 76 +++++++++-- .../Loader/ValidationPasses.Reporting.cs | 3 +- 6 files changed, 318 insertions(+), 17 deletions(-) diff --git a/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs index d03eaaea9..9b4902a04 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/ReportRowCodegenTests.cs @@ -386,4 +386,78 @@ public void A_sourceless_report_with_a_colliding_name_is_not_refused() if (Directory.Exists(outDir)) Directory.Delete(outDir, recursive: true); } } + + // --------------------------------------------------------------------- + // What generates nothing, and what is refused + // --------------------------------------------------------------------- + + private const string ObjectDimensionModel = + """ + { "metadata.root": { "package": "acme::shop", "children": [ + { "object.value": { "name": "Address", "children": [ + { "field.string": { "name": "city" } } + ] } }, + { "object.entity": { "name": "Sale", "children": [ + { "source.rdb": { "@table": "sales" } }, + { "field.long": { "name": "id", "@required": true } }, + { "field.object": { "name": "shipTo", "@objectRef": "Address", "@storage": "jsonb" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } }, + { "dimension.attribute": { "name": "destination", "@of": "Sale.shipTo" } }, + { "measure.aggregate": { "name": "sales", "@agg": "count", "@of": "Sale.id" } } + ] } }, + { "object.report": { "name": "SalesByDestination", "@from": "Sale", + "@dimensions": ["destination"], "@measures": ["sales"]<> } } + ] } } + """; + + private static MetaRoot LoadObjectDimension(bool viewBacked) + { + string source = viewBacked + ? ", \"children\": [ { \"source.rdb\": { \"@kind\": \"view\", \"@view\": \"v_by_destination\" } } ]" + : ""; + var result = new MetaDataLoader().Load( + [new InMemoryStringSource(ObjectDimensionModel.Replace("<>", source), id: "meta.shop.json")]); + Assert.True(result.Errors.Count == 0, + "model did not load:\n" + string.Join("\n", result.Errors.Select(e => $" {e.Code}: {e.Message}"))); + return result.Root; + } + + [Fact] + public void A_dimension_over_a_field_object_is_refused_naming_the_report_and_the_dimension() + { + // The loader accepts it. Left alone, the row class silently has no property for + // the dimension, so the report would read without the column it groups by. + var root = LoadObjectDimension(viewBacked: true); + foreach (var generator in new IGenerator[] { new EntityGenerator(), new DbContextGenerator() }) + { + var ex = Assert.Throws(() => generator.Generate(RunnerContext(root)).ToList()); + Assert.Equal( + "report \"SalesByDestination\": its dimension \"destination\" reads \"acme::shop::Sale.shipTo\", " + + "a field.object. A report over a field.object is not supported; group by a scalar field.", + ex.Message); + } + } + + [Fact] + public void A_sourceless_report_over_a_field_object_generates_nothing_and_is_not_refused() + { + var files = EmitAll(RunnerContext(LoadObjectDimension(viewBacked: false))); + Assert.DoesNotContain(files.Keys, k => k.Contains("SalesByDestination", StringComparison.Ordinal)); + } + + [Fact] + public void An_abstract_view_backed_report_generates_nothing() + { + // An abstract object gets no class in this port, report or not. The TypeScript, + // Java and Python runtimes still read the view (docs/features/reporting.md, Known limits). + string report = Report("SalesTotal", "\"@kind\": \"view\", \"@view\": \"v_sales\"") + .Replace("\"name\": \"SalesTotal\",", "\"name\": \"SalesTotal\", \"abstract\": true,"); + Assert.Contains("\"abstract\": true", report); + var with = EmitAll(RunnerContext(Load(report))); + var without = EmitAll(RunnerContext(Load())); + + Assert.Equal(without.Keys.OrderBy(k => k).ToList(), with.Keys.OrderBy(k => k).ToList()); + foreach (var (path, content) in without) + Assert.True(content == with[path], $"{path} changed"); + } } diff --git a/server/csharp/MetaObjects.Codegen/ReportRows.cs b/server/csharp/MetaObjects.Codegen/ReportRows.cs index 0b8f7f475..2de5a582c 100644 --- a/server/csharp/MetaObjects.Codegen/ReportRows.cs +++ b/server/csharp/MetaObjects.Codegen/ReportRows.cs @@ -13,7 +13,9 @@ // the Table B columns, so both still get a row. A report with no source stays inert, and // so does one whose read source is a materialized view, a stored procedure or a table // function: the lowering skips those kinds, so no relation with the Table B columns is -// promised to exist. +// promised to exist. An ABSTRACT report generates nothing either, view or not: an abstract +// object gets no class in this port. And a view-backed report with a derived field over a +// `field.object` is refused by name (RefuseObjectField): a keyless row cannot own it. // // WHY A SYNTHESIZED OBJECT, NOT A REPORT BRANCH IN EACH GENERATOR // @@ -80,6 +82,7 @@ public static MetaObject RowModel(MetaObject report, MetaRoot root) ?? throw new InvalidOperationException($"report '{report.Name}' declares no read-only source."); var shape = ReportShapes.Of(report, root); RefuseFieldNamedAfterTheRow(report, shape); + RefuseObjectField(report, shape); var model = new MetaObject(new TypeId(report.Type, report.SubType), report.Name); if (report.Package is { } pkg) model.SetPackage(pkg); @@ -118,6 +121,30 @@ private static void RefuseFieldNamedAfterTheRow(MetaObject report, ReportShape s } } + /// + /// Refuse a report with a derived field typed by a field.object (a dimension over + /// an embedded value object, say). The loader accepts it, but a keyless row cannot own + /// the value object: the entity generator would emit the row with no property for the + /// field at all, and the report would read without the column it groups by. Reached + /// only for a report that generates a row. The Java read model and the Kotlin table + /// generator refuse the same report with the same sentence. + /// + private static void RefuseObjectField(MetaObject report, ReportShape shape) + { + foreach (var f in shape.Fields) + { + if (f.TypeSource is not { } src) continue; + // ADR-0039: resolving — an @objectRef the @of field inherits counts. + if (src.SubType != FIELD_SUBTYPE_OBJECT && src.Attr(FIELD_ATTR_OBJECT_REF) is null) continue; + string role = ReportShapes.RoleName(f.Role); + string item = f.Role == ReportFieldRole.Dimension ? f.Dimension!.Name : f.Measure!.Name; + string owner = src.Parent?.ResolutionKey() ?? ""; + throw new InvalidOperationException( + $"report \"{report.Name}\": its {role} \"{item}\" reads \"{owner}.{src.Name}\", " + + $"a field.{src.SubType}. A report over a field.object is not supported; group by a scalar field."); + } + } + private static MetaField DerivedField(ReportField f) { var field = new MetaField(new TypeId(TYPE_FIELD, f.SubType), f.Name); diff --git a/server/csharp/MetaObjects.Conformance.Tests/ReportShapeTests.cs b/server/csharp/MetaObjects.Conformance.Tests/ReportShapeTests.cs index 3c5df1ae2..f2184620d 100644 --- a/server/csharp/MetaObjects.Conformance.Tests/ReportShapeTests.cs +++ b/server/csharp/MetaObjects.Conformance.Tests/ReportShapeTests.cs @@ -111,4 +111,129 @@ public void A_sourceless_report_has_a_shape_and_no_view() Assert.Null(ReportShapes.ReadSource(report)); Assert.Contains("\"view\": null", ReportShapes.ToArtifactJson(result.Root)); } + + // ----------------------------------------------------------------------- + // Reference resolution: the shape must agree with the loader's ValidateReporting + // about what a reference names, or a model that loads clean fails (or is silently + // mistyped) when it is generated. The same cases as the TypeScript report-shape.test.ts. + // ----------------------------------------------------------------------- + + // `a::Base` (abstract): members whose bare `@of` names `Base`. + private const string SharedBase = + """ + { "metadata.root": { "package": "a", "children": [ + { "object.entity": { "name": "Base", "abstract": true, "children": [ + { "field.long": { "name": "id" } }, + { "field.string": { "name": "kind" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } }, + { "dimension.attribute": { "name": "kind", "@of": "Base.kind" } }, + { "measure.aggregate": { "name": "events", "@agg": "count", "@of": "Base.id" } }, + { "measure.aggregate": { "name": "lastKind", "@agg": "max", "@of": "Base.kind" } } + ] } } + ] } } + """; + + private const string Decoy = + """ + { "object.entity": { "name": "Base", "children": [ + { "field.int": { "name": "id" } }, { "field.int": { "name": "kind" } } ] } }, + """; + + // Package `b`: `Ev extends a::Base` and report `R` over it. + private static string EvFile(string before = "", string evExtra = "", string measures = "[\"events\", \"lastKind\"]") => + "{ \"metadata.root\": { \"package\": \"b\", \"children\": [" + before + + " { \"object.entity\": { \"name\": \"Ev\", \"extends\": \"a::Base\", \"children\": [" + + " { \"source.rdb\": { \"@table\": \"evs\" } }" + evExtra + " ] } }," + + " { \"object.report\": { \"name\": \"R\", \"@from\": \"Ev\", \"@dimensions\": [\"kind\"]," + + " \"@measures\": " + measures + ", \"children\": [" + + " { \"source.rdb\": { \"@kind\": \"view\", \"@view\": \"v_r\" } } ] } } ] } }"; + + private static MetaRoot LoadInline(params string[] files) + { + var result = new MetaDataLoader().Load( + files.Select((json, i) => (IMetaDataSource)new InMemoryStringSource(json, id: $"meta.inline{i}.json")).ToList()); + Assert.True(result.Errors.Count == 0, + "model failed to load: " + string.Join("; ", result.Errors.Select(e => e.ToString()))); + return result.Root; + } + + // `name subType ` per derived field of `R`. + private static List Typed(MetaRoot root) => + Shape(root, "R").Fields + .Select(f => $"{f.Name} {f.SubType} {f.TypeSource?.Parent?.ResolutionKey() ?? "-"}") + .ToList(); + + [Fact] + public void A_bare_of_on_a_member_inherited_from_another_package_resolves_in_the_declaring_entitys_package() + { + var root = LoadInline(SharedBase, EvFile()); + Assert.Equal(["kind string a::Base", "events long -", "lastKind string a::Base"], Typed(root)); + } + + [Fact] + public void A_same_named_decoy_in_the_reports_package_does_not_capture_the_reference() + { + var root = LoadInline(SharedBase, EvFile(before: Decoy)); + Assert.Equal(["kind string a::Base", "events long -", "lastKind string a::Base"], Typed(root)); + } + + [Fact] + public void Without_via_the_field_is_read_from_from_so_a_field_from_redeclares_wins() + { + var root = LoadInline(SharedBase, EvFile(evExtra: ", { \"field.int\": { \"name\": \"kind\" } }")); + Assert.Equal(["kind int b::Ev", "events long -", "lastKind int b::Ev"], Typed(root)); + } + + [Fact] + public void A_dotted_measures_item_names_the_measure_by_its_last_segment() + { + var root = LoadInline(SharedBase, EvFile(measures: "[\"Ev.events\", \"a::Base.lastKind\"]")); + Assert.Equal(["kind", "events", "lastKind"], Shape(root, "R").Fields.Select(f => f.Name).ToList()); + } + + [Fact] + public void ReportMeasureItemName_is_the_last_segment() + { + Assert.Equal("total", ReportAccessors.ReportMeasureItemName("total")); + Assert.Equal("total", ReportAccessors.ReportMeasureItemName("Sale.total")); + Assert.Equal("total", ReportAccessors.ReportMeasureItemName("acme::shop::Sale.total")); + Assert.Null(ReportAccessors.ReportMeasureItemOwner("total")); + Assert.Equal("acme::shop::Sale", ReportAccessors.ReportMeasureItemOwner("acme::shop::Sale.total")); + } + + // A report built in code (never added to the root): what the loader would refuse. + private static MetaObject Stray(string pkg, string from, string attr, string item) + { + var report = new MetaObject(new TypeId(TYPE_OBJECT, OBJECT_SUBTYPE_REPORT), "Stray"); + report.SetPackage(pkg); + report.SetAttr(OBJECT_REPORT_ATTR_FROM, from); + report.SetAttr(attr, new List { item }); + return report; + } + + [Fact] + public void A_dotted_measures_item_whose_qualifier_is_not_from_or_an_ancestor_does_not_resolve() + { + var root = LoadInline(SharedBase, EvFile(before: Decoy)); + // Past the loader, which refuses these as ERR_INVALID_REPORT / ERR_REPORT_FOREIGN_MEASURE. + var ex = Assert.Throws(() => + ReportShapes.Of(Stray("b", "Ev", OBJECT_REPORT_ATTR_MEASURES, "Nope.events"), root)); + Assert.Equal("report 'Stray': measure 'Nope.events' on 'Ev' does not resolve.", ex.Message); + // The qualifier resolves in the REPORT's package: b::Base is the decoy, not an ancestor of Ev. + ex = Assert.Throws(() => + ReportShapes.Of(Stray("b", "Ev", OBJECT_REPORT_ATTR_MEASURES, "Base.events"), root)); + Assert.Equal("report 'Stray': measure 'Base.events' on 'Ev' does not resolve.", ex.Message); + } + + [Fact] + public void A_time_dimension_item_with_no_grain_or_a_grain_outside_the_closed_set_does_not_resolve() + { + var root = LoadCanonical(); + var ex = Assert.Throws(() => + ReportShapes.Of(Stray("fitness", "Program", OBJECT_REPORT_ATTR_DIMENSIONS, "createdAt"), root)); + Assert.Equal("report 'Stray': time dimension 'createdAt' grain '' does not resolve.", ex.Message); + ex = Assert.Throws(() => + ReportShapes.Of(Stray("fitness", "Program", OBJECT_REPORT_ATTR_DIMENSIONS, "createdAt:fortnight"), root)); + Assert.Equal("report 'Stray': time dimension 'createdAt' grain 'fortnight' does not resolve.", ex.Message); + } } diff --git a/server/csharp/MetaObjects/Core/Reporting/ReportAccessors.cs b/server/csharp/MetaObjects/Core/Reporting/ReportAccessors.cs index 51ea8e1ec..8226e81f5 100644 --- a/server/csharp/MetaObjects/Core/Reporting/ReportAccessors.cs +++ b/server/csharp/MetaObjects/Core/Reporting/ReportAccessors.cs @@ -48,10 +48,36 @@ public static IReadOnlyList ReportDimensionItems(MetaData o .ToList() .AsReadOnly(); - /// The @measures names. + /// + /// The @measures items AS WRITTEN: each a bare measure name, or a dotted + /// Entity.name (loader rule R3). Use for the measure name. + /// public static IReadOnlyList ReportMeasureNames(MetaData obj) => ReportingValues.StringList(obj.Attr(OBJECT_REPORT_ATTR_MEASURES)); + /// + /// The measure a @measures item names: the segment after its LAST . + /// (total, Sale.total and acme::shop::Sale.total all name + /// total). It is also the derived report field's name. The part before that + /// ., when present, is an entity qualifier (). + /// + public static string ReportMeasureItemName(string item) + { + int dot = item.LastIndexOf(CHILD_REF_SEPARATOR, StringComparison.Ordinal); + return dot == -1 ? item : item[(dot + CHILD_REF_SEPARATOR.Length)..]; + } + + /// + /// The entity qualifier of a dotted @measures item (Sale in + /// Sale.total), or null for a bare item. Loader rule R3: it names @from + /// or an entity @from extends. + /// + public static string? ReportMeasureItemOwner(string item) + { + int dot = item.LastIndexOf(CHILD_REF_SEPARATOR, StringComparison.Ordinal); + return dot == -1 ? null : item[..dot]; + } + /// /// The derived report field for a dimension item: name (attribute) or /// name + Capitalized(grain) (time), e.g. purchasedAt:day → purchasedAtDay. diff --git a/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs b/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs index 11041c22b..a5ebf7300 100644 --- a/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs +++ b/server/csharp/MetaObjects/Core/Reporting/ReportShape.cs @@ -7,6 +7,7 @@ // artifact every port byte-matches; see ReportShapeTests). using System.Text; +using MetaObjects.Loader; using MetaObjects.Meta; namespace MetaObjects.Core.Reporting; @@ -52,15 +53,43 @@ public static class ReportShapes private static readonly HashSet Floating = new(StringComparer.Ordinal) { FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT }; - /// Resolve a dimension's or measure's Entity.field reference to the field node. - public static MetaField? ResolveReportingFieldRef(string reference, MetaObject owner, MetaRoot root) + /// + /// The entity that DECLARES a dimension or measure reached through : + /// the member's parent, which is itself or an entity it extends. A + /// bare entity name inside the member (@of, @via) resolves in THIS entity's + /// package, exactly as the loader's ValidateReporting resolves it + /// (EffectivePackage(ctx.Declaring)), never in 's package or + /// the report's. + /// + public static MetaData ReportingMemberOwner(MetaData member, MetaObject from) => member.Parent ?? from; + + /// + /// Resolve a dimension's or measure's Entity.field reference to the field node. + /// The ONE rule, the same as the loader's (ValidateReporting D1 / M1) and as the + /// TypeScript resolveReportingFieldRef: + /// + /// The entity half resolves relative to the package of , + /// the entity that declares the member (). + /// With (a measure, or a dimension without @via: the + /// reference is about the @from entity's own rows) the named entity must be + /// or an entity it extends, and the field is read from + /// , so a field it redeclares wins. + /// Without (a dimension with @via) the field is read + /// from the named entity. + /// + /// Null when any step fails. + /// + public static MetaField? ResolveReportingFieldRef( + string reference, MetaData declaring, MetaRoot root, MetaObject? host = null) { // `Entity.field`; a package qualifier uses `::`, so the member separator is the LAST dot. int dot = reference.LastIndexOf(CHILD_REF_SEPARATOR, StringComparison.Ordinal); if (dot <= 0) return null; - var entity = NamingRefs.ResolveObjectRef(root, reference[..dot], NamingRefs.EffectivePackage(owner)) as MetaObject; + if (NamingRefs.ResolveObjectRef(root, reference[..dot], NamingRefs.EffectivePackage(declaring)) + is not MetaObject named) return null; + if (host is not null && !ValidationPasses.IsSelfOrAncestor(named, host)) return null; // ADR-0039: resolving, so a field inherited through extends is found. - return entity?.FindField(reference[(dot + CHILD_REF_SEPARATOR.Length)..]); + return (host ?? named).FindField(reference[(dot + CHILD_REF_SEPARATOR.Length)..]); } private static InvalidOperationException Unresolved(string reportName, string what) => @@ -74,30 +103,49 @@ private static ReportField DimensionField(ReportDimensionItem item, MetaObject f { var dim = DeclaredMember(from, TYPE_DIMENSION, item.Name) ?? throw Unresolved(reportName, $"dimension '{item.Name}' on '{from.Name}'"); - var of = ResolveReportingFieldRef(dim.Of() ?? "", from, root) + bool vialess = dim.Via() is null; + var of = ResolveReportingFieldRef(dim.Of() ?? "", ReportingMemberOwner(dim, from), root, vialess ? from : null) ?? throw Unresolved(reportName, $"dimension '{item.Name}' @of"); string name = ReportAccessors.ReportDerivedFieldName(item); // The @required ATTR only, read resolving (ADR-0039); a validator.required child does not count. - bool required = dim.Via() is null && of.Attr(FIELD_ATTR_REQUIRED) is true; + bool required = vialess && of.Attr(FIELD_ATTR_REQUIRED) is true; if (dim.IsTime()) { - return item.Grain == GRAIN_HOUR - ? new ReportField(name, ReportFieldRole.Dimension, FIELD_SUBTYPE_TIMESTAMP, required, of, dim, item.Grain) - : new ReportField(name, ReportFieldRole.Dimension, FIELD_SUBTYPE_DATE, required, null, dim, item.Grain); + // Loader rule R2 guarantees a grain from the closed set; a tree built in code does not. + string? grain = item.Grain; + if (grain is null || !TIME_GRAINS.Contains(grain, StringComparer.Ordinal)) + throw Unresolved(reportName, $"time dimension '{item.Name}' grain '{grain ?? ""}'"); + return grain == GRAIN_HOUR + ? new ReportField(name, ReportFieldRole.Dimension, FIELD_SUBTYPE_TIMESTAMP, required, of, dim, grain) + : new ReportField(name, ReportFieldRole.Dimension, FIELD_SUBTYPE_DATE, required, null, dim, grain); } return new ReportField(name, ReportFieldRole.Dimension, of.SubType, required, of, dim); } - private static ReportField MeasureField(string name, MetaObject from, MetaRoot root, string reportName) + /// + /// One @measures item, bare (total) or dotted (Sale.total, loader + /// rule R3). The measure is named by the item's last segment and looked up on + /// ; a qualifier resolves in the REPORT's package and must be + /// or an entity it extends. + /// + private static ReportField MeasureField(string item, MetaObject report, MetaObject from, MetaRoot root) { + string reportName = report.Name; + string name = ReportAccessors.ReportMeasureItemName(item); + if (ReportAccessors.ReportMeasureItemOwner(item) is { } qualifier) + { + var owner = NamingRefs.ResolveObjectRef(root, qualifier, NamingRefs.EffectivePackage(report)); + if (owner is null || !ValidationPasses.IsSelfOrAncestor(owner, from)) + throw Unresolved(reportName, $"measure '{item}' on '{from.Name}'"); + } var m = DeclaredMember(from, TYPE_MEASURE, name) - ?? throw Unresolved(reportName, $"measure '{name}' on '{from.Name}'"); + ?? throw Unresolved(reportName, $"measure '{item}' on '{from.Name}'"); if (m.IsRatio()) return new ReportField(name, ReportFieldRole.Measure, FIELD_SUBTYPE_DECIMAL, false, Measure: m); string? agg = m.Agg(); if (agg == AGG_COUNT) return new ReportField(name, ReportFieldRole.Measure, FIELD_SUBTYPE_LONG, true, Measure: m); - var of = ResolveReportingFieldRef(m.OfColumns().FirstOrDefault() ?? "", from, root) + var of = ResolveReportingFieldRef(m.OfColumns().FirstOrDefault() ?? "", ReportingMemberOwner(m, from), root, from) ?? throw Unresolved(reportName, $"measure '{name}' @of"); string src = of.SubType; if (agg == AGG_SUM) @@ -131,8 +179,8 @@ public static ReportShape Of(MetaObject report, MetaRoot root) var fields = new List(); foreach (var item in ReportAccessors.ReportDimensionItems(report)) fields.Add(DimensionField(item, from, root, report.Name)); - foreach (string name in ReportAccessors.ReportMeasureNames(report)) - fields.Add(MeasureField(name, from, root, report.Name)); + foreach (string item in ReportAccessors.ReportMeasureNames(report)) + fields.Add(MeasureField(item, report, from, root)); return new ReportShape(report, from, fields.AsReadOnly()); } diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs index f1670a03d..281e00e45 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.Reporting.cs @@ -143,7 +143,8 @@ private static (string Owner, string[] Path)? ReportingSplitDotted(string refere } /// True when is or an entity it extends. - private static bool IsSelfOrAncestor(MetaData? candidate, MetaData entity) + // Internal: the report shape (ReportShapes) applies the same test, so it uses this one. + internal static bool IsSelfOrAncestor(MetaData? candidate, MetaData entity) { var visited = new HashSet(ReferenceEqualityComparer.Instance); for (MetaData? n = entity; n is not null && !visited.Contains(n); n = n.SuperData) From 09b2afe70499b49d484f895592538176ae1bce01 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 11:42:03 -0400 Subject: [PATCH 28/32] fix(python): resolve report references as the loader does; test the Table B rows and carry rules (FR-044) report_shape resolves @of in the declaring entity's package, checks the named entity is @from or an ancestor, and reads the field from @from when there is no @via. A dotted @measures item names the measure by its last segment. The shapes test helper names the view by the read model's rule (primary, else first). New direct tests for sum/avg/min/max by @of subtype and for what a derived field carries from its type source. ADR-0039 comments on the own-only reads. --- .../meta/core/reporting/report_accessors.py | 22 +- .../meta/core/reporting/report_shape.py | 80 ++++- .../src/metaobjects/runtime/object_manager.py | 3 + .../runtime/test_object_manager_report.py | 2 + server/python/tests/test_report_read_model.py | 164 ++++++++++ server/python/tests/test_report_shape.py | 289 +++++++++++++++++- 6 files changed, 541 insertions(+), 19 deletions(-) create mode 100644 server/python/tests/test_report_read_model.py diff --git a/server/python/src/metaobjects/meta/core/reporting/report_accessors.py b/server/python/src/metaobjects/meta/core/reporting/report_accessors.py index 482b171f7..1cc4335c8 100644 --- a/server/python/src/metaobjects/meta/core/reporting/report_accessors.py +++ b/server/python/src/metaobjects/meta/core/reporting/report_accessors.py @@ -9,6 +9,7 @@ from dataclasses import dataclass +from ....naming_refs import CHILD_REF_SEP from ...meta_data import MetaData from ..object.object_constants import ( OBJECT_REPORT_ATTR_DIMENSIONS, @@ -52,10 +53,29 @@ def report_dimension_items(obj: MetaData) -> list[ReportDimensionItem]: def report_measure_names(obj: MetaData) -> list[str]: - """ADR-0039: resolving. The ``@measures`` names.""" + """ADR-0039: resolving. The ``@measures`` items AS WRITTEN: each a bare measure + ``name``, or a dotted ``Entity.name`` (loader rule R3). Use + :func:`report_measure_item_name` for the measure name.""" return _string_list(obj.get_meta_attr(OBJECT_REPORT_ATTR_MEASURES)) +def report_measure_item_name(item: str) -> str: + """The measure a ``@measures`` item names: the segment after its LAST ``.`` + (``total``, ``Sale.total`` and ``acme::shop::Sale.total`` all name ``total``). It is + also the derived report field's name. The part before that ``.``, when present, is an + entity qualifier (:func:`report_measure_item_owner`).""" + dot = item.rfind(CHILD_REF_SEP) + return item if dot == -1 else item[dot + len(CHILD_REF_SEP):] + + +def report_measure_item_owner(item: str) -> str | None: + """The entity qualifier of a dotted ``@measures`` item (``Sale`` in ``Sale.total``), + or ``None`` for a bare item. Loader rule R3: it names ``@from`` or an entity ``@from`` + extends.""" + dot = item.rfind(CHILD_REF_SEP) + return None if dot == -1 else item[:dot] + + def report_derived_field_name(item: ReportDimensionItem) -> str: """The derived report field for a dimension item: ``name`` (attribute) or ``name`` + Capitalized(grain) (time), e.g. ``purchasedAt:day`` -> ``purchasedAtDay``.""" 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 b40d3dc8f..0cbfd268b 100644 --- a/server/python/src/metaobjects/meta/core/reporting/report_shape.py +++ b/server/python/src/metaobjects/meta/core/reporting/report_shape.py @@ -35,6 +35,8 @@ report_derived_field_name, report_dimension_items, report_from, + report_measure_item_name, + report_measure_item_owner, report_measure_names, ) from .reporting_constants import ( @@ -42,6 +44,7 @@ AGG_COUNT, AGG_SUM, GRAIN_HOUR, + TIME_GRAINS, TYPE_DIMENSION, TYPE_MEASURE, ) @@ -80,18 +83,57 @@ def _package_of_key(key: str) -> str: return key[:i] if i >= 0 else "" -def resolve_reporting_field_ref(ref: str, owner: MetaObject, root: MetaRoot) -> MetaField | None: - """Resolve a dimension's or measure's ``Entity.field`` reference to the field node.""" +def _is_self_or_ancestor(candidate: MetaData | None, entity: MetaData) -> bool: + """True when ``candidate`` is ``entity`` or an entity it extends (the super chain). + The loader's own test (``validate_reporting._is_self_or_ancestor``), restated because + this package cannot import the loader (the loader imports it).""" + visited: set[int] = set() + n: MetaData | None = entity + while n is not None and id(n) not in visited: + if n is candidate: + return True + visited.add(id(n)) + n = n.super_data + return False + + +def reporting_member_owner(member: MetaData, from_: MetaObject) -> MetaData: + """The entity that DECLARES a dimension or measure reached through ``from_``: the + member's parent, which is ``from_`` itself or an entity ``from_`` extends. A bare + entity name inside the member (``@of``, ``@via``) resolves in THIS entity's package, + exactly as the loader's ``validate_reporting`` resolves it (``_pkg_of(ctx.declaring)``), + never in ``from_``'s package or the report's.""" + return member.parent if member.parent is not None else from_ + + +def resolve_reporting_field_ref( + ref: str, declaring: MetaData, root: MetaRoot, host: MetaObject | None = None +) -> MetaField | None: + """Resolve a dimension's or measure's ``Entity.field`` reference to the field node. + The ONE rule, the same as the loader's (``validate_reporting`` D1 / M1) and as the + TypeScript ``resolveReportingFieldRef``: + + 1. The entity half resolves relative to the package of ``declaring``, the entity that + declares the member (:func:`reporting_member_owner`). + 2. With ``host`` (a measure, or a dimension without ``@via``: the reference is about + the ``@from`` entity's own rows) the named entity must be ``host`` or an entity it + extends, and the field is read from ``host``, so a field ``host`` redeclares wins. + 3. Without ``host`` (a dimension with ``@via``) the field is read from the named entity. + + ``None`` when any step fails. + """ # ``Entity.field``; a package qualifier uses ``::``, so the member separator is the LAST dot. dot = ref.rfind(CHILD_REF_SEP) if dot <= 0: return None - entity = resolve_object_ref(root, ref[:dot], _package_of_key(owner.resolution_key())) - if not isinstance(entity, MetaObject): + named = resolve_object_ref(root, ref[:dot], _package_of_key(declaring.resolution_key())) + if not isinstance(named, MetaObject): + return None + if host is not None and not _is_self_or_ancestor(named, host): return None # ADR-0039: resolving fields(), so a field inherited through extends is found. member = ref[dot + len(CHILD_REF_SEP):] - return next((f for f in entity.fields() if f.name == member), None) + return next((f for f in (host if host is not None else named).fields() if f.name == member), None) def _unresolved(report_name: str, what: str) -> ValueError: @@ -112,15 +154,21 @@ def _dimension_field( dim = _declared_member(from_, TYPE_DIMENSION, item.name, MetaDimension) if not isinstance(dim, MetaDimension): raise _unresolved(report_name, f"dimension '{item.name}' on '{from_.name}'") - of = resolve_reporting_field_ref(dim.of() or "", from_, root) + vialess = dim.via() is None + of = resolve_reporting_field_ref( + dim.of() or "", reporting_member_owner(dim, from_), root, from_ if vialess else None + ) if of is None: raise _unresolved(report_name, f"dimension '{item.name}' @of") name = report_derived_field_name(item) # ADR-0039 resolving: the @of field's effective @required (the attr only; a # validator.required child does not count). - required = dim.via() is None and of.get_meta_attr(FIELD_ATTR_REQUIRED) is True + required = vialess and of.get_meta_attr(FIELD_ATTR_REQUIRED) is True if dim.is_time(): + # Loader rule R2 guarantees a grain from the closed set; a tree built in code does not. grain = item.grain + if grain is None or grain not in TIME_GRAINS: + raise _unresolved(report_name, f"time dimension '{item.name}' grain '{grain or ''}'") if grain == GRAIN_HOUR: return ReportField( name, ROLE_DIMENSION, FIELD_SUBTYPE_TIMESTAMP, required, of, dimension=dim, grain=grain @@ -129,17 +177,27 @@ def _dimension_field( return ReportField(name, ROLE_DIMENSION, of.sub_type, required, of, dimension=dim) -def _measure_field(name: str, from_: MetaObject, root: MetaRoot, report_name: str) -> ReportField: +def _measure_field(item: str, report: MetaObject, from_: MetaObject, root: MetaRoot) -> ReportField: + """One ``@measures`` item, bare (``total``) or dotted (``Sale.total``, loader rule R3). + The measure is named by the item's last segment and looked up on ``from_``; a qualifier + resolves in the REPORT's package and must be ``from_`` or an entity ``from_`` extends.""" + report_name = report.name + name = report_measure_item_name(item) + qualifier = report_measure_item_owner(item) + if qualifier is not None: + owner = resolve_object_ref(root, qualifier, _package_of_key(report.resolution_key())) + if owner is None or not _is_self_or_ancestor(owner, from_): + raise _unresolved(report_name, f"measure '{item}' on '{from_.name}'") m = _declared_member(from_, TYPE_MEASURE, name, MetaMeasure) if not isinstance(m, MetaMeasure): - raise _unresolved(report_name, f"measure '{name}' on '{from_.name}'") + raise _unresolved(report_name, f"measure '{item}' on '{from_.name}'") if m.is_ratio(): return ReportField(name, ROLE_MEASURE, FIELD_SUBTYPE_DECIMAL, False, measure=m) agg = m.agg() if agg == AGG_COUNT: return ReportField(name, ROLE_MEASURE, FIELD_SUBTYPE_LONG, True, measure=m) cols = m.of_columns() - of = resolve_reporting_field_ref(cols[0] if cols else "", from_, root) + of = resolve_reporting_field_ref(cols[0] if cols else "", reporting_member_owner(m, from_), root, from_) if of is None: raise _unresolved(report_name, f"measure '{name}' @of") src = of.sub_type @@ -172,6 +230,6 @@ def report_shape(report: MetaObject, root: MetaRoot) -> ReportShape: raise _unresolved(report.name, f"@from '{from_name}'") fields = ( *(_dimension_field(item, from_, root, report.name) for item in report_dimension_items(report)), - *(_measure_field(n, from_, root, report.name) for n in report_measure_names(report)), + *(_measure_field(item, report, from_, root) for item in report_measure_names(report)), ) return ReportShape(report, from_, tuple(fields)) diff --git a/server/python/src/metaobjects/runtime/object_manager.py b/server/python/src/metaobjects/runtime/object_manager.py index 32a869956..c111e024d 100644 --- a/server/python/src/metaobjects/runtime/object_manager.py +++ b/server/python/src/metaobjects/runtime/object_manager.py @@ -655,6 +655,9 @@ def _require_entity(self, name: str) -> MetaObject: model = report_read_model(e, self._root) except ValueError as exc: raise ValueError(f"Report '{name}' cannot be read: {exc}") from exc + # ADR-0039: own — the read model is a detached object that extends nothing; its + # sources are exactly the one copy report_read_model() added, so there is no + # inherited layer for an own read to drop. if not any(isinstance(c, MetaSource) for c in model.own_children()): raise ValueError( f"Report '{name}' is not served: it declares no read-only source, " diff --git a/server/python/tests/runtime/test_object_manager_report.py b/server/python/tests/runtime/test_object_manager_report.py index 52c66acb3..26d9886f4 100644 --- a/server/python/tests/runtime/test_object_manager_report.py +++ b/server/python/tests/runtime/test_object_manager_report.py @@ -188,6 +188,8 @@ def test_reading_reports_leaves_the_loaded_tree_untouched() -> None: assert canonical_serialize(root) == before # The report's source node still belongs to the report (never re-parented). report = next(c for c in root.children() if c.name == "ProgramMinutes") + # ADR-0039: own — the assertion is about the nodes the report DECLARES (its source), + # whose parent must still be the report; an inherited child belongs to its base. assert all(c.parent is report for c in report.own_children()) diff --git a/server/python/tests/test_report_read_model.py b/server/python/tests/test_report_read_model.py new file mode 100644 index 000000000..4dfcc3fda --- /dev/null +++ b/server/python/tests/test_report_read_model.py @@ -0,0 +1,164 @@ +"""FR-044 Plan 2 — the report READ MODEL's carry rules (Table B's type-source column). + +A derived field is a new, detached ``field.*`` node. It takes its ``@required`` from the +derived shape and, when Table B gives it a type source, exactly these from that field: +``@currency``, ``@values``, ``@intValueMap``, ``@maxLength``, ``@precision``, ``@scale``, +``@localTime``, ``@objectRef``, ``@storage`` (read RESOLVING), its OWN ``@dbColumnType`` +(never an inherited one), and array-ness. Nothing else: no ``@column``, no ``@default``. + +The canonical corpus exercises few of these, so they are pinned here with inline models. +""" +from __future__ import annotations + +import json + +from metaobjects.loader.meta_data_loader import MetaDataLoader +from metaobjects.loader.sources import InMemoryStringSource +from metaobjects.meta.core.field.field_constants import ( + FIELD_ATTR_COLUMN, + FIELD_ATTR_CURRENCY, + FIELD_ATTR_DEFAULT, + FIELD_ATTR_MAX_LENGTH, + FIELD_ATTR_PRECISION, + FIELD_ATTR_REQUIRED, + FIELD_ATTR_SCALE, + FIELD_ATTR_VALUES, +) +from metaobjects.meta.core.field.meta_field import MetaField +from metaobjects.meta.core.reporting.report_read_model import report_read_model +from metaobjects.meta.persistence.db.db_constants import ( + FIELD_ATTR_DB_COLUMN_TYPE, + FIELD_ATTR_LOCAL_TIME, +) +from metaobjects.shared.base_types import TYPE_OBJECT + +_MODEL = { + "metadata.root": { + "package": "shop", + "children": [ + { + "object.entity": { + "name": "Base", + "abstract": True, + "children": [ + # Inherited by Sale.code below: @maxLength must be carried, the + # physical @dbColumnType must not. + {"field.string": {"name": "code", "@maxLength": 12, "@dbColumnType": "uuid"}}, + ], + } + }, + { + "object.entity": { + "name": "Sale", + "children": [ + {"source.rdb": {"@table": "sales"}}, + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "pk", "@fields": ["id"]}}, + {"field.string": {"name": "code", "extends": "shop::Base.code"}}, + {"field.string": {"name": "ref", "@dbColumnType": "uuid", "@column": "ref_col", + "@required": True, "@default": "x"}}, + {"field.string": {"name": "tags", "isArray": True}}, + {"field.enum": {"name": "status", "@values": ["OPEN", "PAID"]}}, + {"field.currency": {"name": "amountCents", "@currency": "EUR", "@required": True}}, + {"field.decimal": {"name": "weight", "@precision": 10, "@scale": 2}}, + {"field.timestamp": {"name": "bookedAt", "@localTime": True}}, + {"dimension.attribute": {"name": "code", "@of": "Sale.code"}}, + {"dimension.attribute": {"name": "ref", "@of": "Sale.ref"}}, + {"dimension.attribute": {"name": "tags", "@of": "Sale.tags"}}, + {"dimension.attribute": {"name": "status", "@of": "Sale.status"}}, + {"dimension.time": {"name": "bookedAt", "@of": "Sale.bookedAt", "@grains": ["hour", "day"]}}, + {"measure.aggregate": {"name": "sales", "@agg": "count", "@of": "Sale.id"}}, + {"measure.aggregate": {"name": "revenue", "@agg": "sum", "@of": "Sale.amountCents"}}, + {"measure.aggregate": {"name": "minAmount", "@agg": "min", "@of": "Sale.amountCents"}}, + {"measure.aggregate": {"name": "totalWeight", "@agg": "sum", "@of": "Sale.weight"}}, + {"measure.aggregate": {"name": "maxWeight", "@agg": "max", "@of": "Sale.weight"}}, + ], + } + }, + { + "object.report": { + "name": "R", + "@from": "Sale", + "@dimensions": ["code", "ref", "tags", "status", "bookedAt:hour", "bookedAt:day"], + "@measures": ["sales", "revenue", "minAmount", "totalWeight", "maxWeight"], + "children": [{"source.rdb": {"@kind": "view", "@view": "v_r"}}], + } + }, + ], + } +} + + +def _fields() -> dict[str, MetaField]: + result = MetaDataLoader().load([InMemoryStringSource(json.dumps(_MODEL), "meta.shop.json")]) + assert result.errors == [], [e.message for e in result.errors] + report = next(c for c in result.root.children() if c.type == TYPE_OBJECT and c.name == "R") + return {f.name: f for f in report_read_model(report, result.root).fields()} + + +def test_fields_are_one_per_table_b_row_in_order_with_the_derived_subtype() -> None: + assert [(f.name, f.sub_type) for f in _fields().values()] == [ + ("code", "string"), ("ref", "string"), ("tags", "string"), ("status", "enum"), + ("bookedAtHour", "timestamp"), ("bookedAtDay", "date"), + ("sales", "long"), ("revenue", "currency"), ("minAmount", "currency"), + ("totalWeight", "decimal"), ("maxWeight", "decimal"), + ] + + +def test_currency_is_carried_by_a_sum_and_by_a_min() -> None: + fields = _fields() + assert fields["revenue"].get_meta_attr(FIELD_ATTR_CURRENCY) == "EUR" + assert fields["minAmount"].get_meta_attr(FIELD_ATTR_CURRENCY) == "EUR" + + +def test_required_comes_from_the_derived_shape_never_from_the_type_source() -> None: + fields = _fields() + # A min of a required column is still nullable (no rows -> null); a count never is. + assert fields["minAmount"].get_meta_attr(FIELD_ATTR_REQUIRED) is False + assert fields["revenue"].get_meta_attr(FIELD_ATTR_REQUIRED) is False + assert fields["sales"].get_meta_attr(FIELD_ATTR_REQUIRED) is True + assert fields["ref"].get_meta_attr(FIELD_ATTR_REQUIRED) is True + assert fields["code"].get_meta_attr(FIELD_ATTR_REQUIRED) is False + + +def test_values_and_max_length_are_carried_resolving() -> None: + fields = _fields() + assert fields["status"].get_meta_attr(FIELD_ATTR_VALUES) == ["OPEN", "PAID"] + # Sale.code declares no @maxLength of its own: it inherits 12 from Base.code. + assert fields["code"].get_meta_attr(FIELD_ATTR_MAX_LENGTH) == 12 + + +def test_db_column_type_is_carried_only_when_the_of_field_declares_it_itself() -> None: + fields = _fields() + # ADR-0039: own — @dbColumnType is the one deliberately own-only attr; these + # assertions are about exactly that, so they read it with the OWN accessor attr(). + assert fields["ref"].attr(FIELD_ATTR_DB_COLUMN_TYPE) == "uuid" + assert fields["code"].attr(FIELD_ATTR_DB_COLUMN_TYPE) is None + assert fields["code"].get_meta_attr(FIELD_ATTR_DB_COLUMN_TYPE) is None + + +def test_array_ness_is_carried() -> None: + fields = _fields() + assert fields["tags"].resolved_is_array() is True + assert fields["code"].resolved_is_array() is False + + +def test_precision_scale_and_local_time_follow_the_type_source() -> None: + fields = _fields() + # max keeps the @of field as its type source; a sum of a decimal has none. + assert fields["maxWeight"].get_meta_attr(FIELD_ATTR_PRECISION) == 10 + assert fields["maxWeight"].get_meta_attr(FIELD_ATTR_SCALE) == 2 + assert fields["totalWeight"].get_meta_attr(FIELD_ATTR_PRECISION) is None + assert fields["totalWeight"].get_meta_attr(FIELD_ATTR_SCALE) is None + # An hour bucket is the instant itself and keeps @localTime; a day bucket is a bare date. + assert fields["bookedAtHour"].get_meta_attr(FIELD_ATTR_LOCAL_TIME) is True + assert fields["bookedAtDay"].get_meta_attr(FIELD_ATTR_LOCAL_TIME) is None + + +def test_nothing_else_is_carried() -> None: + ref = _fields()["ref"] + # The physical column is the naming strategy applied to the DERIVED name, so the + # @of field's @column is never inherited; nor is its @default. + assert ref.get_meta_attr(FIELD_ATTR_COLUMN) is None + assert ref.get_meta_attr(FIELD_ATTR_DEFAULT) is None + assert sorted(ref.attrs()) == sorted([FIELD_ATTR_REQUIRED, FIELD_ATTR_DB_COLUMN_TYPE]) diff --git a/server/python/tests/test_report_shape.py b/server/python/tests/test_report_shape.py index 8dfd37e26..97f3fe349 100644 --- a/server/python/tests/test_report_shape.py +++ b/server/python/tests/test_report_shape.py @@ -13,17 +13,60 @@ import json from pathlib import Path +import pytest + from metaobjects import load_directory +from metaobjects.loader.meta_data_loader import MetaDataLoader +from metaobjects.loader.sources import InMemoryStringSource from metaobjects.meta.core.field.meta_field import MetaField from metaobjects.meta.core.object.meta_object import MetaObject -from metaobjects.meta.core.object.object_constants import OBJECT_SUBTYPE_REPORT +from metaobjects.meta.core.object.object_constants import ( + OBJECT_REPORT_ATTR_DIMENSIONS, + OBJECT_REPORT_ATTR_FROM, + OBJECT_REPORT_ATTR_MEASURES, + OBJECT_SUBTYPE_REPORT, +) +from metaobjects.meta.core.reporting.report_accessors import ( + report_measure_item_name, + report_measure_item_owner, +) +from metaobjects.meta.core.reporting.report_read_model import report_read_source from metaobjects.meta.core.reporting.report_shape import report_shape -from metaobjects.meta.persistence.source.meta_source import MetaSource from metaobjects.shared.base_types import TYPE_OBJECT CORPUS = Path(__file__).parents[3] / "fixtures" / "persistence-conformance" +def _file(package: str, children: list) -> InMemoryStringSource: + return InMemoryStringSource( + json.dumps({"metadata.root": {"package": package, "children": children}}), f"meta.{package}.json" + ) + + +def _load(*files: InMemoryStringSource): + result = MetaDataLoader().load(list(files)) + assert result.errors == [], [e.message for e in result.errors] + return result.root + + +def _object(root, name: str) -> MetaObject: + return next(c for c in root.children() if c.type == TYPE_OBJECT and c.name == name) + + +#: A minimal entity with one count measure, for the tests that are about the report. +_SALE = { + "object.entity": { + "name": "Sale", + "children": [ + {"source.rdb": {"@table": "sales"}}, + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "pk", "@fields": ["id"]}}, + {"measure.aggregate": {"name": "sales", "@agg": "count", "@of": "Sale.id"}}, + ], + } +} + + def _root(): result = load_directory(CORPUS / "canonical") assert not result.errors, "\n".join(e.message for e in result.errors) @@ -45,11 +88,9 @@ def generate_report_shapes_json(root) -> str: if report.sub_type != OBJECT_SUBTYPE_REPORT: continue shape = report_shape(report, root) - # ADR-0039: own — the report's own declared read-only source names its view; a - # report inherits no source, and a sourceless one has no view. - source = next( - (c for c in report.own_children() if isinstance(c, MetaSource) and c.is_read_only()), None - ) + # The source the lowering names and the runtime reads: the report's own read-only + # source with @role primary, else its first own read-only source. + source = report_read_source(report) reports.append( { "report": report.resolution_key(), @@ -81,3 +122,237 @@ def test_the_canonical_model_has_six_reports() -> None: reports = [c for c in _root().children() if c.type == TYPE_OBJECT and c.sub_type == OBJECT_SUBTYPE_REPORT] assert len(reports) == 6 assert all(isinstance(r, MetaObject) for r in reports) + + +def test_the_view_is_the_primary_read_only_source_else_the_first() -> None: + """The artifact's ``view`` is the source the lowering names and the runtime reads: a + replica declared BEFORE the primary does not name the view.""" + root = _load( + _file( + "shop", + [ + _SALE, + { + "object.report": { + "name": "R", + "@from": "Sale", + "@measures": ["sales"], + "children": [ + {"source.rdb": {"name": "replica", "@kind": "view", "@view": "v_replica", "@role": "replica"}}, + {"source.rdb": {"name": "main", "@kind": "view", "@view": "v_primary"}}, + ], + } + }, + ], + ) + ) + assert json.loads(generate_report_shapes_json(root))["reports"][0]["view"] == "v_primary" + + +# --------------------------------------------------------------------------- +# Reference resolution: the shape must agree with the loader's validate_reporting +# about what a reference names, or a model that loads clean fails (or is silently +# mistyped) when it is read. The same cases as the TypeScript report-shape.test.ts. +# --------------------------------------------------------------------------- + +#: ``a::Base`` (abstract): members whose bare ``@of`` names ``Base``. +_SHARED_BASE = { + "object.entity": { + "name": "Base", + "abstract": True, + "children": [ + {"field.long": {"name": "id"}}, + {"field.string": {"name": "kind"}}, + {"identity.primary": {"name": "pk", "@fields": ["id"]}}, + {"dimension.attribute": {"name": "kind", "@of": "Base.kind"}}, + {"measure.aggregate": {"name": "events", "@agg": "count", "@of": "Base.id"}}, + {"measure.aggregate": {"name": "lastKind", "@agg": "max", "@of": "Base.kind"}}, + ], + } +} + +_DECOY = { + "object.entity": {"name": "Base", "children": [{"field.int": {"name": "id"}}, {"field.int": {"name": "kind"}}]} +} + + +def _ev(extra: list | None = None) -> dict: + return { + "object.entity": { + "name": "Ev", + "extends": "a::Base", + "children": [{"source.rdb": {"@table": "evs"}}, *(extra or [])], + } + } + + +def _ev_report(**attrs) -> dict: + return { + "object.report": { + "name": "R", + "@from": "Ev", + "@dimensions": ["kind"], + "@measures": ["events", "lastKind"], + **attrs, + "children": [{"source.rdb": {"@kind": "view", "@view": "v_r"}}], + } + } + + +def _typed(root) -> list[tuple[str, str, str | None]]: + shape = report_shape(_object(root, "R"), root) + return [ + (f.name, f.sub_type, None if f.type_source is None else f.type_source.parent.resolution_key()) + for f in shape.fields + ] + + +def test_a_bare_of_on_a_member_inherited_from_another_package_resolves_in_the_declaring_package() -> None: + root = _load(_file("a", [_SHARED_BASE]), _file("b", [_ev(), _ev_report()])) + assert _typed(root) == [("kind", "string", "a::Base"), ("events", "long", None), ("lastKind", "string", "a::Base")] + + +def test_a_same_named_decoy_in_the_reports_package_does_not_capture_the_reference() -> None: + root = _load(_file("a", [_SHARED_BASE]), _file("b", [_DECOY, _ev(), _ev_report()])) + assert _typed(root) == [("kind", "string", "a::Base"), ("events", "long", None), ("lastKind", "string", "a::Base")] + + +def test_without_via_the_field_is_read_from_from_so_a_field_from_redeclares_wins() -> None: + root = _load(_file("a", [_SHARED_BASE]), _file("b", [_ev([{"field.int": {"name": "kind"}}]), _ev_report()])) + assert _typed(root) == [("kind", "int", "b::Ev"), ("events", "long", None), ("lastKind", "int", "b::Ev")] + + +def test_a_dotted_measures_item_names_the_measure_by_its_last_segment() -> None: + root = _load( + _file("a", [_SHARED_BASE]), + _file("b", [_ev(), _ev_report(**{"@measures": ["Ev.events", "a::Base.lastKind"]})]), + ) + assert [f[0] for f in _typed(root)] == ["kind", "events", "lastKind"] + + +def test_report_measure_item_name_is_the_last_segment() -> None: + assert report_measure_item_name("total") == "total" + assert report_measure_item_name("Sale.total") == "total" + assert report_measure_item_name("acme::shop::Sale.total") == "total" + assert report_measure_item_owner("total") is None + assert report_measure_item_owner("acme::shop::Sale.total") == "acme::shop::Sale" + + +def _stray(package: str, from_: str, attr: str, item: str) -> MetaObject: + """A report built in code (never added to the root): what the loader would refuse.""" + report = MetaObject(TYPE_OBJECT, OBJECT_SUBTYPE_REPORT, "Stray") + report.package = package + report.set_attr(OBJECT_REPORT_ATTR_FROM, from_) + report.set_attr(attr, [item]) + return report + + +def test_a_dotted_measures_item_whose_qualifier_is_not_from_or_an_ancestor_does_not_resolve() -> None: + root = _load(_file("a", [_SHARED_BASE]), _file("b", [_DECOY, _ev(), _ev_report()])) + # Past the loader, which refuses these as ERR_INVALID_REPORT / ERR_REPORT_FOREIGN_MEASURE. + with pytest.raises(ValueError) as e: + report_shape(_stray("b", "Ev", OBJECT_REPORT_ATTR_MEASURES, "Nope.events"), root) + assert str(e.value) == "report 'Stray': measure 'Nope.events' on 'Ev' does not resolve." + # The qualifier resolves in the REPORT's package: b::Base is the decoy, not an ancestor of Ev. + with pytest.raises(ValueError) as e: + report_shape(_stray("b", "Ev", OBJECT_REPORT_ATTR_MEASURES, "Base.events"), root) + assert str(e.value) == "report 'Stray': measure 'Base.events' on 'Ev' does not resolve." + + +def test_a_time_dimension_item_with_no_grain_or_a_grain_outside_the_closed_set_does_not_resolve() -> None: + root = _root() + with pytest.raises(ValueError) as e: + report_shape(_stray("fitness", "Program", OBJECT_REPORT_ATTR_DIMENSIONS, "createdAt"), root) + assert str(e.value) == "report 'Stray': time dimension 'createdAt' grain '' does not resolve." + with pytest.raises(ValueError) as e: + report_shape(_stray("fitness", "Program", OBJECT_REPORT_ATTR_DIMENSIONS, "createdAt:fortnight"), root) + assert str(e.value) == "report 'Stray': time dimension 'createdAt' grain 'fortnight' does not resolve." + + +# --------------------------------------------------------------------------- +# Table B rows the canonical model does not contain +# --------------------------------------------------------------------------- + +_CUBE = { + "object.entity": { + "name": "Sale", + "extends": "Base", + "children": [ + {"source.rdb": {"@table": "sales"}}, + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "pk", "@fields": ["id"]}}, + {"field.decimal": {"name": "weight", "@precision": 10, "@scale": 2}}, + {"field.double": {"name": "score"}}, + {"field.float": {"name": "ratio"}}, + {"field.string": {"name": "region", "@required": True, "@maxLength": 8}}, + {"field.timestamp": {"name": "bookedAt", "@localTime": True}}, + {"dimension.attribute": {"name": "region", "@of": "Sale.region"}}, + {"dimension.time": {"name": "bookedAt", "@of": "Sale.bookedAt", "@grains": ["hour", "day"]}}, + {"measure.aggregate": {"name": "revenue", "@agg": "sum", "@of": "Sale.amountCents"}}, + {"measure.aggregate": {"name": "avgRevenue", "@agg": "avg", "@of": "Sale.amountCents"}}, + {"measure.aggregate": {"name": "totalWeight", "@agg": "sum", "@of": "Sale.weight"}}, + {"measure.aggregate": {"name": "avgWeight", "@agg": "avg", "@of": "Sale.weight"}}, + {"measure.aggregate": {"name": "totalScore", "@agg": "sum", "@of": "Sale.score"}}, + {"measure.aggregate": {"name": "avgScore", "@agg": "avg", "@of": "Sale.score"}}, + {"measure.aggregate": {"name": "totalRatio", "@agg": "sum", "@of": "Sale.ratio"}}, + {"measure.aggregate": {"name": "avgRatio", "@agg": "avg", "@of": "Sale.ratio"}}, + {"measure.aggregate": {"name": "maxRevenue", "@agg": "max", "@of": "Sale.amountCents"}}, + {"measure.aggregate": {"name": "minWeight", "@agg": "min", "@of": "Sale.weight"}}, + ], + } +} + +_CUBE_BASE = { + "object.entity": { + "name": "Base", + "abstract": True, + "children": [{"field.currency": {"name": "amountCents", "@required": True, "@currency": "USD"}}], + } +} + +_CUBE_REPORT = { + "object.report": { + "name": "R", + "@from": "Sale", + "@dimensions": ["region", "bookedAt:hour", "bookedAt:day"], + "@measures": [ + "revenue", "avgRevenue", "totalWeight", "avgWeight", "totalScore", "avgScore", + "totalRatio", "avgRatio", "maxRevenue", "minWeight", + ], + } +} + + +def test_sum_avg_min_and_max_rows_by_of_subtype() -> None: + root = _load(_file("shop", [_CUBE_BASE, _CUBE, _CUBE_REPORT])) + fields = {f.name: f for f in report_shape(_object(root, "R"), root).fields} + + def row(name: str) -> tuple[str, bool, str | None]: + f = fields[name] + return (f.sub_type, f.required, _type_source(f.type_source)) + + # sum: currency stays currency (and carries its type source); int/long -> long; + # double/float -> double; anything else -> decimal. No sum is required. + assert row("revenue") == ("currency", False, "shop::Base.amountCents") + assert row("totalWeight") == ("decimal", False, None) + assert row("totalScore") == ("double", False, None) + assert row("totalRatio") == ("double", False, None) + # avg: double/float -> double, anything else -> decimal; never a type source. + assert row("avgRevenue") == ("decimal", False, None) + assert row("avgWeight") == ("decimal", False, None) + assert row("avgScore") == ("double", False, None) + assert row("avgRatio") == ("double", False, None) + # min / max keep the @of field's subtype and name it as the type source: the entity + # that DECLARES the field, which for an inherited field is the base. + assert row("maxRevenue") == ("currency", False, "shop::Base.amountCents") + assert row("minWeight") == ("decimal", False, "shop::Sale.weight") + # Dimensions: required only from the @of field's own @required; an hour bucket keeps + # the field as its type source (for @localTime), a coarser one is a bare date. + assert row("region") == ("string", True, "shop::Sale.region") + assert row("bookedAtHour") == ("timestamp", False, "shop::Sale.bookedAt") + assert row("bookedAtDay") == ("date", False, None) + + +def test_a_sourceless_report_has_a_shape_and_no_view() -> None: + root = _load(_file("shop", [_CUBE_BASE, _CUBE, _CUBE_REPORT])) + assert json.loads(generate_report_shapes_json(root))["reports"][0]["view"] is None From 1299ebc13b6eb61b61454f387cfac46da0f8d575 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 11:42:03 -0400 Subject: [PATCH 29/32] docs(changelog): report reference fixes in the four ports, the field.object refusal, OMDB @view projections (FR-044) --- CHANGELOG.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e19fdfea..76af5ef35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,10 +55,19 @@ it until 1.1 ships._ Anyone who declared a view-sourced report under the unreleased 1.1 vocabulary will now see a `CREATE VIEW` from `meta migrate`. Kotlin `gen` fails, naming the report and the dimension or measure, for a view-backed report with a derived field named after a Kotlin hard keyword or - with two derived fields that land on one column property. + with two derived fields that land on one column property. A view-backed report with a + dimension or measure over a `field.object` is refused by name by Java OMDB (on read), Kotlin + `gen` and C# `gen`; group by a scalar field. A `@measures` item may be written dotted + (`Sale.total`) and reads the same as the bare name in every port. Java OQL + (`executeQuery`) with a report as its result class builds rows from the report's derived + fields. ### Fixed +- **Java OMDB reads a projection whose view is named by `@view`.** The read mapping took the + view name from `@table` only, so a projection declared with the kind-matching `@view` alias + had no read mapping. It now resolves the source's physical name (`@view`, then the legacy + `@table`), the same rule the TypeScript toolchain creates the view under. - **Kotlin: a field named after an Exposed `Table` property that was not reserved now gets the `Column` suffix.** An entity or report field named `schemaName` now emits the column property `schemaNameColumn`; it collided with Exposed's `Table.schemaName` and did not compile before. From c7839ec2c263b392219b5bb987102fd13f4c09ca Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 11:49:27 -0400 Subject: [PATCH 30/32] docs(reporting): state the field.object limit per port (FR-044) Java OMDB (on read), Kotlin gen and C# gen now refuse a report whose column is typed by a field.object, naming the report and the item; the TypeScript and Python runtimes read it as parsed JSON. The Known limits entry and the skill reference said the other ports were not gated for it. --- .../skills/metaobjects-authoring/references/reporting.md | 2 +- docs/features/reporting.md | 8 ++++++-- .../skills/metaobjects-authoring/references/reporting.md | 2 +- .../skills/metaobjects-authoring/references/reporting.md | 2 +- .../skills/metaobjects-authoring/references/reporting.md | 2 +- .../skills/metaobjects-authoring/references/reporting.md | 2 +- .../skills/metaobjects-authoring/references/reporting.md | 2 +- 7 files changed, 12 insertions(+), 8 deletions(-) diff --git a/agent-context/skills/metaobjects-authoring/references/reporting.md b/agent-context/skills/metaobjects-authoring/references/reporting.md index 737c6f363..1116503c6 100644 --- a/agent-context/skills/metaobjects-authoring/references/reporting.md +++ b/agent-context/skills/metaobjects-authoring/references/reporting.md @@ -76,7 +76,7 @@ A dimension reached by `@via` joins like a projection does: a required belongs-t - **A report `@from` a TPH subtype is refused** when its view is derived. The subtype shares its base's table with every other subtype, so the view would count all of their rows. Declare the report `@from` the base, with an `@filter` on the discriminator field (`"@filter": { "kind": "ADMIN" }`). An `@sql` or `@unmanaged` report over a subtype is yours to scope. - **An empty `in` list in a filter is refused** at `meta migrate`, naming the report and the field. - **An abstract view-backed report, or one whose source `@kind` is `materializedView`, `storedProc` or `tableFunction`, gets no C# row class and no Kotlin table object.** The TypeScript, Java and Python runtimes still read whatever relation the source names (fine for a materialized view you created, a database error for a routine). -- **Do not group by a `field.object`.** A dimension over one is not supported across ports; group by a scalar field. +- **Do not group by a `field.object`.** A dimension over one, or over a field carrying `@objectRef`, loads everywhere but is refused by name by Java OMDB on read and by Kotlin `gen` and C# `gen`; only the TypeScript and Python runtimes read it (as parsed JSON). Group by a scalar field. ## What a report does not have diff --git a/docs/features/reporting.md b/docs/features/reporting.md index 133eae2a2..26275edde 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -326,8 +326,12 @@ the bodies are valid under MySQL's default `ONLY_FULL_GROUP_BY`. for it, `meta migrate` skips it, and the three runtimes issue a `SELECT` against whatever relation the source names. That works for a materialized view you created and is a database error for a stored procedure or a table function. -- **A dimension over a `field.object` is not supported across ports.** TypeScript and Python - return the parsed JSON; the other ports are not gated for it. Group by a scalar field. +- **A report over a `field.object` is not supported across ports.** A dimension whose `@of` is a + `field.object` (or a field carrying `@objectRef`) loads in every port. Java OMDB then refuses + the read, and Kotlin `gen` and C# `gen` refuse to generate the table or row, each with an + error naming the report and the dimension (the same refusal covers a measure whose column is + typed by such a field). The TypeScript and Python runtimes read it and return the parsed JSON. + Group by a scalar field. - **With `@via`, `@of` must name an entity that has the field** (declared on it or inherited by it); naming a base of the reached entity for a field only the subtype declares loads and then fails `meta migrate`. Without `@via` the field is read from the `@from` entity itself. diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 737c6f363..1116503c6 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -76,7 +76,7 @@ A dimension reached by `@via` joins like a projection does: a required belongs-t - **A report `@from` a TPH subtype is refused** when its view is derived. The subtype shares its base's table with every other subtype, so the view would count all of their rows. Declare the report `@from` the base, with an `@filter` on the discriminator field (`"@filter": { "kind": "ADMIN" }`). An `@sql` or `@unmanaged` report over a subtype is yours to scope. - **An empty `in` list in a filter is refused** at `meta migrate`, naming the report and the field. - **An abstract view-backed report, or one whose source `@kind` is `materializedView`, `storedProc` or `tableFunction`, gets no C# row class and no Kotlin table object.** The TypeScript, Java and Python runtimes still read whatever relation the source names (fine for a materialized view you created, a database error for a routine). -- **Do not group by a `field.object`.** A dimension over one is not supported across ports; group by a scalar field. +- **Do not group by a `field.object`.** A dimension over one, or over a field carrying `@objectRef`, loads everywhere but is refused by name by Java OMDB on read and by Kotlin `gen` and C# `gen`; only the TypeScript and Python runtimes read it (as parsed JSON). Group by a scalar field. ## What a report does not have diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 737c6f363..1116503c6 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -76,7 +76,7 @@ A dimension reached by `@via` joins like a projection does: a required belongs-t - **A report `@from` a TPH subtype is refused** when its view is derived. The subtype shares its base's table with every other subtype, so the view would count all of their rows. Declare the report `@from` the base, with an `@filter` on the discriminator field (`"@filter": { "kind": "ADMIN" }`). An `@sql` or `@unmanaged` report over a subtype is yours to scope. - **An empty `in` list in a filter is refused** at `meta migrate`, naming the report and the field. - **An abstract view-backed report, or one whose source `@kind` is `materializedView`, `storedProc` or `tableFunction`, gets no C# row class and no Kotlin table object.** The TypeScript, Java and Python runtimes still read whatever relation the source names (fine for a materialized view you created, a database error for a routine). -- **Do not group by a `field.object`.** A dimension over one is not supported across ports; group by a scalar field. +- **Do not group by a `field.object`.** A dimension over one, or over a field carrying `@objectRef`, loads everywhere but is refused by name by Java OMDB on read and by Kotlin `gen` and C# `gen`; only the TypeScript and Python runtimes read it (as parsed JSON). Group by a scalar field. ## What a report does not have diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 737c6f363..1116503c6 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -76,7 +76,7 @@ A dimension reached by `@via` joins like a projection does: a required belongs-t - **A report `@from` a TPH subtype is refused** when its view is derived. The subtype shares its base's table with every other subtype, so the view would count all of their rows. Declare the report `@from` the base, with an `@filter` on the discriminator field (`"@filter": { "kind": "ADMIN" }`). An `@sql` or `@unmanaged` report over a subtype is yours to scope. - **An empty `in` list in a filter is refused** at `meta migrate`, naming the report and the field. - **An abstract view-backed report, or one whose source `@kind` is `materializedView`, `storedProc` or `tableFunction`, gets no C# row class and no Kotlin table object.** The TypeScript, Java and Python runtimes still read whatever relation the source names (fine for a materialized view you created, a database error for a routine). -- **Do not group by a `field.object`.** A dimension over one is not supported across ports; group by a scalar field. +- **Do not group by a `field.object`.** A dimension over one, or over a field carrying `@objectRef`, loads everywhere but is refused by name by Java OMDB on read and by Kotlin `gen` and C# `gen`; only the TypeScript and Python runtimes read it (as parsed JSON). Group by a scalar field. ## What a report does not have diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 737c6f363..1116503c6 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -76,7 +76,7 @@ A dimension reached by `@via` joins like a projection does: a required belongs-t - **A report `@from` a TPH subtype is refused** when its view is derived. The subtype shares its base's table with every other subtype, so the view would count all of their rows. Declare the report `@from` the base, with an `@filter` on the discriminator field (`"@filter": { "kind": "ADMIN" }`). An `@sql` or `@unmanaged` report over a subtype is yours to scope. - **An empty `in` list in a filter is refused** at `meta migrate`, naming the report and the field. - **An abstract view-backed report, or one whose source `@kind` is `materializedView`, `storedProc` or `tableFunction`, gets no C# row class and no Kotlin table object.** The TypeScript, Java and Python runtimes still read whatever relation the source names (fine for a materialized view you created, a database error for a routine). -- **Do not group by a `field.object`.** A dimension over one is not supported across ports; group by a scalar field. +- **Do not group by a `field.object`.** A dimension over one, or over a field carrying `@objectRef`, loads everywhere but is refused by name by Java OMDB on read and by Kotlin `gen` and C# `gen`; only the TypeScript and Python runtimes read it (as parsed JSON). Group by a scalar field. ## What a report does not have diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md index 737c6f363..1116503c6 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/reporting.md @@ -76,7 +76,7 @@ A dimension reached by `@via` joins like a projection does: a required belongs-t - **A report `@from` a TPH subtype is refused** when its view is derived. The subtype shares its base's table with every other subtype, so the view would count all of their rows. Declare the report `@from` the base, with an `@filter` on the discriminator field (`"@filter": { "kind": "ADMIN" }`). An `@sql` or `@unmanaged` report over a subtype is yours to scope. - **An empty `in` list in a filter is refused** at `meta migrate`, naming the report and the field. - **An abstract view-backed report, or one whose source `@kind` is `materializedView`, `storedProc` or `tableFunction`, gets no C# row class and no Kotlin table object.** The TypeScript, Java and Python runtimes still read whatever relation the source names (fine for a materialized view you created, a database error for a routine). -- **Do not group by a `field.object`.** A dimension over one is not supported across ports; group by a scalar field. +- **Do not group by a `field.object`.** A dimension over one, or over a field carrying `@objectRef`, loads everywhere but is refused by name by Java OMDB on read and by Kotlin `gen` and C# `gen`; only the TypeScript and Python runtimes read it (as parsed JSON). Group by a scalar field. ## What a report does not have From 46c8d04763de54b60dfc55aca50ca11272e96fda Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 12:57:07 -0400 Subject: [PATCH 31/32] fix(codegen-ts): a @via report lowers in a package whose name contains a dot; document the redeclared-field limit (FR-044) The @via walk was handed the @from entity's resolution key as its head, and the walk splits on every dot, so a report in a package such as com.acme resolved no hop and was refused with a false 'no foreign key' message. The head is now the entity's short name, resolved in its own package, which is still that entity when another package has one of the same name. Known limits gains the quiet form of the @via rule: when the reached subtype redeclares the field and @of names the base, the view reads the base's column. --- docs/features/reporting.md | 6 +- .../src/projection/extract-report-spec.ts | 6 +- .../projection/extract-report-spec.test.ts | 63 +++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/docs/features/reporting.md b/docs/features/reporting.md index 26275edde..c0930a3bf 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -334,7 +334,11 @@ the bodies are valid under MySQL's default `ONLY_FULL_GROUP_BY`. Group by a scalar field. - **With `@via`, `@of` must name an entity that has the field** (declared on it or inherited by it); naming a base of the reached entity for a field only the subtype declares loads and then - fails `meta migrate`. Without `@via` the field is read from the `@from` entity itself. + fails `meta migrate`. The quiet form of the same rule: a `@via` dimension reads its field from + the entity `@of` names, so when the reached subtype **redeclares** that field and `@of` names + the base, the view selects the base's column and type with no error. Qualify `@of` with the + subtype that declares the field you mean. Without `@via` the field is read from the `@from` + entity itself. ### What the corpus gates diff --git a/server/typescript/packages/codegen-ts/src/projection/extract-report-spec.ts b/server/typescript/packages/codegen-ts/src/projection/extract-report-spec.ts index 84c900e2a..250c25731 100644 --- a/server/typescript/packages/codegen-ts/src/projection/extract-report-spec.ts +++ b/server/typescript/packages/codegen-ts/src/projection/extract-report-spec.ts @@ -302,7 +302,11 @@ export function extractReportSpec(report: MetaObject, root: MetaRoot, ctx: Extra `${where} @via '${via}' must be Owner.hop[.hop...], starting at @from '${from.name}' or an entity it extends.`, ); } - const path = walkViaPath([from.resolutionKey(), ...hops].join("."), root, packageOf(from), ctx); + // The head is `from`'s SHORT name, resolved in `from`'s own package: a bare name binds the + // referrer's package first, so it is `from` itself even when another package has an entity + // of that name. Never its resolution key: walkViaPath splits on every `.`, and a package + // name may contain one (`com.acme::F`). + const path = walkViaPath([from.name, ...hops].join("."), root, packageOf(from), ctx); // walkViaPath stops at the first hop it cannot resolve; a partial path would pin the // dimension to the wrong alias, so the whole chain must be walked. if (path.length !== hops.length) { diff --git a/server/typescript/packages/codegen-ts/test/projection/extract-report-spec.test.ts b/server/typescript/packages/codegen-ts/test/projection/extract-report-spec.test.ts index 677b0abb4..15ec7690d 100644 --- a/server/typescript/packages/codegen-ts/test/projection/extract-report-spec.test.ts +++ b/server/typescript/packages/codegen-ts/test/projection/extract-report-spec.test.ts @@ -468,6 +468,69 @@ describe("extractReportSpec: references resolve as the loader resolves them", () }); }); +describe("extractReportSpec: the @via walk starts at @from whatever its package looks like", () => { + /** `F` with a to-one reference to `P`, and a report grouping by P's title through it. */ + const facts = (pkg: string, pTable = "ps"): InMemoryStringSource => + file(pkg, [ + { + "object.entity": { + name: "P", + children: [ + { "source.rdb": { "@table": pTable } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "title" } }, + { "identity.primary": { name: "pk", "@fields": ["id"] } }, + ], + }, + }, + { + "object.entity": { + name: "F", + children: [ + { "source.rdb": { "@table": `${pTable}_facts` } }, + { "field.long": { name: "id" } }, + { "field.long": { name: "pId" } }, + { "identity.primary": { name: "pk", "@fields": ["id"] } }, + { "identity.reference": { name: "pRef", "@fields": ["pId"], "@references": "P" } }, + { "dimension.attribute": { name: "pTitle", "@of": "P.title", "@via": "F.pRef" } }, + { "measure.aggregate": { name: "facts", "@agg": "count", "@of": "F.id" } }, + ], + }, + }, + { + "object.report": { + name: "ByP", + "@from": "F", + "@dimensions": ["pTitle"], + "@measures": ["facts"], + children: [view("v_by_p")], + }, + }, + ]); + const joinsOf = (root: MetaRoot, reportKey: string): unknown[] => { + const report = root.objects().find((o) => o.resolutionKey() === reportKey)!; + const s = extractReportSpec(report, root, CTX); + return [s.joinTree.baseEntity, ...s.joinTree.joins.map((j) => [j.relationship, j.targetEntity])]; + }; + + test.each(["acme", "com.acme", "com.acme::shop.v2"])( + "a @via dimension lowers to one join in package '%s' (a package name may contain a dot)", + async (pkg) => { + const root = await loadFiles([facts(pkg)]); + expect(joinsOf(root, `${pkg}::ByP`)).toEqual([`${pkg}::F`, ["pRef", `${pkg}::P`]]); + }, + ); + + test("the walk starts at THIS report's @from when another package has an entity of the same short name", async () => { + // Loaded in both orders: neither `F` may win by load order. + for (const files of [[facts("one", "ps1"), facts("two", "ps2")], [facts("two", "ps2"), facts("one", "ps1")]]) { + const root = await loadFiles(files); + expect(joinsOf(root, "one::ByP")).toEqual(["one::F", ["pRef", "one::P"]]); + expect(joinsOf(root, "two::ByP")).toEqual(["two::F", ["pRef", "two::P"]]); + } + }); +}); + describe("extractReportSpec: refusals that name what is wrong", () => { test("refuses an abstract @from, naming the report and the entity", async () => { const root = await loadFiles([ From 836c36f82d7b7d28383c58e1914e8e3dec3007d8 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 14:04:47 -0400 Subject: [PATCH 32/32] no-mistakes(document): Fix stale FR-044 roadmap status and C# port codegen inventory for report lowering --- docs/ports/csharp.md | 4 ++++ spec/roadmap.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/ports/csharp.md b/docs/ports/csharp.md index 30e8cc235..8ad59f127 100644 --- a/docs/ports/csharp.md +++ b/docs/ports/csharp.md @@ -107,6 +107,10 @@ Schema migrations are owned by the Node `meta` CLI (ADR-0015) — the C# CLI is The codegen emits: - `Author.g.cs` — class per entity (a mutable attributed POCO, not a record). +- `.g.cs` — keyless row class per **view-backed report** (`object.report` with a + read-only `@kind: view` source); mapped in `AppDbContext` with `DbSet` + + `HasNoKey().ToView(...)`. No routes or filter allowlist for a report. See + [reporting](../features/reporting.md). - `AppDbContext.g.cs` — `DbSet`, projection `.ToView()`, `@storage` owned types via `OwnsOne` (single) / `OwnsMany(...).ToJson(...)` (`@isArray` array-of-VO), enum-as-string via `HasConversion()`. diff --git a/spec/roadmap.md b/spec/roadmap.md index 479cb2a50..a025a461a 100644 --- a/spec/roadmap.md +++ b/spec/roadmap.md @@ -161,7 +161,7 @@ under **Shipped**; planned FRs under **Planned** + the **Release plan**. ✅ shi | FR-041 | Public A/B drift benchmark — coding agents with vs without MetaObjects, pre-registered, friction-first | 📋 **design settled 2026-09-12, unbuilt** — the "proving the value" work; it is what licenses the claims FR-042 §4 withholds. Revised after an adversarial two-reviewer design review (spec §14): the scored task set is now held out from the friction pass (the draft tuned Arm B on the tasks it would later be scored on, with no symmetric loop for the control), Arm A is derived from Arm B's generated output so the seeds differ only in the model and the gate, `n` is calibrated from a pilot instead of asserted (one identical config measured 25/51/28 turns at temperature 0 — 41% CV), H2 is time-to-**correct** rather than time-to-done, the primary is analysed intention-to-treat so an arm cannot win by not finishing, and escaped defects are reported split by whether `meta verify` already covers the invariant class (it covers four of five, so H1 is partly definitional for those). First deliverable is the Phase 0b friction log. Design: `docs/superpowers/specs/2026-09-11-fr-041-drift-ab-benchmark-design.md` | 1.x | — | | FR-042 | First-touch positioning — one typed model, two verbs (README, llms, sites) | 🟢 shipped on the four first-touch surfaces — pitch **locked** 2026-09-12 (two verbs: Generate + Verify; requirements fold into Verify; H1 model-first), Verify clause amended 2026-09-14 to "fails or warns", six pillars carry maturity labels, do-not-say list gated in the `gates` lane. **Remaining:** the drift terminal recording for the .dev hero, and the drift-demo page its CTA should point at (spec §8). Design: `docs/superpowers/specs/2026-09-11-fr-042-first-touch-positioning-design.md` | — | — | | FR-043 | **Libraries** — reusable declared design: model metadata + the requirements that make it checkable (+ an implied generator selection), opted into by name | 🟢 **design approved 2026-09-13, un-deferred** — proposed as a SIXTH pillar. Not greenfield: `library/ai/llm-call.yaml` already ships one (opt-in via the loader's `libraries: ["ai"]`, embedded per port under an `embedded-library drift` gate, with `trace-helper` codegen beside it). Generalises it — requirements as a component, discovery through the codegen catalog's `kind: "library"`, declared package→generator coupling replacing `trace-helper`'s hard-coded `LlmCallBase`, `overlay: true` as the adaptation door and `meta eject` for repackaging. **No new metamodel vocabulary; `metamodelVersion` does not move.** Second library `iam` (users, typed nestable groups, roles as permission bundles, global and group-scoped grants) ships `stability: preview`; its model is verified to load clean under strict. Phase 2: third-party authoring over FR-023's deferred transports. Design: `docs/superpowers/specs/2026-09-13-fr-043-feature-and-nfr-packages-design.md` | 1.1 | — | -| FR-044 | **Core reporting** — declared measures, dimensions, segments and reports (compiled to SQL views in every port), plus Cube and dbt MetricFlow exporters | 🟢 **Plan 1 of 5 shipped on `main` (2026-10-03)** — the vocabulary (`dimension.attribute`, `dimension.time`, `measure.aggregate`, `measure.ratio`, `segment.filter`, `object.report`, plus the relative-date filter value) is registered and loader-validated in all five ports, gated by 27 new conformance fixtures and the new `codegen-noop` corpus; decisions D1–D6 are settled. `metamodelVersion` reads **`1.1`** on `main` (1.0.x PATCH releases held until 1.1 ships). **Reports generate nothing yet** — view lowering and REST are Plans 2–3, then the Cube / dbt MetricFlow exporters; `measure.derived` stays unregistered until FR-037 R5. A query-time engine remains parked with its re-entry trigger. Feature doc: `docs/features/reporting.md`. Design: `docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md` | 1.1 | [#391](https://github.com/metaobjectsdev/metaobjects/issues/391) | +| FR-044 | **Core reporting** — declared measures, dimensions, segments and reports (compiled to SQL views in every port), plus Cube and dbt MetricFlow exporters | 🟢 **Plans 1–2 of 5 shipped on `main` (Plan 1 2026-10-03, Plan 2 2026-10-04)** — the vocabulary (`dimension.attribute`, `dimension.time`, `measure.aggregate`, `measure.ratio`, `segment.filter`, `object.report`, plus the relative-date filter value) is registered and loader-validated in all five ports, gated by 27 new conformance fixtures and the new `codegen-noop` corpus; decisions D1–D6 are settled. `metamodelVersion` reads **`1.1`** on `main` (1.0.x PATCH releases held until 1.1 ships). **Plan 2 lowered the view-backed report**: a report that declares a read-only `source.rdb @kind: view` becomes that view in `meta migrate` (Postgres / SQLite / D1; MySQL through `buildReportViews` + the recipe), every port's runtime reads it (six shared persistence scenarios, columns pinned by `report-shapes.json`), C# generates a keyless EF Core row and Kotlin an Exposed table object, and `meta docs` lists the view on the agent schema page. **Still to come:** REST routes and typed clients (Plan 3), then the Cube / dbt MetricFlow exporters; `measure.derived` stays unregistered until FR-037 R5. A query-time engine remains parked with its re-entry trigger. Feature doc: `docs/features/reporting.md`. Design: `docs/superpowers/specs/2026-10-02-fr-044-core-reporting-design.md` | 1.1 | [#391](https://github.com/metaobjectsdev/metaobjects/issues/391) | _(FR-001 was the original metamodel foundation — pre-dates the FR-numbered tracking.)_ _(FR-032 was developed under the working number "FR-026" — see commit history; renumbered to avoid the FR-026=Forms collision. Design: `docs/superpowers/specs/2026-06-13-fr-032-canonical-fqn-refs-design.md`, ADR-0032.)_