@cap-js/cds-typer 0.14.0 → 0.16.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,14 +4,34 @@ All notable changes to this project will be documented in this file.
4
4
  This project adheres to [Semantic Versioning](http://semver.org/).
5
5
  The format is based on [Keep a Changelog](http://keepachangelog.com/).
6
6
 
7
- ## Version 0.15.0 - TBD
7
+ ## Version 0.17.0 - TBD
8
+
9
+ ## Version 0.16.0 - 2024-02-01
10
+ ### Changed
11
+ - Changed default log level from `NONE` to `ERROR`. See the doc to manually pass in another log level for cds-typer runs
12
+ - Name collisions between automatically generated foreign key fields (`.…_ID`, `.…_code`, etc.) with explicitly named fields will now raise an error
13
+ - Generate CDS types that are actually structured types as if they were entities. This allows the correct representation of mixing aspects and types in CDS inheritance, and also fixes issues with inline enums in such types
14
+
15
+ ### Fixed
16
+ - Externally defined enums can now be used as parameter types in actions
17
+
18
+ ## Version 0.15.0 - 2023-12-21
19
+ ### Added
20
+ - Support for [scoped entities](https://cap.cloud.sap/docs/cds/cdl#scoped-names)
21
+ - Support for [delimited identifiers](https://cap.cloud.sap/docs/cds/cdl#delimited-identifiers)
22
+
23
+ ### Fixed
24
+ - Inline enums are now available during runtime as well
25
+ - 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!
26
+ - 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.
27
+ - Foreign keys of projection entities are now propagated as well
8
28
 
9
29
  ## Version 0.14.0 - 2023-12-13
10
30
  ### Added
11
31
  - Entities that are database views now also receive typings
12
32
 
13
33
  ## Version 0.13.0 - 2023-12-06
14
- ### Changes
34
+ ### Changed
15
35
  - 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
16
36
 
17
37
  ### Added
package/lib/cli.js CHANGED
@@ -23,7 +23,7 @@ const flags = {
23
23
  logLevel: {
24
24
  desc: `Minimum log level that is printed.`,
25
25
  allowed: Object.keys(Levels),
26
- default: Object.keys(Levels).at(-1),
26
+ default: Levels.ERROR,
27
27
  },
28
28
  jsConfigPath: {
29
29
  desc: `Path to where the jsconfig.json should be written.${EOL}If specified, ${toolName} will create a jsconfig.json file and${EOL}set it up to restrict property usage in types entities to${EOL}existing properties only.`,
@@ -90,7 +90,7 @@ const main = async (args) => {
90
90
  compileFromFile(args.positional, {
91
91
  // temporary fix until rootDir is faded out
92
92
  outputDirectory: [args.named.outputDirectory, args.named.rootDir].find(d => d !== './') ?? './',
93
- logLevel: Levels[args.named.logLevel],
93
+ logLevel: Levels[args.named.logLevel] ?? args.named.logLevel,
94
94
  jsConfigPath: args.named.jsConfigPath,
95
95
  inlineDeclarations: args.named.inlineDeclarations,
96
96
  propertiesOptional: args.named.propertiesOptional === 'true'
@@ -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,19 +31,35 @@ 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
- const enumVal = (key, value, enumType) => enumType === 'cds.String' ? JSON.stringify(`${value ?? key}`) : value
62
+ const enumVal = (key, value, enumType) => enumType === 'cds.String' ? JSON.stringify(`${value ?? key}`) : value
45
63
 
46
64
  /**
47
65
  * Converts a CSN type describing an enum into a list of kv-pairs.
@@ -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,34 @@ 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
+ // Edge case: element.parent is set, but no .name property is attached. This happens
377
+ // for inline enums inside types:
378
+ // ```cds
379
+ // type T {
380
+ // x : String enum { ... }; // no element.name for x
381
+ // }
382
+ // ```
383
+ // In that case, we currently resolve to the more general type (cds.String, here)
384
+ if (element.parent?.name) {
385
+ result.isInlineDeclaration = true
386
+ // we use the singular as the initial declaration of these enums takes place
387
+ // while defining the singular class. Which therefore uses the singular over the plural name.
388
+ const cleanEntityName = util.singular4(element.parent, true)
389
+ const enumName = propertyToInlineEnumName(cleanEntityName, element.name)
390
+ result.type = enumName
391
+ result.plainName = enumName
392
+ } else {
393
+ // FIXME: this is the case where users have arrays of enums as action parameter type.
394
+ // Instead of building the proper type (e.g. `'A' | 'B' | ...`, we are instead building
395
+ // the encasing type (e.g. `string` here)
396
+ // We should instead aim for a proper type, i.e.
397
+ // this.#resolveInlineDeclarationType(element.enum, result, file)
398
+ // or
399
+ // stringifyEnumType(csnToEnumPairs(element))
400
+ this.#resolveTypeName(element.type, result)
401
+ }
381
402
  } else {
382
403
  this.resolvePotentialReferenceType(element.type, result, file)
383
404
  }
package/lib/csn.js CHANGED
@@ -240,4 +240,4 @@ const isView = entity => entity.query && !entity.projection
240
240
  */
241
241
  const isUnresolved = entity => entity._unresolved === true
242
242
 
243
- module.exports = { amendCSN, isView, isUnresolved }
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, isView, isUnresolved } = 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
@@ -130,6 +131,24 @@ class Visitor {
130
131
  }
131
132
  }
132
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
+
133
152
  /**
134
153
  * Transforms an entity or CDS aspect into a JS aspect (aka mixin).
135
154
  * That is, for an element A we get:
@@ -168,10 +187,15 @@ class Visitor {
168
187
  // lookup in cds.definitions can fail for inline structs.
169
188
  // We don't really have to care for this case, as keys from such structs are _not_ propagated to
170
189
  // the containing entity.
171
- for (const [kname, kelement] of Object.entries(this.csn.xtended.definitions[element.target]?.keys ?? {})) {
190
+ for (const [kname, kelement] of this.#keys(element.target)) {
172
191
  if (this.resolver.getMaxCardinality(element) === 1) {
173
- kelement.isRefNotNull = !!element.notNull || !!element.key
174
- this.visitElement(`${ename}_${kname}`, kelement, file, buffer)
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)
198
+ }
175
199
  }
176
200
  }
177
201
  }
@@ -247,11 +271,13 @@ class Visitor {
247
271
  const file = this.getNamespaceFile(ns)
248
272
  // entities are expected to be in plural anyway, so we would favour the regular name.
249
273
  // If the user decides to pass a @plural annotation, that gets precedence over the regular name.
250
- let plural = util.unlocalize(
251
- this.resolver.trimNamespace(util.getPluralAnnotation(entity) ? util.plural4(entity, false) : name)
252
- )
253
- const singular = util.unlocalize(util.singular4(entity, true))
254
- if (singular === plural) {
274
+ let plural = this.resolver.trimNamespace(util.getPluralAnnotation(entity) ? util.plural4(entity, false) : name)
275
+ const singular = this.resolver.trimNamespace(util.singular4(entity, true))
276
+ // trimNamespace does not properly detect scoped entities, like A.B where both A and B are
277
+ // entities. So to see if we would run into a naming collision, we forcefully take the last
278
+ // part of the name, so "A.B" and "A.Bs" just become "B" and "Bs" to be compared.
279
+ // FIXME: put this in a util function
280
+ if (singular.split('.').at(-1) === plural.split('.').at(-1)) {
255
281
  plural += '_'
256
282
  this.logger.warning(
257
283
  `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,7 +285,7 @@ class Visitor {
259
285
  }
260
286
  if (singular in this.csn.xtended.definitions) {
261
287
  this.logger.error(
262
- `Derived singular '${singular}' for your entity '${name}', already exists. The resulting types will be erronous. Please consider using '@singular:'/ '@plural:' annotations in your model to resolve this collision.`
288
+ `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.`
263
289
  )
264
290
  }
265
291
  file.addClass(singular, name)
@@ -281,7 +307,7 @@ class Visitor {
281
307
  docify(entity.doc).forEach((d) => buffer.add(d))
282
308
  }
283
309
 
284
- this.#aspectify(name, entity, file.classes, singular)
310
+ this.#aspectify(name, entity, buffer, singular)
285
311
 
286
312
  // PLURAL
287
313
  if (plural.includes('.')) {
@@ -311,11 +337,19 @@ class Visitor {
311
337
  .filter(([, type]) => type?.type !== '$self' && !(type.items?.type === '$self'))
312
338
  .map(([name, type]) => [
313
339
  name,
314
- this.resolver.visitor.inlineDeclarationResolver.getPropertyDatatype(this.resolver.resolveAndRequire(type, file)),
340
+ this.#stringifyFunctionParamType(type, file)
315
341
  ])
316
342
  : []
317
343
  }
318
344
 
345
+ #stringifyFunctionParamType(type, file) {
346
+ // if type.type is not 'cds.String', 'cds.Integer', ..., then we are actually looking
347
+ // at a named enum type. In that case also resolve that type name
348
+ return type.enum && type.type.startsWith('cds.')
349
+ ? stringifyEnumType(csnToEnumPairs(type))
350
+ : this.inlineDeclarationResolver.getPropertyDatatype(this.resolver.resolveAndRequire(type, file))
351
+ }
352
+
319
353
  #printFunction(name, func) {
320
354
  // FIXME: mostly duplicate of printAction -> reuse
321
355
  this.logger.debug(`Printing function ${name}:\n${JSON.stringify(func, null, 2)}`)
@@ -412,12 +446,16 @@ class Visitor {
412
446
  case 'function':
413
447
  this.#printAction(name, entity)
414
448
  break
415
- case 'type':
416
- this.#printType(name, entity)
417
- break
418
449
  case 'aspect':
419
450
  this.#printAspect(name, entity)
420
451
  break
452
+ case 'type': {
453
+ // types like inline definitions can be used very similarly to entities.
454
+ // They can be extended, contain inline enums, etc., so we treat them as entities.
455
+ const handler = entity.elements ? this.#printEntity : this.#printType
456
+ handler.bind(this)(name, entity)
457
+ break
458
+ }
421
459
  case 'event':
422
460
  this.#printEvent(name, entity)
423
461
  break
@@ -425,7 +463,7 @@ class Visitor {
425
463
  this.#printService(name, entity)
426
464
  break
427
465
  default:
428
- this.logger.error(`Unhandled entity kind '${entity.kind}'.`)
466
+ this.logger.debug(`Unhandled entity kind '${entity.kind}'.`)
429
467
  }
430
468
  }
431
469
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/cds-typer",
3
- "version": "0.14.0",
3
+ "version": "0.16.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",