@fougere/adapter-sql 0.3.0-alpha.0 → 0.4.0-alpha.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/README.md +1 -1
- package/dist/crud.d.ts +3 -3
- package/dist/ddl.d.ts.map +1 -1
- package/dist/ddl.js +2 -2
- package/dist/ddl.js.map +1 -1
- package/dist/dialect.d.ts +8 -0
- package/dist/dialect.d.ts.map +1 -1
- package/dist/dialect.js +10 -0
- package/dist/dialect.js.map +1 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +2 -2
- package/dist/diff.js.map +1 -1
- package/dist/fields.d.ts +22 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +2 -0
- package/dist/fields.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/step.js +3 -3
- package/dist/step.js.map +1 -1
- package/dist/table.d.ts +18 -9
- package/dist/table.d.ts.map +1 -1
- package/dist/table.js +22 -17
- package/dist/table.js.map +1 -1
- package/package.json +7 -5
- package/src/check.ts +76 -0
- package/src/crud.ts +570 -0
- package/src/ddl.ts +242 -0
- package/src/dialect.ts +204 -0
- package/src/diff.ts +196 -0
- package/src/fields.ts +24 -0
- package/src/index.ts +40 -0
- package/src/setup.ts +63 -0
- package/src/sqlite.ts +43 -0
- package/src/step.ts +287 -0
- package/src/table.ts +447 -0
- package/src/values.ts +105 -0
package/src/crud.ts
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
import { Lifecycle, Role } from '@fougere/schema';
|
|
2
|
+
/**
|
|
3
|
+
* SqlEntityOrm — per-entity ORM over Kysely, one implementation for every engine.
|
|
4
|
+
*
|
|
5
|
+
* Structurally matches @fougere/core's EntityOrm (duck typed, no dep). There is
|
|
6
|
+
* no generated table object: Kysely addresses tables and columns by name, so the
|
|
7
|
+
* entity stays the only description. The field↔column mapping is explicit rather
|
|
8
|
+
* than a global plugin — auth tables carry their own naming and must not be
|
|
9
|
+
* rewritten behind the caller's back.
|
|
10
|
+
*
|
|
11
|
+
* `create` and `update` re-read the row instead of using `RETURNING`: the
|
|
12
|
+
* contract is to hand back the COMPLETE row, including defaults realised by SQL.
|
|
13
|
+
* That also makes the code identical on MySQL and SQL Server, which have no
|
|
14
|
+
* `RETURNING` clause.
|
|
15
|
+
*/
|
|
16
|
+
import { sql, type Kysely } from 'kysely';
|
|
17
|
+
import { applyCreate, applyUpdate, schemaOf, type Fields, type SchemaView, type SchemaOrCard } from '@fougere/schema';
|
|
18
|
+
import { toTable, toTableName, type TableDef } from './table.js';
|
|
19
|
+
import { resolveDialect, type Dialect, type DialectName } from './dialect.js';
|
|
20
|
+
// The contract entry and not the main one: `FougereError` crosses a process boundary and
|
|
21
|
+
// lives there for that reason, and this package must not drag the boot to raise one.
|
|
22
|
+
import { FougereError, ErrorCode } from '@fougere/core/contract';
|
|
23
|
+
import { codecsOf, type ValueCodec } from './values.js';
|
|
24
|
+
|
|
25
|
+
/** ListOptions — duplicated from @fougere/core to avoid a runtime dep. */
|
|
26
|
+
interface ListOptions {
|
|
27
|
+
limit?: number;
|
|
28
|
+
offset?: number;
|
|
29
|
+
page?: number;
|
|
30
|
+
after?: string;
|
|
31
|
+
orderBy?: string;
|
|
32
|
+
order?: 'asc' | 'desc';
|
|
33
|
+
count?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ListResult<T> extends Array<T> {
|
|
37
|
+
total?: number;
|
|
38
|
+
endCursor?: string;
|
|
39
|
+
hasMore?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface SelectOption {
|
|
43
|
+
select?: SchemaView;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface PrimaryKeyInfo {
|
|
47
|
+
names: string[];
|
|
48
|
+
isComposite: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The primary key, read off the role axis.
|
|
53
|
+
*
|
|
54
|
+
* Used to answer "what identifies a row" — where to point a WHERE, what a cursor
|
|
55
|
+
* carries. The generated ids and managed timestamps that used to be computed here
|
|
56
|
+
* moved to `applyCreate`/`applyUpdate` (`@fougere/schema`): nothing in them was about
|
|
57
|
+
* SQL, and every other storage was re-deriving them from scratch.
|
|
58
|
+
*/
|
|
59
|
+
function analyzeFields(entity: SchemaView): { pk: PrimaryKeyInfo } {
|
|
60
|
+
const pkNames = Object.entries(entity.getFields())
|
|
61
|
+
.filter(([, field]) => Role.of(field).isPrimary)
|
|
62
|
+
.map(([name]) => name);
|
|
63
|
+
|
|
64
|
+
return { pk: { names: pkNames, isComposite: pkNames.length > 1 } };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Slice a key set into what one statement may bind. One slice when it already fits. */
|
|
68
|
+
function chunks<T>(values: T[], size: number): T[][] {
|
|
69
|
+
if (values.length <= size) return [values];
|
|
70
|
+
const out: T[][] = [];
|
|
71
|
+
for (let i = 0; i < values.length; i += size) out.push(values.slice(i, i + size));
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Stand-in for "no upper bound", where the engine still demands a LIMIT. */
|
|
76
|
+
const UNBOUNDED = 1_000_000_000;
|
|
77
|
+
|
|
78
|
+
function pick<T extends Record<string, unknown>>(obj: T, keys: Set<string>): T {
|
|
79
|
+
const result: Record<string, unknown> = {};
|
|
80
|
+
for (const key of keys) if (key in obj) result[key] = obj[key];
|
|
81
|
+
return result as T;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function pickList<T extends Record<string, unknown>>(list: ListResult<T>, keys: Set<string>): ListResult<T> {
|
|
85
|
+
const result = list.map((item) => pick(item, keys)) as ListResult<T>;
|
|
86
|
+
result.total = list.total;
|
|
87
|
+
result.endCursor = list.endCursor;
|
|
88
|
+
result.hasMore = list.hasMore;
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export class SqlEntityOrm {
|
|
93
|
+
private table: TableDef;
|
|
94
|
+
private pk: PrimaryKeyInfo;
|
|
95
|
+
/** The axes `applyCreate`/`applyUpdate` read — held once, they are asked per write. */
|
|
96
|
+
private fields: Fields;
|
|
97
|
+
private selectFields?: Set<string>;
|
|
98
|
+
/** field → column and back; the entity names travel, the SQL names stay inside. */
|
|
99
|
+
private toColumn = new Map<string, string>();
|
|
100
|
+
private toField = new Map<string, string>();
|
|
101
|
+
/** field → the value pair a driver needs; only for the shapes a driver can't bind. */
|
|
102
|
+
private codecs: Map<string, ValueCodec>;
|
|
103
|
+
|
|
104
|
+
/** How many keys one statement may carry here — see `Dialect.maxBindings`. */
|
|
105
|
+
/** Kept whole: the engine answers more than one question, and refusals are one of them. */
|
|
106
|
+
private dialect: Dialect;
|
|
107
|
+
private maxBindings: number;
|
|
108
|
+
/** How this engine spells an upsert, or `false` when it cannot — see `Dialect.upsert`. */
|
|
109
|
+
private upsertClause: 'on conflict' | 'on duplicate key' | false;
|
|
110
|
+
|
|
111
|
+
constructor(
|
|
112
|
+
private db: Kysely<any>,
|
|
113
|
+
source: SchemaOrCard,
|
|
114
|
+
tableName: string,
|
|
115
|
+
selectFields?: Set<string>,
|
|
116
|
+
dialect: DialectName = 'sqlite',
|
|
117
|
+
) {
|
|
118
|
+
const resolved = resolveDialect(dialect);
|
|
119
|
+
this.dialect = resolved;
|
|
120
|
+
this.maxBindings = resolved.maxBindings;
|
|
121
|
+
this.upsertClause = resolved.upsert;
|
|
122
|
+
// Normalized once: the table projection and the axis analysis below both read the
|
|
123
|
+
// schema, and a card handed to each separately would be rebuilt twice into two
|
|
124
|
+
// unrelated field objects. Past this line nothing knows which form arrived.
|
|
125
|
+
const entity = schemaOf(source);
|
|
126
|
+
this.table = toTable(tableName, entity);
|
|
127
|
+
for (const column of this.table.columns) {
|
|
128
|
+
this.toColumn.set(column.field, column.name);
|
|
129
|
+
this.toField.set(column.name, column.field);
|
|
130
|
+
}
|
|
131
|
+
this.codecs = codecsOf(this.table.columns);
|
|
132
|
+
this.pk = analyzeFields(entity).pk;
|
|
133
|
+
this.fields = entity.getFields();
|
|
134
|
+
this.selectFields = selectFields;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The Kysely instance this ORM wraps — no judge sits behind it. See EntityOrm.client. */
|
|
138
|
+
get client(): Kysely<any> {
|
|
139
|
+
return this.db;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Returns a scoped ORM that restricts all read results to the fields of the given schema. */
|
|
143
|
+
output(schema: SchemaView): SqlEntityOrm {
|
|
144
|
+
const scoped = Object.create(this) as SqlEntityOrm;
|
|
145
|
+
(scoped as any).selectFields = new Set(Object.keys(schema.getFields()));
|
|
146
|
+
return scoped;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private resolveSelect(options?: SelectOption): Set<string> | undefined {
|
|
150
|
+
if (options?.select) return new Set(Object.keys(options.select.getFields()));
|
|
151
|
+
return this.selectFields;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private column(field: string): string {
|
|
155
|
+
return this.toColumn.get(field) ?? field;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The value a driver can bind — `true` becomes 1, a Date becomes its ISO string. */
|
|
159
|
+
private write(field: string, value: unknown): unknown {
|
|
160
|
+
return this.codecs.get(field)?.write(value) ?? value;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Entity keys → column keys, and entity values → bindable values. */
|
|
164
|
+
private toRow(data: Record<string, unknown>): Record<string, unknown> {
|
|
165
|
+
const row: Record<string, unknown> = {};
|
|
166
|
+
for (const [key, value] of Object.entries(data)) row[this.column(key)] = this.write(key, value);
|
|
167
|
+
return row;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Column keys → entity keys, and column values → the values the entity declares. */
|
|
171
|
+
private fromRow(row: Record<string, unknown>): Record<string, unknown> {
|
|
172
|
+
const data: Record<string, unknown> = {};
|
|
173
|
+
for (const [key, value] of Object.entries(row)) {
|
|
174
|
+
const field = this.toField.get(key) ?? key;
|
|
175
|
+
data[field] = this.codecs.get(field)?.read(value) ?? value;
|
|
176
|
+
}
|
|
177
|
+
return data;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Apply a primary-key filter (simple or composite).
|
|
182
|
+
*
|
|
183
|
+
* The key crosses to the column exactly like every other value — `whereAll` states
|
|
184
|
+
* the rule two lines below and this did not follow it. It cost nothing while every
|
|
185
|
+
* generated key was a string; a key that holds a Date (`primary(created())`) inserted
|
|
186
|
+
* fine and then failed its own re-read, with the row already persisted.
|
|
187
|
+
*/
|
|
188
|
+
private wherePk<Q extends { where(a: any, b: any, c: any): Q }>(query: Q, id: string | Record<string, unknown>): Q {
|
|
189
|
+
if (this.pk.isComposite) {
|
|
190
|
+
const obj = id as Record<string, unknown>;
|
|
191
|
+
return this.pk.names.reduce((q, name) => q.where(this.column(name), '=', this.write(name, obj[name])), query);
|
|
192
|
+
}
|
|
193
|
+
const name = this.pk.names[0];
|
|
194
|
+
return query.where(this.column(name), '=', this.write(name, id));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// A filter compares against the COLUMN, so its value crosses the same way a write
|
|
198
|
+
// does: `findBy({ done: true })` has to look for 1, not for `true`.
|
|
199
|
+
//
|
|
200
|
+
// A criterion may name a SET — `where: { id: [a, b, c] }` is `IN`, one query for a
|
|
201
|
+
// whole page. Without it a relation had no batch form at all: the GraphQL `one`
|
|
202
|
+
// resolver read row by row (50 calls for a page of 50, measured), while its `many`
|
|
203
|
+
// dual already went through this same door. An empty set matches nothing, said in
|
|
204
|
+
// SQL rather than by returning the whole table.
|
|
205
|
+
private whereAll<Q extends { where(a: any, b: any, c: any): Q }>(query: Q, criteria: Record<string, unknown>): Q {
|
|
206
|
+
return Object.entries(criteria).reduce(
|
|
207
|
+
(q, [key, value]) => Array.isArray(value)
|
|
208
|
+
? q.where(this.column(key), 'in', [...new Set(value)].map((v) => this.write(key, v)))
|
|
209
|
+
: q.where(this.column(key), '=', this.write(key, value)),
|
|
210
|
+
query,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async list(options?: ListOptions & SelectOption & { where?: Record<string, unknown> }): Promise<ListResult<Record<string, unknown>>> {
|
|
215
|
+
let query = this.db.selectFrom(this.table.name).selectAll();
|
|
216
|
+
|
|
217
|
+
// The criteria a caller states — `list({ where: { orderId } })`, and the whole of
|
|
218
|
+
// `listBy`. Named `where` rather than spread across the options so an unknown key
|
|
219
|
+
// stays what it always was (ignored) instead of silently becoming a filter.
|
|
220
|
+
if (options?.where) {
|
|
221
|
+
// `list` is the ONE read that cannot be split: a limit and an order do not
|
|
222
|
+
// recompose across slices, so an oversized set here is refused rather than
|
|
223
|
+
// truncated — and the gesture that does handle it is named.
|
|
224
|
+
this.refuseOversized(options.where, 'list');
|
|
225
|
+
query = this.whereAll(query as any, options.where) as any;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Cursor-based: fetch after a given id (uses the first PK field).
|
|
229
|
+
if (options?.after) {
|
|
230
|
+
query = query.where(this.column(this.pk.names[0]), '>', options.after);
|
|
231
|
+
}
|
|
232
|
+
if (options?.orderBy && this.toColumn.has(options.orderBy)) {
|
|
233
|
+
query = query.orderBy(this.column(options.orderBy), options.order === 'desc' ? 'desc' : 'asc');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const limit = options?.limit;
|
|
237
|
+
// Fetch one extra to determine hasMore.
|
|
238
|
+
if (limit !== undefined) query = query.limit(limit + 1);
|
|
239
|
+
|
|
240
|
+
const offset = options?.page !== undefined && limit !== undefined
|
|
241
|
+
? (options.page - 1) * limit
|
|
242
|
+
: options?.offset;
|
|
243
|
+
if (offset !== undefined && offset > 0) {
|
|
244
|
+
// SQLite and MySQL reject OFFSET without a preceding LIMIT — an offset on
|
|
245
|
+
// its own needs an upper bound that means "everything after".
|
|
246
|
+
if (limit === undefined) query = query.limit(UNBOUNDED);
|
|
247
|
+
query = query.offset(offset);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const rows = (await query.execute()).map((row: any) => this.fromRow(row));
|
|
251
|
+
|
|
252
|
+
const hasMore = limit !== undefined && rows.length > limit;
|
|
253
|
+
const data = hasMore ? rows.slice(0, limit) : rows;
|
|
254
|
+
const result = data as ListResult<Record<string, unknown>>;
|
|
255
|
+
result.hasMore = hasMore;
|
|
256
|
+
|
|
257
|
+
if (data.length > 0) {
|
|
258
|
+
result.endCursor = String(data[data.length - 1][this.pk.names[0]] ?? '');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Count is opt-in — a separate query, over the same FILTER.
|
|
262
|
+
//
|
|
263
|
+
// It used to count the whole table: `list({ where: { authorId }, count: true })`
|
|
264
|
+
// returned this author's page beside everybody's total, so a paginator computed the
|
|
265
|
+
// wrong number of pages and a tenant learned how many rows the other tenants have.
|
|
266
|
+
// `where` is the filter and belongs here; `after`, `limit` and `offset` are the page
|
|
267
|
+
// and do not — `total` is what the query matches, not what this page holds.
|
|
268
|
+
if (options?.count) {
|
|
269
|
+
let counting = this.db.selectFrom(this.table.name).select((eb: any) => eb.fn.countAll().as('count'));
|
|
270
|
+
if (options.where) counting = this.whereAll(counting as any, options.where) as any;
|
|
271
|
+
const row = await counting.executeTakeFirst();
|
|
272
|
+
result.total = Number((row as any)?.count ?? 0);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const sel = this.resolveSelect(options);
|
|
276
|
+
return sel ? pickList(result, sel) : result;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* One query for N keys, never N queries — what every page-level read stands on:
|
|
281
|
+
* a computed field, a relation, a resolver on the other side of a wire.
|
|
282
|
+
*
|
|
283
|
+
* A composite key has no list form: it is refused by name rather than answering a
|
|
284
|
+
* partial result that reads as complete.
|
|
285
|
+
*/
|
|
286
|
+
async findByKeys(ids: readonly string[], options?: SelectOption): Promise<Map<string, Record<string, unknown>>> {
|
|
287
|
+
if (this.pk.isComposite) {
|
|
288
|
+
throw new Error(`${this.table.name}.findByKeys: the primary key is composite (${this.pk.names.join(', ')}) — read them one by one, or filter with \`findAllBy\`.`);
|
|
289
|
+
}
|
|
290
|
+
if (ids.length === 0) return new Map();
|
|
291
|
+
const name = this.pk.names[0]!;
|
|
292
|
+
const sel = this.resolveSelect(options);
|
|
293
|
+
const found = new Map<string, Record<string, unknown>>();
|
|
294
|
+
// Split, because a key set comes from a page and a page has no ceiling. One
|
|
295
|
+
// statement per slice, merged here — the caller never learns there were several.
|
|
296
|
+
for (const slice of chunks([...new Set(ids)], this.maxBindings)) {
|
|
297
|
+
const rows = await this.db
|
|
298
|
+
.selectFrom(this.table.name)
|
|
299
|
+
.selectAll()
|
|
300
|
+
.where(this.column(name), 'in', slice.map((id) => this.write(name, id)))
|
|
301
|
+
.execute();
|
|
302
|
+
for (const row of rows as any[]) {
|
|
303
|
+
const data = this.fromRow(row);
|
|
304
|
+
found.set(String(data[name]), sel ? pick(data, sel) : data);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return found;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The other direction of a relation, in one query — see the port's `findAllByKeys`.
|
|
312
|
+
*
|
|
313
|
+
* The grouping key is read off the ROW rather than trusted from the request: a codec
|
|
314
|
+
* may write a value one way and read it back another, and a group keyed on the
|
|
315
|
+
* request's spelling would then be empty while the rows sit there.
|
|
316
|
+
*/
|
|
317
|
+
async findAllByKeys(
|
|
318
|
+
field: string,
|
|
319
|
+
keys: readonly string[],
|
|
320
|
+
options?: SelectOption,
|
|
321
|
+
): Promise<Map<string, Record<string, unknown>[]>> {
|
|
322
|
+
const grouped = new Map<string, Record<string, unknown>[]>();
|
|
323
|
+
if (keys.length === 0) return grouped;
|
|
324
|
+
const rows = await this.findAllBy({ [field]: [...keys] }, options);
|
|
325
|
+
for (const row of rows) {
|
|
326
|
+
const key = String(row[field]);
|
|
327
|
+
const held = grouped.get(key);
|
|
328
|
+
if (held) held.push(row); else grouped.set(key, [row]);
|
|
329
|
+
}
|
|
330
|
+
return grouped;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Write the row, or make the existing one look like this — one statement.
|
|
335
|
+
*
|
|
336
|
+
* The gesture an import needs and the port did not have: `create` throws on the
|
|
337
|
+
* second run (`UNIQUE constraint failed`), so re-reading anything meant deleting
|
|
338
|
+
* first. Measured pulling 500 rows from an API twice.
|
|
339
|
+
*
|
|
340
|
+
* Both lifecycles are realized, each on the side it belongs to: `applyCreate` fills
|
|
341
|
+
* what a first write owes (a generated key, `created()`, a declared default) and
|
|
342
|
+
* `applyUpdate` stamps what every write owes (`updated()`). On conflict the key and
|
|
343
|
+
* the creation stamps are left alone — a row keeps the moment it appeared, whatever
|
|
344
|
+
* later overwrites say.
|
|
345
|
+
*/
|
|
346
|
+
async upsert(input: Record<string, unknown>, options?: SelectOption): Promise<Record<string, unknown>> {
|
|
347
|
+
if (this.upsertClause === false) {
|
|
348
|
+
throw new Error(
|
|
349
|
+
`${this.table.name}.upsert(): this engine has no upsert clause — read with findById ` +
|
|
350
|
+
`and call create or update, and know that the pair is not atomic.`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
// `applyUpdate` FIRST: `updated()` declares both `create: 'now'` and
|
|
354
|
+
// `update: 'now'`, so filling the creation side first leaves nothing for the
|
|
355
|
+
// update side to stamp — the row would carry the moment it was inserted forever.
|
|
356
|
+
const data = applyCreate(this.fields, applyUpdate(this.fields, input));
|
|
357
|
+
// Never overwritten by a later write: the key identifies the row, and a stamp that
|
|
358
|
+
// is create-ONLY records when it appeared. One that is also `update: 'now'` is the
|
|
359
|
+
// opposite — it exists to move.
|
|
360
|
+
const frozen = this.frozenColumns();
|
|
361
|
+
const row = this.toRow(data);
|
|
362
|
+
const replaced = Object.fromEntries(Object.entries(row).filter(([column]) => !frozen.has(column)));
|
|
363
|
+
|
|
364
|
+
const insert = this.db.insertInto(this.table.name).values(row);
|
|
365
|
+
await (this.upsertClause === 'on conflict'
|
|
366
|
+
? insert.onConflict((oc: any) => oc.columns(this.pk.names.map((n) => this.column(n))).doUpdateSet(replaced))
|
|
367
|
+
: (insert as any).onDuplicateKeyUpdate(replaced)
|
|
368
|
+
).execute();
|
|
369
|
+
|
|
370
|
+
const id = this.pk.isComposite
|
|
371
|
+
? Object.fromEntries(this.pk.names.map((n) => [n, data[n]]))
|
|
372
|
+
: (data[this.pk.names[0]!] as string);
|
|
373
|
+
return (await this.findById(id as never, options))!;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Upsert a whole page in one statement — what an import writes through.
|
|
378
|
+
*
|
|
379
|
+
* Row by row, 500 rows were 500 statements (measured pulling an API); the shape of
|
|
380
|
+
* an import is a page, so the write should be one too. Sliced like every other batch,
|
|
381
|
+
* but by rows × COLUMNS: a statement binds values, not rows, so the ceiling divides.
|
|
382
|
+
*
|
|
383
|
+
* Answers how many rows were written and not the rows themselves. `create` hands back
|
|
384
|
+
* the complete row because a caller acts on it; an import acts on none of them, and
|
|
385
|
+
* re-reading a page to satisfy a symmetry nobody uses would double the work.
|
|
386
|
+
*/
|
|
387
|
+
async upsertAll(inputs: readonly Record<string, unknown>[], _options?: SelectOption): Promise<number> {
|
|
388
|
+
if (this.upsertClause === false) {
|
|
389
|
+
throw new Error(
|
|
390
|
+
`${this.table.name}.upsertAll(): this engine has no upsert clause — write the rows ` +
|
|
391
|
+
`one by one with create or update, and know that the pair is not atomic.`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
if (inputs.length === 0) return 0;
|
|
395
|
+
|
|
396
|
+
const rows = inputs.map((input) => this.toRow(applyCreate(this.fields, applyUpdate(this.fields, input))));
|
|
397
|
+
const frozen = this.frozenColumns();
|
|
398
|
+
const columns = new Set(rows.flatMap((row) => Object.keys(row)));
|
|
399
|
+
const replaced = Object.fromEntries(
|
|
400
|
+
[...columns].filter((column) => !frozen.has(column)).map((column) => [column, sql.ref(`excluded.${column}`)]),
|
|
401
|
+
);
|
|
402
|
+
// A statement binds VALUES: one row costs as many as it has columns.
|
|
403
|
+
const perStatement = Math.max(1, Math.floor(this.maxBindings / Math.max(1, columns.size)));
|
|
404
|
+
|
|
405
|
+
let written = 0;
|
|
406
|
+
for (const slice of chunks([...rows], perStatement)) {
|
|
407
|
+
const insert = this.db.insertInto(this.table.name).values(slice);
|
|
408
|
+
await (this.upsertClause === 'on conflict'
|
|
409
|
+
? insert.onConflict((oc: any) => oc.columns(this.pk.names.map((n) => this.column(n))).doUpdateSet(replaced))
|
|
410
|
+
: (insert as any).onDuplicateKeyUpdate(replaced)
|
|
411
|
+
).execute();
|
|
412
|
+
written += slice.length;
|
|
413
|
+
}
|
|
414
|
+
return written;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* The COLUMNS a later write must not touch: the key, and a stamp that is create-only.
|
|
419
|
+
* One that is also `update: 'now'` is the opposite — it exists to move.
|
|
420
|
+
*/
|
|
421
|
+
private frozenColumns(): Set<string> {
|
|
422
|
+
return new Set([
|
|
423
|
+
...this.pk.names.map((name) => this.column(name)),
|
|
424
|
+
...Object.entries(this.fields)
|
|
425
|
+
.filter(([, field]) => Lifecycle.of(field).stampedOnce)
|
|
426
|
+
.map(([name]) => this.column(name)),
|
|
427
|
+
]);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async findById(id: string | Record<string, unknown>, options?: SelectOption): Promise<Record<string, unknown> | undefined> {
|
|
431
|
+
const row = await this.wherePk(this.db.selectFrom(this.table.name).selectAll() as any, id).executeTakeFirst();
|
|
432
|
+
if (!row) return undefined;
|
|
433
|
+
const data = this.fromRow(row);
|
|
434
|
+
const sel = this.resolveSelect(options);
|
|
435
|
+
return sel ? pick(data, sel) : data;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async findBy(criteria: Record<string, unknown>, options?: SelectOption): Promise<Record<string, unknown> | undefined> {
|
|
439
|
+
const row = await this.whereAll(this.db.selectFrom(this.table.name).selectAll() as any, criteria)
|
|
440
|
+
.limit(1)
|
|
441
|
+
.executeTakeFirst();
|
|
442
|
+
if (!row) return undefined;
|
|
443
|
+
const data = this.fromRow(row);
|
|
444
|
+
const sel = this.resolveSelect(options);
|
|
445
|
+
return sel ? pick(data, sel) : data;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async findAllBy(criteria: Record<string, unknown>, options?: SelectOption): Promise<Record<string, unknown>[]> {
|
|
449
|
+
const sel = this.resolveSelect(options);
|
|
450
|
+
const out: Record<string, unknown>[] = [];
|
|
451
|
+
// A set criterion may hold more values than the engine binds. Splitting is safe
|
|
452
|
+
// HERE because this read has no page and no order: the slices simply concatenate.
|
|
453
|
+
for (const slice of this.splitCriteria(criteria)) {
|
|
454
|
+
const rows = await this.whereAll(this.db.selectFrom(this.table.name).selectAll() as any, slice).execute();
|
|
455
|
+
for (const row of rows as any[]) {
|
|
456
|
+
const data = this.fromRow(row);
|
|
457
|
+
out.push(sel ? pick(data, sel) : data);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return out;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* One criteria object per statement — the oversized set is the one that splits.
|
|
465
|
+
*
|
|
466
|
+
* Only ONE criterion may be split: two split sets would need their cross product,
|
|
467
|
+
* which is a different query, so the second is refused rather than silently wrong.
|
|
468
|
+
*/
|
|
469
|
+
private refuseOversized(criteria: Record<string, unknown>, op: string): void {
|
|
470
|
+
for (const [key, value] of Object.entries(criteria)) {
|
|
471
|
+
if (!Array.isArray(value) || new Set(value).size <= this.maxBindings) continue;
|
|
472
|
+
throw new Error(
|
|
473
|
+
`${this.table.name}.${op}(): \`${key}\` holds ${new Set(value).size} values and this engine ` +
|
|
474
|
+
`binds ${this.maxBindings} — a page and an order cannot be split across statements. ` +
|
|
475
|
+
`Use \`findAllByKeys('${key}', keys)\`, which reads them in slices and groups the answer.`,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private splitCriteria(criteria: Record<string, unknown>): Record<string, unknown>[] {
|
|
481
|
+
const oversized = Object.entries(criteria)
|
|
482
|
+
.filter(([, value]) => Array.isArray(value) && new Set(value).size > this.maxBindings);
|
|
483
|
+
if (oversized.length === 0) return [criteria];
|
|
484
|
+
if (oversized.length > 1) {
|
|
485
|
+
throw new Error(
|
|
486
|
+
`${this.table.name}: ${oversized.map(([key]) => `\`${key}\``).join(' and ')} each hold more than ` +
|
|
487
|
+
`${this.maxBindings} values — split one of them at the call site, they cannot both be sliced.`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
const [key, values] = oversized[0]!;
|
|
491
|
+
return chunks([...new Set(values as unknown[])], this.maxBindings)
|
|
492
|
+
.map((slice) => ({ ...criteria, [key]: slice }));
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async create(input: Record<string, unknown>, options?: SelectOption): Promise<Record<string, unknown>> {
|
|
496
|
+
// The lifecycle axis, realized where it is declared — a generated id, a stamped
|
|
497
|
+
// `createdAt`, a declared default. The column DEFAULT below still holds for a
|
|
498
|
+
// writer that is not us, the way a CHECK does; nothing depends on it any more.
|
|
499
|
+
const data = applyCreate(this.fields, input);
|
|
500
|
+
|
|
501
|
+
await this.refusal(() => this.db.insertInto(this.table.name).values(this.toRow(data)).execute());
|
|
502
|
+
|
|
503
|
+
// Contract: create returns the COMPLETE row (validation judges absence, it
|
|
504
|
+
// never fills) — re-read so SQL-realised defaults appear. Same move as update().
|
|
505
|
+
const id = this.pk.isComposite
|
|
506
|
+
? Object.fromEntries(this.pk.names.map((n) => [n, data[n]]))
|
|
507
|
+
: (data[this.pk.names[0]] as string | undefined);
|
|
508
|
+
const created = id !== undefined ? await this.findById(id) : undefined;
|
|
509
|
+
const result = created ?? data;
|
|
510
|
+
const sel = this.resolveSelect(options);
|
|
511
|
+
return sel ? pick(result, sel) : result;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async update(id: string | Record<string, unknown>, input: Record<string, unknown>, options?: SelectOption): Promise<Record<string, unknown>> {
|
|
515
|
+
const data = applyUpdate(this.fields, input);
|
|
516
|
+
|
|
517
|
+
await this.refusal(() => this.wherePk(this.db.updateTable(this.table.name).set(this.toRow(data)) as any, id).execute());
|
|
518
|
+
|
|
519
|
+
const updated = await this.findById(id);
|
|
520
|
+
const result = updated ?? (typeof id === 'string' ? { id, ...data } : { ...id, ...data });
|
|
521
|
+
const sel = this.resolveSelect(options);
|
|
522
|
+
return sel ? pick(result, sel) : result;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* A duplicate is an ANSWER, not a failure — so it leaves as `CONFLICT` and not as the
|
|
527
|
+
* blank `Internal error` a caller used to get. The engine's own wording never travels:
|
|
528
|
+
* it names a table and a constraint, which is our schema and not the caller's business.
|
|
529
|
+
*
|
|
530
|
+
* Only the dialect can recognize it; this method knows no engine, which is the rule
|
|
531
|
+
* `dialect.ts` exists to keep.
|
|
532
|
+
*/
|
|
533
|
+
private async refusal<R>(write: () => Promise<R>): Promise<R> {
|
|
534
|
+
try {
|
|
535
|
+
return await write();
|
|
536
|
+
} catch (cause) {
|
|
537
|
+
if (!this.dialect.isUniqueViolation(cause)) throw cause;
|
|
538
|
+
throw new FougereError({
|
|
539
|
+
code: ErrorCode.CONFLICT,
|
|
540
|
+
message: `A row with these values already exists in ${this.table.name}.`,
|
|
541
|
+
cause,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async delete(id: string | Record<string, unknown>): Promise<boolean> {
|
|
547
|
+
const before = await this.findById(id);
|
|
548
|
+
if (!before) return false;
|
|
549
|
+
await this.wherePk(this.db.deleteFrom(this.table.name) as any, id).execute();
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
export interface OrmFactoryOptions {
|
|
556
|
+
/** Override table name resolution. Default: camelCase → snake_case + 's'. */
|
|
557
|
+
tableName?: (entityName: string) => string;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Create an OrmFactory backed by Kysely — same call shape on every engine.
|
|
562
|
+
*
|
|
563
|
+
* ```ts
|
|
564
|
+
* const app = await createApp({ createContainer, ormFactory: createOrmFactory(db) });
|
|
565
|
+
* ```
|
|
566
|
+
*/
|
|
567
|
+
export function createOrmFactory(db: Kysely<any>, options?: OrmFactoryOptions, dialect: DialectName = 'sqlite') {
|
|
568
|
+
const resolve = options?.tableName ?? toTableName;
|
|
569
|
+
return (entity: SchemaOrCard, name: string) => new SqlEntityOrm(db, entity, resolve(name), undefined, dialect);
|
|
570
|
+
}
|