@vida-global/core 2.2.1 → 2.3.1
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 +44 -21
- package/lib/activeRecord/baseRecord.js +48 -0
- package/lib/activeRecord/db/migrator.js +35 -3
- package/lib/activeRecord/validations.js +85 -0
- package/lib/server/controllerMixins/validations.js +1 -0
- package/lib/server/doc/validations.md +1 -1
- package/lib/server/openApi/schemas.js +1 -0
- package/package.json +1 -1
- package/test/activeRecord/baseRecord.test.js +72 -0
- package/test/activeRecord/db/migrator.test.js +45 -1
- package/test/activeRecord/helpers/baseRecordMocks.js +8 -0
- package/test/activeRecord/helpers/migrator.js +1 -1
- package/test/activeRecord/helpers/validations.js +38 -0
- package/test/activeRecord/validations.test.js +117 -0
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|
|
@@ -70,6 +70,7 @@ const InstanceMethods = {
|
|
|
70
70
|
|
|
71
71
|
validateIsString(value, options) {
|
|
72
72
|
if (typeof value !== 'string') return 'must be a string';
|
|
73
|
+
if (options?.allowEmpty === false && value.length === 0) return 'must not be empty';
|
|
73
74
|
|
|
74
75
|
if (options?.length?.gte !== undefined) {
|
|
75
76
|
if (value.length < options.length.gte) return `must be greater than or equal to ${options.length.gte} characters`;
|
|
@@ -29,7 +29,7 @@ class UsersController extends VidaServerController {
|
|
|
29
29
|
- `{ presence: true }`
|
|
30
30
|
- `{ isInteger: true }` or `{ isInteger: { gte: 0, lte: 100 } }`
|
|
31
31
|
- `{ isNumber: true }` or `{ isNumber: { gte: 0, lte: 9.99 } }` — accepts integers and decimals.
|
|
32
|
-
- `{ isString: true }` or `{ isString: { length: { gte: 10, lte: 100 }, regex: /foo/ } }`
|
|
32
|
+
- `{ isString: true }` or `{ isString: { allowEmpty: false, length: { gte: 10, lte: 100 }, regex: /foo/ } }` — `allowEmpty` defaults to `true`; when `false`, it rejects `''` but allows whitespace-only strings.
|
|
33
33
|
- `{ isBoolean: true }` or `{ isBoolean: false }` (asserts the exact value)
|
|
34
34
|
- `{ isDateTime: true }`
|
|
35
35
|
- `{ isEnum: { enums: ['a', 'b', 'c'], error: 'optional message' } }`
|
|
@@ -75,6 +75,7 @@ class Schemas {
|
|
|
75
75
|
if (options?.length?.gte !== undefined) schema.minLength = options.length.gte;
|
|
76
76
|
if (options?.length?.lte !== undefined) schema.maxLength = options.length.lte;
|
|
77
77
|
if (options?.regex) schema.pattern = options.regex.source;
|
|
78
|
+
if (options?.allowEmpty === false) schema.minLength = Math.max(schema.minLength || 0, 1);
|
|
78
79
|
return schema;
|
|
79
80
|
}
|
|
80
81
|
|
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();
|
|
@@ -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: '
|
|
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 = {
|
|
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
|
+
});
|