From 90d69fba933c6701ad83f2a129992bfb3b78f76f Mon Sep 17 00:00:00 2001 From: Lars-Erik Roald Date: Tue, 10 Oct 2023 09:36:57 +0200 Subject: [PATCH] V3.2.3 (#54) INSERT ON Conflict resolution #42 --- README.md | 79 +++++++++++++++++++++++++++++++++++++++-- docs/changelog.md | 2 ++ package.json | 2 +- src/client/index.mjs | 30 ++++++++++------ tests/conflicts.test.js | 4 +-- tests/db2.js | 66 ++++++++++++++++++++++++++++++++++ tests/readonly.test.js | 55 +--------------------------- 7 files changed, 168 insertions(+), 70 deletions(-) create mode 100644 tests/db2.js diff --git a/README.md b/README.md index 9ea60372..8adf8461 100644 --- a/README.md +++ b/README.md @@ -335,7 +335,7 @@ const db = map.pg(`Driver=${__dirname}/libsybdrvodb.so;SERVER=sapase;Port=5000;U -
Inserting rows +
Inserting rows In the code below, we initially import the table-mapping feature "map.js" and the setup script "init.js", both of which were defined in the preceding step. The setup script executes a raw query that creates the necessary tables. Subsequently, we insert two customers, named "George" and "Harry", into the customer table, and this is achieved through calling "db.customer.insert". @@ -400,6 +400,59 @@ async function insertRows() { } ``` +__Conflict resolution__ +By default, the strategy for inserting rows is set to an optimistic approach. In this case, if a row is being inserted with an already existing primary key, the database raises an exception. + +Currently, there are three concurrency strategies: +- `optimistic` Raises an exception if another row was already inserted on that primary key. +- `overwrite` Overwrites the property, regardless of changes by others. +- `skipOnConflict` Silently avoids updating the property if another user has modified it in the interim. + +The concurrency option can be set either globally a table or individually for each column. In the example below, we've set the concurrency strategy on vendor table to overwrite except for the column balance which uses the skipOnConflict strategy.. In this particular case, a row with id: 1 already exists, the name and isActive fields will be overwritten, but the balance will remain the same as in the original record, demonstrating the effectiveness of combining multiple concurrency strategies. + +```javascript +import map from './map'; +const db = map.sqlite('demo.db'); +import init from './init'; + +insertRows(); + +async function insertRows() { + await init(); + + db2 = db({ + vendor: { + balance: { + concurrency: 'skipOnConflict' + }, + concurrency: 'overwrite' + } + }); + + await db2.vendor.insert({ + id: 1, + name: 'John', + balance: 100, + isActive: true + }); + + //this will overwrite all fields but balance + const george = await db2.vendor.insert({ + id: 1, + name: 'George', + balance: 177, + isActive: false + }); + console.dir(orders, {depth: Infinity}); + // { + // id: 1, + // name: 'George', + // balance: 100, + // isActive: false + // } +} +``` +
Fetching rows @@ -589,7 +642,7 @@ async function update() { } ``` -__Updating with concurrency__ +__Conflict resolution__ Rows get updated using an optimistic concurrency approach by default. This means if a property being edited was meanwhile altered, an exception is raised, indicating the row was modified by a different user. You can change the concurrency strategy either at the table or column level. Currently, there are three concurrency strategies: @@ -950,6 +1003,28 @@ const map = rdb.map(x => ({ ```
+
Default values +Utilizing default values can be especially useful for automatically populating these fields when the underlying database doesn't offer native support for default value generation. + +In the provided code, the id column's default value is set to a UUID generated by crypto.randomUUID(), and the isActive column's default is set to true. + +```javascript +import rdb from 'rdb'; +import crypto 'crypto'; + +const map = rdb.map(x => ({ + myTable: x.table('myTable').map(({ column }) => ({ + id: column('id').uuid().primary().default(() => crypto.randomUUID()), + name: column('name').string(), + balance: column('balance').numeric(), + isActive: column('isActive').boolean().default(true), + })) +})); + +module.exports = map; +``` +
+
Validation In the previous sections you have already seen the notNull() validator being used on some columns. This will not only generate correct typescript mapping, but also throw an error if value is set to null or undefined. However, sometimes we do not want the notNull-validator to be run on inserts. Typically, when we have an autoincremental key or server generated uuid, it does not make sense to check for null on insert. This is where notNullExceptInsert() comes to rescue. You can also create your own custom validator as shown below. The last kind of validator, is the ajv JSON schema validator. This can be used on json columns as well as any other column type. diff --git a/docs/changelog.md b/docs/changelog.md index 9f010d77..57a25503 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,7 @@ ## Changelog +__3.2.3__ +Conflict resolution on insert. See [#42](https://github.com/alfateam/rdb/issues/42) and [Inserting rows](https://github.com/alfateam/rdb/tree/master#user-content-inserting-rows). __3.2.2__ Bugfix for Sql Server: OFFSET was ignored. See [#46](https://github.com/alfateam/rdb/issues/49). __3.2.1__ diff --git a/package.json b/package.json index 732458ee..c12ae56f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rdb", - "version": "3.2.2", + "version": "3.2.3", "main": "./src/index.js", "browser": "./src/client/index.mjs", "bin": { diff --git a/src/client/index.mjs b/src/client/index.mjs index d2b71a39..20e1faec 100644 --- a/src/client/index.mjs +++ b/src/client/index.mjs @@ -5532,16 +5532,6 @@ function rdbClient(options = {}) { return adapter.post(body); } - async function insertAndForget() { - let args = Array.prototype.slice.call(arguments); - let body = stringify({ - path: 'insertAndForget', - args - }); - let adapter = netAdapter(url, tableName, { axios: axiosInterceptor, tableOptions }); - return adapter.post(body); - } - async function _delete() { let args = Array.prototype.slice.call(arguments); let body = stringify({ @@ -5577,6 +5567,21 @@ function rdbClient(options = {}) { } } + async function insertAndForget(rows, arg1, arg2, ...rest) { + let args = [arg1, {insertAndForget: true, ...arg2}].concat(rest); + if (Array.isArray(rows)) { + let proxy = proxify([], args[0]); + proxy.splice.apply(proxy, [0, 0, ...rows]); + await proxy.saveChanges.apply(proxy, args); + } + else { + let proxy = proxify([], args[0]); + proxy.splice.apply(proxy, [0, 0, rows]); + await proxy.saveChanges.apply(proxy, args); + } + } + + function proxify(itemOrArray, strategy) { if (Array.isArray(itemOrArray)) return proxifyArray(itemOrArray, strategy); @@ -5728,6 +5733,11 @@ function rdbClient(options = {}) { let body = stringify({ patch, options: { strategy, ...concurrencyOptions, deduceStrategy } }); let adapter = netAdapter(url, tableName, { axios: axiosInterceptor, tableOptions }); let p = adapter.patch(body); + if (strategy?.insertAndForget) { + await p; + return; + } + let updatedPositions = extractChangedRowsPositions(array, patch, meta); let insertedPositions = getInsertedRowsPosition(array); let { changed, strategy: newStrategy } = await p; diff --git a/tests/conflicts.test.js b/tests/conflicts.test.js index 0e7b0e12..d427ad93 100644 --- a/tests/conflicts.test.js +++ b/tests/conflicts.test.js @@ -278,14 +278,12 @@ describe('insert overwrite with optimistic column changed', () => { let message; try { - - const row = await db.vendor.insert({ + await db.vendor.insert({ id: 1, name: 'George', balance: 177, isActive: false }); - console.dir(row, {depth: Infinity}); } catch (e) { message = e.message; diff --git a/tests/db2.js b/tests/db2.js new file mode 100644 index 00000000..d6cfb735 --- /dev/null +++ b/tests/db2.js @@ -0,0 +1,66 @@ +const rdb = require('../src/index'); + +const nameSchema = { + type: 'string', +}; + + +function validateName(value) { + if (value && value.length > 10) + throw new Error('Length cannot exceed 10 characters'); +} + +function truthy(value) { + if (!value) + throw new Error('Name must be set'); +} + +const map = rdb.map(x => ({ + customer: x.table('customer').map(({ column }) => ({ + id: column('id').numeric().primary().notNullExceptInsert(), + name: column('name').string().validate(validateName).validate(truthy).JSONSchema(nameSchema), + balance: column('balance').numeric(), + isActive: column('isActive').boolean(), + })), + + package: x.table('package').map(({ column }) => ({ + id: column('packageId').numeric().primary().notNullExceptInsert(), + lineId: column('lineId').numeric().notNullExceptInsert(), + sscc: column('sscc').string() + })), + + order: x.table('_order').map(({ column }) => ({ + id: column('id').numeric().primary().notNullExceptInsert(), + orderDate: column('orderDate').date().notNull(), + customerId: column('customerId').numeric().notNullExceptInsert(), + })), + + orderLine: x.table('orderLine').map(({ column }) => ({ + id: column('id').numeric().primary().notNullExceptInsert(), + orderId: column('orderId').numeric(), + product: column('product').string(), + })), + + deliveryAddress: x.table('deliveryAddress').map(({ column }) => ({ + id: column('id').numeric().primary().notNullExceptInsert(), + orderId: column('orderId').numeric(), + name: column('name').string(), + street: column('street').string(), + postalCode: column('postalCode').string(), + postalPlace: column('postalPlace').string(), + countryCode: column('countryCode').string(), + })) +})).map(x => ({ + orderLine: x.orderLine.map(({ hasMany }) => ({ + packages: hasMany(x.package).by('lineId') + })) +})).map(x => ({ + order: x.order.map(({ hasOne, hasMany, references }) => ({ + customer: references(x.customer).by('customerId'), + deliveryAddress: hasOne(x.deliveryAddress).by('orderId'), + lines: hasMany(x.orderLine).by('orderId') + })) + +})); + +module.exports = map; \ No newline at end of file diff --git a/tests/readonly.test.js b/tests/readonly.test.js index 1ac452a3..e6c239d7 100644 --- a/tests/readonly.test.js +++ b/tests/readonly.test.js @@ -1,6 +1,6 @@ import { describe, test, beforeAll, afterAll, expect } from 'vitest'; import { fileURLToPath } from 'url'; -const map = require('./db'); +const map = require('./db2'); import express from 'express'; import cors from 'cors'; import { json } from 'body-parser'; @@ -140,48 +140,6 @@ describe('readonly everything', () => { balance: { readonly: true }, isActive: { readonly: true } }, - vendor: { - id: { readonly: true }, - name: { readonly: true }, - balance: { readonly: true }, - isActive: { readonly: true } - }, - customer2: { - id: { readonly: true }, - name: { readonly: true }, - balance: { readonly: true }, - isActive: { readonly: true }, - data: { readonly: true }, - picture: { readonly: true } - }, - customerDbNull: { - balance: { - readonly: true, - }, - id: { - readonly: true, - }, - isActive: { - readonly: true, - }, - name: { - readonly: true, - }, - }, - customerDefault: { - balance: { - readonly: true, - }, - id: { - readonly: true, - }, - isActive: { - readonly: true, - }, - name: { - readonly: true, - }, - }, order: { id: { readonly: true }, orderDate: { readonly: true }, @@ -247,17 +205,6 @@ describe('readonly everything', () => { sscc: { readonly: true, }, - }, - datetest: { - id: { readonly: true }, - date: { readonly: true }, - datetime: { readonly: true }, - }, - datetestWithTz: { - id: { readonly: true }, - date: { readonly: true }, - datetime: { readonly: true }, - datetime_tz: { readonly: true }, } }; expect(error?.message).toEqual('Cannot update column name because it is readonly');