@cap-js/cds-typer 0.9.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 +25 -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 +96 -31
- package/lib/visitor.js +75 -29
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,10 +4,34 @@ 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
|
+
### 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!)
|
|
22
|
+
|
|
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
|
|
29
|
+
|
|
30
|
+
## Version 0.10.0 - 2023-09-21
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
- Actions and functions are now attached to a static `.actions` property of each generated class. This reflects the runtime behaviour better than the former way of generating instance methods
|
|
34
|
+
|
|
11
35
|
### Added
|
|
12
36
|
|
|
13
37
|
### Fixed
|
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
|
/**
|
|
@@ -127,18 +132,34 @@ class SourceFile extends File {
|
|
|
127
132
|
* @returns {string} - the stringified lambda
|
|
128
133
|
* @example
|
|
129
134
|
* ```js
|
|
130
|
-
*
|
|
131
|
-
* stringifyLambda({
|
|
132
|
-
* stringifyLambda({name: 'f', parameters: [['p','T']]
|
|
133
|
-
* stringifyLambda({name: 'f', parameters: [['p','T']], returns: 'number'
|
|
135
|
+
* // note: these samples are actually simplified! See below.
|
|
136
|
+
* stringifyLambda({parameters: [['p','T']]}) // f: { (p: T): any, ... }
|
|
137
|
+
* stringifyLambda({name: 'f', parameters: [['p','T']]}) // f: { (p: T) => any, ... }
|
|
138
|
+
* stringifyLambda({name: 'f', parameters: [['p','T']], returns: 'number'}) // f: { (p: T) => number, ... }
|
|
139
|
+
* stringifyLambda({name: 'f', parameters: [['p','T']], returns: 'number', initialiser: '_ => 42'}) // f: { (p: T): string = _ => 42, ... }
|
|
140
|
+
* ```
|
|
141
|
+
*
|
|
142
|
+
* The generated string will not be just the signature of the function. Instead, it will be an object offering a callable signature.
|
|
143
|
+
* On top of that, it will also expose a property `__parameters`, which is an object reflecting the functions parameters.
|
|
144
|
+
* The reason for this is that the CDS runtime actually treats the function parameters as a named object. This can not be rectified via
|
|
145
|
+
* type magic, as parameter names do not exist on type level. So we can not use these names to reuse them as object properties.
|
|
146
|
+
* Instead, we generate this utility object for the runtime to use:
|
|
134
147
|
*
|
|
148
|
+
* @example
|
|
149
|
+
* ```js
|
|
150
|
+
* stringifyLambda({name: 'f', parameters: [['p','T']], returns: 'number'}) // { (p: T): number, __parameters: { p: T } }
|
|
135
151
|
* ```
|
|
136
152
|
*/
|
|
137
|
-
static stringifyLambda({name, parameters=[], returns='any', initialiser}) {
|
|
138
|
-
const
|
|
139
|
-
const
|
|
153
|
+
static stringifyLambda({name, parameters=[], returns='any', initialiser, isStatic=false}) {
|
|
154
|
+
const parameterTypes = parameters.map(([n, t]) => `${n}: ${t}`).join(', ')
|
|
155
|
+
const callableSignature = `(${parameterTypes}): ${returns}`
|
|
156
|
+
let prefix = name ? `${name}: `: ''
|
|
157
|
+
if (prefix && isStatic) {
|
|
158
|
+
prefix = `static ${prefix}`
|
|
159
|
+
}
|
|
140
160
|
const suffix = initialiser ? ` = ${initialiser}` : ''
|
|
141
|
-
|
|
161
|
+
const lambda = `{ ${callableSignature}, __parameters: {${parameterTypes}}, __returns: ${returns} }`
|
|
162
|
+
return prefix + lambda + suffix
|
|
142
163
|
}
|
|
143
164
|
|
|
144
165
|
/**
|
|
@@ -202,31 +223,56 @@ class SourceFile extends File {
|
|
|
202
223
|
|
|
203
224
|
/**
|
|
204
225
|
* Adds an enum to this file.
|
|
205
|
-
* @param {string} fq fully qualified name of the enum
|
|
226
|
+
* @param {string} fq fully qualified name of the enum (entity name within CSN)
|
|
206
227
|
* @param {string} name local name of the enum
|
|
207
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.
|
|
208
232
|
*/
|
|
209
233
|
addEnum(fq, name, kvs) {
|
|
210
|
-
// CDS differ from TS enums as they can use bools as value (TS: only number and string)
|
|
211
|
-
// So we have to emulate enums by adding an object (name -> value mappings)
|
|
212
|
-
// and a type containing all disctinct values.
|
|
213
|
-
// We can get away with this as TS doesn't feature nominal typing, so the structure
|
|
214
|
-
// is all we care about.
|
|
215
|
-
// FIXME: this really should be in visitor, as File should not contain logic of this kind
|
|
216
234
|
this.enums.fqs.push({ name, fq })
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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})
|
|
230
276
|
}
|
|
231
277
|
|
|
232
278
|
/**
|
|
@@ -282,6 +328,19 @@ class SourceFile extends File {
|
|
|
282
328
|
this.types.add(`export type ${clean} = ${rhs};`)
|
|
283
329
|
}
|
|
284
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
|
+
|
|
285
344
|
/**
|
|
286
345
|
* Writes all imports to a buffer, relative to the current file.
|
|
287
346
|
* Creates a new buffer on each call, as concatenating import strings directly
|
|
@@ -311,12 +370,14 @@ class SourceFile extends File {
|
|
|
311
370
|
this.getImports().join(),
|
|
312
371
|
this.preamble.join(),
|
|
313
372
|
this.types.join(),
|
|
314
|
-
this.enums.buffer.join(),
|
|
373
|
+
this.enums.buffer.join(),
|
|
374
|
+
this.inlineEnums.buffer.join(), // needs to be before classes
|
|
315
375
|
namespaces.join(),
|
|
316
376
|
this.aspects.join(), // needs to be before classes
|
|
317
377
|
this.classes.join(),
|
|
318
378
|
this.events.buffer.join(),
|
|
319
379
|
this.actions.buffer.join(),
|
|
380
|
+
this.services.buffer.join() // should be at the end
|
|
320
381
|
].filter(Boolean).join('\n')
|
|
321
382
|
}
|
|
322
383
|
|
|
@@ -346,7 +407,11 @@ class SourceFile extends File {
|
|
|
346
407
|
.concat(['// actions'])
|
|
347
408
|
.concat(this.actions.names.map(name => `module.exports.${name} = '${name}'`))
|
|
348
409
|
.concat(['// enums'])
|
|
349
|
-
.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
|
|
350
415
|
.join('\n') + '\n'
|
|
351
416
|
}
|
|
352
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,26 +159,42 @@ 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
|
+
}
|
|
178
|
+
buffer.add('static actions: {')
|
|
179
|
+
buffer.indent()
|
|
147
180
|
for (const [aname, action] of Object.entries(entity.actions ?? {})) {
|
|
148
181
|
buffer.add(
|
|
149
182
|
SourceFile.stringifyLambda({
|
|
150
183
|
name: aname,
|
|
151
184
|
parameters: this.#stringifyFunctionParams(action.params, file),
|
|
152
|
-
returns: action.returns ? this.resolver.resolveAndRequire(action.returns, file).typeName : 'any'
|
|
153
|
-
initialiser: `undefined as unknown as
|
|
185
|
+
returns: action.returns ? this.resolver.resolveAndRequire(action.returns, file).typeName : 'any'
|
|
186
|
+
//initialiser: `undefined as unknown as typeof ${clean}.${aname}`,
|
|
154
187
|
})
|
|
155
188
|
)
|
|
156
189
|
}
|
|
157
190
|
buffer.outdent()
|
|
158
|
-
buffer.add('};')
|
|
159
191
|
buffer.outdent()
|
|
160
|
-
buffer.add('}')
|
|
192
|
+
buffer.add('}') // end of actions
|
|
193
|
+
|
|
194
|
+
buffer.outdent()
|
|
195
|
+
buffer.add('};') // end of generated class
|
|
196
|
+
buffer.outdent()
|
|
197
|
+
buffer.add('}') // end of aspect
|
|
161
198
|
|
|
162
199
|
// CLASS WITH ADDED ASPECTS
|
|
163
200
|
file.addImport(baseDefinitions.path)
|
|
@@ -187,10 +224,12 @@ class Visitor {
|
|
|
187
224
|
}
|
|
188
225
|
|
|
189
226
|
#staticClassContents(clean, entity) {
|
|
190
|
-
return this.#isDraftEnabled(entity) ? [`static drafts: typeof ${clean}`] : []
|
|
227
|
+
return this.#isDraftEnabled(entity) ? [`static drafts: typeof ${clean}`] : []
|
|
191
228
|
}
|
|
192
229
|
|
|
193
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}' })`
|
|
194
233
|
const clean = this.resolver.trimNamespace(name)
|
|
195
234
|
const ns = this.resolver.resolveNamespace(name.split('.'))
|
|
196
235
|
const file = this.getNamespaceFile(ns)
|
|
@@ -206,7 +245,7 @@ class Visitor {
|
|
|
206
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.`
|
|
207
246
|
)
|
|
208
247
|
}
|
|
209
|
-
if (singular in this.csn.definitions) {
|
|
248
|
+
if (singular in this.csn.xtended.definitions) {
|
|
210
249
|
this.logger.error(
|
|
211
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.`
|
|
212
251
|
)
|
|
@@ -215,10 +254,9 @@ class Visitor {
|
|
|
215
254
|
file.addClass(plural, name)
|
|
216
255
|
|
|
217
256
|
const parent = this.resolver.resolveParent(entity.name)
|
|
218
|
-
const buffer =
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
: file.classes
|
|
257
|
+
const buffer = parent && parent.kind === 'entity'
|
|
258
|
+
? file.getSubNamespace(this.resolver.trimNamespace(parent.name))
|
|
259
|
+
: file.classes
|
|
222
260
|
|
|
223
261
|
// we can't just use "singular" here, as it may have the subnamespace removed:
|
|
224
262
|
// "Books.text" is just "text" in "singular". Within the inflected exports we need
|
|
@@ -231,7 +269,7 @@ class Visitor {
|
|
|
231
269
|
docify(entity.doc).forEach((d) => buffer.add(d))
|
|
232
270
|
}
|
|
233
271
|
|
|
234
|
-
this
|
|
272
|
+
this.#aspectify(name, entity, file.classes, singular)
|
|
235
273
|
|
|
236
274
|
// PLURAL
|
|
237
275
|
if (plural.includes('.')) {
|
|
@@ -241,6 +279,8 @@ class Visitor {
|
|
|
241
279
|
// plural can not be a type alias to $singular[] but needs to be a proper class instead,
|
|
242
280
|
// so it can get passed as value to CQL functions.
|
|
243
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))
|
|
244
284
|
buffer.add('')
|
|
245
285
|
}
|
|
246
286
|
|
|
@@ -289,13 +329,7 @@ class Visitor {
|
|
|
289
329
|
const ns = this.resolver.resolveNamespace(name.split('.'))
|
|
290
330
|
const file = this.getNamespaceFile(ns)
|
|
291
331
|
if ('enum' in type) {
|
|
292
|
-
|
|
293
|
-
const val = (k,v) => type.type === 'cds.String' ? `"${v ?? k}"` : v
|
|
294
|
-
file.addEnum(
|
|
295
|
-
name,
|
|
296
|
-
clean,
|
|
297
|
-
Object.entries(type.enum).map(([k, v]) => [k, val(k, v.val)])
|
|
298
|
-
)
|
|
332
|
+
file.addEnum(name, clean, csnToEnum(type))
|
|
299
333
|
} else {
|
|
300
334
|
// alias
|
|
301
335
|
file.addType(name, clean, this.resolver.resolveAndRequire(type, file).typeName)
|
|
@@ -313,7 +347,7 @@ class Visitor {
|
|
|
313
347
|
// So we separate them into another buffer which is printed before the classes.
|
|
314
348
|
file.addClass(clean, name)
|
|
315
349
|
file.aspects.add(`// the following represents the CDS aspect '${clean}'`)
|
|
316
|
-
this
|
|
350
|
+
this.#aspectify(name, aspect, file.aspects, clean)
|
|
317
351
|
}
|
|
318
352
|
|
|
319
353
|
#printEvent(name, event) {
|
|
@@ -336,6 +370,15 @@ class Visitor {
|
|
|
336
370
|
buffer.add('}')
|
|
337
371
|
}
|
|
338
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
|
+
|
|
339
382
|
/**
|
|
340
383
|
* Visits a single entity from the CSN's definition field.
|
|
341
384
|
* Will call #printEntity or #printAction based on the entity's kind.
|
|
@@ -362,6 +405,9 @@ class Visitor {
|
|
|
362
405
|
case 'event':
|
|
363
406
|
this.#printEvent(name, entity)
|
|
364
407
|
break
|
|
408
|
+
case 'service':
|
|
409
|
+
this.#printService(name, entity)
|
|
410
|
+
break
|
|
365
411
|
default:
|
|
366
412
|
this.logger.error(`Unhandled entity kind '${entity.kind}'.`)
|
|
367
413
|
}
|