diff --git a/src/bone.js b/src/bone.js index 11cbbcba..2a5f6f95 100644 --- a/src/bone.js +++ b/src/bone.js @@ -21,6 +21,8 @@ const { ASSOCIATE_METADATA_MAP, } = require('./constants'); +const columnAttributesKey = Symbol('leoric#columns'); + function looseReadonly(props) { return Object.keys(props).reduce((result, name) => { result[name] = { @@ -206,9 +208,9 @@ class Bone { * @memberof Bone */ _clone(target) { - this.#raw = Object.assign({}, target.getRaw()); - this.#rawSaved = Object.assign({}, target.getRawSaved()); - this.#rawPrevious = Object.assign({}, target.getRawPrevious()); + this.#raw = Object.assign({}, this.getRaw(), target.getRaw()); + this.#rawSaved = Object.assign({}, this.getRawSaved(), target.getRawSaved()); + this.#rawPrevious = Object.assign({}, this.getRawPrevious(), target.getRawPrevious()); } /** @@ -237,6 +239,34 @@ class Bone { return attributes.hasOwnProperty(name); } + /** + * get attributes except virtuals + */ + static get columnAttributes() { + if (this[columnAttributesKey]) return this[columnAttributesKey]; + const { attributes } = this; + this[columnAttributesKey] = {}; + for (const key in this.attributes) { + if (!attributes[key].virtual) this[columnAttributesKey][key] = attributes[key]; + } + return this[columnAttributesKey]; + } + + /** + * get actual update/insert columns to avoid empty insert or update + * @param {Object} data + * @returns + */ + static _getColumns(data) { + if (!Object.keys(data).length) return data; + const attributes = this.columnAttributes; + const res = {}; + for (const key in data) { + if (attributes[key]) res[key] = data[key]; + } + return res; + } + getRaw(key) { if (key) return this.#raw[key]; return this.#raw; @@ -580,7 +610,10 @@ class Bone { if (this.changed(name)) data[name] = this.attribute(name); } - if (Object.keys(data).length === 0) return Promise.resolve(0); + if (!Object.keys(Model._getColumns(data)).length) { + this.syncRaw(); + return Promise.resolve(0); + } const { createdAt, updatedAt } = Model.timestamps; @@ -660,7 +693,10 @@ class Bone { } } - if (Object.keys(changes).length === 0) return Promise.resolve(0); + if (!Object.keys(Model._getColumns(changes)).length) { + this.syncRaw(); + return Promise.resolve(0); + } if (this[primaryKey] == null) { throw new Error(`unset primary key ${primaryKey}`); } @@ -718,7 +754,6 @@ class Bone { for (const name in attributes) { const value = this.attribute(name); const { defaultValue } = attributes[name]; - // console.log(attributes[name], name, defaultValue); if (value != null) { data[name] = value; } else if (value === undefined && defaultValue != null) { @@ -732,6 +767,11 @@ class Bone { this._validateAttributes(validateValues); } + if (!Object.keys(Model._getColumns(data)).length) { + this.syncRaw(); + return this; + } + const spell = new Spell(Model, opts).$insert(data); return spell.later(result => { this[primaryKey] = result.insertId; @@ -859,7 +899,10 @@ class Bone { } } - if (Object.keys(data).length === 0) return Promise.resolve(0); + if (!Object.keys(Model._getColumns(data)).length) { + this.syncRaw(); + return Promise.resolve(0); + } const { createdAt, updatedAt } = Model.timestamps; @@ -980,6 +1023,7 @@ class Bone { for (const hookName of hookNames) { if (this[hookName]) setupSingleHook(this, hookName, this[hookName]); } + this[columnAttributesKey] = null; } /** @@ -1115,6 +1159,7 @@ class Bone { Reflect.deleteProperty(this.prototype, originalName); this.loadAttribute(newName); } + this[columnAttributesKey] = null; } /** @@ -1191,6 +1236,9 @@ class Bone { const { className } = opts; const Model = this.models[className]; if (!Model) throw new Error(`unable to find model "${className}"`); + if (opts.foreignKey && Model.attributes[opts.foreignKey] && Model.attributes[opts.foreignKey].virtual) { + throw new Error(`unable to use virtual attribute ${opts.foreignKey} as foreign key in model ${Model.name}`); + } const { deletedAt } = this.timestamps; if (Model.attributes[deletedAt] && !opts.where) { @@ -1569,6 +1617,7 @@ class Bone { return result; }, {}); + this[columnAttributesKey] = null; Object.defineProperties(this, looseReadonly({ ...hookMethods, attributes, table })); } @@ -1588,7 +1637,7 @@ class Bone { throw new Error('unable to sync model with custom physic tables'); } - const { attributes, columns } = this; + const { columnAttributes: attributes, columns } = this; const columnMap = columns.reduce((result, entry) => { result[entry.columnName] = entry; return result; diff --git a/src/collection.js b/src/collection.js index 0cb7699a..462a0826 100644 --- a/src/collection.js +++ b/src/collection.js @@ -63,14 +63,14 @@ class Collection extends Array { */ function instantiatable(spell) { const { columns, groups, Model } = spell; - const { attributes, tableAlias } = Model; + const { columnAttributes, tableAlias } = Model; if (groups.length > 0) return false; if (columns.length === 0) return true; return columns .filter(({ qualifiers }) => (!qualifiers || qualifiers.includes(tableAlias))) - .every(({ value }) => attributes[value]); + .every(({ value }) => columnAttributes[value]); } /** diff --git a/src/data_types.js b/src/data_types.js index b3ecef60..7dc88f9b 100644 --- a/src/data_types.js +++ b/src/data_types.js @@ -475,6 +475,18 @@ class JSONB extends JSON { } } +class VIRTUAL extends DataType { + constructor() { + super(); + this.dataType = 'virtual'; + this.virtual = true; + } + + toSqlString() { + return 'VIRTUAL'; + } +} + const DataTypes = { STRING, TINYINT, @@ -491,6 +503,7 @@ const DataTypes = { JSONB, BINARY, VARBINARY, + VIRTUAL, }; Object.assign(DataType, DataTypes); diff --git a/src/drivers/abstract/attribute.js b/src/drivers/abstract/attribute.js index 1a3b9373..37a685c1 100644 --- a/src/drivers/abstract/attribute.js +++ b/src/drivers/abstract/attribute.js @@ -9,6 +9,7 @@ const { snakeCase } = require('../../utils/string'); * @param {string} dataType */ function findJsType(DataTypes, type, dataType) { + if (type instanceof DataTypes.VIRTUAL) return ''; if (type instanceof DataTypes.BOOLEAN) return Boolean; if (type instanceof DataTypes.JSON) return JSON; if (type instanceof DataTypes.BINARY || type instanceof DataTypes.BLOB) { @@ -118,6 +119,7 @@ class Attribute { defaultValue, dataType, jsType: findJsType(DataTypes, type, dataType), + virtual: type.virtual, }); } diff --git a/src/drivers/abstract/spellbook.js b/src/drivers/abstract/spellbook.js index 37d55a33..c84bc07b 100644 --- a/src/drivers/abstract/spellbook.js +++ b/src/drivers/abstract/spellbook.js @@ -146,7 +146,7 @@ function qualify(spell) { const baseName = Model.tableAlias; const clarify = node => { if (node.type === 'id' && !node.qualifiers) { - if (Model.attributes[node.value]) node.qualifiers = [baseName]; + if (Model.columnAttributes[node.value]) node.qualifiers = [baseName]; } }; @@ -335,7 +335,7 @@ function formatDelete(spell) { * @param {Spell} spell */ function formatInsert(spell) { - const { Model, sets, attributes: optAttrs, updateOnDuplicate } = spell; + const { Model, sets, columnAttributes: optAttrs, updateOnDuplicate } = spell; const { shardingKey } = Model; const { createdAt } = Model.timestamps; const { escapeId } = Model.driver; @@ -345,22 +345,22 @@ function formatInsert(spell) { let values = []; let placeholders = []; if (Array.isArray(sets)) { - // merge records to get the big picture of involved attributes + // merge records to get the big picture of involved columnAttributes const involved = sets.reduce((result, entry) => { return Object.assign(result, entry); }, {}); - const attributes = []; + const columnAttributes = []; if (optAttrs) { for (const name in optAttrs) { - if (involved.hasOwnProperty(name)) attributes.push(attributes[name]); + if (involved.hasOwnProperty(name)) columnAttributes.push(columnAttributes[name]); } } else { for (const name in involved) { - attributes.push(Model.attributes[name]); + columnAttributes.push(Model.columnAttributes[name]); } } - for (const entry of attributes) { + for (const entry of columnAttributes) { columns.push(entry.columnName); if (updateOnDuplicate && createdAt && entry.name === createdAt && !(Array.isArray(updateOnDuplicate) && updateOnDuplicate.includes(createdAt))) continue; @@ -371,11 +371,11 @@ function formatInsert(spell) { if (shardingKey && entry[shardingKey] == null) { throw new Error(`Sharding key ${Model.table}.${shardingKey} cannot be NULL.`); } - for (const attribute of attributes) { + for (const attribute of columnAttributes) { const { name } = attribute; values.push(entry[name]); } - placeholders.push(`(${new Array(attributes.length).fill('?').join(',')})`); + placeholders.push(`(${new Array(columnAttributes.length).fill('?').join(',')})`); } } else { @@ -488,7 +488,7 @@ function formatUpdate(spell) { function formatUpdateOnDuplicate(spell, columns) { const { updateOnDuplicate, uniqueKeys, Model } = spell; if (!updateOnDuplicate) return ''; - const { attributes, primaryColumn } = Model; + const { columnAttributes, primaryColumn } = Model; const { escapeId } = Model.driver; const actualUniqueKeys = []; @@ -499,9 +499,9 @@ function formatUpdateOnDuplicate(spell, columns) { } else { // conflict_target must be unique // get all unique keys - if (attributes) { - for (const key in attributes) { - const att = attributes[key]; + if (columnAttributes) { + for (const key in columnAttributes) { + const att = columnAttributes[key]; // use the first unique key if (att.unique) { actualUniqueKeys.push(escapeId(att.columnName)); @@ -515,9 +515,9 @@ function formatUpdateOnDuplicate(spell, columns) { } if (Array.isArray(updateOnDuplicate) && updateOnDuplicate.length) { - columns = updateOnDuplicate.map(column => (attributes[column] && attributes[column].columnName )|| column); + columns = updateOnDuplicate.map(column => (columnAttributes[column] && columnAttributes[column].columnName )|| column); } else if (!columns.length) { - columns = Object.values(attributes).map(({ columnName }) => columnName); + columns = Object.values(columnAttributes).map(({ columnName }) => columnName); } const updateKeys = columns.map((column) => `${escapeId(column)}=EXCLUDED.${escapeId(column)}`); diff --git a/src/drivers/mysql/spellbook.js b/src/drivers/mysql/spellbook.js index 796cf402..2c285e41 100644 --- a/src/drivers/mysql/spellbook.js +++ b/src/drivers/mysql/spellbook.js @@ -37,19 +37,19 @@ module.exports = { const { updateOnDuplicate, Model } = spell; if (!updateOnDuplicate) return null; const { escapeId } = Model.driver; - const { attributes, primaryColumn } = Model; + const { columnAttributes, primaryColumn } = Model; if (Array.isArray(updateOnDuplicate) && updateOnDuplicate.length) { - columns = updateOnDuplicate.map(column => (attributes[column] && attributes[column].columnName ) || column) + columns = updateOnDuplicate.map(column => (columnAttributes[column] && columnAttributes[column].columnName ) || column) .filter(column => column !== primaryColumn); } else if (!columns.length) { - columns = Object.values(attributes).map(attribute => attribute.columnName).filter(column => column !== primaryColumn); + columns = Object.values(columnAttributes).map(attribute => attribute.columnName).filter(column => column !== primaryColumn); } const sets = []; // Make sure the correct LAST_INSERT_ID is returned. // - https://stackoverflow.com/questions/778534/mysql-on-duplicate-key-last-insert-id - // if insert attributes include primary column, `primaryKey = LAST_INSERT_ID(primaryKey)` is not need any more + // if insert columnAttributes include primary column, `primaryKey = LAST_INSERT_ID(primaryKey)` is not need any more if (!columns.includes(primaryColumn)) { sets.push(`${escapeId(primaryColumn)} = LAST_INSERT_ID(${escapeId(primaryColumn)})`); } diff --git a/src/drivers/sqlite/spellbook.js b/src/drivers/sqlite/spellbook.js index e04e53a9..f67fe47c 100644 --- a/src/drivers/sqlite/spellbook.js +++ b/src/drivers/sqlite/spellbook.js @@ -8,7 +8,7 @@ function renameSelectExpr(spell) { for (const token of columns) { if (token.type == 'id') { - if (!token.qualifiers && Model.attributes[token.value]) { + if (!token.qualifiers && Model.columnAttributes[token.value]) { token.qualifiers = [Model.tableAlias]; } whitelist.add(token.qualifiers[0]); diff --git a/src/expr_formatter.js b/src/expr_formatter.js index 3676001c..966e6ef3 100644 --- a/src/expr_formatter.js +++ b/src/expr_formatter.js @@ -203,6 +203,9 @@ function coerceLiteral(spell, ast) { const attribute = model && model.attributes[firstArg.value]; if (!attribute) return; + if (attribute.virtual) { + throw new Error(`unable to use virtual attribute ${attribute.name} in model ${model.name}`); + } for (const arg of args.slice(1)) { if (arg.type === 'literal') { diff --git a/src/spell.js b/src/spell.js index 089ae0f6..02ae1238 100644 --- a/src/spell.js +++ b/src/spell.js @@ -13,30 +13,53 @@ const { parseObject } = require('./query_object'); const Raw = require('./raw'); const { AGGREGATOR_MAP } = require('./constants'); +/** + * check condition to avoid use virtual fields as where condtions + * @param {Bone} Model + * @param {Array} conds + */ +function checkCond(Model, conds) { + if (Array.isArray(conds)) { + for (const cond of conds) { + if (cond.type === 'id' && cond.value != null) { + if (Model.attributes[cond.value] && Model.attributes[cond.value].virtual) { + throw new Error(`unable to use virtual attribute ${cond.value} as condition in model ${Model.name}`); + } + } else if (cond.type === 'op' && cond.args && cond.args.length) { + checkCond(Model, cond.args); + } + } + } +} + /** * Parse condition expressions * @example - * parseConditions({ foo: { $op: value } }); - * parseConditions('foo = ?', value); + * parseConditions(Model, { foo: { $op: value } }); + * parseConditions(Model, 'foo = ?', value); + * @param {Bone} Model * @param {(string|Object)} conditions * @param {...*} values * @returns {Array} */ -function parseConditions(conditions, ...values) { +function parseConditions(Model, conditions, ...values) { if (conditions instanceof Raw) return [ conditions ]; + let conds; if (isPlainObject(conditions)) { - return parseObject(conditions); + conds = parseObject(conditions); } else if (typeof conditions == 'string') { - return [parseExpr(conditions, ...values)]; + conds = [parseExpr(conditions, ...values)]; } else { throw new Error(`unexpected conditions ${conditions}`); } + checkCond(Model, conds); + return conds; } function parseSelect(spell, ...names) { const { joins, Model } = spell; if (typeof names[0] === 'function') { - names = Object.keys(Model.attributes).filter(names[0]); + names = Object.keys(Model.columnAttributes).filter(names[0]); } else { names = names.reduce((result, name) => result.concat(name), []); } @@ -53,7 +76,10 @@ function parseSelect(spell, ...names) { if (type != 'id') return; const qualifier = qualifiers && qualifiers[0]; const model = qualifier && joins && (qualifier in joins) ? joins[qualifier].Model : Model; - if (!model.attributes[value]) { + if (!model.columnAttributes[value]) { + if (model.attributes[value]) { + throw new Error(`unable to use virtual attribute ${value} as field in model ${model.name}`); + } throw new Error(`unable to find attribute ${value} in model ${model.name}`); } }); @@ -63,9 +89,9 @@ function parseSelect(spell, ...names) { } /** - * Translate key-value pairs of attributes into key-value pairs of columns. Get ready for the SET part when generating SQL. + * Translate key-value pairs of columnAttributes into key-value pairs of columns. Get ready for the SET part when generating SQL. * @param {Spell} spell - * @param {Object} obj - key-value pairs of attributes + * @param {Object} obj - key-value pairs of columnAttributes * @param {boolean} strict - check attribute exist or not * @returns {Object} */ @@ -73,11 +99,11 @@ function formatValueSet(spell, obj, strict = true) { const { Model } = spell; const sets = {}; for (const name in obj) { - const attribute = Model.attributes[name]; + const attribute = Model.columnAttributes[name]; const value = obj[name]; - if (!attribute && strict) { - throw new Error(`Undefined attribute "${name}"`); + if (!attribute) { + continue; } // raw sql don't need to uncast @@ -91,9 +117,9 @@ function formatValueSet(spell, obj, strict = true) { } /** - * Translate key-value pairs of attributes into key-value pairs of columns. Get ready for the SET part when generating SQL. + * Translate key-value pairs of columnAttributes into key-value pairs of columns. Get ready for the SET part when generating SQL. * @param {Spell} spell - * @param {Object|Array} obj - key-value pairs of attributes + * @param {Object|Array} obj - key-value pairs of columnAttributes */ function parseSet(spell, obj) { let sets; @@ -139,7 +165,7 @@ function joinOnConditions(spell, BaseModel, baseName, refName, { where, associat }; if (!where) where = association.where; if (where) { - const whereConditions = walkExpr(parseConditions(where)[0], node => { + const whereConditions = walkExpr(parseConditions(BaseModel, where)[0], node => { if (node.type == 'id') node.qualifiers = [refName]; }); return { type: 'op', name: 'and', args: [ onConditions, whereConditions ] }; @@ -212,7 +238,7 @@ function joinAssociation(spell, BaseModel, baseName, refName, opts = {}) { const columns = parseSelect({ Model: RefModel }, select); for (const token of columns) { walkExpr(token, node => { - if (node.type === 'id' && !node.qualifiers && RefModel.attributes[node.value]) { + if (node.type === 'id' && !node.qualifiers && RefModel.columnAttributes[node.value]) { node.qualifiers = [refName]; } }); @@ -284,7 +310,7 @@ class Spell { /** * Create a spell. * @param {Model} Model - A sub class of {@link Bone}. - * @param {Object} opts - Extra attributes to be set. + * @param {Object} opts - Extra columnAttributes to be set. */ constructor(Model, opts = {}) { if (Model.synchronized == null) { @@ -299,7 +325,7 @@ class Spell { const { deletedAt } = Model.timestamps; // FIXME: need to implement paranoid mode - if (Model.attributes[deletedAt] && opts.paranoid !== false) { + if (Model.columnAttributes[deletedAt] && opts.paranoid !== false) { scopes.push(scopeDeletedAt); } @@ -502,7 +528,7 @@ class Spell { } /** - * Whitelist attributes to select. Can be called repeatedly to select more attributes. + * Whitelist columnAttributes to select. Can be called repeatedly to select more columnAttributes. * @param {...string} names * @example * .select('title'); @@ -531,7 +557,7 @@ class Spell { const { timestamps } = Model; this.command = 'update'; if (!Number.isFinite(by)) throw new Error(`unexpected increment value ${by}`); - if (!Model.attributes.hasOwnProperty(name)) { + if (!Model.columnAttributes.hasOwnProperty(name)) { throw new Error(`undefined attribute "${name}"`); } @@ -590,7 +616,8 @@ class Spell { * @returns {Spell} */ $where(conditions, ...values) { - this.whereConditions.push(...parseConditions(conditions, ...values)); + const Model = this.Model; + this.whereConditions.push(...parseConditions(Model, conditions, ...values)); return this; } @@ -600,15 +627,16 @@ class Spell { const combined = whereConditions.slice(1).reduce((result, condition) => { return { type: 'op', name: 'and', args: [result, condition] }; }, whereConditions[0]); + const Model = this.Model; this.whereConditions = [ { type: 'op', name: 'or', args: - [combined, ...parseConditions(conditions, ...values)] } + [combined, ...parseConditions(Model, conditions, ...values)] } ]; return this; } /** - * Set GROUP BY attributes. `select_expr` with `AS` is supported, hence following expressions have the same effect: + * Set GROUP BY columnAttributes. `select_expr` with `AS` is supported, hence following expressions have the same effect: * * .select('YEAR(createdAt)) AS year').group('year'); * @@ -619,9 +647,12 @@ class Spell { * @returns {Spell} */ $group(...names) { - const { columns, groups } = this; + const { columns, groups, Model } = this; for (const name of names) { + if (Model.attributes[name] && Model.attributes[name].virtual) { + throw new Error(`unable to use virtual attribute ${name} as group column in model ${Model.name}`); + } const token = parseExpr(name); if (token.type === 'alias') { groups.push({ type: 'id', value: token.value }); @@ -666,10 +697,12 @@ class Spell { }); } else { - this.orders.push([ + const order = [ parseExpr(name), direction && direction.toLowerCase() == 'desc' ? 'desc' : 'asc' - ]); + ]; + checkCond(this.Model, order); + this.orders.push(order); } return this; } @@ -710,10 +743,11 @@ class Spell { * @returns {Spell} */ $having(conditions, ...values) { - for (const condition of parseConditions(conditions, ...values)) { + const Model = this.Model; + for (const condition of parseConditions(Model, conditions, ...values)) { // Postgres can't have alias in HAVING caluse // https://stackoverflow.com/questions/32730296/referring-to-a-select-aggregate-column-alias-in-the-having-clause-in-postgres - if (this.Model.driver.type === 'postgres' && !(condition instanceof Raw)) { + if (Model.driver.type === 'postgres' && !(condition instanceof Raw)) { const { value } = condition.args[0]; for (const column of this.columns) { if (column.value === value && column.type === 'alias') { @@ -784,7 +818,7 @@ class Spell { if (qualifier in joins) { throw new Error(`invalid join target. ${qualifier} already defined.`); } - joins[qualifier] = { Model, on: parseConditions(onConditions, ...values)[0] }; + joins[qualifier] = { Model, on: parseConditions(Model, onConditions, ...values)[0] }; return this; } diff --git a/test/integration/suite/basics.test.js b/test/integration/suite/basics.test.js index 39eea28b..21031980 100644 --- a/test/integration/suite/basics.test.js +++ b/test/integration/suite/basics.test.js @@ -2,7 +2,6 @@ const assert = require('assert').strict; const expect = require('expect.js'); -const sinon = require('sinon'); const { Collection, Bone } = require('../../..'); const Book = require('../../models/book'); @@ -11,28 +10,17 @@ const Post = require('../../models/post'); const TagMap = require('../../models/tagMap'); const User = require('../../models/user'); const Tag = require('../../models/tag'); -const { logger } = require('../../../src/utils'); function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } describe('=> Basic', () => { - let stub; - - before(() => { - stub = sinon.stub(logger, 'warn').callsFake((message) => { - throw new Error(message); - }); - }); - - after(() => { - if (stub) stub.restore(); - }); - describe('=> Attributes', function() { beforeEach(async function() { await Post.remove({}, true); + await User.remove({}, true); + await Post.create({ title: 'New Post', extra: { versions: [2, 3] }, @@ -42,6 +30,7 @@ describe('=> Basic', () => { afterEach(async function () { await Post.remove({}, true); + await User.remove({}, true); }); it('bone.attribute(name)', async function() { @@ -52,6 +41,12 @@ describe('=> Basic', () => { assert.throws(() => post.attribute('missing attribute'), /no attribute/); }); + it('bone.attribute(name) should work with VIRTUAL', async function() { + const user = new User({ nickname: 'Jer', realname: 'Jerry' }); + assert.equal(user.attribute('nickname'), 'JER'); + assert.equal(user.attribute('realname'), 'Jerry'); + }); + it('bone.attribute(unset attribute)', async function() { const post = await Post.first.select('title'); assert.deepEqual(post.thumb, undefined); @@ -67,6 +62,13 @@ describe('=> Basic', () => { assert.equal(post.attribute('title', 'Untitled'), post); }); + it('bone.attribute(name, value) should work with VIRTUAL', async function() { + const user = new User({ nickname: 'Jer', realname: 'Jerry' }); + assert.equal(user.attribute('realname'), 'Jerry'); + assert.equal(user.attribute('realname', 'yoxi'), user); + assert.equal(user.attribute('realname'), 'yoxi'); + }); + it('bone.attribute(unset attribute, value)', async function() { const post = await Post.first.select('title'); expect(() => post.attribute('thumb', 'foo')).to.not.throwException(); @@ -80,15 +82,28 @@ describe('=> Basic', () => { expect(post.hasAttribute()).to.be(false); }); + it('bone.hasAttribute(key) should work with VIRTUAL', async function() { + await User.create({ nickname: 'yes', email: 'ee@1.com' }); + let user = await User.first.select('nickname'); + expect(user.hasAttribute('nickname')).to.be(true); + expect(user.hasAttribute('NotExist')).to.be(false); + expect(user.hasAttribute()).to.be(false); + }); + it('Bone.hasAttribute(key) should work', async function() { expect(Post.hasAttribute('thumb')).to.be(true); expect(Post.hasAttribute('NotExist')).to.be(false); expect(Post.hasAttribute()).to.be(false); + expect(User.hasAttribute('realname')).to.be(true); }); - it('bone.attributeWas(name) should be undefined when initialized', async function() { + it('bone.attributeWas(name) should be null when initialized', async function() { const post = new Post({ title: 'Untitled' }); expect(post.attributeWas('createdAt')).to.be(null); + // VIRTUAL + const user = new User({ nickname: 'Jer' }); + expect(user.attributeWas('realname')).to.be(null); + }); it('bone.attributeWas(name) should return original value if instance is persisted before', async function() { @@ -96,6 +111,15 @@ describe('=> Basic', () => { const titleWas = post.title; post.title = 'Skeleton King'; expect(post.attributeWas('title')).to.eql(titleWas); + // VIRTUAL + const user = await User.create({ nickname: 'yes', email: 'ee@1.com', realname: 'yes' }); + assert.equal(user.realname, 'yes'); + user.realname = 'Jerry'; + expect(user.attributeWas('realname')).to.eql('yes'); + assert.equal(user.realname, 'Jerry'); + await user.reload(); + expect(user.attributeWas('realname')).to.eql('yes'); + assert.equal(user.realname, 'Jerry'); }); it('bone.attributeChanged(name)', async function() { @@ -106,6 +130,14 @@ describe('=> Basic', () => { expect(post.attributeChanged('createdAt')).to.be(true); }); + it('bone.attributeChanged(name) should wok with VRITUAL', async function() { + const user = new User({ nickname: 'Jer' }); + expect(user.realname).to.not.be.ok(); + expect(user.attributeChanged('realname')).to.be(false); + user.realname = 'Yhorm'; + expect(user.attributeChanged('realname')).to.be(true); + }); + it('bone.attributeChanged(name) should be false when first fetched', async function() { const post = await Post.findOne({}); expect(post.attributeChanged('createdAt')).to.be(false); @@ -145,6 +177,16 @@ describe('=> Basic', () => { expect(post2.attribute('thumb').name, 'thumb'); }); + it('Bone.renameAttribute(name, newName) should work with VIRTUAL', async function() { + const user = await User.create({ nickname: 'yes', email: 'ee@1.com', realname: 'yes' }); + expect(user.realname).to.be('yes'); + User.renameAttribute('realname', 'yaho'); + await user.reload(); + expect(user.realname).to.be(undefined); + User.renameAttribute('yaho', 'realname'); + + }); + it('bone.reload()', async function() { const post = await Post.first; await Post.update({ id: post.id }, { title: 'Tyrael' }); @@ -153,6 +195,102 @@ describe('=> Basic', () => { assert.equal(post.title, 'Tyrael'); }); + it('bone.reload() should work with VIRTUAL', async function() { + const user = await User.create({ nickname: 'yes', email: 'ee@1.com', realname: 'yes' }); + assert.equal(user.nickname, 'YES'); + assert.equal(user.realname, 'yes'); + await User.update({ id: user.id }, { nickname: 'Yhorm' }); + await user.reload(); + assert.equal(user.nickname, 'Yhorm'); + assert.equal(user.realname, 'yes'); + }); + + it('bone.select() should not work wihth VIRTUAL', async () => { + await assert.rejects(async () => { + await User.first.select('realname'); + }, /unable to use virtual attribute realname as field in model User/); + + await assert.rejects(async () => { + await User.find({ + realname: 'yes' + }); + }, /unable to use virtual attribute realname as condition in model User/); + + await assert.rejects(async () => { + await User.find({ + $or: [{ + realname: 'yes', + }, { + nickname: 'yes' + }] + }); + }, /unable to use virtual attribute realname as condition in model User/); + + await assert.rejects(async () => { + await User.find({ + $and: [{ + status: 1, + realname: 'yes', + }, { + nickname: 'yes' + }] + }); + }, /unable to use virtual attribute realname as condition in model User/); + + await assert.rejects(async () => { + await User.find('realname=?', 'yes'); + }, /unable to use virtual attribute realname as condition in model User/); + + await assert.rejects(async () => { + await User.find().order('realname'); + }, /unable to use virtual attribute realname as condition in model User/); + + await assert.rejects(async () => { + await User.find().order('realname DESC'); + }, /unable to use virtual attribute realname as condition in model User/); + + await assert.rejects(async () => { + await User.find().order('realname', 'DESC'); + }, /unable to use virtual attribute realname as condition in model User/); + + await assert.rejects(async () => { + await User.find({ + nickname: 'yes' + }).group('realname'); + }, /unable to use virtual attribute realname as group column in model User/); + + await assert.rejects(async () => { + await Post.first.join(User, 'users.realname = articles.authorId'); + }, /unable to use virtual attribute realname as condition in model User/); + + await assert.rejects(async () => { + await Post.first.join(User, `users.id = articles.authorId and users.realname = 'yes'`); + }, /unable to use virtual attribute realname as condition in model User/); + + await assert.rejects(async () => { + Post.hasOne('user', { + foreignKey: 'realname' + }); + }, /unable to use virtual attribute realname as foreign key in model User/); + + Post.hasOne('user', { + foreignKey: 'authorId' + }); + + await assert.rejects(async () => { + await Post.first.with('user').select('user.realname'); + }, /unable to use virtual attribute realname as field in model User/); + + await assert.rejects(async () => { + await Post.include('user').select('user.realname'); + }, /unable to use virtual attribute realname as field in model User/); + + await assert.rejects(async () => { + await Post.first.with('user').where('user.realname = ?', 'yes'); + }, /unable to use virtual attribute realname in model User/); + + }); + it('Bone.previousChanged(key): raw VS rawPrevious', async function () { const post = new Post({ title: 'Untitled', extra: { greeting: 'hello' } }); expect(post.createdAt).to.not.be.ok(); @@ -191,6 +329,44 @@ describe('=> Basic', () => { assert.deepEqual(post.previousChanged().sort(), [ 'extra', 'title', 'updatedAt' ]); }); + it('Bone.previousChanged/previousChanges/changes with VIRTUAL', async function () { + const user = new User({ nickname: 'Jer', realname: 'Yhorm', email: 'ee@yy.com' }); + expect(user.createdAt).to.not.be.ok(); + // should return false before persisting + expect(user.previousChanged('realname')).to.be(false); + + assert.equal(user.previousChanged(), false); + assert.deepEqual(user.previousChanges(), {}); + assert.deepEqual(user.changes(), { email: [ null, 'ee@yy.com' ], level: [ null, 1 ], nickname: [ null, 'JER' ], realname: [ null, 'Yhorm' ], status: [ null, - 1 ] }); + user.realname = 'Yhorm'; + assert.deepEqual(user.changes(), { email: [ null, 'ee@yy.com' ], level: [ null, 1 ], nickname: [ null, 'JER' ], realname: [ null, 'Yhorm' ], status: [ null, - 1 ] }); + await user.save(); + // should return false after first persisting + expect(user.previousChanged('realname')).to.be(true); + assert.deepEqual(user.previousChanged().sort(), [ 'id', 'email', 'nickname', 'status', 'level', 'createdAt', 'realname' ].sort()); + assert.deepEqual(user.previousChanges(), { + id: [ null, user.id ], + email: [ null, 'ee@yy.com' ], + level: [ null, 1 ], + nickname: [ null, 'JER' ], + realname: [ null, 'Yhorm' ], + status: [ null, - 1 ], + createdAt: [ null, user.createdAt ], + }); + assert.deepEqual(user.changes(), {}); + + user.realname = 'Lothric'; + assert.deepEqual(user.changes(), { realname: [ 'Yhorm', 'Lothric' ] }); + + await user.save(); + // should return true after updating + expect(user.previousChanged('realname')).to.be(true); + assert.deepEqual(user.previousChanged(), [ 'realname' ]); + assert.deepEqual(user.previousChanges(), { realname: [ 'Yhorm', 'Lothric' ] }); + assert.deepEqual(user.changes(), {}); + }); + + it('Bone.previousChanges(key): raw VS rawPrevious', async function () { const post = new Post({ title: 'Untitled' }); assert.deepEqual(post.previousChanges('title'), {}); diff --git a/test/models/user.js b/test/models/user.js index 7eb44259..beb559f0 100644 --- a/test/models/user.js +++ b/test/models/user.js @@ -58,6 +58,9 @@ User.init({ birthday: { type: DataTypes.DATE, }, + realname: { + type: DataTypes.VIRTUAL, + } }, {}, { get isValid() { return this.status !== 1; diff --git a/test/unit/bone.test.js b/test/unit/bone.test.js index 258c57c1..c8eadaa0 100644 --- a/test/unit/bone.test.js +++ b/test/unit/bone.test.js @@ -7,7 +7,7 @@ const expect = require('expect.js'); const { TINYINT, MEDIUMINT, BIGINT, STRING, - DATE, + DATE, VIRTUAL, } = DataTypes; describe('=> Bone', function() { @@ -221,6 +221,23 @@ describe('=> Bone', function() { assert.ok(User.timestamps.createdAt); assert.equal(User.attributes.createdAt.columnName, 'gmt_create'); }); + + it('should work with VIRTUAL type', async () => { + class User extends Bone { + static attributes = { + createdAt: DATE, + realName: VIRTUAL, + } + } + assert.deepEqual(Object.keys(User.attributes).sort(), [ 'createdAt', 'realName' ]); + assert.deepEqual(Object.keys(User.columnAttributes).sort(), [ 'createdAt' ]); + User.load([ + { columnName: 'id', columnType: 'bigint', dataType: 'bigint', primaryKey: true }, + { columnName: 'gmt_create', columnType: 'timestamp', dataType: 'timestamp' }, + ]); + assert.deepEqual(Object.keys(User.attributes).sort(), [ 'createdAt', 'id', 'realName' ]); + assert.deepEqual(Object.keys(User.columnAttributes).sort(), [ 'createdAt', 'id' ]); + }); }); describe('=> Bone.loadAttribute()', function() { @@ -239,6 +256,7 @@ describe('=> Bone', function() { User.loadAttribute('foo'); assert.ok(Object.getOwnPropertyDescriptor(User.prototype, 'foo').enumerable); assert.ok(Object.getOwnPropertyDescriptor(User.prototype, 'foo').set); + }); }); @@ -252,7 +270,11 @@ describe('=> Bone', function() { User.load([ { columnName: 'foo', columnType: 'varchar', dataType: 'varchar' }, ]); + assert.deepEqual(Object.keys(User.attributes).sort(), [ 'foo', 'id' ]); + assert.deepEqual(Object.keys(User.columnAttributes).sort(), [ 'foo', 'id' ]); User.renameAttribute('foo', 'bar'); + assert.deepEqual(Object.keys(User.attributes).sort(), [ 'bar', 'id' ]); + assert.deepEqual(Object.keys(User.columnAttributes).sort(), [ 'bar', 'id' ]); assert.ok(Object.getOwnPropertyDescriptor(User.prototype, 'bar').enumerable); }); @@ -315,6 +337,49 @@ describe('=> Bone', function() { expect(note.authorId).to.equal(4); expect(note.updatedAt).to.be.a(Date); }); + + it('should work with VIRTUAL', async function() { + class Note extends Bone { + static attributes = { + authorId: BIGINT, + updatedAt: { type: DATE, allowNull: false }, + halo: VIRTUAL, + } + } + await Note.sync({ force: true }); + const note = await Note.create({ halo: 'yes' }); + expect(note.updatedAt).to.be.a(Date); + assert.equal(note.halo, 'yes'); + }); + + it('should work with VIRTUAL and side effects', async function() { + class Note extends Bone { + static attributes = { + authorId: BIGINT, + updatedAt: { type: DATE, allowNull: false }, + halo: VIRTUAL, + } + get halo() { + return this.attribute('halo'); + } + + set halo(val) { + if (!this.authorId) this.authorId = 0; + this.authorId += 1; + this.attribute('halo', val); + } + } + await Note.sync({ force: true }); + const note = await Note.create({ halo: 'yes' }); + expect(note.updatedAt).to.be.a(Date); + assert.equal(note.halo, 'yes'); + assert.equal(note.authorId, 1); + await note.update({ + halo: 'yo' + }); + assert.equal(note.halo, 'yo'); + assert.equal(note.authorId, 2); + }); }); describe('=> Bone.sync()', function() { @@ -331,6 +396,23 @@ describe('=> Bone', function() { // MySQL 5.x returns column type with length regardless specified or not assert.ok(result.word_count.columnType.startsWith('mediumint')); }); + + it('should work with VIRTUAL', async function() { + class Note extends Bone { + static attributes = { + isPrivate: TINYINT(1), + wordCount: MEDIUMINT, + halo: VIRTUAL, + } + } + await Note.sync({ force: true }); + const result = await Note.describe(); + assert.equal(result.is_private.columnType, 'tinyint(1)'); + // MySQL 5.x returns column type with length regardless specified or not + assert.ok(result.word_count.columnType.startsWith('mediumint')); + assert(!result.halo); + + }); }); describe('=> Bone.hasMany()', function() { diff --git a/test/unit/data_types.test.js b/test/unit/data_types.test.js index e08d3b76..4d84781f 100644 --- a/test/unit/data_types.test.js +++ b/test/unit/data_types.test.js @@ -14,7 +14,7 @@ describe('=> Data Types', () => { DATE, DATEONLY, TINYINT, SMALLINT, MEDIUMINT, INTEGER, BIGINT, JSON, JSONB, - BLOB, BINARY, VARBINARY, + BLOB, BINARY, VARBINARY, VIRTUAL, } = DataTypes; it('STRING', () => { @@ -107,10 +107,16 @@ describe('=> Data Types', () => { // invalid length await assert.rejects(async () => new BLOB('error'), /invalid blob length: error/); }); + + it('VIRTUAL', () => { + assert.equal(new VIRTUAL().dataType, 'virtual'); + assert.equal(new VIRTUAL().toSqlString(), 'VIRTUAL'); + assert.equal(new VIRTUAL().virtual, true); + }); }); describe('=> DataTypes type casting', function() { - const { STRING, BLOB, DATE, DATEONLY, JSON, INTEGER } = DataTypes; + const { STRING, BLOB, DATE, DATEONLY, JSON, INTEGER, VIRTUAL } = DataTypes; it('INTEGER', async () => { assert.equal(new INTEGER().uncast(null), null); @@ -227,6 +233,13 @@ describe('=> DataTypes type casting', function() { assert.equal(new BLOB().cast(buffer).toString(), ''); assert.ok(new BLOB().cast('') instanceof Buffer); }); + + it('VIRTUAL', () => { + assert.equal(new VIRTUAL().cast(null), null); + assert.equal(new VIRTUAL().cast(undefined), undefined); + assert.equal(new VIRTUAL().cast(1), 1); + assert.equal(new VIRTUAL().cast('halo'), 'halo'); + }); }); describe('=> DataTypes.findType()', () => { diff --git a/test/unit/hooks.test.js b/test/unit/hooks.test.js index 20064e3a..4e7c900e 100644 --- a/test/unit/hooks.test.js +++ b/test/unit/hooks.test.js @@ -29,6 +29,7 @@ const attributes = { type: DataTypes.STRING, }, fingerprint: DataTypes.TEXT, + realname: DataTypes.VIRTUAL, }; describe('hooks', function() { @@ -50,6 +51,9 @@ describe('hooks', function() { if (!obj.email) { obj.email = 'hello@yo.com'; } + if (obj.realname && obj.realname.startsWith('Jerr')) { + obj.nickname = obj.realname + 'y'; + } }, afterCreate(obj){ obj.status = 10; @@ -83,12 +87,26 @@ describe('hooks', function() { assert.equal(user.email, 'hello@yo.com'); assert.equal(user.meta.foo, 1); assert.equal(user.status, 10); + const user1 = await User.create({ nickname: 'testy', email: 'yoh@hh', status: 1, realname: 'Jerry' }); + assert.equal(user1.email, 'yoh@hh'); + assert.equal(user1.status, 10); + assert.equal(user1.nickname, 'Jerryy'); + assert.equal(user1.realname, 'Jerry'); + await user1.reload(); + assert.equal(user1.nickname, 'Jerryy'); + assert.equal(user1.realname, 'Jerry'); }); it('create skip hooks', async () => { await assert.rejects(async () => { await User.create({ nickname: 'testy', meta: { foo: 1, bar: 'baz'}, status: 1, }, { hooks: false }); }, /LeoricValidateError: Validation notNull on email failed/); + + const user1 = await User.create({ nickname: 'testy', email: 'yoh@hh', status: 1, realname: 'Jerry' }, { hooks: false }); + assert.equal(user1.email, 'yoh@hh'); + assert.equal(user1.status, 1); + assert.equal(user1.nickname, 'testy'); + assert.equal(user1.realname, 'Jerry'); }); describe('bulkCreate', () => { @@ -97,6 +115,7 @@ describe('hooks', function() { const users = [{ email: 'a@e.com', nickname: 'sss', + realname: 'Jerry' }]; beforeProbe = null; @@ -109,23 +128,27 @@ describe('hooks', function() { await User.remove({}, true); beforeProbe = null; afterProbe = null; - await User.bulkCreate(users, { hooks: false }); + let res = await User.bulkCreate(users, { hooks: false }); assert.equal(beforeProbe, null); assert.equal(afterProbe, null); + assert(res.every(r => r.realname === 'Jerry')); await User.remove({}, true); // individualHooks const users1 = [{ nickname: 'sss', + realname: 'Jerry' }]; - await User.bulkCreate(users1, { individualHooks: true }); + res = await User.bulkCreate(users1, { individualHooks: true }); const user = await User.first; assert.equal(beforeProbe, 'before'); assert.equal(afterProbe, 'after'); assert.equal(user.email, 'hello@yo.com'); - assert.equal(user.nickname, 'sss'); + assert.equal(user.nickname, 'Jerryy'); + + assert(res.every(r => r.realname === 'Jerry')); }); }); @@ -148,6 +171,9 @@ describe('hooks', function() { if (opts.email) { opts.email = 'ho@y.com'; } + if (obj.realname && obj.realname.startsWith('Jerr')) { + obj.nickname = obj.realname + 'y'; + } }, afterUpdate(obj) { if (typeof obj === 'object') { @@ -184,12 +210,19 @@ describe('hooks', function() { it('update', async () => { const user = await User.create({ nickname: 'tim', email: 'h@h.com' ,meta: { foo: 1, bar: 'baz'}, status: 1 }); assert(user.email === 'h@h.com'); + assert(!user.realname); assert.equal(user.nickname, 'tim'); await user.update({ email: 'jik@y.com', + realname: 'Jerr', }); assert.equal(user.email, 'ho@y.com'); assert.equal(user.status, 11); + assert.equal(user.nickname, 'Jerry'); + assert.equal(user.realname, 'Jerr'); + assert.deepEqual(user.previousChanged().sort(), [ 'email', 'nickname', 'status', 'realname' ].sort()); + assert.deepEqual(user.previousChanges('nickname'), { nickname: [ 'tim', 'Jerry' ] }); + assert.deepEqual(user.previousChanges('realname'), { realname: [ null, 'Jerr' ] }); // instance.update before hooks special logic: setup_hooks.js#L131-L151 assert.deepEqual(user.fingerprint, undefined); @@ -199,6 +232,7 @@ describe('hooks', function() { await user.update({ fingerprint: 'halo', willbeIgnore: 'ignore', + realname: 'y', }); }); assert.deepEqual(user.fingerprint, undefined); diff --git a/types/data_types.d.ts b/types/data_types.d.ts index e8e0a766..4bf32615 100644 --- a/types/data_types.d.ts +++ b/types/data_types.d.ts @@ -20,6 +20,8 @@ export default class DataType { static DATE: typeof DATE & INVOKABLE; static DATEONLY: typeof DATEONLY & INVOKABLE; static BOOLEAN: typeof BOOLEAN & INVOKABLE; + static VIRTUAL: typeof VIRTUAL & INVOKABLE; + } declare class STRING extends DataType { @@ -86,3 +88,7 @@ declare class DATEONLY extends DataType { declare class BOOLEAN extends DataType { dataType: 'boolean' } + +declare class VIRTUAL extends DataType { + dataType: 'virtual' +}