From 07392676affc3d5d5ee35f43f99f0db1a98efbca Mon Sep 17 00:00:00 2001 From: I543501 Date: Tue, 27 May 2025 11:46:46 +0200 Subject: [PATCH 01/28] Update delete.test.js --- test/scenarios/bookshop/delete.test.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/scenarios/bookshop/delete.test.js b/test/scenarios/bookshop/delete.test.js index 36d0a319e..62a79e46a 100644 --- a/test/scenarios/bookshop/delete.test.js +++ b/test/scenarios/bookshop/delete.test.js @@ -5,8 +5,12 @@ describe('Bookshop - Delete', () => { const { expect } = cds.test(bookshop) test('Deep delete works for queries with multiple where clauses', async () => { - const del = DELETE.from('sap.capire.bookshop.Genres[ID = 4711]').where('ID = 4712') + const del3 = SELECT.from('sap.capire.bookshop.Genres') + const affectedRows3 = await cds.db.run(del3) + const del = DELETE.from('sap.capire.bookshop.Genres[ID = 10]') const affectedRows = await cds.db.run(del) + const del2 = SELECT.from('sap.capire.bookshop.Genres[ID = 10]') + const affectedRows2 = await cds.db.run(del2) expect(affectedRows).to.be.eq(0) }) From bee671fa5c83f51fb08143570626cea0e7e62760 Mon Sep 17 00:00:00 2001 From: "Dr. David A. Kunz" Date: Tue, 27 May 2025 12:27:07 +0200 Subject: [PATCH 02/28] experimental-deep-delete-hierarchies --- db-service/lib/SQLService.js | 53 ++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/db-service/lib/SQLService.js b/db-service/lib/SQLService.js index 48e5da8ba..24203edaa 100644 --- a/db-service/lib/SQLService.js +++ b/db-service/lib/SQLService.js @@ -253,8 +253,15 @@ class SQLService extends DatabaseService { }) return this.onDELETE({ query, target: transitions.target }) } + + const table = getDBTable(req.target) const { compositions } = table + + // Check if we are in the hierarchy case + let isHierarchy = req.target.elements.LimitedDescendantCount + const recursiveBacklinks = [] + if (compositions) { // Transform CQL`DELETE from Foo[p1] WHERE p2` into CQL`DELETE from Foo[p1 and p2]` let { from, where } = req.query.DELETE @@ -271,6 +278,20 @@ class SQLService extends DatabaseService { Object.values(compositions).map(c => { if (c._target['@cds.persistence.skip'] === true) return if (c._target === req.target) { + if (isHierarchy) { + // special treatment for recursive compositions in hierarchies + function _getBacklinkName(on) { + const i = on.findIndex(e => e.ref && e.ref[0] === '$self') + if (i === -1) return + let ref + if (on[i + 1] && on[i + 1] === '=') ref = on[i + 2].ref + if (on[i - 1] && on[i - 1] === '=') ref = on[i - 2].ref + return ref && ref[ref.length - 1] + } + const backlinkName = _getBacklinkName(c.on) + recursiveBacklinks.push(backlinkName) + return + } // the Genre.children case if (++depth > (c['@depth'] || 3)) return } else if (visited.includes(c._target.name)) @@ -285,6 +306,38 @@ class SQLService extends DatabaseService { return this.onDELETE({ query, depth, visited: [...visited], target: c._target }) }), ) + if (recursiveBacklinks.length) { + let key + // For hierarchies, only a single key is supported + for (const _key in req.target.keys) { + if (key === 'IsActiveEntity') continue + key = _key + break + } + // TODO: If key value is already part of query, we could skip this select + const data = await SELECT.from(req.query.DELETE.from).columns(key).where(req.query.DELETE.where) + const recursiveQuery = SELECT.from(req.query.DELTE.from).columns(key) + const recursiveQueries = [] + for (const backlink of recursiveBacklinks) { + const _recursiveQuery = recursiveQuery.clone() + const where = data.flatMap((d,i) => { + const res = [{ func: 'DistanceTo', args: [{ val: d[key]}, {val: null }] }] + if (i > 0) res.unshift('or') + return res + }) + // [[],[],[]] + _recursiveQuery.SELECT.recurse = { + ref: [backlink], + where + } + recursiveQueries.push(recursiveQuery) + // TODO: We could also perform a DELETE.from(SELECT) based on a recursive SELEECT, to be investiagated + const recursiveResults = await Promise.all(recursiveQueries) // [[], [], []] + // TODO: Also handle transitions etc. + // TODO: call this.onDELETE instead + await DELETE.from(req.target).where({ ref: [key] }, 'in', { list: recursiveResults.flatMap(r => r.map(r1 => ({ val: r1[key] }))) }) + } + } } return this.onSIMPLE(req) } From 840af996e1fd33766fe18cca6f9b72236612ac85 Mon Sep 17 00:00:00 2001 From: I543501 Date: Wed, 28 May 2025 11:23:58 +0200 Subject: [PATCH 03/28] impl --- db-service/lib/SQLService.js | 39 +++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/db-service/lib/SQLService.js b/db-service/lib/SQLService.js index 24203edaa..61e1ea55a 100644 --- a/db-service/lib/SQLService.js +++ b/db-service/lib/SQLService.js @@ -315,27 +315,30 @@ class SQLService extends DatabaseService { break } // TODO: If key value is already part of query, we could skip this select - const data = await SELECT.from(req.query.DELETE.from).columns(key).where(req.query.DELETE.where) - const recursiveQuery = SELECT.from(req.query.DELTE.from).columns(key) + const data = await SELECT.from(from).columns(key).where(where) const recursiveQueries = [] - for (const backlink of recursiveBacklinks) { - const _recursiveQuery = recursiveQuery.clone() - const where = data.flatMap((d,i) => { - const res = [{ func: 'DistanceTo', args: [{ val: d[key]}, {val: null }] }] - if (i > 0) res.unshift('or') - return res - }) - // [[],[],[]] - _recursiveQuery.SELECT.recurse = { - ref: [backlink], - where + if (data.length) { + for (const backlink of recursiveBacklinks) { + const _recursiveQuery = SELECT.from(table).columns(key) + const where = data.flatMap((d, i) => { + const res = [{ func: 'DistanceTo', args: [{ val: d[key] }, { val: null }] }] + if (i > 0) res.unshift('or') + return res + }) + _recursiveQuery.SELECT.recurse = { + ref: [backlink], + where, + } + recursiveQueries.push(_recursiveQuery) } - recursiveQueries.push(recursiveQuery) - // TODO: We could also perform a DELETE.from(SELECT) based on a recursive SELEECT, to be investiagated + // TODO: We could also perform a DELETE.from(SELECT) based on a recursive SELECT, to be investigated const recursiveResults = await Promise.all(recursiveQueries) // [[], [], []] - // TODO: Also handle transitions etc. - // TODO: call this.onDELETE instead - await DELETE.from(req.target).where({ ref: [key] }, 'in', { list: recursiveResults.flatMap(r => r.map(r1 => ({ val: r1[key] }))) }) + if (recursiveResults[0]?.length) { + const query = DELETE.from(req.target).where({ ref: [key] }, 'in', { + list: recursiveResults.flatMap(r => r.map(r1 => ({ val: r1[key] }))), + }) + await this.onSIMPLE({ query, target: table }) + } } } } From 420ec75653f84a4d759965d514e6f4b264246bea Mon Sep 17 00:00:00 2001 From: I543501 Date: Wed, 28 May 2025 11:29:45 +0200 Subject: [PATCH 04/28] . --- db-service/lib/SQLService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db-service/lib/SQLService.js b/db-service/lib/SQLService.js index 61e1ea55a..a382dd526 100644 --- a/db-service/lib/SQLService.js +++ b/db-service/lib/SQLService.js @@ -259,7 +259,7 @@ class SQLService extends DatabaseService { const { compositions } = table // Check if we are in the hierarchy case - let isHierarchy = req.target.elements.LimitedDescendantCount + let isHierarchy = req.target.elements?.LimitedDescendantCount const recursiveBacklinks = [] if (compositions) { From 51bbb4889c3888616bd426c5b27a568e841508d4 Mon Sep 17 00:00:00 2001 From: I543501 Date: Wed, 28 May 2025 15:11:01 +0200 Subject: [PATCH 05/28] . --- test/scenarios/bookshop/delete.test.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test/scenarios/bookshop/delete.test.js b/test/scenarios/bookshop/delete.test.js index 62a79e46a..36d0a319e 100644 --- a/test/scenarios/bookshop/delete.test.js +++ b/test/scenarios/bookshop/delete.test.js @@ -5,12 +5,8 @@ describe('Bookshop - Delete', () => { const { expect } = cds.test(bookshop) test('Deep delete works for queries with multiple where clauses', async () => { - const del3 = SELECT.from('sap.capire.bookshop.Genres') - const affectedRows3 = await cds.db.run(del3) - const del = DELETE.from('sap.capire.bookshop.Genres[ID = 10]') + const del = DELETE.from('sap.capire.bookshop.Genres[ID = 4711]').where('ID = 4712') const affectedRows = await cds.db.run(del) - const del2 = SELECT.from('sap.capire.bookshop.Genres[ID = 10]') - const affectedRows2 = await cds.db.run(del2) expect(affectedRows).to.be.eq(0) }) From 2f7ff6b971a7ad7783c1d3ff76dc4c84ddd959c9 Mon Sep 17 00:00:00 2001 From: I543501 <56645452+larsplessing@users.noreply.github.com> Date: Wed, 28 May 2025 16:14:44 +0200 Subject: [PATCH 06/28] . --- db-service/lib/SQLService.js | 1 - 1 file changed, 1 deletion(-) diff --git a/db-service/lib/SQLService.js b/db-service/lib/SQLService.js index a382dd526..0e4b80217 100644 --- a/db-service/lib/SQLService.js +++ b/db-service/lib/SQLService.js @@ -254,7 +254,6 @@ class SQLService extends DatabaseService { return this.onDELETE({ query, target: transitions.target }) } - const table = getDBTable(req.target) const { compositions } = table From 47319047f00168804b171f0fd6ac00c52a2d3db2 Mon Sep 17 00:00:00 2001 From: "Dr. David A. Kunz" Date: Thu, 3 Jul 2025 17:48:19 +0200 Subject: [PATCH 07/28] fix(hierarchy): only modify where if existent --- db-service/lib/cqn2sql.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db-service/lib/cqn2sql.js b/db-service/lib/cqn2sql.js index 95bef554c..45738e4ba 100644 --- a/db-service/lib/cqn2sql.js +++ b/db-service/lib/cqn2sql.js @@ -285,7 +285,7 @@ class CQN2SQLRenderer { const keys = [] const _target = q._target - if (_target) { + if (_target && where?.length) { for (const _key in _target.keys) { const k = _target.keys[_key] if (!k.virtual && !k.isAssociation && !k.value) { From af498d291ce3c292097706269fe26a2fbb6b824d Mon Sep 17 00:00:00 2001 From: "Dr. David A. Kunz" Date: Thu, 3 Jul 2025 17:49:28 +0200 Subject: [PATCH 08/28] . --- db-service/lib/cqn2sql.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/db-service/lib/cqn2sql.js b/db-service/lib/cqn2sql.js index 45738e4ba..5929ee110 100644 --- a/db-service/lib/cqn2sql.js +++ b/db-service/lib/cqn2sql.js @@ -285,7 +285,7 @@ class CQN2SQLRenderer { const keys = [] const _target = q._target - if (_target && where?.length) { + if (_target) { for (const _key in _target.keys) { const k = _target.keys[_key] if (!k.virtual && !k.isAssociation && !k.value) { @@ -294,11 +294,13 @@ class CQN2SQLRenderer { } // `where` needs to be wrapped to also support `where == ['exists', { SELECT }]` which is not allowed in `START WHERE` - const clone = q.clone() - clone.columns(keys) - clone.SELECT.recurse = undefined - clone.SELECT.expand = undefined // omits JSON - where = [{ list: keys }, 'in', clone] + if (where) { + const clone = q.clone() + clone.columns(keys) + clone.SELECT.recurse = undefined + clone.SELECT.expand = undefined // omits JSON + where = [{ list: keys }, 'in', clone] + } } const requiredComputedColumns = { PARENT_ID: true, NODE_ID: true } From 25d11d2dea54f8be880a25d802c7312cb02a9cc0 Mon Sep 17 00:00:00 2001 From: "Dr. David A. Kunz" Date: Thu, 3 Jul 2025 17:50:09 +0200 Subject: [PATCH 09/28] . --- db-service/lib/cqn2sql.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db-service/lib/cqn2sql.js b/db-service/lib/cqn2sql.js index 5929ee110..5d8cd1b99 100644 --- a/db-service/lib/cqn2sql.js +++ b/db-service/lib/cqn2sql.js @@ -282,10 +282,10 @@ class CQN2SQLRenderer { SELECT_recurse(q) { let { from, columns, where, orderBy, recurse, _internal } = q.SELECT - const keys = [] const _target = q._target if (_target) { + const keys = [] for (const _key in _target.keys) { const k = _target.keys[_key] if (!k.virtual && !k.isAssociation && !k.value) { From 96cdef2abda8b848b7cc5fa917e6acd72d946def Mon Sep 17 00:00:00 2001 From: "Dr. David A. Kunz" Date: Thu, 3 Jul 2025 17:57:00 +0200 Subject: [PATCH 10/28] . --- db-service/lib/cqn2sql.js | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/db-service/lib/cqn2sql.js b/db-service/lib/cqn2sql.js index 5d8cd1b99..5c32b6794 100644 --- a/db-service/lib/cqn2sql.js +++ b/db-service/lib/cqn2sql.js @@ -284,7 +284,7 @@ class CQN2SQLRenderer { const _target = q._target - if (_target) { + if (_target && where) { const keys = [] for (const _key in _target.keys) { const k = _target.keys[_key] @@ -294,13 +294,11 @@ class CQN2SQLRenderer { } // `where` needs to be wrapped to also support `where == ['exists', { SELECT }]` which is not allowed in `START WHERE` - if (where) { - const clone = q.clone() - clone.columns(keys) - clone.SELECT.recurse = undefined - clone.SELECT.expand = undefined // omits JSON - where = [{ list: keys }, 'in', clone] - } + const clone = q.clone() + clone.columns(keys) + clone.SELECT.recurse = undefined + clone.SELECT.expand = undefined // omits JSON + where = [{ list: keys }, 'in', clone] } const requiredComputedColumns = { PARENT_ID: true, NODE_ID: true } From d2e224d29d8acc28711e481c1677e6540f143222 Mon Sep 17 00:00:00 2001 From: I543501 <56645452+larsplessing@users.noreply.github.com> Date: Fri, 4 Jul 2025 09:29:24 +0200 Subject: [PATCH 11/28] . --- db-service/lib/SQLService.js | 37 ++++++++++++------------------------ db-service/lib/cqn2sql.js | 5 ++++- 2 files changed, 16 insertions(+), 26 deletions(-) diff --git a/db-service/lib/SQLService.js b/db-service/lib/SQLService.js index 0e4b80217..9ced6f6ea 100644 --- a/db-service/lib/SQLService.js +++ b/db-service/lib/SQLService.js @@ -309,36 +309,23 @@ class SQLService extends DatabaseService { let key // For hierarchies, only a single key is supported for (const _key in req.target.keys) { - if (key === 'IsActiveEntity') continue + if (req.target.keys[_key].virtual) continue key = _key break } - // TODO: If key value is already part of query, we could skip this select - const data = await SELECT.from(from).columns(key).where(where) - const recursiveQueries = [] - if (data.length) { - for (const backlink of recursiveBacklinks) { - const _recursiveQuery = SELECT.from(table).columns(key) - const where = data.flatMap((d, i) => { - const res = [{ func: 'DistanceTo', args: [{ val: d[key] }, { val: null }] }] - if (i > 0) res.unshift('or') - return res - }) - _recursiveQuery.SELECT.recurse = { - ref: [backlink], - where, - } - recursiveQueries.push(_recursiveQuery) - } - // TODO: We could also perform a DELETE.from(SELECT) based on a recursive SELECT, to be investigated - const recursiveResults = await Promise.all(recursiveQueries) // [[], [], []] - if (recursiveResults[0]?.length) { - const query = DELETE.from(req.target).where({ ref: [key] }, 'in', { - list: recursiveResults.flatMap(r => r.map(r1 => ({ val: r1[key] }))), - }) - await this.onSIMPLE({ query, target: table }) + const _where = [] + for (const backlink of recursiveBacklinks) { + const _recursiveQuery = SELECT.from(table).columns(key) + _recursiveQuery.SELECT.recurse = { + ref: [backlink], + where: from.ref[0].where, } + _where.push({ ref: [key] }, 'in', _recursiveQuery, 'or') } + + _where.pop() + const query = DELETE.from(table).where(_where) + await this.onSIMPLE({ query, target: table }) } } return this.onSIMPLE(req) diff --git a/db-service/lib/cqn2sql.js b/db-service/lib/cqn2sql.js index 95bef554c..b2673a212 100644 --- a/db-service/lib/cqn2sql.js +++ b/db-service/lib/cqn2sql.js @@ -418,7 +418,10 @@ class CQN2SQLRenderer { ? [{ val: 1 }] : ['FROM', { val: 1 }] )] - where = [{ ref: ['NODE_ID'] }, 'IN', isOne ? expandedByOne : expandedByNr] + if (expandedFilter.length && !expandedByOne.list.length && !expandedByNr.list.length) { + if (where?.length) where.push('and', ...expandedFilter) + else where = expandedFilter + } expandedFilter = [] } From da41da3d8285ea2e64622910a820f9ca97b16eae Mon Sep 17 00:00:00 2001 From: I543501 <56645452+larsplessing@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:48:21 +0200 Subject: [PATCH 12/28] . --- db-service/lib/SQLService.js | 32 +++++++++++++++----------------- db-service/lib/cqn2sql.js | 4 +++- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/db-service/lib/SQLService.js b/db-service/lib/SQLService.js index 9ced6f6ea..d2925ddad 100644 --- a/db-service/lib/SQLService.js +++ b/db-service/lib/SQLService.js @@ -257,8 +257,6 @@ class SQLService extends DatabaseService { const table = getDBTable(req.target) const { compositions } = table - // Check if we are in the hierarchy case - let isHierarchy = req.target.elements?.LimitedDescendantCount const recursiveBacklinks = [] if (compositions) { @@ -277,20 +275,17 @@ class SQLService extends DatabaseService { Object.values(compositions).map(c => { if (c._target['@cds.persistence.skip'] === true) return if (c._target === req.target) { - if (isHierarchy) { - // special treatment for recursive compositions in hierarchies - function _getBacklinkName(on) { - const i = on.findIndex(e => e.ref && e.ref[0] === '$self') - if (i === -1) return - let ref - if (on[i + 1] && on[i + 1] === '=') ref = on[i + 2].ref - if (on[i - 1] && on[i - 1] === '=') ref = on[i - 2].ref - return ref && ref[ref.length - 1] - } - const backlinkName = _getBacklinkName(c.on) - recursiveBacklinks.push(backlinkName) - return + // special treatment for recursive compositions in hierarchies + function _getBacklinkName(on) { + const i = on.findIndex(e => e.ref && e.ref[0] === '$self') + if (i === -1) return + let ref + if (on[i + 1] && on[i + 1] === '=') ref = on[i + 2].ref + if (on[i - 1] && on[i - 1] === '=') ref = on[i - 2].ref + return ref && ref[ref.length - 1] } + const backlinkName = _getBacklinkName(c.on) + recursiveBacklinks.push(backlinkName) // the Genre.children case if (++depth > (c['@depth'] || 3)) return } else if (visited.includes(c._target.name)) @@ -305,7 +300,8 @@ class SQLService extends DatabaseService { return this.onDELETE({ query, depth, visited: [...visited], target: c._target }) }), ) - if (recursiveBacklinks.length) { + // only perform one recursive delete for root request + if (recursiveBacklinks.length && !req.depth) { let key // For hierarchies, only a single key is supported for (const _key in req.target.keys) { @@ -328,7 +324,9 @@ class SQLService extends DatabaseService { await this.onSIMPLE({ query, target: table }) } } - return this.onSIMPLE(req) + // skip recursive compositions because they are handled above + if (compositions?.[req.query.DELETE.from.ref[req.query.DELETE.from.ref.length-1]]?._target === req.target) return + else return this.onSIMPLE(req) } } diff --git a/db-service/lib/cqn2sql.js b/db-service/lib/cqn2sql.js index eff208f55..5a9f41bea 100644 --- a/db-service/lib/cqn2sql.js +++ b/db-service/lib/cqn2sql.js @@ -417,10 +417,12 @@ class CQN2SQLRenderer { isOne ? [{ val: 1 }] : ['FROM', { val: 1 }] - )] + )] if (expandedFilter.length && !expandedByOne.list.length && !expandedByNr.list.length) { if (where?.length) where.push('and', ...expandedFilter) else where = expandedFilter + } else { + where = [{ ref: ['NODE_ID'] }, 'IN', isOne ? expandedByOne : expandedByNr] } expandedFilter = [] } From a72cc3047b9cbbda0b67e0b5ca1adc4b9042c4ad Mon Sep 17 00:00:00 2001 From: I543501 <56645452+larsplessing@users.noreply.github.com> Date: Thu, 10 Jul 2025 10:49:57 +0200 Subject: [PATCH 13/28] . --- test/compliance/DELETE.test.js | 156 ++++++++++++++++++ .../compliance/resources/db/complex/index.cds | 2 + 2 files changed, 158 insertions(+) diff --git a/test/compliance/DELETE.test.js b/test/compliance/DELETE.test.js index f5ce0cd46..7b19ce868 100644 --- a/test/compliance/DELETE.test.js +++ b/test/compliance/DELETE.test.js @@ -4,6 +4,143 @@ const Child = 'complex.Child' const GrandChild = 'complex.GrandChild' const RootPWithKeys = 'complex.RootPWithKeys' const ChildPWithWhere = 'complex.ChildPWithWhere' +const recusive = [ + { + ID: 10, + fooRoot: 'Another Low Horror', + parent_ID: 5, + children: [ + { + ID: 101, + fooChild: 'bar', + children: [ + { + ID: 102, + fooGrandChild: 'bar', + }, + ], + }, + { + ID: 103, + fooChild: 'foo', + children: [ + { + ID: 104, + fooGrandChild: 'foo', + }, + ], + }, + ], + }, + { + ID: 11, + fooRoot: 'Another Medium Horror', + parent_ID: 10, + children: [ + { + ID: 111, + fooChild: 'bar', + children: [ + { + ID: 112, + fooGrandChild: 'bar', + }, + ], + }, + { + ID: 113, + fooChild: 'foo', + children: [ + { + ID: 114, + fooGrandChild: 'foo', + }, + ], + }, + ], + }, + { + ID: 12, + fooRoot: 'Another Hard Horror', + parent_ID: 11, + children: [ + { + ID: 121, + fooChild: 'bar', + children: [ + { + ID: 122, + fooGrandChild: 'bar', + }, + ], + }, + { + ID: 123, + fooChild: 'foo', + children: [ + { + ID: 124, + fooGrandChild: 'foo', + }, + ], + }, + ], + }, + { + ID: 13, + fooRoot: 'Another Very Hard Horror', + parent_ID: 11, + children: [ + { + ID: 131, + fooChild: 'bar', + children: [ + { + ID: 132, + fooGrandChild: 'bar', + }, + ], + }, + { + ID: 133, + fooChild: 'foo', + children: [ + { + ID: 134, + fooGrandChild: 'foo', + }, + ], + }, + ], + }, + { + ID: 14, + fooRoot: 'Another Very Very Hard Horror', + parent_ID: 12, + children: [ + { + ID: 141, + fooChild: 'bar', + children: [ + { + ID: 142, + fooGrandChild: 'bar', + }, + ], + }, + { + ID: 143, + fooChild: 'foo', + children: [ + { + ID: 144, + fooGrandChild: 'foo', + }, + ], + }, + ], + }, +] describe('DELETE', () => { const { data, expect } = cds.test(__dirname + '/resources') @@ -61,6 +198,25 @@ describe('DELETE', () => { expect(grandchild.length).to.be.eq(0) }) + test('on root with keys with recursive', async () => { + const insertsResp = await cds.run(INSERT.into(Root).entries(recusive)) + expect(insertsResp.affectedRows).to.be.eq(5) + + const root2 = await cds.run(SELECT.from(Root)) + const child2 = await cds.run(SELECT.from(Child)) + + const deepDelete = await cds.run(DELETE.from(RootPWithKeys).where({ ID: 5 })) + expect(deepDelete).to.be.eq(1) + + const root = await cds.run(SELECT.from(Root)) + expect(root.length).to.be.eq(0) + const child = await cds.run(SELECT.from(Child)) + expect(child.length).to.be.eq(2) + + const grandchild = await cds.run(SELECT.from(GrandChild).where({ ID: 8, or: { ID: 9 } })) + expect(grandchild.length).to.be.eq(0) + }) + test('on child with where', async () => { // only delete entries where fooChild = 'bar' const deepDelete = await cds.run(DELETE.from(ChildPWithWhere)) diff --git a/test/compliance/resources/db/complex/index.cds b/test/compliance/resources/db/complex/index.cds index e98f1c60f..696bdab6f 100644 --- a/test/compliance/resources/db/complex/index.cds +++ b/test/compliance/resources/db/complex/index.cds @@ -9,6 +9,8 @@ using from './keywords'; entity Root { key ID : Integer; fooRoot : String; + parent: Association to Root; + recursive: Composition of many Root on recursive.parent = $self; children : Composition of many Child on children.parent = $self; } From da4f512628c4e73cc34fdec6b30e4a63b6ee655f Mon Sep 17 00:00:00 2001 From: I543501 <56645452+larsplessing@users.noreply.github.com> Date: Fri, 11 Jul 2025 09:14:16 +0200 Subject: [PATCH 14/28] from recursive --- db-service/lib/SQLService.js | 28 ++++++++++++++++++---------- test/compliance/DELETE.test.js | 2 +- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/db-service/lib/SQLService.js b/db-service/lib/SQLService.js index d2925ddad..f9cf1fafd 100644 --- a/db-service/lib/SQLService.js +++ b/db-service/lib/SQLService.js @@ -268,8 +268,7 @@ class SQLService extends DatabaseService { 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 + let { visited = [] } = req visited.push(req.target.name) await Promise.all( Object.values(compositions).map(c => { @@ -286,8 +285,7 @@ class SQLService extends DatabaseService { } const backlinkName = _getBacklinkName(c.on) recursiveBacklinks.push(backlinkName) - // the Genre.children case - if (++depth > (c['@depth'] || 3)) return + return } else if (visited.includes(c._target.name)) throw new Error( `Transitive circular composition detected: \n\n` + @@ -297,11 +295,10 @@ class SQLService extends DatabaseService { // Prepare and run deep query, à la CQL`DELETE from Foo[pred]:comp1.comp2...` const query = DELETE.from({ ref: [...from.ref, c.name] }) query._target = c._target - return this.onDELETE({ query, depth, visited: [...visited], target: c._target }) + return this.onDELETE({ query, visited: [...visited], target: c._target }) }), ) - // only perform one recursive delete for root request - if (recursiveBacklinks.length && !req.depth) { + if (recursiveBacklinks.length) { let key // For hierarchies, only a single key is supported for (const _key in req.target.keys) { @@ -320,13 +317,24 @@ class SQLService extends DatabaseService { } _where.pop() + + const nonRecursiveComps = Object.values(compositions).filter(c => c._target !== req.target) + // Delete all non-recursive compositions from recursive composition + if (nonRecursiveComps.length) { + await Promise.all( + Object.values(nonRecursiveComps).map(c => { + const query = DELETE.from({ ref: [{ id: table.name, where: _where }, c.name] }) + query._target = c._target + return this.onSIMPLE({ query, target: c._target }) + }), + ) + } + // Delete all recursive composition const query = DELETE.from(table).where(_where) await this.onSIMPLE({ query, target: table }) } } - // skip recursive compositions because they are handled above - if (compositions?.[req.query.DELETE.from.ref[req.query.DELETE.from.ref.length-1]]?._target === req.target) return - else return this.onSIMPLE(req) + return this.onSIMPLE(req) } } diff --git a/test/compliance/DELETE.test.js b/test/compliance/DELETE.test.js index 7b19ce868..9c09eab11 100644 --- a/test/compliance/DELETE.test.js +++ b/test/compliance/DELETE.test.js @@ -211,7 +211,7 @@ describe('DELETE', () => { const root = await cds.run(SELECT.from(Root)) expect(root.length).to.be.eq(0) const child = await cds.run(SELECT.from(Child)) - expect(child.length).to.be.eq(2) + expect(child.length).to.be.eq(0) const grandchild = await cds.run(SELECT.from(GrandChild).where({ ID: 8, or: { ID: 9 } })) expect(grandchild.length).to.be.eq(0) From 22c9d29f12db1926d2c525904eff961aa8cc4da9 Mon Sep 17 00:00:00 2001 From: I543501 <56645452+larsplessing@users.noreply.github.com> Date: Fri, 11 Jul 2025 09:16:15 +0200 Subject: [PATCH 15/28] use map --- db-service/lib/SQLService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db-service/lib/SQLService.js b/db-service/lib/SQLService.js index f9cf1fafd..58399fae9 100644 --- a/db-service/lib/SQLService.js +++ b/db-service/lib/SQLService.js @@ -322,7 +322,7 @@ class SQLService extends DatabaseService { // Delete all non-recursive compositions from recursive composition if (nonRecursiveComps.length) { await Promise.all( - Object.values(nonRecursiveComps).map(c => { + nonRecursiveComps.map(c => { const query = DELETE.from({ ref: [{ id: table.name, where: _where }, c.name] }) query._target = c._target return this.onSIMPLE({ query, target: c._target }) From bcbfc7e79bbc50eb9f09c37cb0c12fda3c39434e Mon Sep 17 00:00:00 2001 From: I543501 <56645452+larsplessing@users.noreply.github.com> Date: Fri, 11 Jul 2025 10:43:45 +0200 Subject: [PATCH 16/28] Update DELETE.test.js --- test/compliance/DELETE.test.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/test/compliance/DELETE.test.js b/test/compliance/DELETE.test.js index 9c09eab11..b8dd40c69 100644 --- a/test/compliance/DELETE.test.js +++ b/test/compliance/DELETE.test.js @@ -4,7 +4,7 @@ const Child = 'complex.Child' const GrandChild = 'complex.GrandChild' const RootPWithKeys = 'complex.RootPWithKeys' const ChildPWithWhere = 'complex.ChildPWithWhere' -const recusive = [ +const recusiveData = [ { ID: 10, fooRoot: 'Another Low Horror', @@ -198,18 +198,16 @@ describe('DELETE', () => { expect(grandchild.length).to.be.eq(0) }) - test('on root with keys with recursive', async () => { + test('on root with keys with recursive composition', async () => { const insertsResp = await cds.run(INSERT.into(Root).entries(recusive)) expect(insertsResp.affectedRows).to.be.eq(5) - - const root2 = await cds.run(SELECT.from(Root)) - const child2 = await cds.run(SELECT.from(Child)) const deepDelete = await cds.run(DELETE.from(RootPWithKeys).where({ ID: 5 })) expect(deepDelete).to.be.eq(1) const root = await cds.run(SELECT.from(Root)) expect(root.length).to.be.eq(0) + const child = await cds.run(SELECT.from(Child)) expect(child.length).to.be.eq(0) From 2916ad8ff1bdef1a3b4bdca72e24a0056def2b2f Mon Sep 17 00:00:00 2001 From: I543501 <56645452+larsplessing@users.noreply.github.com> Date: Fri, 11 Jul 2025 10:51:41 +0200 Subject: [PATCH 17/28] Update SQLService.js --- db-service/lib/SQLService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db-service/lib/SQLService.js b/db-service/lib/SQLService.js index 58399fae9..4ce18e045 100644 --- a/db-service/lib/SQLService.js +++ b/db-service/lib/SQLService.js @@ -276,7 +276,7 @@ class SQLService extends DatabaseService { if (c._target === req.target) { // special treatment for recursive compositions in hierarchies function _getBacklinkName(on) { - const i = on.findIndex(e => e.ref && e.ref[0] === '$self') + const i = on?.findIndex(e => e.ref && e.ref[0] === '$self') if (i === -1) return let ref if (on[i + 1] && on[i + 1] === '=') ref = on[i + 2].ref From abd281914260d1d4659a75463258b867929c4ba6 Mon Sep 17 00:00:00 2001 From: I543501 <56645452+larsplessing@users.noreply.github.com> Date: Fri, 11 Jul 2025 11:01:01 +0200 Subject: [PATCH 18/28] Update DELETE.test.js --- test/compliance/DELETE.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/compliance/DELETE.test.js b/test/compliance/DELETE.test.js index b8dd40c69..8ad4b7568 100644 --- a/test/compliance/DELETE.test.js +++ b/test/compliance/DELETE.test.js @@ -199,7 +199,7 @@ describe('DELETE', () => { }) test('on root with keys with recursive composition', async () => { - const insertsResp = await cds.run(INSERT.into(Root).entries(recusive)) + const insertsResp = await cds.run(INSERT.into(Root).entries(recusiveData)) expect(insertsResp.affectedRows).to.be.eq(5) const deepDelete = await cds.run(DELETE.from(RootPWithKeys).where({ ID: 5 })) @@ -207,7 +207,7 @@ describe('DELETE', () => { const root = await cds.run(SELECT.from(Root)) expect(root.length).to.be.eq(0) - + const child = await cds.run(SELECT.from(Child)) expect(child.length).to.be.eq(0) From 4d8d26b99dc41159cacf15853579c1658170ed9a Mon Sep 17 00:00:00 2001 From: I543501 <56645452+larsplessing@users.noreply.github.com> Date: Fri, 11 Jul 2025 12:22:47 +0200 Subject: [PATCH 19/28] recursive compositions of one --- db-service/lib/SQLService.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/db-service/lib/SQLService.js b/db-service/lib/SQLService.js index 4ce18e045..d2f1d9dab 100644 --- a/db-service/lib/SQLService.js +++ b/db-service/lib/SQLService.js @@ -283,9 +283,16 @@ class SQLService extends DatabaseService { if (on[i - 1] && on[i - 1] === '=') ref = on[i - 2].ref return ref && ref[ref.length - 1] } - const backlinkName = _getBacklinkName(c.on) - recursiveBacklinks.push(backlinkName) - return + if (c.on) { + const backlinkName = c.on ? _getBacklinkName(c.on) : c.name + recursiveBacklinks.push(backlinkName) + return + } else { + // recursive composition of one + const query = DELETE.from({ ref: [...from.ref, c.name] }) + query._target = c._target + return this.onSIMPLE({ query, target: table }) + } } else if (visited.includes(c._target.name)) throw new Error( `Transitive circular composition detected: \n\n` + From 5f4006ce9dd7cb668ecce552175bc356d697266c Mon Sep 17 00:00:00 2001 From: I543501 <56645452+larsplessing@users.noreply.github.com> Date: Fri, 11 Jul 2025 12:23:20 +0200 Subject: [PATCH 20/28] Update SQLService.js --- db-service/lib/SQLService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db-service/lib/SQLService.js b/db-service/lib/SQLService.js index d2f1d9dab..fe4a93283 100644 --- a/db-service/lib/SQLService.js +++ b/db-service/lib/SQLService.js @@ -276,7 +276,7 @@ class SQLService extends DatabaseService { if (c._target === req.target) { // special treatment for recursive compositions in hierarchies function _getBacklinkName(on) { - const i = on?.findIndex(e => e.ref && e.ref[0] === '$self') + const i = on.findIndex(e => e.ref && e.ref[0] === '$self') if (i === -1) return let ref if (on[i + 1] && on[i + 1] === '=') ref = on[i + 2].ref From 96c91ef52c202fa6a2f5e88ef0154aacd4a480a5 Mon Sep 17 00:00:00 2001 From: I543501 <56645452+larsplessing@users.noreply.github.com> Date: Mon, 14 Jul 2025 15:15:58 +0200 Subject: [PATCH 21/28] use associations.cds --- db-service/lib/SQLService.js | 36 +++--- test/compliance/DELETE.test.js | 81 +++++++++----- test/compliance/SELECT.test.js | 103 ++++++++++++++---- .../resources/db/complex/associations.cds | 55 ++++++++-- .../db/complex/associationsUnmanaged.cds | 41 +++++-- .../compliance/resources/db/complex/index.cds | 2 - 6 files changed, 230 insertions(+), 88 deletions(-) diff --git a/db-service/lib/SQLService.js b/db-service/lib/SQLService.js index fe4a93283..5cc978121 100644 --- a/db-service/lib/SQLService.js +++ b/db-service/lib/SQLService.js @@ -257,6 +257,8 @@ class SQLService extends DatabaseService { const table = getDBTable(req.target) const { compositions } = table + // Check if we are in the hierarchy case + let isHierarchy = req.target.elements?.LimitedDescendantCount const recursiveBacklinks = [] if (compositions) { @@ -268,31 +270,29 @@ class SQLService extends DatabaseService { if (last.where) [last, where] = [last.id, [{ xpr: last.where }, 'and', { xpr: where }]] from = { ref: [...from.ref.slice(0, -1), { id: last, where }] } } - let { visited = [] } = req + // Process child compositions depth-first + let { depth = 0, visited = [] } = req visited.push(req.target.name) await Promise.all( Object.values(compositions).map(c => { if (c._target['@cds.persistence.skip'] === true) return if (c._target === req.target) { - // special treatment for recursive compositions in hierarchies - function _getBacklinkName(on) { - const i = on.findIndex(e => e.ref && e.ref[0] === '$self') - if (i === -1) return - let ref - if (on[i + 1] && on[i + 1] === '=') ref = on[i + 2].ref - if (on[i - 1] && on[i - 1] === '=') ref = on[i - 2].ref - return ref && ref[ref.length - 1] - } - if (c.on) { - const backlinkName = c.on ? _getBacklinkName(c.on) : c.name + // deep delete for hierarchies + if (isHierarchy) { + function _getBacklinkName(on) { + const i = on.findIndex(e => e.ref && e.ref[0] === '$self') + if (i === -1) return + let ref + if (on[i + 1] && on[i + 1] === '=') ref = on[i + 2].ref + if (on[i - 1] && on[i - 1] === '=') ref = on[i - 2].ref + return ref && ref[ref.length - 1] + } + const backlinkName = _getBacklinkName(c.on) recursiveBacklinks.push(backlinkName) return - } else { - // recursive composition of one - const query = DELETE.from({ ref: [...from.ref, c.name] }) - query._target = c._target - return this.onSIMPLE({ query, target: table }) } + // 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` + @@ -302,7 +302,7 @@ class SQLService extends DatabaseService { // Prepare and run deep query, à la CQL`DELETE from Foo[pred]:comp1.comp2...` const query = DELETE.from({ ref: [...from.ref, c.name] }) query._target = c._target - return this.onDELETE({ query, visited: [...visited], target: c._target }) + return this.onDELETE({ query, depth, visited: [...visited], target: c._target }) }), ) if (recursiveBacklinks.length) { diff --git a/test/compliance/DELETE.test.js b/test/compliance/DELETE.test.js index 8ad4b7568..17b286c8b 100644 --- a/test/compliance/DELETE.test.js +++ b/test/compliance/DELETE.test.js @@ -7,25 +7,24 @@ const ChildPWithWhere = 'complex.ChildPWithWhere' const recusiveData = [ { ID: 10, - fooRoot: 'Another Low Horror', - parent_ID: 5, + fooRoot: 'Horror', children: [ { ID: 101, fooChild: 'bar', children: [ { - ID: 102, + ID: 1011, fooGrandChild: 'bar', }, ], }, { - ID: 103, + ID: 102, fooChild: 'foo', children: [ { - ID: 104, + ID: 1021, fooGrandChild: 'foo', }, ], @@ -34,7 +33,7 @@ const recusiveData = [ }, { ID: 11, - fooRoot: 'Another Medium Horror', + fooRoot: 'Low Horror', parent_ID: 10, children: [ { @@ -42,17 +41,17 @@ const recusiveData = [ fooChild: 'bar', children: [ { - ID: 112, + ID: 1111, fooGrandChild: 'bar', }, ], }, { - ID: 113, + ID: 112, fooChild: 'foo', children: [ { - ID: 114, + ID: 1121, fooGrandChild: 'foo', }, ], @@ -61,7 +60,7 @@ const recusiveData = [ }, { ID: 12, - fooRoot: 'Another Hard Horror', + fooRoot: 'Medium Horror', parent_ID: 11, children: [ { @@ -69,17 +68,17 @@ const recusiveData = [ fooChild: 'bar', children: [ { - ID: 122, + ID: 1211, fooGrandChild: 'bar', }, ], }, { - ID: 123, + ID: 122, fooChild: 'foo', children: [ { - ID: 124, + ID: 1221, fooGrandChild: 'foo', }, ], @@ -88,7 +87,7 @@ const recusiveData = [ }, { ID: 13, - fooRoot: 'Another Very Hard Horror', + fooRoot: 'Hard Horror', parent_ID: 11, children: [ { @@ -96,17 +95,17 @@ const recusiveData = [ fooChild: 'bar', children: [ { - ID: 132, + ID: 1311, fooGrandChild: 'bar', }, ], }, { - ID: 133, + ID: 1312, fooChild: 'foo', children: [ { - ID: 134, + ID: 13121, fooGrandChild: 'foo', }, ], @@ -115,7 +114,7 @@ const recusiveData = [ }, { ID: 14, - fooRoot: 'Another Very Very Hard Horror', + fooRoot: 'Very Hard Horror', parent_ID: 12, children: [ { @@ -123,17 +122,44 @@ const recusiveData = [ fooChild: 'bar', children: [ { - ID: 142, + ID: 1411, fooGrandChild: 'bar', }, ], }, { - ID: 143, + ID: 142, fooChild: 'foo', children: [ { - ID: 144, + ID: 1421, + fooGrandChild: 'foo', + }, + ], + }, + ], + }, + { + ID: 15, + fooRoot: 'Very Very Hard Horror', + parent_ID: 14, + children: [ + { + ID: 151, + fooChild: 'bar', + children: [ + { + ID: 1511, + fooGrandChild: 'bar', + }, + ], + }, + { + ID: 152, + fooChild: 'foo', + children: [ + { + ID: 1521, fooGrandChild: 'foo', }, ], @@ -199,19 +225,20 @@ describe('DELETE', () => { }) test('on root with keys with recursive composition', async () => { - const insertsResp = await cds.run(INSERT.into(Root).entries(recusiveData)) - expect(insertsResp.affectedRows).to.be.eq(5) + const { RootPWithKeys: RootAPWithKeys, Root: RootA, Child: ChildA, GrandChild: GrandChildA } = cds.entities('complex.associations') + const insertsResp = await cds.run(INSERT.into(RootA).entries(recusiveData)) + expect(insertsResp.affectedRows).to.be.eq(6) - const deepDelete = await cds.run(DELETE.from(RootPWithKeys).where({ ID: 5 })) + const deepDelete = await cds.run(DELETE.from(RootAPWithKeys).where({ ID: 10 })) expect(deepDelete).to.be.eq(1) - const root = await cds.run(SELECT.from(Root)) + const root = await cds.run(SELECT.from(RootA)) expect(root.length).to.be.eq(0) - const child = await cds.run(SELECT.from(Child)) + const child = await cds.run(SELECT.from(ChildA)) expect(child.length).to.be.eq(0) - const grandchild = await cds.run(SELECT.from(GrandChild).where({ ID: 8, or: { ID: 9 } })) + const grandchild = await cds.run(SELECT.from(GrandChildA).where({ ID: 8, or: { ID: 9 } })) expect(grandchild.length).to.be.eq(0) }) diff --git a/test/compliance/SELECT.test.js b/test/compliance/SELECT.test.js index 45c40d292..e8762d5e2 100644 --- a/test/compliance/SELECT.test.js +++ b/test/compliance/SELECT.test.js @@ -4,6 +4,63 @@ const cds = require('../cds.js') describe('SELECT', () => { const { expect } = cds.test(__dirname + '/resources') + beforeAll(async () => { + const { Root } = cds.entities('complex.associations') + const { Root: RootUnmanaged } = cds.entities('complex.associations.unmanaged') + const inserts = [ + INSERT.into(Root).entries([ + { + ID: 1, + fooRoot: 'fooRoot1', + children: [ + { + ID: 11, + fooChild: 'fooChild11', + children: [ + { + ID: 111, + fooGrandChild: 'fooGrandChild111', + }, + ], + }, + { + ID:12, + fooChild: 'fooChild12', + children: [ + { + ID: 121, + fooGrandChild: 'fooGrandChild121', + }, + ], + }, + ], + }, + ]), + INSERT.into(RootUnmanaged).entries([ + { + ID: 2, + fooRoot: 'fooRootUnmanaged2', + children: [ + { + ID: 21, + parent_ID: 2, + fooChild: 'fooChildUnmanaged21', + children: [ + { + parent_ID: 21, + ID: 211, + fooGrandChild: 'fooGrandChildUnmanaged211', + }, + ], + }, + ], + }, + ]), + ] + const insertsResp = await cds.run(inserts) + expect(insertsResp[0].affectedRows).to.be.eq(1) + }) + describe('from', () => { test('table', async () => { const { globals } = cds.entities('basic.projection') @@ -218,24 +275,24 @@ describe('SELECT', () => { }) test('expand to many with 200 columns', async () => { - const { Authors } = cds.entities('complex.associations') - const cqn = SELECT([{ ref: ['ID'] }, { ref: ['name'] }, { ref: ['books'], expand: ['*', ...nulls(197)] }]).from(Authors) + const { Child } = cds.entities('complex.associations') + const cqn = SELECT([{ ref: ['ID'] }, { ref: ['fooChild'] }, { ref: ['parent'], expand: ['*', ...nulls(191)] }]).from(Child) const res = await cds.run(cqn) // ensure that all values are returned in json format - assert.strictEqual(Object.keys(res[0].books[0]).length, 200) + assert.strictEqual(Object.keys(res[0].parent).length, 200) }) test('expand to one with 200 columns', async () => { - const { Books } = cds.entities('complex.associations') - const cqn = SELECT([{ ref: ['ID'] }, { ref: ['title'] }, { ref: ['author'], expand: ['*', ...nulls(198)] }]).from(Books) + const { Root } = cds.entities('complex.associations') + const cqn = SELECT([{ ref: ['ID'] }, { ref: ['fooRoot'] }, { ref: ['children'], expand: ['*', ...nulls(197)] }]).from(Root) const res = await cds.run(cqn) // ensure that all values are returned in json format - assert.strictEqual(Object.keys(res[0].author).length, 200) + assert.strictEqual(Object.keys(res[0].children[0]).length, 200) }) test('expand association with static values', async () => { - const { Authors } = cds.entities('complex.associations.unmanaged') - const cqn = cds.ql`SELECT static{*} FROM ${Authors}` + const { Child } = cds.entities('complex.associations.unmanaged') + const cqn = cds.ql`SELECT static{*} FROM ${Child}` const res = await cds.run(cqn) // ensure that all values are returned in json format assert.strictEqual(res[0].static.length, 1) @@ -330,17 +387,19 @@ describe('SELECT', () => { }) test('exists path expression', async () => { - const { Books } = cds.entities('complex.associations') - const cqn = cds.ql`SELECT * FROM ${Books} WHERE exists author.books[author.name = ${'Emily'}]` + const { Child } = cds.entities('complex.associations') + const cqn = cds.ql`SELECT * FROM ${Child} WHERE exists parent.children[parent.fooRoot = ${'fooRoot1'}]` const res = await cds.run(cqn) - expect(res[0]).to.have.property('title', 'Wuthering Heights') + expect(res.length).to.be.equal(2) + expect(res[0]).to.have.property('fooChild', 'fooChild11') + expect(res[1]).to.have.property('fooChild', 'fooChild12') }) test('exists path expression (unmanaged)', async () => { - const { Books } = cds.entities('complex.associations.unmanaged') - const cqn = cds.ql`SELECT * FROM ${Books} WHERE exists author.books[author.name = ${'Emily'}]` + const { Child } = cds.entities('complex.associations.unmanaged') + const cqn = cds.ql`SELECT * FROM ${Child} WHERE exists parent.children[parent.fooRoot = ${'fooRootUnmanaged2'}]` const res = await cds.run(cqn) - expect(res[0]).to.have.property('title', 'Wuthering Heights') + expect(res[0]).to.have.property('fooChild', 'fooChildUnmanaged21') }) test('like wildcard', async () => { @@ -648,15 +707,15 @@ describe('SELECT', () => { }) test('navigation with duplicate identifier in path', async () => { - const { Books } = cds.entities('complex.associations') - const res = await cds.ql`SELECT name { name } FROM ${Books} GROUP BY name.name` - assert.strictEqual(res.length, 1, 'Ensure that all rows are coming back') + const { Root } = cds.entities('complex.associations') + const res = await cds.ql`SELECT children { fooChild } FROM ${Root} GROUP BY children.fooChild` + assert.strictEqual(res.length, 2, 'Ensure that all rows are coming back') }) test('navigation with duplicate identifier in path and aggregation', async () => { - const { Books } = cds.entities('complex.associations') - const res = await cds.ql`SELECT name { name }, count(1) as total FROM ${Books} GROUP BY name.name` - assert.strictEqual(res.length, 1, 'Ensure that all rows are coming back') + const { Root } = cds.entities('complex.associations') + const res = await cds.ql`SELECT children { fooChild }, count(1) as total FROM ${Root} GROUP BY children.fooChild` + assert.strictEqual(res.length, 2, 'Ensure that all rows are coming back') }) }) @@ -954,11 +1013,11 @@ describe('SELECT', () => { describe('count', () => { test('count is preserved with .map', async () => { - const query = SELECT.from('complex.associations.Authors') + const query = SELECT.from('complex.associations.Root') query.SELECT.count = true const result = await query assert.strictEqual(result.$count, 1) - const renamed = result.map(row => ({ key: row.ID, fullName: row.name })) + const renamed = result.map(row => ({ key: row.ID, fullName: row.fooRoot })) assert.strictEqual(renamed.$count, 1) }) }) diff --git a/test/compliance/resources/db/complex/associations.cds b/test/compliance/resources/db/complex/associations.cds index 5fc0edb8c..283bebf80 100644 --- a/test/compliance/resources/db/complex/associations.cds +++ b/test/compliance/resources/db/complex/associations.cds @@ -1,14 +1,51 @@ namespace complex.associations; -entity Books { - key ID : Integer; - title : String(111); - author : Association to Authors; - name : Association to Authors on $self.author.ID = name.ID; +entity Root { + key ID : Integer; + fooRoot : String; + parent : Association to Root; + recursive : Composition of many Root + on recursive.parent = $self; + children : Composition of many Child + on children.parent = $self; } -entity Authors { - key ID : Integer; - name : String(111); - books : Association to many Books on books.author = $self; +entity Child { + key ID : Integer; + fooChild : String; + parent : Association to one Root; + children : Composition of many GrandChild + on children.parent = $self; + static : Association to many Root + on static.children = $self + and static.ID > 0 + and fooChild != null; } + +entity GrandChild { + key ID : Integer; + fooGrandChild : String; + parent : Association to one Child; +} + +extend Root with { + LimitedDescendantCount : Integer = null; + DistanceFromRoot : Integer = null; + DrillState : String = null; + Matched : Boolean = null; + MatchedDescendantCount : Integer = null; + LimitedRank : Integer = null; +} + +entity RootPWithKeys as + projection on Root { + key ID, + fooRoot, + children, + null as LimitedDescendantCount, + null as DistanceFromRoot, + null as DrillState, + null as Matched, + null as MatchedDescendantCount, + null as LimitedRank, + }; diff --git a/test/compliance/resources/db/complex/associationsUnmanaged.cds b/test/compliance/resources/db/complex/associationsUnmanaged.cds index 3a7afae49..9fb2c8b1c 100644 --- a/test/compliance/resources/db/complex/associationsUnmanaged.cds +++ b/test/compliance/resources/db/complex/associationsUnmanaged.cds @@ -1,15 +1,36 @@ namespace complex.associations.unmanaged; -entity Books { - key ID : Integer; - title : String(111); - author_ID: Integer; - author : Association to Authors on author.ID = $self.author_ID; +entity Root { + key ID : Integer; + fooRoot : String(111); + children_ID : Integer; + children : Composition of many Child + on children.ID = $self.children_ID; } -entity Authors { - key ID : Integer; - name : String(111); - books : Association to many Books on books.author = $self; - static : Association to many Books on static.author = $self and static.ID > 0 and name != null; +entity Child { + key ID : Integer; + fooChild : String; + parent : Association to one Root; + children : Composition of many GrandChild + on children.parent = $self; + static : Association to many Root + on static.children = $self + and static.ID > 0 + and fooChild != null; } + +entity GrandChild { + key ID : Integer; + fooGrandChild : String; + parent : Association to one Child; +} + +extend Root with { + LimitedDescendantCount : Integer = null; + DistanceFromRoot : Integer = null; + DrillState : String = null; + Matched : Boolean = null; + MatchedDescendantCount : Integer = null; + LimitedRank : Integer = null; +} \ No newline at end of file diff --git a/test/compliance/resources/db/complex/index.cds b/test/compliance/resources/db/complex/index.cds index 696bdab6f..e98f1c60f 100644 --- a/test/compliance/resources/db/complex/index.cds +++ b/test/compliance/resources/db/complex/index.cds @@ -9,8 +9,6 @@ using from './keywords'; entity Root { key ID : Integer; fooRoot : String; - parent: Association to Root; - recursive: Composition of many Root on recursive.parent = $self; children : Composition of many Child on children.parent = $self; } From b09ee9977e84bc0c44986612ce3bf9d2fc36131a Mon Sep 17 00:00:00 2001 From: I543501 <56645452+larsplessing@users.noreply.github.com> Date: Mon, 14 Jul 2025 15:50:34 +0200 Subject: [PATCH 22/28] use Root instead of Books --- test/compliance/DELETE.test.js | 10 +++++----- test/compliance/INSERT.test.js | 2 +- test/compliance/UPDATE.test.js | 16 ++++++++-------- test/compliance/UPSERT.test.js | 2 +- test/compliance/strictMode.test.js | 20 ++++++++++---------- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/test/compliance/DELETE.test.js b/test/compliance/DELETE.test.js index 17b286c8b..85b07daa8 100644 --- a/test/compliance/DELETE.test.js +++ b/test/compliance/DELETE.test.js @@ -256,10 +256,10 @@ describe('DELETE', () => { }) test('ref', async () => { - const { Authors } = cds.entities('complex.associations') - await INSERT.into(Authors).entries(new Array(9).fill().map((e, i) => ({ ID: 100 + i, name: 'name' + i }))) - const changes = await cds.run(DELETE.from(Authors)) - expect(changes | 0).to.be.eq(10, 'Ensure that all rows are affected') // 1 from csv, 9 newly added + const { Child } = cds.entities('complex.associations') + await INSERT.into(Child).entries(new Array(9).fill().map((e, i) => ({ ID: 100 + i, fooChild: 'fooChild100' + i }))) + const changes = await cds.run(DELETE.from(Child)) + expect(changes | 0).to.be.eq(9, 'Ensure that all rows are affected') }) }) @@ -270,7 +270,7 @@ describe('DELETE', () => { }) test('affected rows', async () => { - const affectedRows = await DELETE.from('complex.associations.Books').where('ID = 4712') + const affectedRows = await DELETE.from('complex.associations.Root').where('ID = 4712') expect(affectedRows).to.be.eq(0) }) }) diff --git a/test/compliance/INSERT.test.js b/test/compliance/INSERT.test.js index 1947b8ac9..8c068306e 100644 --- a/test/compliance/INSERT.test.js +++ b/test/compliance/INSERT.test.js @@ -180,7 +180,7 @@ describe('INSERT', () => { }) test('InsertResult', async () => { - const insert = INSERT.into('complex.associations.Books').entries({ ID: 5 }) + const insert = INSERT.into('complex.associations.Root').entries({ ID: 5 }) const affectedRows = await cds.db.run(insert) // affectedRows is an InsertResult, so we need to do lose comparison here, as strict will not work due to InsertResult expect(affectedRows == 1).to.be.eq(true) diff --git a/test/compliance/UPDATE.test.js b/test/compliance/UPDATE.test.js index 8c90cb110..9171dfb2c 100644 --- a/test/compliance/UPDATE.test.js +++ b/test/compliance/UPDATE.test.js @@ -1,5 +1,5 @@ const cds = require('../cds.js') -const Books = 'complex.associations.Books' +const Root = 'complex.associations.Root' const BooksUnique = 'complex.uniques.Books' describe('UPDATE', () => { @@ -122,16 +122,16 @@ describe('UPDATE', () => { describe('where', () => { test('flat with or on key', async () => { const insert = await cds.run( - INSERT.into(Books).entries([ - { ID: 5, title: 'foo' }, - { ID: 6, title: 'bar' }, + INSERT.into(Root).entries([ + { ID: 5, fooRoot: 'foo' }, + { ID: 6, fooRoot: 'bar' }, ]), ) expect(insert.affectedRows).to.equal(2) const update = await cds.run( - UPDATE.entity(Books) - .set({ title: 'foo' }) + UPDATE.entity(Root) + .set({ fooRoot: 'foo' }) .where({ ID: 5, or: { ID: 6 } }), ) expect(update).to.equal(2) @@ -172,9 +172,9 @@ describe('UPDATE', () => { }) test('affected rows', async () => { - const { count } = await SELECT.one`count(*)`.from('complex.associations.Books') + const { count } = await SELECT.one`count(*)`.from('complex.associations.Root') - const affectedRows = await UPDATE.entity('complex.associations.Books').data({ title: 'Book' }) + const affectedRows = await UPDATE.entity('complex.associations.Root').data({ fooRoot: 'fooRoot1' }) expect(affectedRows).to.be.eq(count) }) }) diff --git a/test/compliance/UPSERT.test.js b/test/compliance/UPSERT.test.js index 02977cc44..271f25ba3 100644 --- a/test/compliance/UPSERT.test.js +++ b/test/compliance/UPSERT.test.js @@ -61,7 +61,7 @@ describe('UPSERT', () => { }) test('affected row', async () => { - const affectedRows = await UPSERT.into('complex.associations.Books').entries({ ID: 9999999, title: 'Book' }) + const affectedRows = await UPSERT.into('complex.associations.Root').entries({ ID: 9999999, fooRoot: 'fooRoot' }) expect(affectedRows).to.be.eq(1) }) }) diff --git a/test/compliance/strictMode.test.js b/test/compliance/strictMode.test.js index 982da32ea..c7ece7a02 100644 --- a/test/compliance/strictMode.test.js +++ b/test/compliance/strictMode.test.js @@ -1,5 +1,5 @@ const cds = require('../cds.js') -const Books = 'complex.associations.Books' +const Root = 'complex.associations.Root' describe('strict mode', () => { beforeAll(() => { @@ -25,14 +25,14 @@ describe('strict mode', () => { describe('UPDATE Scenarios', () => { test('Update with multiple errors', async () => { await runAndExpectError( - UPDATE.entity(Books).where({ ID: 2 }).set({ abc: 'bar', abc2: 'baz' }), + UPDATE.entity(Root).where({ ID: 2 }).set({ abc: 'bar', abc2: 'baz' }), 'STRICT MODE: Trying to UPDATE non existent columns (abc,abc2)', ) }) test('Update with single error', async () => { await runAndExpectError( - UPDATE.entity(Books).where({ ID: 2 }).set({ abc: 'bar' }), + UPDATE.entity(Root).where({ ID: 2 }).set({ abc: 'bar' }), 'STRICT MODE: Trying to UPDATE non existent columns (abc)', ) }) @@ -45,35 +45,35 @@ describe('strict mode', () => { describe('INSERT Scenarios', () => { test('Insert with single error using entries', async () => { await runAndExpectError( - INSERT.into(Books).entries({ abc: 'bar' }), + INSERT.into(Root).entries({ abc: 'bar' }), 'STRICT MODE: Trying to INSERT non existent columns (abc)', ) }) test('Insert with multiple errors using entries', async () => { await runAndExpectError( - INSERT.into(Books).entries([{ abc: 'bar' }, { abc2: 'bar2' }]), + INSERT.into(Root).entries([{ abc: 'bar' }, { abc2: 'bar2' }]), 'STRICT MODE: Trying to INSERT non existent columns (abc,abc2)', ) }) test('Insert with single error using columns and values', async () => { await runAndExpectError( - INSERT.into(Books).columns(['abc']).values(['foo', 'bar']), + INSERT.into(Root).columns(['abc']).values(['foo', 'bar']), 'STRICT MODE: Trying to INSERT non existent columns (abc)', ) }) test('Insert with multiple errors with columns and rows', async () => { await runAndExpectError( - INSERT.into(Books).columns(['abc', 'abc2']).rows(['foo', 'bar'], ['foo2', 'bar2'], ['foo3', 'bar3']), + INSERT.into(Root).columns(['abc', 'abc2']).rows(['foo', 'bar'], ['foo2', 'bar2'], ['foo3', 'bar3']), 'STRICT MODE: Trying to INSERT non existent columns (abc,abc2)', ) }) test('Insert with single error using columns and rows', async () => { await runAndExpectError( - INSERT.into(Books).columns(['abc']).rows(['foo', 'bar'], ['foo2', 'bar2'], ['foo3', 'bar3']), + INSERT.into(Root).columns(['abc']).rows(['foo', 'bar'], ['foo2', 'bar2'], ['foo3', 'bar3']), 'STRICT MODE: Trying to INSERT non existent columns (abc)', ) }) @@ -86,13 +86,13 @@ describe('strict mode', () => { describe('UPSERT Scenarios', () => { test('UPSERT with single error', async () => { await runAndExpectError( - UPSERT.into(Books).entries({ abc: 'bar' }), + UPSERT.into(Root).entries({ abc: 'bar' }), 'STRICT MODE: Trying to UPSERT non existent columns (abc)', ) }) test('UPSERT with multiple errors', async () => { await runAndExpectError( - UPSERT.into(Books).entries({ abc: 'bar', abc2: 'baz' }), + UPSERT.into(Root).entries({ abc: 'bar', abc2: 'baz' }), 'STRICT MODE: Trying to UPSERT non existent columns (abc,abc2)', ) }) From bae98ab50363ffa5563b31e614313a64f3be48e5 Mon Sep 17 00:00:00 2001 From: I543501 <56645452+larsplessing@users.noreply.github.com> Date: Wed, 16 Jul 2025 15:18:53 +0200 Subject: [PATCH 23/28] . --- test/compliance/SELECT.test.js | 6 ++-- .../db/complex/associationsUnmanaged.cds | 35 ++++++++++--------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/test/compliance/SELECT.test.js b/test/compliance/SELECT.test.js index e8762d5e2..dba445dbd 100644 --- a/test/compliance/SELECT.test.js +++ b/test/compliance/SELECT.test.js @@ -291,8 +291,8 @@ describe('SELECT', () => { }) test('expand association with static values', async () => { - const { Child } = cds.entities('complex.associations.unmanaged') - const cqn = cds.ql`SELECT static{*} FROM ${Child}` + const { Root } = cds.entities('complex.associations.unmanaged') + const cqn = cds.ql`SELECT static{*} FROM ${Root}` const res = await cds.run(cqn) // ensure that all values are returned in json format assert.strictEqual(res[0].static.length, 1) @@ -390,7 +390,7 @@ describe('SELECT', () => { const { Child } = cds.entities('complex.associations') const cqn = cds.ql`SELECT * FROM ${Child} WHERE exists parent.children[parent.fooRoot = ${'fooRoot1'}]` const res = await cds.run(cqn) - expect(res.length).to.be.equal(2) + expect(res).to.have.property('length', 2) expect(res[0]).to.have.property('fooChild', 'fooChild11') expect(res[1]).to.have.property('fooChild', 'fooChild12') }) diff --git a/test/compliance/resources/db/complex/associationsUnmanaged.cds b/test/compliance/resources/db/complex/associationsUnmanaged.cds index 9fb2c8b1c..1a0bcd19c 100644 --- a/test/compliance/resources/db/complex/associationsUnmanaged.cds +++ b/test/compliance/resources/db/complex/associationsUnmanaged.cds @@ -1,29 +1,32 @@ namespace complex.associations.unmanaged; entity Root { - key ID : Integer; - fooRoot : String(111); - children_ID : Integer; - children : Composition of many Child - on children.ID = $self.children_ID; + key ID : Integer; + fooRoot : String(111); + children : Composition of many Child + on children.parent = $self; + static : Association to many Child + on static.parent = $self + and static.ID > 0 + and fooRoot != null; } entity Child { - key ID : Integer; - fooChild : String; - parent : Association to one Root; - children : Composition of many GrandChild - on children.parent = $self; - static : Association to many Root - on static.children = $self - and static.ID > 0 - and fooChild != null; + key ID : Integer; + fooChild : String; + parent_ID : Integer; + parent : Association to one Root + on parent.ID = $self.parent_ID; + children : Composition of many GrandChild + on children.parent = $self; } entity GrandChild { key ID : Integer; fooGrandChild : String; - parent : Association to one Child; + parent_ID : Integer; + parent : Association to one Child + on parent.ID = $self.parent_ID; } extend Root with { @@ -33,4 +36,4 @@ extend Root with { Matched : Boolean = null; MatchedDescendantCount : Integer = null; LimitedRank : Integer = null; -} \ No newline at end of file +} From c6339fe8642a7242c91ccfebf97d828092c837a6 Mon Sep 17 00:00:00 2001 From: I543501 <56645452+larsplessing@users.noreply.github.com> Date: Fri, 18 Jul 2025 14:13:17 +0200 Subject: [PATCH 24/28] rec to one --- db-service/lib/cql-functions.js | 4 +- db-service/lib/cqn2sql.js | 38 +++++++++++++++---- test/compliance/DELETE.test.js | 20 ++++++++++ .../resources/db/complex/associations.cds | 1 + 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/db-service/lib/cql-functions.js b/db-service/lib/cql-functions.js index f7433937d..cd86f15cc 100644 --- a/db-service/lib/cql-functions.js +++ b/db-service/lib/cql-functions.js @@ -204,6 +204,8 @@ const HANAFunctions = { // Ensure that the orderBy column are exposed by the source for hierarchy sorting const orderBy = args.xpr.find((_, i, arr) => /ORDER/i.test(arr[i - 2]) && /BY/i.test(arr[i - 1])) + const startWhere = args.xpr.find((_, i, arr) => /START/i.test(arr[i - 2]) && /WHERE/i.test(arr[i - 1])) + const passThroughColumns = src.SELECT.columns.map(c => ({ ref: ['Source', this.column_name(c)] })) src.as = 'H' + (uniqueCounter++) src = this.expr(this.with(src)) @@ -213,7 +215,7 @@ SELECT 1 as HIERARCHY_LEVEL, NODE_ID as HIERARCHY_ROOT_ID FROM ${src} AS Source -WHERE parent_ID IS NULL +WHERE ${startWhere ? this.expr(startWhere) : 'parent_ID IS NULL'} UNION ALL SELECT Parent.HIERARCHY_LEVEL + 1, diff --git a/db-service/lib/cqn2sql.js b/db-service/lib/cqn2sql.js index 5c21c742d..eec2fe613 100644 --- a/db-service/lib/cqn2sql.js +++ b/db-service/lib/cqn2sql.js @@ -357,9 +357,15 @@ class CQN2SQLRenderer { const nodeKeys = [] const parentKeys = [] const association = target.elements[recurse.ref[0]] + const isRecToOne = association._target === q._target && association.is2one && !association._isCompositionBacklink association._foreignKeys.forEach(fk => { - nodeKeys.push(fk.childElement.name) - parentKeys.push(fk.parentElement.name) + if (isRecToOne) { + nodeKeys.push(fk.parentElement.name) + parentKeys.push(fk.childElement.name) + } else { + nodeKeys.push(fk.childElement.name) + parentKeys.push(fk.parentElement.name) + } }) columnsIn.push( @@ -385,11 +391,29 @@ class CQN2SQLRenderer { const stableFrom = getStableFrom(from) const alias = stableFrom.as const source = () => { - return ({ - func: 'HIERARCHY', - args: [{ xpr: ['SOURCE', { SELECT: { columns: columnsIn, from: stableFrom } }, ...(orderBy ? ['SIBLING', 'ORDER', 'BY', `${this.orderBy(orderBy)}`] : [])] }], - as: alias - }) + return { + func: 'HIERARCHY', + args: [ + { + xpr: [ + 'SOURCE', + { SELECT: { columns: columnsIn, from: stableFrom } }, + ...(isRecToOne + ? ['START', 'WHERE', { xpr: [ + { ref: ['parent_ID'] }, 'NOT', 'IN', + { SELECT: { columns: [{ ref: ['NODE_ID'] }], + from: { SELECT: { columns: columnsIn.filter(f => f.as === 'NODE_ID'), from: { ref: stableFrom.ref } } }, + where: [{ ref: ['NODE_ID'] }, '!=', { val: null, param: false }] } + }] + }] + : [] + ), + ...(orderBy ? ['SIBLING', 'ORDER', 'BY', `${this.orderBy(orderBy)}`] : []), + ], + }, + ], + as: alias, + } } const expandedByNr = { list: [] } // DistanceTo(...,null) diff --git a/test/compliance/DELETE.test.js b/test/compliance/DELETE.test.js index 85b07daa8..bbe2cc8af 100644 --- a/test/compliance/DELETE.test.js +++ b/test/compliance/DELETE.test.js @@ -30,6 +30,26 @@ const recusiveData = [ ], }, ], + recursiveToOne: { + ID: 103, + fooRoot: 'Recursive to one Horror', + children: [ + { + ID: 1031, + fooChild: 'bar', + children: [ + { + ID: 10311, + fooGrandChild: 'bar', + }, + ], + recursiveToOne: { + ID: 10312, + fooRoot: 'Recursive to one Horror 2', + }, + }, + ], + }, }, { ID: 11, diff --git a/test/compliance/resources/db/complex/associations.cds b/test/compliance/resources/db/complex/associations.cds index 283bebf80..88a72243c 100644 --- a/test/compliance/resources/db/complex/associations.cds +++ b/test/compliance/resources/db/complex/associations.cds @@ -3,6 +3,7 @@ namespace complex.associations; entity Root { key ID : Integer; fooRoot : String; + recursiveToOne : Composition of one Root; parent : Association to Root; recursive : Composition of many Root on recursive.parent = $self; From 5df37e651a1c5b92a9a54e3bda1fc72e4ad5d347 Mon Sep 17 00:00:00 2001 From: I543501 <56645452+larsplessing@users.noreply.github.com> Date: Fri, 18 Jul 2025 14:21:34 +0200 Subject: [PATCH 25/28] Update SQLService.js --- db-service/lib/SQLService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db-service/lib/SQLService.js b/db-service/lib/SQLService.js index 19e6bf6c0..1acf21263 100644 --- a/db-service/lib/SQLService.js +++ b/db-service/lib/SQLService.js @@ -287,7 +287,7 @@ class SQLService extends DatabaseService { if (on[i - 1] && on[i - 1] === '=') ref = on[i - 2].ref return ref && ref[ref.length - 1] } - const backlinkName = _getBacklinkName(c.on) + const backlinkName = c.on ? _getBacklinkName(c.on) : c.name recursiveBacklinks.push(backlinkName) return } From 1100a463e6c3bcda98005a956d91702c218b72ad Mon Sep 17 00:00:00 2001 From: I543501 <56645452+larsplessing@users.noreply.github.com> Date: Fri, 18 Jul 2025 14:23:39 +0200 Subject: [PATCH 26/28] Update SQLService.js --- db-service/lib/SQLService.js | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/db-service/lib/SQLService.js b/db-service/lib/SQLService.js index 1acf21263..db4fb2f52 100644 --- a/db-service/lib/SQLService.js +++ b/db-service/lib/SQLService.js @@ -257,8 +257,6 @@ class SQLService extends DatabaseService { const table = getDBTable(req.target) const { compositions } = table - // Check if we are in the hierarchy case - let isHierarchy = req.target.elements?.LimitedDescendantCount const recursiveBacklinks = [] if (compositions) { @@ -271,28 +269,24 @@ class SQLService extends DatabaseService { from = { ref: [...from.ref.slice(0, -1), { id: last, where }] } } // Process child compositions depth-first - let { depth = 0, visited = [] } = req + let { visited = [] } = req visited.push(req.target.name) await Promise.all( Object.values(compositions).map(c => { if (c._target['@cds.persistence.skip'] === true) return if (c._target === req.target) { // deep delete for hierarchies - if (isHierarchy) { - function _getBacklinkName(on) { - const i = on.findIndex(e => e.ref && e.ref[0] === '$self') - if (i === -1) return - let ref - if (on[i + 1] && on[i + 1] === '=') ref = on[i + 2].ref - if (on[i - 1] && on[i - 1] === '=') ref = on[i - 2].ref - return ref && ref[ref.length - 1] - } - const backlinkName = c.on ? _getBacklinkName(c.on) : c.name - recursiveBacklinks.push(backlinkName) - return + function _getBacklinkName(on) { + const i = on.findIndex(e => e.ref && e.ref[0] === '$self') + if (i === -1) return + let ref + if (on[i + 1] && on[i + 1] === '=') ref = on[i + 2].ref + if (on[i - 1] && on[i - 1] === '=') ref = on[i - 2].ref + return ref && ref[ref.length - 1] } - // the Genre.children case - if (++depth > (c['@depth'] || 3)) return + const backlinkName = c.on ? _getBacklinkName(c.on) : c.name + recursiveBacklinks.push(backlinkName) + return } else if (visited.includes(c._target.name)) throw new Error( `Transitive circular composition detected: \n\n` + @@ -302,7 +296,7 @@ class SQLService extends DatabaseService { // Prepare and run deep query, à la CQL`DELETE from Foo[pred]:comp1.comp2...` const query = DELETE.from({ ref: [...from.ref, c.name] }) query._target = c._target - return this.onDELETE({ query, depth, visited: [...visited], target: c._target }) + return this.onDELETE({ query, visited: [...visited], target: c._target }) }), ) if (recursiveBacklinks.length) { From a6aac38e078ef37c862c7803cd00ff748ef06401 Mon Sep 17 00:00:00 2001 From: I543501 <56645452+larsplessing@users.noreply.github.com> Date: Mon, 28 Jul 2025 12:11:53 +0200 Subject: [PATCH 27/28] use backwards --- db-service/lib/SQLService.js | 21 ++++-------- db-service/lib/cqn2sql.js | 16 ---------- test/compliance/DELETE.test.js | 32 ++++++++++++++----- .../resources/db/complex/associations.cds | 4 +-- 4 files changed, 32 insertions(+), 41 deletions(-) diff --git a/db-service/lib/SQLService.js b/db-service/lib/SQLService.js index db4fb2f52..bd488f17f 100644 --- a/db-service/lib/SQLService.js +++ b/db-service/lib/SQLService.js @@ -257,7 +257,7 @@ class SQLService extends DatabaseService { const table = getDBTable(req.target) const { compositions } = table - const recursiveBacklinks = [] + const recursiveComps = [] if (compositions) { // Transform CQL`DELETE from Foo[p1] WHERE p2` into CQL`DELETE from Foo[p1 and p2]` @@ -275,17 +275,7 @@ class SQLService extends DatabaseService { Object.values(compositions).map(c => { if (c._target['@cds.persistence.skip'] === true) return if (c._target === req.target) { - // deep delete for hierarchies - function _getBacklinkName(on) { - const i = on.findIndex(e => e.ref && e.ref[0] === '$self') - if (i === -1) return - let ref - if (on[i + 1] && on[i + 1] === '=') ref = on[i + 2].ref - if (on[i - 1] && on[i - 1] === '=') ref = on[i - 2].ref - return ref && ref[ref.length - 1] - } - const backlinkName = c.on ? _getBacklinkName(c.on) : c.name - recursiveBacklinks.push(backlinkName) + recursiveComps.push(c.name) return } else if (visited.includes(c._target.name)) throw new Error( @@ -299,7 +289,7 @@ class SQLService extends DatabaseService { return this.onDELETE({ query, visited: [...visited], target: c._target }) }), ) - if (recursiveBacklinks.length) { + if (recursiveComps.length) { let key // For hierarchies, only a single key is supported for (const _key in req.target.keys) { @@ -308,10 +298,11 @@ class SQLService extends DatabaseService { break } const _where = [] - for (const backlink of recursiveBacklinks) { + for (const comp of recursiveComps) { const _recursiveQuery = SELECT.from(table).columns(key) _recursiveQuery.SELECT.recurse = { - ref: [backlink], + ref: [comp], + backward: false, where: from.ref[0].where, } _where.push({ ref: [key] }, 'in', _recursiveQuery, 'or') diff --git a/db-service/lib/cqn2sql.js b/db-service/lib/cqn2sql.js index eec2fe613..b18e91fa0 100644 --- a/db-service/lib/cqn2sql.js +++ b/db-service/lib/cqn2sql.js @@ -357,15 +357,9 @@ class CQN2SQLRenderer { const nodeKeys = [] const parentKeys = [] const association = target.elements[recurse.ref[0]] - const isRecToOne = association._target === q._target && association.is2one && !association._isCompositionBacklink association._foreignKeys.forEach(fk => { - if (isRecToOne) { - nodeKeys.push(fk.parentElement.name) - parentKeys.push(fk.childElement.name) - } else { nodeKeys.push(fk.childElement.name) parentKeys.push(fk.parentElement.name) - } }) columnsIn.push( @@ -398,16 +392,6 @@ class CQN2SQLRenderer { xpr: [ 'SOURCE', { SELECT: { columns: columnsIn, from: stableFrom } }, - ...(isRecToOne - ? ['START', 'WHERE', { xpr: [ - { ref: ['parent_ID'] }, 'NOT', 'IN', - { SELECT: { columns: [{ ref: ['NODE_ID'] }], - from: { SELECT: { columns: columnsIn.filter(f => f.as === 'NODE_ID'), from: { ref: stableFrom.ref } } }, - where: [{ ref: ['NODE_ID'] }, '!=', { val: null, param: false }] } - }] - }] - : [] - ), ...(orderBy ? ['SIBLING', 'ORDER', 'BY', `${this.orderBy(orderBy)}`] : []), ], }, diff --git a/test/compliance/DELETE.test.js b/test/compliance/DELETE.test.js index bbe2cc8af..913a2a700 100644 --- a/test/compliance/DELETE.test.js +++ b/test/compliance/DELETE.test.js @@ -43,18 +43,34 @@ const recusiveData = [ fooGrandChild: 'bar', }, ], - recursiveToOne: { - ID: 10312, - fooRoot: 'Recursive to one Horror 2', + recursiveToOneChild: { + ID: 1032, + fooChild: 'Recursive to one Horror 2', }, }, ], + recursiveToOne: { + ID: 1033, + fooRoot: 'Recursive to one Horror 3', + recursiveToOne: { + ID: 10331, + fooRoot: 'Recursive to one Horror 3', + recursiveToOne: { + ID: 103311, + fooRoot: 'Recursive to one Horror 3', + recursiveToOne: { + ID: 1033111, + fooRoot: 'Recursive to one Horror 3', + }, + }, + }, + }, }, }, { ID: 11, fooRoot: 'Low Horror', - parent_ID: 10, + recParent_ID: 10, children: [ { ID: 111, @@ -81,7 +97,7 @@ const recusiveData = [ { ID: 12, fooRoot: 'Medium Horror', - parent_ID: 11, + recParent_ID: 11, children: [ { ID: 121, @@ -108,7 +124,7 @@ const recusiveData = [ { ID: 13, fooRoot: 'Hard Horror', - parent_ID: 11, + recParent_ID: 11, children: [ { ID: 131, @@ -135,7 +151,7 @@ const recusiveData = [ { ID: 14, fooRoot: 'Very Hard Horror', - parent_ID: 12, + recParent_ID: 12, children: [ { ID: 141, @@ -162,7 +178,7 @@ const recusiveData = [ { ID: 15, fooRoot: 'Very Very Hard Horror', - parent_ID: 14, + recParent_ID: 14, children: [ { ID: 151, diff --git a/test/compliance/resources/db/complex/associations.cds b/test/compliance/resources/db/complex/associations.cds index 88a72243c..61a44c2ad 100644 --- a/test/compliance/resources/db/complex/associations.cds +++ b/test/compliance/resources/db/complex/associations.cds @@ -4,9 +4,9 @@ entity Root { key ID : Integer; fooRoot : String; recursiveToOne : Composition of one Root; - parent : Association to Root; + recParent : Association to Root; recursive : Composition of many Root - on recursive.parent = $self; + on recursive.recParent = $self; children : Composition of many Child on children.parent = $self; } From 703038c70807640153c82ec53815c62a3f3ce494 Mon Sep 17 00:00:00 2001 From: I543501 <56645452+larsplessing@users.noreply.github.com> Date: Mon, 28 Jul 2025 12:18:51 +0200 Subject: [PATCH 28/28] Update cqn2sql.js --- db-service/lib/cqn2sql.js | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/db-service/lib/cqn2sql.js b/db-service/lib/cqn2sql.js index b18e91fa0..e7633a252 100644 --- a/db-service/lib/cqn2sql.js +++ b/db-service/lib/cqn2sql.js @@ -358,8 +358,8 @@ class CQN2SQLRenderer { const parentKeys = [] const association = target.elements[recurse.ref[0]] association._foreignKeys.forEach(fk => { - nodeKeys.push(fk.childElement.name) - parentKeys.push(fk.parentElement.name) + nodeKeys.push(fk.childElement.name) + parentKeys.push(fk.parentElement.name) }) columnsIn.push( @@ -385,20 +385,12 @@ class CQN2SQLRenderer { const stableFrom = getStableFrom(from) const alias = stableFrom.as const source = () => { - return { + return ({ func: 'HIERARCHY', - args: [ - { - xpr: [ - 'SOURCE', - { SELECT: { columns: columnsIn, from: stableFrom } }, - ...(orderBy ? ['SIBLING', 'ORDER', 'BY', `${this.orderBy(orderBy)}`] : []), - ], - }, - ], - as: alias, - } - } + args: [{ xpr: ['SOURCE', { SELECT: { columns: columnsIn, from: stableFrom } }, ...(orderBy ? ['SIBLING', 'ORDER', 'BY', `${this.orderBy(orderBy)}`] : [])] }], + as: alias + }) + } const expandedByNr = { list: [] } // DistanceTo(...,null) const expandedByOne = { list: [] } // DistanceTo(...,1)