From 67b8edf9b7f6b0fbab0010d7c93ed03a01e103ed Mon Sep 17 00:00:00 2001 From: sjvans <30337871+sjvans@users.noreply.github.com> Date: Thu, 16 Nov 2023 12:29:36 +0100 Subject: [PATCH] feat(temporal data): add time slice key to conflict clause (#249) Co-authored-by: Bob den Os Co-authored-by: Patrice Bender Co-authored-by: Johannes Vogel <31311694+johannes-vogel@users.noreply.github.com> --- db-service/lib/cqn2sql.js | 4 ++- hana/lib/HANAService.js | 25 +++++++-------- hana/test/temporal.test.js | 1 + sqlite/test/general/model.cds | 47 +++++++++++++++++----------- sqlite/test/general/model.js | 9 ++++++ sqlite/test/general/temporal.test.js | 42 +++++++++++++++++++++++++ 6 files changed, 95 insertions(+), 33 deletions(-) create mode 100644 hana/test/temporal.test.js create mode 100644 sqlite/test/general/model.js create mode 100644 sqlite/test/general/temporal.test.js diff --git a/db-service/lib/cqn2sql.js b/db-service/lib/cqn2sql.js index c74703bd5..995c202f4 100644 --- a/db-service/lib/cqn2sql.js +++ b/db-service/lib/cqn2sql.js @@ -527,6 +527,9 @@ class CQN2SQLRenderer { .filter(c => !keys.includes(c)) .map(c => `${this.quote(c)} = excluded.${this.quote(c)}`) + // temporal data + keys.push(...Object.values(q.target.elements).filter(e => e['@cds.valid.from']).map(e => e.name)) + keys = keys.map(k => this.quote(k)) const conflict = updateColumns.length ? `ON CONFLICT(${keys}) DO UPDATE SET ` + updateColumns @@ -872,7 +875,6 @@ class CQN2SQLRenderer { let val = _managed[element[annotation]?.['=']] if (val) sql = `coalesce(${sql}, ${this.func({ func: 'session_context', args: [{ val }] })})` - else if (!isUpdate && element.default) { const d = element.default if (d.val !== undefined || d.ref?.[0] === '$now') { diff --git a/hana/lib/HANAService.js b/hana/lib/HANAService.js index 0212a9b42..1d924cad4 100644 --- a/hana/lib/HANAService.js +++ b/hana/lib/HANAService.js @@ -64,6 +64,10 @@ class HANAService extends SQLService { } async set(variables) { + // REVISIT: required to be compatible with generated views + if (variables['$valid.from']) variables['VALID-FROM'] = variables['$valid.from'] + if (variables['$valid.to']) variables['VALID-TO'] = variables['$valid.to'] + this.dbc.set(variables) } @@ -837,19 +841,9 @@ class HANAService extends SQLService { const element = elements?.[name] || {} // Don't apply input converters for place holders const converter = (sql !== '?' && element[inputConverterKey]) || (e => e) - let managed = element[annotation]?.['='] - switch (managed) { - case '$user.id': - case '$user': - managed = this.func({ func: 'session_context', args: [{ val: '$user.id' }] }) - break - case '$now': - managed = this.func({ func: 'session_context', args: [{ val: '$user.now' }] }) - break - default: - managed = undefined - } - + const val = _managed[element[annotation]?.['=']] + let managed + if (val) managed = this.func({ func: 'session_context', args: [{ val }] }) const type = this.insertType4(element) let extract = sql ?? `${this.quote(name)} ${type} PATH '$.${name}'` if (!isUpdate) { @@ -1073,5 +1067,10 @@ Buffer.prototype.toJSON = function () { const is_regexp = x => x?.constructor?.name === 'RegExp' // NOTE: x instanceof RegExp doesn't work in repl const ObjectKeys = o => (o && [...ObjectKeys(o.__proto__), ...Object.keys(o)]) || [] +const _managed = { + '$user.id': '$user.id', + $user: '$user.id', + $now: '$now', +} module.exports = HANAService diff --git a/hana/test/temporal.test.js b/hana/test/temporal.test.js new file mode 100644 index 000000000..0bdd79d6a --- /dev/null +++ b/hana/test/temporal.test.js @@ -0,0 +1 @@ +require('../../sqlite/test/general/temporal.test') diff --git a/sqlite/test/general/model.cds b/sqlite/test/general/model.cds index f7c2e10d6..61d263f7a 100644 --- a/sqlite/test/general/model.cds +++ b/sqlite/test/general/model.cds @@ -1,27 +1,36 @@ -using {managed} from '@sap/cds/common'; +using { + managed, + temporal +} from '@sap/cds/common'; + +entity db.fooTemporal : managed, temporal { + key ID : Integer; +} @path: '/test' service test { - entity foo : managed { - key ID : Integer; - } + entity foo : managed { + key ID : Integer; + } + + entity bar { + key ID : UUID; + } - entity bar { - key ID : UUID; - } + entity fooLocalized { + key ID : Integer; + text : localized String; + } - entity fooLocalized { - key ID : Integer; - text : localized String; - } + entity fooTemporal as projection on db.fooTemporal; - entity Images { - key ID : Integer; - data : LargeBinary @Core.MediaType: 'image/jpeg'; - } + entity Images { + key ID : Integer; + data : LargeBinary @Core.MediaType: 'image/jpeg'; + } - entity ImagesView as projection on Images { - *, - data as renamedData - } + entity ImagesView as projection on Images { + *, + data as renamedData + } } diff --git a/sqlite/test/general/model.js b/sqlite/test/general/model.js new file mode 100644 index 000000000..888cd688f --- /dev/null +++ b/sqlite/test/general/model.js @@ -0,0 +1,9 @@ +module.exports = srv => { + const { fooTemporal } = srv.entities + + srv.on('CREATE', fooTemporal, async function (req) { + // without the fix, this UPSERT throws + await UPSERT(req.data).into(fooTemporal) + return req.data + }) +} diff --git a/sqlite/test/general/temporal.test.js b/sqlite/test/general/temporal.test.js new file mode 100644 index 000000000..9892badc5 --- /dev/null +++ b/sqlite/test/general/temporal.test.js @@ -0,0 +1,42 @@ +const cds = require('../../../test/cds.js') + +describe('temporal', () => { + const { GET, POST } = cds.test(__dirname, 'model.cds') + + beforeAll(async () => { + const db = await cds.connect.to('db') + const { fooTemporal } = db.model.entities('test') + await db.create(fooTemporal).entries([ + { ID: 1, validFrom: '1990-01-01T00:00:00.000Z', validTo: '9999-12-31T23:59:59.999Z' }, + { ID: 2, validFrom: '2000-01-01T00:00:00.000Z', validTo: '9999-12-31T23:59:59.999Z' } + ]) + }) + + test('READ', async () => { + let validAt, res + + validAt = '1970-01-01T00:00:00.000Z' + res = await GET(`/test/fooTemporal?sap-valid-at=${validAt}`) + expect(res.data.value.length).toBe(0) + + validAt = '1995-01-01T00:00:00.000Z' + res = await GET(`/test/fooTemporal?sap-valid-at=${validAt}`) + expect(res.data.value.length).toBe(1) + const it = res.data.value[0] + expect(it).toMatchObject({ ID: 1 }) + // managed and temporal shall not clash + expect(it.createdAt).not.toEqual(it.validFrom) + + validAt = '2010-01-01T00:00:00.000Z' + res = await GET(`/test/fooTemporal?sap-valid-at=${validAt}`) + expect(res.data.value.length).toBe(2) + }) + + test('UPSERT', async () => { + const validFrom = '2000-01-01T00:00:00.000Z' + const url = `/test/fooTemporal?sap-valid-from=${validFrom}` + const data = { ID: 42, validFrom } + const res = await POST(url, data) + expect(res.data).toMatchObject({ validFrom }) + }) +})