Skip to content

Commit

Permalink
Merge branch 'main' into feat/static-name-property
Browse files Browse the repository at this point in the history
  • Loading branch information
daogrady authored Oct 9, 2023
2 parents 88da21b + 600f577 commit 1ba378e
Show file tree
Hide file tree
Showing 8 changed files with 234 additions and 73 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).

### Added
- Each generated class now contains their original fully qualified name in a static `.name` property
- Inline enums that are defined as literal type of properties are now supported as well (note: this feature is experimental. The location to which enums are generated might change in the future!)

### Fixed
- Fixed an error when an entity uses `type of` on a property they have inherited from another entity
Expand Down
91 changes: 91 additions & 0 deletions lib/components/enum.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Prints an enum to a buffer. To be precise, it prints
* a constant object and a type which together form an artificial enum.
* CDS enums differ from TS enums as they can use bools as value (TS: only number and string)
* So we have to emulate enums by adding an object (name -> value mappings)
* and a type containing all disctinct values.
* We can get away with this as TS doesn't feature nominal typing, so the structure
* is all we care about.
*
* @example
* ```cds
* type E: enum of String {
* a = 'A';
* b = 'B';
* }
* ```
* becomes
* ```ts
* const E = { a: 'A', b: 'B' }
* type E = 'A' | 'B'
* ```
*
* @param {Buffer} buffer Buffer to write into
* @param {string} name local name of the enum
* @param {[string, string][]} kvs list of key-value pairs
*/
function printEnum(buffer, name, kvs, options = {}) {
const opts = {...{export: true}, ...options}
buffer.add('// enum')
buffer.add(`${opts.export ? 'export ' : ''}const ${name} = {`)
buffer.indent()
const vals = new Set()
for (const [k, v] of kvs) {
buffer.add(`${k}: ${JSON.stringify(v)},`)
vals.add(JSON.stringify(v.val ?? v)) // in case of wrapped vals we need to unwrap here for the type
}
buffer.outdent()
buffer.add('} as const;')
buffer.add(`${opts.export ? 'export ' : ''}type ${name} = ${[...vals].join(' | ')}`)
buffer.add('')
}

// in case of strings, wrap in quotes and fallback to key to make sure values are attached for every key
const enumVal = (key, value, enumType) => enumType === 'cds.String' ? `${value ?? key}` : value

/**
* @param {{enum: {[key: name]: string}, type: string}} enumCsn
* @param {{unwrapVals: boolean}} options if `unwrapVals` is passed,
* then the CSN structure `{val:x}` is flattened to just `x`.
* Retaining `val` is closer to the actual CSN structure and should be used where we want
* to mimic the runtime as closely as possible (anoymous enum types).
* Stripping that additional wrapper would be more readable for users.
* @example
* ```ts
* const csn = {enum: {x: {val: 42}, y: {val: -42}}}
* csnToEnum(csn) // -> [['x', 42], ['y': -42]]
* csnToEnum(csn, {unwrapVals: false}) // -> [['x', {val:42}], ['y': {val:-42}]]
* ```
*/
const csnToEnum = ({enum: enm, type}, options = {}) => {
options = {...{unwrapVals: true}, ...options}
return Object.entries(enm).map(([k, v]) => {
const val = enumVal(k, v.val, type)
return [k, options.unwrapVals ? val : { val }]
})
}

/**
*
*/
const propertyToInlineEnumName = (entity, property) => `${entity}_${property}`

// if the type is in csn.definitions, then it's actually referring
// to an external enum. Those are handled elsewhere.
/**
* A type is considered to be an inline enum, iff it has a `.enum` property
* _and_ its type is a CDS primitive, i.e. it is not contained in `cds.definitions`.
* If it is contained there, then it is a standard enum declaration that has its own name.
*
* @param {{type: string}} element
* @param {object} csn
* @returns boolean
*/
const isInlineEnumType = (element, csn) => element.enum && !(element.type in csn.definitions)

module.exports = {
printEnum,
csnToEnum,
propertyToInlineEnumName,
isInlineEnumType
}
16 changes: 13 additions & 3 deletions lib/components/resolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const util = require('../util')
const { Buffer, SourceFile, Path, Library, baseDefinitions } = require("../file")
const { deepRequire, createToManyAssociation, createToOneAssociation, createArrayOf, createCompositionOfMany, createCompositionOfOne } = require('./wrappers')
const { StructuredInlineDeclarationResolver } = require("./inline")
const { isInlineEnumType, propertyToInlineEnumName } = require('./enum')

/** @typedef {{ cardinality?: { max?: '*' | number } }} EntityCSN */
/** @typedef {{ definitions?: Object<string, EntityCSN> }} CSN */
Expand Down Expand Up @@ -167,7 +168,7 @@ class Resolver {
new StructuredInlineDeclarationResolver(this.visitor).printInlineType(undefined, { typeInfo }, into, '')
typeName = into.join(' ')
singular = typeName
plural = createArrayOf(typeName) //`Array<${typeName}>`
plural = createArrayOf(typeName)
} else {
// TODO: make sure the resolution still works. Currently, we only cut off the namespace!
singular = util.singular4(typeInfo.csn)
Expand Down Expand Up @@ -350,14 +351,23 @@ class Resolver {
isArray: false,
}

// FIXME: switch case
if (element?.type === undefined) {
// "fallback" type "empty object". May be overriden via #resolveInlineDeclarationType
// later on with an inline declaration
result.type = '{}'
result.isInlineDeclaration = true
} else {
this.resolvePotentialReferenceType(element.type, result, file)
if (isInlineEnumType(element, this.csn)) {
// we use the singular as the initial declaration of these enums takes place
// while defining the singular class. Which therefore uses the singular over the plural name.
const cleanEntityName = util.singular4(element.parent, true)
const enumName = propertyToInlineEnumName(cleanEntityName, element.name)
result.type = enumName
result.plainName = enumName
result.isInlineDeclaration = true
} else {
this.resolvePotentialReferenceType(element.type, result, file)
}
}

// objects and arrays
Expand Down
2 changes: 1 addition & 1 deletion lib/csn.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ class DraftUnroller {
* (a) aspects via `A: B`, where `B` is draft enabled.
* Note that when an entity extends two other entities of which one has drafts enabled and
* one has not, then the one that is later in the list of mixins "wins":
* @example sdasd
* @example
* ```ts
* @odata.draft.enabled true
* entity T {}
Expand Down
30 changes: 10 additions & 20 deletions lib/file.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const fs = require('fs').promises
const { readFileSync } = require('fs')
const { printEnum } = require('./components/enum')
const path = require('path')

const AUTO_GEN_NOTE = "// This is an automatically generated file. Please do not change its contents manually!"
Expand Down Expand Up @@ -105,6 +106,8 @@ class SourceFile extends File {
this.types = new Buffer()
/** @type {{ buffer: Buffer, fqs: {name: string, fq: string}[]}} */
this.enums = { buffer: new Buffer(), fqs: [] }
/** @type {{ buffer: Buffer }} */
this.inlineEnums = { buffer: new Buffer() }
/** @type {Buffer} */
this.classes = new Buffer()
/** @type {{ buffer: Buffer, names: string[]}} */
Expand Down Expand Up @@ -223,26 +226,12 @@ class SourceFile extends File {
* @param {[string, string][]} kvs list of key-value pairs
*/
addEnum(fq, name, kvs) {
// CDS differ from TS enums as they can use bools as value (TS: only number and string)
// So we have to emulate enums by adding an object (name -> value mappings)
// and a type containing all disctinct values.
// We can get away with this as TS doesn't feature nominal typing, so the structure
// is all we care about.
// FIXME: this really should be in visitor, as File should not contain logic of this kind
this.enums.fqs.push({ name, fq })
const bu = this.enums.buffer
bu.add('// enum')
bu.add(`export const ${name} = {`)
bu.indent()
const vals = new Set()
for (const [k, v] of kvs) {
bu.add(`${k}: ${v},`)
vals.add(v)
}
bu.outdent()
bu.add('}')
bu.add(`export type ${name} = ${[...vals].join(' | ')}`)
bu.add('')
printEnum(this.enums.buffer, name, kvs)
}

addInlineEnum(name, kvs) {
printEnum(this.inlineEnums.buffer, name, kvs, {export: false})
}

/**
Expand Down Expand Up @@ -327,7 +316,8 @@ class SourceFile extends File {
this.getImports().join(),
this.preamble.join(),
this.types.join(),
this.enums.buffer.join(),
this.enums.buffer.join(),
this.inlineEnums.buffer.join(), // needs to be before classes
namespaces.join(),
this.aspects.join(), // needs to be before classes
this.classes.join(),
Expand Down
39 changes: 25 additions & 14 deletions lib/visitor.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const { FlatInlineDeclarationResolver, StructuredInlineDeclarationResolver } = r
const { Resolver } = require('./components/resolver')
const { Logger } = require('./logging')
const { docify } = require('./components/wrappers')
const { csnToEnum, propertyToInlineEnumName, isInlineEnumType } = require('./components/enum')

/** @typedef {import('./file').File} File */
/** @typedef {{ entity: String }} Context */
Expand Down Expand Up @@ -116,24 +117,23 @@ class Visitor {
* @param {Buffer} buffer the buffer to write the resulting definitions into
* @param {string?} cleanName the clean name to use. If not passed, it is derived from the passed name instead.
*/
_aspectify(name, entity, buffer, cleanName = undefined) {
#aspectify(name, entity, buffer, cleanName = undefined) {
const clean = cleanName ?? this.resolver.trimNamespace(name)
const ns = this.resolver.resolveNamespace(name.split('.'))
const file = this.getNamespaceFile(ns)

const identSingular = (name) => name
const identAspect = (name) => `_${name}Aspect`

this.contexts.push({
entity: name,
})
this.contexts.push({ entity: name })

// CLASS ASPECT
buffer.add(`export function ${identAspect(clean)}<TBase extends new (...args: any[]) => object>(Base: TBase) {`)
buffer.indent()
buffer.add(`return class ${clean} extends Base {`)
buffer.indent()

const enums = []
for (const [ename, element] of Object.entries(entity.elements ?? {})) {
this.visitElement(ename, element, file, buffer)

Expand All @@ -146,7 +146,24 @@ class Visitor {
this.visitElement(`${ename}_${kname}`, kelement, file, buffer)
}
}
}

// store inline enums for later handling, as they have to go into one common "static elements" wrapper
if (isInlineEnumType(element, this.csn)) {
enums.push(element)
}
}

if (enums.length) {
buffer.add('static elements = {')
buffer.indent()
for (const e of enums) {
const enumName = propertyToInlineEnumName(clean, e.name)
file.addInlineEnum(enumName, csnToEnum(e, {unwrapVals: false}))
buffer.add(`${e.name}: { enum: ${enumName} },`)
}
buffer.outdent()
buffer.add('}')
}

buffer.add('static actions: {')
buffer.indent()
Expand Down Expand Up @@ -241,7 +258,7 @@ class Visitor {
docify(entity.doc).forEach((d) => buffer.add(d))
}

this._aspectify(name, entity, file.classes, singular)
this.#aspectify(name, entity, file.classes, singular)

// PLURAL
if (plural.includes('.')) {
Expand Down Expand Up @@ -301,13 +318,7 @@ class Visitor {
const ns = this.resolver.resolveNamespace(name.split('.'))
const file = this.getNamespaceFile(ns)
if ('enum' in type) {
// in case of strings, wrap in quotes and fallback to key to make sure values are attached for every key
const val = (k,v) => type.type === 'cds.String' ? `"${v ?? k}"` : v
file.addEnum(
name,
clean,
Object.entries(type.enum).map(([k, v]) => [k, val(k, v.val)])
)
file.addEnum(name, clean, csnToEnum(type))
} else {
// alias
file.addType(name, clean, this.resolver.resolveAndRequire(type, file).typeName)
Expand All @@ -325,7 +336,7 @@ class Visitor {
// So we separate them into another buffer which is printed before the classes.
file.addClass(clean, name)
file.aspects.add(`// the following represents the CDS aspect '${clean}'`)
this._aspectify(name, aspect, file.aspects, clean)
this.#aspectify(name, aspect, file.aspects, clean)
}

#printEvent(name, event) {
Expand Down
3 changes: 2 additions & 1 deletion test/ast.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ function resolveKeyword(node) {
elementType: visit(node.elementType), // only in arraytypes
members: node.members?.map(visit), // only in inline type definitions
indexType: visit(node.indexType), // only in indexedaccesstype
literal: visit(node.literal) // only in literaltype
literal: visit(node.literal), // only in literaltype
expression: visit(node.expression) // only in asexpression ("as const")
}).filter(([,v]) => v !== undefined))
}

Expand Down
Loading

0 comments on commit 1ba378e

Please sign in to comment.