diff --git a/bookshop b/bookshop index d985b72..20420ed 160000 --- a/bookshop +++ b/bookshop @@ -1 +1 @@ -Subproject commit d985b7230346c19191cae17f3685760032591540 +Subproject commit 20420ed1bef842e387ed1b231ef21ac8c630e849 diff --git a/tests/cds.ql.test.js b/tests/cds.ql.test.js new file mode 100644 index 0000000..4ebfe63 --- /dev/null +++ b/tests/cds.ql.test.js @@ -0,0 +1,844 @@ +const cds = require('@sap/cds') +const { expect } = cds.test + +describe('cds.ql → cqn', () => { + + const Foo = { name: 'Foo' } + const Books = { name: 'capire.bookshop.Books' } + + const STAR = '*' + const skip = {to:{eql:()=>skip}} + const srv = new cds.Service + let cqn + + expect.plain = (cqn) => !cqn.SELECT.one && !cqn.SELECT.distinct ? expect(cqn) : skip + expect.one = (cqn) => !cqn.SELECT.distinct ? expect(cqn) : skip + + describe.each(['SELECT', 'SELECT one', 'SELECT distinct'])(`%s...`, (each) => { + + let SELECT; beforeEach(()=> SELECT = ( + each === 'SELECT distinct' ? cds.ql.SELECT.distinct : + each === 'SELECT one' ? cds.ql.SELECT.one : + cds.ql.SELECT + )) + + test(`from Foo`, () => { + expect(cqn = SELECT `from Foo`) + .to.eql(SELECT.from `Foo`) + .to.eql(SELECT.from('Foo')) + .to.eql(SELECT.from(Foo)) + .to.eql(SELECT`Foo`) + .to.eql(SELECT('Foo')) + .to.eql(SELECT(Foo)) + expect.plain(cqn) + .to.eql(CQL`SELECT from Foo`) + .to.eql(srv.read `Foo`) + .to.eql(srv.read('Foo')) + .to.eql(srv.read(Foo)) + .to.eql({ + SELECT: { from: { ref: ['Foo'] } }, + }) + }) + + if (each === 'SELECT') + test('SELECT ( Foo )', () => { + expect({ + SELECT: { from: { ref: ['Foo'] } }, + }) + .to.eql(CQL`SELECT from Foo`) + .to.eql(SELECT(Foo)) + }) + + if (each === 'SELECT') + test('SELECT ( Foo ) .from ( Bar )', () => { + + expect({ + SELECT: { columns:[{ref:['Foo']}], from: { ref: ['Bar'] } }, + }) + .to.eql(CQL`SELECT Foo from Bar`) + .to.eql(SELECT `Foo` .from `Bar`) + .to.eql(SELECT `Foo` .from('Bar')) + .to.eql(SELECT('Foo').from('Bar')) + .to.eql(SELECT(['Foo']).from('Bar')) + .to.eql(SELECT(['Foo']).from('Bar')) + .to.eql(SELECT `Bar` .columns `Foo`) + .to.eql(SELECT `Bar` .columns ('Foo')) + .to.eql(SELECT `Bar` .columns (['Foo'])) + .to.eql(SELECT.from `Bar` .columns ('Foo')) + .to.eql(SELECT.from `Bar` .columns (['Foo'])) + + expect({ + SELECT: { columns:[ + {ref:['Foo']}, + {ref:['Boo']}, + ], from: { ref: ['Bar'] } }, + }) + .to.eql(CQL`SELECT Foo, Boo from Bar`) + .to.eql(SELECT `Foo, Boo` .from `Bar`) + .to.eql(SELECT `Foo, Boo` .from('Bar')) + .to.eql(SELECT('Foo','Boo').from('Bar')) + .to.eql(SELECT(['Foo','Boo']).from('Bar')) + .to.eql(SELECT `Bar` .columns `Foo, Boo`) + .to.eql(SELECT `Bar` .columns `{ Foo, Boo }`) + .to.eql(SELECT `Bar` .columns ('{ Foo, Boo }')) + .to.eql(SELECT `Bar` .columns ('Foo','Boo')) + .to.eql(SELECT `Bar` .columns (['Foo','Boo'])) + .to.eql(SELECT.from `Bar` .columns ('Foo','Boo')) + .to.eql(SELECT.from `Bar` .columns (['Foo','Boo'])) + + expect({ + SELECT: { columns:[ + {ref:['Foo']}, + {ref:['Boo']}, + {ref:['Moo']}, + ], from: { ref: ['Bar'] } }, + }) + .to.eql(CQL`SELECT Foo, Boo, Moo from Bar`) + .to.eql(SELECT `Foo, Boo, Moo` .from `Bar`) + .to.eql(SELECT `Foo, Boo, Moo` .from('Bar')) + .to.eql(SELECT('Foo','Boo','Moo').from('Bar')) + .to.eql(SELECT(['Foo','Boo','Moo']).from('Bar')) + .to.eql(SELECT `Bar` .columns `Foo, Boo, Moo`) + .to.eql(SELECT `Bar` .columns ('Foo','Boo','Moo')) + .to.eql(SELECT `Bar` .columns (['Foo','Boo','Moo'])) + .to.eql(SELECT.from `Bar` .columns ('Foo','Boo','Moo')) + .to.eql(SELECT.from `Bar` .columns (['Foo','Boo','Moo'])) + + + expect({ + SELECT: { one:true, columns:[{ref:['Foo']}], from: { ref: ['Bar'] } }, + }) + // .to.eql(CQL`SELECT one Foo from Bar`) + .to.eql(SELECT.one `Foo` .from `Bar`) + .to.eql(SELECT.one `Foo` .from('Bar')) + .to.eql(SELECT.one('Foo').from('Bar')) + .to.eql(SELECT.one(['Foo']).from('Bar')) + .to.eql(SELECT.one(['Foo']).from('Bar')) + .to.eql(SELECT.one('Bar',['Foo'])) + .to.eql(SELECT.one `Bar` .columns `Foo`) + .to.eql(SELECT.one('Bar').columns('Foo')) + .to.eql(SELECT.one('Bar').columns(['Foo'])) + .to.eql(SELECT.one.from('Bar',['Foo'])) + .to.eql(SELECT.one.from('Bar').columns('Foo')) + .to.eql(SELECT.one.from('Bar').columns(['Foo'])) + + expect({ + SELECT: { one:true, columns:[ + {ref:['Foo']}, + {ref:['Boo']}, + ], from: { ref: ['Bar'] } }, + }) + // .to.eql(CQL`SELECT Foo, Boo from Bar`) + .to.eql(SELECT.one `Foo, Boo` .from `Bar`) + .to.eql(SELECT.one `Foo, Boo` .from('Bar')) + .to.eql(SELECT.one('Foo','Boo').from('Bar')) + .to.eql(SELECT.one(['Foo','Boo']).from('Bar')) + .to.eql(SELECT.one('Bar',['Foo','Boo'])) + .to.eql(SELECT.one `Bar` .columns `Foo, Boo`) + .to.eql(SELECT.one('Bar').columns('Foo','Boo')) + .to.eql(SELECT.one('Bar').columns(['Foo','Boo'])) + .to.eql(SELECT.one.from('Bar',['Foo','Boo'])) + .to.eql(SELECT.one.from('Bar').columns('Foo','Boo')) + .to.eql(SELECT.one.from('Bar').columns(['Foo','Boo'])) + + expect({ + SELECT: { one:true, columns:[ + {ref:['Foo']}, + {ref:['Boo']}, + {ref:['Moo']}, + ], from: { ref: ['Bar'] } }, + }) + // .to.eql(CQL`SELECT Foo, Boo, Moo from Bar`) + .to.eql(SELECT.one `Foo, Boo, Moo` .from `Bar`) + .to.eql(SELECT.one `Foo, Boo, Moo` .from('Bar')) + .to.eql(SELECT.one('Foo','Boo','Moo').from('Bar')) + .to.eql(SELECT.one(['Foo','Boo','Moo']).from('Bar')) + .to.eql(SELECT.one('Bar',['Foo','Boo','Moo'])) + .to.eql(SELECT.one `Bar` .columns `Foo, Boo, Moo`) + .to.eql(SELECT.one('Bar').columns('Foo','Boo','Moo')) + .to.eql(SELECT.one('Bar').columns(['Foo','Boo','Moo'])) + .to.eql(SELECT.one.from('Bar',['Foo','Boo','Moo'])) + .to.eql(SELECT.one.from('Bar').columns('Foo','Boo','Moo')) + .to.eql(SELECT.one.from('Bar').columns(['Foo','Boo','Moo'])) + + }) + + if (each === 'SELECT') + test('from ( Foo )', () => { + expect({ + SELECT: { from: {ref: [{ id:'Foo', where: [{val:11}] }] }} + }) + .to.eql(srv.read`Foo[${11}]`) + .to.eql(SELECT`Foo[${11}]`) + + expect((cqn = SELECT`from Foo[ID=11]`)) + .to.eql(SELECT`from Foo[ID=${11}]`) + .to.eql(SELECT.from `Foo[ID=11]`) + .to.eql(SELECT.from `Foo[ID=${11}]`) + .to.eql(SELECT`Foo[ID=11]`) + expect.plain(cqn) + .to.eql(CQL`SELECT from Foo[ID=11]`) + .to.eql(srv.read`Foo[ID=11]`) + .to.eql({ + SELECT: { from: { + ref: [{ id: 'Foo', where: [{ ref: ['ID'] }, '=', { val: 11 }] }], + }}, + }) + + expect.plain (cqn) + .to.eql(SELECT`Foo[ID=${11}]`) + .to.eql(srv.read`Foo[ID=${11}]`) + + // Following implicitly resolve to SELECT.one + expect(cqn = SELECT.from(Foo,11)) + .to.eql(SELECT.from(Foo,{ID:11})) + .to.eql(SELECT.from(Foo).byKey(11)) + .to.eql(SELECT.from(Foo).byKey({ID:11})) + if (cds.version >= '5.6.0') { + expect.one(cqn) + .to.eql({ + SELECT: { + one: true, + from: { ref: [{ id: 'Foo', where: [{ ref: ['ID'] }, '=', { val: 11 }] }] }, + }, + }) + } else { + expect.one(cqn) + .to.eql({ + SELECT: { + one: true, + from: { ref: ['Foo'] }, + where: [{ ref: ['ID'] }, '=', { val: 11 }], + }, + }) + } + + }) + + test('from Foo {...}', () => { + + expect(cqn = SELECT `*,a,b as c` .from `Foo`) + .to.eql(SELECT `*,a,b as c`. from(Foo)) + .to.eql(SELECT('*','a',{b:'c'}).from`Foo`) + .to.eql(SELECT('*','a',{b:'c'}).from(Foo)) + .to.eql(SELECT(['*','a',{b:'c'}]).from(Foo)) + .to.eql(SELECT.columns('*','a',{b:'c'}).from(Foo)) + .to.eql(SELECT.columns(['*','a',{b:'c'}]).from(Foo)) + .to.eql(SELECT.columns((foo) => { foo`.*`, foo.a, foo.b`as c` }).from(Foo)) + .to.eql(SELECT.columns((foo) => { foo('*'), foo.a, foo.b.as('c') }).from(Foo)) + .to.eql(SELECT.from(Foo).columns('*','a',{b:'c'})) + .to.eql(SELECT.from(Foo).columns(['*','a',{b:'c'}])) + .to.eql(SELECT.from(Foo).columns((foo) => { foo`.*`, foo.a, foo.b`as c` })) + .to.eql(SELECT.from(Foo).columns((foo) => { foo('*'), foo.a, foo.b.as('c') })) + .to.eql(SELECT.from(Foo,['*','a',{b:'c'}])) + .to.eql(SELECT.from(Foo, (foo) => { foo`.*`, foo.a, foo.b`as c` })) + .to.eql(SELECT.from(Foo, (foo) => { foo('*'), foo.a, foo.b.as('c') })) + + expect.plain(cqn) + .to.eql({ + SELECT: { + from: { ref: ['Foo'] }, + columns: [ STAR, { ref: ['a'] }, { ref: ['b'], as: 'c' }], + }, + }) + + expect.plain(cqn) + .to.eql(CQL`SELECT *,a,b as c from Foo`) + .to.eql(CQL`SELECT from Foo {*,a,b as c}`) + + // Test combination with key as second argument to .from + expect(cqn = SELECT.from(Foo, 11, ['a'])) + .to.eql(SELECT.from(Foo, 11, foo => foo.a)) + + if (cds.version >= '5.6.0') { + expect.one(cqn) + .to.eql({ + SELECT: { + one: true, + from: { ref: [{ id: 'Foo', where: [{ ref: ['ID'] }, '=', { val: 11 }]}] }, + columns: [{ ref: ['a'] }] + }, + }) + } else { + expect.one(cqn) + .to.eql({ + SELECT: { + one: true, + from: { ref: ['Foo'] }, + columns: [{ ref: ['a'] }], + where: [{ ref: ['ID'] }, '=', { val: 11 }], + }, + }) + } + + }) + + test('with nested expands', () => { + // SELECT from Foo { *, x, bar.*, car{*}, boo { *, moo.zoo } } + expect(cqn = + SELECT.from (Foo, foo => { + foo`*`, foo.x, foo.car`*`, foo.boo (b => { + b`*`, b.moo.zoo( + x => x.y.z + ) + }) + }) + ).to.eql( + SELECT.from (Foo, foo => { + foo('*'), foo.x, foo.car('*'), foo.boo (b => { + b('*'), b.moo.zoo( + x => x.y.z + ) + }) + }) + ) + + expect.plain(cqn) + .to.eql({ + SELECT: { + from: { ref: ['Foo'] }, + columns: [ + STAR, + { ref: ['x'] }, + { ref: ['car'], expand: ['*'] }, + { + ref: ['boo'], + expand: [ '*', { ref: ['moo', 'zoo'], expand: [{ ref: ['y', 'z'] }] }], + }, + ], + }, + }) + }) + + + test('with nested inlines', () => { + // SELECT from Foo { *, x, bar.*, car{*}, boo { *, moo.zoo } } + expect.plain( + SELECT.from (Foo, foo => { + foo.bar `*`, + foo.bar `.*`, //> leading dot indicates inline + foo.boo(_ => _.moo.zoo), //> underscore arg name indicates inline + foo.boo(x => x.moo.zoo) + }) + ).to.eql({ + SELECT: { + from: { ref: ['Foo'] }, + columns: [ + { ref: ['bar'], expand: ['*'] }, + { ref: ['bar'], inline: ['*'] }, + { ref: ['boo'], inline: [{ ref: ['moo', 'zoo'] }] }, + { ref: ['boo'], expand: [{ ref: ['moo', 'zoo'] }] }, + ], + }, + }) + }) + + }) + + describe ('SELECT where...', ()=>{ + + it('should correctly handle { ... and:{...} }', () => { + expect(SELECT.from(Foo).where({ x: 1, and: { y: 2, or: { z: 3 } } })).to.eql({ + SELECT: { + from: { ref: ['Foo'] }, + where: [ + { ref: ['x'] }, + '=', + { val: 1 }, + 'and', + // '(', + {xpr:[ + { ref: ['y'] }, + '=', + { val: 2 }, + 'or', + { ref: ['z'] }, + '=', + { val: 3 }, + ]}, + // ')', + ], + }, + }) + }) + + test ("where x='*'", ()=>{ + expect (SELECT.from(Foo).where({x:'*'})) + .to.eql(SELECT.from(Foo).where("x='*'")) + .to.eql(SELECT.from(Foo).where("x=",'*')) + .to.eql(SELECT.from(Foo).where`x=${'*'}`) + .to.eql( + CQL`SELECT from Foo where x='*'` + ) + expect (SELECT.from(Foo).where({x:['*',1]})) + .to.eql(SELECT.from(Foo).where("x in ('*',1)")) + .to.eql(SELECT.from(Foo).where("x in",['*',1])) + .to.eql(SELECT.from(Foo).where`x in ${['*',1]}`) + .to.eql( + CQL`SELECT from Foo where x in ('*',1)` + ) + }) + + test ('where, and, or', ()=>{ + expect ( + SELECT.from(Foo).where({x:1,and:{y:2}}) + ).to.eql ( + CQL`SELECT from Foo where x=1 and y=2` + ) .to.eql ({ SELECT: { + from: {ref:['Foo']}, + where: [ + {ref:['x']}, '=', {val:1}, + 'and', + {ref:['y']}, '=', {val:2} + ] + }}) + + const ql_with_groups_fix = !!cds.ql.Query.prototype.flat + if (ql_with_groups_fix) { + + expect ( + SELECT.from(Foo).where({x:1}).or({y:2}).and({z:3}) + ).to.eql ({ SELECT: { + from: {ref:['Foo']}, + where: [ + {ref:['x']}, '=', {val:1}, + 'or', + {ref:['y']}, '=', {val:2}, + 'and', + {ref:['z']}, '=', {val:3}, + ] + }}) + + expect ( + SELECT.from(Foo).where({x:1,or:{y:2}}).and({z:3}) + ).to.eql ({ SELECT: { + from: {ref:['Foo']}, + where: [ + {xpr:[ + {ref:['x']}, '=', {val:1}, + 'or', + {ref:['y']}, '=', {val:2}, + ]}, + 'and', + {ref:['z']}, '=', {val:3}, + ] + }}) + + expect ( + SELECT.from(Foo).where({a:1}).or({x:1,or:{y:2}}).and({z:3}) + ).to.eql ({ SELECT: { + from: {ref:['Foo']}, + where: [ + {ref:['a']}, '=', {val:1}, + 'or', + {xpr:[ + {ref:['x']}, '=', {val:1}, + 'or', + {ref:['y']}, '=', {val:2}, + ]}, + 'and', + {ref:['z']}, '=', {val:3}, + ] + }}) + + expect ( + { SELECT: SELECT.from(Foo).where({x:1,or:{y:2}}).SELECT } + ).to.eql ({ SELECT: { + from: {ref:['Foo']}, + where: [ + {ref:['x']}, '=', {val:1}, + 'or', + {ref:['y']}, '=', {val:2}, + ] + }}) + + } + + + expect ( + SELECT.from(Foo).where({x:1,and:{y:2}}).or({z:3}) + ).to.eql ( + CQL`SELECT from Foo where x=1 and y=2 or z=3` + ) + + expect ( + SELECT.from(Foo).where({x:1}).and({y:2,or:{z:3}}) + ).to.eql ( + CQL`SELECT from Foo where x=1 and ( y=2 or z=3 )` + ) + + expect ( + SELECT.from(Foo).where({1:1}).and({x:1,or:{x:2}}).and({y:2,or:{z:3}}) + ).to.eql ( + CQL`SELECT from Foo where 1=1 and ( x=1 or x=2 ) and ( y=2 or z=3 )` + ) + + expect ( + SELECT.from(Foo).where({x:1,or:{x:2}}).and({y:2,or:{z:3}}) + ).to.eql ( + CQL`SELECT from Foo where ( x=1 or x=2 ) and ( y=2 or z=3 )` + ) + }) + + test('where ({x:[undefined]})', () => { + expect ( + SELECT.from(Foo).where({x:[undefined]}) + ).to.eql ({ SELECT: { + from: {ref:['Foo']}, + where: [ + {ref:['x']}, + 'in', + { list: [ {val:undefined} ] } + ] + }}) + }) + + test('where ( ... cql | {x:y} )', () => { + const args = [`foo`, "'bar'", 3] + const ID = 11 + + // using simple predicate objects + // (Note: this doesn't support paths in left-hand-sides, nor references in arrays) + expect( + SELECT.from(Foo).where({ + ID, + args, + and: { x: { like: '%x%' }, or: { y: { '>=': 9 } } }, + }) + ).to.eql({ + SELECT: { + from: { ref: ['Foo'] }, + where: [ + { ref: ['ID'] }, + '=', + { val: ID }, + 'and', + { ref: ['args'] }, + 'in', + { list: args.map(val => ({ val })) }, + 'and', + { + xpr: [ + { ref: ['x'] }, + 'like', + { val: '%x%' }, + 'or', + { ref: ['y'] }, + '>=', + { val: 9 }, + ] + }, + ], + } + }) + + // using CQL fragments -> uses cds.parse.expr + const is_v2 = !!cds.parse.expr('(1,2)').list + if (is_v2) expect((cqn = CQL`SELECT from Foo where ID=11 and x in ( foo, 'bar', 3)`)).to.eql({ + SELECT: { + from: { ref: ['Foo'] }, + where: [ + { ref: ['ID'] }, + '=', + { val: ID }, + 'and', + { ref: ['x'] }, + 'in', + {list:[ + { ref: ['foo'] }, + { val: 'bar' }, + { val: 3 }, + ]} + ], + }, + }) + else expect((cqn = CQL`SELECT from Foo where ID=11 and x in ( foo, 'bar', 3)`)).to.eql({ + SELECT: { + from: { ref: ['Foo'] }, + where: [ + { ref: ['ID'] }, + '=', + { val: ID }, + 'and', + { ref: ['x'] }, + 'in', + '(', + { ref: ['foo'] }, + ',', + { val: 'bar' }, + ',', + { val: 3 }, + ')', + ], + }, + }) + + if (!is_v2) expect( + SELECT.from(Foo).where(`x=`, 1, `or y.z is null and (a>`, 2, `or b=`, 3, `)`) + ).to.eql(CQL`SELECT from Foo where x=1 or y.z is null and (a>2 or b=3)`) + + expect(SELECT.from(Foo).where(`x between`, 1, `and`, 9)).to.eql( + CQL`SELECT from Foo where x between 1 and 9` + ) + }) + + test('w/ sub selects', () => { + // in where causes + expect(SELECT.from(Foo).where({ x: SELECT('y').from('Bar') })).to.eql( + CQL`SELECT from Foo where x in (SELECT y from Bar)` + ) + + // using query api + expect(SELECT.from('Books').where( + `author.name in`, SELECT('name').from('Authors'))).to.eql(CQL`SELECT from Books where author.name in (SELECT name from Authors)` + ) + + // in classical semi joins + expect( + SELECT('x').from(Foo) .where ( `exists`, + SELECT(1).from('Bar') .where ({ y: { ref: ['x'] } }) + ) // prettier-ignore + ).to.eql(CQL`SELECT x from Foo where exists (SELECT 1 from Bar where y=x)`) + + // in select clauses + cqn = CQL`SELECT from Foo { x, (SELECT y from Bar) as y }` + cds.version >= '3.33.3' && + expect( + SELECT.from(Foo, (foo) => { + foo.x, foo(SELECT.from('Bar', (b) => b.y)).as('y') + }) + ).to.eql(cqn) + cds.version >= '3.33.3' && + expect( + SELECT.from(Foo, ['x', Object.assign(SELECT('y').from('Bar'), { as: 'y' })]) + ).to.eql(cqn) + }) + + test('w/ plain SQL', () => { + expect(SELECT.from(Books) + 'WHERE ...').to.eql( + 'SELECT * FROM capire_bookshop_Books WHERE ...' + ) + }) + + it('should consistently handle *', () => { + expect({ + SELECT: { from: { ref: ['Foo'] }, columns: ['*'] }, + }) + .to.eql(CQL`SELECT * from Foo`) + .to.eql(CQL`SELECT from Foo{*}`) + .to.eql(SELECT('*').from(Foo)) + .to.eql(SELECT.from(Foo,['*'])) + }) + + it('should consistently handle lists', () => { + const ID = 11, args = [{ref:['foo']}, "bar", 3] + const cqn = CQL`SELECT from Foo where ID=11 and x in (foo,'bar',3)` + expect(SELECT.from(Foo).where`ID=${ID} and x in ${args}`).to.eql(cqn) + expect(SELECT.from(Foo).where(`ID=`, ID, `and x in`, args)).to.eql(cqn) + expect(SELECT.from(Foo).where({ ID, x:args })).to.eql(cqn) + }) + + // + }) + + describe(`SELECT for update`, () => { + beforeAll(() => { + delete cds.env.sql.lock_acquire_timeout + }) + + it('no wait', () => { + const q = SELECT.from('Foo').forUpdate() + expect(q.SELECT.forUpdate).eqls({}) + }) + + it('specific wait', () => { + const q = SELECT.from('Foo').forUpdate({ wait: 1 }) + expect(q.SELECT.forUpdate).eqls({ wait: 1 }) + }) + + it('default wait', () => { + cds.env.sql.lock_acquire_timeout = 2 + const q = SELECT.from('Foo').forUpdate() + expect(q.SELECT.forUpdate).eqls({ wait: 2 }) + }) + + it('override default', () => { + cds.env.sql.lock_acquire_timeout = 1 + const q = SELECT.from('Foo').forUpdate({ wait:-1 }) + expect(q.SELECT.forUpdate).eqls({}) + }) + }) + + describe(`INSERT...`, () => { + test('entries ({a,b}, ...)', () => { + const entries = [{ foo: 1 }, { boo: 2 }] + expect(INSERT(...entries).into(Foo)) + .to.eql(INSERT(entries).into(Foo)) + .to.eql(INSERT.into(Foo).entries(...entries)) + .to.eql(INSERT.into(Foo).entries(entries)) + .to.eql({ + INSERT: { into: { ref: ['Foo'] }, entries }, + }) + }) + + test('rows ([1,2], ...)', () => { + expect( + INSERT.into(Foo) + .columns('a', 'b') + .rows([ + [1, 2], + [3, 4], + ]) + ) + .to.eql(INSERT.into(Foo).columns('a', 'b').rows([1, 2], [3, 4])) + .to.eql({ + INSERT: { + into: { ref: ['Foo'] }, + columns: ['a', 'b'], + rows: [ + [1, 2], + [3, 4], + ], + }, + }) + }) + + test('values (1,2)', () => { + expect(INSERT.into(Foo).columns('a', 'b').values([1, 2])) + .to.eql(INSERT.into(Foo).columns('a', 'b').values(1, 2)) + .to.eql({ + INSERT: { into: { ref: ['Foo'] }, columns: ['a', 'b'], values: [1, 2] }, + }) + }) + + test('w/ plain SQL', () => { + expect(INSERT.into(Books) + 'VALUES ...').to.eql( + 'INSERT INTO capire_bookshop_Books VALUES ...' + ) + }) + }) + + describe(`UPDATE...`, () => { + test('entity (..., )', () => { + const cqnWhere = { + UPDATE: { + entity: { ref: ['capire.bookshop.Books'] }, + where: [{ ref: ['ID'] }, '=', { val: 4711 }], + }, + } + expect(UPDATE(Books).where({ ID: 4711 })) + .to.eql(UPDATE(Books).where(`ID=`, 4711)) + .to.eql(cqnWhere) + + const cqnKey = (cds.version >= '5.6.0') ? + { + UPDATE: { + entity: { ref: [{ id: 'capire.bookshop.Books', where: [{ ref: ['ID'] }, '=', { val: 4711 }] }] } + } + } + : cqnWhere + expect(UPDATE(Books, 4711)) + .to.eql(UPDATE(Books, { ID: 4711 })) + .to.eql(UPDATE(Books).byKey(4711)) + .to.eql(UPDATE(Books).byKey({ ID: 4711 })) + .to.eql(UPDATE.entity(Books, 4711)) + .to.eql(UPDATE.entity(Books, { ID: 4711 })) + // etc... + .to.eql(cqnKey) + }) + + /* + UPDATE.with allows to pass in plain data payloads, e.g. as obtained from REST clients. + In addition, UPDATE.with supports specifying expressions, either in CQL fragements + notation or as simple expression objects. + + UPDATE.data allows to pass in plain data payloads, e.g. as obtained from REST clients. + The passed in object can be modified subsequently, e.g. by adding or modifying values + before the query is finally executed. + */ + test('with + data', () => { + if (cds.version < '4.1.0') return + const o = {} + const q = UPDATE(Foo).data(o).with(`bar-=`, 22) + o.foo = 11 + expect(q) + .to.eql(UPDATE(Foo).with(`foo=`, 11, `bar-=`, 22)) + .to.eql(UPDATE(Foo).with({ foo: 11, bar: { '-=': 22 } })) + .to.eql({ + UPDATE: { + entity: { ref: ['Foo'] }, + data: { foo: 11 }, + with: { + bar: { xpr: [{ ref: ['bar'] }, '-', { val: 22 }] }, + }, + }, + }) + + // some more + expect(UPDATE(Foo).with(`bar = coalesce(x,y), car = 'foo''s bar, car'`)).to.eql({ + UPDATE: { + entity: { ref: ['Foo'] }, + data: { + car: "foo's bar, car", + }, + with: { + bar: { func: 'coalesce', args: [{ ref: ['x'] }, { ref: ['y'] }] }, + }, + }, + }) + }) + + test('w/ plain SQL', () => { + expect(UPDATE(Books) + 'SET ...').to.eql('UPDATE capire_bookshop_Books SET ...') + }) + }) + + describe(`DELETE...`, () => { + test('from (..., )', () => { + const cqnWhere = { + DELETE: { + from: { ref: ['capire.bookshop.Books'] }, + where: [{ ref: ['ID'] }, '=', { val: 4711 }], + }, + } + expect(DELETE.from(Books).where({ ID: 4711 })) + .to.eql(DELETE.from(Books).where(`ID=`, 4711)) + .to.eql(cqnWhere) + const cqnKey = (cds.version >= '5.6.0') ? + { + DELETE: { + from: { ref: [{ id: 'capire.bookshop.Books', where: [{ ref: ['ID'] }, '=', { val: 4711 }]}] } + }, + } : cqnWhere + + expect(DELETE(Books, 4711)) + .to.eql(DELETE(Books, { ID: 4711 })) + .to.eql(DELETE.from(Books, 4711)) + .to.eql(DELETE.from(Books, { ID: 4711 })) + .to.eql(DELETE.from(Books).byKey(4711)) + .to.eql(DELETE.from(Books).byKey({ ID: 4711 })) + .to.eql(cqnKey) + }) + + test('/w plain SQL', () => { + expect(DELETE.from(Books) + 'WHERE ...').to.eql( + 'DELETE FROM capire_bookshop_Books WHERE ...' + ) + }) + }) + + describe(`cds.ql etc...`, () => { + it('should keep null and undefined', () => { + for (let each of [null, undefined]) { + expect(SELECT.from(Foo).where({ ID: each })).to.eql({ + SELECT: { + from: { ref: ['Foo'] }, + where: [{ ref: ['ID'] }, '=', { val: each }], + }, + }) + } + }) + }) + + // +}) diff --git a/tests/consuming-actions.test.js b/tests/consuming-actions.test.js new file mode 100644 index 0000000..133eb09 --- /dev/null +++ b/tests/consuming-actions.test.js @@ -0,0 +1,53 @@ +const cds = require("@sap/cds"); +const { expect } = cds.test( + "serve", + "CatalogService", + "--from", + "@capire/bookshop,@capire/common", + "--in-memory" +); + +describe("Consuming actions locally", () => { + let cats, CatalogService, Books, stockBefore; + const BOOK_ID = 251; + const QUANTITY = 1; + + before("bootstrap the database", async () => { + CatalogService = cds.services.CatalogService; + expect(CatalogService).not.to.be.undefined; + + Books = CatalogService.entities.Books; + expect(Books).not.to.be.undefined; + + cats = await cds.connect.to("CatalogService"); + }); + + beforeEach(async () => { + // Read the stock before the action is called + stockBefore = (await cats.get(Books, BOOK_ID)).stock; + }); + + it("calls unbound actions - basic variant using srv.send", async () => { + // Use a managed transaction to create a continuation with an authenticated user + const res1 = await cats.tx({ user: "alice" }, () => { + return cats.send("submitOrder", { book: BOOK_ID, quantity: QUANTITY }); + }); + expect(res1.stock).to.eql(stockBefore - QUANTITY); + }); + + it("calls unbound actions - named args variant", async () => { + // Use a managed transaction to create a continuation with an authenticated user + const res2 = await cats.tx({ user: "alice" }, () => { + return cats.submitOrder({ book: BOOK_ID, quantity: QUANTITY }); + }); + expect(res2.stock).to.eql(stockBefore - QUANTITY); + }); + + it("calls unbound actions - positional args variant", async () => { + // Use a managed transaction to create a continuation with an authenticated user + const res3 = await cats.tx({ user: "alice" }, () => { + return cats.submitOrder(BOOK_ID, QUANTITY); + }); + expect(res3.stock).to.eql(stockBefore - QUANTITY); + }); +}); diff --git a/tests/consuming-services.test.js b/tests/consuming-services.test.js new file mode 100644 index 0000000..6a7c8db --- /dev/null +++ b/tests/consuming-services.test.js @@ -0,0 +1,82 @@ +const cds = require('@sap/cds') +const { expect } = cds.test ('@capire/bookshop') +cds.User.default = cds.User.privileged // disable auth checks + +describe('cap/samples - Consuming Services locally', () => { + + it('bootstrapped the database successfully', ()=>{ + const { AdminService } = cds.services + const { Authors } = AdminService.entities + expect(AdminService).to.exist + expect(Authors).to.exist + }) + + it('supports targets as strings or reflected defs', async () => { + const AdminService = await cds.connect.to('AdminService') + const { Authors } = AdminService.entities + expect (await SELECT.from(Authors)) + // .to.eql(await SELECT.from('Authors')) + .to.eql(await AdminService.read(Authors)) + .to.eql(await AdminService.read('Authors')) + .to.eql(await AdminService.run(SELECT.from(Authors))) + .to.eql(await AdminService.run(SELECT.from('Authors'))) + }) + + it('allows reading from local services using cds.ql', async () => { + const AdminService = await cds.connect.to('AdminService') + const authors = await AdminService.read (`Authors`, a => { + a.name, + a.books((b) => { + b.title, + b.currency((c) => { + c.name, c.symbol + }) + }) + }).where(`name like`, 'E%') + expect(authors).to.containSubset([ + { + name: 'Emily Brontë', + books: [ + { + title: 'Wuthering Heights', + currency: { name: 'British Pound', symbol: '£' }, + }, + ], + }, + { + name: 'Edgar Allen Poe', + books: [ + { title: 'The Raven', currency: { name: 'US Dollar', symbol: '$' } }, + { title: 'Eleonora', currency: { name: 'US Dollar', symbol: '$' } }, + ], + }, + ]) + }) + + it('provides CRUD-style convenience methods', async () => {}) + + it('uses same methods for all kind of services, including dbs', async () => { + const srv = await cds.connect.to('AdminService') + const db = await cds.connect.to('db') + const { Authors } = srv.entities + const projection = (a) => { + a.name, + a.books((b) => { + b.title, + b.currency((c) => { + c.name, c.symbol + }) + }) + } + const query1 = SELECT.from(Authors, projection).where(`name like`, 'E%') + const query2 = cds.read(Authors, projection).where(`name like`, 'E%') + expect(await cds.run(query1)) + .to.eql(await db.run(query1)) + .to.eql(await srv.run(query1)) + .to.eql(await srv.read(Authors, projection).where(`name like`, 'E%')) + .to.eql(await cds.run(query2)) + .to.eql(await db.run(query2)) + .to.eql(await srv.run(query2)) + .to.eql(await db.read(Authors, projection).where(`name like`, 'E%')) + }) +}) diff --git a/tests/custom-handlers.test.js b/tests/custom-handlers.test.js new file mode 100644 index 0000000..2686ca3 --- /dev/null +++ b/tests/custom-handlers.test.js @@ -0,0 +1,15 @@ +const cds = require('@sap/cds') +const { GET, POST, expect } = cds.test ('@capire/bookshop') +cds.User.default = cds.User.Privileged // hard core monkey patch + +describe('cap/samples - Custom Handlers', () => { + + it('should reject out-of-stock orders', async () => { + await expect(POST `/browse/submitOrder ${{ book: 201, quantity: 5 }}`).to.be.fulfilled + await expect(POST `/browse/submitOrder ${{ book: 201, quantity: 5 }}`).to.be.fulfilled + await expect(POST `/browse/submitOrder ${{ book: 201, quantity: 5 }}`).to.be.rejectedWith( + /409 - 5 exceeds stock for book #201/) + const { data } = await GET`/admin/Books/201/stock/$value` + expect(data).to.equal(2) + }) +}) diff --git a/tests/hierarchical-data/hierarchical-data.test.js b/tests/hierarchical-data/hierarchical-data.test.js new file mode 100644 index 0000000..3a38851 --- /dev/null +++ b/tests/hierarchical-data/hierarchical-data.test.js @@ -0,0 +1,123 @@ +const cds = require('@sap/cds') +const { expect } = cds.test.in(__dirname,'..','..') + +describe('cap/samples - Hierarchical Data', ()=>{ + + const csn = CDL` + entity Categories { + key ID : Integer; + name : String; + children : Composition of many Categories on children.parent = $self; + parent : Association to Categories; + } + ` + const model = cds.compile.for.nodejs(csn) + const {Categories:Cats} = model.definitions + + before ('bootstrap sqlite in-memory db...', async()=>{ + await cds.deploy (csn) .to ('sqlite::memory:') // REVISIT: cds.compile.to.sql should accept cds.compiled.for.nodejs models + expect (cds.db) .to.exist + expect (cds.db.model) .to.exist + }) + + it ('supports deeply nested inserts', ()=> INSERT.into (Cats, + { ID:100, name:'Some Cats...', children:[ + { ID:101, name:'Cat', children:[ + { ID:102, name:'Kitty', children:[ + { ID:103, name:'Kitty Cat', children:[ + { ID:104, name:'Aristocat' } ]}, + { ID:105, name:'Kitty Bat' } ]}, + { ID:106, name:'Catwoman', children:[ + { ID:107, name:'Catalina' } ]} ]}, + { ID:108, name:'Catweazle' } + ]} + )) + + it ('should generate correct queries for expands', ()=>{ + let q = SELECT.from (Cats, c => { c.ID, c.name, c.children (c => c.name) }) + expect (q) .to.eql ({ + SELECT: { + from: { ref:[ "Categories" ] }, + columns: [ + { ref: [ "ID" ] }, + { ref: [ "name" ] }, + { ref: [ "children" ], expand: [ {ref:['name']} ] }, + ] + } + }) + /* temp skip for release + if (q.forSQL) expect (q.forSQL()) .to.eql ({ + SELECT: { + from: { ref:[ "Categories" ], as: "Categories" }, + columns: [ + { ref: [ "Categories", "ID" ] }, + { ref: [ "Categories", "name" ] }, + { as: "children", SELECT: { expand: true, + one: false, + columns: [{ ref: [ "children", "name" ]}], + from: { ref:["Categories"], as: "children" }, + where: [ + {ref:[ "Categories", "ID" ]}, "=", {ref:[ "children", "parent_ID" ]} + ], + }}, + ], + } + }) + if (q.toSql) expect (q.toSql()) .to.eql ( + `SELECT json_insert('{}',` + + `'$."ID"',ID,` + + `'$."name"',name,` + + `'$."children"',children->'$'` + + `) as _json_ FROM (` + + `SELECT Categories.ID,Categories.name,(` + + `SELECT jsonb_group_array(jsonb_insert('{}','$."name"',name)) as _json_ FROM (` + + `SELECT children.name FROM Categories as children WHERE Categories.ID = children.parent_ID` + + `)` + + `) as children FROM Categories as Categories` + + `)` + ) + */ + }) + + it ('supports nested reads', ()=> expect ( + SELECT.one.from (Cats, c=>{ + c.ID, c.name.as('parent'), c.children (c=>{ + c.name.as('child') + }) + }) .where ({name:'Cat'}) + ) .to.eventually.eql ( + { ID:101, parent:'Cat', children:[ + { child:'Kitty' }, + { child:'Catwoman' }, + ]} + )) + + it ('supports deeply nested reads', ()=> expect ( + SELECT.one.from (Cats, c=>{ + c.ID, c.name, c.children ( + c => { c.name }, + {levels:3} + ) + }) .where ({name:'Cat'}) + ) .to.eventually.eql ( + { ID:101, name:'Cat', children:[ + { name:'Kitty', children:[ + { name:'Kitty Cat', children:[ + { name:'Aristocat' }, ]}, // level 3 + { name:'Kitty Bat', children:[] }, ]}, + { name:'Catwoman', children:[ + { name:'Catalina', children:[] } ]}, + ]} + )) + + it ('supports cascaded deletes', async()=>{ + const affectedRows = await DELETE.from (Cats) .where ({ID:[102,106]}) + expect (affectedRows) .to.be.greaterThan (0) + await expect (SELECT`ID,name`.from(Cats) ).to.eventually.eql ([ + { ID:100, name:'Some Cats...' }, + { ID:101, name:'Cat' }, + { ID:108, name:'Catweazle' } + ]) + }) + +}) diff --git a/tests/hierarchical-data/requests.http b/tests/hierarchical-data/requests.http new file mode 100644 index 0000000..7849f11 --- /dev/null +++ b/tests/hierarchical-data/requests.http @@ -0,0 +1,37 @@ +################################################# +# +# Genres +# + +GET http://localhost:4004/odata/v4/test/Genres? +### + +GET http://localhost:4004/odata/v4/test/Genres? +&$filter=parent_ID eq null&$select=name +&$expand=children($select=name) +### + +POST http://localhost:4004/odata/v4/test/Genres? +Content-Type: application/json + +{ "ID":"100aaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "name":"Some Sample Genres...", "children":[ + { "ID":"101aaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "name":"Cat", "children":[ + { "ID":"102aaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "name":"Kitty", "children":[ + { "ID":"103aaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "name":"Aristocat" }, + { "ID":"104aaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "name":"Kitty Bat" } ]}, + { "ID":"105aaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "name":"Catwoman", "children":[ + { "ID":"106aaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "name":"Catalina" } ]} ]}, + { "ID":"107aaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "name":"Catweazle" } +]} +### + +GET http://localhost:4004/odata/v4/test/Genres(100aaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa)? +&$expand=children +&$expand=children($expand=children($expand=children($expand=children))) +### + +DELETE http://localhost:4004/odata/v4/test/Genres(103aaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) +### + +DELETE http://localhost:4004/odata/v4/test/Genres(100aaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa) +### diff --git a/tests/hierarchical-data/services.cds b/tests/hierarchical-data/services.cds new file mode 100644 index 0000000..7d6f7b2 --- /dev/null +++ b/tests/hierarchical-data/services.cds @@ -0,0 +1,4 @@ +using { sap.capire.bookshop as my } from '../../db/schema'; +service TestService { + entity Genres as projection on my.Genres; +} diff --git a/tests/localized-data/package.json b/tests/localized-data/package.json new file mode 100644 index 0000000..580c138 --- /dev/null +++ b/tests/localized-data/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "@cap-js/sqlite": "*" + } +} \ No newline at end of file diff --git a/tests/localized-data/services.cds b/tests/localized-data/services.cds new file mode 100644 index 0000000..c49473c --- /dev/null +++ b/tests/localized-data/services.cds @@ -0,0 +1,12 @@ +using { CatalogService, sap.capire.bookshop as my } from '@capire/bookshop'; +using from '@capire/common'; + +extend service CatalogService with { + @cds.localized:false + entity BooksSans as projection on my.Books { + *, //> non-localized defaults, e.g. title + key ID, + texts.title as localized_title, + texts.locale + }; +} diff --git a/tests/localized-data/services.test.js b/tests/localized-data/services.test.js new file mode 100644 index 0000000..b205077 --- /dev/null +++ b/tests/localized-data/services.test.js @@ -0,0 +1,79 @@ +const cds = require('@sap/cds') +const { GET, expect } = cds.test (__dirname) +cds.User.default = cds.User.Privileged // hard core monkey patch + +describe('cap/samples - Localized Data', () => { + + it('serves localized $metadata documents', async () => { + const { data } = await GET(`/browse/$metadata?sap-language=de`, { headers: { 'accept-language': 'de' }}) + expect(data).to.contain('') + }) + + it('supports accept-language header', async () => { + const { data } = await GET(`/browse/Books?$select=title,author`, { + headers: { 'Accept-Language': 'de' }, + }) + expect(data.value).to.containSubset([ + { title: 'Sturmhöhe', author: 'Emily Brontë' }, + { title: 'Jane Eyre', author: 'Charlotte Brontë' }, + { title: 'The Raven', author: 'Edgar Allen Poe' }, + { title: 'Eleonora', author: 'Edgar Allen Poe' }, + { title: 'Catweazle', author: 'Richard Carpenter' }, + ]) + }) + + it('supports queries with $expand', async () => { + const { data } = await GET(`/browse/Books?&$select=title,author&$expand=currency`, { + headers: { 'Accept-Language': 'de' }, + }) + expect(data.value).to.containSubset([ + { title: 'Sturmhöhe', author: 'Emily Brontë', currency: { name: 'Pfund' } }, + { title: 'Jane Eyre', author: 'Charlotte Brontë', currency: { name: 'Pfund' } }, + { title: 'The Raven', author: 'Edgar Allen Poe', currency: { name: 'US-Dollar' } }, + { title: 'Eleonora', author: 'Edgar Allen Poe', currency: { name: 'US-Dollar' } }, + { title: 'Catweazle', author: 'Richard Carpenter', currency: { name: 'Yen' } }, + ]) + }) + + it('supports queries with nested $expand', async () => { + const { data } = await GET(`/admin/Authors`, { + params: { + $filter: `startswith(name,'E')`, + $expand: `books( + $select=title; + $expand=currency( + $select=name,symbol + ) + )`.replace(/\s/g, ''), + $select: `name`, + }, + headers: { 'Accept-Language': 'de' }, + }) + expect(data.value).to.containSubset([ + { + name: 'Emily Brontë', + books: [{ title: 'Sturmhöhe', currency: { name: 'Pfund', symbol: '£' } }], + }, + { + name: 'Edgar Allen Poe', + books: [ + { title: 'The Raven', currency: { name: 'US-Dollar', symbol: '$' } }, + { title: 'Eleonora', currency: { name: 'US-Dollar', symbol: '$' } }, + ], + }, + ]) + }) + + it('supports @cds.localized:false', async ()=>{ + const { data } = await GET(`/browse/BooksSans?&$select=title,localized_title&$expand=currency&$filter=locale eq 'de' or locale eq null`, { + headers: { 'Accept-Language': 'de' }, + }) + expect(data.value).to.containSubset([ + { title: 'Wuthering Heights', localized_title: 'Sturmhöhe', currency: { name: 'British Pound' } }, + { title: 'Jane Eyre', currency: { name: 'British Pound' } }, + { title: 'The Raven', currency: { name: 'US Dollar' } }, + { title: 'Eleonora', currency: { name: 'US Dollar' } }, + { title: 'Catweazle', currency: { name: 'Yen' } }, + ]) + }) +}) diff --git a/tests/messaging.test.js b/tests/messaging.test.js new file mode 100644 index 0000000..289ca17 --- /dev/null +++ b/tests/messaging.test.js @@ -0,0 +1,74 @@ +const cds = require('@sap/cds') +const { expect } = cds.test.in(__dirname,'..') + +describe('cap/samples - Messaging', ()=>{ + + const _model = '@capire/reviews' + const Reviews = 'sap.capire.reviews.Reviews' + beforeAll(()=>{ + cds.User.default = cds.User.Privileged // hard core monkey patch + }) + + it ('should bootstrap sqlite in-memory db', async()=>{ + const db = await cds.deploy (_model) .to ('sqlite::memory:') + await db.delete(Reviews) + expect (db.model) .not.undefined + }) + + let srv + it ('should serve ReviewsService', async()=>{ + srv = await cds.serve('ReviewsService') .from (_model) + expect (srv.name) .to.match (/ReviewsService/) + }) + + let N=0, received=[], M=0 + it ('should add messaging event handlers', ()=>{ + srv.on('reviewed', (msg)=> received.push(msg)) + }) + + it ('should add more messaging event handlers', ()=>{ + srv.on('reviewed', ()=> ++M) + }) + + it ('should add review', async ()=>{ + const review = { subject: "201", title: "Captivating", rating: ++N } + cds._debug = 1 + const response = await srv.create ('Reviews') .entries (review) + expect (response) .to.containSubset (review) + }) + + it ('should add more reviews', ()=> Promise.all ([ + // REVISIT: mass operation should trigger one message per entry + // srv.create('Reviews').entries( + // { ID: 111 + (++N), subject: "201", title: "Captivating", rating: N }, + // { ID: 111 + (++N), subject: "201", title: "Captivating", rating: N }, + // { ID: 111 + (++N), subject: "201", title: "Captivating", rating: N }, + // { ID: 111 + (++N), subject: "201", title: "Captivating", rating: N }, + // ), + srv.create ('Reviews') .entries ( + { ID: String(111 + (++N)), subject: "201", title: "Captivating", rating: N } + ), + srv.create ('Reviews') .entries ( + { ID: String(111 + (++N)), subject: "201", title: "Captivating", rating: N } + ), + srv.create ('Reviews') .entries ( + { ID: String(111 + (++N)), subject: "201", title: "Captivating", rating: N } + ), + srv.create ('Reviews') .entries ( + { ID: String(111 + (++N)), subject: "201", title: "Captivating", rating: N } + ), + ])) + + it ('should have received all messages', async()=> { + await new Promise((done)=>setImmediate(done)) + expect(M).equals(N) + expect(received.length).equals(N) + expect(received.map(m=>m.data)).to.deep.equal([ + { count: 1, subject: '201', rating: 1 }, + { count: 2, subject: '201', rating: 1.5 }, + { count: 3, subject: '201', rating: 2 }, + { count: 4, subject: '201', rating: 2.5 }, + { count: 5, subject: '201', rating: 3 }, + ]) + }) +}) diff --git a/tests/odata.test.js b/tests/odata.test.js new file mode 100644 index 0000000..4731124 --- /dev/null +++ b/tests/odata.test.js @@ -0,0 +1,101 @@ +const cds = require('@sap/cds') +const { GET, expect, axios } = cds.test ('@capire/bookshop') +axios.defaults.auth = { username: 'alice', password: 'admin' } + +describe('cap/samples - Bookshop APIs', () => { + + it('serves $metadata documents in v4', async () => { + const { headers, status, data } = await GET `/browse/$metadata` + expect(status).to.equal(200) + expect(headers).to.contain({ + // 'content-type': 'application/xml', //> fails with 'application/xml;charset=utf-8', which is set by express + 'odata-version': '4.0', + }) + expect(headers['content-type']).to.match(/application\/xml/) + expect(data).to.contain('') + expect(data).to.contain('') + }) + + it('serves ListOfBooks?$expand=genre,currency', async () => { + const Mystery = { name: 'Mystery' } + const Romance = { name: 'Romance' } + const USD = { code: 'USD', name: 'US Dollar', descr: null, symbol: '$' } + const { data } = await GET `/browse/ListOfBooks ${{ + params: { $search: 'Po', $select: `title,author`, $expand:`genre,currency` }, + }}` + expect(data.value).to.containSubset([ + { ID: 251, title: 'The Raven', author: 'Edgar Allen Poe', genre:Mystery, currency:USD }, + { ID: 252, title: 'Eleonora', author: 'Edgar Allen Poe', genre:Romance, currency:USD }, + ]) + }) + + describe('query options...', () => { + + it('supports $search in multiple fields', async () => { + const { data } = await GET `/browse/Books ${{ + params: { $search: 'Po', $select: `title,author` }, + }}` + expect(data.value).to.containSubset([ + { ID: 201, title: 'Wuthering Heights', author: 'Emily Brontë' }, + { ID: 207, title: 'Jane Eyre', author: 'Charlotte Brontë' }, + { ID: 251, title: 'The Raven', author: 'Edgar Allen Poe' }, + { ID: 252, title: 'Eleonora', author: 'Edgar Allen Poe' }, + ]) + }) + + it('supports $select', async () => { + const { data } = await GET(`/browse/Books`, { + params: { $select: `ID,title` }, + }) + expect(data.value).to.containSubset([ + { ID: 201, title: 'Wuthering Heights' }, + { ID: 207, title: 'Jane Eyre' }, + { ID: 251, title: 'The Raven' }, + { ID: 252, title: 'Eleonora' }, + { ID: 271, title: 'Catweazle' }, + ]) + }) + + it('supports $expand', async () => { + const { data } = await GET(`/admin/Authors`, { + params: { + $select: `name`, + $expand: `books($select=title)`, + }, + }) + expect(data.value).to.containSubset([ + { name: 'Emily Brontë', books: [{ title: 'Wuthering Heights' }] }, + { name: 'Charlotte Brontë', books: [{ title: 'Jane Eyre' }] }, + { name: 'Edgar Allen Poe', books: [{ title: 'The Raven' }, { title: 'Eleonora' }] }, + { name: 'Richard Carpenter', books: [{ title: 'Catweazle' }] }, + ]) + }) + + it('supports $value requests', async () => { + const { data } = await GET`/admin/Books/201/stock/$value` + expect(data).to.equal(12) + }) + + it('supports $top/$skip paging', async () => { + const { data: p1 } = await GET`/browse/Books?$select=title&$top=3` + expect(p1.value).to.containSubset([ + { ID: 201, title: 'Wuthering Heights' }, + { ID: 207, title: 'Jane Eyre' }, + { ID: 251, title: 'The Raven' }, + ]) + const { data: p2 } = await GET`/browse/Books?$select=title&$skip=3` + expect(p2.value).to.containSubset([ + { ID: 252, title: 'Eleonora' }, + { ID: 271, title: 'Catweazle' }, + ]) + }) + }) + + it('serves user info', async () => { + const { data: alice } = await GET `/user/me` + expect(alice).to.containSubset({ id: 'alice' }) + const { data: joe } = await GET (`/user/me`, {auth: { username: 'joe' }}) + expect(joe).to.containSubset({ id: 'joe' }) + }) + +}) diff --git a/tests/protocols/bookshop.http b/tests/protocols/bookshop.http new file mode 100644 index 0000000..e4a5267 --- /dev/null +++ b/tests/protocols/bookshop.http @@ -0,0 +1,94 @@ +@server = http://localhost:4004 +@me = Authorization: Basic {{$processEnv USER}}: + + +### ------------------------------------------------------------------------ +# Get service info +GET {{server}}/browse +{{me}} + + +### ------------------------------------------------------------------------ +# Get $metadata document +GET {{server}}/browse/$metadata +{{me}} + + +### ------------------------------------------------------------------------ +# Browse Books as any user +GET {{server}}/browse/ListOfBooks? + # &$select=title,stock + &$expand=genre + # &sap-language=de +{{me}} + + +### ------------------------------------------------------------------------ +# Fetch Authors as admin +GET {{server}}/admin/Authors? + # &$select=name,dateOfBirth,placeOfBirth + # &$expand=books($select=title;$expand=currency) + # &$filter=ID eq 101 + # &sap-language=de +Authorization: Basic alice: + +### ------------------------------------------------------------------------ +# Create Author +POST {{server}}/admin/Authors +Content-Type: application/json;IEEE754Compatible=true +Authorization: Basic alice: + +{ + "ID": 112, + "name": "Shakespeeeeere" +} + + +### ------------------------------------------------------------------------ +# Create book +POST {{server}}/admin/Books +Content-Type: application/json;IEEE754Compatible=true +Authorization: Basic alice: + +{ + "ID": 2, + "title": "Poems : Pocket Poets", + "descr": "The Everyman's Library Pocket Poets hardcover series is popular for its compact size and reasonable price which does not compromise content. Poems: Bronte contains poems that demonstrate a sensibility elemental in its force with an imaginative discipline and flexibility of the highest order. Also included are an Editor's Note and an index of first lines.", + "author": { "ID": 101 }, + "genre": { "ID": "12aaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }, + "stock": 5, + "price": "12.05", + "currency": { "code": "USD" } +} + + +### ------------------------------------------------------------------------ +# Put image to books +PUT {{server}}/admin/Books(2)/image +Content-Type: image/png +Authorization: Basic alice: + +data:image/png;base64,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 + + +### ------------------------------------------------------------------------ +# Reading image from from the server directly +GET {{server}}/browse/Books(2)/image + + +### ------------------------------------------------------------------------ +# Submit Order as authenticated user +# (send that three times to get out-of-stock message) +POST {{server}}/browse/submitOrder +Content-Type: application/json +{{me}} + +{ "book":201, "quantity":5 } + + +### ------------------------------------------------------------------------ +# Browse Genres +GET {{server}}/browse/Genres? +# &$filter=parent_ID eq null&$select=name +# &$expand=children($select=name) +{{me}} diff --git a/tests/protocols/hcql-adapter.test.js b/tests/protocols/hcql-adapter.test.js new file mode 100644 index 0000000..823b9b2 --- /dev/null +++ b/tests/protocols/hcql-adapter.test.js @@ -0,0 +1,126 @@ +const cds = require('@sap/cds/lib') +const { GET, expect, axios } = cds.test(__dirname) + +// Fetch API disallows GET|HEAD requests with body +if (axios.constructor.name === 'Naxios') it = it.skip + +describe ('GET w/ query in body', () => { + + it ('serves CQN query objects in body', async () => { + const {data:books} = await GET ('/hcql/admin', { + headers: { 'Content-Type': 'application/json' }, + data: cds.ql `SELECT from Books` + }) + expect(books).to.be.an('array').of.length(5) + }) + + it ('serves plain CQL strings in body', async () => { + const {data:books} = await GET ('/hcql/admin', { + headers: { 'Content-Type': 'text/plain' }, + data: `SELECT from Books` + }) + expect(books).to.be.an('array').of.length(5) + }) + + it ('serves complex and deep queries', async () => { + const {data:books} = await GET ('/hcql/admin', { + headers: { 'Content-Type': 'text/plain' }, + data: `SELECT from Authors { + name, + books [order by title] { + title, + genre.name as genre + } + }` + }) + expect(books).to.deep.equal([ + { + name: "Emily Brontë", + books: [ + { title: "Wuthering Heights", genre: 'Drama' } + ] + }, + { + name: "Charlotte Brontë", + books: [ + { title: "Jane Eyre", genre: 'Drama' } + ] + }, + { + name: "Edgar Allen Poe", + books: [ + { title: "Eleonora", genre: 'Romance' }, + { title: "The Raven", genre: 'Mystery' }, + ] + }, + { + name: "Richard Carpenter", + books: [ + { title: "Catweazle", genre: 'Fantasy' } + ] + } + ]) + }) + +}) + + +describe ('Sluggified variants', () => { + + test ('GET /Books', async () => { + const {data:books} = await GET ('/hcql/admin/Books') + expect(books).to.be.an('array').of.length(5) + expect(books.length).to.eql(5) //.of.length(5) + }) + + + test ('GET /Books/201', async () => { + const {data:book} = await GET ('/hcql/admin/Books/201') + expect(book).to.be.an('object') + expect(book).to.have.property ('title', "Wuthering Heights") + }) + + test ('GET /Books { title, author.name as author }' , async () => { + const {data:books} = await GET ('/hcql/admin/Books { title, author.name as author } order by ID') + expect(books).to.deep.equal ([ + { title: "Wuthering Heights", author: "Emily Brontë" }, + { title: "Jane Eyre", author: "Charlotte Brontë" }, + { title: "The Raven", author: "Edgar Allen Poe" }, + { title: "Eleonora", author: "Edgar Allen Poe" }, + { title: "Catweazle", author: "Richard Carpenter" } + ]) + }) + + test ('GET /Books/201 w/ CQL tail in URL' , async () => { + const {data:book} = await GET ('/hcql/admin/Books/201 { title, author.name as author } order by ID') + expect(book).to.deep.equal ({ title: "Wuthering Heights", author: "Emily Brontë" }) + }) + + it ('GET /Books/201 w/ CQL fragment in body' , async () => { + const {data:book} = await GET ('/hcql/admin/Books/201', { + headers: { 'Content-Type': 'text/plain' }, + data: `{ title, author.name as author }` + }) + expect(book).to.deep.equal ({ title: "Wuthering Heights", author: "Emily Brontë" }) + }) + + it ('GET /Books/201 w/ CQN fragment in body' , async () => { + const {data:book} = await GET ('/hcql/admin/Books/201', { + data: cds.ql `SELECT title, author.name as author` .SELECT + }) + expect(book).to.deep.equal ({ title: "Wuthering Heights", author: "Emily Brontë" }) + }) + + it ('GET /Books/201 w/ tail in URL plus CQL/CQN fragments in body' , async () => { + const {data:[b1]} = await GET ('/hcql/admin/Books where ID=201', { + data: cds.ql `SELECT title, author.name as author` .SELECT + }) + expect(b1).to.deep.equal ({ title: "Wuthering Heights", author: "Emily Brontë" }) + const {data:[b2]} = await GET ('/hcql/admin/Books where ID=201', { + headers: { 'Content-Type': 'text/plain' }, + data: `{ title, author.name as author }` + }) + expect(b2).to.deep.equal ({ title: "Wuthering Heights", author: "Emily Brontë" }) + }) + +}) \ No newline at end of file diff --git a/tests/protocols/hcql.http b/tests/protocols/hcql.http new file mode 100644 index 0000000..40af178 --- /dev/null +++ b/tests/protocols/hcql.http @@ -0,0 +1,225 @@ +@server = http://localhost:4004 + +GET {{server}}/odata/v4/admin/Authors? +&$select=ID,name +&$expand=books($select=ID,title) +&$count=true +### + +# +# The basic variant expects a CQN object passed as an application/json body +# to a POST request. This is also the fastest one, as it doesn't need CQL parsing. +# Note: $count is returned in X-Total-Count response header +# +GET {{server}}/hcql/admin +Content-Type: application/json +# Accept-Language: de + +{ "SELECT": { + "from": { "ref": [ "Authors" ] }, + "columns": [ + { "ref": [ "name" ] }, + { "ref": [ "books" ], "expand": [ + { "ref": [ "ID" ] }, + { "ref": [ "title" ] } + ]} + ], + "count": true +}} +### + +POST {{server}}/hcql/browse/submitOrder?book=201&quantity=2 +Authorization: Basic alice: +### + +POST {{server}}/hcql/browse/submitOrder +Authorization: Basic alice: +Content-Type: application/json + +{ + "book": 201, + "quantity": 2 +} +### + +GET {{server}}/hcql/browse/submitOrder?book=201&quantity=2 +Authorization: Basic alice: +### + +# +# Alternatively you can pass a CQL string as plain/text body +# +GET {{server}}/hcql/admin +Content-Type: text/plain +# X-Total-Count: true + +SELECT from Authors { name, books { title }} +# SELECT from Books { title, currency } +### + +# +# In addition we offer convenience slug routes... +# .e.g. /srv/entity routes +# + + +GET {{server}}/hcql/admin/Books +### + +GET {{server}}/hcql/admin/Books/201 +### + +GET {{server}}/hcql/admin/Books { ID, title, author.name as author } +### + +GET {{server}}/hcql/admin/Books order by stock desc +Content-Type: text/plain + +{ title, stock } +### + +GET {{server}}/hcql/admin/Books/201 { ID, title, author.name } +### + +GET {{server}}/hcql/admin/Books/201 { ID, title, author{name} } +### + + +POST {{server}}/hcql/admin/Books?title=The Black Cat&author_ID=101 +### + + +POST {{server}}/hcql/admin/Books?title=The Black Cat +Content-Type: application/json + +{ + "author_ID": 101 +} +### + +POST {{server}}/hcql/admin/Books +Content-Type: application/json + +{ + "title": "The Black Cat", + "author": { "ID": 101 } +} +### + +PUT {{server}}/hcql/admin/Books/275?title=Catastrophe +### + +PATCH {{server}}/hcql/admin/Books/275 +Content-Type: application/json + +{ + "title": "Catastrophe" +} +### + +GET {{server}}/hcql/admin/Authors { name, books { ID, title }} +### + +GET {{server}}/hcql/admin/Books { ID, title, author.name as author } order by ID desc +### + + + +// ------------------------------------ + +POST {{server}}/hcql/admin +Content-Type: application/json + +{"SELECT": { "from": { "ref": ["Books"] }}} +### + +POST {{server}}/hcql/admin +Content-Type: text/plain + +SELECT from Authors { + name as author, + books { + title, + stock, + price, + currency { * } + } +} +where name like '%Bro%' +order by name asc +### + + +# +# Simple REST-style URLs as supported as well +# + +GET {{server}}/hcql/admin/Books +### + +GET {{server}}/hcql/admin/Books/201 +### + + +# +# REST-style URLs can be combined with trailing CQL in the path, in plain +# text body, or with projections sent as application/json array +# + +GET {{server}}/hcql/admin/Books order by stock desc +### + +GET {{server}}/hcql/admin/Books { title as book, stock } order by stock desc +### + +GET {{server}}/hcql/admin/Authors +Content-Type: text/plain +Accept-Language: fr + +{ + ID, name as author, + books { + title, + stock, + currency { * } + } +} +where name like '%Bro%' +order by name asc +### + + +GET {{server}}/hcql/admin/Books/201 { title, stock } +### + +GET {{server}}/hcql/admin/Books order by stock desc +Content-Type: text/plain + +{ title, stock } +### + + +# +# CQL adaptor also provides access to the underlying CSN schema +# + +GET {{server}}/hcql/admin/$csn +### + + + +# +# CQL adaptor also supports INSERTs, UPDATEs, DELETEs ... +# + +POST {{server}}/hcql/admin +Content-Type: application/jsonin wonderland + +{ "INSERT": { + "into": "Books", + "entries": [{ + "title": "The Black Cat", + "author": { "ID": 150 } + }] +}} +### diff --git a/tests/protocols/odata.http b/tests/protocols/odata.http new file mode 100644 index 0000000..5793a11 --- /dev/null +++ b/tests/protocols/odata.http @@ -0,0 +1,26 @@ +@server = http://localhost:4004 + +GET {{server}}/odata/v2/admin/Authors +Authorization: Basic alice: +### + +GET {{server}}/odata/v2/admin/Authors?$select=ID,name&$expand=books($select=ID,title) +Authorization: Basic alice: +### + +GET {{server}}/odata/v4/admin/Authors +Authorization: Basic alice: +### + +GET {{server}}/odata/v4/admin/Authors?$select=ID,name&$expand=books($select=ID,title) +Authorization: Basic alice: +### + + +GET {{server}}/rest/admin/Authors +Authorization: Basic alice: +### + +GET {{server}}/rest/admin/Authors?$select=ID,name&$expand=books($select=ID,title) +Authorization: Basic alice: +### diff --git a/tests/protocols/rest.http b/tests/protocols/rest.http new file mode 100644 index 0000000..61706ca --- /dev/null +++ b/tests/protocols/rest.http @@ -0,0 +1,9 @@ +@server = http://localhost:4004 + +GET {{server}}/rest/admin/Authors +Authorization: Basic alice: +### + +GET {{server}}/rest/admin/Authors?$select=ID,name&$expand=books($select=ID,title) +Authorization: Basic alice: +### diff --git a/tests/protocols/services.cds b/tests/protocols/services.cds new file mode 100644 index 0000000..204a1be --- /dev/null +++ b/tests/protocols/services.cds @@ -0,0 +1,4 @@ + +using { CatalogService, AdminService } from '@capire/bookstore'; +annotate CatalogService with @hcql @odata @path:'browse' @requires:[]; +annotate AdminService with @hcql @odata @path:'admin'; \ No newline at end of file