From de515469fd3218d12d7c1e9bd90ae6931a50072e Mon Sep 17 00:00:00 2001 From: Alvaro Cabrera Date: Sat, 31 Dec 2022 20:25:26 -0600 Subject: [PATCH] chore: rework walking strategy to support references --- lib/res.js | 346 +++++++++--------- .../shopping_cart/Example/schema.json | 3 +- .../relations/shopping_cart/File/schema.json | 7 +- tests/res.test.js | 34 +- 4 files changed, 212 insertions(+), 178 deletions(-) diff --git a/lib/res.js b/lib/res.js index 03699ad..ccceb71 100644 --- a/lib/res.js +++ b/lib/res.js @@ -99,7 +99,7 @@ function _fileInfo(baseDir, data, obj) { }; } -function _pushUpload(key, payload, metadata, destFile, onUploadCallback) { +function _pushUpload(payload, metadata, destFile, onUploadCallback) { if (Array.isArray(metadata)) { return Promise.all(metadata.map(item => { /* istanbul ignore else */ @@ -107,7 +107,6 @@ function _pushUpload(key, payload, metadata, destFile, onUploadCallback) { return onUploadCallback({ payload, destFile, - field: key, metadata: item, }); } @@ -123,16 +122,15 @@ function _pushUpload(key, payload, metadata, destFile, onUploadCallback) { payload, metadata, destFile, - field: key, }); } return payload; } -function _saveBase64(key, payload, attachments, onUploadCallback) { +async function _saveBase64(payload, attachments, onUploadCallback) { const baseDir = attachments.baseDir || process.cwd(); - const details = payload[key].match(RE_DATA)[1].split(';'); - const base64Data = payload[key].replace(RE_DATA, ''); + const details = payload.$upload.match(RE_DATA)[1].split(';'); + const base64Data = payload.$upload.replace(RE_DATA, ''); const fileName = details[1] ? `_${details[1].split('name=')[1]}` : `_${details[0].replace(/\W+/g, '.')}`; const destFile = path.join(baseDir, attachments.uploadDir || 'uploads', `${Date.now()}${fileName}`); @@ -148,128 +146,116 @@ function _saveBase64(key, payload, attachments, onUploadCallback) { type: details[0], }; - return Promise.resolve() - .then(() => _pushUpload(key, {}, metadata, destFile, onUploadCallback)) - .then(result => result || metadata); + delete payload.$upload; + return _pushUpload(payload, metadata, destFile, onUploadCallback); } -function _saveUpload(field, payload, attachments, onUploadCallback) { - return Promise.resolve() - .then(() => { - const baseDir = attachments.baseDir || process.cwd(); - const input = _fileInfo(baseDir, attachments.files[payload.$upload], payload); +async function _saveUpload(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 || input.filepath, onUploadCallback); - }); + return _pushUpload(payload, input, input.path || input.filepath, onUploadCallback); } -function _walkInput(data, _schema, models, references, walkCallback) { +function _fixedURL(params) { + const parts = ['url:']; + + /* istanbul ignore else */ + if (params.type) parts.push(params.type, ';'); + /* istanbul ignore else */ + if (params.size) parts.push(params.size, ','); + /* istanbul ignore else */ + if (params.name) parts.push(params.name, '@'); + + return parts.concat(params.path).join(''); +} + +function _walkInput(data, model, models, references, walkCallback) { // eslint-disable-next-line wrap-iife - (function walk(obj, prop, parent, rootSchema) { + (function walk(obj, ref, prop, parent, schema, keypath) { /* 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)); + return obj.forEach((x, i) => walk(x, ref, i, obj, schema.items, keypath.concat(i))); } - /* istanbul ignore else */ - if (!walkCallback(obj, null, null, rootSchema, parent)) { - const _skip = []; - 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; + if (ref && obj[ref.through]) { + const next = obj[ref.through]; - /* istanbul ignore else */ - if (references[key].through) { - schema = models[references[key].through].options.$schema; - } + delete obj[ref.through]; + next[ref.model] = obj; + parent[prop] = next; + return; + } - /* istanbul ignore else */ - if (!_skip.includes(key) && !walkCallback(obj, key, prop, schema, parent)) { - obj[key] = walk(obj[key], key, obj, schema); - } - } else { - /* istanbul ignore else */ - if (references[prop] && references[prop].through && obj[references[prop].through]) { - _skip.push(...Object.keys(references[references[prop].model].properties)); - _skip.push(references[prop].through); - - const ref = obj[references[prop].through]; - - delete obj[references[prop].through]; - obj = { ...ref, [references[prop].model]: obj }; - schema = references[references[prop].through]; - } 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(null, prop, obj, parent, schema, keypath)) { + Object.keys(obj).forEach(key => { + let _schema = schema.properties[key]; + /* istanbul ignore else */ + if (!walkCallback(key, prop, obj[key], obj, _schema, keypath.concat(key))) { + const info = references[key]; /* istanbul ignore else */ - if (!_skip.includes(key) && !walkCallback(obj, key, prop, schema, parent)) { - obj[key] = walk(obj[key], key, obj, schema); + if (info && info.through) { + _schema = info.hasManyItems + ? { items: models[info.through].options.$schema } + : models[info.through].options.$schema; } + walk(obj[key], info, key, obj, _schema, keypath.concat(key)); } }); } return obj; - })(data, null, null, _schema); + })(data, null, null, null, models[model].options.$schema, []); } -function _fixedURL(params) { - const parts = ['url:']; - - /* istanbul ignore else */ - if (params.type) parts.push(params.type, ';'); - /* istanbul ignore else */ - if (params.size) parts.push(params.size, ','); - /* istanbul ignore else */ - if (params.name) parts.push(params.name, '@'); +function _handleFailure(e, name, keypath, association) { + const suffix = association ? ` (${association})` : ''; - return parts.concat(params.path).join(''); + if (!keypath) { + throw new Error(`${e.message} while calling ${name}${suffix}`, { cause: e }); + } else if (!name) { + throw new Error(`${e.message}\n on ${keypath.join('.')}${suffix}`, { cause: e }); + } else { + throw new Error(`${e.message}\n on ${name}.${keypath.join('.')}${suffix}`, { cause: e }); + } } function _buildTasks(references, inputData, modelName, _options, models) { const tasks = { before: [], after: [], - rows: [], + refs: [], }; - _walkInput(inputData, models[modelName].options.$schema, models, references, (obj, key, prop, schema, parent) => { + _walkInput(inputData, modelName, models, references, (key, prop, value, parent, schema, keypath) => { /* istanbul ignore else */ - if (key === null) { - tasks.before.push(() => tasks.rows.push(obj)); - } - + if (!value) return true; /* 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') { - parent[prop] = _fixedURL(result); - } else { - parent[prop] = result; - } - })); + if (key === null) tasks.refs.push(value); + /* istanbul ignore else */ + if (_isData(value.$upload)) { + tasks.before.unshift(() => _saveBase64(value, _options.attachments, _options.upload) + .then(result => { + parent[key] = schema && schema.type === 'string' ? _fixedURL(result) : result; + }).catch(e => _handleFailure(e, modelName, keypath))); return true; } - /* istanbul ignore else */ - if (_isData(obj[key])) { - tasks.before.unshift(() => _saveBase64(key, obj, _options.attachments, _options.upload).then(result => { - obj[key] = schema.type === 'string' ? _fixedURL(result) : result; - })); + if (value.$upload) { + tasks.before.unshift(() => _saveUpload(value, _options.attachments, _options.upload) + .then(result => { + if (schema && schema.type === 'string') { + parent[key] = _fixedURL(result); + } else if (Array.isArray(parent)) { + parent.splice(prop, 1, ...[].concat(result)); + } else { + parent[key] = result; + } + }).catch(e => _handleFailure(e, modelName, keypath))); return true; } }); @@ -281,112 +267,138 @@ function _buildTasks(references, inputData, modelName, _options, models) { return model.update({ ...input, [key]: undefined }, { ...defaults, where: { ...where, [key]: pk } }); } - async function upsert(key, model, context) { + async function upsert(key, model, source, context, keypath = []) { const props = Object.keys(context); const refs = model.associations; for (const field of props) { - /* istanbul ignore else */ - if (refs[field] && context[field]) { - const input = context[field]; - const { - target, through, associationType, targetKey, foreignKey, foreignIdentifier, - } = refs[field]; - + try { /* istanbul ignore else */ - if (associationType === 'BelongsToMany') { - for (const item of input) { - const other = (through && through.model) || model; + if (refs[field] && context[field]) { + const input = context[field]; + const { + target, through, associationType, targetKey, foreignKey, foreignIdentifier, + } = refs[field]; - await upsert(key, target, tasks.rows.shift()); + /* istanbul ignore else */ + if (associationType === 'BelongsToMany') { + for (let i = 0; i < input.length; i += 1) { + const item = input[i]; + + try { + const other = (through && through.model) || model; + + /* istanbul ignore else */ + if (tasks.refs.length > 0) { + await upsert(key, target, null, tasks.refs.shift(), keypath.concat([field, i])); + } + + /* istanbul ignore else */ + if (through) { + await upsert(key, other, other.name, item, keypath.concat([field, i])); + } + + if (isUpdate && (item[targetKey] || item[foreignIdentifier])) { + 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]; + const [row, created] = await other.findOrCreate({ ...defaults, where, defaults: item }); + if (!created) await row.update(item); + } else { + item[foreignKey] = key; + const row = await other.create(item, defaults); + item[targetKey] = _fixPkNull(row, other)[targetKey]; + } + } catch (e) { + _handleFailure(e, null, keypath.concat([field, i]), associationType); + } + } + } - /* istanbul ignore else */ - if (through) { - await upsert(key, other, item); + /* 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] = _fixPkNull(row, target)[targetKey]; } - if (isUpdate && (item[targetKey] || item[foreignIdentifier])) { - const where = { - [foreignKey]: key, - }; + /* istanbul ignore else */ + if (cursor && cursor.rawAttributes[foreignKey]) { + await cursor.update({ [foreignKey]: pk }); + } - /* istanbul ignore else */ - if (item[targetKey]) { - where[targetKey] = item[targetKey]; - } else if (item[foreignIdentifier]) { - where[foreignIdentifier] = item[foreignIdentifier]; - } + context[foreignKey] = pk; + delete context[field]; - delete item[targetKey]; - const [row, created] = await other.findOrCreate({ ...defaults, where, defaults: item }); - if (!created) await row.update(item); - } else { - item[foreignKey] = key; - const row = await other.create(item, defaults); - item[targetKey] = _fixPkNull(row, other)[targetKey]; + /* istanbul ignore else */ + if (tasks.refs.length > 0) { + await upsert(pk, target, null, tasks.refs.shift(), keypath.concat(field)); } } - } - - /* 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] = _fixPkNull(row, target)[targetKey]; - } /* istanbul ignore else */ - if (cursor && cursor.rawAttributes[foreignKey]) { - await cursor.update({ [foreignKey]: pk }); + if (associationType === 'HasMany') { + const pk = target.primaryKeyAttribute; + + for (let i = 0; i < input.length; i += 1) { + const item = input[i]; + + try { + if (isUpdate && item[pk]) { + await update(target, item, null, pk, item[pk]); + } else { + item[foreignKey] = key; + await target.create(item, defaults); + } + } catch (e) { + _handleFailure(e, null, keypath.concat([field, i]), associationType); + } + } } - context[foreignKey] = pk; - delete context[field]; - /* istanbul ignore else */ - if (tasks.rows.length > 0) { - await upsert(pk, target, tasks.rows.shift()); - } - } + if (associationType === 'HasOne') { + const pk = target.primaryKeyAttribute; - /* istanbul ignore else */ - if (associationType === 'HasMany') { - const pk = target.primaryKeyAttribute; + let row = input; + if (Array.isArray(input)) { + row = context[field] = input[0]; + } - for (const item of input) { - if (isUpdate && item[pk]) { - await update(target, item, null, pk, item[pk]); + if (isUpdate && row[pk]) { + await update(target, row, null, pk, row[pk]); } else { - item[foreignKey] = key; - await target.create(item, defaults); + context[foreignKey] = row[foreignKey] = key; + await target.create(row, defaults); } } } + } catch (e) { + const target = !keypath.length ? `${model.name}${key ? `(${key})` : ''}` : null; + const through = keypath.length > 0 && source ? `${field}<${source}>` : field; - /* istanbul ignore else */ - if (associationType === 'HasOne') { - const pk = target.primaryKeyAttribute; - - let row = input; - if (Array.isArray(input)) { - row = context[field] = input[0]; - } - - if (isUpdate && row[pk]) { - await update(target, row, null, pk, row[pk]); - } else { - context[foreignKey] = row[foreignKey] = key; - await target.create(row, defaults); - } - } + _handleFailure(e, target, keypath.concat(through)); } } } - await upsert(fk, models[modelName], tasks.rows.shift()); + try { + await upsert(fk, models[modelName], null, tasks.refs.shift()); + } catch (e) { + _handleFailure(e, `${modelName}#${isUpdate ? 'update' : 'create'}`); + } }); return tasks; diff --git a/tests/fixtures/relations/shopping_cart/Example/schema.json b/tests/fixtures/relations/shopping_cart/Example/schema.json index 710346d..96fbb36 100644 --- a/tests/fixtures/relations/shopping_cart/Example/schema.json +++ b/tests/fixtures/relations/shopping_cart/Example/schema.json @@ -3,7 +3,8 @@ "properties": { "id": { "type": "integer", - "primaryKey": true + "primaryKey": true, + "autoIncrement": true }, "title": { "type": "string" diff --git a/tests/fixtures/relations/shopping_cart/File/schema.json b/tests/fixtures/relations/shopping_cart/File/schema.json index c08a50b..3a0654a 100644 --- a/tests/fixtures/relations/shopping_cart/File/schema.json +++ b/tests/fixtures/relations/shopping_cart/File/schema.json @@ -6,6 +6,10 @@ "primaryKey": true, "autoIncrement": true }, + "kind": { + "type": "string", + "enum": ["ATTACHMENT", "DOWNLOAD", "BACKUP"] + }, "mtime": { "type": "string", "format": "datetime" @@ -22,5 +26,6 @@ "type": { "type": "string" } - } + }, + "required": ["kind"] } diff --git a/tests/res.test.js b/tests/res.test.js index fff98a1..aed5f54 100644 --- a/tests/res.test.js +++ b/tests/res.test.js @@ -199,6 +199,7 @@ settings.forEach(config => { it('should update attachments from nested associations', async () => { const Attachment = JSONSchemaSequelizer.resource(jss, 'Attachment'); + const Example = JSONSchemaSequelizer.resource(jss, 'Example'); const File = JSONSchemaSequelizer.resource(jss, 'File'); const payload = { @@ -213,14 +214,24 @@ settings.forEach(config => { }; await File.actions.create(payload.File); - await Attachment.actions.create({ id: payload.id, label: payload.label, FileId: 1 }); await Attachment.actions.create(payload); + await Attachment.actions.create({ label: payload.label }); - expect(await Attachment.actions.count()).to.eql(2); expect(await File.actions.count()).to.eql(2); + expect(await Attachment.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); + + await Example.actions.create({ + title: 'TITLE', + fileset: [{ + ...payload.File, + Attachment: { label: 'LABEL' }, + }], + }); + expect(await File.actions.count()).to.eql(2); + expect(await Attachment.actions.count()).to.eql(3); }); it('should update data from single associations', () => { @@ -279,14 +290,19 @@ settings.forEach(config => { .then(() => Product.actions.create({ name: 'Test', price: 0.99, - image: 'data:mime/type;base64,x', + image: { + kind: 'ATTACHMENT', + $upload: 'data:mime/type;base64,x', + }, image2: { + kind: 'ATTACHMENT', $upload: 'foo', }, images: [ - { $upload: 'baz' }, + { kind: 'ATTACHMENT', $upload: 'baz' }, ], attachment: { + kind: 'ATTACHMENT', $upload: 'test', }, })) @@ -362,8 +378,8 @@ settings.forEach(config => { Product: { name: 'Test', price: 0.99, - image: { $upload: 'ok' }, - images: [{ $upload: 'not' }, { $upload: 'test' }], + image: { $upload: 'ok', kind: 'DOWNLOAD' }, + images: [{ $upload: 'not', kind: 'ATTACHMENT' }, { $upload: 'test', kind: 'BACKUP' }], }, }, ], @@ -389,7 +405,7 @@ settings.forEach(config => { id: 5, name: 'OSOM', image: { id: 7, path: 'OK' }, - images: [{ id: 8, path: 'WUT' }, { $upload: 'ok', path: 'OTHER' }], + images: [{ id: 8, path: 'WUT' }, { $upload: 'ok', path: 'OTHER', kind: 'BACKUP' }], }, }, ], @@ -425,9 +441,9 @@ settings.forEach(config => { return Promise.resolve() .then(() => Attachment.actions.create({ label: 'test', - File: { $upload: 'ok' }, + File: { $upload: 'ok', kind: 'BACKUP' }, })).then(([, result]) => { - expect(result).to.eql({ label: 'test', FileId: 11, id: 3 }); + expect(result).to.eql({ label: 'test', FileId: 11, id: 4 }); }); });