@cap-js/db-service 2.8.1 → 2.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 +30 -0
- package/lib/SQLService.js +5 -8
- package/lib/cqn2sql.js +127 -44
- package/lib/cqn4sql.js +314 -34
- package/lib/infer/index.js +84 -32
- package/lib/infer/join-tree.js +8 -6
- package/lib/infer/pseudos.js +12 -11
- package/lib/search.js +1 -1
- package/lib/utils.js +29 -0
- package/package.json +2 -2
package/lib/infer/index.js
CHANGED
|
@@ -4,20 +4,8 @@ const cds = require('@sap/cds')
|
|
|
4
4
|
|
|
5
5
|
const JoinTree = require('./join-tree')
|
|
6
6
|
const { pseudos } = require('./pseudos')
|
|
7
|
-
const { isCalculatedOnRead, getImplicitAlias, getModelUtils, defineProperty, hasOwnSkip } = require('../utils')
|
|
8
|
-
const cdsTypes = cds.
|
|
9
|
-
definitions: {
|
|
10
|
-
Timestamp: { type: 'cds.Timestamp' },
|
|
11
|
-
DateTime: { type: 'cds.DateTime' },
|
|
12
|
-
Date: { type: 'cds.Date' },
|
|
13
|
-
Time: { type: 'cds.Time' },
|
|
14
|
-
String: { type: 'cds.String' },
|
|
15
|
-
Decimal: { type: 'cds.Decimal' },
|
|
16
|
-
Integer: { type: 'cds.Integer' },
|
|
17
|
-
Boolean: { type: 'cds.Boolean' },
|
|
18
|
-
},
|
|
19
|
-
}).definitions
|
|
20
|
-
for (const each in cdsTypes) cdsTypes[`cds.${each}`] = cdsTypes[each]
|
|
7
|
+
const { isCalculatedOnRead, getImplicitAlias, getModelUtils, defineProperty, hasOwnSkip, isRuntimeView } = require('../utils')
|
|
8
|
+
const cdsTypes = cds.builtin.types
|
|
21
9
|
/**
|
|
22
10
|
* @param {import('@sap/cds/apis/cqn').Query|string} originalQuery
|
|
23
11
|
* @param {import('@sap/cds/apis/csn').CSN} [model]
|
|
@@ -203,6 +191,7 @@ function infer(originalQuery, model) {
|
|
|
203
191
|
const dollarSelfRefs = []
|
|
204
192
|
columns.forEach(col => {
|
|
205
193
|
if (col === '*') {
|
|
194
|
+
if (wildcardSelect) throw new Error('Duplicate wildcard "*" in column list')
|
|
206
195
|
wildcardSelect = true
|
|
207
196
|
} else if (col.val !== undefined || col.xpr || col.SELECT || col.func || col.param) {
|
|
208
197
|
const as = col.as || col.func || col.val
|
|
@@ -471,10 +460,10 @@ function infer(originalQuery, model) {
|
|
|
471
460
|
const element = elements[id]
|
|
472
461
|
if (inInfixFilter) {
|
|
473
462
|
const nextStep = arg.ref[1]?.id || arg.ref[1]
|
|
474
|
-
if (isNonForeignKeyNavigation(element, nextStep)) {
|
|
463
|
+
if (isNonForeignKeyNavigation(element, nextStep) || arg.ref[0]?.where) {
|
|
475
464
|
if (inExists) {
|
|
476
465
|
defineProperty($baseLink, 'pathExpressionInsideFilter', true)
|
|
477
|
-
} else {
|
|
466
|
+
} else if (!inFrom) {
|
|
478
467
|
rejectNonFkNavigation(element, element.on ? $baseLink.definition.name : nextStep)
|
|
479
468
|
}
|
|
480
469
|
}
|
|
@@ -535,10 +524,10 @@ function infer(originalQuery, model) {
|
|
|
535
524
|
if (element) {
|
|
536
525
|
if ($baseLink && inInfixFilter) {
|
|
537
526
|
const nextStep = arg.ref[i + 1]?.id || arg.ref[i + 1]
|
|
538
|
-
if (isNonForeignKeyNavigation(element, nextStep)) {
|
|
527
|
+
if (isNonForeignKeyNavigation(element, nextStep) || arg.ref[i-1]?.where) {
|
|
539
528
|
if (inExists) {
|
|
540
529
|
defineProperty($baseLink, 'pathExpressionInsideFilter', true)
|
|
541
|
-
} else {
|
|
530
|
+
} else if (!inFrom) {
|
|
542
531
|
rejectNonFkNavigation(element, element.on ? $baseLink.definition.name : nextStep)
|
|
543
532
|
}
|
|
544
533
|
}
|
|
@@ -577,7 +566,7 @@ function infer(originalQuery, model) {
|
|
|
577
566
|
if (step.where) {
|
|
578
567
|
const danglingFilter = !(arg.ref[i + 1] || arg.expand || arg.inline || inExists)
|
|
579
568
|
const definition = arg.$refLinks[i].definition
|
|
580
|
-
if ((!definition.target && definition.kind !== 'entity') || (!inFrom && danglingFilter))
|
|
569
|
+
if ((!definition.target && definition.kind !== 'entity') || (!inFrom && !inCalcElement && danglingFilter))
|
|
581
570
|
throw new Error('A filter can only be provided when navigating along associations')
|
|
582
571
|
if (!inFrom && !arg.expand)defineProperty(arg, 'isJoinRelevant', true)
|
|
583
572
|
let skipJoinsForFilter = false
|
|
@@ -586,9 +575,11 @@ function infer(originalQuery, model) {
|
|
|
586
575
|
// books[exists genre[code='A']].title --> column is join relevant but inner exists filter is not
|
|
587
576
|
skipJoinsForFilter = true
|
|
588
577
|
} else if (token.ref || token.xpr || token.list) {
|
|
578
|
+
// For scoped queries (non-dangling filters in FROM), treat filter contents as EXISTS context
|
|
579
|
+
// because they will become part of an EXISTS subquery
|
|
589
580
|
inferArg(token, false, arg.$refLinks[i], {
|
|
590
581
|
...context,
|
|
591
|
-
inExists: skipJoinsForFilter || inExists,
|
|
582
|
+
inExists: skipJoinsForFilter || inExists || (inFrom && !danglingFilter),
|
|
592
583
|
inXpr: !!token.xpr,
|
|
593
584
|
inInfixFilter: true,
|
|
594
585
|
inFrom,
|
|
@@ -598,7 +589,7 @@ function infer(originalQuery, model) {
|
|
|
598
589
|
applyToFunctionArgs(token.args, inferArg, [
|
|
599
590
|
false,
|
|
600
591
|
arg.$refLinks[i],
|
|
601
|
-
{ inExists: skipJoinsForFilter || inExists, inXpr: true, inInfixFilter: true, inFrom },
|
|
592
|
+
{ inExists: skipJoinsForFilter || inExists || (inFrom && !danglingFilter), inXpr: true, inInfixFilter: true, inFrom },
|
|
602
593
|
])
|
|
603
594
|
}
|
|
604
595
|
}
|
|
@@ -607,7 +598,8 @@ function infer(originalQuery, model) {
|
|
|
607
598
|
|
|
608
599
|
if(!arg.$refLinks[i].$main)
|
|
609
600
|
arg.$refLinks[i].alias = !arg.ref[i + 1] && arg.as ? arg.as : id.split('.').pop()
|
|
610
|
-
|
|
601
|
+
const def = getDefinition(arg.$refLinks[i].definition.target)
|
|
602
|
+
if (hasOwnSkip(def) && !isRuntimeView(def)) isPersisted = false
|
|
611
603
|
if (!arg.ref[i + 1]) {
|
|
612
604
|
const flatName = nameSegments.join('_')
|
|
613
605
|
defineProperty(arg, 'flatName', flatName)
|
|
@@ -667,7 +659,11 @@ function infer(originalQuery, model) {
|
|
|
667
659
|
// ignore whole expand if target of assoc along path has ”@cds.persistence.skip”
|
|
668
660
|
if (arg.expand) {
|
|
669
661
|
const { $refLinks } = arg
|
|
670
|
-
|
|
662
|
+
|
|
663
|
+
const skip = $refLinks.some(link => {
|
|
664
|
+
const def = getDefinition(link.definition.target)
|
|
665
|
+
return hasOwnSkip(def) && !isRuntimeView(def)
|
|
666
|
+
})
|
|
671
667
|
if (skip) {
|
|
672
668
|
$refLinks[$refLinks.length - 1].skipExpand = true
|
|
673
669
|
return
|
|
@@ -720,24 +716,65 @@ function infer(originalQuery, model) {
|
|
|
720
716
|
)
|
|
721
717
|
}
|
|
722
718
|
let elements = {}
|
|
719
|
+
let seenWildcard = false
|
|
723
720
|
inline.forEach(inlineCol => {
|
|
724
721
|
inferArg(inlineCol, null, $leafLink, { inXpr: true, baseColumn: col })
|
|
725
722
|
if (inlineCol === '*') {
|
|
723
|
+
if (seenWildcard) throw new Error(`Duplicate wildcard "*" in inline of "${col.as || col.ref.map(idOnly).join('_')}"`)
|
|
724
|
+
seenWildcard = true
|
|
726
725
|
const wildCardElements = {}
|
|
727
726
|
// either the `.elements´ of the struct or the `.elements` of the assoc target
|
|
728
|
-
const
|
|
727
|
+
const targetDef = getDefinition($leafLink.definition.target)
|
|
728
|
+
const leafLinkElements = targetDef?.elements || $leafLink.definition.elements
|
|
729
|
+
const isAssociation = !!$leafLink.definition.target
|
|
730
|
+
|
|
731
|
+
const deferredCalcElements = []
|
|
729
732
|
Object.entries(leafLinkElements).forEach(([k, v]) => {
|
|
730
733
|
const name = namePrefix ? `${namePrefix}_${k}` : k
|
|
731
734
|
// if overwritten/excluded omit from wildcard elements
|
|
732
735
|
// in elements the names are already flat so consider the prefix
|
|
733
736
|
// in excluding, the elements are addressed without the prefix
|
|
734
|
-
if (!(name in elements || col.excluding?.includes(k)))
|
|
737
|
+
if (!(name in elements || col.excluding?.includes(k))) {
|
|
738
|
+
wildCardElements[name] = v
|
|
739
|
+
|
|
740
|
+
if(v.value) {
|
|
741
|
+
// defer linkCalculatedElement calls until after all association joins are registered
|
|
742
|
+
// so that the join tree order is correct
|
|
743
|
+
deferredCalcElements.push({ k, v })
|
|
744
|
+
}
|
|
745
|
+
else if (isAssociation && !v.virtual && v.type !== 'cds.LargeBinary' && !(v.on && !v.keys)) {
|
|
746
|
+
// Check if this element is a foreign key (FK elements don't need join)
|
|
747
|
+
const isFK = $leafLink.definition.keys?.some(key => key.ref[0] === k)
|
|
748
|
+
if (!isFK) {
|
|
749
|
+
// Create a fake column with ref [<inlined assoc>, <element name>] and proper $refLinks
|
|
750
|
+
const fakeCol = {
|
|
751
|
+
ref: [...col.ref, k],
|
|
752
|
+
}
|
|
753
|
+
// Copy $refLinks and add new link for the target element with proper alias
|
|
754
|
+
const fakeRefLinks = [
|
|
755
|
+
...$refLinks,
|
|
756
|
+
{ definition: v, target: targetDef, alias: k }
|
|
757
|
+
]
|
|
758
|
+
defineProperty(fakeCol, '$refLinks', fakeRefLinks)
|
|
759
|
+
defineProperty(fakeCol, 'isJoinRelevant', true)
|
|
760
|
+
// Merge into join tree
|
|
761
|
+
inferred.joinTree.mergeColumn(fakeCol, originalQuery.outerQueries)
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
735
765
|
})
|
|
766
|
+
// link calculated elements after association joins are registered in the join tree
|
|
767
|
+
for (const { k, v } of deferredCalcElements) {
|
|
768
|
+
linkCalculatedElement(
|
|
769
|
+
{ ref: [k], $refLinks: [{ definition: v, target: targetDef }] },
|
|
770
|
+
$leafLink,
|
|
771
|
+
)
|
|
772
|
+
}
|
|
736
773
|
elements = { ...elements, ...wildCardElements }
|
|
737
774
|
} else {
|
|
738
775
|
const nameParts = namePrefix ? [namePrefix] : []
|
|
739
776
|
if (inlineCol.as) nameParts.push(inlineCol.as)
|
|
740
|
-
else nameParts.push(...inlineCol.ref.map(idOnly))
|
|
777
|
+
else if (inlineCol.ref) nameParts.push(...inlineCol.ref.map(idOnly))
|
|
741
778
|
const name = nameParts.join('_')
|
|
742
779
|
if (inlineCol.inline) {
|
|
743
780
|
const inlineElements = resolveInline(inlineCol, name)
|
|
@@ -746,9 +783,11 @@ function infer(originalQuery, model) {
|
|
|
746
783
|
const expandElements = resolveExpand(inlineCol)
|
|
747
784
|
elements = { ...elements, [name]: expandElements }
|
|
748
785
|
} else if (inlineCol.val) {
|
|
749
|
-
elements[name] =
|
|
786
|
+
elements[name] = getCdsTypeForVal(inlineCol.val)
|
|
750
787
|
} else if (inlineCol.func) {
|
|
751
788
|
elements[name] = {}
|
|
789
|
+
} else if (inlineCol.xpr) {
|
|
790
|
+
elements[name] = {}
|
|
752
791
|
} else {
|
|
753
792
|
elements[name] = inlineCol.$refLinks[inlineCol.$refLinks.length - 1].definition
|
|
754
793
|
}
|
|
@@ -776,6 +815,16 @@ function infer(originalQuery, model) {
|
|
|
776
815
|
`Unexpected “expand” on “${col.ref.map(idOnly)}”; can only be used after a reference to a structure, association or table alias`,
|
|
777
816
|
)
|
|
778
817
|
}
|
|
818
|
+
// Check for duplicate wildcards before creating the subquery
|
|
819
|
+
let seenWildcard = false
|
|
820
|
+
for (const e of expand) {
|
|
821
|
+
if (e === '*') {
|
|
822
|
+
if (seenWildcard) {
|
|
823
|
+
throw new Error(`Duplicate wildcard "*" in expand of "${col.as || col.ref.map(idOnly).join('_')}"`)
|
|
824
|
+
}
|
|
825
|
+
seenWildcard = true
|
|
826
|
+
}
|
|
827
|
+
}
|
|
779
828
|
const target = getDefinition($leafLink.definition.target)
|
|
780
829
|
if (target) {
|
|
781
830
|
const expandSubquery = {
|
|
@@ -867,7 +916,7 @@ function infer(originalQuery, model) {
|
|
|
867
916
|
arg,
|
|
868
917
|
null,
|
|
869
918
|
{ definition: parentElementDefinition, target: parentElementDefinition },
|
|
870
|
-
{ inCalcElement: true },
|
|
919
|
+
{ inCalcElement: true, ...context },
|
|
871
920
|
)
|
|
872
921
|
const basePath =
|
|
873
922
|
column.$refLinks?.length > 1
|
|
@@ -1091,7 +1140,10 @@ function infer(originalQuery, model) {
|
|
|
1091
1140
|
if ($refLinks?.[$refLinks.length - 1].definition.elements)
|
|
1092
1141
|
// no cast on structure
|
|
1093
1142
|
cds.error`Structured elements can't be cast to a different type`
|
|
1094
|
-
|
|
1143
|
+
const cdsType = cdsTypes[cast.type]
|
|
1144
|
+
thing.cast = cdsType ? new cdsType.constructor(cast) : cast
|
|
1145
|
+
if (cdsType)
|
|
1146
|
+
thing.cast.type = cdsType._type
|
|
1095
1147
|
return thing.cast
|
|
1096
1148
|
}
|
|
1097
1149
|
|
|
@@ -1121,11 +1173,11 @@ function infer(originalQuery, model) {
|
|
|
1121
1173
|
// if(val === null) return {type:'cds.String'}
|
|
1122
1174
|
switch (typeof val) {
|
|
1123
1175
|
case 'string':
|
|
1124
|
-
return cdsTypes.String
|
|
1176
|
+
return new cdsTypes.String.constructor()
|
|
1125
1177
|
case 'boolean':
|
|
1126
|
-
return cdsTypes.Boolean
|
|
1178
|
+
return new cdsTypes.Boolean.constructor()
|
|
1127
1179
|
case 'number':
|
|
1128
|
-
return Number.isSafeInteger(val) ? cdsTypes.Integer : cdsTypes.Decimal
|
|
1180
|
+
return Number.isSafeInteger(val) ? new cdsTypes.Integer.constructor() : new cdsTypes.Decimal.constructor()
|
|
1129
1181
|
default:
|
|
1130
1182
|
return {}
|
|
1131
1183
|
}
|
package/lib/infer/join-tree.js
CHANGED
|
@@ -133,20 +133,22 @@ class JoinTree {
|
|
|
133
133
|
*
|
|
134
134
|
* @param {string} alias - The original alias name.
|
|
135
135
|
* @param {unknown[]} outerQueries - An array of outer queries.
|
|
136
|
+
* @param {string} key - The key to be used for storing the alias in the map. If not provided, the upper-case version of the alias will be used as the key.
|
|
136
137
|
* @returns {string} - The next unambiguous table alias.
|
|
137
138
|
*/
|
|
138
|
-
addNextAvailableTableAlias(alias, outerQueries) {
|
|
139
|
+
addNextAvailableTableAlias(alias, outerQueries, key) {
|
|
139
140
|
const upperAlias = alias.toUpperCase()
|
|
140
|
-
if (this._queryAliases.get(upperAlias) || outerQueries?.some(outer => outerHasAlias(outer))) {
|
|
141
|
+
if (this._queryAliases.get(upperAlias) || outerQueries?.some(outer => outerHasAlias(outer, key))) {
|
|
141
142
|
let j = 2
|
|
142
|
-
while (this._queryAliases.get(upperAlias + j) || outerQueries?.some(outer => outerHasAlias(outer, j))) j += 1
|
|
143
|
+
while (this._queryAliases.get(upperAlias + j) || outerQueries?.some(outer => outerHasAlias(outer, key, j))) j += 1
|
|
143
144
|
alias += j
|
|
144
145
|
}
|
|
145
|
-
this._queryAliases.set(alias.toUpperCase(), alias)
|
|
146
|
+
this._queryAliases.set(key || alias.toUpperCase(), alias)
|
|
146
147
|
return alias
|
|
147
148
|
|
|
148
|
-
function outerHasAlias(outer, number) {
|
|
149
|
-
|
|
149
|
+
function outerHasAlias(outer, searchInValues = false, number) {
|
|
150
|
+
const currAlias = number ? upperAlias + number : upperAlias
|
|
151
|
+
return searchInValues ? Array.from(outer.joinTree._queryAliases.values()).includes(currAlias) : outer.joinTree._queryAliases.get(currAlias)
|
|
150
152
|
}
|
|
151
153
|
}
|
|
152
154
|
|
package/lib/infer/pseudos.js
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
const cds = require('@sap/cds')
|
|
4
|
+
const { String, Timestamp } = cds.builtin.types
|
|
5
|
+
|
|
5
6
|
const pseudos = {
|
|
6
7
|
elements: {
|
|
7
8
|
$user: {
|
|
8
9
|
elements: {
|
|
9
|
-
id:
|
|
10
|
-
locale:
|
|
11
|
-
tenant:
|
|
10
|
+
id: String,
|
|
11
|
+
locale: String, // deprecated
|
|
12
|
+
tenant: String, // deprecated
|
|
12
13
|
},
|
|
13
14
|
},
|
|
14
|
-
$now:
|
|
15
|
-
$at:
|
|
16
|
-
$from:
|
|
17
|
-
$to:
|
|
18
|
-
$locale:
|
|
19
|
-
$tenant:
|
|
15
|
+
$now: Timestamp,
|
|
16
|
+
$at: Timestamp,
|
|
17
|
+
$from: Timestamp,
|
|
18
|
+
$to: Timestamp,
|
|
19
|
+
$locale: String,
|
|
20
|
+
$tenant: String,
|
|
20
21
|
},
|
|
21
22
|
}
|
|
22
23
|
|
package/lib/search.js
CHANGED
|
@@ -174,7 +174,7 @@ const computeColumnsToBeSearched = (cqn, entity = { __searchableColumns: [] }) =
|
|
|
174
174
|
// only strings can be searched
|
|
175
175
|
if (element?.type !== DEFAULT_SEARCHABLE_TYPE) {
|
|
176
176
|
if (column.xpr) return
|
|
177
|
-
if (column.func && !(column.func in aggregateFunctions)) return
|
|
177
|
+
if (column.func && !(column.func.toUpperCase() in aggregateFunctions)) return
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
const searchTerm = {}
|
package/lib/utils.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const cds = require('@sap/cds')
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Formats a ref array into a string representation.
|
|
5
7
|
* If the first step is an entity, the separator is a colon, otherwise a dot.
|
|
@@ -27,6 +29,25 @@ function hasOwnSkip(definition) {
|
|
|
27
29
|
)
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
function isRuntimeView(definition) {
|
|
33
|
+
if (!definition || !cds.env.features.runtime_views) return false
|
|
34
|
+
if (definition['_isRuntimeView']) return true
|
|
35
|
+
if (!definition['@cds.persistence.skip']) {
|
|
36
|
+
Object.defineProperty(definition, '_isRuntimeView', {
|
|
37
|
+
value: true,
|
|
38
|
+
writable: false,
|
|
39
|
+
configurable: true,
|
|
40
|
+
enumerable: false
|
|
41
|
+
})
|
|
42
|
+
return true
|
|
43
|
+
}
|
|
44
|
+
// views with "as select from" variant are also runtime views, even if they are annotated with persistence skip
|
|
45
|
+
if (definition.query && !definition.query._target) return true
|
|
46
|
+
if (definition.query) return isRuntimeView(definition.query._target)
|
|
47
|
+
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
|
|
30
51
|
/**
|
|
31
52
|
* Determines if a definition is calculated on read.
|
|
32
53
|
* - Stored calculated elements are not unfolded
|
|
@@ -136,6 +157,12 @@ function getModelUtils(model, query) {
|
|
|
136
157
|
}
|
|
137
158
|
}
|
|
138
159
|
|
|
160
|
+
function resolveTable(target) {
|
|
161
|
+
if (target.query?._target && !Object.prototype.hasOwnProperty.call(target, '@cds.persistence.table'))
|
|
162
|
+
return resolveTable(target.query._target)
|
|
163
|
+
return target
|
|
164
|
+
}
|
|
165
|
+
|
|
139
166
|
// export the function to be used in other modules
|
|
140
167
|
module.exports = {
|
|
141
168
|
prettyPrintRef,
|
|
@@ -145,4 +172,6 @@ module.exports = {
|
|
|
145
172
|
defineProperty,
|
|
146
173
|
getModelUtils,
|
|
147
174
|
hasOwnSkip,
|
|
175
|
+
isRuntimeView,
|
|
176
|
+
resolveTable
|
|
148
177
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cap-js/db-service",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.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": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"generic-pool": "^3.9.0"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
|
-
"@sap/cds": ">=9.
|
|
30
|
+
"@sap/cds": ">=9.8"
|
|
31
31
|
},
|
|
32
32
|
"license": "Apache-2.0"
|
|
33
33
|
}
|