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 a9f8584..b8fc093 100644 --- a/lib/cds-test.js +++ b/lib/cds-test.js @@ -24,15 +24,21 @@ 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 ()=>{ + 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) - this.server = server - this.url = url - }) + self.server = server + self.url = url + }, 30000) // gracefully shutdown cds server... after (async ()=>{ @@ -208,8 +214,8 @@ let _expect = undefined } else if (is_jest) { // it's jest - global.before = (msg,fn) => { return global.beforeAll(fn||msg) } - global.after = (msg,fn) => global.afterAll(fn||msg) + global.before = (msg,fn,time) => global.beforeAll(typeof fn === 'function' ? fn : msg,time||fn) + global.after = (msg,fn,time) => global.afterAll(typeof fn === 'function' ? fn : msg,time||fn) } else { // it's node --test @@ -217,7 +223,8 @@ let _expect = undefined describe.each = test.each = describe.skip.each = test.skip.each = each global.describe = describe; global.xdescribe = describe.skip global.test = global.it = test; global.xtest = test.skip - global.beforeAll = global.before = (msg,fn=msg) => { + global.beforeAll = global.before = (msg,fn) => { + if (typeof msg === 'function') fn = msg if (fn.length > 0) { const f = fn; fn = (_,done)=>f(done) } return before(fn) // doesn't work for some reason } diff --git a/lib/java-hcql.js b/lib/java-hcql.js new file mode 100644 index 0000000..9cf8251 --- /dev/null +++ b/lib/java-hcql.js @@ -0,0 +1,42 @@ +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.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) + }) + } + 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..4c80ba1 --- /dev/null +++ b/lib/java.js @@ -0,0 +1,82 @@ +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 enviroment with application linked model + cds.model = cds.linked(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 + 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 enviroment with application linked model + cds.model = cds.linked(await cds.load([...from, path.resolve(srv, 'db.cds')])) + } + + let res, rej + const ready = new Promise((resolve, reject) => { + res = resolve + rej = reject + }) + + const p = await port() + const url = `http://localhost:${p}` + const app = 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 = () => Promise.all([ + app.kill(), + fs.rm(path.resolve(srv, 'db.cds')), + fs.rm(path.resolve(srv, 'db.json')), + ]) + + // 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() + + 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..1b2c7e6 --- /dev/null +++ b/test/app/pom.xml @@ -0,0 +1,136 @@ + + + 4.0.0 + + customer + app-parent + ${revision} + pom + + app parent + + + + 1.0.0-SNAPSHOT + + + 21 + 3.2.0 + 3.3.3 + 8.1.2 + + 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 + + + + + + + + + maven-compiler-plugin + 3.13.0 + + ${jdk.version} + UTF-8 + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + true + + + + + + maven-surefire-plugin + 3.3.1 + + + + + org.codehaus.mojo + flatten-maven-plugin + 1.6.0 + + true + resolveCiFriendliesOnly + + + + flatten + process-resources + + flatten + + + + flatten.clean + clean + + clean + + + + + + + + maven-enforcer-plugin + 3.5.0 + + + 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..3b01dd0 --- /dev/null +++ b/test/app/srv/pom.xml @@ -0,0 +1,125 @@ + + 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.services.version} + + + cds.clean + + clean + + + + + cds.resolve + + resolve + + + + + cds.build + + cds + + + + build --for java + deploy --to h2 --dry > "${project.basedir}/src/main/resources/schema-h2.sql" + + + + + + cds.generate + + generate + + + cds.gen + + + + + + + + \ 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..91bf0f3 --- /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.schema-locations: classpath:schema-h2.sql +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 68c629e..821ebde 100644 --- a/test/app/test/sample-bookshop.test.js +++ b/test/app/test/sample-bookshop.test.js @@ -2,11 +2,17 @@ const cds_test = require ('../../..') const describe = global.describe ?? require('node:test').describe 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` + 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