@metaobjectsdev/runtime-ts 0.9.0 → 0.10.0
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/drivers/drizzle-driver.d.ts.map +1 -1
- package/dist/drivers/drizzle-driver.js +5 -1
- package/dist/drivers/drizzle-driver.js.map +1 -1
- package/dist/drivers/in-memory-driver.js +2 -0
- package/dist/drivers/in-memory-driver.js.map +1 -1
- package/dist/drivers/kysely-driver.js +1 -0
- package/dist/drivers/kysely-driver.js.map +1 -1
- package/dist/drizzle-fastify/index.d.ts +16 -2
- package/dist/drizzle-fastify/index.d.ts.map +1 -1
- package/dist/drizzle-fastify/index.js +45 -13
- package/dist/drizzle-fastify/index.js.map +1 -1
- package/dist/drizzle-fastify/mount-m2m.d.ts +29 -0
- package/dist/drizzle-fastify/mount-m2m.d.ts.map +1 -0
- package/dist/drizzle-fastify/mount-m2m.js +94 -0
- package/dist/drizzle-fastify/mount-m2m.js.map +1 -0
- package/dist/drizzle-fastify/util.d.ts +2 -0
- package/dist/drizzle-fastify/util.d.ts.map +1 -1
- package/dist/drizzle-fastify/util.js +5 -0
- package/dist/drizzle-fastify/util.js.map +1 -1
- package/dist/extract-object.d.ts.map +1 -1
- package/dist/extract-object.js +14 -5
- package/dist/extract-object.js.map +1 -1
- package/dist/identity-strategy.d.ts.map +1 -1
- package/dist/identity-strategy.js +2 -1
- package/dist/identity-strategy.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/llm-recorder.d.ts +72 -0
- package/dist/llm-recorder.d.ts.map +1 -0
- package/dist/llm-recorder.js +82 -0
- package/dist/llm-recorder.js.map +1 -0
- package/dist/n2m-resolver.d.ts +7 -8
- package/dist/n2m-resolver.d.ts.map +1 -1
- package/dist/n2m-resolver.js +115 -38
- package/dist/n2m-resolver.js.map +1 -1
- package/dist/object-manager.d.ts +4 -0
- package/dist/object-manager.d.ts.map +1 -1
- package/dist/object-manager.js +71 -18
- package/dist/object-manager.js.map +1 -1
- package/dist/persistence-driver.d.ts +3 -0
- package/dist/persistence-driver.d.ts.map +1 -1
- package/dist/query-builder.d.ts +13 -3
- package/dist/query-builder.d.ts.map +1 -1
- package/dist/query-builder.js +19 -10
- package/dist/query-builder.js.map +1 -1
- package/dist/tph.d.ts +14 -0
- package/dist/tph.d.ts.map +1 -0
- package/dist/tph.js +37 -0
- package/dist/tph.js.map +1 -0
- package/dist/type-coercer.d.ts.map +1 -1
- package/dist/type-coercer.js +91 -8
- package/dist/type-coercer.js.map +1 -1
- package/dist/validator-runner.d.ts.map +1 -1
- package/dist/validator-runner.js +24 -3
- package/dist/validator-runner.js.map +1 -1
- package/package.json +62 -51
- package/src/drivers/drizzle-driver.ts +5 -0
- package/src/drivers/in-memory-driver.ts +2 -0
- package/src/drivers/kysely-driver.ts +1 -0
- package/src/drizzle-fastify/index.ts +55 -14
- package/src/drizzle-fastify/mount-m2m.ts +126 -0
- package/src/drizzle-fastify/util.ts +6 -0
- package/src/extract-object.ts +16 -6
- package/src/identity-strategy.ts +2 -1
- package/src/index.ts +7 -0
- package/src/llm-recorder.ts +166 -0
- package/src/n2m-resolver.ts +143 -57
- package/src/object-manager.ts +67 -18
- package/src/persistence-driver.ts +2 -1
- package/src/query-builder.ts +33 -8
- package/src/tph.ts +46 -0
- package/src/type-coercer.ts +94 -8
- package/src/validator-runner.ts +23 -3
package/src/n2m-resolver.ts
CHANGED
|
@@ -1,28 +1,48 @@
|
|
|
1
|
-
// Two-stage
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
// Two-stage M:N resolution.
|
|
2
|
+
//
|
|
3
|
+
// A M:N relationship declares only the slim FR-018 vocabulary on the source
|
|
4
|
+
// entity: `@cardinality: "many"` + `@objectRef: <target>` + `@through:
|
|
5
|
+
// <junction>` (plus optional `@sourceRefField` / `@symmetric` for self-joins).
|
|
6
|
+
// It does NOT restate the junction FK columns — those are DERIVED from the
|
|
7
|
+
// junction entity's two `identity.reference` children via the shared
|
|
8
|
+
// `deriveM2MFields` helper (the SSOT for FK direction, the same one the loader
|
|
9
|
+
// validator + every other port use). This kills the pre-FR-018 stopgap that
|
|
10
|
+
// read `@joinEntity` / `@joinFields` off the relationship.
|
|
11
|
+
//
|
|
12
|
+
// Resolution has three modes (see the FR-018 design):
|
|
13
|
+
// 1. Hetero (source != target): junction WHERE sourceField (IN|=) source.pk,
|
|
14
|
+
// collect targetField, then target WHERE pk IN (...).
|
|
15
|
+
// 2. Directed self-join (`@sourceRefField`): identical traversal; the helper
|
|
16
|
+
// has already picked which junction FK is the source side.
|
|
17
|
+
// 3. Symmetric self-join (`@symmetric: true`): single-row storage, union on
|
|
18
|
+
// read — junction WHERE sourceField (IN|=) id OR targetField (IN|=) id;
|
|
19
|
+
// for each row the related id is whichever FK column is NOT the source id.
|
|
20
|
+
|
|
21
|
+
import type { ColumnNamingStrategy, MetaData, MetaObject, MetaRoot } from "@metaobjectsdev/metadata";
|
|
5
22
|
import {
|
|
6
23
|
TYPE_OBJECT, TYPE_FIELD, TYPE_RELATIONSHIP,
|
|
7
|
-
RELATIONSHIP_ATTR_CARDINALITY, RELATIONSHIP_ATTR_OBJECT_REF,
|
|
8
|
-
RELATIONSHIP_ATTR_JOIN_ENTITY, RELATIONSHIP_ATTR_JOIN_FIELDS,
|
|
24
|
+
RELATIONSHIP_ATTR_CARDINALITY, RELATIONSHIP_ATTR_OBJECT_REF, RELATIONSHIP_ATTR_THROUGH,
|
|
9
25
|
CARDINALITY_MANY,
|
|
10
26
|
DEFAULT_COLUMN_NAMING_STRATEGY,
|
|
11
27
|
resolveColumnName,
|
|
28
|
+
deriveM2MFields,
|
|
12
29
|
} from "@metaobjectsdev/metadata";
|
|
30
|
+
import type { MetaRelationship } from "@metaobjectsdev/metadata";
|
|
13
31
|
import { MetadataError } from "./errors.js";
|
|
14
32
|
import { buildSelectSpec, resolvePkFields } from "./query-builder.js";
|
|
15
|
-
import type { SelectSpec, PrimitiveValue, Row } from "./persistence-driver.js";
|
|
33
|
+
import type { SelectSpec, WhereClause, PrimitiveValue, Row } from "./persistence-driver.js";
|
|
16
34
|
|
|
17
35
|
export interface N2mDescriptor {
|
|
18
|
-
/** Entity that declares the relationship (source of the lookup; its PK feeds
|
|
36
|
+
/** Entity that declares the relationship (source of the lookup; its PK feeds sourceField). */
|
|
19
37
|
sourceEntityName: string;
|
|
20
38
|
targetEntityName: string;
|
|
21
39
|
joinEntityName: string;
|
|
22
|
-
/**
|
|
40
|
+
/** Junction FK field holding the source-side key (derived from the junction's references). */
|
|
23
41
|
sourceJoinField: string;
|
|
24
|
-
/**
|
|
42
|
+
/** Junction FK field holding the target-side key (derived from the junction's references). */
|
|
25
43
|
targetJoinField: string;
|
|
44
|
+
/** Undirected self-join: union both junction FK columns at read time. */
|
|
45
|
+
symmetric: boolean;
|
|
26
46
|
}
|
|
27
47
|
|
|
28
48
|
export interface N2mLazyOutput {
|
|
@@ -31,12 +51,9 @@ export interface N2mLazyOutput {
|
|
|
31
51
|
makeTargetSpec: (joinRows: Row[]) => SelectSpec | null;
|
|
32
52
|
}
|
|
33
53
|
|
|
34
|
-
export
|
|
35
|
-
joinSpec: SelectSpec;
|
|
36
|
-
makeTargetSpec: (joinRows: Row[]) => SelectSpec | null;
|
|
37
|
-
}
|
|
54
|
+
export type N2mBatchOutput = N2mLazyOutput;
|
|
38
55
|
|
|
39
|
-
/** Returns null if the named relationship is not N
|
|
56
|
+
/** Returns null if the named relationship is not M:N — caller should try resolveRelationDescriptor. */
|
|
40
57
|
export function resolveN2mDescriptor(
|
|
41
58
|
sourceEntity: MetaData,
|
|
42
59
|
relationName: string,
|
|
@@ -46,29 +63,37 @@ export function resolveN2mDescriptor(
|
|
|
46
63
|
if (child.type !== TYPE_RELATIONSHIP) continue;
|
|
47
64
|
if (child.name !== relationName) continue;
|
|
48
65
|
if (child.ownAttr(RELATIONSHIP_ATTR_CARDINALITY) !== CARDINALITY_MANY) continue;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
66
|
+
if (child.ownAttr(RELATIONSHIP_ATTR_THROUGH) === undefined) continue; // 1:N many — not M:N.
|
|
67
|
+
|
|
68
|
+
const rel = child as MetaRelationship;
|
|
69
|
+
const targetEntityName = rel.ownAttr(RELATIONSHIP_ATTR_OBJECT_REF) as string | undefined;
|
|
70
|
+
const joinEntityName = rel.ownAttr(RELATIONSHIP_ATTR_THROUGH) as string | undefined;
|
|
71
|
+
if (!targetEntityName || !joinEntityName) {
|
|
53
72
|
throw new MetadataError(
|
|
54
|
-
`N
|
|
73
|
+
`M:N relationship '${relationName}' on '${sourceEntity.name}' requires @objectRef + @through`,
|
|
55
74
|
{ entity: sourceEntity.name },
|
|
56
75
|
);
|
|
57
76
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
|
|
78
|
+
// Derive the [sourceFK, targetFK] junction columns from the junction's two
|
|
79
|
+
// identity.reference children (handles hetero / directed / symmetric).
|
|
80
|
+
let fields;
|
|
81
|
+
try {
|
|
82
|
+
fields = deriveM2MFields(rel, sourceEntity as MetaObject, root as MetaRoot);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
throw new MetadataError(
|
|
85
|
+
`M:N relationship '${relationName}' on '${sourceEntity.name}': ${(e as Error).message}`,
|
|
86
|
+
{ entity: sourceEntity.name },
|
|
87
|
+
);
|
|
65
88
|
}
|
|
89
|
+
|
|
66
90
|
return {
|
|
67
91
|
sourceEntityName: sourceEntity.name,
|
|
68
92
|
targetEntityName,
|
|
69
93
|
joinEntityName,
|
|
70
|
-
sourceJoinField:
|
|
71
|
-
targetJoinField:
|
|
94
|
+
sourceJoinField: fields.sourceField,
|
|
95
|
+
targetJoinField: fields.targetField,
|
|
96
|
+
symmetric: rel.symmetric,
|
|
72
97
|
};
|
|
73
98
|
}
|
|
74
99
|
return null;
|
|
@@ -80,21 +105,7 @@ export function buildN2mLazySpecs(
|
|
|
80
105
|
root: MetaData,
|
|
81
106
|
strategy: ColumnNamingStrategy = DEFAULT_COLUMN_NAMING_STRATEGY,
|
|
82
107
|
): N2mLazyOutput {
|
|
83
|
-
|
|
84
|
-
const targetEntity = mustGetEntity(root, desc.targetEntityName);
|
|
85
|
-
const sourcePkField = resolvePkFields(mustGetEntity(root, desc.sourceEntityName))[0]!;
|
|
86
|
-
const sourcePkValue = sourceRecord[sourcePkField];
|
|
87
|
-
|
|
88
|
-
const joinSpec = buildSelectSpec(joinEntity, { [desc.sourceJoinField]: sourcePkValue as PrimitiveValue }, {}, undefined, strategy);
|
|
89
|
-
|
|
90
|
-
const makeTargetSpec = (joinRows: Row[]): SelectSpec | null => {
|
|
91
|
-
const targetIds = collectTargetIds(joinRows, desc.targetJoinField, joinEntity, strategy);
|
|
92
|
-
if (targetIds.length === 0) return null;
|
|
93
|
-
const targetPkField = resolvePkFields(targetEntity)[0]!;
|
|
94
|
-
return buildSelectSpec(targetEntity, { [targetPkField]: targetIds }, {}, undefined, strategy);
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
return { joinSpec, makeTargetSpec };
|
|
108
|
+
return buildSpecs(desc, sourceRecord, root, strategy);
|
|
98
109
|
}
|
|
99
110
|
|
|
100
111
|
export function buildN2mBatchSpecs(
|
|
@@ -103,51 +114,126 @@ export function buildN2mBatchSpecs(
|
|
|
103
114
|
root: MetaData,
|
|
104
115
|
strategy: ColumnNamingStrategy = DEFAULT_COLUMN_NAMING_STRATEGY,
|
|
105
116
|
): N2mBatchOutput {
|
|
117
|
+
return buildSpecs(desc, sourceRecords, root, strategy);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Single + batch share one code path: a single record is the one-element case.
|
|
121
|
+
function buildSpecs(
|
|
122
|
+
desc: N2mDescriptor,
|
|
123
|
+
source: Row | Row[],
|
|
124
|
+
root: MetaData,
|
|
125
|
+
strategy: ColumnNamingStrategy,
|
|
126
|
+
): N2mLazyOutput {
|
|
106
127
|
const joinEntity = mustGetEntity(root, desc.joinEntityName);
|
|
107
128
|
const targetEntity = mustGetEntity(root, desc.targetEntityName);
|
|
108
|
-
const
|
|
109
|
-
const
|
|
129
|
+
const sourceEntity = mustGetEntity(root, desc.sourceEntityName);
|
|
130
|
+
const sourcePkField = resolvePkFields(sourceEntity)[0]!;
|
|
131
|
+
|
|
132
|
+
const records = Array.isArray(source) ? source : [source];
|
|
133
|
+
const sourceIds = collectIds(records, sourcePkField);
|
|
134
|
+
const sourceIdSet = new Set<PrimitiveValue>(sourceIds);
|
|
135
|
+
|
|
136
|
+
const sourceCol = resolveJoinColumnName(joinEntity, desc.sourceJoinField, strategy);
|
|
137
|
+
const targetCol = resolveJoinColumnName(joinEntity, desc.targetJoinField, strategy);
|
|
110
138
|
|
|
111
|
-
|
|
139
|
+
// buildSelectSpec compiles a filter on the entity's fields; for symmetric we
|
|
140
|
+
// need an OR across two columns, which the filter DSL can't express. So we
|
|
141
|
+
// build the join spec directly off buildSelectSpec then swap in the where.
|
|
142
|
+
const joinSpec = buildSelectSpec(joinEntity, undefined, {}, undefined, strategy);
|
|
143
|
+
joinSpec.where = desc.symmetric
|
|
144
|
+
? { kind: "or", clauses: [inOrEq(sourceCol, sourceIds), inOrEq(targetCol, sourceIds)] }
|
|
145
|
+
: inOrEq(sourceCol, sourceIds);
|
|
112
146
|
|
|
113
147
|
const makeTargetSpec = (joinRows: Row[]): SelectSpec | null => {
|
|
114
|
-
const targetIds =
|
|
148
|
+
const targetIds = desc.symmetric
|
|
149
|
+
? collectSymmetricTargetIds(joinRows, sourceCol, targetCol, sourceIdSet)
|
|
150
|
+
: collectColumnIds(joinRows, targetCol);
|
|
115
151
|
if (targetIds.length === 0) return null;
|
|
116
152
|
const targetPkField = resolvePkFields(targetEntity)[0]!;
|
|
117
|
-
|
|
153
|
+
// PK values are always string|number; the IN filter type excludes boolean.
|
|
154
|
+
const ids = targetIds.filter((v): v is string | number => typeof v !== "boolean");
|
|
155
|
+
return buildSelectSpec(targetEntity, { [targetPkField]: ids }, {}, undefined, strategy);
|
|
118
156
|
};
|
|
119
157
|
|
|
120
158
|
return { joinSpec, makeTargetSpec };
|
|
121
159
|
}
|
|
122
160
|
|
|
161
|
+
/** `= x` for a single id, `IN (...)` for many (degenerate empty → IN [] = no rows). */
|
|
162
|
+
function inOrEq(column: string, ids: PrimitiveValue[]): WhereClause {
|
|
163
|
+
return ids.length === 1
|
|
164
|
+
? { kind: "eq", column, value: ids[0]! }
|
|
165
|
+
: { kind: "in", column, values: ids };
|
|
166
|
+
}
|
|
167
|
+
|
|
123
168
|
function mustGetEntity(root: MetaData, name: string): MetaData {
|
|
124
169
|
const e = root.ownChildren().find((c) => c.type === TYPE_OBJECT && c.name === name);
|
|
125
170
|
if (!e) throw new MetadataError(`Entity '${name}' not found`, { entity: name });
|
|
126
171
|
return e;
|
|
127
172
|
}
|
|
128
173
|
|
|
129
|
-
function collectIds(records: Row[], pkField: string):
|
|
174
|
+
function collectIds(records: Row[], pkField: string): PrimitiveValue[] {
|
|
130
175
|
const seen = new Set<PrimitiveValue>();
|
|
131
176
|
for (const r of records) {
|
|
132
177
|
const v = r[pkField];
|
|
133
178
|
if (v === null || v === undefined) continue;
|
|
134
179
|
seen.add(v as PrimitiveValue);
|
|
135
180
|
}
|
|
136
|
-
return [...seen]
|
|
181
|
+
return [...seen];
|
|
137
182
|
}
|
|
138
183
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
): (string | number)[] {
|
|
142
|
-
// joinRows are raw column-keyed (driver hasn't been to-JS-row'd yet); resolve the metadata field name to its DB column.
|
|
143
|
-
const dbColumn = resolveJoinColumnName(joinEntity, targetJoinField, strategy);
|
|
184
|
+
/** Distinct values of a raw (column-keyed) join column. */
|
|
185
|
+
function collectColumnIds(joinRows: Row[], dbColumn: string): PrimitiveValue[] {
|
|
144
186
|
const seen = new Set<PrimitiveValue>();
|
|
145
187
|
for (const r of joinRows) {
|
|
146
188
|
const v = r[dbColumn];
|
|
147
189
|
if (v === null || v === undefined) continue;
|
|
148
190
|
seen.add(v as PrimitiveValue);
|
|
149
191
|
}
|
|
150
|
-
return [...seen]
|
|
192
|
+
return [...seen];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Symmetric union-on-read: this gathers the set of related ids to FETCH for the
|
|
197
|
+
* second-stage query. For each junction row (a,b) that surfaced via the
|
|
198
|
+
* `a IN ids OR b IN ids` join filter, the related endpoint is the column that is
|
|
199
|
+
* NOT a source — EXCEPT when BOTH columns are sources (two mutually-related
|
|
200
|
+
* records queried in the same batch), where both must be fetched so the
|
|
201
|
+
* eager-include grouping can attach a→b AND b→a. A self-loop row (a==b, a a
|
|
202
|
+
* source) yields a itself.
|
|
203
|
+
*
|
|
204
|
+
* Membership is compared by string-coerced key: the source ids come from the
|
|
205
|
+
* in-process source record (e.g. a JS number) while the junction FK values come
|
|
206
|
+
* straight off the driver, where a BIGINT key arrives as a string. Comparing
|
|
207
|
+
* raw would miss the match (number 1 !== string "1"); string keys bridge it.
|
|
208
|
+
*/
|
|
209
|
+
function collectSymmetricTargetIds(
|
|
210
|
+
joinRows: Row[], sourceCol: string, targetCol: string, sourceIds: Set<PrimitiveValue>,
|
|
211
|
+
): PrimitiveValue[] {
|
|
212
|
+
const sourceKeys = new Set<string>([...sourceIds].map(String));
|
|
213
|
+
const seen = new Set<PrimitiveValue>();
|
|
214
|
+
const add = (v: PrimitiveValue | null | undefined): void => {
|
|
215
|
+
if (v === null || v === undefined) return;
|
|
216
|
+
seen.add(v);
|
|
217
|
+
};
|
|
218
|
+
for (const r of joinRows) {
|
|
219
|
+
const a = r[sourceCol] as PrimitiveValue | null | undefined;
|
|
220
|
+
const b = r[targetCol] as PrimitiveValue | null | undefined;
|
|
221
|
+
const aIsSource = a !== null && a !== undefined && sourceKeys.has(String(a));
|
|
222
|
+
const bIsSource = b !== null && b !== undefined && sourceKeys.has(String(b));
|
|
223
|
+
// When a is a source, b is its related id; when b is a source, a is its
|
|
224
|
+
// related id. Both can hold at once (mutually-related batch members) — fetch
|
|
225
|
+
// both endpoints then. Falls back to "the non-matched column" when only one
|
|
226
|
+
// side matched (the common single-source-lookup case).
|
|
227
|
+
if (aIsSource) add(b);
|
|
228
|
+
if (bIsSource) add(a);
|
|
229
|
+
if (!aIsSource && !bIsSource) {
|
|
230
|
+
// Row surfaced via the join filter but neither column string-matches a
|
|
231
|
+
// source id (e.g. number/string skew not bridged here) — keep prior
|
|
232
|
+
// behavior: take whichever side is present.
|
|
233
|
+
add(a ?? b);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return [...seen];
|
|
151
237
|
}
|
|
152
238
|
|
|
153
239
|
export function resolveJoinColumnName(
|
package/src/object-manager.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { MetaData } from "@metaobjectsdev/metadata";
|
|
2
2
|
import {
|
|
3
3
|
TYPE_OBJECT, TYPE_FIELD,
|
|
4
|
-
FIELD_SUBTYPE_INT,
|
|
4
|
+
FIELD_SUBTYPE_INT,
|
|
5
5
|
FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, FIELD_SUBTYPE_DECIMAL,
|
|
6
6
|
} from "@metaobjectsdev/metadata";
|
|
7
7
|
import type {
|
|
@@ -11,8 +11,9 @@ import type {
|
|
|
11
11
|
import {
|
|
12
12
|
buildSelectSpec, buildCountSpec, buildInsertSpec, buildUpdateSpec, buildDeleteSpec,
|
|
13
13
|
resolvePkFields, compileFilter,
|
|
14
|
-
type Filter, type QueryOpts,
|
|
14
|
+
type Filter, type QueryOpts, type DiscriminatorScope,
|
|
15
15
|
} from "./query-builder.js";
|
|
16
|
+
import { tphSubtypeOf, type TphSubtype } from "./tph.js";
|
|
16
17
|
import {
|
|
17
18
|
buildNameMap, resolveTableName, DEFAULT_COLUMN_NAMING_STRATEGY,
|
|
18
19
|
type ColumnNamingStrategy, type EntityNameMap,
|
|
@@ -81,6 +82,20 @@ export class ObjectManager {
|
|
|
81
82
|
return m;
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
/** TPH discriminator scope for a subtype entity, or undefined for non-TPH. */
|
|
86
|
+
private tphScope(entity: MetaData): DiscriminatorScope | undefined {
|
|
87
|
+
const t = tphSubtypeOf(entity);
|
|
88
|
+
return t ? { field: t.field, value: t.value } : undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** AND the TPH discriminator predicate into a filter (subtype-scoped reads). */
|
|
92
|
+
private scopeFilter(filter: Filter | undefined, tph: TphSubtype | null): Filter | undefined {
|
|
93
|
+
if (tph === null) return filter;
|
|
94
|
+
const disc = { [tph.field]: { $eq: tph.value } } as Filter;
|
|
95
|
+
if (filter === undefined) return disc;
|
|
96
|
+
return { $and: [filter, disc] } as Filter;
|
|
97
|
+
}
|
|
98
|
+
|
|
84
99
|
async findById(entityName: string, id: unknown, opts: ReadOpts = {}): Promise<Row | null> {
|
|
85
100
|
const entity = this.requireEntity(entityName);
|
|
86
101
|
const pkField = resolvePkFields(entity)[0]!;
|
|
@@ -90,7 +105,8 @@ export class ObjectManager {
|
|
|
90
105
|
async findFirst(entityName: string, filter: Filter, opts: ReadOpts = {}): Promise<Row | null> {
|
|
91
106
|
const entity = this.requireEntity(entityName);
|
|
92
107
|
const driver = opts.tx ?? this.driver;
|
|
93
|
-
const
|
|
108
|
+
const scoped = this.scopeFilter(filter, tphSubtypeOf(entity));
|
|
109
|
+
const spec = buildSelectSpec(entity, scoped, { ...opts, limit: 1 }, undefined, this.columnNamingStrategy);
|
|
94
110
|
const row = await driver.selectOne(spec);
|
|
95
111
|
if (row === null) return null;
|
|
96
112
|
const jsRow = this.toJsRow(entity, row);
|
|
@@ -103,7 +119,8 @@ export class ObjectManager {
|
|
|
103
119
|
async findMany(entityName: string, filter?: Filter, opts: ReadOpts = {}): Promise<Row[]> {
|
|
104
120
|
const entity = this.requireEntity(entityName);
|
|
105
121
|
const driver = opts.tx ?? this.driver;
|
|
106
|
-
const
|
|
122
|
+
const scoped = this.scopeFilter(filter, tphSubtypeOf(entity));
|
|
123
|
+
const spec = buildSelectSpec(entity, scoped, opts, undefined, this.columnNamingStrategy);
|
|
107
124
|
const rows = (await driver.selectMany(spec)).map((r) => this.toJsRow(entity, r));
|
|
108
125
|
if (opts.include && opts.include.length > 0) {
|
|
109
126
|
await this.attachIncludes(entity, rows, opts.include, driver);
|
|
@@ -114,7 +131,8 @@ export class ObjectManager {
|
|
|
114
131
|
async count(entityName: string, filter?: Filter, opts: Pick<ReadOpts, "tx"> = {}): Promise<number> {
|
|
115
132
|
const entity = this.requireEntity(entityName);
|
|
116
133
|
const driver = opts.tx ?? this.driver;
|
|
117
|
-
|
|
134
|
+
const scoped = this.scopeFilter(filter, tphSubtypeOf(entity));
|
|
135
|
+
return driver.count(buildCountSpec(entity, scoped, this.columnNamingStrategy));
|
|
118
136
|
}
|
|
119
137
|
|
|
120
138
|
async load(refString: string): Promise<Row | null> {
|
|
@@ -145,7 +163,11 @@ export class ObjectManager {
|
|
|
145
163
|
const entity = this.requireEntity(entityName);
|
|
146
164
|
const driver = opts.tx ?? this.driver;
|
|
147
165
|
|
|
148
|
-
const
|
|
166
|
+
const restricted0 = this.applyViewRestriction(entity, data, opts.view);
|
|
167
|
+
// FR-017 TPH: a subtype create injects its discriminator value (the entity
|
|
168
|
+
// names the subtype; the caller never sets it).
|
|
169
|
+
const tph = tphSubtypeOf(entity);
|
|
170
|
+
const restricted = tph ? { ...restricted0, [tph.field]: tph.value } : restricted0;
|
|
149
171
|
const ident = resolveIdentity(entity, restricted);
|
|
150
172
|
const merged: Row = { ...restricted, ...ident.values };
|
|
151
173
|
|
|
@@ -164,7 +186,15 @@ export class ObjectManager {
|
|
|
164
186
|
const entity = this.requireEntity(entityName);
|
|
165
187
|
const driver = opts.tx ?? this.driver;
|
|
166
188
|
|
|
167
|
-
const
|
|
189
|
+
const restricted0 = this.applyViewRestriction(entity, data, opts.view);
|
|
190
|
+
// FR-017 TPH: the discriminator is immutable — a subtype can't be changed
|
|
191
|
+
// into another subtype, so strip it from the patch before validating/writing.
|
|
192
|
+
const tph = tphSubtypeOf(entity);
|
|
193
|
+
let restricted = restricted0;
|
|
194
|
+
if (tph && tph.field in restricted0) {
|
|
195
|
+
const { [tph.field]: _omit, ...rest } = restricted0;
|
|
196
|
+
restricted = rest;
|
|
197
|
+
}
|
|
168
198
|
|
|
169
199
|
// Partial mode: only validate fields the caller actually passed; absent keys are untouched.
|
|
170
200
|
const validation = runValidators(entity, restricted, { partial: true });
|
|
@@ -173,7 +203,8 @@ export class ObjectManager {
|
|
|
173
203
|
}
|
|
174
204
|
|
|
175
205
|
const coerced = coerceRowOnWrite(entity, restricted, driver.dialect);
|
|
176
|
-
|
|
206
|
+
// FR-017 TPH: scope the by-id update to the subtype (cross-subtype → not found).
|
|
207
|
+
const spec = buildUpdateSpec(entity, coerced, id, this.columnNamingStrategy, this.tphScope(entity));
|
|
177
208
|
const dbRow = await driver.update(spec);
|
|
178
209
|
if (dbRow === null) {
|
|
179
210
|
const mode = opts.ifMissing ?? DEFAULT_IF_MISSING;
|
|
@@ -186,7 +217,8 @@ export class ObjectManager {
|
|
|
186
217
|
async delete(entityName: string, id: unknown, opts: WriteOpts = {}): Promise<boolean> {
|
|
187
218
|
const entity = this.requireEntity(entityName);
|
|
188
219
|
const driver = opts.tx ?? this.driver;
|
|
189
|
-
|
|
220
|
+
// FR-017 TPH: scope the by-id delete to the subtype (cross-subtype → not found).
|
|
221
|
+
const spec = buildDeleteSpec(entity, id, this.columnNamingStrategy, this.tphScope(entity));
|
|
190
222
|
const n = await driver.delete(spec);
|
|
191
223
|
if (n === 0) {
|
|
192
224
|
const mode = opts.ifMissing ?? DEFAULT_IF_MISSING;
|
|
@@ -334,16 +366,33 @@ export class ObjectManager {
|
|
|
334
366
|
const sourceJoinDbCol = resolveJoinColumnName(joinEntity, n2m.sourceJoinField, this.columnNamingStrategy);
|
|
335
367
|
const targetJoinDbCol = resolveJoinColumnName(joinEntity, n2m.targetJoinField, this.columnNamingStrategy);
|
|
336
368
|
const targetPk = resolvePkFields(target)[0]!;
|
|
337
|
-
|
|
338
|
-
|
|
369
|
+
// Key everything by String(): the join rows are raw (a BIGINT FK arrives
|
|
370
|
+
// as a string from the driver) while the JS rows' keys are coerced, so a
|
|
371
|
+
// number↔string mismatch would silently drop matches.
|
|
372
|
+
const key = (v: unknown): string | null => (v === null || v === undefined ? null : String(v));
|
|
373
|
+
const targetById = new Map(targetRows.map((r) => [key(r[targetPk]), r]));
|
|
374
|
+
const sourceKeys = new Set(records.map((r) => key(r[sourcePk])));
|
|
375
|
+
const grouped = new Map<string, Row[]>();
|
|
376
|
+
const attach = (ownerKey: string | null, relatedKey: string | null): void => {
|
|
377
|
+
if (ownerKey === null) return;
|
|
378
|
+
const t = relatedKey === null ? undefined : targetById.get(relatedKey);
|
|
379
|
+
if (!t) return;
|
|
380
|
+
if (!grouped.has(ownerKey)) grouped.set(ownerKey, []);
|
|
381
|
+
grouped.get(ownerKey)!.push(t);
|
|
382
|
+
};
|
|
339
383
|
for (const j of joinRows) {
|
|
340
|
-
const
|
|
341
|
-
const
|
|
342
|
-
if (
|
|
343
|
-
|
|
344
|
-
|
|
384
|
+
const a = key(j[sourceJoinDbCol]);
|
|
385
|
+
const b = key(j[targetJoinDbCol]);
|
|
386
|
+
if (n2m.symmetric) {
|
|
387
|
+
// Union-on-read: a row (a,b) relates a↔b. Attach to whichever
|
|
388
|
+
// endpoint(s) are in this batch; the related id is the OTHER one.
|
|
389
|
+
if (a !== null && sourceKeys.has(a)) attach(a, b);
|
|
390
|
+
if (b !== null && a !== b && sourceKeys.has(b)) attach(b, a);
|
|
391
|
+
} else {
|
|
392
|
+
attach(a, b);
|
|
393
|
+
}
|
|
345
394
|
}
|
|
346
|
-
for (const r of records) r[inc] = grouped.get(r[sourcePk]) ?? [];
|
|
395
|
+
for (const r of records) r[inc] = grouped.get(key(r[sourcePk]) ?? "\0") ?? [];
|
|
347
396
|
continue;
|
|
348
397
|
}
|
|
349
398
|
|
|
@@ -444,7 +493,7 @@ function formatValidationMessage(entityName: string, errors: { field: string; ru
|
|
|
444
493
|
}
|
|
445
494
|
|
|
446
495
|
const NUMERIC_SUBTYPES = new Set([
|
|
447
|
-
FIELD_SUBTYPE_INT,
|
|
496
|
+
FIELD_SUBTYPE_INT,
|
|
448
497
|
FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, FIELD_SUBTYPE_DECIMAL,
|
|
449
498
|
]);
|
|
450
499
|
|
|
@@ -12,7 +12,8 @@ export type WhereClause =
|
|
|
12
12
|
| { kind: "in"; column: string; values: PrimitiveValue[] }
|
|
13
13
|
| { kind: "like"; column: string; pattern: string }
|
|
14
14
|
| { kind: "isNull"; column: string; not: boolean }
|
|
15
|
-
| { kind: "and"; clauses: WhereClause[] }
|
|
15
|
+
| { kind: "and"; clauses: WhereClause[] }
|
|
16
|
+
| { kind: "or"; clauses: WhereClause[] };
|
|
16
17
|
|
|
17
18
|
export interface OrderBy {
|
|
18
19
|
column: string;
|
package/src/query-builder.ts
CHANGED
|
@@ -38,13 +38,15 @@ export interface QueryOpts {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
export function resolvePkFields(entity: MetaData): string[] {
|
|
41
|
-
|
|
41
|
+
// Effective children (own + inherited via super) so a TPH subtype resolves
|
|
42
|
+
// the discriminator base's primary identity (FR-017).
|
|
43
|
+
const primary = entity.children().find(
|
|
42
44
|
(c) => c.type === TYPE_IDENTITY && c.subType === IDENTITY_SUBTYPE_PRIMARY,
|
|
43
45
|
);
|
|
44
46
|
if (!primary) {
|
|
45
47
|
throw new MetadataError(`Entity '${entity.name}' has no primary identity`, { entity: entity.name });
|
|
46
48
|
}
|
|
47
|
-
const attr = primary.
|
|
49
|
+
const attr = primary.attr(IDENTITY_ATTR_FIELDS);
|
|
48
50
|
if (!Array.isArray(attr) || attr.length === 0) {
|
|
49
51
|
throw new MetadataError(`Entity '${entity.name}' primary identity has no @fields`, { entity: entity.name });
|
|
50
52
|
}
|
|
@@ -53,14 +55,15 @@ export function resolvePkFields(entity: MetaData): string[] {
|
|
|
53
55
|
|
|
54
56
|
function listFieldNames(entity: MetaData): string[] {
|
|
55
57
|
const out: string[] = [];
|
|
56
|
-
|
|
58
|
+
// Effective children so a TPH subtype's column set is base fields + own.
|
|
59
|
+
for (const child of entity.children()) {
|
|
57
60
|
if (child.type === TYPE_FIELD) out.push(child.name);
|
|
58
61
|
}
|
|
59
62
|
return out;
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
function getField(entity: MetaData, fieldName: string): MetaData {
|
|
63
|
-
const f = entity.
|
|
66
|
+
const f = entity.children().find((c) => c.type === TYPE_FIELD && c.name === fieldName);
|
|
64
67
|
if (!f) {
|
|
65
68
|
throw new MetadataError(
|
|
66
69
|
`Unknown field '${fieldName}' on entity '${entity.name}'`,
|
|
@@ -208,9 +211,32 @@ export function buildInsertSpec(
|
|
|
208
211
|
};
|
|
209
212
|
}
|
|
210
213
|
|
|
214
|
+
/**
|
|
215
|
+
* A TPH discriminator scope: AND'd into the by-id where so a subtype-scoped
|
|
216
|
+
* update/delete only matches rows of that subtype (a row of a different
|
|
217
|
+
* subtype is invisible → not found). `field` is the discriminator FIELD name;
|
|
218
|
+
* the column is resolved via the entity's naming strategy.
|
|
219
|
+
*/
|
|
220
|
+
export interface DiscriminatorScope {
|
|
221
|
+
field: string;
|
|
222
|
+
value: PrimitiveValue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function byIdWhere(
|
|
226
|
+
entity: MetaData, id: unknown, scope: DiscriminatorScope | undefined,
|
|
227
|
+
strategy: ColumnNamingStrategy,
|
|
228
|
+
): WhereClause {
|
|
229
|
+
const pkColumn = resolveColumnName(getField(entity, resolvePkFields(entity)[0]!), strategy);
|
|
230
|
+
const pkEq: WhereClause = { kind: "eq", column: pkColumn, value: id as PrimitiveValue };
|
|
231
|
+
if (scope === undefined) return pkEq;
|
|
232
|
+
const discColumn = resolveColumnName(getField(entity, scope.field), strategy);
|
|
233
|
+
return { kind: "and", clauses: [pkEq, { kind: "eq", column: discColumn, value: scope.value }] };
|
|
234
|
+
}
|
|
235
|
+
|
|
211
236
|
export function buildUpdateSpec(
|
|
212
237
|
entity: MetaData, data: Row, id: unknown,
|
|
213
238
|
strategy: ColumnNamingStrategy = DEFAULT_COLUMN_NAMING_STRATEGY,
|
|
239
|
+
scope?: DiscriminatorScope,
|
|
214
240
|
): UpdateSpec {
|
|
215
241
|
const values = rowToColumns(entity, data, strategy);
|
|
216
242
|
const pkFields = resolvePkFields(entity);
|
|
@@ -220,11 +246,10 @@ export function buildUpdateSpec(
|
|
|
220
246
|
{ entity: entity.name },
|
|
221
247
|
);
|
|
222
248
|
}
|
|
223
|
-
const pkColumn = resolveColumnName(getField(entity, pkFields[0]!), strategy);
|
|
224
249
|
return {
|
|
225
250
|
table: resolveTableName(entity),
|
|
226
251
|
values,
|
|
227
|
-
where:
|
|
252
|
+
where: byIdWhere(entity, id, scope, strategy),
|
|
228
253
|
returning: listFieldNames(entity).map((f) => resolveColumnName(getField(entity, f), strategy)),
|
|
229
254
|
};
|
|
230
255
|
}
|
|
@@ -232,6 +257,7 @@ export function buildUpdateSpec(
|
|
|
232
257
|
export function buildDeleteSpec(
|
|
233
258
|
entity: MetaData, id: unknown,
|
|
234
259
|
strategy: ColumnNamingStrategy = DEFAULT_COLUMN_NAMING_STRATEGY,
|
|
260
|
+
scope?: DiscriminatorScope,
|
|
235
261
|
): DeleteSpec {
|
|
236
262
|
const pkFields = resolvePkFields(entity);
|
|
237
263
|
if (pkFields.length !== 1) {
|
|
@@ -240,9 +266,8 @@ export function buildDeleteSpec(
|
|
|
240
266
|
{ entity: entity.name },
|
|
241
267
|
);
|
|
242
268
|
}
|
|
243
|
-
const pkColumn = resolveColumnName(getField(entity, pkFields[0]!), strategy);
|
|
244
269
|
return {
|
|
245
270
|
table: resolveTableName(entity),
|
|
246
|
-
where:
|
|
271
|
+
where: byIdWhere(entity, id, scope, strategy),
|
|
247
272
|
};
|
|
248
273
|
}
|
package/src/tph.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// FR-017 — table-per-hierarchy (TPH) discriminator resolution for the runtime.
|
|
2
|
+
//
|
|
3
|
+
// A TPH SUBTYPE is an entity that declares `@discriminatorValue` and `extends`
|
|
4
|
+
// (transitively) a base that declares `@discriminator`. The ObjectManager uses
|
|
5
|
+
// this to:
|
|
6
|
+
// - inject the discriminator value on create (the entity names the subtype;
|
|
7
|
+
// the caller never sets it);
|
|
8
|
+
// - scope every read/update/delete to the subtype (a row of a different
|
|
9
|
+
// subtype is invisible), mirroring the generated per-subtype route's
|
|
10
|
+
// cross-subtype 404 and the drizzle-fastify `discriminator` option;
|
|
11
|
+
// - treat the discriminator as immutable (stripped from update patches).
|
|
12
|
+
//
|
|
13
|
+
// The discriminator FIELD name lives on the base (`@discriminator`); the VALUE
|
|
14
|
+
// lives on the subtype (`@discriminatorValue`). For deep hierarchies the base
|
|
15
|
+
// may be any ancestor, so we walk the resolved super chain to find it.
|
|
16
|
+
|
|
17
|
+
import type { MetaData } from "@metaobjectsdev/metadata";
|
|
18
|
+
import { OBJECT_ATTR_DISCRIMINATOR, OBJECT_ATTR_DISCRIMINATOR_VALUE } from "@metaobjectsdev/metadata";
|
|
19
|
+
|
|
20
|
+
export interface TphSubtype {
|
|
21
|
+
/** The discriminator field NAME (declared on the base via `@discriminator`). */
|
|
22
|
+
field: string;
|
|
23
|
+
/** This subtype's discriminator VALUE (declared via `@discriminatorValue`). */
|
|
24
|
+
value: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* If `entity` is a TPH subtype, return its discriminator field + value; else
|
|
29
|
+
* null. A subtype declares `@discriminatorValue` and inherits `@discriminator`
|
|
30
|
+
* from an ancestor in its resolved super chain.
|
|
31
|
+
*/
|
|
32
|
+
export function tphSubtypeOf(entity: MetaData): TphSubtype | null {
|
|
33
|
+
const value = entity.ownAttr(OBJECT_ATTR_DISCRIMINATOR_VALUE);
|
|
34
|
+
if (value === undefined || value === null) return null;
|
|
35
|
+
|
|
36
|
+
// Walk the resolved super chain to find the @discriminator-bearing ancestor.
|
|
37
|
+
let ancestor: MetaData | undefined = entity.superResolved;
|
|
38
|
+
while (ancestor !== undefined) {
|
|
39
|
+
const field = ancestor.ownAttr(OBJECT_ATTR_DISCRIMINATOR);
|
|
40
|
+
if (field !== undefined && field !== null) {
|
|
41
|
+
return { field: String(field), value: String(value) };
|
|
42
|
+
}
|
|
43
|
+
ancestor = ancestor.superResolved;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|