@cap-js/cds-typer 0.8.0 → 0.10.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,7 @@ 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.8.1 - TBD
7
+ ## Version 0.10.1 - TBD
8
8
 
9
9
  ### Changed
10
10
 
@@ -12,6 +12,25 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).
12
12
 
13
13
  ### Fixed
14
14
 
15
+ ## Version 0.10.0 - 2023-09-21
16
+
17
+ ### Changed
18
+ - Actions and functions are now attached to a static `.actions` property of each generated class. This reflects the runtime behaviour better than the former way of generating instance methods
19
+
20
+ ### Added
21
+
22
+ ### Fixed
23
+
24
+ ## Version 0.9.0 - 2023-09-08
25
+
26
+ ### Changed
27
+
28
+ ### Added
29
+ - Support for drafts via `@odata.draft.enabled` annotation
30
+
31
+ ### Fixed
32
+ - Foreign keys are now propagated more than one level (think: `x_ID_ID_ID`)
33
+
15
34
 
16
35
  ## Version 0.8.0 - 2023-09-05
17
36
 
package/lib/csn.js ADDED
@@ -0,0 +1,267 @@
1
+ const annotation = '@odata.draft.enabled'
2
+
3
+ class DraftUnroller {
4
+ /** @type {Set<string>} */
5
+ #positives = new Set()
6
+ /** @type {{[key: string]: boolean}} */
7
+ #draftable = {}
8
+ /** @type {{[key: string]: string}} */
9
+ #projections
10
+ /** @type {object[]} */
11
+ #entities
12
+ #csn
13
+ set csn(c) {
14
+ this.#csn = c
15
+ this.#entities = Object.values(c.definitions)
16
+ this.#projections = this.#entities.reduce((pjs, entity) => {
17
+ if (entity.projection) {
18
+ pjs[entity.name] = entity.projection.from.ref[0]
19
+ }
20
+ return pjs
21
+ }, {})
22
+ }
23
+ get csn() { return this.#csn }
24
+
25
+ /**
26
+ * @param entity {object | string} - entity to set draftable annotation for.
27
+ * @param value {boolean} - whether the entity is draftable.
28
+ */
29
+ #setDraftable(entity, value) {
30
+ if (typeof entity === 'string') entity = this.#getDefinition(entity)
31
+ entity[annotation] = value
32
+ this.#draftable[entity.name] = value
33
+ if (value) {
34
+ this.#positives.add(entity.name)
35
+ } else {
36
+ this.#positives.delete(entity.name)
37
+ }
38
+ }
39
+
40
+ /**
41
+ * @param entity {object | string} - entity to look draftability up for.
42
+ * @returns {boolean}
43
+ */
44
+ #getDraftable(entity) {
45
+ if (typeof entity === 'string') entity = this.#getDefinition(entity)
46
+ return this.#draftable[entity.name] ??= this.#propagateInheritance(entity)
47
+ }
48
+
49
+ /**
50
+ * @param name {string} - name of the entity.
51
+ */
52
+ #getDefinition(name) { return this.csn.definitions[name] }
53
+
54
+ /**
55
+ * Propagate draft annotations through inheritance (includes).
56
+ * The latest annotation through the inheritance chain "wins".
57
+ * Annotations on the entity itself are always queued last, so they will always be decisive over ancestors.
58
+ *
59
+ * @param entity {object} - entity to pull draftability from its parents.
60
+ */
61
+ #propagateInheritance(entity) {
62
+ const annotations = (entity.includes ?? []).map(parent => this.#getDraftable(parent))
63
+ annotations.push(entity[annotation])
64
+ this.#setDraftable(entity, annotations.filter(a => a !== undefined).at(-1) ?? false)
65
+ }
66
+
67
+ /**
68
+ * Propagate draft-enablement through projections.
69
+ */
70
+ #propagateProjections() {
71
+ const propagate = (from, to) => {
72
+ do {
73
+ this.#setDraftable(to, this.#getDraftable(to) || this.#getDraftable(from))
74
+ from = to
75
+ to = this.#projections[to]
76
+ } while (to)
77
+ }
78
+
79
+ for (let [projection, target] of Object.entries(this.#projections)) {
80
+ propagate(projection, target)
81
+ propagate(target, projection)
82
+ }
83
+ }
84
+
85
+ /**
86
+ * If an entity E is draftable and contains any composition of entities,
87
+ * then those entities also become draftable. Recursively.
88
+ *
89
+ * @param entity {object} - entity to propagate all compositions from.
90
+ */
91
+ #propagateCompositions(entity) {
92
+ if (!this.#getDraftable(entity)) return
93
+
94
+ for (const comp of Object.values(entity.compositions ?? {})) {
95
+ const target = this.#getDefinition(comp.target)
96
+ const current = this.#getDraftable(target)
97
+ if (!current) {
98
+ this.#setDraftable(target, true)
99
+ this.#propagateCompositions(target)
100
+ }
101
+ }
102
+ }
103
+
104
+ unroll(csn) {
105
+ this.csn = csn
106
+
107
+ // inheritance
108
+ for (const entity of this.#entities) {
109
+ this.#propagateInheritance(entity)
110
+ }
111
+
112
+ // transitivity through compositions
113
+ // we have to do this in a second pass, as we only now know which entities are draft-enables themselves
114
+ for (const entity of this.#entities) {
115
+ this.#propagateCompositions(entity)
116
+ }
117
+
118
+ this.#propagateProjections()
119
+ }
120
+ }
121
+
122
+ // note to self: following doc uses @ homoglyph instead of @, as the latter apparently has special semantics in code listings
123
+ /**
124
+ * We are unrolling the @odata.draft.enabled annotations into related entities manually.
125
+ * This includes three scenarios:
126
+ *
127
+ * (a) aspects via `A: B`, where `B` is draft enabled.
128
+ * Note that when an entity extends two other entities of which one has drafts enabled and
129
+ * one has not, then the one that is later in the list of mixins "wins":
130
+ * @example sdasd
131
+ * ```ts
132
+ * @odata.draft.enabled true
133
+ * entity T {}
134
+ * @odata.draft.enabled false
135
+ * entity F {}
136
+ * entity A: T,F {} // draft not enabled
137
+ * entity B: F,T {} // draft enabled
138
+ * ```
139
+ *
140
+ * (b) Draft enabled projections make the entity we project on draft enabled.
141
+ * @example
142
+ * ```ts
143
+ * @odata.draft.enabled: true
144
+ * entity A as projection on B {}
145
+ * entity B {} // draft enabled
146
+ * ```
147
+ *
148
+ * (c) Entities that are draft enabled propagate this property down through compositions:
149
+ *
150
+ * ```ts
151
+ * @odata.draft.enabled: true
152
+ * entity A {
153
+ * b: Composition of B
154
+ * }
155
+ * entity B {} // draft enabled
156
+ * ```
157
+ */
158
+ function unrollDraftability(csn) {
159
+ new DraftUnroller().unroll(csn)
160
+ }
161
+
162
+ /**
163
+ * Propagates keys elements through the CSN. This includes
164
+ *
165
+ * (a) keys that are explicitly declared as key in an entity
166
+ * (b) keys from aspects the entity extends
167
+ *
168
+ * This explicit propagation is required to add foreign key relations
169
+ * to referring entities.
170
+ *
171
+ * @example
172
+ * ```cds
173
+ * entity A: cuid { key name: String; }
174
+ * entity B { ref: Association to one A }
175
+ * ```
176
+ * must yield
177
+ * ```ts
178
+ * class A {
179
+ * ID: UUID // inherited from cuid
180
+ * name: String;
181
+ * }
182
+ * class B {
183
+ * ref: Association.to<A>
184
+ * ref_ID: UUID
185
+ * ref_name: String;
186
+ * }
187
+ * ```
188
+ * @returns {{[key: string]: object}}
189
+ */
190
+ function propagateForeignKeys(csn) {
191
+ for (const element of Object.values(csn.definitions)) {
192
+ Object.defineProperty(element, 'keys', {
193
+ get: function () {
194
+ // cached access to all immediately defined _and_ inherited keys.
195
+ // They need to be explicitly accessible in subclasses to generate
196
+ // foreign key fields from Associations/ Compositions.
197
+ if (!Object.hasOwn(this, '__keys')) {
198
+ const ownKeys = Object.entries(this.elements ?? {}).filter(([,el]) => el.key === true)
199
+ const inheritedKeys = this.includes?.flatMap(parent => Object.entries(csn.definitions[parent].keys)) ?? []
200
+ // not sure why, but .associations contains both Associations, as well as Compositions in CSN.
201
+ // (.compositions contains only Compositions, if any)
202
+ const remoteKeys = Object.entries(this.associations ?? {})
203
+ .filter(([,{key}]) => key) // only follow associations that are keys, that way we avoid cycles
204
+ .flatMap(([kname, key]) => Object.entries(csn.definitions[key.target].keys)
205
+ .map(([ckname, ckey]) => [`${kname}_${ckname}`, ckey]))
206
+
207
+ this.__keys = Object.fromEntries(ownKeys
208
+ .concat(inheritedKeys)
209
+ .concat(remoteKeys)
210
+ .filter(([,ckey]) => !ckey.target) // discard keys that are Associations. Those are already part of .elements
211
+ )
212
+ }
213
+ return this.__keys
214
+ }
215
+ })
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Entities inherit their ancestors annotations:
221
+ * https://cap.cloud.sap/docs/cds/cdl#annotation-propagation
222
+ * This is a problem if we annotate @singular/ @plural to an entity A,
223
+ * as we don't want all descendents B, C, ... to share the ancestor's
224
+ * annotated inflexion
225
+ * -> remove all such annotations that appear in a parent as well.
226
+ * BUT: we can't just delete the attributes. Imagine three classes
227
+ * A <- B <- C
228
+ * where A contains a @singular annotation.
229
+ * If we erase the annotation from B, C will still contain it and
230
+ * can not detect that its own annotation was inherited without
231
+ * travelling up the entire inheritance chain up to A.
232
+ * So instead, we monkey patch and maintain a dictionary "erased"
233
+ * when removing an annotation which we also check.
234
+ * @deprecated since we use the xtended flavour for CSN, we don't need to fix this anymore
235
+ */
236
+ function propagateInflectionAnnotations(csn) {
237
+ const erase = (entity, parent, attr) => {
238
+ if (attr in entity) {
239
+ const ea = entity[attr]
240
+ if (parent[attr] === ea || (parent.erased && parent.erased[attr] === ea)) {
241
+ entity.erased ??= {}
242
+ entity.erased[attr] = ea
243
+ delete entity[attr]
244
+ //this.logger.info(`Removing inherited attribute ${attr} from ${entity.name}.`)
245
+ }
246
+ }
247
+ }
248
+
249
+ for (const entity of Object.values(csn.definitions)) {
250
+ let i = 0
251
+ while (
252
+ (getSingularAnnotation(entity) || getPluralAnnotation(entity)) &&
253
+ i < (entity.includes ?? []).length
254
+ ) {
255
+ const parent = csn.definitions[entity.includes[i]]
256
+ Object.values(annotations).flat().forEach(an => erase(entity, parent, an))
257
+ i++
258
+ }
259
+ }
260
+ }
261
+
262
+ function amendCSN(csn) {
263
+ unrollDraftability(csn)
264
+ propagateForeignKeys(csn)
265
+ }
266
+
267
+ module.exports = { amendCSN }
package/lib/file.js CHANGED
@@ -127,18 +127,34 @@ class SourceFile extends File {
127
127
  * @returns {string} - the stringified lambda
128
128
  * @example
129
129
  * ```js
130
- * stringifyLambda({parameters: [['p','T']]} // (p: T) => any
131
- * stringifyLambda({name: 'f', parameters: [['p','T']]} // f: (p: T) => any
132
- * stringifyLambda({name: 'f', parameters: [['p','T']], returns: 'number'} // f: (p: T) => number
133
- * stringifyLambda({name: 'f', parameters: [['p','T']], returns: 'number', initialiser: '_ => 42'} // f: (p: T) => string = _ => 42
130
+ * // note: these samples are actually simplified! See below.
131
+ * stringifyLambda({parameters: [['p','T']]}) // f: { (p: T): any, ... }
132
+ * stringifyLambda({name: 'f', parameters: [['p','T']]}) // f: { (p: T) => any, ... }
133
+ * stringifyLambda({name: 'f', parameters: [['p','T']], returns: 'number'}) // f: { (p: T) => number, ... }
134
+ * stringifyLambda({name: 'f', parameters: [['p','T']], returns: 'number', initialiser: '_ => 42'}) // f: { (p: T): string = _ => 42, ... }
135
+ * ```
136
+ *
137
+ * The generated string will not be just the signature of the function. Instead, it will be an object offering a callable signature.
138
+ * On top of that, it will also expose a property `__parameters`, which is an object reflecting the functions parameters.
139
+ * The reason for this is that the CDS runtime actually treats the function parameters as a named object. This can not be rectified via
140
+ * type magic, as parameter names do not exist on type level. So we can not use these names to reuse them as object properties.
141
+ * Instead, we generate this utility object for the runtime to use:
134
142
  *
143
+ * @example
144
+ * ```js
145
+ * stringifyLambda({name: 'f', parameters: [['p','T']], returns: 'number'}) // { (p: T): number, __parameters: { p: T } }
135
146
  * ```
136
147
  */
137
- static stringifyLambda({name, parameters=[], returns='any', initialiser}) {
138
- const signature = `(${parameters.map(([n, t]) => `${n}: ${t}`).join(', ')}) => ${returns}`
139
- const prefix = name ? `${name}: `: ''
148
+ static stringifyLambda({name, parameters=[], returns='any', initialiser, isStatic=false}) {
149
+ const parameterTypes = parameters.map(([n, t]) => `${n}: ${t}`).join(', ')
150
+ const callableSignature = `(${parameterTypes}): ${returns}`
151
+ let prefix = name ? `${name}: `: ''
152
+ if (prefix && isStatic) {
153
+ prefix = `static ${prefix}`
154
+ }
140
155
  const suffix = initialiser ? ` = ${initialiser}` : ''
141
- return prefix + signature + suffix
156
+ const lambda = `{ ${callableSignature}, __parameters: {${parameterTypes}}, __returns: ${returns} }`
157
+ return prefix + lambda + suffix
142
158
  }
143
159
 
144
160
  /**
package/lib/util.js CHANGED
@@ -184,66 +184,6 @@ const parseCommandlineArgs = (argv, validFlags) => {
184
184
  }
185
185
  }
186
186
 
187
- /**
188
- * Entities inherit their ancestors annotations:
189
- * https://cap.cloud.sap/docs/cds/cdl#annotation-propagation
190
- * This is a problem if we annotate @singular/ @plural to an entity A,
191
- * as we don't want all descendents B, C, ... to share the ancestor's
192
- * annotated inflexion
193
- * -> remove all such annotations that appear in a parent as well.
194
- * BUT: we can't just delete the attributes. Imagine three classes
195
- * A <- B <- C
196
- * where A contains a @singular annotation.
197
- * If we erase the annotation from B, C will still contain it and
198
- * can not detect that its own annotation was inherited without
199
- * travelling up the entire inheritance chain up to A.
200
- * So instead, we monkey patch and maintain a dictionary "erased"
201
- * when removing an annotation which we also check.
202
- */
203
- function fixCSN(csn) {
204
- for (const element of Object.values(csn.definitions)) {
205
- Object.defineProperty(element, 'keys', {
206
- get: function () {
207
- // cached access to all immediately defined _and_ inherited keys.
208
- // They need to be explicitly accessible in subclasses to generate
209
- // foreign key fields from Associations/ Compositions.
210
- if (!Object.hasOwn(this, '__keys')) {
211
- const ownKeys = Object.fromEntries(Object.entries(this.elements ?? {}).filter(([,el]) => el.key === true))
212
- const inheritedKeys = this.includes?.flatMap(parent => csn.definitions[parent].keys) ?? []
213
- this.__keys = inheritedKeys.reduce((ks, ps) => ({...ps, ...ks}), ownKeys)
214
- }
215
- return this.__keys
216
- }
217
- })
218
- }
219
-
220
- // FIXME: delete after merge with draft-enablement PR
221
- return
222
- const erase = (entity, parent, attr) => {
223
- if (attr in entity) {
224
- const ea = entity[attr]
225
- if (parent[attr] === ea || (parent.erased && parent.erased[attr] === ea)) {
226
- entity.erased ??= {}
227
- entity.erased[attr] = ea
228
- delete entity[attr]
229
- //this.logger.info(`Removing inherited attribute ${attr} from ${entity.name}.`)
230
- }
231
- }
232
- }
233
-
234
- for (const entity of Object.values(csn.definitions)) {
235
- let i = 0
236
- while (
237
- (getSingularAnnotation(entity) || getPluralAnnotation(entity)) &&
238
- i < (entity.includes ?? []).length
239
- ) {
240
- const parent = csn.definitions[entity.includes[i]]
241
- Object.values(annotations).flat().forEach(an => erase(entity, parent, an))
242
- i++
243
- }
244
- }
245
- }
246
-
247
187
  module.exports = {
248
188
  annotations,
249
189
  getSingularAnnotation,
@@ -252,6 +192,5 @@ module.exports = {
252
192
  singular4,
253
193
  plural4,
254
194
  parseCommandlineArgs,
255
- deepMerge,
256
- fixCSN
195
+ deepMerge
257
196
  }
package/lib/visitor.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const util = require('./util')
4
4
 
5
+ const { amendCSN } = require('./csn')
5
6
  const { SourceFile, baseDefinitions, Buffer } = require('./file')
6
7
  const { FlatInlineDeclarationResolver, StructuredInlineDeclarationResolver } = require('./components/inline')
7
8
  const { Resolver } = require('./components/resolver')
@@ -59,7 +60,7 @@ class Visitor {
59
60
  * @param {VisitorOptions} options
60
61
  */
61
62
  constructor(csn, options = {}, logger = new Logger()) {
62
- util.fixCSN(csn)
63
+ amendCSN(csn)
63
64
  this.options = { ...defaults, ...options }
64
65
  this.logger = logger
65
66
  this.csn = csn
@@ -128,8 +129,10 @@ class Visitor {
128
129
  buffer.indent()
129
130
  buffer.add(`return class ${clean} extends Base {`)
130
131
  buffer.indent()
132
+
131
133
  for (const [ename, element] of Object.entries(entity.elements ?? {})) {
132
134
  this.visitElement(ename, element, file, buffer)
135
+
133
136
  // make foreign keys explicit
134
137
  if ('target' in element) {
135
138
  // lookup in cds.definitions can fail for inline structs.
@@ -139,21 +142,27 @@ class Visitor {
139
142
  this.visitElement(`${ename}_${kname}`, kelement, file, buffer)
140
143
  }
141
144
  }
142
- }
145
+ }
146
+
147
+ buffer.add('static actions: {')
148
+ buffer.indent()
143
149
  for (const [aname, action] of Object.entries(entity.actions ?? {})) {
144
150
  buffer.add(
145
151
  SourceFile.stringifyLambda({
146
152
  name: aname,
147
153
  parameters: this.#stringifyFunctionParams(action.params, file),
148
- returns: action.returns ? this.resolver.resolveAndRequire(action.returns, file).typeName : 'any',
149
- initialiser: `undefined as unknown as this['${aname}']`
154
+ returns: action.returns ? this.resolver.resolveAndRequire(action.returns, file).typeName : 'any'
155
+ //initialiser: `undefined as unknown as typeof ${clean}.${aname}`,
150
156
  })
151
157
  )
152
158
  }
153
159
  buffer.outdent()
154
- buffer.add('};')
160
+ buffer.add('}') // end of actions
161
+
162
+ buffer.outdent()
163
+ buffer.add('};') // end of generated class
155
164
  buffer.outdent()
156
- buffer.add('}')
165
+ buffer.add('}') // end of aspect
157
166
 
158
167
  // CLASS WITH ADDED ASPECTS
159
168
  file.addImport(baseDefinitions.path)
@@ -173,11 +182,19 @@ class Visitor {
173
182
  `${baseDefinitions.path.asIdentifier()}.Entity`
174
183
  )
175
184
 
176
- buffer.add(`export class ${identSingular(clean)} extends ${rhs} {}`)
185
+ buffer.add(`export class ${identSingular(clean)} extends ${rhs} {${this.#staticClassContents(clean, entity).join('\n')}}`)
177
186
  //buffer.add(`export type ${clean} = InstanceType<typeof ${identSingular(clean)}>`)
178
187
  this.contexts.pop()
179
188
  }
180
189
 
190
+ #isDraftEnabled(entity) {
191
+ return entity['@odata.draft.enabled'] === true
192
+ }
193
+
194
+ #staticClassContents(clean, entity) {
195
+ return this.#isDraftEnabled(entity) ? [`static drafts: typeof ${clean}`] : []
196
+ }
197
+
181
198
  #printEntity(name, entity) {
182
199
  const clean = this.resolver.trimNamespace(name)
183
200
  const ns = this.resolver.resolveNamespace(name.split('.'))
@@ -228,7 +245,7 @@ class Visitor {
228
245
  }
229
246
  // plural can not be a type alias to $singular[] but needs to be a proper class instead,
230
247
  // so it can get passed as value to CQL functions.
231
- buffer.add(`export class ${plural} extends Array<${singular}> {}`)
248
+ buffer.add(`export class ${plural} extends Array<${singular}> {${this.#staticClassContents(singular, entity).join('\n')}}`)
232
249
  buffer.add('')
233
250
  }
234
251
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/cds-typer",
3
- "version": "0.8.0",
3
+ "version": "0.10.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",
@@ -22,10 +22,7 @@
22
22
  "doc:clean": "rm -rf ./doc",
23
23
  "doc:prepare": "npm run doc:clean && mkdir -p doc/types",
24
24
  "doc:typegen": "./node_modules/.bin/tsc ./lib/*.js --skipLibCheck --declaration --allowJs --emitDeclarationOnly --outDir doc/types && cd doc/types && tsc --init",
25
- "doc:html": "npm run doc:typegen && ./node_modules/.bin/typedoc 'doc/types/**/*.d.ts' --entryPointStrategy expand --out doc/html --tsconfig doc/types/tsconfig.json",
26
- "doc:md": "npm run doc:typegen && ./node_modules/.bin/typedoc --plugin typedoc-plugin-markdown 'doc/types/compile.d.ts' --out doc/md --tsconfig doc/types/tsconfig.json",
27
- "doc:cli": "npm run cli -- --help > ./doc/cli.txt",
28
- "doc:full": "npm run doc:prepare && npm run doc:html && npm run doc:cli"
25
+ "doc:cli": "npm run cli -- --help > ./doc/cli.txt"
29
26
  },
30
27
  "files": [
31
28
  "lib/",
@@ -48,8 +45,6 @@
48
45
  "eslint-config-prettier": "^8.5.0",
49
46
  "eslint-plugin-prettier": "^4.0.0",
50
47
  "jest": "^29",
51
- "typedoc": "^0.24.8",
52
- "typedoc-plugin-markdown": "^3.15.3",
53
48
  "typescript": ">=4.6.4"
54
49
  },
55
50
  "jest": {