@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 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.9.1 - TBD
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 csn = await cds.linked(await cds.load(paths, { docs: true, flavor: 'xtended' }))
49
- return compileFromCSN(csn, parameters)
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 csn {CSN}
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) //`Array<${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
- this.resolvePotentialReferenceType(element.type, result, file)
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(entity) {
45
- if (typeof entity === 'string') entity = this.#getDefinition(entity)
46
- return this.#draftable[entity.name] ??= this.#propagateInheritance(entity)
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.includes ?? []).map(parent => this.#getDraftable(parent))
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 sdasd
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
- * stringifyLambda({parameters: [['p','T']]} // (p: T) => any
131
- * stringifyLambda({name: 'f', parameters: [['p','T']]} // f: (p: T) => any
132
- * stringifyLambda({name: 'f', parameters: [['p','T']], returns: 'number'} // f: (p: T) => number
133
- * stringifyLambda({name: 'f', parameters: [['p','T']], returns: 'number', initialiser: '_ => 42'} // f: (p: T) => string = _ => 42
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 signature = `(${parameters.map(([n, t]) => `${n}: ${t}`).join(', ')}) => ${returns}`
139
- const prefix = name ? `${name}: `: ''
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
- return prefix + signature + suffix
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
- const bu = this.enums.buffer
218
- bu.add('// enum')
219
- bu.add(`export const ${name} = {`)
220
- bu.indent()
221
- const vals = new Set()
222
- for (const [k, v] of kvs) {
223
- bu.add(`${k}: ${v},`)
224
- vals.add(v)
225
- }
226
- bu.outdent()
227
- bu.add('}')
228
- bu.add(`export type ${name} = ${[...vals].join(' | ')}`)
229
- bu.add('')
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, name}) => `module.exports.${name} = Object.fromEntries(Object.entries(cds.model.definitions['${fq}'].enum).map(([k,v]) => [k,v.val]))`))
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
- this.visitEntity(name, entity)
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
- _aspectify(name, entity, buffer, cleanName = undefined) {
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 this['${aname}']`
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
- parent && parent.kind === 'entity'
220
- ? file.getSubNamespace(this.resolver.trimNamespace(parent.name))
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._aspectify(name, entity, file.classes, singular)
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
- // in case of strings, wrap in quotes and fallback to key to make sure values are attached for every key
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._aspectify(name, aspect, file.aspects, clean)
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/cds-typer",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Generates .ts files for a CDS model to receive code completion in VS Code",
5
5
  "main": "index.js",
6
6
  "repository": "github:cap-js/cds-typer",