@metaobjectsdev/runtime-ts 1.0.12 → 1.1.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/decimal-wire.d.ts +54 -0
- package/dist/decimal-wire.d.ts.map +1 -0
- package/dist/decimal-wire.js +158 -0
- package/dist/decimal-wire.js.map +1 -0
- package/dist/drizzle-fastify/filter-allowlist.d.ts +4 -2
- package/dist/drizzle-fastify/filter-allowlist.d.ts.map +1 -1
- package/dist/drizzle-fastify/filter-allowlist.js +2 -1
- package/dist/drizzle-fastify/filter-allowlist.js.map +1 -1
- package/dist/drizzle-fastify/filter-parser.d.ts.map +1 -1
- package/dist/drizzle-fastify/filter-parser.js +23 -9
- package/dist/drizzle-fastify/filter-parser.js.map +1 -1
- package/dist/drizzle-fastify/filter-value-format.d.ts.map +1 -1
- package/dist/drizzle-fastify/filter-value-format.js +4 -1
- package/dist/drizzle-fastify/filter-value-format.js.map +1 -1
- package/dist/drizzle-fastify/index.d.ts +8 -0
- package/dist/drizzle-fastify/index.d.ts.map +1 -1
- package/dist/drizzle-fastify/index.js +4 -2
- package/dist/drizzle-fastify/index.js.map +1 -1
- package/dist/drizzle-fastify/mount-m2m.d.ts +2 -0
- package/dist/drizzle-fastify/mount-m2m.d.ts.map +1 -1
- package/dist/drizzle-fastify/mount-m2m.js +2 -1
- package/dist/drizzle-fastify/mount-m2m.js.map +1 -1
- package/dist/drizzle-fastify/mount-read-only.d.ts +25 -1
- package/dist/drizzle-fastify/mount-read-only.d.ts.map +1 -1
- package/dist/drizzle-fastify/mount-read-only.js +43 -28
- package/dist/drizzle-fastify/mount-read-only.js.map +1 -1
- package/dist/extract-object.js +5 -7
- package/dist/extract-object.js.map +1 -1
- package/dist/hono/index.d.ts +8 -0
- package/dist/hono/index.d.ts.map +1 -1
- package/dist/hono/index.js +4 -2
- package/dist/hono/index.js.map +1 -1
- package/dist/hono/mount-read-only.d.ts +25 -1
- package/dist/hono/mount-read-only.d.ts.map +1 -1
- package/dist/hono/mount-read-only.js +51 -36
- package/dist/hono/mount-read-only.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/n2m-resolver.d.ts.map +1 -1
- package/dist/n2m-resolver.js +4 -3
- package/dist/n2m-resolver.js.map +1 -1
- package/dist/net-format.d.ts +8 -0
- package/dist/net-format.d.ts.map +1 -0
- package/dist/net-format.js +34 -0
- package/dist/net-format.js.map +1 -0
- package/dist/object-manager.d.ts +20 -7
- package/dist/object-manager.d.ts.map +1 -1
- package/dist/object-manager.js +68 -13
- package/dist/object-manager.js.map +1 -1
- package/dist/query-builder.d.ts.map +1 -1
- package/dist/query-builder.js +11 -2
- package/dist/query-builder.js.map +1 -1
- package/dist/relation-resolver.d.ts.map +1 -1
- package/dist/relation-resolver.js +11 -11
- package/dist/relation-resolver.js.map +1 -1
- package/dist/type-coercer.d.ts +1 -1
- package/dist/type-coercer.d.ts.map +1 -1
- package/dist/type-coercer.js +114 -5
- package/dist/type-coercer.js.map +1 -1
- package/dist/validator-runner.d.ts.map +1 -1
- package/dist/validator-runner.js +192 -35
- package/dist/validator-runner.js.map +1 -1
- package/package.json +3 -3
- package/src/decimal-wire.ts +154 -0
- package/src/drizzle-fastify/filter-allowlist.ts +5 -1
- package/src/drizzle-fastify/filter-parser.ts +22 -9
- package/src/drizzle-fastify/filter-value-format.ts +4 -0
- package/src/drizzle-fastify/index.ts +11 -2
- package/src/drizzle-fastify/mount-m2m.ts +4 -1
- package/src/drizzle-fastify/mount-read-only.ts +68 -31
- package/src/extract-object.ts +5 -7
- package/src/hono/index.ts +11 -2
- package/src/hono/mount-read-only.ts +76 -37
- package/src/index.ts +2 -1
- package/src/n2m-resolver.ts +4 -2
- package/src/net-format.ts +37 -0
- package/src/object-manager.ts +80 -18
- package/src/query-builder.ts +14 -1
- package/src/relation-resolver.ts +15 -14
- package/src/type-coercer.ts +116 -5
- package/src/validator-runner.ts +195 -32
package/src/object-manager.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { MetaData } from "@metaobjectsdev/metadata";
|
|
2
|
+
import { physicalNamingGuideUrl } from "@metaobjectsdev/metadata/release-docs";
|
|
2
3
|
import {
|
|
3
4
|
TYPE_OBJECT, TYPE_FIELD,
|
|
5
|
+
OBJECT_SUBTYPE_REPORT,
|
|
6
|
+
isMetaObject, isMetaRoot, isReadOnlySource, reportReadModel,
|
|
4
7
|
FIELD_SUBTYPE_INT,
|
|
5
8
|
FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, FIELD_SUBTYPE_DECIMAL,
|
|
6
9
|
} from "@metaobjectsdev/metadata";
|
|
@@ -15,7 +18,7 @@ import {
|
|
|
15
18
|
} from "./query-builder.js";
|
|
16
19
|
import { tphSubtypeOf, type TphSubtype } from "./tph.js";
|
|
17
20
|
import {
|
|
18
|
-
buildNameMap, resolveTableName,
|
|
21
|
+
buildNameMap, resolveTableName, physicalNamingOf, physicalNamingRemovedMessage,
|
|
19
22
|
type ColumnNamingStrategy, type EntityNameMap,
|
|
20
23
|
} from "@metaobjectsdev/metadata";
|
|
21
24
|
import { coerceRowOnRead, coerceRowOnWrite } from "./type-coercer.js";
|
|
@@ -41,12 +44,6 @@ import { VALID_ENTITY_NAME, DEFAULT_IF_MISSING } from "./constants.js";
|
|
|
41
44
|
export interface ObjectManagerOptions {
|
|
42
45
|
metadata: MetaData;
|
|
43
46
|
driver: PersistenceDriver;
|
|
44
|
-
/**
|
|
45
|
-
* Column-naming strategy for fields with no `@column` override. Defaults to
|
|
46
|
-
* `"snake_case"`. Must agree with the codegen / migration config that
|
|
47
|
-
* created the schema (a mismatch yields columns the runtime can't address).
|
|
48
|
-
*/
|
|
49
|
-
columnNamingStrategy?: ColumnNamingStrategy;
|
|
50
47
|
}
|
|
51
48
|
|
|
52
49
|
export interface ReadOpts extends QueryOpts {
|
|
@@ -70,7 +67,15 @@ export class ObjectManager {
|
|
|
70
67
|
constructor(opts: ObjectManagerOptions) {
|
|
71
68
|
this.metadata = opts.metadata;
|
|
72
69
|
this.driver = opts.driver;
|
|
73
|
-
|
|
70
|
+
// ADR-0058: the column rule is the project's one setting (`naming` in
|
|
71
|
+
// .metaobjects/config.json), carried by the loaded root — the same value codegen and
|
|
72
|
+
// `meta migrate` read, so the runtime cannot address columns the schema does not have.
|
|
73
|
+
// The 1.0 option is refused rather than dropped: its value may be the one that matches.
|
|
74
|
+
const removedNaming = (opts as { columnNamingStrategy?: unknown }).columnNamingStrategy;
|
|
75
|
+
if (removedNaming !== undefined) {
|
|
76
|
+
throw new Error(physicalNamingRemovedMessage("the ObjectManager columnNamingStrategy option", String(removedNaming), physicalNamingGuideUrl()));
|
|
77
|
+
}
|
|
78
|
+
this.columnNamingStrategy = physicalNamingOf(this.metadata).columns;
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
private nameMap(entity: MetaData): EntityNameMap {
|
|
@@ -111,7 +116,7 @@ export class ObjectManager {
|
|
|
111
116
|
}
|
|
112
117
|
|
|
113
118
|
async findById(entityName: string, id: unknown, opts: ReadOpts = {}): Promise<Row | null> {
|
|
114
|
-
const entity = this.
|
|
119
|
+
const entity = this.requireIdentified(entityName, "findById");
|
|
115
120
|
const pkField = resolvePkFields(entity)[0]!;
|
|
116
121
|
return this.findFirst(entityName, { [pkField]: this.coerceIdArg(entity, id) as string | number }, opts);
|
|
117
122
|
}
|
|
@@ -151,7 +156,7 @@ export class ObjectManager {
|
|
|
151
156
|
|
|
152
157
|
async load(refString: string): Promise<Row | null> {
|
|
153
158
|
const { entity: entityName, pkValues } = decodeRef(refString);
|
|
154
|
-
const entity = this.
|
|
159
|
+
const entity = this.requireIdentified(entityName, "load");
|
|
155
160
|
const pkFields = resolvePkFields(entity);
|
|
156
161
|
if (pkValues.length !== pkFields.length) {
|
|
157
162
|
throw new MetadataError(
|
|
@@ -169,12 +174,12 @@ export class ObjectManager {
|
|
|
169
174
|
}
|
|
170
175
|
|
|
171
176
|
refOf(entityName: string, record: Row): string {
|
|
172
|
-
const entity = this.
|
|
177
|
+
const entity = this.requireIdentified(entityName, "refOf");
|
|
173
178
|
return encodeRef(entityName, record, resolvePkFields(entity));
|
|
174
179
|
}
|
|
175
180
|
|
|
176
181
|
async create(entityName: string, data: Row, opts: WriteOpts = {}): Promise<Row> {
|
|
177
|
-
const entity = this.
|
|
182
|
+
const entity = this.requireIdentified(entityName, "create");
|
|
178
183
|
const driver = opts.tx ?? this.driver;
|
|
179
184
|
|
|
180
185
|
const restricted0 = this.applyViewRestriction(entity, data, opts.view);
|
|
@@ -197,7 +202,7 @@ export class ObjectManager {
|
|
|
197
202
|
}
|
|
198
203
|
|
|
199
204
|
async update(entityName: string, id: unknown, data: Row, opts: WriteOpts = {}): Promise<Row | null> {
|
|
200
|
-
const entity = this.
|
|
205
|
+
const entity = this.requireIdentified(entityName, "update");
|
|
201
206
|
const driver = opts.tx ?? this.driver;
|
|
202
207
|
|
|
203
208
|
const restricted0 = this.applyViewRestriction(entity, data, opts.view);
|
|
@@ -229,7 +234,7 @@ export class ObjectManager {
|
|
|
229
234
|
}
|
|
230
235
|
|
|
231
236
|
async delete(entityName: string, id: unknown, opts: WriteOpts = {}): Promise<boolean> {
|
|
232
|
-
const entity = this.
|
|
237
|
+
const entity = this.requireIdentified(entityName, "delete");
|
|
233
238
|
const driver = opts.tx ?? this.driver;
|
|
234
239
|
// FR-017 TPH: scope the by-id delete to the subtype (cross-subtype → not found).
|
|
235
240
|
const spec = buildDeleteSpec(entity, this.coerceIdArg(entity, id), this.columnNamingStrategy, this.tphScope(entity));
|
|
@@ -243,7 +248,7 @@ export class ObjectManager {
|
|
|
243
248
|
}
|
|
244
249
|
|
|
245
250
|
async createMany(entityName: string, dataArray: Row[], opts: WriteOpts = {}): Promise<Row[]> {
|
|
246
|
-
const entity = this.
|
|
251
|
+
const entity = this.requireIdentified(entityName, "createMany");
|
|
247
252
|
const driver = opts.tx ?? this.driver;
|
|
248
253
|
|
|
249
254
|
// Validate + identity-resolve every row before any insert so a late failure can't leave partial state.
|
|
@@ -274,7 +279,7 @@ export class ObjectManager {
|
|
|
274
279
|
}
|
|
275
280
|
|
|
276
281
|
async updateMany(entityName: string, filter: Filter, partial: Row, opts: WriteOpts = {}): Promise<number> {
|
|
277
|
-
const entity = this.
|
|
282
|
+
const entity = this.requireIdentified(entityName, "updateMany");
|
|
278
283
|
const driver = opts.tx ?? this.driver;
|
|
279
284
|
const restricted = this.applyViewRestriction(entity, partial, opts.view);
|
|
280
285
|
const v = runValidators(entity, restricted, { partial: true });
|
|
@@ -292,7 +297,7 @@ export class ObjectManager {
|
|
|
292
297
|
}
|
|
293
298
|
|
|
294
299
|
async deleteMany(entityName: string, filter: Filter, opts: WriteOpts = {}): Promise<number> {
|
|
295
|
-
const entity = this.
|
|
300
|
+
const entity = this.requireIdentified(entityName, "deleteMany");
|
|
296
301
|
const driver = opts.tx ?? this.driver;
|
|
297
302
|
const spec: DeleteManySpec = {
|
|
298
303
|
table: resolveTableName(entity),
|
|
@@ -304,7 +309,7 @@ export class ObjectManager {
|
|
|
304
309
|
async transaction<T>(fn: (txOm: ObjectManager) => Promise<T>): Promise<T> {
|
|
305
310
|
return this.driver.transaction(async (txDriver) => {
|
|
306
311
|
const txOm = new ObjectManager({
|
|
307
|
-
metadata: this.metadata, driver: txDriver,
|
|
312
|
+
metadata: this.metadata, driver: txDriver,
|
|
308
313
|
});
|
|
309
314
|
return fn(txOm);
|
|
310
315
|
});
|
|
@@ -464,6 +469,12 @@ export class ObjectManager {
|
|
|
464
469
|
}
|
|
465
470
|
|
|
466
471
|
private requireEntity(entityName: string): MetaData {
|
|
472
|
+
const entity = this.requireObject(entityName);
|
|
473
|
+
return entity.subType === OBJECT_SUBTYPE_REPORT ? this.reportReadModelOf(entity) : entity;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** The declared object node, exactly as loaded (a report is NOT swapped for its read model). */
|
|
477
|
+
private requireObject(entityName: string): MetaData {
|
|
467
478
|
if (!VALID_ENTITY_NAME.test(entityName)) {
|
|
468
479
|
throw new UnsafeNameError(
|
|
469
480
|
`Unsafe entity name '${entityName}'`,
|
|
@@ -478,6 +489,57 @@ export class ObjectManager {
|
|
|
478
489
|
return entity;
|
|
479
490
|
}
|
|
480
491
|
|
|
492
|
+
/**
|
|
493
|
+
* FR-044: a report declares no fields — its read shape is derived — so it is read
|
|
494
|
+
* through a detached read model carrying one real field per derived field and the
|
|
495
|
+
* report's own read-only source. Everything downstream (column list, filter and
|
|
496
|
+
* sort resolution, the name map, read coercion) then treats it as it treats a
|
|
497
|
+
* projection. The model is built once per report node and never joins the tree.
|
|
498
|
+
*
|
|
499
|
+
* A report with no read-only source of its own has no view (Table A), so there is
|
|
500
|
+
* nothing to read: refused here rather than falling through to a default table name.
|
|
501
|
+
*/
|
|
502
|
+
private reportReadModelOf(report: MetaData): MetaData {
|
|
503
|
+
if (!isMetaObject(report) || !isMetaRoot(this.metadata)) {
|
|
504
|
+
throw new MetadataError(
|
|
505
|
+
`Report '${report.name}' cannot be read: the ObjectManager's metadata is not a loaded root`,
|
|
506
|
+
{ entity: report.name },
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
let model: MetaData;
|
|
510
|
+
try {
|
|
511
|
+
model = reportReadModel(report, this.metadata);
|
|
512
|
+
} catch (cause) {
|
|
513
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
514
|
+
throw new MetadataError(`Report '${report.name}' cannot be read: ${detail}`, { entity: report.name, cause });
|
|
515
|
+
}
|
|
516
|
+
if (!model.children().some((c) => isReadOnlySource(c))) {
|
|
517
|
+
throw new MetadataError(
|
|
518
|
+
`Report '${report.name}' is not served: it declares no read-only source, so it has no view to read`,
|
|
519
|
+
{ entity: report.name },
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
return model;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Resolve an entity for an operation that needs an identity or writes: get-by-id,
|
|
527
|
+
* reference encode/decode, and every create/update/delete. A report (FR-044) is an
|
|
528
|
+
* aggregate over a view — it has no primary key and no write target — so these are
|
|
529
|
+
* refused by name before anything else is looked at (a sourceless report included).
|
|
530
|
+
*/
|
|
531
|
+
private requireIdentified(entityName: string, op: string): MetaData {
|
|
532
|
+
const entity = this.requireObject(entityName);
|
|
533
|
+
if (entity.subType === OBJECT_SUBTYPE_REPORT) {
|
|
534
|
+
throw new MetadataError(
|
|
535
|
+
`${op} is not supported on '${entityName}': a report is read-only and has no identity ` +
|
|
536
|
+
`(read it with findMany, findFirst or count)`,
|
|
537
|
+
{ entity: entityName },
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
return entity;
|
|
541
|
+
}
|
|
542
|
+
|
|
481
543
|
private toJsRow(entity: MetaData, dbRow: Row): Row {
|
|
482
544
|
const { dbToJs } = this.nameMap(entity);
|
|
483
545
|
const out: Row = {};
|
package/src/query-builder.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
TYPE_FIELD, TYPE_IDENTITY,
|
|
4
4
|
IDENTITY_SUBTYPE_PRIMARY,
|
|
5
5
|
IDENTITY_ATTR_FIELDS,
|
|
6
|
+
OBJECT_SUBTYPE_REPORT,
|
|
6
7
|
DEFAULT_COLUMN_NAMING_STRATEGY,
|
|
7
8
|
resolveTableName, resolveColumnName,
|
|
8
9
|
} from "@metaobjectsdev/metadata";
|
|
@@ -193,7 +194,19 @@ export function buildSelectSpec(
|
|
|
193
194
|
strategy: ColumnNamingStrategy = DEFAULT_COLUMN_NAMING_STRATEGY,
|
|
194
195
|
): SelectSpec {
|
|
195
196
|
const allFields = projectedFields ?? listFieldNames(entity);
|
|
196
|
-
|
|
197
|
+
// A report's read model (FR-044) has no identity, so there is no key to add to the
|
|
198
|
+
// column list. Scoped to the report subtype: every other object still requires one.
|
|
199
|
+
const isReport = entity.subType === OBJECT_SUBTYPE_REPORT;
|
|
200
|
+
// A report node as DECLARED has no field children (its shape is derived); only its
|
|
201
|
+
// read model does. Selecting from the bare node would be a query with no columns.
|
|
202
|
+
if (isReport && listFieldNames(entity).length === 0) {
|
|
203
|
+
throw new MetadataError(
|
|
204
|
+
`Report '${entity.name}' has no fields to select: a report is read through its read model ` +
|
|
205
|
+
`(reportReadModel), not through the declared node`,
|
|
206
|
+
{ entity: entity.name },
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
const pkFields = isReport ? [] : resolvePkFields(entity);
|
|
197
210
|
const fieldSet = new Set<string>(allFields);
|
|
198
211
|
for (const pk of pkFields) fieldSet.add(pk);
|
|
199
212
|
|
package/src/relation-resolver.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
CARDINALITY_ONE, CARDINALITY_MANY,
|
|
7
7
|
DEFAULT_COLUMN_NAMING_STRATEGY,
|
|
8
8
|
resolveRelationshipReference,
|
|
9
|
+
refTarget,
|
|
9
10
|
} from "@metaobjectsdev/metadata";
|
|
10
11
|
import { MetadataError } from "./errors.js";
|
|
11
12
|
import {
|
|
@@ -31,7 +32,7 @@ export interface RelationDescriptor {
|
|
|
31
32
|
*/
|
|
32
33
|
function findReferenceFkField(
|
|
33
34
|
holder: MetaData,
|
|
34
|
-
targetName:
|
|
35
|
+
targetName: MetaData,
|
|
35
36
|
relationshipName: string,
|
|
36
37
|
sourceRefField?: string,
|
|
37
38
|
): string | undefined {
|
|
@@ -68,26 +69,26 @@ export function resolveRelationDescriptor(
|
|
|
68
69
|
{ entity: sourceEntity.name },
|
|
69
70
|
);
|
|
70
71
|
}
|
|
71
|
-
// ADR-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
if (!fkField) {
|
|
72
|
+
// ADR-0059 R7: the target the loader bound, never a short-name match.
|
|
73
|
+
const target = refTarget(child, RELATIONSHIP_ATTR_OBJECT_REF);
|
|
74
|
+
if (!target || target.type !== TYPE_OBJECT) {
|
|
75
75
|
throw new MetadataError(
|
|
76
|
-
`
|
|
76
|
+
`Target entity '${targetEntityName}' not found for relation '${relationName}' on '${sourceEntity.name}'`,
|
|
77
77
|
{ entity: sourceEntity.name },
|
|
78
78
|
);
|
|
79
79
|
}
|
|
80
|
-
// ADR-0039:
|
|
81
|
-
const
|
|
82
|
-
|
|
80
|
+
// ADR-0039: resolving — @sourceRefField may be inherited via extends.
|
|
81
|
+
const declaredRefField = child.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined;
|
|
82
|
+
const fkField = findReferenceFkField(sourceEntity, target, child.name, declaredRefField);
|
|
83
|
+
if (!fkField) {
|
|
83
84
|
throw new MetadataError(
|
|
84
|
-
`
|
|
85
|
+
`Relationship '${relationName}' on '${sourceEntity.name}' has no identity.reference targeting '${targetEntityName}'`,
|
|
85
86
|
{ entity: sourceEntity.name },
|
|
86
87
|
);
|
|
87
88
|
}
|
|
88
89
|
return {
|
|
89
90
|
cardinality: "one",
|
|
90
|
-
targetEntityName,
|
|
91
|
+
targetEntityName: target.name,
|
|
91
92
|
sourceField: fkField,
|
|
92
93
|
targetField: resolvePkFields(target)[0]!,
|
|
93
94
|
};
|
|
@@ -103,15 +104,15 @@ export function resolveRelationDescriptor(
|
|
|
103
104
|
// ADR-0039: effective attrs — @cardinality/@objectRef may be inherited.
|
|
104
105
|
const card = child.attr(RELATIONSHIP_ATTR_CARDINALITY);
|
|
105
106
|
if (card !== CARDINALITY_ONE) continue;
|
|
106
|
-
|
|
107
|
-
if (
|
|
107
|
+
// ADR-0059 R7: compare the bound target, not a name.
|
|
108
|
+
if (refTarget(child, RELATIONSHIP_ATTR_OBJECT_REF) !== sourceEntity) continue;
|
|
108
109
|
const inverseName = inversePluralName(other.name);
|
|
109
110
|
if (inverseName !== relationName) continue;
|
|
110
111
|
// ADR-0039: resolving — @sourceRefField may be inherited via extends. This
|
|
111
112
|
// FK lives on `other` and belongs to `other`'s own relationship `child` —
|
|
112
113
|
// not to anything declared on sourceEntity (see #368 module comment).
|
|
113
114
|
const declaredRefField = child.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD) as string | undefined;
|
|
114
|
-
const fkField = findReferenceFkField(other, sourceEntity
|
|
115
|
+
const fkField = findReferenceFkField(other, sourceEntity, child.name, declaredRefField);
|
|
115
116
|
if (!fkField) {
|
|
116
117
|
throw new MetadataError(
|
|
117
118
|
`Inverse relationship for '${relationName}' on '${sourceEntity.name}': entity '${other.name}' has no identity.reference targeting '${sourceEntity.name}'`,
|
package/src/type-coercer.ts
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
// - SQLite has no native boolean: booleans are stored as 0/1 ints, so we map
|
|
4
4
|
// boolean↔int on the way in/out. MySQL's BOOLEAN is TINYINT(1) and mysql2 reads it
|
|
5
5
|
// back as 0/1, so it takes the same mapping.
|
|
6
|
+
// - field.decimal is the canonical decimal STRING on every dialect (ADR-0019 Amendment 1): a
|
|
7
|
+
// string Postgres/MySQL hand back loses its scale (`100.5000` -> `100.5`), a number SQLite/D1
|
|
8
|
+
// hand back is rendered at 15 significant digits, and a value written as a number or in
|
|
9
|
+
// exponent form is bound as its exact canonical string.
|
|
6
10
|
// - JSONB-backed object/map fields: the driver (node-postgres) does NOT
|
|
7
11
|
// serialize a plain JS object to a jsonb column — it must arrive as a JSON
|
|
8
12
|
// string, or the write fails / writes "[object Object]". A `field.object`
|
|
@@ -14,25 +18,32 @@
|
|
|
14
18
|
// strings for temporal columns directly, and the temporal read parsers pin the
|
|
15
19
|
// wire form.
|
|
16
20
|
|
|
17
|
-
import type
|
|
21
|
+
import { refTarget, type MetaData } from "@metaobjectsdev/metadata";
|
|
18
22
|
import {
|
|
19
23
|
TYPE_FIELD,
|
|
20
24
|
FIELD_SUBTYPE_BOOLEAN,
|
|
25
|
+
FIELD_SUBTYPE_DECIMAL,
|
|
26
|
+
FIELD_SUBTYPE_MAP,
|
|
21
27
|
FIELD_SUBTYPE_ENUM,
|
|
22
28
|
FIELD_SUBTYPE_OBJECT,
|
|
23
29
|
FIELD_SUBTYPE_TIMESTAMP,
|
|
24
30
|
FIELD_ATTR_STORAGE,
|
|
31
|
+
FIELD_ATTR_OBJECT_REF,
|
|
32
|
+
OBJECT_SUBTYPE_VALUE,
|
|
25
33
|
FIELD_ATTR_DB_COLUMN_TYPE,
|
|
26
34
|
FIELD_ATTR_INT_VALUE_MAP,
|
|
35
|
+
FIELD_ATTR_PRECISION,
|
|
36
|
+
FIELD_ATTR_SCALE,
|
|
27
37
|
STORAGE_JSONB,
|
|
28
38
|
STORAGE_FLATTENED,
|
|
29
39
|
DB_COLUMN_TYPE_JSONB,
|
|
30
40
|
} from "@metaobjectsdev/metadata";
|
|
31
41
|
import type { Dialect, Row } from "./persistence-driver.js";
|
|
42
|
+
import { decimalFits, decimalToWire, parseDecimalInput } from "./decimal-wire.js";
|
|
32
43
|
|
|
33
44
|
export function coerceRowOnRead(entity: MetaData, row: Row, dialect: Dialect): Row {
|
|
34
45
|
const hydrated = parseJsonTextArrays(entity, deserializeJsonbObjectFields(entity, row));
|
|
35
|
-
const decoded = decodeIntBackedEnums(entity, hydrated);
|
|
46
|
+
const decoded = canonicalizeDecimals(entity, decodeIntBackedEnums(entity, hydrated));
|
|
36
47
|
if (dialect !== "sqlite" && dialect !== "mysql") return decoded;
|
|
37
48
|
return mapBooleansFromInt(entity, decoded);
|
|
38
49
|
}
|
|
@@ -130,7 +141,7 @@ function encodeIntBackedEnums(entity: MetaData, row: Row): Row {
|
|
|
130
141
|
}
|
|
131
142
|
|
|
132
143
|
export function coerceRowOnWrite(entity: MetaData, row: Row, dialect: Dialect): Row {
|
|
133
|
-
const jsonbColumned = serializeJsonbColumns(entity, row);
|
|
144
|
+
const jsonbColumned = serializeJsonbColumns(entity, bindDecimals(entity, row));
|
|
134
145
|
// Dialect-independent: an int-backed enum's column is INTEGER on every dialect
|
|
135
146
|
// (SQLite has one integer storage class), so the symbol→int encode is not gated
|
|
136
147
|
// on the dialect the way the boolean mapping below is.
|
|
@@ -212,8 +223,10 @@ function mapBooleansFromInt(entity: MetaData, row: Row): Row {
|
|
|
212
223
|
if (child.type !== TYPE_FIELD) continue;
|
|
213
224
|
if (child.subType !== FIELD_SUBTYPE_BOOLEAN) continue;
|
|
214
225
|
const v = out[child.name];
|
|
215
|
-
|
|
216
|
-
|
|
226
|
+
// A report's boolean measure (`@agg: any` / `all`) under a condition is a MySQL BIGINT, which
|
|
227
|
+
// the pool's `bigNumberStrings` hands back as text; a plain TINYINT(1) arrives as a number.
|
|
228
|
+
if (v === 0 || v === "0") out[child.name] = false;
|
|
229
|
+
else if (v === 1 || v === "1") out[child.name] = true;
|
|
217
230
|
}
|
|
218
231
|
return out;
|
|
219
232
|
}
|
|
@@ -279,3 +292,101 @@ function parseJsonTextArrays(entity: MetaData, row: Row): Row {
|
|
|
279
292
|
}
|
|
280
293
|
return out ?? row;
|
|
281
294
|
}
|
|
295
|
+
|
|
296
|
+
/** The `object.value` a jsonb `field.object` references, or undefined (not a value object, or unresolved). */
|
|
297
|
+
function valueObjectOf(field: MetaData): MetaData | undefined {
|
|
298
|
+
// A field.map of value objects binds @objectRef the same way (its values are the value objects).
|
|
299
|
+
if (field.type !== TYPE_FIELD || (field.subType !== FIELD_SUBTYPE_OBJECT && field.subType !== FIELD_SUBTYPE_MAP)) return undefined;
|
|
300
|
+
// ADR-0059 R7: the object the loader bound @objectRef to.
|
|
301
|
+
const target = refTarget(field, FIELD_ATTR_OBJECT_REF);
|
|
302
|
+
return target?.subType === OBJECT_SUBTYPE_VALUE ? target : undefined;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Apply `convert` to every `field.decimal` of `owner` present in `row`, and recurse into a
|
|
307
|
+
* `field.object` whose value object declares decimals (captain ruling C1: a decimal inside a
|
|
308
|
+
* jsonb value object follows the top-level rule). `convert` answers the new value; a row, an
|
|
309
|
+
* object or an array is copied only when something changed.
|
|
310
|
+
*/
|
|
311
|
+
function mapDecimals(
|
|
312
|
+
owner: MetaData,
|
|
313
|
+
row: Row,
|
|
314
|
+
convert: (field: MetaData, value: unknown) => unknown,
|
|
315
|
+
): Row {
|
|
316
|
+
let out: Row | null = null;
|
|
317
|
+
// Effective children (ADR-0039): a decimal may be inherited via extends, and a report's
|
|
318
|
+
// derived ratio / average / sum is a decimal field of the report itself.
|
|
319
|
+
for (const child of owner.children()) {
|
|
320
|
+
if (child.type !== TYPE_FIELD) continue;
|
|
321
|
+
if (!(child.name in row)) continue;
|
|
322
|
+
const v = row[child.name];
|
|
323
|
+
if (v === null || v === undefined) continue;
|
|
324
|
+
let next: unknown = v;
|
|
325
|
+
if (child.subType === FIELD_SUBTYPE_DECIMAL) {
|
|
326
|
+
next = convert(child, v);
|
|
327
|
+
} else {
|
|
328
|
+
const vo = valueObjectOf(child);
|
|
329
|
+
if (vo === undefined) continue;
|
|
330
|
+
const one = (el: unknown): unknown =>
|
|
331
|
+
el !== null && typeof el === "object" && !Array.isArray(el) ? mapDecimals(vo, el as Row, convert) : el;
|
|
332
|
+
if (child.subType === FIELD_SUBTYPE_MAP) {
|
|
333
|
+
// The rule applies to EACH map value; keys are arbitrary text, never compared with the
|
|
334
|
+
// value object's member names (a key named `amount` is just a key).
|
|
335
|
+
if (typeof v === "object" && !Array.isArray(v)) {
|
|
336
|
+
let mapped: Row | null = null;
|
|
337
|
+
for (const [key, el] of Object.entries(v as Row)) {
|
|
338
|
+
const m = one(el);
|
|
339
|
+
if (m !== el) (mapped ??= { ...(v as Row) })[key] = m;
|
|
340
|
+
}
|
|
341
|
+
if (mapped !== null) next = mapped;
|
|
342
|
+
}
|
|
343
|
+
} else if (Array.isArray(v)) {
|
|
344
|
+
const mapped = v.map(one);
|
|
345
|
+
if (mapped.some((el, i) => el !== v[i])) next = mapped;
|
|
346
|
+
} else {
|
|
347
|
+
next = one(v);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (next === v) continue;
|
|
351
|
+
out ??= { ...row };
|
|
352
|
+
out[child.name] = next;
|
|
353
|
+
}
|
|
354
|
+
return out ?? row;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Read side: every `field.decimal` the row carries becomes its canonical string. */
|
|
358
|
+
function canonicalizeDecimals(entity: MetaData, row: Row): Row {
|
|
359
|
+
return mapDecimals(entity, row, (_field, v) => decimalToWire(v));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Write side: a `field.decimal` value is bound as its exact canonical string, and a decimal
|
|
364
|
+
* declared `isArray` as an array of them. A string in the JSON-number grammar or a number a double
|
|
365
|
+
* holds exactly is accepted; anything else, or a value the declared `@precision` / `@scale` cannot
|
|
366
|
+
* hold (W3), is refused here rather than reaching a column that would round it, reject it with a
|
|
367
|
+
* driver error, or store garbage. A decimal inside a jsonb value object is bound the same way.
|
|
368
|
+
*/
|
|
369
|
+
function bindDecimals(entity: MetaData, row: Row): Row {
|
|
370
|
+
return mapDecimals(entity, row, (child, v) => {
|
|
371
|
+
// ADR-0039: resolving — a decimal, its isArray and its @precision / @scale may be inherited.
|
|
372
|
+
const precision = child.attr(FIELD_ATTR_PRECISION);
|
|
373
|
+
const scale = child.attr(FIELD_ATTR_SCALE);
|
|
374
|
+
const bind = (value: unknown): string => {
|
|
375
|
+
const canonical = parseDecimalInput(value);
|
|
376
|
+
if (canonical === undefined) {
|
|
377
|
+
throw new Error(`field.decimal '${child.name}' cannot be written: ${JSON.stringify(value)} is not a decimal number (a string in the JSON-number grammar, or a number a double holds exactly: send a string for more than 15 significant digits)`);
|
|
378
|
+
}
|
|
379
|
+
if (!decimalFits(canonical, typeof precision === "number" ? precision : undefined, typeof scale === "number" ? scale : undefined)) {
|
|
380
|
+
throw new Error(`field.decimal '${child.name}' cannot be written: ${canonical} does not fit @precision ${String(precision)} / @scale ${String(scale ?? 0)}`);
|
|
381
|
+
}
|
|
382
|
+
return canonical;
|
|
383
|
+
};
|
|
384
|
+
if (child.resolvedIsArray()) {
|
|
385
|
+
if (!Array.isArray(v)) {
|
|
386
|
+
throw new Error(`field.decimal '${child.name}' cannot be written: ${JSON.stringify(v)} is not an array of decimal numbers`);
|
|
387
|
+
}
|
|
388
|
+
return v.map((element) => (element === null ? null : bind(element)));
|
|
389
|
+
}
|
|
390
|
+
return bind(v);
|
|
391
|
+
});
|
|
392
|
+
}
|