diff --git a/.gitignore b/.gitignore index 2359260..6af6f9b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,10 @@ gen/ *.sqlite-shm *.sqlite-wal .vscode/ + +**/gen/ +**/edmx/ +**/target/ +.flattened-pom.xml +schema*.sql +*.log* diff --git a/lib/cds-test.js b/lib/cds-test.js index 582fed2..8f82784 100644 --- a/lib/cds-test.js +++ b/lib/cds-test.js @@ -24,14 +24,20 @@ class Test extends require('./axios') { default: this.in(folder_or_cmd); args.push ('--in-memory?') } const {cds} = this + const self = this // launch cds server... - before (async ()=>{ - process.env.cds_test_temp = cds.utils.path.resolve (cds.root,'_out',''+process.pid) - if (!args.includes('--port')) args.push ('--port', '0') - let { server, url } = await cds.exec (...args) - this.server = server - this.url = url + before (async function () { + process.env.cds_test_temp = cds.utils.path.resolve(cds.root,'_out',''+process.pid) + if (!args.includes('--port')) args.push('--port', '0') + if (cds.env.profiles.indexOf('java') > -1 && cds.env.profiles.indexOf('node') < 0) { + this.timeout?.(30000) + cds.exec + cds.exec = require('./java').bind(self) + } + let { server, url } = await cds.exec(...args) + self.server = server + self.url = url }) // gracefully shutdown cds server... diff --git a/lib/java-hcql.js b/lib/java-hcql.js new file mode 100644 index 0000000..5166d5f --- /dev/null +++ b/lib/java-hcql.js @@ -0,0 +1,43 @@ +const cds = require('@sap/cds') + +const InsertResults = require('@cap-js/db-service/lib/InsertResults') + +module.exports = class extends cds.Service { + init() { + this.on('*', async req => { + const { axios } = this.options + + // REVISIT: make draft and text direct access work + if ( + req.target.isDraft || + req.target.name === 'DRAFT.DraftAdministrativeData' || + req.target.includes?.includes('sap.common.TextsAspect') + ) return [] + + if (req.query.INSERT?.rows) { + req.query.INSERT.entries = req.query.INSERT?.rows + .map(r => req.query.INSERT.columns.reduce((l, c, i) => { + l[c] = r[i] + return l + }, {})) + req.query.INSERT.rows = undefined + } + + const service = cds.model.services[req.path.split('.')[0]] + if (!service) { + const sub = req.query[req.query.kind] + const ref = sub.from || sub.into || sub.entity + if (!ref) { debugger } + if (ref.ref[0].id) ref.ref[0].id = 'db.' + ref.ref[0].id + else ref.ref[0] = 'db.' + ref.ref[0] + } + const res = await axios.post('/hcql/' + (service?.['@path'] ?? 'db'), req.query, { headers: { 'content-type': 'application/json' } }) + + // Convert HCQL result format to @cap-js/db-service compliant results + if (req.query.SELECT) return req.query.SELECT?.one ? res.data.data[0] : res.data.data + if (req.query.INSERT) return new InsertResults(req.query, res.data.data) + return res.data.rowCounts?.reduce((l, c) => l + c) ?? res.data.data + }) + } + url4() { return 'Java Proxy' } +} \ No newline at end of file diff --git a/lib/java.js b/lib/java.js new file mode 100644 index 0000000..6d15e74 --- /dev/null +++ b/lib/java.js @@ -0,0 +1,86 @@ +const childProcess = require('child_process') +const { setTimeout } = require('node:timers/promises') + +module.exports = async function java(...args) { + const { cds } = this + const { fs: { promises: fs }, path } = cds.utils + const srv = path.resolve(cds.root, cds.env.folders.srv) + + // forces java to respond @odata.context and @odata.count just like the node runtime + this.axios.defaults.headers.common['Odata-Version'] = '4.0' + + cds.env.requires.db = { impl: require.resolve('./java-hcql.js'), axios: this.axios } + + const [_, options] = require('@sap/cds/bin/args')(require('@sap/cds/bin/serve'), args) + + // load application model + const from = [...(options.from?.split(',') ?? ['*'])] + const model = await cds.load(from) + if (model.definitions.db) { + // link test environment with application linked model + cds.model = model + } else { + // enhance java model with database hcql service + const db = { ...model, definitions: { db: { kind: 'service', '@path': 'db', '@protocol': ['hcql'], '@requires': 'any' } } } + const services = [] + for (const name in model.definitions) { + const def = model.definitions[name] + if (def.kind === 'service') services.push(name) + if (def.kind !== 'entity') continue + if (services.find(s => name.startsWith(s))) continue + if (name.endsWith('.transitions_')) continue + db.definitions['db.' + name] = { "kind": "entity", "projection": { "from": { "ref": [name] } } } + } + await Promise.all([ + fs.writeFile(path.resolve(srv, 'db.cds'), `using from './db.json';`), + fs.writeFile(path.resolve(srv, 'db.json'), JSON.stringify(db)), + ]) + + // link test environment with application linked model + cds.model = await cds.load([...from, path.resolve(srv, 'db.cds')]) + } + + cds.model = cds.linked(cds.compile.for.java(cds.model)) + cds.entities + + let res, rej + const ready = new Promise((resolve, reject) => { + res = resolve + rej = reject + }) + + const p = await port() + const url = `http://localhost:${p}` + const jarFile = path.resolve(cds.root, cds.env.folders.srv, 'target/app-exec.jar') + const jarFileExists = await fs.access(jarFile).then(() => true, () => false) + const app = jarFileExists + ? childProcess.spawn('java', [`-jar`, jarFile, `--server.port=${p}`], { cwd: cds.root, stdio: 'inherit', env: process.env }) + : childProcess.spawn('mvn', ['spring-boot:run', `-Dspring-boot.run.arguments=--server.port=${p}`], { cwd: cds.root, stdio: 'inherit', env: process.env }) + app.on('error', rej) + app.on('exit', () => rej(new Error('Application failed to start.'))) + cds.shutdown = () => app.kill() + + // REVISIT: make it call an actual /health check + // ping the server until it responds + const ping = () => cds.test.axios.get(url).catch(() => ping()) + ping().then(res) + await ready + + // connect to primary database hcql proxy service + await cds.connect.to('db') + + return { server: { address: () => { return p } }, url } +} + +function port() { + return new Promise((resolve, reject) => { + const net = require('net') + const server = net.createServer() + server.on('error', reject) + + server.listen(() => { + const { port } = server.address() + server.close(() => resolve(port)) + }) + }) +} \ No newline at end of file diff --git a/test/app/pom.xml b/test/app/pom.xml new file mode 100644 index 0000000..3f73e56 --- /dev/null +++ b/test/app/pom.xml @@ -0,0 +1,146 @@ + + + 4.0.0 + + customer + app-parent + ${revision} + pom + + app parent + + + + 1.0.0-SNAPSHOT + + + 21 + 4.6.2 + 3.5.8 + + https://nodejs.org/dist/ + UTF-8 + + + + srv + + + + + + + com.sap.cds + cds-services-bom + ${cds.services.version} + pom + import + + + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + + + + + + + + + com.sap.cds + cds-maven-plugin + ${cds.services.version} + + + + + + + + maven-compiler-plugin + 3.14.1 + + ${jdk.version} + UTF-8 + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + true + + + + + + maven-surefire-plugin + 3.5.4 + + + + + org.codehaus.mojo + flatten-maven-plugin + 1.7.3 + + true + resolveCiFriendliesOnly + + + + flatten + process-resources + + flatten + + + + flatten.clean + clean + + clean + + + + + + + + maven-enforcer-plugin + 3.6.2 + + + Project Structure Checks + + enforce + + + + + 3.6.3 + + + ${jdk.version} + + + + true + + + + + + + diff --git a/test/app/srv/admin-service.cds b/test/app/srv/admin-service.cds index 6a56af6..0adfe86 100644 --- a/test/app/srv/admin-service.cds +++ b/test/app/srv/admin-service.cds @@ -1,6 +1,6 @@ using { sap.capire.bookshop as my } from '../db/schema'; -@path: '/admin' +@path: 'admin' service AdminService { entity Books as projection on my.Books; entity Authors as projection on my.Authors; diff --git a/test/app/srv/cat-service.cds b/test/app/srv/cat-service.cds index bd493e1..49d5647 100644 --- a/test/app/srv/cat-service.cds +++ b/test/app/srv/cat-service.cds @@ -1,4 +1,6 @@ using { sap.capire.bookshop as my } from '../db/schema'; + +@path: 'catalog' service CatalogService { /** For displaying lists of Books */ diff --git a/test/app/srv/draft-service.cds b/test/app/srv/draft-service.cds index 92b0fea..7a7f049 100644 --- a/test/app/srv/draft-service.cds +++ b/test/app/srv/draft-service.cds @@ -1,6 +1,6 @@ using { sap.capire.bookshop as my } from '../db/schema'; -@path: '/draft' +@path: 'draft' service DraftService { @odata.draft.enabled entity Books as projection on my.Books; diff --git a/test/app/srv/pom.xml b/test/app/srv/pom.xml new file mode 100644 index 0000000..8e0cf7d --- /dev/null +++ b/test/app/srv/pom.xml @@ -0,0 +1,128 @@ + + 4.0.0 + + + app-parent + customer + ${revision} + + + app + jar + + app + + + + + + com.sap.cds + cds-starter-spring-boot + + + + org.springframework.boot + spring-boot-devtools + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + com.sap.cds + cds-adapter-odata-v4 + runtime + + + + com.sap.cds + cds-adapter-hcql + runtime + + + + com.h2database + h2 + runtime + + + + + ${project.artifactId} + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + false + + + + repackage + + repackage + + + exec + + + + + + + + com.sap.cds + cds-maven-plugin + + + cds.clean + + clean + + + + + cds.resolve + + resolve + + + + + cds.build + + cds + + + + build --for java + deploy --to h2 --with-mocks --dry --out "${project.basedir}/src/main/resources/schema-h2.sql" + + + + + + cds.generate + + generate + + + cds.gen + true + true + true + true + + + + + + + + \ No newline at end of file diff --git a/test/app/srv/src/main/java/customer/app/Application.java b/test/app/srv/src/main/java/customer/app/Application.java new file mode 100644 index 0000000..37cd623 --- /dev/null +++ b/test/app/srv/src/main/java/customer/app/Application.java @@ -0,0 +1,13 @@ +package customer.app; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + +} diff --git a/test/app/srv/src/main/resources/application.yaml b/test/app/srv/src/main/resources/application.yaml new file mode 100644 index 0000000..5da61c9 --- /dev/null +++ b/test/app/srv/src/main/resources/application.yaml @@ -0,0 +1,45 @@ +cds: + security: + # Match mock users with default node mock users + mock: + users: + - name: alice + tenant: t1 + roles: + - admin + - name: bob + tenant: t1 + roles: + - builder + - name: carol + tenant: t1 + roles: + - admin + - builder + - name: dave + tenant: t1 + features: + roles: + - admin + - name: erin + tenant: t2 + roles: + - admin + - builder + - name: fred + tenant: t2 + features: + - isbn + - name: me + tenant: t1 + features: + - "*" + - name: yves + roles: + - internal-user +--- +spring: + config.activate.on-profile: default + sql.init.platform: h2 +cds: + data-source.auto-config.enabled: false diff --git a/test/app/test/sample-bookshop.test.js b/test/app/test/sample-bookshop.test.js index 491013a..8f23532 100644 --- a/test/app/test/sample-bookshop.test.js +++ b/test/app/test/sample-bookshop.test.js @@ -1,11 +1,17 @@ const cds_test = require('../../../lib/cds-test') describe('Sample tests', () => { - const { GET, expect } = cds_test(__dirname+'/..') + const { GET, expect, cds } = cds_test (__dirname+'/..') it('serves Books', async () => { const { data } = await GET`/odata/v4/catalog/Books` expect(data.value.length).to.be.greaterThanOrEqual(5) }) + it('database Books', async () => { + const { Books } = cds.entities('sap.capire.bookshop') + const data = await cds.ql`SELECT ID FROM ${Books}` + expect(data.length).to.be.greaterThanOrEqual(5) + }) + }) \ No newline at end of file