@relations-dil/relations 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +824 -0
- package/dist/dynamic.d.ts +122 -0
- package/dist/dynamic.js +164 -0
- package/dist/errors.d.ts +31 -0
- package/dist/errors.js +50 -0
- package/dist/field.d.ts +157 -0
- package/dist/field.js +660 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +75 -0
- package/dist/kinds.d.ts +99 -0
- package/dist/kinds.js +169 -0
- package/dist/migrations.d.ts +62 -0
- package/dist/migrations.js +293 -0
- package/dist/mock.d.ts +88 -0
- package/dist/mock.js +511 -0
- package/dist/model.d.ts +277 -0
- package/dist/model.js +1159 -0
- package/dist/overscore.d.ts +66 -0
- package/dist/overscore.js +226 -0
- package/dist/record.d.ts +58 -0
- package/dist/record.js +246 -0
- package/dist/registry.d.ts +24 -0
- package/dist/registry.js +27 -0
- package/dist/relation.d.ts +114 -0
- package/dist/relation.js +145 -0
- package/dist/source.d.ts +147 -0
- package/dist/source.js +409 -0
- package/dist/titles.d.ts +43 -0
- package/dist/titles.js +115 -0
- package/dist/typed.d.ts +99 -0
- package/dist/typed.js +44 -0
- package/dist/util.d.ts +16 -0
- package/dist/util.js +117 -0
- package/package.json +68 -0
package/dist/model.js
ADDED
|
@@ -0,0 +1,1159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Models: what you actually write, and the only thing that knows about relations.
|
|
3
|
+
*/
|
|
4
|
+
import * as overscore from './overscore.js';
|
|
5
|
+
import { FieldError, ModelError } from './errors.js';
|
|
6
|
+
import { Field } from './field.js';
|
|
7
|
+
import { Record } from './record.js';
|
|
8
|
+
import { source as registered } from './registry.js';
|
|
9
|
+
import { compare as order, underscore } from './util.js';
|
|
10
|
+
import { bool, builtin, dict, float, int, list, set, str } from './kinds.js';
|
|
11
|
+
/** Marks a constructor call as coming from relations itself rather than a caller. */
|
|
12
|
+
const INTERNAL = Symbol('relations.internal');
|
|
13
|
+
/** Identity attributes that go into a definition under their underscored name. */
|
|
14
|
+
const DEFINE = ['id', 'unique', 'index'];
|
|
15
|
+
/** Uppercase attributes that never go into a definition. */
|
|
16
|
+
const UNDEFINE = [
|
|
17
|
+
'ID',
|
|
18
|
+
'TITLES',
|
|
19
|
+
'LIST',
|
|
20
|
+
'UNIQUE',
|
|
21
|
+
'INDEX',
|
|
22
|
+
'ORDER',
|
|
23
|
+
'CHUNK',
|
|
24
|
+
'PARENTS',
|
|
25
|
+
'CHILDREN',
|
|
26
|
+
'SISTERS',
|
|
27
|
+
'BROTHERS'
|
|
28
|
+
];
|
|
29
|
+
/** Uppercase attributes relations sets itself, so extras can be spotted. */
|
|
30
|
+
const KNOWN = new Set([...UNDEFINE, 'SOURCE', 'TITLE', 'NAME', 'TIE']);
|
|
31
|
+
function plain(value) {
|
|
32
|
+
return overscore.isDict(value);
|
|
33
|
+
}
|
|
34
|
+
/** A trailing plain object is named values; everything before it is positional. */
|
|
35
|
+
function split(args) {
|
|
36
|
+
const values = [...args];
|
|
37
|
+
let named = {};
|
|
38
|
+
if (values.length && plain(values[values.length - 1])) {
|
|
39
|
+
named = { ...values.pop() };
|
|
40
|
+
}
|
|
41
|
+
return { positional: values, named };
|
|
42
|
+
}
|
|
43
|
+
/** Options a caller may pass alongside field values, the way Python passes kwargs. */
|
|
44
|
+
const OPTIONS = {
|
|
45
|
+
_read: 'read',
|
|
46
|
+
_child: 'child',
|
|
47
|
+
_parent: 'parent',
|
|
48
|
+
_sibling: 'sibling',
|
|
49
|
+
_action: 'action',
|
|
50
|
+
_mode: 'mode',
|
|
51
|
+
_chunk: 'chunk',
|
|
52
|
+
_bulk: 'bulk',
|
|
53
|
+
_size: 'size',
|
|
54
|
+
_related: 'related'
|
|
55
|
+
};
|
|
56
|
+
/** Pull relations' own options out of named values, leaving the field values behind. */
|
|
57
|
+
function options(named, into) {
|
|
58
|
+
for (const [key, option] of Object.entries(OPTIONS)) {
|
|
59
|
+
if (key in named) {
|
|
60
|
+
;
|
|
61
|
+
into[option] = named[key];
|
|
62
|
+
delete named[key];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function classy(value) {
|
|
67
|
+
return /^\s*class[\s{]/.test(Function.prototype.toString.call(value));
|
|
68
|
+
}
|
|
69
|
+
/** The kind that best describes a value, used to type defaults and options. */
|
|
70
|
+
function kindOfValue(value) {
|
|
71
|
+
if (typeof value === 'boolean') {
|
|
72
|
+
return bool;
|
|
73
|
+
}
|
|
74
|
+
if (typeof value === 'number') {
|
|
75
|
+
return Number.isInteger(value) ? int : float;
|
|
76
|
+
}
|
|
77
|
+
if (typeof value === 'string') {
|
|
78
|
+
return str;
|
|
79
|
+
}
|
|
80
|
+
if (value instanceof Set) {
|
|
81
|
+
return set;
|
|
82
|
+
}
|
|
83
|
+
if (Array.isArray(value)) {
|
|
84
|
+
return list;
|
|
85
|
+
}
|
|
86
|
+
return dict;
|
|
87
|
+
}
|
|
88
|
+
/** Turn a `static fields` entry into an actual Field. */
|
|
89
|
+
export function toField(spec) {
|
|
90
|
+
if (spec instanceof Field) {
|
|
91
|
+
return spec;
|
|
92
|
+
}
|
|
93
|
+
if (builtin(spec)) {
|
|
94
|
+
return new Field(spec);
|
|
95
|
+
}
|
|
96
|
+
if (spec instanceof Set) {
|
|
97
|
+
return new Field(set, { options: [...spec].sort() });
|
|
98
|
+
}
|
|
99
|
+
if (Array.isArray(spec)) {
|
|
100
|
+
if (!spec.length) {
|
|
101
|
+
throw new Error('a field declared as an array needs at least one option');
|
|
102
|
+
}
|
|
103
|
+
return new Field(kindOfValue(spec[0]), { default: spec[0], options: spec });
|
|
104
|
+
}
|
|
105
|
+
if (typeof spec === 'function') {
|
|
106
|
+
if (classy(spec)) {
|
|
107
|
+
throw new Error(`class kinds need attr - declare the field as { kind: ${spec.name}, attr: [...] }`);
|
|
108
|
+
}
|
|
109
|
+
const sample = spec();
|
|
110
|
+
return new Field(kindOfValue(sample), { default: spec });
|
|
111
|
+
}
|
|
112
|
+
if (plain(spec) && 'kind' in spec) {
|
|
113
|
+
return new Field(spec.kind, spec);
|
|
114
|
+
}
|
|
115
|
+
throw new Error(`cannot make a field from ${JSON.stringify(spec)}`);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* What's knowable about a model without having an instance of it: its name, its fields,
|
|
119
|
+
* its indexes, its relations. Sources and migrations work against this.
|
|
120
|
+
*/
|
|
121
|
+
export class ModelIdentity {
|
|
122
|
+
/** Name of the source this model lives in. */
|
|
123
|
+
SOURCE = null;
|
|
124
|
+
/** Human-facing title of the model. */
|
|
125
|
+
TITLE = null;
|
|
126
|
+
/** Name of the model in the source. */
|
|
127
|
+
NAME = null;
|
|
128
|
+
/** Whether this model is a many-to-many tie table. */
|
|
129
|
+
TIE = null;
|
|
130
|
+
/** Parent relations, keyed by the attribute on this model. */
|
|
131
|
+
PARENTS = {};
|
|
132
|
+
/** Child relations, keyed by the attribute on this model. */
|
|
133
|
+
CHILDREN = {};
|
|
134
|
+
/** Sister relations, keyed by the attribute on this model. */
|
|
135
|
+
SISTERS = {};
|
|
136
|
+
/** Brother relations, keyed by the attribute on this model. */
|
|
137
|
+
BROTHERS = {};
|
|
138
|
+
/** Default chunk size for related lookups and bulk creates. */
|
|
139
|
+
CHUNK = 100;
|
|
140
|
+
/** The model's fields, in order. Records are built from these. */
|
|
141
|
+
_fields = new Record();
|
|
142
|
+
/** Name of the id field, if there is one. */
|
|
143
|
+
_id = null;
|
|
144
|
+
/** Fields that make up a title. */
|
|
145
|
+
_titles = [];
|
|
146
|
+
/** Fields to show in a listing. */
|
|
147
|
+
_list = [];
|
|
148
|
+
/** Unique indexes, keyed by index name. */
|
|
149
|
+
_unique = {};
|
|
150
|
+
/** Regular indexes, keyed by index name. */
|
|
151
|
+
_index = {};
|
|
152
|
+
/** Default sort order, as signed field names. */
|
|
153
|
+
_order = [];
|
|
154
|
+
/** The name of a field, whether given by name or position. */
|
|
155
|
+
_fieldName(field) {
|
|
156
|
+
if (!this._fields.has(field)) {
|
|
157
|
+
throw new ModelError(this, `cannot find field ${field} in ${this.NAME}`);
|
|
158
|
+
}
|
|
159
|
+
if (typeof field === 'string') {
|
|
160
|
+
return field;
|
|
161
|
+
}
|
|
162
|
+
return this._fields._order[field].name;
|
|
163
|
+
}
|
|
164
|
+
/** Turn a sort spec into signed field names, checking each one exists. */
|
|
165
|
+
_ordering(sort) {
|
|
166
|
+
const sorts = typeof sort === 'string' ? [sort] : [...sort];
|
|
167
|
+
const ordering = [];
|
|
168
|
+
for (const each of sorts) {
|
|
169
|
+
const field = ['-', '+'].includes(each[0]) ? each.slice(1) : each;
|
|
170
|
+
if (!this._fields.has(field.split('__')[0])) {
|
|
171
|
+
throw new ModelError(this, `unknown sort field ${field}`);
|
|
172
|
+
}
|
|
173
|
+
ordering.push(each === field ? `+${each}` : each);
|
|
174
|
+
}
|
|
175
|
+
return ordering;
|
|
176
|
+
}
|
|
177
|
+
/** The parent relation a field points at, if any. */
|
|
178
|
+
_ancestor(field) {
|
|
179
|
+
for (const relation of Object.values(this.PARENTS)) {
|
|
180
|
+
if (field === relation.childParentRef) {
|
|
181
|
+
return relation;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
/** The full definition of this model, for schemas and migrations. */
|
|
187
|
+
define() {
|
|
188
|
+
const definition = {
|
|
189
|
+
fields: this._fields.define()
|
|
190
|
+
};
|
|
191
|
+
for (const attr of DEFINE) {
|
|
192
|
+
const value = this[`_${attr}`];
|
|
193
|
+
if (value !== null && value !== undefined) {
|
|
194
|
+
definition[attr] = value;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Any uppercase attribute that carries meaning lands in the definition, lowercased.
|
|
198
|
+
for (const attr of Object.keys(this)) {
|
|
199
|
+
if (attr[0] !== '_' &&
|
|
200
|
+
attr === attr.toUpperCase() &&
|
|
201
|
+
!UNDEFINE.includes(attr) &&
|
|
202
|
+
this[attr] !== null &&
|
|
203
|
+
this[attr] !== undefined) {
|
|
204
|
+
definition[attr.toLowerCase()] = this[attr];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return definition;
|
|
208
|
+
}
|
|
209
|
+
/** How to get from a previous definition of this model to this one. */
|
|
210
|
+
migrate(previous, definition) {
|
|
211
|
+
return MigrationsRef.model(previous, definition ?? this.define());
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Set by migrations.ts at import time, so identity.migrate works without a circular import.
|
|
215
|
+
export const MigrationsRef = {
|
|
216
|
+
model: () => {
|
|
217
|
+
throw new Error('migrations not loaded');
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
/**
|
|
221
|
+
* A model. Extend it, declare `static source` and `static fields`, and you have
|
|
222
|
+
* something you can create, retrieve, update and delete against any source.
|
|
223
|
+
*
|
|
224
|
+
* ```ts
|
|
225
|
+
* class Unit extends Model {
|
|
226
|
+
* static source = 'example'
|
|
227
|
+
* static fields = { id: int, name: str }
|
|
228
|
+
* }
|
|
229
|
+
*
|
|
230
|
+
* await new Unit('yep').create()
|
|
231
|
+
* const unit = await Unit.one({ name: 'yep' }).retrieve()
|
|
232
|
+
* ```
|
|
233
|
+
*/
|
|
234
|
+
export class Model extends ModelIdentity {
|
|
235
|
+
/** The single loaded record, in one mode. */
|
|
236
|
+
_record = null;
|
|
237
|
+
/** The loaded models, in many mode. */
|
|
238
|
+
_models = null;
|
|
239
|
+
/** Loaded parent models, keyed by attribute. */
|
|
240
|
+
_parents = {};
|
|
241
|
+
/** Loaded child models, keyed by attribute. */
|
|
242
|
+
_children = {};
|
|
243
|
+
/** Loaded sister models, keyed by attribute. */
|
|
244
|
+
_sisters = {};
|
|
245
|
+
/** Loaded brother models, keyed by attribute. */
|
|
246
|
+
_brothers = {};
|
|
247
|
+
/** Pending sibling-attribute criteria, keyed by relation attribute. */
|
|
248
|
+
_ties = {};
|
|
249
|
+
/** What this model is to whatever reached it. */
|
|
250
|
+
_role = 'model';
|
|
251
|
+
/** Whether this model holds one record or many. */
|
|
252
|
+
_mode = null;
|
|
253
|
+
/** Whether this is a bulk insert. */
|
|
254
|
+
_bulk = false;
|
|
255
|
+
/** Chunk size for this model. */
|
|
256
|
+
_chunk = 100;
|
|
257
|
+
/** How many to accumulate before a bulk insert flushes. */
|
|
258
|
+
_size = 100;
|
|
259
|
+
/** The current fuzzy match. */
|
|
260
|
+
_like = null;
|
|
261
|
+
/** Sorting to apply on retrieve. */
|
|
262
|
+
_sort = null;
|
|
263
|
+
/** How many records to retrieve. */
|
|
264
|
+
_limit = null;
|
|
265
|
+
/** Where to start retrieving from. */
|
|
266
|
+
_offset = 0;
|
|
267
|
+
/** What this model is doing. */
|
|
268
|
+
_action = 'create';
|
|
269
|
+
/** Fields set automatically because of a relation. */
|
|
270
|
+
_related = {};
|
|
271
|
+
/** Whether a limit was reached, so there may be more records than were returned. */
|
|
272
|
+
overflow = false;
|
|
273
|
+
constructor(...args) {
|
|
274
|
+
super();
|
|
275
|
+
const internal = args[0] === INTERNAL ? { ...args[1] } : {};
|
|
276
|
+
const { positional, named } = split(args[0] === INTERNAL ? internal.args ?? [] : args);
|
|
277
|
+
options(named, internal);
|
|
278
|
+
this.constructor.thy(this);
|
|
279
|
+
this._parents = {};
|
|
280
|
+
this._children = {};
|
|
281
|
+
this._sisters = {};
|
|
282
|
+
this._brothers = {};
|
|
283
|
+
this._ties = {};
|
|
284
|
+
this._related = {};
|
|
285
|
+
this._role = 'model';
|
|
286
|
+
this._action = internal.action ?? 'create';
|
|
287
|
+
this._chunk = internal.chunk ?? this.CHUNK;
|
|
288
|
+
if (internal.read !== undefined) {
|
|
289
|
+
// Built from a record that came back out of a source.
|
|
290
|
+
this._mode = 'one';
|
|
291
|
+
this._action = 'update';
|
|
292
|
+
this._record = this._build('update', { read: internal.read });
|
|
293
|
+
}
|
|
294
|
+
else if (internal.child !== undefined) {
|
|
295
|
+
// Built as the parent of a model that asked for it.
|
|
296
|
+
this._related = internal.child;
|
|
297
|
+
this._role = 'parent';
|
|
298
|
+
this._mode = 'one';
|
|
299
|
+
this._action = 'retrieve';
|
|
300
|
+
this._record = this._build('retrieve', { defaults: false });
|
|
301
|
+
this.filter(...positional, named);
|
|
302
|
+
}
|
|
303
|
+
else if (internal.parent !== undefined) {
|
|
304
|
+
// Built as the child of a model that asked for it.
|
|
305
|
+
this._related = internal.parent;
|
|
306
|
+
this._role = 'child';
|
|
307
|
+
this._mode = internal.mode ?? null;
|
|
308
|
+
this._action = Object.values(this._related)[0] !== null ? 'retrieve' : 'create';
|
|
309
|
+
if (this._action === 'retrieve') {
|
|
310
|
+
this._record = this._build('retrieve', { defaults: false });
|
|
311
|
+
this.filter(...positional, named);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
else if (internal.sibling !== undefined) {
|
|
315
|
+
// Built as the far side of a many-to-many.
|
|
316
|
+
this._mode = 'many';
|
|
317
|
+
this._action = 'retrieve';
|
|
318
|
+
this._record = this._build('retrieve', { defaults: false });
|
|
319
|
+
this.filter(...positional, { ...internal.sibling, ...named });
|
|
320
|
+
}
|
|
321
|
+
else if (this._action === 'retrieve') {
|
|
322
|
+
this._mode = internal.mode ?? null;
|
|
323
|
+
this._record = this._build('retrieve', { defaults: false });
|
|
324
|
+
this.filter(...positional, named);
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
this._bulk = internal.bulk ?? false;
|
|
328
|
+
this._size = internal.size ?? this._chunk;
|
|
329
|
+
this._mode = internal.mode ?? (this._bulk || (positional.length && Array.isArray(positional[0])) ? 'many' : 'one');
|
|
330
|
+
this._related = internal.related ?? {};
|
|
331
|
+
if (this._mode === 'many') {
|
|
332
|
+
this._models = [];
|
|
333
|
+
if (positional.length) {
|
|
334
|
+
for (const each of positional[0]) {
|
|
335
|
+
this._models.push(construct(this.constructor, {
|
|
336
|
+
action: 'create',
|
|
337
|
+
related: this._related,
|
|
338
|
+
args: Array.isArray(each) ? each : [each]
|
|
339
|
+
}));
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
this._record = this._build('create', { positional, named });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return wrap(this);
|
|
348
|
+
}
|
|
349
|
+
// ------------------------------------------------------------------ identity
|
|
350
|
+
/**
|
|
351
|
+
* Work out everything knowable about this model class. Called for you on every
|
|
352
|
+
* instance; call it directly when you need a model's shape without an instance.
|
|
353
|
+
*/
|
|
354
|
+
static thy(target) {
|
|
355
|
+
const cls = this;
|
|
356
|
+
const self = target ?? new ModelIdentity();
|
|
357
|
+
self.TITLE = cls.title ?? cls.name;
|
|
358
|
+
self.NAME = cls.store ?? underscore(self.TITLE);
|
|
359
|
+
// Build the fields.
|
|
360
|
+
const fields = new Record();
|
|
361
|
+
for (const [name, spec] of Object.entries(cls.fields ?? {})) {
|
|
362
|
+
const field = toField(spec);
|
|
363
|
+
field.name = name;
|
|
364
|
+
fields.append(field);
|
|
365
|
+
}
|
|
366
|
+
self._fields = fields;
|
|
367
|
+
// Which field is the id.
|
|
368
|
+
const id = cls.id === undefined ? 0 : cls.id;
|
|
369
|
+
self._id = id === null ? null : self._fieldName(id);
|
|
370
|
+
// Which fields make up a title.
|
|
371
|
+
let titles = cls.titles;
|
|
372
|
+
if (!titles) {
|
|
373
|
+
const found = [];
|
|
374
|
+
for (const field of self._fields._order) {
|
|
375
|
+
if (self._id === field.name) {
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (field.kind === int || field.kind === str) {
|
|
379
|
+
found.push(field.name);
|
|
380
|
+
if (field.kind === str && field._none === null) {
|
|
381
|
+
field.none = false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (field.kind === str) {
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
titles = found;
|
|
389
|
+
}
|
|
390
|
+
self._titles = typeof titles === 'string' ? [titles] : [...titles];
|
|
391
|
+
for (const field of self._titles) {
|
|
392
|
+
if (!self._fields.has(field.split('__')[0])) {
|
|
393
|
+
throw new ModelError(self, `cannot find field ${field} from titles`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// Which fields to list.
|
|
397
|
+
if (cls.list) {
|
|
398
|
+
self._list = typeof cls.list === 'string' ? [cls.list] : [...cls.list];
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
self._list = [...self._titles];
|
|
402
|
+
if (self._id && !self._list.includes(self._id)) {
|
|
403
|
+
self._list.unshift(self._id);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
for (const field of self._list) {
|
|
407
|
+
if (!self._fields.has(field.split('__')[0])) {
|
|
408
|
+
throw new ModelError(self, `cannot find field ${field} from list`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// Unique indexes: unset means the titles, explicitly empty means none.
|
|
412
|
+
let unique = cls.unique;
|
|
413
|
+
if (unique === undefined) {
|
|
414
|
+
unique = self._titles;
|
|
415
|
+
}
|
|
416
|
+
else if (!unique || (Array.isArray(unique) && !unique.length)) {
|
|
417
|
+
unique = {};
|
|
418
|
+
}
|
|
419
|
+
if (typeof unique === 'string') {
|
|
420
|
+
unique = [unique];
|
|
421
|
+
}
|
|
422
|
+
if (Array.isArray(unique)) {
|
|
423
|
+
unique = unique.length ? { [unique.join('-')]: unique } : {};
|
|
424
|
+
}
|
|
425
|
+
self._unique = unique;
|
|
426
|
+
for (const [name, group] of Object.entries(self._unique)) {
|
|
427
|
+
for (const field of group) {
|
|
428
|
+
if (!self._fields.has(field.split('__')[0])) {
|
|
429
|
+
throw new ModelError(self, `cannot find field ${field} from unique ${name}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// Regular indexes.
|
|
434
|
+
let index = cls.index ?? {};
|
|
435
|
+
if (typeof index === 'string') {
|
|
436
|
+
index = [index];
|
|
437
|
+
}
|
|
438
|
+
if (Array.isArray(index)) {
|
|
439
|
+
index = index.length ? { [index.join('-')]: index } : {};
|
|
440
|
+
}
|
|
441
|
+
self._index = index;
|
|
442
|
+
for (const [name, group] of Object.entries(self._index)) {
|
|
443
|
+
for (const field of group) {
|
|
444
|
+
if (!self._fields.has(field.split('__')[0])) {
|
|
445
|
+
throw new ModelError(self, `cannot find field ${field} from index ${name}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// Injected fields have to point at a list or dict field that exists.
|
|
450
|
+
for (const field of self._fields._order) {
|
|
451
|
+
if (!field.inject) {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
const root = field.inject.split('__')[0];
|
|
455
|
+
const into = self._fields.field(root);
|
|
456
|
+
if (into === undefined) {
|
|
457
|
+
throw new FieldError(field, `cannot find field ${root} from inject ${field.inject}`);
|
|
458
|
+
}
|
|
459
|
+
if (into.kind !== list && into.kind !== dict) {
|
|
460
|
+
throw new FieldError(field, `field ${root} not list or dict from inject ${field.inject}`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
// Default sort order.
|
|
464
|
+
if (cls.order) {
|
|
465
|
+
self._order = self._ordering(cls.order);
|
|
466
|
+
}
|
|
467
|
+
else if (cls.order === undefined && Object.keys(self._unique).length === 1) {
|
|
468
|
+
self._order = self._ordering(Object.values(self._unique)[0]);
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
self._order = [];
|
|
472
|
+
}
|
|
473
|
+
self.CHUNK = cls.chunk ?? 100;
|
|
474
|
+
// Relations, registered on the class by the Relation constructors.
|
|
475
|
+
self.PARENTS = cls.PARENTS ?? {};
|
|
476
|
+
self.CHILDREN = cls.CHILDREN ?? {};
|
|
477
|
+
self.SISTERS = cls.SISTERS ?? {};
|
|
478
|
+
self.BROTHERS = cls.BROTHERS ?? {};
|
|
479
|
+
self.TIE = cls.TIE ?? null;
|
|
480
|
+
// Anything else the class declared in uppercase travels with the identity.
|
|
481
|
+
for (let level = cls; level && level !== Function.prototype; level = Object.getPrototypeOf(level)) {
|
|
482
|
+
for (const attr of Object.getOwnPropertyNames(level)) {
|
|
483
|
+
if (attr === attr.toUpperCase() && /^[A-Z]/.test(attr) && !KNOWN.has(attr) && !(attr in self)) {
|
|
484
|
+
self[attr] = cls[attr];
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
for (const relation of Object.values(self.SISTERS)) {
|
|
489
|
+
const field = self._fields.field(relation.brotherSisterRef);
|
|
490
|
+
field.store = false;
|
|
491
|
+
field.tied = true;
|
|
492
|
+
}
|
|
493
|
+
for (const relation of Object.values(self.BROTHERS)) {
|
|
494
|
+
const field = self._fields.field(relation.sisterBrotherRef);
|
|
495
|
+
field.store = false;
|
|
496
|
+
field.tied = true;
|
|
497
|
+
}
|
|
498
|
+
// Let the source have its say.
|
|
499
|
+
self.SOURCE = cls.source ?? null;
|
|
500
|
+
registered(self.SOURCE)?.init(self);
|
|
501
|
+
return self;
|
|
502
|
+
}
|
|
503
|
+
/** Register a parent relation on this class. */
|
|
504
|
+
static _parent(relation) {
|
|
505
|
+
const cls = this;
|
|
506
|
+
if (!Object.prototype.hasOwnProperty.call(cls, 'PARENTS')) {
|
|
507
|
+
cls.PARENTS = { ...(cls.PARENTS ?? {}) };
|
|
508
|
+
}
|
|
509
|
+
cls.PARENTS[relation.childParentAttr] = relation;
|
|
510
|
+
}
|
|
511
|
+
/** Register a child relation on this class. */
|
|
512
|
+
static _child(relation) {
|
|
513
|
+
const cls = this;
|
|
514
|
+
if (!Object.prototype.hasOwnProperty.call(cls, 'CHILDREN')) {
|
|
515
|
+
cls.CHILDREN = { ...(cls.CHILDREN ?? {}) };
|
|
516
|
+
}
|
|
517
|
+
cls.CHILDREN[relation.parentChildAttr] = relation;
|
|
518
|
+
}
|
|
519
|
+
/** Register a sister relation on this class. */
|
|
520
|
+
static _sister(relation) {
|
|
521
|
+
const cls = this;
|
|
522
|
+
if (!Object.prototype.hasOwnProperty.call(cls, 'SISTERS')) {
|
|
523
|
+
cls.SISTERS = { ...(cls.SISTERS ?? {}) };
|
|
524
|
+
}
|
|
525
|
+
cls.SISTERS[relation.brotherSisterAttr] = relation;
|
|
526
|
+
}
|
|
527
|
+
/** Register a brother relation on this class. */
|
|
528
|
+
static _brother(relation) {
|
|
529
|
+
const cls = this;
|
|
530
|
+
if (!Object.prototype.hasOwnProperty.call(cls, 'BROTHERS')) {
|
|
531
|
+
cls.BROTHERS = { ...(cls.BROTHERS ?? {}) };
|
|
532
|
+
}
|
|
533
|
+
cls.BROTHERS[relation.sisterBrotherAttr] = relation;
|
|
534
|
+
}
|
|
535
|
+
// ------------------------------------------------------------------ internals
|
|
536
|
+
/** Whether a name is one of this model's relation attributes. */
|
|
537
|
+
_isRelation(name) {
|
|
538
|
+
return name in this.PARENTS || name in this.CHILDREN || name in this.SISTERS || name in this.BROTHERS;
|
|
539
|
+
}
|
|
540
|
+
/** The related model behind a relation attribute. */
|
|
541
|
+
_relate(name) {
|
|
542
|
+
if (this._bulk) {
|
|
543
|
+
throw new ModelError(this, 'cannot access relatives in bulk mode');
|
|
544
|
+
}
|
|
545
|
+
if (name in this.PARENTS) {
|
|
546
|
+
const relation = this.PARENTS[name];
|
|
547
|
+
if (!this._parents[name]) {
|
|
548
|
+
this._parents[name] =
|
|
549
|
+
this._action === 'retrieve'
|
|
550
|
+
? relation.Parent.many().limit(this._chunk)
|
|
551
|
+
: construct(relation.Parent, {
|
|
552
|
+
child: { [relation.parentId]: this._item(relation.childParentRef) }
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
return this._parents[name];
|
|
556
|
+
}
|
|
557
|
+
if (name in this.CHILDREN) {
|
|
558
|
+
const relation = this.CHILDREN[name];
|
|
559
|
+
if (!this._children[name]) {
|
|
560
|
+
this._children[name] =
|
|
561
|
+
this._action === 'retrieve'
|
|
562
|
+
? relation.Child.many().limit(this._chunk)
|
|
563
|
+
: construct(relation.Child, {
|
|
564
|
+
parent: { [relation.childParentRef]: this._record.get(relation.parentId) },
|
|
565
|
+
mode: relation.MODE
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
return this._children[name];
|
|
569
|
+
}
|
|
570
|
+
if (name in this.SISTERS) {
|
|
571
|
+
const relation = this.SISTERS[name];
|
|
572
|
+
if (!this._sisters[name]) {
|
|
573
|
+
this._sisters[name] = construct(relation.Sister, {
|
|
574
|
+
sibling: { [`${relation.sisterId}__in`]: this._record.get(relation.brotherSisterRef) }
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
return this._sisters[name];
|
|
578
|
+
}
|
|
579
|
+
if (name in this.BROTHERS) {
|
|
580
|
+
const relation = this.BROTHERS[name];
|
|
581
|
+
if (!this._brothers[name]) {
|
|
582
|
+
this._brothers[name] = construct(relation.Brother, {
|
|
583
|
+
sibling: { [`${relation.brotherId}__in`]: this._record.get(relation.sisterBrotherRef) }
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
return this._brothers[name];
|
|
587
|
+
}
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
/** Hold on to a sibling-attribute filter until the source can resolve it. */
|
|
591
|
+
_tie(name, remainder, value) {
|
|
592
|
+
this._ties[name] = this._ties[name] ?? {};
|
|
593
|
+
this._ties[name][remainder] = value;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Fold criteria gathered on related models into this model's own criteria. Retrieving
|
|
597
|
+
* the relatives is what makes this async: `Test.many({unit__name: 'x'})` has to find
|
|
598
|
+
* the matching units before it can filter tests by their ids.
|
|
599
|
+
*/
|
|
600
|
+
async _collate() {
|
|
601
|
+
for (const [attr, relation] of Object.entries(this.PARENTS)) {
|
|
602
|
+
const parent = this._parents[attr];
|
|
603
|
+
if (parent) {
|
|
604
|
+
await parent._ensure();
|
|
605
|
+
this._record.filter(`${relation.childParentRef}__in`, parent._item(relation.parentId));
|
|
606
|
+
this.overflow = this.overflow || parent.overflow;
|
|
607
|
+
delete this._parents[attr];
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
for (const [attr, relation] of Object.entries(this.CHILDREN)) {
|
|
611
|
+
const child = this._children[attr];
|
|
612
|
+
if (child) {
|
|
613
|
+
await child._ensure();
|
|
614
|
+
this._record.filter(`${relation.parentId}__in`, child._item(relation.childParentRef));
|
|
615
|
+
this.overflow = this.overflow || child.overflow;
|
|
616
|
+
delete this._children[attr];
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
/** Keep relations honest when the field they hang off changes. */
|
|
621
|
+
_propagate(field, value) {
|
|
622
|
+
const name = this._fieldName(field);
|
|
623
|
+
if (name in this._related) {
|
|
624
|
+
this._related[name] = value;
|
|
625
|
+
}
|
|
626
|
+
for (const [attr, relation] of Object.entries(this.PARENTS)) {
|
|
627
|
+
if (name === relation.childParentRef) {
|
|
628
|
+
this._parents[attr] = null;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// Only push into children that already exist. Reaching for one that doesn't would
|
|
632
|
+
// mean a retrieve, and a property assignment can't wait on the source.
|
|
633
|
+
for (const [attr, relation] of Object.entries(this.CHILDREN)) {
|
|
634
|
+
const child = this._children[attr];
|
|
635
|
+
if (name !== relation.parentId || !child) {
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
child._related[relation.childParentRef] = value;
|
|
639
|
+
for (const model of child._models ?? []) {
|
|
640
|
+
model._setItem(relation.childParentRef, value);
|
|
641
|
+
}
|
|
642
|
+
child._record?.set(relation.childParentRef, value);
|
|
643
|
+
}
|
|
644
|
+
for (const [attr, relation] of Object.entries(this.SISTERS)) {
|
|
645
|
+
if (name === relation.brotherSisterRef) {
|
|
646
|
+
this._sisters[attr] = null;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
for (const [attr, relation] of Object.entries(this.BROTHERS)) {
|
|
650
|
+
if (name === relation.sisterBrotherRef) {
|
|
651
|
+
this._brothers[attr] = null;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
/** Fill a record in from positional and named values. */
|
|
656
|
+
_input(record, positional, named) {
|
|
657
|
+
let index = 0;
|
|
658
|
+
for (const value of positional) {
|
|
659
|
+
while (index < record._order.length &&
|
|
660
|
+
(record._order[index].auto || record._order[index].name in this._related)) {
|
|
661
|
+
index += 1;
|
|
662
|
+
}
|
|
663
|
+
record.set(index, value);
|
|
664
|
+
index += 1;
|
|
665
|
+
}
|
|
666
|
+
for (const [name, value] of Object.entries(named)) {
|
|
667
|
+
record.set(name, value);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/** Build a fresh record for an action. */
|
|
671
|
+
_build(action, options = {}) {
|
|
672
|
+
const record = this._fields.copy();
|
|
673
|
+
record._action = action;
|
|
674
|
+
if (options.defaults ?? true) {
|
|
675
|
+
for (const field of record._order) {
|
|
676
|
+
if (field.default !== null) {
|
|
677
|
+
field.value = typeof field.default === 'function' ? field.default() : field.default;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (options.read !== undefined) {
|
|
682
|
+
record.read(options.read);
|
|
683
|
+
}
|
|
684
|
+
for (const [field, value] of Object.entries(this._related)) {
|
|
685
|
+
record.set(field, value);
|
|
686
|
+
}
|
|
687
|
+
this._input(record, options.positional ?? [], options.named ?? {});
|
|
688
|
+
return record;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Guard synchronous access. A model that still has to hit the source can't fill
|
|
692
|
+
* itself in from a property getter, so say so plainly instead of returning nothing.
|
|
693
|
+
*/
|
|
694
|
+
_ensureSync() {
|
|
695
|
+
if (this._action === 'retrieve') {
|
|
696
|
+
throw new ModelError(this, 'not retrieved yet - await model.retrieve() before reading values');
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
/** Retrieve if this model still needs to, then carry on. */
|
|
700
|
+
async _ensure() {
|
|
701
|
+
if (this._action === 'retrieve') {
|
|
702
|
+
if (this._record._action === 'update') {
|
|
703
|
+
throw new ModelError(this, 'need to update');
|
|
704
|
+
}
|
|
705
|
+
await this.retrieve();
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
/** Every model this one stands for: itself in one mode, its models in many. */
|
|
709
|
+
_each(action) {
|
|
710
|
+
if (this._record && (action === undefined || this._action === action)) {
|
|
711
|
+
return [this._proxy];
|
|
712
|
+
}
|
|
713
|
+
if (this._models) {
|
|
714
|
+
return this._models.filter((model) => action === undefined || model._action === action);
|
|
715
|
+
}
|
|
716
|
+
return [];
|
|
717
|
+
}
|
|
718
|
+
/** Read a value the way `model[key]` does. */
|
|
719
|
+
_item(key) {
|
|
720
|
+
// A path is a relation walk only when it doesn't start at one of our own fields.
|
|
721
|
+
if (typeof key === 'string' && key.includes('__') && !this._fields.has(key.split('__')[0])) {
|
|
722
|
+
return this._path(key);
|
|
723
|
+
}
|
|
724
|
+
this._ensureSync();
|
|
725
|
+
if (this._role === 'child' && this._mode === 'one') {
|
|
726
|
+
if (this._models?.length) {
|
|
727
|
+
return this._models[0]._item(key);
|
|
728
|
+
}
|
|
729
|
+
throw new ModelError(this, 'no record');
|
|
730
|
+
}
|
|
731
|
+
if (this._mode === 'one') {
|
|
732
|
+
if (typeof key === 'string' && this._isRelation(key)) {
|
|
733
|
+
return this._relate(key);
|
|
734
|
+
}
|
|
735
|
+
return this._record.get(key);
|
|
736
|
+
}
|
|
737
|
+
if (this._models === null) {
|
|
738
|
+
throw new ModelError(this, 'no records');
|
|
739
|
+
}
|
|
740
|
+
if (typeof key === 'number') {
|
|
741
|
+
return this._models[key < 0 ? this._models.length + key : key];
|
|
742
|
+
}
|
|
743
|
+
return this._models.map((model) => model._item(key));
|
|
744
|
+
}
|
|
745
|
+
/** Write a value the way `model[key] = value` does. */
|
|
746
|
+
_setItem(key, value) {
|
|
747
|
+
this._ensureSync();
|
|
748
|
+
if (this._role === 'child' && this._mode === 'one') {
|
|
749
|
+
if (this._models?.length) {
|
|
750
|
+
this._models[0]._setItem(key, value);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
throw new ModelError(this, 'no record');
|
|
754
|
+
}
|
|
755
|
+
if (this._mode === 'one') {
|
|
756
|
+
;
|
|
757
|
+
this._record.set(key, value);
|
|
758
|
+
if (typeof key !== 'string' || !key.includes('__')) {
|
|
759
|
+
this._propagate(key, value);
|
|
760
|
+
}
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
if (typeof key === 'number') {
|
|
764
|
+
throw new ModelError(this, 'no override');
|
|
765
|
+
}
|
|
766
|
+
if (!this._models?.length) {
|
|
767
|
+
throw new ModelError(this, 'no records');
|
|
768
|
+
}
|
|
769
|
+
for (const model of this._models) {
|
|
770
|
+
model._setItem(key, value);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
/** Whether a field is reachable right now, the way `key in model` asks in Python. */
|
|
774
|
+
_contains(key) {
|
|
775
|
+
this._ensureSync();
|
|
776
|
+
if (this._role === 'child' && this._mode === 'one') {
|
|
777
|
+
return this._models?.length ? this._models[0]._contains(key) : false;
|
|
778
|
+
}
|
|
779
|
+
if (this._mode === 'one') {
|
|
780
|
+
return this._record.has(key);
|
|
781
|
+
}
|
|
782
|
+
return this._models?.length ? this._fields.has(key) : false;
|
|
783
|
+
}
|
|
784
|
+
/** Walk a double-underscored path that starts at a relation. */
|
|
785
|
+
_path(name) {
|
|
786
|
+
let current = this._proxy;
|
|
787
|
+
for (const place of overscore.parse(name)) {
|
|
788
|
+
current = current instanceof Model ? current._item(place) : overscore.get(current, [place]);
|
|
789
|
+
}
|
|
790
|
+
return current;
|
|
791
|
+
}
|
|
792
|
+
/** The proxy wrapping this model, so internals hand back the same object callers hold. */
|
|
793
|
+
_proxy = null;
|
|
794
|
+
// ------------------------------------------------------------------ building queries
|
|
795
|
+
/** Add criteria. Positional values match fields in order, named ones by `field__operator`. */
|
|
796
|
+
filter(...args) {
|
|
797
|
+
const { positional, named } = split(args);
|
|
798
|
+
for (const [field, value] of Object.entries(this._related)) {
|
|
799
|
+
;
|
|
800
|
+
this._record.filter(field, value);
|
|
801
|
+
}
|
|
802
|
+
positional.forEach((value, index) => {
|
|
803
|
+
;
|
|
804
|
+
this._record.filter(index, value);
|
|
805
|
+
});
|
|
806
|
+
for (const [name, value] of Object.entries(named)) {
|
|
807
|
+
if (name === 'like') {
|
|
808
|
+
this._like = value;
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
const index = name.indexOf('__');
|
|
812
|
+
const head = index === -1 ? name : name.slice(0, index);
|
|
813
|
+
const rest = index === -1 ? null : name.slice(index + 2);
|
|
814
|
+
if (rest !== null && (head in this.SISTERS || head in this.BROTHERS)) {
|
|
815
|
+
this._tie(head, rest, value);
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
const relation = rest !== null && this._isRelation(head) ? this._relate(head) : null;
|
|
819
|
+
if (relation !== null) {
|
|
820
|
+
relation.filter({ [rest]: value });
|
|
821
|
+
}
|
|
822
|
+
else {
|
|
823
|
+
;
|
|
824
|
+
this._record.filter(name, value);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return this;
|
|
828
|
+
}
|
|
829
|
+
/** A model set up to insert many records without reading their ids back. */
|
|
830
|
+
static bulk(size) {
|
|
831
|
+
return construct(this, {
|
|
832
|
+
action: 'create',
|
|
833
|
+
mode: 'many',
|
|
834
|
+
bulk: true,
|
|
835
|
+
size: size ?? this.chunk ?? 100
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
/** A model that will retrieve exactly one record. */
|
|
839
|
+
static one(...args) {
|
|
840
|
+
return construct(this, { action: 'retrieve', mode: 'one', args });
|
|
841
|
+
}
|
|
842
|
+
/** A model that will retrieve any number of records. */
|
|
843
|
+
static many(...args) {
|
|
844
|
+
return construct(this, { action: 'retrieve', mode: 'many', args });
|
|
845
|
+
}
|
|
846
|
+
/** Sort: on a retrieve it's added to the query, otherwise the loaded models are sorted. */
|
|
847
|
+
sort(...args) {
|
|
848
|
+
if (this._mode === 'one') {
|
|
849
|
+
throw new ModelError(this, 'cannot sort one');
|
|
850
|
+
}
|
|
851
|
+
if (!args.length) {
|
|
852
|
+
return this;
|
|
853
|
+
}
|
|
854
|
+
const sorting = this._ordering(args);
|
|
855
|
+
if (this._action === 'retrieve') {
|
|
856
|
+
this._sort = this._sort ?? [];
|
|
857
|
+
this._sort.push(...sorting);
|
|
858
|
+
}
|
|
859
|
+
else {
|
|
860
|
+
const models = [...(this._models ?? [])];
|
|
861
|
+
models.sort((first, second) => {
|
|
862
|
+
for (const sort of sorting) {
|
|
863
|
+
const field = sort.slice(1);
|
|
864
|
+
const cmp = order(first._item(field), second._item(field));
|
|
865
|
+
if (cmp !== 0) {
|
|
866
|
+
return sort[0] === '+' ? cmp : -cmp;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
return 0;
|
|
870
|
+
});
|
|
871
|
+
this._models = models;
|
|
872
|
+
}
|
|
873
|
+
return this;
|
|
874
|
+
}
|
|
875
|
+
/** How many records to retrieve, and where to start. */
|
|
876
|
+
limit(limit) {
|
|
877
|
+
if (this._action !== 'retrieve') {
|
|
878
|
+
throw new ModelError(this, 'can only limit retrieve');
|
|
879
|
+
}
|
|
880
|
+
const options = typeof limit === 'object' && limit !== null ? limit : { limit };
|
|
881
|
+
const size = options.perPage ?? options.limit ?? this.CHUNK;
|
|
882
|
+
this._limit = size;
|
|
883
|
+
this._offset = options.page !== undefined ? (options.page - 1) * size : options.start ?? 0;
|
|
884
|
+
return this;
|
|
885
|
+
}
|
|
886
|
+
/** Set values on the loaded record, or stage a mass update across many. */
|
|
887
|
+
set(...args) {
|
|
888
|
+
const { positional, named } = split(args);
|
|
889
|
+
if (this._action === 'retrieve') {
|
|
890
|
+
if (this._mode === 'one') {
|
|
891
|
+
throw new ModelError(this, 'not retrieved yet - await model.retrieve() before setting values');
|
|
892
|
+
}
|
|
893
|
+
;
|
|
894
|
+
this._record._action = 'update';
|
|
895
|
+
}
|
|
896
|
+
for (const model of this._each()) {
|
|
897
|
+
this._input(model._record, positional, named);
|
|
898
|
+
}
|
|
899
|
+
return this;
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Add a record. In many mode you can add as many as you like.
|
|
903
|
+
*
|
|
904
|
+
* On a relation that hasn't been read yet, adding starts a fresh set to create rather
|
|
905
|
+
* than quietly going to the source - `await model.relation.retrieve()` first when you
|
|
906
|
+
* want the existing records loaded alongside the new ones.
|
|
907
|
+
*/
|
|
908
|
+
add(...args) {
|
|
909
|
+
if (this._action === 'retrieve') {
|
|
910
|
+
if (this._role !== 'child') {
|
|
911
|
+
throw new ModelError(this, 'not retrieved yet - await model.retrieve() before adding');
|
|
912
|
+
}
|
|
913
|
+
this._action = 'create';
|
|
914
|
+
this._record = null;
|
|
915
|
+
this._models = this._models ?? [];
|
|
916
|
+
}
|
|
917
|
+
const { positional, named } = split(args);
|
|
918
|
+
const count = named._count ?? 1;
|
|
919
|
+
delete named._count;
|
|
920
|
+
if (this._role === 'child' && this._mode === 'one') {
|
|
921
|
+
if (this._models?.length || count > 1) {
|
|
922
|
+
throw new ModelError(this, 'only one allowed');
|
|
923
|
+
}
|
|
924
|
+
this._models = [
|
|
925
|
+
construct(this.constructor, {
|
|
926
|
+
action: 'create',
|
|
927
|
+
related: this._related,
|
|
928
|
+
args: [...positional, named]
|
|
929
|
+
})
|
|
930
|
+
];
|
|
931
|
+
}
|
|
932
|
+
else if (this._mode === 'one') {
|
|
933
|
+
throw new ModelError(this, 'only one allowed');
|
|
934
|
+
}
|
|
935
|
+
else {
|
|
936
|
+
this._models = this._models ?? [];
|
|
937
|
+
for (let each = 0; each < count; each++) {
|
|
938
|
+
this._models.push(construct(this.constructor, {
|
|
939
|
+
action: 'create',
|
|
940
|
+
related: this._related,
|
|
941
|
+
args: [...positional, named]
|
|
942
|
+
}));
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return this;
|
|
946
|
+
}
|
|
947
|
+
/** Add a record, flushing to the source when a bulk model fills up. */
|
|
948
|
+
async queue(...args) {
|
|
949
|
+
this.add(...args);
|
|
950
|
+
if (this._bulk && this._models.length >= this._size) {
|
|
951
|
+
await this.create();
|
|
952
|
+
}
|
|
953
|
+
return this;
|
|
954
|
+
}
|
|
955
|
+
// ------------------------------------------------------------------ values
|
|
956
|
+
/** Number of records in many mode, or fields in one mode. */
|
|
957
|
+
get size() {
|
|
958
|
+
if (this._role === 'child' && this._mode === 'one') {
|
|
959
|
+
return this._models?.length ? this._models[0].size : 0;
|
|
960
|
+
}
|
|
961
|
+
if (this._mode === 'one') {
|
|
962
|
+
return this._record.size;
|
|
963
|
+
}
|
|
964
|
+
return this._models?.length ?? 0;
|
|
965
|
+
}
|
|
966
|
+
/** The loaded models, in many mode. */
|
|
967
|
+
get models() {
|
|
968
|
+
return this._models ?? [];
|
|
969
|
+
}
|
|
970
|
+
/** Models in many mode, field names in one mode. */
|
|
971
|
+
*[Symbol.iterator]() {
|
|
972
|
+
this._ensureSync();
|
|
973
|
+
if (this._role === 'child' && this._mode === 'one') {
|
|
974
|
+
yield* this._models?.length ? this._models[0] : [];
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
if (this._mode === 'one') {
|
|
978
|
+
yield* this._record;
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
yield* this._models ?? [];
|
|
982
|
+
}
|
|
983
|
+
/** Field names of the loaded record. */
|
|
984
|
+
keys() {
|
|
985
|
+
this._ensureSync();
|
|
986
|
+
if (this._mode === 'many') {
|
|
987
|
+
throw new ModelError(this, 'no keys with many');
|
|
988
|
+
}
|
|
989
|
+
if (this._role === 'child') {
|
|
990
|
+
return this._models?.length ? this._models[0].keys() : [];
|
|
991
|
+
}
|
|
992
|
+
return this._record.keys();
|
|
993
|
+
}
|
|
994
|
+
/** Every value, keyed by field name - or an array of those in many mode. */
|
|
995
|
+
export() {
|
|
996
|
+
this._ensureSync();
|
|
997
|
+
if (this._record) {
|
|
998
|
+
return this._record.export();
|
|
999
|
+
}
|
|
1000
|
+
if (this._models) {
|
|
1001
|
+
return this._models.map((model) => model.export());
|
|
1002
|
+
}
|
|
1003
|
+
return [];
|
|
1004
|
+
}
|
|
1005
|
+
/** So `JSON.stringify(model)` does what you'd hope. */
|
|
1006
|
+
toJSON() {
|
|
1007
|
+
return this.export();
|
|
1008
|
+
}
|
|
1009
|
+
// ------------------------------------------------------------------ the source
|
|
1010
|
+
/** The definition of this model, for schemas and migrations. */
|
|
1011
|
+
static define(...args) {
|
|
1012
|
+
const cls = this;
|
|
1013
|
+
return need(cls.source, cls).define(cls.thy().define(), ...args);
|
|
1014
|
+
}
|
|
1015
|
+
/** Write this model's records to the source. */
|
|
1016
|
+
async create(...args) {
|
|
1017
|
+
if (!['create', 'update'].includes(this._action)) {
|
|
1018
|
+
throw new ModelError(this, `cannot create during ${this._action}`);
|
|
1019
|
+
}
|
|
1020
|
+
await need(this.SOURCE, this).create(this._proxy, ...args);
|
|
1021
|
+
return this;
|
|
1022
|
+
}
|
|
1023
|
+
/** How many records match. */
|
|
1024
|
+
async count(...args) {
|
|
1025
|
+
if (!['update', 'retrieve'].includes(this._action)) {
|
|
1026
|
+
throw new ModelError(this, `cannot count during ${this._action}`);
|
|
1027
|
+
}
|
|
1028
|
+
return need(this.SOURCE, this).count(this._proxy, ...args);
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Read this model's records out of the source. Pass `false` to get `null` instead of
|
|
1032
|
+
* an error when a one-mode retrieve finds nothing.
|
|
1033
|
+
*/
|
|
1034
|
+
async retrieve(verify = true, ...args) {
|
|
1035
|
+
if (this._action !== 'retrieve') {
|
|
1036
|
+
throw new ModelError(this, `cannot retrieve during ${this._action}`);
|
|
1037
|
+
}
|
|
1038
|
+
const retrieved = await need(this.SOURCE, this).retrieve(this._proxy, verify, ...args);
|
|
1039
|
+
return retrieved === null ? null : this;
|
|
1040
|
+
}
|
|
1041
|
+
/** Titles for the matching records, keyed by id. */
|
|
1042
|
+
async titles(...args) {
|
|
1043
|
+
if (!['update', 'retrieve'].includes(this._action)) {
|
|
1044
|
+
throw new ModelError(this, `cannot titles during ${this._action}`);
|
|
1045
|
+
}
|
|
1046
|
+
return need(this.SOURCE, this).titles(this._proxy, ...args);
|
|
1047
|
+
}
|
|
1048
|
+
/** Write changes back to the source. Resolves to how many records changed. */
|
|
1049
|
+
async update(...args) {
|
|
1050
|
+
if (!['update', 'retrieve'].includes(this._action)) {
|
|
1051
|
+
throw new ModelError(this, `cannot update during ${this._action}`);
|
|
1052
|
+
}
|
|
1053
|
+
return need(this.SOURCE, this).update(this._proxy, ...args);
|
|
1054
|
+
}
|
|
1055
|
+
/** Remove records from the source. Resolves to how many were removed. */
|
|
1056
|
+
async delete(...args) {
|
|
1057
|
+
if (!['update', 'retrieve'].includes(this._action)) {
|
|
1058
|
+
throw new ModelError(this, `cannot delete during ${this._action}`);
|
|
1059
|
+
}
|
|
1060
|
+
if (this._action === 'retrieve' && this._mode === 'one') {
|
|
1061
|
+
await this.retrieve();
|
|
1062
|
+
}
|
|
1063
|
+
return need(this.SOURCE, this).delete(this._proxy, ...args);
|
|
1064
|
+
}
|
|
1065
|
+
/** The query a source would run for an action, without running it. */
|
|
1066
|
+
query(action, ...args) {
|
|
1067
|
+
const source = need(this.SOURCE, this);
|
|
1068
|
+
const model = this._proxy;
|
|
1069
|
+
if (this._action === 'create') {
|
|
1070
|
+
return source.createQuery(model, ...args)?.bind(model);
|
|
1071
|
+
}
|
|
1072
|
+
if (this._action === 'retrieve' && action === 'count') {
|
|
1073
|
+
return source.countQuery(model, ...args)?.bind(model);
|
|
1074
|
+
}
|
|
1075
|
+
if (this._action === 'retrieve' && action === 'titles') {
|
|
1076
|
+
return source.titlesQuery(model, ...args)?.bind(model);
|
|
1077
|
+
}
|
|
1078
|
+
if (action === 'update' || (action === undefined && this._action === 'update')) {
|
|
1079
|
+
return source.updateQuery(model, ...args)?.bind(model);
|
|
1080
|
+
}
|
|
1081
|
+
if (action === 'delete') {
|
|
1082
|
+
return source.deleteQuery(model, ...args)?.bind(model);
|
|
1083
|
+
}
|
|
1084
|
+
return source.retrieveQuery(model, ...args)?.bind(model);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
/** The source a model needs, or a clear error about why it isn't there. */
|
|
1088
|
+
function need(name, model) {
|
|
1089
|
+
const found = registered(name);
|
|
1090
|
+
if (found === undefined) {
|
|
1091
|
+
throw new ModelError(model, `no source registered as '${name}' - construct one before using this model`);
|
|
1092
|
+
}
|
|
1093
|
+
return found;
|
|
1094
|
+
}
|
|
1095
|
+
/** Build a model the way relations does internally, bypassing the public constructor. */
|
|
1096
|
+
export function construct(cls, internal) {
|
|
1097
|
+
return new cls(INTERNAL, internal);
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Wrap a model so field names and relation attributes work as ordinary properties.
|
|
1101
|
+
* `unit.name`, `unit.name = 'x'`, `unit.test[0].name`, `units[2]` all land here.
|
|
1102
|
+
*/
|
|
1103
|
+
function wrap(model) {
|
|
1104
|
+
const proxy = new Proxy(model, {
|
|
1105
|
+
get(target, prop, receiver) {
|
|
1106
|
+
if (typeof prop === 'symbol') {
|
|
1107
|
+
return Reflect.get(target, prop, receiver);
|
|
1108
|
+
}
|
|
1109
|
+
const name = prop;
|
|
1110
|
+
if (/^-?\d+$/.test(name) && target._mode === 'many') {
|
|
1111
|
+
return target._item(parseInt(name, 10));
|
|
1112
|
+
}
|
|
1113
|
+
if (name.startsWith('_') || name !== name.toLowerCase() || Reflect.has(target, prop)) {
|
|
1114
|
+
return Reflect.get(target, prop, receiver);
|
|
1115
|
+
}
|
|
1116
|
+
if (target._isRelation(name)) {
|
|
1117
|
+
target._ensureSync();
|
|
1118
|
+
if (target._mode !== 'one') {
|
|
1119
|
+
throw new ModelError(target, `cannot access '${name}' in many mode`);
|
|
1120
|
+
}
|
|
1121
|
+
return target._relate(name);
|
|
1122
|
+
}
|
|
1123
|
+
if (target._fields.has(name.split('__')[0])) {
|
|
1124
|
+
return target._item(name);
|
|
1125
|
+
}
|
|
1126
|
+
if (name.includes('__')) {
|
|
1127
|
+
return target._path(name);
|
|
1128
|
+
}
|
|
1129
|
+
return Reflect.get(target, prop, receiver);
|
|
1130
|
+
},
|
|
1131
|
+
set(target, prop, value, receiver) {
|
|
1132
|
+
if (typeof prop === 'symbol') {
|
|
1133
|
+
return Reflect.set(target, prop, value, receiver);
|
|
1134
|
+
}
|
|
1135
|
+
const name = prop;
|
|
1136
|
+
if (/^-?\d+$/.test(name) && target._mode === 'many') {
|
|
1137
|
+
target._setItem(parseInt(name, 10), value);
|
|
1138
|
+
return true;
|
|
1139
|
+
}
|
|
1140
|
+
if (!name.startsWith('_') &&
|
|
1141
|
+
name === name.toLowerCase() &&
|
|
1142
|
+
!Reflect.has(target, prop) &&
|
|
1143
|
+
target._fields.has(name.split('__')[0])) {
|
|
1144
|
+
target._setItem(name, value);
|
|
1145
|
+
return true;
|
|
1146
|
+
}
|
|
1147
|
+
return Reflect.set(target, prop, value, receiver);
|
|
1148
|
+
},
|
|
1149
|
+
has(target, prop) {
|
|
1150
|
+
if (typeof prop === 'string' && !prop.startsWith('_') && target._fields.has(prop.split('__')[0])) {
|
|
1151
|
+
return target._contains(prop);
|
|
1152
|
+
}
|
|
1153
|
+
return Reflect.has(target, prop);
|
|
1154
|
+
}
|
|
1155
|
+
});
|
|
1156
|
+
model._proxy = proxy;
|
|
1157
|
+
return proxy;
|
|
1158
|
+
}
|
|
1159
|
+
//# sourceMappingURL=model.js.map
|