@vida-global/core 2.2.0 → 2.3.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/lib/activeRecord/README.md +69 -23
- package/lib/activeRecord/baseRecord.js +48 -0
- package/lib/activeRecord/db/migration.js +2 -2
- package/lib/activeRecord/db/migrator.js +108 -23
- package/lib/activeRecord/validations.js +85 -0
- package/package.json +1 -1
- package/test/activeRecord/baseRecord.test.js +72 -0
- package/test/activeRecord/db/migrator.test.js +206 -15
- package/test/activeRecord/helpers/baseRecordMocks.js +8 -0
- package/test/activeRecord/helpers/migrator.js +17 -3
- package/test/activeRecord/helpers/validations.js +38 -0
- package/test/activeRecord/validations.test.js +117 -0
|
@@ -72,6 +72,8 @@ Wire up the migration CLI by adding scripts to `package.json`:
|
|
|
72
72
|
|
|
73
73
|
Generate a migration with `npm run db:create_migration createUsers`. Pass `-- --databaseId metrics` to scope the migration to a non-default database. Run `npm run db:migrate` to apply migrations or `npm run db:rollback` to undo the most recent one.
|
|
74
74
|
|
|
75
|
+
Migration files are named `<timestamp>_<description>.js`, with the database id appended before the extension when one is given — for example `1754500000000_createUsers.js` or `1754500000000_createUsers.metrics.js`. The leading timestamp is the version recorded in `migration_versions` and the order migrations run in, so files sort chronologically by name. Files that don't match this pattern are ignored.
|
|
76
|
+
|
|
75
77
|
```js
|
|
76
78
|
module.exports = {
|
|
77
79
|
up: async function() {
|
|
@@ -98,11 +100,11 @@ module.exports = {
|
|
|
98
100
|
Available migration helpers:
|
|
99
101
|
|
|
100
102
|
```js
|
|
101
|
-
createTable(tableName, details, { timestamps, paranoid })
|
|
103
|
+
createTable(tableName, details, { timestamps, paranoid, id })
|
|
102
104
|
dropTable(tableName)
|
|
103
105
|
addColumn(tableName, columnName, columnDetails)
|
|
104
106
|
removeColumn(tableName, columnName)
|
|
105
|
-
addIndex(tableName, fields, { concurrently, unique, name, where })
|
|
107
|
+
addIndex(tableName, fields, { concurrently, unique, name, using, where })
|
|
106
108
|
removeIndex(tableName, indexNameOrAttributes, concurrently=false)
|
|
107
109
|
renameColumn(tableName, oldName, newName)
|
|
108
110
|
changeColumn(tableName, columnName, dataTypeOrOptions)
|
|
@@ -110,10 +112,33 @@ changeColumn(tableName, columnName, dataTypeOrOptions)
|
|
|
110
112
|
|
|
111
113
|
**Conventions:**
|
|
112
114
|
|
|
113
|
-
- `createTable` adds
|
|
114
|
-
- Pass `{
|
|
115
|
+
- `createTable` adds a `BIGINT` auto-increment `id` primary key and `created_at` / `updated_at` columns by default. Pass `{ timestamps: false }` to skip the timestamps.
|
|
116
|
+
- Pass `{ id: 'uuid' }` for a UUID primary key, or `{ id: 'int' }` for a 4-byte integer one. A UUID key has no database default — models generate the value, so a record has its id before it is saved. An `id` written into the details object still wins over all of this.
|
|
117
|
+
- A `BIGINT` column reads back as a string (`'42'`, not `42`). Postgres returns 64-bit integers as text so nothing is lost above `Number.MAX_SAFE_INTEGER`, and nothing converts them on the way in.
|
|
118
|
+
- Pass `{ paranoid: true }` to `createTable` to add a nullable `deleted_at` column for soft deletes.
|
|
119
|
+
- Models over that table soft delete without any extra configuration: `initializationOptions` turns on `paranoid` for any table whose schema has `deleted_at` alongside the timestamp columns, so `destroy()` sets `deleted_at` and ordinary queries skip the row. A table created with `{ paranoid: true, timestamps: false }` has the column but does not qualify.
|
|
120
|
+
- Unique indexes on a paranoid table should be partial — `where: { deleted_at: null }` — or a soft-deleted row will still block re-creating the same one.
|
|
121
|
+
- An index created without a `name` gets `idx_<table>_<columns>`, so `addIndex('users', ['team_id', 'email'])` creates `idx_users_team_id_email`. `removeIndex` applies the same default, which means an index added by its columns can be dropped by its columns. Postgres truncates identifiers at 63 characters, so name long multi-column indexes explicitly.
|
|
115
122
|
- Column names are automatically converted to snake_case, so `teamId` in the details object becomes `team_id` in the database.
|
|
116
|
-
- Each migration runs inside a transaction.
|
|
123
|
+
- Each migration runs inside a transaction, so a migration that throws part way through leaves the database untouched.
|
|
124
|
+
|
|
125
|
+
Postgres refuses a few statements inside a transaction block — `CREATE INDEX CONCURRENTLY` and `DROP INDEX CONCURRENTLY` most of all. A migration that needs one of those opts out of the transaction entirely:
|
|
126
|
+
|
|
127
|
+
```js
|
|
128
|
+
module.exports = {
|
|
129
|
+
transactional: false,
|
|
130
|
+
|
|
131
|
+
up: async function() {
|
|
132
|
+
await this.addIndex('users', ['team_id'], { concurrently: true });
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
down: async function() {
|
|
136
|
+
await this.removeIndex('users', ['team_id'], true);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
An opted-out migration is **not** rolled back if it fails, so keep it to the single statement that needs it and put everything else in its own migration.
|
|
117
142
|
|
|
118
143
|
|
|
119
144
|
## Models
|
|
@@ -163,7 +188,7 @@ More query examples: [Sequelize model querying basics](https://sequelize.org/doc
|
|
|
163
188
|
|
|
164
189
|
### Aggregators and functions
|
|
165
190
|
|
|
166
|
-
|
|
191
|
+
The `fn` and `col` helpers are exposed on the model class:
|
|
167
192
|
|
|
168
193
|
```js
|
|
169
194
|
const rows = await User.findAll({
|
|
@@ -204,7 +229,7 @@ User.initialize();
|
|
|
204
229
|
|
|
205
230
|
## Virtual fields
|
|
206
231
|
|
|
207
|
-
Add a derived attribute by defining `_virtualGet${PropertyName}` (and optionally `_virtualSet${PropertyName}`). The framework registers a
|
|
232
|
+
Add a derived attribute by defining `_virtualGet${PropertyName}` (and optionally `_virtualSet${PropertyName}`). The framework registers a `VIRTUAL` column for it, so the property appears in `dataValues`, `toJSON()`, and `toApiResponse()`. Virtual accessors are inherited through the class hierarchy.
|
|
208
233
|
|
|
209
234
|
```js
|
|
210
235
|
class User extends ActiveRecord.BaseRecord {
|
|
@@ -257,7 +282,7 @@ This feature requires the Redis client wired up via `lib/redis`.
|
|
|
257
282
|
|
|
258
283
|
## Hooks
|
|
259
284
|
|
|
260
|
-
`BaseRecord` inherits
|
|
285
|
+
`BaseRecord` inherits a full hook system. Register a hook on the model class with `addHook`:
|
|
261
286
|
|
|
262
287
|
```js
|
|
263
288
|
User.addHook('beforeCreate', async (user, options) => {
|
|
@@ -274,7 +299,7 @@ See [Sequelize hooks](https://sequelize.org/docs/v6/other-topics/hooks/) for the
|
|
|
274
299
|
|
|
275
300
|
## Associations
|
|
276
301
|
|
|
277
|
-
Define associations using
|
|
302
|
+
Define associations using the standard association API after the model is initialized:
|
|
278
303
|
|
|
279
304
|
```js
|
|
280
305
|
User.initialize();
|
|
@@ -289,22 +314,43 @@ See [Sequelize associations](https://sequelize.org/docs/v6/core-concepts/assocs/
|
|
|
289
314
|
|
|
290
315
|
## Validators
|
|
291
316
|
|
|
292
|
-
|
|
317
|
+
Register validations with `addValidation` after the model is initialized, the same way hooks are registered. Config keys are the [standard validators](https://sequelize.org/docs/v6/core-concepts/validations-and-constraints/), plus the ones listed below:
|
|
293
318
|
|
|
294
319
|
```js
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
320
|
+
User.initialize();
|
|
321
|
+
|
|
322
|
+
User.addValidation('email', { isEmail: true, len: [1, 255] });
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
A custom validation is a function. It is called with the record as `this` and the column's value as its only argument, and fails by throwing:
|
|
326
|
+
|
|
327
|
+
```js
|
|
328
|
+
User.addValidation('team_id', {
|
|
329
|
+
matchesOrg(value) {
|
|
330
|
+
if (value && !this.org_id) throw new Error('a team member needs an org');
|
|
306
331
|
}
|
|
307
|
-
}
|
|
332
|
+
});
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Leave the column out to validate the record as a whole:
|
|
336
|
+
|
|
337
|
+
```js
|
|
338
|
+
User.addValidation({
|
|
339
|
+
adminMustHaveTeam() {
|
|
340
|
+
if (this.is_admin && !this.team_id) throw new Error('admin users must belong to a team');
|
|
341
|
+
}
|
|
342
|
+
});
|
|
308
343
|
```
|
|
309
344
|
|
|
310
|
-
|
|
345
|
+
### unique
|
|
346
|
+
|
|
347
|
+
There is no standard uniqueness validator — a `unique` column option builds an index, it does not check anything — so `addValidation` supplies one:
|
|
348
|
+
|
|
349
|
+
```js
|
|
350
|
+
Tag.addValidation('normalized_name', { unique: { scope: ['org_id'] } });
|
|
351
|
+
Account.addValidation('subdomain', { unique: true });
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
`scope` names the columns the value has to be unique *within*. The record never counts as its own duplicate, and soft-deleted rows are ignored on paranoid models. The query only runs when the column or one of its scope columns changed.
|
|
355
|
+
|
|
356
|
+
This is a read-then-check, so two simultaneous requests can both pass it. Keep a unique index on the table — the validation is there for the error message, the index is what makes it true.
|
|
@@ -7,6 +7,7 @@ const nodeUtil = require('util');
|
|
|
7
7
|
const { redisClientFactory } = require('../redis');
|
|
8
8
|
const { camelize, tableize } = require('inflection');
|
|
9
9
|
const utils = require('./utils');
|
|
10
|
+
const validations = require('./validations');
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class BaseRecord extends Model {
|
|
@@ -204,6 +205,53 @@ class BaseRecord extends Model {
|
|
|
204
205
|
}
|
|
205
206
|
|
|
206
207
|
|
|
208
|
+
/***********************************************************************************************
|
|
209
|
+
* VALIDATIONS
|
|
210
|
+
***********************************************************************************************/
|
|
211
|
+
static addValidation(column, config) {
|
|
212
|
+
if (!config) return this._addModelValidations(column);
|
|
213
|
+
|
|
214
|
+
this._assertValidatableColumn(column);
|
|
215
|
+
|
|
216
|
+
this._mergeColumnValidations(column, validations.buildColumnValidations(column, config));
|
|
217
|
+
return this;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
static _addModelValidations(config) {
|
|
222
|
+
this._assertInitialized();
|
|
223
|
+
|
|
224
|
+
this.options.validate = { ...this.options.validate, ...config };
|
|
225
|
+
return this;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
// Sequelize reads a column's validations off the prototype but rebuilds them from the attribute
|
|
230
|
+
// whenever the model is refreshed, so both have to point at the one object.
|
|
231
|
+
static _mergeColumnValidations(column, columnValidations) {
|
|
232
|
+
const attribute = this.rawAttributes[column];
|
|
233
|
+
attribute.validate = { ...attribute.validate, ...columnValidations };
|
|
234
|
+
|
|
235
|
+
this.prototype.validators[column] = attribute.validate;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
static _assertValidatableColumn(column) {
|
|
240
|
+
this._assertInitialized();
|
|
241
|
+
|
|
242
|
+
if (this.rawAttributes[column]) return;
|
|
243
|
+
|
|
244
|
+
throw new Error(`Cannot validate ${this.name}.${column}: the column is not in the schema`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
static _assertInitialized() {
|
|
249
|
+
if (this._initialized) return;
|
|
250
|
+
|
|
251
|
+
throw new Error(`${this.name} must be initialized before validations are added`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
207
255
|
/***********************************************************************************************
|
|
208
256
|
* DB CONNECTION
|
|
209
257
|
***********************************************************************************************/
|
|
@@ -101,7 +101,7 @@ function generateMigrationFile(description, databaseId) {
|
|
|
101
101
|
|
|
102
102
|
|
|
103
103
|
function fileNameForMigration(description, databaseId) {
|
|
104
|
-
const name = `${
|
|
104
|
+
const name = `${new Date().getTime()}_${description}`;
|
|
105
105
|
const dbSuffix = databaseId ? `.${databaseId}` : '';
|
|
106
106
|
return `${name}${dbSuffix}.js`;
|
|
107
107
|
}
|
|
@@ -113,7 +113,7 @@ function fileNameForMigration(description, databaseId) {
|
|
|
113
113
|
function getMigrations() {
|
|
114
114
|
const files = fs.readdirSync(utils.migrationsDirectory);
|
|
115
115
|
const migrations = files.map(f => {
|
|
116
|
-
const m = f.match(
|
|
116
|
+
const m = f.match(/^(?<version>\d+)_[^.]+(\.(?<databaseId>\w+))?\.js$/);
|
|
117
117
|
if (!m) return;
|
|
118
118
|
return {fileName: f, databaseId: m.groups.databaseId, version: m.groups.version};
|
|
119
119
|
});
|
|
@@ -3,11 +3,15 @@ const utils = require('../utils');
|
|
|
3
3
|
const { Sequelize, Op } = require('sequelize');
|
|
4
4
|
const { underscore } = require('inflection');
|
|
5
5
|
|
|
6
|
+
const ID_TYPES = ['bigint', 'int', 'uuid'];
|
|
7
|
+
const DEFAULT_ID_TYPE = 'bigint';
|
|
8
|
+
|
|
6
9
|
|
|
7
10
|
class Migrator {
|
|
8
11
|
#connection;
|
|
9
12
|
#fileName;
|
|
10
13
|
#migrationModule;
|
|
14
|
+
#transaction = null;
|
|
11
15
|
|
|
12
16
|
DataTypes = Sequelize.DataTypes;
|
|
13
17
|
Operators = Op;
|
|
@@ -22,19 +26,32 @@ class Migrator {
|
|
|
22
26
|
async run() {
|
|
23
27
|
if (!this.migrationModule.up) throw new Error('Missing migration `up`');
|
|
24
28
|
|
|
25
|
-
|
|
26
|
-
await this.#connection.queryInterface.transaction(async () => {
|
|
27
|
-
await migrator.migrationModule.up.call(migrator);
|
|
28
|
-
});
|
|
29
|
+
await this.perform(() => this.migrationModule.up.call(this));
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
async rollback() {
|
|
33
34
|
if (!this.migrationModule.down) return;
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
await this.perform(() => this.migrationModule.down.call(this));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async perform(callback) {
|
|
41
|
+
if (!this.transactional) return await callback();
|
|
42
|
+
|
|
43
|
+
await this.performInTransaction(callback);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async performInTransaction(callback) {
|
|
48
|
+
await this.#connection.queryInterface.transaction(async transaction => {
|
|
49
|
+
this.#transaction = transaction;
|
|
50
|
+
try {
|
|
51
|
+
await callback();
|
|
52
|
+
} finally {
|
|
53
|
+
this.#transaction = null;
|
|
54
|
+
}
|
|
38
55
|
});
|
|
39
56
|
}
|
|
40
57
|
|
|
@@ -58,6 +75,14 @@ class Migrator {
|
|
|
58
75
|
}
|
|
59
76
|
|
|
60
77
|
|
|
78
|
+
// Migrations opt out with `transactional: false`. Needed for statements Postgres refuses to
|
|
79
|
+
// run inside a transaction block, most notably CREATE/DROP INDEX CONCURRENTLY. An opted-out
|
|
80
|
+
// migration is not rolled back when it fails.
|
|
81
|
+
get transactional() {
|
|
82
|
+
return this.migrationModule.transactional !== false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
61
86
|
importMigrationModule() {
|
|
62
87
|
const filePath = `${utils.migrationsDirectory}/${this.#fileName}`;
|
|
63
88
|
return require(filePath);
|
|
@@ -68,7 +93,7 @@ class Migrator {
|
|
|
68
93
|
* MIGRATION ACTIONS
|
|
69
94
|
***********************************************************************************************/
|
|
70
95
|
async createTable(tableName, details, options={}) {
|
|
71
|
-
details = {...this.
|
|
96
|
+
details = {...this.idColumn(options.id), ...details};
|
|
72
97
|
if (options.timestamps !== false) {
|
|
73
98
|
details = {...details, ...this.defaultTimestampColumns};
|
|
74
99
|
}
|
|
@@ -77,7 +102,7 @@ class Migrator {
|
|
|
77
102
|
}
|
|
78
103
|
|
|
79
104
|
details = this.normalizeTableDetails(details);
|
|
80
|
-
await this.#sequelizeQueryInterface.createTable(tableName, details);
|
|
105
|
+
await this.#sequelizeQueryInterface.createTable(tableName, details, this.queryOptions);
|
|
81
106
|
}
|
|
82
107
|
|
|
83
108
|
|
|
@@ -93,13 +118,42 @@ class Migrator {
|
|
|
93
118
|
}
|
|
94
119
|
|
|
95
120
|
|
|
96
|
-
|
|
121
|
+
idColumn(idType=DEFAULT_ID_TYPE) {
|
|
122
|
+
if (!ID_TYPES.includes(idType)) {
|
|
123
|
+
throw new Error(`Unknown id type "${idType}". Use one of: ${ID_TYPES.join(', ')}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return this[`${idType}IdColumn`];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
get bigintIdColumn() {
|
|
131
|
+
return this.autoIncrementIdColumn(this.DataTypes.BIGINT);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
get intIdColumn() {
|
|
136
|
+
return this.autoIncrementIdColumn(this.DataTypes.INTEGER);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
get uuidIdColumn() {
|
|
141
|
+
return {
|
|
142
|
+
id: {
|
|
143
|
+
allowNull: false,
|
|
144
|
+
primaryKey: true,
|
|
145
|
+
type: this.DataTypes.UUID, }
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
autoIncrementIdColumn(type) {
|
|
97
151
|
return {
|
|
98
152
|
id: {
|
|
99
153
|
allowNull: false,
|
|
100
154
|
autoIncrement: true,
|
|
101
155
|
primaryKey: true,
|
|
102
|
-
type:
|
|
156
|
+
type: type, }
|
|
103
157
|
};
|
|
104
158
|
}
|
|
105
159
|
|
|
@@ -116,8 +170,6 @@ class Migrator {
|
|
|
116
170
|
}
|
|
117
171
|
|
|
118
172
|
|
|
119
|
-
// Records use soft deletes when the table has this column: initializationOptions turns paranoid
|
|
120
|
-
// on for any model whose schema includes it.
|
|
121
173
|
get deletedAtColumn() {
|
|
122
174
|
return {
|
|
123
175
|
deleted_at: {
|
|
@@ -128,25 +180,25 @@ class Migrator {
|
|
|
128
180
|
|
|
129
181
|
|
|
130
182
|
async dropTable(tableName) {
|
|
131
|
-
await this.#sequelizeQueryInterface.dropTable(tableName);
|
|
183
|
+
await this.#sequelizeQueryInterface.dropTable(tableName, this.queryOptions);
|
|
132
184
|
}
|
|
133
185
|
|
|
134
186
|
|
|
135
187
|
async addColumn(tableName, columnName, columnDetails) {
|
|
136
|
-
await this.#sequelizeQueryInterface.addColumn(tableName, columnName, columnDetails);
|
|
188
|
+
await this.#sequelizeQueryInterface.addColumn(tableName, columnName, columnDetails, this.queryOptions);
|
|
137
189
|
}
|
|
138
190
|
|
|
139
191
|
|
|
140
192
|
async removeColumn(tableName, columnName) {
|
|
141
|
-
await this.#sequelizeQueryInterface.removeColumn(tableName, columnName);
|
|
193
|
+
await this.#sequelizeQueryInterface.removeColumn(tableName, columnName, this.queryOptions);
|
|
142
194
|
}
|
|
143
195
|
|
|
144
196
|
|
|
145
|
-
async addIndex(tableName, fields, { concurrently, unique, name, using, where }) {
|
|
146
|
-
const options = {fields: fields};
|
|
197
|
+
async addIndex(tableName, fields, { concurrently, unique, name, using, where }={}) {
|
|
198
|
+
const options = {fields: fields, ...this.queryOptions};
|
|
199
|
+
options.name = name || this.defaultIndexName(tableName, fields);
|
|
147
200
|
if (concurrently) options.concurrently = true;
|
|
148
201
|
if (unique) options.unique = true;
|
|
149
|
-
if (name) options.name = name;
|
|
150
202
|
if (using) options.using = using;
|
|
151
203
|
if (where) options.where = where;
|
|
152
204
|
|
|
@@ -155,20 +207,53 @@ class Migrator {
|
|
|
155
207
|
|
|
156
208
|
|
|
157
209
|
async removeIndex(tableName, indexNameOrAttributes, concurrently=false) {
|
|
158
|
-
const options
|
|
210
|
+
const options = this.queryOptions;
|
|
159
211
|
if (concurrently) options.concurrently = true;
|
|
160
|
-
|
|
212
|
+
|
|
213
|
+
const indexName = this.indexNameFor(tableName, indexNameOrAttributes);
|
|
214
|
+
await this.#sequelizeQueryInterface.removeIndex(tableName, indexName, options);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
/***********************************************************************************************
|
|
219
|
+
* INDEX NAMING
|
|
220
|
+
***********************************************************************************************/
|
|
221
|
+
// Indexes created without an explicit name get `idx_<table>_<columns>`. removeIndex applies the
|
|
222
|
+
// same default so an index can be dropped by its columns, the way it was added.
|
|
223
|
+
indexNameFor(tableName, indexNameOrAttributes) {
|
|
224
|
+
if (typeof indexNameOrAttributes === 'string') return indexNameOrAttributes;
|
|
225
|
+
return this.defaultIndexName(tableName, indexNameOrAttributes);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
defaultIndexName(tableName, fields) {
|
|
230
|
+
const columnNames = fields.map(field => this.indexColumnName(field));
|
|
231
|
+
return `idx_${tableName}_${columnNames.join('_')}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
indexColumnName(field) {
|
|
236
|
+
if (typeof field === 'string') return field;
|
|
237
|
+
return field.name;
|
|
161
238
|
}
|
|
162
239
|
|
|
163
240
|
|
|
164
241
|
async renameColumn(tableName, oldName, newName) {
|
|
165
242
|
newName = underscore(newName);
|
|
166
|
-
await this.#sequelizeQueryInterface.renameColumn(tableName, oldName, newName);
|
|
243
|
+
await this.#sequelizeQueryInterface.renameColumn(tableName, oldName, newName, this.queryOptions);
|
|
167
244
|
}
|
|
168
245
|
|
|
169
246
|
|
|
170
247
|
async changeColumn(tableName, columnName, dataTypeOrOptions) {
|
|
171
|
-
await this.#sequelizeQueryInterface.changeColumn(tableName, columnName, dataTypeOrOptions);
|
|
248
|
+
await this.#sequelizeQueryInterface.changeColumn(tableName, columnName, dataTypeOrOptions, this.queryOptions);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
/***********************************************************************************************
|
|
253
|
+
* QUERY OPTIONS
|
|
254
|
+
***********************************************************************************************/
|
|
255
|
+
get queryOptions() {
|
|
256
|
+
return { transaction: this.#transaction };
|
|
172
257
|
}
|
|
173
258
|
|
|
174
259
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const UNIQUE = 'unique';
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
/***************************************************************************************************
|
|
5
|
+
* CONFIGURATION
|
|
6
|
+
***************************************************************************************************/
|
|
7
|
+
function buildColumnValidations(column, config) {
|
|
8
|
+
const validations = {};
|
|
9
|
+
for (const [key, value] of Object.entries(config)) {
|
|
10
|
+
validations[key] = buildColumnValidation(column, key, value);
|
|
11
|
+
}
|
|
12
|
+
return validations;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
function buildColumnValidation(column, key, value) {
|
|
17
|
+
if (key != UNIQUE) return value;
|
|
18
|
+
|
|
19
|
+
return buildUniqueValidation(column, value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
/***************************************************************************************************
|
|
24
|
+
* UNIQUENESS
|
|
25
|
+
***************************************************************************************************/
|
|
26
|
+
function buildUniqueValidation(column, config) {
|
|
27
|
+
const options = uniqueOptions(config);
|
|
28
|
+
|
|
29
|
+
return function(value) {
|
|
30
|
+
return validateUniqueness(this, column, value, options);
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
function uniqueOptions(config) {
|
|
36
|
+
if (config === true) return {};
|
|
37
|
+
|
|
38
|
+
return config;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async function validateUniqueness(record, column, value, options) {
|
|
43
|
+
if (!uniquenessChanged(record, column, options)) return;
|
|
44
|
+
|
|
45
|
+
const duplicate = await record.constructor.findOne({
|
|
46
|
+
where: uniquenessConditions(record, column, value, options)
|
|
47
|
+
});
|
|
48
|
+
if (duplicate) throw new Error(`${column} must be unique`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
function uniquenessChanged(record, column, options) {
|
|
53
|
+
const columns = [column, ...scopeColumns(options)];
|
|
54
|
+
return columns.some(scopedColumn => record.changed(scopedColumn));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
function uniquenessConditions(record, column, value, options) {
|
|
59
|
+
const primaryKey = record.constructor.primaryKeyAttribute;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
[column]: value,
|
|
63
|
+
[primaryKey]: { [record.constructor.Operators.ne]: record[primaryKey] },
|
|
64
|
+
...scopeConditions(record, options)
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
function scopeConditions(record, options) {
|
|
70
|
+
const conditions = {};
|
|
71
|
+
for (const column of scopeColumns(options)) {
|
|
72
|
+
conditions[column] = record[column];
|
|
73
|
+
}
|
|
74
|
+
return conditions;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
function scopeColumns(options) {
|
|
79
|
+
return options.scope || [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
module.exports = {
|
|
84
|
+
buildColumnValidations
|
|
85
|
+
}
|
package/package.json
CHANGED
|
@@ -268,6 +268,78 @@ describe('BaseRecord', () => {
|
|
|
268
268
|
});
|
|
269
269
|
|
|
270
270
|
|
|
271
|
+
describe('BaseRecord.addValidation', () => {
|
|
272
|
+
function buildModel() {
|
|
273
|
+
class User extends BaseRecord {}
|
|
274
|
+
User.initialize();
|
|
275
|
+
return User;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
it ('adds the validations to the column', () => {
|
|
280
|
+
const User = buildModel();
|
|
281
|
+
|
|
282
|
+
User.addValidation(Helpers.colName, {notEmpty: true});
|
|
283
|
+
|
|
284
|
+
expect(User.rawAttributes[Helpers.colName].validate).toEqual({notEmpty: true});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
it ('reads the column validations off the prototype and the attribute as one object', () => {
|
|
289
|
+
const User = buildModel();
|
|
290
|
+
|
|
291
|
+
User.addValidation(Helpers.colName, {notEmpty: true});
|
|
292
|
+
|
|
293
|
+
expect(User.prototype.validators[Helpers.colName]).toBe(User.rawAttributes[Helpers.colName].validate);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
it ('keeps the validations already registered for the column', () => {
|
|
298
|
+
const User = buildModel();
|
|
299
|
+
const validator = jest.fn();
|
|
300
|
+
|
|
301
|
+
User.addValidation(Helpers.colName, {notEmpty: true});
|
|
302
|
+
User.addValidation(Helpers.colName, {isTaken: validator});
|
|
303
|
+
|
|
304
|
+
expect(User.rawAttributes[Helpers.colName].validate).toEqual({notEmpty: true, isTaken: validator});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
it ('returns the model so calls can be chained', () => {
|
|
309
|
+
const User = buildModel();
|
|
310
|
+
|
|
311
|
+
expect(User.addValidation(Helpers.colName, {notEmpty: true})).toBe(User);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
it ('adds the validations to the model itself when no column is named', () => {
|
|
316
|
+
const User = buildModel();
|
|
317
|
+
const validator = jest.fn();
|
|
318
|
+
|
|
319
|
+
User.addValidation({teamIsPresent: validator});
|
|
320
|
+
|
|
321
|
+
expect(User.options.validate).toEqual({teamIsPresent: validator});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
it ('refuses a column that is not in the schema', () => {
|
|
326
|
+
const User = buildModel();
|
|
327
|
+
const colName = TestHelpers.Faker.Text.randomString();
|
|
328
|
+
|
|
329
|
+
expect(() => User.addValidation(colName, {notEmpty: true}))
|
|
330
|
+
.toThrow(`Cannot validate User.${colName}: the column is not in the schema`);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
it ('refuses a model that has not been initialized', () => {
|
|
335
|
+
class User extends BaseRecord {}
|
|
336
|
+
|
|
337
|
+
expect(() => User.addValidation(Helpers.colName, {notEmpty: true}))
|
|
338
|
+
.toThrow('User must be initialized before validations are added');
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
|
|
271
343
|
describe('BaseRecord.configureVirtualAccessors', () => {
|
|
272
344
|
it ('adds a VIRTUAL column when a _virtualGet method is defined on the subclass', () => {
|
|
273
345
|
const getter = jest.fn();
|
|
@@ -45,23 +45,158 @@ describe('Migrator', () => {
|
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
describe('Migrator transaction threading', () => {
|
|
49
|
+
it ('passes the open transaction to operations run inside `up`', async () => {
|
|
50
|
+
const tableName = Helpers.randomString();
|
|
51
|
+
|
|
52
|
+
await Helpers.runInMigration(async function() {
|
|
53
|
+
await this.createTable(tableName, {});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(
|
|
57
|
+
tableName,
|
|
58
|
+
expect.any(Object),
|
|
59
|
+
Helpers.inTransaction,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it ('passes the open transaction to operations run inside `down`', async () => {
|
|
64
|
+
const tableName = Helpers.randomString();
|
|
65
|
+
Helpers.migrationModule.down.mockImplementationOnce(async function() {
|
|
66
|
+
await this.dropTable(tableName);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await Helpers.migrator.rollback();
|
|
70
|
+
|
|
71
|
+
expect(Helpers.mockQueryInterface.dropTable).toHaveBeenCalledWith(tableName, Helpers.inTransaction);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it ('clears the transaction once the migration finishes', async () => {
|
|
75
|
+
const tableName = Helpers.randomString();
|
|
76
|
+
|
|
77
|
+
await Helpers.runInMigration(async function() {});
|
|
78
|
+
await Helpers.migrator.dropTable(tableName);
|
|
79
|
+
|
|
80
|
+
expect(Helpers.mockQueryInterface.dropTable).toHaveBeenCalledWith(tableName, Helpers.noTransaction);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it ('clears the transaction when the migration throws', async () => {
|
|
84
|
+
const tableName = Helpers.randomString();
|
|
85
|
+
|
|
86
|
+
await expect(Helpers.runInMigration(async function() {
|
|
87
|
+
throw new Error('migration blew up');
|
|
88
|
+
})).rejects.toThrow('migration blew up');
|
|
89
|
+
await Helpers.migrator.dropTable(tableName);
|
|
90
|
+
|
|
91
|
+
expect(Helpers.mockQueryInterface.dropTable).toHaveBeenCalledWith(tableName, Helpers.noTransaction);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
describe('Migrator opting out of the transaction', () => {
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
delete Helpers.migrationModule.transactional;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it ('runs `up` outside a transaction when the migration sets transactional to false', async () => {
|
|
102
|
+
const tableName = Helpers.randomString();
|
|
103
|
+
Helpers.migrationModule.transactional = false;
|
|
104
|
+
|
|
105
|
+
await Helpers.runInMigration(async function() {
|
|
106
|
+
await this.addIndex(tableName, ['name'], { concurrently: true });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(Helpers.transactionSpy).not.toHaveBeenCalled();
|
|
110
|
+
expect(Helpers.mockQueryInterface.addIndex).toHaveBeenCalledWith(tableName, {
|
|
111
|
+
fields: ['name'],
|
|
112
|
+
name: `idx_${tableName}_name`,
|
|
113
|
+
concurrently: true,
|
|
114
|
+
...Helpers.noTransaction,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it ('runs `down` outside a transaction when the migration sets transactional to false', async () => {
|
|
119
|
+
const tableName = Helpers.randomString();
|
|
120
|
+
Helpers.migrationModule.transactional = false;
|
|
121
|
+
Helpers.migrationModule.down.mockImplementationOnce(async function() {
|
|
122
|
+
await this.dropTable(tableName);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await Helpers.migrator.rollback();
|
|
126
|
+
|
|
127
|
+
expect(Helpers.transactionSpy).not.toHaveBeenCalled();
|
|
128
|
+
expect(Helpers.mockQueryInterface.dropTable).toHaveBeenCalledWith(tableName, Helpers.noTransaction);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it ('still uses a transaction when transactional is unset', async () => {
|
|
132
|
+
await Helpers.migrator.run();
|
|
133
|
+
|
|
134
|
+
expect(Helpers.transactionSpy).toHaveBeenCalledTimes(1);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
|
|
48
139
|
describe('Migrator#createTable', () => {
|
|
49
140
|
const defaultColumns = {
|
|
50
|
-
id: { allowNull: false, autoIncrement: true, primaryKey: true, type: '
|
|
141
|
+
id: { allowNull: false, autoIncrement: true, primaryKey: true, type: 'BIGINT' },
|
|
51
142
|
created_at: { allowNull: false, type: 'DATE' },
|
|
52
143
|
updated_at: { allowNull: false, type: 'DATE' },
|
|
53
144
|
};
|
|
54
145
|
|
|
146
|
+
it ('gives the table a bigint auto-increment id', async () => {
|
|
147
|
+
const tableName = Helpers.randomString();
|
|
148
|
+
|
|
149
|
+
await Helpers.migrator.createTable(tableName, {});
|
|
150
|
+
|
|
151
|
+
const [, columns] = Helpers.mockQueryInterface.createTable.mock.calls[0];
|
|
152
|
+
expect(columns.id).toEqual({ allowNull: false, autoIncrement: true, primaryKey: true, type: 'BIGINT' });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it ('gives the table an integer id when asked for one', async () => {
|
|
156
|
+
const tableName = Helpers.randomString();
|
|
157
|
+
|
|
158
|
+
await Helpers.migrator.createTable(tableName, {}, { id: 'int' });
|
|
159
|
+
|
|
160
|
+
const [, columns] = Helpers.mockQueryInterface.createTable.mock.calls[0];
|
|
161
|
+
expect(columns.id).toEqual({ allowNull: false, autoIncrement: true, primaryKey: true, type: 'INTEGER' });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it ('gives the table a uuid id that the database does not generate', async () => {
|
|
165
|
+
const tableName = Helpers.randomString();
|
|
166
|
+
|
|
167
|
+
await Helpers.migrator.createTable(tableName, {}, { id: 'uuid' });
|
|
168
|
+
|
|
169
|
+
const [, columns] = Helpers.mockQueryInterface.createTable.mock.calls[0];
|
|
170
|
+
expect(columns.id).toEqual({ allowNull: false, primaryKey: true, type: 'UUID' });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it ('refuses an id type it does not recognize', async () => {
|
|
174
|
+
const idType = Helpers.randomString();
|
|
175
|
+
|
|
176
|
+
await expect(Helpers.migrator.createTable(Helpers.randomString(), {}, { id: idType }))
|
|
177
|
+
.rejects.toThrow(`Unknown id type "${idType}". Use one of: bigint, int, uuid`);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it ('lets the details override the id column', async () => {
|
|
181
|
+
const tableName = Helpers.randomString();
|
|
182
|
+
const id = { allowNull: false, primaryKey: true, type: 'UUID' };
|
|
183
|
+
|
|
184
|
+
await Helpers.migrator.createTable(tableName, { id });
|
|
185
|
+
|
|
186
|
+
const [, columns] = Helpers.mockQueryInterface.createTable.mock.calls[0];
|
|
187
|
+
expect(columns.id).toEqual(id);
|
|
188
|
+
});
|
|
189
|
+
|
|
55
190
|
it ('creates tables with provided details', async () => {
|
|
56
191
|
const tableName = Helpers.randomString();
|
|
57
192
|
const details = { ...defaultColumns, [Helpers.randomString().toLowerCase()]: Helpers.randomNumber() };
|
|
58
193
|
|
|
59
194
|
await Helpers.migrator.createTable(tableName, details);
|
|
60
195
|
|
|
61
|
-
expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, details);
|
|
196
|
+
expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, details, Helpers.noTransaction);
|
|
62
197
|
});
|
|
63
198
|
|
|
64
|
-
it ('adds a deleted_at column
|
|
199
|
+
it ('adds a nullable deleted_at column when paranoid is set', async () => {
|
|
65
200
|
const tableName = Helpers.randomString();
|
|
66
201
|
const details = { ...defaultColumns };
|
|
67
202
|
|
|
@@ -70,16 +205,27 @@ describe('Migrator', () => {
|
|
|
70
205
|
expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, {
|
|
71
206
|
...defaultColumns,
|
|
72
207
|
deleted_at: { allowNull: true, type: 'DATE' },
|
|
73
|
-
});
|
|
208
|
+
}, Helpers.noTransaction);
|
|
74
209
|
});
|
|
75
210
|
|
|
76
|
-
it ('
|
|
211
|
+
it ('omits deleted_at when paranoid is not set', async () => {
|
|
77
212
|
const tableName = Helpers.randomString();
|
|
78
|
-
const details = { ...defaultColumns };
|
|
79
213
|
|
|
80
|
-
await Helpers.migrator.createTable(tableName,
|
|
214
|
+
await Helpers.migrator.createTable(tableName, { ...defaultColumns });
|
|
215
|
+
|
|
216
|
+
const [, columns] = Helpers.mockQueryInterface.createTable.mock.calls[0];
|
|
217
|
+
expect(columns).not.toHaveProperty('deleted_at');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it ('adds deleted_at alongside timestamps: false', async () => {
|
|
221
|
+
const tableName = Helpers.randomString();
|
|
222
|
+
|
|
223
|
+
await Helpers.migrator.createTable(tableName, {}, { paranoid: true, timestamps: false });
|
|
81
224
|
|
|
82
|
-
|
|
225
|
+
const [, columns] = Helpers.mockQueryInterface.createTable.mock.calls[0];
|
|
226
|
+
expect(columns).toHaveProperty('deleted_at');
|
|
227
|
+
expect(columns).not.toHaveProperty('created_at');
|
|
228
|
+
expect(columns).not.toHaveProperty('updated_at');
|
|
83
229
|
});
|
|
84
230
|
|
|
85
231
|
it ('snake_cases created table columns', async () => {
|
|
@@ -91,7 +237,7 @@ describe('Migrator', () => {
|
|
|
91
237
|
expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, {
|
|
92
238
|
...defaultColumns,
|
|
93
239
|
my_column: 1,
|
|
94
|
-
});
|
|
240
|
+
}, Helpers.noTransaction);
|
|
95
241
|
});
|
|
96
242
|
});
|
|
97
243
|
|
|
@@ -116,7 +262,7 @@ describe('Migrator', () => {
|
|
|
116
262
|
|
|
117
263
|
await Helpers.migrator.renameColumn(tableName, oldName, 'myColumn');
|
|
118
264
|
|
|
119
|
-
expect(Helpers.mockQueryInterface.renameColumn).toHaveBeenCalledWith(tableName, oldName, 'my_column');
|
|
265
|
+
expect(Helpers.mockQueryInterface.renameColumn).toHaveBeenCalledWith(tableName, oldName, 'my_column', Helpers.noTransaction);
|
|
120
266
|
});
|
|
121
267
|
});
|
|
122
268
|
|
|
@@ -129,12 +275,12 @@ describe('Migrator', () => {
|
|
|
129
275
|
])('builds addIndex options for %s', async (_label, options) => {
|
|
130
276
|
const tableName = Helpers.randomString();
|
|
131
277
|
const fields = [Helpers.randomString(), Helpers.randomString()];
|
|
132
|
-
const expectedOptions = { fields };
|
|
278
|
+
const expectedOptions = { fields, ...Helpers.noTransaction };
|
|
133
279
|
|
|
134
280
|
if (options.concurrently) expectedOptions.concurrently = true;
|
|
135
281
|
if (options.unique) expectedOptions.unique = true;
|
|
136
282
|
if (options.where) expectedOptions.where = options.where;
|
|
137
|
-
|
|
283
|
+
expectedOptions.name = options.name || `idx_${tableName}_${fields.join('_')}`;
|
|
138
284
|
if (options.using) expectedOptions.using = options.using;
|
|
139
285
|
|
|
140
286
|
await Helpers.migrator.addIndex(tableName, fields, options);
|
|
@@ -147,8 +293,8 @@ describe('Migrator', () => {
|
|
|
147
293
|
|
|
148
294
|
describe('Migrator#removeIndex', () => {
|
|
149
295
|
it.each([
|
|
150
|
-
['default options', false, {}],
|
|
151
|
-
['concurrently', true, { concurrently: true }],
|
|
296
|
+
['default options', false, { transaction: null }],
|
|
297
|
+
['concurrently', true, { concurrently: true, transaction: null }],
|
|
152
298
|
])('passes removeIndex options for %s', async (_label, concurrently, expectedOptions) => {
|
|
153
299
|
const tableName = Helpers.randomString();
|
|
154
300
|
const fields = [Helpers.randomString(), Helpers.randomString()];
|
|
@@ -158,9 +304,54 @@ describe('Migrator', () => {
|
|
|
158
304
|
expect(Helpers.mockQueryInterface.removeIndex).toHaveBeenCalledTimes(1);
|
|
159
305
|
expect(Helpers.mockQueryInterface.removeIndex).toHaveBeenCalledWith(
|
|
160
306
|
tableName,
|
|
161
|
-
fields
|
|
307
|
+
`idx_${tableName}_${fields.join('_')}`,
|
|
162
308
|
expectedOptions,
|
|
163
309
|
);
|
|
164
310
|
});
|
|
311
|
+
|
|
312
|
+
it ('leaves an explicit index name alone', async () => {
|
|
313
|
+
const tableName = Helpers.randomString();
|
|
314
|
+
const indexName = Helpers.randomString();
|
|
315
|
+
|
|
316
|
+
await Helpers.migrator.removeIndex(tableName, indexName);
|
|
317
|
+
|
|
318
|
+
expect(Helpers.mockQueryInterface.removeIndex).toHaveBeenCalledWith(
|
|
319
|
+
tableName,
|
|
320
|
+
indexName,
|
|
321
|
+
Helpers.noTransaction,
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
describe('Migrator#defaultIndexName', () => {
|
|
328
|
+
it ('builds idx_<table>_<columns> from a single column', () => {
|
|
329
|
+
expect(Helpers.migrator.defaultIndexName('users', ['team_id'])).toBe('idx_users_team_id');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it ('joins multiple columns with underscores', () => {
|
|
333
|
+
expect(Helpers.migrator.defaultIndexName('users', ['team_id', 'email'])).toBe('idx_users_team_id_email');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it ('reads the column name off object-style fields', () => {
|
|
337
|
+
const fields = ['team_id', { name: 'email', order: 'DESC' }];
|
|
338
|
+
|
|
339
|
+
expect(Helpers.migrator.defaultIndexName('users', fields)).toBe('idx_users_team_id_email');
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
describe('Migrator#addIndex default naming', () => {
|
|
345
|
+
it ('names an index when no options are passed at all', async () => {
|
|
346
|
+
const tableName = Helpers.randomString();
|
|
347
|
+
|
|
348
|
+
await Helpers.migrator.addIndex(tableName, ['team_id']);
|
|
349
|
+
|
|
350
|
+
expect(Helpers.mockQueryInterface.addIndex).toHaveBeenCalledWith(tableName, {
|
|
351
|
+
fields: ['team_id'],
|
|
352
|
+
name: `idx_${tableName}_team_id`,
|
|
353
|
+
...Helpers.noTransaction,
|
|
354
|
+
});
|
|
355
|
+
});
|
|
165
356
|
});
|
|
166
357
|
});
|
|
@@ -30,10 +30,18 @@ class MockModel {
|
|
|
30
30
|
static init = jest.fn()
|
|
31
31
|
static findByPk = jest.fn();
|
|
32
32
|
static findAll = jest.fn();
|
|
33
|
+
static findOne = jest.fn();
|
|
33
34
|
static primaryKeyAttribute = TestHelpers.Faker.Text.randomString();
|
|
34
35
|
toJSON() { return this.dataValues }
|
|
35
36
|
}
|
|
36
37
|
MockModel.findAll.mockImplementation(() => []);
|
|
38
|
+
// Sequelize stores the schema and the options it was initialized with, and anything reading a
|
|
39
|
+
// model's validations expects them to have been reset by then.
|
|
40
|
+
MockModel.init.mockImplementation(function(schema, options) {
|
|
41
|
+
this.rawAttributes = schema;
|
|
42
|
+
this.options = options;
|
|
43
|
+
this.prototype.validators = {};
|
|
44
|
+
});
|
|
37
45
|
|
|
38
46
|
|
|
39
47
|
class MockSequelize {
|
|
@@ -13,6 +13,9 @@ const connectionConfig = { database: ' ', host: ' ', password: ' ', username:
|
|
|
13
13
|
const migrationModule = { up: jest.fn(), down: jest.fn() };
|
|
14
14
|
const randomNumber = TestHelpers.Faker.Math.randomNumber
|
|
15
15
|
const randomString = TestHelpers.Faker.Text.randomString;
|
|
16
|
+
const transaction = { id: randomString() };
|
|
17
|
+
const noTransaction = { transaction: null };
|
|
18
|
+
const inTransaction = { transaction };
|
|
16
19
|
const mockQueryInterface = {
|
|
17
20
|
createTable: jest.fn(),
|
|
18
21
|
dropTable: jest.fn(),
|
|
@@ -35,7 +38,7 @@ function randomColumnDetails() {
|
|
|
35
38
|
***************************************************************************************************/
|
|
36
39
|
jest.mock('sequelize', () => {
|
|
37
40
|
class MockSequelize {
|
|
38
|
-
static DataTypes = {
|
|
41
|
+
static DataTypes = { BIGINT: 'BIGINT', DATE: 'DATE', INTEGER: 'INTEGER', UUID: 'UUID' };
|
|
39
42
|
}
|
|
40
43
|
return { Sequelize: MockSequelize };
|
|
41
44
|
});
|
|
@@ -52,7 +55,7 @@ jest.spyOn(migrator, 'migrationModule', 'get').mockImplementation(() => migratio
|
|
|
52
55
|
|
|
53
56
|
|
|
54
57
|
const transactionSpy = jest.spyOn(QueryInterface.prototype, 'transaction');
|
|
55
|
-
transactionSpy.mockImplementation(callback => callback());
|
|
58
|
+
transactionSpy.mockImplementation(callback => callback(transaction));
|
|
56
59
|
|
|
57
60
|
|
|
58
61
|
/***************************************************************************************************
|
|
@@ -61,18 +64,29 @@ transactionSpy.mockImplementation(callback => callback());
|
|
|
61
64
|
async function expectQueryInterfaceCall(methodName, args) {
|
|
62
65
|
await migrator[methodName](...args);
|
|
63
66
|
expect(mockQueryInterface[methodName]).toHaveBeenCalledTimes(1);
|
|
64
|
-
expect(mockQueryInterface[methodName]).toHaveBeenCalledWith(...args);
|
|
67
|
+
expect(mockQueryInterface[methodName]).toHaveBeenCalledWith(...args, noTransaction);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
// Runs `operation` from inside a migration's `up` so it sees the open transaction.
|
|
72
|
+
async function runInMigration(operation) {
|
|
73
|
+
migrationModule.up.mockImplementationOnce(operation);
|
|
74
|
+
await migrator.run();
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
|
|
68
78
|
module.exports = {
|
|
69
79
|
expectQueryInterfaceCall,
|
|
70
80
|
...Fixtures,
|
|
81
|
+
inTransaction,
|
|
71
82
|
migrationModule,
|
|
72
83
|
migrator,
|
|
73
84
|
mockQueryInterface,
|
|
85
|
+
noTransaction,
|
|
74
86
|
randomColumnDetails,
|
|
75
87
|
randomNumber,
|
|
76
88
|
randomString,
|
|
89
|
+
runInMigration,
|
|
90
|
+
transaction,
|
|
77
91
|
transactionSpy,
|
|
78
92
|
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const TestHelpers = require('@vida-global/test-helpers');
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
/***************************************************************************************************
|
|
5
|
+
* VARIABLES / VARIABLE GENERATION
|
|
6
|
+
***************************************************************************************************/
|
|
7
|
+
const NOT_EQUAL = Symbol('ne');
|
|
8
|
+
const PRIMARY_KEY = 'id';
|
|
9
|
+
const column = TestHelpers.Faker.Text.randomString();
|
|
10
|
+
const scopeColumn = TestHelpers.Faker.Text.randomString();
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
/***************************************************************************************************
|
|
14
|
+
* MOCKS
|
|
15
|
+
***************************************************************************************************/
|
|
16
|
+
// The validations only reach a record through the few members Sequelize gives every model instance,
|
|
17
|
+
// so a plain object stands in for one and keeps a live model out of these tests.
|
|
18
|
+
function buildRecord({changed = () => true, ...attributes} = {}) {
|
|
19
|
+
return {
|
|
20
|
+
[PRIMARY_KEY]: null, // sequelize's value for an unsaved record
|
|
21
|
+
...attributes,
|
|
22
|
+
changed: jest.fn(changed),
|
|
23
|
+
constructor: {
|
|
24
|
+
findOne: jest.fn().mockResolvedValue(null),
|
|
25
|
+
primaryKeyAttribute: PRIMARY_KEY,
|
|
26
|
+
Operators: {ne: NOT_EQUAL}
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
module.exports = {
|
|
33
|
+
buildRecord,
|
|
34
|
+
column,
|
|
35
|
+
NOT_EQUAL,
|
|
36
|
+
PRIMARY_KEY,
|
|
37
|
+
scopeColumn
|
|
38
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const { buildColumnValidations } = require('../../lib/activeRecord/validations');
|
|
2
|
+
const Helpers = require('./helpers/validations');
|
|
3
|
+
const TestHelpers = require('@vida-global/test-helpers');
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
const COLUMN = Helpers.column;
|
|
7
|
+
const SCOPE_COLUMN = Helpers.scopeColumn;
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
jest.clearAllMocks();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
describe('buildColumnValidations', () => {
|
|
16
|
+
|
|
17
|
+
it ('passes a built-in validation through untouched', () => {
|
|
18
|
+
expect(buildColumnValidations(COLUMN, {len: [1, 255]})).toEqual({len: [1, 255]});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
it ('passes a custom validation function through untouched', () => {
|
|
23
|
+
const validator = jest.fn();
|
|
24
|
+
|
|
25
|
+
expect(buildColumnValidations(COLUMN, {isTaken: validator})).toEqual({isTaken: validator});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
describe('unique', () => {
|
|
30
|
+
function buildUnique(config = {scope: [SCOPE_COLUMN]}) {
|
|
31
|
+
return buildColumnValidations(COLUMN, {unique: config}).unique;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
it ('takes only the value, so sequelize awaits it instead of handing it a callback', () => {
|
|
36
|
+
expect(buildUnique().length).toEqual(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
it ('looks for another record holding the value', async() => {
|
|
41
|
+
const id = TestHelpers.Faker.Text.randomString();
|
|
42
|
+
const record = Helpers.buildRecord({id, [SCOPE_COLUMN]: 17});
|
|
43
|
+
const value = TestHelpers.Faker.Text.randomString();
|
|
44
|
+
|
|
45
|
+
await buildUnique().call(record, value);
|
|
46
|
+
|
|
47
|
+
expect(record.constructor.findOne).toHaveBeenCalledTimes(1);
|
|
48
|
+
expect(record.constructor.findOne).toHaveBeenCalledWith({
|
|
49
|
+
where: {
|
|
50
|
+
[COLUMN]: value,
|
|
51
|
+
[SCOPE_COLUMN]: 17,
|
|
52
|
+
[Helpers.PRIMARY_KEY]: {[Helpers.NOT_EQUAL]: id}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
it ('looks for a duplicate anywhere when the validation is not scoped', async() => {
|
|
59
|
+
const id = TestHelpers.Faker.Text.randomString();
|
|
60
|
+
const record = Helpers.buildRecord({id});
|
|
61
|
+
const value = TestHelpers.Faker.Text.randomString();
|
|
62
|
+
|
|
63
|
+
await buildUnique(true).call(record, value);
|
|
64
|
+
|
|
65
|
+
expect(record.constructor.findOne).toHaveBeenCalledWith({
|
|
66
|
+
where: {[COLUMN]: value, [Helpers.PRIMARY_KEY]: {[Helpers.NOT_EQUAL]: id}}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
it ('compares against the empty key of a record that has not been saved yet', async() => {
|
|
72
|
+
const record = Helpers.buildRecord();
|
|
73
|
+
const value = TestHelpers.Faker.Text.randomString();
|
|
74
|
+
|
|
75
|
+
await buildUnique(true).call(record, value);
|
|
76
|
+
|
|
77
|
+
expect(record.constructor.findOne).toHaveBeenCalledWith({
|
|
78
|
+
where: {[COLUMN]: value, [Helpers.PRIMARY_KEY]: {[Helpers.NOT_EQUAL]: null}}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
it ('does not look for a duplicate when neither the value nor its scope changed', async() => {
|
|
84
|
+
const record = Helpers.buildRecord({changed: () => false});
|
|
85
|
+
|
|
86
|
+
await buildUnique().call(record, TestHelpers.Faker.Text.randomString());
|
|
87
|
+
|
|
88
|
+
expect(record.constructor.findOne).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
it ('looks again when the scope changed even though the value did not', async() => {
|
|
93
|
+
const record = Helpers.buildRecord({changed: (column) => column == SCOPE_COLUMN});
|
|
94
|
+
|
|
95
|
+
await buildUnique().call(record, TestHelpers.Faker.Text.randomString());
|
|
96
|
+
|
|
97
|
+
expect(record.constructor.findOne).toHaveBeenCalledTimes(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
it ('passes when nothing else holds the value', async() => {
|
|
102
|
+
const record = Helpers.buildRecord();
|
|
103
|
+
|
|
104
|
+
await expect(buildUnique().call(record, TestHelpers.Faker.Text.randomString()))
|
|
105
|
+
.resolves.toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
it ('fails when something else already holds the value', async() => {
|
|
110
|
+
const record = Helpers.buildRecord();
|
|
111
|
+
record.constructor.findOne.mockResolvedValue({id: TestHelpers.Faker.Text.randomString()});
|
|
112
|
+
|
|
113
|
+
await expect(buildUnique().call(record, TestHelpers.Faker.Text.randomString()))
|
|
114
|
+
.rejects.toThrow(`${COLUMN} must be unique`);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|