@vida-global/core 2.2.1 → 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.
@@ -100,7 +100,7 @@ module.exports = {
100
100
  Available migration helpers:
101
101
 
102
102
  ```js
103
- createTable(tableName, details, { timestamps, paranoid })
103
+ createTable(tableName, details, { timestamps, paranoid, id })
104
104
  dropTable(tableName)
105
105
  addColumn(tableName, columnName, columnDetails)
106
106
  removeColumn(tableName, columnName)
@@ -112,9 +112,11 @@ changeColumn(tableName, columnName, dataTypeOrOptions)
112
112
 
113
113
  **Conventions:**
114
114
 
115
- - `createTable` adds an auto-increment `id` primary key and `created_at` / `updated_at` columns by default. Pass `{ timestamps: false }` to skip the timestamps.
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.
116
118
  - Pass `{ paranoid: true }` to `createTable` to add a nullable `deleted_at` column for soft deletes.
117
- - Models over that table soft delete without any extra configuration: `initializationOptions` turns on Sequelize's `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.
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.
118
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.
119
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.
120
122
  - Column names are automatically converted to snake_case, so `teamId` in the details object becomes `team_id` in the database.
@@ -186,7 +188,7 @@ More query examples: [Sequelize model querying basics](https://sequelize.org/doc
186
188
 
187
189
  ### Aggregators and functions
188
190
 
189
- Sequelize's `fn` and `col` helpers are exposed on the model class:
191
+ The `fn` and `col` helpers are exposed on the model class:
190
192
 
191
193
  ```js
192
194
  const rows = await User.findAll({
@@ -227,7 +229,7 @@ User.initialize();
227
229
 
228
230
  ## Virtual fields
229
231
 
230
- 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.
231
233
 
232
234
  ```js
233
235
  class User extends ActiveRecord.BaseRecord {
@@ -280,7 +282,7 @@ This feature requires the Redis client wired up via `lib/redis`.
280
282
 
281
283
  ## Hooks
282
284
 
283
- `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`:
284
286
 
285
287
  ```js
286
288
  User.addHook('beforeCreate', async (user, options) => {
@@ -297,7 +299,7 @@ See [Sequelize hooks](https://sequelize.org/docs/v6/other-topics/hooks/) for the
297
299
 
298
300
  ## Associations
299
301
 
300
- 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:
301
303
 
302
304
  ```js
303
305
  User.initialize();
@@ -312,22 +314,43 @@ See [Sequelize associations](https://sequelize.org/docs/v6/core-concepts/assocs/
312
314
 
313
315
  ## Validators
314
316
 
315
- 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:
316
318
 
317
319
  ```js
318
- class User extends ActiveRecord.BaseRecord {
319
- static get initializationOptions() {
320
- const opts = super.initializationOptions;
321
- opts.options.validate = {
322
- adminMustHaveTeam() {
323
- if (this.is_admin && !this.team_id) {
324
- throw new Error('admin users must belong to a team');
325
- }
326
- }
327
- };
328
- 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');
329
331
  }
330
- }
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
+ });
331
343
  ```
332
344
 
333
- 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
  ***********************************************************************************************/
@@ -3,6 +3,9 @@ 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;
@@ -90,7 +93,7 @@ class Migrator {
90
93
  * MIGRATION ACTIONS
91
94
  ***********************************************************************************************/
92
95
  async createTable(tableName, details, options={}) {
93
- details = {...this.defaultIdColumn, ...details};
96
+ details = {...this.idColumn(options.id), ...details};
94
97
  if (options.timestamps !== false) {
95
98
  details = {...details, ...this.defaultTimestampColumns};
96
99
  }
@@ -115,13 +118,42 @@ class Migrator {
115
118
  }
116
119
 
117
120
 
118
- 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) {
119
151
  return {
120
152
  id: {
121
153
  allowNull: false,
122
154
  autoIncrement: true,
123
155
  primaryKey: true,
124
- type: this.DataTypes.INTEGER, }
156
+ type: type, }
125
157
  };
126
158
  }
127
159
 
@@ -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.1",
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();
@@ -138,11 +138,55 @@ describe('Migrator', () => {
138
138
 
139
139
  describe('Migrator#createTable', () => {
140
140
  const defaultColumns = {
141
- id: { allowNull: false, autoIncrement: true, primaryKey: true, type: 'INTEGER' },
141
+ id: { allowNull: false, autoIncrement: true, primaryKey: true, type: 'BIGINT' },
142
142
  created_at: { allowNull: false, type: 'DATE' },
143
143
  updated_at: { allowNull: false, type: 'DATE' },
144
144
  };
145
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
+
146
190
  it ('creates tables with provided details', async () => {
147
191
  const tableName = Helpers.randomString();
148
192
  const details = { ...defaultColumns, [Helpers.randomString().toLowerCase()]: Helpers.randomNumber() };
@@ -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 {
@@ -38,7 +38,7 @@ function randomColumnDetails() {
38
38
  ***************************************************************************************************/
39
39
  jest.mock('sequelize', () => {
40
40
  class MockSequelize {
41
- static DataTypes = { INTEGER: 'INTEGER', DATE: 'DATE' };
41
+ static DataTypes = { BIGINT: 'BIGINT', DATE: 'DATE', INTEGER: 'INTEGER', UUID: 'UUID' };
42
42
  }
43
43
  return { Sequelize: MockSequelize };
44
44
  });
@@ -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
+ });