diff --git a/CHANGELOG.md b/CHANGELOG.md index f0f67f56..b805f5f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/components/enum.js b/lib/components/enum.js new file mode 100644 index 00000000..9edf85d1 --- /dev/null +++ b/lib/components/enum.js @@ -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 +} \ No newline at end of file diff --git a/lib/components/resolver.js b/lib/components/resolver.js index f4eb6ba6..0b5c338d 100644 --- a/lib/components/resolver.js +++ b/lib/components/resolver.js @@ -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 }} CSN */ @@ -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) @@ -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 diff --git a/lib/csn.js b/lib/csn.js index 96f57304..ab9554a1 100644 --- a/lib/csn.js +++ b/lib/csn.js @@ -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 {} diff --git a/lib/file.js b/lib/file.js index 9d793550..8012f115 100644 --- a/lib/file.js +++ b/lib/file.js @@ -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!" @@ -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[]}} */ @@ -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}) } /** @@ -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(), diff --git a/lib/visitor.js b/lib/visitor.js index bb9d1d8f..64eb2f8e 100644 --- a/lib/visitor.js +++ b/lib/visitor.js @@ -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 */ @@ -116,7 +117,7 @@ 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) @@ -124,9 +125,7 @@ class Visitor { 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)} object>(Base: TBase) {`) @@ -134,6 +133,7 @@ class Visitor { 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) @@ -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() @@ -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('.')) { @@ -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) @@ -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) { diff --git a/test/ast.js b/test/ast.js index 4a7fa5d2..8089afae 100644 --- a/test/ast.js +++ b/test/ast.js @@ -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)) } diff --git a/test/unit/enum.test.js b/test/unit/enum.test.js index 2fb633b2..dd78d6d9 100644 --- a/test/unit/enum.test.js +++ b/test/unit/enum.test.js @@ -20,45 +20,102 @@ describe('Enum Types', () => { ast = new ASTWrapper(path.join(paths[1], 'index.ts')) }) - test('string enums values', async () => { - expect(ast.tree.find(n => n.name === 'Gender' - && n.initializer.female === 'female' - && n.initializer.male === 'male' - && n.initializer.non_binary === 'non-binary')) - .toBeTruthy() + describe('Static Enum Property', () => { + test('Wrapper Present', async () => { + expect(ast.getAspects().find(({name, members}) => name === '_InlineEnumAspect' + && members?.find(member => member.name === 'elements' && member.modifiers?.find(m => m.keyword === 'static'))) + ).toBeTruthy() + }) }) - test('string enums type alias', async () => { - expect(ast.getTypeAliasDeclarations().find(n => n.name === 'Gender' - && ['male', 'female', 'non-binary'].every(t => n.types.includes(t)))) - .toBeTruthy() - }) + describe('Anonymous', () => { + describe('String Enum', () => { + test('Definition Present', async () => + expect(ast.tree.find(n => n.name === 'InlineEnum_gender' + && n.initializer.expression.female.val === 'female' + && n.initializer.expression.male.val === 'male' + && n.initializer.expression.non_binary.val === 'non-binary')) + .toBeTruthy()) - test('int enums values', async () => { - expect(ast.tree.find(n => n.name === 'Status' - && n.initializer.submitted === 1 - && n.initializer.unknown === 0 - && n.initializer.cancelled === -1)) - .toBeTruthy() - }) + test('Referring Property', async () => + expect(ast.getAspects().find(({name, members}) => name === '_InlineEnumAspect' + && members?.find(member => member.name === 'gender' && member.type?.full === 'InlineEnum_gender'))) + .toBeTruthy()) + + }) + + describe('Int Enum', () => { + test('Definition Present', async () => + expect(ast.tree.find(n => n.name === 'InlineEnum_status' + && n.initializer.expression.submitted.val === 1 + && n.initializer.expression.fulfilled.val === 2 + && n.initializer.expression.canceled.val === -1 + && n.initializer.expression.shipped.val === 42)) + .toBeTruthy()) + + test('Referring Property', async () => + expect(ast.getAspects().find(({name, members}) => name === '_InlineEnumAspect' + && members?.find(member => member.name === 'status' && member.type?.full === 'InlineEnum_status'))) + .toBeTruthy()) + }) + + describe('Mixed Enum', () => { + test('Definition Present', async () => + expect(ast.tree.find(n => n.name === 'InlineEnum_yesno' + && n.initializer.expression.catchall.val === 42 + && n.initializer.expression.no.val === false + && n.initializer.expression.yes.val === true + && n.initializer.expression.yesnt.val === false)) + .toBeTruthy()) - test('int enums type alias', async () => { - expect(ast.getTypeAliasDeclarations().find(n => n.name === 'Status' - && [-1, 0, 1].every(t => n.types.includes(t)))) - .toBeTruthy() + test('Referring Property', async () => + expect(ast.getAspects().find(({name, members}) => name === '_InlineEnumAspect' + && members?.find(member => member.name === 'yesno' && member.type?.full === 'InlineEnum_yesno'))) + .toBeTruthy()) + }) }) - test('mixed enums values', async () => { - ast.tree.find(n => n.name === 'Truthy' - && n.yes === true - && n.no === false - && n.yesnt === false - && n.catchall === 42 - )}) - - test('mixed enums type alias', async () => { - expect(ast.getTypeAliasDeclarations().find(n => n.name === 'Truthy' - && [true, false, 42].every(t => n.types.includes(t)))) - .toBeTruthy() + describe('Named', () => { + describe('String Enum', () => { + test('Values', async () => + expect(ast.tree.find(n => n.name === 'Gender' + && n.initializer.expression.female === 'female' + && n.initializer.expression.male === 'male' + && n.initializer.expression.non_binary === 'non-binary')) + .toBeTruthy()) + + test('Type Alias', async () => + expect(ast.getTypeAliasDeclarations().find(n => n.name === 'Gender' + && ['male', 'female', 'non-binary'].every(t => n.types.includes(t)))) + .toBeTruthy()) + }) + + describe('Int Enum', () => { + test('Values', async () => + expect(ast.tree.find(n => n.name === 'Status' + && n.initializer.expression.submitted === 1 + && n.initializer.expression.unknown === 0 + && n.initializer.expression.cancelled === -1)) + .toBeTruthy()) + + test('Type Alias', async () => + expect(ast.getTypeAliasDeclarations().find(n => n.name === 'Status' + && [-1, 0, 1].every(t => n.types.includes(t)))) + .toBeTruthy()) + }) + + describe('Mixed Enum', () => { + test('Values', async () => + ast.tree.find(n => n.name === 'Truthy' + && n.yes === true + && n.no === false + && n.yesnt === false + && n.catchall === 42)) + + test('Type Alias', async () => + expect(ast.getTypeAliasDeclarations().find(n => n.name === 'Truthy' + && [true, false, 42].every(t => n.types.includes(t)))) + .toBeTruthy()) + }) }) }) \ No newline at end of file