@cap-js/db-service 1.10.2 → 1.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/lib/cqn2sql.js +12 -33
- package/lib/cqn4sql.js +90 -4
- package/lib/infer/index.js +6 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
- The format is based on [Keep a Changelog](http://keepachangelog.com/).
|
|
5
5
|
- This project adheres to [Semantic Versioning](http://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [1.10.3](https://github.com/cap-js/cds-dbs/compare/db-service-v1.10.2...db-service-v1.10.3) (2024-07-05)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
* rewrite assoc chains if intermediate assoc is not fk ([#715](https://github.com/cap-js/cds-dbs/issues/715)) ([3873f9a](https://github.com/cap-js/cds-dbs/commit/3873f9adce3ff26cafb2b18b9d2115758b0f0830))
|
|
13
|
+
* Support expand with group by clause ([#721](https://github.com/cap-js/cds-dbs/issues/721)) ([90c9e6a](https://github.com/cap-js/cds-dbs/commit/90c9e6a4da9d4a3451ec0ed60dd0815c04600134))
|
|
14
|
+
|
|
7
15
|
## [1.10.2](https://github.com/cap-js/cds-dbs/compare/db-service-v1.10.1...db-service-v1.10.2) (2024-06-25)
|
|
8
16
|
|
|
9
17
|
|
package/lib/cqn2sql.js
CHANGED
|
@@ -222,6 +222,7 @@ class CQN2SQLRenderer {
|
|
|
222
222
|
if (distinct) sql += ` DISTINCT`
|
|
223
223
|
if (!_empty(columns)) sql += ` ${columns}`
|
|
224
224
|
if (!_empty(from)) sql += ` FROM ${this.from(from)}`
|
|
225
|
+
else sql += this.from_dummy()
|
|
225
226
|
if (!_empty(where)) sql += ` WHERE ${this.where(where)}`
|
|
226
227
|
if (!_empty(groupBy)) sql += ` GROUP BY ${this.groupBy(groupBy)}`
|
|
227
228
|
if (!_empty(having)) sql += ` HAVING ${this.having(having)}`
|
|
@@ -310,12 +311,7 @@ class CQN2SQLRenderer {
|
|
|
310
311
|
*/
|
|
311
312
|
column_expr(x, q) {
|
|
312
313
|
if (x === '*') return '*'
|
|
313
|
-
|
|
314
|
-
// REVISIT: that should move out of here!
|
|
315
|
-
if (x?.element?.['@cds.extension']) {
|
|
316
|
-
return `extensions__->${this.string('$."' + x.element.name + '"')} as ${x.as || x.element.name}`
|
|
317
|
-
}
|
|
318
|
-
///////////////////////////////////////////////////////////////////////////////////////
|
|
314
|
+
|
|
319
315
|
let sql = this.expr({ param: false, __proto__: x })
|
|
320
316
|
let alias = this.column_alias4(x, q)
|
|
321
317
|
if (alias) sql += ' as ' + this.quote(alias)
|
|
@@ -351,6 +347,14 @@ class CQN2SQLRenderer {
|
|
|
351
347
|
return `${this.from(from.args[0])} ${from.join} JOIN ${this.from(from.args[1])} ON ${this.where(from.on)}`
|
|
352
348
|
}
|
|
353
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Renders a FROM clause for when the query does not have a target
|
|
352
|
+
* @returns {string} SQL
|
|
353
|
+
*/
|
|
354
|
+
from_dummy() {
|
|
355
|
+
return ''
|
|
356
|
+
}
|
|
357
|
+
|
|
354
358
|
/**
|
|
355
359
|
* Renders a FROM clause into generic SQL
|
|
356
360
|
* @param {import('./infer/cqn').ref['ref'][0]['args']} args
|
|
@@ -480,7 +484,7 @@ class CQN2SQLRenderer {
|
|
|
480
484
|
: ObjectKeys(INSERT.entries[0])
|
|
481
485
|
|
|
482
486
|
/** @type {string[]} */
|
|
483
|
-
this.columns = columns
|
|
487
|
+
this.columns = columns
|
|
484
488
|
|
|
485
489
|
if (!elements) {
|
|
486
490
|
this.entries = INSERT.entries.map(e => columns.map(c => e[c]))
|
|
@@ -493,24 +497,7 @@ class CQN2SQLRenderer {
|
|
|
493
497
|
elements,
|
|
494
498
|
!!q.UPSERT,
|
|
495
499
|
)
|
|
496
|
-
const extraction = extractions
|
|
497
|
-
.map(c => {
|
|
498
|
-
const element = elements?.[c.name]
|
|
499
|
-
if (element?.['@cds.extension']) {
|
|
500
|
-
return false
|
|
501
|
-
}
|
|
502
|
-
if (c.name === 'extensions__') {
|
|
503
|
-
const merges = extractions.filter(c => elements?.[c.name]?.['@cds.extension'])
|
|
504
|
-
if (merges.length) {
|
|
505
|
-
c.sql = `json_set(ifnull(${c.sql},'{}'),${merges.map(
|
|
506
|
-
c => this.string('$."' + c.name + '"') + ',' + c.sql,
|
|
507
|
-
)})`
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
return c
|
|
511
|
-
})
|
|
512
|
-
.filter(a => a)
|
|
513
|
-
.map(c => c.sql)
|
|
500
|
+
const extraction = extractions.map(c => c.sql)
|
|
514
501
|
|
|
515
502
|
// Include this.values for placeholders
|
|
516
503
|
/** @type {unknown[][]} */
|
|
@@ -783,14 +770,6 @@ class CQN2SQLRenderer {
|
|
|
783
770
|
}
|
|
784
771
|
}
|
|
785
772
|
|
|
786
|
-
columns = columns.map(c => {
|
|
787
|
-
if (q.elements?.[c.name]?.['@cds.extension']) return {
|
|
788
|
-
name: 'extensions__',
|
|
789
|
-
sql: `jsonb_set(extensions__,${this.string('$."' + c.name + '"')},${c.sql})`,
|
|
790
|
-
}
|
|
791
|
-
return c
|
|
792
|
-
})
|
|
793
|
-
|
|
794
773
|
const extraction = this.managed(columns, elements, true).map(c => `${this.quote(c.name)}=${c.sql}`)
|
|
795
774
|
|
|
796
775
|
sql += ` SET ${extraction}`
|
package/lib/cqn4sql.js
CHANGED
|
@@ -799,10 +799,20 @@ function cqn4sql(originalQuery, model) {
|
|
|
799
799
|
? column.ref.slice(1).map(idOnly).join('_') // omit explicit table alias from name of column
|
|
800
800
|
: column.ref.map(idOnly).join('_'))
|
|
801
801
|
|
|
802
|
+
// if there is a group by on the main query, all
|
|
803
|
+
// columns of the expand must be in the groupBy
|
|
804
|
+
if (transformedQuery.SELECT.groupBy) {
|
|
805
|
+
const baseRef =
|
|
806
|
+
column.$refLinks[0].definition.SELECT || column.$refLinks[0].definition.kind === 'entity'
|
|
807
|
+
? column.ref.slice(1)
|
|
808
|
+
: column.ref
|
|
809
|
+
|
|
810
|
+
return _subqueryForGroupBy(column, baseRef, columnAlias)
|
|
811
|
+
}
|
|
812
|
+
|
|
802
813
|
// we need to respect the aliases of the outer query, so the columnAlias might not be suitable
|
|
803
814
|
// as table alias for the correlated subquery
|
|
804
815
|
const uniqueSubqueryAlias = getNextAvailableTableAlias(columnAlias, originalQuery.outerQueries)
|
|
805
|
-
|
|
806
816
|
// `SELECT from Authors { books.genre as genreOfBooks { name } } becomes `SELECT from Books:genre as genreOfBooks`
|
|
807
817
|
const from = { ref: subqueryFromRef, as: uniqueSubqueryAlias }
|
|
808
818
|
const subqueryBase = Object.fromEntries(
|
|
@@ -815,7 +825,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
815
825
|
from,
|
|
816
826
|
columns: JSON.parse(JSON.stringify(column.expand)),
|
|
817
827
|
expand: true,
|
|
818
|
-
one: column.$refLinks
|
|
828
|
+
one: column.$refLinks.at(-1).definition.is2one,
|
|
819
829
|
},
|
|
820
830
|
}
|
|
821
831
|
const expanded = transformSubquery(subquery)
|
|
@@ -851,6 +861,74 @@ function cqn4sql(originalQuery, model) {
|
|
|
851
861
|
}
|
|
852
862
|
return subq
|
|
853
863
|
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Generates a special subquery for the `expand` of the `column`.
|
|
867
|
+
* All columns in the `expand` must be part of the GROUP BY clause of the main query.
|
|
868
|
+
* If this is the case, the subqueries columns match the corresponding references of the group by.
|
|
869
|
+
* Nested expands are also supported.
|
|
870
|
+
*
|
|
871
|
+
* @param {Object} column - To expand.
|
|
872
|
+
* @param {Array} baseRef - The base reference for the expanded column.
|
|
873
|
+
* @param {string} subqueryAlias - The alias of the `expand` subquery column.
|
|
874
|
+
* @returns {Object} - The subquery object.
|
|
875
|
+
* @throws {Error} - If one of the `ref`s in the `column.expand` is not part of the GROUP BY clause.
|
|
876
|
+
*/
|
|
877
|
+
function _subqueryForGroupBy(column, baseRef, subqueryAlias) {
|
|
878
|
+
const groupByLookup = new Map(
|
|
879
|
+
transformedQuery.SELECT.groupBy.map(c => [c.ref && c.ref.map(refWithConditions).join('.'), c]),
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
// to be attached to dummy query
|
|
883
|
+
const elements = {}
|
|
884
|
+
const expandedColumns = column.expand.map(expand => {
|
|
885
|
+
const fullRef = [...baseRef, ...expand.ref]
|
|
886
|
+
|
|
887
|
+
if (expand.expand) {
|
|
888
|
+
const nested = _subqueryForGroupBy(expand, fullRef, expand.as || expand.ref.map(idOnly).join('_'))
|
|
889
|
+
elements[expand.as || expand.ref.map(idOnly).join('_')] = nested
|
|
890
|
+
return nested
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const groupByRef = groupByLookup.get(fullRef.map(refWithConditions).join('.'))
|
|
894
|
+
if (!groupByRef) {
|
|
895
|
+
throw new Error(
|
|
896
|
+
`The expanded column "${fullRef.map(refWithConditions).join('.')}" must be part of the group by clause`,
|
|
897
|
+
)
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const columnCopy = Object.create(groupByRef)
|
|
901
|
+
if (expand.as) {
|
|
902
|
+
columnCopy.as = expand.as
|
|
903
|
+
}
|
|
904
|
+
if (columnCopy.isJoinRelevant) {
|
|
905
|
+
const tableAlias = getQuerySourceName(columnCopy)
|
|
906
|
+
const name = calculateElementName(columnCopy)
|
|
907
|
+
columnCopy.ref = [tableAlias, name]
|
|
908
|
+
}
|
|
909
|
+
elements[expand.as || expand.ref.map(idOnly).join('_')] = columnCopy.$refLinks.at(-1).definition
|
|
910
|
+
return columnCopy
|
|
911
|
+
})
|
|
912
|
+
|
|
913
|
+
const SELECT = {
|
|
914
|
+
from: null,
|
|
915
|
+
columns: expandedColumns,
|
|
916
|
+
}
|
|
917
|
+
return Object.defineProperties(
|
|
918
|
+
{},
|
|
919
|
+
{
|
|
920
|
+
SELECT: {
|
|
921
|
+
value: Object.defineProperties(SELECT, {
|
|
922
|
+
expand: { value: true, writable: true }, // non-enumerable
|
|
923
|
+
one: { value: column.$refLinks.at(-1).definition.is2one, writable: true }, // non-enumerable
|
|
924
|
+
}),
|
|
925
|
+
enumerable: true,
|
|
926
|
+
},
|
|
927
|
+
as: { value: subqueryAlias, enumerable: true, writable: true },
|
|
928
|
+
elements: { value: elements }, // non-enumerable
|
|
929
|
+
},
|
|
930
|
+
)
|
|
931
|
+
}
|
|
854
932
|
}
|
|
855
933
|
|
|
856
934
|
function getTransformedOrderByGroupBy(columns, inOrderBy = false) {
|
|
@@ -1573,7 +1651,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1573
1651
|
transformedFrom.args.push(f)
|
|
1574
1652
|
}
|
|
1575
1653
|
})
|
|
1576
|
-
return { transformedFrom }
|
|
1654
|
+
return { transformedFrom, transformedWhere: existingWhere }
|
|
1577
1655
|
} else if (from.SELECT) {
|
|
1578
1656
|
transformedFrom = transformSubquery(from)
|
|
1579
1657
|
if (from.as) {
|
|
@@ -1583,7 +1661,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1583
1661
|
// select from anonymous query, use artificial alias
|
|
1584
1662
|
transformedFrom.as = Object.keys(originalQuery.sources)[0]
|
|
1585
1663
|
}
|
|
1586
|
-
return { transformedFrom }
|
|
1664
|
+
return { transformedFrom, transformedWhere: existingWhere }
|
|
1587
1665
|
} else {
|
|
1588
1666
|
return _transformFrom()
|
|
1589
1667
|
}
|
|
@@ -2251,4 +2329,12 @@ function setElementOnColumns(col, element) {
|
|
|
2251
2329
|
|
|
2252
2330
|
const getName = col => col.as || col.ref?.at(-1)
|
|
2253
2331
|
const idOnly = ref => ref.id || ref
|
|
2332
|
+
const refWithConditions = step => {
|
|
2333
|
+
let appendix
|
|
2334
|
+
const { args, where } = step
|
|
2335
|
+
if (where && args) appendix = JSON.stringify(where) + JSON.stringify(args)
|
|
2336
|
+
else if (where) appendix = JSON.stringify(where)
|
|
2337
|
+
else if (args) appendix = JSON.stringify(args)
|
|
2338
|
+
return appendix ? step.id + appendix : step
|
|
2339
|
+
}
|
|
2254
2340
|
const is_regexp = x => x?.constructor?.name === 'RegExp' // NOTE: x instanceof RegExp doesn't work in repl
|
package/lib/infer/index.js
CHANGED
|
@@ -1013,9 +1013,11 @@ function infer(originalQuery, model) {
|
|
|
1013
1013
|
}
|
|
1014
1014
|
return true
|
|
1015
1015
|
}
|
|
1016
|
-
if (assoc
|
|
1016
|
+
if (assoc) {
|
|
1017
1017
|
// foreign key access without filters never join relevant
|
|
1018
|
-
return false
|
|
1018
|
+
if (assoc.keys?.some(key => key.ref.every((step, j) => column.ref[i + j] === step))) return false
|
|
1019
|
+
// <assoc>.<anotherAssoc>.<…> is join relevant as <anotherAssoc> is not fk of <assoc>
|
|
1020
|
+
return true
|
|
1019
1021
|
}
|
|
1020
1022
|
if (link.definition.target && link.definition.keys) {
|
|
1021
1023
|
if (column.ref[i + 1] || assoc) fkAccess = false
|
|
@@ -1198,6 +1200,8 @@ function infer(originalQuery, model) {
|
|
|
1198
1200
|
firstStepIsEntity = true
|
|
1199
1201
|
if (arg.$refLinks.length === 1) return `${cur.definition.name}`
|
|
1200
1202
|
return `${cur.definition.name}`
|
|
1203
|
+
} else if (cur.definition.SELECT) {
|
|
1204
|
+
return `${cur.definition.as}`
|
|
1201
1205
|
}
|
|
1202
1206
|
const dot = i === 1 && firstStepIsEntity ? ':' : '.' // divide with colon if first step is entity
|
|
1203
1207
|
return res !== '' ? res + dot + cur.definition.name : cur.definition.name
|
package/package.json
CHANGED