@cap-js/db-service 1.8.0 → 1.9.0
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 +25 -0
- package/lib/cql-functions.js +0 -14
- package/lib/cqn2sql.js +78 -33
- package/lib/cqn4sql.js +49 -23
- package/lib/deep-queries.js +27 -3
- package/lib/fill-in-keys.js +4 -2
- package/lib/infer/index.js +92 -38
- package/lib/infer/join-tree.js +2 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,31 @@
|
|
|
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.9.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.8.0...db-service-v1.9.0) (2024-05-08)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
* Add missing `func` cqn structures ([#629](https://github.com/cap-js/cds-dbs/issues/629)) ([9d7539a](https://github.com/cap-js/cds-dbs/commit/9d7539ab0fc7e70a6a00c0bd9cb4b3e362976e16))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
* **`order by`:** reject non-fk traversals of own columns in order by ([#599](https://github.com/cap-js/cds-dbs/issues/599)) ([3288d63](https://github.com/cap-js/cds-dbs/commit/3288d63f0ee6a96580a3b2138ecb24a944371cf1))
|
|
18
|
+
* Align all quote functions with @sap/cds-compiler ([#619](https://github.com/cap-js/cds-dbs/issues/619)) ([42e9828](https://github.com/cap-js/cds-dbs/commit/42e9828baf11ec55281ea634ce56ce93e6741b91))
|
|
19
|
+
* assign artificial alias if selecting from anonymous subquery ([#608](https://github.com/cap-js/cds-dbs/issues/608)) ([e1a7711](https://github.com/cap-js/cds-dbs/commit/e1a77119f0a5241cfe4f50a37a473f2325ba5bde))
|
|
20
|
+
* avoid spread operator ([#630](https://github.com/cap-js/cds-dbs/issues/630)) ([a39fb65](https://github.com/cap-js/cds-dbs/commit/a39fb65f9419fe60e0324741039d004b40082903))
|
|
21
|
+
* flat update with arbitrary where clauses ([#598](https://github.com/cap-js/cds-dbs/issues/598)) ([f108798](https://github.com/cap-js/cds-dbs/commit/f108798c6c8035f9cdd0b9c6b8f334f1454c2faa))
|
|
22
|
+
* improved `=` and `!=` with val `null` ([#626](https://github.com/cap-js/cds-dbs/issues/626)) ([cbcfe3b](https://github.com/cap-js/cds-dbs/commit/cbcfe3b15e8ebcf7e844dc5406e4bc228d4c94c9))
|
|
23
|
+
* Improved placeholders and limit clause ([#567](https://github.com/cap-js/cds-dbs/issues/567)) ([d5d5dbb](https://github.com/cap-js/cds-dbs/commit/d5d5dbb7219bcef6134440715cf756fdd439f076))
|
|
24
|
+
* multiple result responses ([#602](https://github.com/cap-js/cds-dbs/issues/602)) ([bf0bed4](https://github.com/cap-js/cds-dbs/commit/bf0bed4549fe816e35481b0c9a7547a522a5a593))
|
|
25
|
+
* only consider persisted columns for simple operations ([#592](https://github.com/cap-js/cds-dbs/issues/592)) ([6e31bda](https://github.com/cap-js/cds-dbs/commit/6e31bda1bb15b1770b75c8971773806a26f7d452))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
* require `>= sap/cds@7.9.0` ([#627](https://github.com/cap-js/cds-dbs/issues/627)) ([f4d09e2](https://github.com/cap-js/cds-dbs/commit/f4d09e27c3b07dd88925e196aefc1087d8357f7a))
|
|
31
|
+
|
|
7
32
|
## [1.8.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.7.0...db-service-v1.8.0) (2024-04-12)
|
|
8
33
|
|
|
9
34
|
|
package/lib/cql-functions.js
CHANGED
|
@@ -215,20 +215,6 @@ const StandardFunctions = {
|
|
|
215
215
|
*/
|
|
216
216
|
mindatetime: () => "'0001-01-01T00:00:00.000Z'",
|
|
217
217
|
|
|
218
|
-
// odata spec defines the date time offset type as a normal ISO time stamp
|
|
219
|
-
// Where the timezone can either be 'Z' (for UTC) or [+|-]xx:xx for the time offset
|
|
220
|
-
/**
|
|
221
|
-
* Generates SQL statement that produces the offset in minutes of a given date time offset string
|
|
222
|
-
* @param {string} x
|
|
223
|
-
* @returns {string}
|
|
224
|
-
*/
|
|
225
|
-
totaloffsetminutes: x => `case
|
|
226
|
-
when substr(${x}, length(${x})) = 'z' then 0
|
|
227
|
-
else sign( cast( substr(${x}, length(${x}) - 5) as Integer )) *
|
|
228
|
-
( cast( strftime('%H', substr(${x}, length(${x}) - 4 )) as Integer ) * 60 +
|
|
229
|
-
cast( strftime('%M', substr(${x},length(${x}) - 4 )) as Integer ))
|
|
230
|
-
end`,
|
|
231
|
-
|
|
232
218
|
// odata spec defines the value format for totalseconds as a duration like: P12DT23H59M59.999999999999S
|
|
233
219
|
// P -> duration indicator
|
|
234
220
|
// D -> days, T -> Time seperator, H -> hours, M -> minutes, S -> fractional seconds
|
package/lib/cqn2sql.js
CHANGED
|
@@ -165,12 +165,14 @@ class CQN2SQLRenderer {
|
|
|
165
165
|
/** @type {Object<string,import('@sap/cds/apis/csn').Definition>} */
|
|
166
166
|
static TypeMap = {
|
|
167
167
|
// Utilizing cds.linked inheritance
|
|
168
|
+
UUID: () => `NVARCHAR(36)`,
|
|
168
169
|
String: e => `NVARCHAR(${e.length || 5000})`,
|
|
169
170
|
Binary: e => `VARBINARY(${e.length || 5000})`,
|
|
170
|
-
|
|
171
|
-
Int32: () => 'INTEGER',
|
|
171
|
+
UInt8: () => 'TINYINT',
|
|
172
172
|
Int16: () => 'SMALLINT',
|
|
173
|
-
|
|
173
|
+
Int32: () => 'INT',
|
|
174
|
+
Int64: () => 'BIGINT',
|
|
175
|
+
Integer: () => 'INT',
|
|
174
176
|
Integer64: () => 'BIGINT',
|
|
175
177
|
LargeString: () => 'NCLOB',
|
|
176
178
|
LargeBinary: () => 'BLOB',
|
|
@@ -178,12 +180,11 @@ class CQN2SQLRenderer {
|
|
|
178
180
|
Composition: () => false,
|
|
179
181
|
array: () => 'NCLOB',
|
|
180
182
|
// HANA types
|
|
181
|
-
|
|
182
|
-
'cds.hana.TINYINT': () => 'REAL',
|
|
183
|
+
'cds.hana.TINYINT': () => 'TINYINT',
|
|
183
184
|
'cds.hana.REAL': () => 'REAL',
|
|
184
185
|
'cds.hana.CHAR': e => `CHAR(${e.length || 1})`,
|
|
185
186
|
'cds.hana.ST_POINT': () => 'ST_POINT',
|
|
186
|
-
'cds.hana.ST_GEOMETRY': () => '
|
|
187
|
+
'cds.hana.ST_GEOMETRY': () => 'ST_GEOMETRY',
|
|
187
188
|
}
|
|
188
189
|
|
|
189
190
|
// DROP Statements ------------------------------------------------
|
|
@@ -211,7 +212,7 @@ class CQN2SQLRenderer {
|
|
|
211
212
|
if (from?.join && !q.SELECT.columns) {
|
|
212
213
|
throw new Error('CQN query using joins must specify the selected columns.')
|
|
213
214
|
}
|
|
214
|
-
|
|
215
|
+
|
|
215
216
|
// REVISIT: When selecting from an entity that is not in the model the from.where are not normalized (as cqn4sql is skipped)
|
|
216
217
|
if (!where && from?.ref?.length === 1 && from.ref[0]?.where) where = from.ref[0]?.where
|
|
217
218
|
let columns = this.SELECT_columns(q)
|
|
@@ -290,7 +291,7 @@ class CQN2SQLRenderer {
|
|
|
290
291
|
return `extensions__->${this.string('$."' + x.element.name + '"')} as ${x.as || x.element.name}`
|
|
291
292
|
}
|
|
292
293
|
///////////////////////////////////////////////////////////////////////////////////////
|
|
293
|
-
let sql = this.expr(x)
|
|
294
|
+
let sql = this.expr({ param: false, __proto__: x })
|
|
294
295
|
let alias = this.column_alias4(x, q)
|
|
295
296
|
if (alias) sql += ' as ' + this.quote(alias)
|
|
296
297
|
return sql
|
|
@@ -302,7 +303,7 @@ class CQN2SQLRenderer {
|
|
|
302
303
|
* @returns {string}
|
|
303
304
|
*/
|
|
304
305
|
column_alias4(x) {
|
|
305
|
-
return typeof x.as === 'string' ? x.as : x.func
|
|
306
|
+
return typeof x.as === 'string' ? x.as : x.func || x.val
|
|
306
307
|
}
|
|
307
308
|
|
|
308
309
|
/**
|
|
@@ -387,7 +388,7 @@ class CQN2SQLRenderer {
|
|
|
387
388
|
*/
|
|
388
389
|
limit({ rows, offset }) {
|
|
389
390
|
if (!rows) throw new Error('Rows parameter is missing in SELECT.limit(rows, offset)')
|
|
390
|
-
return !offset ?
|
|
391
|
+
return !offset ? this.val(rows) : `${this.val(rows)} OFFSET ${this.val(offset)}`
|
|
391
392
|
}
|
|
392
393
|
|
|
393
394
|
/**
|
|
@@ -749,8 +750,9 @@ class CQN2SQLRenderer {
|
|
|
749
750
|
if (_with) _add(_with, x => this.expr(x))
|
|
750
751
|
function _add(data, sql4) {
|
|
751
752
|
for (let c in data) {
|
|
752
|
-
|
|
753
|
-
|
|
753
|
+
const columnExistsInDatabase =
|
|
754
|
+
elements && c in elements && !elements[c].virtual && !elements[c].isAssociation && !elements[c].value
|
|
755
|
+
if (!elements || columnExistsInDatabase) {
|
|
754
756
|
columns.push({ name: c, sql: sql4(data[c]) })
|
|
755
757
|
}
|
|
756
758
|
}
|
|
@@ -798,8 +800,8 @@ class CQN2SQLRenderer {
|
|
|
798
800
|
if (x.param) return wrap(this.param(x))
|
|
799
801
|
if ('ref' in x) return wrap(this.ref(x))
|
|
800
802
|
if ('val' in x) return wrap(this.val(x))
|
|
801
|
-
if ('xpr' in x) return wrap(this.xpr(x))
|
|
802
803
|
if ('func' in x) return wrap(this.func(x))
|
|
804
|
+
if ('xpr' in x) return wrap(this.xpr(x))
|
|
803
805
|
if ('list' in x) return wrap(this.list(x))
|
|
804
806
|
if ('SELECT' in x) return wrap(`(${this.SELECT(x)})`)
|
|
805
807
|
else throw cds.error`Unsupported expr: ${x}`
|
|
@@ -831,18 +833,32 @@ class CQN2SQLRenderer {
|
|
|
831
833
|
operator(x, i, xpr) {
|
|
832
834
|
|
|
833
835
|
// Translate = to IS NULL for rhs operand being NULL literal
|
|
834
|
-
if (x === '=') return xpr[i + 1]?.val === null
|
|
836
|
+
if (x === '=') return xpr[i + 1]?.val === null
|
|
837
|
+
? _inline_null(xpr[i + 1]) || 'is'
|
|
838
|
+
: '='
|
|
835
839
|
|
|
836
840
|
// Translate == to IS NOT NULL for rhs operand being NULL literal, otherwise ...
|
|
837
841
|
// Translate == to IS NOT DISTINCT FROM, unless both operands cannot be NULL
|
|
838
|
-
if (x === '==') return xpr[i + 1]?.val === null
|
|
842
|
+
if (x === '==') return xpr[i + 1]?.val === null
|
|
843
|
+
? _inline_null(xpr[i + 1]) || 'is'
|
|
844
|
+
: _not_null(i - 1) && _not_null(i + 1)
|
|
845
|
+
? '='
|
|
846
|
+
: this.is_not_distinct_from_
|
|
839
847
|
|
|
840
848
|
// Translate != to IS NULL for rhs operand being NULL literal, otherwise...
|
|
841
849
|
// Translate != to IS DISTINCT FROM, unless both operands cannot be NULL
|
|
842
|
-
if (x === '!=') return xpr[i + 1]?.val === null
|
|
850
|
+
if (x === '!=') return xpr[i + 1]?.val === null
|
|
851
|
+
? _inline_null(xpr[i + 1]) || 'is not'
|
|
852
|
+
: _not_null(i - 1) && _not_null(i + 1)
|
|
853
|
+
? '<>'
|
|
854
|
+
: this.is_distinct_from_
|
|
843
855
|
|
|
844
856
|
else return x
|
|
845
857
|
|
|
858
|
+
function _inline_null(n) {
|
|
859
|
+
n.param = false
|
|
860
|
+
}
|
|
861
|
+
|
|
846
862
|
/** Checks if the operand at xpr[i+-1] can be NULL. @returns true if not */
|
|
847
863
|
function _not_null(i) {
|
|
848
864
|
const operand = xpr[i]
|
|
@@ -883,27 +899,34 @@ class CQN2SQLRenderer {
|
|
|
883
899
|
}
|
|
884
900
|
|
|
885
901
|
/**
|
|
886
|
-
* Renders a value into the correct SQL syntax
|
|
902
|
+
* Renders a value into the correct SQL syntax or a placeholder for a prepared statement
|
|
887
903
|
* @param {import('./infer/cqn').val} param0
|
|
888
904
|
* @returns {string} SQL
|
|
889
905
|
*/
|
|
890
906
|
val({ val, param }) {
|
|
891
907
|
switch (typeof val) {
|
|
892
908
|
case 'function': throw new Error('Function values not supported.')
|
|
893
|
-
case 'undefined':
|
|
909
|
+
case 'undefined': val = null
|
|
910
|
+
break
|
|
894
911
|
case 'boolean': return `${val}`
|
|
895
|
-
case 'number': return `${val}` // REVISIT for HANA
|
|
896
912
|
case 'object':
|
|
897
|
-
if (val
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
913
|
+
if (val !== null) {
|
|
914
|
+
if (val instanceof Date) val = val.toJSON() // returns null if invalid
|
|
915
|
+
else if (val instanceof Readable); // go on with default below
|
|
916
|
+
else if (Buffer.isBuffer(val)); // go on with default below
|
|
917
|
+
else if (is_regexp(val)) val = val.source
|
|
918
|
+
else val = JSON.stringify(val)
|
|
919
|
+
}
|
|
904
920
|
}
|
|
905
|
-
if (!this.values || param === false)
|
|
906
|
-
|
|
921
|
+
if (!this.values || param === false) {
|
|
922
|
+
switch (typeof val) {
|
|
923
|
+
case 'string': return this.string(val)
|
|
924
|
+
case 'object': return 'NULL'
|
|
925
|
+
default:
|
|
926
|
+
return `${val}`
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
this.values.push(val)
|
|
907
930
|
return '?'
|
|
908
931
|
}
|
|
909
932
|
|
|
@@ -913,9 +936,32 @@ class CQN2SQLRenderer {
|
|
|
913
936
|
* @param {import('./infer/cqn').func} param0
|
|
914
937
|
* @returns {string} SQL
|
|
915
938
|
*/
|
|
916
|
-
func({ func, args }) {
|
|
917
|
-
|
|
918
|
-
|
|
939
|
+
func({ func, args, xpr }) {
|
|
940
|
+
const wrap = e => (e === '*' ? e : { __proto__: e, toString: (x = e) => this.expr(x) })
|
|
941
|
+
args = args || []
|
|
942
|
+
if (Array.isArray(args)) {
|
|
943
|
+
args = args.map(wrap)
|
|
944
|
+
} else if (typeof args === 'object') {
|
|
945
|
+
const org = args
|
|
946
|
+
const wrapped = {
|
|
947
|
+
toString: () => {
|
|
948
|
+
const ret = []
|
|
949
|
+
for (const prop in org) {
|
|
950
|
+
ret.push(`${this.quote(prop)} => ${wrapped[prop]}`)
|
|
951
|
+
}
|
|
952
|
+
return ret.join(',')
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
for (const prop in args) {
|
|
956
|
+
wrapped[prop] = wrap(args[prop])
|
|
957
|
+
}
|
|
958
|
+
args = wrapped
|
|
959
|
+
} else {
|
|
960
|
+
cds.error`Invalid arguments provided for function '${func}' (${args})`
|
|
961
|
+
}
|
|
962
|
+
const fn = this.class.Functions[func]?.apply(this.class.Functions, args) || `${func}(${args})`
|
|
963
|
+
if (xpr) return `${fn} ${this.xpr({ xpr })}`
|
|
964
|
+
return fn
|
|
919
965
|
}
|
|
920
966
|
|
|
921
967
|
/**
|
|
@@ -967,8 +1013,7 @@ class CQN2SQLRenderer {
|
|
|
967
1013
|
quote(s) {
|
|
968
1014
|
if (typeof s !== 'string') return '"' + s + '"'
|
|
969
1015
|
if (s.includes('"')) return '"' + s.replace(/"/g, '""') + '"'
|
|
970
|
-
|
|
971
|
-
if (s in this.class.ReservedWords || /^\d|[$' ?@./\\]/.test(s)) return '"' + s + '"'
|
|
1016
|
+
if (s in this.class.ReservedWords || !/^[A-Za-z_][A-Za-z_$0-9]*$/.test(s)) return '"' + s + '"'
|
|
972
1017
|
return s
|
|
973
1018
|
}
|
|
974
1019
|
|
package/lib/cqn4sql.js
CHANGED
|
@@ -488,21 +488,23 @@ function cqn4sql(originalQuery, model) {
|
|
|
488
488
|
}
|
|
489
489
|
|
|
490
490
|
function getTransformedColumn(col) {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
return xpr
|
|
495
|
-
} else if (col.func) {
|
|
496
|
-
const func = {
|
|
491
|
+
let ret
|
|
492
|
+
if (col.func) {
|
|
493
|
+
ret = {
|
|
497
494
|
func: col.func,
|
|
498
|
-
args:
|
|
495
|
+
args: getTransformedFunctionArgs(col.args),
|
|
499
496
|
as: col.func, // may be overwritten by the explicit alias
|
|
500
497
|
}
|
|
501
|
-
if (col.cast) func.cast = col.cast
|
|
502
|
-
return func
|
|
503
|
-
} else {
|
|
504
|
-
return copy(col)
|
|
505
498
|
}
|
|
499
|
+
if (col.xpr) {
|
|
500
|
+
ret ??= {}
|
|
501
|
+
ret.xpr = getTransformedTokenStream(col.xpr)
|
|
502
|
+
}
|
|
503
|
+
if (ret) {
|
|
504
|
+
if (col.cast) ret.cast = col.cast
|
|
505
|
+
return ret
|
|
506
|
+
}
|
|
507
|
+
return copy(col)
|
|
506
508
|
}
|
|
507
509
|
|
|
508
510
|
function handleEmptyColumns(columns) {
|
|
@@ -537,7 +539,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
537
539
|
} else if (val) {
|
|
538
540
|
res = { val }
|
|
539
541
|
} else if (func) {
|
|
540
|
-
res = { args:
|
|
542
|
+
res = { args: getTransformedFunctionArgs(value.args, baseLink), func: value.func }
|
|
541
543
|
}
|
|
542
544
|
if (!omitAlias) res.as = column.as || column.name || column.flatName
|
|
543
545
|
return res
|
|
@@ -931,7 +933,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
931
933
|
let transformedColumn
|
|
932
934
|
if (col.SELECT) transformedColumn = transformSubquery(col)
|
|
933
935
|
else if (col.xpr) transformedColumn = { xpr: getTransformedTokenStream(col.xpr) }
|
|
934
|
-
else if (col.func) transformedColumn = { args:
|
|
936
|
+
else if (col.func) transformedColumn = { args: getTransformedFunctionArgs(col.args), func: col.func }
|
|
935
937
|
// val
|
|
936
938
|
else transformedColumn = copy(col)
|
|
937
939
|
if (col.sort) transformedColumn.sort = col.sort
|
|
@@ -1427,15 +1429,13 @@ function cqn4sql(originalQuery, model) {
|
|
|
1427
1429
|
}
|
|
1428
1430
|
} else if (token.SELECT) {
|
|
1429
1431
|
result = transformSubquery(token)
|
|
1430
|
-
} else
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
return t
|
|
1438
|
-
})
|
|
1432
|
+
} else {
|
|
1433
|
+
if (token.xpr) {
|
|
1434
|
+
result.xpr = getTransformedTokenStream(token.xpr, $baseLink)
|
|
1435
|
+
}
|
|
1436
|
+
if (token.func && token.args) {
|
|
1437
|
+
result.args = getTransformedFunctionArgs(token.args, $baseLink)
|
|
1438
|
+
}
|
|
1439
1439
|
}
|
|
1440
1440
|
|
|
1441
1441
|
transformedTokenStream.push(result)
|
|
@@ -1576,9 +1576,13 @@ function cqn4sql(originalQuery, model) {
|
|
|
1576
1576
|
return { transformedFrom }
|
|
1577
1577
|
} else if (from.SELECT) {
|
|
1578
1578
|
transformedFrom = transformSubquery(from)
|
|
1579
|
-
if (from.as)
|
|
1579
|
+
if (from.as) {
|
|
1580
1580
|
// preserve explicit TA
|
|
1581
1581
|
transformedFrom.as = from.as
|
|
1582
|
+
} else {
|
|
1583
|
+
// select from anonymous query, use artificial alias
|
|
1584
|
+
transformedFrom.as = Object.keys(originalQuery.sources)[0]
|
|
1585
|
+
}
|
|
1582
1586
|
return { transformedFrom }
|
|
1583
1587
|
} else {
|
|
1584
1588
|
return _transformFrom()
|
|
@@ -2138,6 +2142,27 @@ function cqn4sql(originalQuery, model) {
|
|
|
2138
2142
|
return getLastStringSegment(inferred.$combinedElements[node.ref[0].id || node.ref[0]]?.[0].index)
|
|
2139
2143
|
}
|
|
2140
2144
|
}
|
|
2145
|
+
function getTransformedFunctionArgs(args, $baseLink = null) {
|
|
2146
|
+
let result = null
|
|
2147
|
+
if (Array.isArray(args)) {
|
|
2148
|
+
result = args.map(t => {
|
|
2149
|
+
if (!t.val)
|
|
2150
|
+
// this must not be touched
|
|
2151
|
+
return getTransformedTokenStream([t], $baseLink)[0]
|
|
2152
|
+
return t
|
|
2153
|
+
})
|
|
2154
|
+
} else if (typeof args === 'object') {
|
|
2155
|
+
result = {}
|
|
2156
|
+
for (const prop in args) {
|
|
2157
|
+
const t = args[prop]
|
|
2158
|
+
if (!t.val)
|
|
2159
|
+
// this must not be touched
|
|
2160
|
+
result[prop] = getTransformedTokenStream([t], $baseLink)[0]
|
|
2161
|
+
else result[prop] = t
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
return result
|
|
2165
|
+
}
|
|
2141
2166
|
}
|
|
2142
2167
|
|
|
2143
2168
|
module.exports = Object.assign(cqn4sql, {
|
|
@@ -2222,6 +2247,7 @@ function setElementOnColumns(col, element) {
|
|
|
2222
2247
|
writable: true,
|
|
2223
2248
|
})
|
|
2224
2249
|
}
|
|
2250
|
+
|
|
2225
2251
|
const getName = col => col.as || col.ref?.at(-1)
|
|
2226
2252
|
const idOnly = ref => ref.id || ref
|
|
2227
2253
|
const is_regexp = x => x?.constructor?.name === 'RegExp' // NOTE: x instanceof RegExp doesn't work in repl
|
package/lib/deep-queries.js
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
const cds = require('@sap/cds')
|
|
2
|
-
const { compareJson } = require('@sap/cds/libx/_runtime/cds-services/services/utils/compareJson')
|
|
3
2
|
const { _target_name4 } = require('./SQLService')
|
|
3
|
+
const InsertResult = require('../lib/InsertResults')
|
|
4
|
+
|
|
5
|
+
// REVISIT: remove old path with cds^8
|
|
6
|
+
let _compareJson
|
|
7
|
+
const compareJson = (...args) => {
|
|
8
|
+
if (!_compareJson) {
|
|
9
|
+
try {
|
|
10
|
+
// new path
|
|
11
|
+
_compareJson = require('@sap/cds/libx/_runtime/common/utils/compareJson').compareJson
|
|
12
|
+
} catch {
|
|
13
|
+
// old path
|
|
14
|
+
_compareJson = require('@sap/cds/libx/_runtime/cds-services/services/utils/compareJson').compareJson
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return _compareJson(...args)
|
|
18
|
+
}
|
|
4
19
|
|
|
5
20
|
const handledDeep = Symbol('handledDeep')
|
|
6
21
|
|
|
@@ -35,7 +50,15 @@ async function onDeep(req, next) {
|
|
|
35
50
|
if (query.UPDATE) return this.onUPDATE({ query })
|
|
36
51
|
if (query.DELETE) return this.onSIMPLE({ query })
|
|
37
52
|
}))
|
|
38
|
-
return
|
|
53
|
+
return (
|
|
54
|
+
beforeData.length ||
|
|
55
|
+
new InsertResult(query, [
|
|
56
|
+
{
|
|
57
|
+
changes: Array.isArray(req.data) ? req.data.length : 1,
|
|
58
|
+
...(res[0]?.results[0]?.lastInsertRowid ? { lastInsertRowid: res[0].results[0].lastInsertRowid } : {}),
|
|
59
|
+
},
|
|
60
|
+
])
|
|
61
|
+
)
|
|
39
62
|
}
|
|
40
63
|
|
|
41
64
|
const hasDeep = (q, target) => {
|
|
@@ -239,7 +262,7 @@ const _getDeepQueries = (diff, target, root = false) => {
|
|
|
239
262
|
queries.push(cqn)
|
|
240
263
|
}
|
|
241
264
|
|
|
242
|
-
queries.push(
|
|
265
|
+
for (const q of subQueries) queries.push(q)
|
|
243
266
|
}
|
|
244
267
|
|
|
245
268
|
const insertQueries = new Map()
|
|
@@ -265,4 +288,5 @@ module.exports = {
|
|
|
265
288
|
onDeep,
|
|
266
289
|
getDeepQueries,
|
|
267
290
|
getExpandForDeep,
|
|
291
|
+
hasDeep,
|
|
268
292
|
}
|
package/lib/fill-in-keys.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const cds = require('@sap/cds')
|
|
2
|
+
const { hasDeep } = require('../lib/deep-queries')
|
|
2
3
|
|
|
3
4
|
// REVISIT: very deep & fragile dependencies to internal modules -> copy these into here
|
|
4
5
|
const propagateForeignKeys = require('@sap/cds/libx/_runtime/common/utils/propagateForeignKeys')
|
|
@@ -57,9 +58,10 @@ const generateUUIDandPropagateKeys = (entity, data, event) => {
|
|
|
57
58
|
module.exports = async function fill_in_keys(req, next) {
|
|
58
59
|
// REVISIT dummy handler until we have input processing
|
|
59
60
|
if (!req.target || !this.model || req.target._unresolved) return next()
|
|
60
|
-
|
|
61
|
+
// only for deep update
|
|
62
|
+
if (req.event === 'UPDATE' && hasDeep(req.query, req.target)) {
|
|
61
63
|
// REVISIT for deep update we need to inject the keys first
|
|
62
|
-
enrichDataWithKeysFromWhere
|
|
64
|
+
enrichDataWithKeysFromWhere(req.data, req, this)
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
// REVISIT no input processing for INPUT with rows/values
|
package/lib/infer/index.js
CHANGED
|
@@ -123,8 +123,11 @@ function infer(originalQuery, model) {
|
|
|
123
123
|
} else if (from.args) {
|
|
124
124
|
from.args.forEach(a => inferTarget(a, querySources))
|
|
125
125
|
} else if (from.SELECT) {
|
|
126
|
-
infer(from, model) // we need the .elements in the sources
|
|
127
|
-
|
|
126
|
+
const subqueryInFrom = infer(from, model) // we need the .elements in the sources
|
|
127
|
+
// if no explicit alias is provided, we make up one
|
|
128
|
+
const subqueryAlias =
|
|
129
|
+
from.as || subqueryInFrom.joinTree.addNextAvailableTableAlias('__select__', subqueryInFrom.outerQueries)
|
|
130
|
+
querySources[subqueryAlias] = { definition: from }
|
|
128
131
|
} else if (typeof from === 'string') {
|
|
129
132
|
// TODO: Create unique alias, what about duplicates?
|
|
130
133
|
const definition = getDefinition(from) || cds.error`"${from}" not found in the definitions of your model`
|
|
@@ -163,7 +166,7 @@ function infer(originalQuery, model) {
|
|
|
163
166
|
function attachRefLinksToArg(arg, $baseLink = null, expandOrExists = false) {
|
|
164
167
|
const { ref, xpr, args, list } = arg
|
|
165
168
|
if (xpr) xpr.forEach(t => attachRefLinksToArg(t, $baseLink, expandOrExists))
|
|
166
|
-
if (args) args
|
|
169
|
+
if (args) applyToFunctionArgs(args, attachRefLinksToArg, [$baseLink, expandOrExists])
|
|
167
170
|
if (list) list.forEach(arg => attachRefLinksToArg(arg, $baseLink, expandOrExists))
|
|
168
171
|
if (!ref) return
|
|
169
172
|
init$refLinks(arg)
|
|
@@ -180,13 +183,15 @@ function infer(originalQuery, model) {
|
|
|
180
183
|
// only fk access in infix filter
|
|
181
184
|
const nextStep = ref[1]?.id || ref[1]
|
|
182
185
|
// no unmanaged assoc in infix filter path
|
|
183
|
-
if (!expandOrExists && e.on)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
186
|
+
if (!expandOrExists && e.on) {
|
|
187
|
+
const err = `Unexpected unmanaged association “${e.name}” in filter expression of “${$baseLink.definition.name}”`
|
|
188
|
+
throw new Error(err)
|
|
189
|
+
}
|
|
187
190
|
// no non-fk traversal in infix filter
|
|
188
191
|
if (!expandOrExists && nextStep && !isForeignKeyOf(nextStep, e))
|
|
189
|
-
throw new Error(
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Only foreign keys of “${e.name}” can be accessed in infix filter, but found “${nextStep}”`,
|
|
194
|
+
)
|
|
190
195
|
}
|
|
191
196
|
arg.$refLinks.push({ definition: e, target: definition })
|
|
192
197
|
// filter paths are flattened
|
|
@@ -301,10 +306,15 @@ function infer(originalQuery, model) {
|
|
|
301
306
|
if (queryElements[as]) cds.error`Duplicate definition of element “${as}”`
|
|
302
307
|
if (col.xpr || col.SELECT) {
|
|
303
308
|
queryElements[as] = getElementForXprOrSubquery(col)
|
|
304
|
-
}
|
|
305
|
-
|
|
309
|
+
}
|
|
310
|
+
if (col.func) {
|
|
311
|
+
if (col.args) {
|
|
312
|
+
// {func}.args are optional
|
|
313
|
+
applyToFunctionArgs(col.args, inferQueryElement, [false])
|
|
314
|
+
}
|
|
306
315
|
queryElements[as] = getElementForCast(col)
|
|
307
|
-
}
|
|
316
|
+
}
|
|
317
|
+
if (!queryElements[as]) {
|
|
308
318
|
// either binding parameter (col.param) or value
|
|
309
319
|
queryElements[as] = col.cast ? getElementForCast(col) : getCdsTypeForVal(col.val)
|
|
310
320
|
}
|
|
@@ -334,17 +344,38 @@ function infer(originalQuery, model) {
|
|
|
334
344
|
// link $refLinks -> special name resolution rules for orderBy
|
|
335
345
|
orderBy.forEach(token => {
|
|
336
346
|
let $baseLink
|
|
347
|
+
let rejectJoinRelevantPath
|
|
337
348
|
// first check if token ref is resolvable in query elements
|
|
338
349
|
if (columns) {
|
|
339
|
-
const
|
|
340
|
-
const
|
|
341
|
-
|
|
350
|
+
const firstStep = token.ref?.[0].id || token.ref?.[0]
|
|
351
|
+
const tokenPointsToQueryElements = columns.some(c => {
|
|
352
|
+
const columnName = c.as || c.flatName || c.ref?.at(-1).id || c.ref?.at(-1) || c.func
|
|
353
|
+
return columnName === firstStep
|
|
354
|
+
})
|
|
355
|
+
const needsElementsOfQueryAsBase =
|
|
356
|
+
tokenPointsToQueryElements &&
|
|
357
|
+
queryElements[token.ref?.[0]] &&
|
|
358
|
+
/* expand on structure can be addressed */ !queryElements[token.ref?.[0]].$assocExpand
|
|
359
|
+
|
|
360
|
+
// if the ref points into the query itself and follows an exposed association
|
|
361
|
+
// to a non-fk column, we must reject the ref, as we can't join with the queries own results
|
|
362
|
+
rejectJoinRelevantPath = needsElementsOfQueryAsBase
|
|
363
|
+
if (needsElementsOfQueryAsBase) $baseLink = { definition: { elements: queryElements }, target: inferred }
|
|
342
364
|
} else {
|
|
343
365
|
// fallback to elements of query source
|
|
344
366
|
$baseLink = null
|
|
345
367
|
}
|
|
346
368
|
|
|
347
369
|
inferQueryElement(token, false, $baseLink)
|
|
370
|
+
if (token.isJoinRelevant && rejectJoinRelevantPath) {
|
|
371
|
+
// reverse the array, find the last association and calculate the index of the association in non-reversed order
|
|
372
|
+
const assocIndex =
|
|
373
|
+
token.$refLinks.length - 1 - token.$refLinks.reverse().findIndex(link => link.definition.isAssociation)
|
|
374
|
+
|
|
375
|
+
throw new Error(
|
|
376
|
+
`Can follow managed association “${token.ref[assocIndex].id || token.ref[assocIndex]}” only to the keys of its target, not to “${token.ref[assocIndex + 1].id || token.ref[assocIndex + 1]}”`,
|
|
377
|
+
)
|
|
378
|
+
}
|
|
348
379
|
})
|
|
349
380
|
}
|
|
350
381
|
|
|
@@ -467,9 +498,11 @@ function infer(originalQuery, model) {
|
|
|
467
498
|
*/
|
|
468
499
|
|
|
469
500
|
function inferQueryElement(column, insertIntoQueryElements = true, $baseLink = null, context) {
|
|
470
|
-
const { inExists, inExpr,
|
|
501
|
+
const { inExists, inExpr, inCalcElement, baseColumn, inInfixFilter } = context || {}
|
|
471
502
|
if (column.param || column.SELECT) return // parameter references are only resolved into values on execution e.g. :val, :1 or ?
|
|
472
|
-
if (column.args)
|
|
503
|
+
if (column.args) {
|
|
504
|
+
applyToFunctionArgs(column.args, inferQueryElement, [false, $baseLink, context])
|
|
505
|
+
}
|
|
473
506
|
if (column.list) column.list.forEach(arg => inferQueryElement(arg, false, $baseLink, context))
|
|
474
507
|
if (column.xpr)
|
|
475
508
|
column.xpr.forEach(token => inferQueryElement(token, false, $baseLink, { ...context, inExpr: true })) // e.g. function in expression
|
|
@@ -594,21 +627,25 @@ function infer(originalQuery, model) {
|
|
|
594
627
|
if (!column.$refLinks[i].definition.target || danglingFilter)
|
|
595
628
|
throw new Error('A filter can only be provided when navigating along associations')
|
|
596
629
|
if (!column.expand) Object.defineProperty(column, 'isJoinRelevant', { value: true })
|
|
597
|
-
|
|
598
|
-
let skipJoinsForFilter = inExists
|
|
630
|
+
let skipJoinsForFilter = false
|
|
599
631
|
step.where.forEach(token => {
|
|
600
632
|
if (token === 'exists') {
|
|
601
|
-
//
|
|
633
|
+
// books[exists genre[code='A']].title --> column is join relevant but inner exists filter is not
|
|
602
634
|
skipJoinsForFilter = true
|
|
603
635
|
} else if (token.ref || token.xpr) {
|
|
604
636
|
inferQueryElement(token, false, column.$refLinks[i], {
|
|
605
637
|
inExists: skipJoinsForFilter,
|
|
606
638
|
inExpr: !!token.xpr,
|
|
639
|
+
inInfixFilter: true,
|
|
607
640
|
})
|
|
608
641
|
} else if (token.func) {
|
|
609
|
-
token.args
|
|
610
|
-
|
|
611
|
-
|
|
642
|
+
if (token.args) {
|
|
643
|
+
applyToFunctionArgs(token.args, inferQueryElement, [
|
|
644
|
+
false,
|
|
645
|
+
column.$refLinks[i],
|
|
646
|
+
{ inExists: skipJoinsForFilter, inExpr: true, inInfixFilter: true },
|
|
647
|
+
])
|
|
648
|
+
}
|
|
612
649
|
}
|
|
613
650
|
})
|
|
614
651
|
}
|
|
@@ -668,17 +705,19 @@ function infer(originalQuery, model) {
|
|
|
668
705
|
* @param {CSN.Element} assoc if this is an association, the next step must be a foreign key of the element.
|
|
669
706
|
*/
|
|
670
707
|
function rejectNonFkAccess(assoc) {
|
|
671
|
-
if (
|
|
708
|
+
if (inInfixFilter && assoc.target) {
|
|
672
709
|
// only fk access in infix filter
|
|
673
710
|
const nextStep = column.ref[i + 1]?.id || column.ref[i + 1]
|
|
674
711
|
// no unmanaged assoc in infix filter path
|
|
675
|
-
if (!inExists && assoc.on)
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
712
|
+
if (!inExists && assoc.on) {
|
|
713
|
+
const err = `Unexpected unmanaged association “${assoc.name}” in filter expression of “${$baseLink.definition.name}”`
|
|
714
|
+
throw new Error(err)
|
|
715
|
+
}
|
|
679
716
|
// no non-fk traversal in infix filter in non-exists path
|
|
680
717
|
if (nextStep && !assoc.on && !isForeignKeyOf(nextStep, assoc))
|
|
681
|
-
throw new Error(
|
|
718
|
+
throw new Error(
|
|
719
|
+
`Only foreign keys of “${assoc.name}” can be accessed in infix filter, but found “${nextStep}”`,
|
|
720
|
+
)
|
|
682
721
|
}
|
|
683
722
|
}
|
|
684
723
|
})
|
|
@@ -727,12 +766,14 @@ function infer(originalQuery, model) {
|
|
|
727
766
|
function resolveInline(col, namePrefix = col.as || col.flatName) {
|
|
728
767
|
const { inline, $refLinks } = col
|
|
729
768
|
const $leafLink = $refLinks[$refLinks.length - 1]
|
|
730
|
-
if(!$leafLink.definition.target && !$leafLink.definition.elements) {
|
|
731
|
-
throw new Error(
|
|
769
|
+
if (!$leafLink.definition.target && !$leafLink.definition.elements) {
|
|
770
|
+
throw new Error(
|
|
771
|
+
`Unexpected “inline” on “${col.ref.map(idOnly)}”; can only be used after a reference to a structure, association or table alias`,
|
|
772
|
+
)
|
|
732
773
|
}
|
|
733
774
|
let elements = {}
|
|
734
775
|
inline.forEach(inlineCol => {
|
|
735
|
-
inferQueryElement(inlineCol, false, $leafLink, { inExpr: true,
|
|
776
|
+
inferQueryElement(inlineCol, false, $leafLink, { inExpr: true, baseColumn: col })
|
|
736
777
|
if (inlineCol === '*') {
|
|
737
778
|
const wildCardElements = {}
|
|
738
779
|
// either the `.elements´ of the struct or the `.elements` of the assoc target
|
|
@@ -783,8 +824,10 @@ function infer(originalQuery, model) {
|
|
|
783
824
|
function resolveExpand(col) {
|
|
784
825
|
const { expand, $refLinks } = col
|
|
785
826
|
const $leafLink = $refLinks?.[$refLinks.length - 1] || inferred.SELECT.from.$refLinks.at(-1) // fallback to anonymous expand
|
|
786
|
-
if(!$leafLink.definition.target && !$leafLink.definition.elements) {
|
|
787
|
-
throw new Error(
|
|
827
|
+
if (!$leafLink.definition.target && !$leafLink.definition.elements) {
|
|
828
|
+
throw new Error(
|
|
829
|
+
`Unexpected “expand” on “${col.ref.map(idOnly)}”; can only be used after a reference to a structure, association or table alias`,
|
|
830
|
+
)
|
|
788
831
|
}
|
|
789
832
|
const target = getDefinition($leafLink.definition.target)
|
|
790
833
|
if (target) {
|
|
@@ -807,7 +850,7 @@ function infer(originalQuery, model) {
|
|
|
807
850
|
if (e === '*') {
|
|
808
851
|
elements = { ...elements, ...$leafLink.definition.elements }
|
|
809
852
|
} else {
|
|
810
|
-
inferQueryElement(e, false, $leafLink, { inExpr: true
|
|
853
|
+
inferQueryElement(e, false, $leafLink, { inExpr: true })
|
|
811
854
|
if (e.expand) elements[e.as || e.flatName] = resolveExpand(e)
|
|
812
855
|
if (e.inline) elements = { ...elements, ...resolveInline(e) }
|
|
813
856
|
else elements[e.as || e.flatName] = e.$refLinks ? e.$refLinks[e.$refLinks.length - 1].definition : e
|
|
@@ -850,7 +893,7 @@ function infer(originalQuery, model) {
|
|
|
850
893
|
const calcElement = column.$refLinks?.[column.$refLinks.length - 1].definition || column
|
|
851
894
|
if (alreadySeenCalcElements.has(calcElement)) return
|
|
852
895
|
else alreadySeenCalcElements.add(calcElement)
|
|
853
|
-
const { ref, xpr
|
|
896
|
+
const { ref, xpr } = calcElement.value
|
|
854
897
|
if (ref || xpr) {
|
|
855
898
|
baseLink = baseLink || { definition: calcElement.parent, target: calcElement.parent }
|
|
856
899
|
attachRefLinksToArg(calcElement.value, baseLink, true)
|
|
@@ -864,8 +907,9 @@ function infer(originalQuery, model) {
|
|
|
864
907
|
}
|
|
865
908
|
mergePathsIntoJoinTree(calcElement.value, basePath)
|
|
866
909
|
}
|
|
867
|
-
|
|
868
|
-
|
|
910
|
+
|
|
911
|
+
if (calcElement.value.args) {
|
|
912
|
+
const processArgument = (arg, calcElement, column) => {
|
|
869
913
|
inferQueryElement(
|
|
870
914
|
arg,
|
|
871
915
|
false,
|
|
@@ -877,7 +921,12 @@ function infer(originalQuery, model) {
|
|
|
877
921
|
? { $refLinks: column.$refLinks.slice(0, -1), ref: column.ref.slice(0, -1) }
|
|
878
922
|
: { $refLinks: [], ref: [] }
|
|
879
923
|
mergePathsIntoJoinTree(arg, basePath)
|
|
880
|
-
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (calcElement.value.args) {
|
|
927
|
+
applyToFunctionArgs(calcElement.value.args, processArgument, [calcElement, column])
|
|
928
|
+
}
|
|
929
|
+
}
|
|
881
930
|
|
|
882
931
|
/**
|
|
883
932
|
* Calculates all paths from a given ref and merges them into the join tree.
|
|
@@ -1167,4 +1216,9 @@ function isForeignKeyOf(e, assoc) {
|
|
|
1167
1216
|
}
|
|
1168
1217
|
const idOnly = ref => ref.id || ref
|
|
1169
1218
|
|
|
1219
|
+
function applyToFunctionArgs(funcArgs, cb, cbArgs) {
|
|
1220
|
+
if (Array.isArray(funcArgs)) funcArgs.forEach(arg => cb(arg, ...cbArgs))
|
|
1221
|
+
else if (typeof funcArgs === 'object') Object.keys(funcArgs).forEach(prop => cb(funcArgs[prop], ...cbArgs))
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1170
1224
|
module.exports = infer
|
package/lib/infer/join-tree.js
CHANGED
|
@@ -176,6 +176,8 @@ class JoinTree {
|
|
|
176
176
|
i += 1 // skip first step which is table alias
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
// if no root node was found, the column is selected from a subquery
|
|
180
|
+
if(!node) return
|
|
179
181
|
while (i < col.ref.length) {
|
|
180
182
|
const step = col.ref[i]
|
|
181
183
|
const { where, args } = step
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cap-js/db-service",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "CDS base database service",
|
|
5
5
|
"homepage": "https://github.com/cap-js/cds-dbs/tree/main/db-service#cds-base-database-service",
|
|
6
6
|
"repository": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"test": "jest --silent"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
|
-
"@sap/cds": ">=7.
|
|
32
|
+
"@sap/cds": ">=7.9"
|
|
33
33
|
},
|
|
34
34
|
"license": "SEE LICENSE"
|
|
35
35
|
}
|