diff --git a/postgres/Dockerfile b/postgres/Dockerfile new file mode 100644 index 000000000..2f252940f --- /dev/null +++ b/postgres/Dockerfile @@ -0,0 +1,3 @@ +FROM postgres:16-bullseye + +RUN apt-get update && apt-get -y install postgresql-16-http diff --git a/postgres/lib/PostgresService.js b/postgres/lib/PostgresService.js index d35feb3ee..b1ac25f57 100644 --- a/postgres/lib/PostgresService.js +++ b/postgres/lib/PostgresService.js @@ -622,6 +622,7 @@ GROUP BY k DROP USER IF EXISTS "${creds.user}"; CREATE GROUP "${creds.usergroup}"; CREATE USER "${creds.user}" WITH CREATEROLE IN GROUP "${creds.usergroup}" PASSWORD '${creds.user}'; + ALTER USER "${creds.user}" SUPERUSER; GRANT "${creds.usergroup}" TO "${creds.user}" WITH ADMIN OPTION; `) await this.exec(`CREATE DATABASE "${creds.database}" OWNER="${creds.user}" TEMPLATE=template0`) diff --git a/postgres/pg-stack.yml b/postgres/pg-stack.yml index c88c308b9..e12b275f3 100644 --- a/postgres/pg-stack.yml +++ b/postgres/pg-stack.yml @@ -3,13 +3,13 @@ version: '3.1' services: db: - image: postgres:16-alpine + build: . restart: always environment: POSTGRES_PASSWORD: postgres ports: - '5432:5432' - command: ['postgres', '-c', 'log_statement=all'] + command: postgres ### use at will at dev time - save mem on ci time # adminer: # image: adminer diff --git a/postgres/test/integration.cds b/postgres/test/integration.cds new file mode 100644 index 000000000..07f0ffe49 --- /dev/null +++ b/postgres/test/integration.cds @@ -0,0 +1,6 @@ +using {CatalogService} from '../../test/bookshop/apis/CatalogService'; + +service integration { + entity Genres as projection on CatalogService.Genres; + entity Books as projection on CatalogService.ListOfBooks; +} diff --git a/postgres/test/integration.test.js b/postgres/test/integration.test.js new file mode 100644 index 000000000..5bed73c8d --- /dev/null +++ b/postgres/test/integration.test.js @@ -0,0 +1,86 @@ +const cds = require('../../test/cds.js') + +describe('Data Integration', () => { + before(() => { + cds.env.features.ieee754compatible = true + }) + + const _deploy = cds.deploy + cds.deploy = async function () { + const sys = await cds.connect.to('sys', { + ...cds.requires.db, + credentials: { + ...cds.db.options.credentials, + user: `${cds.db.options.credentials.database}_USER_MANAGER`, + password: `${cds.db.options.credentials.database}_USER_MANAGER`, + }, + }) + await sys.run(`CREATE EXTENSION IF NOT EXISTS http SCHEMA public`) + + const db = await cds.connect.to('db') + + const convertInput = cds.db.class.CQN2SQL._convertInput ?? new cds.db.class.CQN2SQL().class._convertInput + + for (const name in cds.model.definitions) { + const entity = cds.model.definitions[name] + if (!entity.__REMOTE__) continue + + const columns = [] + function add(element, name = element.name) { + if (element.isAssociation) { + for (const key of element.keys || []) { + if (key.ref.length > 1) { cds.error`Association with deep foreign key currently not supported!!!` } + if (!entity.elements[`${name}_${key.ref[0]}`]) add(element._target.elements[key.ref[0]], `${name}_${key.ref[0]}`) + } + } else { + const converter = element[convertInput] || (a => a) + columns.push(`${converter(`value->>'${name}'`)} as ${name}`) + } + } + + for (const name in entity.elements) add(entity.elements[name]) + + const from = `jsonb_array_elements((SELECT (content::jsonb->>'value')::jsonb FROM public.http_get('http://host.docker.internal:4004/browse/${name.split('.').at(-1)}?src=postgres') LIMIT 1))` + + await db.run(`CREATE VIEW ${entity} AS SELECT ${columns} FROM ${from}`) + } + + return _deploy.apply(this, arguments) + } + + cds.on('loaded', (csn) => { + const remotes = [] + for (const name in csn.definitions) { + const service = csn.definitions[name] + if (service.kind !== 'service') continue + if (service['@data.product']) remotes.push(name) + } + + for (const name in csn.definitions) { + const entity = csn.definitions[name] + if (entity.kind !== 'entity') continue + const service = remotes.find(srv => name.startsWith(srv)) + if (!service) continue + entity.__REMOTE__ = true + entity['@cds.persistence.exists'] = true + } + }) + + const { expect, GET } = cds.test(__dirname, 'integration.cds') + + test('debug', async () => { + const [db, org] = await Promise.all([ + GET`/odata/v4/integration/Books`, + GET`http://localhost:4004/browse/ListOfBooks?src=test`, + ]) + expect(db.data.value).deep.eq(org.data.value) + }) + + test('expand', async () => { + const [db, org] = await Promise.all([ + GET`/odata/v4/integration/Books?$expand=genre`, + GET`http://localhost:4004/browse/ListOfBooks?$expand=genre&src=test`, + ]) + expect(db.data.value).deep.eq(org.data.value) + }) +}) \ No newline at end of file diff --git a/sqlite/lib/SQLiteService.js b/sqlite/lib/SQLiteService.js index 90d966101..32c629cc4 100644 --- a/sqlite/lib/SQLiteService.js +++ b/sqlite/lib/SQLiteService.js @@ -1,3 +1,5 @@ +const child_process = require('node:child_process') + const { SQLService } = require('@cap-js/db-service') const cds = require('@sap/cds') const sqlite = require('better-sqlite3') @@ -41,6 +43,13 @@ class SQLiteService extends SQLService { dbc.function('hour', deterministic, d => d === null ? null : toDate(d, true).getUTCHours()) dbc.function('minute', deterministic, d => d === null ? null : toDate(d, true).getUTCMinutes()) dbc.function('second', deterministic, d => d === null ? null : toDate(d, true).getUTCSeconds()) + + let http_get = url => child_process.execSync(`node -e "fetch('${url}').then(r => r.body.pipeTo(require('node:stream').Writable.toWeb(process.stdout)))"`) + try { + child_process.execSync(`curl --help`) + http_get = url => child_process.execSync(`curl "${url}"`, { stdio: ['ignore', 'pipe', 'ignore'] }) + } catch { } + dbc.function('http_get', deterministic, http_get) if (!dbc.memory) dbc.pragma('journal_mode = WAL') return dbc }, diff --git a/sqlite/test/integration.cds b/sqlite/test/integration.cds new file mode 100644 index 000000000..07f0ffe49 --- /dev/null +++ b/sqlite/test/integration.cds @@ -0,0 +1,6 @@ +using {CatalogService} from '../../test/bookshop/apis/CatalogService'; + +service integration { + entity Genres as projection on CatalogService.Genres; + entity Books as projection on CatalogService.ListOfBooks; +} diff --git a/sqlite/test/integration.test.js b/sqlite/test/integration.test.js new file mode 100644 index 000000000..c1b0280b1 --- /dev/null +++ b/sqlite/test/integration.test.js @@ -0,0 +1,76 @@ +const cds = require('../../test/cds.js') + +describe('Data Integration', () => { + before(() => { + cds.env.features.ieee754compatible = true + }) + + const _deploy = cds.deploy + cds.deploy = async function () { + const db = await cds.connect.to('db') + + const convertInput = cds.db.class.CQN2SQL._convertInput ?? new cds.db.class.CQN2SQL().class._convertInput + + for (const name in cds.model.definitions) { + const entity = cds.model.definitions[name] + if (!entity.__REMOTE__) continue + + const columns = [] + function add(element, name = element.name) { + if (element.isAssociation) { + for (const key of element.keys || []) { + if (key.ref.length > 1) { cds.error`Association with deep foreign key currently not supported!!!` } + if (!entity.elements[`${name}_${key.ref[0]}`]) add(element._target.elements[key.ref[0]], `${name}_${key.ref[0]}`) + } + } else { + const converter = element[convertInput] || (a => a) + columns.push(`${converter(`value->>'$.${JSON.stringify(name)}'`)} as ${name}`) + } + } + + for (const name in entity.elements) add(entity.elements[name]) + + const from = `json_each(http_get('http://localhost:4004/browse/${name.split('.').at(-1)}?src=sqlite')->>'$.value')` + + await db.run(`CREATE VIEW ${entity} AS SELECT ${columns} FROM ${from}`) + } + + return _deploy.apply(this, arguments) + } + + cds.on('loaded', (csn) => { + const remotes = [] + for (const name in csn.definitions) { + const service = csn.definitions[name] + if (service.kind !== 'service') continue + if (service['@data.product']) remotes.push(name) + } + + for (const name in csn.definitions) { + const entity = csn.definitions[name] + if (entity.kind !== 'entity') continue + const service = remotes.find(srv => name.startsWith(srv)) + if (!service) continue + entity.__REMOTE__ = true + entity['@cds.persistence.exists'] = true + } + }) + + const { expect, GET } = cds.test(__dirname, 'integration.cds') + + test('simple', async () => { + const [db, org] = await Promise.all([ + GET`/odata/v4/integration/Books`, + GET`http://localhost:4004/browse/ListOfBooks?src=test`, + ]) + expect(db.data.value).deep.eq(org.data.value) + }) + + test('expand', async () => { + const [db, org] = await Promise.all([ + GET`/odata/v4/integration/Books?$expand=genre`, + GET`http://localhost:4004/browse/ListOfBooks?$expand=genre&src=test`, + ]) + expect(db.data.value).deep.eq(org.data.value) + }) +}) \ No newline at end of file diff --git a/test/bookshop/apis/CatalogService/cds-plugin.js b/test/bookshop/apis/CatalogService/cds-plugin.js new file mode 100644 index 000000000..50e1fa4f3 --- /dev/null +++ b/test/bookshop/apis/CatalogService/cds-plugin.js @@ -0,0 +1 @@ +// just a tag file for plug & play \ No newline at end of file diff --git a/test/bookshop/apis/CatalogService/index.cds b/test/bookshop/apis/CatalogService/index.cds new file mode 100644 index 000000000..98370f8fd --- /dev/null +++ b/test/bookshop/apis/CatalogService/index.cds @@ -0,0 +1,3 @@ +// This file acts as a central facade to exported service definitions. +// You can modify it to tweak things, without your changes being overridden. +using from './services'; \ No newline at end of file diff --git a/test/bookshop/apis/CatalogService/package.json b/test/bookshop/apis/CatalogService/package.json new file mode 100644 index 000000000..8c0617ab8 --- /dev/null +++ b/test/bookshop/apis/CatalogService/package.json @@ -0,0 +1,9 @@ +{ + "name": "@capire/bookshop-CatalogService", + "version": "1.0.0", + "cds": { + "requires": { + "CatalogService": true + } + } +} diff --git a/test/bookshop/apis/CatalogService/services.csn b/test/bookshop/apis/CatalogService/services.csn new file mode 100644 index 000000000..7d35ded0a --- /dev/null +++ b/test/bookshop/apis/CatalogService/services.csn @@ -0,0 +1,289 @@ +{ + "definitions": { + "CatalogService": { + "@source": "srv/cat-service.cds", + "kind": "service", + "@path": "/browse", + "@data.product": true, + "@cds.external": 2 + }, + "CatalogService.ListOfBooks": { + "kind": "entity", + "@readonly": true, + "elements": { + "createdAt": { + "@cds.on.insert": { + "=": "$now" + }, + "@Core.Immutable": true, + "@title": "{i18n>CreatedAt}", + "@readonly": true, + "type": "cds.Timestamp" + }, + "modifiedAt": { + "@cds.on.insert": { + "=": "$now" + }, + "@cds.on.update": { + "=": "$now" + }, + "@title": "{i18n>ChangedAt}", + "@readonly": true, + "type": "cds.Timestamp" + }, + "ID": { + "key": true, + "type": "cds.Integer" + }, + "title": { + "type": "cds.String", + "length": 111 + }, + "author": { + "type": "cds.String", + "length": 111 + }, + "genre": { + "type": "cds.Association", + "target": "CatalogService.Genres", + "default": { + "val": 10 + } + }, + "stock": { + "type": "cds.Integer" + }, + "price": { + "type": "cds.Decimal" + }, + "currency": { + "@title": "{i18n>Currency}", + "@description": "{i18n>CurrencyCode.Description}", + "type": "Currency", + "target": "CatalogService.Currencies" + }, + "image": { + "@Core.MediaType": "image/png", + "type": "cds.LargeBinary" + }, + "footnotes": { + "items": { + "type": "cds.String" + } + }, + "authorsAddress": { + "type": "cds.String" + } + } + }, + "CatalogService.Genres": { + "kind": "entity", + "@cds.autoexposed": true, + "@cds.autoexpose": true, + "@cds.persistence.skip": "if-unused", + "@cds.odata.valuelist": true, + "elements": { + "name": { + "@title": "{i18n>Name}", + "type": "cds.String", + "length": 255 + }, + "descr": { + "@title": "{i18n>Description}", + "type": "cds.String", + "length": 1000 + }, + "ID": { + "key": true, + "type": "cds.Integer" + }, + "parent": { + "type": "cds.Association", + "target": "CatalogService.Genres" + }, + "children": { + "type": "cds.Composition", + "cardinality": { + "max": "*" + }, + "target": "CatalogService.Genres", + "on": [ + { + "ref": [ + "children", + "parent" + ] + }, + "=", + { + "ref": [ + "$self" + ] + } + ] + } + } + }, + "CatalogService.Currencies": { + "kind": "entity", + "@cds.autoexposed": true, + "@cds.autoexpose": true, + "@cds.persistence.skip": "if-unused", + "@cds.odata.valuelist": true, + "elements": { + "name": { + "@title": "{i18n>Name}", + "type": "cds.String", + "length": 255 + }, + "descr": { + "@title": "{i18n>Description}", + "type": "cds.String", + "length": 1000 + }, + "code": { + "@title": "{i18n>CurrencyCode}", + "@Common.Text": { + "=": "name" + }, + "key": true, + "type": "cds.String", + "length": 3 + }, + "symbol": { + "@title": "{i18n>CurrencySymbol}", + "type": "cds.String", + "length": 5 + }, + "minorUnit": { + "@title": "{i18n>CurrencyMinorUnit}", + "type": "cds.Int16" + } + } + }, + "CatalogService.Books": { + "kind": "entity", + "@readonly": true, + "elements": { + "createdAt": { + "@cds.on.insert": { + "=": "$now" + }, + "@Core.Immutable": true, + "@title": "{i18n>CreatedAt}", + "@readonly": true, + "type": "cds.Timestamp" + }, + "modifiedAt": { + "@cds.on.insert": { + "=": "$now" + }, + "@cds.on.update": { + "=": "$now" + }, + "@title": "{i18n>ChangedAt}", + "@readonly": true, + "type": "cds.Timestamp" + }, + "ID": { + "key": true, + "type": "cds.Integer" + }, + "title": { + "type": "cds.String", + "length": 111 + }, + "descr": { + "type": "cds.String", + "length": 1111 + }, + "author": { + "type": "cds.String", + "length": 111 + }, + "genre": { + "type": "cds.Association", + "target": "CatalogService.Genres", + "default": { + "val": 10 + } + }, + "stock": { + "type": "cds.Integer" + }, + "price": { + "type": "cds.Decimal" + }, + "currency": { + "@title": "{i18n>Currency}", + "@description": "{i18n>CurrencyCode.Description}", + "type": "Currency", + "target": "CatalogService.Currencies" + }, + "image": { + "@Core.MediaType": "image/png", + "type": "cds.LargeBinary" + }, + "footnotes": { + "items": { + "type": "cds.String" + } + }, + "authorsAddress": { + "type": "cds.String" + } + } + }, + "CatalogService.submitOrder": { + "kind": "action", + "params": { + "book": { + "type": { + "ref": [ + "CatalogService.Books", + "ID" + ] + } + }, + "quantity": { + "type": "cds.Integer" + } + }, + "returns": { + "elements": { + "stock": { + "type": "cds.Integer" + } + } + } + }, + "CatalogService.OrderedBook": { + "kind": "event", + "elements": { + "book": { + "type": { + "ref": [ + "CatalogService.Books", + "ID" + ] + } + }, + "quantity": { + "type": "cds.Integer" + }, + "buyer": { + "type": "cds.String" + } + } + } + }, + "meta": { + "creator": "CDS Compiler v6.3.5", + "flavor": "inferred", + "minified": true + }, + "$version": "2.0", + "requires": [ + "@sap/cds/common" + ] +} \ No newline at end of file