Compare commits

..

1 Commits

Author SHA1 Message Date
Daniel
2ddaf45c5f OData Containment switched on 2020-11-20 19:04:33 +01:00
27 changed files with 83 additions and 112 deletions

View File

@@ -21,7 +21,6 @@
}, },
"rules": { "rules": {
"no-console": "off", "no-console": "off",
"require-atomic-updates": "off", "require-atomic-updates": "off"
"require-await":"warn"
} }
} }

0
bookshop/sqlite.db Normal file
View File

View File

@@ -7,6 +7,6 @@ module.exports = cds.service.impl (function(){
/** Generate primary keys for target entity in request */ /** Generate primary keys for target entity in request */
async function genid (req) { async function genid (req) {
const {ID} = await SELECT.one.from(req.target).columns('max(ID) as ID') const {ID} = await cds.tx(req).run (SELECT.one.from(req.target).columns('max(ID) as ID'))
req.data.ID = ID - ID % 100 + 100 + 1 req.data.ID = ID - ID % 100 + 100 + 1
} }

View File

@@ -1,12 +1,12 @@
using { sap.capire.bookshop as my } from '../db/schema'; using { sap.capire.bookshop as my } from '../db/schema';
service CatalogService @(path:'/browse') { service CatalogService @(path:'/browse') {
@readonly entity Books as SELECT from my.Books { *, @readonly entity Books as SELECT from my.Books {*,
author.name as author author.name as author
} excluding { createdBy, modifiedBy }; } excluding { createdBy, modifiedBy };
@readonly entity ListOfBooks as SELECT from Books @readonly entity ListOfBooks as SELECT from Books
excluding { descr }; excluding { descr, stock };
@requires: 'authenticated-user' @requires: 'authenticated-user'
action submitOrder ( book: Books:ID, amount: Integer ) returns { stock: Integer }; action submitOrder ( book: Books:ID, amount: Integer ) returns { stock: Integer };

View File

@@ -1,30 +1,25 @@
const cds = require('@sap/cds') const cds = require('@sap/cds')
const { Books } = cds.entities ('sap.capire.bookshop')
class CatalogService extends cds.ApplicationService { init(){ class CatalogService extends cds.ApplicationService { async init(){
// Reflect entities from model
const { Books } = cds.entities ('sap.capire.bookshop')
// Reduce stock of ordered books if available stock suffices // Reduce stock of ordered books if available stock suffices
this.on ('submitOrder', async req => { this.on ('submitOrder', async req => {
const {book,amount} = req.data const {book,amount} = req.data, tx = cds.tx(req)
// Read stock from database let {stock} = await tx.read('stock').from(Books,book)
let {stock} = await SELECT.from (Books, book, b => b.stock)
if (stock >= amount) { if (stock >= amount) {
// Reduce stock by ordered amount await tx.update (Books,book).with ({ stock: stock -= amount })
await UPDATE (Books,book) .with ({ stock: stock -= amount }) this.emit ('OrderedBook', { book, amount, buyer:req.user.id })
// Emit event to inform others return { stock }
await this.emit ('OrderedBook', { book, amount, buyer:req.user.id })
// Return reduced stock to caller
return req.reply ({ stock })
} }
// Return error about insufficient stock
else return req.error (409,`${amount} exceeds stock for book #${book}`) else return req.error (409,`${amount} exceeds stock for book #${book}`)
}) })
// Add some discount for overstocked books // Add some discount for overstocked books
this.after ('READ','Books', each => { this.after ('READ','Books', each => {
if (each.stock > 111) each.title += ` -- 11% discount!` if (each.stock > 111) {
each.title += ` -- 11% discount!`
}
}) })
return super.init() return super.init()

View File

@@ -1,8 +1,4 @@
{ {
"name": "@capire/common", "name": "@capire/common",
"description": "Provides a pre-built extension package for std @sap/cds/common", "version": "1.0.0"
"version": "1.0.0",
"dependencies": {
"@sap/cds": "latest"
}
} }

View File

@@ -19,7 +19,7 @@ extend Books with {
// Extend Orders with Books as Products // Extend Orders with Books as Products
// //
using { sap.capire.orders.Orders_Items } from '@capire/orders'; using { sap.capire.orders.OrderItems } from '@capire/orders';
extend Orders_Items with { extend OrderItems with {
book : Association to Books on product.ID = book.ID book : Association to Books on product.ID = book.ID
} }

View File

@@ -5,17 +5,13 @@
module.exports = async()=>{ // called by server.js module.exports = async()=>{ // called by server.js
const cds = require('@sap/cds') const cds = require('@sap/cds')
// Connect to services to mashup
const CatalogService = await cds.connect.to ('CatalogService') const CatalogService = await cds.connect.to ('CatalogService')
const ReviewsService = await cds.connect.to ('ReviewsService') const ReviewsService = await cds.connect.to ('ReviewsService')
const OrdersService = await cds.connect.to ('OrdersService') const OrdersService = await cds.connect.to ('OrdersService')
const db = await cds.connect.to ('db') const db = await cds.connect.to ('db')
// Reflect entity definitions used below... // reflect entity definitions used below...
const { Books } = db.entities ('sap.capire.bookshop') const { Books } = db.entities ('sap.capire.bookshop')
const { Orders } = OrdersService.entities
const { Reviews } = ReviewsService.entities
// //
// Delegate requests to read reviews to the ReviewsService // Delegate requests to read reviews to the ReviewsService
@@ -24,7 +20,7 @@ module.exports = async()=>{ // called by server.js
CatalogService.prepend (srv => srv.on ('READ', 'Books/reviews', (req) => { CatalogService.prepend (srv => srv.on ('READ', 'Books/reviews', (req) => {
console.debug ('> delegating request to ReviewsService') console.debug ('> delegating request to ReviewsService')
const [id] = req.params, { columns, limit } = req.query.SELECT const [id] = req.params, { columns, limit } = req.query.SELECT
return SELECT.from (Reviews,columns).limit(limit).where({subject:String(id)}) return ReviewsService.tx(req).read ('Reviews',columns).limit(limit).where({subject:String(id)})
})) }))
// //
@@ -32,9 +28,8 @@ module.exports = async()=>{ // called by server.js
// //
CatalogService.on ('OrderedBook', async (msg) => { CatalogService.on ('OrderedBook', async (msg) => {
const { book, amount, buyer } = msg.data const { book, amount, buyer } = msg.data
const { title, price } = await SELECT.from (Books, book, b => { b.title, b.price }) const { title, price } = await db.tx(msg).read (Books, book, b => { b.title, b.price })
// FIXME: Fails due to Draft glitches when OrdersService is remote return OrdersService.tx(msg).create ('Orders').entries({
return INSERT.into (Orders).entries({
OrderNo: 'Order at '+ (new Date).toLocaleString(), OrderNo: 'Order at '+ (new Date).toLocaleString(),
Items: [{ product:{ID:`${book}`}, title, price, amount }], Items: [{ product:{ID:`${book}`}, title, price, amount }],
buyer, createdBy: buyer buyer, createdBy: buyer
@@ -47,18 +42,18 @@ module.exports = async()=>{ // called by server.js
ReviewsService.on ('reviewed', (msg) => { ReviewsService.on ('reviewed', (msg) => {
console.debug ('> received:', msg.event, msg.data) console.debug ('> received:', msg.event, msg.data)
const { subject, rating } = msg.data const { subject, rating } = msg.data
return UPDATE (Books,subject) .with ({rating}) return UPDATE(Books,subject).with({rating})
// ^ Note: the framework will execute this and take care for db.tx
}) })
// //
// Reduce stock of ordered books when orders are modified in admin UI // Reduce stock of ordered books for orders are created from Orders admin UI
// //
OrdersService.on ('OrderChanged', (msg) => { OrdersService.on ('OrderChanged', async (msg) => {
console.debug ('> received:', msg.event, msg.data) console.debug ('> received:', msg.event, msg.data)
const { product, deltaAmount } = msg.data const { product, deltaAmount } = msg.data
return UPDATE (Books) .where ('ID =', product) return UPDATE (Books) .where ('ID =', product)
.and ('stock >=', deltaAmount) .and ('stock >=', deltaAmount)
.set ('stock -=', deltaAmount) .set ('stock -=', deltaAmount)
}) })
} }

View File

@@ -42,24 +42,7 @@ GET {{bookshop}}/browse/Books(201)?
################################################# #################################################
# #
# Orders Service, incl. draft choreography # Orders Service
# #
@newOrderID = e939604c-ab83-4d4f-bdb6-95fe30b3773e
GET {{bookshop}}/orders/Orders GET {{bookshop}}/orders/Orders
### Create order, still inactive
POST {{bookshop}}/orders/Orders
Content-Type: application/json
{"ID": "{{newOrderID}}"}
### Get inactive order. We have to specify `IsActiveEntity`.
GET {{bookshop}}/orders/Orders(ID={{newOrderID}},IsActiveEntity=false)
### Activate order using `.../<servicename>.draftActivate`
POST {{bookshop}}/orders/Orders(ID={{newOrderID}},IsActiveEntity=false)/OrdersService.draftActivate
Content-Type: application/json
### Get active order
GET {{bookshop}}/orders/Orders(ID={{newOrderID}},IsActiveEntity=true)

View File

@@ -68,7 +68,7 @@ annotate OrdersService.Orders with @(
annotate OrdersService.Orders_Items with @( annotate OrdersService.OrderItems with @(
UI: { UI: {
LineItem: [ LineItem: [
{Value: product_ID, Label:'Product ID'}, {Value: product_ID, Label:'Product ID'},

View File

@@ -121,7 +121,7 @@
"name": "sap.fe.templates.ObjectPage", "name": "sap.fe.templates.ObjectPage",
"options": { "options": {
"settings" : { "settings" : {
"entitySet": "Orders_Items" "entitySet": "OrderItems"
} }
} }
}, },

View File

@@ -1,4 +1,4 @@
ID;up__ID;amount;product_ID;title;price ID;order_ID;amount;product_ID;title;price
58040e66-1dcd-4ffb-ab10-fdce32028b79;7e2f2640-6866-4dcf-8f4d-3027aa831cad;1;201;Wuthering Heights;11.11 58040e66-1dcd-4ffb-ab10-fdce32028b79;7e2f2640-6866-4dcf-8f4d-3027aa831cad;1;201;Wuthering Heights;11.11
64e718c9-ff99-47f1-8ca3-950c850777d4;7e2f2640-6866-4dcf-8f4d-3027aa831cad;1;271;Catweazle;15 64e718c9-ff99-47f1-8ca3-950c850777d4;7e2f2640-6866-4dcf-8f4d-3027aa831cad;1;271;Catweazle;15
e9641166-e050-4261-bfee-d1e797e6cb7f;64e718c9-ff99-47f1-8ca3-950c850777d4;2;252;Eleonora;28 e9641166-e050-4261-bfee-d1e797e6cb7f;64e718c9-ff99-47f1-8ca3-950c850777d4;2;252;Eleonora;28
1 ID up__ID order_ID amount product_ID title price
2 58040e66-1dcd-4ffb-ab10-fdce32028b79 7e2f2640-6866-4dcf-8f4d-3027aa831cad 7e2f2640-6866-4dcf-8f4d-3027aa831cad 1 201 Wuthering Heights 11.11
3 64e718c9-ff99-47f1-8ca3-950c850777d4 7e2f2640-6866-4dcf-8f4d-3027aa831cad 7e2f2640-6866-4dcf-8f4d-3027aa831cad 1 271 Catweazle 15
4 e9641166-e050-4261-bfee-d1e797e6cb7f 64e718c9-ff99-47f1-8ca3-950c850777d4 64e718c9-ff99-47f1-8ca3-950c850777d4 2 252 Eleonora 28

View File

@@ -1,26 +1,26 @@
using { Currency, User, managed, cuid } from '@sap/cds/common'; using { Currency, User, managed, cuid } from '@sap/cds/common';
using from '@capire/common';
namespace sap.capire.orders; namespace sap.capire.orders;
entity Orders : cuid, managed { entity Orders : cuid, managed {
OrderNo : String @title:'Order Number'; //> readable key OrderNo : String @title:'Order Number'; //> readable key
Items : Composition of many Orders_Items on Items.up_ = $self; Items : Composition of many OrderItems on Items.order = $self;
buyer : User; buyer : User;
currency : Currency; currency : Currency;
} }
entity Orders_Items { entity OrderItems {
key ID : UUID; key ID : UUID;
up_ : Association to Orders; order : Association to Orders;
product : Association to Products @assert.integrity:false; // REVISIT: this is a temporary workaround for a glitch in cds-runtime @assert.integrity:false // REVISIT: this is a temporary workaround for a glitch in cds-runtime
product : Association to Products;
amount : Integer; amount : Integer;
title : String; title : String;
price : Double; price : Double;
} }
/** This is a stand-in for arbitrary ordered Products */ /** This is a stand-in for arbitrary ordered Products */
entity Products @(cds.persistence.skip:'always',cds.autoexpose) { @cds.persistence.skip:'always'
entity Products {
key ID : String; key ID : String;
} }
// Activate extension package
using from '@capire/common';

View File

@@ -4,5 +4,10 @@
"dependencies": { "dependencies": {
"@capire/common": "*", "@capire/common": "*",
"@sap/cds": "^4.3.0" "@sap/cds": "^4.3.0"
},
"cds": {
"odata": {
"containment": true
}
} }
} }

View File

@@ -3,20 +3,24 @@ class OrdersService extends cds.ApplicationService {
/** register custom handlers */ /** register custom handlers */
init(){ init(){
const { Orders_Items:OrderItems } = this.entities const { OrderItems } = this.entities
this.before ('UPDATE', 'Orders', async function(req) { this.before ('UPDATE', 'Orders', async function(req) {
const { ID, Items } = req.data const { ID, Items } = req.data
if (Items) for (let { product_ID, amount } of Items) { if (Items) for (let { product_ID, amount } of Items) {
const { amount:before } = await SELECT.one.from (OrderItems, oi => oi.amount) .where ({up__ID:ID, product_ID}) const { amount:before } = await cds.tx(req).run (
if (amount != before) await this.orderChanged (product_ID, amount-before) SELECT.one.from (OrderItems, oi => oi.amount) .where ({order_ID:ID, product_ID})
)
if (amount != before) this.orderChanged (product_ID, amount-before)
} }
}) })
this.before ('DELETE', 'Orders', async function(req) { this.before ('DELETE', 'Orders', async function(req) {
const { ID } = req.data const { ID } = req.data
const Items = await SELECT.from (OrderItems, oi => { oi.product_ID, oi.amount }) .where ({up__ID:ID}) const Items = await cds.tx(req).run (
if (Items) await Promise.all (Items.map(it => this.orderChanged (it.product_ID, -it.amount))) SELECT.from (OrderItems, oi => { oi.product_ID, oi.amount }) .where ({order_ID:ID})
)
if (Items) for (let it of Items) this.orderChanged (it.product_ID, -it.amount)
}) })
return super.init() return super.init()

View File

@@ -31,9 +31,6 @@
"mocha": { "mocha": {
"parallel": true "parallel": true
}, },
"engines": {
"node": ">= 12.18"
},
"jest": { "jest": {
"testEnvironment": "node" "testEnvironment": "node"
}, },

View File

@@ -37,7 +37,7 @@ const reviews = new Vue ({
reviews.message = {} reviews.message = {}
}, },
newReview () { async newReview () {
reviews.review = {} reviews.review = {}
reviews.message = {} reviews.message = {}
setTimeout (()=> $('form > input').focus(), 111) setTimeout (()=> $('form > input').focus(), 111)

View File

@@ -1,5 +1,5 @@
const cds = require ('@sap/cds') const cds = require ('@sap/cds')
module.exports = cds.service.impl (function(){ module.exports = cds.service.impl (async function(){
// Get the CSN definition for Reviews from the db schema for sub-sequent queries // Get the CSN definition for Reviews from the db schema for sub-sequent queries
// ( Note: we explicitly specify the namespace to support embedded reuse ) // ( Note: we explicitly specify the namespace to support embedded reuse )
@@ -12,16 +12,19 @@ module.exports = cds.service.impl (function(){
// Emit an event to inform subscribers about new avg ratings for reviewed subjects // Emit an event to inform subscribers about new avg ratings for reviewed subjects
this.after (['CREATE','UPDATE','DELETE'], 'Reviews', async function(_,req) { this.after (['CREATE','UPDATE','DELETE'], 'Reviews', async function(_,req) {
const {subject} = req.data const {subject} = req.data
const {rating} = await SELECT.one (['round(avg(rating),2) as rating']) .from (Reviews) .where ({subject}) const {rating} = await cds.tx(req) .run (
SELECT.one (['round(avg(rating),2) as rating']) .from (Reviews) .where ({subject})
)
global.it || console.log ('< emitting:', 'reviewed', { subject, rating }) global.it || console.log ('< emitting:', 'reviewed', { subject, rating })
await this.emit ('reviewed', { subject, rating }) this.emit ('reviewed', { subject, rating })
}) })
// Increment counter for reviews considered helpful // Increment counter for reviews considered helpful
this.on ('like', (req) => { this.on ('like', (req) => {
if (!req.user) return req.reject(400, 'You must be identified to like a review') if (!req.user) return req.reject(400, 'You must be identified to like a review')
const {review} = req.data, {user} = req const {review} = req.data, {user} = req
return cds.run ([ const tx = cds.tx(req)
return tx.run ([
INSERT.into (Likes) .entries ({review_ID: review, user: user.id}), INSERT.into (Likes) .entries ({review_ID: review, user: user.id}),
UPDATE (Reviews) .set({liked: {'+=': 1}}) .where({ID:review}) UPDATE (Reviews) .set({liked: {'+=': 1}}) .where({ID:review})
]).catch(() => req.reject(400, 'You already liked that review')) ]).catch(() => req.reject(400, 'You already liked that review'))
@@ -31,8 +34,9 @@ module.exports = cds.service.impl (function(){
this.on ('unlike', async (req) => { this.on ('unlike', async (req) => {
if (!req.user) return req.reject(400, 'You must be identified to remove a former like of yours') if (!req.user) return req.reject(400, 'You must be identified to remove a former like of yours')
const {review} = req.data, {user} = req const {review} = req.data, {user} = req
const affectedRows = await DELETE.from (Likes) .where ({review_ID: review,user: user.id}) const tx = cds.tx(req)
if (affectedRows === 1) return UPDATE (Reviews) .set ({liked: {'-=': 1}}) .where ({ID:review}) const affectedRows = await tx.run (DELETE.from (Likes) .where ({review_ID: review,user: user.id}))
if (affectedRows === 1) return tx.run (UPDATE (Reviews) .set ({liked: {'-=': 1}}) .where ({ID:review}))
}) })
}) })

View File

@@ -1,5 +1,5 @@
const { expect } = require('../test')
const cds = require('@sap/cds/lib') const cds = require('@sap/cds/lib')
const { expect } = cds.test
const CQL = ([cql]) => cds.parse.cql(cql) const CQL = ([cql]) => cds.parse.cql(cql)
const Foo = { name: 'Foo' } const Foo = { name: 'Foo' }
const Books = { name: 'capire.bookshop.Books' } const Books = { name: 'capire.bookshop.Books' }

View File

@@ -1,7 +1,7 @@
const { expect } = require('../test') .run (
'serve', 'AdminService', '--from', '@capire/bookshop,@capire/common', '--in-memory'
)
const cds = require('@sap/cds/lib') const cds = require('@sap/cds/lib')
const { expect } = cds.test (
'serve', 'AdminService', '--from', '@capire/bookshop,@capire/common', '--in-memory'
).in(__dirname)
describe('Consuming Services locally', () => { describe('Consuming Services locally', () => {
// //

View File

@@ -1,7 +1,5 @@
const { GET, POST, expect } = require('../test') .run ('bookshop') const cds = require('@sap/cds/lib'); cds.User = cds.User.Privileged // skip auth
const cds = require('@sap/cds/lib') const { GET, POST, expect } = cds.test('bookshop').in(__dirname,'..')
if (cds.User.default) cds.User.default = cds.User.Privileged // hard core monkey patch
else cds.User = cds.User.Privileged // hard core monkey patch for older cds releases
describe('Custom Handlers', () => { describe('Custom Handlers', () => {

View File

@@ -1,4 +1,5 @@
const { GET, expect } = require('../test') .run ('serve','hello/world.cds') const cds = require('@sap/cds/lib')
const { GET, expect } = cds.test('serve','hello/world.cds').in(__dirname,'..')
describe('Hello world!', () => { describe('Hello world!', () => {

View File

@@ -1,5 +1,6 @@
const {expect} = require('../test') const cwd = process.cwd(); process.chdir (__dirname) //> only for internal CI/CD@SAP
const cds = require('@sap/cds/lib') const cds = require('@sap/cds/lib')
const {expect} = cds.test
// monkey patching older releases: // monkey patching older releases:
if (!cds.compile.cdl) cds.compile.cdl = cds.parse if (!cds.compile.cdl) cds.compile.cdl = cds.parse
@@ -24,6 +25,8 @@ describe('Hierarchical Data', ()=>{
expect (cds.db.model) .to.exist expect (cds.db.model) .to.exist
}) })
after(()=> process.chdir(cwd))
it ('supports deeply nested inserts', ()=> INSERT.into (Cats, it ('supports deeply nested inserts', ()=> INSERT.into (Cats,
{ ID:100, name:'Some Cats...', children:[ { ID:100, name:'Some Cats...', children:[
{ ID:101, name:'Cat', children:[ { ID:101, name:'Cat', children:[

View File

@@ -1,6 +0,0 @@
const test = require('@sap/cds/lib/utils/tests').in(__dirname,'..')
module.exports = Object.assign(test,{run:test})
// REVISIT: With upcoming release of @sap/cds this should become:
// module.exports = require('@sap/cds/tests').in(__dirname,'..')

View File

@@ -1,7 +1,5 @@
const { GET, expect } = require('../test') .run ('serve', 'test/localized-data.cds', '--in-memory') const cds = require('@sap/cds/lib'); cds.User = cds.User.Privileged // skip auth
const cds = require('@sap/cds/lib') const { GET, expect } = cds.test ('serve', __dirname+'/localized-data.cds', '--in-memory')
if (cds.User.default) cds.User.default = cds.User.Privileged // hard core monkey patch
else cds.User = cds.User.Privileged // hard core monkey patch for older cds releases
describe('Localized Data', () => { describe('Localized Data', () => {

View File

@@ -1,11 +1,13 @@
const { expect } = require('../test')
const cds = require('@sap/cds/lib') const cds = require('@sap/cds/lib')
const cwd = process.cwd(); process.chdir (__dirname) //> only for internal CI/CD@SAP
const {expect} = cds.test
const _model = '@capire/reviews' const _model = '@capire/reviews'
if (cds.User.default) cds.User.default = cds.User.Privileged // hard core monkey patch cds.User = cds.User.Privileged // hard core monkey patch
else cds.User = cds.User.Privileged // hard core monkey patch for older cds releases
describe('Messaging', ()=>{ describe('Messaging', ()=>{
after(()=> process.chdir(cwd))
it ('should bootstrap sqlite in-memory db', async()=>{ it ('should bootstrap sqlite in-memory db', async()=>{
const db = await cds.deploy (_model) .to ('sqlite::memory:') const db = await cds.deploy (_model) .to ('sqlite::memory:')
await db.delete('Reviews') await db.delete('Reviews')

View File

@@ -1,11 +1,8 @@
const { GET, expect } = require('../test') .run ('bookshop') const cds = require('@sap/cds/lib'); cds.User = cds.User.Privileged // skip auth
const cds = require('@sap/cds/lib') const { GET, expect } = cds.test('bookshop').in(__dirname,'..')
if (cds.User.default) cds.User.default = cds.User.Privileged // hard core monkey patch
else cds.User = cds.User.Privileged // hard core monkey patch for older cds releases
describe('OData Protocol', () => { describe('OData Protocol', () => {
it('serves $metadata documents in v4', async () => { it('serves $metadata documents in v4', async () => {
const { headers, status, data } = await GET `/browse/$metadata` const { headers, status, data } = await GET `/browse/$metadata`
expect(status).to.equal(200) expect(status).to.equal(200)