orange-dragonfly-model 0.11.5 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,511 @@
1
- # Orange Dragonfly Model
2
-
3
- One day Orange Dragonfly will become a NodeJS framework. For now I'm starting to publish its components.
4
-
5
- This library is extension of ActiveRecord class from `orange-dragonfly-orm` library. It defines access functionality and implements lookup and validation functionality.
1
+ # Orange Dragonfly Model
2
+
3
+ An active-record model class for the Orange Dragonfly framework. Extends [`orange-dragonfly-orm`](https://github.com/charger88/orange-dragonfly-orm)'s `ActiveRecord` with validation, access control, field restrictions, and structured output serialisation.
4
+
5
+ ## Table of contents
6
+
7
+ - [Installation](#installation)
8
+ - [Quick start](#quick-start)
9
+ - [Defining a model](#defining-a-model)
10
+ - [Field restrictions](#field-restrictions)
11
+ - [CRUD operations](#crud-operations)
12
+ - [Querying](#querying)
13
+ - [Validation](#validation)
14
+ - [Access control](#access-control)
15
+ - [Output serialisation](#output-serialisation)
16
+ - [Error handling](#error-handling)
17
+ - [Deprecated API](#deprecated-api)
18
+
19
+ ---
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ npm install orange-dragonfly-model
25
+ ```
26
+
27
+ `orange-dragonfly-orm` and `orange-dragonfly-validator` are regular dependencies and are installed automatically.
28
+
29
+ ---
30
+
31
+ ## Quick start
32
+
33
+ ```typescript
34
+ import { Model } from 'orange-dragonfly-model'
35
+ import type { ODValidatorRulesSchema } from 'orange-dragonfly-validator'
36
+ import { Relation } from 'orange-dragonfly-orm'
37
+
38
+ class Article extends Model {
39
+ static override get validation_rules(): ODValidatorRulesSchema {
40
+ return {
41
+ id: { required: false, type: 'integer', min: 1 },
42
+ title: { required: true, type: 'string', min: 1, max: 255 },
43
+ body: { required: true, type: 'string', min: 1 },
44
+ author_id: { required: true, type: 'integer', min: 1 },
45
+ published: { required: false, type: 'boolean' },
46
+ created_at: { required: false, type: 'integer' },
47
+ updated_at: { required: false, type: 'integer' },
48
+ }
49
+ }
50
+
51
+ static override get unique_keys(): string[][] {
52
+ return [['title']]
53
+ }
54
+
55
+ static override get available_relations() {
56
+ return {
57
+ author: Relation.parent(this, User),
58
+ }
59
+ }
60
+
61
+ override get output(): Record<string, unknown> {
62
+ return {
63
+ id: this.id,
64
+ title: this.data.title,
65
+ published: this.data.published,
66
+ }
67
+ }
68
+ }
69
+
70
+ // Create
71
+ const article = await Article.create({ title: 'Hello', body: 'World', author_id: 1 })
72
+
73
+ // Update
74
+ await article.update({ title: 'Hello World' })
75
+
76
+ // Lookup
77
+ const q = Article.lookupQuery({ title: 'Hello World' })
78
+ const results = await q.select()
79
+
80
+ // Serialise with relations
81
+ const json = await article.getExtendedOutput(['author'])
82
+ ```
83
+
84
+ ---
85
+
86
+ ## Defining a model
87
+
88
+ Subclass `Model` and override the static getters that describe the model's shape and behaviour. Every getter is optional — the base class provides safe defaults.
89
+
90
+ ### validation_rules
91
+
92
+ Returns an [`orange-dragonfly-validator`](https://github.com/charger88/orange-dragonfly-validator) schema that describes every field the model accepts. This schema is used by `validate()`, `create()`, `update()`, and `lookupQuery()`.
93
+
94
+ ```typescript
95
+ static override get validation_rules(): ODValidatorRulesSchema {
96
+ return {
97
+ id: { required: false, type: 'integer', min: 1 },
98
+ username: { required: true, type: 'string', min: 1, max: 64 },
99
+ email: { required: true, type: 'string', min: 5, max: 255 },
100
+ active: { required: false, type: 'boolean' },
101
+ }
102
+ }
103
+ ```
104
+
105
+ The default rule set contains only `id`.
106
+
107
+ **Boolean coercion.** During validation, fields declared as `type: 'boolean'` are automatically coerced: the integer `1` becomes `true` and `0` becomes `false`. This handles databases that store booleans as integers.
108
+
109
+ **Relation integrity.** For every `parent`-mode relation defined in `available_relations`, `validate()` checks that the referenced parent record exists whenever the foreign-key field is present and non-null/non-zero.
110
+
111
+ ### special_fields
112
+
113
+ Derived automatically from `validation_rules` — no override needed. A field is "special" when its name is one of `created_at`, `updated_at`, or `deleted_at`. Special fields are managed by `ActiveRecord.save()` and `ActiveRecord.remove()` and are automatically added to `restricted_for_create` and `restricted_for_update`.
114
+
115
+ ```typescript
116
+ // If your rules include created_at and updated_at:
117
+ static override get validation_rules(): ODValidatorRulesSchema {
118
+ return {
119
+ id: { required: false, type: 'integer', min: 1 },
120
+ name: { required: true, type: 'string' },
121
+ created_at: { required: false, type: 'integer' },
122
+ updated_at: { required: false, type: 'integer' },
123
+ }
124
+ }
125
+ // → special_fields returns ['created_at', 'updated_at'] automatically
126
+ // → restricted_for_create returns ['id', 'created_at', 'updated_at'] automatically
127
+ ```
128
+
129
+ ### unique_keys
130
+
131
+ A list of field-name groups that must be unique across the table. Each inner array is one composite key. Checked automatically on every `save()`.
132
+
133
+ ```typescript
134
+ static override get unique_keys(): string[][] {
135
+ return [
136
+ ['email'], // email alone must be unique
137
+ ['first_name', 'last_name'], // combination must be unique
138
+ ]
139
+ }
140
+ ```
141
+
142
+ When a uniqueness violation is detected during save, an `OrangeDatabaseInputValidationError` is thrown with every field in the failing key listed in `.info`.
143
+
144
+ **Null handling.** By default, null values in a key cause the uniqueness check to pass (treating null as always-unique). This matches SQL `UNIQUE` index behaviour where `NULL ≠ NULL`. You can control this per-call via the `ignore_null` parameter of `checkUniqueness()`.
145
+
146
+ ### fulltext_indexes
147
+
148
+ Declares the fulltext indexes that exist on the table. This is informational metadata — the library itself does not automatically build MATCH/AGAINST queries from it, but your query-builder layer or framework tooling can inspect it.
149
+
150
+ ```typescript
151
+ static override get fulltext_indexes(): string[][] {
152
+ return [
153
+ ['title', 'body'], // composite fulltext index
154
+ ]
155
+ }
156
+ ```
157
+
158
+ ### ignore_extra_fields
159
+
160
+ When `true`, fields present in input data that are not declared in `validation_rules` are silently dropped instead of raising an error. Applies to `create()`, `update()`, `lookupQuery()`, and `_preSave()`.
161
+
162
+ Useful when the application shares a database with other systems and columns may be added to a table outside of your control.
163
+
164
+ ```typescript
165
+ static override get ignore_extra_fields(): boolean {
166
+ return true
167
+ }
168
+ ```
169
+
170
+ Default: `false` — unknown fields throw `OrangeDatabaseInputValidationError`.
171
+
172
+ ---
173
+
174
+ ## Field restrictions
175
+
176
+ These getters return an array of field names that are blocked in specific operations. Attempting to pass a restricted field throws `OrangeDatabaseInputValidationError`.
177
+
178
+ ### restricted_for_lookup
179
+
180
+ Fields that callers may not filter by in `lookupQuery()`.
181
+
182
+ ```typescript
183
+ static override get restricted_for_lookup(): string[] {
184
+ return ['password_hash', 'secret_token']
185
+ }
186
+ ```
187
+
188
+ Default: `[]`.
189
+
190
+ ### restricted_for_create
191
+
192
+ Fields that callers may not supply to `create()`. Defaults to `['id', ...special_fields]` — override to extend it.
193
+
194
+ ```typescript
195
+ static override get restricted_for_create(): string[] {
196
+ return super.restricted_for_create.concat(['role']) // callers cannot set role on creation
197
+ }
198
+ ```
199
+
200
+ ### restricted_for_update
201
+
202
+ Fields that callers may not supply to `update()`. Defaults to `['id', ...special_fields]`.
203
+
204
+ ```typescript
205
+ static override get restricted_for_update(): string[] {
206
+ return super.restricted_for_update.concat(['email']) // email cannot be changed after creation
207
+ }
208
+ ```
209
+
210
+ ### restricted_for_output
211
+
212
+ Relation names that `getExtendedOutput()` will refuse to embed. Trying to include a restricted relation throws a plain `Error`.
213
+
214
+ ```typescript
215
+ static override get restricted_for_output(): string[] {
216
+ return ['payment_details']
217
+ }
218
+ ```
219
+
220
+ Default: `[]`.
221
+
222
+ ---
223
+
224
+ ## CRUD operations
225
+
226
+ ### create
227
+
228
+ Validates field names and restrictions, then inserts a new record. Validation rules and uniqueness constraints are enforced by `_preSave()` before the DB write.
229
+
230
+ ```typescript
231
+ const user = await User.create({
232
+ username: 'alice',
233
+ email: 'alice@example.com',
234
+ })
235
+ // → User instance with id set by the database
236
+ ```
237
+
238
+ Throws `OrangeDatabaseInputValidationError` when:
239
+ - a field is not in `validation_rules` (and `ignore_extra_fields` is `false`)
240
+ - a field is in `restricted_for_create`
241
+ - validation fails (type mismatch, required field missing, uniqueness violation, etc.)
242
+
243
+ ### update
244
+
245
+ Merges the supplied fields into the record and saves it. The instance must already have an `id` (i.e. it was loaded from the database or returned by `create()`).
246
+
247
+ ```typescript
248
+ await article.update({ title: 'Revised title', body: 'Updated content' })
249
+ ```
250
+
251
+ Throws `Error('You can update saved object only')` if `this.id` is falsy. Throws `OrangeDatabaseInputValidationError` for the same field-level reasons as `create()`.
252
+
253
+ ---
254
+
255
+ ## Querying
256
+
257
+ ### lookupQuery
258
+
259
+ Builds a `SelectQuery` from a plain data object, applying field validation and restriction checks before any SQL is generated. Returns the query so you can chain further conditions or execute it.
260
+
261
+ ```typescript
262
+ // Simple equality
263
+ const q = User.lookupQuery({ username: 'alice' })
264
+ const users = await q.select()
265
+
266
+ // Array → IN (...)
267
+ const q2 = User.lookupQuery({ id: [1, 2, 3] })
268
+
269
+ // Compose with an existing query (e.g. add ORDER BY before calling select)
270
+ const base = User.selectQuery().orderBy('created_at', 'DESC')
271
+ const q3 = User.lookupQuery({ active: true }, base)
272
+ ```
273
+
274
+ Fields not present in `validation_rules` throw unless `ignore_extra_fields` is `true`. Fields in `restricted_for_lookup` always throw.
275
+
276
+ ---
277
+
278
+ ## Validation
279
+
280
+ ### validate
281
+
282
+ Called automatically by `_preSave()` on every `create()` / `update()` / `save()`. Can also be called manually.
283
+
284
+ Performs three checks in order:
285
+
286
+ 1. **Schema validation** — runs `orange-dragonfly-validator` `parse()` against `this.data` using `validation_rules`.
287
+ 2. **Custom validation** — calls `custom_validation()` and collects any returned errors.
288
+ 3. **Relation integrity** — for each `parent`-mode relation, if the foreign-key field is set to a non-null, non-zero value, verifies the parent record exists.
289
+
290
+ ```typescript
291
+ const user = new User({ username: '', email: 'not-an-email' })
292
+ await user.validate() // throws OrangeDatabaseInputValidationError
293
+ ```
294
+
295
+ ### custom_validation
296
+
297
+ Override to add application-specific validation logic. Return `null` (or an empty object) on success, or a `Record<string, string>` mapping field names to error messages.
298
+
299
+ ```typescript
300
+ override async custom_validation(): Promise<Record<string, string> | null> {
301
+ if (this.data.password !== this.data.password_confirm) {
302
+ return { password_confirm: 'Passwords do not match' }
303
+ }
304
+ if (await User.selectQuery().where('email', this.data.email).exists()) {
305
+ return { email: 'Email is already taken' }
306
+ }
307
+ return null
308
+ }
309
+ ```
310
+
311
+ ### checkUniqueness
312
+
313
+ Checks all `unique_keys` constraints against the database. Called automatically during `_preSave()` with `exception_mode = true` and `ignore_null = true`. Can be called manually for pre-flight checks.
314
+
315
+ ```typescript
316
+ // Returns boolean
317
+ const isOk = await article.checkUniqueness()
318
+
319
+ // Throws OrangeDatabaseInputValidationError if not unique
320
+ await article.checkUniqueness(true)
321
+
322
+ // Treat null values as non-unique (strict mode)
323
+ await article.checkUniqueness(true, false)
324
+ ```
325
+
326
+ | Parameter | Default | Description |
327
+ |---|---|---|
328
+ | `exception_mode` | `false` | Throw instead of returning `false` when a violation is found |
329
+ | `ignore_null` | `false` | Return `true` immediately when any key field is `null` (null is always-unique) |
330
+
331
+ ---
332
+
333
+ ## Access control
334
+
335
+ ### accessible
336
+
337
+ Override to implement per-object access control. Receives the requesting user and an optional mode string. Returns `true` when access should be granted.
338
+
339
+ The base implementation grants access when `mode` is `null` and denies it otherwise — a safe default that lets you add access checks incrementally.
340
+
341
+ ```typescript
342
+ override async accessible(user: AuthUser | null, mode: string | null = null): Promise<boolean> {
343
+ if (!user) return false
344
+ if (user.role === 'admin') return true
345
+ if (mode === 'write') return this.data.owner_id === user.id
346
+ return true // read access for any authenticated user
347
+ }
348
+ ```
349
+
350
+ ### findAndCheckAccessOrDie
351
+
352
+ Fetches a record by id and calls `accessible()`. Throws a plain `Error` if the record is not found or not accessible — suitable for use in API route handlers.
353
+
354
+ ```typescript
355
+ // In a route handler:
356
+ const article = await Article.findAndCheckAccessOrDie(req.params.id, req.user, 'write')
357
+ // Continues only if the record exists and accessible(user, 'write') returns true
358
+ ```
359
+
360
+ Error messages:
361
+ - `"Article #42 not found"` — record missing
362
+ - `"Article #42 is not accessible"` — mode is `null`
363
+ - `"Article #42 is not accessible for write"` — mode is provided
364
+
365
+ ---
366
+
367
+ ## Output serialisation
368
+
369
+ ### output
370
+
371
+ A getter that returns the public representation of the record. Override to expose exactly the fields you want — omit sensitive data and include any computed values.
372
+
373
+ ```typescript
374
+ override get output(): Record<string, unknown> {
375
+ return {
376
+ id: this.id,
377
+ username: this.data.username,
378
+ joined_at: this.data.created_at,
379
+ }
380
+ }
381
+ ```
382
+
383
+ The base implementation returns `{ id: this.id }`.
384
+
385
+ ### formatOutput
386
+
387
+ Called by `getExtendedOutput()`. Override when the output shape depends on the access context (e.g. return fewer fields for relation embeds).
388
+
389
+ ```typescript
390
+ override formatOutput(mode: string | null = null): Record<string, unknown> {
391
+ const base = this.output
392
+ if (mode?.startsWith('relation:')) {
393
+ // Return a compact representation when embedded as a relation
394
+ return { id: base.id, username: base.username }
395
+ }
396
+ return base
397
+ }
398
+ ```
399
+
400
+ The base implementation delegates to `output` regardless of `mode`.
401
+
402
+ ### getExtendedOutput
403
+
404
+ Embeds relation data alongside the base output. Pass a flat list of relation names; use colon-separated paths for nesting.
405
+
406
+ ```typescript
407
+ // Embed a single relation
408
+ const data = await article.getExtendedOutput(['author'])
409
+ // → { id, title, ..., ':author': { id, username } }
410
+
411
+ // Nest relations: embed author's profile inside author
412
+ const data = await article.getExtendedOutput(['author', 'author:profile'])
413
+ // → { ..., ':author': { ..., ':profile': { ... } } }
414
+
415
+ // Array relations work automatically
416
+ const data = await post.getExtendedOutput(['comments'])
417
+ // → { ..., ':comments': [{ ... }, { ... }] }
418
+ ```
419
+
420
+ Relation keys in the returned object are prefixed with `:` to distinguish them from plain fields. Relations listed in `restricted_for_output` throw `OrangeDatabaseModelError` when requested.
421
+
422
+ The `mode` string is threaded through `formatOutput()` calls on embedded objects as `"relation:ParentModel.relationName"`, which lets models adjust their output when they are rendered as embedded relations.
423
+
424
+ ---
425
+
426
+ ## Error handling
427
+
428
+ The library uses a typed error hierarchy, all rooted in `OrangeDatabaseError` from `orange-dragonfly-orm`:
429
+
430
+ ```
431
+ OrangeDatabaseError (orange-dragonfly-orm)
432
+ ├── OrangeDatabaseInputError (orange-dragonfly-orm)
433
+ │ └── OrangeDatabaseInputValidationError field-level validation failures
434
+ └── OrangeDatabaseModelError model configuration / programming errors
435
+ └── OrangeDatabaseModelRuntimeError runtime state errors
436
+ └── OrangeDatabaseModelAccessError access-control failures
437
+ ```
438
+
439
+ All classes are exported from `orange-dragonfly-model`.
440
+
441
+ ### OrangeDatabaseInputValidationError
442
+
443
+ Thrown by `create()`, `update()`, `lookupQuery()`, `validate()`, and `checkUniqueness()` whenever field-level input is invalid. Carries an `info` map of per-field error messages.
444
+
445
+ ```typescript
446
+ import { OrangeDatabaseInputValidationError } from 'orange-dragonfly-model'
447
+
448
+ try {
449
+ await User.create({ username: '', email: 'bad' })
450
+ } catch (e) {
451
+ if (e instanceof OrangeDatabaseInputValidationError) {
452
+ console.log(e.message) // 'Validation failed'
453
+ console.log(e.info) // { username: '...', email: '...' }
454
+ }
455
+ }
456
+ ```
457
+
458
+ ### OrangeDatabaseModelError
459
+
460
+ Thrown for model configuration or programming errors — for example, calling `getExtendedOutput()` with a relation that is in `restricted_for_output`, or accessing a model whose `validation_rules` returns `null`.
461
+
462
+ ### OrangeDatabaseModelRuntimeError
463
+
464
+ Thrown for runtime state violations — for example, calling `update()` on an unsaved instance, or `findAndCheckAccessOrDie()` when the requested record does not exist.
465
+
466
+ ### OrangeDatabaseModelAccessError
467
+
468
+ Thrown by `findAndCheckAccessOrDie()` when `accessible()` returns `false`. Extends `OrangeDatabaseModelRuntimeError`, so a single `catch (e instanceof OrangeDatabaseModelRuntimeError)` covers both not-found and access-denied cases if needed.
469
+
470
+ ```typescript
471
+ import {
472
+ OrangeDatabaseModelRuntimeError,
473
+ OrangeDatabaseModelAccessError,
474
+ } from 'orange-dragonfly-model'
475
+
476
+ try {
477
+ const article = await Article.findAndCheckAccessOrDie(id, user, 'write')
478
+ } catch (e) {
479
+ if (e instanceof OrangeDatabaseModelAccessError) {
480
+ // 403 — record exists but user cannot access it
481
+ } else if (e instanceof OrangeDatabaseModelRuntimeError) {
482
+ // 404 — record not found
483
+ }
484
+ }
485
+ ```
486
+
487
+ ---
488
+
489
+ ## Deprecated API
490
+
491
+ The following uppercase getters were renamed for naming consistency. They still work as proxies but will be removed in a future major version.
492
+
493
+ | Deprecated | Replacement |
494
+ |---|---|
495
+ | `IGNORE_EXTRA_FIELDS` | `ignore_extra_fields` |
496
+ | `UNIQUE_KEYS` | `unique_keys` |
497
+ | `FULLTEXT_INDEXES` | `fulltext_indexes` |
498
+
499
+ Migrate by renaming your overrides:
500
+
501
+ ```typescript
502
+ // Before
503
+ static override get UNIQUE_KEYS(): string[][] {
504
+ return [['email']]
505
+ }
506
+
507
+ // After
508
+ static override get unique_keys(): string[][] {
509
+ return [['email']]
510
+ }
511
+ ```