@metaobjectsdev/runtime-ts 0.5.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +189 -0
- package/README.md +102 -0
- package/dist/constants.d.ts +5 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +6 -0
- package/dist/constants.js.map +1 -0
- package/dist/drivers/drizzle-driver.d.ts +25 -0
- package/dist/drivers/drizzle-driver.d.ts.map +1 -0
- package/dist/drivers/drizzle-driver.js +405 -0
- package/dist/drivers/drizzle-driver.js.map +1 -0
- package/dist/drivers/in-memory-driver.d.ts +11 -0
- package/dist/drivers/in-memory-driver.d.ts.map +1 -0
- package/dist/drivers/in-memory-driver.js +232 -0
- package/dist/drivers/in-memory-driver.js.map +1 -0
- package/dist/drivers/index.d.ts +4 -0
- package/dist/drivers/index.d.ts.map +1 -0
- package/dist/drivers/index.js +4 -0
- package/dist/drivers/index.js.map +1 -0
- package/dist/drivers/kysely-driver.d.ts +12 -0
- package/dist/drivers/kysely-driver.d.ts.map +1 -0
- package/dist/drivers/kysely-driver.js +203 -0
- package/dist/drivers/kysely-driver.js.map +1 -0
- package/dist/drizzle-fastify/filter-allowlist.d.ts +19 -0
- package/dist/drizzle-fastify/filter-allowlist.d.ts.map +1 -0
- package/dist/drizzle-fastify/filter-allowlist.js +10 -0
- package/dist/drizzle-fastify/filter-allowlist.js.map +1 -0
- package/dist/drizzle-fastify/filter-parser.d.ts +28 -0
- package/dist/drizzle-fastify/filter-parser.d.ts.map +1 -0
- package/dist/drizzle-fastify/filter-parser.js +185 -0
- package/dist/drizzle-fastify/filter-parser.js.map +1 -0
- package/dist/drizzle-fastify/index.d.ts +48 -0
- package/dist/drizzle-fastify/index.d.ts.map +1 -0
- package/dist/drizzle-fastify/index.js +181 -0
- package/dist/drizzle-fastify/index.js.map +1 -0
- package/dist/drizzle-fastify/mount-read-only.d.ts +17 -0
- package/dist/drizzle-fastify/mount-read-only.d.ts.map +1 -0
- package/dist/drizzle-fastify/mount-read-only.js +159 -0
- package/dist/drizzle-fastify/mount-read-only.js.map +1 -0
- package/dist/drizzle-fastify/util.d.ts +5 -0
- package/dist/drizzle-fastify/util.d.ts.map +1 -0
- package/dist/drizzle-fastify/util.js +12 -0
- package/dist/drizzle-fastify/util.js.map +1 -0
- package/dist/errors.d.ts +68 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +86 -0
- package/dist/errors.js.map +1 -0
- package/dist/fastify/index.d.ts +65 -0
- package/dist/fastify/index.d.ts.map +1 -0
- package/dist/fastify/index.js +118 -0
- package/dist/fastify/index.js.map +1 -0
- package/dist/identity-strategy.d.ts +10 -0
- package/dist/identity-strategy.d.ts.map +1 -0
- package/dist/identity-strategy.js +67 -0
- package/dist/identity-strategy.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/n2m-resolver.d.ts +27 -0
- package/dist/n2m-resolver.d.ts.map +1 -0
- package/dist/n2m-resolver.js +103 -0
- package/dist/n2m-resolver.js.map +1 -0
- package/dist/object-manager.d.ts +51 -0
- package/dist/object-manager.d.ts.map +1 -0
- package/dist/object-manager.js +355 -0
- package/dist/object-manager.js.map +1 -0
- package/dist/persistence-driver.d.ts +88 -0
- package/dist/persistence-driver.d.ts.map +1 -0
- package/dist/persistence-driver.js +5 -0
- package/dist/persistence-driver.js.map +1 -0
- package/dist/query-builder.d.ts +35 -0
- package/dist/query-builder.d.ts.map +1 -0
- package/dist/query-builder.js +178 -0
- package/dist/query-builder.js.map +1 -0
- package/dist/ref-codec.d.ts +8 -0
- package/dist/ref-codec.d.ts.map +1 -0
- package/dist/ref-codec.js +42 -0
- package/dist/ref-codec.js.map +1 -0
- package/dist/relation-resolver.d.ts +27 -0
- package/dist/relation-resolver.d.ts.map +1 -0
- package/dist/relation-resolver.js +136 -0
- package/dist/relation-resolver.js.map +1 -0
- package/dist/type-coercer.d.ts +5 -0
- package/dist/type-coercer.d.ts.map +1 -0
- package/dist/type-coercer.js +43 -0
- package/dist/type-coercer.js.map +1 -0
- package/dist/validator-runner.d.ts +14 -0
- package/dist/validator-runner.d.ts.map +1 -0
- package/dist/validator-runner.js +155 -0
- package/dist/validator-runner.js.map +1 -0
- package/dist/view.d.ts +19 -0
- package/dist/view.d.ts.map +1 -0
- package/dist/view.js +60 -0
- package/dist/view.js.map +1 -0
- package/package.json +87 -0
- package/src/constants.ts +7 -0
- package/src/drivers/drizzle-driver.ts +474 -0
- package/src/drivers/in-memory-driver.ts +256 -0
- package/src/drivers/index.ts +3 -0
- package/src/drivers/kysely-driver.ts +240 -0
- package/src/drizzle-fastify/filter-allowlist.ts +31 -0
- package/src/drizzle-fastify/filter-parser.ts +229 -0
- package/src/drizzle-fastify/index.ts +225 -0
- package/src/drizzle-fastify/mount-read-only.ts +187 -0
- package/src/drizzle-fastify/util.ts +10 -0
- package/src/errors.ts +114 -0
- package/src/fastify/index.ts +181 -0
- package/src/identity-strategy.ts +92 -0
- package/src/index.ts +24 -0
- package/src/n2m-resolver.ts +152 -0
- package/src/object-manager.ts +444 -0
- package/src/persistence-driver.ts +92 -0
- package/src/query-builder.ts +224 -0
- package/src/ref-codec.ts +64 -0
- package/src/relation-resolver.ts +171 -0
- package/src/type-coercer.ts +39 -0
- package/src/validator-runner.ts +168 -0
- package/src/view.ts +82 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import type { MetaData } from "@metaobjectsdev/metadata";
|
|
2
|
+
import {
|
|
3
|
+
TYPE_OBJECT, TYPE_FIELD,
|
|
4
|
+
FIELD_SUBTYPE_INT, FIELD_SUBTYPE_SHORT, FIELD_SUBTYPE_BYTE,
|
|
5
|
+
FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, FIELD_SUBTYPE_DECIMAL,
|
|
6
|
+
} from "@metaobjectsdev/metadata";
|
|
7
|
+
import type {
|
|
8
|
+
PersistenceDriver, Row, WhereClause,
|
|
9
|
+
InsertManySpec, UpdateManySpec, DeleteManySpec,
|
|
10
|
+
} from "./persistence-driver.js";
|
|
11
|
+
import {
|
|
12
|
+
buildSelectSpec, buildCountSpec, buildInsertSpec, buildUpdateSpec, buildDeleteSpec,
|
|
13
|
+
resolvePkFields, compileFilter,
|
|
14
|
+
type Filter, type QueryOpts,
|
|
15
|
+
} from "./query-builder.js";
|
|
16
|
+
import { buildNameMap, resolveTableName, type EntityNameMap } from "@metaobjectsdev/metadata";
|
|
17
|
+
import { coerceRowOnRead, coerceRowOnWrite } from "./type-coercer.js";
|
|
18
|
+
import { decodeRef, encodeRef } from "./ref-codec.js";
|
|
19
|
+
import { runValidators } from "./validator-runner.js";
|
|
20
|
+
import { resolveIdentity } from "./identity-strategy.js";
|
|
21
|
+
import {
|
|
22
|
+
viewFieldNames,
|
|
23
|
+
fieldViewSpec, entityViewSpec,
|
|
24
|
+
type FieldViewSpec, type EntityViewSpec,
|
|
25
|
+
} from "./view.js";
|
|
26
|
+
import type { ValidationResult } from "./validator-runner.js";
|
|
27
|
+
import {
|
|
28
|
+
resolveRelationDescriptor, buildLazyRelateSpec, buildIncludeBatchSpec,
|
|
29
|
+
} from "./relation-resolver.js";
|
|
30
|
+
import {
|
|
31
|
+
resolveN2mDescriptor, buildN2mLazySpecs, buildN2mBatchSpecs,
|
|
32
|
+
resolveJoinColumnName,
|
|
33
|
+
} from "./n2m-resolver.js";
|
|
34
|
+
import { MetadataError, UnsafeNameError, ValidationError, NotFoundError } from "./errors.js";
|
|
35
|
+
import { VALID_ENTITY_NAME, DEFAULT_IF_MISSING } from "./constants.js";
|
|
36
|
+
|
|
37
|
+
export interface ObjectManagerOptions {
|
|
38
|
+
metadata: MetaData;
|
|
39
|
+
driver: PersistenceDriver;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ReadOpts extends QueryOpts {
|
|
43
|
+
view?: string;
|
|
44
|
+
include?: string[];
|
|
45
|
+
tx?: PersistenceDriver;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface WriteOpts {
|
|
49
|
+
view?: string;
|
|
50
|
+
tx?: PersistenceDriver;
|
|
51
|
+
ifMissing?: "throw" | "ignore";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class ObjectManager {
|
|
55
|
+
private readonly metadata: MetaData;
|
|
56
|
+
private readonly driver: PersistenceDriver;
|
|
57
|
+
private readonly nameMapCache = new Map<string, EntityNameMap>();
|
|
58
|
+
|
|
59
|
+
constructor(opts: ObjectManagerOptions) {
|
|
60
|
+
this.metadata = opts.metadata;
|
|
61
|
+
this.driver = opts.driver;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private nameMap(entity: MetaData): EntityNameMap {
|
|
65
|
+
let m = this.nameMapCache.get(entity.name);
|
|
66
|
+
if (!m) {
|
|
67
|
+
m = buildNameMap(entity);
|
|
68
|
+
this.nameMapCache.set(entity.name, m);
|
|
69
|
+
}
|
|
70
|
+
return m;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async findById(entityName: string, id: unknown, opts: ReadOpts = {}): Promise<Row | null> {
|
|
74
|
+
const entity = this.requireEntity(entityName);
|
|
75
|
+
const pkField = resolvePkFields(entity)[0]!;
|
|
76
|
+
return this.findFirst(entityName, { [pkField]: id as string | number }, opts);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async findFirst(entityName: string, filter: Filter, opts: ReadOpts = {}): Promise<Row | null> {
|
|
80
|
+
const entity = this.requireEntity(entityName);
|
|
81
|
+
const driver = opts.tx ?? this.driver;
|
|
82
|
+
const spec = buildSelectSpec(entity, filter, { ...opts, limit: 1 });
|
|
83
|
+
const row = await driver.selectOne(spec);
|
|
84
|
+
if (row === null) return null;
|
|
85
|
+
const jsRow = this.toJsRow(entity, row);
|
|
86
|
+
if (opts.include && opts.include.length > 0) {
|
|
87
|
+
await this.attachIncludes(entity, [jsRow], opts.include, driver);
|
|
88
|
+
}
|
|
89
|
+
return jsRow;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async findMany(entityName: string, filter?: Filter, opts: ReadOpts = {}): Promise<Row[]> {
|
|
93
|
+
const entity = this.requireEntity(entityName);
|
|
94
|
+
const driver = opts.tx ?? this.driver;
|
|
95
|
+
const spec = buildSelectSpec(entity, filter, opts);
|
|
96
|
+
const rows = (await driver.selectMany(spec)).map((r) => this.toJsRow(entity, r));
|
|
97
|
+
if (opts.include && opts.include.length > 0) {
|
|
98
|
+
await this.attachIncludes(entity, rows, opts.include, driver);
|
|
99
|
+
}
|
|
100
|
+
return rows;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async count(entityName: string, filter?: Filter, opts: Pick<ReadOpts, "tx"> = {}): Promise<number> {
|
|
104
|
+
const entity = this.requireEntity(entityName);
|
|
105
|
+
const driver = opts.tx ?? this.driver;
|
|
106
|
+
return driver.count(buildCountSpec(entity, filter));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async load(refString: string): Promise<Row | null> {
|
|
110
|
+
const { entity: entityName, pkValues } = decodeRef(refString);
|
|
111
|
+
const entity = this.requireEntity(entityName);
|
|
112
|
+
const pkFields = resolvePkFields(entity);
|
|
113
|
+
if (pkValues.length !== pkFields.length) {
|
|
114
|
+
throw new MetadataError(
|
|
115
|
+
`Reference '${refString}' has ${pkValues.length} PK values; entity '${entityName}' expects ${pkFields.length}`,
|
|
116
|
+
{ entity: entityName },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const filter: Filter = {};
|
|
120
|
+
for (let i = 0; i < pkFields.length; i++) {
|
|
121
|
+
const fieldName = pkFields[i]!;
|
|
122
|
+
const rawValue = pkValues[i]!;
|
|
123
|
+
filter[fieldName] = coercePkValue(entity, fieldName, rawValue);
|
|
124
|
+
}
|
|
125
|
+
return this.findFirst(entityName, filter);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
refOf(entityName: string, record: Row): string {
|
|
129
|
+
const entity = this.requireEntity(entityName);
|
|
130
|
+
return encodeRef(entityName, record, resolvePkFields(entity));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async create(entityName: string, data: Row, opts: WriteOpts = {}): Promise<Row> {
|
|
134
|
+
const entity = this.requireEntity(entityName);
|
|
135
|
+
const driver = opts.tx ?? this.driver;
|
|
136
|
+
|
|
137
|
+
const restricted = this.applyViewRestriction(entity, data, opts.view);
|
|
138
|
+
const ident = resolveIdentity(entity, restricted);
|
|
139
|
+
const merged: Row = { ...restricted, ...ident.values };
|
|
140
|
+
|
|
141
|
+
const validation = runValidators(entity, merged);
|
|
142
|
+
if (!validation.ok) {
|
|
143
|
+
throw new ValidationError(formatValidationMessage(entityName, validation.errors), { entity: entityName, errors: validation.errors });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const coerced = coerceRowOnWrite(entity, merged, driver.dialect);
|
|
147
|
+
const spec = buildInsertSpec(entity, coerced);
|
|
148
|
+
const dbRow = await driver.insert(spec);
|
|
149
|
+
return this.toJsRow(entity, dbRow);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async update(entityName: string, id: unknown, data: Row, opts: WriteOpts = {}): Promise<Row | null> {
|
|
153
|
+
const entity = this.requireEntity(entityName);
|
|
154
|
+
const driver = opts.tx ?? this.driver;
|
|
155
|
+
|
|
156
|
+
const restricted = this.applyViewRestriction(entity, data, opts.view);
|
|
157
|
+
|
|
158
|
+
// Partial mode: only validate fields the caller actually passed; absent keys are untouched.
|
|
159
|
+
const validation = runValidators(entity, restricted, { partial: true });
|
|
160
|
+
if (!validation.ok) {
|
|
161
|
+
throw new ValidationError(formatValidationMessage(entityName, validation.errors), { entity: entityName, errors: validation.errors });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const coerced = coerceRowOnWrite(entity, restricted, driver.dialect);
|
|
165
|
+
const spec = buildUpdateSpec(entity, coerced, id);
|
|
166
|
+
const dbRow = await driver.update(spec);
|
|
167
|
+
if (dbRow === null) {
|
|
168
|
+
const mode = opts.ifMissing ?? DEFAULT_IF_MISSING;
|
|
169
|
+
if (mode === "throw") throw new NotFoundError(`${entityName} ${String(id)} not found`, { entity: entityName, id });
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
return this.toJsRow(entity, dbRow);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async delete(entityName: string, id: unknown, opts: WriteOpts = {}): Promise<boolean> {
|
|
176
|
+
const entity = this.requireEntity(entityName);
|
|
177
|
+
const driver = opts.tx ?? this.driver;
|
|
178
|
+
const spec = buildDeleteSpec(entity, id);
|
|
179
|
+
const n = await driver.delete(spec);
|
|
180
|
+
if (n === 0) {
|
|
181
|
+
const mode = opts.ifMissing ?? DEFAULT_IF_MISSING;
|
|
182
|
+
if (mode === "throw") throw new NotFoundError(`${entityName} ${String(id)} not found`, { entity: entityName, id });
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async createMany(entityName: string, dataArray: Row[], opts: WriteOpts = {}): Promise<Row[]> {
|
|
189
|
+
const entity = this.requireEntity(entityName);
|
|
190
|
+
const driver = opts.tx ?? this.driver;
|
|
191
|
+
|
|
192
|
+
// Validate + identity-resolve every row before any insert so a late failure can't leave partial state.
|
|
193
|
+
const validatedRows: Row[] = [];
|
|
194
|
+
for (const data of dataArray) {
|
|
195
|
+
const restricted = this.applyViewRestriction(entity, data, opts.view);
|
|
196
|
+
const ident = resolveIdentity(entity, restricted);
|
|
197
|
+
const merged: Row = { ...restricted, ...ident.values };
|
|
198
|
+
const v = runValidators(entity, merged);
|
|
199
|
+
if (!v.ok) {
|
|
200
|
+
throw new ValidationError(formatValidationMessage(entityName, v.errors), { entity: entityName, errors: v.errors });
|
|
201
|
+
}
|
|
202
|
+
validatedRows.push(merged);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const spec: InsertManySpec = {
|
|
206
|
+
table: resolveTableName(entity),
|
|
207
|
+
rows: validatedRows.map((r) => this.toDbRow(entity, coerceRowOnWrite(entity, r, driver.dialect))),
|
|
208
|
+
returning: this.allDbColumns(entity),
|
|
209
|
+
};
|
|
210
|
+
// Wrap the batch in a transaction so a driver-level constraint failure on row N
|
|
211
|
+
// rolls back rows 1..N-1. Caller-supplied opts.tx already provides this.
|
|
212
|
+
const dbRows = opts.tx
|
|
213
|
+
? await driver.insertMany(spec)
|
|
214
|
+
: await driver.transaction((tx) => tx.insertMany(spec));
|
|
215
|
+
return dbRows.map((r) => this.toJsRow(entity, r));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async updateMany(entityName: string, filter: Filter, partial: Row, opts: WriteOpts = {}): Promise<number> {
|
|
219
|
+
const entity = this.requireEntity(entityName);
|
|
220
|
+
const driver = opts.tx ?? this.driver;
|
|
221
|
+
const restricted = this.applyViewRestriction(entity, partial, opts.view);
|
|
222
|
+
const v = runValidators(entity, restricted, { partial: true });
|
|
223
|
+
if (!v.ok) {
|
|
224
|
+
throw new ValidationError(formatValidationMessage(entityName, v.errors), { entity: entityName, errors: v.errors });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const coerced = coerceRowOnWrite(entity, restricted, driver.dialect);
|
|
228
|
+
const spec: UpdateManySpec = {
|
|
229
|
+
table: resolveTableName(entity),
|
|
230
|
+
values: this.toDbRow(entity, coerced),
|
|
231
|
+
where: requireNonEmptyFilter(entity, filter, "updateMany"),
|
|
232
|
+
};
|
|
233
|
+
return driver.updateMany(spec);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async deleteMany(entityName: string, filter: Filter, opts: WriteOpts = {}): Promise<number> {
|
|
237
|
+
const entity = this.requireEntity(entityName);
|
|
238
|
+
const driver = opts.tx ?? this.driver;
|
|
239
|
+
const spec: DeleteManySpec = {
|
|
240
|
+
table: resolveTableName(entity),
|
|
241
|
+
where: requireNonEmptyFilter(entity, filter, "deleteMany"),
|
|
242
|
+
};
|
|
243
|
+
return driver.deleteMany(spec);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async transaction<T>(fn: (txOm: ObjectManager) => Promise<T>): Promise<T> {
|
|
247
|
+
return this.driver.transaction(async (txDriver) => {
|
|
248
|
+
const txOm = new ObjectManager({ metadata: this.metadata, driver: txDriver });
|
|
249
|
+
return fn(txOm);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async relate(entityName: string, record: Row, relationName: string, opts: ReadOpts = {}): Promise<Row | Row[] | null> {
|
|
254
|
+
const entity = this.requireEntity(entityName);
|
|
255
|
+
const driver = opts.tx ?? this.driver;
|
|
256
|
+
|
|
257
|
+
// N:M is checked first because its descriptor is more specific; fall through to 1:1/1:N otherwise.
|
|
258
|
+
const n2m = resolveN2mDescriptor(entity, relationName, this.metadata);
|
|
259
|
+
if (n2m !== null) {
|
|
260
|
+
const target = this.requireEntity(n2m.targetEntityName);
|
|
261
|
+
const { joinSpec, makeTargetSpec } = buildN2mLazySpecs(n2m, record, this.metadata);
|
|
262
|
+
const joinRows = await driver.selectMany(joinSpec);
|
|
263
|
+
const targetSpec = makeTargetSpec(joinRows);
|
|
264
|
+
if (targetSpec === null) return [];
|
|
265
|
+
const rows = await driver.selectMany(targetSpec);
|
|
266
|
+
return rows.map((r) => this.toJsRow(target, r));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const desc = resolveRelationDescriptor(entity, relationName, this.metadata);
|
|
270
|
+
const target = this.requireEntity(desc.targetEntityName);
|
|
271
|
+
const spec = buildLazyRelateSpec(desc, record, this.metadata);
|
|
272
|
+
if (spec === null) return desc.cardinality === "one" ? null : [];
|
|
273
|
+
if (desc.cardinality === "one") {
|
|
274
|
+
const row = await driver.selectOne(spec);
|
|
275
|
+
return row === null ? null : this.toJsRow(target, row);
|
|
276
|
+
}
|
|
277
|
+
const rows = await driver.selectMany(spec);
|
|
278
|
+
return rows.map((r) => this.toJsRow(target, r));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
viewFields(entityName: string, viewName: string): string[] {
|
|
282
|
+
return viewFieldNames(this.requireEntity(entityName), viewName);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
fieldView(entityName: string, fieldName: string, viewName: string): FieldViewSpec | null {
|
|
286
|
+
return fieldViewSpec(this.requireEntity(entityName), fieldName, viewName);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
entityView(entityName: string, viewName: string): EntityViewSpec {
|
|
290
|
+
return entityViewSpec(this.requireEntity(entityName), viewName);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
validate(entityName: string, data: Row): ValidationResult {
|
|
294
|
+
return runValidators(this.requireEntity(entityName), data);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private async attachIncludes(
|
|
298
|
+
entity: MetaData,
|
|
299
|
+
records: Row[],
|
|
300
|
+
includes: string[],
|
|
301
|
+
driver: PersistenceDriver,
|
|
302
|
+
): Promise<void> {
|
|
303
|
+
for (const inc of includes) {
|
|
304
|
+
if (inc.includes(".")) {
|
|
305
|
+
throw new MetadataError(
|
|
306
|
+
`Nested includes ('${inc}') are not supported in v0.1; flat includes only`,
|
|
307
|
+
{ entity: entity.name },
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const n2m = resolveN2mDescriptor(entity, inc, this.metadata);
|
|
312
|
+
if (n2m !== null) {
|
|
313
|
+
const target = this.requireEntity(n2m.targetEntityName);
|
|
314
|
+
const { joinSpec, makeTargetSpec } = buildN2mBatchSpecs(n2m, records, this.metadata);
|
|
315
|
+
const joinRows = await driver.selectMany(joinSpec);
|
|
316
|
+
const targetSpec = makeTargetSpec(joinRows);
|
|
317
|
+
const targetRows = targetSpec === null ? [] : (await driver.selectMany(targetSpec)).map((r) => this.toJsRow(target, r));
|
|
318
|
+
|
|
319
|
+
const sourcePk = resolvePkFields(entity)[0]!;
|
|
320
|
+
const joinEntity = this.requireEntity(n2m.joinEntityName);
|
|
321
|
+
const sourceJoinDbCol = resolveJoinColumnName(joinEntity, n2m.sourceJoinField);
|
|
322
|
+
const targetJoinDbCol = resolveJoinColumnName(joinEntity, n2m.targetJoinField);
|
|
323
|
+
const targetPk = resolvePkFields(target)[0]!;
|
|
324
|
+
const targetById = new Map(targetRows.map((r) => [r[targetPk], r]));
|
|
325
|
+
const grouped = new Map<unknown, Row[]>();
|
|
326
|
+
for (const j of joinRows) {
|
|
327
|
+
const sk = j[sourceJoinDbCol];
|
|
328
|
+
const tk = j[targetJoinDbCol];
|
|
329
|
+
if (!grouped.has(sk)) grouped.set(sk, []);
|
|
330
|
+
const t = targetById.get(tk);
|
|
331
|
+
if (t) grouped.get(sk)!.push(t);
|
|
332
|
+
}
|
|
333
|
+
for (const r of records) r[inc] = grouped.get(r[sourcePk]) ?? [];
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const desc = resolveRelationDescriptor(entity, inc, this.metadata);
|
|
338
|
+
const target = this.requireEntity(desc.targetEntityName);
|
|
339
|
+
const spec = buildIncludeBatchSpec(desc, records, this.metadata);
|
|
340
|
+
const targetRows = spec === null ? [] : (await driver.selectMany(spec)).map((r) => this.toJsRow(target, r));
|
|
341
|
+
|
|
342
|
+
if (desc.cardinality === "one") {
|
|
343
|
+
const byKey = new Map(targetRows.map((r) => [r[desc.targetField], r]));
|
|
344
|
+
for (const r of records) r[inc] = byKey.get(r[desc.sourceField]) ?? null;
|
|
345
|
+
} else {
|
|
346
|
+
const grouped = new Map<unknown, Row[]>();
|
|
347
|
+
for (const t of targetRows) {
|
|
348
|
+
const k = t[desc.targetField];
|
|
349
|
+
if (!grouped.has(k)) grouped.set(k, []);
|
|
350
|
+
grouped.get(k)!.push(t);
|
|
351
|
+
}
|
|
352
|
+
for (const r of records) r[inc] = grouped.get(r[desc.sourceField]) ?? [];
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private toDbRow(entity: MetaData, jsRow: Row): Row {
|
|
358
|
+
const { jsToDb } = this.nameMap(entity);
|
|
359
|
+
const out: Row = {};
|
|
360
|
+
for (const [jsName, dbCol] of jsToDb) {
|
|
361
|
+
if (jsName in jsRow) out[dbCol] = jsRow[jsName];
|
|
362
|
+
}
|
|
363
|
+
return out;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private allDbColumns(entity: MetaData): string[] {
|
|
367
|
+
return [...this.nameMap(entity).jsToDb.values()];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private applyViewRestriction(entity: MetaData, data: Row, viewName: string | undefined): Row {
|
|
371
|
+
if (viewName === undefined) return data;
|
|
372
|
+
const allowed = new Set(viewFieldNames(entity, viewName));
|
|
373
|
+
const offending = Object.keys(data).filter((k) => !allowed.has(k));
|
|
374
|
+
if (offending.length > 0) {
|
|
375
|
+
throw new ValidationError(
|
|
376
|
+
`View '${viewName}' on '${entity.name}' does not allow fields: ${offending.join(", ")}`,
|
|
377
|
+
{
|
|
378
|
+
entity: entity.name,
|
|
379
|
+
errors: offending.map((field) => ({
|
|
380
|
+
field, rule: "view_restricted",
|
|
381
|
+
message: `Field '${field}' is not in view '${viewName}'`,
|
|
382
|
+
})),
|
|
383
|
+
},
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
return data;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private requireEntity(entityName: string): MetaData {
|
|
390
|
+
if (!VALID_ENTITY_NAME.test(entityName)) {
|
|
391
|
+
throw new UnsafeNameError(
|
|
392
|
+
`Unsafe entity name '${entityName}'`,
|
|
393
|
+
{ value: entityName },
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
const entity = this.metadata.ownChildren().find((c) => c.type === TYPE_OBJECT && c.name === entityName);
|
|
397
|
+
if (!entity) {
|
|
398
|
+
throw new MetadataError(`Unknown entity '${entityName}'`, { entity: entityName });
|
|
399
|
+
}
|
|
400
|
+
return entity;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private toJsRow(entity: MetaData, dbRow: Row): Row {
|
|
404
|
+
const { dbToJs } = this.nameMap(entity);
|
|
405
|
+
const out: Row = {};
|
|
406
|
+
for (const [dbCol, jsName] of dbToJs) {
|
|
407
|
+
if (dbCol in dbRow) out[jsName] = dbRow[dbCol];
|
|
408
|
+
}
|
|
409
|
+
return coerceRowOnRead(entity, out, this.driver.dialect);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// updateMany / deleteMany with an empty filter would silently affect every row.
|
|
414
|
+
// Force callers to be explicit (use $or-style or a tautology if they really mean "all").
|
|
415
|
+
function requireNonEmptyFilter(entity: MetaData, filter: Filter, op: string): WhereClause {
|
|
416
|
+
const where = compileFilter(entity, filter);
|
|
417
|
+
if (where === null) {
|
|
418
|
+
throw new MetadataError(
|
|
419
|
+
`${op} on '${entity.name}' requires a non-empty filter — pass an explicit condition or use a per-row loop`,
|
|
420
|
+
{ entity: entity.name },
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
return where;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function formatValidationMessage(entityName: string, errors: { field: string; rule: string }[]): string {
|
|
427
|
+
const summary = errors.map((e) => `${e.field}: ${e.rule}`).join("; ");
|
|
428
|
+
return `Validation failed for ${entityName} (${summary})`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const NUMERIC_SUBTYPES = new Set([
|
|
432
|
+
FIELD_SUBTYPE_INT, FIELD_SUBTYPE_SHORT, FIELD_SUBTYPE_BYTE,
|
|
433
|
+
FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, FIELD_SUBTYPE_DECIMAL,
|
|
434
|
+
]);
|
|
435
|
+
|
|
436
|
+
// decodeRef always returns strings; numeric PK fields need coercion back to number.
|
|
437
|
+
function coercePkValue(entity: MetaData, fieldName: string, rawValue: string): string | number {
|
|
438
|
+
const field = entity.ownChildren().find((c) => c.type === TYPE_FIELD && c.name === fieldName);
|
|
439
|
+
if (field && NUMERIC_SUBTYPES.has(field.subType)) {
|
|
440
|
+
const n = Number(rawValue);
|
|
441
|
+
if (!Number.isNaN(n)) return n;
|
|
442
|
+
}
|
|
443
|
+
return rawValue;
|
|
444
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Concrete impls: kyselyDriver (real DBs), inMemoryDriver (tests).
|
|
2
|
+
// The driver IS the unit of transaction — transaction(fn) yields a tx-scoped sub-driver.
|
|
3
|
+
// Java analog: ObjectConnection.
|
|
4
|
+
|
|
5
|
+
export type Dialect = "sqlite" | "postgres" | "memory";
|
|
6
|
+
|
|
7
|
+
export type PrimitiveValue = string | number | boolean;
|
|
8
|
+
|
|
9
|
+
export type WhereClause =
|
|
10
|
+
| { kind: "eq" | "ne"; column: string; value: PrimitiveValue | null }
|
|
11
|
+
| { kind: "gt" | "gte" | "lt" | "lte"; column: string; value: number | string }
|
|
12
|
+
| { kind: "in"; column: string; values: PrimitiveValue[] }
|
|
13
|
+
| { kind: "like"; column: string; pattern: string }
|
|
14
|
+
| { kind: "isNull"; column: string; not: boolean }
|
|
15
|
+
| { kind: "and"; clauses: WhereClause[] };
|
|
16
|
+
|
|
17
|
+
export interface OrderBy {
|
|
18
|
+
column: string;
|
|
19
|
+
direction: "asc" | "desc";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type Row = Record<string, unknown>;
|
|
23
|
+
|
|
24
|
+
export interface SelectSpec {
|
|
25
|
+
table: string;
|
|
26
|
+
/** PK columns are always included by the caller. */
|
|
27
|
+
columns: string[];
|
|
28
|
+
where?: WhereClause;
|
|
29
|
+
orderBy?: OrderBy[];
|
|
30
|
+
limit?: number;
|
|
31
|
+
offset?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CountSpec {
|
|
35
|
+
table: string;
|
|
36
|
+
where?: WhereClause;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface InsertSpec {
|
|
40
|
+
table: string;
|
|
41
|
+
values: Row;
|
|
42
|
+
/** Columns to return — driver handles RETURNING / lastInsertId per dialect. */
|
|
43
|
+
returning: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface InsertManySpec {
|
|
47
|
+
table: string;
|
|
48
|
+
rows: Row[];
|
|
49
|
+
returning: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface UpdateSpec {
|
|
53
|
+
table: string;
|
|
54
|
+
values: Row;
|
|
55
|
+
where: WhereClause;
|
|
56
|
+
returning: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface UpdateManySpec {
|
|
60
|
+
table: string;
|
|
61
|
+
values: Row;
|
|
62
|
+
where: WhereClause;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface DeleteSpec {
|
|
66
|
+
table: string;
|
|
67
|
+
where: WhereClause;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface DeleteManySpec {
|
|
71
|
+
table: string;
|
|
72
|
+
where: WhereClause;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface PersistenceDriver {
|
|
76
|
+
readonly dialect: Dialect;
|
|
77
|
+
|
|
78
|
+
selectOne(spec: SelectSpec): Promise<Row | null>;
|
|
79
|
+
selectMany(spec: SelectSpec): Promise<Row[]>;
|
|
80
|
+
count(spec: CountSpec): Promise<number>;
|
|
81
|
+
|
|
82
|
+
insert(spec: InsertSpec): Promise<Row>;
|
|
83
|
+
insertMany(spec: InsertManySpec): Promise<Row[]>;
|
|
84
|
+
|
|
85
|
+
update(spec: UpdateSpec): Promise<Row | null>;
|
|
86
|
+
updateMany(spec: UpdateManySpec): Promise<number>;
|
|
87
|
+
|
|
88
|
+
delete(spec: DeleteSpec): Promise<number>;
|
|
89
|
+
deleteMany(spec: DeleteManySpec): Promise<number>;
|
|
90
|
+
|
|
91
|
+
transaction<T>(fn: (txDriver: PersistenceDriver) => Promise<T>): Promise<T>;
|
|
92
|
+
}
|