@orieljs/framework 0.1.0-alpha.6 → 0.1.0-alpha.7
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/NOTICE +4 -0
- package/README.md +10 -1
- package/TRADEMARKS.md +33 -0
- package/dist/{chunk-ZOEPKBVF.js → chunk-4MWG3B7G.js} +1347 -428
- package/dist/chunk-4MWG3B7G.js.map +1 -0
- package/dist/{chunk-POEP4B2M.js → chunk-7ZQZYDOC.js} +745 -105
- package/dist/chunk-7ZQZYDOC.js.map +1 -0
- package/dist/{chunk-I63ZFMX6.js → chunk-GVNLZZTN.js} +1 -1
- package/dist/chunk-GVNLZZTN.js.map +1 -0
- package/dist/{chunk-MFTZQMZM.js → chunk-VOYW3R5J.js} +4 -4
- package/dist/chunk-VOYW3R5J.js.map +1 -0
- package/dist/contracts.d.ts +515 -17
- package/dist/contracts.js +1 -1
- package/dist/index.d-WQpfdIbG.d.ts +630 -0
- package/dist/index.d.ts +964 -152
- package/dist/index.js +4279 -866
- package/dist/index.js.map +1 -1
- package/dist/{jsx-runtime-CBZQYYDS.d.ts → jsx-runtime-6um4Mwfb.d.ts} +96 -5
- package/dist/jsx-runtime.d.ts +2 -2
- package/dist/jsx-runtime.js +3 -3
- package/dist/{testing-BM481Hdz.d.ts → testing-CKwfWJGn.d.ts} +180 -15
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +3 -3
- package/docs/authentication.md +487 -0
- package/docs/authorization.md +435 -0
- package/docs/cache.md +647 -0
- package/docs/collections.md +2270 -0
- package/docs/configuration.md +334 -0
- package/docs/console.md +651 -0
- package/docs/container.md +430 -0
- package/docs/controllers.md +406 -0
- package/docs/csrf.md +126 -0
- package/docs/database-testing.md +144 -0
- package/docs/database.md +236 -0
- package/docs/deployment.md +276 -0
- package/docs/eloquent-collections.md +218 -0
- package/docs/eloquent-factories.md +255 -0
- package/docs/eloquent-mutators.md +254 -0
- package/docs/eloquent-observers.md +145 -0
- package/docs/eloquent-relationships.md +557 -0
- package/docs/eloquent.md +494 -0
- package/docs/encryption.md +104 -0
- package/docs/errors.md +318 -0
- package/docs/events.md +336 -0
- package/docs/facades.md +278 -0
- package/docs/hashing.md +118 -0
- package/docs/helpers.md +1281 -0
- package/docs/http-tests.md +386 -0
- package/docs/inertia.md +485 -0
- package/docs/installation.md +209 -0
- package/docs/lifecycle.md +234 -0
- package/docs/live-tests.md +226 -0
- package/docs/live.md +910 -0
- package/docs/logging.md +255 -0
- package/docs/mail.md +619 -0
- package/docs/middleware.md +384 -0
- package/docs/migrations.md +416 -0
- package/docs/mocking.md +264 -0
- package/docs/navigation.json +273 -0
- package/docs/notifications.md +225 -0
- package/docs/pagination.md +247 -0
- package/docs/passwords.md +207 -0
- package/docs/providers.md +187 -0
- package/docs/queries.md +600 -0
- package/docs/queues.md +330 -0
- package/docs/requests.md +533 -0
- package/docs/responses.md +444 -0
- package/docs/routing.md +738 -0
- package/docs/scheduling.md +277 -0
- package/docs/seeding.md +122 -0
- package/docs/session.md +409 -0
- package/docs/starter-kits.md +396 -0
- package/docs/storage.md +192 -0
- package/docs/structure.md +264 -0
- package/docs/testing.md +165 -0
- package/docs/time-travel.md +138 -0
- package/docs/validation.md +1792 -0
- package/docs/verification.md +152 -0
- package/docs/views.md +566 -0
- package/docs/vite.md +322 -0
- package/package.json +34 -32
- package/dist/chunk-I63ZFMX6.js.map +0 -1
- package/dist/chunk-MFTZQMZM.js.map +0 -1
- package/dist/chunk-POEP4B2M.js.map +0 -1
- package/dist/chunk-ZOEPKBVF.js.map +0 -1
- package/dist/index.d-BJyJx6U1.d.ts +0 -319
package/docs/eloquent.md
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
# Eloquent: getting started
|
|
2
|
+
|
|
3
|
+
Each table of your database has a model, a class that reads its rows as objects, writes them back, and knows its
|
|
4
|
+
relations to the other tables. Oriel's models follow Laravel's Eloquent.
|
|
5
|
+
|
|
6
|
+
## Introduction
|
|
7
|
+
|
|
8
|
+
A model extends `Model`, and its class name gives its table: `Flight` reads and writes the `flights` table. A model
|
|
9
|
+
query takes every method of the [query builder](queries.md), and returns models:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
const flights = await Flight.where('active', 1).orderBy('name').limit(10).get()
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Before you start, set your database connection in `config/database.ts` (see [Getting started](database.md)).
|
|
16
|
+
|
|
17
|
+
## Generating model classes
|
|
18
|
+
|
|
19
|
+
`make:model` writes a model in `app/Models`:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
node oriel make:model Flight
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Its options write the model's other files too: a migration (`-m`), a factory (`-f`), a seeder (`-s`), a controller
|
|
26
|
+
(`-c`, a resource controller with `-r`, an API one with `--api`), form requests for it (`-R`), a policy (`--policy`),
|
|
27
|
+
or all of them (`-a`). Short options combine:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
node oriel make:model Flight -mfs
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Inspecting models
|
|
34
|
+
|
|
35
|
+
`node oriel model:types` writes `types/models.d.ts`: each model's columns, read from your database, with their types, so
|
|
36
|
+
`flight.name` is a `string` and `Flight.where('nme', …)` is a type error. `migrate`, `migrate:fresh` and
|
|
37
|
+
`migrate:refresh` run it for you when they finish, outside production, in an app with a `types` folder, as a new app
|
|
38
|
+
has. It reads your tables, so run your migrations first. A model must be a named export of its file to get its types.
|
|
39
|
+
|
|
40
|
+
## Model conventions
|
|
41
|
+
|
|
42
|
+
A model's settings are fields of its class, most of them `protected override` fields:
|
|
43
|
+
|
|
44
|
+
```ts title="app/Models/Flight.ts"
|
|
45
|
+
import { Model } from '@orieljs/framework'
|
|
46
|
+
|
|
47
|
+
export class Flight extends Model {
|
|
48
|
+
protected override table = 'my_flights'
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Table names
|
|
53
|
+
|
|
54
|
+
A model's table is the plural, in snake case, of its class name: `Flight` uses `flights`, and `AirTrafficController`
|
|
55
|
+
`air_traffic_controllers`. `table` names another.
|
|
56
|
+
|
|
57
|
+
### Primary keys
|
|
58
|
+
|
|
59
|
+
A model's primary key is `id`, an auto-incrementing integer. `primaryKey` names another column; a key that doesn't
|
|
60
|
+
increment sets `override incrementing = false` (a public field, as `timestamps` is), and a string key
|
|
61
|
+
`protected override keyType = 'string' as const`:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
export class Flight extends Model {
|
|
65
|
+
protected override primaryKey = 'flight_id'
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
#### UUID and ULID keys
|
|
70
|
+
|
|
71
|
+
The `HasUuids` mixin gives a model a UUID key in place of an integer, made when the model is created: a version 7 UUID,
|
|
72
|
+
which sorts by time, so the index stays compact. `HasUlids` gives a lower-case ULID:
|
|
73
|
+
|
|
74
|
+
```ts title="app/Models/Article.ts"
|
|
75
|
+
import { HasUuids, Model } from '@orieljs/framework'
|
|
76
|
+
|
|
77
|
+
export class Article extends HasUuids(Model) {}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Their tables need a `uuid('id')` or `ulid('id')` primary key column (see [Migrations](migrations.md)). A route
|
|
81
|
+
parameter or a `find()` with a malformed id finds nothing, without a query.
|
|
82
|
+
|
|
83
|
+
> [!NOTE]
|
|
84
|
+
> Laravel adds abilities to a model with traits (`use HasUuids;`). TypeScript classes have one parent, so Oriel's are
|
|
85
|
+
> mixins, functions that wrap the class you extend: `HasUuids(Model)`, and a new app's
|
|
86
|
+
> `HasApiTokens(Notifiable(Authorizable(AuthenticatableModel(Model))))`.
|
|
87
|
+
|
|
88
|
+
### Timestamps
|
|
89
|
+
|
|
90
|
+
A model sets `created_at` and `updated_at` when it is created and saved. `timestamps = false` turns this off, and the
|
|
91
|
+
static `CREATED_AT` and `UPDATED_AT` rename the columns, or turn one off with `null`:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
export class Flight extends Model {
|
|
95
|
+
override timestamps = false
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class Booking extends Model {
|
|
99
|
+
static override UPDATED_AT = null
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Timestamps are stored in UTC, to the second.
|
|
104
|
+
|
|
105
|
+
### Database connections
|
|
106
|
+
|
|
107
|
+
A model uses the default connection, unless `connection` names another:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
export class Flight extends Model {
|
|
111
|
+
protected override connection = 'pgsql'
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Default attribute values
|
|
116
|
+
|
|
117
|
+
`attributes` gives a new model's default values:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
export class Flight extends Model {
|
|
121
|
+
protected override attributes = { options: '[]', delayed: false }
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Attributes and their types
|
|
126
|
+
|
|
127
|
+
A model's attributes are its row's columns, read and written as properties: `flight.name`. Their types come from
|
|
128
|
+
`types/models.d.ts`. Without it, declare them yourself, with `declare`:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
export class Flight extends Model {
|
|
132
|
+
declare name: string
|
|
133
|
+
declare delayed: boolean
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
> [!WARNING]
|
|
138
|
+
> Never give an attribute a class field, such as `name = ''` or `name!: string`: a field hides the attribute, so
|
|
139
|
+
> the model never reads or writes the column. `declare` adds only the type.
|
|
140
|
+
|
|
141
|
+
A column named like one of `Model`'s own members, such as `table`, `exists` or `update`, can't be read as a property:
|
|
142
|
+
read it with `getAttribute('update')`.
|
|
143
|
+
|
|
144
|
+
## Retrieving models
|
|
145
|
+
|
|
146
|
+
`all()` returns every row of the model's table, as models in a collection:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
for (const flight of await Flight.all()) {
|
|
150
|
+
console.log(flight.name)
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
#### Building queries
|
|
155
|
+
|
|
156
|
+
A query starts with a static method of the model, such as `where()`, and runs with `get()`:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
const flights = await Flight.where('active', 1).orderBy('name').limit(10).get()
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`Flight.query()` starts an empty one.
|
|
163
|
+
|
|
164
|
+
#### Refreshing models
|
|
165
|
+
|
|
166
|
+
`fresh()` reads the model again from the database and returns a new one, and `refresh()` reads it into the model
|
|
167
|
+
itself, its loaded relations included:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
const flight = await Flight.where('number', 'FR 900').firstOrFail()
|
|
171
|
+
|
|
172
|
+
const freshFlight = await flight.fresh()
|
|
173
|
+
|
|
174
|
+
await flight.refresh()
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Collections
|
|
178
|
+
|
|
179
|
+
`get()` and `all()` return an `EloquentCollection`, a [collection](collections.md) with methods for models (see
|
|
180
|
+
[Eloquent collections](eloquent-collections.md)). It is iterable:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
const flights = await Flight.where('destination', 'Paris').get()
|
|
184
|
+
|
|
185
|
+
const names = flights.map((flight) => flight.name).all()
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Chunking results
|
|
189
|
+
|
|
190
|
+
`chunk()` reads a large table a part at a time, ordered by the key, and `chunkById()` pages by the key, for a function
|
|
191
|
+
that changes the rows:
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
await Flight.chunk(200, async (flights) => {
|
|
195
|
+
for (const flight of flights) {
|
|
196
|
+
// ...
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Streaming results lazily
|
|
202
|
+
|
|
203
|
+
`lazy()` returns a lazy collection, which reads the rows a part at a time as you go through it:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
for await (const flight of Flight.lazy()) {
|
|
207
|
+
// ...
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Retrieving single models / aggregates
|
|
212
|
+
|
|
213
|
+
`find()` returns the model with that key, and `first()` the first of a query, or `undefined`:
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
const flight = await Flight.find(1)
|
|
217
|
+
|
|
218
|
+
const active = await Flight.where('active', 1).first()
|
|
219
|
+
|
|
220
|
+
const first = await Flight.firstWhere('active', 1)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
#### Not found exceptions
|
|
224
|
+
|
|
225
|
+
`findOrFail()` and `firstOrFail()` throw a `ModelNotFoundError` when there is no model. Uncaught, it answers 404:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
const flight = await Flight.findOrFail(1)
|
|
229
|
+
|
|
230
|
+
const flightOver = await Flight.where('legs', '>', 3).firstOrFail()
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Retrieving or creating models
|
|
234
|
+
|
|
235
|
+
`firstOrCreate()` finds the model that matches its first argument, or creates it with both arguments. `firstOrNew()`
|
|
236
|
+
does the same, but leaves the new model unsaved:
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
const flight = await Flight.firstOrCreate({ name: 'London to Paris' }, { delayed: 1, arrival_time: '11:30' })
|
|
240
|
+
|
|
241
|
+
const unsaved = await Flight.firstOrNew({ name: 'London to Paris' })
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Retrieving aggregates
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
const count = await Flight.where('active', 1).count()
|
|
248
|
+
|
|
249
|
+
const max = await Flight.where('active', 1).max('price')
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Inserting and updating models
|
|
253
|
+
|
|
254
|
+
### Inserts
|
|
255
|
+
|
|
256
|
+
`create()` saves a new model, with the attributes you give, and returns it:
|
|
257
|
+
|
|
258
|
+
```ts title="app/Http/Controllers/FlightController.ts"
|
|
259
|
+
import { redirect, request } from '@orieljs/framework'
|
|
260
|
+
import { Flight } from '../../Models/Flight'
|
|
261
|
+
import { Controller } from './Controller'
|
|
262
|
+
|
|
263
|
+
export class FlightController extends Controller {
|
|
264
|
+
/** Store a new flight in the database. */
|
|
265
|
+
async store() {
|
|
266
|
+
// Validate the request...
|
|
267
|
+
|
|
268
|
+
await Flight.create({ name: request().string('name').toString() })
|
|
269
|
+
|
|
270
|
+
return redirect('/flights')
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
`make()` builds the model without saving it, and `save()` saves it:
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
const flight = Flight.make({ name: 'London to Paris' })
|
|
279
|
+
|
|
280
|
+
await flight.save()
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
> [!NOTE]
|
|
284
|
+
> Laravel builds a model with `new Flight(['name' => …])`. An Oriel model's constructor takes nothing: use
|
|
285
|
+
> `Flight.make()` or `Flight.create()`.
|
|
286
|
+
|
|
287
|
+
### Updates
|
|
288
|
+
|
|
289
|
+
`save()` saves a model you changed. Only the changed attributes are written, and `updated_at` is set:
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
const flight = await Flight.findOrFail(1)
|
|
293
|
+
|
|
294
|
+
flight.name = 'Paris to London'
|
|
295
|
+
|
|
296
|
+
await flight.save()
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
`update()` sets attributes and saves, on a model or on every row a query matches:
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
declare const flight: Flight
|
|
303
|
+
// ---cut---
|
|
304
|
+
await flight.update({ delayed: 1 })
|
|
305
|
+
|
|
306
|
+
await Flight.where('active', 1).where('destination', 'San Diego').update({ delayed: 1 })
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
A query's `update()` writes the rows directly: their models aren't read, and their events don't fire.
|
|
310
|
+
|
|
311
|
+
`updateOrCreate()` updates the model that matches its first argument, or creates one:
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
const flight = await Flight.updateOrCreate({ departure: 'Oakland', destination: 'San Diego' }, { price: 99, discounted: 1 })
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
#### Examining attribute changes
|
|
318
|
+
|
|
319
|
+
`isDirty()` tells you whether a model, or an attribute, changed since it was read, and `isClean()` the opposite.
|
|
320
|
+
`wasChanged()` tells you whether the last `save()` changed it, and `getOriginal()` gives the values as they were read:
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
const user = await User.create({ name: 'Taylor', email: 'taylor@example.com', password: '' })
|
|
324
|
+
|
|
325
|
+
user.name = 'Painter'
|
|
326
|
+
|
|
327
|
+
user.isDirty() // true
|
|
328
|
+
user.isDirty('name') // true
|
|
329
|
+
user.isDirty('email') // false
|
|
330
|
+
user.getOriginal('name') // 'Taylor'
|
|
331
|
+
|
|
332
|
+
await user.save()
|
|
333
|
+
|
|
334
|
+
user.wasChanged('name') // true
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### Mass assignment
|
|
338
|
+
|
|
339
|
+
`create()`, `make()`, `update()` and `fill()` set only the attributes listed in the model's `fillable`, so a request
|
|
340
|
+
can't set a column you didn't mean it to, such as `is_admin`:
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
export class Flight extends Model {
|
|
344
|
+
protected override fillable = ['name']
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Other attributes are left out without an error. A model with neither `fillable` nor a `guarded` of its own takes none,
|
|
349
|
+
and `create()` with attributes throws a `MassAssignmentError`. `guarded` lists the attributes that can't be set, in
|
|
350
|
+
place of `fillable`; `guarded = []` lets every attribute through.
|
|
351
|
+
|
|
352
|
+
`forceFill()` and `forceCreate()` set every attribute you give, and `Model.unguarded()` runs a function with the check
|
|
353
|
+
off, as your seeders do.
|
|
354
|
+
|
|
355
|
+
### Upserts
|
|
356
|
+
|
|
357
|
+
`upsert()` on a query inserts the rows, and updates those that exist, as on the [query builder](queries.md#upserts).
|
|
358
|
+
|
|
359
|
+
## Deleting models
|
|
360
|
+
|
|
361
|
+
`delete()` deletes a model:
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
const flight = await Flight.findOrFail(1)
|
|
365
|
+
|
|
366
|
+
await flight.delete()
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
`destroy()` deletes by key. It reads each model and deletes it, so their events fire:
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
await Flight.destroy(1)
|
|
373
|
+
|
|
374
|
+
await Flight.destroy(1, 2, 3)
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
A query's `delete()` deletes the rows directly, without events:
|
|
378
|
+
|
|
379
|
+
```ts
|
|
380
|
+
const deleted = await Flight.where('active', 0).delete()
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### Soft deleting
|
|
384
|
+
|
|
385
|
+
A soft-deleted model stays in its table, with a `deleted_at` date, and queries leave it out. Turn it on with
|
|
386
|
+
`softDeletes = true`, and give the table a `deleted_at` column with `table.softDeletes()` in a migration:
|
|
387
|
+
|
|
388
|
+
```ts title="app/Models/Flight.ts"
|
|
389
|
+
import { Model } from '@orieljs/framework'
|
|
390
|
+
|
|
391
|
+
export class Flight extends Model {
|
|
392
|
+
protected override softDeletes = true
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
`delete()` then sets `deleted_at`, `trashed()` tells you whether a model is soft-deleted, `restore()` brings it back,
|
|
397
|
+
and `forceDelete()` deletes it for good:
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
const flight = await Flight.findOrFail(1)
|
|
401
|
+
|
|
402
|
+
await flight.delete()
|
|
403
|
+
|
|
404
|
+
if (flight.trashed()) {
|
|
405
|
+
await flight.restore()
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
await flight.forceDelete()
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
> [!NOTE]
|
|
412
|
+
> Laravel's soft deletes are a trait, `SoftDeletes`. Oriel's are a setting of the model, `softDeletes = true`.
|
|
413
|
+
|
|
414
|
+
### Querying soft deleted models
|
|
415
|
+
|
|
416
|
+
`withTrashed()` includes soft-deleted models in a query, and `onlyTrashed()` returns only them:
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
const flights = await Flight.withTrashed().where('account_id', 1).get()
|
|
420
|
+
|
|
421
|
+
const trashed = await Flight.onlyTrashed().get()
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
> [!NOTE]
|
|
425
|
+
> Laravel's prunable models (`Prunable`, `model:prune`) and `replicate()` aren't in Oriel.
|
|
426
|
+
|
|
427
|
+
## Query scopes
|
|
428
|
+
|
|
429
|
+
### Global scopes
|
|
430
|
+
|
|
431
|
+
A global scope adds a condition to every query of a model. Add one in the model's static `booted()`, with a name and
|
|
432
|
+
a function, or with a class that has an `apply()` method:
|
|
433
|
+
|
|
434
|
+
```ts title="app/Models/Flight.ts"
|
|
435
|
+
import { Model } from '@orieljs/framework'
|
|
436
|
+
|
|
437
|
+
export class Flight extends Model {
|
|
438
|
+
static override booted(): void {
|
|
439
|
+
this.addGlobalScope('active', (query) => {
|
|
440
|
+
query.where('active', 1)
|
|
441
|
+
})
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
`withoutGlobalScope('active')` leaves one out of a query, and `withoutGlobalScopes()` all of them:
|
|
447
|
+
|
|
448
|
+
```ts
|
|
449
|
+
const flights = await Flight.withoutGlobalScope('active').get()
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### Local scopes
|
|
453
|
+
|
|
454
|
+
A local scope is a static method whose name starts with `scope`. It receives the query, and the rest of the
|
|
455
|
+
arguments:
|
|
456
|
+
|
|
457
|
+
```ts title="app/Models/Post.ts"
|
|
458
|
+
import { Model, type Builder } from '@orieljs/framework'
|
|
459
|
+
|
|
460
|
+
export class Post extends Model {
|
|
461
|
+
static scopePublished(query: Builder<Post>) {
|
|
462
|
+
return query.whereNotNull('published_at')
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
Call it without the prefix, on the model or on a query:
|
|
468
|
+
|
|
469
|
+
```ts
|
|
470
|
+
const posts = await Post.published().orderBy('created_at').get()
|
|
471
|
+
|
|
472
|
+
const recent = await Post.where('author_id', 1).published().get()
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
`model:types` adds the scopes to the model's types, so `Post.published()` is typed.
|
|
476
|
+
|
|
477
|
+
## Comparing models
|
|
478
|
+
|
|
479
|
+
`is()` tells you whether two models are the same row: the same key, table and connection. `isNot()` is its
|
|
480
|
+
opposite:
|
|
481
|
+
|
|
482
|
+
```ts
|
|
483
|
+
declare const post: Post
|
|
484
|
+
declare const another: Post
|
|
485
|
+
// ---cut---
|
|
486
|
+
if (post.is(another)) {
|
|
487
|
+
// ...
|
|
488
|
+
}
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
## Events
|
|
492
|
+
|
|
493
|
+
Models dispatch events as they are created, updated, saved, deleted and restored, which you can listen for with
|
|
494
|
+
closures or observers: see [Observers](eloquent-observers.md).
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Encryption
|
|
2
|
+
|
|
3
|
+
Encrypt values your app stores or sends, such as another service's API token, with your app's key, and decrypt them
|
|
4
|
+
when you need them back.
|
|
5
|
+
|
|
6
|
+
## Introduction
|
|
7
|
+
|
|
8
|
+
Oriel's encrypter uses AES-256 or AES-128, through Node's own `crypto` module. Every value it encrypts is
|
|
9
|
+
authenticated, so a value that was changed after it was encrypted can't be decrypted. Your app's cookies, its
|
|
10
|
+
sessions when `session.encrypt` is on, and the `Crypt` facade all use it.
|
|
11
|
+
|
|
12
|
+
## Configuration
|
|
13
|
+
|
|
14
|
+
The encrypter's key is `key` in `config/app.ts`, which reads `APP_KEY` from `.env`. The installer generates it, and
|
|
15
|
+
`node oriel key:generate` writes a new one: a random key for your cipher, written as `base64:` and its bytes (see
|
|
16
|
+
[The application key](configuration.md#the-application-key)).
|
|
17
|
+
|
|
18
|
+
`cipher` in `config/app.ts` names the cipher, `AES-256-GCM` in a new app. `AES-128-GCM`, `AES-256-CBC` and `AES-128-CBC`
|
|
19
|
+
are the others, in upper or lower case. The key must have the length the cipher takes: 32 bytes for AES-256 and 16 for
|
|
20
|
+
AES-128.
|
|
21
|
+
|
|
22
|
+
> [!NOTE]
|
|
23
|
+
> Laravel's default cipher is AES-256-CBC, which signs each value with an HMAC. Oriel's is AES-256-GCM, which
|
|
24
|
+
> authenticates each value itself. Both are supported, and both encrypt a value in the same format as Laravel, so a
|
|
25
|
+
> value encrypted with `encryptString()` by a Laravel app with the same key and cipher decrypts in Oriel, and the
|
|
26
|
+
> other way round.
|
|
27
|
+
|
|
28
|
+
An app without a key fails the first time it needs the encrypter, such as on its first request to a page of the `web`
|
|
29
|
+
group, whose cookies it encrypts, with "No application encryption key has been specified.".
|
|
30
|
+
|
|
31
|
+
### Gracefully rotating encryption keys
|
|
32
|
+
|
|
33
|
+
A new key can't decrypt what the old one encrypted, so changing it signs every user out, as their session cookies no
|
|
34
|
+
longer decrypt, and makes every value you encrypted with the old key unreadable. List your previous keys in
|
|
35
|
+
`APP_PREVIOUS_KEYS`, separated by commas:
|
|
36
|
+
|
|
37
|
+
```dotenv
|
|
38
|
+
APP_KEY="base64:J63qRTDLub5NuZvP+kb8YIorGS6qFYHKVo6u7179stY="
|
|
39
|
+
APP_PREVIOUS_KEYS="base64:2nLsGFGzyoae2ax3EF2Lyq/hH6QghBGLIq5uL+Gp8/w="
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Oriel then encrypts with the current key only, and decrypts with the current key or any previous one. Your users stay
|
|
43
|
+
signed in, and the values you stored stay readable, while you encrypt them again with the new key.
|
|
44
|
+
|
|
45
|
+
## Using the encrypter
|
|
46
|
+
|
|
47
|
+
#### Encrypting a value
|
|
48
|
+
|
|
49
|
+
The `Crypt` facade's `encryptString()` encrypts a string:
|
|
50
|
+
|
|
51
|
+
```ts title="app/Http/Controllers/DigitalOceanTokenController.ts"
|
|
52
|
+
import { Crypt, redirect, request } from '@orieljs/framework'
|
|
53
|
+
import type { User } from '../../Models/User'
|
|
54
|
+
import { Controller } from './Controller'
|
|
55
|
+
|
|
56
|
+
export class DigitalOceanTokenController extends Controller {
|
|
57
|
+
/** Store a DigitalOcean API token for the user. */
|
|
58
|
+
async store() {
|
|
59
|
+
const user = (await request().user<User>())!
|
|
60
|
+
|
|
61
|
+
await user.forceFill({ token: Crypt.encryptString(request().string('token').toString()) }).save()
|
|
62
|
+
|
|
63
|
+
return redirect('/secrets')
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`encrypt()` takes any value that JSON can hold, such as an object, and turns it into JSON before it encrypts it. The
|
|
69
|
+
`encrypt()` and `decrypt()` helpers do the same as the facade's methods:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const payload = Crypt.encrypt({ userId: 1, plan: 'pro' })
|
|
73
|
+
|
|
74
|
+
const sameKind = encrypt({ userId: 1, plan: 'pro' })
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
> [!NOTE]
|
|
78
|
+
> Laravel's `encrypt()` turns a value into a PHP string with `serialize()`. Oriel's uses JSON, so a value from
|
|
79
|
+
> `encrypt()` doesn't decrypt in the other framework; `encryptString()` values do. As JSON, a `Date` comes back as its
|
|
80
|
+
> ISO string.
|
|
81
|
+
|
|
82
|
+
#### Decrypting a value
|
|
83
|
+
|
|
84
|
+
`decryptString()` decrypts what `encryptString()` encrypted, and `decrypt()` what `encrypt()` did, giving back the
|
|
85
|
+
value. A value that can't be decrypted, because it was changed, was encrypted with another key, or isn't a payload at
|
|
86
|
+
all, throws a `DecryptError`:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
declare const encryptedValue: string
|
|
90
|
+
// ---cut---
|
|
91
|
+
try {
|
|
92
|
+
const decrypted = Crypt.decryptString(encryptedValue)
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (!(error instanceof DecryptError)) throw error
|
|
95
|
+
|
|
96
|
+
// ...
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`decrypt()` takes the type of the value it gives back, which is a cast, not a check:
|
|
101
|
+
`Crypt.decrypt<{ userId: number; plan: string }>(payload)`.
|
|
102
|
+
|
|
103
|
+
The encrypter's methods are synchronous, and the facade and helpers need your app to be running, as in a request, a
|
|
104
|
+
job or a command.
|