@metaobjectsdev/codegen-ts 0.16.0 → 0.17.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 +10 -0
- package/dist/column-mapper.d.ts.map +1 -1
- package/dist/column-mapper.js +21 -2
- package/dist/column-mapper.js.map +1 -1
- package/dist/naming.d.ts +4 -0
- package/dist/naming.d.ts.map +1 -1
- package/dist/naming.js +6 -0
- package/dist/naming.js.map +1 -1
- package/dist/projection/build-projection-views.d.ts +5 -1
- package/dist/projection/build-projection-views.d.ts.map +1 -1
- package/dist/projection/build-projection-views.js +165 -47
- package/dist/projection/build-projection-views.js.map +1 -1
- package/dist/projection/extract-view-spec.d.ts +8 -1
- package/dist/projection/extract-view-spec.d.ts.map +1 -1
- package/dist/projection/extract-view-spec.js +430 -29
- package/dist/projection/extract-view-spec.js.map +1 -1
- package/dist/projection/view-ddl-emit.d.ts.map +1 -1
- package/dist/projection/view-ddl-emit.js +142 -25
- package/dist/projection/view-ddl-emit.js.map +1 -1
- package/dist/projection/view-spec.d.ts +101 -3
- package/dist/projection/view-spec.d.ts.map +1 -1
- package/dist/templates/drizzle-schema.d.ts.map +1 -1
- package/dist/templates/drizzle-schema.js +9 -0
- package/dist/templates/drizzle-schema.js.map +1 -1
- package/dist/templates/entity-file.d.ts.map +1 -1
- package/dist/templates/entity-file.js +39 -3
- package/dist/templates/entity-file.js.map +1 -1
- package/dist/templates/inferred-types.d.ts +1 -1
- package/dist/templates/inferred-types.d.ts.map +1 -1
- package/dist/templates/inferred-types.js +13 -1
- package/dist/templates/inferred-types.js.map +1 -1
- package/dist/templates/projection-decl.d.ts.map +1 -1
- package/dist/templates/projection-decl.js +9 -70
- package/dist/templates/projection-decl.js.map +1 -1
- package/dist/templates/queries-file.d.ts.map +1 -1
- package/dist/templates/queries-file.js +117 -43
- package/dist/templates/queries-file.js.map +1 -1
- package/dist/templates/queries.d.ts +21 -4
- package/dist/templates/queries.d.ts.map +1 -1
- package/dist/templates/queries.js +48 -12
- package/dist/templates/queries.js.map +1 -1
- package/dist/templates/view-decl.d.ts +28 -0
- package/dist/templates/view-decl.d.ts.map +1 -0
- package/dist/templates/view-decl.js +107 -0
- package/dist/templates/view-decl.js.map +1 -0
- package/dist/templates/zod-validators.d.ts +6 -0
- package/dist/templates/zod-validators.d.ts.map +1 -1
- package/dist/templates/zod-validators.js +53 -7
- package/dist/templates/zod-validators.js.map +1 -1
- package/package.json +6 -6
- package/src/column-mapper.ts +26 -1
- package/src/naming.ts +7 -0
- package/src/projection/build-projection-views.ts +211 -50
- package/src/projection/extract-view-spec.ts +468 -29
- package/src/projection/view-ddl-emit.ts +158 -24
- package/src/projection/view-spec.ts +104 -3
- package/src/reference/entity.ts +11 -1
- package/src/reference/queries.ts +4 -1
- package/src/templates/drizzle-schema.ts +7 -0
- package/src/templates/entity-file.ts +46 -3
- package/src/templates/inferred-types.ts +18 -1
- package/src/templates/projection-decl.ts +8 -74
- package/src/templates/queries-file.ts +133 -48
- package/src/templates/queries.ts +50 -11
- package/src/templates/view-decl.ts +128 -0
- package/src/templates/zod-validators.ts +54 -9
|
@@ -10,18 +10,17 @@
|
|
|
10
10
|
import { code, imp, joinCode, type Code } from "ts-poet";
|
|
11
11
|
import {
|
|
12
12
|
MetaField, MetaObject, type MetaRoot,
|
|
13
|
-
FIELD_SUBTYPE_OBJECT, FIELD_ATTR_OBJECT_REF, stripPackage,
|
|
14
13
|
} from "@metaobjectsdev/metadata";
|
|
15
14
|
import { projectionViewName } from "../projection/extract-view-spec.js";
|
|
16
15
|
import { columnNameFromField, toSnakeCase, pluralize } from "../naming.js";
|
|
17
16
|
import { GENERATED_HEADER } from "../constants.js";
|
|
18
17
|
import type { ColumnNamingStrategy } from "../metaobjects-config.js";
|
|
19
18
|
import type { RenderContext } from "../render-context.js";
|
|
20
|
-
import { mapColumnType } from "../column-mapper.js";
|
|
21
19
|
import { valueObjectModuleSpecifier } from "../import-path.js";
|
|
22
20
|
import { renderFilterAllowlist, renderSortAllowlist } from "./filter-allowlist.js";
|
|
23
21
|
import { renderFilterType } from "./filter-type.js";
|
|
24
|
-
import { inferViewKind,
|
|
22
|
+
import { inferViewKind, currencyMetaFor, labelFor } from "./field-meta.js";
|
|
23
|
+
import { renderExistingViewDecl, renderViewReadZodObject } from "./view-decl.js";
|
|
25
24
|
|
|
26
25
|
// ---------------------------------------------------------------------------
|
|
27
26
|
// Public interface
|
|
@@ -100,16 +99,7 @@ export function renderProjectionDecl(
|
|
|
100
99
|
ctx
|
|
101
100
|
? valueObjectModuleSpecifier(refBase, ctx.packageOf, projection.package, ctx.outputLayout, ctx.extStyle)
|
|
102
101
|
: `./${refBase}.js`;
|
|
103
|
-
const objectRefOf = (f: MetaField): string | undefined => {
|
|
104
|
-
if (f.subType !== FIELD_SUBTYPE_OBJECT) return undefined;
|
|
105
|
-
const ref = f.attr(FIELD_ATTR_OBJECT_REF);
|
|
106
|
-
return typeof ref === "string" && ref.length > 0 ? stripPackage(ref) : undefined;
|
|
107
|
-
};
|
|
108
102
|
|
|
109
|
-
const viewFn = dialect === "postgres" ? "pgView" : "sqliteView";
|
|
110
|
-
const viewModule =
|
|
111
|
-
dialect === "postgres" ? "drizzle-orm/pg-core" : "drizzle-orm/sqlite-core";
|
|
112
|
-
const viewSym = imp(`${viewFn}@${viewModule}`);
|
|
113
103
|
const z = imp("z@zod");
|
|
114
104
|
|
|
115
105
|
// Read-model generation needs only the view name — NOT the join/DDL
|
|
@@ -136,57 +126,6 @@ export function renderProjectionDecl(
|
|
|
136
126
|
// inherited fields are not duplicated.
|
|
137
127
|
for (const f of projection.ownFields()) allFields.push(f);
|
|
138
128
|
|
|
139
|
-
// A field.object passthrough carries the value-object's Zod schema (so the
|
|
140
|
-
// view's read schema + inferred type expose the VO shape, not z.unknown()).
|
|
141
|
-
const zodLines: Code[] = allFields.map((f) => {
|
|
142
|
-
// The read schema's nullability MUST mirror the view column's: a column that
|
|
143
|
-
// is not `.notNull()` infers `T | null` in Drizzle's SELECT type, so the Zod
|
|
144
|
-
// read type (and thus the generated query's return type) must be `.nullable()`
|
|
145
|
-
// too — otherwise `db.select().from(view)` yields `T | null` into a non-null
|
|
146
|
-
// `<Name>` field and the generated query fails to compile under strict TS.
|
|
147
|
-
const nullable =
|
|
148
|
-
mapColumnType(f, dialect, columnNamingStrategy, timestampMode)
|
|
149
|
-
.modifiers.includes(".notNull()")
|
|
150
|
-
? ""
|
|
151
|
-
: ".nullable()";
|
|
152
|
-
const refBase = objectRefOf(f);
|
|
153
|
-
if (refBase) {
|
|
154
|
-
const schemaSym = imp(`${refBase}InsertSchema@${voModule(refBase)}`);
|
|
155
|
-
const base = f.resolvedIsArray()
|
|
156
|
-
? code`${z}.array(${schemaSym})`
|
|
157
|
-
: code`${schemaSym}`;
|
|
158
|
-
return code` ${f.name}: ${base}${nullable}`;
|
|
159
|
-
}
|
|
160
|
-
return code` ${f.name}: ${z}.${zodTypeFor(f).replace(/^z\./, "")}${nullable}`;
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
// Typed view column map for the Drizzle `.existing()` declaration — keyed by
|
|
164
|
-
// projection field name, valued by the column builder for the (renamed)
|
|
165
|
-
// physical view column, so `db.select().from(<view>)` is typed. Honors
|
|
166
|
-
// `@dbColumnType` (e.g. a passthrough of a jsonb column). `.existing()` views
|
|
167
|
-
// carry type + physical name only — no PK/default/notNull modifiers.
|
|
168
|
-
const viewColumnLines: Code[] = allFields.map((f) => {
|
|
169
|
-
const spec = mapColumnType(f, dialect, columnNamingStrategy, timestampMode);
|
|
170
|
-
const colSym = imp(`${spec.fnName}@${spec.importModule}`);
|
|
171
|
-
const optsArg =
|
|
172
|
-
spec.fnOptions && Object.keys(spec.fnOptions).length > 0
|
|
173
|
-
? `, ${JSON.stringify(spec.fnOptions)}`
|
|
174
|
-
: "";
|
|
175
|
-
// Of the table modifiers, only `.notNull()` carries to an existing view (it
|
|
176
|
-
// shapes the SELECT type); `.primaryKey()`/`.default()`/`.references()` are
|
|
177
|
-
// table-DDL concerns and invalid on a `.existing()` view declaration.
|
|
178
|
-
const notNull = spec.modifiers.includes(".notNull()") ? ".notNull()" : "";
|
|
179
|
-
// Narrow a jsonb passthrough to its value-object type — `.$type<VO>()` —
|
|
180
|
-
// mirroring the entity column, so the read row is typed (not `unknown`).
|
|
181
|
-
let dollarType: Code | string = "";
|
|
182
|
-
const dtr = spec.dollarTypeRef;
|
|
183
|
-
if (dtr?.kind === "objectRef") {
|
|
184
|
-
const voTypeSym = imp(`${dtr.name}@${voModule(dtr.name)}`);
|
|
185
|
-
dollarType = dtr.array ? code`.$type<${voTypeSym}[]>()` : code`.$type<${voTypeSym}>()`;
|
|
186
|
-
}
|
|
187
|
-
return code` ${f.name}: ${colSym}(${JSON.stringify(spec.dbName)}${optsArg})${dollarType}${notNull}`;
|
|
188
|
-
});
|
|
189
|
-
|
|
190
129
|
const constFieldLines: string[] = allFields.map((f) => {
|
|
191
130
|
const dbCol = columnNameFromField(f.name, columnNamingStrategy);
|
|
192
131
|
const view = inferViewKind(f);
|
|
@@ -205,19 +144,14 @@ export function renderProjectionDecl(
|
|
|
205
144
|
|
|
206
145
|
const sections: Code[] = [
|
|
207
146
|
...(includeViewDecl
|
|
208
|
-
? [
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
// not to attempt DDL for this declaration.
|
|
212
|
-
export const ${camelName}View = ${viewSym}(${JSON.stringify(viewName)}, {
|
|
213
|
-
${joinCode(viewColumnLines, { on: ",\n" })}
|
|
214
|
-
}).existing();
|
|
215
|
-
`]
|
|
147
|
+
? [renderExistingViewDecl(allFields, viewName, `${camelName}View`, {
|
|
148
|
+
dialect, columnNamingStrategy, timestampMode, voModule,
|
|
149
|
+
})]
|
|
216
150
|
: []),
|
|
217
151
|
code`
|
|
218
|
-
export const ${projName}Schema = ${
|
|
219
|
-
|
|
220
|
-
});
|
|
152
|
+
export const ${projName}Schema = ${renderViewReadZodObject(allFields, {
|
|
153
|
+
dialect, columnNamingStrategy, timestampMode, voModule,
|
|
154
|
+
})};
|
|
221
155
|
`,
|
|
222
156
|
code`
|
|
223
157
|
export type ${projName} = ${z}.infer<typeof ${projName}Schema>;
|
|
@@ -13,16 +13,40 @@ import {
|
|
|
13
13
|
renderFindByIdFn,
|
|
14
14
|
renderListFn,
|
|
15
15
|
renderCreateFn,
|
|
16
|
+
renderInsertPreservingFn,
|
|
16
17
|
renderUpdateFn,
|
|
17
18
|
renderDeleteByIdFn,
|
|
18
19
|
renderReverseFinderFns,
|
|
19
20
|
reverseFksFor,
|
|
20
21
|
getPkInfo,
|
|
22
|
+
getPkFields,
|
|
21
23
|
} from "./queries.js";
|
|
22
|
-
import { pluralize, findByIdFnName, listFnName } from "../naming.js";
|
|
24
|
+
import { pluralize, findByIdFnName, listFnName, createFnName, insertPreservingFnName, updateFnName } from "../naming.js";
|
|
23
25
|
import { GENERATED_HEADER } from "../constants.js";
|
|
24
26
|
import { isTphDiscriminatorBase, tphConcreteSubtypes } from "./tph-discriminator.js";
|
|
25
|
-
import { isProjection } from "../projection/projection-detector.js";
|
|
27
|
+
import { isProjection, isWriteThrough } from "../projection/projection-detector.js";
|
|
28
|
+
import { hasAutoSetFields } from "./zod-validators.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The dialect-correct Drizzle `Db` type alias + its import — parameter-passed into every
|
|
32
|
+
* generated CRUD helper (ADR-0008), so the signatures `find…(db: Db, …)` typecheck without
|
|
33
|
+
* the consumer importing anything to construct one. Shared by every queries-file renderer.
|
|
34
|
+
* - Postgres: `PgDatabase<…>` is the base every PG driver extends (node-postgres,
|
|
35
|
+
* postgres.js, Neon, Vercel, pglite) — accepts whichever driver the consumer chose.
|
|
36
|
+
* - SQLite: `BaseSQLiteDatabase<"sync" | "async", …>` accepts BOTH sync (better-sqlite3)
|
|
37
|
+
* and async (libsql/Turso/D1) drivers; the generated queries `await` results, valid on either.
|
|
38
|
+
*/
|
|
39
|
+
function dbTypeBlock(dialect: "postgres" | "sqlite"): { import: string; alias: string } {
|
|
40
|
+
return dialect === "postgres"
|
|
41
|
+
? {
|
|
42
|
+
import: `import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";`,
|
|
43
|
+
alias: `type Db = PgDatabase<PgQueryResultHKT, Record<string, never>>;`,
|
|
44
|
+
}
|
|
45
|
+
: {
|
|
46
|
+
import: `import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";`,
|
|
47
|
+
alias: `type Db = BaseSQLiteDatabase<"sync" | "async", unknown>;`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
26
50
|
|
|
27
51
|
export function renderQueriesFile(obj: MetaObject, ctx: RenderContext): string {
|
|
28
52
|
// FR-017 Tier 2 — a TPH discriminator base gets a polymorphic queries file:
|
|
@@ -42,6 +66,13 @@ export function renderQueriesFile(obj: MetaObject, ctx: RenderContext): string {
|
|
|
42
66
|
return renderProjectionQueriesFile(obj, ctx);
|
|
43
67
|
}
|
|
44
68
|
|
|
69
|
+
// #214 — a write-through entity read-view (FR-024 §7): READS route to the replica
|
|
70
|
+
// view (returning the derived fields), WRITES target the table. A hybrid of the
|
|
71
|
+
// projection read path + the vanilla write path.
|
|
72
|
+
if (isWriteThrough(obj)) {
|
|
73
|
+
return renderWriteThroughQueriesFile(obj, ctx);
|
|
74
|
+
}
|
|
75
|
+
|
|
45
76
|
const entityName = obj.name;
|
|
46
77
|
// Import the entity's own file. Same target → relative "./Entity"; cross
|
|
47
78
|
// target → importBase-qualified package path.
|
|
@@ -58,21 +89,12 @@ export function renderQueriesFile(obj: MetaObject, ctx: RenderContext): string {
|
|
|
58
89
|
// helper (ADR-0008). Emit the dialect-correct Drizzle type alias so the
|
|
59
90
|
// signatures `findXxx(db: Db, ...)` typecheck without the consumer importing
|
|
60
91
|
// anything to construct one. Consumers pass any compatible Drizzle instance.
|
|
61
|
-
const dbTypeImport =
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
// Postgres: the base class every PG driver extends (node-postgres,
|
|
68
|
-
// postgres.js, Neon, Vercel, pglite) — not just node-postgres, so any
|
|
69
|
-
// PG driver the consumer chose is accepted.
|
|
70
|
-
? `type Db = PgDatabase<PgQueryResultHKT, Record<string, never>>;`
|
|
71
|
-
// SQLite: accept BOTH sync (better-sqlite3) and async (libsql/Turso/D1)
|
|
72
|
-
// drivers. Generated queries `await` their results, which is valid on
|
|
73
|
-
// either result-kind; pinning `<"async">` wrongly rejected better-sqlite3
|
|
74
|
-
// (the most common SQLite driver) with "is not assignable".
|
|
75
|
-
: `type Db = BaseSQLiteDatabase<"sync" | "async", unknown>;`;
|
|
92
|
+
const { import: dbTypeImport, alias: dbTypeAlias } = dbTypeBlock(ctx.dialect);
|
|
93
|
+
|
|
94
|
+
// #203 — an @autoSet entity additionally imports its preserving-shape schema
|
|
95
|
+
// and emits the `insertPreserving<Entity>` escape hatch after `create<Entity>`.
|
|
96
|
+
const autoSet = hasAutoSetFields(obj);
|
|
97
|
+
const preservingImport = autoSet ? `, ${entityName}InsertPreservingSchema` : "";
|
|
76
98
|
|
|
77
99
|
// Literal imports (Db type + entity types) live in a code block so they sort
|
|
78
100
|
// alongside ts-poet's hoisted imp() imports at the top of the body.
|
|
@@ -80,7 +102,7 @@ export function renderQueriesFile(obj: MetaObject, ctx: RenderContext): string {
|
|
|
80
102
|
${dbTypeImport}
|
|
81
103
|
${dbTypeAlias}
|
|
82
104
|
|
|
83
|
-
import { ${varName}, type ${entityName}, type ${entityName}Patch, ${entityName}InsertSchema, ${entityName}UpdateSchema } from ${JSON.stringify(entityFileName)};
|
|
105
|
+
import { ${varName}, type ${entityName}, type ${entityName}Patch, ${entityName}InsertSchema${preservingImport}, ${entityName}UpdateSchema } from ${JSON.stringify(entityFileName)};
|
|
84
106
|
`;
|
|
85
107
|
|
|
86
108
|
const sections: Code[] = [
|
|
@@ -88,6 +110,7 @@ import { ${varName}, type ${entityName}, type ${entityName}Patch, ${entityName}I
|
|
|
88
110
|
renderFindByIdFn(obj, ctx),
|
|
89
111
|
renderListFn(obj, ctx),
|
|
90
112
|
renderCreateFn(obj, ctx),
|
|
113
|
+
...(autoSet ? [renderInsertPreservingFn(obj, ctx)] : []),
|
|
91
114
|
renderUpdateFn(obj, ctx),
|
|
92
115
|
renderDeleteByIdFn(obj, ctx),
|
|
93
116
|
];
|
|
@@ -130,21 +153,7 @@ function renderProjectionQueriesFile(obj: MetaObject, ctx: RenderContext): strin
|
|
|
130
153
|
const { fieldName: pkField, tsType: pkType } = getPkInfo(obj, ctx);
|
|
131
154
|
const eqSym = imp("eq@drizzle-orm");
|
|
132
155
|
|
|
133
|
-
const dbTypeImport =
|
|
134
|
-
ctx.dialect === "postgres"
|
|
135
|
-
? `import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";`
|
|
136
|
-
: `import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";`;
|
|
137
|
-
const dbTypeAlias =
|
|
138
|
-
ctx.dialect === "postgres"
|
|
139
|
-
// Postgres: the base class every PG driver extends (node-postgres,
|
|
140
|
-
// postgres.js, Neon, Vercel, pglite) — not just node-postgres, so any
|
|
141
|
-
// PG driver the consumer chose is accepted.
|
|
142
|
-
? `type Db = PgDatabase<PgQueryResultHKT, Record<string, never>>;`
|
|
143
|
-
// SQLite: accept BOTH sync (better-sqlite3) and async (libsql/Turso/D1)
|
|
144
|
-
// drivers. Generated queries `await` their results, which is valid on
|
|
145
|
-
// either result-kind; pinning `<"async">` wrongly rejected better-sqlite3
|
|
146
|
-
// (the most common SQLite driver) with "is not assignable".
|
|
147
|
-
: `type Db = BaseSQLiteDatabase<"sync" | "async", unknown>;`;
|
|
156
|
+
const { import: dbTypeImport, alias: dbTypeAlias } = dbTypeBlock(ctx.dialect);
|
|
148
157
|
|
|
149
158
|
const literalImports = code`
|
|
150
159
|
${dbTypeImport}
|
|
@@ -175,6 +184,96 @@ export async function ${listFnName(entityName)}(db: Db, opts?: { limit?: number;
|
|
|
175
184
|
return header + body;
|
|
176
185
|
}
|
|
177
186
|
|
|
187
|
+
/**
|
|
188
|
+
* #214 — the queries file for a write-through entity read-view (FR-024 §7).
|
|
189
|
+
*
|
|
190
|
+
* Reads (`find<Name>ById`, `list<Plural>`, reverse finders) SELECT from the replica
|
|
191
|
+
* `<camel>View` so they return the derived fields; writes (`create`/`update`/`delete`)
|
|
192
|
+
* target the write TABLE (derived-free, #213). A create/update re-reads the row THROUGH
|
|
193
|
+
* the view by PK — a derived field is only computable via the view's join, so the
|
|
194
|
+
* returned `<Entity>` (view shape) is read-your-writes correct.
|
|
195
|
+
*/
|
|
196
|
+
function renderWriteThroughQueriesFile(obj: MetaObject, ctx: RenderContext): string {
|
|
197
|
+
const entityName = obj.name;
|
|
198
|
+
const camelName = entityName.charAt(0).toLowerCase() + entityName.slice(1);
|
|
199
|
+
const viewVar = `${camelName}View`;
|
|
200
|
+
const tableVar = ctx.collectionName(entityName);
|
|
201
|
+
const singularVar = camelName;
|
|
202
|
+
const entityFileName = entityModuleSpecifier(
|
|
203
|
+
ctx.selfTarget, ctx.entityModuleTarget, obj.package, entityName, ctx.extStyle,
|
|
204
|
+
);
|
|
205
|
+
const { fieldName: pkField, tsType: pkType } = getPkInfo(obj, ctx);
|
|
206
|
+
const pkFields = getPkFields(obj);
|
|
207
|
+
const eqSym = imp("eq@drizzle-orm");
|
|
208
|
+
const andSym = imp("and@drizzle-orm");
|
|
209
|
+
const autoSet = hasAutoSetFields(obj);
|
|
210
|
+
|
|
211
|
+
const { import: dbTypeImport, alias: dbTypeAlias } = dbTypeBlock(ctx.dialect);
|
|
212
|
+
|
|
213
|
+
const preservingImport = autoSet ? `, ${entityName}InsertPreservingSchema` : "";
|
|
214
|
+
const literalImports = code`
|
|
215
|
+
${dbTypeImport}
|
|
216
|
+
${dbTypeAlias}
|
|
217
|
+
|
|
218
|
+
import { ${viewVar}, ${tableVar}, type ${entityName}, type ${entityName}Patch, ${entityName}InsertSchema${preservingImport}, ${entityName}UpdateSchema } from ${JSON.stringify(entityFileName)};
|
|
219
|
+
`;
|
|
220
|
+
|
|
221
|
+
// The view re-read predicate keyed on ALL primary-key columns of `source` (the
|
|
222
|
+
// insert's returning() row), so a composite PK re-reads the EXACT written row rather
|
|
223
|
+
// than any view row sharing the first key component. `andSym`/`pkFields[i]` are only
|
|
224
|
+
// referenced when the PK is composite, so a single-PK entity emits a bare `eq(...)`.
|
|
225
|
+
const viewByAllPk = (source: string): Code =>
|
|
226
|
+
pkFields.length > 1
|
|
227
|
+
? code`${andSym}(${joinCode(pkFields.map((f) => code`${eqSym}(${viewVar}.${f}, ${source}.${f})`), { on: ", " })})`
|
|
228
|
+
: code`${eqSym}(${viewVar}.${pkField}, ${source}.${pkField})`;
|
|
229
|
+
|
|
230
|
+
// A create/insertPreserving writes the table, then reads the persisted row back
|
|
231
|
+
// through the view so the returned <Entity> carries the derived fields.
|
|
232
|
+
const insertReturningView = (fnName: string, schemaName: string): Code => code`
|
|
233
|
+
export async function ${fnName}(db: Db, data: unknown): Promise<${entityName}> {
|
|
234
|
+
const validated = ${schemaName}.parse(data);
|
|
235
|
+
const [${singularVar}] = await db.insert(${tableVar}).values(validated).returning();
|
|
236
|
+
const [row] = await db.select().from(${viewVar}).where(${viewByAllPk(`${singularVar}!`)}).limit(1);
|
|
237
|
+
return row!;
|
|
238
|
+
}
|
|
239
|
+
`;
|
|
240
|
+
|
|
241
|
+
const updateFn = code`
|
|
242
|
+
export async function ${updateFnName(entityName)}(db: Db, ${pkField}: ${pkType}, patch: ${entityName}Patch): Promise<${entityName} | null> {
|
|
243
|
+
const validated = ${entityName}UpdateSchema.parse(patch);
|
|
244
|
+
// PATCH-5: an empty patch is a no-op — return the current (view) row.
|
|
245
|
+
if (Object.keys(validated).length === 0) return ${findByIdFnName(entityName)}(db, ${pkField});
|
|
246
|
+
const updated = await db.update(${tableVar}).set(validated).where(${eqSym}(${tableVar}.${pkField}, ${pkField})).returning();
|
|
247
|
+
if (updated.length === 0) return null;
|
|
248
|
+
const [row] = await db.select().from(${viewVar}).where(${eqSym}(${viewVar}.${pkField}, ${pkField})).limit(1);
|
|
249
|
+
return row ?? null;
|
|
250
|
+
}
|
|
251
|
+
`;
|
|
252
|
+
|
|
253
|
+
const sections: Code[] = [
|
|
254
|
+
literalImports,
|
|
255
|
+
// Reads route to the replica view.
|
|
256
|
+
renderFindByIdFn(obj, ctx, viewVar),
|
|
257
|
+
renderListFn(obj, ctx, viewVar),
|
|
258
|
+
// Writes target the table (create/update re-read through the view; delete is boolean).
|
|
259
|
+
insertReturningView(createFnName(entityName), `${entityName}InsertSchema`),
|
|
260
|
+
...(autoSet ? [insertReturningView(insertPreservingFnName(entityName), `${entityName}InsertPreservingSchema`)] : []),
|
|
261
|
+
updateFn,
|
|
262
|
+
renderDeleteByIdFn(obj, ctx),
|
|
263
|
+
];
|
|
264
|
+
// Reverse finders are reads → route to the view.
|
|
265
|
+
for (const fk of reverseFksFor(obj)) {
|
|
266
|
+
sections.push(renderReverseFinderFns(obj, fk, ctx, viewVar));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const body = joinCode(sections, { on: "\n" }).toString();
|
|
270
|
+
const header =
|
|
271
|
+
`// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
|
|
272
|
+
`// Source metadata: ${entityName} (${obj.fqn()}) — write-through entity read-view (reads → view, writes → table)\n` +
|
|
273
|
+
`// Customize via ${entityName}.extra.ts in this directory (additional queries, custom logic).\n`;
|
|
274
|
+
return header + body;
|
|
275
|
+
}
|
|
276
|
+
|
|
178
277
|
/**
|
|
179
278
|
* FR-017 Tier 2 — the polymorphic + per-subtype queries file for a TPH base.
|
|
180
279
|
*
|
|
@@ -203,21 +302,7 @@ function renderTphQueriesFile(base: MetaObject, ctx: RenderContext): string {
|
|
|
203
302
|
const eqSym = imp("eq@drizzle-orm");
|
|
204
303
|
const andSym = imp("and@drizzle-orm");
|
|
205
304
|
|
|
206
|
-
const dbTypeImport =
|
|
207
|
-
ctx.dialect === "postgres"
|
|
208
|
-
? `import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";`
|
|
209
|
-
: `import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";`;
|
|
210
|
-
const dbTypeAlias =
|
|
211
|
-
ctx.dialect === "postgres"
|
|
212
|
-
// Postgres: the base class every PG driver extends (node-postgres,
|
|
213
|
-
// postgres.js, Neon, Vercel, pglite) — not just node-postgres, so any
|
|
214
|
-
// PG driver the consumer chose is accepted.
|
|
215
|
-
? `type Db = PgDatabase<PgQueryResultHKT, Record<string, never>>;`
|
|
216
|
-
// SQLite: accept BOTH sync (better-sqlite3) and async (libsql/Turso/D1)
|
|
217
|
-
// drivers. Generated queries `await` their results, which is valid on
|
|
218
|
-
// either result-kind; pinning `<"async">` wrongly rejected better-sqlite3
|
|
219
|
-
// (the most common SQLite driver) with "is not assignable".
|
|
220
|
-
: `type Db = BaseSQLiteDatabase<"sync" | "async", unknown>;`;
|
|
305
|
+
const { import: dbTypeImport, alias: dbTypeAlias } = dbTypeBlock(ctx.dialect);
|
|
221
306
|
|
|
222
307
|
// --- Polymorphic base reads ---
|
|
223
308
|
const polymorphic = code`
|
package/src/templates/queries.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
findByIdFnName,
|
|
10
10
|
listFnName,
|
|
11
11
|
createFnName,
|
|
12
|
+
insertPreservingFnName,
|
|
12
13
|
updateFnName,
|
|
13
14
|
deleteByIdFnName,
|
|
14
15
|
reverseFinderFnName,
|
|
@@ -27,17 +28,28 @@ function subTypeToTsType(subType: string): "number" | "boolean" | "string" {
|
|
|
27
28
|
/** Get the PK field name and its TS type for a given entity. */
|
|
28
29
|
export function getPkInfo(entity: MetaObject, ctx: RenderContext): { fieldName: string; tsType: string } {
|
|
29
30
|
// Use primaryIdentity() to find the primary identity (may be inherited from extends:/super:).
|
|
30
|
-
const
|
|
31
|
-
const rawFields = primary?.attr(IDENTITY_ATTR_FIELDS);
|
|
32
|
-
const fields = Array.isArray(rawFields) ? rawFields : (typeof rawFields === "string" ? [rawFields] : undefined);
|
|
33
|
-
const pkFieldName = fields?.[0] ?? "id";
|
|
31
|
+
const pkFieldName = getPkFields(entity)[0] ?? "id";
|
|
34
32
|
const pkInfo = ctx.pkMap.get(entity.name);
|
|
35
33
|
const subType = pkInfo?.fieldSubType ?? "long";
|
|
36
34
|
return { fieldName: pkFieldName, tsType: subTypeToTsType(subType) };
|
|
37
35
|
}
|
|
38
36
|
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
/** All primary-key field names, in order. `getPkInfo` exposes only the first (the
|
|
38
|
+
* query layer's single-PK finder/update signatures key on it); #214's write-through
|
|
39
|
+
* create re-read keys on ALL of them so a composite PK re-reads the exact written row
|
|
40
|
+
* (via the insert's returning() values), not any row sharing the first key component. */
|
|
41
|
+
export function getPkFields(entity: MetaObject): string[] {
|
|
42
|
+
const rawFields = entity.primaryIdentity()?.attr(IDENTITY_ATTR_FIELDS);
|
|
43
|
+
if (Array.isArray(rawFields)) return rawFields.filter((f): f is string => typeof f === "string");
|
|
44
|
+
return typeof rawFields === "string" ? [rawFields] : [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// #214 — a write-through entity read-view routes READS to its replica view; the read
|
|
48
|
+
// renderers below accept an optional `readVar` (the collection to SELECT from). Absent,
|
|
49
|
+
// they read the entity's own table (the vanilla path, byte-identical). Writes always
|
|
50
|
+
// target the table, so create/update/delete take no such override.
|
|
51
|
+
export function renderFindByIdFn(entity: MetaObject, ctx: RenderContext, readVar?: string): Code {
|
|
52
|
+
const varName = readVar ?? ctx.collectionName(entity.name);
|
|
41
53
|
const entityName = entity.name;
|
|
42
54
|
const singularVar = entityName.charAt(0).toLowerCase() + entityName.slice(1);
|
|
43
55
|
const { fieldName: pkField, tsType: pkType } = getPkInfo(entity, ctx);
|
|
@@ -52,8 +64,8 @@ export async function ${fnName}(db: Db, ${pkField}: ${pkType}): Promise<${entity
|
|
|
52
64
|
`;
|
|
53
65
|
}
|
|
54
66
|
|
|
55
|
-
export function renderListFn(entity: MetaObject, ctx: RenderContext): Code {
|
|
56
|
-
const varName = ctx.collectionName(entity.name);
|
|
67
|
+
export function renderListFn(entity: MetaObject, ctx: RenderContext, readVar?: string): Code {
|
|
68
|
+
const varName = readVar ?? ctx.collectionName(entity.name);
|
|
57
69
|
const entityName = entity.name;
|
|
58
70
|
// Pluralize the PascalCase entity name, preserving capitalization
|
|
59
71
|
// (e.g., "Category" -> "Categories", not "Categorys").
|
|
@@ -85,6 +97,31 @@ export async function ${fnName}(db: Db, data: unknown): Promise<${entityName}> {
|
|
|
85
97
|
`;
|
|
86
98
|
}
|
|
87
99
|
|
|
100
|
+
/**
|
|
101
|
+
* #203 — the `insertPreserving<Entity>` escape hatch. Cross-port with the Java /
|
|
102
|
+
* Kotlin / C# / Python ports: emitted ONLY for an entity that declares @autoSet
|
|
103
|
+
* fields, it persists the row WITHOUT the create-time now() stamp — the @autoSet
|
|
104
|
+
* columns are written verbatim from the caller's data — for import / restore /
|
|
105
|
+
* replication paths that must keep the original timestamps. It validates through
|
|
106
|
+
* `<Entity>InsertPreservingSchema` (the preserving-shape schema whose @autoSet
|
|
107
|
+
* columns carry no transform); the normal `create<Entity>` always stamps now().
|
|
108
|
+
*/
|
|
109
|
+
export function renderInsertPreservingFn(entity: MetaObject, ctx: RenderContext): Code {
|
|
110
|
+
const varName = ctx.collectionName(entity.name);
|
|
111
|
+
const entityName = entity.name;
|
|
112
|
+
const singularVar = entityName.charAt(0).toLowerCase() + entityName.slice(1);
|
|
113
|
+
const fnName = insertPreservingFnName(entityName);
|
|
114
|
+
const schemaName = `${entityName}InsertPreservingSchema`;
|
|
115
|
+
|
|
116
|
+
return code`
|
|
117
|
+
export async function ${fnName}(db: Db, data: unknown): Promise<${entityName}> {
|
|
118
|
+
const validated = ${schemaName}.parse(data);
|
|
119
|
+
const [${singularVar}] = await db.insert(${varName}).values(validated).returning();
|
|
120
|
+
return ${singularVar}!;
|
|
121
|
+
}
|
|
122
|
+
`;
|
|
123
|
+
}
|
|
124
|
+
|
|
88
125
|
export function renderUpdateFn(entity: MetaObject, ctx: RenderContext): Code {
|
|
89
126
|
const varName = ctx.collectionName(entity.name);
|
|
90
127
|
const entityName = entity.name;
|
|
@@ -157,9 +194,11 @@ export function reverseFksFor(entity: MetaObject): ReverseFk[] {
|
|
|
157
194
|
return out;
|
|
158
195
|
}
|
|
159
196
|
|
|
160
|
-
/** Render the single + batched reverse finders for one FK on `entity`.
|
|
161
|
-
|
|
162
|
-
|
|
197
|
+
/** Render the single + batched reverse finders for one FK on `entity`. `readVar`
|
|
198
|
+
* (#214) routes the SELECT to a write-through entity's replica view; absent, it
|
|
199
|
+
* reads the entity's own table (vanilla, byte-identical). */
|
|
200
|
+
export function renderReverseFinderFns(entity: MetaObject, fk: ReverseFk, ctx: RenderContext, readVar?: string): Code {
|
|
201
|
+
const varName = readVar ?? ctx.collectionName(entity.name);
|
|
163
202
|
const entityName = entity.name;
|
|
164
203
|
// The Drizzle table object is keyed by the LOGICAL field name (the DB column
|
|
165
204
|
// name is the argument to integer()/text()), so column access uses fk.fkField.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Shared Drizzle `.existing()` view declaration builder.
|
|
2
|
+
//
|
|
3
|
+
// A view-backed read model — a projection (read-only, view-only) OR the replica
|
|
4
|
+
// view of a write-through entity (FR-024 §7, #214) — declares its physical SQL
|
|
5
|
+
// view to Drizzle via `<viewVar> = pgView/sqliteView(<name>, { …cols }).existing()`
|
|
6
|
+
// so `db.select().from(<viewVar>)` is typed and `.existing()` tells Drizzle not to
|
|
7
|
+
// attempt DDL (the SQL view is created/owned by migrate-ts). Both hosts emit the
|
|
8
|
+
// SAME declaration shape, so it lives here once (extracted from projection-decl).
|
|
9
|
+
|
|
10
|
+
import { code, imp, joinCode, type Code } from "ts-poet";
|
|
11
|
+
import {
|
|
12
|
+
type MetaField, FIELD_SUBTYPE_OBJECT, FIELD_ATTR_OBJECT_REF, stripPackage,
|
|
13
|
+
} from "@metaobjectsdev/metadata";
|
|
14
|
+
import type { ColumnNamingStrategy } from "../metaobjects-config.js";
|
|
15
|
+
import { mapColumnType } from "../column-mapper.js";
|
|
16
|
+
import { zodTypeFor } from "./field-meta.js";
|
|
17
|
+
|
|
18
|
+
export interface ViewDeclOpts {
|
|
19
|
+
readonly dialect: "postgres" | "sqlite";
|
|
20
|
+
readonly columnNamingStrategy: ColumnNamingStrategy;
|
|
21
|
+
/** Drives the timestamp column TS type (Date vs string) in the view declaration. */
|
|
22
|
+
readonly timestampMode: "date" | "string";
|
|
23
|
+
/** Resolve a value-object short name → its import module specifier. */
|
|
24
|
+
readonly voModule: (refBase: string) => string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The typed view column map for a Drizzle `.existing()` declaration — keyed by
|
|
29
|
+
* field name, valued by the column builder for the (renamed) physical view column,
|
|
30
|
+
* so `db.select().from(<view>)` is typed. Honors `@dbColumnType`; `.existing()`
|
|
31
|
+
* views carry type + physical name only (no PK/default/notNull DDL modifiers).
|
|
32
|
+
*/
|
|
33
|
+
function viewColumnLine(f: MetaField, opts: ViewDeclOpts): Code {
|
|
34
|
+
const { dialect, columnNamingStrategy, timestampMode, voModule } = opts;
|
|
35
|
+
const spec = mapColumnType(f, dialect, columnNamingStrategy, timestampMode);
|
|
36
|
+
const colSym = imp(`${spec.fnName}@${spec.importModule}`);
|
|
37
|
+
const optsArg =
|
|
38
|
+
spec.fnOptions && Object.keys(spec.fnOptions).length > 0
|
|
39
|
+
? `, ${JSON.stringify(spec.fnOptions)}`
|
|
40
|
+
: "";
|
|
41
|
+
// #204 — of the table modifiers, only `.array()` (postgres native array element
|
|
42
|
+
// typing) and `.notNull()` (shapes the SELECT type) carry to an existing view;
|
|
43
|
+
// `.primaryKey()`/`.default()`/`.references()`/`.unique()` are table-DDL concerns
|
|
44
|
+
// and invalid on a `.existing()` view declaration. Preserve the canonical order.
|
|
45
|
+
const viewModifiers = spec.modifiers
|
|
46
|
+
.filter((m) => m === ".array()" || m === ".notNull()")
|
|
47
|
+
.join("");
|
|
48
|
+
// Narrow the column to its resolved element/value type — `.$type<…>()` — mirroring
|
|
49
|
+
// the entity column so the read row is typed (not `unknown`) and an array column
|
|
50
|
+
// reads as `T[]`, not `T`.
|
|
51
|
+
let dollarType: Code | string = "";
|
|
52
|
+
const dtr = spec.dollarTypeRef;
|
|
53
|
+
if (dtr?.kind === "scalar") {
|
|
54
|
+
dollarType = `.$type<${dtr.tsType}${dtr.array ? "[]" : ""}>()`;
|
|
55
|
+
} else if (dtr?.kind === "objectRef") {
|
|
56
|
+
const voTypeSym = imp(`${dtr.name}@${voModule(dtr.name)}`);
|
|
57
|
+
dollarType = dtr.array ? code`.$type<${voTypeSym}[]>()` : code`.$type<${voTypeSym}>()`;
|
|
58
|
+
} else if (dtr?.kind === "map") {
|
|
59
|
+
dollarType = "scalar" in dtr.value
|
|
60
|
+
? `.$type<Record<string, ${dtr.value.scalar}>>()`
|
|
61
|
+
: code`.$type<Record<string, ${imp(`${dtr.value.objectRef}@${voModule(dtr.value.objectRef)}`)}>>()`;
|
|
62
|
+
}
|
|
63
|
+
return code` ${f.name}: ${colSym}(${JSON.stringify(spec.dbName)}${optsArg})${dollarType}${viewModifiers}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Emit `export const <viewVar> = <viewFn>(<viewName>, { …cols }).existing();` for a
|
|
68
|
+
* view-backed read model (projection or write-through replica view). `fields` are the
|
|
69
|
+
* view's exposed columns (already resolved to effective fields by the caller).
|
|
70
|
+
*/
|
|
71
|
+
export function renderExistingViewDecl(
|
|
72
|
+
fields: readonly MetaField[],
|
|
73
|
+
viewName: string,
|
|
74
|
+
viewVar: string,
|
|
75
|
+
opts: ViewDeclOpts,
|
|
76
|
+
): Code {
|
|
77
|
+
const viewFn = opts.dialect === "postgres" ? "pgView" : "sqliteView";
|
|
78
|
+
const viewModule = opts.dialect === "postgres" ? "drizzle-orm/pg-core" : "drizzle-orm/sqlite-core";
|
|
79
|
+
const viewSym = imp(`${viewFn}@${viewModule}`);
|
|
80
|
+
const viewColumnLines = fields.map((f) => viewColumnLine(f, opts));
|
|
81
|
+
return code`
|
|
82
|
+
// View declaration — Drizzle uses this for typed SELECT queries.
|
|
83
|
+
// The SQL view is created/managed by migrate-ts; .existing() tells Drizzle
|
|
84
|
+
// not to attempt DDL for this declaration.
|
|
85
|
+
export const ${viewVar} = ${viewSym}(${JSON.stringify(viewName)}, {
|
|
86
|
+
${joinCode(viewColumnLines, { on: ",\n" })}
|
|
87
|
+
}).existing();
|
|
88
|
+
`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The Zod object body for a view-backed read model — one `field: z.<type>[.nullable()]`
|
|
93
|
+
* line per column, the read counterpart of {@link renderExistingViewDecl}. A column that
|
|
94
|
+
* is not `.notNull()` in the view is `.nullable()` (Drizzle's SELECT infers `T | null`),
|
|
95
|
+
* so `z.infer<>` of the object equals the view row type. This is the dialect-agnostic read
|
|
96
|
+
* type (a Drizzle view exposes `$inferSelect` on Postgres but not SQLite). A `field.object`
|
|
97
|
+
* passthrough carries the value-object's schema so the read type exposes the VO shape.
|
|
98
|
+
* `voModule` resolves a value-object short name → its import module.
|
|
99
|
+
*/
|
|
100
|
+
export function renderViewReadZodObject(fields: readonly MetaField[], opts: ViewDeclOpts): Code {
|
|
101
|
+
const { dialect, columnNamingStrategy, timestampMode, voModule } = opts;
|
|
102
|
+
const z = imp("z@zod");
|
|
103
|
+
const lines: Code[] = fields.map((f) => {
|
|
104
|
+
const nullable =
|
|
105
|
+
mapColumnType(f, dialect, columnNamingStrategy, timestampMode).modifiers.includes(".notNull()")
|
|
106
|
+
? ""
|
|
107
|
+
: ".nullable()";
|
|
108
|
+
const refBase =
|
|
109
|
+
f.subType === FIELD_SUBTYPE_OBJECT
|
|
110
|
+
? (() => {
|
|
111
|
+
const ref = f.attr(FIELD_ATTR_OBJECT_REF);
|
|
112
|
+
return typeof ref === "string" && ref.length > 0 ? stripPackage(ref) : undefined;
|
|
113
|
+
})()
|
|
114
|
+
: undefined;
|
|
115
|
+
if (refBase) {
|
|
116
|
+
const schemaSym = imp(`${refBase}InsertSchema@${voModule(refBase)}`);
|
|
117
|
+
const base = f.resolvedIsArray() ? code`${z}.array(${schemaSym})` : code`${schemaSym}`;
|
|
118
|
+
return code` ${f.name}: ${base}${nullable}`;
|
|
119
|
+
}
|
|
120
|
+
// #204 — an array passthrough reads as `T[]`; zodTypeFor returns the ELEMENT type.
|
|
121
|
+
const inner = code`${z}.${zodTypeFor(f).replace(/^z\./, "")}`;
|
|
122
|
+
const zbase = f.resolvedIsArray() ? code`${z}.array(${inner})` : inner;
|
|
123
|
+
return code` ${f.name}: ${zbase}${nullable}`;
|
|
124
|
+
});
|
|
125
|
+
return code`${z}.object({
|
|
126
|
+
${joinCode(lines, { on: ",\n" })}
|
|
127
|
+
})`;
|
|
128
|
+
}
|