rip-lang 3.16.0 → 3.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -4
- package/bin/rip +201 -18
- package/bin/rip-schema +175 -0
- package/docs/AGENTS.md +1 -1
- package/docs/RIP-APP.md +200 -19
- package/docs/RIP-DUCKDB.md +64 -1
- package/docs/RIP-INTRO.md +4 -4
- package/docs/RIP-LANG.md +36 -38
- package/docs/RIP-SCHEMA.md +1204 -364
- package/docs/RIP-TYPES.md +74 -103
- package/docs/demo/README.md +4 -3
- package/docs/dist/rip.js +4195 -966
- package/docs/dist/rip.min.js +1161 -284
- package/docs/dist/rip.min.js.br +0 -0
- package/docs/example/index.json +7 -7
- package/docs/example/index.json.br +0 -0
- package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
- package/docs/extensions/vscode/print/print-latest.vsix +0 -0
- package/docs/extensions/vscode/rip/index.html +2 -1
- package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
- package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
- package/docs/extensions/vscode/rip/vscode-rip-0.6.0.vsix +0 -0
- package/docs/index.html +1 -1
- package/docs/ui/bundle.json +55 -55
- package/docs/ui/bundle.json.br +0 -0
- package/docs/ui/hljs-rip.js +1 -1
- package/docs/ui/index.html +1 -1
- package/package.json +15 -7
- package/rip-loader.js +59 -2
- package/src/AGENTS.md +43 -12
- package/src/browser.js +52 -11
- package/src/compiler.js +538 -80
- package/src/components.js +488 -48
- package/src/dts.js +80 -50
- package/src/grammar/README.md +29 -170
- package/src/grammar/grammar.rip +17 -12
- package/src/grammar/solar.rip +4 -17
- package/src/lexer.js +82 -32
- package/src/parser.js +229 -229
- package/src/schema/dts.js +328 -54
- package/src/schema/loader-server.js +2 -1
- package/src/schema/runtime-browser-stubs.js +20 -9
- package/src/schema/runtime-ddl.js +161 -44
- package/src/schema/runtime-migrate.js +681 -0
- package/src/schema/runtime-orm.js +698 -54
- package/src/schema/runtime-validate.js +808 -24
- package/src/schema/runtime.generated.js +2395 -135
- package/src/schema/schema.js +1054 -94
- package/src/typecheck.js +1610 -127
- package/src/types.js +90 -6
- package/src/grammar/lunar.rip +0 -2412
- /package/docs/demo/{components → routes}/_layout.rip +0 -0
- /package/docs/demo/{components → routes}/about.rip +0 -0
- /package/docs/demo/{components → routes}/card.rip +0 -0
- /package/docs/demo/{components → routes}/counter.rip +0 -0
- /package/docs/demo/{components → routes}/index.rip +0 -0
- /package/docs/demo/{components → routes}/todos.rip +0 -0
|
@@ -13,20 +13,91 @@
|
|
|
13
13
|
// pinned by the test suite.
|
|
14
14
|
|
|
15
15
|
/* eslint-disable no-undef, no-unused-vars */
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
// ---- Adapter (Contract v2) -------------------------------------------------
|
|
17
|
+
//
|
|
18
|
+
// `query(sql, params) → {columns, data, rowCount}` is the only REQUIRED
|
|
19
|
+
// method. v2 adds optional capabilities that the runtime feature-detects:
|
|
20
|
+
//
|
|
21
|
+
// begin(options?) → TxHandle transactions (schema.transaction!)
|
|
22
|
+
// TxHandle: { query(sql, params), commit(), rollback() }
|
|
23
|
+
// capabilities: { tx: true, ... } truthful self-report (informational)
|
|
24
|
+
//
|
|
25
|
+
// Calling a feature whose method is absent throws a clear error — never
|
|
26
|
+
// a silent fallback.
|
|
27
|
+
//
|
|
28
|
+
// The default adapter talks to a duckdb-harbor server (or any
|
|
29
|
+
// /sql-compatible server). Configuration via env:
|
|
30
|
+
//
|
|
31
|
+
// RIP_DB_URL base URL (default http://127.0.0.1:9494; legacy DB_URL
|
|
32
|
+
// honored as a fallback)
|
|
33
|
+
// RIP_DB_TOKEN bearer token, required when harbor runs authenticated
|
|
34
|
+
//
|
|
35
|
+
// Transactions use harbor's session protocol: POST /sql/sessions/new
|
|
36
|
+
// pins a DB session (connection) so BEGIN/COMMIT survive across
|
|
37
|
+
// requests; statements carry the sessionId; the session is destroyed
|
|
38
|
+
// after COMMIT/ROLLBACK. Harbor enforces an idle TTL, so an abandoned
|
|
39
|
+
// transaction auto-rolls-back server-side.
|
|
40
|
+
function __schemaDefaultAdapter(overrides) {
|
|
41
|
+
const env = (typeof process !== 'undefined' && process.env) || {};
|
|
42
|
+
const base = () => String(
|
|
43
|
+
overrides?.url || env.RIP_DB_URL || env.DB_URL || 'http://127.0.0.1:9494').replace(/\/+$/, '');
|
|
44
|
+
const headers = () => {
|
|
45
|
+
const h = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
|
|
46
|
+
const token = overrides?.token || env.RIP_DB_TOKEN;
|
|
47
|
+
if (token) h['Authorization'] = 'Bearer ' + token;
|
|
48
|
+
return h;
|
|
49
|
+
};
|
|
50
|
+
async function post(path, body) {
|
|
51
|
+
const res = await fetch(base() + path, {
|
|
52
|
+
method: 'POST', headers: headers(), body: JSON.stringify(body),
|
|
53
|
+
});
|
|
54
|
+
let data;
|
|
55
|
+
try { data = await res.json(); } catch { data = {}; }
|
|
56
|
+
if (!res.ok || data.ok === false || data.error) {
|
|
57
|
+
const err = new Error(data.error || ('db request failed: ' + res.status + ' ' + (res.statusText || '')));
|
|
58
|
+
if (data.errorCode) err.code = data.errorCode;
|
|
59
|
+
if (data.errorDetails) err.details = data.errorDetails;
|
|
60
|
+
err.httpStatus = res.status;
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
return data;
|
|
64
|
+
}
|
|
18
65
|
return {
|
|
19
66
|
async query(sql, params) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
67
|
+
return post('/sql', params && params.length ? { sql, params } : { sql });
|
|
68
|
+
},
|
|
69
|
+
async begin(options) {
|
|
70
|
+
let session;
|
|
71
|
+
try {
|
|
72
|
+
session = await post('/sql/sessions/new', {});
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if (e && e.httpStatus === 403) {
|
|
75
|
+
e.message = 'transactions need a harbor DB session, and harbor denied session creation ' +
|
|
76
|
+
'(authz policy __HARBOR_ADMIN__:sessions:create). Allow it in your harbor_authorization_function ' +
|
|
77
|
+
'or set harbor_allow_admin_without_authz = true on a trusted deployment. Original error: ' + e.message;
|
|
78
|
+
}
|
|
79
|
+
throw e;
|
|
80
|
+
}
|
|
81
|
+
const sessionId = session.sessionId;
|
|
82
|
+
const run = (sql, params) =>
|
|
83
|
+
post('/sql', params && params.length ? { sql, params, sessionId } : { sql, sessionId });
|
|
84
|
+
const drop = async () => {
|
|
85
|
+
// Best-effort: harbor's idle TTL reaps abandoned sessions, so a
|
|
86
|
+
// failed DELETE only delays cleanup, never leaks a transaction.
|
|
87
|
+
try {
|
|
88
|
+
await fetch(base() + '/sql/sessions/' + sessionId, { method: 'DELETE', headers: headers() });
|
|
89
|
+
} catch {}
|
|
90
|
+
};
|
|
91
|
+
await run('BEGIN');
|
|
92
|
+
return {
|
|
93
|
+
query: run,
|
|
94
|
+
async commit() { await run('COMMIT'); await drop(); },
|
|
95
|
+
async rollback() {
|
|
96
|
+
try { await run('ROLLBACK'); } finally { await drop(); }
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
capabilities: { tx: true },
|
|
30
101
|
};
|
|
31
102
|
}
|
|
32
103
|
|
|
@@ -34,6 +105,205 @@ let __schemaAdapter = __schemaDefaultAdapter();
|
|
|
34
105
|
|
|
35
106
|
function __schemaSetAdapter(a) { __schemaAdapter = a; }
|
|
36
107
|
|
|
108
|
+
// Resolve the adapter for a def: its own `on:` adapter, else the
|
|
109
|
+
// process-global one. Migration internals pass def = null → global.
|
|
110
|
+
function __schemaAdapterFor(def) {
|
|
111
|
+
return (def && def._adapter) || __schemaAdapter;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Build a NEW adapter value (without installing it globally) — the
|
|
115
|
+
// counterpart of `schema :model, on: analytics`:
|
|
116
|
+
//
|
|
117
|
+
// analytics = schema.connect url: env.ANALYTICS_URL, token: env.ANALYTICS_TOKEN
|
|
118
|
+
// Event = schema :model, on: analytics
|
|
119
|
+
//
|
|
120
|
+
// Same duckdb-harbor contract as the default adapter (query + begin +
|
|
121
|
+
// capabilities), pinned to an explicit URL/token instead of env vars.
|
|
122
|
+
function __schemaConnect(opts) {
|
|
123
|
+
const o = typeof opts === 'string' ? { url: opts } : (opts || {});
|
|
124
|
+
if (!o.url) throw new Error("schema.connect({url, token?}): a url is required");
|
|
125
|
+
return __schemaDefaultAdapter({ url: o.url, token: o.token });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---- Transactions ----------------------------------------------------------
|
|
129
|
+
//
|
|
130
|
+
// schema.transaction! -> propagates ambiently: every ORM
|
|
131
|
+
// user = User.create! ... call inside the block routes
|
|
132
|
+
// Order.create! userId: user.id, ... through the transaction's handle
|
|
133
|
+
// user via AsyncLocalStorage. Model code
|
|
134
|
+
// is unchanged inside the block.
|
|
135
|
+
//
|
|
136
|
+
// Block throws → ROLLBACK, exception propagates, afterRollback hooks fire.
|
|
137
|
+
// Block returns → COMMIT, value returned, afterCommit hooks fire.
|
|
138
|
+
// Nested call → joins the ambient transaction (Active Record's default;
|
|
139
|
+
// DuckDB has no SAVEPOINT, so nested-as-independent-unit
|
|
140
|
+
// cannot be honored on the primary backend).
|
|
141
|
+
//
|
|
142
|
+
// The ALS instance is created lazily on first use — `node:async_hooks`
|
|
143
|
+
// exists in Bun and Node, and the import never runs in browser bundles
|
|
144
|
+
// (this fragment is server/migration-only).
|
|
145
|
+
let __schemaTxALS = null;
|
|
146
|
+
|
|
147
|
+
// The ALS store is a Map<adapter, txContext> — each adapter gets its
|
|
148
|
+
// own slot, so a transaction pinned to one adapter never captures ORM
|
|
149
|
+
// calls bound to another (cross-adapter atomicity is impossible and
|
|
150
|
+
// the runtime never pretends otherwise), and an inner transaction on a
|
|
151
|
+
// second adapter doesn't shadow the outer one.
|
|
152
|
+
function __schemaTxStore(adapter) {
|
|
153
|
+
if (!__schemaTxALS) return null;
|
|
154
|
+
const map = __schemaTxALS.getStore();
|
|
155
|
+
return (map && map.get(adapter)) || null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The single SQL funnel. Every ORM-issued statement flows through here:
|
|
159
|
+
// it resolves the def's adapter, routes to that adapter's ambient
|
|
160
|
+
// transaction handle when one exists, and translates DB constraint
|
|
161
|
+
// violations into structured SchemaErrors.
|
|
162
|
+
async function __schemaRunSQL(def, sql, params) {
|
|
163
|
+
const adapter = __schemaAdapterFor(def);
|
|
164
|
+
const tx = __schemaTxStore(adapter);
|
|
165
|
+
try {
|
|
166
|
+
return await (tx ? tx.handle.query(sql, params) : adapter.query(sql, params));
|
|
167
|
+
} catch (e) {
|
|
168
|
+
throw __schemaTranslateDBError(e, def);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function __schemaTransaction(optsOrFn, maybeFn) {
|
|
173
|
+
const fn = typeof optsOrFn === 'function' ? optsOrFn : maybeFn;
|
|
174
|
+
const opts = typeof optsOrFn === 'function' ? {} : (optsOrFn || {});
|
|
175
|
+
if (typeof fn !== 'function') {
|
|
176
|
+
throw new Error('schema.transaction(fn): expected a function (got ' + typeof fn + ')');
|
|
177
|
+
}
|
|
178
|
+
// `on:` pins the transaction to a specific adapter (per-schema
|
|
179
|
+
// adapters); default is the process-global one.
|
|
180
|
+
const adapter = opts.on || __schemaAdapter;
|
|
181
|
+
|
|
182
|
+
// Nested transaction on the SAME adapter joins the ambient one — the
|
|
183
|
+
// inner block's writes commit or roll back with the outer transaction.
|
|
184
|
+
// A nested transaction on a DIFFERENT adapter is independent.
|
|
185
|
+
if (__schemaTxStore(adapter)) return fn();
|
|
186
|
+
|
|
187
|
+
if (typeof adapter.begin !== 'function') {
|
|
188
|
+
throw new Error(
|
|
189
|
+
'schema.transaction(): the configured adapter does not support transactions ' +
|
|
190
|
+
'(no begin() method; see Adapter Contract v2). Install an adapter with begin() ' +
|
|
191
|
+
'or use the default @rip-lang/db adapter against duckdb-harbor.');
|
|
192
|
+
}
|
|
193
|
+
if (!__schemaTxALS) {
|
|
194
|
+
const { AsyncLocalStorage } = await import('node:async_hooks');
|
|
195
|
+
__schemaTxALS = new AsyncLocalStorage();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const handle = await adapter.begin(opts);
|
|
199
|
+
// `after` collects {def, inst} for every save/destroy that completed
|
|
200
|
+
// inside the transaction on a model declaring afterCommit/afterRollback.
|
|
201
|
+
const store = { adapter, handle, after: [] };
|
|
202
|
+
// Copy-on-run: other adapters' ambient contexts stay visible inside
|
|
203
|
+
// the block; only this adapter's slot is (re)bound.
|
|
204
|
+
const nextMap = new Map(__schemaTxALS.getStore() || []);
|
|
205
|
+
nextMap.set(adapter, store);
|
|
206
|
+
let result;
|
|
207
|
+
try {
|
|
208
|
+
result = await __schemaTxALS.run(nextMap, fn);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
try { await handle.rollback(); } catch {}
|
|
211
|
+
await __schemaFlushTxHooks(store, 'afterRollback');
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
await handle.commit();
|
|
215
|
+
// afterCommit runs OUTSIDE the transaction — this is where emails,
|
|
216
|
+
// webhooks, and cache invalidation belong (they must never observe
|
|
217
|
+
// uncommitted state). Exceptions here propagate to the caller but
|
|
218
|
+
// cannot roll anything back: the COMMIT already happened.
|
|
219
|
+
await __schemaFlushTxHooks(store, 'afterCommit');
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function __schemaFlushTxHooks(store, hookName) {
|
|
224
|
+
// Dedupe by instance: a row saved twice in one transaction gets one
|
|
225
|
+
// callback, matching Active Record's after_commit semantics.
|
|
226
|
+
const seen = new Set();
|
|
227
|
+
for (const entry of store.after) {
|
|
228
|
+
if (seen.has(entry.inst)) continue;
|
|
229
|
+
seen.add(entry.inst);
|
|
230
|
+
await __schemaRunHook(entry.def, entry.inst, hookName);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Queue an instance's commit-time hooks on the ambient transaction for
|
|
235
|
+
// ITS adapter. Returns true when queued; false means "no ambient tx —
|
|
236
|
+
// fire now".
|
|
237
|
+
function __schemaEnqueueTxHook(def, inst) {
|
|
238
|
+
const tx = __schemaTxStore(__schemaAdapterFor(def));
|
|
239
|
+
if (!tx) return false;
|
|
240
|
+
tx.after.push({ def, inst });
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---- Constraint-violation translation --------------------------------------
|
|
245
|
+
//
|
|
246
|
+
// The ORM wraps every adapter call (via __schemaRunSQL). Errors that are
|
|
247
|
+
// recognizably DB constraint violations are translated into SchemaError
|
|
248
|
+
// so a `save!` that trips a UNIQUE index fails the same way a `save!`
|
|
249
|
+
// that trips a validator does: structured {field, error, message} issues.
|
|
250
|
+
// Unrecognized errors propagate untouched. The original error is kept
|
|
251
|
+
// as `.cause` for debugging.
|
|
252
|
+
//
|
|
253
|
+
// Recognition is message-pattern based (DuckDB first). Deliberately NOT
|
|
254
|
+
// added: `validates_uniqueness_of`-style pre-checks — they race. The DB
|
|
255
|
+
// constraint is the check; translation makes it ergonomic.
|
|
256
|
+
function __schemaTranslateDBError(e, def) {
|
|
257
|
+
const msg = (e && e.message) || '';
|
|
258
|
+
const issue = __schemaConstraintIssue(msg);
|
|
259
|
+
if (!issue) return e;
|
|
260
|
+
const err = new SchemaError([issue], def ? def.name : null, def ? def.kind : null);
|
|
261
|
+
err.cause = e;
|
|
262
|
+
return err;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function __schemaConstraintIssue(msg) {
|
|
266
|
+
let m;
|
|
267
|
+
// DuckDB: Constraint Error: Duplicate key "email: a@b.c" violates unique constraint ...
|
|
268
|
+
// (also "violates primary key constraint")
|
|
269
|
+
m = msg.match(/[Dd]uplicate key "([A-Za-z0-9_]+):[^"]*" violates (?:unique|primary key) constraint/);
|
|
270
|
+
if (m || /violates unique constraint/i.test(msg)) {
|
|
271
|
+
const field = m ? __schemaCamel(m[1]) : '';
|
|
272
|
+
return { field, error: 'unique', message: (field || 'value') + ' already taken' };
|
|
273
|
+
}
|
|
274
|
+
// DuckDB: Constraint Error: NOT NULL constraint failed: users.name
|
|
275
|
+
m = msg.match(/NOT NULL constraint failed:\s*(?:[A-Za-z0-9_]+\.)?([A-Za-z0-9_]+)/i);
|
|
276
|
+
if (m) {
|
|
277
|
+
const field = __schemaCamel(m[1]);
|
|
278
|
+
return { field, error: 'required', message: field + ' is required' };
|
|
279
|
+
}
|
|
280
|
+
// DuckDB: Constraint Error: Violates foreign key constraint because ... "user_id: 99" ...
|
|
281
|
+
if (/[Vv]iolates foreign key constraint/.test(msg)) {
|
|
282
|
+
m = msg.match(/"([A-Za-z0-9_]+):[^"]*"/);
|
|
283
|
+
const field = m ? __schemaCamel(m[1]) : '';
|
|
284
|
+
return { field, error: 'reference', message: (field || 'reference') + ' refers to a missing or still-referenced record' };
|
|
285
|
+
}
|
|
286
|
+
// DuckDB: Constraint Error: CHECK constraint failed: <table>
|
|
287
|
+
if (/CHECK constraint failed/i.test(msg)) {
|
|
288
|
+
return { field: '', error: 'check', message: msg };
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---- Query builder ----------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
// Run a scope body with `this` bound to a query builder. `builder` is
|
|
296
|
+
// null when invoked from a model static (User.active()) — a fresh
|
|
297
|
+
// builder is created; chained scope calls pass the existing builder.
|
|
298
|
+
// Scopes conventionally return the builder (`@where` does), but a body
|
|
299
|
+
// that returns something else falls back to the builder so chains never
|
|
300
|
+
// break on a stray trailing expression.
|
|
301
|
+
function __schemaInvokeScope(def, builder, fn, args) {
|
|
302
|
+
const q = builder || new __SchemaQuery(def);
|
|
303
|
+
const out = fn.apply(q, args);
|
|
304
|
+
return out instanceof __SchemaQuery ? out : q;
|
|
305
|
+
}
|
|
306
|
+
|
|
37
307
|
class __SchemaQuery {
|
|
38
308
|
constructor(def, opts = {}) {
|
|
39
309
|
this._def = def;
|
|
@@ -42,7 +312,26 @@ class __SchemaQuery {
|
|
|
42
312
|
this._limit = null;
|
|
43
313
|
this._offset = null;
|
|
44
314
|
this._order = null;
|
|
45
|
-
this.
|
|
315
|
+
this._includes = [];
|
|
316
|
+
this._unscoped = false;
|
|
317
|
+
this._defaultScopeApplied = false;
|
|
318
|
+
// Soft-delete filter mode: 'live' (default), 'all' (.withDeleted),
|
|
319
|
+
// 'deleted' (.onlyDeleted). Pre-v2 `includeDeleted` option maps to 'all'.
|
|
320
|
+
this._deleted = opts.includeDeleted === true ? 'all' : 'live';
|
|
321
|
+
// Per-model scopes install as own methods so chains compose in any
|
|
322
|
+
// order: User.where(role: 'admin').active().since(d). Builder
|
|
323
|
+
// method names win on collision (normalize rejects those anyway).
|
|
324
|
+
const scopes = def._normalize().scopes;
|
|
325
|
+
if (scopes && scopes.size) {
|
|
326
|
+
for (const [sname, sfn] of scopes) {
|
|
327
|
+
if (!(sname in this)) {
|
|
328
|
+
Object.defineProperty(this, sname, {
|
|
329
|
+
enumerable: false, configurable: true,
|
|
330
|
+
value: (...args) => __schemaInvokeScope(def, this, sfn, args),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
46
335
|
}
|
|
47
336
|
where(cond, ...params) {
|
|
48
337
|
if (typeof cond === 'string') {
|
|
@@ -53,6 +342,9 @@ class __SchemaQuery {
|
|
|
53
342
|
const col = __schemaSnake(k);
|
|
54
343
|
if (v === null || v === undefined) {
|
|
55
344
|
this._clauses.push('"' + col + '" IS NULL');
|
|
345
|
+
} else if (Array.isArray(v)) {
|
|
346
|
+
this._clauses.push('"' + col + '" IN (' + v.map(() => '?').join(', ') + ')');
|
|
347
|
+
this._params.push(...v);
|
|
56
348
|
} else {
|
|
57
349
|
this._clauses.push('"' + col + '" = ?');
|
|
58
350
|
this._params.push(v);
|
|
@@ -65,12 +357,34 @@ class __SchemaQuery {
|
|
|
65
357
|
offset(n) { this._offset = n; return this; }
|
|
66
358
|
order(spec) { this._order = spec; return this; }
|
|
67
359
|
orderBy(spec) { return this.order(spec); }
|
|
360
|
+
includes(...specs) {
|
|
361
|
+
this._includes.push(...__schemaNormalizeIncludes(specs));
|
|
362
|
+
return this;
|
|
363
|
+
}
|
|
364
|
+
withDeleted() { this._deleted = 'all'; return this; }
|
|
365
|
+
onlyDeleted() { this._deleted = 'deleted'; return this; }
|
|
366
|
+
unscoped() { this._unscoped = true; return this; }
|
|
367
|
+
// @defaultScope applies lazily at terminal time (all/count/updateAll/
|
|
368
|
+
// deleteAll) so .unscoped() works no matter where it appears in the
|
|
369
|
+
// chain, and so the default's clauses never double-apply.
|
|
370
|
+
_applyDefaultScope() {
|
|
371
|
+
if (this._unscoped || this._defaultScopeApplied) return;
|
|
372
|
+
this._defaultScopeApplied = true;
|
|
373
|
+
const fn = this._def._normalize().defaultScope;
|
|
374
|
+
if (fn) fn.call(this);
|
|
375
|
+
}
|
|
376
|
+
_whereParts(norm) {
|
|
377
|
+
const where = [...this._clauses];
|
|
378
|
+
if (norm.softDelete) {
|
|
379
|
+
if (this._deleted === 'live') where.push('"deleted_at" IS NULL');
|
|
380
|
+
else if (this._deleted === 'deleted') where.push('"deleted_at" IS NOT NULL');
|
|
381
|
+
}
|
|
382
|
+
return where;
|
|
383
|
+
}
|
|
68
384
|
_buildSQL() {
|
|
69
385
|
const n = this._def._normalize();
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
const where = [...this._clauses];
|
|
73
|
-
if (!this._includeDeleted && n.softDelete) where.push('"deleted_at" IS NULL');
|
|
386
|
+
const parts = ['SELECT * FROM "' + n.tableName + '"'];
|
|
387
|
+
const where = this._whereParts(n);
|
|
74
388
|
if (where.length) parts.push('WHERE ' + where.join(' AND '));
|
|
75
389
|
if (this._order) parts.push('ORDER BY ' + this._order);
|
|
76
390
|
if (this._limit != null) parts.push('LIMIT ' + this._limit);
|
|
@@ -78,9 +392,16 @@ class __SchemaQuery {
|
|
|
78
392
|
return parts.join(' ');
|
|
79
393
|
}
|
|
80
394
|
async all() {
|
|
395
|
+
this._applyDefaultScope();
|
|
81
396
|
const sql = this._buildSQL();
|
|
82
|
-
const res = await
|
|
83
|
-
|
|
397
|
+
const res = await __schemaRunSQL(this._def, sql, this._params);
|
|
398
|
+
const instances = (res.data || []).map(row => this._def._hydrate(res.columns, row));
|
|
399
|
+
// Eager loading: batched second queries (WHERE fk IN (...)) that
|
|
400
|
+
// fill the relation memos. Never changes the root result set.
|
|
401
|
+
if (this._includes.length && instances.length) {
|
|
402
|
+
await __schemaPreload(this._def, instances, this._includes);
|
|
403
|
+
}
|
|
404
|
+
return instances;
|
|
84
405
|
}
|
|
85
406
|
async first() {
|
|
86
407
|
this._limit = 1;
|
|
@@ -88,14 +409,136 @@ class __SchemaQuery {
|
|
|
88
409
|
return arr[0] || null;
|
|
89
410
|
}
|
|
90
411
|
async count() {
|
|
412
|
+
this._applyDefaultScope();
|
|
91
413
|
const n = this._def._normalize();
|
|
92
414
|
const parts = ['SELECT COUNT(*) FROM "' + n.tableName + '"'];
|
|
93
|
-
const where =
|
|
94
|
-
if (!this._includeDeleted && n.softDelete) where.push('"deleted_at" IS NULL');
|
|
415
|
+
const where = this._whereParts(n);
|
|
95
416
|
if (where.length) parts.push('WHERE ' + where.join(' AND '));
|
|
96
|
-
const res = await
|
|
417
|
+
const res = await __schemaRunSQL(this._def, parts.join(' '), this._params);
|
|
97
418
|
return res.data?.[0]?.[0] || 0;
|
|
98
419
|
}
|
|
420
|
+
// One UPDATE statement for every matching row. Bypasses validation
|
|
421
|
+
// and per-instance hooks — the name says "all", the docs say "raw".
|
|
422
|
+
// Returns the adapter's reported row count when available.
|
|
423
|
+
async updateAll(values) {
|
|
424
|
+
this._applyDefaultScope();
|
|
425
|
+
const n = this._def._normalize();
|
|
426
|
+
const keys = values && typeof values === 'object' ? Object.keys(values) : [];
|
|
427
|
+
if (!keys.length) throw new Error('updateAll: requires at least one column to set');
|
|
428
|
+
const sets = [];
|
|
429
|
+
const params = [];
|
|
430
|
+
for (const k of keys) {
|
|
431
|
+
const name = __schemaCamel(k);
|
|
432
|
+
const field = n.fields.get(name);
|
|
433
|
+
sets.push('"' + __schemaSnake(k) + '" = ?');
|
|
434
|
+
params.push(__schemaSerialize(values[k], field));
|
|
435
|
+
}
|
|
436
|
+
if (n.timestamps) {
|
|
437
|
+
sets.push('"updated_at" = ?');
|
|
438
|
+
params.push(new Date().toISOString());
|
|
439
|
+
}
|
|
440
|
+
const where = this._whereParts(n);
|
|
441
|
+
let sql = 'UPDATE "' + n.tableName + '" SET ' + sets.join(', ');
|
|
442
|
+
if (where.length) sql += ' WHERE ' + where.join(' AND ');
|
|
443
|
+
const res = await __schemaRunSQL(this._def, sql, [...params, ...this._params]);
|
|
444
|
+
return res.rowCount ?? res.rows ?? null;
|
|
445
|
+
}
|
|
446
|
+
// One statement for every matching row. Soft-delete aware: on a
|
|
447
|
+
// @softDelete model this is an UPDATE setting deleted_at; on a hard
|
|
448
|
+
// model it's a real DELETE. Bypasses per-instance hooks (bulk path).
|
|
449
|
+
async deleteAll() {
|
|
450
|
+
this._applyDefaultScope();
|
|
451
|
+
const n = this._def._normalize();
|
|
452
|
+
const where = this._whereParts(n);
|
|
453
|
+
let sql, params;
|
|
454
|
+
if (n.softDelete && this._deleted === 'live') {
|
|
455
|
+
sql = 'UPDATE "' + n.tableName + '" SET "deleted_at" = ?';
|
|
456
|
+
params = [new Date().toISOString(), ...this._params];
|
|
457
|
+
} else {
|
|
458
|
+
sql = 'DELETE FROM "' + n.tableName + '"';
|
|
459
|
+
params = this._params;
|
|
460
|
+
}
|
|
461
|
+
if (where.length) sql += ' WHERE ' + where.join(' AND ');
|
|
462
|
+
const res = await __schemaRunSQL(this._def, sql, params);
|
|
463
|
+
return res.rowCount ?? res.rows ?? null;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ---- Eager loading ----------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
// Normalize .includes arguments into [{name, children}] trees. Accepts
|
|
470
|
+
// :symbols, strings, arrays, and nested maps to any depth:
|
|
471
|
+
// .includes(:orders)
|
|
472
|
+
// .includes(:author, comments: :author)
|
|
473
|
+
function __schemaNormalizeIncludes(specs) {
|
|
474
|
+
const out = [];
|
|
475
|
+
for (const s of specs) {
|
|
476
|
+
if (s == null) continue;
|
|
477
|
+
if (typeof s === 'symbol') out.push({ name: Symbol.keyFor(s) || s.description, children: [] });
|
|
478
|
+
else if (typeof s === 'string') out.push({ name: s, children: [] });
|
|
479
|
+
else if (Array.isArray(s)) out.push(...__schemaNormalizeIncludes(s));
|
|
480
|
+
else if (typeof s === 'object') {
|
|
481
|
+
for (const [k, v] of Object.entries(s)) {
|
|
482
|
+
out.push({ name: k, children: __schemaNormalizeIncludes([v]) });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return out;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Batched preload: one query per relation per nesting level
|
|
490
|
+
// (`WHERE fk IN (?, …)`), never JOINs — no row duplication, uniform
|
|
491
|
+
// across belongs_to / has_one / has_many. Results land in the relation
|
|
492
|
+
// memo, so `user.orders!` resolves from cache with no query. Invisible
|
|
493
|
+
// to call sites: preloading is purely a performance fact.
|
|
494
|
+
async function __schemaPreload(def, instances, specs) {
|
|
495
|
+
if (!instances.length || !specs.length) return;
|
|
496
|
+
const norm = def._normalize();
|
|
497
|
+
for (const spec of specs) {
|
|
498
|
+
const rel = norm.relations.get(spec.name);
|
|
499
|
+
if (!rel) {
|
|
500
|
+
throw new Error(
|
|
501
|
+
"schema: includes('" + spec.name + "') — no such relation on " + (def.name || 'model') +
|
|
502
|
+
'. Declared relations: ' + ([...norm.relations.keys()].join(', ') || '(none)'));
|
|
503
|
+
}
|
|
504
|
+
const target = __SchemaRegistry.get(rel.target);
|
|
505
|
+
if (!target) throw new Error('schema: unknown relation target "' + rel.target + '" from ' + (def.name || 'anon'));
|
|
506
|
+
const children = [];
|
|
507
|
+
if (rel.kind === 'belongsTo') {
|
|
508
|
+
const fkCamel = __schemaCamel(rel.foreignKey);
|
|
509
|
+
const ids = [...new Set(instances.map(i => i[fkCamel]).filter(v => v != null))];
|
|
510
|
+
const rows = ids.length ? await target.findMany(ids) : [];
|
|
511
|
+
const pk = target._normalize().primaryKey;
|
|
512
|
+
const byId = new Map(rows.map(r => [r[pk], r]));
|
|
513
|
+
for (const inst of instances) {
|
|
514
|
+
const v = inst[fkCamel] != null ? (byId.get(inst[fkCamel]) ?? null) : null;
|
|
515
|
+
__schemaRelMemoSet(inst, spec.name, v);
|
|
516
|
+
if (v && !children.includes(v)) children.push(v);
|
|
517
|
+
}
|
|
518
|
+
} else {
|
|
519
|
+
const pk = norm.primaryKey;
|
|
520
|
+
const fkCamel = __schemaCamel(rel.foreignKey);
|
|
521
|
+
const ids = [...new Set(instances.map(i => i[pk]).filter(v => v != null))];
|
|
522
|
+
let rows = [];
|
|
523
|
+
if (ids.length) {
|
|
524
|
+
rows = await new __SchemaQuery(target)
|
|
525
|
+
.where('"' + rel.foreignKey + '" IN (' + ids.map(() => '?').join(', ') + ')', ...ids)
|
|
526
|
+
.all();
|
|
527
|
+
}
|
|
528
|
+
const groups = new Map();
|
|
529
|
+
for (const r of rows) {
|
|
530
|
+
const k = r[fkCamel];
|
|
531
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
532
|
+
groups.get(k).push(r);
|
|
533
|
+
children.push(r);
|
|
534
|
+
}
|
|
535
|
+
for (const inst of instances) {
|
|
536
|
+
const g = groups.get(inst[pk]) || [];
|
|
537
|
+
__schemaRelMemoSet(inst, spec.name, rel.kind === 'hasOne' ? (g[0] ?? null) : g);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (spec.children.length) await __schemaPreload(target, children, spec.children);
|
|
541
|
+
}
|
|
99
542
|
}
|
|
100
543
|
|
|
101
544
|
async function __schemaResolveRelation(def, inst, rel) {
|
|
@@ -115,11 +558,26 @@ async function __schemaResolveRelation(def, inst, rel) {
|
|
|
115
558
|
return null;
|
|
116
559
|
}
|
|
117
560
|
|
|
561
|
+
// ---- Save / destroy ----------------------------------------------------------
|
|
562
|
+
|
|
118
563
|
async function __schemaRunHook(def, inst, name) {
|
|
119
564
|
const fn = def._normalize().hooks.get(name);
|
|
120
565
|
if (fn) await fn.call(inst);
|
|
121
566
|
}
|
|
122
567
|
|
|
568
|
+
// After a successful save/destroy: queue afterCommit/afterRollback on
|
|
569
|
+
// the ambient transaction, or fire afterCommit immediately when no
|
|
570
|
+
// transaction is open (AR semantics: outside a tx, "commit" is the
|
|
571
|
+
// statement itself). Only models that declare one of the two hooks pay
|
|
572
|
+
// any cost here.
|
|
573
|
+
async function __schemaSettleTxHooks(def, inst) {
|
|
574
|
+
const hooks = def._normalize().hooks;
|
|
575
|
+
if (!hooks.has('afterCommit') && !hooks.has('afterRollback')) return;
|
|
576
|
+
if (!__schemaEnqueueTxHook(def, inst)) {
|
|
577
|
+
await __schemaRunHook(def, inst, 'afterCommit');
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
123
581
|
async function __schemaSave(def, inst) {
|
|
124
582
|
// Re-entry guard. Same-instance re-entry into save() — typically a
|
|
125
583
|
// hook on this very instance calling .save() on `this` — would race
|
|
@@ -182,24 +640,9 @@ async function __schemaSave(def, inst) {
|
|
|
182
640
|
}
|
|
183
641
|
}
|
|
184
642
|
const sql = 'INSERT INTO "' + norm.tableName + '" (' + cols.join(', ') + ') VALUES (' + placeholders.join(', ') + ') RETURNING *';
|
|
185
|
-
const res = await
|
|
643
|
+
const res = await __schemaRunSQL(def, sql, values);
|
|
186
644
|
if (res.data?.[0] && res.columns) {
|
|
187
|
-
|
|
188
|
-
const snake = res.columns[i].name;
|
|
189
|
-
const key = __schemaCamel(snake);
|
|
190
|
-
if (!(key in inst)) {
|
|
191
|
-
Object.defineProperty(inst, key, { value: res.data[0][i], enumerable: true, writable: true, configurable: true });
|
|
192
|
-
} else {
|
|
193
|
-
inst[key] = res.data[0][i];
|
|
194
|
-
}
|
|
195
|
-
if (snake !== key && !(snake in inst)) {
|
|
196
|
-
Object.defineProperty(inst, snake, {
|
|
197
|
-
enumerable: false, configurable: true,
|
|
198
|
-
get() { return this[key]; },
|
|
199
|
-
set(v) { this[key] = v; },
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
}
|
|
645
|
+
__schemaAbsorbRow(inst, res.columns, res.data[0]);
|
|
203
646
|
}
|
|
204
647
|
// Now that the RETURNING columns (id, @timestamps, FKs) are on the
|
|
205
648
|
// instance, !> eager-derived fields can see them. Mirrors the hydrate
|
|
@@ -347,7 +790,7 @@ async function __schemaSave(def, inst) {
|
|
|
347
790
|
}
|
|
348
791
|
values.push(wherePk);
|
|
349
792
|
const sql = 'UPDATE "' + norm.tableName + '" SET ' + sets.join(', ') + ' WHERE "' + pk + '" = ?';
|
|
350
|
-
await
|
|
793
|
+
await __schemaRunSQL(def, sql, values);
|
|
351
794
|
inst._snapshot = nextSnap;
|
|
352
795
|
}
|
|
353
796
|
}
|
|
@@ -356,6 +799,7 @@ async function __schemaSave(def, inst) {
|
|
|
356
799
|
if (isNew) await __schemaRunHook(def, inst, 'afterCreate');
|
|
357
800
|
else await __schemaRunHook(def, inst, 'afterUpdate');
|
|
358
801
|
await __schemaRunHook(def, inst, 'afterSave');
|
|
802
|
+
await __schemaSettleTxHooks(def, inst);
|
|
359
803
|
return inst;
|
|
360
804
|
|
|
361
805
|
} finally {
|
|
@@ -363,19 +807,59 @@ async function __schemaSave(def, inst) {
|
|
|
363
807
|
}
|
|
364
808
|
}
|
|
365
809
|
|
|
366
|
-
|
|
810
|
+
// Absorb a RETURNING row onto an instance: camelCase canonical own
|
|
811
|
+
// properties plus non-enumerable snake_case aliases. Shared by the
|
|
812
|
+
// INSERT path, upsert, and restore.
|
|
813
|
+
function __schemaAbsorbRow(inst, columns, row) {
|
|
814
|
+
for (let i = 0; i < columns.length; i++) {
|
|
815
|
+
const snake = columns[i].name;
|
|
816
|
+
const key = __schemaCamel(snake);
|
|
817
|
+
if (!(key in inst)) {
|
|
818
|
+
Object.defineProperty(inst, key, { value: row[i], enumerable: true, writable: true, configurable: true });
|
|
819
|
+
} else {
|
|
820
|
+
inst[key] = row[i];
|
|
821
|
+
}
|
|
822
|
+
if (snake !== key && !(snake in inst)) {
|
|
823
|
+
Object.defineProperty(inst, snake, {
|
|
824
|
+
enumerable: false, configurable: true,
|
|
825
|
+
get() { return this[key]; },
|
|
826
|
+
set(v) { this[key] = v; },
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
async function __schemaDestroy(def, inst, opts) {
|
|
367
833
|
if (!inst._persisted) return inst;
|
|
368
834
|
const norm = def._normalize();
|
|
835
|
+
const hard = opts && opts.hard === true;
|
|
369
836
|
await __schemaRunHook(def, inst, 'beforeDestroy');
|
|
370
|
-
if (norm.softDelete) {
|
|
837
|
+
if (norm.softDelete && !hard) {
|
|
371
838
|
const now = new Date().toISOString();
|
|
372
|
-
await
|
|
839
|
+
await __schemaRunSQL(def, 'UPDATE "' + norm.tableName + '" SET "deleted_at" = ? WHERE "' + norm.primaryKey + '" = ?', [now, inst[norm.primaryKey]]);
|
|
373
840
|
inst.deletedAt = now;
|
|
374
841
|
} else {
|
|
375
|
-
await
|
|
842
|
+
await __schemaRunSQL(def, 'DELETE FROM "' + norm.tableName + '" WHERE "' + norm.primaryKey + '" = ?', [inst[norm.primaryKey]]);
|
|
376
843
|
inst._persisted = false;
|
|
377
844
|
}
|
|
378
845
|
await __schemaRunHook(def, inst, 'afterDestroy');
|
|
846
|
+
await __schemaSettleTxHooks(def, inst);
|
|
847
|
+
return inst;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Soft-delete recovery: UPDATE ... SET deleted_at = NULL. Fires the
|
|
851
|
+
// update lifecycle (beforeUpdate/afterUpdate) like Active Record's
|
|
852
|
+
// touch-style writes. Only meaningful on @softDelete models.
|
|
853
|
+
async function __schemaRestore(def, inst) {
|
|
854
|
+
const norm = def._normalize();
|
|
855
|
+
if (!norm.softDelete) {
|
|
856
|
+
throw new Error('schema: restore() requires @softDelete on ' + (def.name || 'model'));
|
|
857
|
+
}
|
|
858
|
+
if (!inst._persisted) return inst;
|
|
859
|
+
await __schemaRunHook(def, inst, 'beforeUpdate');
|
|
860
|
+
await __schemaRunSQL(def, 'UPDATE "' + norm.tableName + '" SET "deleted_at" = NULL WHERE "' + norm.primaryKey + '" = ?', [inst[norm.primaryKey]]);
|
|
861
|
+
inst.deletedAt = null;
|
|
862
|
+
await __schemaRunHook(def, inst, 'afterUpdate');
|
|
379
863
|
return inst;
|
|
380
864
|
}
|
|
381
865
|
|
|
@@ -386,20 +870,27 @@ function __schemaSerialize(v, field) {
|
|
|
386
870
|
return v;
|
|
387
871
|
}
|
|
388
872
|
|
|
389
|
-
// ORM prototype augmentations — added to __SchemaDef
|
|
873
|
+
// ---- ORM prototype augmentations — added to __SchemaDef ----------------------
|
|
390
874
|
|
|
391
875
|
__SchemaDef.prototype.find = async function (id) {
|
|
392
876
|
this._assertModel('find');
|
|
877
|
+
// Routed through the builder so find honors the same filters as every
|
|
878
|
+
// other read: the @softDelete `deleted_at IS NULL` filter and the
|
|
879
|
+
// model's @defaultScope (Active Record semantics — default_scope
|
|
880
|
+
// applies to find). `User.unscoped().where(id: …).first!` is the
|
|
881
|
+
// escape hatch.
|
|
882
|
+
const norm = this._normalize();
|
|
883
|
+
return new __SchemaQuery(this).where({ [norm.primaryKey]: id }).first();
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
__SchemaDef.prototype.findMany = async function (ids) {
|
|
887
|
+
this._assertModel('findMany');
|
|
888
|
+
if (!Array.isArray(ids)) throw new Error('schema: findMany(ids) expects an array');
|
|
889
|
+
if (!ids.length) return [];
|
|
393
890
|
const norm = this._normalize();
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
// Harbor returns rowCount (not the legacy `rows` alias). Treat both
|
|
398
|
-
// as authoritative so the runtime works against any /sql adapter
|
|
399
|
-
// that has a row-count field, regardless of which name it uses.
|
|
400
|
-
const n = res.rowCount ?? res.rows;
|
|
401
|
-
if (!n || !res.data?.[0]) return null;
|
|
402
|
-
return this._hydrate(res.columns, res.data[0]);
|
|
891
|
+
return new __SchemaQuery(this)
|
|
892
|
+
.where('"' + norm.primaryKey + '" IN (' + ids.map(() => '?').join(', ') + ')', ...ids)
|
|
893
|
+
.all();
|
|
403
894
|
};
|
|
404
895
|
|
|
405
896
|
__SchemaDef.prototype.where = function (cond, ...params) {
|
|
@@ -407,6 +898,26 @@ __SchemaDef.prototype.where = function (cond, ...params) {
|
|
|
407
898
|
return new __SchemaQuery(this).where(cond, ...params);
|
|
408
899
|
};
|
|
409
900
|
|
|
901
|
+
__SchemaDef.prototype.includes = function (...specs) {
|
|
902
|
+
this._assertModel('includes');
|
|
903
|
+
return new __SchemaQuery(this).includes(...specs);
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
__SchemaDef.prototype.withDeleted = function () {
|
|
907
|
+
this._assertModel('withDeleted');
|
|
908
|
+
return new __SchemaQuery(this).withDeleted();
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
__SchemaDef.prototype.onlyDeleted = function () {
|
|
912
|
+
this._assertModel('onlyDeleted');
|
|
913
|
+
return new __SchemaQuery(this).onlyDeleted();
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
__SchemaDef.prototype.unscoped = function () {
|
|
917
|
+
this._assertModel('unscoped');
|
|
918
|
+
return new __SchemaQuery(this).unscoped();
|
|
919
|
+
};
|
|
920
|
+
|
|
410
921
|
__SchemaDef.prototype.all = function () {
|
|
411
922
|
this._assertModel('all');
|
|
412
923
|
return new __SchemaQuery(this).all();
|
|
@@ -445,6 +956,139 @@ __SchemaDef.prototype.create = async function (data) {
|
|
|
445
956
|
return inst;
|
|
446
957
|
};
|
|
447
958
|
|
|
959
|
+
// INSERT ... ON CONFLICT (target) DO UPDATE SET ... RETURNING *.
|
|
960
|
+
//
|
|
961
|
+
// User.upsert! {email: "a@b.c", name: "Alice"}, on: :email
|
|
962
|
+
//
|
|
963
|
+
// Validates the row and fires beforeValidation / beforeSave / afterSave.
|
|
964
|
+
// beforeCreate/beforeUpdate do NOT fire — the runtime cannot know which
|
|
965
|
+
// branch the database took. The conflict target accepts a :symbol,
|
|
966
|
+
// string, or array of either (composite targets).
|
|
967
|
+
__SchemaDef.prototype.upsert = async function (data, opts) {
|
|
968
|
+
this._assertModel('upsert');
|
|
969
|
+
const norm = this._normalize();
|
|
970
|
+
const on = opts && (opts.on ?? opts.conflict);
|
|
971
|
+
if (on == null) throw new Error("schema: upsert(data, on: :column) requires a conflict target");
|
|
972
|
+
const targets = (Array.isArray(on) ? on : [on]).map(t =>
|
|
973
|
+
__schemaSnake(typeof t === 'symbol' ? (Symbol.keyFor(t) || t.description) : String(t)));
|
|
974
|
+
|
|
975
|
+
const klass = this._getClass();
|
|
976
|
+
const canonical = {};
|
|
977
|
+
if (data && typeof data === 'object') {
|
|
978
|
+
for (const k of Object.keys(data)) canonical[__schemaCamel(k)] = data[k];
|
|
979
|
+
}
|
|
980
|
+
const inst = new klass(this._applyDefaults(canonical), false);
|
|
981
|
+
for (const [k, v] of Object.entries(canonical)) {
|
|
982
|
+
if (!(k in inst)) {
|
|
983
|
+
Object.defineProperty(inst, k, { value: v, enumerable: true, writable: true, configurable: true });
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
await __schemaRunHook(this, inst, 'beforeValidation');
|
|
988
|
+
const errs = this._validateFields(inst, true);
|
|
989
|
+
if (errs.length) throw new SchemaError(errs, this.name, this.kind);
|
|
990
|
+
await __schemaRunHook(this, inst, 'afterValidation');
|
|
991
|
+
await __schemaRunHook(this, inst, 'beforeSave');
|
|
992
|
+
|
|
993
|
+
const cols = [], placeholders = [], values = [];
|
|
994
|
+
for (const [n, f] of norm.fields) {
|
|
995
|
+
const v = inst[n];
|
|
996
|
+
if (v == null) continue;
|
|
997
|
+
cols.push(__schemaSnake(n));
|
|
998
|
+
placeholders.push('?');
|
|
999
|
+
values.push(__schemaSerialize(v, f));
|
|
1000
|
+
}
|
|
1001
|
+
for (const [, rel] of norm.relations) {
|
|
1002
|
+
if (rel.kind !== 'belongsTo') continue;
|
|
1003
|
+
const v = inst[__schemaCamel(rel.foreignKey)];
|
|
1004
|
+
if (v != null) {
|
|
1005
|
+
cols.push(rel.foreignKey);
|
|
1006
|
+
placeholders.push('?');
|
|
1007
|
+
values.push(v);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
if (!cols.length) throw new Error('schema: upsert() requires at least one column');
|
|
1011
|
+
const updateCols = cols.filter(c => !targets.includes(c));
|
|
1012
|
+
let conflict = ' ON CONFLICT (' + targets.map(t => '"' + t + '"').join(', ') + ')';
|
|
1013
|
+
if (updateCols.length) {
|
|
1014
|
+
const sets = updateCols.map(c => '"' + c + '" = EXCLUDED."' + c + '"');
|
|
1015
|
+
if (norm.timestamps) sets.push('"updated_at" = CURRENT_TIMESTAMP');
|
|
1016
|
+
conflict += ' DO UPDATE SET ' + sets.join(', ');
|
|
1017
|
+
} else {
|
|
1018
|
+
conflict += ' DO NOTHING';
|
|
1019
|
+
}
|
|
1020
|
+
const sql = 'INSERT INTO "' + norm.tableName + '" (' + cols.map(c => '"' + c + '"').join(', ') + ')' +
|
|
1021
|
+
' VALUES (' + placeholders.join(', ') + ')' + conflict + ' RETURNING *';
|
|
1022
|
+
const res = await __schemaRunSQL(this, sql, values);
|
|
1023
|
+
if (res.data?.[0] && res.columns) __schemaAbsorbRow(inst, res.columns, res.data[0]);
|
|
1024
|
+
this._applyEagerDerived(inst);
|
|
1025
|
+
inst._snapshot = __schemaSnapshot(norm, inst);
|
|
1026
|
+
inst._persisted = true;
|
|
1027
|
+
await __schemaRunHook(this, inst, 'afterSave');
|
|
1028
|
+
await __schemaSettleTxHooks(this, inst);
|
|
1029
|
+
return inst;
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
// Bulk insert: validates EVERY row first (collecting all failures into
|
|
1033
|
+
// one SchemaError, issues prefixed `[i].field`, before any SQL), then
|
|
1034
|
+
// issues one multi-VALUES INSERT ... RETURNING *. Per-instance hooks
|
|
1035
|
+
// are deliberately skipped — this is the bulk path; use create! in a
|
|
1036
|
+
// loop when hooks matter. Returns hydrated instances.
|
|
1037
|
+
__SchemaDef.prototype.insertMany = async function (rows) {
|
|
1038
|
+
this._assertModel('insertMany');
|
|
1039
|
+
if (!Array.isArray(rows)) throw new Error('schema: insertMany(rows) expects an array');
|
|
1040
|
+
if (!rows.length) return [];
|
|
1041
|
+
const norm = this._normalize();
|
|
1042
|
+
|
|
1043
|
+
const canonicalRows = [];
|
|
1044
|
+
const allErrs = [];
|
|
1045
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1046
|
+
const canonical = {};
|
|
1047
|
+
const data = rows[i];
|
|
1048
|
+
if (data && typeof data === 'object') {
|
|
1049
|
+
for (const k of Object.keys(data)) canonical[__schemaCamel(k)] = data[k];
|
|
1050
|
+
}
|
|
1051
|
+
this._applyDefaults(canonical);
|
|
1052
|
+
for (const e of this._validateFields(canonical, true)) {
|
|
1053
|
+
allErrs.push({
|
|
1054
|
+
field: '[' + i + ']' + (e.field ? '.' + e.field : ''),
|
|
1055
|
+
error: e.error,
|
|
1056
|
+
message: '[' + i + '] ' + e.message,
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
canonicalRows.push(canonical);
|
|
1060
|
+
}
|
|
1061
|
+
if (allErrs.length) throw new SchemaError(allErrs, this.name, this.kind);
|
|
1062
|
+
|
|
1063
|
+
// Column set = union of written columns across rows (missing values
|
|
1064
|
+
// insert as NULL / column default).
|
|
1065
|
+
const colSet = new Set();
|
|
1066
|
+
for (const row of canonicalRows) {
|
|
1067
|
+
for (const [n] of norm.fields) if (row[n] != null) colSet.add(n);
|
|
1068
|
+
for (const [, rel] of norm.relations) {
|
|
1069
|
+
if (rel.kind !== 'belongsTo') continue;
|
|
1070
|
+
if (row[__schemaCamel(rel.foreignKey)] != null) colSet.add(__schemaCamel(rel.foreignKey));
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
const colNames = [...colSet];
|
|
1074
|
+
if (!colNames.length) throw new Error('schema: insertMany() requires at least one column');
|
|
1075
|
+
const values = [];
|
|
1076
|
+
const tuples = [];
|
|
1077
|
+
for (const row of canonicalRows) {
|
|
1078
|
+
const slots = [];
|
|
1079
|
+
for (const n of colNames) {
|
|
1080
|
+
slots.push('?');
|
|
1081
|
+
values.push(__schemaSerialize(row[n] ?? null, norm.fields.get(n)));
|
|
1082
|
+
}
|
|
1083
|
+
tuples.push('(' + slots.join(', ') + ')');
|
|
1084
|
+
}
|
|
1085
|
+
const sql = 'INSERT INTO "' + norm.tableName + '" (' +
|
|
1086
|
+
colNames.map(n => '"' + __schemaSnake(n) + '"').join(', ') + ') VALUES ' +
|
|
1087
|
+
tuples.join(', ') + ' RETURNING *';
|
|
1088
|
+
const res = await __schemaRunSQL(this, sql, values);
|
|
1089
|
+
return (res.data || []).map(row => this._hydrate(res.columns, row));
|
|
1090
|
+
};
|
|
1091
|
+
|
|
448
1092
|
__SchemaDef.prototype._assertModel = function (api) {
|
|
449
1093
|
if (this.kind !== 'model') {
|
|
450
1094
|
throw new Error('schema: .' + api + '() is :model-only (got :' + this.kind + ')');
|