├── .gitignore ├── .istanbul.yaml ├── .npmignore ├── .npmrc ├── Dockerfile ├── README.md ├── docker └── supervisor.conf ├── package.json ├── src ├── categories.ts ├── controller.ts ├── index.ts ├── model.ts ├── products.ts ├── server.ts └── utils │ ├── connect.ts │ ├── convertResults.ts │ ├── insert.ts │ ├── replace.ts │ └── update.ts ├── test └── pgsql.spec.js └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | npm-debug.log 4 | yarn-error.log 5 | coverage 6 | report 7 | .tslint 8 | lib -------------------------------------------------------------------------------- /.istanbul.yaml: -------------------------------------------------------------------------------- 1 | verbose: false 2 | instrumentation: 3 | root: . 4 | extensions: 5 | - .js 6 | default-excludes: true 7 | excludes: [] 8 | embed-source: false 9 | variable: __coverage__ 10 | compact: true 11 | preserve-comments: false 12 | complete-copy: false 13 | save-baseline: false 14 | baseline-file: ./coverage/coverage-baseline.json 15 | include-all-sources: false 16 | include-pid: false 17 | es-modules: true 18 | reporting: 19 | print: summary 20 | reports: 21 | - text 22 | dir: ./coverage 23 | watermarks: 24 | statements: [50, 80] 25 | lines: [50, 80] 26 | functions: [50, 80] 27 | branches: [50, 80] 28 | report-config: 29 | clover: {file: clover.xml} 30 | cobertura: {file: cobertura-coverage.xml} 31 | json: {file: coverage-final.json} 32 | json-summary: {file: coverage-summary.json} 33 | lcovonly: {file: lcov.info} 34 | teamcity: {file: null, blockName: Code Coverage Summary} 35 | text: {file: null, maxCols: 0} 36 | text-lcov: {file: lcov.info} 37 | text-summary: {file: null} 38 | hooks: 39 | hook-run-in-context: false 40 | post-require-hook: null 41 | handle-sigint: false 42 | check: 43 | global: 44 | statements: 0 45 | lines: 0 46 | branches: 0 47 | functions: 0 48 | excludes: [] 49 | each: 50 | statements: 0 51 | lines: 0 52 | branches: 0 53 | functions: 0 54 | excludes: [] -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | yarn-error.log 3 | coverage 4 | report -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @types:registry=https://registry.npmjs.org 2 | loglevel="warn" 3 | //SECRET -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest 2 | 3 | # Prepare ubuntu 4 | ENV DEBIAN_FRONTEND noninteractive 5 | RUN ln -sf /bin/bash /bin/sh 6 | 7 | # Configure standard environment 8 | WORKDIR /root/app 9 | 10 | COPY ./ /root/app/ 11 | 12 | # Install supervisord 13 | RUN apt-get update 14 | RUN apt-get install -y supervisor nano 15 | RUN mkdir -p /var/log/supervisor 16 | COPY ./docker/supervisor.conf /etc/supervisor/conf.d/ 17 | 18 | RUN npm config set @types:registry https://registry.npmjs.org 19 | RUN npm install -q 20 | RUN npm cache clean 21 | RUN npm run build 22 | 23 | ENV NODE_ENV production 24 | 25 | CMD /usr/bin/supervisord -n -c /etc/supervisor/supervisord.conf 26 | 27 | EXPOSE 3000 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # odata-v4-server-pgsql-example 2 | PostgreSQL Server example for **[JayStack OData V4 Server](https://github.com/jaystack/odata-v4-server)** 3 | 4 | ## About JayStack OData V4 Server (odata-v4-server) 5 | 6 | With JayStack OData v4 Server you can build your own data endpoints without the hassle of implementing any protocol-level code. This framework binds OData v4 requests to your annotated controller functions, and compiles OData v4 compatible response. Clients can access services through OData-compliant HTTP requests. We recommend the JayData library for consuming OData v4 APIs. 7 | 8 | This example uses **JayStack OData V4 Server [(odata-v4-server)](https://github.com/jaystack/odata-v4-server)** and [odata-v4-pgsql](https://github.com/jaystack/odata-v4-pgsql) repositories. 9 | 10 | You can read more about **JayStack OData V4 Server** in our tutorial at ... 11 | 12 | Also there are sevaral other examples on **JayStack OData V4 Server (odata-v4-server)**: 13 | - [client example using React](https://github.com/jaystack/odata-v4-server-react-client-example) 14 | - [server example using MySql](https://github.com/jaystack/odata-v4-mysql-example) 15 | - [server example using MSSql](https://github.com/jaystack/odata-v4-server-mssql-example) 16 | - [server example using MongoDb](https://github.com/jaystack/odata-v4-server-mongodb-example) 17 | 18 | ## Technical details of this example 19 | ### Setting up the database 20 | You have to create the database manually using this command after connecting to the default database: 21 | ```SQL 22 | CREATE DATABASE northwind; 23 | ``` 24 | 25 | ### Setting up the connection 26 | You may customize the db connection options 27 | by editing [connect.ts](https://github.com/jaystack/odata-v4-server-pgsql-example/blob/master/src/utils/connect.ts#L29-L30). 28 | By default, these are the options: 29 | ```js 30 | const pool = new pg.Pool({ 31 | user: 'postgres', 32 | password: 'postgres', 33 | database: 'northwind' 34 | }); 35 | ``` 36 | By default, the database will listen on `port` `5432` therefore it is not set above. 37 | 38 | ### Building the application 39 | ``` 40 | npm run build 41 | ``` 42 | 43 | ### Testing the application 44 | ``` 45 | npm test 46 | ``` 47 | 48 | ### Starting the application 49 | ``` 50 | npm start 51 | ``` 52 | 53 | ### Creating sample data 54 | After starting the application (it will listen on `localhost:3000` by default) you can generate / recreate the sample dataset 55 | by submitting [localhost:3000/initDb](http://localhost:3000/initDb). 56 | Alternatively if you start unit tests (`npm test`) then the database will be initialized automatically. 57 | -------------------------------------------------------------------------------- /docker/supervisor.conf: -------------------------------------------------------------------------------- 1 | [program:odata-v4-server-pgsql-example] 2 | directory=/root/app 3 | command=/usr/local/bin/node lib/index.js 4 | autorestart=true 5 | stdout_logfile=/var/log/supervisor/odata-v4-server-pgsql-example.out.log 6 | stderr_logfile=/var/log/supervisor/odata-v4-server-pgsql-example.err.log 7 | stdout_logfile_backups=5 8 | stderr_logfile_backups=5 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "odata-v4-server-pgsql-example", 3 | "version": "0.1.0", 4 | "description": "OData V4 Server with Postgres example", 5 | "main": "lib/index.js", 6 | "typings": "lib/index", 7 | "directories": { 8 | "test": "test" 9 | }, 10 | "scripts": { 11 | "build": "npm run tsc", 12 | "tsc": "tsc", 13 | "tsc:w": "tsc -w", 14 | "test": "mocha test/*.spec.js --reporter mochawesome --reporter-options reportDir=report,reportName=odata-v4-server,reportTitle=\"OData V4 Server\"", 15 | "pretest": "npm run build", 16 | "start": "node ." 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/jaystack/odata-v4-server-pgsql-example.git" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/jaystack/odata-v4-server-pgsql-example/issues" 24 | }, 25 | "homepage": "https://github.com/jaystack/odata-v4-server-pgsql-example#readme", 26 | "keywords": [ 27 | "odata", 28 | "server", 29 | "postgres" 30 | ], 31 | "author": "JayStack", 32 | "license": "ISC", 33 | "devDependencies": { 34 | "@types/express": "^4.0.34", 35 | "@types/pg": "^6.1.34", 36 | "@types/ramda": "0.0.2", 37 | "chai": "^3.5.0", 38 | "mocha": "^3.2.0", 39 | "mochawesome": "^1.5.4", 40 | "typescript": "^2.0.10" 41 | }, 42 | "dependencies": { 43 | "express": "^4.14.0", 44 | "odata-v4-pg": "^0.1.0", 45 | "odata-v4-server": "^0.1.18", 46 | "pg": "^6.1.0", 47 | "ramda": "^0.22.1" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/categories.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | "Id": 1, 4 | "Name": "Beverages", 5 | "Description": "Soft drinks" 6 | }, 7 | { 8 | "Id": 2, 9 | "Name": "Grains/Cereals", 10 | "Description": "Breads" 11 | }, 12 | { 13 | "Id": 3, 14 | "Name": "Meat/Poultry", 15 | "Description": "Prepared meats" 16 | }, 17 | { 18 | "Id": 4, 19 | "Name": "Produce", 20 | "Description": "Dried fruit and bean curd" 21 | }, 22 | { 23 | "Id": 5, 24 | "Name": "Seafood", 25 | "Description": "Seaweed and fish" 26 | }, 27 | { 28 | "Id": 6, 29 | "Name": "Condiments", 30 | "Description": "Sweet and savory sauces" 31 | }, 32 | { 33 | "Id": 7, 34 | "Name": "Dairy Products", 35 | "Description": "Cheeses" 36 | }, 37 | { 38 | "Id": 8, 39 | "Name": "Confections", 40 | "Description": "Desserts" 41 | } 42 | ] -------------------------------------------------------------------------------- /src/controller.ts: -------------------------------------------------------------------------------- 1 | import { ODataController, Edm, odata, ODataQuery } from "odata-v4-server"; 2 | import { createQuery } from "odata-v4-pg"; 3 | import convertResults from "./utils/convertResults"; 4 | import { Product, Category } from "./model"; 5 | import connect from "./utils/connect"; 6 | import insert from "./utils/insert"; 7 | import replace from "./utils/replace"; 8 | import update from "./utils/update"; 9 | 10 | @odata.type(Product) 11 | export class ProductsController extends ODataController { 12 | 13 | @odata.GET 14 | async select( @odata.query query: ODataQuery): Promise { 15 | const db = await connect(); 16 | const sqlQuery = createQuery(query); 17 | const {rows} = await db.query(sqlQuery.from('"Products"'), sqlQuery.parameters); 18 | return convertResults(rows); 19 | } 20 | 21 | @odata.GET 22 | async selectOne( @odata.key key: number, @odata.query query: ODataQuery): Promise { 23 | const db = await connect(); 24 | const sqlQuery = createQuery(query); 25 | const {rows} = await db.query(`SELECT ${sqlQuery.select} FROM "Products" 26 | WHERE "Id" = $${sqlQuery.parameters.length + 1} AND 27 | (${sqlQuery.where})`, 28 | [...sqlQuery.parameters, key] 29 | ); 30 | return convertResults(rows)[0]; 31 | } 32 | 33 | @odata.GET("Category") 34 | async getCategory( @odata.result product: Product, @odata.query query: ODataQuery): Promise { 35 | const db = await connect(); 36 | const sqlQuery = createQuery(query); 37 | const {rows} = await db.query(`SELECT ${sqlQuery.select} FROM "Categories" 38 | WHERE "Id" = $${sqlQuery.parameters.length + 1} AND 39 | (${sqlQuery.where})`, 40 | [...sqlQuery.parameters, product.CategoryId] 41 | ); 42 | return convertResults(rows)[0]; 43 | } 44 | 45 | @odata.POST("Category").$ref 46 | @odata.PUT("Category").$ref 47 | async setCategory( @odata.key key: number, @odata.link link: number): Promise { 48 | const db = await connect(); 49 | const {rowCount} = await db.query(`UPDATE "Products" SET "CategoryId" = $2 WHERE "Id" = $1`, [key, link]); 50 | return rowCount; 51 | } 52 | 53 | @odata.DELETE("Category").$ref 54 | async unsetCategory( @odata.key key: number): Promise { 55 | const db = await connect(); 56 | const {rowCount} = await db.query(`UPDATE "Products" SET "CategoryId" = NULL WHERE "Id" = $1`, [key]); 57 | return rowCount; 58 | } 59 | 60 | @odata.POST 61 | async insert( @odata.body data: any): Promise { 62 | const db = await connect(); 63 | const {rows} = await insert(db, "Products", [data]); 64 | return convertResults(rows)[0]; 65 | } 66 | 67 | @odata.PUT 68 | async upsert( @odata.key key: number, @odata.body data: any, @odata.context context: any): Promise { 69 | const db = await connect(); 70 | const {rows} = await replace(db, "Products", key, data); 71 | return convertResults(rows)[0]; 72 | } 73 | 74 | @odata.PATCH 75 | async update( @odata.key key: number, @odata.body delta: any): Promise { 76 | const db = await connect(); 77 | const {rows} = await update(db, "Products", key, delta); 78 | return convertResults(rows)[0]; 79 | } 80 | 81 | @odata.DELETE 82 | async remove( @odata.key key: number): Promise { 83 | const db = await connect(); 84 | const {rowCount} = await db.query(`DELETE FROM "Products" WHERE "Id" = $1`, [key]); 85 | return rowCount; 86 | } 87 | 88 | @Edm.Function 89 | @Edm.EntityType(Product) 90 | async getCheapest(): Promise { 91 | const db = await connect(); 92 | const {rows} = await db.query(`SELECT * FROM "Products" ORDER BY "UnitPrice" LIMIT 1`); 93 | return convertResults(rows)[0]; 94 | } 95 | 96 | @Edm.Function 97 | @Edm.Collection(Edm.EntityType(Product)) 98 | async getInPriceRange( @Edm.Decimal min: number, @Edm.Decimal max: number): Promise { 99 | const db = await connect(); 100 | const {rows} = await db.query(`SELECT * FROM "Products" WHERE "UnitPrice" >= $1 AND "UnitPrice" <= $2`, [min, max]); 101 | return convertResults(rows); 102 | } 103 | 104 | @Edm.Action 105 | async swapPrice( @Edm.String a: number, @Edm.String b: number) { 106 | const db = await connect(); 107 | const {rows} = await db.query(`SELECT "Id", "UnitPrice" FROM "Products" WHERE "Id" IN ($1, $2)`, [a, b]); 108 | const aProduct = rows.find(product => product.Id === a); 109 | const bProduct = rows.find(product => product.Id === b); 110 | await db.query(`UPDATE "Products" SET "UnitPrice" = $1 WHERE "Id" = $2`, [bProduct.UnitPrice, aProduct.Id]); 111 | await db.query(`UPDATE "Products" SET "UnitPrice" = $1 WHERE "Id" = $2`, [aProduct.UnitPrice, bProduct.Id]); 112 | } 113 | 114 | @Edm.Action 115 | async discountProduct( @Edm.String productId: number, @Edm.Int32 percent: number) { 116 | const db = await connect(); 117 | await db.query(`UPDATE "Products" SET "UnitPrice" = $1 * "UnitPrice" WHERE "Id" = $2`, 118 | [((100 - percent) / 100), productId] 119 | ); 120 | } 121 | } 122 | 123 | @odata.type(Category) 124 | export class CategoriesController extends ODataController { 125 | 126 | @odata.GET 127 | async select( @odata.query query: ODataQuery): Promise { 128 | const db = await connect(); 129 | const sqlQuery = createQuery(query); 130 | const {rows} = await db.query(sqlQuery.from('"Categories"'), sqlQuery.parameters); 131 | return convertResults(rows); 132 | } 133 | 134 | @odata.GET 135 | async selectOne( @odata.key key: number, @odata.query query: ODataQuery): Promise { 136 | const db = await connect(); 137 | const sqlQuery = createQuery(query); 138 | const {rows} = await db.query(`SELECT ${sqlQuery.select} FROM "Categories" 139 | WHERE "Id" = $${sqlQuery.parameters.length + 1} AND 140 | (${sqlQuery.where})`, 141 | [...sqlQuery.parameters, key] 142 | ); 143 | return convertResults(rows)[0]; 144 | } 145 | 146 | @odata.GET("Products") 147 | async getProducts( @odata.result category: Category, @odata.query query: ODataQuery): Promise { 148 | const db = await connect(); 149 | const sqlQuery = createQuery(query); 150 | const {rows} = await db.query(`SELECT ${sqlQuery.select} FROM "Products" 151 | WHERE "CategoryId" = $${sqlQuery.parameters.length + 1} AND 152 | (${sqlQuery.where})`, 153 | [...sqlQuery.parameters, category.Id] 154 | ); 155 | return convertResults(rows); 156 | } 157 | 158 | @odata.GET("Products") 159 | async getProduct( @odata.key key: number, @odata.result category: Category, @odata.query query: ODataQuery): Promise { 160 | const db = await connect(); 161 | const sqlQuery = createQuery(query); 162 | const {rows} = await db.query(`SELECT ${sqlQuery.select} FROM "Products" 163 | WHERE "Id" = $${sqlQuery.parameters.length + 1} AND 164 | "CategoryId" = $${sqlQuery.parameters.length + 2} AND 165 | (${sqlQuery.where})`, 166 | [...sqlQuery.parameters, key, category.Id] 167 | ); 168 | return convertResults(rows)[0]; 169 | } 170 | 171 | @odata.POST("Products").$ref 172 | @odata.PUT("Products").$ref 173 | async setCategory( @odata.key key: number, @odata.link link: string): Promise { 174 | const db = await connect(); 175 | const {rowCount} = await db.query(`UPDATE "Products" SET "CategoryId" = $1 WHERE "Id" = $2`, [key, link]); 176 | return rowCount; 177 | } 178 | 179 | @odata.DELETE("Products").$ref 180 | async unsetCategory( @odata.key key: number, @odata.link link: string): Promise { 181 | const db = await connect(); 182 | const {rowCount} = await db.query(`UPDATE "Products" SET "CategoryId" = NULL WHERE "Id" = $1`, [key]); 183 | return rowCount; 184 | } 185 | 186 | @odata.POST 187 | async insert( @odata.body data: any): Promise { 188 | const db = await connect(); 189 | const {rows} = await insert(db, "Categories", [data]); 190 | return convertResults(rows)[0]; 191 | } 192 | 193 | @odata.PUT 194 | async upsert( @odata.key key: number, @odata.body data: any, @odata.context context: any): Promise { 195 | const db = await connect(); 196 | const {rows} = await replace(db, "Categories", key, data); 197 | return convertResults(rows)[0]; 198 | } 199 | 200 | @odata.PATCH 201 | async update( @odata.key key: number, @odata.body delta: any): Promise { 202 | const db = await connect(); 203 | const {rows} = await update(db, "Categories", key, delta); 204 | return convertResults(rows)[0]; 205 | } 206 | 207 | @odata.DELETE 208 | async remove( @odata.key key: number): Promise { 209 | const db = await connect(); 210 | const {rowCount} = await db.query(`DELETE FROM "Categories" WHERE "Id" = $1`, [key]); 211 | return rowCount; 212 | } 213 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as express from "express"; 2 | import { NorthwindServer } from "./server"; 3 | 4 | export default NorthwindServer.create(3000); -------------------------------------------------------------------------------- /src/model.ts: -------------------------------------------------------------------------------- 1 | import { Edm, odata } from "odata-v4-server"; 2 | import connect from "./utils/connect"; 3 | 4 | @Edm.Annotate({ 5 | term: "UI.DisplayName", 6 | string: "Products" 7 | }) 8 | export class Product { 9 | @Edm.Key 10 | @Edm.Computed 11 | @Edm.Int32 12 | @Edm.Annotate({ 13 | term: "UI.DisplayName", 14 | string: "Product identifier" 15 | }, { 16 | term: "UI.ControlHint", 17 | string: "ReadOnly" 18 | }) 19 | Id: number 20 | 21 | @Edm.Int32 22 | @Edm.Required 23 | CategoryId: number 24 | 25 | @Edm.EntityType("Category") 26 | @Edm.Partner("Products") 27 | Category: Category 28 | 29 | @Edm.Boolean 30 | Discontinued: boolean 31 | 32 | @Edm.String 33 | @Edm.Annotate({ 34 | term: "UI.DisplayName", 35 | string: "Product title" 36 | }, { 37 | term: "UI.ControlHint", 38 | string: "ShortText" 39 | }) 40 | Name: string 41 | 42 | @Edm.String 43 | @Edm.Annotate({ 44 | term: "UI.DisplayName", 45 | string: "Product English name" 46 | }, { 47 | term: "UI.ControlHint", 48 | string: "ShortText" 49 | }) 50 | QuantityPerUnit: string 51 | 52 | @Edm.Decimal 53 | @Edm.Annotate({ 54 | term: "UI.DisplayName", 55 | string: "Unit price of product" 56 | }, { 57 | term: "UI.ControlHint", 58 | string: "Decimal" 59 | }) 60 | UnitPrice: number 61 | 62 | @Edm.Function 63 | @Edm.Decimal 64 | getUnitPrice( @odata.result result: Product) { 65 | return result.UnitPrice; 66 | } 67 | 68 | @Edm.Action 69 | async invertDiscontinued( @odata.result result: Product) { 70 | const db = await connect(); 71 | await db.query(`UPDATE "Products" SET "Discontinued" = $1 WHERE "Id" = $2`, [!result.Discontinued, result.Id]); 72 | } 73 | 74 | @Edm.Action 75 | async setDiscontinued( @odata.result result: Product, @Edm.Boolean value: boolean) { 76 | const db = await connect(); 77 | await db.query(`UPDATE "Products" SET "Discontinued" = $1 WHERE "Id" = $2`, [value, result.Id]); 78 | } 79 | } 80 | 81 | @Edm.Annotate({ 82 | term: "UI.DisplayName", 83 | string: "Categories" 84 | }) 85 | export class Category { 86 | @Edm.Key 87 | @Edm.Computed 88 | @Edm.Int32 89 | @Edm.Annotate({ 90 | term: "UI.DisplayName", 91 | string: "Category identifier" 92 | }, 93 | { 94 | term: "UI.ControlHint", 95 | string: "ReadOnly" 96 | }) 97 | Id: number 98 | 99 | @Edm.String 100 | Description: string 101 | 102 | @Edm.String 103 | @Edm.Annotate({ 104 | term: "UI.DisplayName", 105 | string: "Category name" 106 | }, 107 | { 108 | term: "UI.ControlHint", 109 | string: "ShortText" 110 | }) 111 | Name: string 112 | 113 | @Edm.Collection(Edm.EntityType("Product")) 114 | @Edm.Partner("Category") 115 | Products: Product[] 116 | } -------------------------------------------------------------------------------- /src/products.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | "QuantityPerUnit": "10 boxes x 20 bags", 4 | "UnitPrice": 39, 5 | "CategoryId": 1, 6 | "Name": "Chai", 7 | "Discontinued": false, 8 | "Id": 1 9 | }, 10 | { 11 | "QuantityPerUnit": "24 - 12 oz bottles", 12 | "UnitPrice": 19, 13 | "CategoryId": 1, 14 | "Name": "Chang", 15 | "Discontinued": true, 16 | "Id": 2 17 | }, 18 | { 19 | "QuantityPerUnit": "12 - 550 ml bottles", 20 | "UnitPrice": 10, 21 | "CategoryId": 6, 22 | "Name": "Aniseed Syrup", 23 | "Discontinued": false, 24 | "Id": 3 25 | }, 26 | { 27 | "QuantityPerUnit": "48 - 6 oz jars", 28 | "UnitPrice": 22, 29 | "CategoryId": 6, 30 | "Name": "Chef Anton's Cajun Seasoning", 31 | "Discontinued": true, 32 | "Id": 4 33 | }, 34 | { 35 | "QuantityPerUnit": "36 boxes", 36 | "UnitPrice": 21.35, 37 | "CategoryId": 6, 38 | "Name": "Chef Anton's Gumbo Mix", 39 | "Discontinued": false, 40 | "Id": 5 41 | }, 42 | { 43 | "QuantityPerUnit": "12 - 8 oz jars", 44 | "UnitPrice": 25, 45 | "CategoryId": 6, 46 | "Name": "Grandma's Boysenberry Spread", 47 | "Discontinued": false, 48 | "Id": 6 49 | }, 50 | { 51 | "QuantityPerUnit": "12 - 200 ml jars", 52 | "UnitPrice": 31, 53 | "CategoryId": 5, 54 | "Name": "Ikura", 55 | "Discontinued": false, 56 | "Id": 7 57 | }, 58 | { 59 | "QuantityPerUnit": "1 kg pkg.", 60 | "UnitPrice": 21, 61 | "CategoryId": 7, 62 | "Name": "Queso Cabrales", 63 | "Discontinued": false, 64 | "Id": 8 65 | }, 66 | { 67 | "QuantityPerUnit": "10 - 500 g pkgs.", 68 | "UnitPrice": 38, 69 | "CategoryId": 7, 70 | "Name": "Queso Manchego La Pastora", 71 | "Discontinued": true, 72 | "Id": 9 73 | }, 74 | { 75 | "QuantityPerUnit": "2 kg box", 76 | "UnitPrice": 6, 77 | "CategoryId": 5, 78 | "Name": "Konbu", 79 | "Discontinued": false, 80 | "Id": 10 81 | }, 82 | { 83 | "QuantityPerUnit": "40 - 100 g pkgs.", 84 | "UnitPrice": 23.25, 85 | "CategoryId": 4, 86 | "Name": "Tofu", 87 | "Discontinued": false, 88 | "Id": 11 89 | }, 90 | { 91 | "QuantityPerUnit": "24 - 250 ml bottles", 92 | "UnitPrice": 15.5, 93 | "CategoryId": 6, 94 | "Name": "Genen Shouyu", 95 | "Discontinued": false, 96 | "Id": 12 97 | }, 98 | { 99 | "QuantityPerUnit": "32 - 500 g boxes", 100 | "UnitPrice": 17.45, 101 | "CategoryId": 8, 102 | "Name": "Pavlova", 103 | "Discontinued": false, 104 | "Id": 13 105 | }, 106 | { 107 | "QuantityPerUnit": "20 - 1 kg tins", 108 | "UnitPrice": 39, 109 | "CategoryId": 3, 110 | "Name": "Alice Mutton", 111 | "Discontinued": false, 112 | "Id": 14 113 | }, 114 | { 115 | "QuantityPerUnit": "16 kg pkg.", 116 | "UnitPrice": 62.5, 117 | "CategoryId": 5, 118 | "Name": "Carnarvon Tigers", 119 | "Discontinued": false, 120 | "Id": 15 121 | }, 122 | { 123 | "QuantityPerUnit": "10 boxes x 12 pieces", 124 | "UnitPrice": 9.2, 125 | "CategoryId": 8, 126 | "Name": "Teatime Chocolate Biscuits", 127 | "Discontinued": true, 128 | "Id": 16 129 | }, 130 | { 131 | "QuantityPerUnit": "30 gift boxes", 132 | "UnitPrice": 81, 133 | "CategoryId": 8, 134 | "Name": "Sir Rodney's Marmalade", 135 | "Discontinued": false, 136 | "Id": 17 137 | }, 138 | { 139 | "QuantityPerUnit": "24 pkgs. x 4 pieces", 140 | "UnitPrice": 10, 141 | "CategoryId": 8, 142 | "Name": "Sir Rodney's Scones", 143 | "Discontinued": false, 144 | "Id": 18 145 | }, 146 | { 147 | "QuantityPerUnit": "12 - 1 lb pkgs.", 148 | "UnitPrice": 30, 149 | "CategoryId": 4, 150 | "Name": "Uncle Bob's Organic Dried Pears", 151 | "Discontinued": true, 152 | "Id": 19 153 | }, 154 | { 155 | "QuantityPerUnit": "12 - 12 oz jars", 156 | "UnitPrice": 40, 157 | "CategoryId": 6, 158 | "Name": "Northwoods Cranberry Sauce", 159 | "Discontinued": false, 160 | "Id": 20 161 | }, 162 | { 163 | "QuantityPerUnit": "18 - 500 g pkgs.", 164 | "UnitPrice": 97, 165 | "CategoryId": 3, 166 | "Name": "Mishi Kobe Niku", 167 | "Discontinued": false, 168 | "Id": 21 169 | }, 170 | { 171 | "QuantityPerUnit": "24 - 500 g pkgs.", 172 | "UnitPrice": 21, 173 | "CategoryId": 2, 174 | "Name": "Gustaf's Knäckebröd", 175 | "Discontinued": false, 176 | "Id": 22 177 | }, 178 | { 179 | "QuantityPerUnit": "12 - 250 g pkgs.", 180 | "UnitPrice": 9, 181 | "CategoryId": 2, 182 | "Name": "Tunnbröd", 183 | "Discontinued": false, 184 | "Id": 23 185 | }, 186 | { 187 | "QuantityPerUnit": "12 - 355 ml cans", 188 | "UnitPrice": 4.5, 189 | "CategoryId": 1, 190 | "Name": "Guaraná Fantástica", 191 | "Discontinued": false, 192 | "Id": 24 193 | }, 194 | { 195 | "QuantityPerUnit": "20 - 450 g glasses", 196 | "UnitPrice": 14, 197 | "CategoryId": 8, 198 | "Name": "NuNuCa Nuß-Nougat-Creme", 199 | "Discontinued": true, 200 | "Id": 25 201 | }, 202 | { 203 | "QuantityPerUnit": "100 - 250 g bags", 204 | "UnitPrice": 31.23, 205 | "CategoryId": 8, 206 | "Name": "Gumbär Gummibärchen", 207 | "Discontinued": false, 208 | "Id": 26 209 | }, 210 | { 211 | "QuantityPerUnit": "10 - 200 g glasses", 212 | "UnitPrice": 25.89, 213 | "CategoryId": 5, 214 | "Name": "Nord-Ost Matjeshering", 215 | "Discontinued": true, 216 | "Id": 27 217 | }, 218 | { 219 | "QuantityPerUnit": "12 - 100 g pkgs", 220 | "UnitPrice": 12.5, 221 | "CategoryId": 7, 222 | "Name": "Gorgonzola Telino", 223 | "Discontinued": false, 224 | "Id": 28 225 | }, 226 | { 227 | "QuantityPerUnit": "24 - 200 g pkgs.", 228 | "UnitPrice": 32, 229 | "CategoryId": 7, 230 | "Name": "Mascarpone Fabioli", 231 | "Discontinued": false, 232 | "Id": 29 233 | }, 234 | { 235 | "QuantityPerUnit": "500 g", 236 | "UnitPrice": 2.5, 237 | "CategoryId": 7, 238 | "Name": "Geitost", 239 | "Discontinued": false, 240 | "Id": 30 241 | }, 242 | { 243 | "QuantityPerUnit": "24 - 12 oz bottles", 244 | "UnitPrice": 14, 245 | "CategoryId": 1, 246 | "Name": "Sasquatch Ale", 247 | "Discontinued": false, 248 | "Id": 31 249 | }, 250 | { 251 | "QuantityPerUnit": "24 - 12 oz bottles", 252 | "UnitPrice": 18, 253 | "CategoryId": 1, 254 | "Name": "Steeleye Stout", 255 | "Discontinued": false, 256 | "Id": 32 257 | }, 258 | { 259 | "QuantityPerUnit": "24 - 250 g jars", 260 | "UnitPrice": 19, 261 | "CategoryId": 5, 262 | "Name": "Inlagd Sill", 263 | "Discontinued": false, 264 | "Id": 33 265 | }, 266 | { 267 | "QuantityPerUnit": "12 - 500 g pkgs.", 268 | "UnitPrice": 26, 269 | "CategoryId": 5, 270 | "Name": "Gravad lax", 271 | "Discontinued": false, 272 | "Id": 34 273 | }, 274 | { 275 | "QuantityPerUnit": "12 - 75 cl bottles", 276 | "UnitPrice": 263.5, 277 | "CategoryId": 1, 278 | "Name": "Côte de Blaye", 279 | "Discontinued": false, 280 | "Id": 35 281 | }, 282 | { 283 | "QuantityPerUnit": "750 cc per bottle", 284 | "UnitPrice": 18, 285 | "CategoryId": 1, 286 | "Name": "Chartreuse verte", 287 | "Discontinued": false, 288 | "Id": 36 289 | }, 290 | { 291 | "QuantityPerUnit": "24 - 4 oz tins", 292 | "UnitPrice": 18.4, 293 | "CategoryId": 5, 294 | "Name": "Boston Crab Meat", 295 | "Discontinued": false, 296 | "Id": 37 297 | }, 298 | { 299 | "QuantityPerUnit": "12 - 12 oz cans", 300 | "UnitPrice": 9.65, 301 | "CategoryId": 5, 302 | "Name": "Jack's New England Clam Chowder", 303 | "Discontinued": true, 304 | "Id": 38 305 | }, 306 | { 307 | "QuantityPerUnit": "32 - 1 kg pkgs.", 308 | "UnitPrice": 14, 309 | "CategoryId": 2, 310 | "Name": "Singaporean Hokkien Fried Mee", 311 | "Discontinued": true, 312 | "Id": 39 313 | }, 314 | { 315 | "QuantityPerUnit": "16 - 500 g tins", 316 | "UnitPrice": 46, 317 | "CategoryId": 1, 318 | "Name": "Ipoh Coffee", 319 | "Discontinued": false, 320 | "Id": 40 321 | }, 322 | { 323 | "QuantityPerUnit": "1k pkg.", 324 | "UnitPrice": 9.5, 325 | "CategoryId": 5, 326 | "Name": "Rogede sild", 327 | "Discontinued": false, 328 | "Id": 41 329 | }, 330 | { 331 | "QuantityPerUnit": "4 - 450 g glasses", 332 | "UnitPrice": 12, 333 | "CategoryId": 5, 334 | "Name": "Spegesild", 335 | "Discontinued": false, 336 | "Id": 42 337 | }, 338 | { 339 | "QuantityPerUnit": "10 - 4 oz boxes", 340 | "UnitPrice": 9.5, 341 | "CategoryId": 8, 342 | "Name": "Zaanse koeken", 343 | "Discontinued": false, 344 | "Id": 43 345 | }, 346 | { 347 | "QuantityPerUnit": "100 - 100 g pieces", 348 | "UnitPrice": 43.9, 349 | "CategoryId": 8, 350 | "Name": "Schoggi Schokolade", 351 | "Discontinued": false, 352 | "Id": 44 353 | }, 354 | { 355 | "QuantityPerUnit": "25 - 825 g cans", 356 | "UnitPrice": 45.6, 357 | "CategoryId": 4, 358 | "Name": "Rössle Sauerkraut", 359 | "Discontinued": false, 360 | "Id": 45 361 | }, 362 | { 363 | "QuantityPerUnit": "50 bags x 30 sausgs.", 364 | "UnitPrice": 123.79, 365 | "CategoryId": 3, 366 | "Name": "Thüringer Rostbratwurst", 367 | "Discontinued": true, 368 | "Id": 46 369 | }, 370 | { 371 | "QuantityPerUnit": "10 pkgs.", 372 | "UnitPrice": 12.75, 373 | "CategoryId": 8, 374 | "Name": "Chocolade", 375 | "Discontinued": false, 376 | "Id": 47 377 | }, 378 | { 379 | "QuantityPerUnit": "24 - 50 g pkgs.", 380 | "UnitPrice": 20, 381 | "CategoryId": 8, 382 | "Name": "Maxilaku", 383 | "Discontinued": false, 384 | "Id": 48 385 | }, 386 | { 387 | "QuantityPerUnit": "12 - 100 g bars", 388 | "UnitPrice": 16.25, 389 | "CategoryId": 8, 390 | "Name": "Valkoinen suklaa", 391 | "Discontinued": true, 392 | "Id": 49 393 | }, 394 | { 395 | "QuantityPerUnit": "50 - 300 g pkgs.", 396 | "UnitPrice": 53, 397 | "CategoryId": 4, 398 | "Name": "Manjimup Dried Apples", 399 | "Discontinued": true, 400 | "Id": 50 401 | }, 402 | { 403 | "QuantityPerUnit": "16 - 2 kg boxes", 404 | "UnitPrice": 7, 405 | "CategoryId": 2, 406 | "Name": "Filo Mix", 407 | "Discontinued": false, 408 | "Id": 51 409 | }, 410 | { 411 | "QuantityPerUnit": "24 - 250 g pkgs.", 412 | "UnitPrice": 38, 413 | "CategoryId": 2, 414 | "Name": "Gnocchi di nonna Alice", 415 | "Discontinued": true, 416 | "Id": 52 417 | }, 418 | { 419 | "QuantityPerUnit": "24 - 250 g pkgs.", 420 | "UnitPrice": 19.5, 421 | "CategoryId": 2, 422 | "Name": "Ravioli Angelo", 423 | "Discontinued": false, 424 | "Id": 53 425 | }, 426 | { 427 | "QuantityPerUnit": "24 pieces", 428 | "UnitPrice": 13.25, 429 | "CategoryId": 5, 430 | "Name": "Escargots de Bourgogne", 431 | "Discontinued": false, 432 | "Id": 54 433 | }, 434 | { 435 | "QuantityPerUnit": "5 kg pkg.", 436 | "UnitPrice": 55, 437 | "CategoryId": 7, 438 | "Name": "Raclette Courdavault", 439 | "Discontinued": false, 440 | "Id": 55 441 | }, 442 | { 443 | "QuantityPerUnit": "15 - 300 g rounds", 444 | "UnitPrice": 34, 445 | "CategoryId": 7, 446 | "Name": "Camembert Pierrot", 447 | "Discontinued": true, 448 | "Id": 56 449 | }, 450 | { 451 | "QuantityPerUnit": "24 - 500 ml bottles", 452 | "UnitPrice": 28.5, 453 | "CategoryId": 6, 454 | "Name": "Sirop d'érable", 455 | "Discontinued": true, 456 | "Id": 57 457 | }, 458 | { 459 | "QuantityPerUnit": "48 pies", 460 | "UnitPrice": 49.3, 461 | "CategoryId": 8, 462 | "Name": "Tarte au sucre", 463 | "Discontinued": false, 464 | "Id": 58 465 | }, 466 | { 467 | "QuantityPerUnit": "15 - 625 g jars", 468 | "UnitPrice": 43.9, 469 | "CategoryId": 6, 470 | "Name": "Vegie-spread", 471 | "Discontinued": false, 472 | "Id": 59 473 | }, 474 | { 475 | "QuantityPerUnit": "20 bags x 4 pieces", 476 | "UnitPrice": 33.25, 477 | "CategoryId": 2, 478 | "Name": "Wimmers gute Semmelknödel", 479 | "Discontinued": true, 480 | "Id": 60 481 | }, 482 | { 483 | "QuantityPerUnit": "32 - 8 oz bottles", 484 | "UnitPrice": 21.05, 485 | "CategoryId": 6, 486 | "Name": "Louisiana Fiery Hot Pepper Sauce", 487 | "Discontinued": true, 488 | "Id": 61 489 | }, 490 | { 491 | "QuantityPerUnit": "24 - 8 oz jars", 492 | "UnitPrice": 17, 493 | "CategoryId": 6, 494 | "Name": "Louisiana Hot Spiced Okra", 495 | "Discontinued": false, 496 | "Id": 62 497 | }, 498 | { 499 | "QuantityPerUnit": "24 - 12 oz bottles", 500 | "UnitPrice": 14, 501 | "CategoryId": 1, 502 | "Name": "Laughing Lumberjack Lager", 503 | "Discontinued": true, 504 | "Id": 63 505 | }, 506 | { 507 | "QuantityPerUnit": "10 boxes x 8 pieces", 508 | "UnitPrice": 12.5, 509 | "CategoryId": 8, 510 | "Name": "Scottish Longbreads", 511 | "Discontinued": false, 512 | "Id": 64 513 | }, 514 | { 515 | "QuantityPerUnit": "Crate", 516 | "UnitPrice": 666, 517 | "CategoryId": 7, 518 | "Name": "MyProduct", 519 | "Discontinued": true, 520 | "Id": 65 521 | }, 522 | { 523 | "QuantityPerUnit": "24 - 355 ml bottles", 524 | "UnitPrice": 15, 525 | "CategoryId": 1, 526 | "Name": "Outback Lager", 527 | "Discontinued": false, 528 | "Id": 66 529 | }, 530 | { 531 | "QuantityPerUnit": "10 - 500 g pkgs.", 532 | "UnitPrice": 21.5, 533 | "CategoryId": 7, 534 | "Name": "Flotemysost", 535 | "Discontinued": false, 536 | "Id": 67 537 | }, 538 | { 539 | "QuantityPerUnit": "24 - 200 g pkgs.", 540 | "UnitPrice": 34.8, 541 | "CategoryId": 7, 542 | "Name": "Mozzarella di Giovanni", 543 | "Discontinued": false, 544 | "Id": 68 545 | }, 546 | { 547 | "QuantityPerUnit": "24 - 150 g jars", 548 | "UnitPrice": 15, 549 | "CategoryId": 5, 550 | "Name": "Röd Kaviar", 551 | "Discontinued": false, 552 | "Id": 69 553 | }, 554 | { 555 | "QuantityPerUnit": "48 pieces", 556 | "UnitPrice": 32.8, 557 | "CategoryId": 3, 558 | "Name": "Perth Pasties", 559 | "Discontinued": true, 560 | "Id": 70 561 | }, 562 | { 563 | "QuantityPerUnit": "16 pies", 564 | "UnitPrice": 7.45, 565 | "CategoryId": 3, 566 | "Name": "Tourtière", 567 | "Discontinued": true, 568 | "Id": 71 569 | }, 570 | { 571 | "QuantityPerUnit": "24 boxes x 2 pies", 572 | "UnitPrice": 24, 573 | "CategoryId": 3, 574 | "Name": "Pâté chinois", 575 | "Discontinued": true, 576 | "Id": 72 577 | }, 578 | { 579 | "QuantityPerUnit": "5 kg pkg.", 580 | "UnitPrice": 10, 581 | "CategoryId": 4, 582 | "Name": "Longlife Tofu", 583 | "Discontinued": false, 584 | "Id": 73 585 | }, 586 | { 587 | "QuantityPerUnit": "24 - 0.5 l bottles", 588 | "UnitPrice": 7.75, 589 | "CategoryId": 1, 590 | "Name": "Rhönbräu Klosterbier", 591 | "Discontinued": true, 592 | "Id": 74 593 | }, 594 | { 595 | "QuantityPerUnit": "500 ml", 596 | "UnitPrice": 18, 597 | "CategoryId": 1, 598 | "Name": "Lakkalikööri", 599 | "Discontinued": false, 600 | "Id": 75 601 | }, 602 | { 603 | "QuantityPerUnit": "12 boxes", 604 | "UnitPrice": 13, 605 | "CategoryId": 6, 606 | "Name": "Original Frankfurter grüne Soße", 607 | "Discontinued": false, 608 | "Id": 76 609 | } 610 | ] -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { ODataServer, ODataController, Edm, odata, ODataQuery } from "odata-v4-server"; 2 | import { ProductsController, CategoriesController } from "./controller"; 3 | import connect from "./utils/connect"; 4 | import categories from "./categories"; 5 | import products from "./products"; 6 | import insert from "./utils/insert"; 7 | 8 | @odata.namespace("Northwind") 9 | @odata.controller(ProductsController, true) 10 | @odata.controller(CategoriesController, true) 11 | export class NorthwindServer extends ODataServer { 12 | 13 | @Edm.ActionImport 14 | async initDb() { 15 | const db = await connect(); 16 | 17 | await db.query(`DROP TABLE IF EXISTS "Categories", "Products"`); 18 | 19 | await db.query(`CREATE TABLE "Categories" ( 20 | "Id" SERIAL PRIMARY KEY, 21 | "Name" VARCHAR(32), 22 | "Description" VARCHAR(25) 23 | );`); 24 | 25 | await db.query(`CREATE TABLE "Products" ( 26 | "Id" SERIAL PRIMARY KEY, 27 | "Name" VARCHAR(32), 28 | "QuantityPerUnit" VARCHAR(20), 29 | "UnitPrice" NUMERIC(5,2), 30 | "CategoryId" INT, 31 | "Discontinued" BOOLEAN 32 | );`); 33 | 34 | await insert(db, "Categories", categories); 35 | 36 | await insert(db, "Products", products); 37 | } 38 | } -------------------------------------------------------------------------------- /src/utils/connect.ts: -------------------------------------------------------------------------------- 1 | import * as pg from "pg"; 2 | 3 | let db: pg.Client | null = null; 4 | 5 | function promisify(client) { 6 | return new Proxy(client, { 7 | get(target, name) { 8 | if (name !== 'query') 9 | return target[name]; 10 | 11 | return function (...args) { 12 | return new Promise((resolve, reject) => { 13 | target.query(...args, (err, result) => { 14 | if (err) return reject(err); 15 | resolve(result); 16 | }); 17 | }); 18 | } 19 | } 20 | }); 21 | } 22 | 23 | export default async function (): Promise { 24 | 25 | if (db) 26 | return db; 27 | 28 | const pool = new pg.Pool({ 29 | user: 'postgres', 30 | password: 'postgres', 31 | database: 'northwind' 32 | }); 33 | 34 | return new Promise((resolve: Function, reject: Function) => { 35 | pool.connect((err, client) => { 36 | if (err) return reject(err); 37 | db = promisify(client); 38 | resolve(db); 39 | }); 40 | }); 41 | } -------------------------------------------------------------------------------- /src/utils/convertResults.ts: -------------------------------------------------------------------------------- 1 | function filterNullValues(item) { 2 | const newItem = {}; 3 | Object.keys(item) 4 | .filter(key => item[key] !== null) 5 | .forEach(key => newItem[key] = item[key]); 6 | return newItem; 7 | } 8 | 9 | export default function (rows) { 10 | return rows.map(row => 11 | Object.assign({}, filterNullValues(row), "UnitPrice" in row && row.UnitPrice !== null ? 12 | { UnitPrice: parseFloat(row.UnitPrice) } : 13 | {} 14 | ) 15 | ); 16 | } -------------------------------------------------------------------------------- /src/utils/insert.ts: -------------------------------------------------------------------------------- 1 | import * as pg from "pg"; 2 | import { flatten } from "ramda"; 3 | 4 | /** 5 | * This function returns a clause string such as: 6 | * ($1, $2, $3), 7 | * ($2, $3, $4), 8 | * ($5, $5, $7) 9 | * 10 | * The parameters are the 11 | * 1) the items in Object[] format: [{Id: 1, Name: 'foo', Active: true}] 12 | * 2) if you provide types, it will enforce the type annotation such as: 13 | * ($1::int, $2::varchar, $3::boolean), 14 | * ($2::int, $3::varchar, $4::boolean), 15 | * ($5::int, $5::varchar, $7::boolean) 16 | */ 17 | 18 | function getPrepareClause(items: any[], types?: string[]): string { 19 | const metaColumns = Array.from({ length: Object.keys(items[0]).length }); 20 | return items.map( 21 | (item, i) => '(' + metaColumns.map( 22 | (_, j) => types ? `$${i * metaColumns.length + j + 1}::${types[j]}` : `$${i * metaColumns.length + j + 1}` 23 | ).join(', ') + ')' 24 | ).join(',\n'); 25 | } 26 | 27 | async function ensureIdIncrement(db: pg.Client, tableName: string, items: any[]) { 28 | if (!items.some(item => "Id" in item)) 29 | return; 30 | 31 | const {rows: [{"?column?": max}]} = await db.query(`SELECT MAX("Id")+1 FROM "${tableName}"`); 32 | 33 | await db.query(`ALTER SEQUENCE "${tableName}_Id_seq" RESTART WITH ${max}`); 34 | } 35 | 36 | export default async function (db: pg.Client, tableName: string, items: any[], propertyNameProjection?: string[], types?: string[]) { 37 | if (items.length === 0) 38 | return; 39 | 40 | const properties = propertyNameProjection || Object.keys(items[0]); 41 | 42 | const clause = `INSERT INTO "${tableName}" 43 | (${properties.map(propName => `"${propName}"`).join(', ')}) 44 | VALUES 45 | ${getPrepareClause(items, types)} 46 | RETURNING *`; 47 | 48 | const values = flatten(items.map(item => properties.map(propName => item[propName]))); 49 | 50 | const insertionResult = await db.query(clause, values); 51 | 52 | await ensureIdIncrement(db, tableName, items); 53 | 54 | return insertionResult; 55 | } -------------------------------------------------------------------------------- /src/utils/replace.ts: -------------------------------------------------------------------------------- 1 | import * as pg from "pg"; 2 | import insert from "./insert"; 3 | 4 | export default async function (db: pg.Client, tableName: string, id: number, item: any) { 5 | await db.query(`DELETE FROM "${tableName}" WHERE "Id" = $1`, [id]); 6 | return await insert(db, tableName, [Object.assign({}, item, { Id: id })]); 7 | } -------------------------------------------------------------------------------- /src/utils/update.ts: -------------------------------------------------------------------------------- 1 | import * as pg from "pg"; 2 | 3 | export default async function (db: pg.Client, tableName: string, id: number, delta: any) { 4 | 5 | const properties = Object.keys(delta); 6 | 7 | const clause = `UPDATE "${tableName}" 8 | SET ${properties.map((propName, i) => `"${propName}" = $${i + 1}`).join(', ')} 9 | WHERE "Id" = ${id} 10 | RETURNING *`; 11 | 12 | const values = properties.map(propName => delta[propName]); 13 | 14 | return await db.query(clause, values); 15 | } -------------------------------------------------------------------------------- /test/pgsql.spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { NorthwindServer } = require("../lib/server"); 4 | const { Product, Category } = require("../lib/model"); 5 | const {expect} = require("chai"); 6 | const products = require("../lib/products").default; 7 | const categories = require("../lib/categories").default; 8 | 9 | function createTest(testcase, command, compare, body){ 10 | it(`${testcase} (${command})`, () => { 11 | let test = command.split(" "); 12 | return NorthwindServer.execute(test.slice(1).join(" "), test[0], body).then((result) => { 13 | expect(result).to.deep.equal(compare); 14 | }); 15 | }); 16 | } 17 | 18 | createTest.only = function(testcase, command, compare, body) { 19 | it.only(`${testcase} (${command})`, () => { 20 | let test = command.split(" "); 21 | return NorthwindServer.execute(test.slice(1).join(" "), test[0], body).then((result) => { 22 | expect(result).to.deep.equal(compare); 23 | }); 24 | }); 25 | } 26 | 27 | function xcreateTest(testcase, command, compare, body) { 28 | xit(`${testcase} (${command})`, () => {}); 29 | } 30 | 31 | describe("OData V4 example server", () => { 32 | 33 | beforeEach(() => { 34 | return NorthwindServer.execute("/initDb", "POST"); 35 | }); 36 | 37 | describe("Products", () => { 38 | createTest("should get all products", "GET /Products", { 39 | statusCode: 200, 40 | body: { 41 | "@odata.context": "http://localhost/$metadata#Products", 42 | value: products.map(product => Object.assign({}, product, { 43 | "@odata.id": `http://localhost/Products(${product.Id})`, 44 | "@odata.editLink": `http://localhost/Products(${product.Id})` 45 | })) 46 | }, 47 | elementType: Product, 48 | contentType: "application/json" 49 | }); 50 | 51 | createTest("should get products by filter", "GET /Products?$filter=Name eq 'Chai'", { 52 | statusCode: 200, 53 | body: { 54 | "@odata.context": "http://localhost/$metadata#Products", 55 | value: products.filter(product => product.Name == "Chai").map(product => Object.assign({}, product, { 56 | "@odata.id": `http://localhost/Products(${product.Id})`, 57 | "@odata.editLink": `http://localhost/Products(${product.Id})` 58 | })) 59 | }, 60 | elementType: Product, 61 | contentType: "application/json" 62 | }); 63 | 64 | createTest("should get products by filter and select", "GET /Products?$filter=Name eq 'Chai'&$select=Name,UnitPrice", { 65 | statusCode: 200, 66 | body: { 67 | "@odata.context": "http://localhost/$metadata#Products(Name,UnitPrice)", 68 | value: products.filter(product => product.Name == "Chai").map((product) => { 69 | return { 70 | Name: product.Name, 71 | UnitPrice: product.UnitPrice 72 | }; 73 | }) 74 | }, 75 | elementType: Product, 76 | contentType: "application/json" 77 | }); 78 | 79 | xcreateTest("should get products in name order", "GET /Products?$orderby=Name", { 80 | statusCode: 200, 81 | body: { 82 | "@odata.context": "http://localhost/$metadata#Products", 83 | value: products.map((product) => { 84 | return Object.assign({}, product, { 85 | "@odata.id": `http://localhost/Products(${product.Id})`, 86 | "@odata.editLink": `http://localhost/Products(${product.Id})`, 87 | }); 88 | }).sort((a, b) => { 89 | if (a.Name < b.Name) 90 | return -1; 91 | if (a.Name > b.Name) 92 | return 1; 93 | return 0; 94 | }) 95 | }, 96 | elementType: Product, 97 | contentType: "application/json" 98 | }); 99 | 100 | createTest("should get product by key", "GET /Products(1)", { 101 | statusCode: 200, 102 | body: Object.assign({ 103 | "@odata.context": "http://localhost/$metadata#Products/$entity" 104 | }, products.filter(product => product.Id == 1).map(product => Object.assign({}, product, { 105 | "@odata.id": `http://localhost/Products(${product.Id})`, 106 | "@odata.editLink": `http://localhost/Products(${product.Id})` 107 | }))[0] 108 | ), 109 | elementType: Product, 110 | contentType: "application/json" 111 | }); 112 | 113 | it("should create new product", () => { 114 | return NorthwindServer.execute("/Products", "POST", { 115 | Name: "New product", 116 | CategoryId: categories[0].Id 117 | }).then((result) => { 118 | expect(result).to.deep.equal({ 119 | statusCode: 201, 120 | body: { 121 | "@odata.context": "http://localhost/$metadata#Products/$entity", 122 | "@odata.id": `http://localhost/Products(${result.body.Id})`, 123 | "@odata.editLink": `http://localhost/Products(${result.body.Id})`, 124 | Id: result.body.Id, 125 | Name: "New product", 126 | CategoryId: categories[0].Id 127 | }, 128 | elementType: Product, 129 | contentType: "application/json" 130 | }); 131 | }); 132 | }); 133 | 134 | it("should update product", () => { 135 | return NorthwindServer.execute("/Products(1)", "PUT", { 136 | Name: "Chai (updated)" 137 | }).then((result) => { 138 | expect(result).to.deep.equal({ 139 | statusCode: 204 140 | }); 141 | 142 | return NorthwindServer.execute("/Products(1)", "GET").then((result) => { 143 | expect(result).to.deep.equal({ 144 | statusCode: 200, 145 | body: { 146 | "@odata.context": "http://localhost/$metadata#Products/$entity", 147 | "@odata.id": `http://localhost/Products(1)`, 148 | "@odata.editLink": `http://localhost/Products(1)`, 149 | Name: "Chai (updated)", 150 | Id: 1 151 | }, 152 | elementType: Product, 153 | contentType: "application/json" 154 | }); 155 | }); 156 | }); 157 | }); 158 | 159 | it("should delta update product", () => { 160 | return NorthwindServer.execute("/Products(1)", "PATCH", { 161 | Name: "Chai (updated)" 162 | }).then((result) => { 163 | expect(result).to.deep.equal({ 164 | statusCode: 204 165 | }); 166 | 167 | return NorthwindServer.execute("/Products(1)", "GET").then((result) => { 168 | expect(result).to.deep.equal({ 169 | statusCode: 200, 170 | body: products.filter(product => product.Id == 1).map(product => Object.assign({}, product, { 171 | "@odata.context": "http://localhost/$metadata#Products/$entity", 172 | "@odata.id": `http://localhost/Products(${product.Id})`, 173 | "@odata.editLink": `http://localhost/Products(${product.Id})`, 174 | Name: "Chai (updated)" 175 | }))[0], 176 | elementType: Product, 177 | contentType: "application/json" 178 | }); 179 | }); 180 | }); 181 | }); 182 | 183 | it("should delete product", () => { 184 | return NorthwindServer.execute("/Products(1)", "DELETE").then((result) => { 185 | expect(result).to.deep.equal({ 186 | statusCode: 204 187 | }); 188 | 189 | return NorthwindServer.execute("/Products(1)", "GET").then(() => { 190 | throw new Error("Product should be deleted."); 191 | }, (err) => { 192 | expect(err.name).to.equal("ResourceNotFoundError"); 193 | }); 194 | }); 195 | }); 196 | 197 | createTest("should get category by product", "GET /Products(1)/Category", { 198 | statusCode: 200, 199 | body: Object.assign({ 200 | "@odata.context": "http://localhost/$metadata#Categories/$entity" 201 | }, categories.filter(category => category.Id == 1).map(category => Object.assign({}, category, { 202 | "@odata.id": `http://localhost/Categories(${category.Id})`, 203 | "@odata.editLink": `http://localhost/Categories(${category.Id})` 204 | }))[0] 205 | ), 206 | elementType: Category, 207 | contentType: "application/json" 208 | }); 209 | 210 | it("should create category reference on product", () => { 211 | return NorthwindServer.execute("/Products(1)/Category/$ref", "POST", { 212 | "@odata.id": "http://localhost/Categories(2)" 213 | }).then((result) => { 214 | expect(result).to.deep.equal({ 215 | statusCode: 204 216 | }); 217 | 218 | return NorthwindServer.execute("/Products(1)/Category", "GET").then((result) => { 219 | expect(result).to.deep.equal({ 220 | statusCode: 200, 221 | body: Object.assign({ 222 | "@odata.context": "http://localhost/$metadata#Categories/$entity" 223 | }, categories.filter(category => category.Id == 2).map(category => Object.assign({}, category, { 224 | "@odata.id": `http://localhost/Categories(${category.Id})`, 225 | "@odata.editLink": `http://localhost/Categories(${category.Id})` 226 | }))[0] 227 | ), 228 | elementType: Category, 229 | contentType: "application/json" 230 | }) 231 | }); 232 | }); 233 | }); 234 | 235 | it("should update category reference on product", () => { 236 | return NorthwindServer.execute("/Products(1)/Category/$ref", "PUT", { 237 | "@odata.id": "http://localhost/Categories(2)" 238 | }).then((result) => { 239 | expect(result).to.deep.equal({ 240 | statusCode: 204 241 | }); 242 | 243 | return NorthwindServer.execute("/Products(1)/Category", "GET").then((result) => { 244 | expect(result).to.deep.equal({ 245 | statusCode: 200, 246 | body: Object.assign({ 247 | "@odata.context": "http://localhost/$metadata#Categories/$entity" 248 | }, categories.filter(category => category.Id == 2).map(category => Object.assign({}, category, { 249 | "@odata.id": `http://localhost/Categories(${category.Id})`, 250 | "@odata.editLink": `http://localhost/Categories(${category.Id})` 251 | }))[0] 252 | ), 253 | elementType: Category, 254 | contentType: "application/json" 255 | }) 256 | }); 257 | }); 258 | }); 259 | 260 | it("should delete category reference on product", () => { 261 | return NorthwindServer.execute("/Products(1)/Category/$ref", "DELETE").then((result) => { 262 | expect(result).to.deep.equal({ 263 | statusCode: 204 264 | }); 265 | 266 | return NorthwindServer.execute("/Products(1)/Category", "GET").then((result) => { 267 | throw new Error("Category reference should be deleted."); 268 | }, (err) => { 269 | expect(err.name).to.equal("ResourceNotFoundError"); 270 | }); 271 | }); 272 | }); 273 | 274 | createTest("should get the cheapest product", "GET /Products/Northwind.getCheapest()", { 275 | statusCode: 200, 276 | body: Object.assign( 277 | products.filter(product => product.UnitPrice === 2.5).map(product => Object.assign({}, product, { 278 | "@odata.id": `http://localhost/Products(${product.Id})`, 279 | "@odata.editLink": `http://localhost/Products(${product.Id})` 280 | }))[0], { 281 | "@odata.context": "http://localhost/$metadata#Products/$entity" 282 | } 283 | ), 284 | elementType: Product, 285 | contentType: "application/json" 286 | }); 287 | 288 | createTest("should get products in UnitPrice range: 5-8", "GET /Products/Northwind.getInPriceRange(min=5,max=8)", { 289 | statusCode: 200, 290 | body: { 291 | "@odata.context": "http://localhost/$metadata#Products", 292 | value: products.filter(product => product.UnitPrice >=5 && product.UnitPrice <= 8).map((product) => { 293 | return Object.assign({}, product, { 294 | "@odata.id": `http://localhost/Products(${product.Id})`, 295 | "@odata.editLink": `http://localhost/Products(${product.Id})`, 296 | }); 297 | }) 298 | }, 299 | elementType: Product, 300 | contentType: "application/json" 301 | }); 302 | 303 | createTest("should get the price of a product", "GET /Products(1)/Northwind.getUnitPrice()", { 304 | statusCode: 200, 305 | body: { 306 | value: 39, 307 | "@odata.context": "http://localhost/$metadata#Edm.Decimal" 308 | }, 309 | elementType: "Edm.Decimal", 310 | contentType: "application/json" 311 | }); 312 | 313 | it("should invert Discontinued value on a product", () => { 314 | return NorthwindServer.execute("/Products(76)/Northwind.invertDiscontinued", "POST") 315 | .then((result) => { 316 | expect(result).to.deep.equal({ 317 | statusCode: 204 318 | }); 319 | 320 | return NorthwindServer.execute("/Products(76)", "GET").then((result) => { 321 | expect(result).to.deep.equal({ 322 | statusCode: 200, 323 | body: Object.assign({ 324 | "@odata.context": "http://localhost/$metadata#Products/$entity" 325 | }, products.filter(product => product.Id == 76).map(product => Object.assign({}, product, { 326 | "@odata.id": `http://localhost/Products(${product.Id})`, 327 | "@odata.editLink": `http://localhost/Products(${product.Id})`, 328 | Discontinued: true 329 | }))[0] 330 | ), 331 | elementType: Product, 332 | contentType: "application/json" 333 | }) 334 | }); 335 | }); 336 | }); 337 | 338 | it("should set Discontinued value on a product", () => { 339 | return NorthwindServer.execute("/Products(2)/Northwind.setDiscontinued", "POST", {value: true}) 340 | .then((result) => { 341 | expect(result).to.deep.equal({ 342 | statusCode: 204 343 | }); 344 | 345 | return NorthwindServer.execute("/Products(2)", "GET").then((result) => { 346 | expect(result).to.deep.equal({ 347 | statusCode: 200, 348 | body: Object.assign({ 349 | "@odata.context": "http://localhost/$metadata#Products/$entity" 350 | }, products.filter(product => product.Id == 2).map(product => Object.assign({}, product, { 351 | "@odata.id": `http://localhost/Products(${product.Id})`, 352 | "@odata.editLink": `http://localhost/Products(${product.Id})`, 353 | Discontinued: true 354 | }))[0] 355 | ), 356 | elementType: Product, 357 | contentType: "application/json" 358 | }) 359 | }); 360 | }); 361 | }); 362 | 363 | it("should swap two products UnitPrice", () => { 364 | return NorthwindServer.execute("/Products/Northwind.swapPrice", "POST", {a: 74, b: 75}) 365 | .then((result) => { 366 | expect(result).to.deep.equal({ 367 | statusCode: 204 368 | }); 369 | 370 | return NorthwindServer.execute("/Products(74)", "GET").then((result) => { 371 | expect(result).to.deep.equal({ 372 | statusCode: 200, 373 | body: Object.assign({ 374 | "@odata.context": "http://localhost/$metadata#Products/$entity" 375 | }, products.filter(product => product.Id == 74).map(product => Object.assign({}, product, { 376 | "@odata.id": `http://localhost/Products(${product.Id})`, 377 | "@odata.editLink": `http://localhost/Products(${product.Id})`, 378 | UnitPrice: 18 379 | }))[0] 380 | ), 381 | elementType: Product, 382 | contentType: "application/json" 383 | }) 384 | }) 385 | .then(() => { 386 | return NorthwindServer.execute("/Products(75)", "GET").then((result) => { 387 | expect(result).to.deep.equal({ 388 | statusCode: 200, 389 | body: Object.assign({ 390 | "@odata.context": "http://localhost/$metadata#Products/$entity" 391 | }, products.filter(product => product.Id == 75).map(product => Object.assign({}, product, { 392 | "@odata.id": `http://localhost/Products(${product.Id})`, 393 | "@odata.editLink": `http://localhost/Products(${product.Id})`, 394 | UnitPrice: 7.75 395 | }))[0] 396 | ), 397 | elementType: Product, 398 | contentType: "application/json" 399 | }) 400 | }) 401 | }); 402 | }); 403 | }); 404 | 405 | it("should discount a product", () => { 406 | return NorthwindServer.execute("/Products/Northwind.discountProduct", "POST", {productId: 3, percent: 10}) 407 | .then((result) => { 408 | expect(result).to.deep.equal({ 409 | statusCode: 204 410 | }); 411 | 412 | return NorthwindServer.execute("/Products(3)", "GET").then((result) => { 413 | expect(result).to.deep.equal({ 414 | statusCode: 200, 415 | body: Object.assign({ 416 | "@odata.context": "http://localhost/$metadata#Products/$entity" 417 | }, products.filter(product => product.Id == 3).map(product => Object.assign({}, product, { 418 | "@odata.id": `http://localhost/Products(${product.Id})`, 419 | "@odata.editLink": `http://localhost/Products(${product.Id})`, 420 | UnitPrice: 9 421 | }))[0] 422 | ), 423 | elementType: Product, 424 | contentType: "application/json" 425 | }) 426 | }) 427 | }); 428 | }); 429 | }); 430 | 431 | describe("Categories", () => { 432 | createTest("should get all categories", "GET /Categories", { 433 | statusCode: 200, 434 | body: { 435 | "@odata.context": "http://localhost/$metadata#Categories", 436 | value: categories.map(category => Object.assign({}, category, { 437 | "@odata.id": `http://localhost/Categories(${category.Id})`, 438 | "@odata.editLink": `http://localhost/Categories(${category.Id})` 439 | })) 440 | }, 441 | elementType: Category, 442 | contentType: "application/json" 443 | }); 444 | 445 | createTest("should get categories by filter", "GET /Categories?$filter=Name eq 'Beverages'", { 446 | statusCode: 200, 447 | body: { 448 | "@odata.context": "http://localhost/$metadata#Categories", 449 | value: categories.filter(category => category.Name == "Beverages").map(category => Object.assign({}, category, { 450 | "@odata.id": `http://localhost/Categories(${category.Id})`, 451 | "@odata.editLink": `http://localhost/Categories(${category.Id})` 452 | })) 453 | }, 454 | elementType: Category, 455 | contentType: "application/json" 456 | }); 457 | 458 | createTest("should get categories by filter and select", "GET /Categories?$filter=Name eq 'Beverages'&$select=Name,Description", { 459 | statusCode: 200, 460 | body: { 461 | "@odata.context": "http://localhost/$metadata#Categories(Name,Description)", 462 | value: categories.filter(category => category.Name == "Beverages").map((category) => { 463 | return { 464 | Name: category.Name, 465 | Description: category.Description 466 | }; 467 | }) 468 | }, 469 | elementType: Category, 470 | contentType: "application/json" 471 | }); 472 | 473 | createTest("should get category by key", "GET /Categories(1)", { 474 | statusCode: 200, 475 | body: Object.assign({ 476 | "@odata.context": "http://localhost/$metadata#Categories/$entity" 477 | }, categories.filter(category => category.Id == 1).map(category => Object.assign({}, category, { 478 | "@odata.id": `http://localhost/Categories(${category.Id})`, 479 | "@odata.editLink": `http://localhost/Categories(${category.Id})` 480 | }))[0] 481 | ), 482 | elementType: Category, 483 | contentType: "application/json" 484 | }); 485 | 486 | it("should create new category", () => { 487 | return NorthwindServer.execute("/Categories", "POST", { 488 | Name: "New category", 489 | Description: "Test category" 490 | }).then((result) => { 491 | expect(result).to.deep.equal({ 492 | statusCode: 201, 493 | body: { 494 | "@odata.context": "http://localhost/$metadata#Categories/$entity", 495 | "@odata.id": `http://localhost/Categories(${result.body.Id})`, 496 | "@odata.editLink": `http://localhost/Categories(${result.body.Id})`, 497 | Id: result.body.Id, 498 | Name: "New category", 499 | Description: "Test category" 500 | }, 501 | elementType: Category, 502 | contentType: "application/json" 503 | }); 504 | }); 505 | }); 506 | 507 | it("should update category", () => { 508 | return NorthwindServer.execute("/Categories(1)", "PUT", { 509 | Name: "Beverages (updated)" 510 | }).then((result) => { 511 | expect(result).to.deep.equal({ 512 | statusCode: 204 513 | }); 514 | 515 | return NorthwindServer.execute("/Categories(1)", "GET").then((result) => { 516 | expect(result).to.deep.equal({ 517 | statusCode: 200, 518 | body: { 519 | "@odata.context": "http://localhost/$metadata#Categories/$entity", 520 | "@odata.id": `http://localhost/Categories(1)`, 521 | "@odata.editLink": `http://localhost/Categories(1)`, 522 | Name: "Beverages (updated)", 523 | Id: 1 524 | }, 525 | elementType: Category, 526 | contentType: "application/json" 527 | }); 528 | }); 529 | }); 530 | }); 531 | 532 | it("should delta update category", () => { 533 | return NorthwindServer.execute("/Categories(1)", "PATCH", { 534 | Name: "Beverages (updated)" 535 | }).then((result) => { 536 | expect(result).to.deep.equal({ 537 | statusCode: 204 538 | }); 539 | 540 | return NorthwindServer.execute("/Categories(1)", "GET").then((result) => { 541 | expect(result).to.deep.equal({ 542 | statusCode: 200, 543 | body: categories.filter(category => category.Id == 1).map(category => Object.assign({ 544 | "@odata.context": "http://localhost/$metadata#Categories/$entity", 545 | "@odata.id": `http://localhost/Categories(${category.Id})`, 546 | "@odata.editLink": `http://localhost/Categories(${category.Id})` 547 | }, category, { 548 | Name: "Beverages (updated)" 549 | }))[0], 550 | elementType: Category, 551 | contentType: "application/json" 552 | }); 553 | }); 554 | }); 555 | }); 556 | 557 | it("should delete category", () => { 558 | return NorthwindServer.execute("/Categories(1)", "DELETE").then((result) => { 559 | expect(result).to.deep.equal({ 560 | statusCode: 204 561 | }); 562 | 563 | return NorthwindServer.execute("/Categories(1)", "GET").then(() => { 564 | throw new Error("Product should be deleted."); 565 | }, (err) => { 566 | expect(err.name).to.equal("ResourceNotFoundError"); 567 | }); 568 | }); 569 | }); 570 | 571 | createTest("should get products by category", "GET /Categories(1)/Products", { 572 | statusCode: 200, 573 | body: { 574 | "@odata.context": "http://localhost/$metadata#Categories(1)/Products", 575 | value: products.filter(product => product.CategoryId == 1).map(product => Object.assign({ 576 | "@odata.id": `http://localhost/Products(${product.Id})`, 577 | "@odata.editLink": `http://localhost/Products(${product.Id})` 578 | }, product)) 579 | }, 580 | elementType: Product, 581 | contentType: "application/json" 582 | }); 583 | 584 | createTest("should get products by category", "GET /Categories(1)/Products(1)", { 585 | statusCode: 200, 586 | body: Object.assign({}, { 587 | "@odata.context": "http://localhost/$metadata#Products/$entity", 588 | }, 589 | products.filter(product => product.Id == 1 && product.CategoryId == 1).map(product => Object.assign({}, product, { 590 | "@odata.id": `http://localhost/Products(${product.Id})`, 591 | "@odata.editLink": `http://localhost/Products(${product.Id})` 592 | }))[0] 593 | ), 594 | elementType: Product, 595 | contentType: "application/json" 596 | }); 597 | 598 | it("should create product reference on category", () => { 599 | return NorthwindServer.execute("/Categories(2)/Products/$ref", "POST", { 600 | "@odata.id": "http://localhost/Products(1)" 601 | }).then((result) => { 602 | expect(result).to.deep.equal({ 603 | statusCode: 204 604 | }); 605 | 606 | return NorthwindServer.execute("/Products(1)/Category", "GET").then((result) => { 607 | expect(result).to.deep.equal({ 608 | statusCode: 200, 609 | body: Object.assign({ 610 | "@odata.context": "http://localhost/$metadata#Categories/$entity" 611 | }, categories.filter(category => category.Id == 2).map(category => Object.assign({ 612 | "@odata.id": `http://localhost/Categories(${category.Id})`, 613 | "@odata.editLink": `http://localhost/Categories(${category.Id})` 614 | }, category))[0] 615 | ), 616 | elementType: Category, 617 | contentType: "application/json" 618 | }) 619 | }); 620 | }); 621 | }); 622 | 623 | it("should update product reference on category", () => { 624 | return NorthwindServer.execute("/Categories(2)/Products/$ref", "PUT", { 625 | "@odata.id": "http://localhost/Products(1)" 626 | }).then((result) => { 627 | expect(result).to.deep.equal({ 628 | statusCode: 204 629 | }); 630 | 631 | return NorthwindServer.execute("/Products(1)/Category", "GET").then((result) => { 632 | expect(result).to.deep.equal({ 633 | statusCode: 200, 634 | body: Object.assign({ 635 | "@odata.context": "http://localhost/$metadata#Categories/$entity" 636 | }, categories.filter(category => category.Id == 2).map(category => Object.assign({ 637 | "@odata.id": `http://localhost/Categories(${category.Id})`, 638 | "@odata.editLink": `http://localhost/Categories(${category.Id})` 639 | }, category))[0] 640 | ), 641 | elementType: Category, 642 | contentType: "application/json" 643 | }) 644 | }); 645 | }); 646 | }); 647 | 648 | it("should delete product reference on category", () => { 649 | return NorthwindServer.execute("/Categories(1)/Products/$ref?$id=http://localhost/Products(1)", "DELETE").then((result) => { 650 | expect(result).to.deep.equal({ 651 | statusCode: 204 652 | }); 653 | 654 | return NorthwindServer.execute("/Products(1)/Category", "GET").then((result) => { 655 | throw new Error("Category reference should be deleted."); 656 | }, (err) => { 657 | expect(err.name).to.equal("ResourceNotFoundError"); 658 | }); 659 | }); 660 | }); 661 | }); 662 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "target": "es6", 5 | "module": "commonjs", 6 | "declaration": true, 7 | "experimentalDecorators": true, 8 | "outDir": "lib", 9 | "rootDir": "src", 10 | "moduleResolution": "node" 11 | }, 12 | "exclude": [ 13 | "node_modules", 14 | "lib" 15 | ] 16 | } --------------------------------------------------------------------------------