From ae26dffd5207a54b8130e559c0a86b50b5d9c1ec Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Mon, 11 Dec 2023 13:12:15 +0100 Subject: [PATCH 1/7] Fall back to inferred flavour for view entities --- lib/visitor.js | 3 ++- test/unit/files/views/model.cds | 14 ++++++++++++++ test/unit/output.test.js | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 test/unit/files/views/model.cds diff --git a/lib/visitor.js b/lib/visitor.js index 96fd1f57..56fe6359 100644 --- a/lib/visitor.js +++ b/lib/visitor.js @@ -100,7 +100,8 @@ 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)}`) + this.logger.warning(`Falling back to inferred flavour for unresolved entity: ${name}`) + this.visitEntity(name, this.csn.inferred.definitions[name]) } else { this.visitEntity(name, entity) } diff --git a/test/unit/files/views/model.cds b/test/unit/files/views/model.cds new file mode 100644 index 00000000..061d90b2 --- /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 { + key 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. }) From eaad6167a58a6dd620662eb24ec4bdb9b2a69daf Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Tue, 12 Dec 2023 15:59:06 +0100 Subject: [PATCH 2/7] Only treat entities as view if they have no .projection property --- lib/csn.js | 4 +++- lib/visitor.js | 11 +++++++---- test/ast.js | 1 + test/unit/enum.test.js | 28 +++++++++++++------------- test/unit/files/views/model.cds | 2 +- test/unit/views.test.js | 35 +++++++++++++++++++++++++++++++++ 6 files changed, 61 insertions(+), 20 deletions(-) create mode 100644 test/unit/views.test.js diff --git a/lib/csn.js b/lib/csn.js index 9d1f06cd..358ceec6 100644 --- a/lib/csn.js +++ b/lib/csn.js @@ -226,4 +226,6 @@ function amendCSN(csn) { propagateForeignKeys(csn) } -module.exports = { amendCSN } \ No newline at end of file +const isView = entity => entity.query && !entity.projection + +module.exports = { amendCSN, isView } \ No newline at end of file diff --git a/lib/visitor.js b/lib/visitor.js index 56fe6359..f4e9d363 100644 --- a/lib/visitor.js +++ b/lib/visitor.js @@ -2,7 +2,7 @@ const util = require('./util') -const { amendCSN } = require('./csn') +const { amendCSN, isView } = require('./csn') // eslint-disable-next-line no-unused-vars const { SourceFile, baseDefinitions, Buffer } = require('./file') const { FlatInlineDeclarationResolver, StructuredInlineDeclarationResolver } = require('./components/inline') @@ -100,10 +100,13 @@ class Visitor { visitDefinitions() { for (const [name, entity] of Object.entries(this.csn.xtended.definitions)) { if (entity._unresolved === true) { - this.logger.warning(`Falling back to inferred flavour for unresolved entity: ${name}`) - this.visitEntity(name, this.csn.inferred.definitions[name]) + this.logger.warning(`Skipping unresolved entity: ${name}`) } else { - this.visitEntity(name, entity) + // 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. + this.visitEntity(name, isView(entity) ? this.csn.inferred.definitions[name] : entity) } } // FIXME: optimise 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 index 061d90b2..2287a643 100644 --- a/test/unit/files/views/model.cds +++ b/test/unit/files/views/model.cds @@ -8,7 +8,7 @@ entity Foo { entity FooView as select from Foo { - key id, + id, code, code as alias }; 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) + }) +}) From 2114c5014fc0e9efd7ca77b58c931fcf26f0a919 Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Tue, 12 Dec 2023 16:00:22 +0100 Subject: [PATCH 3/7] Add changelog entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index deb5603a..575763c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ 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 +### Added +- Entities that are database views now also receive typings ## Version 0.13.0 - 2023-12-06 ### Changes From 69392aed1932a5877248f22cf9952e3b7162a5b5 Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Wed, 13 Dec 2023 09:53:13 +0100 Subject: [PATCH 4/7] Lookup _unresolved entities from inferred csn too --- lib/csn.js | 14 +++++++++++++- lib/visitor.js | 15 +++++---------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/lib/csn.js b/lib/csn.js index 358ceec6..fd09677c 100644 --- a/lib/csn.js +++ b/lib/csn.js @@ -226,6 +226,18 @@ function amendCSN(csn) { propagateForeignKeys(csn) } +/** + * 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 -module.exports = { amendCSN, isView } \ No newline at end of file +/** + * @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 f4e9d363..16c05945 100644 --- a/lib/visitor.js +++ b/lib/visitor.js @@ -2,7 +2,7 @@ const util = require('./util') -const { amendCSN, isView } = 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,15 +99,10 @@ class Visitor { */ visitDefinitions() { for (const [name, entity] of Object.entries(this.csn.xtended.definitions)) { - if (entity._unresolved === true) { - this.logger.warning(`Skipping unresolved entity: ${name}`) - } else { - // 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. - this.visitEntity(name, isView(entity) ? this.csn.inferred.definitions[name] : entity) - } + const toVisit = isView(entity) || isUnresolved + ? this.csn.inferred.definitions[name] + : entity + this.visitEntity(name, toVisit) } // FIXME: optimise // We are currently working with two flavours of CSN: From b958423d46e3a303aeaabc8b26a279e088b9ec09 Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Wed, 13 Dec 2023 10:06:33 +0100 Subject: [PATCH 5/7] Revert isUnresolved change --- lib/visitor.js | 9 +++++---- test/unit/references.test.js | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/visitor.js b/lib/visitor.js index 16c05945..c0988cf6 100644 --- a/lib/visitor.js +++ b/lib/visitor.js @@ -99,10 +99,11 @@ class Visitor { */ visitDefinitions() { for (const [name, entity] of Object.entries(this.csn.xtended.definitions)) { - const toVisit = isView(entity) || isUnresolved - ? this.csn.inferred.definitions[name] - : entity - this.visitEntity(name, toVisit) + if (isUnresolved(entity)) { + this.logger.warning(`Skipping unresolved entity: ${name}`) + } else { + this.visitEntity(name, entity._unresolved === true || isView(entity) ? this.csn.inferred.definitions[name] : entity) + } } // FIXME: optimise // We are currently working with two flavours of CSN: 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', () => { From cf61cdf1e4d182610eff4d25f51b286cc8f47950 Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Wed, 13 Dec 2023 10:09:16 +0100 Subject: [PATCH 6/7] Visit unresolved entities that are also views --- lib/visitor.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/visitor.js b/lib/visitor.js index c0988cf6..4f7dea78 100644 --- a/lib/visitor.js +++ b/lib/visitor.js @@ -99,10 +99,12 @@ class Visitor { */ visitDefinitions() { for (const [name, entity] of Object.entries(this.csn.xtended.definitions)) { - if (isUnresolved(entity)) { - this.logger.warning(`Skipping unresolved entity: ${name}`) + if (isView(entity)) { + this.visitEntity(name, this.csn.inferred.definitions[name]) + } else if (!isUnresolved(entity)) { + this.visitEntity(name, entity) } else { - this.visitEntity(name, entity._unresolved === true || isView(entity) ? this.csn.inferred.definitions[name] : entity) + this.logger.warning(`Skipping unresolved entity: ${name}`) } } // FIXME: optimise From 12e2232679e1082d94e9149cff08dfb7ca21bdfa Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Wed, 13 Dec 2023 11:15:01 +0100 Subject: [PATCH 7/7] Bump version number --- CHANGELOG.md | 4 +++- package.json | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 575763c8..8f2c95dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ 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 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",