@cap-js/cds-typer 0.10.0 → 0.11.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 +16 -1
- package/lib/compile.js +5 -5
- package/lib/components/enum.js +108 -0
- package/lib/components/resolver.js +19 -5
- package/lib/csn.js +11 -6
- package/lib/file.js +72 -23
- package/lib/visitor.js +66 -25
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,13 +4,28 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
This project adheres to [Semantic Versioning](http://semver.org/).
|
|
5
5
|
The format is based on [Keep a Changelog](http://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
-
## Version 0.
|
|
7
|
+
## Version 0.12.0 - TBD
|
|
8
8
|
|
|
9
9
|
### Changed
|
|
10
10
|
|
|
11
11
|
### Added
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
## Version 0.11.0 - 2023-10-10
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- Autoexposed entities in services are now also generated
|
|
20
|
+
- Each generated class now contains their original fully qualified name in a static `.name` property
|
|
21
|
+
- Inline enums that are defined as literal type of properties are now supported as well (note: this feature is experimental. The location to which enums are generated might change in the future!)
|
|
12
22
|
|
|
13
23
|
### Fixed
|
|
24
|
+
- Fixed an error when an entity uses `type of` on a property they have inherited from another entity
|
|
25
|
+
- Fixed an error during draftability propagation when defining compositions on types that are declared inline
|
|
26
|
+
|
|
27
|
+
### Removed
|
|
28
|
+
- `compileFromCSN` is no longer part of the package's API
|
|
14
29
|
|
|
15
30
|
## Version 0.10.0 - 2023-09-21
|
|
16
31
|
|
package/lib/compile.js
CHANGED
|
@@ -45,13 +45,14 @@ const writeJsConfig = (path, logger) => {
|
|
|
45
45
|
*/
|
|
46
46
|
const compileFromFile = async (inputFile, parameters) => {
|
|
47
47
|
const paths = typeof inputFile === 'string' ? normalize(inputFile) : inputFile.map(f => normalize(f))
|
|
48
|
-
const
|
|
49
|
-
|
|
48
|
+
const xtended = await cds.linked(await cds.load(paths, { docs: true, flavor: 'xtended' }))
|
|
49
|
+
const inferred = await cds.linked(await cds.load(paths, { docs: true }))
|
|
50
|
+
return compileFromCSN({xtended, inferred}, parameters)
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
/**
|
|
53
54
|
* Compiles a CSN object to Typescript types.
|
|
54
|
-
* @param
|
|
55
|
+
* @param {{xtended: CSN, inferred: CSN}} csn
|
|
55
56
|
* @param parameters {CompileParameters} path to root directory for all generated files, min log level
|
|
56
57
|
*/
|
|
57
58
|
const compileFromCSN = async (csn, parameters) => {
|
|
@@ -69,6 +70,5 @@ const compileFromCSN = async (csn, parameters) => {
|
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
module.exports = {
|
|
72
|
-
compileFromFile
|
|
73
|
-
compileFromCSN,
|
|
73
|
+
compileFromFile
|
|
74
74
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prints an enum to a buffer. To be precise, it prints
|
|
3
|
+
* a constant object and a type which together form an artificial enum.
|
|
4
|
+
* CDS enums differ from TS enums as they can use bools as value (TS: only number and string)
|
|
5
|
+
* So we have to emulate enums by adding an object (name -> value mappings)
|
|
6
|
+
* and a type containing all disctinct values.
|
|
7
|
+
* We can get away with this as TS doesn't feature nominal typing, so the structure
|
|
8
|
+
* is all we care about.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```cds
|
|
12
|
+
* type E: enum of String {
|
|
13
|
+
* a = 'A';
|
|
14
|
+
* b = 'B';
|
|
15
|
+
* }
|
|
16
|
+
* ```
|
|
17
|
+
* becomes
|
|
18
|
+
* ```ts
|
|
19
|
+
* const E = { a: 'A', b: 'B' }
|
|
20
|
+
* type E = 'A' | 'B'
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* @param {Buffer} buffer Buffer to write into
|
|
24
|
+
* @param {string} name local name of the enum, i.e. the name under which it should be created in the .ts file
|
|
25
|
+
* @param {[string, string][]} kvs list of key-value pairs
|
|
26
|
+
*/
|
|
27
|
+
function printEnum(buffer, name, kvs, options = {}) {
|
|
28
|
+
const opts = {...{export: true}, ...options}
|
|
29
|
+
buffer.add('// enum')
|
|
30
|
+
buffer.add(`${opts.export ? 'export ' : ''}const ${name} = {`)
|
|
31
|
+
buffer.indent()
|
|
32
|
+
const vals = new Set()
|
|
33
|
+
for (const [k, v] of kvs) {
|
|
34
|
+
buffer.add(`${k}: ${JSON.stringify(v)},`)
|
|
35
|
+
vals.add(JSON.stringify(v.val ?? v)) // in case of wrapped vals we need to unwrap here for the type
|
|
36
|
+
}
|
|
37
|
+
buffer.outdent()
|
|
38
|
+
buffer.add('} as const;')
|
|
39
|
+
buffer.add(`${opts.export ? 'export ' : ''}type ${name} = ${[...vals].join(' | ')}`)
|
|
40
|
+
buffer.add('')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// in case of strings, wrap in quotes and fallback to key to make sure values are attached for every key
|
|
44
|
+
const enumVal = (key, value, enumType) => enumType === 'cds.String' ? `${value ?? key}` : value
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {{enum: {[key: name]: string}, type: string}} enumCsn
|
|
48
|
+
* @param {{unwrapVals: boolean}} options if `unwrapVals` is passed,
|
|
49
|
+
* then the CSN structure `{val:x}` is flattened to just `x`.
|
|
50
|
+
* Retaining `val` is closer to the actual CSN structure and should be used where we want
|
|
51
|
+
* to mimic the runtime as closely as possible (anoymous enum types).
|
|
52
|
+
* Stripping that additional wrapper would be more readable for users.
|
|
53
|
+
* @example
|
|
54
|
+
* ```ts
|
|
55
|
+
* const csn = {enum: {x: {val: 42}, y: {val: -42}}}
|
|
56
|
+
* csnToEnum(csn) // -> [['x', 42], ['y': -42]]
|
|
57
|
+
* csnToEnum(csn, {unwrapVals: false}) // -> [['x', {val:42}], ['y': {val:-42}]]
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
const csnToEnum = ({enum: enm, type}, options = {}) => {
|
|
61
|
+
options = {...{unwrapVals: true}, ...options}
|
|
62
|
+
return Object.entries(enm).map(([k, v]) => {
|
|
63
|
+
const val = enumVal(k, v.val, type)
|
|
64
|
+
return [k, options.unwrapVals ? val : { val }]
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {string} entity
|
|
70
|
+
* @param {string} property
|
|
71
|
+
*/
|
|
72
|
+
const propertyToAnonymousEnumName = (entity, property) => `${entity}_${property}`
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A type is considered to be an inline enum, iff it has a `.enum` property
|
|
76
|
+
* _and_ its type is a CDS primitive, i.e. it is not contained in `cds.definitions`.
|
|
77
|
+
* If it is contained there, then it is a standard enum declaration that has its own name.
|
|
78
|
+
*
|
|
79
|
+
* @param {{type: string}} element
|
|
80
|
+
* @param {object} csn
|
|
81
|
+
* @returns boolean
|
|
82
|
+
*/
|
|
83
|
+
const isInlineEnumType = (element, csn) => element.enum && !(element.type in csn.definitions)
|
|
84
|
+
|
|
85
|
+
const stringifyEnumImplementation = (name, enm) => `module.exports.${name} = Object.fromEntries(Object.entries(${enm}).map(([k,v]) => [k,v.val]))`
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} name
|
|
89
|
+
* @param {string} fq
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
const stringifyNamedEnum = (name, fq) => stringifyEnumImplementation(name, `cds.model.definitions['${fq}'].enum`)
|
|
93
|
+
/**
|
|
94
|
+
* @param {string} name
|
|
95
|
+
* @param {string} fq
|
|
96
|
+
* @param {string} property
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
const stringifyAnonymousEnum = (name, fq, property) => stringifyEnumImplementation(fq, `cds.model.definitions['${name}'].elements.${property}.enum`)
|
|
100
|
+
|
|
101
|
+
module.exports = {
|
|
102
|
+
printEnum,
|
|
103
|
+
csnToEnum,
|
|
104
|
+
propertyToAnonymousEnumName,
|
|
105
|
+
isInlineEnumType,
|
|
106
|
+
stringifyNamedEnum,
|
|
107
|
+
stringifyAnonymousEnum
|
|
108
|
+
}
|
|
@@ -4,6 +4,7 @@ const util = require('../util')
|
|
|
4
4
|
const { Buffer, SourceFile, Path, Library, baseDefinitions } = require("../file")
|
|
5
5
|
const { deepRequire, createToManyAssociation, createToOneAssociation, createArrayOf, createCompositionOfMany, createCompositionOfOne } = require('./wrappers')
|
|
6
6
|
const { StructuredInlineDeclarationResolver } = require("./inline")
|
|
7
|
+
const { isInlineEnumType, propertyToInlineEnumName, propertyToAnonymousEnumName } = require('./enum')
|
|
7
8
|
|
|
8
9
|
/** @typedef {{ cardinality?: { max?: '*' | number } }} EntityCSN */
|
|
9
10
|
/** @typedef {{ definitions?: Object<string, EntityCSN> }} CSN */
|
|
@@ -63,7 +64,7 @@ const Builtins = {
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
class Resolver {
|
|
66
|
-
get csn() { return this.visitor.csn }
|
|
67
|
+
get csn() { return this.visitor.csn.inferred }
|
|
67
68
|
|
|
68
69
|
/** @param {Visitor} visitor */
|
|
69
70
|
constructor(visitor) {
|
|
@@ -106,6 +107,7 @@ class Resolver {
|
|
|
106
107
|
* @returns {string} the entity name without leading namespace.
|
|
107
108
|
*/
|
|
108
109
|
trimNamespace(p) {
|
|
110
|
+
// TODO: we might want to cache this
|
|
109
111
|
// start on right side, go up while we have an entity at hand
|
|
110
112
|
// we cant start on left side, as that clashes with undefined entities like "sap"
|
|
111
113
|
const parts = p.split('.')
|
|
@@ -167,7 +169,7 @@ class Resolver {
|
|
|
167
169
|
new StructuredInlineDeclarationResolver(this.visitor).printInlineType(undefined, { typeInfo }, into, '')
|
|
168
170
|
typeName = into.join(' ')
|
|
169
171
|
singular = typeName
|
|
170
|
-
plural = createArrayOf(typeName)
|
|
172
|
+
plural = createArrayOf(typeName)
|
|
171
173
|
} else {
|
|
172
174
|
// TODO: make sure the resolution still works. Currently, we only cut off the namespace!
|
|
173
175
|
singular = util.singular4(typeInfo.csn)
|
|
@@ -310,10 +312,13 @@ class Resolver {
|
|
|
310
312
|
* read from left to right which does not contain a kind 'context' or 'service'.
|
|
311
313
|
* That is, if in the above example 'D' is a context and 'E' is a service,
|
|
312
314
|
* the resulting namespace is 'a.b.c'.
|
|
313
|
-
* @param {string[]} pathParts the distinct parts of the namespace, i.e. ['a','b','c','D','E']
|
|
315
|
+
* @param {string[] | string} pathParts the distinct parts of the namespace, i.e. ['a','b','c','D','E'] or a single path interspersed with periods
|
|
314
316
|
* @returns {string} the namespace's name, i.e. 'a.b.c'.
|
|
315
317
|
*/
|
|
316
318
|
resolveNamespace(pathParts) {
|
|
319
|
+
if (typeof pathParts === 'string') {
|
|
320
|
+
pathParts = pathParts.split('.')
|
|
321
|
+
}
|
|
317
322
|
let result
|
|
318
323
|
while (result === undefined) {
|
|
319
324
|
const path = pathParts.join('.')
|
|
@@ -350,14 +355,23 @@ class Resolver {
|
|
|
350
355
|
isArray: false,
|
|
351
356
|
}
|
|
352
357
|
|
|
353
|
-
// FIXME: switch case
|
|
354
358
|
if (element?.type === undefined) {
|
|
355
359
|
// "fallback" type "empty object". May be overriden via #resolveInlineDeclarationType
|
|
356
360
|
// later on with an inline declaration
|
|
357
361
|
result.type = '{}'
|
|
358
362
|
result.isInlineDeclaration = true
|
|
359
363
|
} else {
|
|
360
|
-
|
|
364
|
+
if (isInlineEnumType(element, this.csn)) {
|
|
365
|
+
// we use the singular as the initial declaration of these enums takes place
|
|
366
|
+
// while defining the singular class. Which therefore uses the singular over the plural name.
|
|
367
|
+
const cleanEntityName = util.singular4(element.parent, true)
|
|
368
|
+
const enumName = propertyToAnonymousEnumName(cleanEntityName, element.name)
|
|
369
|
+
result.type = enumName
|
|
370
|
+
result.plainName = enumName
|
|
371
|
+
result.isInlineDeclaration = true
|
|
372
|
+
} else {
|
|
373
|
+
this.resolvePotentialReferenceType(element.type, result, file)
|
|
374
|
+
}
|
|
361
375
|
}
|
|
362
376
|
|
|
363
377
|
// objects and arrays
|
package/lib/csn.js
CHANGED
|
@@ -28,6 +28,7 @@ class DraftUnroller {
|
|
|
28
28
|
*/
|
|
29
29
|
#setDraftable(entity, value) {
|
|
30
30
|
if (typeof entity === 'string') entity = this.#getDefinition(entity)
|
|
31
|
+
if (!entity) return // inline definition -- not found in definitions
|
|
31
32
|
entity[annotation] = value
|
|
32
33
|
this.#draftable[entity.name] = value
|
|
33
34
|
if (value) {
|
|
@@ -41,9 +42,13 @@ class DraftUnroller {
|
|
|
41
42
|
* @param entity {object | string} - entity to look draftability up for.
|
|
42
43
|
* @returns {boolean}
|
|
43
44
|
*/
|
|
44
|
-
#getDraftable(
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
#getDraftable(entityOrName) {
|
|
46
|
+
const entity = (typeof entityOrName === 'string')
|
|
47
|
+
? this.#getDefinition(entityOrName)
|
|
48
|
+
: entityOrName
|
|
49
|
+
// assert(typeof entity !== 'string')
|
|
50
|
+
const name = entity?.name ?? entityOrName
|
|
51
|
+
return this.#draftable[name] ??= this.#propagateInheritance(entity)
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
/**
|
|
@@ -59,8 +64,8 @@ class DraftUnroller {
|
|
|
59
64
|
* @param entity {object} - entity to pull draftability from its parents.
|
|
60
65
|
*/
|
|
61
66
|
#propagateInheritance(entity) {
|
|
62
|
-
const annotations = (entity
|
|
63
|
-
annotations.push(entity[annotation])
|
|
67
|
+
const annotations = (entity?.includes ?? []).map(parent => this.#getDraftable(parent))
|
|
68
|
+
annotations.push(entity?.[annotation])
|
|
64
69
|
this.#setDraftable(entity, annotations.filter(a => a !== undefined).at(-1) ?? false)
|
|
65
70
|
}
|
|
66
71
|
|
|
@@ -127,7 +132,7 @@ class DraftUnroller {
|
|
|
127
132
|
* (a) aspects via `A: B`, where `B` is draft enabled.
|
|
128
133
|
* Note that when an entity extends two other entities of which one has drafts enabled and
|
|
129
134
|
* one has not, then the one that is later in the list of mixins "wins":
|
|
130
|
-
* @example
|
|
135
|
+
* @example
|
|
131
136
|
* ```ts
|
|
132
137
|
* @odata.draft.enabled true
|
|
133
138
|
* entity T {}
|
package/lib/file.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs').promises
|
|
4
4
|
const { readFileSync } = require('fs')
|
|
5
|
+
const { printEnum, stringifyNamedEnum, stringifyAnonymousEnum, propertyToAnonymousEnumName } = require('./components/enum')
|
|
5
6
|
const path = require('path')
|
|
6
7
|
|
|
7
8
|
const AUTO_GEN_NOTE = "// This is an automatically generated file. Please do not change its contents manually!"
|
|
@@ -103,8 +104,10 @@ class SourceFile extends File {
|
|
|
103
104
|
this.events = { buffer: new Buffer(), fqs: []}
|
|
104
105
|
/** @type {Buffer} */
|
|
105
106
|
this.types = new Buffer()
|
|
106
|
-
/** @type {{ buffer: Buffer, fqs: {name: string, fq: string}[]}} */
|
|
107
|
+
/** @type {{ buffer: Buffer, fqs: {name: string, fq: string, property?: string}[]}} */
|
|
107
108
|
this.enums = { buffer: new Buffer(), fqs: [] }
|
|
109
|
+
/** @type {{ buffer: Buffer }} */
|
|
110
|
+
this.inlineEnums = { buffer: new Buffer() }
|
|
108
111
|
/** @type {Buffer} */
|
|
109
112
|
this.classes = new Buffer()
|
|
110
113
|
/** @type {{ buffer: Buffer, names: string[]}} */
|
|
@@ -119,6 +122,8 @@ class SourceFile extends File {
|
|
|
119
122
|
this.typeNames = {}
|
|
120
123
|
/** @type {[string, string, string][]} */
|
|
121
124
|
this.inflections = []
|
|
125
|
+
/** @type {{ buffer: Buffer, names: string[]}} */
|
|
126
|
+
this.services = { buffer: new Buffer(), names: [] }
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
/**
|
|
@@ -218,31 +223,56 @@ class SourceFile extends File {
|
|
|
218
223
|
|
|
219
224
|
/**
|
|
220
225
|
* Adds an enum to this file.
|
|
221
|
-
* @param {string} fq fully qualified name of the enum
|
|
226
|
+
* @param {string} fq fully qualified name of the enum (entity name within CSN)
|
|
222
227
|
* @param {string} name local name of the enum
|
|
223
228
|
* @param {[string, string][]} kvs list of key-value pairs
|
|
229
|
+
* @param {string} [property] property to which the enum is attached.
|
|
230
|
+
* If given, the enum is considered to be an anonymous inline definition of an enum.
|
|
231
|
+
* If not, it is considered to be regular, named enum.
|
|
224
232
|
*/
|
|
225
233
|
addEnum(fq, name, kvs) {
|
|
226
|
-
// CDS differ from TS enums as they can use bools as value (TS: only number and string)
|
|
227
|
-
// So we have to emulate enums by adding an object (name -> value mappings)
|
|
228
|
-
// and a type containing all disctinct values.
|
|
229
|
-
// We can get away with this as TS doesn't feature nominal typing, so the structure
|
|
230
|
-
// is all we care about.
|
|
231
|
-
// FIXME: this really should be in visitor, as File should not contain logic of this kind
|
|
232
234
|
this.enums.fqs.push({ name, fq })
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
235
|
+
printEnum(this.enums.buffer, name, kvs)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Adds an anonymous enum to this file.
|
|
240
|
+
* @param {string} entityCleanName name of the entity the enum is attached to without namespace
|
|
241
|
+
* @param {string} entityFqName name of the entity the enum is attached to with namespace
|
|
242
|
+
*
|
|
243
|
+
* @param {string} propertyName property to which the enum is attached.
|
|
244
|
+
* @param {[string, string][]} kvs list of key-value pairs
|
|
245
|
+
* If given, the enum is considered to be an anonymous inline definition of an enum.
|
|
246
|
+
* If not, it is considered to be regular, named enum.
|
|
247
|
+
*
|
|
248
|
+
* @example
|
|
249
|
+
* ```js
|
|
250
|
+
* addAnonymousEnum('Books.genre', 'Books', 'genre', [['horror','horror']])
|
|
251
|
+
* ```
|
|
252
|
+
* generates
|
|
253
|
+
* ```js
|
|
254
|
+
* // index.js
|
|
255
|
+
* module.exports.Books.genre = F(cds.model.definitions['Books'].elements.genre.enum)
|
|
256
|
+
* // F(...) is a function that maps a CSN enum to a more convenient style
|
|
257
|
+
* ```
|
|
258
|
+
* and also
|
|
259
|
+
* ```ts
|
|
260
|
+
* // index.ts
|
|
261
|
+
* const Books_genre = { horror: 'horror' }
|
|
262
|
+
* type Books_genre = 'horror'
|
|
263
|
+
* class Book {
|
|
264
|
+
* static genre = Books_genre
|
|
265
|
+
* genre: Books_genre
|
|
266
|
+
* }
|
|
267
|
+
* ```
|
|
268
|
+
*/
|
|
269
|
+
addAnonymousEnum(entityCleanName, entityFqName, propertyName, kvs) {
|
|
270
|
+
this.enums.fqs.push({
|
|
271
|
+
name: entityFqName,
|
|
272
|
+
property: propertyName,
|
|
273
|
+
fq: `${entityCleanName}.${propertyName}`
|
|
274
|
+
})
|
|
275
|
+
printEnum(this.inlineEnums.buffer, propertyToAnonymousEnumName(entityCleanName, propertyName), kvs, {export: false})
|
|
246
276
|
}
|
|
247
277
|
|
|
248
278
|
/**
|
|
@@ -298,6 +328,19 @@ class SourceFile extends File {
|
|
|
298
328
|
this.types.add(`export type ${clean} = ${rhs};`)
|
|
299
329
|
}
|
|
300
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Adds a service to the file.
|
|
333
|
+
* We consider each service its own distinct namespace and therefore expect
|
|
334
|
+
* at most one service per file.
|
|
335
|
+
* @param {string} fq the fully qualified name of the service
|
|
336
|
+
*/
|
|
337
|
+
addService(fq) {
|
|
338
|
+
if (this.services.names.length) {
|
|
339
|
+
throw new Error(`trying to add more than one service to file ${this.path.asDirectory()}. Existing service is ${this.services.names[0]}, trying to add ${fq}`)
|
|
340
|
+
}
|
|
341
|
+
this.services.names.push(fq)
|
|
342
|
+
}
|
|
343
|
+
|
|
301
344
|
/**
|
|
302
345
|
* Writes all imports to a buffer, relative to the current file.
|
|
303
346
|
* Creates a new buffer on each call, as concatenating import strings directly
|
|
@@ -327,12 +370,14 @@ class SourceFile extends File {
|
|
|
327
370
|
this.getImports().join(),
|
|
328
371
|
this.preamble.join(),
|
|
329
372
|
this.types.join(),
|
|
330
|
-
this.enums.buffer.join(),
|
|
373
|
+
this.enums.buffer.join(),
|
|
374
|
+
this.inlineEnums.buffer.join(), // needs to be before classes
|
|
331
375
|
namespaces.join(),
|
|
332
376
|
this.aspects.join(), // needs to be before classes
|
|
333
377
|
this.classes.join(),
|
|
334
378
|
this.events.buffer.join(),
|
|
335
379
|
this.actions.buffer.join(),
|
|
380
|
+
this.services.buffer.join() // should be at the end
|
|
336
381
|
].filter(Boolean).join('\n')
|
|
337
382
|
}
|
|
338
383
|
|
|
@@ -362,7 +407,11 @@ class SourceFile extends File {
|
|
|
362
407
|
.concat(['// actions'])
|
|
363
408
|
.concat(this.actions.names.map(name => `module.exports.${name} = '${name}'`))
|
|
364
409
|
.concat(['// enums'])
|
|
365
|
-
.concat(this.enums.fqs.map(({fq,
|
|
410
|
+
.concat(this.enums.fqs.map(({name, fq, property}) => property
|
|
411
|
+
? stringifyAnonymousEnum(name, fq, property)
|
|
412
|
+
: stringifyNamedEnum(name, fq)))
|
|
413
|
+
// FIXME: move stringification of service into own module
|
|
414
|
+
.concat(this.services.names.map(name => `module.exports.default = { name: '${name}' }`)) // there should be only one
|
|
366
415
|
.join('\n') + '\n'
|
|
367
416
|
}
|
|
368
417
|
}
|
package/lib/visitor.js
CHANGED
|
@@ -8,6 +8,7 @@ const { FlatInlineDeclarationResolver, StructuredInlineDeclarationResolver } = r
|
|
|
8
8
|
const { Resolver } = require('./components/resolver')
|
|
9
9
|
const { Logger } = require('./logging')
|
|
10
10
|
const { docify } = require('./components/wrappers')
|
|
11
|
+
const { csnToEnum, propertyToAnonymousEnumName, isInlineEnumType } = require('./components/enum')
|
|
11
12
|
|
|
12
13
|
/** @typedef {import('./file').File} File */
|
|
13
14
|
/** @typedef {{ entity: String }} Context */
|
|
@@ -56,11 +57,11 @@ class Visitor {
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
|
-
* @param csn root CSN
|
|
60
|
+
* @param {{xtended: CSN, inferred: CSN}} csn root CSN
|
|
60
61
|
* @param {VisitorOptions} options
|
|
61
62
|
*/
|
|
62
63
|
constructor(csn, options = {}, logger = new Logger()) {
|
|
63
|
-
amendCSN(csn)
|
|
64
|
+
amendCSN(csn.xtended)
|
|
64
65
|
this.options = { ...defaults, ...options }
|
|
65
66
|
this.logger = logger
|
|
66
67
|
this.csn = csn
|
|
@@ -96,8 +97,29 @@ class Visitor {
|
|
|
96
97
|
* Visits all definitions within the CSN definitions.
|
|
97
98
|
*/
|
|
98
99
|
visitDefinitions() {
|
|
99
|
-
for (const [name, entity] of Object.entries(this.csn.definitions)) {
|
|
100
|
-
|
|
100
|
+
for (const [name, entity] of Object.entries(this.csn.xtended.definitions)) {
|
|
101
|
+
if (entity._unresolved === true) {
|
|
102
|
+
this.logger.error(`Skipping unresolved entity: ${JSON.stringify(entity)}`)
|
|
103
|
+
} else {
|
|
104
|
+
this.visitEntity(name, entity)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// FIXME: optimise
|
|
108
|
+
// We are currently working with two flavours of CSN:
|
|
109
|
+
// xtended, as it is as close as possible to an OOP class hierarchy
|
|
110
|
+
// inferred, as it contains information missing in xtended
|
|
111
|
+
// This is less than optimal and has to be revisited at some point!
|
|
112
|
+
const handledKeys = new Set(Object.keys(this.csn.xtended.definitions))
|
|
113
|
+
// we are looking for autoexposed entities in services
|
|
114
|
+
const missing = Object.entries(this.csn.inferred.definitions).filter(([key]) => !key.endsWith('.texts') &&!handledKeys.has(key))
|
|
115
|
+
for (const [name, entity] of missing) {
|
|
116
|
+
// instead of using the definition from inferred CSN, we refer to the projected entity from xtended CSN instead.
|
|
117
|
+
// The latter contains the CSN fixes (propagated foreign keys, etc) and none of the localised fields we don't handle yet.
|
|
118
|
+
if (entity.projection) {
|
|
119
|
+
this.visitEntity(name, this.csn.xtended.definitions[entity.projection.from.ref[0]])
|
|
120
|
+
} else {
|
|
121
|
+
this.logger.error(`Expecting an autoexposed projection within a service. Skipping ${name}`)
|
|
122
|
+
}
|
|
101
123
|
}
|
|
102
124
|
}
|
|
103
125
|
|
|
@@ -112,7 +134,7 @@ class Visitor {
|
|
|
112
134
|
* @param {Buffer} buffer the buffer to write the resulting definitions into
|
|
113
135
|
* @param {string?} cleanName the clean name to use. If not passed, it is derived from the passed name instead.
|
|
114
136
|
*/
|
|
115
|
-
|
|
137
|
+
#aspectify(name, entity, buffer, cleanName = undefined) {
|
|
116
138
|
const clean = cleanName ?? this.resolver.trimNamespace(name)
|
|
117
139
|
const ns = this.resolver.resolveNamespace(name.split('.'))
|
|
118
140
|
const file = this.getNamespaceFile(ns)
|
|
@@ -120,9 +142,7 @@ class Visitor {
|
|
|
120
142
|
const identSingular = (name) => name
|
|
121
143
|
const identAspect = (name) => `_${name}Aspect`
|
|
122
144
|
|
|
123
|
-
this.contexts.push({
|
|
124
|
-
entity: name,
|
|
125
|
-
})
|
|
145
|
+
this.contexts.push({ entity: name })
|
|
126
146
|
|
|
127
147
|
// CLASS ASPECT
|
|
128
148
|
buffer.add(`export function ${identAspect(clean)}<TBase extends new (...args: any[]) => object>(Base: TBase) {`)
|
|
@@ -130,6 +150,7 @@ class Visitor {
|
|
|
130
150
|
buffer.add(`return class ${clean} extends Base {`)
|
|
131
151
|
buffer.indent()
|
|
132
152
|
|
|
153
|
+
const enums = []
|
|
133
154
|
for (const [ename, element] of Object.entries(entity.elements ?? {})) {
|
|
134
155
|
this.visitElement(ename, element, file, buffer)
|
|
135
156
|
|
|
@@ -138,12 +159,22 @@ class Visitor {
|
|
|
138
159
|
// lookup in cds.definitions can fail for inline structs.
|
|
139
160
|
// We don't really have to care for this case, as keys from such structs are _not_ propagated to
|
|
140
161
|
// the containing entity.
|
|
141
|
-
for (const [kname, kelement] of Object.entries(this.csn.definitions[element.target]?.keys ?? {})) {
|
|
162
|
+
for (const [kname, kelement] of Object.entries(this.csn.xtended.definitions[element.target]?.keys ?? {})) {
|
|
142
163
|
this.visitElement(`${ename}_${kname}`, kelement, file, buffer)
|
|
143
164
|
}
|
|
144
165
|
}
|
|
145
|
-
}
|
|
146
166
|
|
|
167
|
+
// store inline enums for later handling, as they have to go into one common "static elements" wrapper
|
|
168
|
+
if (isInlineEnumType(element, this.csn.xtended)) {
|
|
169
|
+
enums.push(element)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
buffer.indent()
|
|
174
|
+
for (const e of enums) {
|
|
175
|
+
buffer.add(`static ${e.name} = ${propertyToAnonymousEnumName(clean, e.name)}`)
|
|
176
|
+
file.addAnonymousEnum(clean, name, e.name, csnToEnum(e, {unwrapVals: true}))
|
|
177
|
+
}
|
|
147
178
|
buffer.add('static actions: {')
|
|
148
179
|
buffer.indent()
|
|
149
180
|
for (const [aname, action] of Object.entries(entity.actions ?? {})) {
|
|
@@ -157,6 +188,7 @@ class Visitor {
|
|
|
157
188
|
)
|
|
158
189
|
}
|
|
159
190
|
buffer.outdent()
|
|
191
|
+
buffer.outdent()
|
|
160
192
|
buffer.add('}') // end of actions
|
|
161
193
|
|
|
162
194
|
buffer.outdent()
|
|
@@ -192,10 +224,12 @@ class Visitor {
|
|
|
192
224
|
}
|
|
193
225
|
|
|
194
226
|
#staticClassContents(clean, entity) {
|
|
195
|
-
return this.#isDraftEnabled(entity) ? [`static drafts: typeof ${clean}`] : []
|
|
227
|
+
return this.#isDraftEnabled(entity) ? [`static drafts: typeof ${clean}`] : []
|
|
196
228
|
}
|
|
197
229
|
|
|
198
230
|
#printEntity(name, entity) {
|
|
231
|
+
// static .name has to be defined more forcefully: https://github.com/microsoft/TypeScript/issues/442
|
|
232
|
+
const overrideNameProperty = (clazz, content) => `Object.defineProperty(${clazz}, 'name', { value: '${content}' })`
|
|
199
233
|
const clean = this.resolver.trimNamespace(name)
|
|
200
234
|
const ns = this.resolver.resolveNamespace(name.split('.'))
|
|
201
235
|
const file = this.getNamespaceFile(ns)
|
|
@@ -211,7 +245,7 @@ class Visitor {
|
|
|
211
245
|
`Derived singular and plural forms for '${singular}' are the same. This usually happens when your CDS entities are named following singular flexion. Consider naming your entities in plural or providing '@singular:'/ '@plural:' annotations to have a clear distinction between the two. Plural form will be renamed to '${plural}' to avoid compilation errors within the output.`
|
|
212
246
|
)
|
|
213
247
|
}
|
|
214
|
-
if (singular in this.csn.definitions) {
|
|
248
|
+
if (singular in this.csn.xtended.definitions) {
|
|
215
249
|
this.logger.error(
|
|
216
250
|
`Derived singular '${singular}' for your entity '${name}', already exists. The resulting types will be erronous. Please consider using '@singular:'/ '@plural:' annotations in your model to resolve this collision.`
|
|
217
251
|
)
|
|
@@ -220,10 +254,9 @@ class Visitor {
|
|
|
220
254
|
file.addClass(plural, name)
|
|
221
255
|
|
|
222
256
|
const parent = this.resolver.resolveParent(entity.name)
|
|
223
|
-
const buffer =
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
: file.classes
|
|
257
|
+
const buffer = parent && parent.kind === 'entity'
|
|
258
|
+
? file.getSubNamespace(this.resolver.trimNamespace(parent.name))
|
|
259
|
+
: file.classes
|
|
227
260
|
|
|
228
261
|
// we can't just use "singular" here, as it may have the subnamespace removed:
|
|
229
262
|
// "Books.text" is just "text" in "singular". Within the inflected exports we need
|
|
@@ -236,7 +269,7 @@ class Visitor {
|
|
|
236
269
|
docify(entity.doc).forEach((d) => buffer.add(d))
|
|
237
270
|
}
|
|
238
271
|
|
|
239
|
-
this
|
|
272
|
+
this.#aspectify(name, entity, file.classes, singular)
|
|
240
273
|
|
|
241
274
|
// PLURAL
|
|
242
275
|
if (plural.includes('.')) {
|
|
@@ -246,6 +279,8 @@ class Visitor {
|
|
|
246
279
|
// plural can not be a type alias to $singular[] but needs to be a proper class instead,
|
|
247
280
|
// so it can get passed as value to CQL functions.
|
|
248
281
|
buffer.add(`export class ${plural} extends Array<${singular}> {${this.#staticClassContents(singular, entity).join('\n')}}`)
|
|
282
|
+
buffer.add(overrideNameProperty(singular, entity.name))
|
|
283
|
+
buffer.add(overrideNameProperty(plural, entity.name))
|
|
249
284
|
buffer.add('')
|
|
250
285
|
}
|
|
251
286
|
|
|
@@ -294,13 +329,7 @@ class Visitor {
|
|
|
294
329
|
const ns = this.resolver.resolveNamespace(name.split('.'))
|
|
295
330
|
const file = this.getNamespaceFile(ns)
|
|
296
331
|
if ('enum' in type) {
|
|
297
|
-
|
|
298
|
-
const val = (k,v) => type.type === 'cds.String' ? `"${v ?? k}"` : v
|
|
299
|
-
file.addEnum(
|
|
300
|
-
name,
|
|
301
|
-
clean,
|
|
302
|
-
Object.entries(type.enum).map(([k, v]) => [k, val(k, v.val)])
|
|
303
|
-
)
|
|
332
|
+
file.addEnum(name, clean, csnToEnum(type))
|
|
304
333
|
} else {
|
|
305
334
|
// alias
|
|
306
335
|
file.addType(name, clean, this.resolver.resolveAndRequire(type, file).typeName)
|
|
@@ -318,7 +347,7 @@ class Visitor {
|
|
|
318
347
|
// So we separate them into another buffer which is printed before the classes.
|
|
319
348
|
file.addClass(clean, name)
|
|
320
349
|
file.aspects.add(`// the following represents the CDS aspect '${clean}'`)
|
|
321
|
-
this
|
|
350
|
+
this.#aspectify(name, aspect, file.aspects, clean)
|
|
322
351
|
}
|
|
323
352
|
|
|
324
353
|
#printEvent(name, event) {
|
|
@@ -341,6 +370,15 @@ class Visitor {
|
|
|
341
370
|
buffer.add('}')
|
|
342
371
|
}
|
|
343
372
|
|
|
373
|
+
#printService(name, service) {
|
|
374
|
+
this.logger.debug(`Printing service ${name}:\n${JSON.stringify(service, null, 2)}`)
|
|
375
|
+
const ns = this.resolver.resolveNamespace(name)
|
|
376
|
+
const file = this.getNamespaceFile(ns)
|
|
377
|
+
// service.name is clean of namespace
|
|
378
|
+
file.services.buffer.add(`export default { name: '${service.name}' }`)
|
|
379
|
+
file.addService(service.name)
|
|
380
|
+
}
|
|
381
|
+
|
|
344
382
|
/**
|
|
345
383
|
* Visits a single entity from the CSN's definition field.
|
|
346
384
|
* Will call #printEntity or #printAction based on the entity's kind.
|
|
@@ -367,6 +405,9 @@ class Visitor {
|
|
|
367
405
|
case 'event':
|
|
368
406
|
this.#printEvent(name, entity)
|
|
369
407
|
break
|
|
408
|
+
case 'service':
|
|
409
|
+
this.#printService(name, entity)
|
|
410
|
+
break
|
|
370
411
|
default:
|
|
371
412
|
this.logger.error(`Unhandled entity kind '${entity.kind}'.`)
|
|
372
413
|
}
|