diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index def1829..c436bcc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,7 +11,7 @@ on: jobs: build: - timeout-minutes: 4 + timeout-minutes: 6 strategy: fail-fast: false diff --git a/lib/intern.ts b/lib/intern.ts index 1aca600..38229d3 100644 --- a/lib/intern.ts +++ b/lib/intern.ts @@ -1,3 +1,5 @@ +const Assert = require('assert') + export class intern { static is_new(ent: any): boolean { // NOTE: This function is intended for use by the #save method. This @@ -34,7 +36,10 @@ export class intern { static is_upsert(msg: any): boolean { const { ent, q } = msg - return intern.is_new(ent) && Array.isArray(q.upsert$) + + return null != q && + intern.is_new(ent) && + Array.isArray(q.upsert$) } @@ -92,69 +97,161 @@ export class intern { } + static is_object(x: any): boolean { + return '[object Object]' === toString.call(x) + } + + + static is_date(x: any): boolean { + return '[object Date]' === toString.call(x) + } + + + static eq_dates(lv: Date, rv: Date): boolean { + return lv.getTime() === rv.getTime() + } + + + static matches_qobj(q: any, mement: any): boolean { + const qprops = Object.keys(q) + + + // NOTE: If the query is an empty object, then we are matching + // all entities. + + if (0 === qprops.length) { + return true + } + + + const does_match = qprops.every(qp => { + const qv = q[qp] + + + if ('and$' === qp) { + Assert(Array.isArray(qv), + 'The and$-operator must be an array') + + return qv.every((subq: any) => intern.matches_qobj(subq, mement)) + } + + + if ('or$' === qp) { + Assert(Array.isArray(qv), + 'The or$-operator must be an array') + + return qv.some((subq: any) => intern.matches_qobj(subq, mement)) + } + + + if (-1 !== qp.indexOf('$')) { + // + // NOTE: We ignore Seneca qualifiers. + // + return true + } + + + if (!(qp in mement)) { + return false + } + + + const ev = mement[qp] + + if (Array.isArray(qv)) { + return -1 !== qv.indexOf(ev) + } + + + if (intern.is_date(qv)) { + return intern.is_date(ev) && intern.eq_dates(qv, ev) + } + + + if (intern.is_object(qv)) { + const qops = Object.keys(qv) + + const does_satisfy_ops = qops.every(op => { + // NOTE: This is the legacy mongo-style constraints. + // + + if ('$ne' === op) return ev != qv[op] + if ('$gte' === op) return ev >= qv[op] + if ('$gt' === op) return ev > qv[op] + if ('$lt' === op) return ev < qv[op] + if ('$lte' === op) return ev <= qv[op] + if ('$in' === op) return qv[op].includes(ev) + if ('$nin' === op) return !qv[op].includes(ev) + + // + // NOTE: The definition for the legacy mongo-style constraints + // ends here. + + + if ('eq$' === op) return ev === qv.eq$ + if ('ne$' === op) return ev !== qv.ne$ + if ('gte$' === op) return ev >= qv.gte$ + if ('gt$' === op) return ev > qv.gt$ + if ('lt$' === op) return ev < qv.lt$ + if ('lte$' === op) return ev <= qv.lte$ + if ('in$' === op) return qv.in$.includes(ev) + if ('nin$' === op) return !qv.nin$.includes(ev) + + + // NOTE: We ignore unknown constraints. + // + + return true + }) + + return does_satisfy_ops + } + + + return qv === ev + }) + + + return does_match + } + + // NOTE: Seneca supports a reasonable set of features // in terms of listing. This function can handle // sorting, skiping, limiting and general retrieval. // static listents(seneca: any, entmap: any, qent: any, q: any, done: any) { - let list = [] + let list: any = [] - let canon = qent.canon$({ object: true }) - let base = canon.base - let name = canon.name + const canon = qent.canon$({ object: true }) + const { base, name } = canon - let entset = entmap[base] ? entmap[base][name] : null - let ent + const entset = entmap[base] ? entmap[base][name] : null if (null != entset && null != q) { if ('string' == typeof q) { - ent = entset[q] - if (ent) { + const match = entset[q] + + if (match) { + const ent = qent.make$(match) list.push(ent) } } else if (Array.isArray(q)) { - q.forEach(function(id) { - let ent = entset[id] - if (ent) { - ent = qent.make$(ent) + for (const id of q) { + const match = entset[id] + + if (match) { + const ent = qent.make$(match) list.push(ent) } - }) - } else if ('object' === typeof q) { - let entids = Object.keys(entset) - next_ent: for (let id of entids) { - ent = entset[id] - for (let p in q) { - let qv = q[p] // query val - let ev = ent[p] // ent val - - if (-1 === p.indexOf('$')) { - if (Array.isArray(qv)) { - if (-1 === qv.indexOf(ev)) { - continue next_ent - } - } else if (null != qv && 'object' === typeof qv) { - // mongo style constraints - if ( - (null != qv.$ne && qv.$ne == ev) || - (null != qv.$gte && qv.$gte > ev) || - (null != qv.$gt && qv.$gt >= ev) || - (null != qv.$lt && qv.$lt <= ev) || - (null != qv.$lte && qv.$lte < ev) || - (null != qv.$in && -1 === qv.$in.indexOf(ev)) || - (null != qv.$nin && -1 !== qv.$nin.indexOf(ev)) || - false - ) { - continue next_ent - } - } else if (qv !== ev) { - continue next_ent - } - } - } - ent = qent.make$(ent) - list.push(ent) } + } else if ('object' === typeof q) { + const mements = Object.values(entset) + const matches = mements.filter(mement => intern.matches_qobj(q, mement)) + const ents = matches.map(match => qent.make$(match)) + + list = list.concat(ents) } } @@ -166,7 +263,7 @@ export class intern { } let sd = q.sort$[sf] < 0 ? -1 : 1 - list = list.sort(function(a, b) { + list = list.sort(function (a: any, b: any) { return sd * (a[sf] < b[sf] ? -1 : a[sf] === b[sf] ? 0 : 1) }) } diff --git a/test/mem.test.js b/test/mem.test.js index cc0f523..9466250 100644 --- a/test/mem.test.js +++ b/test/mem.test.js @@ -103,6 +103,11 @@ describe('mem-store tests', function () { script: lab, }) + Shared.extended({ + seneca: makeSenecaForTest(), + script: lab + }) + it('export-native', function (fin) { Assert.ok( seneca.export('mem-store$1/native') || seneca.export('mem-store/1/native') @@ -590,7 +595,9 @@ describe('mem-store tests', function () { return fin(err) } - expect(out).to.equal([product]) + expect(Array.isArray(out)).to.equal(true) + expect(out.length).to.equal(1) + expect(out[0]).to.contain(product) return fin() } @@ -888,6 +895,196 @@ describe('additional mem-store tests', () => { }) }) + describe('#load by date', () => { + const millenium = new Date(2000, 0, 1) + const elvis_bday = new Date(1935, 0, 8) + + + let seneca + + async function setupTest() { + seneca = makeSenecaForTest() + + await saveEnt(seneca.make('products').data$({ created_at: elvis_bday })) + await saveEnt(seneca.make('products').data$({ created_at: millenium })) + } + + + it('can query by date', async (fin) => { + await setupTest() + + return seneca.make('products') + .load$({ created_at: makeDateSimilarTo(millenium) }, (err, out) => { + if (err) { + return fin(err) + } + + expect(out).to.contain({ + created_at: millenium + }) + + return fin() + }) + }) + + it('can query by date', async (fin) => { + await setupTest() + + return seneca.make('products') + .load$({ created_at: makeDateSimilarTo(elvis_bday) }, (err, out) => { + if (err) { + return fin(err) + } + + expect(out).to.contain({ + created_at: elvis_bday + }) + + return fin() + }) + }) + + it('fails when trying to compare a date field to anything else', (fin) => { + return seneca.make('products') + .load$({ created_at: 123 }, (err, out) => { + if (err) { + return fin(err) + } + + expect(out).to.equal(null) + + return fin() + }) + }) + }) + + describe('#list by date', () => { + const millenium = new Date(2000, 0, 1) + const elvis_bday = new Date(1935, 0, 8) + + + let seneca + + async function setupTest() { + seneca = makeSenecaForTest() + + await saveEnt(seneca.make('products').data$({ created_at: elvis_bday })) + await saveEnt(seneca.make('products').data$({ created_at: millenium })) + } + + + it('can query by date', async (fin) => { + await setupTest() + + return seneca.make('products') + .list$({ created_at: makeDateSimilarTo(millenium) }, (err, out) => { + if (err) { + return fin(err) + } + + expect(out.length).to.equal(1) + + expect(out[0]).to.contain({ + created_at: millenium + }) + + return fin() + }) + }) + + it('can query by date', async (fin) => { + await setupTest() + + return seneca.make('products') + .list$({ created_at: makeDateSimilarTo(elvis_bday) }, (err, out) => { + if (err) { + return fin(err) + } + + expect(out.length).to.equal(1) + + expect(out[0]).to.contain({ + created_at: elvis_bday + }) + + return fin() + }) + }) + + it('cannot match a non-date field', async (fin) => { + await setupTest() + + return seneca.make('products') + .list$({ id: makeDateSimilarTo(elvis_bday) }, (err, out) => { + if (err) { + return fin(err) + } + + expect(out.length).to.equal(0) + + return fin() + }) + }) + }) + + describe('#remove by date', () => { + const millenium = new Date(2000, 0, 1) + const elvis_bday = new Date(1935, 0, 8) + + + let seneca + + async function setupTest() { + seneca = makeSenecaForTest() + + await saveEnt(seneca.make('products').data$({ created_at: elvis_bday })) + await saveEnt(seneca.make('products').data$({ created_at: millenium })) + } + + + it('can query by date', async (fin) => { + await setupTest() + + return seneca.make('products') + .remove$({ created_at: makeDateSimilarTo(millenium) }, (err) => { + if (err) { + return fin(err) + } + + return seneca.make('products').list$({}, (err, out) => { + expect(out.length).to.equal(1) + + expect(out[0]).to.contain({ + created_at: elvis_bday + }) + + return fin() + }) + }) + }) + + it('can query by date', async (fin) => { + await setupTest() + + return seneca.make('products') + .remove$({ created_at: makeDateSimilarTo(elvis_bday) }, (err, out) => { + if (err) { + return fin(err) + } + + return seneca.make('products').list$({}, (err, out) => { + expect(out.length).to.equal(1) + + expect(out[0]).to.contain({ + created_at: millenium + }) + + return fin() + }) + }) + }) + }) + describe('logging', () => { describe('#save', () => { const all_logs = [] @@ -1235,3 +1432,10 @@ function make_it(lab) { ) } } +function saveEnt(ent, save_opts = {}) { + return Util.promisify(ent.save$).call(ent, save_opts) +} + +function makeDateSimilarTo(date) { + return new Date(date) +}