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 : '' }}
+
+
+
+
+
+
+
+
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)