/** * derived-field.ts — the « colonne dérivée » of the pagespec/entité.md: a * READ column whose value is not a field of the entity — it lives behind a * NAVIGATION (Driver.User.Email) or inside a DATED CHILD (the vehicle's open * registration's plate, the last usable odometer reading). * * The motif the round-3 report called its fil rouge: FOUR client workarounds * were the same missing notion — `PlateNumber` (open registration), * `CurrentMileage` (latest reading), `OfficeId/OfficeName` (open site * assignment), `Email` (User navigation). Declared as data, the projection is * GENERATED (scaffold-business `projectField`), the TS interface carries the * member (lib/field-read-surface: derived = read-only), and DEV-UI-033 stops * flagging the column as an unresolved FK. * * v1 sources (closes all four workarounds): * - `nav` — one navigation hop, one property; * - `child` — one child collection, one selected property, picked by * `latest()` (most recent row) or `open-period()` * (the row whose end date is null). * v2 (`derived-aggregates`, a named follow-up): aggregates (count/sum), * multi-hop traversal, server sort/search on derived columns. */ import { z } from 'zod' export const DerivedNavSchema = z.object({ kind: z.literal('nav'), /** Navigation property on the entity (`User`). */ nav: z.string().min(1), /** SINGLE property on the target (`Email`) — no traversal in v1. */ property: z.string().min(1), /** FK column guarding the hop — defaults to `{nav}Id`. */ fkField: z.string().min(1).optional(), }) export const DerivedChildSchema = z.object({ kind: z.literal('child'), /** Child collection navigation on the entity (`Registrations`). */ collection: z.string().min(1), /** Property selected on the child (`PlateNumber`). */ select: z.string().min(1), pick: z.discriminatedUnion('mode', [ z.object({ mode: z.literal('latest'), dateField: z.string().min(1) }), z.object({ mode: z.literal('open-period'), endField: z.string().min(1) }), ]), }) export const DerivedFieldSchema = z.discriminatedUnion('kind', [DerivedNavSchema, DerivedChildSchema]) export type DerivedField = z.infer const CS_TYPE: Record = { string: 'string', int: 'int', integer: 'int', number: 'decimal', decimal: 'decimal', bool: 'bool', boolean: 'bool', datetime: 'DateTime', date: 'DateOnly', guid: 'Guid', } /** * The EF-translatable C# projection for a derived field, over the query * variable `x`. Value types surface as NULLABLE (`(T?)`) so "no open period / * no reading yet" reads null, never default(T)'s misleading zero. * * `fkNullable(fkField)` tells whether a nav hop's FK column is optional — * a nullable FK guards the hop (`x.UserId != null ? … : null`). */ export function derivedExpr( fieldType: string, derived: DerivedField, fkNullable: (fkField: string) => boolean, ): string { const base = CS_TYPE[fieldType.toLowerCase()] ?? fieldType const cast = base === 'string' ? '' : `(${base}?)` if (derived.kind === 'nav') { const fk = derived.fkField ?? `${derived.nav}Id` const read = `x.${derived.nav}!.${derived.property}` return fkNullable(fk) ? `(x.${fk} != null ? ${cast === '' ? '' : cast}${read} : null)` : `x.${derived.nav}.${derived.property}` } const select = `.Select(c => ${cast}c.${derived.select}).FirstOrDefault()` return derived.pick.mode === 'latest' ? `x.${derived.collection}.OrderByDescending(c => c.${derived.pick.dateField})${select}` : `x.${derived.collection}.Where(c => c.${derived.pick.endField} == null)${select}` }