@cap-js/cds-typer 0.16.0 → 0.17.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,7 +4,11 @@ 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.17.0 - TBD
7
+ ## Version 0.18.0 - TBD
8
+
9
+ ## Version 0.17.0 - 2024-03-05
10
+ ### Fixed
11
+ - Fixed a bug where refering to an externally defined enum via the `typeof` syntax would crash the type generation
8
12
 
9
13
  ## Version 0.16.0 - 2024-02-01
10
14
  ### Changed
@@ -29,13 +29,9 @@ const { normalise } = require('./identifier')
29
29
  function printEnum(buffer, name, kvs, options = {}) {
30
30
  const opts = {...{export: true}, ...options}
31
31
  buffer.add('// enum')
32
- buffer.add(`${opts.export ? 'export ' : ''}const ${name} = {`)
33
- buffer.indent()
34
- for (const [k, v] of kvs) {
35
- buffer.add(`${normalise(k)}: ${v},`)
36
- }
37
- buffer.outdent()
38
- buffer.add('} as const;')
32
+ buffer.addIndentedBlock(`${opts.export ? 'export ' : ''}const ${name} = {`, () =>
33
+ kvs.forEach(([k, v]) => buffer.add(`${normalise(k)}: ${v},`))
34
+ , '} as const;')
39
35
  buffer.add(`${opts.export ? 'export ' : ''}type ${name} = ${stringifyEnumType(kvs)}`)
40
36
  buffer.add('')
41
37
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Check if an element references another type.
3
+ * This happens for foreign key relationships
4
+ * and for the typeof syntax.
5
+ *
6
+ * ```cds
7
+ * entity E {
8
+ * x: Integer
9
+ * }
10
+ *
11
+ * entity F {
12
+ * y: E.x // <- ref
13
+ * }
14
+ * ```
15
+ *
16
+ * @param {{type: any}} element
17
+ * @returns boolean
18
+ */
19
+ const isReferenceType = (element) => element.type && Object.hasOwn(element.type, 'ref')
20
+
21
+ module.exports = {
22
+ isReferenceType
23
+ }
@@ -6,6 +6,7 @@ const { Buffer, SourceFile, Path, Library, baseDefinitions } = require('../file'
6
6
  const { deepRequire, createToManyAssociation, createToOneAssociation, createArrayOf, createCompositionOfMany, createCompositionOfOne } = require('./wrappers')
7
7
  const { StructuredInlineDeclarationResolver } = require('./inline')
8
8
  const { isInlineEnumType, propertyToInlineEnumName } = require('./enum')
9
+ const { isReferenceType } = require('./reference')
9
10
 
10
11
  /** @typedef {{ cardinality?: { max?: '*' | number } }} EntityCSN */
11
12
  /** @typedef {{ definitions?: Object<string, EntityCSN> }} CSN */
@@ -55,7 +56,7 @@ const Builtins = {
55
56
  Float: 'number',
56
57
  Double: 'number',
57
58
  Boolean: 'boolean',
58
- // note: the date-related types _can_ be Date in some cases, but let's start with string
59
+ // note: the date-related types are strings on purpose, which reflects their runtime behaviour
59
60
  Date: 'string', // yyyy-mm-dd
60
61
  DateTime: 'string', // yyyy-mm-dd + time + TZ (precision: seconds
61
62
  Time: 'string',
@@ -370,7 +371,7 @@ class Resolver {
370
371
  result.type = '{}'
371
372
  result.isInlineDeclaration = true
372
373
  } else {
373
- if (isInlineEnumType(element, this.csn)) {
374
+ if (!isReferenceType(element) && isInlineEnumType(element, this.csn)) {
374
375
  // element.parent is only set if the enum is attached to an entity's property.
375
376
  // If it is missing then we are dealing with an inline parameter type of an action.
376
377
  // Edge case: element.parent is set, but no .name property is attached. This happens
package/lib/csn.js CHANGED
@@ -234,10 +234,12 @@ function amendCSN(csn) {
234
234
  */
235
235
  const isView = entity => entity.query && !entity.projection
236
236
 
237
+ const isDraftEnabled = entity => entity['@odata.draft.enabled'] === true
238
+
237
239
  /**
238
240
  * @see isView
239
241
  * Unresolved entities have to be looked up from inferred csn.
240
242
  */
241
243
  const isUnresolved = entity => entity._unresolved === true
242
244
 
243
- module.exports = { amendCSN, isView, isUnresolved, propagateForeignKeys }
245
+ module.exports = { amendCSN, isView, isDraftEnabled, isUnresolved, propagateForeignKeys }
package/lib/file.js CHANGED
@@ -93,10 +93,13 @@ class Library extends File {
93
93
  * Source file containing several buffers.
94
94
  */
95
95
  class SourceFile extends File {
96
+ /**
97
+ * @param {string | Path} path
98
+ */
96
99
  constructor(path) {
97
100
  super()
98
101
  /** @type {Path} */
99
- this.path = new Path(path.split('.'))
102
+ this.path = path instanceof Path ? path : new Path(path.split('.'))
100
103
  /** @type {Object} */
101
104
  this.imports = {}
102
105
  /** @type {Buffer} */
@@ -480,6 +483,33 @@ class Buffer {
480
483
  add(part) {
481
484
  this.parts.push(this.currentIndent + part)
482
485
  }
486
+
487
+ /**
488
+ * Adds an element to the buffer with one level of indent.
489
+ * @param {string | (() => void)} part either a string or a function. If it is a string, it is added to the buffer.
490
+ * If not, it is expected to be a function that manipulates the buffer as a side effect.
491
+ */
492
+ addIndented(part) {
493
+ this.indent()
494
+ if (typeof part === 'function') {
495
+ part()
496
+ } else if (Array.isArray(part)) {
497
+ part.forEach(p => this.add(p))
498
+ }
499
+ this.outdent()
500
+ }
501
+
502
+ /**
503
+ * Adds an element to a buffer with one level of indent and and opener and closer surrounding it.
504
+ * @param {string} opener the string to put before the indent
505
+ * @param {string} content the content to indent (see {@link addIndented})
506
+ * @param {string} closer the string to put after the indent
507
+ */
508
+ addIndentedBlock(opener, content, closer) {
509
+ this.add(opener)
510
+ this.addIndented(content)
511
+ this.add(closer)
512
+ }
483
513
  }
484
514
 
485
515
  /**
@@ -548,6 +578,38 @@ class Path {
548
578
  }
549
579
  }
550
580
 
581
+ // TODO: having the repository pattern in place we can separate (some of) the printing logic from the visitor.
582
+ // Most of it hinges primarily on resolving specific files. We can now pass the repository and the resolver to a printer.
583
+ class FileRepository {
584
+ #files = {}
585
+
586
+ /**
587
+ * @param {string} name
588
+ * @param {SourceFile} file
589
+ */
590
+ add(name, file) {
591
+ this.#files[name] = file
592
+ }
593
+
594
+ /**
595
+ * Determines the file corresponding to the namespace.
596
+ * If no such file exists yet, it is created first.
597
+ * @param {string | Path} path the name of the namespace (foo.bar.baz)
598
+ * @returns {SourceFile} the file corresponding to that namespace name
599
+ */
600
+ getNamespaceFile(path) {
601
+ const key = path instanceof Path ? path.asNamespace() : path
602
+ return (this.#files[key] ??= new SourceFile(path))
603
+ }
604
+
605
+ /**
606
+ * @returns {SourceFile[]}
607
+ */
608
+ getFiles() {
609
+ return Object.values(this.#files)
610
+ }
611
+ }
612
+
551
613
  /**
552
614
  * Base definitions used throughout the typing process,
553
615
  * such as Associations and Compositions.
@@ -583,7 +645,7 @@ export type EntitySet<T> = T[] & {
583
645
 
584
646
  export type DeepRequired<T> = {
585
647
  [K in keyof T]: DeepRequired<T[K]>
586
- } & Required<T>;
648
+ } & Exclude<Required<T>, null>;
587
649
  `)
588
650
 
589
651
  /**
@@ -618,6 +680,7 @@ module.exports = {
618
680
  Library,
619
681
  Buffer,
620
682
  File,
683
+ FileRepository,
621
684
  SourceFile,
622
685
  Path,
623
686
  writeout,
package/lib/visitor.js CHANGED
@@ -2,14 +2,15 @@
2
2
 
3
3
  const util = require('./util')
4
4
 
5
- const { amendCSN, isView, isUnresolved, propagateForeignKeys } = require('./csn')
5
+ const { amendCSN, isView, isUnresolved, propagateForeignKeys, isDraftEnabled } = require('./csn')
6
6
  // eslint-disable-next-line no-unused-vars
7
- const { SourceFile, baseDefinitions, Buffer } = require('./file')
7
+ const { SourceFile, FileRepository, baseDefinitions, Buffer } = require('./file')
8
8
  const { FlatInlineDeclarationResolver, StructuredInlineDeclarationResolver } = require('./components/inline')
9
9
  const { Resolver } = require('./components/resolver')
10
10
  const { Logger } = require('./logging')
11
11
  const { docify } = require('./components/wrappers')
12
12
  const { csnToEnumPairs, propertyToInlineEnumName, isInlineEnumType, stringifyEnumType } = require('./components/enum')
13
+ const { isReferenceType } = require('./components/reference')
13
14
 
14
15
  /** @typedef {import('./file').File} File */
15
16
  /** @typedef {{ entity: String }} Context */
@@ -54,7 +55,7 @@ class Visitor {
54
55
  * @returns {File[]} a full list of files to be written
55
56
  */
56
57
  getWriteoutFiles() {
57
- return Object.values(this.files).concat(this.resolver.getUsedLibraries())
58
+ return this.fileRepository.getFiles().concat(this.resolver.getUsedLibraries())
58
59
  }
59
60
 
60
61
  /**
@@ -74,9 +75,9 @@ class Visitor {
74
75
  /** @type {Resolver} */
75
76
  this.resolver = new Resolver(this)
76
77
 
77
- /** @type {Object<string, File>} */
78
- this.files = {}
79
- this.files[baseDefinitions.path.asIdentifier()] = baseDefinitions
78
+ /** @type {FileRepository} */
79
+ this.fileRepository = new FileRepository()
80
+ this.fileRepository.add(baseDefinitions.path.asNamespace(), baseDefinitions)
80
81
  this.inlineDeclarationResolver =
81
82
  this.options.inlineDeclarations === 'structured'
82
83
  ? new StructuredInlineDeclarationResolver(this)
@@ -85,16 +86,6 @@ class Visitor {
85
86
  this.visitDefinitions()
86
87
  }
87
88
 
88
- /**
89
- * Determines the file corresponding to the namespace.
90
- * If no such file exists yet, it is created first.
91
- * @param {string} path the name of the namespace (foo.bar.baz)
92
- * @returns {SourceFile} the file corresponding to that namespace name
93
- */
94
- getNamespaceFile(path) {
95
- return (this.files[path] ??= new SourceFile(path))
96
- }
97
-
98
89
  /**
99
90
  * Visits all definitions within the CSN definitions.
100
91
  */
@@ -163,7 +154,7 @@ class Visitor {
163
154
  #aspectify(name, entity, buffer, cleanName = undefined) {
164
155
  const clean = cleanName ?? this.resolver.trimNamespace(name)
165
156
  const ns = this.resolver.resolveNamespace(name.split('.'))
166
- const file = this.getNamespaceFile(ns)
157
+ const file = this.fileRepository.getNamespaceFile(ns)
167
158
 
168
159
  const identSingular = (name) => name
169
160
  const identAspect = (name) => `_${name}Aspect`
@@ -171,66 +162,54 @@ class Visitor {
171
162
  this.contexts.push({ entity: name })
172
163
 
173
164
  // CLASS ASPECT
174
- buffer.add(`export function ${identAspect(clean)}<TBase extends new (...args: any[]) => object>(Base: TBase) {`)
175
- buffer.indent()
176
- buffer.add(`return class ${clean} extends Base {`)
177
- buffer.indent()
178
-
179
- const enums = []
180
- const exclusions = new Set(entity.projection?.excluding ?? [])
181
- const elements = Object.entries(entity.elements ?? {}).filter(([ename]) => !exclusions.has(ename))
182
- for (const [ename, element] of elements) {
183
- this.visitElement(ename, element, file, buffer)
184
-
185
- // make foreign keys explicit
186
- if ('target' in element) {
187
- // lookup in cds.definitions can fail for inline structs.
188
- // We don't really have to care for this case, as keys from such structs are _not_ propagated to
189
- // the containing entity.
190
- for (const [kname, kelement] of this.#keys(element.target)) {
191
- if (this.resolver.getMaxCardinality(element) === 1) {
192
- const foreignKey = `${ename}_${kname}`
193
- if (Object.hasOwn(entity.elements, foreignKey)) {
194
- this.logger.error(`Attempting to generate a foreign key reference called '${foreignKey}' in type definition for entity ${name}. But a property of that name is already defined explicitly. Consider renaming that property.`)
195
- } else {
196
- kelement.isRefNotNull = !!element.notNull || !!element.key
197
- this.visitElement(foreignKey, kelement, file, buffer)
165
+ buffer.addIndentedBlock(`export function ${identAspect(clean)}<TBase extends new (...args: any[]) => object>(Base: TBase) {`, function () {
166
+ buffer.addIndentedBlock(`return class ${clean} extends Base {`, function () {
167
+ const enums = []
168
+ const exclusions = new Set(entity.projection?.excluding ?? [])
169
+ const elements = Object.entries(entity.elements ?? {}).filter(([ename]) => !exclusions.has(ename))
170
+ for (const [ename, element] of elements) {
171
+ this.visitElement(ename, element, file, buffer)
172
+
173
+ // make foreign keys explicit
174
+ if ('target' in element) {
175
+ // lookup in cds.definitions can fail for inline structs.
176
+ // We don't really have to care for this case, as keys from such structs are _not_ propagated to
177
+ // the containing entity.
178
+ for (const [kname, kelement] of this.#keys(element.target)) {
179
+ if (this.resolver.getMaxCardinality(element) === 1) { // FIXME: kelement?
180
+ const foreignKey = `${ename}_${kname}`
181
+ if (Object.hasOwn(entity.elements, foreignKey)) {
182
+ this.logger.error(`Attempting to generate a foreign key reference called '${foreignKey}' in type definition for entity ${name}. But a property of that name is already defined explicitly. Consider renaming that property.`)
183
+ } else {
184
+ kelement.isRefNotNull = !!element.notNull || !!element.key
185
+ this.visitElement(foreignKey, kelement, file, buffer)
186
+ }
187
+ }
198
188
  }
199
189
  }
200
- }
201
- }
202
190
 
203
- // store inline enums for later handling, as they have to go into one common "static elements" wrapper
204
- if (isInlineEnumType(element, this.csn.xtended)) {
205
- enums.push(element)
206
- }
207
- }
208
-
209
- buffer.indent()
210
- for (const e of enums) {
211
- buffer.add(`static ${e.name} = ${propertyToInlineEnumName(clean, e.name)}`)
212
- file.addInlineEnum(clean, name, e.name, csnToEnumPairs(e, {unwrapVals: true}))
213
- }
214
- buffer.add('static actions: {')
215
- buffer.indent()
216
- for (const [aname, action] of Object.entries(entity.actions ?? {})) {
217
- buffer.add(
218
- SourceFile.stringifyLambda({
219
- name: aname,
220
- parameters: this.#stringifyFunctionParams(action.params, file),
221
- returns: action.returns ? this.resolver.resolveAndRequire(action.returns, file).typeName : 'any'
222
- //initialiser: `undefined as unknown as typeof ${clean}.${aname}`,
223
- })
224
- )
225
- }
226
- buffer.outdent()
227
- buffer.outdent()
228
- buffer.add('}') // end of actions
191
+ // store inline enums for later handling, as they have to go into one common "static elements" wrapper
192
+ if (isInlineEnumType(element, this.csn.xtended)) {
193
+ enums.push(element)
194
+ }
195
+ }
229
196
 
230
- buffer.outdent()
231
- buffer.add('};') // end of generated class
232
- buffer.outdent()
233
- buffer.add('}') // end of aspect
197
+ buffer.addIndented(function() {
198
+ for (const e of enums) {
199
+ buffer.add(`static ${e.name} = ${propertyToInlineEnumName(clean, e.name)}`)
200
+ file.addInlineEnum(clean, name, e.name, csnToEnumPairs(e, {unwrapVals: true}))
201
+ }
202
+ const actions = Object.entries(entity.actions ?? {})
203
+ buffer.addIndentedBlock('static actions: {',
204
+ actions.map(([aname, action]) => SourceFile.stringifyLambda({
205
+ name: aname,
206
+ parameters: this.#stringifyFunctionParams(action.params, file),
207
+ returns: action.returns ? this.resolver.resolveAndRequire(action.returns, file).typeName : 'any'
208
+ }))
209
+ , '}') // end of actions
210
+ }.bind(this))
211
+ }.bind(this), '};') // end of generated class
212
+ }.bind(this), '}') // end of aspect
234
213
 
235
214
  // CLASS WITH ADDED ASPECTS
236
215
  file.addImport(baseDefinitions.path)
@@ -255,12 +234,8 @@ class Visitor {
255
234
  this.contexts.pop()
256
235
  }
257
236
 
258
- #isDraftEnabled(entity) {
259
- return entity['@odata.draft.enabled'] === true
260
- }
261
-
262
237
  #staticClassContents(clean, entity) {
263
- return this.#isDraftEnabled(entity) ? [`static drafts: typeof ${clean}`] : []
238
+ return isDraftEnabled(entity) ? [`static drafts: typeof ${clean}`] : []
264
239
  }
265
240
 
266
241
  #printEntity(name, entity) {
@@ -268,7 +243,7 @@ class Visitor {
268
243
  const overrideNameProperty = (clazz, content) => `Object.defineProperty(${clazz}, 'name', { value: '${content}' })`
269
244
  const clean = this.resolver.trimNamespace(name)
270
245
  const ns = this.resolver.resolveNamespace(name.split('.'))
271
- const file = this.getNamespaceFile(ns)
246
+ const file = this.fileRepository.getNamespaceFile(ns)
272
247
  // entities are expected to be in plural anyway, so we would favour the regular name.
273
248
  // If the user decides to pass a @plural annotation, that gets precedence over the regular name.
274
249
  let plural = this.resolver.trimNamespace(util.getPluralAnnotation(entity) ? util.plural4(entity, false) : name)
@@ -354,7 +329,7 @@ class Visitor {
354
329
  // FIXME: mostly duplicate of printAction -> reuse
355
330
  this.logger.debug(`Printing function ${name}:\n${JSON.stringify(func, null, 2)}`)
356
331
  const ns = this.resolver.resolveNamespace(name.split('.'))
357
- const file = this.getNamespaceFile(ns)
332
+ const file = this.fileRepository.getNamespaceFile(ns)
358
333
  const params = this.#stringifyFunctionParams(func.params, file)
359
334
  const returns = this.resolver.visitor.inlineDeclarationResolver.getPropertyDatatype(
360
335
  this.resolver.resolveAndRequire(func.returns, file)
@@ -365,7 +340,7 @@ class Visitor {
365
340
  #printAction(name, action) {
366
341
  this.logger.debug(`Printing action ${name}:\n${JSON.stringify(action, null, 2)}`)
367
342
  const ns = this.resolver.resolveNamespace(name.split('.'))
368
- const file = this.getNamespaceFile(ns)
343
+ const file = this.fileRepository.getNamespaceFile(ns)
369
344
  const params = this.#stringifyFunctionParams(action.params, file)
370
345
  const returns = this.resolver.visitor.inlineDeclarationResolver.getPropertyDatatype(
371
346
  this.resolver.resolveAndRequire(action.returns, file)
@@ -375,10 +350,9 @@ class Visitor {
375
350
 
376
351
  #printType(name, type) {
377
352
  this.logger.debug(`Printing type ${name}:\n${JSON.stringify(type, null, 2)}`)
378
- const clean = this.resolver.trimNamespace(name)
379
- const ns = this.resolver.resolveNamespace(name.split('.'))
380
- const file = this.getNamespaceFile(ns)
381
- if ('enum' in type) {
353
+ const [ns, clean] = this.resolver.untangle(name)
354
+ const file = this.fileRepository.getNamespaceFile(ns)
355
+ if ('enum' in type && !isReferenceType(type)) { // skip references to enums
382
356
  file.addEnum(name, clean, csnToEnumPairs(type))
383
357
  } else {
384
358
  // alias
@@ -389,9 +363,8 @@ class Visitor {
389
363
 
390
364
  #printAspect(name, aspect) {
391
365
  this.logger.debug(`Printing aspect ${name}`)
392
- const clean = this.resolver.trimNamespace(name)
393
- const ns = this.resolver.resolveNamespace(name.split('.'))
394
- const file = this.getNamespaceFile(ns)
366
+ const [ns, clean] = this.resolver.untangle(name)
367
+ const file = this.fileRepository.getNamespaceFile(ns)
395
368
  // aspects are technically classes and can therefore be added to the list of defined classes.
396
369
  // Still, when using them as mixins for a class, they need to already be defined.
397
370
  // So we separate them into another buffer which is printed before the classes.
@@ -402,28 +375,25 @@ class Visitor {
402
375
 
403
376
  #printEvent(name, event) {
404
377
  this.logger.debug(`Printing event ${name}`)
405
- const clean = this.resolver.trimNamespace(name)
406
- const ns = this.resolver.resolveNamespace(name.split('.'))
407
- const file = this.getNamespaceFile(ns)
378
+ const [ns, clean] = this.resolver.untangle(name)
379
+ const file = this.fileRepository.getNamespaceFile(ns)
408
380
  file.addEvent(clean, name)
409
381
  const buffer = file.events.buffer
410
382
  buffer.add('// event')
411
- buffer.add(`export class ${clean} {`)
412
- buffer.indent()
413
- const propOpt = this.options.propertiesOptional
414
- this.options.propertiesOptional = false
415
- for (const [ename, element] of Object.entries(event.elements ?? {})) {
416
- this.visitElement(ename, element, file, buffer)
417
- }
418
- this.options.propertiesOptional = propOpt
419
- buffer.outdent()
420
- buffer.add('}')
383
+ buffer.addIndentedBlock(`export class ${clean} {`, function() {
384
+ const propOpt = this.options.propertiesOptional
385
+ this.options.propertiesOptional = false
386
+ for (const [ename, element] of Object.entries(event.elements ?? {})) {
387
+ this.visitElement(ename, element, file, buffer)
388
+ }
389
+ this.options.propertiesOptional = propOpt
390
+ }.bind(this), '}')
421
391
  }
422
392
 
423
393
  #printService(name, service) {
424
394
  this.logger.debug(`Printing service ${name}:\n${JSON.stringify(service, null, 2)}`)
425
395
  const ns = this.resolver.resolveNamespace(name)
426
- const file = this.getNamespaceFile(ns)
396
+ const file = this.fileRepository.getNamespaceFile(ns)
427
397
  // service.name is clean of namespace
428
398
  file.services.buffer.add(`export default { name: '${service.name}' }`)
429
399
  file.addService(service.name)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/cds-typer",
3
- "version": "0.16.0",
3
+ "version": "0.17.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",