@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
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
# Database: migrations
|
|
2
|
+
|
|
3
|
+
Migrations are your database's version control: each one changes the schema, in code, so every copy of your app,
|
|
4
|
+
your colleagues' and your servers', builds the same tables.
|
|
5
|
+
|
|
6
|
+
## Introduction
|
|
7
|
+
|
|
8
|
+
A migration is a class with an `up()` method, which changes the database, and a `down()` method, which undoes it.
|
|
9
|
+
They describe tables with the `Schema` facade, which writes the SQL for SQLite, PostgreSQL and MySQL. Your migrations
|
|
10
|
+
are in `database/migrations`, and a new app has five: its users, sessions and password reset tokens, its cache, its
|
|
11
|
+
jobs, its personal access tokens and its notifications.
|
|
12
|
+
|
|
13
|
+
## Generating migrations
|
|
14
|
+
|
|
15
|
+
`make:migration` writes a migration in `database/migrations`. The name of the file starts with the date and time, so
|
|
16
|
+
the migrations run in the order you write them:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
node oriel make:migration create_flights_table
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
A name such as `create_flights_table` writes a migration that creates the `flights` table, and one such as
|
|
23
|
+
`add_votes_to_users_table` a migration that changes `users`. `--create=flights` and `--table=users` name the table
|
|
24
|
+
yourself, and `--path` writes the file in another folder.
|
|
25
|
+
|
|
26
|
+
## Migration structure
|
|
27
|
+
|
|
28
|
+
A migration's file default-exports the class. `up()` adds tables, columns or indexes; `down()` reverses what `up()`
|
|
29
|
+
did:
|
|
30
|
+
|
|
31
|
+
```ts title="database/migrations/2026_10_20_000000_create_flights_table.ts"
|
|
32
|
+
import { Migration, Schema } from '@orieljs/framework'
|
|
33
|
+
|
|
34
|
+
export default class extends Migration {
|
|
35
|
+
/** Run the migrations. */
|
|
36
|
+
async up() {
|
|
37
|
+
await Schema.create('flights', (table) => {
|
|
38
|
+
table.id()
|
|
39
|
+
table.string('name')
|
|
40
|
+
table.string('airline')
|
|
41
|
+
table.timestamps()
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Reverse the migrations. */
|
|
46
|
+
async down() {
|
|
47
|
+
await Schema.dropIfExists('flights')
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
#### Setting the migration connection
|
|
53
|
+
|
|
54
|
+
A migration runs on the default connection, unless its `connection` names another:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
export default class extends Migration {
|
|
58
|
+
override connection = 'pgsql'
|
|
59
|
+
|
|
60
|
+
async up() {
|
|
61
|
+
// ...
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async down() {
|
|
65
|
+
// ...
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
#### Migrations in a transaction
|
|
71
|
+
|
|
72
|
+
On PostgreSQL and SQLite, each migration runs in a transaction, so one that fails halfway leaves nothing behind.
|
|
73
|
+
MySQL can't roll back schema changes, so there a failed migration may leave part of its work. Set
|
|
74
|
+
`withinTransaction = false` on a migration that mustn't run in a transaction.
|
|
75
|
+
|
|
76
|
+
## Running migrations
|
|
77
|
+
|
|
78
|
+
`migrate` runs the migrations that haven't run yet:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
node oriel migrate
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`migrate:status` shows which have run. `migrate` records each one in the `migrations` table, which it creates the first
|
|
85
|
+
time, in a numbered batch: the migrations of one `migrate` are a batch, or one batch each with `--step`.
|
|
86
|
+
|
|
87
|
+
When it finishes, outside production and in an app with a `types` folder, `migrate` writes `types/models.d.ts` again, so
|
|
88
|
+
your models' types follow your tables (see the `model:types` command).
|
|
89
|
+
|
|
90
|
+
#### Forcing migrations to run in production
|
|
91
|
+
|
|
92
|
+
In production, `migrate` and the other commands that change your schema stop, unless you pass `--force`:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
node oriel migrate --force
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Rolling back migrations
|
|
99
|
+
|
|
100
|
+
`migrate:rollback` undoes the last batch, running each migration's `down()`. `--step` undoes that many migrations
|
|
101
|
+
instead, and `--batch` a batch by its number:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
node oriel migrate:rollback
|
|
105
|
+
|
|
106
|
+
node oriel migrate:rollback --step=5
|
|
107
|
+
|
|
108
|
+
node oriel migrate:rollback --batch=3
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`migrate:reset` undoes every migration.
|
|
112
|
+
|
|
113
|
+
#### Roll back and migrate using a single command
|
|
114
|
+
|
|
115
|
+
`migrate:refresh` undoes every migration, then runs them all again; `--step` undoes and runs again only that many.
|
|
116
|
+
`--seed` runs your seeders after it:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
node oriel migrate:refresh
|
|
120
|
+
|
|
121
|
+
node oriel migrate:refresh --seed
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
#### Drop all tables and migrate
|
|
125
|
+
|
|
126
|
+
`migrate:fresh` drops every table, without running any `down()`, then runs every migration:
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
node oriel migrate:fresh
|
|
130
|
+
|
|
131
|
+
node oriel migrate:fresh --seed
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
> [!WARNING]
|
|
135
|
+
> `migrate:fresh` drops every table of the connection, those your migrations didn't make included.
|
|
136
|
+
|
|
137
|
+
Each command takes `--database` to run on another connection, and `--path` to run the migrations of another folder.
|
|
138
|
+
|
|
139
|
+
## Tables
|
|
140
|
+
|
|
141
|
+
### Creating tables
|
|
142
|
+
|
|
143
|
+
`Schema.create()` takes the table's name, and a function that defines its columns:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
await Schema.create('users', (table) => {
|
|
147
|
+
table.id()
|
|
148
|
+
table.string('name')
|
|
149
|
+
table.string('email')
|
|
150
|
+
table.timestamps()
|
|
151
|
+
})
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
#### Checking for table / column existence
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
if (await Schema.hasTable('users')) {
|
|
158
|
+
// The "users" table exists...
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (await Schema.hasColumn('users', 'email')) {
|
|
162
|
+
// The "users" table exists and has an "email" column...
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`hasColumns()` checks a list of columns, and `hasIndex()` an index.
|
|
167
|
+
|
|
168
|
+
#### Database connection
|
|
169
|
+
|
|
170
|
+
`Schema.connection()` works on another connection:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
await Schema.connection('sqlite').create('users', (table) => {
|
|
174
|
+
table.id()
|
|
175
|
+
})
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Updating tables
|
|
179
|
+
|
|
180
|
+
`Schema.table()` changes a table that exists:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
await Schema.table('users', (table) => {
|
|
184
|
+
table.integer('votes')
|
|
185
|
+
})
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Renaming / dropping tables
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
await Schema.rename('posts', 'articles')
|
|
192
|
+
|
|
193
|
+
await Schema.drop('users')
|
|
194
|
+
|
|
195
|
+
await Schema.dropIfExists('users')
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Columns
|
|
199
|
+
|
|
200
|
+
### Creating columns
|
|
201
|
+
|
|
202
|
+
Inside `Schema.table()`, each column method adds a column:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
await Schema.table('users', (table) => {
|
|
206
|
+
table.integer('votes')
|
|
207
|
+
})
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Available column types
|
|
211
|
+
|
|
212
|
+
| Method | Column |
|
|
213
|
+
| --- | --- |
|
|
214
|
+
| `id()` | An auto-incrementing `bigint` primary key, `id` |
|
|
215
|
+
| `increments('id')`, `bigIncrements('id')` | An auto-incrementing primary key |
|
|
216
|
+
| `integer()`, `bigInteger()`, `mediumInteger()`, `smallInteger()`, `tinyInteger()` | Integers |
|
|
217
|
+
| `unsignedInteger()`, `unsignedBigInteger()`, `unsignedSmallInteger()`, `unsignedTinyInteger()` | Unsigned integers |
|
|
218
|
+
| `foreignId('user_id')` | An unsigned `bigint` for a foreign key (see [Foreign key constraints](#foreign-key-constraints)) |
|
|
219
|
+
| `decimal('amount', 8, 2)` | A fixed-point number, with its precision and scale |
|
|
220
|
+
| `float('amount', 53)`, `double('amount')` | Floating-point numbers |
|
|
221
|
+
| `boolean('confirmed')` | A boolean |
|
|
222
|
+
| `string('name', 100)` | A `varchar`, 255 characters unless you give a length |
|
|
223
|
+
| `char('code', 4)` | A fixed-length string |
|
|
224
|
+
| `text()`, `mediumText()`, `longText()` | Text |
|
|
225
|
+
| `json()`, `jsonb()` | JSON (`jsonb` on PostgreSQL) |
|
|
226
|
+
| `uuid('id')`, `ulid('id')` | A UUID or a ULID |
|
|
227
|
+
| `binary('data')` | Binary data |
|
|
228
|
+
| `enum('difficulty', ['easy', 'hard'])` | One of a list of values |
|
|
229
|
+
| `date()`, `dateTime()`, `time()`, `timestamp()` | Dates and times, with an optional precision |
|
|
230
|
+
| `timestamps()` | Nullable `created_at` and `updated_at` timestamps |
|
|
231
|
+
| `nullableTimestamps()` | The same |
|
|
232
|
+
| `softDeletes()` | A nullable `deleted_at` timestamp, for soft deletes |
|
|
233
|
+
| `rememberToken()` | A nullable `remember_token`, for "Remember me" |
|
|
234
|
+
| `morphs('taggable')`, `nullableMorphs('taggable')` | `taggable_id` and `taggable_type`, for a polymorphic relation |
|
|
235
|
+
|
|
236
|
+
SQLite stores every integer type as `integer`, and JSON as text.
|
|
237
|
+
|
|
238
|
+
> [!NOTE]
|
|
239
|
+
> Laravel's `foreignIdFor()`, `foreignUuid()`, `uuidMorphs()`, the time zone columns (`timestampTz()` and the like),
|
|
240
|
+
> `year()`, `set()`, `ipAddress()`, `macAddress()`, generated columns (`virtualAs()`) and a column's own charset or
|
|
241
|
+
> collation aren't in Oriel. `timestamps()` returns nothing, so it can't take modifiers.
|
|
242
|
+
|
|
243
|
+
### Column modifiers
|
|
244
|
+
|
|
245
|
+
Modifiers follow the column:
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
await Schema.table('users', (table) => {
|
|
249
|
+
table.string('email').nullable()
|
|
250
|
+
})
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
| Modifier | Description |
|
|
254
|
+
| --- | --- |
|
|
255
|
+
| `nullable()` | The column takes `NULL` |
|
|
256
|
+
| `default(value)` | The column's default value; `DB.raw()` writes an expression |
|
|
257
|
+
| `unsigned()` | An unsigned integer (MySQL) |
|
|
258
|
+
| `autoIncrement()` | An auto-incrementing integer, the primary key |
|
|
259
|
+
| `primary()`, `unique()`, `index()` | An index on the column (see [Indexes](#indexes)) |
|
|
260
|
+
| `useCurrent()` | A timestamp column defaults to the current time |
|
|
261
|
+
| `useCurrentOnUpdate()` | A timestamp column takes the current time when the row changes (MySQL) |
|
|
262
|
+
| `comment('...')` | A comment on the column (MySQL and PostgreSQL) |
|
|
263
|
+
| `after('column')`, `first()` | Where the column goes (MySQL) |
|
|
264
|
+
|
|
265
|
+
#### Default expressions
|
|
266
|
+
|
|
267
|
+
A default given with `DB.raw()` is written as SQL, so it can call a function:
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
await Schema.create('flights', (table) => {
|
|
271
|
+
table.id()
|
|
272
|
+
table.json('movies').default(DB.raw('(JSON_ARRAY())'))
|
|
273
|
+
table.timestamp('created_at').default(DB.raw('CURRENT_TIMESTAMP'))
|
|
274
|
+
})
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Modifying columns
|
|
278
|
+
|
|
279
|
+
`change()` gives an existing column a new definition. The column becomes exactly what you write, so repeat every
|
|
280
|
+
modifier you keep:
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
await Schema.table('users', (table) => {
|
|
284
|
+
table.string('name', 50).nullable().change()
|
|
285
|
+
})
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Here `name` becomes a nullable `varchar(50)`; without `nullable()`, it would become `NOT NULL`, which fails while a row
|
|
289
|
+
holds `NULL`.
|
|
290
|
+
|
|
291
|
+
In one `Schema.table()`, changes run after the columns you add and before everything else, such as renames, indexes
|
|
292
|
+
and drops: to change a column you rename, rename it in a `Schema.table()` of its own first.
|
|
293
|
+
|
|
294
|
+
### Renaming columns
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
await Schema.table('users', (table) => {
|
|
298
|
+
table.renameColumn('from', 'to')
|
|
299
|
+
})
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### Dropping columns
|
|
303
|
+
|
|
304
|
+
`dropColumn()` takes a column, or a list of them:
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
await Schema.table('users', (table) => {
|
|
308
|
+
table.dropColumn(['votes', 'avatar', 'location'])
|
|
309
|
+
})
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
`dropTimestamps()`, `dropSoftDeletes()`, `dropRememberToken()` and `dropMorphs('taggable')` drop the columns their
|
|
313
|
+
creating method added.
|
|
314
|
+
|
|
315
|
+
## Indexes
|
|
316
|
+
|
|
317
|
+
### Creating indexes
|
|
318
|
+
|
|
319
|
+
`unique()` after a column makes it unique, and `index()` indexes it:
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
await Schema.table('users', (table) => {
|
|
323
|
+
table.string('email').unique()
|
|
324
|
+
})
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
On the table, they take one column or a list, for a compound index, and a name:
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
await Schema.table('users', (table) => {
|
|
331
|
+
table.index(['account_id', 'created_at'])
|
|
332
|
+
table.unique('email', 'unique_email')
|
|
333
|
+
table.primary(['id', 'parent_id'])
|
|
334
|
+
})
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
An index without a name is named after the table, the columns and its kind: `users_email_unique`.
|
|
338
|
+
|
|
339
|
+
### Dropping indexes
|
|
340
|
+
|
|
341
|
+
`dropUnique()` and `dropIndex()` take the index's name, or its columns, from which the name is made; `dropPrimary()`
|
|
342
|
+
takes the name, `{table}_pkey` unless you give one:
|
|
343
|
+
|
|
344
|
+
```ts
|
|
345
|
+
await Schema.table('users', (table) => {
|
|
346
|
+
table.dropUnique('users_email_unique')
|
|
347
|
+
table.dropIndex(['account_id', 'created_at'])
|
|
348
|
+
})
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Foreign key constraints
|
|
352
|
+
|
|
353
|
+
`foreign()` adds a foreign key: the column, the column it refers to, and the table:
|
|
354
|
+
|
|
355
|
+
```ts
|
|
356
|
+
await Schema.table('posts', (table) => {
|
|
357
|
+
table.unsignedBigInteger('user_id')
|
|
358
|
+
|
|
359
|
+
table.foreign('user_id').references('id').on('users')
|
|
360
|
+
})
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
`foreignId()` with `constrained()` does both in one line: `constrained()` guesses the table from the column's name,
|
|
364
|
+
`users` for `user_id`, or takes it:
|
|
365
|
+
|
|
366
|
+
```ts
|
|
367
|
+
await Schema.table('posts', (table) => {
|
|
368
|
+
table.foreignId('user_id').constrained()
|
|
369
|
+
|
|
370
|
+
table.foreignId('author_id').constrained('users')
|
|
371
|
+
})
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
The actions on delete and update follow:
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
await Schema.table('posts', (table) => {
|
|
378
|
+
table.foreignId('user_id').constrained().cascadeOnDelete()
|
|
379
|
+
})
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
| Method | Action |
|
|
383
|
+
| --- | --- |
|
|
384
|
+
| `cascadeOnDelete()`, `cascadeOnUpdate()` | Delete or update the child rows too |
|
|
385
|
+
| `restrictOnDelete()`, `restrictOnUpdate()` | Refuse the delete or the update |
|
|
386
|
+
| `nullOnDelete()`, `nullOnUpdate()` | Set the child's column to `NULL` |
|
|
387
|
+
| `noActionOnDelete()`, `noActionOnUpdate()` | Leave the check to the end of the statement |
|
|
388
|
+
|
|
389
|
+
`dropForeign()` drops a foreign key, given its columns: `table.dropForeign(['user_id'])`.
|
|
390
|
+
`dropConstrainedForeignId('user_id')` drops the key and the column.
|
|
391
|
+
|
|
392
|
+
#### Toggling foreign key constraints
|
|
393
|
+
|
|
394
|
+
```ts
|
|
395
|
+
await Schema.enableForeignKeyConstraints()
|
|
396
|
+
|
|
397
|
+
await Schema.disableForeignKeyConstraints()
|
|
398
|
+
|
|
399
|
+
await Schema.withoutForeignKeyConstraints(async () => {
|
|
400
|
+
// Constraints disabled within this closure...
|
|
401
|
+
})
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
## SQLite and schema changes
|
|
405
|
+
|
|
406
|
+
SQLite can't change a column, or add or drop a primary or foreign key, on a table that exists. Oriel does these as
|
|
407
|
+
Laravel does: it builds the table again with the new definition, copies the rows over, and drops the old one. You
|
|
408
|
+
write the migration as for any database, with `Schema.table()`.
|
|
409
|
+
|
|
410
|
+
Each migration on SQLite runs in a transaction with foreign keys turned off, as SQLite asks for schema changes, and
|
|
411
|
+
checks every foreign key before it commits: a migration that leaves a row pointing at a missing parent fails, and
|
|
412
|
+
nothing of it is kept. With foreign keys off, `ON DELETE` and `ON UPDATE` actions don't run inside the migration, so
|
|
413
|
+
change the child rows yourself first, or set `withinTransaction = false`.
|
|
414
|
+
|
|
415
|
+
> [!NOTE]
|
|
416
|
+
> Laravel's `schema:dump`, `db:wipe`, `migrate --pretend`, full text indexes and `renameIndex()` aren't in Oriel.
|
package/docs/mocking.md
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# Fakes
|
|
2
|
+
|
|
3
|
+
A fake stands in for a service in your tests: it records what your code asks of it, does nothing else, and lets you
|
|
4
|
+
check what was asked. Oriel has fakes for the bus, events, mail, notifications, queues and storage.
|
|
5
|
+
|
|
6
|
+
## Introduction
|
|
7
|
+
|
|
8
|
+
`Mail.fake()` and the other `fake()` methods put a fake in place of the service, for the rest of the test, and return
|
|
9
|
+
it. Check your code's work on the fake they return:
|
|
10
|
+
|
|
11
|
+
```ts title="tests/Feature/OrderShipment.test.ts"
|
|
12
|
+
import { Mail } from '@orieljs/framework'
|
|
13
|
+
import { test } from 'vitest'
|
|
14
|
+
import { OrderShipped } from '../../app/Mail/OrderShipped'
|
|
15
|
+
import { TestCase } from '../TestCase'
|
|
16
|
+
|
|
17
|
+
const t = TestCase.boot()
|
|
18
|
+
|
|
19
|
+
test('orders can be shipped', async () => {
|
|
20
|
+
const mail = Mail.fake()
|
|
21
|
+
|
|
22
|
+
await t.post('/orders/1/shipment')
|
|
23
|
+
|
|
24
|
+
mail.assertSent(OrderShipped)
|
|
25
|
+
})
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Each test has a new application, so a fake ends with its test.
|
|
29
|
+
|
|
30
|
+
> [!NOTE]
|
|
31
|
+
> Laravel's facades also have Mockery's `shouldReceive()`, `spy()` and `partialMock()`, and its tests have
|
|
32
|
+
> `$this->mock()`. Oriel has none of them: for a service without a fake, put a stand-in of your own in its place with
|
|
33
|
+
> its facade's `swap()`, or `t.app.instance(Service, standIn)`. Vitest's `vi.fn()` and `vi.spyOn()` work on any
|
|
34
|
+
> object. [Facades](facades.md#facades-vs-dependency-injection) shows `swap()`.
|
|
35
|
+
|
|
36
|
+
## Bus fake
|
|
37
|
+
|
|
38
|
+
`Bus.fake()` records the jobs your code dispatches, and runs none of them:
|
|
39
|
+
|
|
40
|
+
```ts title="tests/Feature/Podcasts.test.ts"
|
|
41
|
+
import { Bus } from '@orieljs/framework'
|
|
42
|
+
import { RefreshDatabase } from '@orieljs/framework/testing'
|
|
43
|
+
import { test } from 'vitest'
|
|
44
|
+
import { OptimizePodcast } from '../../app/Jobs/OptimizePodcast'
|
|
45
|
+
import { ProcessPodcast } from '../../app/Jobs/ProcessPodcast'
|
|
46
|
+
import { TestCase } from '../TestCase'
|
|
47
|
+
|
|
48
|
+
const t = TestCase.boot({ uses: [RefreshDatabase] })
|
|
49
|
+
|
|
50
|
+
test('podcasts can be published', async () => {
|
|
51
|
+
const bus = Bus.fake()
|
|
52
|
+
|
|
53
|
+
await t.post('/podcasts', { title: 'Episode 1' })
|
|
54
|
+
|
|
55
|
+
bus.assertDispatched(ProcessPodcast, (job) => job.podcast.title === 'Episode 1')
|
|
56
|
+
bus.assertDispatched(ProcessPodcast, 1)
|
|
57
|
+
bus.assertNotDispatched(OptimizePodcast)
|
|
58
|
+
})
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The second argument is a function that receives each job and returns `true` for the one you expect, or a number of
|
|
62
|
+
dispatches. Give `fake()` a list of jobs to fake only those, and the others run as usual: `Bus.fake([ProcessPodcast])`.
|
|
63
|
+
`BusFake.fakeExcept([ShipOrder])`, from `@orieljs/framework/testing`, fakes all but those.
|
|
64
|
+
|
|
65
|
+
| Assertion | Passes when |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| `assertDispatched(job, filterOrCount?)`, `assertDispatchedTimes(job, times)` | The job was dispatched, matching the filter, or that many times |
|
|
68
|
+
| `assertNotDispatched(job, filter?)`, `assertNothingDispatched()` | It wasn't, or no job was |
|
|
69
|
+
| `assertDispatchedSync(job)`, `assertNotDispatchedSync(job)` | It was dispatched with `dispatchSync()`, or wasn't |
|
|
70
|
+
| `assertDispatchedAfterResponse(job)`, `assertNotDispatchedAfterResponse(job)` | It was dispatched to run after the response, or wasn't |
|
|
71
|
+
| `assertDispatchedWithoutChain(job)` | It was dispatched with no chain |
|
|
72
|
+
|
|
73
|
+
`bus.dispatched(ProcessPodcast)` returns the dispatched jobs, to check them with Vitest's `expect()`, and
|
|
74
|
+
`dispatchedSync()` and `dispatchedAfterResponse()` the others.
|
|
75
|
+
|
|
76
|
+
### Job chains
|
|
77
|
+
|
|
78
|
+
`assertChained()` checks a chain, by class or by instance:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { BusFake } from '@orieljs/framework/testing'
|
|
82
|
+
import { OptimizePodcast } from './app/Jobs/OptimizePodcast'
|
|
83
|
+
import { ProcessPodcast } from './app/Jobs/ProcessPodcast'
|
|
84
|
+
import { PublishPodcast } from './app/Jobs/PublishPodcast'
|
|
85
|
+
|
|
86
|
+
declare const bus: BusFake
|
|
87
|
+
// ---cut---
|
|
88
|
+
bus.assertChained([ProcessPodcast, OptimizePodcast, PublishPodcast])
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
An instance in the list matches a job of its class with the same fields.
|
|
92
|
+
|
|
93
|
+
### Job batches
|
|
94
|
+
|
|
95
|
+
`assertBatched()` receives each batch, with its `name` and `jobs`, and passes when the function returns `true` for one:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { BusFake } from '@orieljs/framework/testing'
|
|
99
|
+
|
|
100
|
+
declare const bus: BusFake
|
|
101
|
+
// ---cut---
|
|
102
|
+
bus.assertBatched((batch) => batch.name === 'import-csv' && batch.jobs.length === 10)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`assertBatchCount(count)` checks the number of batches, and `assertNothingBatched()` that there are none.
|
|
106
|
+
|
|
107
|
+
## Event fake
|
|
108
|
+
|
|
109
|
+
`Event.fake()` records the events your code dispatches, and runs none of their listeners:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
// @filename: app/Events/OrderShipped.ts
|
|
113
|
+
export class OrderShipped {
|
|
114
|
+
constructor(readonly orderId: number) {}
|
|
115
|
+
}
|
|
116
|
+
// ---cut---
|
|
117
|
+
import { OrderShipped } from './app/Events/OrderShipped'
|
|
118
|
+
|
|
119
|
+
const events = Event.fake()
|
|
120
|
+
|
|
121
|
+
// Ship the order...
|
|
122
|
+
|
|
123
|
+
events.assertDispatched(OrderShipped, (event) => event.orderId === 1)
|
|
124
|
+
events.assertDispatchedTimes(OrderShipped, 1)
|
|
125
|
+
events.assertNotDispatched('order.cancelled')
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`assertNothingDispatched()` checks that no event was dispatched at all.
|
|
129
|
+
|
|
130
|
+
`assertListening(OrderShipped, SendShipmentNotification)` checks that a listener is registered for an event, and
|
|
131
|
+
`dispatched(event)` returns the dispatched events.
|
|
132
|
+
|
|
133
|
+
> [!WARNING]
|
|
134
|
+
> Model events, such as `creating` and `saved`, are events too. `Event.fake()` with no list stops them as well, so a
|
|
135
|
+
> model whose `creating` sets a value doesn't set it. Fake only the events you check: `Event.fake([OrderShipped])`.
|
|
136
|
+
|
|
137
|
+
`EventFake.fakeExcept([OrderCreated])`, from `@orieljs/framework/testing`, fakes every event but those.
|
|
138
|
+
|
|
139
|
+
## Mail fake
|
|
140
|
+
|
|
141
|
+
`Mail.fake()` records each mail your code sends or queues, and sends nothing:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import { OrderShipped } from './app/Mail/OrderShipped'
|
|
145
|
+
|
|
146
|
+
const mail = Mail.fake()
|
|
147
|
+
|
|
148
|
+
// Ship the order...
|
|
149
|
+
|
|
150
|
+
mail.assertSent(OrderShipped, (mailable, record) => record.hasTo('taylor@example.com'))
|
|
151
|
+
mail.assertSent(OrderShipped, 1)
|
|
152
|
+
mail.assertNotQueued(OrderShipped)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The filter receives the mailable and its record, which has `hasTo()`, `hasCc()`, `hasBcc()` and `hasSubject()`. A
|
|
156
|
+
mailable that would be queued counts as queued, not sent.
|
|
157
|
+
|
|
158
|
+
| Assertion | Passes when |
|
|
159
|
+
| --- | --- |
|
|
160
|
+
| `assertSent(mailable, filterOrCount?)`, `assertSentTimes(mailable, times)` | The mail was sent, matching the filter, or that many times |
|
|
161
|
+
| `assertNotSent(mailable)`, `assertNothingSent()`, `assertSentCount(count)` | It wasn't sent, no mail was, or that many were |
|
|
162
|
+
| `assertQueued(mailable, filterOrCount?)`, `assertNotQueued(mailable)` | It was queued, or wasn't |
|
|
163
|
+
| `assertNothingQueued()`, `assertQueuedCount(count)` | No mail was queued, or that many were |
|
|
164
|
+
| `assertOutgoingCount(count)`, `assertNothingOutgoing()` | That many mails were sent or queued, or none |
|
|
165
|
+
|
|
166
|
+
`mail.sent(OrderShipped)` and `mail.queued(OrderShipped)` return the mailables. Nothing is rendered, so the fake
|
|
167
|
+
doesn't check your views.
|
|
168
|
+
|
|
169
|
+
## Notification fake
|
|
170
|
+
|
|
171
|
+
`Notification.fake()` records each notification your code sends, and delivers none:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
import { User } from './app/Models/User'
|
|
175
|
+
import { InvoicePaid } from './app/Notifications/InvoicePaid'
|
|
176
|
+
|
|
177
|
+
const notifications = Notification.fake()
|
|
178
|
+
|
|
179
|
+
const user = await User.findOrFail(1)
|
|
180
|
+
|
|
181
|
+
// Pay the invoice...
|
|
182
|
+
|
|
183
|
+
notifications.assertSentTo(user, InvoicePaid, (notification, channels) => channels.includes('mail'))
|
|
184
|
+
notifications.assertNotSentTo(user, InvoicePaid, (notification) => notification.amount === '0')
|
|
185
|
+
notifications.assertCount(1)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The filter receives the notification, its channels and the notifiable. `assertSentTo()` also takes a list of
|
|
189
|
+
notifiables, which must each have the notification.
|
|
190
|
+
|
|
191
|
+
| Assertion | Passes when |
|
|
192
|
+
| --- | --- |
|
|
193
|
+
| `assertSentTo(notifiable, notification, filterOrCount?)` | The notifiable was sent the notification |
|
|
194
|
+
| `assertSentToTimes(notifiable, notification, times)` | It was sent it that many times |
|
|
195
|
+
| `assertNotSentTo(notifiable, notification, filter?)`, `assertNothingSentTo(notifiable)` | It wasn't sent it, or anything |
|
|
196
|
+
| `assertSentTimes(notification, times)`, `assertCount(count)` | That many were sent, of that class or of all |
|
|
197
|
+
| `assertNothingSent()` | No notification was sent |
|
|
198
|
+
| `assertSentOnDemand(notification, filterOrCount?)` | It was sent to a route, with `Notification.route()` |
|
|
199
|
+
|
|
200
|
+
`notifications.sent(user, InvoicePaid)` returns the notifications the user was sent.
|
|
201
|
+
|
|
202
|
+
## Queue fake
|
|
203
|
+
|
|
204
|
+
`Queue.fake()` records the jobs pushed on the queue, and runs none of them. Unlike `Bus.fake()`, it sees the queue
|
|
205
|
+
each job is pushed on:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
import { ProcessPodcast } from './app/Jobs/ProcessPodcast'
|
|
209
|
+
|
|
210
|
+
const queue = Queue.fake()
|
|
211
|
+
|
|
212
|
+
// Publish the podcast...
|
|
213
|
+
|
|
214
|
+
queue.assertPushed(ProcessPodcast, 1)
|
|
215
|
+
queue.assertPushedOn('audio', ProcessPodcast)
|
|
216
|
+
queue.assertPushed(ProcessPodcast, (job, queueName) => queueName === 'audio')
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
| Assertion | Passes when |
|
|
220
|
+
| --- | --- |
|
|
221
|
+
| `assertPushed(job, filterOrCount?)`, `assertPushedTimes(job, times)` | The job was pushed, matching the filter, or that many times |
|
|
222
|
+
| `assertPushedOn(queue, job, filter?)` | It was pushed on that queue |
|
|
223
|
+
| `assertNotPushed(job, filter?)`, `assertNothingPushed()` | It wasn't, or no job was |
|
|
224
|
+
| `assertCount(count)` | That many jobs were pushed |
|
|
225
|
+
| `assertPushedWithChain(job, chain)`, `assertPushedWithoutChain(job)` | It was pushed with that chain after it, or with none |
|
|
226
|
+
|
|
227
|
+
`queue.pushed(ProcessPodcast)` returns the pushed jobs. Give `fake()` a list of jobs to fake only those.
|
|
228
|
+
|
|
229
|
+
## Storage fake
|
|
230
|
+
|
|
231
|
+
`Storage.fake()` puts a disk in memory in place of one of yours, so a test writes no files, and returns it:
|
|
232
|
+
|
|
233
|
+
```ts title="tests/Feature/Avatar.test.ts"
|
|
234
|
+
import { Storage } from '@orieljs/framework'
|
|
235
|
+
import { FakeFile } from '@orieljs/framework/testing'
|
|
236
|
+
import { test } from 'vitest'
|
|
237
|
+
import { TestCase } from '../TestCase'
|
|
238
|
+
|
|
239
|
+
const t = TestCase.boot()
|
|
240
|
+
|
|
241
|
+
test('albums can be uploaded', async () => {
|
|
242
|
+
const disk = Storage.fake('photos')
|
|
243
|
+
|
|
244
|
+
await t.post('/photos', { photos: [FakeFile.image('photo1.jpg'), FakeFile.image('photo2.jpg')] })
|
|
245
|
+
|
|
246
|
+
await disk.assertExists(['photo1.jpg', 'photo2.jpg'])
|
|
247
|
+
await disk.assertMissing('missing.jpg')
|
|
248
|
+
await disk.assertCount('', 2)
|
|
249
|
+
})
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
With no name, it fakes the default disk. The assertions read the disk, so they are async: `await` each one.
|
|
253
|
+
|
|
254
|
+
| Assertion | Passes when |
|
|
255
|
+
| --- | --- |
|
|
256
|
+
| `assertExists(paths, contents?)` | Each file is on the disk, with those contents |
|
|
257
|
+
| `assertMissing(paths)` | No file is at the paths |
|
|
258
|
+
| `assertCount(folder, count, recursive?)` | The folder has that many files |
|
|
259
|
+
| `assertDirectoryEmpty(folder)` | The folder has no files |
|
|
260
|
+
|
|
261
|
+
## Interacting with time
|
|
262
|
+
|
|
263
|
+
`t.travelTo()`, `t.travel()` and `t.freezeTime()` move the clock your code reads. [Time travel](time-travel.md) covers
|
|
264
|
+
them.
|