@cap-js/cds-typer
Advanced tools
Comparing version 0.31.0 to 0.32.0
@@ -7,3 +7,3 @@ # Changelog | ||
### Added | ||
### Added | ||
### Changed | ||
@@ -15,2 +15,17 @@ ### Deprecated | ||
## [0.32.0] - 2025-01-14 | ||
### Added | ||
- dedicated classes for inline compositions | ||
- dedicated text-classes for entities with `localized` elements | ||
### Changed | ||
- prefixed builtin types like `Promise` and `Record` with `globalThis.`, to allow using names of builtin types for entities without collisions | ||
- bumped peer-dependency to `@cap-js/cds-types` to `>=0.9` | ||
### Deprecated | ||
### Removed | ||
### Fixed | ||
- referencing another entity's property of type `cds.String` in an enum will now properly quote the generated values | ||
### Security | ||
## [0.31.0] - 2024-12-16 | ||
@@ -17,0 +32,0 @@ ### Fixed |
@@ -70,3 +70,3 @@ const { normalise } = require('./identifier') | ||
* will fall back to the key if no value is provided. | ||
* @param {import('../typedefs').resolver.EnumCSN} enumCsn - the CSN type describing the enum | ||
* @param {import('../typedefs').resolver.EnumCSN & { resolvedType?: string }} enumCsn - the CSN type describing the enum | ||
* @param {{unwrapVals: boolean} | {}} options - if `unwrapVals` is passed, | ||
@@ -85,6 +85,6 @@ * then the CSN structure `{val:x}` is flattened to just `x`. | ||
*/ | ||
const csnToEnumPairs = ({enum: enm, type}, options = {}) => { | ||
const csnToEnumPairs = ({enum: enm, type, resolvedType}, options = {}) => { | ||
const actualOptions = {...{unwrapVals: true}, ...options} | ||
return Object.entries(enm).map(([k, v]) => { | ||
const val = enumVal(k, v.val, type) | ||
const val = enumVal(k, v.val, resolvedType ?? type) // if type is a ref, prefer the resolvedType to catch references to cds.Strings | ||
return [k, (actualOptions.unwrapVals ? val : { val })] | ||
@@ -91,0 +91,0 @@ }) |
module.exports = { | ||
empty: 'Record<never, never>' | ||
empty: 'globalThis.Record<never, never>' | ||
} |
const { LOG } = require('./logging') | ||
const { annotations } = require('./util') | ||
@@ -296,2 +297,26 @@ const DRAFT_ENABLED_ANNO = '@odata.draft.enabled' | ||
/** | ||
* Clears "correct" singular/plural annotations from inferred model | ||
* copies the ones from the xtended model. | ||
* | ||
* This is done to prevent potential duplicate class names because of annotation propagation. | ||
* @param {{inferred: CSN, xtended: CSN}} csn - CSN models | ||
*/ | ||
function propagateInflectionAnnotations(csn) { | ||
const singularAnno = annotations.singular[0] | ||
const pluralAnno = annotations.plural[0] | ||
for (const [name, def] of Object.entries(csn.inferred.definitions)) { | ||
const xtendedDef = csn.xtended.definitions[name] | ||
// we keep the annotations from definition specific to the inferred model (e.g. inline compositions) | ||
if (!xtendedDef) continue | ||
// clear annotations from inferred definition | ||
if (Object.hasOwn(def, singularAnno)) delete def[singularAnno] | ||
if (Object.hasOwn(def, pluralAnno)) delete def[pluralAnno] | ||
// transfer annotation from xtended if existing | ||
if (Object.hasOwn(xtendedDef, singularAnno)) def[singularAnno] = xtendedDef[singularAnno] | ||
if (Object.hasOwn(xtendedDef, pluralAnno)) def[pluralAnno] = xtendedDef[pluralAnno] | ||
} | ||
} | ||
/** | ||
* @param {EntityCSN} entity - the entity | ||
@@ -315,2 +340,21 @@ */ | ||
/** | ||
* Heuristic way of looking up a reference type. | ||
* We currently only support up to two segments, | ||
* the first referring to the entity, a possible second | ||
* referring to an element of the entity. | ||
* @param {CSN} csn - CSN | ||
* @param {string[]} ref - reference | ||
* @returns {EntityCSN} | ||
*/ | ||
function lookUpRefType (csn, ref) { | ||
if (ref.length > 2) throw new Error(`Unsupported reference type ${ref.join('.')} with ${ref.length} segments. Please report this error.`) | ||
/** @type {EntityCSN | undefined} */ | ||
let result = csn.definitions[ref[0]] // entity | ||
if (ref.length === 1) return result | ||
result = result?.elements?.[ref[1]] // property | ||
if (!result) throw new Error(`Failed to look up reference type ${ref.join('.')}`) | ||
return result | ||
} | ||
module.exports = { | ||
@@ -331,3 +375,5 @@ collectDraftEnabledEntities, | ||
propagateForeignKeys, | ||
isCsnAny | ||
propagateInflectionAnnotations, | ||
isCsnAny, | ||
lookUpRefType | ||
} |
@@ -260,2 +260,3 @@ 'use strict' | ||
buffer.closed = false | ||
buffer.namespace = name | ||
buffer.add(`export namespace ${name} {`) | ||
@@ -290,2 +291,4 @@ buffer.indent() | ||
* @param {[string, string][]} kvs - list of key-value pairs | ||
* @param {Buffer} [buffer] - if buffer is of subnamespace the enum will be added there, | ||
* otherwise to the inline enums of the file | ||
* @param {string[]} doc - the enum docs | ||
@@ -315,12 +318,28 @@ * If given, the enum is considered to be an inline definition of an enum. | ||
*/ | ||
addInlineEnum(entityCleanName, entityFqName, propertyName, kvs, doc=[]) { | ||
addInlineEnum(entityCleanName, entityFqName, propertyName, kvs, buffer, doc=[]) { | ||
const namespacedEntity = [buffer?.namespace, entityCleanName].filter(Boolean).join('.') | ||
this.enums.data.push({ | ||
name: `${entityCleanName}.${propertyName}`, | ||
name: `${namespacedEntity}.${propertyName}`, | ||
property: propertyName, | ||
kvs, | ||
fq: `${entityCleanName}.${propertyName}` | ||
fq: `${namespacedEntity}.${propertyName}` | ||
}) | ||
const entityProxy = this.entityProxies[entityCleanName] ?? (this.entityProxies[entityCleanName] = []) | ||
const entityProxy = this.entityProxies[namespacedEntity] ?? (this.entityProxies[namespacedEntity] = []) | ||
entityProxy.push(propertyName) | ||
printEnum(this.inlineEnums.buffer, propertyToInlineEnumName(entityCleanName, propertyName), kvs, {export: false}, doc) | ||
// REVISIT: find a better way to do this??? | ||
const printEnumToBuffer = (/** @type {Buffer} */buffer) => printEnum(buffer, propertyToInlineEnumName(entityCleanName, propertyName), kvs, {export: false}, doc) | ||
if (buffer?.namespace) { | ||
const tempBuffer = new Buffer() | ||
// we want to put the enums on class level | ||
tempBuffer.indent() | ||
printEnumToBuffer(tempBuffer) | ||
// we want to write the enums at the beginning of the namespace | ||
const [first,...rest] = buffer.parts | ||
buffer.parts = [first, ...tempBuffer.parts, ...rest] | ||
} else { | ||
printEnumToBuffer(this.inlineEnums.buffer) | ||
} | ||
} | ||
@@ -496,8 +515,11 @@ | ||
singularRhs: `createEntityProxy(['${namespace}', '${original}'], { target: { is_singular: true }${customPropsStr} })`, | ||
pluralRhs: `createEntityProxy(['${namespace}', '${original}'])`, | ||
pluralRhs: `createEntityProxy(['${namespace}', '${original}'], { target: { is_singular: false }})`, | ||
} | ||
} else { | ||
// standard entity: csn.Books | ||
// inline entity: csn['Books.texts'] | ||
const csnAccess = original.includes('.') ? `csn['${original}']` : `csn.${original}` | ||
return { | ||
singularRhs: `{ is_singular: true, __proto__: csn.${original} }`, | ||
pluralRhs: `csn.${original}` | ||
singularRhs: `{ is_singular: true, __proto__: ${csnAccess} }`, | ||
pluralRhs: csnAccess | ||
} | ||
@@ -596,2 +618,7 @@ } | ||
this.closed = false | ||
/** | ||
* Required for inline enums of inline compositions or text entities | ||
* @type {string | undefined} | ||
*/ | ||
this.namespace = undefined | ||
} | ||
@@ -598,0 +625,0 @@ |
@@ -87,3 +87,3 @@ /** | ||
printDeconstructedImport (imports, from) { | ||
return `import { ${imports.join(', ')} } from '${from}'` | ||
return `import { ${imports.join(', ')} } from '${from}/index.js'` | ||
} | ||
@@ -93,3 +93,5 @@ | ||
printExport (name, value) { | ||
return `export const ${name} = ${value}` | ||
return name.includes('.') | ||
? `${name} = ${value}` | ||
: `export const ${name} = ${value}` | ||
} | ||
@@ -96,0 +98,0 @@ |
@@ -106,3 +106,3 @@ 'use strict' | ||
*/ | ||
const createPromiseOf = t => `Promise<${t}>` | ||
const createPromiseOf = t => `globalThis.Promise<${t}>` | ||
@@ -109,0 +109,0 @@ /** |
@@ -64,2 +64,18 @@ const { isType } = require('../csn') | ||
/** @type {Set<string> | undefined} */ | ||
#inheritedElements | ||
/** @returns set of inherited elements (e.g. ID of aspect cuid) */ | ||
get inheritedElements() { | ||
if (this.#inheritedElements) return this.#inheritedElements | ||
this.#inheritedElements = new Set() | ||
for (const parentName of this.csn.includes ?? []) { | ||
const parent = this.#repository.getByFq(parentName) | ||
for (const element of Object.keys(parent?.csn?.elements ?? {})) { | ||
this.#inheritedElements.add(element) | ||
} | ||
} | ||
return this.#inheritedElements | ||
} | ||
/** @returns the **inferred** csn for this entity. */ | ||
@@ -66,0 +82,0 @@ get csn () { |
@@ -27,3 +27,3 @@ 'use strict' | ||
class Resolver { | ||
get csn() { return this.visitor.csn.inferred } | ||
get csn() { return this.visitor.csn } | ||
@@ -169,7 +169,9 @@ /** @param {Visitor} visitor - the visitor */ | ||
const defs = this.visitor.csn.inferred.definitions | ||
const defs = this.visitor.csn.definitions | ||
// check if name is already an entity, then we do not have a property access, but a nested entity | ||
if (defs[p]?.kind === 'entity') return [] | ||
// assume parts to contain [Namespace, Service, Entity1, Entity2, Entity3, property1, property2] | ||
/** @type {string} */ | ||
// @ts-expect-error - nope, we know there is at least one element | ||
let qualifier = parts.shift() | ||
let qualifier = /** @type {string} */ (parts.shift()) | ||
// find first entity from left (Entity1) | ||
@@ -245,2 +247,4 @@ while ((!defs[qualifier] || !isEntity(defs[qualifier])) && parts.length) { | ||
plural = util.getPluralAnnotation(typeInfo.csn) ?? typeInfo.plainName | ||
// remove leading entity name | ||
if (plural.includes('.')) plural = last(plural) | ||
singular = util.getSingularAnnotation(typeInfo.csn) ?? util.singular4(typeInfo.csn, true) // util.singular4(typeInfo.csn, true) // can not use `plural` to honor possible @singular annotation | ||
@@ -317,14 +321,2 @@ | ||
// FIXME: super hack!! | ||
// Inflection currently does not retain the scope of the entity. | ||
// But we can't just fix it in inflection(...), as that would break several other things | ||
// So we bandaid-fix it back here, as it is the least intrusive place -- but this should get fixed asap! | ||
if (target.type) { | ||
const untangled = this.visitor.entityRepository.getByFqOrThrow(target.type) | ||
const scope = untangled.scope.join('.') | ||
if (scope && !singular.startsWith(scope)) { | ||
singular = `${scope}.${singular}` | ||
} | ||
} | ||
typeName = cardinality > 1 | ||
@@ -377,4 +369,10 @@ ? toMany(plural) | ||
if (target && !typeInfo.isDeepRequire) { | ||
const { propertyAccess } = this.visitor.entityRepository.getByFq(target) ?? {} | ||
if (propertyAccess?.length) { | ||
const { propertyAccess, scope } = this.visitor.entityRepository.getByFq(target) ?? {} | ||
if (scope?.length) { | ||
// update inflections with proper prefix, e.g. Books.text, Books.texts | ||
typeInfo.inflection = { | ||
singular: [...scope, typeInfo.inflection?.singular].join('.'), | ||
plural: [...scope, typeInfo.inflection?.plural].join('.') | ||
} | ||
} else if (propertyAccess?.length) { | ||
const element = target.slice(0, -propertyAccess.join('.').length - 1) | ||
@@ -460,2 +458,3 @@ const access = this.visitor.inlineDeclarationResolver.getTypeLookup(propertyAccess) | ||
/** @type {TypeResolveInfo} */ | ||
const result = { | ||
@@ -578,3 +577,3 @@ isBuiltin: false, // will be rectified in the corresponding handlers, if needed | ||
resolveTypeName(t, into) { | ||
const result = into ?? {} | ||
const result = into ?? /** @type {TypeResolveInfo} */({}) | ||
const path = t.split('.') | ||
@@ -581,0 +580,0 @@ const builtin = this.builtinResolver.resolveBuiltin(path) |
@@ -16,3 +16,3 @@ export module resolver { | ||
elements?: { [key: string]: EntityCSN } | ||
key?: string // custom!! | ||
key?: boolean // custom!! | ||
keys?: { [key:string]: any } | ||
@@ -29,2 +29,4 @@ kind: string, | ||
name: string, | ||
'@singular'?: string, | ||
'@plural'?: string, | ||
'@odata.draft.enabled'?: boolean // custom! | ||
@@ -51,3 +53,4 @@ _unresolved?: boolean | ||
export type EnumCSN = EntityCSN & { | ||
enum: {[key:name]: string} | ||
enum: {[key:name]: string}, | ||
resolvedType?: string // custom property! When .type points to a ref, the visitor will resolve the ref into this property | ||
} | ||
@@ -54,0 +57,0 @@ |
@@ -18,6 +18,6 @@ /** @typedef { import('./typedefs').util.Annotations} Annotations */ | ||
const annotations = { | ||
const annotations = /** @type {const} */ ({ | ||
singular: ['@singular'], | ||
plural: ['@plural'], | ||
} | ||
}) | ||
@@ -24,0 +24,0 @@ /** |
'use strict' | ||
const util = require('./util') | ||
const { isView, isUnresolved, propagateForeignKeys, collectDraftEnabledEntities, isDraftEnabled, isType, isProjection, getMaxCardinality, isViewOrProjection, isEnum, isEntity } = require('./csn') | ||
const { propagateForeignKeys, propagateInflectionAnnotations, collectDraftEnabledEntities, isDraftEnabled, isType, getMaxCardinality, isViewOrProjection, isEnum, isEntity, lookUpRefType } = require('./csn') | ||
// eslint-disable-next-line no-unused-vars | ||
@@ -47,8 +45,9 @@ const { SourceFile, FileRepository, Buffer, Path } = require('./file') | ||
constructor(csn) { | ||
propagateForeignKeys(csn.xtended) | ||
propagateForeignKeys(csn.inferred) | ||
// has to be executed on the inferred model as autoexposed entities are not included in the xtended csn | ||
propagateInflectionAnnotations(csn) | ||
collectDraftEnabledEntities(csn.inferred) | ||
this.csn = csn | ||
// xtendend csn not required after this point -> continue with inferred | ||
this.csn = csn.inferred | ||
/** @type {Context[]} **/ | ||
@@ -78,38 +77,5 @@ this.contexts = [] | ||
visitDefinitions() { | ||
for (const [name, entity] of Object.entries(this.csn.xtended.definitions)) { | ||
if (isView(entity)) { | ||
this.visitEntity(name, this.csn.inferred.definitions[name]) | ||
} else if (isProjection(entity) || !isUnresolved(entity)) { | ||
this.visitEntity(name, entity) | ||
} else { | ||
LOG.warn(`Skipping unresolved entity: ${name}`) | ||
} | ||
for (const [name, entity] of Object.entries(this.csn.definitions)) { | ||
this.visitEntity(name, entity) | ||
} | ||
// FIXME: optimise | ||
// We are currently working with two flavours of CSN: | ||
// xtended, as it is as close as possible to an OOP class hierarchy | ||
// inferred, as it contains information missing in xtended | ||
// This is less than optimal and has to be revisited at some point! | ||
const handledKeys = new Set(Object.keys(this.csn.xtended.definitions)) | ||
// we are looking for autoexposed entities in services | ||
const missing = Object.entries(this.csn.inferred.definitions).filter(([key]) => !key.endsWith('.texts') &&!handledKeys.has(key)) | ||
for (const [name, entity] of missing) { | ||
// instead of using the definition from inferred CSN, we refer to the projected entity from xtended CSN instead. | ||
// The latter contains the CSN fixes (propagated foreign keys, etc) and none of the localised fields we don't handle yet. | ||
if (entity.projection) { | ||
const targetName = entity.projection.from.ref[0] | ||
// FIXME: references to types of entity properties may be missing from xtendend flavour (see #103) | ||
// this should be revisted once we settle on a single flavour. | ||
const target = this.csn.xtended.definitions[targetName] ?? this.csn.inferred.definitions[targetName] | ||
if (target.kind !== 'type') { | ||
// skip if the target is a property, like in: | ||
// books: Association to many Author.books ... | ||
// as this would result in a type definition that | ||
// name-clashes with the actual declaration of Author | ||
this.visitEntity(name, target) | ||
} | ||
} else { | ||
LOG.error(`Expecting an autoexposed projection within a service. Skipping ${name}`) | ||
} | ||
} | ||
} | ||
@@ -124,11 +90,4 @@ | ||
#keys(fq) { | ||
// FIXME: this is actually pretty bad, as not only have to propagate keys through | ||
// both flavours of CSN (see constructor), but we are now also collecting them from | ||
// both flavours and deduplicating them. | ||
// xtended contains keys that have been inherited from parents | ||
// inferred contains keys from queried entities (thing `entity Foo as select from Bar`, where Bar has keys) | ||
// So we currently need them both. | ||
return Object.entries({ | ||
...this.csn.inferred.definitions[fq]?.keys ?? {}, | ||
...this.csn.xtended.definitions[fq]?.keys ?? {} | ||
...this.csn.definitions[fq]?.keys ?? {} | ||
}) | ||
@@ -239,3 +198,3 @@ } | ||
// types are not inflected, so don't change those to singular | ||
const csn = this.csn.inferred.definitions[fq] | ||
const csn = this.csn.definitions[fq] | ||
const ident = isType(csn) | ||
@@ -270,2 +229,3 @@ ? clean | ||
const inheritedElements = !isViewOrProjection(entity) ? info.inheritedElements : null | ||
this.contexts.push({ entity: fq }) | ||
@@ -282,6 +242,3 @@ | ||
for (let [ename, element] of Object.entries(entity.elements ?? [])) { | ||
if (element.target && /\.texts?/.test(element.target)) { | ||
LOG.warn(`referring to .texts property in ${fq}. This is currently not supported and will be ignored.`) | ||
continue | ||
} | ||
if (inheritedElements?.has(ename)) continue | ||
this.visitElement({name: ename, element, file, buffer, resolverOptions}) | ||
@@ -301,3 +258,4 @@ | ||
const kelement = Object.assign(Object.create(originalKeyElement), { | ||
isRefNotNull: !!element.notNull || !!element.key | ||
isRefNotNull: !!element.notNull || !!element.key, | ||
key: element.key | ||
}) | ||
@@ -311,3 +269,3 @@ this.visitElement({name: foreignKey, element: kelement, file, buffer, resolverOptions}) | ||
// store inline enums for later handling, as they have to go into one common "static elements" wrapper | ||
if (isInlineEnumType(element, this.csn.xtended)) { | ||
if (isInlineEnumType(element, this.csn)) { | ||
enums.push(element) | ||
@@ -325,3 +283,6 @@ } | ||
})) | ||
file.addInlineEnum(clean, fq, e.name, csnToEnumPairs(e, {unwrapVals: true}), eDoc) | ||
if (typeof e?.type !== 'string' && e?.type?.ref) { | ||
e.resolvedType = /** @type {string} */(lookUpRefType(this.csn, e.type.ref)?.type) | ||
} | ||
file.addInlineEnum(clean, fq, e.name, csnToEnumPairs(e, {unwrapVals: true}), buffer, eDoc) | ||
} | ||
@@ -369,3 +330,3 @@ | ||
const info = this.entityRepository.getByFqOrThrow(fq) | ||
const { namespace: ns, entityName: clean, inflection } = info | ||
const { namespace: ns, entityName: clean, inflection, scope } = info | ||
const file = this.fileRepository.getNamespaceFile(ns) | ||
@@ -386,3 +347,3 @@ let { singular, plural } = inflection | ||
const namespacedSingular = `${ns.asNamespace()}.${singular}` | ||
if (!isType(entity) && namespacedSingular !== fq && namespacedSingular in this.csn.xtended.definitions) { | ||
if (!isType(entity) && namespacedSingular !== fq && namespacedSingular in this.csn.definitions) { | ||
LOG.error( | ||
@@ -400,17 +361,12 @@ `Derived singular '${singular}' for your entity '${fq}', 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.` | ||
// we can't just use "singular" here, as it may have the subnamespace removed: | ||
// "Books.text" is just "text" in "singular". Within the inflected exports we need | ||
// to have Books.texts = Books.text, so we derive the singular once more without cutting off the ns. | ||
// Directly deriving it from the plural makes sure we retain any parent namespaces of kind "entity", | ||
// which would not be possible while already in singular form, as "Book.text" could not be resolved in CSN. | ||
// edge case: @singular annotation present. singular4 will take care of that. | ||
file.addInflection(util.singular4(entity, true), plural, clean) | ||
if (scope?.length > 0) { | ||
/** @param {string} n - name of entity */ | ||
const scoped = n => [...scope, n].join('.') | ||
file.addInflection(scoped(singular), scoped(plural), scoped(clean)) | ||
} else { | ||
file.addInflection(singular, plural, clean) | ||
} | ||
// in case of projections `entity` is empty -> retrieve from inferred csn where the actual properties are rolled out | ||
const target = isProjection(entity) || isView(entity) | ||
? this.csn.inferred.definitions[fq] | ||
: entity | ||
this.#aspectify(fq, entity, buffer, { cleanName: singular }) | ||
this.#aspectify(fq, target, buffer, { cleanName: singular }) | ||
buffer.add(overrideNameProperty(singular, entity.name)) | ||
@@ -531,3 +487,3 @@ buffer.add(`Object.defineProperty(${singular}, 'is_singular', { value: true })`) | ||
} else { | ||
const isEnumReference = typeof type.type === 'string' && isEnum(this.csn.inferred.definitions[type?.type]) | ||
const isEnumReference = typeof type.type === 'string' && isEnum(this.csn.definitions[type?.type]) | ||
// alias | ||
@@ -534,0 +490,0 @@ file.addType(fq, entityName, this.resolver.resolveAndRequire(type, file).typeName, isEnumReference) |
{ | ||
"name": "@cap-js/cds-typer", | ||
"version": "0.31.0", | ||
"version": "0.32.0", | ||
"description": "Generates .ts files for a CDS model to receive code completion in VS Code", | ||
@@ -45,3 +45,3 @@ "main": "index.js", | ||
"peerDependencies": { | ||
"@cap-js/cds-types": ">=0.6.4", | ||
"@cap-js/cds-types": ">=0.9", | ||
"@sap/cds": ">=8" | ||
@@ -48,0 +48,0 @@ }, |
220996
4339