@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.
@@ -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 an auto-increment `id` primary key and `created_at` / `updated_at` columns by default. Pass `{ timestamps: false }` to skip the timestamps.
114
- - Pass `{ paranoid: true }` to add a nullable `deleted_at` column. Models over that table soft delete: `initializationOptions` turns on `paranoid` for any table whose schema has `deleted_at`, so `destroy()` sets the column and ordinary queries skip the row. Unique indexes on a paranoid table should be partial — `where: { deleted_at: null }` — or a deleted row will still block re-creating the same one.
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
- Sequelize's `fn` and `col` helpers are exposed on the model class:
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 Sequelize `VIRTUAL` column for it, so the property appears in `dataValues`, `toJSON()`, and `toApiResponse()`. Virtual accessors are inherited through the class hierarchy.
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 Sequelize's full hook system. Register a hook on the model class with `addHook`:
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 Sequelize's standard API after the model is initialized:
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
- Sequelize's column-level validators continue to work — declare them on the model's column definition or via the `validate` option:
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
- class User extends ActiveRecord.BaseRecord {
296
- static get initializationOptions() {
297
- const opts = super.initializationOptions;
298
- opts.options.validate = {
299
- adminMustHaveTeam() {
300
- if (this.is_admin && !this.team_id) {
301
- throw new Error('admin users must belong to a team');
302
- }
303
- }
304
- };
305
- return opts;
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
- See [Sequelize validators](https://sequelize.org/docs/v6/core-concepts/validations-and-constraints/).
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 = `${description}_${new Date().getTime()}`;
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(/_(?<version>\d+)(\.(?<databaseId>\w+))?\.js$/);
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
- const migrator = this;
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
- const migrator = this;
36
- await this.#connection.queryInterface.transaction(async () => {
37
- await migrator.migrationModule.down.call(migrator);
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.defaultIdColumn, ...details};
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
- get defaultIdColumn() {
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: this.DataTypes.INTEGER, }
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
- await this.#sequelizeQueryInterface.removeIndex(tableName, indexNameOrAttributes, options);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vida-global/core",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Core libraries for supporting Vida development",
5
5
  "author": "",
6
6
  "license": "ISC",
@@ -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: 'INTEGER' },
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 for paranoid tables', async () => {
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 ('leaves deleted_at off tables that are not paranoid', async () => {
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, details);
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
- expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, defaultColumns);
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
- if (options.name) expectedOptions.name = options.name;
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 = { INTEGER: 'INTEGER', DATE: 'DATE' };
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
+ });