diff --git a/History.md b/History.md index b0fb2358..442ab528 100644 --- a/History.md +++ b/History.md @@ -1,3 +1,11 @@ +0.1.6 / 2017-12-21 +================== + + * New: proper `.first`, `.last`, `.all`, and `.get(index)` + * Fix: accept `Date`, `boolean`, and `Set` values + * Fix: `Model.unscoped` + * Fix: `Model.remove({}, true)` should be unscoped + 0.1.5 / 2017-12-20 ================== diff --git a/Readme.md b/Readme.md index 215bc10e..a085ceb1 100644 --- a/Readme.md +++ b/Readme.md @@ -58,7 +58,7 @@ async function() { | `Post.update({ id: 42 }, { title: 'Skeleton King' })` | `UPDATE posts SET title = 'Skeleton King' WHERE id = 42` | | `Post.remove({ id: 42 })` | `DELETE FROM posts WHERE id = 42` | -A more detailed syntax table may be found at the [documentation](http://cyj.me/leoric) site. +A more detailed syntax table may be found at the [documentation](http://cyj.me/leoric/#syntax-table) site. ## Migrations diff --git a/lib/bone.js b/lib/bone.js index bb677991..7553016f 100644 --- a/lib/bone.js +++ b/lib/bone.js @@ -588,11 +588,19 @@ class Bone { } static get first() { - return this.findOne().$order(this.primaryKey) + return this.find().first } static get last() { - return this.findOne().$order(this.primaryKey, 'desc') + return this.find().last + } + + static get unscoped() { + return this.find().unscoped + } + + static get(index) { + return this.find().$get(index) } static find(conditions, ...values) { @@ -633,28 +641,29 @@ class Bone { static findOne(conditions, ...values) { const spell = this.find(conditions, ...values).$limit(1) - spell.onresolve = results => { - return results.length > 0 && results[0] instanceof this ? results[0] : null - } - return spell + return spell.$get(0) } static include(...names) { return this.find().$with(...names) } - static where(conditions, ...values) { - return this.find(conditions, ...values) - } - static select(...attributes) { return this.find().$select(...attributes) } + static where(conditions, ...values) { + return this.find(conditions, ...values) + } + static group(...names) { return this.find().$group(...names) } + static order(name, order) { + return this.find().$order(name, order) + } + static join(Model, conditions, ...values) { return this.find().$join(Model, conditions, ...values) } @@ -721,7 +730,7 @@ class Bone { }) }) }) - return spell.$where(conditions).$delete() + return spell.unscoped.$where(conditions).$delete() } else if (this.schema.deletedAt) { return this.update(conditions, { deletedAt: new Date() }) diff --git a/lib/expr.js b/lib/expr.js index a7d0279d..8edb5396 100644 --- a/lib/expr.js +++ b/lib/expr.js @@ -113,12 +113,24 @@ function parseExpr(str, ...values) { else if (Array.isArray(value)) { return { type: 'array', value } } + else if (value instanceof Date) { + return { type: 'date', value } + } + else if (value instanceof Set) { + return { type: 'array', value: Array.from(value) } + } else if (typeof value == 'number') { return { type: 'number', value } } else if (typeof value == 'string') { return { type: 'string', value } } + else if (typeof value == 'boolean') { + return { type: 'boolean', value: value } + } + else if (typeof value.toSqlString == 'function') { + return { type: 'subquery', value } + } else { throw new Error(`Unexpected value ${value} at ${valueIndex - 1}`) } diff --git a/lib/spell.js b/lib/spell.js index c2066ca1..6b2369f8 100644 --- a/lib/spell.js +++ b/lib/spell.js @@ -31,6 +31,15 @@ function isIdentifier(name) { return /^[0-9a-z$_.]+$/i.test(name) } +/** + * Allows two types of params: + * + * parseConditions({ foo: { $op: value } }) + * parseConditions('foo = ?', value) + * + * @param {(string|Object)} conditions + * @param {...*} values + */ function parseConditions(conditions, ...values) { if (typeof conditions == 'object') { return parseObjectConditions(conditions) @@ -43,24 +52,29 @@ function parseConditions(conditions, ...values) { } } +/** + * A wrapper of parseExpr to help parsing values in object conditions. + * @param {*} value + */ function parseObjectValue(value) { - if (value == null) { - return { type: 'null' } - } - else if (Array.isArray(value)) { - return { type: 'array', value } - } - else if (typeof value == 'string') { - return { type: 'string', value } + try { + return parseExpr('?', value) } - else if (typeof value == 'number') { - return { type: 'number', value } - } - else { + catch (err) { throw new Error(`Unexpected object value ${value}`) } } +/** + * parse conditions in MongoDB style, which is quite polular in ORMs for JavaScript, e.g. + * + * { foo: null } + * { foo: { $gt: new Date(2012, 4, 15) } } + * { foo: { $between: [1, 10] } } + * + * See OPERATOR_MAP for supported `$op`s. + * @param {Object} conditions + */ function parseObjectConditions(conditions) { const result = [] @@ -71,7 +85,7 @@ function parseObjectConditions(conditions) { result.push({ type: 'op', name: 'in', - args: [ parseExpr(name), { type: 'array', value } ] + args: [ parseExpr(name), { type: 'subquery', value } ] }) } else if (value != null && typeof value == 'object' && !Array.isArray(value) && Object.keys(value).length == 1) { @@ -99,6 +113,15 @@ function parseObjectConditions(conditions) { return result } +/** + * A helper method that translates select to filter function from following types: + * + * name => ['foo', 'bar'].includes(name) // as is + * 'foo bar' // + * ['foo', 'bar'] // as the arrow function above + * + * @param {(function|string|string[])} select + */ function parseSelect(select) { const type = typeof select @@ -111,6 +134,11 @@ function parseSelect(select) { } } +/** + * Translate key-value pairs of attributes 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 + */ function parseSet(spell, obj) { const { Model } = spell const sets = {} @@ -121,14 +149,29 @@ function parseSet(spell, obj) { return sets } -function formatOrders(spell) { +/** + * Format orders into ORDER BY clause in SQL + * @param {Object[]} orders + */ +function formatOrders(orders) { const formatOrder = ([token, order]) => { const column = formatColumn(token) return order == 'desc' ? `${column} DESC` : column } - return spell.orders.map(formatOrder) + return orders.map(formatOrder) } +/** + * Format token into identifiers/functions/etc. in SQL + * + * formatColumn({ type: 'id', value: 'title' }) + * // => `title` + * + * formatColumn({ type: 'func', name: 'year', args: [ { type: 'id', value: 'createdAt' } ] }) + * // => YEAR(`createdAt`) + * + * @param {(string|Object)} token + */ function formatColumn(token) { if (typeof token == 'string') token = parseExpr(token) @@ -154,10 +197,14 @@ function formatExpr(ast) { switch (type) { case 'string': case 'number': + case 'date': + case 'boolean': case 'null': return escape(value) + case 'subquery': + return `(${value.toSqlString()})` case 'array': - return `(${value.toSqlString ? value.toSqlString() : escape(value)})` + return `(${escape(value)})` case 'wildcard': return '*' case 'id': @@ -252,7 +299,7 @@ function formatSelectWithoutJoin(spell) { chunks.push(`HAVING ${formatConditions(havingConditions)}`) } - if (orders.length > 0) chunks.push(`ORDER BY ${formatOrders(spell).join(', ')}`) + if (orders.length > 0) chunks.push(`ORDER BY ${formatOrders(orders).join(', ')}`) if (rowCount > 0) chunks.push(`LIMIT ${rowCount}`) if (skip > 0) chunks.push(`OFFSET ${skip}`) @@ -400,7 +447,7 @@ function formatSelectWithJoin(spell) { } } - if (orders.length > 0) chunks.push(`ORDER BY ${formatOrders(spell).join(', ')}`) + if (orders.length > 0) chunks.push(`ORDER BY ${formatOrders(orders).join(', ')}`) return chunks.join(' ') } @@ -575,7 +622,6 @@ class Spell { constructor(Model, factory, opts) { this.Model = Model this.factory = factory - this.triggered = false Object.assign(this, { command: 'select', columns: [], @@ -637,6 +683,20 @@ class Spell { return spell } + get all() { + return this + } + + get first() { + const spell = this.order(this.Model.primaryKey) + return spell.$get(0) + } + + get last() { + const spell = this.order(this.Model.primaryKey, 'desc') + return spell.$get(0) + } + get dup() { return new Spell(this.Model, this.factory, { command: this.command, @@ -650,27 +710,33 @@ class Spell { joins: this.joins, skip: this.skip, rowCount: this.rowCount, - scopes: [...this.scopes], - triggered: false, - onresolve: this.onresolve + scopes: [...this.scopes] }) } + $get(index) { + const { factory, Model } = this + this.factory = spell => { + return factory(spell).then(results => { + const result = results[index] + return result instanceof Model ? result : null + }) + } + return this + } + /** * Fake this as a thenable object. * @param {Function} resolve * @param {Function} reject */ then(resolve, reject) { - // `Model.findOne()` needs to edit the resolved results before returning it to user. Hence a intermediate resolver called `onresolve` is added. It gets copied when the spell is duplicated too. - let promise = new Promise(resolve => { + this.promise = new Promise(resolve => { setImmediate(() => { resolve(this.factory.call(null, this)) }) }) - const { onresolve } = this - if (onresolve) promise = promise.then(onresolve) - return promise.then(resolve, reject) + return this.promise.then(resolve, reject) } /** @@ -714,7 +780,7 @@ class Spell { $from(table) { this.table = table instanceof Spell - ? { type: 'array', value: table } + ? { type: 'subquery', value: table } : parseExpr(table) return this } diff --git a/package.json b/package.json index ef9fd8ee..824a3fed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "leoric", - "version": "0.1.5", + "version": "0.1.6", "description": "Object-relational mapping for Node.js", "main": "index.js", "scripts": { diff --git a/tests/dumpfile.sql b/tests/dumpfile.sql index c9e8e1a7..30c303ee 100644 --- a/tests/dumpfile.sql +++ b/tests/dumpfile.sql @@ -13,6 +13,7 @@ CREATE TABLE `articles` ( `extra` text, `thumb` varchar(1000) DEFAULT NULL, `author_id` bigint(20) unsigned DEFAULT NULL, + `is_private`tinyint(1) unsigned DEFAULT 0, PRIMARY KEY (`id`) ); diff --git a/tests/test.bone.js b/tests/test.bone.js index 1e8741bd..953b94a2 100644 --- a/tests/test.bone.js +++ b/tests/test.bone.js @@ -38,12 +38,23 @@ describe('=> Attributes', function() { expect(() => post.attribute('non-existant attribute')).to.throwException() }) + it('bone.attribute(missing attribute)', async function() { + const post = await Post.first.select('title') + expect(() => post.thumb).to.throwException() + expect(() => post.attribute('thumb')).to.throwException() + }) + it('bone.attribute(name, value)', async function() { const post = new Post({ title: 'Untitled' }) post.attribute('title', undefined) expect(post.attribute('title')).to.be(null) }) + it('bone.attribute(missing attribute, value)', async function() { + const post = await Post.first.select('title') + expect(() => post.attribute('thumn', 'foo')).to.throwException() + }) + it('bone.attributeWas(name) should be undefined when initialized', async function() { const post = new Post({ title: 'Untitled' }) expect(post.attributeWas('createdAt')).to.be(null) @@ -266,9 +277,12 @@ describe('=> Type casting', function() { describe('=> Query', function() { before(async function() { - await Post.create({ title: 'King Leoric' }) - await Post.create({ title: 'Archbishop Lazarus' }) - await Post.create({ title: 'Archangel Tyrael'}) + await Promise.all([ + Post.create({ id: 1, title: 'King Leoric', createdAt: new Date(2017, 10) }), + Post.create({ id: 2, title: 'Archbishop Lazarus', createdAt: new Date(2017, 10) }), + Post.create({ id: 3, title: 'Archangel Tyrael', isPrivate: true }), + Post.create({ id: 4, title: 'Diablo', deletedAt: new Date(2012, 4, 15) }) + ]) }) after(async function() { @@ -293,6 +307,19 @@ describe('=> Query', function() { expect(post.title).to.be('Archangel Tyrael') }) + it('.unscoped', async function() { + const posts = await Post.unscoped.all + expect(posts.length).to.be(4) + const post = await Post.unscoped.last + expect(post).to.eql(posts[3]) + }) + + it('.get()', async function() { + expect(await Post.get(0)).to.eql(await Post.first) + expect(await Post.get(2)).to.eql(await Post.last) + expect(await Post.unscoped.get(2)).to.eql(await Post.unscoped.last) + }) + it('.findOne()', async function() { const post = await Post.findOne() expect(post).to.be.a(Post) @@ -347,6 +374,24 @@ describe('=> Query', function() { ]) }) + it('.find({ foo: Set })', async function() { + const posts = await Post.find({ title: new Set(['King Leoric', 'Archangel Tyrael']) }) + expect(posts.map(post => post.title)).to.eql([ + 'King Leoric', 'Archangel Tyrael' + ]) + }) + + it('.find({ foo: Date })', async function() { + const posts = await Post.find('createdAt <= ?', new Date(2017, 11)) + expect(posts.map(post => post.title)).to.eql(['King Leoric', 'Archbishop Lazarus']) + }) + + it('.find({ foo: boolean })', async function() { + const posts = await Post.find({ isPrivate: true }) + expect(posts.map(post => post.title)).to.eql([ 'Archangel Tyrael' ]) + expect((await Post.find({ isPrivate: false })).length).to.be(2) + }) + it('.find { limit }', async function() { const posts = await Post.find({}, { limit: 1 }) expect(posts.length).to.equal(1) @@ -374,7 +419,6 @@ describe('=> Query', function() { }) it('.find aliased attribute', async function() { - await Post.create({ title: 'Diablo', deletedAt: new Date(2012, 4, 15) }) const post = await Post.findOne({ deletedAt: { $ne: null } }) expect(post.deletedAt).to.be.a(Date) }) @@ -403,6 +447,12 @@ describe('=> Query $op', function() { expect(posts[0].title).to.equal('King Leoric') }) + it('.find $eq Date', async function() { + const posts = await Post.find({ createdAt: { $eq: new Date(2012, 4, 15) } }) + expect(posts.length).to.be(1) + expect(posts[0].title).to.be('King Leoric') + }) + it('.find $gt', async function() { const posts = await Post.find({ id: { $gt: 99 } }, { limit: 10 }) expect(posts.length).to.be.above(0) @@ -527,7 +577,7 @@ describe('=> Scopes', function() { expect(post.title).to.be('King Leoric') }) - it ('.update().unscoped', async function() { + it('.update().unscoped', async function() { await Post.update({ title: 'King Leoric' }, { title: 'Skeleton King' }) expect(await Post.findOne({ title: 'Skeleton King' })).to.be(null) await Post.update({ title: 'King Leoric' }, { title: 'Skeleton King' }).unscoped @@ -741,7 +791,7 @@ describe('=> Remove', function() { it('Bone.remove(where, true) should REMOVE rows no matter the presence of deletedAt', async function() { await Post.create({ title: 'King Leoric' }) expect(await Post.remove({ title: 'King Leoric' }, true)).to.be(1) - expect(await Post.findOne({ title: 'King Leoric' })).to.be(null) + expect(await Post.unscoped.all).to.empty() }) }) @@ -784,6 +834,47 @@ describe('=> Calculations', function() { }) }) +// https://dev.mysql.com/doc/refman/5.7/en/date-and-time-functions.html +describe('=> Date Functions', function() { + before(async function() { + await Promise.all([ + Post.create({ title: 'King Leoric', createdAt: new Date(2012, 4, 15) }), + Post.create({ title: 'Archbishop Lazarus', createdAt: new Date(2012, 4, 15) }), + Post.create({ title: 'Leah', createdAt: new Date(2017, 10, 11) }) + ]) + }) + + after(async function() { + await Post.remove({}, true) + }) + + it('SELECT YEAR(date)', async function() { + expect(await Post.select('YEAR(createdAt) as year').order('year')).to.eql([ + { year: 2012 }, { year: 2012 }, { year: 2017 } + ]) + }) + + it('WHERE YEAR(date)', async function() { + const posts = await Post.select('title').where('YEAR(createdAt) = 2017') + expect(posts.map(post => post.title)).to.eql(['Leah']) + }) + + // TODO: .group('MONTH(createdAt) as month') + it('GROUP BY MONTH(date)', async function() { + expect(await Post.group('MONTH(createdAt)').count()).to.eql([ + { count: 2, 'MONTH(`gmt_create`)': 5 }, + { count: 1, 'MONTH(`gmt_create`)': 11 } + ]) + }) + + it('ORDER BY DAY(date)', async function() { + const posts = await Post.order('DAY(createdAt)').select('title') + expect(posts.map(post => post.title)).to.eql([ + 'Leah', 'King Leoric', 'Archbishop Lazarus' + ]) + }) +}) + describe('=> Count / Group / Having', function() { before(async function() { await Promise.all([ diff --git a/tests/test.expr.js b/tests/test.expr.js index 20d27605..eadc9a6a 100644 --- a/tests/test.expr.js +++ b/tests/test.expr.js @@ -81,8 +81,8 @@ describe('=> parse unary operators', function() { }) }) -describe('=> parse compound expressions', function() { - it('parse placeholder', function() { +describe('=> parse placeholder', function() { + it('parse placeholder of string', function() { expect(expr('title like ?', '%Leoric%')).to.eql({ type: 'op', name: 'like', @@ -93,6 +93,24 @@ describe('=> parse compound expressions', function() { }) }) + it('parse placeholder of Set', function() { + expect(expr('?', new Set(['foo', 'bar']))).to.eql({ + type: 'array', value: ['foo', 'bar'] + }) + }) + + it('parse placeholder of Date', function() { + const date = new Date(2012, 4, 15) + expect(expr('?', date)).to.eql({ type: 'date', value: date }) + }) + + it('parse placeholder of boolean', function() { + expect(expr('?', true)).to.eql({ type: 'boolean', value: true }) + expect(expr('?', false)).to.eql({ type: 'boolean', value: false }) + }) +}) + +describe('=> parse compound expressions', function() { it('parse expressions with priorities', function() { expect(expr('id > 100 OR (id != 1 AND id != 2)')).to.eql({ type: 'op',