@cap-js/cds-typer 0.13.0 → 0.15.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,25 @@ 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.14.0 - TBD
7
+ ## Version 0.16.0 - TBD
8
+
9
+ ## Version 0.15.0 - 2023-12-21
10
+ ### Added
11
+ - Support for [scoped entities](https://cap.cloud.sap/docs/cds/cdl#scoped-names)
12
+ - Support for [delimited identifiers](https://cap.cloud.sap/docs/cds/cdl#delimited-identifiers)
13
+
14
+ ### Fixed
15
+ - Inline enums are now available during runtime as well
16
+ - Inline enums can now be used as action parameter types as well. These enums will not have a runtime representation, but will only assert type safety!
17
+ - Arrays of inline enum values can now be used as action parameters too. But they will only be represented by their enclosing type for now, i.e. `string`, `number`, etc.
18
+ - Foreign keys of projection entities are now propagated as well
19
+
20
+ ## Version 0.14.0 - 2023-12-13
21
+ ### Added
22
+ - Entities that are database views now also receive typings
8
23
 
9
24
  ## Version 0.13.0 - 2023-12-06
10
- ### Changes
25
+ ### Changed
11
26
  - Enums are now generated ecplicitly in the respective _index.js_ files and don't have to extract their values from the model at runtime anymore
12
27
 
13
28
  ### Added
@@ -1,3 +1,5 @@
1
+ const { normalise } = require('./identifier')
2
+
1
3
  /**
2
4
  * Prints an enum to a buffer. To be precise, it prints
3
5
  * a constant object and a type which together form an artificial enum.
@@ -29,17 +31,33 @@ function printEnum(buffer, name, kvs, options = {}) {
29
31
  buffer.add('// enum')
30
32
  buffer.add(`${opts.export ? 'export ' : ''}const ${name} = {`)
31
33
  buffer.indent()
32
- const vals = new Set()
33
34
  for (const [k, v] of kvs) {
34
- buffer.add(`${k}: ${v},`)
35
- vals.add(v?.val ?? v) // in case of wrapped vals we need to unwrap here for the type
35
+ buffer.add(`${normalise(k)}: ${v},`)
36
36
  }
37
37
  buffer.outdent()
38
38
  buffer.add('} as const;')
39
- buffer.add(`${opts.export ? 'export ' : ''}type ${name} = ${[...vals].join(' | ')}`)
39
+ buffer.add(`${opts.export ? 'export ' : ''}type ${name} = ${stringifyEnumType(kvs)}`)
40
40
  buffer.add('')
41
41
  }
42
42
 
43
+ /**
44
+ * Stringifies a list of enum key-value pairs into the righthand side of a TS type.
45
+ * @param {[string, string][]} kvs list of key-value pairs
46
+ * @returns {string} a stringified type
47
+ * @example
48
+ * ```js
49
+ * ['A', 'B', 'A'] // -> '"A" | "B"'
50
+ * ```
51
+ */
52
+ const stringifyEnumType = kvs => [...uniqueValues(kvs)].join(' | ')
53
+
54
+ /**
55
+ * Extracts all unique values from a list of enum key-value pairs.
56
+ * If the value is an object, then the `.val` property is used.
57
+ * @param {[string, any | {val: any}][]} kvs
58
+ */
59
+ const uniqueValues = kvs => new Set(kvs.map(([,v]) => v?.val ?? v)) // in case of wrapped vals we need to unwrap here for the type
60
+
43
61
  // in case of strings, wrap in quotes and fallback to key to make sure values are attached for every key
44
62
  const enumVal = (key, value, enumType) => enumType === 'cds.String' ? JSON.stringify(`${value ?? key}`) : value
45
63
 
@@ -104,7 +122,8 @@ const isInlineEnumType = (element, csn) => element.enum && !(element.type in csn
104
122
  * @param {string} name
105
123
  * @param {[string, string][]} kvs a list of key-value pairs. Values that are falsey are replaced by
106
124
  */
107
- const stringifyEnumImplementation = (name, kvs) => `module.exports.${name} = { ${kvs.map(([k,v]) => `${k}: ${v}`).join(', ')} }`
125
+ // ??= for inline enums. If there is some static property of that name, we don't want to override it (for example: ".actions"
126
+ const stringifyEnumImplementation = (name, kvs) => `module.exports.${name} ??= { ${kvs.map(([k,v]) => `${normalise(k)}: ${v}`).join(', ')} }`
108
127
 
109
128
 
110
129
  module.exports = {
@@ -112,5 +131,6 @@ module.exports = {
112
131
  csnToEnumPairs,
113
132
  propertyToInlineEnumName,
114
133
  isInlineEnumType,
115
- stringifyEnumImplementation
134
+ stringifyEnumImplementation,
135
+ stringifyEnumType
116
136
  }
@@ -0,0 +1,15 @@
1
+ const isValidIdent = /^[_$a-zA-Z][$\w]*$/
2
+
3
+ /**
4
+ * Normalises an identifier to a valid JavaScript identifier.
5
+ * I.e. either the identifier itself or a quoted string.
6
+ * @param {string} ident the identifier to normalise
7
+ * @returns {string} the normalised identifier
8
+ */
9
+ const normalise = ident => ident && !isValidIdent.test(ident)
10
+ ? `"${ident}"`
11
+ : ident
12
+
13
+ module.exports = {
14
+ normalise
15
+ }
@@ -1,4 +1,5 @@
1
1
  const { SourceFile, Buffer } = require('../file')
2
+ const { normalise } = require('./identifier')
2
3
  const { docify } = require('./wrappers')
3
4
 
4
5
  /**
@@ -8,7 +9,6 @@ const { docify } = require('./wrappers')
8
9
  * their resolution mechanism.
9
10
  */
10
11
  class InlineDeclarationResolver {
11
-
12
12
  /**
13
13
  * @param {string} name
14
14
  * @param {import('./resolver').TypeResolveInfo} type
@@ -155,7 +155,7 @@ class FlatInlineDeclarationResolver extends InlineDeclarationResolver {
155
155
  flatten(prefix, type) {
156
156
  return type.typeInfo.structuredType
157
157
  ? Object.entries(type.typeInfo.structuredType).map(([k,v]) => this.flatten(`${this.prefix(prefix)}${k}`, v))
158
- : [`${prefix}${this.getPropertyTypeSeparator()} ${this.getPropertyDatatype(type)}`]
158
+ : [`${normalise(prefix)}${this.getPropertyTypeSeparator()} ${this.getPropertyDatatype(type)}`]
159
159
  }
160
160
 
161
161
  printInlineType(name, type, buffer) {
@@ -198,7 +198,7 @@ class StructuredInlineDeclarationResolver extends InlineDeclarationResolver {
198
198
  this.printDepth++
199
199
  const lineEnding = this.printDepth > 1 ? ',' : statementEnd
200
200
  if (type.typeInfo.structuredType) {
201
- const prefix = name ? `${name}${this.getPropertyTypeSeparator()}`: ''
201
+ const prefix = name ? `${normalise(name)}${this.getPropertyTypeSeparator()}`: ''
202
202
  buffer.add(`${prefix} {`)
203
203
  buffer.indent()
204
204
  for (const [n, t] of Object.entries(type.typeInfo.structuredType)) {
@@ -207,7 +207,7 @@ class StructuredInlineDeclarationResolver extends InlineDeclarationResolver {
207
207
  buffer.outdent()
208
208
  buffer.add(`}${this.getPropertyDatatype(type, '')}${lineEnding}`)
209
209
  } else {
210
- buffer.add(`${name}${this.getPropertyTypeSeparator()} ${this.getPropertyDatatype(type)}${lineEnding}`)
210
+ buffer.add(`${normalise(name)}${this.getPropertyTypeSeparator()} ${this.getPropertyDatatype(type)}${lineEnding}`)
211
211
  }
212
212
  this.printDepth--
213
213
  return buffer
@@ -371,13 +371,26 @@ class Resolver {
371
371
  result.isInlineDeclaration = true
372
372
  } else {
373
373
  if (isInlineEnumType(element, this.csn)) {
374
- // we use the singular as the initial declaration of these enums takes place
375
- // while defining the singular class. Which therefore uses the singular over the plural name.
376
- const cleanEntityName = util.singular4(element.parent, true)
377
- const enumName = propertyToInlineEnumName(cleanEntityName, element.name)
378
- result.type = enumName
379
- result.plainName = enumName
380
- result.isInlineDeclaration = true
374
+ // element.parent is only set if the enum is attached to an entity's property.
375
+ // If it is missing then we are dealing with an inline parameter type of an action.
376
+ if (element.parent) {
377
+ result.isInlineDeclaration = true
378
+ // we use the singular as the initial declaration of these enums takes place
379
+ // while defining the singular class. Which therefore uses the singular over the plural name.
380
+ const cleanEntityName = util.singular4(element.parent, true)
381
+ const enumName = propertyToInlineEnumName(cleanEntityName, element.name)
382
+ result.type = enumName
383
+ result.plainName = enumName
384
+ } else {
385
+ // FIXME: this is the case where users have arrays of enums as action parameter type.
386
+ // Instead of building the proper type (e.g. `'A' | 'B' | ...`, we are instead building
387
+ // the encasing type (e.g. `string` here)
388
+ // We should instead aim for a proper type, i.e.
389
+ // this.#resolveInlineDeclarationType(element.enum, result, file)
390
+ // or
391
+ // stringifyEnumType(csnToEnumPairs(element))
392
+ this.#resolveTypeName(element.type, result)
393
+ }
381
394
  } else {
382
395
  this.resolvePotentialReferenceType(element.type, result, file)
383
396
  }
package/lib/csn.js CHANGED
@@ -226,4 +226,18 @@ function amendCSN(csn) {
226
226
  propagateForeignKeys(csn)
227
227
  }
228
228
 
229
- module.exports = { amendCSN }
229
+ /**
230
+ * FIXME: this is pretty handwavey: we are looking for view-entities,
231
+ * i.e. ones that have a query, but are not a cds level projection.
232
+ * Those are still not expanded and we have to retrieve their definition
233
+ * with all properties from the inferred model.
234
+ */
235
+ const isView = entity => entity.query && !entity.projection
236
+
237
+ /**
238
+ * @see isView
239
+ * Unresolved entities have to be looked up from inferred csn.
240
+ */
241
+ const isUnresolved = entity => entity._unresolved === true
242
+
243
+ module.exports = { amendCSN, isView, isUnresolved, propagateForeignKeys }
package/lib/file.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs').promises
4
4
  const { readFileSync } = require('fs')
5
5
  const { printEnum, propertyToInlineEnumName, stringifyEnumImplementation } = require('./components/enum')
6
+ const { normalise } = require('./components/identifier')
6
7
  const path = require('path')
7
8
 
8
9
  const AUTO_GEN_NOTE = "// This is an automatically generated file. Please do not change its contents manually!"
@@ -151,9 +152,9 @@ class SourceFile extends File {
151
152
  * ```
152
153
  */
153
154
  static stringifyLambda({name, parameters=[], returns='any', initialiser, isStatic=false}) {
154
- const parameterTypes = parameters.map(([n, t]) => `${n}: ${t}`).join(', ')
155
+ const parameterTypes = parameters.map(([n, t]) => `${normalise(n)}: ${t}`).join(', ')
155
156
  const callableSignature = `(${parameterTypes}): ${returns}`
156
- let prefix = name ? `${name}: `: ''
157
+ let prefix = name ? `${normalise(name)}: `: ''
157
158
  if (prefix && isStatic) {
158
159
  prefix = `static ${prefix}`
159
160
  }
@@ -268,7 +269,7 @@ class SourceFile extends File {
268
269
  */
269
270
  addInlineEnum(entityCleanName, entityFqName, propertyName, kvs) {
270
271
  this.enums.data.push({
271
- name: entityFqName,
272
+ name: `${entityCleanName}.${propertyName}`,
272
273
  property: propertyName,
273
274
  kvs,
274
275
  fq: `${entityCleanName}.${propertyName}`
@@ -375,11 +376,11 @@ class SourceFile extends File {
375
376
  this.types.join(),
376
377
  this.enums.buffer.join(),
377
378
  this.inlineEnums.buffer.join(), // needs to be before classes
378
- namespaces.join(),
379
379
  this.aspects.join(), // needs to be before classes
380
380
  this.classes.join(),
381
381
  this.events.buffer.join(),
382
- this.actions.buffer.join()
382
+ this.actions.buffer.join(),
383
+ namespaces.join() // needs to be after classes for possible declaration merging
383
384
  ].filter(Boolean).join('\n')
384
385
  }
385
386
 
package/lib/util.js CHANGED
@@ -51,6 +51,7 @@ const getPluralAnnotation = (csn) => csn[annotations.plural.find(a => Object.has
51
51
  * unlocalize("{i18n>Foo}") -> "Foo"
52
52
  * @param {string} name the entity name (singular or plural).
53
53
  * @returns {string} the name without localisation syntax or untouched.
54
+ * @deprecated we have dropped this feature altogether, users specify custom names via @singular/@plural now
54
55
  */
55
56
  const unlocalize = (name) => {
56
57
  const match = name.match(/\{i18n>(.*)\}/)
package/lib/visitor.js CHANGED
@@ -2,14 +2,14 @@
2
2
 
3
3
  const util = require('./util')
4
4
 
5
- const { amendCSN } = require('./csn')
5
+ const { amendCSN, isView, isUnresolved, propagateForeignKeys } = require('./csn')
6
6
  // eslint-disable-next-line no-unused-vars
7
7
  const { SourceFile, 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
- const { csnToEnumPairs, propertyToInlineEnumName, isInlineEnumType } = require('./components/enum')
12
+ const { csnToEnumPairs, propertyToInlineEnumName, isInlineEnumType, stringifyEnumType } = require('./components/enum')
13
13
 
14
14
  /** @typedef {import('./file').File} File */
15
15
  /** @typedef {{ entity: String }} Context */
@@ -63,6 +63,7 @@ class Visitor {
63
63
  */
64
64
  constructor(csn, options = {}, logger = new Logger()) {
65
65
  amendCSN(csn.xtended)
66
+ propagateForeignKeys(csn.inferred)
66
67
  this.options = { ...defaults, ...options }
67
68
  this.logger = logger
68
69
  this.csn = csn
@@ -99,10 +100,12 @@ class Visitor {
99
100
  */
100
101
  visitDefinitions() {
101
102
  for (const [name, entity] of Object.entries(this.csn.xtended.definitions)) {
102
- if (entity._unresolved === true) {
103
- this.logger.error(`Skipping unresolved entity: ${JSON.stringify(entity)}`)
104
- } else {
103
+ if (isView(entity)) {
104
+ this.visitEntity(name, this.csn.inferred.definitions[name])
105
+ } else if (!isUnresolved(entity)) {
105
106
  this.visitEntity(name, entity)
107
+ } else {
108
+ this.logger.warning(`Skipping unresolved entity: ${name}`)
106
109
  }
107
110
  }
108
111
  // FIXME: optimise
@@ -128,6 +131,24 @@ class Visitor {
128
131
  }
129
132
  }
130
133
 
134
+ /**
135
+ * Retrieves all the keys from an entity.
136
+ * That is: all keys that are present in both inferred, as well as xtended flavour.
137
+ * @returns {[string, object][]} array of key name and key element pairs
138
+ */
139
+ #keys(name) {
140
+ // FIXME: this is actually pretty bad, as not only have to propagate keys through
141
+ // both flavours of CSN (see constructor), but we are now also collecting them from
142
+ // both flavours and deduplicating them.
143
+ // xtended contains keys that have been inherited from parents
144
+ // inferred contains keys from queried entities (thing `entity Foo as select from Bar`, where Bar has keys)
145
+ // So we currently need them both.
146
+ return Object.entries({
147
+ ...this.csn.inferred.definitions[name]?.keys ?? {},
148
+ ...this.csn.xtended.definitions[name]?.keys ?? {}
149
+ })
150
+ }
151
+
131
152
  /**
132
153
  * Transforms an entity or CDS aspect into a JS aspect (aka mixin).
133
154
  * That is, for an element A we get:
@@ -166,7 +187,7 @@ class Visitor {
166
187
  // lookup in cds.definitions can fail for inline structs.
167
188
  // We don't really have to care for this case, as keys from such structs are _not_ propagated to
168
189
  // the containing entity.
169
- for (const [kname, kelement] of Object.entries(this.csn.xtended.definitions[element.target]?.keys ?? {})) {
190
+ for (const [kname, kelement] of this.#keys(element.target)) {
170
191
  if (this.resolver.getMaxCardinality(element) === 1) {
171
192
  kelement.isRefNotNull = !!element.notNull || !!element.key
172
193
  this.visitElement(`${ename}_${kname}`, kelement, file, buffer)
@@ -245,11 +266,13 @@ class Visitor {
245
266
  const file = this.getNamespaceFile(ns)
246
267
  // entities are expected to be in plural anyway, so we would favour the regular name.
247
268
  // If the user decides to pass a @plural annotation, that gets precedence over the regular name.
248
- let plural = util.unlocalize(
249
- this.resolver.trimNamespace(util.getPluralAnnotation(entity) ? util.plural4(entity, false) : name)
250
- )
251
- const singular = util.unlocalize(util.singular4(entity, true))
252
- if (singular === plural) {
269
+ let plural = this.resolver.trimNamespace(util.getPluralAnnotation(entity) ? util.plural4(entity, false) : name)
270
+ const singular = this.resolver.trimNamespace(util.singular4(entity, true))
271
+ // trimNamespace does not properly detect scoped entities, like A.B where both A and B are
272
+ // entities. So to see if we would run into a naming collision, we forcefully take the last
273
+ // part of the name, so "A.B" and "A.Bs" just become "B" and "Bs" to be compared.
274
+ // FIXME: put this in a util function
275
+ if (singular.split('.').at(-1) === plural.split('.').at(-1)) {
253
276
  plural += '_'
254
277
  this.logger.warning(
255
278
  `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.`
@@ -279,7 +302,7 @@ class Visitor {
279
302
  docify(entity.doc).forEach((d) => buffer.add(d))
280
303
  }
281
304
 
282
- this.#aspectify(name, entity, file.classes, singular)
305
+ this.#aspectify(name, entity, buffer, singular)
283
306
 
284
307
  // PLURAL
285
308
  if (plural.includes('.')) {
@@ -309,11 +332,17 @@ class Visitor {
309
332
  .filter(([, type]) => type?.type !== '$self' && !(type.items?.type === '$self'))
310
333
  .map(([name, type]) => [
311
334
  name,
312
- this.resolver.visitor.inlineDeclarationResolver.getPropertyDatatype(this.resolver.resolveAndRequire(type, file)),
335
+ this.#stringifyFunctionParamType(type, file)
313
336
  ])
314
337
  : []
315
338
  }
316
339
 
340
+ #stringifyFunctionParamType(type, file) {
341
+ return type.enum
342
+ ? stringifyEnumType(csnToEnumPairs(type))
343
+ : this.inlineDeclarationResolver.getPropertyDatatype(this.resolver.resolveAndRequire(type, file))
344
+ }
345
+
317
346
  #printFunction(name, func) {
318
347
  // FIXME: mostly duplicate of printAction -> reuse
319
348
  this.logger.debug(`Printing function ${name}:\n${JSON.stringify(func, null, 2)}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/cds-typer",
3
- "version": "0.13.0",
3
+ "version": "0.15.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",