@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/README.md
ADDED
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
# relations-dil
|
|
2
|
+
|
|
3
|
+
**A data interface layer that doesn't care what's underneath it.**
|
|
4
|
+
|
|
5
|
+
Define your models once. Point them at a source at runtime. The same model description
|
|
6
|
+
drives a database in one service and a REST API in the next — because the shape of your
|
|
7
|
+
data doesn't change just because you changed where you put it.
|
|
8
|
+
|
|
9
|
+
A TypeScript port (usable from plain JavaScript) of [python-relations](https://github.com/relations-dil/python-relations).
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @relations-dil/relations
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Contents
|
|
18
|
+
|
|
19
|
+
- [The idea](#the-idea)
|
|
20
|
+
- [Quick start](#quick-start)
|
|
21
|
+
- [Async: what changed from Python, and why](#async-what-changed-from-python-and-why)
|
|
22
|
+
- [TypeScript](#typescript)
|
|
23
|
+
- [Fields](#fields)
|
|
24
|
+
- [Querying](#querying)
|
|
25
|
+
- [Relations](#relations)
|
|
26
|
+
- [Titles](#titles)
|
|
27
|
+
- [Migrations](#migrations)
|
|
28
|
+
- [Models from data (YAML, JSON, anywhere)](#models-from-data-yaml-json-anywhere)
|
|
29
|
+
- [Writing your own source](#writing-your-own-source)
|
|
30
|
+
- [Reserved names](#reserved-names)
|
|
31
|
+
- [Differences from python-relations](#differences-from-python-relations)
|
|
32
|
+
- [API reference](#api-reference)
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## The idea
|
|
37
|
+
|
|
38
|
+
Most ORMs bind a model to a backend. Relations doesn't. A model here is a description of
|
|
39
|
+
structure — fields, kinds, indexes, how it relates to other models — and nothing else.
|
|
40
|
+
Where that structure lives is decided at runtime by whichever source registered itself
|
|
41
|
+
under the model's `source` name.
|
|
42
|
+
|
|
43
|
+
That matters when you're building a service out of microservices. The service that owns
|
|
44
|
+
the data talks to a database. Everything else talks to that service's API. Both use the
|
|
45
|
+
*same model file*. You describe your data once, and you stop writing CRUD endpoints and
|
|
46
|
+
CRUD clients that say the same thing in three different dialects.
|
|
47
|
+
|
|
48
|
+
This package is the base layer: the abstract classes, plus a complete in-memory source so
|
|
49
|
+
you can run the whole thing today without picking a backend.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { Model, MockSource, OneToMany, int, str } from '@relations-dil/relations'
|
|
57
|
+
|
|
58
|
+
// A base to share the source across your models.
|
|
59
|
+
class Base extends Model {
|
|
60
|
+
static source = 'example'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
class Unit extends Base {
|
|
64
|
+
static fields = { id: int, name: str }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
class Test extends Base {
|
|
68
|
+
static fields = { id: int, unit_id: int, name: str }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Relations are declared outside the models, so a model stays a plain description
|
|
72
|
+
// of its own shape.
|
|
73
|
+
new OneToMany(Unit, Test)
|
|
74
|
+
|
|
75
|
+
// This makes "example" an in-memory store. Swap this line to change backends.
|
|
76
|
+
new MockSource('example')
|
|
77
|
+
|
|
78
|
+
// Create a Unit named "yep".
|
|
79
|
+
await new Unit('yep').create()
|
|
80
|
+
|
|
81
|
+
// Retrieve it and read its id.
|
|
82
|
+
const yep = await Unit.one({ name: 'yep' }).retrieve()
|
|
83
|
+
yep.id // 1
|
|
84
|
+
|
|
85
|
+
// Create two more. The first non-auto field is filled positionally.
|
|
86
|
+
await new Unit([['people'], ['stuff']]).create()
|
|
87
|
+
|
|
88
|
+
// Retrieve one, change it, save it. update() resolves to the number of records changed.
|
|
89
|
+
const stuff = await Unit.one({ name: 'stuff' }).retrieve()
|
|
90
|
+
stuff.name = 'things'
|
|
91
|
+
await stuff.update() // 1
|
|
92
|
+
|
|
93
|
+
// Add a child through the relation.
|
|
94
|
+
const unit = await Unit.one(2).retrieve()
|
|
95
|
+
unit.test.add('moar')
|
|
96
|
+
await unit.update()
|
|
97
|
+
|
|
98
|
+
// Find tests whose parent unit is named "things", with an id above 0.
|
|
99
|
+
const tests = await Test.many({ unit__name: 'things', id__gt: 0 }).retrieve()
|
|
100
|
+
tests[0].name // 'moar'
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Field values are ordinary properties. `unit.name`, `unit.name = 'x'`, `units[0].name` all
|
|
104
|
+
work — a `Proxy` maps them onto the record underneath.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Async: what changed from Python, and why
|
|
109
|
+
|
|
110
|
+
Python's relations is synchronous. JavaScript can't be: the runtime is single-threaded, so
|
|
111
|
+
anything that talks to a database, a socket, or a file hands back a `Promise` instead of
|
|
112
|
+
blocking. That has exactly one consequence for this library, and it's worth understanding
|
|
113
|
+
because everything else follows from it.
|
|
114
|
+
|
|
115
|
+
**Six methods touch the source. They are async. Everything else is not.**
|
|
116
|
+
|
|
117
|
+
| Async — awaits the source | Sync — builds criteria in memory |
|
|
118
|
+
| ------------------------- | -------------------------------- |
|
|
119
|
+
| `create()` | `one()` `many()` `bulk()` |
|
|
120
|
+
| `retrieve()` | `filter()` `sort()` `limit()` |
|
|
121
|
+
| `count()` | `set()` `add()` |
|
|
122
|
+
| `update()` | `export()` `query()` |
|
|
123
|
+
| `delete()` | field get and set |
|
|
124
|
+
| `titles()` | `define()` `thy()` |
|
|
125
|
+
|
|
126
|
+
So you chain synchronously and `await` once, at the end:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
const units = await Unit.many({ name__like: 'thing' }).sort('-name').limit(10).retrieve()
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**The one thing this costs.** Python does a lazy retrieve on attribute access —
|
|
133
|
+
`Unit.one(name="yep").id` quietly runs the query when you touch `.id`. JavaScript cannot:
|
|
134
|
+
a property getter is synchronous, and there is no `await` available inside one. So
|
|
135
|
+
retrieval is explicit here, at exactly one point:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
// Python
|
|
139
|
+
Unit.one(name="yep").id
|
|
140
|
+
|
|
141
|
+
// JavaScript
|
|
142
|
+
const unit = await Unit.one({ name: 'yep' }).retrieve()
|
|
143
|
+
unit.id
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Reading a field off a model that hasn't been retrieved throws a clear error rather than
|
|
147
|
+
returning nothing:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
ModelError: unit: not retrieved yet - await model.retrieve() before reading values
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The same applies to reaching a parent from a child, which is also a query:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
const test = await Test.one({ name: 'moar' }).retrieve()
|
|
157
|
+
const unit = await test.unit.retrieve()
|
|
158
|
+
unit.name
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**Doing things at the same time.** Independent queries should not wait on each other:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
// Sequential — two round trips, one after the other
|
|
165
|
+
const a = await Unit.one({ id: 1 }).retrieve()
|
|
166
|
+
const b = await Unit.one({ id: 2 }).retrieve()
|
|
167
|
+
|
|
168
|
+
// Concurrent — both in flight at once
|
|
169
|
+
const [a, b] = await Promise.all([
|
|
170
|
+
Unit.one({ id: 1 }).retrieve(),
|
|
171
|
+
Unit.one({ id: 2 }).retrieve()
|
|
172
|
+
])
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
**Errors.** `await` throws, so ordinary `try`/`catch` works:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
try {
|
|
179
|
+
await new Unit('yep').create()
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (error instanceof UniqueError) {
|
|
182
|
+
// the source rolled back
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## TypeScript
|
|
190
|
+
|
|
191
|
+
Field access goes through a `Proxy`, which the type system can't see into on its own.
|
|
192
|
+
`fields()` closes that gap — declare the fields once and the base class knows their types:
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
import { fields, int, str, list } from '@relations-dil/relations'
|
|
196
|
+
|
|
197
|
+
class Unit extends fields({ id: int, name: str, tags: list }) {
|
|
198
|
+
static source = 'example'
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const unit = await Unit.one({ name: 'yep' }).retrieve()
|
|
202
|
+
|
|
203
|
+
unit.name.toUpperCase() // string — checked
|
|
204
|
+
unit.nmae // error: property does not exist
|
|
205
|
+
unit.name = 5 // error: not a string
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
In many mode, every field reads back as an array, and indexing gives you one model:
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
const units = await Unit.many().retrieve()
|
|
212
|
+
|
|
213
|
+
units.name // string[]
|
|
214
|
+
units[0].name // string
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Pass a base class as the second argument to share configuration:
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
class Base extends Model {
|
|
221
|
+
static source = 'example'
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
class Unit extends fields({ id: int, name: str }, Base) {}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Relations are declared separately from the model, so add them with `declare` when you want
|
|
228
|
+
them typed. `declare` is erased at compile time and costs nothing at runtime:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
class Unit extends fields({ id: int, name: str }, Base) {
|
|
232
|
+
declare test: Test
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
class Test extends fields({ id: int, unit_id: int, name: str }, Base) {
|
|
236
|
+
declare unit: Unit
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
new OneToMany(Unit, Test)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
**You don't have to use any of this.** Plain `static fields = { ... }` on a `Model` works
|
|
243
|
+
identically at runtime; you just get `any` for field access. The package ships `.d.ts`
|
|
244
|
+
files either way, so JavaScript users get autocomplete on the library API.
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## Fields
|
|
249
|
+
|
|
250
|
+
Python has `int`, `str` and `dict` as first-class values you can hand to a field.
|
|
251
|
+
JavaScript doesn't, so relations exports the same seven names as sentinels:
|
|
252
|
+
|
|
253
|
+
| Kind | Holds | Notes |
|
|
254
|
+
| ------- | --------------------- | ---------------------------------------------------------------- |
|
|
255
|
+
| `bool` | `true` / `false` | Casts the strings `"false"`, `"no"`, `"0"`, `""` to `false` |
|
|
256
|
+
| `int` | whole number | Truncates toward zero, like Python's `int()` |
|
|
257
|
+
| `float` | number | |
|
|
258
|
+
| `str` | string | |
|
|
259
|
+
| `set` | `Set` | Never null; exports as a sorted array, or in `options` order |
|
|
260
|
+
| `list` | array | Never null; defaults to `[]` |
|
|
261
|
+
| `dict` | plain object | Never null; defaults to `{}` |
|
|
262
|
+
|
|
263
|
+
### Declaring them
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
class Thing extends Model {
|
|
267
|
+
static source = 'example'
|
|
268
|
+
static fields = {
|
|
269
|
+
id: int, // a kind
|
|
270
|
+
name: str,
|
|
271
|
+
status: ['open', 'closed'], // options, first is the default
|
|
272
|
+
tags: new Set(['red', 'green', 'blue']), // a set field with options
|
|
273
|
+
created: () => Date.now(), // a function is a default factory
|
|
274
|
+
note: { kind: str, none: true, length: 40 } // the full form
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Field options
|
|
280
|
+
|
|
281
|
+
| Option | What it does |
|
|
282
|
+
| ------------ | ---------------------------------------------------------------------------- |
|
|
283
|
+
| `store` | Name in the source. Defaults to the field name. `false` means never stored. |
|
|
284
|
+
| `none` | Whether null is allowed. Inferred when not set. |
|
|
285
|
+
| `default` | A value, or a function returning one. |
|
|
286
|
+
| `options` | The complete set of allowed values. |
|
|
287
|
+
| `validation` | A regular expression, a pattern string, or a predicate. |
|
|
288
|
+
| `length` | Length of the value, for sources that care. |
|
|
289
|
+
| `readonly` | Whether the caller may write it. |
|
|
290
|
+
| `auto` | Whether the source generates it — an auto-increment id, say. |
|
|
291
|
+
| `refresh` | Reset to the default on update when untouched. Good for `updated_at`. |
|
|
292
|
+
| `inject` | Store this value inside another `list` or `dict` field, at a path. |
|
|
293
|
+
| `extract` | Pull interior values out into their own stored columns, so they can be indexed.|
|
|
294
|
+
| `attr` | How to flatten a class-kind value. Required when `kind` is a class. |
|
|
295
|
+
| `init` | How to rebuild a class-kind value. Defaults to `attr`. |
|
|
296
|
+
| `titles` | Which attributes of a class kind make up its title. Defaults to `attr`. |
|
|
297
|
+
|
|
298
|
+
### Class kinds
|
|
299
|
+
|
|
300
|
+
A field can hold instances of your own class, as long as you say how to flatten it:
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
class Point {
|
|
304
|
+
constructor(values) {
|
|
305
|
+
this.x = values.x
|
|
306
|
+
this.y = values.y
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
class Place extends Model {
|
|
311
|
+
static source = 'example'
|
|
312
|
+
static fields = {
|
|
313
|
+
id: int,
|
|
314
|
+
name: str,
|
|
315
|
+
at: { kind: Point, attr: ['x', 'y'] }
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const place = new Place({ name: 'home', at: { x: 1, y: 2 } })
|
|
320
|
+
place.at instanceof Point // true
|
|
321
|
+
// stored as { x: 1, y: 2 }
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### inject and extract
|
|
325
|
+
|
|
326
|
+
`inject` writes a field *into* another field, so a handful of loose values can share one
|
|
327
|
+
JSON column. `extract` copies a value *out* of a container into its own stored key, so a
|
|
328
|
+
backend can index it.
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
static fields = {
|
|
332
|
+
id: int,
|
|
333
|
+
meta: { kind: dict, extract: ['owner__name'] }, // stored also as meta__owner__name
|
|
334
|
+
flag: { kind: bool, inject: 'meta__flags__0' } // stored inside meta
|
|
335
|
+
}
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
## Querying
|
|
341
|
+
|
|
342
|
+
`one()` expects exactly one record and throws if it finds none or several. `many()` takes
|
|
343
|
+
whatever it finds. Pass `false` to `retrieve()` to get `null` instead of an error:
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
await Unit.one({ name: 'yep' }).retrieve() // throws if missing
|
|
347
|
+
await Unit.one({ name: 'yep' }).retrieve(false) // null if missing
|
|
348
|
+
await Unit.many({ name__like: 'ye' }).retrieve()
|
|
349
|
+
await Unit.many().count()
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### Operators
|
|
353
|
+
|
|
354
|
+
Criteria are `field__operator`. No operator means `eq`. Prefix any operator with `not_` to
|
|
355
|
+
invert it.
|
|
356
|
+
|
|
357
|
+
| Operator | Meaning | Multiple values |
|
|
358
|
+
| -------- | -------------------------------- | --------------- |
|
|
359
|
+
| `eq` | equal | |
|
|
360
|
+
| `null` | is or isn't null | |
|
|
361
|
+
| `gt` | greater than | |
|
|
362
|
+
| `gte` | greater than or equal | |
|
|
363
|
+
| `lt` | less than | |
|
|
364
|
+
| `lte` | less than or equal | |
|
|
365
|
+
| `like` | contains, case-insensitive | |
|
|
366
|
+
| `start` | starts with, case-insensitive | |
|
|
367
|
+
| `end` | ends with, case-insensitive | |
|
|
368
|
+
| `in` | is one of | ✓ |
|
|
369
|
+
| `has` | contains all of | ✓ |
|
|
370
|
+
| `any` | contains at least one of | ✓ |
|
|
371
|
+
| `all` | contains exactly these | ✓ |
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
await Unit.many({ id__gt: 3, name__not_like: 'test', status__in: ['open', 'held'] }).retrieve()
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### Reaching inside containers
|
|
378
|
+
|
|
379
|
+
The same double-underscore notation reaches into `list` and `dict` fields, and into class
|
|
380
|
+
kinds through their `attr` mapping:
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
await Thing.many({ meta__owner__name: 'gaf' }).retrieve()
|
|
384
|
+
await Thing.many({ tags__0: 'red' }).retrieve()
|
|
385
|
+
await Thing.many({ meta__count__gte: 5 }).retrieve()
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Extra underscores change how a place is read — this is the
|
|
389
|
+
[overscore](#overscore) notation, exported separately if you want it:
|
|
390
|
+
|
|
391
|
+
| Underscores | Following | Meaning | Example | Equivalent |
|
|
392
|
+
| ----------- | --------- | -------------------- | ---------- | ------------- |
|
|
393
|
+
| 2 | word | key | `a__b` | `['a']['b']` |
|
|
394
|
+
| 2 | number | index | `a__1` | `['a'][1]` |
|
|
395
|
+
| 3 | number | negative index | `a___2` | `['a'][-2]` |
|
|
396
|
+
| 4 | number | numeric key | `a____3` | `['a']['3']` |
|
|
397
|
+
| 5 | number | negative numeric key | `a_____4` | `['a']['-4']` |
|
|
398
|
+
|
|
399
|
+
### Fuzzy matching
|
|
400
|
+
|
|
401
|
+
`like` as a criterion name searches every title field at once, following parent relations:
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
await Unit.many({ like: 'thing' }).retrieve()
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
### Sorting and limiting
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
await Unit.many().sort('-name', '+id').retrieve()
|
|
411
|
+
await Unit.many().limit(10).retrieve()
|
|
412
|
+
await Unit.many().limit({ page: 3, perPage: 25 }).retrieve()
|
|
413
|
+
|
|
414
|
+
const page = await Unit.many().limit(10).retrieve()
|
|
415
|
+
page.overflow // true when the limit was reached, so there may be more
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
### Mass updates and deletes
|
|
419
|
+
|
|
420
|
+
Set values on a `many()` and every matching record gets them:
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
await Unit.many({ status: 'open' }).set({ status: 'closed' }).update() // number changed
|
|
424
|
+
await Unit.many({ status: 'closed' }).delete() // number removed
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
### Bulk inserts
|
|
428
|
+
|
|
429
|
+
For loading a lot of records without reading their ids back:
|
|
430
|
+
|
|
431
|
+
```ts
|
|
432
|
+
const bulk = Unit.bulk(1000)
|
|
433
|
+
|
|
434
|
+
for (const row of rows) {
|
|
435
|
+
await bulk.queue(row.name) // flushes to the source every 1000
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
await bulk.create() // whatever's left
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
443
|
+
## Relations
|
|
444
|
+
|
|
445
|
+
Relations are declared outside the models. That's deliberate: a model stays a plain
|
|
446
|
+
description of its own shape, so it can be reused in a service that doesn't know the
|
|
447
|
+
other side exists.
|
|
448
|
+
|
|
449
|
+
```ts
|
|
450
|
+
new OneToMany(Unit, Test) // unit.test (many), test.unit (one)
|
|
451
|
+
new OneToOne(Test, Case) // test.case (one), case.test (one)
|
|
452
|
+
new ManyToMany(Sister, Brother, Tie) // sister.brother, brother.sister, through Tie
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
The joining field is found by convention — `unit_id` on the child — and every part of it
|
|
456
|
+
can be spelled out instead:
|
|
457
|
+
|
|
458
|
+
```ts
|
|
459
|
+
new OneToMany(Unit, Test, {
|
|
460
|
+
parentChildAttr: 'checks', // what the parent calls its children
|
|
461
|
+
childParentAttr: 'owner', // what the child calls its parent
|
|
462
|
+
parentId: 'uuid', // the field on the parent being pointed at
|
|
463
|
+
childParentRef: 'owner_uuid' // the field on the child doing the pointing
|
|
464
|
+
})
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
### Working through them
|
|
468
|
+
|
|
469
|
+
```ts
|
|
470
|
+
// Create children with the parent, in one go.
|
|
471
|
+
const unit = new Unit('a')
|
|
472
|
+
unit.test.add('one').add('two')
|
|
473
|
+
await unit.create()
|
|
474
|
+
|
|
475
|
+
// Add to an existing parent.
|
|
476
|
+
const found = await Unit.one({ name: 'a' }).retrieve()
|
|
477
|
+
found.test.add('three')
|
|
478
|
+
await found.update()
|
|
479
|
+
|
|
480
|
+
// Filter by the other side. Relations does the intermediate query for you.
|
|
481
|
+
await Test.many({ unit__name: 'a' }).retrieve() // tests whose unit is named a
|
|
482
|
+
await Unit.many({ test__name: 'one' }).retrieve() // units having a test named one
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
> **Note.** `relation.add()` on a relation you haven't read starts a fresh set to create,
|
|
486
|
+
> rather than quietly querying the source. When you want the existing records loaded
|
|
487
|
+
> alongside the new ones, `await parent.child.retrieve()` first.
|
|
488
|
+
|
|
489
|
+
### Many to many
|
|
490
|
+
|
|
491
|
+
Both sides carry a `list` field of the other side's ids, and the tie is a real model, so
|
|
492
|
+
it migrates like anything else:
|
|
493
|
+
|
|
494
|
+
```ts
|
|
495
|
+
class Sister extends Base { static fields = { id: int, name: str, brother_id: list } }
|
|
496
|
+
class Brother extends Base { static fields = { id: int, name: str, sister_id: list } }
|
|
497
|
+
|
|
498
|
+
class Tie extends Base {
|
|
499
|
+
static fields = { sister_id: int, brother_id: int }
|
|
500
|
+
static id = null
|
|
501
|
+
static unique = { sister_brother: ['sister_id', 'brother_id'] }
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
new ManyToMany(Sister, Brother, Tie)
|
|
505
|
+
|
|
506
|
+
await new Brother({ name: 'tom', sister_id: [1, 2] }).create()
|
|
507
|
+
|
|
508
|
+
await Brother.many({ sister_id__all: [1, 2] }).retrieve() // tied to both
|
|
509
|
+
await Brother.many({ sister_id__any: [2] }).retrieve() // tied to either
|
|
510
|
+
await Sister.many({ brother__name: 'tom' }).retrieve() // by the sibling's fields
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
---
|
|
514
|
+
|
|
515
|
+
## Titles
|
|
516
|
+
|
|
517
|
+
A title is the human-readable label for a record, assembled from its title fields — and,
|
|
518
|
+
where a title field points at a parent, from that parent's title too.
|
|
519
|
+
|
|
520
|
+
```ts
|
|
521
|
+
const titles = await Unit.many().titles()
|
|
522
|
+
|
|
523
|
+
titles.ids // [1, 2] — in retrieval order
|
|
524
|
+
titles.get(1) // ['yep']
|
|
525
|
+
titles.fields // ['name']
|
|
526
|
+
titles.format // formatting instructions, one per value
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
Which fields make up a title is inferred (the leading `int` and `str` fields), or you can
|
|
530
|
+
say so:
|
|
531
|
+
|
|
532
|
+
```ts
|
|
533
|
+
class Item extends Base {
|
|
534
|
+
static fields = { id: int, owner_id: int, name: str }
|
|
535
|
+
static titles = ['owner_id', 'name'] // owner_id resolves through the relation
|
|
536
|
+
}
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
---
|
|
540
|
+
|
|
541
|
+
## Migrations
|
|
542
|
+
|
|
543
|
+
Definitions are backend-neutral JSON. A source converts them into whatever it needs — DDL
|
|
544
|
+
for a database, nothing at all for an API — so one migration history drives every backend
|
|
545
|
+
your models are used with.
|
|
546
|
+
|
|
547
|
+
```ts
|
|
548
|
+
import { Migrations, models } from '@relations-dil/relations'
|
|
549
|
+
import * as schema from './models.js'
|
|
550
|
+
|
|
551
|
+
const migrations = new Migrations('ddl')
|
|
552
|
+
|
|
553
|
+
// Snapshot the models. Writes ddl/definition.json, and a timestamped migration
|
|
554
|
+
// alongside the previous snapshot. Resolves to whether anything changed.
|
|
555
|
+
await migrations.generate(models(schema))
|
|
556
|
+
|
|
557
|
+
// Convert into a source's own form, under ddl/<source>/<kind>/
|
|
558
|
+
await migrations.convert('example')
|
|
559
|
+
|
|
560
|
+
// Apply everything not yet applied.
|
|
561
|
+
await migrations.apply('example')
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
### Renames
|
|
565
|
+
|
|
566
|
+
A removed field and an added field can be the same field under a new name, and nothing in
|
|
567
|
+
the definitions can tell you which. Python prompts on stdin. This takes a function, so the
|
|
568
|
+
same code works in a script, a test, or a CLI you write yourself:
|
|
569
|
+
|
|
570
|
+
```ts
|
|
571
|
+
const migrations = new Migrations('ddl', {
|
|
572
|
+
renamer: (what, added, removed) => {
|
|
573
|
+
// what: "people fields", "models", "unit unique indexes", ...
|
|
574
|
+
// return { <removed name>: <added name> } for each rename
|
|
575
|
+
return removed.includes('nickname') && added.includes('handle')
|
|
576
|
+
? { nickname: 'handle' }
|
|
577
|
+
: {}
|
|
578
|
+
}
|
|
579
|
+
})
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
The default treats nothing as a rename, so unattended runs never block.
|
|
583
|
+
|
|
584
|
+
---
|
|
585
|
+
|
|
586
|
+
## Models from data (YAML, JSON, anywhere)
|
|
587
|
+
|
|
588
|
+
`identity.define()` turns a model class into a plain object. `modelsFrom()` turns it back
|
|
589
|
+
into working model classes. The round trip means a set of models can be written as data,
|
|
590
|
+
saved, shipped, and loaded somewhere that has never seen the code:
|
|
591
|
+
|
|
592
|
+
```yaml
|
|
593
|
+
# app.yaml
|
|
594
|
+
models:
|
|
595
|
+
unit:
|
|
596
|
+
source: example
|
|
597
|
+
name: unit
|
|
598
|
+
id: id
|
|
599
|
+
unique: {name: [name]}
|
|
600
|
+
fields:
|
|
601
|
+
- {kind: int, name: id, store: id, auto: true}
|
|
602
|
+
- {kind: str, name: name, store: name, none: false}
|
|
603
|
+
test:
|
|
604
|
+
source: example
|
|
605
|
+
name: test
|
|
606
|
+
id: id
|
|
607
|
+
unique: {name: [name]}
|
|
608
|
+
fields:
|
|
609
|
+
- {kind: int, name: id, store: id, auto: true}
|
|
610
|
+
- {kind: int, name: unit_id, store: unit_id}
|
|
611
|
+
- {kind: str, name: name, store: name, none: false}
|
|
612
|
+
|
|
613
|
+
relations:
|
|
614
|
+
- {kind: OneToMany, parent: unit, child: test}
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
```ts
|
|
618
|
+
import { modelsFrom, MockSource } from '@relations-dil/relations'
|
|
619
|
+
import { parse } from 'yaml' // any YAML parser; relations has no opinion and no dependency
|
|
620
|
+
|
|
621
|
+
const app = parse(await readFile('app.yaml', 'utf8'))
|
|
622
|
+
|
|
623
|
+
new MockSource('example')
|
|
624
|
+
|
|
625
|
+
const { unit: Unit, test: Test } = modelsFrom(app.models, { relations: app.relations })
|
|
626
|
+
|
|
627
|
+
await new Unit('yep').create()
|
|
628
|
+
const found = await Unit.one({ name: 'yep' }).retrieve()
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
And back the other way:
|
|
632
|
+
|
|
633
|
+
```ts
|
|
634
|
+
import { definitionsOf } from '@relations-dil/relations'
|
|
635
|
+
import { stringify } from 'yaml'
|
|
636
|
+
|
|
637
|
+
await writeFile('app.yaml', stringify({ models: definitionsOf([Unit, Test]) }))
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
The data your models hold is already plain too — `model.export()` and `JSON.stringify(model)`
|
|
641
|
+
give you objects keyed by field name, so an app *and* its data can both be a file you save
|
|
642
|
+
and load.
|
|
643
|
+
|
|
644
|
+
---
|
|
645
|
+
|
|
646
|
+
## Writing your own source
|
|
647
|
+
|
|
648
|
+
Subclass `Source`, name it, and models pointing at that name will use it. Registration
|
|
649
|
+
happens in the constructor.
|
|
650
|
+
|
|
651
|
+
```ts
|
|
652
|
+
import { Source } from '@relations-dil/relations'
|
|
653
|
+
|
|
654
|
+
class PostgresSource extends Source {
|
|
655
|
+
static KIND = 'postgresql'
|
|
656
|
+
|
|
657
|
+
constructor(name, options) {
|
|
658
|
+
super(name, options)
|
|
659
|
+
this.pool = options.pool
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Called once per model, to prepare its fields for this backend.
|
|
663
|
+
init(model) {
|
|
664
|
+
super.init(model)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async create(model) {
|
|
668
|
+
await super.create(model) // checks bulk/tie constraints
|
|
669
|
+
|
|
670
|
+
for (const creating of model._each('create')) {
|
|
671
|
+
const values = creating._record.create({})
|
|
672
|
+
const { rows } = await this.pool.query(/* ... */, Object.values(values))
|
|
673
|
+
if (model._id) creating[model._id] = rows[0][model._id]
|
|
674
|
+
creating._action = 'update'
|
|
675
|
+
creating._record._action = 'update'
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
model._action = 'update'
|
|
679
|
+
return model
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async retrieve(model, verify = true) {
|
|
683
|
+
await super.retrieve(model, verify) // resolves many-to-many criteria
|
|
684
|
+
// ...
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
async count(model) { await super.count(model); /* ... */ }
|
|
688
|
+
async update(model) { /* ... */ }
|
|
689
|
+
async delete(model) { /* ... */ }
|
|
690
|
+
async titles(model) { await super.titles(model); /* ... */ }
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
new PostgresSource('example', { pool })
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
The base class does the parts that are the same everywhere: resolving many-to-many
|
|
697
|
+
criteria into plain id filters (`Source.collateTies`), reading and writing tie records
|
|
698
|
+
(`Source.retrieveTies`, `Source.createTies`, `Source.deleteTies`), and walking records and
|
|
699
|
+
fields for definitions and migrations. `MockSource` in `src/mock.ts` is a complete,
|
|
700
|
+
readable implementation — about 500 lines — and is the best reference.
|
|
701
|
+
|
|
702
|
+
---
|
|
703
|
+
|
|
704
|
+
## Reserved names
|
|
705
|
+
|
|
706
|
+
A field can't be named the same as something a model already answers to, because both
|
|
707
|
+
would be reached as `model.<name>`. If your column really is called one of these, use
|
|
708
|
+
`store`:
|
|
709
|
+
|
|
710
|
+
```ts
|
|
711
|
+
static fields = {
|
|
712
|
+
id: int,
|
|
713
|
+
count: { kind: int, store: 'count', name: 'total' } // ...or just name it something else
|
|
714
|
+
}
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
The reserved list is exported as `RESERVED`:
|
|
718
|
+
|
|
719
|
+
`action`, `add`, `append`, `bulk`, `catch`, `constructor`, `count`, `create`, `define`,
|
|
720
|
+
`delete`, `export`, `filter`, `finally`, `insert`, `keys`, `like`, `limit`, `many`,
|
|
721
|
+
`match`, `models`, `one`, `overflow`, `prepare`, `query`, `queue`, `read`, `retrieve`,
|
|
722
|
+
`satisfy`, `set`, `size`, `sort`, `then`, `thy`, `titles`, `toJSON`, `update`, `write`
|
|
723
|
+
|
|
724
|
+
Field names also can't contain `__` (that's the path separator) or start with `_`.
|
|
725
|
+
|
|
726
|
+
---
|
|
727
|
+
|
|
728
|
+
## Differences from python-relations
|
|
729
|
+
|
|
730
|
+
The port is verified against the Python test suite — 340 tests, ported assertion by
|
|
731
|
+
assertion. Where behaviour differs, it's for a reason:
|
|
732
|
+
|
|
733
|
+
| | Python | Here | Why |
|
|
734
|
+
| --- | --- | --- | --- |
|
|
735
|
+
| **Retrieval** | Lazy, on attribute access | Explicit `await …retrieve()` | A JS property getter can't await |
|
|
736
|
+
| **CRUD** | Synchronous | `async` | JS I/O is non-blocking, always |
|
|
737
|
+
| **Class config** | `SOURCE`, `NAME`, `TITLE`, `ID`… | `static source`, `store`, `title`, `id`… | `static name` collides with `Function.name`; `store` matches the field vocabulary |
|
|
738
|
+
| **Source methods** | `create_field`, `has_ties` | `createField`, `hasTies` | camelCase |
|
|
739
|
+
| **Migration renames** | Prompts on stdin | A `renamer` function | Works unattended, and in tests |
|
|
740
|
+
| **`add()` on an unread relation** | Retrieves first | Starts a fresh set | The retrieve would have to be awaited |
|
|
741
|
+
| **`bool` casting** | `bool("false")` is `true` | `"false"`, `"no"`, `"0"`, `""` are `false` | Sane for HTTP params; Python's is a wart |
|
|
742
|
+
| **`int` default of `false`** | Allowed (`bool` subclasses `int`) | Rejected | JS has no such relationship |
|
|
743
|
+
| **`error.message`** | Bare text; name added by `__str__` | Name included; bare text on `.detail` | `assert.throws` and stack traces read `.message` |
|
|
744
|
+
| **Sorting on several keys** | Only the first key was used | All keys, in order | Python bug |
|
|
745
|
+
| **`titles` as a field name** | Allowed (typo in the reserved list) | Reserved | It's a real method |
|
|
746
|
+
|
|
747
|
+
The double-underscore criteria notation, the field options, the relation conventions, the
|
|
748
|
+
definition and migration JSON format, and the `overscore` path notation are all identical,
|
|
749
|
+
so definitions written by either implementation are readable by the other.
|
|
750
|
+
|
|
751
|
+
---
|
|
752
|
+
|
|
753
|
+
## API reference
|
|
754
|
+
|
|
755
|
+
### Models
|
|
756
|
+
|
|
757
|
+
| | |
|
|
758
|
+
| --- | --- |
|
|
759
|
+
| `class X extends Model` | Declare a model. `static source`, `static fields`, and the config statics below. |
|
|
760
|
+
| `fields(spec, base?)` | A typed base class. See [TypeScript](#typescript). |
|
|
761
|
+
| `X.thy()` | Everything knowable about the model without an instance. |
|
|
762
|
+
| `X.define()` | The model's definition, as its source wants it. |
|
|
763
|
+
| `X.one(...)` / `X.many(...)` / `X.bulk(size?)` | Start a query. |
|
|
764
|
+
| `new X(...)` | Start a create. A trailing plain object is named values; anything before it is positional. An array of arrays or objects creates many. |
|
|
765
|
+
|
|
766
|
+
Config statics: `source`, `store`, `title`, `id`, `titles`, `list`, `unique`, `index`,
|
|
767
|
+
`order`, `chunk`.
|
|
768
|
+
|
|
769
|
+
Instance: `filter()`, `sort()`, `limit()`, `set()`, `add()`, `queue()`, `export()`,
|
|
770
|
+
`toJSON()`, `keys()`, `size`, `models`, `overflow`, `query()`, and the six async methods
|
|
771
|
+
`create()`, `retrieve()`, `count()`, `update()`, `delete()`, `titles()`.
|
|
772
|
+
|
|
773
|
+
### Kinds
|
|
774
|
+
|
|
775
|
+
`bool`, `int`, `float`, `str`, `set`, `list`, `dict` — plus any class, with `attr`.
|
|
776
|
+
|
|
777
|
+
### Relations
|
|
778
|
+
|
|
779
|
+
`OneToMany`, `OneToOne`, `ManyToMany`, and the `Relation` base.
|
|
780
|
+
|
|
781
|
+
### Sources
|
|
782
|
+
|
|
783
|
+
`Source` (subclass this), `MockSource` (in-memory, complete), `register()`, `source()`,
|
|
784
|
+
`unregister()`, `clear()`, `SOURCES`.
|
|
785
|
+
|
|
786
|
+
### Migrations and data
|
|
787
|
+
|
|
788
|
+
`Migrations`, `models(module)`, `modelFrom()`, `modelsFrom()`, `definitionsOf()`.
|
|
789
|
+
|
|
790
|
+
### Errors
|
|
791
|
+
|
|
792
|
+
`FieldError`, `RecordError`, `ModelError`, `UniqueError`, `MigrationsError`,
|
|
793
|
+
`OverscoreError`. All carry the thing that went wrong, not just a message.
|
|
794
|
+
|
|
795
|
+
### overscore
|
|
796
|
+
|
|
797
|
+
The double-underscore path notation, usable on its own:
|
|
798
|
+
|
|
799
|
+
```ts
|
|
800
|
+
import * as overscore from '@relations-dil/relations/overscore'
|
|
801
|
+
|
|
802
|
+
overscore.get(data, 'things__a__b__0____1')
|
|
803
|
+
overscore.set(data, 'things__a__b__0____1', 'yep')
|
|
804
|
+
overscore.has(data, 'things__a')
|
|
805
|
+
overscore.parse('a__0___1') // ['a', 0, -1]
|
|
806
|
+
overscore.compile(['a', 0, -1]) // 'a__0___1'
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
---
|
|
810
|
+
|
|
811
|
+
## Development
|
|
812
|
+
|
|
813
|
+
```bash
|
|
814
|
+
npm install
|
|
815
|
+
npm run build # compile to dist/
|
|
816
|
+
npm test # compile and run the full suite
|
|
817
|
+
npm run typecheck # types only, no output
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
No runtime dependencies.
|
|
821
|
+
|
|
822
|
+
## License
|
|
823
|
+
|
|
824
|
+
MIT
|