@cap-js/cds-typer 0.7.0 → 0.9.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.7.1 - TBD
7
+ ## Version 0.9.1 - TBD
8
8
 
9
9
  ### Changed
10
10
 
@@ -12,6 +12,28 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).
12
12
 
13
13
  ### Fixed
14
14
 
15
+ ## Version 0.9.0 - 2023-09-08
16
+
17
+ ### Changed
18
+
19
+ ### Added
20
+ - Support for drafts via `@odata.draft.enabled` annotation
21
+
22
+ ### Fixed
23
+ - Foreign keys are now propagated more than one level (think: `x_ID_ID_ID`)
24
+
25
+
26
+ ## Version 0.8.0 - 2023-09-05
27
+
28
+ ### Changed
29
+
30
+ ### Added
31
+
32
+ ### Fixed
33
+ - Foreign keys that are inherited via aspects are now also generated in addition to the resolved property (see 0.7.0)
34
+ - Explicitly annotated `@singular` and `@plural` names are now properly used in generated _index.js_ files
35
+
36
+
15
37
  ## Version 0.7.0 - 2023-08-22
16
38
 
17
39
  ### Changed
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
@@ -335,6 +335,9 @@ class SourceFile extends File {
335
335
  .flatMap(([singular, plural, original]) => Array.from(new Set([
336
336
  `module.exports.${singular} = csn.${original}`,
337
337
  `module.exports.${plural} = csn.${original}`,
338
+ // FIXME: we currently produce at most 3 entries.
339
+ // This could be an issue when the user re-used the original name in a @singular/@plural annotation.
340
+ // Seems unlikely, but we have to eliminate the original entry if users start running into this.
338
341
  `module.exports.${original} = csn.${original}`
339
342
  ])))
340
343
  ) // singular -> plural aliases
package/lib/util.js CHANGED
@@ -184,48 +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
- const erase = (entity, parent, attr) => {
205
- if (attr in entity) {
206
- const ea = entity[attr]
207
- if (parent[attr] === ea || (parent.erased && parent.erased[attr] === ea)) {
208
- entity.erased ??= {}
209
- entity.erased[attr] = ea
210
- delete entity[attr]
211
- //this.logger.info(`Removing inherited attribute ${attr} from ${entity.name}.`)
212
- }
213
- }
214
- }
215
-
216
- for (const entity of Object.values(csn.definitions)) {
217
- let i = 0
218
- while (
219
- (getSingularAnnotation(entity) || getPluralAnnotation(entity)) &&
220
- i < (entity.includes ?? []).length
221
- ) {
222
- const parent = csn.definitions[entity.includes[i]]
223
- Object.values(annotations).flat().forEach(an => erase(entity, parent, an))
224
- i++
225
- }
226
- }
227
- }
228
-
229
187
  module.exports = {
230
188
  annotations,
231
189
  getSingularAnnotation,
@@ -234,6 +192,5 @@ module.exports = {
234
192
  singular4,
235
193
  plural4,
236
194
  parseCommandlineArgs,
237
- deepMerge,
238
- fixCSN
195
+ deepMerge
239
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,6 +60,7 @@ class Visitor {
59
60
  * @param {VisitorOptions} options
60
61
  */
61
62
  constructor(csn, options = {}, logger = new Logger()) {
63
+ amendCSN(csn)
62
64
  this.options = { ...defaults, ...options }
63
65
  this.logger = logger
64
66
  this.csn = csn
@@ -127,13 +129,21 @@ class Visitor {
127
129
  buffer.indent()
128
130
  buffer.add(`return class ${clean} extends Base {`)
129
131
  buffer.indent()
132
+
130
133
  for (const [ename, element] of Object.entries(entity.elements ?? {})) {
131
134
  this.visitElement(ename, element, file, buffer)
135
+
132
136
  // make foreign keys explicit
133
- for (const [fkname, fkelement] of Object.entries(element.foreignKeys ?? {})) {
134
- this.visitElement(`${ename}_${fkname}`, fkelement, file, buffer)
137
+ if ('target' in element) {
138
+ // lookup in cds.definitions can fail for inline structs.
139
+ // We don't really have to care for this case, as keys from such structs are _not_ propagated to
140
+ // the containing entity.
141
+ for (const [kname, kelement] of Object.entries(this.csn.definitions[element.target]?.keys ?? {})) {
142
+ this.visitElement(`${ename}_${kname}`, kelement, file, buffer)
143
+ }
135
144
  }
136
- }
145
+ }
146
+
137
147
  for (const [aname, action] of Object.entries(entity.actions ?? {})) {
138
148
  buffer.add(
139
149
  SourceFile.stringifyLambda({
@@ -167,11 +177,19 @@ class Visitor {
167
177
  `${baseDefinitions.path.asIdentifier()}.Entity`
168
178
  )
169
179
 
170
- buffer.add(`export class ${identSingular(clean)} extends ${rhs} {}`)
180
+ buffer.add(`export class ${identSingular(clean)} extends ${rhs} {${this.#staticClassContents(clean, entity).join('\n')}}`)
171
181
  //buffer.add(`export type ${clean} = InstanceType<typeof ${identSingular(clean)}>`)
172
182
  this.contexts.pop()
173
183
  }
174
184
 
185
+ #isDraftEnabled(entity) {
186
+ return entity['@odata.draft.enabled'] === true
187
+ }
188
+
189
+ #staticClassContents(clean, entity) {
190
+ return this.#isDraftEnabled(entity) ? [`static drafts: typeof ${clean}`] : []
191
+ }
192
+
175
193
  #printEntity(name, entity) {
176
194
  const clean = this.resolver.trimNamespace(name)
177
195
  const ns = this.resolver.resolveNamespace(name.split('.'))
@@ -207,7 +225,8 @@ class Visitor {
207
225
  // to have Books.texts = Books.text, so we derive the singular once more without cutting off the ns.
208
226
  // Directly deriving it from the plural makes sure we retain any parent namespaces of kind "entity",
209
227
  // which would not be possible while already in singular form, as "Book.text" could not be resolved in CSN.
210
- file.addInflection(util.singular4(plural), plural, clean)
228
+ // edge case: @singular annotation present. singular4 will take care of that.
229
+ file.addInflection(util.singular4(entity, true), plural, clean)
211
230
  if ('doc' in entity) {
212
231
  docify(entity.doc).forEach((d) => buffer.add(d))
213
232
  }
@@ -221,7 +240,7 @@ class Visitor {
221
240
  }
222
241
  // plural can not be a type alias to $singular[] but needs to be a proper class instead,
223
242
  // so it can get passed as value to CQL functions.
224
- buffer.add(`export class ${plural} extends Array<${singular}> {}`)
243
+ buffer.add(`export class ${plural} extends Array<${singular}> {${this.#staticClassContents(singular, entity).join('\n')}}`)
225
244
  buffer.add('')
226
245
  }
227
246
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/cds-typer",
3
- "version": "0.7.0",
3
+ "version": "0.9.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/",
@@ -43,12 +40,11 @@
43
40
  "@sap/cds": ">=6"
44
41
  },
45
42
  "devDependencies": {
43
+ "acorn": "^8.10.0",
46
44
  "eslint": "^8.15.0",
47
45
  "eslint-config-prettier": "^8.5.0",
48
46
  "eslint-plugin-prettier": "^4.0.0",
49
47
  "jest": "^29",
50
- "typedoc": "^0.24.8",
51
- "typedoc-plugin-markdown": "^3.15.3",
52
48
  "typescript": ">=4.6.4"
53
49
  },
54
50
  "jest": {