diff --git a/db-service/lib/cqn2sql.js b/db-service/lib/cqn2sql.js index e3b7b7dcc..ff4828aed 100644 --- a/db-service/lib/cqn2sql.js +++ b/db-service/lib/cqn2sql.js @@ -540,6 +540,7 @@ class CQN2SQLRenderer { let sepsub = '' for (const key in row) { let val = row[key] + const type = elements[key]?.type if (val === undefined) continue const keyJSON = `${sepsub}${JSON.stringify(key)}:` if (!sepsub) sepsub = ',' @@ -741,7 +742,7 @@ class CQN2SQLRenderer { const managed = this._managed.slice(0, columns.length) const extractkeys = managed - .filter(c => keys.includes(c.name)) + // .filter(c => keys.includes(c.name)) .map(c => `${c.onInsert || c.sql} as ${this.quote(c.name)}`) const entity = this.name(q.target?.name || UPSERT.into.ref[0], q) @@ -825,6 +826,7 @@ class CQN2SQLRenderer { const wrap = x.cast ? sql => `cast(${sql} as ${this.type4(x.cast)})` : sql => sql if (typeof x === 'string') throw cds.error`Unsupported expr: ${x}` if (x.param) return wrap(this.param(x)) + if ('json' in x) return wrap(this.json(x)) if ('ref' in x) return wrap(this.ref(x)) if ('val' in x) return wrap(this.val(x)) if ('func' in x) return wrap(this.func(x)) @@ -1000,6 +1002,20 @@ class CQN2SQLRenderer { return `(${list.map(e => this.expr(e))})` } + json(arg) { + const { props, elements, json } = arg + const { _convertInput } = this.class + let val = typeof json === 'string' ? json : (arg.json = JSON.stringify(json)) + if (val[val.length - 1] === ',') val = arg.json = val.slice(0, -1) + ']' + if (val[val.length - 1] === '[') val = arg.json = val + ']' + this.values.push(val) + const extraction = props.map(p => { + const element = elements?.[p] + return this.managed_extract(p, element, a => element[_convertInput]?.(a, element) || a).extract + }) + return `(SELECT ${extraction} FROM json_each(?))` + } + /** * Renders a javascript string into a SQL string literal * @param {string} s @@ -1055,6 +1071,7 @@ class CQN2SQLRenderer { managed(columns, elements) { const cdsOnInsert = '@cds.on.insert' const cdsOnUpdate = '@cds.on.update' + const cdsImmutable = '@Core.Immutable' const { _convertInput } = this.class // Ensure that missing managed columns are added @@ -1075,6 +1092,10 @@ class CQN2SQLRenderer { const keys = ObjectKeys(elements).filter(e => elements[e].key && !elements[e].isAssociation) const keyZero = keys[0] && this.quote(keys[0]) + const hasChanges = this.managed_changed( + [...columns, ...requiredColumns] + .filter(({ name }) => !elements?.[name]?.key && !elements?.[name]?.[cdsOnUpdate] && !elements?.[name]?.[cdsImmutable]) + ) return [...columns, ...requiredColumns].map(({ name, sql }) => { const element = elements?.[name] || {} @@ -1097,21 +1118,22 @@ class CQN2SQLRenderer { if (onUpdate) onUpdate = this.expr(onUpdate) const qname = this.quote(name) + const immutable = element[cdsImmutable] const insert = onInsert ? this.managed_default(name, converter(onInsert), sql) : sql - const update = onUpdate ? this.managed_default(name, converter(onUpdate), sql) : sql + const update = immutable ? undefined : onUpdate ? this.managed_default(name, converter(onUpdate), sql) : sql const upsert = keyZero && ( // upsert requires the keys to be provided for the existance join (default values optional) element.key - // If both insert and update have the same managed definition exclude the old value check - || (onInsert && onUpdate && insert === update) ? `${insert} as ${qname}` : `CASE WHEN OLD.${keyZero} IS NULL THEN ${ // If key of old is null execute insert insert } ELSE ${ // Else execute managed update or keep old if no new data if provided - onUpdate ? update : this.managed_default(name, `OLD.${qname}`, update) + !update + ? `OLD.${qname}` + : this.managed_default(name, `OLD.${qname}`, update) } END as ${qname}` ) @@ -1145,6 +1167,13 @@ class CQN2SQLRenderer { managed_default(name, managed, src) { return `(CASE WHEN json_type(value,${this.managed_extract(name).extract.slice(8)}) IS NULL THEN ${managed} ELSE ${src} END)` } + + managed_changed(cols/*, comps*/) { + return `CASE WHEN ${[ + ...cols.map(({ name }) => `json_type(value,${this.managed_extract(name).extract.slice(8)}) IS NOT NULL AND OLD.${this.quote(name)} ${this.is_distinct_from_} NEW.${this.quote(name)}`), + // ...comps.map(({ name }) => `json_type(value,${this.managed_extract(name).extract.slice(8)}) IS NOT NULL`) + ].join(' OR ')} THEN TRUE ELSE FALSE END` + } } Readable.prototype[require('node:util').inspect.custom] = Readable.prototype.toJSON = function () { return this._raw || `[object ${this.constructor.name}]` } diff --git a/db-service/lib/deep-genres.sql b/db-service/lib/deep-genres.sql new file mode 100644 index 000000000..43f98a81d --- /dev/null +++ b/db-service/lib/deep-genres.sql @@ -0,0 +1,126 @@ +-- DEBUG => this.dbc._native.prepare(cds.utils.fs.readFileSync(__dirname + '/deep-genres.sql','utf-8')).exec([JSON.stringify(query.UPDATE.data)]) +DO (IN JSON NCLOB => ?) BEGIN + + -- Extract genres with a depth of 3 (like: '$.children[*].children[*]') + Genres = SELECT + NEW.name, + NEW."$.NAME", + NEW.descr, + NEW."$.DESCR", + NEW.ID, + NEW."$.ID", + NEW.parent_ID, + NEW."$.PARENT_ID", + NEW."$.CHILDREN" + FROM + JSON_TABLE( + :JSON, + '$' COLUMNS( + name NVARCHAR(1020) PATH '$.name', + "$.NAME" NVARCHAR(2147483647) FORMAT JSON PATH '$.name', + descr NVARCHAR(4000) PATH '$.descr', + "$.DESCR" NVARCHAR(2147483647) FORMAT JSON PATH '$.descr', + ID INT PATH '$.ID', + "$.ID" NVARCHAR(2147483647) FORMAT JSON PATH '$.ID', + parent_ID INT PATH '$.parent_ID', + "$.PARENT_ID" NVARCHAR(2147483647) FORMAT JSON PATH '$.parent_ID', + "$.CHILDREN" NVARCHAR(2147483647) FORMAT JSON PATH '$.children' + ) + ERROR ON ERROR + ) AS NEW + UNION ALL + SELECT + NEW.name, + NEW."$.NAME", + NEW.descr, + NEW."$.DESCR", + NEW.ID, + NEW."$.ID", + NEW.parent_ID, + NEW."$.PARENT_ID", + NEW."$.CHILDREN" + FROM + JSON_TABLE( + :JSON, + '$.children[*]' COLUMNS( + name NVARCHAR(1020) PATH '$.name', + "$.NAME" NVARCHAR(2147483647) FORMAT JSON PATH '$.name', + descr NVARCHAR(4000) PATH '$.descr', + "$.DESCR" NVARCHAR(2147483647) FORMAT JSON PATH '$.descr', + ID INT PATH '$.ID', + "$.ID" NVARCHAR(2147483647) FORMAT JSON PATH '$.ID', + parent_ID INT PATH '$.parent_ID', + "$.PARENT_ID" NVARCHAR(2147483647) FORMAT JSON PATH '$.parent_ID', + "$.CHILDREN" NVARCHAR(2147483647) FORMAT JSON PATH '$.children' + ) + ERROR ON ERROR + ) AS NEW + UNION ALL + SELECT + NEW.name, + NEW."$.NAME", + NEW.descr, + NEW."$.DESCR", + NEW.ID, + NEW."$.ID", + NEW.parent_ID, + NEW."$.PARENT_ID", + NEW."$.CHILDREN" + FROM + JSON_TABLE( + :JSON, + '$.children[*].children[*]' COLUMNS( + name NVARCHAR(1020) PATH '$.name', + "$.NAME" NVARCHAR(2147483647) FORMAT JSON PATH '$.name', + descr NVARCHAR(4000) PATH '$.descr', + "$.DESCR" NVARCHAR(2147483647) FORMAT JSON PATH '$.descr', + ID INT PATH '$.ID', + "$.ID" NVARCHAR(2147483647) FORMAT JSON PATH '$.ID', + parent_ID INT PATH '$.parent_ID', + "$.PARENT_ID" NVARCHAR(2147483647) FORMAT JSON PATH '$.parent_ID', + "$.CHILDREN" NVARCHAR(2147483647) FORMAT JSON PATH '$.children' + ) + ERROR ON ERROR + ) AS NEW; + + -- DELETE all children of parents that are no longer in the dataset + DELETE FROM TestService_Genres WHERE + (parent_ID) IN (SELECT ID FROM :Genres WHERE "$.CHILDREN" IS NOT NULL) + AND + (ID) NOT IN (SELECT ID FROM :Genres); + + -- UPSERT new deep genres entries + UPSERT sap_capire_bookshop_Genres (name, descr, ID, parent_ID) + SELECT + CASE + WHEN OLD.ID IS NULL THEN NEW.name + ELSE ( + CASE + WHEN "$.NAME" IS NULL THEN OLD.name + ELSE NEW.name + END + ) + END as name, + CASE + WHEN OLD.ID IS NULL THEN NEW.descr + ELSE ( + CASE + WHEN "$.DESCR" IS NULL THEN OLD.descr + ELSE NEW.descr + END + ) + END as descr, + NEW.ID as ID, + CASE + WHEN OLD.ID IS NULL THEN NEW.parent_ID + ELSE ( + CASE + WHEN "$.PARENT_ID" IS NULL THEN OLD.parent_ID + ELSE NEW.parent_ID + END + ) + END as parent_ID + FROM + :Genres AS NEW + LEFT JOIN sap_capire_bookshop_Genres AS OLD ON NEW.ID = OLD.ID; +END; \ No newline at end of file diff --git a/db-service/lib/deep-queries.js b/db-service/lib/deep-queries.js index 043acf2bd..5ee840e13 100644 --- a/db-service/lib/deep-queries.js +++ b/db-service/lib/deep-queries.js @@ -1,24 +1,9 @@ -const cds = require('@sap/cds') const { _target_name4 } = require('./SQLService') const ROOT = Symbol('root') -// REVISIT: remove old path with cds^8 -let _compareJson -const compareJson = (...args) => { - if (!_compareJson) { - try { - // new path - _compareJson = require('@sap/cds/libx/_runtime/common/utils/compareJson').compareJson - } catch { - // old path - _compareJson = require('@sap/cds/libx/_runtime/cds-services/services/utils/compareJson').compareJson - } - } - return _compareJson(...args) -} - -const handledDeep = Symbol('handledDeep') +const uselist = false +const usestaticgenres = false /** * @callback nextCallback @@ -33,7 +18,6 @@ const handledDeep = Symbol('handledDeep') */ async function onDeep(req, next) { const { query } = req - if (handledDeep in query) return next() // REVISIT: req.target does not match the query.INSERT target for path insert // const target = query.sources[Object.keys(query.sources)[0]] @@ -42,18 +26,17 @@ async function onDeep(req, next) { const { target } = this.infer(query) if (!hasDeep(query, target)) return next() - const beforeData = query.INSERT ? [] : await this.run(getExpandForDeep(query, target, true)) - if (query.UPDATE && !beforeData.length) return 0 - - const queries = getDeepQueries(query, beforeData, target) + const queries = await getDeepQueries.call(this, query, target) // first delete, then update, then insert because of potential unique constraints: // - deletes never trigger unique constraints, but can prevent them -> execute first // - updates can trigger and prevent unique constraints -> execute second // - inserts can only trigger unique constraints -> execute last - await Promise.all(Array.from(queries.deletes.values()).map(query => this.onDELETE({ query, target: query._target }))) - await Promise.all(queries.updates.map(query => this.onUPDATE({ query }))) + await Promise.all(Array.from(queries.deletes.values()).map(query => this.onDELETE({ query }))) + await Promise.all(Array.from(queries.updates.values()).map(query => this.onUPDATE({ query }))) + await Promise.all(Array.from(queries.upserts.values()).map(query => this.onUPSERT({ query }))) + // TODO: return root UPDATE or UPSERT results when not doing an INSERT const rootQuery = queries.inserts.get(ROOT) queries.inserts.delete(ROOT) const [rootResult] = await Promise.all([ @@ -61,7 +44,7 @@ async function onDeep(req, next) { ...Array.from(queries.inserts.values()).map(query => this.onINSERT({ query })), ]) - return rootResult ?? beforeData.length + return rootResult ?? 1 } const hasDeep = (q, target) => { @@ -72,232 +55,169 @@ const hasDeep = (q, target) => { } } -// unofficial config! -const DEEP_DELETE_MAX_RECURSION_DEPTH = - (cds.env.features.recursion_depth && Number(cds.env.features.recursion_depth)) || 4 // we use 4 here as our test data has a max depth of 3 - // IMPORTANT: Skip only if @cds.persistence.skip is `true` → e.g. this skips skipping targets marked with @cds.persistence.skip: 'if-unused' const _hasPersistenceSkip = target => target?.['@cds.persistence.skip'] === true -const getColumnsFromDataOrKeys = (data, target) => { - if (Array.isArray(data)) { - // loop and get all columns from current level - const columns = new Set() - data.forEach(row => - Object.keys(row || target.keys) - .filter(propName => !target.elements[propName]?.isAssociation) - .forEach(entry => { - columns.add(entry) - }), - ) - return Array.from(columns).map(c => ({ ref: [c] })) - } else { - // get all columns from current level - return Object.keys(data || target.keys) - .filter(propName => target.elements[propName] && !target.elements[propName].isAssociation) - .map(c => ({ ref: [c] })) - } -} - -const _calculateExpandColumns = (target, data, expandColumns = [], elementMap = new Map()) => { - const compositions = target.compositions || {} - - if (expandColumns.length === 0) { - // REVISIT: ensure that all keys are included in the expand columns - expandColumns.push(...getColumnsFromDataOrKeys(data, target)) - } +/** + * @param {import('@sap/cds/apis/cqn').Query} query + * @param {unknown[]} dbData + * @param {import('@sap/cds/apis/csn').Definition} target + * @returns + */ +const getDeepQueries = async function (query, target) { + if (query.INSERT) { + const inserts = new Map() - for (const compName in compositions) { - let compositionData - if (data === null || (Array.isArray(data) && !data.length)) { - compositionData = null - } else { - compositionData = data[compName] - } + const step = (entry, target) => { + for (const comp in target.compositions) { + if (!entry[comp]) continue - // ignore not provided compositions as nothing happens with them (expect deep delete) - if (compositionData === undefined) { - // fill columns in case - continue - } + const composition = target.compositions[comp] + const compTarget = composition._target - const composition = compositions[compName] + if (_hasPersistenceSkip(compTarget)) continue - const fqn = composition.parent.name + ':' + composition.name - const seen = elementMap.get(fqn) - if (seen && seen >= DEEP_DELETE_MAX_RECURSION_DEPTH) { - // recursion -> abort - return expandColumns - } + if (!inserts.has(compTarget)) inserts.set(compTarget, INSERT([]).into(compTarget)) + const cqn = inserts.get(compTarget) - let expandColumn = expandColumns.find(expandColumn => expandColumn.ref[0] === composition.name) - if (!expandColumn) { - expandColumn = { - ref: [composition.name], - expand: getColumnsFromDataOrKeys(compositionData, composition._target), + const childEntries = entry[comp] + if (composition.is2many) { + cqn.INSERT.entries = [...cqn.INSERT.entries, ...entry[comp]] + for (const childEntry of childEntries) { + step(childEntry, compTarget) + } + } else { + cqn.INSERT.entries = [...cqn.INSERT.entries, entry[comp]] + step(childEntries, compTarget) + } } - - expandColumns.push(expandColumn) } + inserts.set(ROOT, query) - // expand deep - // Make a copy and do not share the same map among brother compositions - // as we're only interested in deep recursions, not wide recursions. - const newElementMap = new Map(elementMap) - newElementMap.set(fqn, (seen && seen + 1) || 1) - - if (composition.is2many) { - // expandColumn.expand = getColumnsFromDataOrKeys(compositionData, composition._target) - if (compositionData === null || compositionData.length === 0) { - // deep delete, get all subitems until recursion depth - _calculateExpandColumns(composition._target, null, expandColumn.expand, newElementMap) - continue - } + for (const entry of query.INSERT.entries) { + step(entry, target) + } - for (const row of compositionData) { - _calculateExpandColumns(composition._target, row, expandColumn.expand, newElementMap) - } - } else { - // to one - _calculateExpandColumns(composition._target, compositionData, expandColumn.expand, newElementMap) + return { + deletes: new Map(), + updates: new Map(), + upserts: new Map(), + inserts: inserts, } } - return expandColumns -} - -/** - * @param {import('@sap/cds/apis/cqn').Query} query - * @param {import('@sap/cds/apis/csn').Definition} target - */ -const getExpandForDeep = (query, target) => { - const { entity, data = null, where } = query.UPDATE - const columns = _calculateExpandColumns(target, data) - return SELECT(columns).from(entity).where(where) -} -/** - * @param {import('@sap/cds/apis/cqn').Query} query - * @param {unknown[]} dbData - * @param {import('@sap/cds/apis/csn').Definition} target - * @returns - */ -const getDeepQueries = (query, dbData, target) => { - let queryData - if (query.INSERT) { - queryData = query.INSERT.entries - } - if (query.DELETE) { - queryData = [] - } - if (query.UPDATE) { - queryData = [query.UPDATE.data] - } + if (query.UPDATE || query.UPSERT) { + const deletes = new Map() + const upserts = new Map() + const updates = new Map() - let diff = compareJson(queryData, dbData, target) - if (!Array.isArray(diff)) { - diff = [diff] - } - return _getDeepQueries(diff, target) -} + if (usestaticgenres && query.target.name === 'TestService.Genres') { + query.target.__deep_sql ??= cds.utils.fs.readFileSync(__dirname + '/deep-genres.sql', 'utf-8') -const _hasManagedElements = target => { - return Object.keys(target.elements).filter(elementName => target.elements[elementName]['@cds.on.update']).length > 0 -} + const ps = await this.prepare(query.target.__deep_sql) + const res = await ps.run([JSON.stringify(query.UPDATE.data || query.UPSERT.entity)]) -/** - * @param {unknown[]} diff - * @param {import('@sap/cds/apis/csn').Definition} target - * @param {Map} deletes - * @param {Map} inserts - * @param {Object[]} updates - * @param {boolean} [root=true] - * @returns {Object|Boolean} - */ -const _getDeepQueries = (diff, target, deletes = new Map(), inserts = new Map(), updates = [], root = true) => { - // flag to determine if queries were created - let dirty = false - for (const diffEntry of diff) { - if (diffEntry === undefined) continue - - let childrenDirty = false - for (const prop in diffEntry) { - // handle deep operations - - const propData = diffEntry[prop] - - if (target.elements[prop] && _hasPersistenceSkip(target.elements[prop]._target)) { - delete diffEntry[prop] - } else if (target.compositions?.[prop]) { - const arrayed = Array.isArray(propData) ? propData : [propData] - childrenDirty = - arrayed - .map(subEntry => - _getDeepQueries([subEntry], target.elements[prop]._target, deletes, inserts, updates, false), - ) - .some(a => a) || childrenDirty - delete diffEntry[prop] - } else if (diffEntry[prop] === undefined) { - // restore current behavior, if property is undefined, not part of payload - delete diffEntry[prop] + return { + deletes, + upserts, + updates, + inserts: new Map(), } } - // handle current entity level - const op = diffEntry._op - delete diffEntry._op + const step = (entry, target) => { + for (const comp in target.compositions) { + if (!entry[comp]) continue + + const composition = target.compositions[comp] + const compTarget = composition._target + + if (_hasPersistenceSkip(compTarget)) continue + + if (!upserts.has(compTarget)) upserts.set(compTarget, UPSERT([]).into(compTarget)) + const ups = upserts.get(compTarget) + + if (!deletes.has(compTarget)) { + const fkeynames = composition._foreignKeys.map(k => k.childElement.name) + const keynames = Object.keys(compTarget.keys).filter(k => !compTarget.keys[k].isAssociation && !compTarget.keys[k].virtual) + const fkeyrefs = { list: fkeynames.map(k => ({ ref: [k] })) } + const keyrefs = { list: keynames.map(k => ({ ref: [k] })) } + const pkeynames = composition._foreignKeys.map(k => k.parentElement.name) + const fkeys = uselist + ? { list: [] } + : { + props: pkeynames, + elements: target.elements, + json: '[', + } + const nkeys = uselist + ? { list: [] } + : { + props: keynames, + elements: compTarget.elements, + json: '[', + } + + const del = DELETE.from(compTarget) + .where([ + fkeyrefs, 'in', fkeys, + 'and', + keyrefs, 'not', 'in', nkeys, + ]) + + del.addFKey = uselist + ? Function('entry', `this.list.push({list:[${pkeynames.map(k => `{val:entry[${JSON.stringify(k)}]}`).join(',')}]})`).bind(fkeys) + : Function('entry', `this.json += '{${pkeynames.map(k => `${JSON.stringify(k)}:' + entry[${JSON.stringify(k)}] + '`).join('')}},'`).bind(fkeys) + del.addKey = uselist + ? Function('entry', `this.list.push({list:[${keynames.map(k => `{val:entry[${JSON.stringify(k)}]}`).join(',')}]})`).bind(nkeys) + : Function('entry', `this.json += '{${keynames.map(k => `${JSON.stringify(k)}:' + entry[${JSON.stringify(k)}] + '`).join('')}},'`).bind(nkeys) + + deletes.set(compTarget, del) + } - if (diffEntry._old != null) { - delete diffEntry._old + const del = deletes.get(compTarget) + const childEntries = entry[comp] + + del.addFKey(entry) + if (composition.is2many) { + for (const childEntry of childEntries) { + ups.UPSERT.entries.push(childEntry) + del.addKey(childEntry) + step(childEntry, compTarget) + } + } else { + del.addKey(childEntries) + ups.UPSERT.entries.push(childEntries) + step(childEntries, compTarget) + } + } } - if (op === 'create') { - dirty = true - const id = root ? ROOT : target.name - const insert = inserts.get(id) - if (insert) { - insert.INSERT.entries.push(diffEntry) - } else { - const q = INSERT.into(target).entries(diffEntry) - inserts.set(id, q) - } - } else if (op === 'delete') { - dirty = true - const keys = cds.utils - .Object_keys(target.keys) - .filter(key => !target.keys[key].virtual && !target.keys[key].isAssociation) - - const keyVals = keys.map(k => ({ val: diffEntry[k] })) - const currDelete = deletes.get(target.name) - if (currDelete) currDelete.DELETE.where[2].list.push({ list: keyVals }) - else { - const left = { list: keys.map(k => ({ ref: [k] })) } - const right = { list: [{ list: keyVals }] } - deletes.set(target.name, DELETE.from(target).where([left, 'in', right])) - } - } else if (op === 'update' || (op === undefined && (root || childrenDirty) && _hasManagedElements(target))) { - dirty = true - // TODO do we need the where here? - const keys = target.keys - const cqn = UPDATE(target).with(diffEntry) - for (const key in keys) { - if (keys[key].virtual) continue - if (!keys[key].isAssociation) { - cqn.where(key + '=', diffEntry[key]) - } - delete diffEntry[key] + if (query.UPDATE) { + updates.set(ROOT, query) + const data = query.UPDATE.data + step(data, target) + } + else if (query.UPSERT) { + upserts.set(ROOT, query) + for (const data of query.UPSERT.entries) { + step(data, target, [...query.UPDATE.entity.ref]) } - cqn.with(diffEntry) - updates.push(cqn) + } + + return { + deletes, + upserts, + updates, + inserts: new Map(), } } - return root ? { updates, inserts, deletes } : dirty } module.exports = { onDeep, hasDeep, getDeepQueries, // only for testing - getExpandForDeep, // only for testing } diff --git a/db-service/test/deep/deep.test.js b/db-service/test/deep/deep.test.js index 0d89b26f9..93b6423d6 100644 --- a/db-service/test/deep/deep.test.js +++ b/db-service/test/deep/deep.test.js @@ -1,774 +1,14 @@ const cds = require('../../../test/cds') -const {expect} = cds.test.in(__dirname) // IMPORTANT: that has to go before loading cds.env below +const { expect } = cds.test.in(__dirname) // IMPORTANT: that has to go before loading cds.env below cds.env.features.recursion_depth = 2 -const { getDeepQueries, getExpandForDeep } = require('../../lib/deep-queries') +const { getDeepQueries } = require('../../lib/deep-queries') describe('test deep query generation', () => { cds.test() let model; beforeAll(() => model = cds.model) - describe('deep expand', () => { - // SKIPPED because that test is testing obsolete internal implementation of deep delete - test.skip('Deep DELETE with to-one all data provided', () => { - const query = getExpandForDeep(DELETE.from(model.definitions.Root).where({ ID: 1 }), model.definitions.Root) - expect(query).to.eql({ - SELECT: { - from: { ref: ['Root'] }, - where: [ - { - ref: ['ID'], - }, - '=', - { - val: 1, - }, - ], - columns: [ - { ref: ['ID'] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - ], - }, - ], - }, - ], - }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - ], - }, - ], - }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - ], - }, - ], - }, - ], - }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - ], - }, - ], - }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - }) - }) - test('Deep UPDATE with to-one all data provided', () => { - const query = getExpandForDeep( - UPDATE.entity(model.definitions.Root).with({ - ID: 1, - toOneChild: { ID: 10, toOneSubChild: { ID: 30 } }, - }), - model.definitions.Root, - ) - expect(query).to.eql({ - SELECT: { - from: { ref: ['Root'] }, - columns: [ - { ref: ['ID'] }, - { ref: ['toOneChild'], expand: [{ ref: ['ID'] }, { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }] }, - ], - }, - }) - }) - - test('Deep UPDATE with to-many delete children', () => { - const query = getExpandForDeep( - UPDATE.entity(model.definitions.Root).with({ - ID: 1, - toManyChild: [ - { ID: 10, toManySubChild: [{ ID: 20, subText: 'sub' }] }, - { ID: 21, text: 'text' }, - ], - }), - model.definitions.Root, - ) - expect(query).to.eql({ - SELECT: { - from: { ref: ['Root'] }, - columns: [ - { ref: ['ID'] }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['text'] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }, { ref: ['subText'] }] }, - ], - }, - ], - }, - }) - }) - - test('Deep UPDATE with to-one delete children', () => { - const query = getExpandForDeep( - UPDATE.entity(model.definitions.Root).with({ - ID: 1, - toOneChild: null, - }), - model.definitions.Root, - ) - expect(query).to.eql({ - SELECT: { - from: { ref: ['Root'] }, - columns: [ - { ref: ['ID'] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - ], - }, - ], - }, - ], - }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - ], - }, - ], - }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - }) - }) - - test('UPDATE builds expand based on data', () => { - const query = getExpandForDeep( - UPDATE.entity(model.definitions.Root).with({ - ID: 1, - toOneChild: { ID: 10 }, - toManyChild: [ - { ID: 11, toOneSubChild: { ID: 20 }, toManySubChild: null, text: 'foo' }, - { - ID: 12, - toManyChild: [ - { ID: 13, toManyChild: null }, - { ID: 14, toManySubChild: [{ ID: 21 }] }, - ], - }, - ], - }), - model.definitions.Root, - ) - // TODO toManySubChild: null -> max recursion - expect(query).to.eql({ - SELECT: { - from: { ref: ['Root'] }, - columns: [ - { ref: ['ID'] }, - { ref: ['toOneChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['text'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - ], - }, - ], - }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - ], - }, - ], - }, - }) - }) - - test('UPDATE works when removing all children', () => { - const query = getExpandForDeep( - UPDATE.entity(model.definitions.Root).with({ - ID: 1, - toOneChild: { ID: 10 }, - toManyChild: [], - }), - model.definitions.Root, - ) - - // expectation also needs to be adapted - expect(query).to.containSubset({ - SELECT: { - from: { ref: ['Root'] }, - columns: [ - { ref: ['ID'] }, - { ref: ['toOneChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - ], - }, - ], - }, - ], - }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - ], - }, - ], - }, - { - ref: ['toManyChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneChild'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneSubChild'], expand: [{ ref: ['ID'] }] }, - { ref: ['toManySubChild'], expand: [{ ref: ['ID'] }] }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - }) - }) - - test.skip('works with recursive and stops after getting to the same level 2 times', () => { - const query = getExpandForDeep( - DELETE.from(model.definitions.Recursive).where({ ID: 5 }), - model.definitions.Recursive, - ) - expect(query).to.eql({ - SELECT: { - from: { ref: ['Recursive'] }, - where: [ - { - ref: ['ID'], - }, - '=', - { - val: 5, - }, - ], - columns: [ - { ref: ['ID'] }, - { - ref: ['toOneRecursive'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneRecursive'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneTransient'], - expand: [ - { ref: ['ID'] }, - { - ref: ['toOneRecursive'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneRecursive'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneTransient'], - expand: [ - { ref: ['ID'] }, - { - ref: ['toOneRecursive'], - expand: [{ ref: ['ID'] }, { ref: ['toOneRecursive'], expand: [{ ref: ['ID'] }] }], - }, - ], - }, - ], - }, - ], - }, - ], - }, - { - ref: ['toOneTransient'], - expand: [ - { ref: ['ID'] }, - { - ref: ['toOneRecursive'], - expand: [ - { ref: ['ID'] }, - { - ref: ['toOneRecursive'], - expand: [ - { ref: ['ID'] }, - { ref: ['toOneRecursive'], expand: [{ ref: ['ID'] }] }, - { - ref: ['toOneTransient'], - expand: [ - { ref: ['ID'] }, - { - ref: ['toOneRecursive'], - expand: [{ ref: ['ID'] }, { ref: ['toOneRecursive'], expand: [{ ref: ['ID'] }] }], - }, - ], - }, - ], - }, - { - ref: ['toOneTransient'], - expand: [ - { ref: ['ID'] }, - { - ref: ['toOneRecursive'], - expand: [ - { ref: ['ID'] }, - { - ref: ['toOneRecursive'], - expand: [{ ref: ['ID'] }, { ref: ['toOneRecursive'], expand: [{ ref: ['ID'] }] }], - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }, - }) - }) - }) - describe('INSERT', () => { test('creates sub inserts', () => { const query = INSERT.into(model.definitions.Root).entries([ @@ -784,23 +24,23 @@ describe('test deep query generation', () => { ], }, ]) - const { inserts, updates, deletes } = getDeepQueries(query, [], model.definitions.Root) + const { inserts, updates, deletes } = getDeepQueries(query, model.definitions.Root) - const expectedInserts = [ - INSERT.into(model.definitions.Root) + const expectedInserts = { + [model.definitions.Root.name]: INSERT.into(model.definitions.Root) .entries([{ ID: 1 }, { ID: 2 }, { ID: 3 }]), - INSERT.into(model.definitions.Child) - .entries([{ ID: 1 }, { ID: 2 }, { ID: 3 }, { ID: 4 }, { ID: 6 }, { ID: 7 }, { ID: 5 }, { ID: 9 }, { ID: 8 }]), - INSERT.into(model.definitions.SubChild) + [model.definitions.Child.name]: INSERT.into(model.definitions.Child) + .entries([{ ID: 1 }, { ID: 2 }, { ID: 3 }, { ID: 4 }, { ID: 5 }, { ID: 8 }, { ID: 6 }, { ID: 7 }, { ID: 9 }]), + [model.definitions.SubChild.name]: INSERT.into(model.definitions.SubChild) .entries([{ ID: 10 }, { ID: 11 }, { ID: 12 }, { ID: 13 }]), - ] + } const insertsArray = Array.from(inserts.values()) - const updatesArray = Array.from(updates) + const updatesArray = Array.from(updates.values()) const deletesArray = Array.from(deletes.values()) - expectedInserts.forEach(insert => { - expect(insertsArray).to.deep.contain(insert) + insertsArray.forEach(insert => { + expect(insert).to.deep.containSubset(expectedInserts[insert.target.name]) }) expect(updatesArray.length).to.eq(0) diff --git a/hana/lib/HANAService.js b/hana/lib/HANAService.js index e85d9cc97..48bd6579b 100644 --- a/hana/lib/HANAService.js +++ b/hana/lib/HANAService.js @@ -32,6 +32,7 @@ class HANAService extends SQLService { this.on(['COMMIT'], this.onCOMMIT) this.on(['ROLLBACK'], this.onROLLBACK) this.on(['SELECT', 'INSERT', 'UPSERT', 'UPDATE', 'DELETE'], this.onNOTFOUND) + this.on(['INSERT', 'UPSERT', 'UPDATE'], require('./deep-queries').onDeep) return super.init() } @@ -1108,6 +1109,22 @@ SELECT ${mixing} FROM JSON_TABLE(SRC.JSON, '$' COLUMNS(${extraction})) AS NEW LE return super.list(list) } + json(arg) { + const { props, elements, json } = arg + const { _convertInput } = this.class + let val = typeof json === 'string' ? json : (arg.json = JSON.stringify(json)) + if (val[val.length - 1] === ',') val = arg.json = val.slice(0, -1) + ']' + if (val[val.length - 1] === '[') val = arg.json = val + ']' + this.values.push(val) + const extractions = props.map(p => { + const element = elements?.[p] + return this.managed_extract(p, element, a => element[_convertInput]?.(a, element) || a) + }) + const converter = extractions.map(e => e.sql) + const extraction = extractions.map(e => e.extract) + return `(SELECT ${converter} FROM JSON_TABLE(?, '$' COLUMNS(${extraction}) ERROR ON ERROR) as NEW)` + } + quote(s) { // REVISIT: casing in quotes when reading from entities it uppercase // When returning columns from a query they should be case sensitive @@ -1137,6 +1154,13 @@ SELECT ${mixing} FROM JSON_TABLE(SRC.JSON, '$' COLUMNS(${extraction})) AS NEW LE } } + managed_changed(cols/*, comps*/) { + return `CASE WHEN ${[ + ...cols.map(({ name }) => `${this.quote('$.' + name)} IS NOT NULL AND OLD.${this.quote(name)} ${this.is_distinct_from_} NEW.${this.quote(name)}`), + // ...comps.map(({ name }) => `json_type(value,${this.managed_extract(name).extract.slice(8)}) IS NOT NULL`) + ].join(' OR ')} THEN TRUE ELSE FALSE END` + } + managed_default(name, managed, src) { return `(CASE WHEN ${this.quote('$.' + name)} IS NULL THEN ${managed} ELSE ${src} END)` } @@ -1164,6 +1188,8 @@ SELECT ${mixing} FROM JSON_TABLE(SRC.JSON, '$' COLUMNS(${extraction})) AS NEW LE Binary: () => `NVARCHAR(2147483647)`, array: () => `NVARCHAR(2147483647) FORMAT JSON`, Map: () => `NVARCHAR(2147483647) FORMAT JSON`, + Association: () => `NVARCHAR(2147483647) FORMAT JSON`, + Composition: () => `NVARCHAR(2147483647) FORMAT JSON`, Vector: () => `NVARCHAR(2147483647)`, Decimal: () => `DECIMAL`, diff --git a/hana/lib/deep-queries.js b/hana/lib/deep-queries.js new file mode 100644 index 000000000..706be1fad --- /dev/null +++ b/hana/lib/deep-queries.js @@ -0,0 +1,265 @@ +const { Readable } = require('stream') + +const { _target_name4 } = require('@cap-js/db-service/lib/SQLService') + +const ROOT = Symbol('root') +const DEEP_INSERT_SQL = Symbol('deep insert sql') +const DEEP_UPSERT_SQL = Symbol('deep upsert sql') + +/** + * @callback nextCallback + * @param {Error|undefined} error + * @returns {Promise} + */ + +/** + * @param {import('@sap/cds/apis/services').Request} req + * @param {nextCallback} next + * @returns {Promise} + */ +async function onDeep(req, next) { + const { query } = req + + // REVISIT: req.target does not match the query.INSERT target for path insert + // const target = query.sources[Object.keys(query.sources)[0]] + if (!this.model?.definitions[_target_name4(req.query)]) return next() + + const { target } = this.infer(query) + if (!hasDeep(query, target)) return next() + + return getDeepQueries.call(this, query, target) +} + +const hasDeep = (q, target) => { + if (q.INSERT && !q.INSERT.entries) return false + if (q.UPSERT && !q.UPSERT.entries) return false + for (const _ in target.compositions) return true + return false +} + +// IMPORTANT: Skip only if @cds.persistence.skip is `true` → e.g. this skips skipping targets marked with @cds.persistence.skip: 'if-unused' +const _hasPersistenceSkip = target => target?.['@cds.persistence.skip'] === true + +/** + * @param {import('@sap/cds/apis/cqn').Query} query + * @param {unknown[]} dbData + * @param {import('@sap/cds/apis/csn').Definition} target + * @returns + */ +const getDeepQueries = async function (query, target) { + const getEntries = (query) => { + const cqn2sql = new this.class.CQN2SQL(this) + cqn2sql.cqn = query + const entries = query.INSERT + ? query.INSERT.entries + : query.UPSERT.entries + if (entries[0] instanceof Readable) { + entries[0].type = 'json' + return entries[0] + } + return Readable.from( + cqn2sql.INSERT_entries_stream(entries), + { ObjectMode: false }, + ) + } + + const extract = new Map() + const deletes = new Map() + const upserts = new Map() + const inserts = new Map() + + const renderTarget = (target) => { + const elements = target.elements + const elems = Object.keys(target.elements) + const columns = elems.filter(c => c in elements && !elements[c].virtual && !elements[c].value && !elements[c].isAssociation) + const compositions = elems.filter(c => c in elements && elements[c].isComposition) + + const cqn2sql = new this.class.CQN2SQL() + + const variableName = cqn2sql.name(target.name) + + const managed = cqn2sql.managed([...columns, ...compositions].map(name => ({ name })), elements) + const extraction = managed.map(c => c.extract) + + const deepDeletes = deletes.has(target) && getDeepDeletes.call(this, { query: deletes.get(target) }) + .join(';\n') + .replace(/([^ ]*) as "VAR"/gi, (_, b) => `:${b} as "VAR"`) + + return { + extract: `${variableName} = ${extract.get(target).map(p => `SELECT * FROM JSON_TABLE(:JSON, '${p.join('.')}' COLUMNS(${extraction}))`).join(' UNION ALL ')}`, + deletes: deepDeletes, + inserts: inserts.has(target) && + this.cqn2sql(inserts.get(target)).sql + .replace('WITH SRC AS (SELECT ? AS JSON FROM DUMMY UNION ALL SELECT TO_NCLOB(NULL) AS JSON FROM DUMMY)', '') + .replace(/JSON_TABLE\(.*\) AS NEW/, `:${variableName} AS NEW`), + upserts: upserts.has(target) && + this.cqn2sql(upserts.get(target)).sql + .replace('WITH SRC AS (SELECT ? AS JSON FROM DUMMY UNION ALL SELECT TO_NCLOB(NULL) AS JSON FROM DUMMY)', '') + .replace(/JSON_TABLE\(.*\) AS NEW/, `:${variableName} AS NEW`), + } + } + + const render = () => { + const sqls = { + extract: [], + deletes: [], + inserts: [], + upserts: [], + } + for (const [key] of extract) { + const curSql = renderTarget(key) + sqls.extract.push(curSql.extract) + if (curSql.deletes) sqls.deletes.push(curSql.deletes) + if (curSql.inserts) sqls.inserts.push(curSql.inserts) + if (curSql.upserts) sqls.upserts.push(curSql.upserts) + } + + return `DO (IN JSON NCLOB => ?) BEGIN + ${sqls.extract.join(';\n')}${sqls.extract.length ? ';' : ''} + ${sqls.deletes.join(';\n')}${sqls.deletes.length ? ';' : ''} + ${sqls.inserts.join(';\n')}${sqls.inserts.length ? ';' : ''} + ${sqls.upserts.join(';\n')}${sqls.upserts.length ? ';' : ''} + END;` + } + + if (query.INSERT) { + if (target[DEEP_INSERT_SQL]) { + const ps = await this.prepare(target[DEEP_INSERT_SQL]) + return ps.run([getEntries(query)]) + } + + const step = (target, path, visited = []) => { + for (const comp in target.compositions) { + const composition = target.compositions[comp] + const compTarget = composition._target + + if (visited.reduce((l, c) => c === composition ? l + 1 : l, 1) > (composition['@depth'] || 3)) continue + if (_hasPersistenceSkip(compTarget)) continue + + if (!inserts.has(compTarget)) inserts.set(compTarget, INSERT([]).into(compTarget)) + + const compPath = composition.is2many ? [...path, comp + '[*]'] : [...path, comp] + if (!extract.has(compTarget)) extract.set(compTarget, []) + extract.get(compTarget).push(compPath) + + step(compTarget, compPath, [...visited, composition]) + } + } + + inserts.set(target, query) + extract.set(target, []) + + const rootPath = ['$'] + extract.get(target).push(rootPath) + step(target, rootPath) + + const sql = target[DEEP_INSERT_SQL] = render() + + const ps = await this.prepare(sql) + return ps.run([getEntries(query)]) + } + + if (query.UPDATE) { + query = UPSERT([query.UPDATE.data]).into(target) + } + + if (query.UPSERT) { + if (target[DEEP_UPSERT_SQL]) { + const ps = await this.prepare(target[DEEP_UPSERT_SQL]) + return ps.run([getEntries(query)]) + } + + const step = (target, path, visited = []) => { + for (const comp in target.compositions) { + const composition = target.compositions[comp] + const compTarget = composition._target + + if (visited.reduce((l, c) => c === composition ? l + 1 : l, 1) > (composition['@depth'] || 3)) continue + if (_hasPersistenceSkip(compTarget)) continue + + if (!upserts.has(compTarget)) upserts.set(compTarget, UPSERT([]).into(compTarget)) + if (!deletes.has(compTarget)) { + const fkeynames = composition._foreignKeys.map(k => k.childElement.name) + const keynames = Object.keys(compTarget.keys).filter(k => !compTarget.keys[k].isAssociation && !compTarget.keys[k].virtual) + const fkeyrefs = fkeynames.map(k => ({ ref: [k] })) + const keyrefs = keynames.map(k => ({ ref: [k] })) + const pkeyrefs = composition._foreignKeys.map(k => ({ ref: [k.parentElement.name] })) + const fkeys = SELECT(pkeyrefs).from({ ref: [target.name], as: 'VAR' }).where([comp, 'is', 'not', 'null']) + const nkeys = SELECT(keyrefs).from({ ref: [compTarget.name], as: 'VAR' }) + + const del = DELETE.from(compTarget) + .where([ + { list: fkeyrefs }, 'in', fkeys, + 'and', + { list: keyrefs }, 'not', 'in', nkeys, + ]) + deletes.set(compTarget, del) + } + + const compPath = composition.is2many ? [...path, comp + '[*]'] : [...path, comp] + if (!extract.has(compTarget)) extract.set(compTarget, []) + extract.get(compTarget).push(compPath) + + step(compTarget, compPath, [...visited, composition]) + } + } + + upserts.set(target, query) + extract.set(target, []) + + const rootPath = ['$'] + extract.get(target).push(rootPath) + step(target, rootPath) + + const sql = target[DEEP_UPSERT_SQL] = render() + + const ps = await this.prepare(sql) + return ps.run([getEntries(query)]) + } + +} + +// Modified to be static version from SQLService +const getDeepDeletes = function deep_delete(/** @type {Request} */ req) { + let ret = [] + let { compositions } = (req.target ??= req.query.target) + if (compositions) { + // Transform CQL`DELETE from Foo[p1] WHERE p2` into CQL`DELETE from Foo[p1 and p2]` + let { from, where } = req.query.DELETE + if (typeof from === 'string') from = { ref: [from] } + if (where) { + let last = from.ref.at(-1) + if (last.where) [last, where] = [last.id, [{ xpr: last.where }, 'and', { xpr: where }]] + from = { ref: [...from.ref.slice(0, -1), { id: last, where }] } + } + // Process child compositions depth-first + let { depth = 0, visited = [] } = req + visited.push(req.target.name) + + ret = Object.values(compositions).map(c => { + if (c._target['@cds.persistence.skip'] === true) return + if (c._target === req.target) { + // the Genre.children case + if (++depth > (c['@depth'] || 3)) return + } else if (visited.includes(c._target.name)) + throw new Error( + `Transitive circular composition detected: \n\n` + + ` ${visited.join(' > ')} > ${c._target.name} \n\n` + + `These are not supported by deep delete.`, + ) + // Prepare and run deep query, à la CQL`DELETE from Foo[pred]:comp1.comp2...` + const query = DELETE.from({ ref: [...from.ref, c.name] }) + return deep_delete.call(this, { query, depth, visited: [...visited], target: c._target }) + }) + .flat() + .filter(a => a) + } + ret.push(this.cqn2sql(req.query).sql) + return ret +} + +module.exports = { + onDeep, + hasDeep, + getDeepQueries, // only for testing +} diff --git a/postgres/lib/PostgresService.js b/postgres/lib/PostgresService.js index 681ca9646..4ff00ecdf 100644 --- a/postgres/lib/PostgresService.js +++ b/postgres/lib/PostgresService.js @@ -459,6 +459,33 @@ GROUP BY k return ref[0] === '?' ? `$${this._paramCount++}` : `:${ref}` } + list(list) { + const first = list.list[0] + // If the list only contains of lists it is replaced with a json function and a placeholder + if (this.values && first.list && !first.list.find(v => v.val == null)) { + this.values.push(JSON.stringify(list.list)) + const extraction = first.list.map((v, i) => this.class.InputConverters.Decimal(`(value->${i})->>'val'`)) + return `(SELECT ${extraction} FROM json_array_elements($${this.values.length}::json))` + } + // normal SQL behavior + return super.list(list) + } + + json(arg) { + const { props, elements, json } = arg + const { _convertInput } = this.class + let val = typeof json === 'string' ? json : (arg.json = JSON.stringify(json)) + if (val[val.length - 1] === ',') val = arg.json = val.slice(0, -1) + ']' + if (val[val.length - 1] === '[') val = arg.json = val + ']' + this.values.push(val) + const extraction = props.map(p => { + const element = elements?.[p] + const converter = a => element[_convertInput]?.(a, element) || this.class.InputConverters[element.type.slice(4)]?.(a, element) || a + return converter(this.managed_extract(p, element, converter).extract) + }) + return `(SELECT ${extraction} FROM json_array_elements($${this.values.length}::json))` + } + val(val) { const ret = super.val(val) return ret === '?' ? `$${this.values.length}` : ret diff --git a/postgres/pg-stack.yml b/postgres/pg-stack.yml index c88c308b9..7d15a4164 100644 --- a/postgres/pg-stack.yml +++ b/postgres/pg-stack.yml @@ -9,7 +9,7 @@ services: POSTGRES_PASSWORD: postgres ports: - '5432:5432' - command: ['postgres', '-c', 'log_statement=all'] + command: ['postgres', '-c', 'log_statement=none'] ### use at will at dev time - save mem on ci time # adminer: # image: adminer diff --git a/sqlite/lib/SQLiteService.js b/sqlite/lib/SQLiteService.js index fdfe46e65..913bc1eab 100644 --- a/sqlite/lib/SQLiteService.js +++ b/sqlite/lib/SQLiteService.js @@ -172,6 +172,18 @@ class SQLiteService extends SQLService { } } + list(list) { + const first = list.list[0] + // If the list only contains of lists it is replaced with a json function and a placeholder + if (this.values && first.list && !first.list.find(v => v.val == null)) { + this.values.push(JSON.stringify(list.list)) + const extraction = first.list.map((v, i) => `value->>'$.list[${i}].val'`) + return `(SELECT ${extraction} FROM json_each(?))` + } + // normal SQL behavior + return super.list(list) + } + val(v) { if (typeof v.val === 'boolean') v.val = v.val ? 1 : 0 else if (Buffer.isBuffer(v.val)) v.val = v.val.toString('base64') diff --git a/test/scenarios/bookshop/delete.test.js b/test/scenarios/bookshop/delete.test.js index dc0f67238..2aff3fc50 100644 --- a/test/scenarios/bookshop/delete.test.js +++ b/test/scenarios/bookshop/delete.test.js @@ -16,7 +16,7 @@ describe('Bookshop - Delete', () => { { ID: 998 }, { ID: 1, - toB: { + toB: [{ ID: 12, toA: [{ ID: 121 }], toC: [ @@ -35,11 +35,11 @@ describe('Bookshop - Delete', () => { ], }, ], - }, - toC: { + }], + toC: [{ ID: 13, toA: [{ ID: 13 }], - }, + }], }, ]) const del = DELETE.from('sap.capire.bookshop.A').where('ID = 1') diff --git a/test/scenarios/maps/clob.cds b/test/scenarios/maps/clob.cds new file mode 100644 index 000000000..408be8c86 --- /dev/null +++ b/test/scenarios/maps/clob.cds @@ -0,0 +1,5 @@ +using {cuid} from '@sap/cds/common'; + +entity Map : cuid { + map : cds.Map; +} diff --git a/test/scenarios/maps/clob.test.js b/test/scenarios/maps/clob.test.js new file mode 100644 index 000000000..7bbc4485e --- /dev/null +++ b/test/scenarios/maps/clob.test.js @@ -0,0 +1,51 @@ +const cds = require('../../cds.js') +const { Readable } = require('stream') + +const { gen, rows } = require('./data.js') +const { run } = require('./perf.js') + +describe('Map - CLOB', () => { + const { expect } = cds.test(__dirname, __dirname + '/clob.cds') + + test('perf', async () => { + const { Map } = cds.entities + + console.log('Starting Insert...') + const s = performance.now() + await INSERT(Readable.from(gen(), { objectMode: false })).into(Map) + const dur = performance.now() - s + console.log('Finished Insert:', dur, '(', (rows / dur), 'rows/ms)') + + const [{ count: rowCount }] = await cds.ql`SELECT count() from ${Map}` + expect(rowCount).eq(rows) + console.log('Validated Insert.') + + /* HANA +Starting Insert... +Finished Insert: 10261.39113 ( 3.19332920701172 rows/ms) +Validated Insert. +$top=30 avg: 3 ms cold: 30 ms +ID='1' avg: 3 ms cold: 34 ms + */ + + /* postgres +Starting Insert... +Finished Insert: 13024.595653 ( 2.51585545325182 rows/ms) +Validated Insert. +$top=30 avg: 6 ms cold: 9 ms +ID='1' avg: 0 ms cold: 4 ms + */ + + /* sqlite +Starting Insert... +Finished Insert: 2072.096841 ( 15.813932704123069 rows/ms) +Validated Insert. +$top=30 avg: 1 ms cold: 2 ms +ID='1' avg: 0 ms cold: 1 ms + */ + + await run('$top=30', cds.ql`SELECT ID, map FROM ${Map} LIMIT ${30}`) + await run(`ID='1'`, cds.ql`SELECT ID, map FROM ${Map} WHERE ID=${'1'} LIMIT ${1}`) + }) + +}) \ No newline at end of file diff --git a/test/scenarios/maps/comp.cds b/test/scenarios/maps/comp.cds new file mode 100644 index 000000000..4da656bc6 --- /dev/null +++ b/test/scenarios/maps/comp.cds @@ -0,0 +1,8 @@ +using {cuid} from '@sap/cds/common'; + +entity Map : cuid { + map : Composition of many { + key ![key] : String(255); + value : String(5000); + } +} diff --git a/test/scenarios/maps/comp.test.js b/test/scenarios/maps/comp.test.js new file mode 100644 index 000000000..2849c10dd --- /dev/null +++ b/test/scenarios/maps/comp.test.js @@ -0,0 +1,38 @@ +const cds = require('../../cds.js') +const { Readable } = require('stream') + +const { gen, rows, maps } = require('./data.js') +const { run } = require('./perf.js') + +describe('Map - Composition', () => { + const { expect } = cds.test(__dirname, __dirname + '/comp.cds') + + test('perf', async () => { + const { Map } = cds.entities + let s, dur + + console.log('Starting Insert...') + s = performance.now() + await INSERT(Readable.from(gen(), { objectMode: false })).into(Map) + dur = performance.now() - s + console.log('Finished Insert:', dur, '(', (rows / dur), 'rows/ms)') + + const [{ count: rowCount }] = await cds.ql`SELECT count() FROM ${Map}` + const [{ count: mapCount }] = await cds.ql`SELECT count() FROM ${Map}.map` + expect(rowCount).eq(rows) + expect(mapCount).eq(maps * rows) + console.log('Validated Insert.') + + /* +Starting Insert... +Finished Insert: 28935.987634 ( 1.1324306747179196 rows/ms) +Validated Insert. +$top=30 avg: 18 ms cold: 165 ms +ID='1' avg: 4 ms cold: 83 ms + */ + + await run('$top=30', cds.ql`SELECT ID, map {*} FROM ${Map} LIMIT ${30}`) + await run(`ID='1'`, cds.ql`SELECT ID, map {*} FROM ${Map} WHERE ID=${'1'} LIMIT ${1}`) + }) + +}) \ No newline at end of file diff --git a/test/scenarios/maps/data.js b/test/scenarios/maps/data.js new file mode 100644 index 000000000..80ada854c --- /dev/null +++ b/test/scenarios/maps/data.js @@ -0,0 +1,21 @@ +const rows = 1 << 15 +const maps = 1e2 +const gen = function* () { + yield '[' + let sep = '' + for (let ID = 0; ID < rows; ID++) { + let row = `${sep}{"ID":${ID},"map":[` + let rowSep = '' + for (let key = 0; key < maps; key++) { + if (rowSep) row += rowSep + else rowSep = ',' + row += `{"up__ID":"${ID}","key":"${key}","value":"a value for \\"${key}\\""}` + } + row += ']}' + yield row + sep = ',' + } + yield ']' +} + +module.exports = { rows, maps, gen } \ No newline at end of file diff --git a/test/scenarios/maps/docs.cds b/test/scenarios/maps/docs.cds new file mode 100644 index 000000000..4da656bc6 --- /dev/null +++ b/test/scenarios/maps/docs.cds @@ -0,0 +1,8 @@ +using {cuid} from '@sap/cds/common'; + +entity Map : cuid { + map : Composition of many { + key ![key] : String(255); + value : String(5000); + } +} diff --git a/test/scenarios/maps/docs.test.js b/test/scenarios/maps/docs.test.js new file mode 100644 index 000000000..f56eb3b39 --- /dev/null +++ b/test/scenarios/maps/docs.test.js @@ -0,0 +1,287 @@ +const cds = require('../../cds.js') +const { Readable } = require('stream') + +const { gen, rows, maps } = require('./data.js') +const { run } = require('./perf.js') + +describe('Map - Composition', () => { + const { expect } = cds.test(__dirname, __dirname + '/comp.cds') + + test('perf', async () => { + const { Map } = cds.entities + let s, dur + + await cds.run(`DROP TABLE Map_map`) + await cds.run(`CREATE COLLECTION Map_map`) + + console.log('Starting Insert...') + s = performance.now() + await cds.run(insertSQL, [Readable.from(gen(), { objectMode: false })]) + dur = performance.now() - s + console.log('Finished Insert:', dur, '(', (rows / dur), 'rows/ms)') + + const [{ count: rowCount }] = await cds.ql`SELECT count() FROM ${Map}` + expect(rowCount).eq(rows) + console.log('Validated Insert.') + + /* +Starting Insert... +Finished Insert: 11326.809322000001 ( 2.89295944413533 rows/ms) +Validated Insert. +$top=30 (v1) avg: 2257 ms cold: 2335 ms +$top=30 (v2) avg: 3877 ms cold: 3875 ms +ID='1' (v1) avg: 2236 ms cold: 2281 ms +ID='1' (v2) avg: 2249 ms cold: 2295 ms +$top=30 (v1,hint) avg: 9 ms cold: 35 ms +$top=30 (v2,hint) avg: 12 ms cold: 32 ms +ID='1' (v1,hint) avg: 4 ms cold: 31 ms +ID='1' (v2,hint) avg: 2249 ms cold: 2241 ms + */ + + await run(`$top=30 (v1)`, top30SQL1) + await run(`$top=30 (v2)`, top30SQL2) + + await run(`ID='1' (v1)`, oneSQL1) + await run(`ID='1' (v2)`, oneSQL2) + + const hint = `WITH HINT(SEMI_JOIN_REDUCTION)` + await run(`$top=30 (v1,hint)`, top30SQL1 + hint) + await run(`$top=30 (v2,hint)`, top30SQL2 + hint) + + await run(`ID='1' (v1,hint)`, oneSQL1 + hint) + await run(`ID='1' (v2,hint)`, oneSQL2 + hint) + }) + +}) + +const insertSQL = `DO (IN JSON NCLOB => ?) BEGIN + Map = SELECT * FROM JSON_TABLE(:JSON, '$' COLUMNS(ID NVARCHAR(36) PATH '$.ID',"MAP" NVARCHAR(2147483647) FORMAT JSON PATH '$.map')); + + INSERT INTO "MAP" (ID) + SELECT NEW.ID FROM :Map AS NEW; + INSERT INTO Map_map SELECT '{"up__ID":"' || JSON_VALUE("MAP", '$[0].up__ID') || '","data":' || "MAP" || '}' FROM :Map AS NEW; +END;` + +const top30SQL1 = ` +WITH Map_map as ( + SELECT "up__ID" as up__ID, "data" as data FROM Map_map +), +"MAP" as ( + SELECT + *, + '$[' || lpad("$$RN$$", 6, '0') as _path_ + FROM + ( + SELECT + *, + ROW_NUMBER() OVER () as "$$RN$$" + FROM + ( + SELECT + "MAP".ID + FROM + "MAP" as "MAP" + LIMIT + 30 + ) as "MAP" + ) as "MAP" +), +"Map_map" as ( + SELECT + *, + _parent_path_ || '].map[' || lpad("$$RN$$", 6, '0') as _path_ + FROM + ( + SELECT + *, + ROW_NUMBER() OVER (PARTITION BY _parent_path_) as "$$RN$$" + FROM + ( + SELECT + map2.data, + "MAP"._path_ as _parent_path_ + FROM + "MAP" as "MAP" + inner JOIN Map_map as map2 on "MAP".ID = up__ID + ) as map2 + ) as map2 +) +SELECT + * +FROM + ( + SELECT + _path_ as "_path_", + '{}' as "_blobs_", + '{"map":null}' as "_expands_", + ( + SELECT + ID as "ID" + FROM + DUMMY FOR JSON ( + 'format' = 'no', + 'omitnull' = 'no', + 'arraywrap' = 'no' + ) RETURNS NVARCHAR(2147483647) + ) as "_json_" + FROM + "MAP" + ) +UNION +ALL ( + SELECT + _path_ as "_path_", + '{}' as "_blobs_", + '{}' as "_expands_", + data as "_json_" + FROM + "Map_map" +) +ORDER BY + "_path_" ASC +` + +const top30SQL2 = ` +WITH Map_map as ( + SELECT "up__ID" as up__ID, "data" as data FROM Map_map +) +SELECT + '$[0' as "_path_", + '{}' as "_blobs_", + '{}' as "_expands_", + ( + SELECT + ID as "ID", + map as "map" + FROM + DUMMY FOR JSON ( + 'format' = 'no', + 'omitnull' = 'no', + 'arraywrap' = 'no' + ) RETURNS NVARCHAR(2147483647) + ) as "_json_" +FROM ( + SELECT + "MAP".ID, + (SELECT data FROM Map_map WHERE up__ID = "MAP".ID) as map + FROM + "MAP" as "MAP" + LIMIT + 30 +) +` + +const oneSQL1 = ` +WITH Map_map as ( + SELECT "up__ID" as up__ID, "data" as data FROM Map_map +), +"MAP" as ( + SELECT + *, + '$[' || lpad("$$RN$$", 6, '0') as _path_ + FROM + ( + SELECT + *, + ROW_NUMBER() OVER () as "$$RN$$" + FROM + ( + SELECT + "MAP".ID + FROM + "MAP" as "MAP" + WHERE + ID = '1' + ) as "MAP" + ) as "MAP" +), +"Map_map" as ( + SELECT + *, + _parent_path_ || '].map[' || lpad("$$RN$$", 6, '0') as _path_ + FROM + ( + SELECT + *, + ROW_NUMBER() OVER (PARTITION BY _parent_path_) as "$$RN$$" + FROM + ( + SELECT + map2.data, + "MAP"._path_ as _parent_path_ + FROM + "MAP" as "MAP" + inner JOIN Map_map as map2 on "MAP".ID = up__ID + ) as map2 + ) as map2 +) +SELECT + * +FROM + ( + SELECT + _path_ as "_path_", + '{}' as "_blobs_", + '{"map":null}' as "_expands_", + ( + SELECT + ID as "ID" + FROM + DUMMY FOR JSON ( + 'format' = 'no', + 'omitnull' = 'no', + 'arraywrap' = 'no' + ) RETURNS NVARCHAR(2147483647) + ) as "_json_" + FROM + "MAP" + ) +UNION +ALL ( + SELECT + _path_ as "_path_", + '{}' as "_blobs_", + '{}' as "_expands_", + data as "_json_" + FROM + "Map_map" +) +ORDER BY + "_path_" ASC +` + +const oneSQL2 = ` +WITH Map_map as ( + SELECT "up__ID" as up__ID, "data" as data FROM Map_map +) +SELECT + '$[0' as "_path_", + '{}' as "_blobs_", + '{}' as "_expands_", + ( + SELECT + ID as "ID", + map as "map" + FROM + DUMMY FOR JSON ( + 'format' = 'no', + 'omitnull' = 'no', + 'arraywrap' = 'no' + ) RETURNS NVARCHAR(2147483647) + ) as "_json_" +FROM ( + SELECT + "MAP".ID, + Map_map.data as map + FROM ( + SELECT + "MAP".ID + FROM + "MAP" as "MAP" + WHERE + ID = '1' + ) as "MAP" + LEFT JOIN Map_map as Map_map + ON "MAP".ID = Map_map.up__ID +) +` \ No newline at end of file diff --git a/test/scenarios/maps/perf.js b/test/scenarios/maps/perf.js new file mode 100644 index 000000000..5f2455bb4 --- /dev/null +++ b/test/scenarios/maps/perf.js @@ -0,0 +1,19 @@ +module.exports.run = async function (name, query) { + return cds.tx(async tx => { + const parseRows = typeof query === 'string' + + let s = performance.now() + const res = await tx.run(query) + if (parseRows) tx.parseRows(res) + const cold = performance.now() - s + s = performance.now() + + const runs = 100 + for (let i = 0; i < runs; i++) { + const res = await tx.run(query) + if (parseRows) tx.parseRows(res) + } + const dur = performance.now() - s + console.log(name.padEnd(20, ' '), 'avg:', (dur / runs) >>> 0, 'ms', 'cold:', cold >>> 0, 'ms') + }) +} \ No newline at end of file