Comparing version 0.4.1 to 0.4.2
@@ -0,1 +1,20 @@ | ||
0.4.2 / 2019-03-29 | ||
================== | ||
* New: `Spell#orWhere()` and `Spell#orHaving()` | ||
* New: arithmetic operators | ||
* New: unary operators such as unary minus `-` and bit invertion `~` | ||
* Fix: unset attribute should be overwritable | ||
* Fix: `attributeChanged()` should be false if attribute is unset and not overwritten | ||
* Fix: subclass with incomplete getter/setter should be complemented | ||
* Fix: sharding key validation on `Bone.update()` and `Bone.save()` | ||
* Fix: sharding key should be along with primary key on `bone.remove()` | ||
* Fix: `Bone.cast()` should leave `null` as is | ||
* Fix: `INSERT ... UPDATE` with `id = LAST_INSERT_ID(id)` in MySQL | ||
* Fix: `Model.find({ name: { $op1, $op2 } })` object conditions with multiple operators | ||
* Fix: prefixing result set with qualifiers if query contains join relations and is not dispatchable | ||
* Fix: `Spell#$get(index)` with LIMIT | ||
* Docs: `Model.transaction()` | ||
* Docs: definition types with `index.d.ts` | ||
0.4.1 / 2019-03-21 | ||
@@ -2,0 +21,0 @@ ================== |
43
index.js
@@ -45,19 +45,2 @@ 'use strict' | ||
async function tableInfo(pool, tables) { | ||
const queries = tables.map(table => { | ||
return pool.Leoric_query(`PRAGMA table_info(${pool.escapeId(table)})`) | ||
}) | ||
const results = await Promise.all(queries) | ||
const schema = {} | ||
for (let i = 0; i < tables.length; i++) { | ||
const table = tables[i] | ||
const { rows } = results[i] | ||
const columns = rows.map(({ name, type, notnull, dflt_value, pk }) => { | ||
return { name, type, isNullable: notnull == 1, default: dflt_value } | ||
}) | ||
schema[table] = columns | ||
} | ||
return schema | ||
} | ||
function findClient(name) { | ||
@@ -70,4 +53,2 @@ switch (name) { | ||
return require('./lib/clients/pg') | ||
case 'sqlite3': | ||
return require('./lib/clients/sqlite') | ||
default: | ||
@@ -79,2 +60,3 @@ throw new Error(`Unsupported database ${name}`) | ||
async function requireModels(dir) { | ||
if (!dir || typeof dir !== 'string') throw new Error(`Unexpected dir (${dir})`) | ||
const entries = await fs.readdir(dir, { withFileTypes: true }) | ||
@@ -99,28 +81,27 @@ const models = [] | ||
* @param {string|Bone[]} opts.models - an array of models | ||
* @param {Object} opts. | ||
* @returns {Pool} the connection pool in case we need to perform raw query | ||
*/ | ||
const connect = async function Leoric_connect(opts) { | ||
if (Bone.pool) return | ||
if (Bone.pool) { | ||
throw new Error('connected already') | ||
} | ||
opts = Object.assign({ client: 'mysql', database: opts.db }, opts) | ||
const { client, database } = opts | ||
const pool = findClient(client)(opts) | ||
const dir = opts.model || opts.models | ||
const models = Array.isArray(dir) ? dir : (await requireModels(dir)) | ||
const Models = Array.isArray(dir) ? dir : (await requireModels(dir)) | ||
if (models.length <= 0) throw new Error('Unable to find any models') | ||
if (Models.length <= 0) throw new Error(`Unable to find models (${dir})`) | ||
const pool = findClient(client)(opts) | ||
Bone.model = {} | ||
Bone.pool = pool | ||
Collection.pool = pool | ||
const query = client.includes('sqlite') | ||
? tableInfo(pool, models.map(model => model.physicTable)) | ||
: schemaInfo(pool, database, models.map(model => model.physicTable)) | ||
const schema = await query | ||
for (const Model of models) { | ||
const schema = await schemaInfo(pool, database, Models.map(Model => Model.physicTable)) | ||
for (const Model of Models) { | ||
Model.describeTable(schema[Model.physicTable]) | ||
Bone.model[Model.name] = Model | ||
} | ||
Bone.models = models | ||
for (const Model of Bone.models) { | ||
for (const Model of Models) { | ||
Model.describe() | ||
@@ -127,0 +108,0 @@ } |
170
lib/bone.js
@@ -71,2 +71,5 @@ 'use strict' | ||
* @alias Bone | ||
* @property {Object} raw | ||
* @property {Object} rawOriginal | ||
* @property {Set} rawUnset | ||
* @example | ||
@@ -94,3 +97,3 @@ * const post = new Post() | ||
}, | ||
rawMissing: { | ||
rawUnset: { | ||
value: new Set(), | ||
@@ -113,3 +116,3 @@ writable: false, | ||
* const post = Post.select('title').first | ||
* post.createdAt // throw Error('Missing attribute createdAt') | ||
* post.createdAt // throw Error('Unset attribute "createdAt"') | ||
* | ||
@@ -134,8 +137,10 @@ * This is the underlying method of attribute getter/setters: | ||
if (!(name in schema)) { | ||
throw new Error(`${this.constructor.name} has no attribute called ${name}`) | ||
throw new Error(`${this.constructor.name} has no attribute "${name}"`) | ||
} | ||
if (this.rawMissing.has(name)) throw new Error(`Missing attribute ${name}`) | ||
if (arguments.length > 1) { | ||
this.raw[name] = value | ||
this.rawUnset.delete(name) | ||
return this | ||
} else { | ||
if (this.rawUnset.has(name)) throw new Error(`Unset attribute "${name}"`) | ||
const value = this.raw[name] | ||
@@ -156,3 +161,3 @@ // make sure null is returned if value is undefined | ||
attributeWas(name) { | ||
if (this.rawMissing.has(name)) throw new Error(`Missing attribute ${name}`) | ||
if (this.rawUnset.has(name)) throw new Error(`Unset attribute "${name}"`) | ||
const value = this.rawOriginal[name] | ||
@@ -173,2 +178,3 @@ return value == null ? null : value | ||
attributeChanged(name) { | ||
if (this.rawUnset.has(name)) return false | ||
const value = this.attribute(name) | ||
@@ -186,3 +192,3 @@ const valueWas = this.attributeWas(name) | ||
*/ | ||
inspect() { | ||
[util.inspect.custom]() { | ||
return this.constructor.name + ' ' + util.inspect(this.toJSON()) | ||
@@ -202,3 +208,3 @@ } | ||
for (const name in this.constructor.schema) { | ||
if (!this.rawMissing.has(name) && this[name] != null) { | ||
if (!this.rawUnset.has(name) && this[name] != null) { | ||
obj[name] = this[name] | ||
@@ -208,5 +214,6 @@ } | ||
for (const name in this) { | ||
if (this.hasOwnProperty(name) && this[name] != null) { | ||
obj[name] = this[name] | ||
for (const name of Object.keys(this)) { | ||
const value = this[name] | ||
if (value != null) { | ||
obj[name] = typeof value.toJSON === 'function' ? value.toJSON() : value | ||
} | ||
@@ -229,9 +236,10 @@ } | ||
for (const name in this.constructor.schema) { | ||
if (!this.rawMissing.has(name)) obj[name] = this.attribute(name) | ||
if (!this.rawUnset.has(name)) obj[name] = this.attribute(name) | ||
} | ||
for (const name in this) { | ||
if (this.hasOwnProperty(name)) { | ||
obj[name] = this[name] | ||
} | ||
for (const name of Object.keys(this)) { | ||
const value = this[name] | ||
obj[name] = value != null && typeof value.toObject == 'function' | ||
? value.toObject() | ||
: value | ||
} | ||
@@ -253,12 +261,13 @@ | ||
*/ | ||
save() { | ||
async save() { | ||
const { primaryKey } = this.constructor | ||
if (this.rawMissing.has(primaryKey)) throw new Error('Missing primary key') | ||
if (this.rawUnset.has(primaryKey)) throw new Error(`Unset primary key ${primaryKey}`) | ||
if (this[primaryKey] == null) { | ||
return this.create().then(() => this) | ||
await this.create() | ||
} else if (this.attributeChanged(primaryKey)) { | ||
return this.upsert().then(() => this) | ||
await this.upsert() | ||
} else { | ||
return this.update().then(() => this) | ||
await this.update() | ||
} | ||
return this | ||
} | ||
@@ -296,7 +305,2 @@ | ||
if (schema.createdAt && !this.createdAt) this.createdAt = new Date() | ||
if (schema.updatedAt && !(this.updatedAt && this.attributeChanged('updatedAt'))) { | ||
this.updatedAt = new Date() | ||
} | ||
for (const name in schema) { | ||
@@ -306,6 +310,11 @@ if (this.attributeChanged(name)) data[name] = this.attribute(name) | ||
if (Object.keys(data).length === 0) { | ||
return Promise.resolve() | ||
if (Object.keys(data).length === 0) return Promise.resolve(0) | ||
if (schema.createdAt && !this.createdAt) data.createdAt = new Date() | ||
if (schema.updatedAt && !(this.updatedAt && this.attributeChanged('updatedAt'))) { | ||
data.updatedAt = new Date() | ||
} | ||
if (this[primaryKey]) data[primaryKey] = this[primaryKey] | ||
// About LAST_INSERT_ID() | ||
@@ -330,8 +339,4 @@ // - http://dev.mysql.com/doc/refman/5.7/en/information-functions.html#function_last-insert-id | ||
const data = {} | ||
const { schema, primaryKey } = this.constructor | ||
const { schema, primaryKey, shardingKey } = this.constructor | ||
if (schema.updatedAt && !this.updatedAt) { | ||
this.updatedAt = new Date() | ||
} | ||
for (let name in schema) { | ||
@@ -343,5 +348,11 @@ if (this.attributeChanged(name)) { | ||
return this.constructor.update({ | ||
[primaryKey]: this[primaryKey] | ||
}, data).then(count => { | ||
if (Object.keys(data).length === 0) return Promise.resolve(0) | ||
if (this[primaryKey] == null) { | ||
throw new Error(`Unable to UPDATE due to unset primary key ${primaryKey}`) | ||
} | ||
const where = { [primaryKey]: this[primaryKey] } | ||
if (shardingKey) where[shardingKey] = this[shardingKey] | ||
return this.constructor.update(where, data).then(count => { | ||
if (count === 1) this.syncRaw() | ||
@@ -390,9 +401,12 @@ return count | ||
const Model = this.constructor | ||
const { primaryKey } = Model | ||
const { primaryKey, shardingKey } = Model | ||
if (!this[primaryKey]) { | ||
if (this[primaryKey] == null) { | ||
throw new Error('The instance is not persisted yet.') | ||
} | ||
return Model.remove({ [primaryKey]: this[primaryKey] }, forceDelete) | ||
const condition = { [primaryKey]: this[primaryKey] } | ||
if (shardingKey) condition[shardingKey] = this[shardingKey] | ||
return Model.remove(condition, forceDelete) | ||
} | ||
@@ -462,2 +476,3 @@ | ||
for (const name in schema) { | ||
const descriptor = Object.getOwnPropertyDescriptor(this.prototype, name) | ||
descriptors[name] = Object.assign({ | ||
@@ -469,5 +484,7 @@ get() { | ||
this.attribute(name, value) | ||
}, | ||
enumerable: true | ||
}, Object.getOwnPropertyDescriptor(this.prototype, name)) | ||
} | ||
}, Object.keys(descriptor || {}).reduce((result, key) => { | ||
if (descriptor[key] != null) result[key] = descriptor[key] | ||
return result | ||
}, {})) | ||
} | ||
@@ -588,3 +605,7 @@ | ||
static renameAttribute(originalName, newName) { | ||
if (originalName in this.schema) { | ||
if (this.schema.hasOwnProperty(newName)) { | ||
throw new Error(`Unable to override existing attribute "${newName}"`) | ||
} | ||
if (this.schema.hasOwnProperty(originalName)) { | ||
this.schema[newName] = this.schema[originalName] | ||
@@ -607,3 +628,3 @@ this.amehcs[this.schema[newName].column] = newName | ||
/** | ||
* Find model by class name. Models are stored at {@link Bone.models}. | ||
* Find model by class name. Models are stored at {@link Bone.model}. | ||
* @param {string} className | ||
@@ -613,8 +634,5 @@ * @returns {Bone} | ||
static reflectClass(className) { | ||
for (const Model of this.models) { | ||
if (Model.name === className) { | ||
return Model | ||
} | ||
} | ||
throw new Error(`Cannot find Class definition of ${className}`) | ||
const Model = this.model[className] | ||
if (!Model) throw new Error(`Unable to find model "${className}"`) | ||
return Model | ||
} | ||
@@ -652,2 +670,4 @@ | ||
static cast(value, type) { | ||
if (value == null) return value | ||
switch (type) { | ||
@@ -779,7 +799,8 @@ case JSON: | ||
for (const qualifier in spell.joins) { | ||
const values = row[qualifier] | ||
const { Model, hasMany } = spell.joins[qualifier] | ||
// It seems mysql2 nests rows with table name instead of qualifier. | ||
const values = row[qualifier] || row[Model.table] | ||
const id = values[Model.primaryColumn] | ||
if (hasMany) { | ||
if (!current[qualifier]) current[qualifier] = [] | ||
if (!current[qualifier]) current[qualifier] = new Collection() | ||
if (!id || current[qualifier].some(item => item[Model.primaryKey] == id)) continue | ||
@@ -810,7 +831,8 @@ current[qualifier].push(Model.instantiate(values)) | ||
for (const row of rows) { | ||
const result = {} | ||
const result = { '': {} } | ||
for (const qualifier in row) { | ||
const data = row[qualifier] | ||
const obj = result[qualifier] || (result[qualifier] = {}) | ||
if (qualifier == '') { | ||
Object.assign(result, data) | ||
Object.assign(obj, data) | ||
} | ||
@@ -821,3 +843,4 @@ else if (qualifier in joins || qualifier == spell.Model.aliasName || qualifier == spell.Model.table) { | ||
const name = Model.amehcs[column] | ||
result[name || column] = data[column] | ||
if (name) obj[name] = data[column] | ||
else result[''][column] = data[column] | ||
} | ||
@@ -832,3 +855,8 @@ } | ||
return results | ||
if (Object.keys(joins).length > 0) return results | ||
return results.map(result => { | ||
const merged = {} | ||
for (const obj of Object.values(result)) Object.assign(merged, obj) | ||
return merged | ||
}) | ||
} | ||
@@ -844,3 +872,3 @@ | ||
const instance = new this() | ||
const { raw, rawOriginal, rawMissing } = instance | ||
const { raw, rawOriginal, rawUnset } = instance | ||
@@ -854,3 +882,3 @@ for (const name in this.schema) { | ||
} else { | ||
rawMissing.add(name) | ||
rawUnset.add(name) | ||
} | ||
@@ -917,4 +945,3 @@ } | ||
static findOne(conditions, ...values) { | ||
const spell = this.find(conditions, ...values).$limit(1) | ||
return spell.$get(0) | ||
return this.find(conditions, ...values).$get(0) | ||
} | ||
@@ -961,2 +988,7 @@ | ||
static update(conditions, values) { | ||
if (Object.keys(values).length <= 0) { | ||
// nothing to update | ||
return Promise.resolve(0) | ||
} | ||
const spell = new Spell(this, async spell => { | ||
@@ -990,15 +1022,11 @@ const result = await this.query(spell, values) | ||
static remove(conditions, forceDelete = false) { | ||
if (forceDelete === true) { | ||
const spell = new Spell(this, async spell => { | ||
const result = await this.query(spell) | ||
return result.affectedRows | ||
}) | ||
return spell.unscoped.$where(conditions).$delete() | ||
} | ||
else if (this.schema.deletedAt) { | ||
if (forceDelete !== true && this.schema.deletedAt) { | ||
return this.update(conditions, { deletedAt: new Date() }) | ||
} | ||
else { | ||
throw new Error('Soft delete not available.') | ||
} | ||
const spell = new Spell(this, async spell => { | ||
const result = await this.query(spell) | ||
return result.affectedRows | ||
}) | ||
return spell.unscoped.$where(conditions).$delete() | ||
} | ||
@@ -1032,4 +1060,4 @@ | ||
} catch (err) { | ||
console.error(err.stack) | ||
await connection.Leoric_query('ROLLBACK') | ||
throw err | ||
} finally { | ||
@@ -1036,0 +1064,0 @@ connection.release() |
@@ -12,3 +12,3 @@ 'use strict' | ||
toJSON() { | ||
return this.map(function(element) { | ||
return Array.from(this, function(element) { | ||
if (typeof element.toJSON === 'function') { | ||
@@ -26,3 +26,3 @@ return element.toJSON() | ||
toObject() { | ||
return this.map(function(element) { | ||
return Array.from(this, function(element) { | ||
if (typeof element.toObject === 'function') { | ||
@@ -29,0 +29,0 @@ return element.toObject() |
176
lib/expr.js
@@ -7,3 +7,2 @@ 'use strict' | ||
* @example | ||
* const parseExpr = require('./lib/expr') | ||
* parseExpr('COUNT(1) AS count') | ||
@@ -19,3 +18,3 @@ * // => { type: 'alias', value: 'count', | ||
const UNARY_OPERATORS = [ | ||
'not', '!' | ||
'not', '!', '-', '~' | ||
] | ||
@@ -39,2 +38,3 @@ | ||
'like', | ||
'+', | ||
'-', | ||
@@ -46,3 +46,6 @@ 'mod', '%', | ||
'or', '||', | ||
'*' | ||
'xor', | ||
'*', | ||
'/', | ||
'^' | ||
] | ||
@@ -58,2 +61,10 @@ | ||
const OPERATORS = new Set( | ||
['between', 'not between', ...UNARY_OPERATORS, ...BINARY_OPERATORS].sort((a, b) => { | ||
if (a.length > b.length) return -1 | ||
else if (a.length < b.length) return 1 | ||
else return 0 | ||
}) | ||
) | ||
/** | ||
@@ -64,2 +75,4 @@ * A map of operator alias. Operators like `&&` will be translated into `and` to make ast handling a bit easier. | ||
const OPERATOR_ALIAS_MAP = { | ||
'is': '=', | ||
'is not': '!=', | ||
'!': 'not', | ||
@@ -69,3 +82,3 @@ '&&': 'and', | ||
'||': 'or', | ||
'%': 'mod' | ||
'mod': '%' | ||
} | ||
@@ -85,19 +98,34 @@ | ||
const PRECEDENCES = [ | ||
'not', | ||
'and', | ||
'xor', | ||
'or' | ||
['^'], // left out unary minus on purpose | ||
['*', '/', 'div', '%', 'mod'], | ||
['-', '+'], | ||
['=', '>=', '>', '<=', '<', '<>', '!=', 'like', 'in'], | ||
['not'], | ||
['and'], | ||
['xor'], | ||
['or'] | ||
] | ||
/** | ||
* Check if the left operator has higher precedence over the right one. For example: | ||
* | ||
* isEqualOrHigher('and', 'or') // => true | ||
* isEqualOrHigher('and', 'and') // => true | ||
* | ||
* Compare the precedence of two operators. Operators with lower indices have higher priorities. | ||
* @example | ||
* precedes('and', 'or') // => -1 | ||
* precedes('and', 'and') // => 0 | ||
* precedes('+', '/') // => 1 | ||
* @param {string} left - name of the left operator | ||
* @param {string} right - name of the right operator | ||
*/ | ||
function isEqualOrHigher(left, right) { | ||
return left == right || PRECEDENCES.indexOf(left) < PRECEDENCES.indexOf(right) | ||
function precedes(left, right) { | ||
if (left == right) return 0 | ||
let leftIndex = -1 | ||
let rightIndex = -1 | ||
for (const i of PRECEDENCES.keys()) { | ||
const operators = PRECEDENCES[i] | ||
if (operators.includes(left)) leftIndex = i | ||
if (operators.includes(right)) rightIndex = i | ||
} | ||
return leftIndex < rightIndex ? -1 : (leftIndex == rightIndex ? 0 : 1) | ||
} | ||
@@ -153,3 +181,3 @@ | ||
next() | ||
return { type: 'func', name: name.toLowerCase(), args } | ||
return { type: 'func', name, args } | ||
} | ||
@@ -195,6 +223,15 @@ | ||
if (/['"]/.test(chr)) return string() | ||
if (chr == '*') return wildcard() | ||
if (chr == '?') return placeholder() | ||
if (chr == '(') return array() | ||
for (const name of OPERATORS) { | ||
const j = i + name.length | ||
const chunk = str.slice(i, j) | ||
if (chunk.toLowerCase() == name && (str[j] == ' ' || !/[a-z]$/.test(name))) { | ||
i += name.length | ||
chr = str[i] | ||
return { type: 'op', name: OPERATOR_ALIAS_MAP[name] || name, args: [] } | ||
} | ||
} | ||
let value = '' | ||
@@ -205,17 +242,13 @@ while (chr && /[a-z0-9$_.]/i.test(chr)) { | ||
} | ||
const lowerCase = value.toLowerCase() | ||
if (chr == '(') return func(value) | ||
if (chr == '!') { | ||
next() | ||
return { type: 'op', name: 'not', args: [] } | ||
if (!value) { | ||
throw new Error(`Unexpected token ${chr}`) | ||
} | ||
if (chr && !/[), ]/.test(chr)) throw new Error(`Unexpected token ${chr}`) | ||
const lowerCase = value.toLowerCase() | ||
if (UNARY_OPERATORS.includes(lowerCase)) { | ||
return { type: 'op', name: lowerCase, args: [] } | ||
} | ||
else if (MODIFIERS.includes(lowerCase)) { | ||
return { type: 'mod', name: lowerCase, args: [] } | ||
} | ||
else if (chr == '(') { | ||
return func(lowerCase) | ||
} | ||
else if (lowerCase == 'null') { | ||
@@ -232,28 +265,11 @@ return { type: 'literal', value: null } | ||
function keyword() { | ||
let value = '' | ||
while (chr && chr != ' ') { | ||
value += chr | ||
next() | ||
} | ||
if (/^not$/i.test(value) && /^\s+(?:between|in|like)/i.test(str.slice(i))) { | ||
space() | ||
value += ` ${keyword()}` | ||
} | ||
else if (/^is$/i.test(value) && /^\s+not/i.test(str)) { | ||
space() | ||
value += ' not' | ||
} | ||
return OPERATOR_ALIAS_MAP[value] || value.toLowerCase() | ||
} | ||
function between(name, t) { | ||
function between(op, t) { | ||
space() | ||
const start = token() | ||
space() | ||
const conj = keyword() | ||
if (conj != 'and') throw new Error(`Unexpected conj ${conj}`) | ||
const conj = token() | ||
if (conj.name != 'and') throw new Error(`Unexpected conj ${conj}`) | ||
space() | ||
const end = token() | ||
return { type: 'op', name, args: [ t, start, end ] } | ||
return { ...op, args: [ t, start, end ] } | ||
} | ||
@@ -266,25 +282,45 @@ | ||
function unary(op) { | ||
const arg = chr == '(' ? expr() : token() | ||
if (op.name == '-' && arg.type == 'literal' && Number.isFinite(arg.value)) { | ||
return { type: 'literal', value: -arg.value } | ||
} else { | ||
return { ...op, args: [arg] } | ||
} | ||
} | ||
function operator(t) { | ||
const name = keyword() | ||
if (name == 'as') return alias(t) | ||
if (name == 'between' || name == 'not between') { | ||
return between(name, t) | ||
const op = token() | ||
if (op.name == 'as') return alias(t) | ||
if (op.name == 'between' || op.name == 'not between') { | ||
return between(op, t) | ||
} | ||
else if (BINARY_OPERATORS.includes(name)) { | ||
if (BINARY_OPERATORS.includes(op.name)) { | ||
space() | ||
const isLower = chr == '(' | ||
const operand = LOGICAL_OPERATORS.includes(name) ? expr() : token() | ||
if (operand.type == 'op' && !isLower && isEqualOrHigher(name, operand.name)) { | ||
const operand = LOGICAL_OPERATORS.includes(op.name) ? expr() : token() | ||
// parseExpr('1 > -1') | ||
if (UNARY_OPERATORS.includes(operand.name) && operand.args.length == 0) { | ||
return { ...op, args: [t, unary(operand)] } | ||
} | ||
else if (operand.type == 'op' && operand.args.length < 2) { | ||
throw new Error(`Unexpected token ${operand.name}`) | ||
} | ||
// parseExpr('a = 1 && b = 2 && c = 3') | ||
else if (operand.type == 'op' && !isLower && precedes(op.name, operand.name) <= 0) { | ||
const { args } = operand | ||
operand.args = [ | ||
{ type: 'op', name, args: [ t, args[0] ] }, | ||
args[1] | ||
] | ||
operand.args = [{ ...op, args: [t, args[0]] }, args[1]] | ||
return operand | ||
} else { | ||
return { type: 'op', name, args: [ t, operand ] } | ||
} | ||
// parseExpr('a + b * c') | ||
else if (operand.type !== 'op' && t.type == 'op' && precedes(op.name, t.name) < 0) { | ||
t.args[1] = { ...op, args: [t.args[1], operand] } | ||
return t | ||
} | ||
else { | ||
return { ...op, args: [t, operand] } | ||
} | ||
} | ||
else { | ||
throw new Error(`Unexpected keyword ${name}`) | ||
throw new Error(`Unexpected token ${op.name}`) | ||
} | ||
@@ -298,7 +334,10 @@ } | ||
if (node) { | ||
if (UNARY_OPERATORS.includes(node.name)) { | ||
node.args[0] = expr() | ||
} else if (MODIFIERS.includes(node.name)) { | ||
// check arguments length to differentiate unary minus and binary minus | ||
if (UNARY_OPERATORS.includes(node.name) && node.args.length == 0) { | ||
node = unary(node) | ||
} | ||
else if (MODIFIERS.includes(node.name)) { | ||
node.args[0] = token() | ||
} else { | ||
} | ||
else { | ||
node = operator(node) | ||
@@ -312,2 +351,5 @@ } | ||
} | ||
else if (chr == '*') { | ||
node = wildcard() | ||
} | ||
else { | ||
@@ -367,2 +409,2 @@ node = token() | ||
module.exports = { parseExpr, parseExprList } | ||
module.exports = { parseExpr, parseExprList, precedes } |
335
lib/spell.js
@@ -9,3 +9,3 @@ 'use strict' | ||
const SqlString = require('sqlstring') | ||
const { parseExprList, parseExpr } = require('./expr') | ||
const { parseExprList, parseExpr, precedes } = require('./expr') | ||
@@ -36,8 +36,11 @@ const OPERATOR_MAP = { | ||
function isPlainObject(value) { | ||
return Object.prototype.toString.call(value) === '[object Object]' | ||
} | ||
/** | ||
* Allows two types of params: | ||
* | ||
* parseConditions({ foo: { $op: value } }) | ||
* parseConditions('foo = ?', value) | ||
* | ||
* Parse condition expressions | ||
* @example | ||
* parseConditions({ foo: { $op: value } }) | ||
* parseConditions('foo = ?', value) | ||
* @param {(string|Object)} conditions | ||
@@ -48,3 +51,3 @@ * @param {...*} values | ||
function parseConditions(conditions, ...values) { | ||
if (typeof conditions == 'object') { | ||
if (isPlainObject(conditions)) { | ||
return parseObjectConditions(conditions) | ||
@@ -72,2 +75,46 @@ } | ||
/** | ||
* Check if object condition is an operator condition, such as `{ $gte: 100, $lt: 200 }`. | ||
* @param {Object} condition | ||
* @returns {boolean} | ||
*/ | ||
function isOperatorCondition(condition) { | ||
return isPlainObject(condition) && | ||
Object.keys(condition).length > 0 && | ||
Object.keys(condition).every($op => OPERATOR_MAP.hasOwnProperty($op)) | ||
} | ||
/** | ||
* parse operator condition into expression ast | ||
* @example | ||
* parseOperatorCondition('id', { $gt: 0, $lt: 999999 }) | ||
* // => { type: 'op', name: 'and', args: [ ... ]} | ||
* @param {string} name | ||
* @param {Object} condition | ||
* @returns {Object} | ||
*/ | ||
function parseOperatorCondition(name, condition) { | ||
let node | ||
for (const $op in condition) { | ||
const op = OPERATOR_MAP[$op] | ||
const args = [ parseExpr(name) ] | ||
const val = condition[$op] | ||
if (op == 'between' || op == 'not between') { | ||
args.push(parseObjectValue(val[0]), parseObjectValue(val[1])) | ||
} else { | ||
args.push(parseObjectValue(val)) | ||
} | ||
if (node) { | ||
node = { type: 'op', name: 'and', args: [node, { type: 'op', name: op, args } ] } | ||
} else { | ||
node = { type: 'op', name: op, args } | ||
} | ||
} | ||
return node | ||
} | ||
/** | ||
* parse conditions in MongoDB style, which is quite polular in ORMs for JavaScript. See {@link module:lib/spell~OPERATOR_MAP} for supported `$op`s. | ||
@@ -93,14 +140,4 @@ * @example | ||
} | ||
else if (value != null && typeof value == 'object' && !Array.isArray(value) && Object.keys(value).length == 1) { | ||
for (const $op in value) { | ||
const op = OPERATOR_MAP[$op] | ||
const args = [ parseExpr(name) ] | ||
const val = value[$op] | ||
if (op == 'between' || op == 'not between') { | ||
args.push(parseObjectValue(val[0]), parseObjectValue(val[1])) | ||
} else { | ||
args.push(parseObjectValue(val)) | ||
} | ||
result.push({ type: 'op', name: op, args }) | ||
} | ||
else if (isOperatorCondition(value)) { | ||
result.push(parseOperatorCondition(name, value)) | ||
} | ||
@@ -141,3 +178,2 @@ else { | ||
} | ||
token.value = model.unalias(value) | ||
}) | ||
@@ -159,5 +195,5 @@ } | ||
if (name in Model.schema) { | ||
sets[Model.unalias(name)] = Model.uncast(obj[name], Model.schema[name].type) | ||
sets[name] = Model.uncast(obj[name], Model.schema[name].type) | ||
} else { | ||
throw new Error(`Invalid attribute ${name}`) | ||
throw new Error(`Undefined attribute "${name}"`) | ||
} | ||
@@ -207,7 +243,10 @@ } | ||
const { value, qualifiers } = ast | ||
const Model = findModel(spell, qualifiers) | ||
const column = Model.unalias(value) | ||
const { pool } = spell.Model | ||
if (qualifiers && qualifiers.length > 0) { | ||
return `${qualifiers.map(pool.escapeId).join('.')}.${pool.escapeId(value)}` | ||
return `${qualifiers.map(pool.escapeId).join('.')}.${pool.escapeId(column)}` | ||
} else { | ||
return pool.escapeId(value) | ||
return pool.escapeId(column) | ||
} | ||
@@ -286,3 +325,3 @@ } | ||
const params = args.map(arg => { | ||
return isLogicalOp(ast) && isLogicalOp(arg) | ||
return isLogicalOp(ast) && isLogicalOp(arg) && precedes(name, arg.name) <= 0 | ||
? `(${formatExpr(spell, arg)})` | ||
@@ -298,2 +337,5 @@ : formatExpr(spell, arg) | ||
} | ||
else if ('!~-'.includes(name) && params.length == 1) { | ||
return `${name} ${params[0]}` | ||
} | ||
else if (args[1].type == 'literal' && args[1].value == null) { | ||
@@ -506,3 +548,3 @@ if (['=', '!='].includes(name)) { | ||
if (selection[qualifier].length > 0 && spell.dispatchable) { | ||
selection[qualifier].push({ type: 'id', qualifiers: [qualifier], value: relation.Model.primaryColumn }) | ||
selection[qualifier].push({ type: 'id', qualifiers: [qualifier], value: relation.Model.primaryKey }) | ||
} | ||
@@ -537,3 +579,3 @@ walkExpr(relation.on, ({ type, qualifiers, value }) => { | ||
subspell.orders.unshift([{ type, value }, order]) | ||
if (Model.columns.includes(value)) token.qualifiers = [baseName] | ||
if (Model.attributes.includes(value)) token.qualifiers = [baseName] | ||
} | ||
@@ -582,3 +624,3 @@ } | ||
if (havingConditions.length > 0) { | ||
if (!spell.derivable && havingConditions.length > 0) { | ||
havingConditions.reduce(collectLiteral, values) | ||
@@ -598,6 +640,6 @@ chunks.push(`HAVING ${formatConditions(spell, havingConditions)}`) | ||
const { whereConditions } = spell | ||
const { shardingKey, shardingColumn, table } = spell.Model | ||
const { shardingKey, table } = spell.Model | ||
if (shardingKey && !whereConditions.some(condition => findExpr(condition, { type: 'id', value: shardingColumn }))) { | ||
throw new Error(`Sharding key ${shardingKey} of table ${table} is required.`) | ||
if (shardingKey && !whereConditions.some(condition => findExpr(condition, { type: 'id', value: shardingKey }))) { | ||
throw new Error(`Sharding key ${table}.${shardingKey} is required.`) | ||
} | ||
@@ -616,7 +658,7 @@ | ||
const { Model, whereConditions } = spell | ||
const { pool, shardingKey, shardingColumn } = Model | ||
const { pool, shardingKey } = Model | ||
const table = pool.escapeId(Model.table) | ||
if (shardingKey && !whereConditions.some(condition => findExpr(condition, { type: 'id', value: shardingColumn }))) { | ||
throw new Error(`Sharding key ${shardingKey} of table ${Model.table} is required.`) | ||
if (shardingKey && !whereConditions.some(condition => findExpr(condition, { type: 'id', value: shardingKey }))) { | ||
throw new Error(`Sharding key ${Model.table}.${shardingKey} is required.`) | ||
} | ||
@@ -642,3 +684,3 @@ | ||
.map(condition => { | ||
return isLogicalOp(condition) && condition.name == 'or' | ||
return isLogicalOp(condition) && condition.name == 'or' && conditions.length > 1 | ||
? `(${formatExpr(spell, condition)})` | ||
@@ -657,6 +699,6 @@ : formatExpr(spell, condition) | ||
const { pool, shardingKey } = Model | ||
const columns = Object.keys(sets).map(pool.escapeId) | ||
const columns = Object.keys(sets).map(column => pool.escapeId(Model.unalias(column))) | ||
if (shardingKey && sets[shardingKey] == null) { | ||
throw new Error(`Sharding key "${shardingKey}" of table "${Model.table}" is required.`) | ||
throw new Error(`Sharding key ${Model.table}.${shardingKey} cannot be NULL.`) | ||
} | ||
@@ -676,12 +718,17 @@ | ||
const { Model, sets, whereConditions } = spell | ||
const { pool, shardingKey, shardingColumn } = Model | ||
const { pool, shardingKey } = Model | ||
const values = [] | ||
const assigns = [] | ||
if (shardingKey && (sets[shardingKey] == null || !whereConditions.some(condition => findExpr(condition, { type: 'id', value: shardingColumn })))) { | ||
throw new Error(`Sharding key "${shardingKey}" of table "${Model.table}" is required.`) | ||
if (shardingKey) { | ||
if (sets.hasOwnProperty(shardingKey) && sets[shardingKey] == null) { | ||
throw new Error(`Sharding key ${Model.table}.${shardingKey} cannot be NULL`) | ||
} | ||
if (!whereConditions.some(condition => findExpr(condition, { type: 'id', value: shardingKey }))) { | ||
throw new Error(`Sharding key ${Model.table}.${shardingKey} is required.`) | ||
} | ||
} | ||
for (const column in sets) { | ||
assigns.push(`${pool.escapeId(column)} = ?`) | ||
assigns.push(`${pool.escapeId(Model.unalias(column))} = ?`) | ||
values.push(sets[column]) | ||
@@ -698,15 +745,30 @@ } | ||
/** | ||
* Taking advantage of MySQL's `on duplicate key update`, though knex does not support this because it has got several databases to be compliant of. PostgreSQL has got proper `upsert`. Maybe we can file a PR to knex someday. | ||
* | ||
* References: | ||
* - http://dev.mysql.com/doc/refman/5.7/en/insert-on-duplicate.html | ||
* - https://github.com/tgriesser/knex/issues/701 | ||
* upsert | ||
* @param {Spell} spell | ||
*/ | ||
function formatUpsert(spell) { | ||
const { Model, sets } = spell | ||
const { pool, shardingKey } = Model | ||
if (shardingKey && sets[shardingKey] == null) { | ||
throw new Error(`Sharding key ${Model.table}.${shardingKey} cannot be NULL`) | ||
} | ||
switch (pool.Leoric_type) { | ||
case 'mysql': | ||
return formatMysqlUpsert(spell) | ||
default: | ||
return formatDefaultUpsert(spell) | ||
} | ||
} | ||
/** | ||
* INSERT ... ON CONFLICT ... UPDATE SET | ||
* - https://www.postgresql.org/docs/9.5/static/sql-insert.html | ||
* - https://www.sqlite.org/lang_UPSERT.html | ||
* | ||
* @param {Spell} spell | ||
*/ | ||
function formatUpsert(spell) { | ||
function formatDefaultUpsert(spell) { | ||
const { Model, sets } = spell | ||
const { pool, shardingKey } = Model | ||
const { pool, primaryColumn } = Model | ||
const insert = formatInsert(spell) | ||
@@ -716,16 +778,39 @@ const values = insert.values | ||
if (shardingKey && sets[shardingKey] == null) { | ||
throw new Error(`Sharding key "${shardingKey}" of table "${Model.table}" is required`) | ||
for (const column in sets) { | ||
assigns.push(`${pool.escapeId(Model.unalias(column))} = ?`) | ||
values.push(sets[column]) | ||
} | ||
return { | ||
sql: `${insert.sql} ON CONFLICT (${pool.escapeId(primaryColumn)}) DO UPDATE SET ${assigns.join(', ')}`, | ||
values | ||
} | ||
} | ||
/** | ||
* INSERT ... ON DUPLICATE KEY UPDATE | ||
* - http://dev.mysql.com/doc/refman/5.7/en/insert-on-duplicate.html | ||
* @param {Spell} spell | ||
*/ | ||
function formatMysqlUpsert(spell) { | ||
const { Model, sets } = spell | ||
const { pool, primaryKey, primaryColumn } = Model | ||
const insert = formatInsert(spell) | ||
const assigns = [] | ||
const values = insert.values | ||
// Make sure the correct LAST_INSERT_ID is returned. | ||
// - https://stackoverflow.com/questions/778534/mysql-on-duplicate-key-last-insert-id | ||
assigns.push(`${pool.escapeId(primaryColumn)} = LAST_INSERT_ID(${pool.escapeId(primaryColumn)})`) | ||
for (const column in sets) { | ||
assigns.push(`${pool.escapeId(column)} = ?`) | ||
values.push(sets[column]) | ||
if (column !== primaryKey) { | ||
assigns.push(`${pool.escapeId(Model.unalias(column))} = ?`) | ||
values.push(sets[column]) | ||
} | ||
} | ||
return { | ||
sql: pool.Leoric_type === 'mysql' | ||
? `${insert.sql} ON DUPLICATE KEY UPDATE ${assigns.join(', ')}` | ||
: `${insert.sql} ON CONFLICT (${pool.escapeId(Model.primaryColumn)}) DO UPDATE SET ${assigns.join(', ')}`, | ||
values: values | ||
sql: `${insert.sql} ON DUPLICATE KEY UPDATE ${assigns.join(', ')}`, | ||
values | ||
} | ||
@@ -835,4 +920,4 @@ } | ||
if (columns && !columns.includes(RefModel.primaryColumn)) { | ||
columns.push(parseExpr(`${refName}.${RefModel.primaryColumn}`)) | ||
if (columns && !columns.includes(RefModel.primaryKey)) { | ||
columns.push(parseExpr(`${refName}.${RefModel.primaryKey}`)) | ||
} | ||
@@ -873,3 +958,3 @@ spell.joins[refName] = { | ||
const Model = qualifier && qualifier != spell.Model.aliasName | ||
? spell.joins[qualifier].Model | ||
? (spell.joins.hasOwnProperty(qualifier) ? spell.joins[qualifier].Model : null) | ||
: spell.Model | ||
@@ -880,11 +965,2 @@ if (!Model) throw new Error(`Unabled to find model ${qualifiers}`) | ||
function unalias(spell, expr) { | ||
return walkExpr(expr, token => { | ||
if (token.type == 'id') { | ||
const Model = findModel(spell, token.qualifiers) | ||
token.value = Model.unalias(token.value) | ||
} | ||
}) | ||
} | ||
/** | ||
@@ -901,4 +977,3 @@ * If Model supports soft delete, and deletedAt isn't specified in whereConditions yet, and the table isn't a subquery, append a default where({ deletedAt: null }). | ||
walkExpr(condition, ({ type, value }) => { | ||
if (type != 'id') return | ||
if (value == 'deletedAt' || value == schema.deletedAt.column) { | ||
if (type == 'id' && value == 'deletedAt') { | ||
found = true | ||
@@ -976,3 +1051,7 @@ } | ||
} | ||
return this.groups.length == 0 | ||
if (this.groups.length > 0) return false | ||
if (this.table.value instanceof Spell && Object.keys(this.table.value.joins).length > 0) { | ||
return false | ||
} | ||
return true | ||
} | ||
@@ -1021,9 +1100,7 @@ | ||
get first() { | ||
const spell = this.order(this.Model.primaryKey) | ||
return spell.$limit(1).$get(0) | ||
return this.order(this.Model.primaryKey).$get(0) | ||
} | ||
get last() { | ||
const spell = this.order(this.Model.primaryKey, 'desc') | ||
return spell.$limit(1).$get(0) | ||
return this.order(this.Model.primaryKey, 'desc').$get(0) | ||
} | ||
@@ -1053,3 +1130,3 @@ | ||
/** | ||
* Get the instance from collection by index. Helper method for {@link Bone#first} and {@link Bone#last}. | ||
* Get nth record. | ||
* @param {number} index | ||
@@ -1060,5 +1137,7 @@ * @returns {Bone} | ||
const { factory, Model } = this | ||
this.$limit(1) | ||
if (index > 0) this.$offset(index) | ||
this.factory = spell => { | ||
return factory(spell).then(results => { | ||
const result = results[index] | ||
const result = results[0] | ||
return result instanceof Model ? result : null | ||
@@ -1170,6 +1249,19 @@ }) | ||
$where(conditions, ...values) { | ||
this.whereConditions.push(...parseConditions(conditions, ...values).map(unalias.bind(null, this))) | ||
this.whereConditions.push(...parseConditions(conditions, ...values)) | ||
return this | ||
} | ||
$orWhere(conditions, ...values) { | ||
const { whereConditions } = this | ||
if (whereConditions.length == 0) return this.$where(conditions, ...values) | ||
const combined = whereConditions.slice(1).reduce((result, condition) => { | ||
return { type: 'op', name: 'and', args: [result, condition] } | ||
}, whereConditions[0]) | ||
this.whereConditions = [ | ||
{ type: 'op', name: 'or', args: | ||
[combined, ...parseConditions(conditions, ...values)] } | ||
] | ||
return this | ||
} | ||
/** | ||
@@ -1193,3 +1285,3 @@ * Set GROUP BY attributes. `select_expr` with `AS` is supported, hence following expressions have the same effect: | ||
for (const name of names) { | ||
const token = unalias(this, parseExpr(name)) | ||
const token = parseExpr(name) | ||
if (token.type == 'alias') { | ||
@@ -1206,2 +1298,3 @@ columns.push(token) | ||
/** | ||
* Set the ORDER of the query | ||
* @example | ||
@@ -1212,19 +1305,19 @@ * .order('title') | ||
* @param {string|Object} name | ||
* @param {string} order | ||
* @param {string} direction | ||
* @returns {Spell} | ||
*/ | ||
$order(name, order) { | ||
if (typeof name == 'object') { | ||
$order(name, direction) { | ||
if (isPlainObject(name)) { | ||
for (const prop in name) { | ||
this.$order(prop, name[prop] || 'asc') | ||
this.$order(prop, name[prop]) | ||
} | ||
} | ||
else if (!order) { | ||
[name, order] = name.split(/\s+/) | ||
this.$order(name, order || 'asc') | ||
else if (name.includes(' ')) { | ||
[name, direction] = name.split(/\s+/) | ||
this.$order(name, direction) | ||
} | ||
else { | ||
this.orders.push([ | ||
unalias(this, parseExpr(name)), | ||
order && order.toLowerCase() == 'desc' ? 'desc' : 'asc' | ||
parseExpr(name), | ||
direction && direction.toLowerCase() == 'desc' ? 'desc' : 'asc' | ||
]) | ||
@@ -1260,2 +1353,3 @@ } | ||
/** | ||
* Set the HAVING conditions, which usually appears in GROUP queries only. | ||
* @example | ||
@@ -1270,4 +1364,4 @@ * .having('average between ? and ?', 10, 20) | ||
*/ | ||
$having(conditions, values) { | ||
for (const condition of parseConditions(conditions, values)) { | ||
$having(conditions, ...values) { | ||
for (const condition of parseConditions(conditions, ...values)) { | ||
// Postgres can't have alias in HAVING caluse | ||
@@ -1284,3 +1378,3 @@ // https://stackoverflow.com/questions/32730296/referring-to-a-select-aggregate-column-alias-in-the-having-clause-in-postgres | ||
} | ||
this.havingConditions.push(unalias(this, condition)) | ||
this.havingConditions.push(condition) | ||
} | ||
@@ -1290,3 +1384,17 @@ return this | ||
$orHaving(conditions, ...values) { | ||
this.$having(conditions, ...values) | ||
const { havingConditions } = this | ||
const len = havingConditions.length | ||
const condition = havingConditions.slice(1, len - 1).reduce((result, condition) => { | ||
return { type: 'op', name: 'and', args: [result, condition] } | ||
}, havingConditions[0]) | ||
this.havingConditions = [ | ||
{ type: 'op', name: 'or', args: [condition, havingConditions[len - 1]] } | ||
] | ||
return this | ||
} | ||
/** | ||
* LEFT JOIN predefined associations in model. | ||
* @example | ||
@@ -1302,3 +1410,3 @@ * .with('attachment') | ||
for (const qualifier of qualifiers) { | ||
if (typeof qualifier == 'object') { | ||
if (isPlainObject(qualifier)) { | ||
for (const key in qualifier) { | ||
@@ -1311,6 +1419,2 @@ joinRelation(this, this.Model, this.Model.aliasName, key, qualifier[key]) | ||
} | ||
for (const qualifier in this.joins) { | ||
const relation = this.joins[qualifier] | ||
relation.on = unalias(this, relation.on) | ||
} | ||
return this | ||
@@ -1320,2 +1424,3 @@ } | ||
/** | ||
* LEFT JOIN arbitrary models with specified ON conditions. | ||
* @example | ||
@@ -1340,4 +1445,3 @@ * .join(User, 'users.id = posts.authorId') | ||
} | ||
joins[qualifier] = { Model } | ||
joins[qualifier].on = unalias(this, parseConditions(onConditions, ...values)[0]) | ||
joins[qualifier] = { Model, on: parseConditions(onConditions, ...values)[0] } | ||
return this | ||
@@ -1372,3 +1476,3 @@ } | ||
*/ | ||
batch(size = 1000) { | ||
async * batch(size = 1000) { | ||
const limit = parseInt(size, 10) | ||
@@ -1378,25 +1482,16 @@ if (!(limit > 0)) throw new Error(`Invalid batch limit ${size}`) | ||
const spell = this.limit(limit) | ||
const { factory } = spell | ||
let queue | ||
let i = 0 | ||
const next = () => { | ||
if (!queue) queue = factory(spell) | ||
return queue.then(results => { | ||
if (results.length == 0) return Promise.resolve({ done: true }) | ||
if (results[i]) { | ||
return { | ||
done: results.length < limit && i == results.length - 1, | ||
value: results[i++] | ||
} | ||
} else { | ||
queue = null | ||
i = 0 | ||
spell.$offset(spell.skip + limit) | ||
return next() | ||
} | ||
}) | ||
let results = await spell | ||
while (results.length > 0) { | ||
for (const result of results) { | ||
yield result | ||
} | ||
results = await spell.$offset(spell.skip + limit) | ||
} | ||
return { next } | ||
} | ||
/** | ||
* Format spell into `{ sql, values }`. | ||
* @returns {Object} | ||
*/ | ||
format() { | ||
@@ -1403,0 +1498,0 @@ for (const scope of this.scopes) scope.call(this) |
{ | ||
"name": "leoric", | ||
"version": "0.4.1", | ||
"version": "0.4.2", | ||
"description": "JavaScript Object-relational mapping alchemy", | ||
"main": "index.js", | ||
"types": "index.d.ts", | ||
"scripts": { | ||
"jsdoc": "rm -rf docs/api && jsdoc -c .jsdoc.json -d docs/api -t node_modules/@cara/minami", | ||
"pretest": "./test/prepare.sh", | ||
"test": "DEBUG=leoric mocha --exit test/test.*.js", | ||
"coveralls": "nyc mocha --exit test/test.*.js && nyc report --reporter=text-lcov | coveralls" | ||
"test": "for test in $(ls test/test.*.js); do DEBUG=leoric mocha --exit --timeout 5000 ${test} || exit $?; done", | ||
"coveralls": "for test in $(ls test/test.*.js); do nyc --no-clean mocha --exit --timeout 5000 ${test} || exit $?; done && nyc report --reporter=text-lcov | coveralls" | ||
}, | ||
@@ -12,0 +13,0 @@ "repository": { |
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
121007
15
3398