@cap-js/cds-typer 0.17.0 → 0.18.1

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,19 @@ 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.18.0 - TBD
7
+ ## Version 0.19.0 - TBD
8
+
9
+
10
+ ## Version 0.18.1 - 2024-03-13
11
+ ### Fix
12
+ - Remove faulty plural for CDS `type` definitions from the generated _index.js_ files
13
+
14
+ ## Version 0.18.0 - 2024-03-12
15
+ ### Added
16
+ - Improved support for projections, including projections on inline definitions, and on views, as well as support for explicit exclusion and selection of properties
17
+
18
+ ### Changed
19
+ - [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
8
20
 
9
21
  ## Version 0.17.0 - 2024-03-05
10
22
  ### Fixed
@@ -67,6 +67,34 @@ const Builtins = {
67
67
  }
68
68
 
69
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
+
70
98
  get csn() { return this.visitor.csn.inferred }
71
99
 
72
100
  /** @param {Visitor} visitor */
@@ -91,18 +119,18 @@ class Resolver {
91
119
  * to end up with both the resolved Path of the namespace,
92
120
  * and the clean name of the class.
93
121
  * @param {string} fq the fully qualified name of an entity.
94
- * @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
95
123
  */
96
124
  untangle(fq) {
97
125
  const ns = this.resolveNamespace(fq.split('.'))
98
- const name = this.trimNamespace(fq)
99
- 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)]
100
128
  }
101
129
 
102
130
  /**
103
131
  * Convenience method to shave off the namespace of a fully qualified path.
104
132
  * More specifically, only the parts (reading from right to left) that are of
105
- * kind "entity" are retained.
133
+ * kind "entity" or something similar are retained.
106
134
  * a.b.c.Foo -> Foo
107
135
  * Bar -> Bar
108
136
  * sap.cap.Book.text -> Book.text (assuming Book and text are both of kind "entity")
@@ -110,19 +138,15 @@ class Resolver {
110
138
  * @returns {string} the entity name without leading namespace.
111
139
  */
112
140
  trimNamespace(p) {
113
- // TODO: we might want to cache this
114
- // start on right side, go up while we have an entity at hand
115
- // we cant start on left side, as that clashes with undefined entities like "sap"
141
+ if (this.#getCachedNamespace(p)) return this.#getCachedNamespace(p)
116
142
  const parts = p.split('.')
117
- if (parts.length <= 1) {
118
- return p
119
- }
143
+ if (parts.length <= 1) return p
120
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
121
148
  let qualifier = parts.join('.')
122
- while (
123
- this.csn.definitions[qualifier] &&
124
- ['entity', 'type', 'aspect', 'event'].includes(this.csn.definitions[qualifier].kind)
125
- ) {
149
+ while (defs[qualifier] && ['entity', 'type', 'aspect', 'event'].includes(defs[qualifier].kind)) {
126
150
  parts.pop()
127
151
  qualifier = parts.join('.')
128
152
  }
@@ -130,6 +154,51 @@ class Resolver {
130
154
  return qualifier ? p.substring(qualifier.length + 1) : p
131
155
  }
132
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
+
133
202
  /**
134
203
  * Generates singular and plural inflection for the passed type.
135
204
  * Several cases are covered here:
@@ -149,7 +218,7 @@ class Resolver {
149
218
  if (typeInfo.csn?.kind === 'type') {
150
219
  return {
151
220
  singular: typeInfo.plainName,
152
- plural: typeInfo.plainName,
221
+ plural: createArrayOf(typeInfo.plainName),
153
222
  typeName: typeInfo.plainName,
154
223
  }
155
224
  }
@@ -175,14 +244,13 @@ class Resolver {
175
244
  plural = createArrayOf(typeName)
176
245
  } else {
177
246
  // TODO: make sure the resolution still works. Currently, we only cut off the namespace!
178
- singular = util.singular4(typeInfo.csn)
179
- 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
180
249
 
181
250
  // don't slice off namespace if it isn't part of the inflected name.
182
251
  // This happens when the user adds an annotation and singular4 therefore
183
- // already returns an identifier without namespace
252
+ // already returns an identifier without namespace. Plural has ns already sliced off.
184
253
  if (namespace && singular.startsWith(namespace)) {
185
- // TODO: not totally sure why plural doesn't have to be sliced
186
254
  singular = singular.slice(namespace.length + 1)
187
255
  }
188
256
 
@@ -192,7 +260,7 @@ class Resolver {
192
260
  }
193
261
  }
194
262
  if (!singular || !plural) {
195
- 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.`)
196
264
  }
197
265
 
198
266
  return { typeName, singular, plural }
@@ -278,6 +346,13 @@ class Resolver {
278
346
  typeInfo.inflection = this.inflect(typeInfo)
279
347
  }
280
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
+
281
356
  // add fallback inflection. Mainly needed for array-of with builtin types.
282
357
  // (array-of relies on inflection being present, which is not the case in builtin)
283
358
  typeInfo.inflection ??= {
@@ -349,9 +424,7 @@ class Resolver {
349
424
  resolveType(element, file) {
350
425
  // while resolving inline declarations, it can happen that we land here
351
426
  // with an already resolved type. In that case, just return the type we have.
352
- if (element && Object.hasOwn(element, 'isBuiltin')) {
353
- return element
354
- }
427
+ if (element && Object.hasOwn(element, 'isBuiltin')) return element
355
428
 
356
429
  const cardinality = this.getMaxCardinality(element)
357
430
 
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,12 +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
+
237
258
  const isDraftEnabled = entity => entity['@odata.draft.enabled'] === true
238
259
 
260
+ const isType = entity => entity?.kind === 'type'
261
+
239
262
  /**
240
263
  * @see isView
241
264
  * Unresolved entities have to be looked up from inferred csn.
242
265
  */
243
266
  const isUnresolved = entity => entity._unresolved === true
244
267
 
245
- module.exports = { amendCSN, isView, isDraftEnabled, 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
@@ -403,12 +403,12 @@ class SourceFile extends File {
403
403
  // or when plural4 produced weird inflection.
404
404
  .flatMap(([singular, plural, original]) => Array.from(new Set([
405
405
  `module.exports.${singular} = csn.${original}`,
406
- `module.exports.${plural} = csn.${original}`,
406
+ /Array<.*>/.test(plural) ? undefined : `module.exports.${plural} = csn.${original}`,
407
407
  // FIXME: we currently produce at most 3 entries.
408
408
  // This could be an issue when the user re-used the original name in a @singular/@plural annotation.
409
409
  // Seems unlikely, but we have to eliminate the original entry if users start running into this.
410
410
  `module.exports.${original} = csn.${original}`
411
- ])))
411
+ ].filter(Boolean)))) // FIXME: this is a hack to support CDS types that will produce "Array<MyType>" as plural, which we do not want as export in the index.js files
412
412
  ) // singular -> plural aliases
413
413
  .concat(['// events'])
414
414
  .concat(this.events.fqs.map(({fq, name}) => `module.exports.${name} = '${fq}'`))
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,7 +2,7 @@
2
2
 
3
3
  const util = require('./util')
4
4
 
5
- const { amendCSN, isView, isUnresolved, propagateForeignKeys, isDraftEnabled } = require('./csn')
5
+ const { amendCSN, isView, isUnresolved, propagateForeignKeys, isDraftEnabled, isType, isProjection } = require('./csn')
6
6
  // eslint-disable-next-line no-unused-vars
7
7
  const { SourceFile, FileRepository, baseDefinitions, Buffer } = require('./file')
8
8
  const { FlatInlineDeclarationResolver, StructuredInlineDeclarationResolver } = require('./components/inline')
@@ -93,7 +93,7 @@ class Visitor {
93
93
  for (const [name, entity] of Object.entries(this.csn.xtended.definitions)) {
94
94
  if (isView(entity)) {
95
95
  this.visitEntity(name, this.csn.inferred.definitions[name])
96
- } else if (!isUnresolved(entity)) {
96
+ } else if (isProjection(entity) || !isUnresolved(entity)) {
97
97
  this.visitEntity(name, entity)
98
98
  } else {
99
99
  this.logger.warning(`Skipping unresolved entity: ${name}`)
@@ -149,13 +149,12 @@ class Visitor {
149
149
  * @param {string} name the name of the entity
150
150
  * @param {CSN} element the pointer into the CSN to extract the elements from
151
151
  * @param {Buffer} buffer the buffer to write the resulting definitions into
152
- * @param {string?} cleanName the clean name to use. If not passed, it is derived from the passed name instead.
152
+ * @param {{cleanName?: string}} options
153
153
  */
154
- #aspectify(name, entity, buffer, cleanName = undefined) {
155
- const clean = cleanName ?? this.resolver.trimNamespace(name)
154
+ #aspectify(name, entity, buffer, options = {}) {
155
+ const clean = options?.cleanName ?? this.resolver.trimNamespace(name)
156
156
  const ns = this.resolver.resolveNamespace(name.split('.'))
157
157
  const file = this.fileRepository.getNamespaceFile(ns)
158
-
159
158
  const identSingular = (name) => name
160
159
  const identAspect = (name) => `_${name}Aspect`
161
160
 
@@ -165,9 +164,11 @@ class Visitor {
165
164
  buffer.addIndentedBlock(`export function ${identAspect(clean)}<TBase extends new (...args: any[]) => object>(Base: TBase) {`, function () {
166
165
  buffer.addIndentedBlock(`return class ${clean} extends Base {`, function () {
167
166
  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) {
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
+ }
171
172
  this.visitElement(ename, element, file, buffer)
172
173
 
173
174
  // make foreign keys explicit
@@ -241,24 +242,30 @@ class Visitor {
241
242
  #printEntity(name, entity) {
242
243
  // static .name has to be defined more forcefully: https://github.com/microsoft/TypeScript/issues/442
243
244
  const overrideNameProperty = (clazz, content) => `Object.defineProperty(${clazz}, 'name', { value: '${content}' })`
244
- const clean = this.resolver.trimNamespace(name)
245
- const ns = this.resolver.resolveNamespace(name.split('.'))
245
+ const [ns, clean] = this.resolver.untangle(name)
246
246
  const file = this.fileRepository.getNamespaceFile(ns)
247
247
  // entities are expected to be in plural anyway, so we would favour the regular name.
248
248
  // If the user decides to pass a @plural annotation, that gets precedence over the regular name.
249
+
250
+ /*
249
251
  let plural = this.resolver.trimNamespace(util.getPluralAnnotation(entity) ? util.plural4(entity, false) : name)
250
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
+
251
256
  // trimNamespace does not properly detect scoped entities, like A.B where both A and B are
252
257
  // entities. So to see if we would run into a naming collision, we forcefully take the last
253
258
  // part of the name, so "A.B" and "A.Bs" just become "B" and "Bs" to be compared.
254
259
  // FIXME: put this in a util function
255
- if (singular.split('.').at(-1) === plural.split('.').at(-1)) {
256
- plural += '_'
260
+ //if (singular.split('.').at(-1) === plural.split('.').at(-1)) {
261
+ if (plural.split('.').at(-1) === `${singular.split('.').at(-1)}_`) {
262
+ //plural += '_'
257
263
  this.logger.warning(
258
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.`
259
265
  )
260
266
  }
261
- 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) {
262
269
  this.logger.error(
263
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.`
264
271
  )
@@ -278,22 +285,33 @@ class Visitor {
278
285
  // which would not be possible while already in singular form, as "Book.text" could not be resolved in CSN.
279
286
  // edge case: @singular annotation present. singular4 will take care of that.
280
287
  file.addInflection(util.singular4(entity, true), plural, clean)
281
- if ('doc' in entity) {
282
- docify(entity.doc).forEach((d) => buffer.add(d))
283
- }
288
+ docify(entity.doc).forEach(d => buffer.add(d))
284
289
 
285
- this.#aspectify(name, entity, buffer, singular)
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)
297
+
298
+ this.#aspectify(name, target, buffer, { cleanName: singular })
299
+
300
+ buffer.add(overrideNameProperty(singular, entity.name))
286
301
 
287
302
  // PLURAL
288
- if (plural.includes('.')) {
289
- // Foo.text -> namespace Foo { class text { ... }}
290
- 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))
291
314
  }
292
- // plural can not be a type alias to $singular[] but needs to be a proper class instead,
293
- // so it can get passed as value to CQL functions.
294
- buffer.add(`export class ${plural} extends Array<${singular}> {${this.#staticClassContents(singular, entity).join('\n')}}`)
295
- buffer.add(overrideNameProperty(singular, entity.name))
296
- buffer.add(overrideNameProperty(plural, entity.name))
297
315
  buffer.add('')
298
316
  }
299
317
 
@@ -370,7 +388,7 @@ class Visitor {
370
388
  // So we separate them into another buffer which is printed before the classes.
371
389
  file.addClass(clean, name)
372
390
  file.aspects.add(`// the following represents the CDS aspect '${clean}'`)
373
- this.#aspectify(name, aspect, file.aspects, clean)
391
+ this.#aspectify(name, aspect, file.aspects, { cleanName: clean })
374
392
  }
375
393
 
376
394
  #printEvent(name, event) {
@@ -423,7 +441,7 @@ class Visitor {
423
441
  // types like inline definitions can be used very similarly to entities.
424
442
  // They can be extended, contain inline enums, etc., so we treat them as entities.
425
443
  const handler = entity.elements ? this.#printEntity : this.#printType
426
- handler.bind(this)(name, entity)
444
+ handler.call(this, name, entity)
427
445
  break
428
446
  }
429
447
  case 'event':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/cds-typer",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
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
  }