@metaobjectsdev/codegen-ts 0.23.1 → 0.23.2
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 +32 -0
- package/dist/column-mapper.d.ts.map +1 -1
- package/dist/column-mapper.js +91 -8
- package/dist/column-mapper.js.map +1 -1
- package/dist/enum-meta.d.ts +20 -0
- package/dist/enum-meta.d.ts.map +1 -1
- package/dist/enum-meta.js +32 -1
- package/dist/enum-meta.js.map +1 -1
- package/dist/projection/extract-view-spec.d.ts.map +1 -1
- package/dist/projection/extract-view-spec.js +70 -6
- package/dist/projection/extract-view-spec.js.map +1 -1
- package/dist/templates/drizzle-schema.d.ts.map +1 -1
- package/dist/templates/drizzle-schema.js +67 -4
- package/dist/templates/drizzle-schema.js.map +1 -1
- package/dist/templates/filter-allowlist.d.ts.map +1 -1
- package/dist/templates/filter-allowlist.js +4 -2
- package/dist/templates/filter-allowlist.js.map +1 -1
- package/dist/templates/filter-type.d.ts.map +1 -1
- package/dist/templates/filter-type.js +6 -2
- package/dist/templates/filter-type.js.map +1 -1
- package/package.json +6 -6
- package/src/column-mapper.ts +124 -8
- package/src/enum-meta.ts +37 -1
- package/src/projection/extract-view-spec.ts +88 -4
- package/src/templates/drizzle-schema.ts +70 -6
- package/src/templates/filter-allowlist.ts +4 -2
- package/src/templates/filter-type.ts +6 -2
package/src/column-mapper.ts
CHANGED
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
AGG_COLLECT,
|
|
47
47
|
} from "@metaobjectsdev/metadata";
|
|
48
48
|
import { columnNameFromField } from "./naming.js";
|
|
49
|
-
import { enumValues } from "./enum-meta.js";
|
|
49
|
+
import { enumValues, intValueMapOf, intValueForMember } from "./enum-meta.js";
|
|
50
50
|
import { DEFAULT_COLUMN_NAMING_STRATEGY, stripPackage } from "@metaobjectsdev/metadata";
|
|
51
51
|
import type { Dialect, ColumnNamingStrategy } from "./metaobjects-config.js";
|
|
52
52
|
|
|
@@ -212,9 +212,42 @@ function canonicalizeSqlExpr(value: string): string {
|
|
|
212
212
|
return value; // unrecognized — pass through (function calls etc.)
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
/**
|
|
216
|
+
* An int-backed `field.enum` column: a generated Drizzle `customType` whose
|
|
217
|
+
* `toDriver`/`fromDriver` translate member symbol <-> stored integer, so the
|
|
218
|
+
* codec lives in the COLUMN definition rather than in the query layer.
|
|
219
|
+
*
|
|
220
|
+
* This is the TS analogue of what every other port already does at its own
|
|
221
|
+
* `MetaField` codec seam (EF Core `HasConversion`, OMDB `JdbcFieldCodec`, Exposed
|
|
222
|
+
* `customEnumeration`, Python `ObjectManager` coercion) — which is why it was
|
|
223
|
+
* chosen over a Zod write-transform plus a bespoke read-decode: TS's generated
|
|
224
|
+
* queries hand back raw Drizzle rows and have no decode seam at all, so a
|
|
225
|
+
* query-layer codec would have meant inventing one and wrapping every generated
|
|
226
|
+
* read. Binding through the column type also makes filter values encode for free.
|
|
227
|
+
*/
|
|
228
|
+
export interface EnumIntCustomType {
|
|
229
|
+
/** Local const name for the customType column helper, e.g. `orderStatusEnumCol`. */
|
|
230
|
+
fnConstName: string;
|
|
231
|
+
/** Local const name for the symbol->int map, e.g. `ORDER_STATUS_TO_INT`. */
|
|
232
|
+
toIntConstName: string;
|
|
233
|
+
/** Local const name for the int->symbol map, e.g. `ORDER_STATUS_FROM_INT`. */
|
|
234
|
+
fromIntConstName: string;
|
|
235
|
+
/** Physical column type for `dataType()` — always integer for an int-backed enum. */
|
|
236
|
+
dataType: string;
|
|
237
|
+
/** Member symbols, in `@values` order (the TS union and the map key order). */
|
|
238
|
+
members: string[];
|
|
239
|
+
/** Member symbol -> stored integer. */
|
|
240
|
+
intByMember: Record<string, number>;
|
|
241
|
+
}
|
|
242
|
+
|
|
215
243
|
export interface ColumnSpec {
|
|
216
244
|
/** Drizzle function name, e.g., "text", "integer", "varchar". */
|
|
217
245
|
fnName: string;
|
|
246
|
+
/**
|
|
247
|
+
* When set, `fnName` names a LOCAL generated const (this spec's customType
|
|
248
|
+
* helper) rather than a Drizzle export — the renderer must NOT `imp()` it.
|
|
249
|
+
*/
|
|
250
|
+
enumIntCustomType?: EnumIntCustomType;
|
|
218
251
|
/** DB column name (snake_case from field name, or @column override). */
|
|
219
252
|
dbName: string;
|
|
220
253
|
/** Positional args after dbName (currently always empty; reserved). */
|
|
@@ -342,6 +375,48 @@ function objectRefBaseName(field: MetaField): string | undefined {
|
|
|
342
375
|
return undefined;
|
|
343
376
|
}
|
|
344
377
|
|
|
378
|
+
/** SCREAMING_SNAKE_CASE for a generated map const name. */
|
|
379
|
+
function screamingSnake(s: string): string {
|
|
380
|
+
return s
|
|
381
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
382
|
+
.replace(/[^A-Za-z0-9]+/g, "_")
|
|
383
|
+
.toUpperCase();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Build the customType descriptor for an int-backed `field.enum`, or undefined
|
|
388
|
+
* when `@values` is missing (the field then degrades to a plain integer column
|
|
389
|
+
* rather than emitting a codec over an unknown member set).
|
|
390
|
+
*
|
|
391
|
+
* Names are derived from the FIELD name, so the consts are per-entity-file and
|
|
392
|
+
* self-contained. A shared enum consumed by N entities therefore emits N small
|
|
393
|
+
* identical helpers rather than requiring a cross-module import — the same
|
|
394
|
+
* self-contained tradeoff the per-entity enum union already makes.
|
|
395
|
+
*/
|
|
396
|
+
function buildEnumIntCustomType(
|
|
397
|
+
field: MetaField,
|
|
398
|
+
intByMember: Record<string, number>,
|
|
399
|
+
): EnumIntCustomType | undefined {
|
|
400
|
+
const members = enumValues(field);
|
|
401
|
+
if (members === undefined || members.length === 0) return undefined;
|
|
402
|
+
// Every member must map — the loader pins key-set-equals-@values (Check 5b), so a
|
|
403
|
+
// miss is unreachable; throwing beats emitting a codec with a hole in it.
|
|
404
|
+
for (const m of members) {
|
|
405
|
+
intValueForMember(intByMember, m, `customType codec for field '${field.name}'`);
|
|
406
|
+
}
|
|
407
|
+
const base = field.name.replace(/[^A-Za-z0-9]/g, "");
|
|
408
|
+
const camel = base.charAt(0).toLowerCase() + base.slice(1);
|
|
409
|
+
const screaming = screamingSnake(base);
|
|
410
|
+
return {
|
|
411
|
+
fnConstName: `${camel}IntEnum`,
|
|
412
|
+
toIntConstName: `${screaming}_TO_INT`,
|
|
413
|
+
fromIntConstName: `${screaming}_FROM_INT`,
|
|
414
|
+
dataType: "integer",
|
|
415
|
+
members,
|
|
416
|
+
intByMember,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
345
420
|
export function mapColumnType(
|
|
346
421
|
field: MetaField,
|
|
347
422
|
dialect: Dialect,
|
|
@@ -355,6 +430,8 @@ export function mapColumnType(
|
|
|
355
430
|
|
|
356
431
|
let fnName: string;
|
|
357
432
|
let fnOptions: Record<string, unknown> | undefined;
|
|
433
|
+
// Set only for an int-backed field.enum — see EnumIntCustomType.
|
|
434
|
+
let enumIntCustomType: EnumIntCustomType | undefined;
|
|
358
435
|
|
|
359
436
|
let leadingComment: string | undefined;
|
|
360
437
|
if (dialect === "sqlite") {
|
|
@@ -405,8 +482,20 @@ export function mapColumnType(
|
|
|
405
482
|
// "string" by the time it reaches here for this dialect.
|
|
406
483
|
fnName = "text";
|
|
407
484
|
break;
|
|
408
|
-
case FIELD_SUBTYPE_STRING:
|
|
409
485
|
case FIELD_SUBTYPE_ENUM:
|
|
486
|
+
// An INT-BACKED enum stores the mapped integer on SQLite too — SQLite has
|
|
487
|
+
// one integer storage class, so this matches migrate-ts's integer{32}.
|
|
488
|
+
{
|
|
489
|
+
const im = intValueMapOf(field);
|
|
490
|
+
if (im !== undefined) {
|
|
491
|
+
enumIntCustomType = buildEnumIntCustomType(field, im);
|
|
492
|
+
fnName = enumIntCustomType?.fnConstName ?? "integer";
|
|
493
|
+
} else {
|
|
494
|
+
fnName = "text";
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
break;
|
|
498
|
+
case FIELD_SUBTYPE_STRING:
|
|
410
499
|
case FIELD_SUBTYPE_UUID:
|
|
411
500
|
case FIELD_SUBTYPE_URI:
|
|
412
501
|
case FIELD_SUBTYPE_INET:
|
|
@@ -524,6 +613,22 @@ export function mapColumnType(
|
|
|
524
613
|
fnName = "jsonb";
|
|
525
614
|
break;
|
|
526
615
|
case FIELD_SUBTYPE_ENUM:
|
|
616
|
+
// An INT-BACKED enum (@intValueMap, design D5) stores the mapped integer,
|
|
617
|
+
// so the Drizzle column is integer — matching migrate-ts's expected-schema.
|
|
618
|
+
// The TS-facing type stays the member-string union; the symbol<->int
|
|
619
|
+
// translation happens at the write/read boundary. Scalar only: D7 makes
|
|
620
|
+
// @intValueMap + isArray ERR_ENUM_INT_VALUE_MAP_ARRAY at load, so an array
|
|
621
|
+
// enum reaching here is always string-backed.
|
|
622
|
+
{
|
|
623
|
+
const im = intValueMapOf(field);
|
|
624
|
+
if (im !== undefined) {
|
|
625
|
+
enumIntCustomType = buildEnumIntCustomType(field, im);
|
|
626
|
+
fnName = enumIntCustomType?.fnConstName ?? "integer";
|
|
627
|
+
} else {
|
|
628
|
+
fnName = "text";
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
break;
|
|
527
632
|
default:
|
|
528
633
|
fnName = "text";
|
|
529
634
|
break;
|
|
@@ -669,6 +774,7 @@ export function mapColumnType(
|
|
|
669
774
|
};
|
|
670
775
|
if (fnOptions !== undefined) result.fnOptions = fnOptions;
|
|
671
776
|
if (defaultExpr !== undefined) result.defaultExpr = defaultExpr;
|
|
777
|
+
if (enumIntCustomType !== undefined) result.enumIntCustomType = enumIntCustomType;
|
|
672
778
|
if (dollarTypeRef !== undefined) result.dollarTypeRef = dollarTypeRef;
|
|
673
779
|
if (leadingComment !== undefined) result.leadingComment = leadingComment;
|
|
674
780
|
|
|
@@ -676,12 +782,22 @@ export function mapColumnType(
|
|
|
676
782
|
if (subType === FIELD_SUBTYPE_ENUM && !isArray) {
|
|
677
783
|
const values = enumValues(field);
|
|
678
784
|
if (values !== undefined && values.length > 0) {
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
785
|
+
const intMap = intValueMapOf(field);
|
|
786
|
+
let list: string;
|
|
787
|
+
if (intMap !== undefined) {
|
|
788
|
+
// Int-backed: the column holds integers, so the CHECK lists them unquoted.
|
|
789
|
+
// Keyed BY MEMBER through the map (not Object.values) so the constraint can
|
|
790
|
+
// never disagree with @values, which stays the SSOT. Must match
|
|
791
|
+
// migrate-ts's buildChecks exactly or `meta verify` reports permanent drift.
|
|
792
|
+
list = values
|
|
793
|
+
.map((v) => String(intValueForMember(intMap, v, `CHECK for column '${dbName}'`)))
|
|
794
|
+
.join(", ");
|
|
795
|
+
} else {
|
|
796
|
+
// Single-quote escaping is belt-and-suspenders: the loader's
|
|
797
|
+
// ENUM_MEMBER_PATTERN already rejects quote-bearing members (members are
|
|
798
|
+
// validated to be identifier-safe), so this never fires in practice.
|
|
799
|
+
list = values.map((v) => `'${v.replace(/'/g, "''")}'`).join(", ");
|
|
800
|
+
}
|
|
685
801
|
result.checkConstraint = `${dbName} IN (${list})`;
|
|
686
802
|
}
|
|
687
803
|
}
|
package/src/enum-meta.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// extraction and the z.enum([...]) expression are derived in exactly one place.
|
|
4
4
|
|
|
5
5
|
import type { MetaField } from "@metaobjectsdev/metadata";
|
|
6
|
-
import { FIELD_ATTR_VALUES } from "@metaobjectsdev/metadata";
|
|
6
|
+
import { FIELD_ATTR_VALUES, FIELD_ATTR_INT_VALUE_MAP } from "@metaobjectsdev/metadata";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Effective enum member values (`@values`) for a field, as strings.
|
|
@@ -20,6 +20,42 @@ export function enumValues(field: MetaField): string[] | undefined {
|
|
|
20
20
|
return values.map((v) => String(v));
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The effective `@intValueMap` (member symbol → integer) for an int-backed enum,
|
|
25
|
+
* or undefined when the enum is string-backed. Its PRESENCE is the whole trigger
|
|
26
|
+
* for integer persistence (design D5) — there is no separate flag or config.
|
|
27
|
+
*
|
|
28
|
+
* ADR-0039: RESOLVING (`attr`, not `ownAttr`), and this is load-bearing rather
|
|
29
|
+
* than incidental. Post-#246 an own `@intValueMap` declared against a shared
|
|
30
|
+
* (root-level abstract) enum is `ERR_ENUM_EXTENDS_VALUES_CONFLICT`, so the map
|
|
31
|
+
* lives on the SHARED DECLARATION and every consuming field INHERITS it. An
|
|
32
|
+
* own-only read would therefore see undefined on exactly the shape adopters are
|
|
33
|
+
* steered toward, and silently emit a string codec into an integer column.
|
|
34
|
+
*/
|
|
35
|
+
export function intValueMapOf(field: MetaField): Record<string, number> | undefined {
|
|
36
|
+
const raw = field.attr(FIELD_ATTR_INT_VALUE_MAP);
|
|
37
|
+
if (raw === undefined || raw === null || typeof raw !== "object") return undefined;
|
|
38
|
+
return raw as Record<string, number>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The integer a member symbol persists as, for an int-backed enum. Throws when the
|
|
43
|
+
* member has no mapping — the loader pins key-set-equals-`@values` (Check 5b) in
|
|
44
|
+
* every port, so a miss is unreachable and must not be papered over: emitting the
|
|
45
|
+
* symbol instead would fail only at INSERT time, against a live database.
|
|
46
|
+
*/
|
|
47
|
+
export function intValueForMember(
|
|
48
|
+
intMap: Record<string, number>,
|
|
49
|
+
member: string,
|
|
50
|
+
context: string,
|
|
51
|
+
): number {
|
|
52
|
+
const n = intMap[member];
|
|
53
|
+
if (typeof n !== "number") {
|
|
54
|
+
throw new Error(`@intValueMap has no integer for member '${member}' (${context}).`);
|
|
55
|
+
}
|
|
56
|
+
return n;
|
|
57
|
+
}
|
|
58
|
+
|
|
23
59
|
/** Build the Zod expression for a set of enum members, e.g. `z.enum(["A", "B"])`. */
|
|
24
60
|
export function zodEnumExpr(values: string[]): string {
|
|
25
61
|
return `z.enum([${values.map((v) => JSON.stringify(v)).join(", ")}])`;
|
|
@@ -32,6 +32,8 @@ import {
|
|
|
32
32
|
FILTER_OP_LT,
|
|
33
33
|
FILTER_OP_LTE,
|
|
34
34
|
FILTER_OP_IS_NULL,
|
|
35
|
+
FILTER_OP_LIKE,
|
|
36
|
+
FIELD_SUBTYPE_ENUM,
|
|
35
37
|
FILTER_COMPOSE_AND,
|
|
36
38
|
FILTER_COMPOSE_OR,
|
|
37
39
|
SORT_ORDER_DESC,
|
|
@@ -47,6 +49,7 @@ import {
|
|
|
47
49
|
type AggregateFunction,
|
|
48
50
|
} from "@metaobjectsdev/metadata";
|
|
49
51
|
import { type MetaData, type MetaField, type MetaRoot, MetaObject } from "@metaobjectsdev/metadata";
|
|
52
|
+
import { intValueMapOf } from "../enum-meta.js";
|
|
50
53
|
import {
|
|
51
54
|
columnNameFromField,
|
|
52
55
|
viewNameFromProjection,
|
|
@@ -126,13 +129,84 @@ function resolveAggregateFilter(
|
|
|
126
129
|
kind: "cmp",
|
|
127
130
|
ref: `${alias}.${sourceColumnNameFor(field, ctx)}`,
|
|
128
131
|
op,
|
|
129
|
-
|
|
132
|
+
// Same int-backed-enum encoding as the row-scope @filter below: this scoping
|
|
133
|
+
// filter renders as a SQL literal too (FILTER (WHERE …) / CASE WHEN), so a
|
|
134
|
+
// member symbol would land unencoded in an integer comparison.
|
|
135
|
+
value: encodeIntEnumFilterValue(
|
|
136
|
+
opObj[op],
|
|
137
|
+
op,
|
|
138
|
+
field.subType === FIELD_SUBTYPE_ENUM ? intValueMapOf(field) : undefined,
|
|
139
|
+
key,
|
|
140
|
+
entity.name,
|
|
141
|
+
),
|
|
130
142
|
});
|
|
131
143
|
}
|
|
132
144
|
if (clauses.length === 0) return undefined;
|
|
133
145
|
return clauses.length === 1 ? clauses[0]! : { kind: "and", clauses };
|
|
134
146
|
}
|
|
135
147
|
|
|
148
|
+
/**
|
|
149
|
+
* The `@intValueMap` of every int-backed `field.enum` the projection declares, keyed
|
|
150
|
+
* by field name. Only int-backed enums appear, so a lookup miss means "no encoding".
|
|
151
|
+
*
|
|
152
|
+
* `fields()` (effective) and `intValueMapOf` (which reads `attr`, RESOLVING) — a
|
|
153
|
+
* projection's fields are bound through `extends` to the base entity's, and post-#246
|
|
154
|
+
* the map itself commonly lives one hop further up on a shared abstract declaration.
|
|
155
|
+
* Own-only at either hop would silently emit the member symbol into an integer column
|
|
156
|
+
* (ADR-0039).
|
|
157
|
+
*/
|
|
158
|
+
function intEnumMapsOf(projection: MetaObject): ReadonlyMap<string, Record<string, number>> {
|
|
159
|
+
const out = new Map<string, Record<string, number>>();
|
|
160
|
+
for (const f of projection.fields()) {
|
|
161
|
+
if (f.subType !== FIELD_SUBTYPE_ENUM) continue;
|
|
162
|
+
const map = intValueMapOf(f);
|
|
163
|
+
if (map !== undefined) out.set(f.name, map);
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Lower a filter value for an int-backed `field.enum` from its member SYMBOL to the
|
|
170
|
+
* INTEGER it persists as. A no-op for every other field (`intMap` undefined), so a
|
|
171
|
+
* string-backed enum's SQL is byte-identical.
|
|
172
|
+
*
|
|
173
|
+
* `isNull` is skipped — its value is a boolean, not a member. `like` is unreachable:
|
|
174
|
+
* `opsForField` removes it from an int-backed enum's band, so the loader rejects it
|
|
175
|
+
* before codegen; the explicit throw makes that a loud failure rather than a
|
|
176
|
+
* `LIKE NaN`. An unmapped member is likewise loader-unreachable (the key set is
|
|
177
|
+
* pinned equal to `@values`) and throws for the same reason — silently emitting the
|
|
178
|
+
* symbol would produce DDL that fails only at apply time, against a live database.
|
|
179
|
+
*/
|
|
180
|
+
function encodeIntEnumFilterValue(
|
|
181
|
+
value: unknown,
|
|
182
|
+
op: string,
|
|
183
|
+
intMap: Record<string, number> | undefined,
|
|
184
|
+
fieldName: string,
|
|
185
|
+
projectionName: string,
|
|
186
|
+
): unknown {
|
|
187
|
+
if (intMap === undefined) return value;
|
|
188
|
+
if (op === FILTER_OP_IS_NULL) return value;
|
|
189
|
+
if (op === FILTER_OP_LIKE) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`Projection ${projectionName}: view @filter uses "like" on "${fieldName}", an ` +
|
|
192
|
+
`int-backed field.enum (@intValueMap) — it stores as an integer column, so a ` +
|
|
193
|
+
`substring match is not expressible. Use eq/ne/in.`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const encode = (v: unknown): unknown => {
|
|
197
|
+
if (typeof v !== "string") return v;
|
|
198
|
+
const n = intMap[v];
|
|
199
|
+
if (typeof n !== "number") {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`Projection ${projectionName}: view @filter value "${v}" for "${fieldName}" has no ` +
|
|
202
|
+
`entry in @intValueMap.`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return n;
|
|
206
|
+
};
|
|
207
|
+
return Array.isArray(value) ? value.map(encode) : encode(value);
|
|
208
|
+
}
|
|
209
|
+
|
|
136
210
|
/**
|
|
137
211
|
* #207 — resolve a projection's row-scope `@filter` (the desugared canonical
|
|
138
212
|
* `{ field: { op: value }, and?, or? }`) into a {@link ViewFilterClause} whose
|
|
@@ -152,13 +226,14 @@ function resolveViewFilter(
|
|
|
152
226
|
filter: unknown,
|
|
153
227
|
columnsByField: ReadonlyMap<string, SelectColumn>,
|
|
154
228
|
projectionName: string,
|
|
229
|
+
intMapsByField: ReadonlyMap<string, Record<string, number>>,
|
|
155
230
|
): ViewFilterClause | undefined {
|
|
156
231
|
if (typeof filter !== "object" || filter === null || Array.isArray(filter)) return undefined;
|
|
157
232
|
const clauses: ViewFilterClause[] = [];
|
|
158
233
|
for (const [key, val] of Object.entries(filter as Record<string, unknown>)) {
|
|
159
234
|
if (key === FILTER_AND || key === FILTER_OR) {
|
|
160
235
|
const subs = (Array.isArray(val) ? val : [])
|
|
161
|
-
.map((s) => resolveViewFilter(s, columnsByField, projectionName))
|
|
236
|
+
.map((s) => resolveViewFilter(s, columnsByField, projectionName, intMapsByField))
|
|
162
237
|
.filter((c): c is ViewFilterClause => c !== undefined);
|
|
163
238
|
if (subs.length > 0) clauses.push({ kind: key === FILTER_AND ? "and" : "or", clauses: subs });
|
|
164
239
|
continue;
|
|
@@ -177,7 +252,14 @@ function resolveViewFilter(
|
|
|
177
252
|
// becomes its own comparison, AND-composed (dropping all-but-the-first would silently
|
|
178
253
|
// widen the exposed row set). The loader has already validated every op for this
|
|
179
254
|
// field's subtype.
|
|
180
|
-
for (const [op,
|
|
255
|
+
for (const [op, rawValue] of Object.entries(desugarClause(val))) {
|
|
256
|
+
// An INT-BACKED field.enum (@intValueMap, design D5) stores as an INTEGER
|
|
257
|
+
// column, so the authored member SYMBOL must become its integer before it is
|
|
258
|
+
// rendered as a SQL literal. The Drizzle customType handles the runtime query
|
|
259
|
+
// path, but view DDL is emitted as literal SQL text and never touches Drizzle.
|
|
260
|
+
const value = encodeIntEnumFilterValue(
|
|
261
|
+
rawValue, op, intMapsByField.get(key), key, projectionName,
|
|
262
|
+
);
|
|
181
263
|
if (col.kind === "passthrough") {
|
|
182
264
|
clauses.push({ kind: "cmp", ref: `${col.sourceAlias}.${col.sourceColumn}`, op, value });
|
|
183
265
|
} else if (col.kind === "computed") {
|
|
@@ -990,7 +1072,9 @@ export function extractViewSpec(
|
|
|
990
1072
|
const columnsByField = new Map<string, SelectColumn>(
|
|
991
1073
|
selectSpec.columns.map((c) => [c.fieldName, c] as const),
|
|
992
1074
|
);
|
|
993
|
-
where = resolveViewFilter(
|
|
1075
|
+
where = resolveViewFilter(
|
|
1076
|
+
rawFilter, columnsByField, projection.name, intEnumMapsOf(projection),
|
|
1077
|
+
);
|
|
994
1078
|
}
|
|
995
1079
|
}
|
|
996
1080
|
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "@metaobjectsdev/metadata";
|
|
14
14
|
import { fieldDeclaringPackage, type RenderContext } from "../render-context.js";
|
|
15
15
|
import { crossEntitySpecifier, valueObjectModuleSpecifier } from "../import-path.js";
|
|
16
|
-
import { mapColumnType, type ColumnSpec } from "../column-mapper.js";
|
|
16
|
+
import { mapColumnType, type ColumnSpec, type EnumIntCustomType } from "../column-mapper.js";
|
|
17
17
|
import { tableNameFromEntity, columnNameFromField } from "../naming.js";
|
|
18
18
|
import { renderRelationsBlock } from "./relations-block.js";
|
|
19
19
|
import { renderDocsFor } from "./jsdoc.js";
|
|
@@ -66,6 +66,9 @@ export function renderDrizzleSchema(obj: MetaObject, ctx: RenderContext): Code {
|
|
|
66
66
|
const columnLines: Code[] = [];
|
|
67
67
|
// Collect CHECK constraints for enum columns; emitted as table-level check() callbacks.
|
|
68
68
|
const checkConstraints: Array<{ name: string; expr: string }> = [];
|
|
69
|
+
// Int-backed field.enum customType helpers, emitted ahead of the table. Keyed by
|
|
70
|
+
// const name so a shared enum used by two fields of the SAME entity emits once.
|
|
71
|
+
const enumIntTypes = new Map<string, EnumIntCustomType>();
|
|
69
72
|
for (const child of obj.fields()) {
|
|
70
73
|
// #213 — a derived (origin-bearing) field is read-only, materialized on the
|
|
71
74
|
// read (view) side, NOT a column on the entity's write table (FR-024 §7).
|
|
@@ -78,6 +81,9 @@ export function renderDrizzleSchema(obj: MetaObject, ctx: RenderContext): Code {
|
|
|
78
81
|
// Compute the column spec once per field and reuse it for both the column
|
|
79
82
|
// line and the CHECK collection.
|
|
80
83
|
const spec = mapColumnType(child, ctx.dialect, ctx.columnNamingStrategy, ctx.timestampMode);
|
|
84
|
+
if (spec.enumIntCustomType !== undefined) {
|
|
85
|
+
enumIntTypes.set(spec.enumIntCustomType.fnConstName, spec.enumIntCustomType);
|
|
86
|
+
}
|
|
81
87
|
const fieldDocs = renderDocsFor(child);
|
|
82
88
|
const columnLine = renderColumn(spec, child, ctx, isPk, pkGeneration, fkInfo, isComposite, isUnique, obj.package, obj.name);
|
|
83
89
|
columnLines.push(fieldDocs ? code` ${fieldDocs}\n${columnLine}` : columnLine);
|
|
@@ -103,6 +109,9 @@ export function renderDrizzleSchema(obj: MetaObject, ctx: RenderContext): Code {
|
|
|
103
109
|
// #213 — a TPH subtype's derived field is read-only too; never a table column.
|
|
104
110
|
if (child.isDerived()) continue;
|
|
105
111
|
const spec = mapColumnType(child, ctx.dialect, ctx.columnNamingStrategy, ctx.timestampMode);
|
|
112
|
+
if (spec.enumIntCustomType !== undefined) {
|
|
113
|
+
enumIntTypes.set(spec.enumIntCustomType.fnConstName, spec.enumIntCustomType);
|
|
114
|
+
}
|
|
106
115
|
const fieldDocs = renderDocsFor(child);
|
|
107
116
|
const columnLine = renderColumn(
|
|
108
117
|
spec, child, ctx, false, undefined, fkMap.get(child.name), isComposite, false, obj.package, obj.name, true,
|
|
@@ -177,11 +186,60 @@ ${joinCode(columnLines, { on: ",\n", trim: false })}
|
|
|
177
186
|
// Emit the relations() block (returns null if no relations).
|
|
178
187
|
const relationsBlock = renderRelationsBlock(obj, ctx);
|
|
179
188
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
189
|
+
// Int-backed enum codecs are declared BEFORE the table that references them.
|
|
190
|
+
// Sorted by const name so output is deterministic regardless of field order.
|
|
191
|
+
const enumIntBlocks = [...enumIntTypes.values()]
|
|
192
|
+
.sort((a, b) => a.fnConstName.localeCompare(b.fnConstName))
|
|
193
|
+
.map((t) => renderEnumIntCustomType(t, importModule));
|
|
183
194
|
|
|
184
|
-
|
|
195
|
+
const blocks: Code[] = [...enumIntBlocks, tableBlock];
|
|
196
|
+
if (relationsBlock !== null) blocks.push(relationsBlock);
|
|
197
|
+
return blocks.length === 1 ? blocks[0]! : joinCode(blocks, { on: "\n" });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Render an int-backed `field.enum`'s Drizzle `customType` helper plus its two
|
|
202
|
+
* lookup maps.
|
|
203
|
+
*
|
|
204
|
+
* The codec lives HERE, in the column definition, so nothing downstream needs to
|
|
205
|
+
* know about it: `db.insert().values()` encodes on bind, a selected row decodes on
|
|
206
|
+
* read, and a filter comparison encodes because Drizzle binds through the column
|
|
207
|
+
* type. That is why this shape was chosen over a Zod write-transform plus a
|
|
208
|
+
* generated read-decode — TS's generated queries return raw Drizzle rows and have
|
|
209
|
+
* no decode seam, so the query-layer approach meant inventing one and wrapping
|
|
210
|
+
* every generated read. It is also the direct analogue of what the other four
|
|
211
|
+
* ports already do (EF Core `HasConversion`, OMDB `JdbcFieldCodec`, Exposed
|
|
212
|
+
* `customEnumeration`, Python `ObjectManager` coercion).
|
|
213
|
+
*
|
|
214
|
+
* `fromDriver` throws on an unmapped integer rather than returning undefined: a
|
|
215
|
+
* value outside the map means the DB holds data the model says is impossible
|
|
216
|
+
* (a hand-written INSERT, or a member removed without a migration), and silently
|
|
217
|
+
* yielding `undefined` for a non-nullable field would surface far from the cause.
|
|
218
|
+
*/
|
|
219
|
+
function renderEnumIntCustomType(t: EnumIntCustomType, importModule: string): Code {
|
|
220
|
+
const customTypeSym = imp(`customType@${importModule}`);
|
|
221
|
+
const union = t.members.map((m) => JSON.stringify(m)).join(" | ");
|
|
222
|
+
const toEntries = t.members
|
|
223
|
+
.map((m) => `${JSON.stringify(m)}: ${t.intByMember[m]}`)
|
|
224
|
+
.join(", ");
|
|
225
|
+
const fromEntries = t.members
|
|
226
|
+
.map((m) => `${t.intByMember[m]}: ${JSON.stringify(m)}`)
|
|
227
|
+
.join(", ");
|
|
228
|
+
return code`
|
|
229
|
+
const ${t.toIntConstName} = { ${toEntries} } as const satisfies Record<${union}, number>;
|
|
230
|
+
const ${t.fromIntConstName}: Record<number, ${union}> = { ${fromEntries} };
|
|
231
|
+
const ${t.fnConstName} = ${customTypeSym}<{ data: ${union}; driverData: number }>({
|
|
232
|
+
dataType: () => ${JSON.stringify(t.dataType)},
|
|
233
|
+
toDriver: (value) => ${t.toIntConstName}[value],
|
|
234
|
+
fromDriver: (value) => {
|
|
235
|
+
const member = ${t.fromIntConstName}[value];
|
|
236
|
+
if (member === undefined) {
|
|
237
|
+
throw new Error(\`unmapped ${t.fnConstName} value: \${value}\`);
|
|
238
|
+
}
|
|
239
|
+
return member;
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
`;
|
|
185
243
|
}
|
|
186
244
|
|
|
187
245
|
interface FkInfo {
|
|
@@ -266,7 +324,13 @@ function renderColumn(
|
|
|
266
324
|
// and suppress any DB default (other-subtype rows must stay NULL here).
|
|
267
325
|
forceNullable: boolean = false,
|
|
268
326
|
): Code {
|
|
269
|
-
|
|
327
|
+
// An int-backed field.enum's column function is a LOCAL generated const (the
|
|
328
|
+
// customType helper emitted into this same file), so it must not be imported
|
|
329
|
+
// from drizzle-orm/*-core like a built-in column type would be.
|
|
330
|
+
const fnSym =
|
|
331
|
+
spec.enumIntCustomType !== undefined
|
|
332
|
+
? spec.enumIntCustomType.fnConstName
|
|
333
|
+
: imp(`${spec.fnName}@${spec.importModule}`);
|
|
270
334
|
|
|
271
335
|
const dbNameLit = JSON.stringify(spec.dbName);
|
|
272
336
|
let baseCall: Code;
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
FIELD_SUBTYPE_TIME,
|
|
14
14
|
FIELD_SUBTYPE_TIMESTAMP,
|
|
15
15
|
FIELD_SUBTYPE_CURRENCY,
|
|
16
|
-
|
|
16
|
+
opsForField,
|
|
17
17
|
} from "@metaobjectsdev/metadata";
|
|
18
18
|
import { sortableFields } from "./filter-shared.js";
|
|
19
19
|
import type { RenderContext } from "../render-context.js";
|
|
@@ -72,7 +72,9 @@ export const ${entity.name}FilterAllowlist = {} as const satisfies FilterAllowli
|
|
|
72
72
|
}
|
|
73
73
|
const rows = fields
|
|
74
74
|
.map((f) => {
|
|
75
|
-
|
|
75
|
+
// opsForField, not opsForSubType — an int-backed field.enum (@intValueMap)
|
|
76
|
+
// stores as an integer, so `like` (a substring match) is not in its band.
|
|
77
|
+
const ops = opsForField(f).map((o) => JSON.stringify(o)).join(", ");
|
|
76
78
|
const sub = filterSubTypeFor(f.subType);
|
|
77
79
|
// Only field.timestamp is governed by timestampMode — Drizzle types
|
|
78
80
|
// field.date / field.time as strings under every dialect.
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
FIELD_SUBTYPE_LONG,
|
|
12
12
|
FIELD_SUBTYPE_DOUBLE,
|
|
13
13
|
FIELD_SUBTYPE_FLOAT,
|
|
14
|
-
|
|
14
|
+
opsForField,
|
|
15
15
|
} from "@metaobjectsdev/metadata";
|
|
16
16
|
import { isSortableField } from "./filter-shared.js";
|
|
17
17
|
|
|
@@ -34,7 +34,11 @@ function tsNameFor(fieldSubType: string): string {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
function renderFieldUnion(field: MetaField): string {
|
|
37
|
-
|
|
37
|
+
// opsForField, not opsForSubType — an int-backed field.enum (@intValueMap) stores
|
|
38
|
+
// as an integer, so `like` is not in its band. The client type and the server
|
|
39
|
+
// allowlist MUST agree: offering `like` here that the allowlist 400s is a
|
|
40
|
+
// client/server mismatch of exactly the kind filter-shared.ts exists to prevent.
|
|
41
|
+
const ops = opsForField(field);
|
|
38
42
|
const tsName = tsNameFor(field.subType);
|
|
39
43
|
const opEntries = ops.map((op) => {
|
|
40
44
|
if (op === "in") return `in?: ${tsName}[]`;
|