@cap-js/cds-typer 0.16.0 → 0.18.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,18 @@ 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.19.0 - TBD
8
+
9
+ ## Version 0.18.0 - 2024-03-12
10
+ ### Added
11
+ - Improved support for projections, including projections on inline definitions, and on views, as well as support for explicit exclusion and selection of properties
12
+
13
+ ### Changed
14
+ - [breaking] CDS `type` definitions will not be inflected. Whatever inflection you define them in will be assumed treated as a singular form and will not receive a plural form anymore
15
+
16
+ ## Version 0.17.0 - 2024-03-05
17
+ ### Fixed
18
+ - Fixed a bug where refering to an externally defined enum via the `typeof` syntax would crash the type generation
8
19
 
9
20
  ## Version 0.16.0 - 2024-02-01
10
21
  ### 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',
@@ -66,6 +67,34 @@ const Builtins = {
66
67
  }
67
68
 
68
69
  class Resolver {
70
+
71
+ #caches = {
72
+ /**
73
+ * @type {{ [qualifier: string]: string }}
74
+ */
75
+ namespaces: {},
76
+ /**
77
+ * @type {{ [qualifier: string]: string }}
78
+ */
79
+ propertyAccesses: {}
80
+ }
81
+
82
+ /**
83
+ * @param {string} qualifier
84
+ * @returns {string?}
85
+ */
86
+ #getCachedNamespace (qualifier) {
87
+ return this.#caches.namespaces[qualifier]
88
+ }
89
+
90
+ /**
91
+ * @param {string} qualifier
92
+ * @returns {string?}
93
+ */
94
+ #getCachedPropertyAccess (qualifier) {
95
+ return this.#caches.propertyAccesses[qualifier]
96
+ }
97
+
69
98
  get csn() { return this.visitor.csn.inferred }
70
99
 
71
100
  /** @param {Visitor} visitor */
@@ -90,18 +119,18 @@ class Resolver {
90
119
  * to end up with both the resolved Path of the namespace,
91
120
  * and the clean name of the class.
92
121
  * @param {string} fq the fully qualified name of an entity.
93
- * @returns {[Path, string]} a tuple, [0] holding the path to the namespace, [1] holding the clean name of the entity.
122
+ * @returns {[Path, string, string[]]} a tuple, [0] holding the path to the namespace, [1] holding the clean name of the entity, [2] holding chained property accesses
94
123
  */
95
124
  untangle(fq) {
96
125
  const ns = this.resolveNamespace(fq.split('.'))
97
- const name = this.trimNamespace(fq)
98
- return [new Path(ns.split('.')), name]
126
+ const name = this.trimNamespace(fq).split('.').at(-1) // nested entities would return Foo.Bar, so we only take the last part to get the actual entity name
127
+ return [new Path(ns.split('.')), name, this.findPropertyAccess(name)]
99
128
  }
100
129
 
101
130
  /**
102
131
  * Convenience method to shave off the namespace of a fully qualified path.
103
132
  * More specifically, only the parts (reading from right to left) that are of
104
- * kind "entity" are retained.
133
+ * kind "entity" or something similar are retained.
105
134
  * a.b.c.Foo -> Foo
106
135
  * Bar -> Bar
107
136
  * sap.cap.Book.text -> Book.text (assuming Book and text are both of kind "entity")
@@ -109,19 +138,15 @@ class Resolver {
109
138
  * @returns {string} the entity name without leading namespace.
110
139
  */
111
140
  trimNamespace(p) {
112
- // TODO: we might want to cache this
113
- // start on right side, go up while we have an entity at hand
114
- // we cant start on left side, as that clashes with undefined entities like "sap"
141
+ if (this.#getCachedNamespace(p)) return this.#getCachedNamespace(p)
115
142
  const parts = p.split('.')
116
- if (parts.length <= 1) {
117
- return p
118
- }
143
+ if (parts.length <= 1) return p
119
144
 
145
+ // start on right side, go up while we have an entity at hand
146
+ // we cant start on left side, as that clashes with undefined entities like "sap"
147
+ const defs = this.csn.definitions
120
148
  let qualifier = parts.join('.')
121
- while (
122
- this.csn.definitions[qualifier] &&
123
- ['entity', 'type', 'aspect', 'event'].includes(this.csn.definitions[qualifier].kind)
124
- ) {
149
+ while (defs[qualifier] && ['entity', 'type', 'aspect', 'event'].includes(defs[qualifier].kind)) {
125
150
  parts.pop()
126
151
  qualifier = parts.join('.')
127
152
  }
@@ -129,6 +154,51 @@ class Resolver {
129
154
  return qualifier ? p.substring(qualifier.length + 1) : p
130
155
  }
131
156
 
157
+ /**
158
+ * From a fully qualified path, finds the parts that are property accesses.
159
+ * This are specifically used in CDS' `typeof` syntax, where a property can
160
+ * refer to another entity's property type.
161
+ * @example
162
+ * ```
163
+ * namespace namespace;
164
+ * entity Entity {
165
+ * x: Composition of { y: Composition of z: { a: Integer }}
166
+ * }
167
+ *
168
+ * // somewhere else
169
+ * entity Foo {
170
+ * x: namespace.Entity.x.y.z;
171
+ * }
172
+ * ```
173
+ *
174
+ * @example
175
+ * ```js
176
+ * findPropertyAccess('namespace') // []
177
+ * findPropertyAccess('namespace.Entity') // []
178
+ * findPropertyAccess('namespace.Entity.x') // ['x']
179
+ * findPropertyAccess('namespace.Entity.x.y.z') // ['x', 'y', 'z']
180
+ * ```
181
+ */
182
+ findPropertyAccess(p) {
183
+ if (this.#getCachedPropertyAccess(p)) return this.#getCachedPropertyAccess(p)
184
+ const parts = p.split('.')
185
+ if (parts.length <= 1) return []
186
+
187
+ // start on right side, go up while we have an entity at hand
188
+ // we cant start on left side, as that clashes with undefined entities like "sap"
189
+ // sadly we have to use the extended flavour here, as inferred csn contains artificial entities for
190
+ // this kind of property access
191
+ const defs = this.visitor.csn.xtended.definitions
192
+ const properties = []
193
+ let qualifier = parts.join('.')
194
+ while (!defs[qualifier] && parts.length) {
195
+ properties.unshift(parts.pop())
196
+ qualifier = parts.join('.')
197
+ }
198
+
199
+ return properties
200
+ }
201
+
132
202
  /**
133
203
  * Generates singular and plural inflection for the passed type.
134
204
  * Several cases are covered here:
@@ -148,7 +218,7 @@ class Resolver {
148
218
  if (typeInfo.csn?.kind === 'type') {
149
219
  return {
150
220
  singular: typeInfo.plainName,
151
- plural: typeInfo.plainName,
221
+ plural: createArrayOf(typeInfo.plainName),
152
222
  typeName: typeInfo.plainName,
153
223
  }
154
224
  }
@@ -174,14 +244,13 @@ class Resolver {
174
244
  plural = createArrayOf(typeName)
175
245
  } else {
176
246
  // TODO: make sure the resolution still works. Currently, we only cut off the namespace!
177
- singular = util.singular4(typeInfo.csn)
178
- plural = util.getPluralAnnotation(typeInfo.csn) ? util.plural4(typeInfo.csn) : typeInfo.plainName
247
+ plural = util.getPluralAnnotation(typeInfo.csn) ?? typeInfo.plainName
248
+ singular = util.getSingularAnnotation(typeInfo.csn) ?? util.singular4(typeInfo.csn, true) // util.singular4(typeInfo.csn, true) // can not use `plural` to honor possible @singular annotation
179
249
 
180
250
  // don't slice off namespace if it isn't part of the inflected name.
181
251
  // This happens when the user adds an annotation and singular4 therefore
182
- // already returns an identifier without namespace
252
+ // already returns an identifier without namespace. Plural has ns already sliced off.
183
253
  if (namespace && singular.startsWith(namespace)) {
184
- // TODO: not totally sure why plural doesn't have to be sliced
185
254
  singular = singular.slice(namespace.length + 1)
186
255
  }
187
256
 
@@ -191,7 +260,7 @@ class Resolver {
191
260
  }
192
261
  }
193
262
  if (!singular || !plural) {
194
- this.logger.error(`Singular ('${singular}') or plural ('${plural}') for '${typeName}' is empty.`)
263
+ this.visitor.logger.error(`Singular ('${singular}') or plural ('${plural}') for '${typeName}' is empty.`)
195
264
  }
196
265
 
197
266
  return { typeName, singular, plural }
@@ -277,6 +346,13 @@ class Resolver {
277
346
  typeInfo.inflection = this.inflect(typeInfo)
278
347
  }
279
348
 
349
+ const [,,propertyAccess] = this.untangle(typeName)
350
+ if (propertyAccess.length) {
351
+ const element = typeName.slice(0, -propertyAccess.join('.').length - 1)
352
+ const access = this.visitor.inlineDeclarationResolver.getTypeLookup(propertyAccess)
353
+ typeName = deepRequire(element) + access
354
+ }
355
+
280
356
  // add fallback inflection. Mainly needed for array-of with builtin types.
281
357
  // (array-of relies on inflection being present, which is not the case in builtin)
282
358
  typeInfo.inflection ??= {
@@ -348,9 +424,7 @@ class Resolver {
348
424
  resolveType(element, file) {
349
425
  // while resolving inline declarations, it can happen that we land here
350
426
  // with an already resolved type. In that case, just return the type we have.
351
- if (element && Object.hasOwn(element, 'isBuiltin')) {
352
- return element
353
- }
427
+ if (element && Object.hasOwn(element, 'isBuiltin')) return element
354
428
 
355
429
  const cardinality = this.getMaxCardinality(element)
356
430
 
@@ -370,7 +444,7 @@ class Resolver {
370
444
  result.type = '{}'
371
445
  result.isInlineDeclaration = true
372
446
  } else {
373
- if (isInlineEnumType(element, this.csn)) {
447
+ if (!isReferenceType(element) && isInlineEnumType(element, this.csn)) {
374
448
  // element.parent is only set if the enum is attached to an entity's property.
375
449
  // If it is missing then we are dealing with an inline parameter type of an action.
376
450
  // Edge case: element.parent is set, but no .name property is attached. This happens
package/lib/csn.js CHANGED
@@ -14,8 +14,8 @@ class DraftUnroller {
14
14
  this.#csn = c
15
15
  this.#entities = Object.values(c.definitions)
16
16
  this.#projections = this.#entities.reduce((pjs, entity) => {
17
- if (entity.projection) {
18
- pjs[entity.name] = entity.projection.from.ref[0]
17
+ if (isProjection(entity)) {
18
+ pjs[entity.name] = getProjectionTarget(entity)
19
19
  }
20
20
  return pjs
21
21
  }, {})
@@ -234,10 +234,46 @@ function amendCSN(csn) {
234
234
  */
235
235
  const isView = entity => entity.query && !entity.projection
236
236
 
237
+ const isProjection = entity => entity.projection
238
+
239
+ const getViewTarget = entity => entity.query?.SELECT?.from?.ref?.[0]
240
+
241
+ const getProjectionTarget = entity => entity.projection?.from?.ref?.[0]
242
+
243
+ const getProjectionAliases = entity => {
244
+ const aliases = {}
245
+ let all = false
246
+ for (const col of entity?.projection?.columns ?? []) {
247
+ if (col === '*') {
248
+ all = true
249
+ } else if (col.ref) {
250
+ (aliases[col.ref[0]] ??= []).push(col.as ?? col.ref[0])
251
+ } else {
252
+ // TODO: error, casting seems to miss ref...
253
+ }
254
+ }
255
+ return { aliases, all }
256
+ }
257
+
258
+ const isDraftEnabled = entity => entity['@odata.draft.enabled'] === true
259
+
260
+ const isType = entity => entity?.kind === 'type'
261
+
237
262
  /**
238
263
  * @see isView
239
264
  * Unresolved entities have to be looked up from inferred csn.
240
265
  */
241
266
  const isUnresolved = entity => entity._unresolved === true
242
267
 
243
- module.exports = { amendCSN, isView, isUnresolved, propagateForeignKeys }
268
+ module.exports = {
269
+ amendCSN,
270
+ isView,
271
+ isProjection,
272
+ isDraftEnabled,
273
+ isUnresolved,
274
+ isType,
275
+ getProjectionTarget,
276
+ getProjectionAliases,
277
+ getViewTarget,
278
+ propagateForeignKeys
279
+ }
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/util.js CHANGED
@@ -70,7 +70,7 @@ const singular4 = (dn, stripped = false) => {
70
70
  n = n.match(last)[0]
71
71
  }
72
72
  return (
73
- getSingularAnnotation(dn) ||
73
+ getSingularAnnotation(dn) ??
74
74
  (/.*species|news$/i.test(n)
75
75
  ? n
76
76
  : /.*ess$/.test(n)
@@ -102,7 +102,7 @@ const plural4 = (dn, stripped) => {
102
102
  n = n.match(last)[0]
103
103
  }
104
104
  return (
105
- getPluralAnnotation(dn) ||
105
+ getPluralAnnotation(dn) ??
106
106
  (/.*analysis|status|species|news$/i.test(n)
107
107
  ? n
108
108
  : /.*[^aeiou]y$/.test(n)
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, isType, isProjection } = 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
  */
@@ -102,7 +93,7 @@ class Visitor {
102
93
  for (const [name, entity] of Object.entries(this.csn.xtended.definitions)) {
103
94
  if (isView(entity)) {
104
95
  this.visitEntity(name, this.csn.inferred.definitions[name])
105
- } else if (!isUnresolved(entity)) {
96
+ } else if (isProjection(entity) || !isUnresolved(entity)) {
106
97
  this.visitEntity(name, entity)
107
98
  } else {
108
99
  this.logger.warning(`Skipping unresolved entity: ${name}`)
@@ -158,79 +149,68 @@ class Visitor {
158
149
  * @param {string} name the name of the entity
159
150
  * @param {CSN} element the pointer into the CSN to extract the elements from
160
151
  * @param {Buffer} buffer the buffer to write the resulting definitions into
161
- * @param {string?} cleanName the clean name to use. If not passed, it is derived from the passed name instead.
152
+ * @param {{cleanName?: string}} options
162
153
  */
163
- #aspectify(name, entity, buffer, cleanName = undefined) {
164
- const clean = cleanName ?? this.resolver.trimNamespace(name)
154
+ #aspectify(name, entity, buffer, options = {}) {
155
+ const clean = options?.cleanName ?? this.resolver.trimNamespace(name)
165
156
  const ns = this.resolver.resolveNamespace(name.split('.'))
166
- const file = this.getNamespaceFile(ns)
167
-
157
+ const file = this.fileRepository.getNamespaceFile(ns)
168
158
  const identSingular = (name) => name
169
159
  const identAspect = (name) => `_${name}Aspect`
170
160
 
171
161
  this.contexts.push({ entity: name })
172
162
 
173
163
  // 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)
164
+ buffer.addIndentedBlock(`export function ${identAspect(clean)}<TBase extends new (...args: any[]) => object>(Base: TBase) {`, function () {
165
+ buffer.addIndentedBlock(`return class ${clean} extends Base {`, function () {
166
+ const enums = []
167
+ for (let [ename, element] of Object.entries(entity.elements ?? {})) {
168
+ if (element.target && /\.texts?/.test(element.target)) {
169
+ this.logger.warning(`referring to .texts property in ${name}. This is currently not supported and will be ignored.`)
170
+ continue
171
+ }
172
+ this.visitElement(ename, element, file, buffer)
173
+
174
+ // make foreign keys explicit
175
+ if ('target' in element) {
176
+ // lookup in cds.definitions can fail for inline structs.
177
+ // We don't really have to care for this case, as keys from such structs are _not_ propagated to
178
+ // the containing entity.
179
+ for (const [kname, kelement] of this.#keys(element.target)) {
180
+ if (this.resolver.getMaxCardinality(element) === 1) { // FIXME: kelement?
181
+ const foreignKey = `${ename}_${kname}`
182
+ if (Object.hasOwn(entity.elements, foreignKey)) {
183
+ 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.`)
184
+ } else {
185
+ kelement.isRefNotNull = !!element.notNull || !!element.key
186
+ this.visitElement(foreignKey, kelement, file, buffer)
187
+ }
188
+ }
198
189
  }
199
190
  }
200
- }
201
- }
202
191
 
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
192
+ // store inline enums for later handling, as they have to go into one common "static elements" wrapper
193
+ if (isInlineEnumType(element, this.csn.xtended)) {
194
+ enums.push(element)
195
+ }
196
+ }
229
197
 
230
- buffer.outdent()
231
- buffer.add('};') // end of generated class
232
- buffer.outdent()
233
- buffer.add('}') // end of aspect
198
+ buffer.addIndented(function() {
199
+ for (const e of enums) {
200
+ buffer.add(`static ${e.name} = ${propertyToInlineEnumName(clean, e.name)}`)
201
+ file.addInlineEnum(clean, name, e.name, csnToEnumPairs(e, {unwrapVals: true}))
202
+ }
203
+ const actions = Object.entries(entity.actions ?? {})
204
+ buffer.addIndentedBlock('static actions: {',
205
+ actions.map(([aname, action]) => SourceFile.stringifyLambda({
206
+ name: aname,
207
+ parameters: this.#stringifyFunctionParams(action.params, file),
208
+ returns: action.returns ? this.resolver.resolveAndRequire(action.returns, file).typeName : 'any'
209
+ }))
210
+ , '}') // end of actions
211
+ }.bind(this))
212
+ }.bind(this), '};') // end of generated class
213
+ }.bind(this), '}') // end of aspect
234
214
 
235
215
  // CLASS WITH ADDED ASPECTS
236
216
  file.addImport(baseDefinitions.path)
@@ -255,35 +235,37 @@ class Visitor {
255
235
  this.contexts.pop()
256
236
  }
257
237
 
258
- #isDraftEnabled(entity) {
259
- return entity['@odata.draft.enabled'] === true
260
- }
261
-
262
238
  #staticClassContents(clean, entity) {
263
- return this.#isDraftEnabled(entity) ? [`static drafts: typeof ${clean}`] : []
239
+ return isDraftEnabled(entity) ? [`static drafts: typeof ${clean}`] : []
264
240
  }
265
241
 
266
242
  #printEntity(name, entity) {
267
243
  // static .name has to be defined more forcefully: https://github.com/microsoft/TypeScript/issues/442
268
244
  const overrideNameProperty = (clazz, content) => `Object.defineProperty(${clazz}, 'name', { value: '${content}' })`
269
- const clean = this.resolver.trimNamespace(name)
270
- const ns = this.resolver.resolveNamespace(name.split('.'))
271
- const file = this.getNamespaceFile(ns)
245
+ const [ns, clean] = this.resolver.untangle(name)
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.
249
+
250
+ /*
274
251
  let plural = this.resolver.trimNamespace(util.getPluralAnnotation(entity) ? util.plural4(entity, false) : name)
275
252
  const singular = this.resolver.trimNamespace(util.singular4(entity, true))
253
+ */
254
+ let { singular, plural } = this.resolver.inflect({csn: entity, plainName: clean}, ns.asNamespace())
255
+
276
256
  // trimNamespace does not properly detect scoped entities, like A.B where both A and B are
277
257
  // entities. So to see if we would run into a naming collision, we forcefully take the last
278
258
  // part of the name, so "A.B" and "A.Bs" just become "B" and "Bs" to be compared.
279
259
  // FIXME: put this in a util function
280
- if (singular.split('.').at(-1) === plural.split('.').at(-1)) {
281
- plural += '_'
260
+ //if (singular.split('.').at(-1) === plural.split('.').at(-1)) {
261
+ if (plural.split('.').at(-1) === `${singular.split('.').at(-1)}_`) {
262
+ //plural += '_'
282
263
  this.logger.warning(
283
264
  `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.`
284
265
  )
285
266
  }
286
- if (singular in this.csn.xtended.definitions) {
267
+ // as types are not inflected, their singular will always clash and there is also no plural for them anyway
268
+ if (!isType(entity) && singular in this.csn.xtended.definitions) {
287
269
  this.logger.error(
288
270
  `Derived singular '${singular}' for your entity '${name}', already exists. The resulting types will be erronous. Consider using '@singular:'/ '@plural:' annotations in your model or move the offending declarations into different namespaces to resolve this collision.`
289
271
  )
@@ -303,22 +285,33 @@ class Visitor {
303
285
  // which would not be possible while already in singular form, as "Book.text" could not be resolved in CSN.
304
286
  // edge case: @singular annotation present. singular4 will take care of that.
305
287
  file.addInflection(util.singular4(entity, true), plural, clean)
306
- if ('doc' in entity) {
307
- docify(entity.doc).forEach((d) => buffer.add(d))
308
- }
288
+ docify(entity.doc).forEach(d => buffer.add(d))
289
+
290
+ // in case of projections `entity` is empty -> retrieve from inferred csn where the actual properties are rolled out
291
+ const target = isProjection(entity) || isView(entity)
292
+ ? this.csn.inferred.definitions[name]
293
+ : entity
294
+
295
+ // draft enablement is stored in csn.xtended. Iff we took the entity from csn.inferred, we have to carry the draft-enablement over at this point
296
+ target['@odata.draft.enabled'] = isDraftEnabled(entity)
309
297
 
310
- this.#aspectify(name, entity, buffer, singular)
298
+ this.#aspectify(name, target, buffer, { cleanName: singular })
299
+
300
+ buffer.add(overrideNameProperty(singular, entity.name))
311
301
 
312
302
  // PLURAL
313
- if (plural.includes('.')) {
314
- // Foo.text -> namespace Foo { class text { ... }}
315
- plural = plural.split('.').pop()
303
+
304
+ // types do not receive a plural
305
+ if (!isType(entity)) {
306
+ if (plural.includes('.')) {
307
+ // Foo.text -> namespace Foo { class text { ... }}
308
+ plural = plural.split('.').at(-1)
309
+ }
310
+ // plural can not be a type alias to $singular[] but needs to be a proper class instead,
311
+ // so it can get passed as value to CQL functions.
312
+ buffer.add(`export class ${plural} extends Array<${singular}> {${this.#staticClassContents(singular, entity).join('\n')}}`)
313
+ buffer.add(overrideNameProperty(plural, entity.name))
316
314
  }
317
- // plural can not be a type alias to $singular[] but needs to be a proper class instead,
318
- // so it can get passed as value to CQL functions.
319
- buffer.add(`export class ${plural} extends Array<${singular}> {${this.#staticClassContents(singular, entity).join('\n')}}`)
320
- buffer.add(overrideNameProperty(singular, entity.name))
321
- buffer.add(overrideNameProperty(plural, entity.name))
322
315
  buffer.add('')
323
316
  }
324
317
 
@@ -354,7 +347,7 @@ class Visitor {
354
347
  // FIXME: mostly duplicate of printAction -> reuse
355
348
  this.logger.debug(`Printing function ${name}:\n${JSON.stringify(func, null, 2)}`)
356
349
  const ns = this.resolver.resolveNamespace(name.split('.'))
357
- const file = this.getNamespaceFile(ns)
350
+ const file = this.fileRepository.getNamespaceFile(ns)
358
351
  const params = this.#stringifyFunctionParams(func.params, file)
359
352
  const returns = this.resolver.visitor.inlineDeclarationResolver.getPropertyDatatype(
360
353
  this.resolver.resolveAndRequire(func.returns, file)
@@ -365,7 +358,7 @@ class Visitor {
365
358
  #printAction(name, action) {
366
359
  this.logger.debug(`Printing action ${name}:\n${JSON.stringify(action, null, 2)}`)
367
360
  const ns = this.resolver.resolveNamespace(name.split('.'))
368
- const file = this.getNamespaceFile(ns)
361
+ const file = this.fileRepository.getNamespaceFile(ns)
369
362
  const params = this.#stringifyFunctionParams(action.params, file)
370
363
  const returns = this.resolver.visitor.inlineDeclarationResolver.getPropertyDatatype(
371
364
  this.resolver.resolveAndRequire(action.returns, file)
@@ -375,10 +368,9 @@ class Visitor {
375
368
 
376
369
  #printType(name, type) {
377
370
  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) {
371
+ const [ns, clean] = this.resolver.untangle(name)
372
+ const file = this.fileRepository.getNamespaceFile(ns)
373
+ if ('enum' in type && !isReferenceType(type)) { // skip references to enums
382
374
  file.addEnum(name, clean, csnToEnumPairs(type))
383
375
  } else {
384
376
  // alias
@@ -389,41 +381,37 @@ class Visitor {
389
381
 
390
382
  #printAspect(name, aspect) {
391
383
  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)
384
+ const [ns, clean] = this.resolver.untangle(name)
385
+ const file = this.fileRepository.getNamespaceFile(ns)
395
386
  // aspects are technically classes and can therefore be added to the list of defined classes.
396
387
  // Still, when using them as mixins for a class, they need to already be defined.
397
388
  // So we separate them into another buffer which is printed before the classes.
398
389
  file.addClass(clean, name)
399
390
  file.aspects.add(`// the following represents the CDS aspect '${clean}'`)
400
- this.#aspectify(name, aspect, file.aspects, clean)
391
+ this.#aspectify(name, aspect, file.aspects, { cleanName: clean })
401
392
  }
402
393
 
403
394
  #printEvent(name, event) {
404
395
  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)
396
+ const [ns, clean] = this.resolver.untangle(name)
397
+ const file = this.fileRepository.getNamespaceFile(ns)
408
398
  file.addEvent(clean, name)
409
399
  const buffer = file.events.buffer
410
400
  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('}')
401
+ buffer.addIndentedBlock(`export class ${clean} {`, function() {
402
+ const propOpt = this.options.propertiesOptional
403
+ this.options.propertiesOptional = false
404
+ for (const [ename, element] of Object.entries(event.elements ?? {})) {
405
+ this.visitElement(ename, element, file, buffer)
406
+ }
407
+ this.options.propertiesOptional = propOpt
408
+ }.bind(this), '}')
421
409
  }
422
410
 
423
411
  #printService(name, service) {
424
412
  this.logger.debug(`Printing service ${name}:\n${JSON.stringify(service, null, 2)}`)
425
413
  const ns = this.resolver.resolveNamespace(name)
426
- const file = this.getNamespaceFile(ns)
414
+ const file = this.fileRepository.getNamespaceFile(ns)
427
415
  // service.name is clean of namespace
428
416
  file.services.buffer.add(`export default { name: '${service.name}' }`)
429
417
  file.addService(service.name)
@@ -453,7 +441,7 @@ class Visitor {
453
441
  // types like inline definitions can be used very similarly to entities.
454
442
  // They can be extended, contain inline enums, etc., so we treat them as entities.
455
443
  const handler = entity.elements ? this.#printEntity : this.#printType
456
- handler.bind(this)(name, entity)
444
+ handler.call(this, name, entity)
457
445
  break
458
446
  }
459
447
  case 'event':
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.18.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",
@@ -41,6 +41,7 @@
41
41
  },
42
42
  "devDependencies": {
43
43
  "@babel/eslint-parser": "^7.23.3",
44
+ "@stylistic/eslint-plugin-js": "^1.6.3",
44
45
  "acorn": "^8.10.0",
45
46
  "eslint": "^8.15.0",
46
47
  "jest": "^29",
@@ -48,8 +49,7 @@
48
49
  },
49
50
  "jest": {
50
51
  "projects": [
51
- "test/unit.jest.config.js",
52
- "test/int.jest.config.js"
52
+ "test/unit.jest.config.js"
53
53
  ]
54
54
  }
55
55
  }