@metaobjectsdev/codegen-ts 0.15.21-rc.1 → 0.16.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/column-mapper.d.ts +9 -0
- package/dist/column-mapper.d.ts.map +1 -1
- package/dist/column-mapper.js +8 -2
- package/dist/column-mapper.js.map +1 -1
- package/dist/generators/api-doc-render.d.ts.map +1 -1
- package/dist/generators/api-doc-render.js +8 -4
- package/dist/generators/api-doc-render.js.map +1 -1
- package/dist/generators/api-model.js +4 -4
- package/dist/generators/api-model.js.map +1 -1
- package/dist/projection/build-projection-views.d.ts +36 -1
- package/dist/projection/build-projection-views.d.ts.map +1 -1
- package/dist/projection/build-projection-views.js +87 -1
- package/dist/projection/build-projection-views.js.map +1 -1
- package/dist/templates/queries-file.js +1 -1
- package/dist/templates/queries-file.js.map +1 -1
- package/dist/templates/queries.d.ts.map +1 -1
- package/dist/templates/queries.js +14 -3
- package/dist/templates/queries.js.map +1 -1
- package/dist/templates/routes-file.js +9 -1
- package/dist/templates/routes-file.js.map +1 -1
- package/dist/templates/value-object-file.d.ts.map +1 -1
- package/dist/templates/value-object-file.js +8 -2
- package/dist/templates/value-object-file.js.map +1 -1
- package/dist/templates/zod-validators.d.ts.map +1 -1
- package/dist/templates/zod-validators.js +65 -26
- package/dist/templates/zod-validators.js.map +1 -1
- package/package.json +6 -6
- package/src/column-mapper.ts +8 -2
- package/src/generators/api-doc-render.ts +8 -4
- package/src/generators/api-model.ts +4 -4
- package/src/projection/build-projection-views.ts +126 -1
- package/src/reference/queries.ts +1 -1
- package/src/templates/queries-file.ts +1 -1
- package/src/templates/queries.ts +14 -3
- package/src/templates/routes-file.ts +9 -1
- package/src/templates/value-object-file.ts +8 -1
- package/src/templates/zod-validators.ts +61 -23
|
@@ -9,20 +9,43 @@
|
|
|
9
9
|
// drift / snapshot as the `views` input. This is the ONE place view SQL is produced.
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
|
+
type AggregateFunction,
|
|
12
13
|
type MetaData,
|
|
14
|
+
MetaObject,
|
|
13
15
|
MetaRoot,
|
|
14
16
|
MetaSource,
|
|
15
17
|
SOURCE_KIND_VIEW,
|
|
18
|
+
TYPE_FIELD,
|
|
19
|
+
TYPE_IDENTITY,
|
|
20
|
+
TYPE_ORIGIN,
|
|
16
21
|
resolveTableName,
|
|
17
22
|
resolveTableSchema,
|
|
18
23
|
} from "@metaobjectsdev/metadata";
|
|
19
24
|
import { isProjection } from "./projection-detector.js";
|
|
20
25
|
import { extractViewSpec } from "./extract-view-spec.js";
|
|
21
26
|
import { emitViewDdl } from "./view-ddl-emit.js";
|
|
22
|
-
import type { JoinNode, JoinTree } from "./view-spec.js";
|
|
27
|
+
import type { JoinNode, JoinTree, ViewSpec } from "./view-spec.js";
|
|
23
28
|
import type { ColumnNamingStrategy } from "../metaobjects-config.js";
|
|
24
29
|
|
|
25
30
|
/** Structurally matches migrate-ts's `ViewDescriptor` (name + body sql + optional schema). */
|
|
31
|
+
/**
|
|
32
|
+
* One output column of the view, in SELECT order, described PHYSICALLY (table +
|
|
33
|
+
* column, not entity + field).
|
|
34
|
+
*
|
|
35
|
+
* migrate-ts resolves each of these to a `SqlType` against its own expected table
|
|
36
|
+
* descriptors. Deliberately no SqlType here: codegen-ts stays ignorant of migrate
|
|
37
|
+
* concerns, and migrate-ts stays ignorant of metadata traversal — the existing
|
|
38
|
+
* layering (migrate-ts never imports codegen-ts; the CLI threads views in).
|
|
39
|
+
*
|
|
40
|
+
* Postgres allows a non-destructive `CREATE OR REPLACE VIEW` only when the existing
|
|
41
|
+
* output columns are a PREFIX of the new ones (same names, same types, same order).
|
|
42
|
+
* That decision cannot be made without knowing the column list, which is why it is
|
|
43
|
+
* carried here.
|
|
44
|
+
*/
|
|
45
|
+
export type ExpectedViewColumn =
|
|
46
|
+
| { kind: "passthrough"; name: string; sourceTable: string; sourceColumn: string }
|
|
47
|
+
| { kind: "aggregate"; name: string; sourceTable: string; sourceColumn: string; agg: AggregateFunction };
|
|
48
|
+
|
|
26
49
|
export interface ExpectedView {
|
|
27
50
|
name: string;
|
|
28
51
|
schema?: string;
|
|
@@ -34,6 +57,15 @@ export interface ExpectedView {
|
|
|
34
57
|
* so the view must be dropped before and recreated after.
|
|
35
58
|
*/
|
|
36
59
|
dependsOn: string[];
|
|
60
|
+
/**
|
|
61
|
+
* The view's output columns, in SELECT order — i.e. DECLARATION order, since
|
|
62
|
+
* extractViewSpec walks the projection's children in order. That is exactly the
|
|
63
|
+
* order Postgres's OR-REPLACE prefix rule wants: a field appended to the
|
|
64
|
+
* projection lands last, so the change stays non-destructive. (Re-canonicalizing
|
|
65
|
+
* to, say, alphabetical order would be strictly WORSE — it would scatter an
|
|
66
|
+
* appended field into the middle and force a destructive drop+create.)
|
|
67
|
+
*/
|
|
68
|
+
columns: ExpectedViewColumn[];
|
|
37
69
|
}
|
|
38
70
|
|
|
39
71
|
export interface BuildProjectionViewsOptions {
|
|
@@ -71,27 +103,120 @@ export function buildProjectionViews(
|
|
|
71
103
|
// through here silently created a PLAIN view under the matview's name.
|
|
72
104
|
// Matviews are hand-managed, like the documented custom-SQL-view
|
|
73
105
|
// exception: migrate neither creates nor drops them.
|
|
106
|
+
// - a STANDALONE read-model — a plain view projection declaring its own
|
|
107
|
+
// columns, with no origin.* children — is hand-authored SQL too. Codegen
|
|
108
|
+
// already treats it that way on purpose (see projection-decl.ts: "lets a
|
|
109
|
+
// standalone read-only view-entity — explicit columns, no `extends` —
|
|
110
|
+
// generate its read model … standalone views hand-author their SQL").
|
|
111
|
+
// The SCHEMA path never got that memo: extractViewSpec THREW on it
|
|
112
|
+
// ("cannot derive the base entity"), and because the CLI calls this
|
|
113
|
+
// function unconditionally, ONE such projection aborted `meta migrate`
|
|
114
|
+
// for the ENTIRE model — every other entity included. Same crash class as
|
|
115
|
+
// the proc projections above. Skip it instead.
|
|
74
116
|
// ADR-0039: own — mirrors isProjection/viewName's own-source classification.
|
|
75
117
|
const readOnlySource = projection.ownChildren().find(
|
|
76
118
|
(c): c is MetaSource => c instanceof MetaSource && c.isReadOnly(),
|
|
77
119
|
);
|
|
78
120
|
if (readOnlySource?.effectiveKind !== SOURCE_KIND_VIEW) continue;
|
|
121
|
+
if (!viewIsDerived(projection)) continue;
|
|
79
122
|
const spec = extractViewSpec(projection, root, { columnNamingStrategy });
|
|
80
123
|
const baseTableName = joinTables[spec.joinTree.baseEntity];
|
|
81
124
|
if (!baseTableName) continue; // unresolved base — skip (loader/codegen surface the error elsewhere)
|
|
82
125
|
const body = emitViewDdl(spec, { dialect, baseTableName, joinTables, bodyOnly: true });
|
|
83
126
|
const schema = resolveTableSchema(projection);
|
|
84
127
|
const dependsOn = collectDependsOn(spec.joinTree, baseTableName, joinTables);
|
|
128
|
+
const columns = collectViewColumns(spec, baseTableName, joinTables);
|
|
85
129
|
out.push({
|
|
86
130
|
name: spec.viewName,
|
|
87
131
|
sql: body,
|
|
88
132
|
dependsOn,
|
|
133
|
+
columns,
|
|
89
134
|
...(schema !== undefined ? { schema } : {}),
|
|
90
135
|
});
|
|
91
136
|
}
|
|
92
137
|
return out;
|
|
93
138
|
}
|
|
94
139
|
|
|
140
|
+
/**
|
|
141
|
+
* The SELECT list as PHYSICAL (table, column) pairs, in emitted order.
|
|
142
|
+
*
|
|
143
|
+
* SelectColumn carries a join ALIAS, which means nothing outside this module — so
|
|
144
|
+
* resolve every alias to its physical table first.
|
|
145
|
+
*/
|
|
146
|
+
function collectViewColumns(
|
|
147
|
+
spec: ViewSpec,
|
|
148
|
+
baseTableName: string,
|
|
149
|
+
joinTables: Readonly<Record<string, string>>,
|
|
150
|
+
): ExpectedViewColumn[] {
|
|
151
|
+
const aliasToTable = new Map<string, string>([[spec.joinTree.baseAlias, baseTableName]]);
|
|
152
|
+
const walk = (node: JoinNode): void => {
|
|
153
|
+
const t = joinTables[node.targetEntity];
|
|
154
|
+
if (t) aliasToTable.set(node.alias, t);
|
|
155
|
+
for (const child of node.children) walk(child);
|
|
156
|
+
};
|
|
157
|
+
for (const j of spec.joinTree.joins) walk(j);
|
|
158
|
+
|
|
159
|
+
const out: ExpectedViewColumn[] = [];
|
|
160
|
+
for (const c of spec.selectSpec.columns) {
|
|
161
|
+
const sourceTable = aliasToTable.get(c.sourceAlias);
|
|
162
|
+
// An unresolvable alias would make the column list a lie, and a wrong list would
|
|
163
|
+
// make the diff propose an ILLEGAL `CREATE OR REPLACE VIEW` that fails at apply.
|
|
164
|
+
// Drop the whole list instead: migrate-ts treats an absent list as "unknown" and
|
|
165
|
+
// fails safe to a gated drop+create.
|
|
166
|
+
if (sourceTable === undefined) return [];
|
|
167
|
+
out.push(
|
|
168
|
+
c.kind === "aggregate"
|
|
169
|
+
? { kind: "aggregate", name: c.dbColAlias, sourceTable, sourceColumn: c.sourceColumn, agg: c.agg }
|
|
170
|
+
: { kind: "passthrough", name: c.dbColAlias, sourceTable, sourceColumn: c.sourceColumn },
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Is this projection's view DERIVED from the model — i.e. does migrate own its DDL?
|
|
178
|
+
*
|
|
179
|
+
* A derived view is synthesized from a BASE entity, and the base is anchored by an
|
|
180
|
+
* `extends` binding on the projection's own identity or one of its own fields (that is
|
|
181
|
+
* exactly what `baseEntityFor` resolves). So:
|
|
182
|
+
*
|
|
183
|
+
* - ANCHORED (any own identity/field carries `extends`) → derived. Migrate generates
|
|
184
|
+
* and owns the view. If the anchor is ambiguous, extractViewSpec throws — correctly,
|
|
185
|
+
* that is a real authoring error.
|
|
186
|
+
*
|
|
187
|
+
* - NOT anchored, but carries `origin.*` children → the author clearly MEANT a derived
|
|
188
|
+
* view (an origin says "this column comes from that entity's column") but gave it
|
|
189
|
+
* nothing to derive FROM. That is a malformed projection: fall through to
|
|
190
|
+
* extractViewSpec so it throws its actionable message ("declare an extends-bound
|
|
191
|
+
* identity to anchor the base"). Do NOT silently skip it.
|
|
192
|
+
*
|
|
193
|
+
* - NOT anchored and NO origins → a STANDALONE read-model: it declares its own columns
|
|
194
|
+
* and says nothing about where they come from, so there is nothing to synthesize a
|
|
195
|
+
* body from. Its SQL is hand-authored. Codegen already treats this shape as
|
|
196
|
+
* legitimate and intended (projection-decl.ts generates its read model and notes
|
|
197
|
+
* "standalone views hand-author … their SQL") — the schema path simply never agreed,
|
|
198
|
+
* and threw, which aborted `meta migrate` for the WHOLE model.
|
|
199
|
+
*
|
|
200
|
+
* Trade-off, stated plainly: a hand-authored view is UNMANAGED, so `meta verify --db`
|
|
201
|
+
* cannot drift-check it. That is the same bounded exception the docs already carve out
|
|
202
|
+
* for custom SQL views and matviews — not a new hole. What changes is only that ONE
|
|
203
|
+
* such view no longer blocks migration of every other entity in the tree.
|
|
204
|
+
*
|
|
205
|
+
* ADR-0039: own — a projection's derivation is declared locally; an inherited extends or
|
|
206
|
+
* origin belongs to the parent's own view.
|
|
207
|
+
*/
|
|
208
|
+
function viewIsDerived(projection: MetaObject): boolean {
|
|
209
|
+
const own = projection.ownChildren();
|
|
210
|
+
const anchored = own.some(
|
|
211
|
+
(c) => (c.type === TYPE_IDENTITY || c.type === TYPE_FIELD) && c.superRef !== undefined,
|
|
212
|
+
);
|
|
213
|
+
if (anchored) return true;
|
|
214
|
+
// Origins without an anchor = malformed, not standalone. Let extractViewSpec say so.
|
|
215
|
+
return own.some(
|
|
216
|
+
(f) => f.type === TYPE_FIELD && f.ownChildren().some((c) => c.type === TYPE_ORIGIN),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
95
220
|
/** The base table plus every joined table, deduped — the physical tables the view reads. */
|
|
96
221
|
function collectDependsOn(
|
|
97
222
|
joinTree: JoinTree,
|
package/src/reference/queries.ts
CHANGED
|
@@ -70,7 +70,7 @@ function renderQueries(obj: MetaObject, ctx: RenderContext): string {
|
|
|
70
70
|
${dbTypeImport}
|
|
71
71
|
${dbTypeAlias}
|
|
72
72
|
|
|
73
|
-
import { ${varName}, type ${entityName}, ${entityName}InsertSchema } from ${JSON.stringify(entityFileName)};
|
|
73
|
+
import { ${varName}, type ${entityName}, type ${entityName}Patch, ${entityName}InsertSchema, ${entityName}UpdateSchema } from ${JSON.stringify(entityFileName)};
|
|
74
74
|
`;
|
|
75
75
|
|
|
76
76
|
const sections: Code[] = [
|
|
@@ -80,7 +80,7 @@ export function renderQueriesFile(obj: MetaObject, ctx: RenderContext): string {
|
|
|
80
80
|
${dbTypeImport}
|
|
81
81
|
${dbTypeAlias}
|
|
82
82
|
|
|
83
|
-
import { ${varName}, type ${entityName}, ${entityName}InsertSchema } from ${JSON.stringify(entityFileName)};
|
|
83
|
+
import { ${varName}, type ${entityName}, type ${entityName}Patch, ${entityName}InsertSchema, ${entityName}UpdateSchema } from ${JSON.stringify(entityFileName)};
|
|
84
84
|
`;
|
|
85
85
|
|
|
86
86
|
const sections: Code[] = [
|
package/src/templates/queries.ts
CHANGED
|
@@ -91,12 +91,23 @@ export function renderUpdateFn(entity: MetaObject, ctx: RenderContext): Code {
|
|
|
91
91
|
const singularVar = entityName.charAt(0).toLowerCase() + entityName.slice(1);
|
|
92
92
|
const { fieldName: pkField, tsType: pkType } = getPkInfo(entity, ctx);
|
|
93
93
|
const fnName = updateFnName(entityName);
|
|
94
|
-
const
|
|
94
|
+
const findByIdFn = findByIdFnName(entityName);
|
|
95
|
+
// PATCH contract (FR-035): validate the caller's assignments against the
|
|
96
|
+
// UPDATE schema (all-optional, PK/@readOnly excluded, no insert-time transforms
|
|
97
|
+
// like @autoSet-onCreate → now() or the InsertSchema's discriminator handling)
|
|
98
|
+
// — NOT `InsertSchema.partial()`. The typed `<Entity>Patch` param makes a
|
|
99
|
+
// renamed/dropped field a compile error at every call site (PATCH-1..4);
|
|
100
|
+
// `.set()` writes ONLY the assigned columns, so an omitted field is untouched.
|
|
101
|
+
const updateSchemaName = `${entityName}UpdateSchema`;
|
|
102
|
+
const patchType = `${entityName}Patch`;
|
|
95
103
|
const eqSym = imp("eq@drizzle-orm");
|
|
96
104
|
|
|
97
105
|
return code`
|
|
98
|
-
export async function ${fnName}(db: Db, ${pkField}: ${pkType},
|
|
99
|
-
const validated = ${
|
|
106
|
+
export async function ${fnName}(db: Db, ${pkField}: ${pkType}, patch: ${patchType}): Promise<${entityName} | null> {
|
|
107
|
+
const validated = ${updateSchemaName}.parse(patch);
|
|
108
|
+
// PATCH-5: an empty patch is a no-op — return the current row rather than let
|
|
109
|
+
// Drizzle throw on an empty SET clause.
|
|
110
|
+
if (Object.keys(validated).length === 0) return ${findByIdFn}(db, ${pkField});
|
|
100
111
|
const [${singularVar}] = await db.update(${varName}).set(validated).where(${eqSym}(${varName}.${pkField}, ${pkField})).returning();
|
|
101
112
|
return ${singularVar} ?? null;
|
|
102
113
|
}
|
|
@@ -352,6 +352,14 @@ function renderTphRoutesFile(base: MetaObject, ctx: RenderContext): string {
|
|
|
352
352
|
ctx.selfTarget, ctx.entityModuleTarget, sub.package, sub.name, ctx.extStyle,
|
|
353
353
|
);
|
|
354
354
|
const subInsertSym = imp(`${sub.name}InsertSchema@${subFileSpec}`);
|
|
355
|
+
// FR-036 Program B: the per-subtype UPDATE must carry the FR-035 present-key
|
|
356
|
+
// tristate (a non-@required subtype column accepts an explicit null → clears;
|
|
357
|
+
// a @required column's explicit null is rejected). The subtype's own
|
|
358
|
+
// UpdateSchema encodes exactly that (.optional() + .nullable() for non-required),
|
|
359
|
+
// whereas insertSchema.partial() only makes fields optional, not nullable — so a
|
|
360
|
+
// PATCH {col: null} on a nullable subtype column wrongly 400'd. The base
|
|
361
|
+
// polymorphic mount already uses the base UpdateSchema; this aligns the subtypes.
|
|
362
|
+
const subUpdateSym = imp(`${sub.name}UpdateSchema@${subFileSpec}`);
|
|
355
363
|
// FR-017 Tier 3: each subtype carries its OWN filter/sort allowlist
|
|
356
364
|
// (discriminator excluded — it's pinned by this path).
|
|
357
365
|
const subFilterSym = imp(`${sub.name}FilterAllowlist@${subFileSpec}`);
|
|
@@ -363,7 +371,7 @@ function renderTphRoutesFile(base: MetaObject, ctx: RenderContext): string {
|
|
|
363
371
|
db: ${dbSym},
|
|
364
372
|
table: ${tableSym},
|
|
365
373
|
insertSchema: ${subInsertSym}.omit({ ${discField}: true }),
|
|
366
|
-
updateSchema: ${
|
|
374
|
+
updateSchema: ${subUpdateSym},
|
|
367
375
|
filterAllowlist: ${subFilterSym},
|
|
368
376
|
sortAllowlist: ${subSortSym},
|
|
369
377
|
dialect: ${dialectLit},
|
|
@@ -14,6 +14,7 @@ import type { RenderContext } from "../render-context.js";
|
|
|
14
14
|
import { renderValueObjectInterface, renderEnumTypeAliases } from "./inferred-types.js";
|
|
15
15
|
import {
|
|
16
16
|
renderInsertSchemaOnly,
|
|
17
|
+
renderZodValidators,
|
|
17
18
|
isTphSubtype,
|
|
18
19
|
renderTphSubtypeReadSchema,
|
|
19
20
|
tphDiscriminatorPin,
|
|
@@ -49,7 +50,13 @@ export function renderValueObjectFile(obj: MetaObject, apiPrefix = "", ctx?: Ren
|
|
|
49
50
|
renderValueObjectInterface(obj, ctx),
|
|
50
51
|
...(enumAliases !== null ? [enumAliases] : []),
|
|
51
52
|
...(tphReadSchema !== null ? [tphReadSchema] : []),
|
|
52
|
-
|
|
53
|
+
// FR-036 Program B: a TPH subtype needs BOTH schemas — its per-subtype PATCH
|
|
54
|
+
// route must carry the FR-035 present-key tristate (a non-@required subtype
|
|
55
|
+
// column accepts an explicit null → clears), which lives in the UpdateSchema
|
|
56
|
+
// (.nullable() for non-required). renderZodValidators emits <Sub>InsertSchema +
|
|
57
|
+
// <Sub>UpdateSchema (the update omits the pinned discriminator). A pure value
|
|
58
|
+
// object stays insert-only (no PATCH semantics — an UpdateSchema would mislead).
|
|
59
|
+
tphSubtype ? renderZodValidators(obj, ctx) : renderInsertSchemaOnly(obj, ctx),
|
|
53
60
|
...(tphConstants !== null ? [tphConstants] : []),
|
|
54
61
|
...(tphFilterAllowlist !== null ? [tphFilterAllowlist] : []),
|
|
55
62
|
...(tphSortAllowlist !== null ? [tphSortAllowlist] : []),
|
|
@@ -36,6 +36,9 @@ import { sharedEnumImportSpecifier } from "../enum-import.js";
|
|
|
36
36
|
import { sharedEnumZodConstName } from "./enums-file.js";
|
|
37
37
|
import type { RenderContext } from "../render-context.js";
|
|
38
38
|
import { valueObjectModuleSpecifier } from "../import-path.js";
|
|
39
|
+
// FR-035: the SAME required-predicate that drives the Drizzle column's .notNull()
|
|
40
|
+
// drives the UpdateSchema's .nullable() exclusion — shared so they cannot drift.
|
|
41
|
+
import { isRequired } from "../column-mapper.js";
|
|
39
42
|
|
|
40
43
|
/**
|
|
41
44
|
* FR-017 Tier 1 — when this object is a TPH subtype (@discriminatorValue set
|
|
@@ -109,19 +112,34 @@ ${joinCode(fieldLines, { on: ",\n" })}
|
|
|
109
112
|
`;
|
|
110
113
|
}
|
|
111
114
|
|
|
115
|
+
/** Field names participating in the object's PRIMARY identity, normalized to a
|
|
116
|
+
* list. Empty when the object has no primary identity. */
|
|
117
|
+
function primaryIdentityFieldNames(obj: MetaObject): string[] {
|
|
118
|
+
const primary = obj.primaryIdentity();
|
|
119
|
+
if (!primary) return [];
|
|
120
|
+
const fields = primary.attr(IDENTITY_ATTR_FIELDS);
|
|
121
|
+
if (Array.isArray(fields)) return fields.map(String);
|
|
122
|
+
if (typeof fields === "string") return [fields];
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
|
|
112
126
|
/** Auto-generated PK field names that should be omitted from InsertSchema. */
|
|
113
127
|
function autoGenPkFieldNames(obj: MetaObject): Set<string> {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
128
|
+
const generation = obj.primaryIdentity()?.attr(IDENTITY_ATTR_GENERATION);
|
|
129
|
+
if (generation !== GENERATION_INCREMENT && generation !== GENERATION_UUID) return new Set();
|
|
130
|
+
return new Set(primaryIdentityFieldNames(obj));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** ALL field names participating in the object's PRIMARY identity, regardless of
|
|
134
|
+
* @generation. A PK column is never NULL (single-col via Drizzle `.primaryKey()`,
|
|
135
|
+
* composite via `.notNull()`), so FR-035 PATCH-2 must NOT make a PK field
|
|
136
|
+
* `.nullable()` in the UpdateSchema even when it carries no @required — otherwise
|
|
137
|
+
* an explicit null typechecks into Drizzle's `.set()` (which rejects null on a
|
|
138
|
+
* not-null column) and would violate NOT NULL at runtime. Unlike
|
|
139
|
+
* autoGenPkFieldNames (which only lists increment/uuid PKs, already excluded from
|
|
140
|
+
* the schema), this covers assigned + extended-identity projection PKs too. */
|
|
141
|
+
function primaryKeyFieldNames(obj: MetaObject): Set<string> {
|
|
142
|
+
return new Set(primaryIdentityFieldNames(obj));
|
|
125
143
|
}
|
|
126
144
|
|
|
127
145
|
/**
|
|
@@ -259,6 +277,7 @@ export function updateSchemaFields(obj: MetaObject): SchemaFieldShape[] {
|
|
|
259
277
|
export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code {
|
|
260
278
|
const z = imp("z@zod");
|
|
261
279
|
const autoGenPkFields = autoGenPkFieldNames(obj);
|
|
280
|
+
const pkFields = primaryKeyFieldNames(obj);
|
|
262
281
|
const tphPin = tphDiscriminatorPin(obj);
|
|
263
282
|
|
|
264
283
|
const insertFieldLines: Code[] = [];
|
|
@@ -305,9 +324,16 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code
|
|
|
305
324
|
// zodFieldExpr already appends .optional() when the field is non-required
|
|
306
325
|
// OR has a default; only append once more when it didn't.
|
|
307
326
|
const baseExpr = zodFieldExpr(child, obj, ctx);
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
)
|
|
327
|
+
let expr = fieldWillBeOptional(child) ? baseExpr : code`${baseExpr}.optional()`;
|
|
328
|
+
// FR-035 PATCH-2: a NON-@required field additionally accepts an explicit
|
|
329
|
+
// null — a present null CLEARS the column (`.set({field: null})` writes NULL).
|
|
330
|
+
// Keyed on required-ness, NOT fieldWillBeOptional: a required-with-@default
|
|
331
|
+
// field stays non-nullable so an explicit null on it is a 400 (validation),
|
|
332
|
+
// never a silent clear. PK fields are excluded: their Drizzle column is
|
|
333
|
+
// always not-null, so a nullable Zod type would fail `.set()`'s typecheck
|
|
334
|
+
// (and NOT NULL at runtime) — see primaryKeyFieldNames.
|
|
335
|
+
if (!isRequired(child) && !pkFields.has(child.name)) expr = code`${expr}.nullable()`;
|
|
336
|
+
updateFieldLines.push(code` ${child.name}: ${expr}`);
|
|
311
337
|
}
|
|
312
338
|
}
|
|
313
339
|
|
|
@@ -325,6 +351,10 @@ ${joinCode(insertFieldLines, { on: ",\n" })}
|
|
|
325
351
|
${docsPrefix}export const ${updateSchemaName} = ${z}.object({
|
|
326
352
|
${joinCode(updateFieldLines, { on: ",\n" })}
|
|
327
353
|
});
|
|
354
|
+
|
|
355
|
+
/** Typed patch shape for ${obj.name}: every settable field, optional (FR-035 PATCH). A
|
|
356
|
+
* renamed/dropped field is a compile error at every \`update${obj.name}\` call site. */
|
|
357
|
+
export type ${obj.name}Patch = ${z}.input<typeof ${updateSchemaName}>;
|
|
328
358
|
`;
|
|
329
359
|
}
|
|
330
360
|
|
|
@@ -488,12 +518,8 @@ function zodFieldExpr(field: MetaField, owner?: MetaObject, ctx?: RenderContext)
|
|
|
488
518
|
/** Mirrors the optional-or-not decision inside appendValidatorChain so the update-schema
|
|
489
519
|
* caller can avoid stacking a second `.optional()` onto an already-optional expression. */
|
|
490
520
|
function fieldWillBeOptional(field: MetaField): boolean {
|
|
491
|
-
let isRequired = field.attr(FIELD_ATTR_REQUIRED) === true;
|
|
492
|
-
for (const child of field.validators()) {
|
|
493
|
-
if (child.subType === VALIDATOR_SUBTYPE_REQUIRED) isRequired = true;
|
|
494
|
-
}
|
|
495
521
|
const hasDefault = field.attr(FIELD_ATTR_DEFAULT) !== undefined;
|
|
496
|
-
return !isRequired || hasDefault;
|
|
522
|
+
return !isRequired(field) || hasDefault;
|
|
497
523
|
}
|
|
498
524
|
|
|
499
525
|
/** Numeric field subtypes whose Zod base is `z.number()` — value bounds apply. */
|
|
@@ -525,7 +551,10 @@ function appendValidatorChain(base: Code, field: MetaField): Code {
|
|
|
525
551
|
if (child.subType === VALIDATOR_SUBTYPE_LENGTH) {
|
|
526
552
|
const max = child.attr(VALIDATOR_ATTR_MAX);
|
|
527
553
|
const min = child.attr(VALIDATOR_ATTR_MIN);
|
|
528
|
-
|
|
554
|
+
// FR-036 A3: @maxLength × validator.length @max = strictest-wins (min).
|
|
555
|
+
// maxLen already carries the field-level @maxLength; fold the validator's
|
|
556
|
+
// @max in as a lower bound so the effective cap is min(@maxLength, @max).
|
|
557
|
+
if (typeof max === "number") maxLen = maxLen === undefined ? max : Math.min(maxLen, max);
|
|
529
558
|
if (typeof min === "number") minLen = min;
|
|
530
559
|
}
|
|
531
560
|
if (child.subType === VALIDATOR_SUBTYPE_REGEX) {
|
|
@@ -559,10 +588,19 @@ function appendValidatorChain(base: Code, field: MetaField): Code {
|
|
|
559
588
|
if (arrMin !== undefined) chain = code`${chain}.min(${arrMin})`;
|
|
560
589
|
if (arrMax !== undefined) chain = code`${chain}.max(${arrMax})`;
|
|
561
590
|
} else if (field.subType === FIELD_SUBTYPE_STRING && !isJsonbBag) {
|
|
562
|
-
|
|
563
|
-
|
|
591
|
+
// FR-036 Pin 1: a @required string is non-empty. The floor is max(@min, 1) so an
|
|
592
|
+
// explicit `validator.length @min: 0` on a required field can't suppress it to
|
|
593
|
+
// `.min(0)` (which would accept ""). A non-required field keeps its authored @min.
|
|
594
|
+
const effectiveMin = Math.max(minLen ?? 0, isRequired ? 1 : 0);
|
|
595
|
+
if (effectiveMin > 0) chain = code`${chain}.min(${effectiveMin})`;
|
|
564
596
|
if (maxLen !== undefined) chain = code`${chain}.max(${maxLen})`;
|
|
565
|
-
if (pattern !== undefined)
|
|
597
|
+
if (pattern !== undefined) {
|
|
598
|
+
// FR-036 Pin 2: validator.regex @pattern is FULL-MATCH — the whole value
|
|
599
|
+
// must match. JS RegExp.test searches, so anchor as ^(?:…)$ (always-wrap;
|
|
600
|
+
// a redundant anchor on an already-anchored pattern still matches identically).
|
|
601
|
+
const anchored = `^(?:${pattern})$`;
|
|
602
|
+
chain = code`${chain}.regex(new RegExp(${JSON.stringify(anchored)}))`;
|
|
603
|
+
}
|
|
566
604
|
} else if (NUMERIC_FIELD_SUBTYPES.has(field.subType)) {
|
|
567
605
|
if (numMin !== undefined) chain = code`${chain}.min(${numMin})`;
|
|
568
606
|
if (numMax !== undefined) chain = code`${chain}.max(${numMax})`;
|