diff --git a/CHANGELOG.md b/CHANGELOG.md index deb5603a..8f2c95dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,11 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). The format is based on [Keep a Changelog](http://keepachangelog.com/). -## Version 0.14.0 - TBD +## Version 0.15.0 - TBD + +## Version 0.14.0 - 2023-12-13 +### Added +- Entities that are database views now also receive typings ## Version 0.13.0 - 2023-12-06 ### Changes diff --git a/lib/csn.js b/lib/csn.js index 9d1f06cd..fd09677c 100644 --- a/lib/csn.js +++ b/lib/csn.js @@ -226,4 +226,18 @@ function amendCSN(csn) { propagateForeignKeys(csn) } -module.exports = { amendCSN } \ No newline at end of file +/** + * FIXME: this is pretty handwavey: we are looking for view-entities, + * i.e. ones that have a query, but are not a cds level projection. + * Those are still not expanded and we have to retrieve their definition + * with all properties from the inferred model. + */ +const isView = entity => entity.query && !entity.projection + +/** + * @see isView + * Unresolved entities have to be looked up from inferred csn. + */ +const isUnresolved = entity => entity._unresolved === true + +module.exports = { amendCSN, isView, isUnresolved } \ No newline at end of file diff --git a/lib/visitor.js b/lib/visitor.js index 96fd1f57..4f7dea78 100644 --- a/lib/visitor.js +++ b/lib/visitor.js @@ -2,7 +2,7 @@ const util = require('./util') -const { amendCSN } = require('./csn') +const { amendCSN, isView, isUnresolved } = require('./csn') // eslint-disable-next-line no-unused-vars const { SourceFile, baseDefinitions, Buffer } = require('./file') const { FlatInlineDeclarationResolver, StructuredInlineDeclarationResolver } = require('./components/inline') @@ -99,10 +99,12 @@ class Visitor { */ visitDefinitions() { for (const [name, entity] of Object.entries(this.csn.xtended.definitions)) { - if (entity._unresolved === true) { - this.logger.error(`Skipping unresolved entity: ${JSON.stringify(entity)}`) - } else { + if (isView(entity)) { + this.visitEntity(name, this.csn.inferred.definitions[name]) + } else if (!isUnresolved(entity)) { this.visitEntity(name, entity) + } else { + this.logger.warning(`Skipping unresolved entity: ${name}`) } } // FIXME: optimise diff --git a/package.json b/package.json index b5aa4619..d095fcc2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cap-js/cds-typer", - "version": "0.13.0", + "version": "0.14.0", "description": "Generates .ts files for a CDS model to receive code completion in VS Code", "main": "index.js", "repository": "github:cap-js/cds-typer", diff --git a/test/ast.js b/test/ast.js index 4261da37..89f23228 100644 --- a/test/ast.js +++ b/test/ast.js @@ -410,6 +410,7 @@ const checkKeyword = (node, expected) => node?.keyword === expected const check = { isString: node => checkKeyword(node, 'string'), isNumber: node => checkKeyword(node, 'number'), + isBoolean: node => checkKeyword(node, 'boolean'), isAny: node => checkKeyword(node, 'any'), isStatic: node => checkKeyword(node, 'static'), isIndexedAccessType: node => checkKeyword(node, 'indexedaccesstype'), diff --git a/test/unit/enum.test.js b/test/unit/enum.test.js index db7c2ea0..cce3c131 100644 --- a/test/unit/enum.test.js +++ b/test/unit/enum.test.js @@ -11,26 +11,26 @@ const dir = locations.testOutput('enums_test') // FIXME: missing: inline enums (entity Foo { bar: String enum { ... }}) describe('Enum Types', () => { - let ast + let astw beforeEach(async () => await fs.unlink(dir).catch(() => {})) beforeAll(async () => { const paths = await cds2ts .compileFromFile(locations.unit.files('enums/model.cds'), { outputDirectory: dir, inlineDeclarations: 'structured' }) - ast = new ASTWrapper(path.join(paths[1], 'index.ts')) + astw = new ASTWrapper(path.join(paths[1], 'index.ts')) }) describe('Anonymous', () => { describe('String Enum', () => { test('Definition Present', async () => - expect(ast.tree.find(n => n.name === 'InlineEnum_gender' + expect(astw.tree.find(n => n.name === 'InlineEnum_gender' && n.initializer.expression.female === 'female' && n.initializer.expression.male === 'male' && n.initializer.expression.non_binary === 'non-binary')) .toBeTruthy()) test('Referring Property', async () => - expect(ast.getAspects().find(({name, members}) => name === '_InlineEnumAspect' + expect(astw.getAspects().find(({name, members}) => name === '_InlineEnumAspect' && members?.find(member => member.name === 'gender' && check.isNullable(member.type, [t => t?.full === 'InlineEnum_gender'])))) .toBeTruthy()) @@ -38,7 +38,7 @@ describe('Enum Types', () => { describe('Int Enum', () => { test('Definition Present', async () => - expect(ast.tree.find(n => n.name === 'InlineEnum_status' + expect(astw.tree.find(n => n.name === 'InlineEnum_status' && n.initializer.expression.submitted === 1 && n.initializer.expression.fulfilled === 2 && n.initializer.expression.canceled === -1 @@ -46,14 +46,14 @@ describe('Enum Types', () => { .toBeTruthy()) test('Referring Property', async () => - expect(ast.getAspects().find(({name, members}) => name === '_InlineEnumAspect' + expect(astw.getAspects().find(({name, members}) => name === '_InlineEnumAspect' && members?.find(member => member.name === 'status' && check.isNullable(member.type, [t => t?.full === 'InlineEnum_status'])))) .toBeTruthy()) }) describe('Mixed Enum', () => { test('Definition Present', async () => - expect(ast.tree.find(n => n.name === 'InlineEnum_yesno' + expect(astw.tree.find(n => n.name === 'InlineEnum_yesno' && n.initializer.expression.catchall === 42 && n.initializer.expression.no === false && n.initializer.expression.yes === true @@ -61,7 +61,7 @@ describe('Enum Types', () => { .toBeTruthy()) test('Referring Property', async () => - expect(ast.getAspects().find(({name, members}) => name === '_InlineEnumAspect' + expect(astw.getAspects().find(({name, members}) => name === '_InlineEnumAspect' && members?.find(member => member.name === 'yesno' && check.isNullable(member.type, [t => t?.full === 'InlineEnum_yesno'])))) .toBeTruthy()) }) @@ -70,42 +70,42 @@ describe('Enum Types', () => { describe('Named', () => { describe('String Enum', () => { test('Values', async () => - expect(ast.tree.find(n => n.name === 'Gender' + expect(astw.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' + expect(astw.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' + expect(astw.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' + expect(astw.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' + astw.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' + expect(astw.getTypeAliasDeclarations().find(n => n.name === 'Truthy' && [true, false, 42].every(t => n.types.includes(t)))) .toBeTruthy()) }) diff --git a/test/unit/files/views/model.cds b/test/unit/files/views/model.cds new file mode 100644 index 00000000..2287a643 --- /dev/null +++ b/test/unit/files/views/model.cds @@ -0,0 +1,14 @@ +namespace views_test; + +entity Foo { + id: Integer; + code: String; + flag: Boolean; +} + +entity FooView as + select from Foo { + id, + code, + code as alias + }; diff --git a/test/unit/output.test.js b/test/unit/output.test.js index a0ac7929..1b780d6f 100644 --- a/test/unit/output.test.js +++ b/test/unit/output.test.js @@ -188,7 +188,7 @@ describe('Compilation', () => { ]) // ...are currently exceptions where both singular _and_ plural // are annotated and the original name is used as an export on top of that. - // So _three_ exports per entity. If we every choose to remove this third one, + // So _three_ exports per entity. If we ever choose to remove this third one, // then this test has to reflect that. }) diff --git a/test/unit/references.test.js b/test/unit/references.test.js index 298cb748..5695a9d9 100644 --- a/test/unit/references.test.js +++ b/test/unit/references.test.js @@ -6,7 +6,7 @@ const cds2ts = require('../../lib/compile') const { ASTWrapper, check } = require('../ast') const { locations } = require('../util') -const dir = locations.unit.files('output/references') +const dir = locations.testOutput('output/references') // compilation produces semantically complete Typescript describe('References', () => { diff --git a/test/unit/views.test.js b/test/unit/views.test.js new file mode 100644 index 00000000..187299de --- /dev/null +++ b/test/unit/views.test.js @@ -0,0 +1,35 @@ +'use strict' + +const fs = require('fs').promises +const path = require('path') +const cds2ts = require('../../lib/compile') +const { ASTWrapper, check } = require('../ast') +const { locations } = require('../util') + +const dir = locations.testOutput('views_test') + +describe('View Entities', () => { + let astw + + beforeEach(async () => await fs.unlink(dir).catch(() => {})) + beforeAll(async () => { + const paths = await cds2ts + .compileFromFile(locations.unit.files('views/model.cds'), { outputDirectory: dir, inlineDeclarations: 'structured' }) + astw = new ASTWrapper(path.join(paths[1], 'index.ts')) + }) + + test('View Entity Present', () => { + astw.exists('_FooViewAspect') + }) + + test('Expected Properties Present', () => { + astw.exists('_FooViewAspect', 'id', ({type}) => check.isNullable(type, [check.isNumber])) + astw.exists('_FooViewAspect', 'code', ({type}) => check.isNullable(type, [check.isString])) + // including alias + astw.exists('_FooViewAspect', 'alias', ({type}) => check.isNullable(type, [check.isString])) + }) + + test('Unselected Field Not Present', () => { + expect(() => astw.exists('_FooViewAspect', 'flag', ({type}) => check.isNullable(type, [check.isString]))).toThrow(Error) + }) +})