Skip to content

Commit

Permalink
feat: add VIRTUAL data type (#289)
Browse files Browse the repository at this point in the history
* feat: add VIRTUAL data type

* feat: check illegal use of virtual attributes

* fix: unit test

Co-authored-by: JimmyDaddy <[email protected]>
Co-authored-by: Chen Yangjian <[email protected]>
  • Loading branch information
3 people authored Mar 10, 2022
1 parent 247ce0b commit 69e2a6a
Show file tree
Hide file tree
Showing 15 changed files with 495 additions and 80 deletions.
65 changes: 57 additions & 8 deletions src/bone.js
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {
Expand Down Expand Up @@ -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());
}

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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}`);
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -980,6 +1023,7 @@ class Bone {
for (const hookName of hookNames) {
if (this[hookName]) setupSingleHook(this, hookName, this[hookName]);
}
this[columnAttributesKey] = null;
}

/**
Expand Down Expand Up @@ -1115,6 +1159,7 @@ class Bone {
Reflect.deleteProperty(this.prototype, originalName);
this.loadAttribute(newName);
}
this[columnAttributesKey] = null;
}

/**
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1569,6 +1617,7 @@ class Bone {
return result;
}, {});

this[columnAttributesKey] = null;
Object.defineProperties(this, looseReadonly({ ...hookMethods, attributes, table }));
}

Expand All @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/collection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}

/**
Expand Down
13 changes: 13 additions & 0 deletions src/data_types.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -491,6 +503,7 @@ const DataTypes = {
JSONB,
BINARY,
VARBINARY,
VIRTUAL,
};

Object.assign(DataType, DataTypes);
Expand Down
2 changes: 2 additions & 0 deletions src/drivers/abstract/attribute.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -118,6 +119,7 @@ class Attribute {
defaultValue,
dataType,
jsType: findJsType(DataTypes, type, dataType),
virtual: type.virtual,
});
}

Expand Down
30 changes: 15 additions & 15 deletions src/drivers/abstract/spellbook.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
};

Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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 = [];

Expand All @@ -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));
Expand All @@ -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)}`);

Expand Down
8 changes: 4 additions & 4 deletions src/drivers/mysql/spellbook.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)})`);
}
Expand Down
2 changes: 1 addition & 1 deletion src/drivers/sqlite/spellbook.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
3 changes: 3 additions & 0 deletions src/expr_formatter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
Loading

0 comments on commit 69e2a6a

Please sign in to comment.