diff --git a/.eslintrc b/.eslintrc index 40fe0cb5..79a8f02d 100644 --- a/.eslintrc +++ b/.eslintrc @@ -22,6 +22,7 @@ "rules": { "no-console": "off", "require-atomic-updates": "off", - "require-await":"warn" + "require-await":"warn", + "no-unused-vars": ["warn", { "argsIgnorePattern": "_" }] } } diff --git a/bookstore/package.json b/bookstore/package.json index f69dae7a..3f89fc9f 100644 --- a/bookstore/package.json +++ b/bookstore/package.json @@ -6,6 +6,7 @@ "@capire/reviews": "*", "@capire/orders": "*", "@capire/common": "*", + "@capire/data-viewer": "*", "@sap/cds": "^5", "express": "^4.17.1" }, diff --git a/bookstore/server.js b/bookstore/server.js index 638d9c52..15a41c60 100644 --- a/bookstore/server.js +++ b/bookstore/server.js @@ -8,6 +8,7 @@ cds.once('bootstrap',(app)=>{ app.serve ('/bookshop') .from ('@capire/bookshop','app/vue') app.serve ('/reviews') .from ('@capire/reviews','app/vue') app.serve ('/orders') .from('@capire/orders','app/orders') + app.serve ('/data') .from('@capire/data-viewer','app/viewer') }) // Add Swagger UI diff --git a/bookstore/srv/mashup.cds b/bookstore/srv/mashup.cds index 1099e29f..5bcc4446 100644 --- a/bookstore/srv/mashup.cds +++ b/bookstore/srv/mashup.cds @@ -30,3 +30,6 @@ extend Orders with { // Add orders fiori app (in case of embedded orders service) using from '@capire/orders/app/fiori'; + +// Add data browser +using from '@capire/data-viewer'; diff --git a/data-viewer/app/viewer/app.js b/data-viewer/app/viewer/app.js new file mode 100644 index 00000000..e326ece6 --- /dev/null +++ b/data-viewer/app/viewer/app.js @@ -0,0 +1,72 @@ +/* global Vue axios */ //> from vue.html +const GET = (url) => axios.get('/-data'+url) +const storageGet = (key, def) => localStorage.getItem('data-viewer:'+key) || def +const storageSet = (key, val) => localStorage.setItem('data-viewer:'+key, val) + +const viewer = new Vue ({ + + el:'#app', + + data: { + dataSource: storageGet('data-source', 'db'), + skip: storageGet('skip', 0), + top: storageGet('top', 20), + entity: storageGet('entity') ? JSON.parse(storageGet('entity')) : undefined, + entities: [], + columns: [], + data: [], + rowDetails: undefined, + }, + + watch: { + dataSource: (v) => { storageSet('data-source', v); viewer.fetchEntities() }, + skip: (v) => { storageSet('skip', v); if (viewer.entity) viewer.fetchData() }, + top: (v) => { storageSet('top', v); if (viewer.entity) viewer.fetchData() }, + }, + + methods: { + + async fetchEntities () { + let url = `/Entities` + if (viewer.dataSource === 'db') url += `?dataSource=db` + const {data} = await GET(url) + viewer.entities = data.value + const entity = viewer.entity && viewer.entities.find(e => e.name === viewer.entity.name) + if (entity) { // restore selection from previous fetch + viewer.columns = entity.columns + await viewer.fetchData(entity) + } else { + viewer.entity = undefined + viewer.columns = [] + viewer.data = [] + viewer.rowDetails = {} + } + }, + + async inspectEntity (eve) { + const entity = viewer.entity = viewer.entities [eve.currentTarget.rowIndex-1] + storageSet('entity', JSON.stringify(entity)) + viewer.columns = viewer.entities.find(e => e.name === entity.name).columns + return await this.fetchData() + }, + + async fetchData () { + let url = `/Data?entity=${viewer.entity.name}&$skip=${viewer.skip}&$top=${viewer.top}` + if (viewer.dataSource === 'db') url += `&dataSource=db` + const {data} = await GET(url) + viewer.data = data.value.map(d => d.record.map(r => r.data)) + viewer.rowDetails = undefined + }, + + inspectRow (eve) { + viewer.rowDetails = {} + const selectedRow = eve.currentTarget.rowIndex-1 + viewer.data[selectedRow].forEach((line, colIndex) => { + viewer.rowDetails[viewer.columns[colIndex].name] = line + }) + }, + + } +}) + +viewer.fetchEntities() diff --git a/data-viewer/app/viewer/index.html b/data-viewer/app/viewer/index.html new file mode 100644 index 00000000..121c4d4d --- /dev/null +++ b/data-viewer/app/viewer/index.html @@ -0,0 +1,83 @@ + + + + + Data Browser + + + + + + + +
+ +

{{ document.title }}{{ entity ? ' – ' + entity.name : '' }}

+ +
+ + +
+
+ + + + +
+
+ + + + + + + +
{{ col.name }}
{{ d }}
+
+ +
+ + + + + +
{{ value }}{{ key }}
+
+
+
+ +
+ + + + diff --git a/data-viewer/index.cds b/data-viewer/index.cds new file mode 100644 index 00000000..d16b292c --- /dev/null +++ b/data-viewer/index.cds @@ -0,0 +1 @@ +using from './srv/data-service'; \ No newline at end of file diff --git a/data-viewer/package.json b/data-viewer/package.json new file mode 100644 index 00000000..bc5ea5e0 --- /dev/null +++ b/data-viewer/package.json @@ -0,0 +1,13 @@ +{ + "name": "@capire/data-viewer", + "version": "0.1.0", + "description": "A generic browser for data", + "dependencies": { + "@sap/cds": "^5.0.4" + }, + "files": [ + "app", + "srv", + "index.cds" + ] +} diff --git a/data-viewer/srv/data-service.cds b/data-viewer/srv/data-service.cds new file mode 100644 index 00000000..0db76c4d --- /dev/null +++ b/data-viewer/srv/data-service.cds @@ -0,0 +1,29 @@ +/** + * Exposes data + entity metadata + */ +//@requires:'admin' +service DataService @( path:'-data' ) { + + /** + * Metadata like name and columns/elements + */ + entity Entities { + key name : String; + columns: Composition of many { + name : String; + type : String; + isKey: Boolean; + } + } + + /** + * The actual data, organized by column name + */ + entity Data { + record : array of { + column : String; + data : String; + } + } + +} diff --git a/data-viewer/srv/data-service.js b/data-viewer/srv/data-service.js new file mode 100644 index 00000000..57fe7a3b --- /dev/null +++ b/data-viewer/srv/data-service.js @@ -0,0 +1,58 @@ +const cds = require('@sap/cds') +const log = cds.log('data') + +class DataService extends cds.ApplicationService { init(){ + + this.on ('READ', 'Entities', req => { + const { dataSource } = req.req.query + const srvPrefixes = cds.db.model.all('service').map(srv => srv.name+'.') + const dataSourceFilter = dataSource === 'db' + ? e => e['@cds.persistence.skip'] !== true // for DB, excl. entities w/o persistence + : e => !!srvPrefixes.find(srvName => e.name.startsWith(srvName)) // only entities reachable from a service + + return cds.db.model.all('entity') + .filter (e => req.data && req.data.name ? e.name === req.data.name : true) // honor name filter from request, if any + .filter (e => !e.name.startsWith('DRAFT.')) // exclude synthetic stuff + .filter (e => !e.name.startsWith('DataService.')) // exclude this service + .filter (dataSourceFilter) + .sort((e1, e2) => e1.name.localeCompare(e2.name)) + .map(e => { + const columns = Object.entries(e.elements) + .filter(([_, el]) => !(el instanceof cds.Association)) // exclude assocs+compositions + .map(([name, el]) => { return { name, type: el.type, isKey:!!el.key }}) + return { name: e.name, columns } + }) + }) + + this.on ('READ', 'Data', async req => { + const { entity: entityName, dataSource: dataSourceName } = req.req.query + if (!entityName) return req.reject(400, `Must provide 'entity' query`) + const entity = cds.db.model.definitions[entityName] + if (!entity) return req.reject(404, 'No such entity: ' + entityName) + + const query = SELECT.from(entity) + query.SELECT.limit = req.query.SELECT.limit // use $skip / $top from request + + const dataSource = findDataSource(dataSourceName, entityName) + const res = await dataSource.run(query) + return res.map((line) => { + const record = Object.entries(line).map(([column, data]) => {return {column, data}}) + return { record } + }) + }) + + return super.init() +}} + +module.exports = { DataService } + +function findDataSource(dataSourceName, entityName) { + for (let srv of Object.values(cds.services)) { // all connected services + if (!srv.name) continue // FIXME intermediate/pending in cds.services ? + if (dataSourceName === srv.name || entityName.startsWith(srv.name+'.')) { + log._debug && log.debug(`using ${srv.name} as data source`) + return srv + } + } + return cds.services.db // fallback +} diff --git a/package.json b/package.json index 8094963e..5e0dfd83 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "@capire/bookstore": "./bookstore", "@capire/bookshop": "./bookshop", "@capire/common": "./common", + "@capire/data-viewer": "./data-viewer", "@capire/fiori": "./fiori", "@capire/hello": "./hello", "@capire/media": "./media", diff --git a/samples.md b/samples.md index c03f8fb0..ad897763 100644 --- a/samples.md +++ b/samples.md @@ -58,9 +58,11 @@ Each sub directory essentially is an individual npm package arranged in an [all- - [@capire/reviews](reviews) - [@capire/orders](orders) - [@capire/common](common) -- [The Vue.js app](bookshop/app/vue) imported from bookshop is served as well -- [The Vue.js app](reviews/app/vue) imported from reviews is served as well -- [The Fiori app](orders/app) imported from orders is served as well + - [@capire/data-viewer](data-viewer) +- [The Vue.js app](bookshop/app/vue) imported from `bookshop` is served as well +- [The Vue.js app](reviews/app/vue) imported from `reviews` is served as well +- [The Vue.js app](data-viewer/app/data) imported from `data-viewer` is served as well +- [The Fiori app](orders/app) imported from `orders` is served as well - [OpenAPI export + Swagger UI](https://cap.cloud.sap/docs/advanced/openapi)