From 40b05d4b232ee9f232309b66ca49be9b267d9a49 Mon Sep 17 00:00:00 2001 From: Alvaro Cabrera Date: Fri, 12 Aug 2022 15:02:47 -0500 Subject: [PATCH] chore: refactor nesting support for resources --- .eslintrc | 7 +- lib/index.js | 25 + lib/res.js | 434 ++++++++---------- lib/util.js | 88 ++-- package.json | 9 +- .../shopping_cart/Product/schema.json | 3 + tests/main.test.js | 22 +- tests/res.test.js | 202 ++++++-- tests/umzug.test.js | 12 +- 9 files changed, 455 insertions(+), 347 deletions(-) diff --git a/.eslintrc b/.eslintrc index 0b54fb3..3b45d01 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,12 +1,13 @@ { "env": { - "es6": true, "node": true, + "es2021": true, "browser": true }, "extends": "airbnb-base", "parserOptions": { - "sourceType": "module" + "sourceType": "module", + "ecmaVersion": "latest" }, "rules" : { "max-len": ["error", { @@ -16,6 +17,8 @@ "no-multi-assign": 0, "strict": 0, "no-console": 0, + "no-await-in-loop": 0, + "no-restricted-syntax": 0, "prefer-destructuring": 0, "function-paren-newline": 0, "global-require": 0, diff --git a/lib/index.js b/lib/index.js index 9a14b75..fbbde17 100644 --- a/lib/index.js +++ b/lib/index.js @@ -216,6 +216,28 @@ function JSONSchemaSequelizer(settings, refs, cwd) { return this; }; + function assocTypes(model, bundle) { + const keys = Object.keys(model.associations); + const props = model.options.$schema.properties; + + return keys.reduce((memo, cur) => { + if (props[cur].items) { + if (props[cur].items.id) { + /* istanbul ignore else */ + if (!bundle[props[cur].items.id]) memo[cur] = { items: { $ref: props[cur].items.id } }; + } else { + memo[cur] = { items: props[cur].items }; + } + } else if (props[cur].id) { + /* istanbul ignore else */ + if (!bundle[props[cur].id]) memo[cur] = { $ref: props[cur].id }; + } else { + memo[cur] = props[cur]; + } + return memo; + }, {}); + } + // initialize refs (private) function hydrate(bundle) { const _models = {}; @@ -236,6 +258,7 @@ function JSONSchemaSequelizer(settings, refs, cwd) { // keep model references _defns[ref].$references = { + associations: assocTypes(_models[ref].model, bundle), primaryKeys: Object.keys(attrs) .filter(x => attrs[x].primaryKey || attrs[x].references) .map(k => { @@ -305,6 +328,8 @@ function JSONSchemaSequelizer(settings, refs, cwd) { conn = conn || null; + settings.dialect = settings.dialect || settings.connection.split(':')[0]; + if (settings.logging) { settings.logging(`\rWaiting ${settings.dialect} for connection...\x1b[K`); } diff --git a/lib/res.js b/lib/res.js index 647c407..bc8529d 100644 --- a/lib/res.js +++ b/lib/res.js @@ -9,80 +9,20 @@ const types = require('./types'); const RE_DATA = /^data:(.+?);base64,/; -function _unflattenData(target, props, keys, rfx) { - Object.keys(props).forEach(key => { - /* istanbul ignore else */ - if (props[key] !== null) { - const parts = key.split('.'); - const name = parts.pop(); - - let obj = target; - - while (parts.length) { - const prop = parts.shift(); - - /* istanbul ignore else */ - if (!obj[prop]) { - obj[prop] = {}; - } - - obj = obj[prop]; - } - - obj[name] = props[key]; - } - }); - - keys.forEach(key => { - /* istanbul ignore else */ - if (rfx[key].hasManyItems && !Array.isArray(target[key])) { - target[key] = target[key] ? [target[key]] : []; - } - }); - - return target; -} - -function _unflattenObj(pk, names, values, references) { - const grouped = []; - const keys = {}; - - values.forEach(item => { - const row = _unflattenData({}, item, names, references); - - /* istanbul ignore else */ - if (typeof keys[row[pk]] === 'undefined') { - keys[row[pk]] = grouped.length; - grouped.push(row); - return; - } - - const prev = grouped[keys[row[pk]]]; - - names.forEach(prop => { - /* istanbul ignore else */ - if (!Array.isArray(prev[prop])) { - prev[prop] = []; - } - - prev[prop] = prev[prop].concat(row[prop]); - }); - }); - - return grouped; -} - function _cleanData(values, model) { Object.keys(model.rawAttributes).forEach(key => { const prop = model.rawAttributes[key]; + /* istanbul ignore else */ if ((prop.primaryKey && !values[key]) || (prop._autoGenerated && !prop.references) || (prop.references && !values[key])) { delete values[key]; } + /* istanbul ignore else */ if (prop.references) { const { Model, fieldName } = prop.type.options; + /* istanbul ignore else */ if (values[Model.name] && values[Model.name][fieldName]) { values[key] = values[Model.name][fieldName]; } @@ -93,6 +33,7 @@ function _cleanData(values, model) { const assoc = model.associations[key]; const target = (assoc.through && assoc.through.model) || assoc.target; + /* istanbul ignore else */ if (Array.isArray(values[key])) { values[key] = values[key].map(x => _cleanData(x, target)); } else if (values[key]) { @@ -103,23 +44,11 @@ function _cleanData(values, model) { return values; } -// FIXME: investigate the root-cause of pk=null... -function _fixPkNull(row, model) { - /* istanbul ignore else */ - if (row[model.primaryKeyAttribute] === null && row.null > 0) { - row.dataValues[model.primaryKeyAttribute] = row.null; - delete row.null; - } - - return row; -} - function _isData(value) { return typeof value === 'string' && RE_DATA.test(value); } function _fixRefs(refs, values) { - // FIXME: consider apply this recursively? Object.keys(refs).forEach(ref => { /* istanbul ignore else */ if (Array.isArray(values[ref]) && refs[ref] && refs[ref].through) { @@ -131,9 +60,7 @@ function _fixRefs(refs, values) { const sub = item[through]; delete item[through]; - sub[refs[ref].model] = item; - return sub; }); } @@ -148,18 +75,17 @@ function _fileInfo(baseDir, data, obj) { /* istanbul ignore else */ if (Array.isArray(data)) { - // FIXME: additional fields on uploads return data.map(x => _fileInfo(baseDir, x, obj)); } delete obj.$upload; return { - ...obj, mtime: (data.lastModifiedDate || new Date()).toGMTString(), - path: path.relative(baseDir, data.path), - name: data.name, + path: path.relative(baseDir, data.path || data.filepath), + name: data.name || data.originalFilename, + type: data.type || data.mimetype, size: data.size, - type: data.type, + ...obj, }; } @@ -224,177 +150,206 @@ function _saveUpload(field, payload, attachments, onUploadCallback) { const baseDir = attachments.baseDir || process.cwd(); const input = _fileInfo(baseDir, attachments.files[payload.$upload], payload); - return _pushUpload(field, payload, input, input.path, onUploadCallback); + return _pushUpload(field, payload, input, input.path || input.filepath, onUploadCallback); }); } -// FIXME: too much complexity for updating related rows, just create new ones!!! +function _walkInput(data, _schema, models, references, walkCallback) { + // eslint-disable-next-line wrap-iife + (function walk(obj, prop, parent, rootSchema) { + /* istanbul ignore else */ + if (!obj || typeof obj !== 'object') return obj; + + /* istanbul ignore else */ + if (Array.isArray(obj)) { + return obj.map(x => walk(x, prop, parent, rootSchema)); + } + + /* istanbul ignore else */ + if (!walkCallback(obj, null, null, rootSchema, parent)) { + Object.keys(obj).forEach(key => { + let schema = rootSchema; + if (references[key]) { + schema = rootSchema.properties ? rootSchema.properties[key] : schema; + schema = Array.isArray(obj[key]) ? schema.items : schema; + + /* istanbul ignore else */ + if (references[key].through) { + schema = models[references[key].through].options.$schema; + } + + /* istanbul ignore else */ + if (!walkCallback(obj, key, prop, schema, parent)) { + obj[key] = walk(obj[key], key, obj, schema); + } + } else { + /* istanbul ignore else */ + if (references[prop] && models[references[prop].model]) { + schema = models[references[prop].model].options.$schema; + } else if (schema.properties && key !== '$upload') { + schema = schema.properties[key]; + } + + /* istanbul ignore else */ + if (!walkCallback(obj, key, prop, schema, parent)) { + obj[key] = walk(obj[key], key, obj, schema); + } + } + }); + } + return obj; + })(data, null, null, _schema); +} + function _buildTasks(references, inputData, modelName, _options, models) { const tasks = { before: [], after: [], + rows: [], }; - if (_options.attachments) { - // eslint-disable-next-line wrap-iife - (function walk(obj, prop, model, parent) { - /* istanbul ignore else */ - if (!obj || typeof obj !== 'object') return obj; + _walkInput(inputData, models[modelName].options.$schema, models, references, (obj, key, prop, schema, parent) => { + /* istanbul ignore else */ + if (key === null) { + tasks.before.push(() => { + tasks.rows.push([obj, Object.keys(obj)]); + }); + } - /* istanbul ignore else */ - if (Array.isArray(obj)) return obj.map(x => walk(x, prop, model, parent)); + /* istanbul ignore else */ + if (key === '$upload') { + tasks.before.unshift(() => _saveUpload(prop, obj, _options.attachments).then(result => { + if (schema.type === 'array') { + /* istanbul ignore else */ + if (Array.isArray(result)) { + result.forEach((v, i) => Object.assign(parent[prop][i], v)); + } + } else if (schema.type === 'object') { + parent[prop] = result; + } else if (schema.type === 'string') { + const parts = ['url:']; - Object.keys(obj).forEach(key => { - if (key === '$upload') { - tasks.before.push(() => _saveUpload(prop, obj, _options.attachments).then(result => { - parent[prop] = result; - })); - } else if (_isData(obj[key])) { - tasks.before.push(() => _saveBase64(key, obj, _options.attachments, _options.upload).then(result => { - obj[key] = result; - })); + /* istanbul ignore else */ + if (result.type) parts.push(result.type, ';'); + /* istanbul ignore else */ + if (result.size) parts.push(result.size, ','); + /* istanbul ignore else */ + if (result.name) parts.push(result.name, '@'); + + parent[prop] = parts.concat(result.path).join(''); } else { - obj[key] = walk(obj[key], key, model, obj); + parent[prop] = result; } - }); - return obj; - })(inputData, null, models[modelName]); - } - - Object.keys(inputData).forEach(prop => { - const _schema = models[modelName].options.$schema.properties[prop]; - const _attr = models[modelName].rawAttributes[prop] || {}; + })); + return true; + } /* istanbul ignore else */ - if (!_schema) { - throw new Error(`Missing schema for '${prop}' (given ${JSON.stringify(inputData[prop])})`); + if (_isData(obj[key])) { + tasks.before.unshift(() => _saveBase64(key, obj, _options.attachments, _options.upload).then(result => { + obj[key] = result; + })); + return true; } + }); - if (_attr.references && typeof inputData[prop] === 'object') { - const _ref = _attr.type.options.Model; - const _pk = _ref.primaryKeyAttribute; - - tasks.before.push(_opts => _ref.create(inputData[prop], { logging: _opts.logging }) - .then(result => { - inputData[prop] = _fixPkNull(result, _ref)[_pk]; - })); - } else { - const assoc = models[modelName].associations && models[modelName].associations[prop]; - const ref = references[prop]; + tasks.after.push(async (fk, opts, isUpdate) => { + const defaults = { logging: opts.logging, transaction: opts.transaction }; - /* istanbul ignore else */ - if (ref && assoc) { - const through = ref.through && typeof ref.through.model === 'string' ? ref.through.model : ref.through; - const fields = ref.references.primaryKeys.map(x => x.prop); + function update(model, input, where, key, pk) { + return model.update({ ...input, [key]: undefined }, { ...defaults, where: { ...where, [key]: pk } }); + } - const items = !Array.isArray(inputData[prop]) - ? [inputData[prop]] - : inputData[prop]; + async function upsert(key, model, [context, properties]) { + const refs = model.associations; - const _ref = models[ref.model]; - const _pk = _ref.primaryKeyAttribute; - const _attrs = through && models[through].rawAttributes; + for (const field of properties) { + /* istanbul ignore else */ + if (refs[field]) { + const input = context[field]; + const { + target, through, associationType, targetKey, foreignKey, foreignIdentifier, + } = refs[field]; - items.filter(Boolean).forEach(item => { /* istanbul ignore else */ - if (_attrs) { - Object.keys(_attrs).forEach(field => { - /* istanbul ignore else */ - if (_attrs[field].references && typeof item[field] === 'object') { - // doUpload(_schema.items || _schema, field, item, models[through]); - - tasks.before.push(_opts => _ref.create(item[field], { logging: _opts.logging }) - .then(result => { - item[field] = _fixPkNull(result, _ref)[_pk]; - })); - } - }); - } + if (associationType === 'BelongsToMany') { + for (const item of input) { + const other = (through && through.model) || model; - tasks.after.push((fk, opts, isUpdate) => { - const logging = opts.logging; + await upsert(key, other, tasks.rows.shift()); - const _value = item[ref.model]; - const _keys = Object.keys(_ref.primaryKeys); - const _isNew = _value && !_keys.every(x => _value[x]); - const _whereOpts = fields.reduce((prev, cur) => { - /* istanbul ignore else */ - if (item[cur]) { - prev[cur] = item[cur]; + if (isUpdate) { + const where = { + [foreignKey]: key, + }; + + /* istanbul ignore else */ + if (item[targetKey]) { + where[targetKey] = item[targetKey]; + } else if (item[foreignIdentifier]) { + where[foreignIdentifier] = item[foreignIdentifier]; + } + + delete item[targetKey]; + await other.update(item, { ...defaults, where }); + } else { + item[foreignKey] = key; + const row = await other.create(item, defaults); + item[targetKey] = row[targetKey]; } - - delete item[cur]; - return prev; - }, {}); - - const props = { - [assoc.foreignKey]: fk, - }; - - const refs = Object.keys(_ref.associations).reduce((memo, cur) => { - /* istanbul ignore else */ - if (_value && _value[cur]) memo.push(_ref.associations[cur]); - return memo; - }, []); - - /* istanbul ignore else */ - if (!isUpdate && _value && _value[_pk]) { - item[through ? assoc.otherKey : assoc.foreignKey] = _value[_pk]; } + } - /* istanbul ignore else */ - if (item[assoc.otherKey] && !_whereOpts[_ref.primaryKeyAttribute]) { - _whereOpts[assoc.otherKey] = item[assoc.otherKey]; - - delete item[assoc.otherKey]; + /* istanbul ignore else */ + if (associationType === 'BelongsTo') { + let pk; + if (input[targetKey]) { + await update(target, input, null, targetKey, pk = input[targetKey]); + } else { + const row = await target.create(input, defaults); + pk = input[targetKey] = row[targetKey]; } - /* istanbul ignore else */ - if (_isNew) { - /* istanbul ignore else */ - if (through) { - return _ref.create(_value || {}, { logging, include: refs }) - .then(x => _fixPkNull(x, _ref)) - .then(x => models[through].create(Object.assign({ [assoc.otherKey]: x[_pk] }, item, props)), { logging }) - .then(x => _fixPkNull(x, models[through])); - } - } else if (through) { - /* istanbul ignore else */ - if (!_whereOpts[models[through].primaryKeyAttribute]) { - _whereOpts[assoc.foreignKey] = props[assoc.foreignKey]; + context[foreignKey] = pk; + delete context[field]; + await upsert(pk, target, tasks.rows.shift()); + } - delete props[assoc.foreignKey]; + /* istanbul ignore else */ + if (associationType === 'HasMany') { + const pk = target.primaryKeyAttribute; + + for (const item of input) { + if (item[pk]) { + await update(target, item, null, pk, item[pk]); + } else { + item[foreignKey] = key; + await target.create(item, defaults); } + } + } - /* istanbul ignore else */ - if (isUpdate) { - return _options.noupdate !== true && models[through] - .update(Object.assign(item, props), { where: _whereOpts, logging }); - } + /* istanbul ignore else */ + if (associationType === 'HasOne') { + const pk = target.primaryKeyAttribute; - return models[through] - .create(Object.assign(item, props, _whereOpts), { logging, include: refs }) - .then(x => _fixPkNull(x, models[through])); - } else if (isUpdate) { - return _options.noupdate !== true && _ref - .update(item, { where: Object.assign(props, _whereOpts), logging, include: refs }); + let row = input; + if (Array.isArray(input)) { + row = context[field] = input[0]; } - /* istanbul ignore else */ - if (Array.isArray(inputData[prop]) && inputData[prop].length > 1) { - return Promise.all(inputData[prop] - .map(x => _ref.create(Object.assign(x, props), { logging, incude: refs }).then(y => _fixPkNull(y, _ref)))); + if (row[pk]) { + await update(target, row, null, pk, row[pk]); + } else { + context[foreignKey] = row[foreignKey] = key; + await target.create(row, defaults); } - - return _ref.create(Object.assign( - (Array.isArray(inputData[prop]) && inputData[prop][0]) || inputData[prop], - typeof item === 'object' ? item : null, - props), { logging, include: refs }) - .then(x => _fixPkNull(x, _ref)); - }); - }); + } + } } } + + await upsert(fk, models[modelName], tasks.rows.shift()); }); return tasks; @@ -406,7 +361,7 @@ function _mixOptions(source, target) { Object.keys(source).forEach(key => { if (!target[key]) { target[key] = source[key]; - } else { + } else if (!['include', 'transaction'].includes(key)) { target[key] = _mixOptions(source[key], target[key] || {}); } }); @@ -483,10 +438,12 @@ function _packOptions(action, model, obj) { props.model = target; props.as = key; + /* istanbul ignore else */ if (model.associations[key].through) { const refName = model.associations[key].through.model.name; const srcName = model.associations[key].target.name; + /* istanbul ignore else */ if (props[refName]) { props.include = props.include.concat(props[refName][srcName].include); props.attributes = props.attributes.concat(props[refName][srcName].attributes); @@ -513,11 +470,9 @@ function _packOptions(action, model, obj) { return obj; } -function _getOpts(model, props, action, params) { +function _getOpts(model, props, action, options) { const fields = Object.assign({}, model.options.$attributes || {}); - const attrs = fields[action] - || fields.findAll - || []; + const attrs = fields[action] || (action !== 'count' ? fields.findAll : null) || []; const obj = { include: [], @@ -526,27 +481,26 @@ function _getOpts(model, props, action, params) { /* istanbul ignore else */ if (fields.where) { - params.where = Object.assign({}, params.where, fields.where); + options.where = Object.assign({}, options.where, fields.where); } /* istanbul ignore else */ - if (params.search) { + if (options.search) { const lookup = fields.search || fields.findAll; const orWhere = []; - let query = params.search.replace(/[^^$\w]+/g, '%'); + let query = options.search.replace(/[^^$\w]+/g, '%'); query = `%${query}%`.replace(/^%\^/, '').replace(/\$%$/, ''); - // FIXME: nested lookup is not well-tested! lookup.forEach(key => { if (!key.includes('.')) { orWhere.push({ [key]: { [Op.like]: query } }); } else { const [ref] = key.split('.'); - params.include = params.include || []; - params.include.push({ + options.include = options.include || []; + options.include.push({ model: model.associations[ref].target, as: ref, }); @@ -555,9 +509,9 @@ function _getOpts(model, props, action, params) { } }); - delete params.search; - params.where = params.where || {}; - params.where[Op.or] = orWhere; + delete options.search; + options.where = options.where || {}; + options.where[Op.or] = orWhere; } attrs.forEach(field => { @@ -596,7 +550,7 @@ function _getOpts(model, props, action, params) { } }); - return _packOptions(action, model, _mixOptions(params, obj)); + return _packOptions(action, model, _mixOptions(options, obj)); } module.exports = (conn, options, modelName) => { @@ -754,7 +708,7 @@ module.exports = (conn, options, modelName) => { return Promise.resolve() .then(() => { - _payload = _cleanData(payload || options.payload, model); + _payload = _cleanData({ ...(payload || options.payload) }, model); _tasks = _buildTasks(_props, _payload, model.name, options, conn.models); _opts = _getOpts(model, _props, isUpdate ? 'update' : 'create', _options); }) @@ -765,7 +719,7 @@ module.exports = (conn, options, modelName) => { /* istanbul ignore else */ if (!Array.isArray(row)) { - pk = _fixPkNull(row, model)[model.primaryKeyAttribute]; + pk = row[model.primaryKeyAttribute]; } /* istanbul ignore else */ @@ -790,26 +744,10 @@ module.exports = (conn, options, modelName) => { _options.logging = options.logging || _options.logging; _options.where = Object.assign({}, _options.where, _where); - const _pk = model.primaryKeyAttribute; const _opts = _getOpts(model, _props, method, _options); - const _names = _opts.include.map(inc => inc.as); - - // see: https://github.com/sequelize/sequelize/pull/9384 - _opts.raw = true; - _opts.plain = false; return Promise.resolve() .then(() => model[method](_opts)) - .then(data => { - /* istanbul ignore else */ - if (method.indexOf('find') === 0) { - const result = _unflattenObj(_pk, _names, data, instance.options.refs); - - return method === 'findOne' ? result[0] : result; - } - - return data; - }) .then(ok) .catch(err); } diff --git a/lib/util.js b/lib/util.js index dcafffb..c60a104 100644 --- a/lib/util.js +++ b/lib/util.js @@ -469,6 +469,12 @@ function makeFK(properties, model, type, sub, db) { } function makeRefs(models, defns, conn) { + function log(msg) { + if (conn.options.logging) { + conn.options.logging(`Associate: ${msg}`); + } + } + Object.keys(models).map(model => { const a = models[model]; @@ -482,6 +488,8 @@ function makeRefs(models, defns, conn) { /* istanbul ignore else */ if (models[b.target].model.virtual !== true) { + log(`${a.model.name} ${b.method} ${b.target}`); + a.model[b.method](models[b.target].model, copy(b.params)); /* istanbul ignore else */ @@ -490,6 +498,11 @@ function makeRefs(models, defns, conn) { ? { model: b.params.through } : b.params.through; + /* istanbul ignore else */ + if (models[b.target] && models[through.model]) { + models[through.model].model.options.$schema.properties[b.target] = models[b.target].model.options.$schema; + } + const _unique = through.unique !== false; const _options = conn.models[through.model].options; @@ -513,6 +526,10 @@ function makeRefs(models, defns, conn) { _options.$references = {}; _options.$dependencies = {}; } else { + log(`${through.model} belongsTo ${b.target}`); + + models[through.model].model.belongsTo(models[b.target].model); + // sync foreign-keys makeFK(_options.$schema.properties, a.model, b.method, _unique, conn.dialect.name); makeFK(_options.$schema.properties, models[b.target].model, b.method, _unique, conn.dialect.name); @@ -546,50 +563,51 @@ function makeRefs(models, defns, conn) { }); return a; - }) - .filter(x => x) - .forEach(a => { - Object.keys(a.model.rawAttributes).forEach(prop => { - /* istanbul ignore else */ - if (a.model.rawAttributes[prop].references) { - const refs = a.model.rawAttributes[prop].references; - const type = a.model.rawAttributes[prop].type; + }).forEach(a => { + /* istanbul ignore else */ + if (!a) return; - /* istanbul ignore else */ - if (!a.model.options.$schema.properties[prop] - && !defns[a.model.name].$schema.properties[prop] - && type.options.Model.options.$schema.properties[refs.key]) { - // append associated refs - a.model.options.$schema.properties[prop] = defns[a.model.name].$schema.properties[prop] = { - type: type.options.Model.options.$schema.properties[refs.key].type, - }; - } + Object.keys(a.model.rawAttributes).forEach(prop => { + /* istanbul ignore else */ + if (a.model.rawAttributes[prop].references) { + const refs = a.model.rawAttributes[prop].references; + const type = a.model.rawAttributes[prop].type; + + /* istanbul ignore else */ + if (!a.model.options.$schema.properties[prop] + && !defns[a.model.name].$schema.properties[prop] + && type.options.Model.options.$schema.properties[refs.key]) { + // append associated refs + a.model.options.$schema.properties[prop] = defns[a.model.name].$schema.properties[prop] = { + type: type.options.Model.options.$schema.properties[refs.key].type, + }; } + } - const b = a.model.rawAttributes[prop]; + const b = a.model.rawAttributes[prop]; + /* istanbul ignore else */ + if (a.model.rawAttributes[prop].primaryKey && b.type.options.Model) { /* istanbul ignore else */ - if (a.model.rawAttributes[prop].primaryKey && b.type.options.Model) { - /* istanbul ignore else */ - if (b.Model !== b.type.options.Model) { - a.refs[prop] = { - method: 'belongsTo', - target: b.type.options.Model.name, - params: { - through: b.type.options.Model.name, - as: prop, - }, - }; + if (b.Model !== b.type.options.Model) { + a.refs[prop] = { + method: 'belongsTo', + target: b.type.options.Model.name, + params: { + through: b.type.options.Model.name, + as: prop, + }, + }; - a.model.options.$schema.required = a.model.options.$schema.required || []; - a.model.options.$schema.required.push(prop); + a.model.options.$schema.required = a.model.options.$schema.required || []; + a.model.options.$schema.required.push(prop); - defns[a.model.name].$schema.required = defns[a.model.name].$schema.required || []; - defns[a.model.name].$schema.required.push(prop); - } + defns[a.model.name].$schema.required = defns[a.model.name].$schema.required || []; + defns[a.model.name].$schema.required.push(prop); } - }); + } }); + }); } function sortModels(deps) { diff --git a/package.json b/package.json index de55cb1..e0ebb14 100644 --- a/package.json +++ b/package.json @@ -24,28 +24,27 @@ "coverage:unit": "npm run coverage -- npm run test:unit", "codecov": "codecov --file=coverage/lcov.info -e TRAVIS_NODE_VERSION", "report": "nyc report", - "pretest": "npm run lint" + "_pretest": "npm run lint" }, "devDependencies": { "chai": "^4.2.0", "codecov": "^3.1.0", - "eslint": "^7.0.0", + "eslint": "^7.2.0", "eslint-config-airbnb-base": "^14.0.0", - "eslint-plugin-import": "^2.8.0", + "eslint-plugin-import": "^2.18.2", "mocha": "^8.2.1", "nyc": "^15.1.0", "pg": "^8.7.3", "sqlite3": "^5.0.0", "testdouble": "^3.16.6" }, - "optionalDependencies": {}, "dependencies": { "@types/json-schema": "^7.0.9", "@types/umzug": "^2.3.2", "fs-extra": "^10.1.0", "glob": "^8.0.3", "json-schema-ref-parser": "^9.0.1", - "sequelize": "^6.12.2", + "sequelize": "^6.28.0", "type-fest": "^2.0.0", "umzug": "^3.1.1", "wargs": "^0.9.1" diff --git a/tests/fixtures/relations/shopping_cart/Product/schema.json b/tests/fixtures/relations/shopping_cart/Product/schema.json index 1d28155..201e535 100644 --- a/tests/fixtures/relations/shopping_cart/Product/schema.json +++ b/tests/fixtures/relations/shopping_cart/Product/schema.json @@ -21,6 +21,9 @@ }, "image2": { "$ref": "File" + }, + "attachment": { + "type": "string" } }, "required": [ diff --git a/tests/main.test.js b/tests/main.test.js index b5f4e0a..e453b77 100644 --- a/tests/main.test.js +++ b/tests/main.test.js @@ -181,14 +181,20 @@ describe('JSONSchemaSequelizer()', () => { }, })) .then(() => Blog.actions.findOne({ search: '^osom' })) - .then(result => expect(result).to.eql({ - id: 3, - name: 'Osom blog!', - myPosts: [ - { BlogId: 3, id: 3, title: 'This thing works' }, - ], - featuredPost: { featuredPostId: 3, id: 4, title: 'OK' }, - })); + .then(result => { + expect(result.toJSON()).to.eql({ + id: 3, + name: 'Osom blog!', + myPosts: [ + { + featuredPostId: null, BlogId: 3, id: 3, title: 'This thing works', + }, + ], + featuredPost: { + featuredPostId: 3, BlogId: null, id: 4, title: 'OK', + }, + }); + }); }); }); }); diff --git a/tests/res.test.js b/tests/res.test.js index 04a9035..fff98a1 100644 --- a/tests/res.test.js +++ b/tests/res.test.js @@ -1,3 +1,4 @@ +const td = require('testdouble'); const { expect } = require('chai'); const JSONSchemaSequelizer = require('../lib'); const t = require('./_sequelize'); @@ -17,7 +18,11 @@ const settings = [ }, ]; -/* global describe, it */ +/* global afterEach, describe, it */ + +afterEach(() => { + td.reset(); +}); settings.forEach(config => { let jss = null; @@ -37,7 +42,7 @@ settings.forEach(config => { jss.models.Cart.options.$attributes = { findOne: ['items.name', 'items.price'], }; - }); + }).catch(console.log); }); it('should skip primaryKeys when unique is false', () => { @@ -60,24 +65,24 @@ settings.forEach(config => { ], }; - return Promise.resolve().then(() => { - return jss.models.Product.create({ + return Promise.resolve() + .then(() => jss.models.Product.create({ name: 'Test', price: 1.23, - }); - }).then(() => { - return Cart.actions.create(data); - }).then(([row]) => { - return row.getItems({ - order: ['createdAt'], - }); - }) + })) + .then(() => jss.sequelize.transaction()) + .then(_t => { + return Cart.actions.create(data, { + transaction: config.dialect === 'sqlite' ? _t : null, + }).then(result => _t.commit().then(() => result)); + }) + .then(([row]) => row.getItems({ order: ['createdAt'] })) .then(result => { const fixedData = result.map(x => { return [x.name, parseFloat(x.price), x.CartItem.qty]; - }); + }).sort((a, b) => b[2] - a[2]); - expect(fixedData).to.eql([['Test', 1.23, 4], ['One', 0.99, 5]]); + expect(fixedData).to.eql([['One', 0.99, 5], ['Test', 1.23, 4]]); }); }); @@ -159,36 +164,65 @@ settings.forEach(config => { }); }); - it('should create data from nested associations ', () => { - return Promise.resolve() - .then(() => Cart.actions.create({ - items: [ - { qty: 2, Product: { name: 'Example', price: 0.20 } }, - { qty: 2, Product: { id: 2, name: 'One', price: 0.99 } }, - { qty: 2, Product: { id: 1, name: 'Test', price: 1.23 } }, - ], - })) - .then(([row]) => Cart.actions.findOne({ where: { id: row.id } }).then(x => { - expect((x.items.reduce((a, b) => a + (b.Product.price * b.qty), 0)).toFixed(2)).to.eql('4.84'); - })) - .then(() => Cart.actions.count().then(x => expect(x).to.eql(1))) - .then(() => jss.models.Product.count().then(x => expect(x).to.eql(3))) - .then(() => jss.models.CartItem.count().then(x => expect(x).to.eql(3))); + it('should create data from nested associations', async () => { + const [row] = await Cart.actions.create({ + items: [ + { qty: 2, Product: { name: 'Example', price: 0.20 } }, + { qty: 3, Product: { id: 2, name: 'One', price: 0.99 } }, + { qty: 4, Product: { id: 1, name: 'Test', price: 1.23 } }, + ], + }); + + expect(await Cart.actions.count()).to.eql(1); + expect(await jss.models.Product.count()).to.eql(3); + expect(await jss.models.CartItem.count()).to.eql(3); + + const result = await Cart.actions.findOne({ where: { id: row.id } }); + + expect((result.items.reduce((a, b) => a + (b.Product.price * b.qty), 0)).toFixed(2)).to.eql('8.29'); }); - it('should update data from nested associations ', () => { + it('should update data from nested associations', () => { return Promise.resolve() .then(() => Cart.actions.update({ items: [ - { id: 4, qty: 1, Product: { id: 1 } }, - { id: 5, qty: 1, Product: { id: 2 } }, + { id: 4, qty: 1, ProductId: 1 }, + { id: 5, qty: 1, Product: { id: 2, name: 'OSOM' } }, ], }, { where: { id: 2 } })) .then(() => Cart.actions.findOne({ where: { id: 2 } }).then(x => { + x.items.sort((a, b) => b.ProductId - a.ProductId); expect((x.items.reduce((a, b) => a + (b.Product.price * b.qty), 0)).toFixed(2)).to.eql('2.62'); + expect(x.items.map(p => [p.Product.id, p.Product.name].join('.'))).to.eql(['3.Example', '2.OSOM', '1.Test']); })); }); + it('should update attachments from nested associations', async () => { + const Attachment = JSONSchemaSequelizer.resource(jss, 'Attachment'); + const File = JSONSchemaSequelizer.resource(jss, 'File'); + + const payload = { + label: 'xxx', + File: { + kind: 'ATTACHMENT', + name: 'brightfox_logo.png', + type: 'image/png', + size: 13774, + path: 'tmp/6e13a9f4d09b7a8d03e55fe00.png', + }, + }; + + await File.actions.create(payload.File); + await Attachment.actions.create({ id: payload.id, label: payload.label, FileId: 1 }); + await Attachment.actions.create(payload); + + expect(await Attachment.actions.count()).to.eql(2); + expect(await File.actions.count()).to.eql(2); + expect(await Attachment.actions.update({ ...payload, File: { ...payload.File, id: 2 } })).to.eql([2]); + expect(await Attachment.actions.count()).to.eql(2); + expect(await File.actions.count()).to.eql(2); + }); + it('should update data from single associations', () => { const Product = JSONSchemaSequelizer.resource(jss, 'Product'); @@ -215,37 +249,79 @@ settings.forEach(config => { attachments: { files: { foo: { - path: '/tmp/uploads/bar', + path: '/tmp/uploads/foo', }, baz: [{ path: '/tmp/uploads/buzz', }, { path: '/tmp/uploads/bazzinga', }], + test: { + path: '/tmp/uploads/h12v3gj4byg2f34', + name: 'testing', + type: 'any/type', + size: 1234, + }, }, baseDir: '/tmp', uploadDir: 'uploads', }, }, 'Product'); + jss.models.Product.options.$attributes = { + findOne: ['name', 'price', 'image.path', 'images.path', 'attachment'], + }; + + td.replace(Date, 'now'); + td.when(Date.now()).thenReturn(0); + return Promise.resolve() .then(() => Product.actions.create({ name: 'Test', price: 0.99, - image: 'data:foo/bar;base64,x', + image: 'data:mime/type;base64,x', image2: { $upload: 'foo', }, images: [ { $upload: 'baz' }, ], + attachment: { + $upload: 'test', + }, })) - .then(([, result]) => { + .then(([x, result]) => { + expect(x.id).to.eql(result.id); expect(result.image.imageId).to.eql(result.id); expect(result.image2.image2Id).to.eql(result.id); - expect(result.image2.path).to.eql('uploads/bar'); + expect(result.image2.path).to.eql('uploads/foo'); expect(result.images[0].ProductId).to.eql(result.id); + expect(result.images[1].ProductId).to.eql(result.id); expect(result.images[1].path).to.eql('uploads/bazzinga'); + expect(result.attachment).to.eql('url:any/type;1234,testing@uploads/h12v3gj4byg2f34'); + + return Product.actions.findOne({ where: { id: x.id } }).then(sample => { + sample = sample.toJSON(); + sample.price = parseFloat(sample.price); + sample.images.sort((a, b) => b.id - a.id); + expect(sample).to.eql({ + id: 4, + name: 'Test', + price: 0.99, + attachment: 'url:any/type;1234,testing@uploads/h12v3gj4byg2f34', + image: { + ProductId: null, fileId: null, image2Id: null, imageId: 4, id: 3, path: 'uploads/0_mime.type', + }, + images: [ + { + fileId: null, image2Id: null, imageId: null, ProductId: 4, id: 6, path: 'uploads/bazzinga', + }, + { + fileId: null, image2Id: null, imageId: null, ProductId: 4, id: 5, path: 'uploads/buzz', + }, + ], + }); + }); }); }); @@ -258,6 +334,12 @@ settings.forEach(config => { ok: { path: '/tmp/uploads/bar', }, + not: [{ + path: '/tmp/uploads/buah', + }], + test: { + path: '/tmp/uploads/OSOM', + }, }, baseDir: '/tmp', uploadDir: 'uploads', @@ -265,11 +347,11 @@ settings.forEach(config => { }, 'Cart'); jss.models.Product.options.$attributes = { - findOne: ['name', 'price', 'image.path'], + findOne: ['name', 'price', 'image.path', 'images.path'], }; jss.models.Cart.options.$attributes = { - findOne: ['items.name', 'items.price', 'items.image.path'], + findOne: ['items.name', 'items.price', 'items.image.path', 'items.images.path'], }; return Promise.resolve() @@ -281,6 +363,7 @@ settings.forEach(config => { name: 'Test', price: 0.99, image: { $upload: 'ok' }, + images: [{ $upload: 'not' }, { $upload: 'test' }], }, }, ], @@ -288,12 +371,45 @@ settings.forEach(config => { .then(([row]) => Cart.actions.findOne({ where: { id: row.id } })) .then(result => { expect(result.items[0].Product.image.path).to.eql('uploads/bar'); + expect(result.items[0].Product.images.length).to.eql(2); return Product.actions.findOne({ where: { id: result.items[0].ProductId } }) - .then(data => expect(data.image.path).to.eql('uploads/bar')); - }); + .then(data => { + data.images.sort((a, b) => a.id - b.id); + expect(data.image.path).to.eql('uploads/bar'); + expect(data.images[0].path).to.eql('uploads/buah'); + expect(data.images[1].path).to.eql('uploads/OSOM'); + }); + }) + .then(() => Cart.actions.update({ + items: [ + { + id: 6, + qty: 3, + Product: { + id: 5, + name: 'OSOM', + image: { id: 7, path: 'OK' }, + images: [{ id: 8, path: 'WUT' }, { $upload: 'ok', path: 'OTHER' }], + }, + }, + ], + }, { where: { id: 3 } })) + .then(() => Cart.actions.findOne({ where: { id: 3 } }).then(result => { + expect(result.items[0].qty).to.eql(3); + expect(result.items[0].Product.name).to.eql('OSOM'); + expect(result.items[0].Product.image.path).to.eql('OK'); + expect(result.items[0].Product.images.length).to.eql(3); + return Product.actions.findOne({ where: { id: 5 } }).then(data => { + data.images.sort((a, b) => a.id - b.id); + expect(data.image.path).to.eql('OK'); + expect(data.images[0].path).to.eql('WUT'); + expect(data.images[1].path).to.eql('uploads/OSOM'); + expect(data.images[2].path).to.eql('OTHER'); + }); + })); }); - it('should process uploads from associated foreignKeys', () => { + it('should process uploads from associated models', () => { const Attachment = JSONSchemaSequelizer.resource(jss, { attachments: { files: { @@ -309,9 +425,9 @@ settings.forEach(config => { return Promise.resolve() .then(() => Attachment.actions.create({ label: 'test', - FileId: { $upload: 'ok' }, + File: { $upload: 'ok' }, })).then(([, result]) => { - expect(result).to.eql({ label: 'test', FileId: 6, id: 1 }); + expect(result).to.eql({ label: 'test', FileId: 11, id: 3 }); }); }); diff --git a/tests/umzug.test.js b/tests/umzug.test.js index eebbb68..5f5032f 100644 --- a/tests/umzug.test.js +++ b/tests/umzug.test.js @@ -43,7 +43,7 @@ describe('Umzug support', () => { await JSONSchemaSequelizerCLI.execute(db, 'migrate'); const { callCount, calls } = td.explain(log); - expect(callCount).to.eql(18); + expect(callCount).to.eql(22); expect(calls.pop().args).to.eql(['\rNo pending migrations']); expect(calls.pop().args).to.eql(['\rNo executed migrations']); }); @@ -52,8 +52,8 @@ describe('Umzug support', () => { await JSONSchemaSequelizerCLI.execute(db, 'migrate', { flags: { make: true } }); const { calls } = td.explain(log); - expect(calls.pop().args[0]).to.match(/write.*create_blog/); expect(calls.pop().args[0]).to.match(/write.*create_family/); + expect(calls.pop().args[0]).to.match(/write.*create_blog/); expect(calls.pop().args[0]).to.match(/write.*create_person/); expect(calls.pop().args[0]).to.match(/write.*create_post/); }); @@ -64,14 +64,14 @@ describe('Umzug support', () => { const { calls } = td.explain(log); expect(calls.pop().args).to.eql(['\r4 migrations were applied']); - expect(calls.pop().args[0]).to.match(/migrated.*create_blog/); - calls.length -= 5; - - expect(calls.pop().args[0]).to.match(/migrating.*create_blog/); expect(calls.pop().args[0]).to.match(/migrated.*create_family/); calls.length -= 5; expect(calls.pop().args[0]).to.match(/migrating.*create_family/); + expect(calls.pop().args[0]).to.match(/migrated.*create_blog/); + calls.length -= 5; + + expect(calls.pop().args[0]).to.match(/migrating.*create_blog/); expect(calls.pop().args[0]).to.match(/migrated.*create_person/); calls.length -= 5;