@dwtechs/antity-pgsql 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -2
- package/dist/antity-pgsql.d.ts +2 -0
- package/dist/antity-pgsql.js +71 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -120,6 +120,7 @@ router.get("/", ..., entity.get);
|
|
|
120
120
|
// Using substacks (recommended) - combines normalize, validate, and database operation
|
|
121
121
|
router.post("/", ...entity.addArraySubstack);
|
|
122
122
|
router.put("/", ...entity.updateArraySubstack);
|
|
123
|
+
router.put("/preferences", ...entity.syncArraySubstack);
|
|
123
124
|
|
|
124
125
|
// Or manually chain middlewares
|
|
125
126
|
router.post("/manual", entity.normalizeArray, entity.validateArray, ..., entity.add);
|
|
@@ -192,6 +193,7 @@ class SQLEntity {
|
|
|
192
193
|
get updateOneSubstack(): SubstackTuple;
|
|
193
194
|
get upsertArraySubstack(): SubstackTuple;
|
|
194
195
|
get upsertOneSubstack(): SubstackTuple;
|
|
196
|
+
get syncArraySubstack(): SubstackTuple;
|
|
195
197
|
|
|
196
198
|
query: {
|
|
197
199
|
select: (
|
|
@@ -245,6 +247,7 @@ class SQLEntity {
|
|
|
245
247
|
add: (req: Request, res: Response, next: NextFunction) => Promise<void>;
|
|
246
248
|
update: (req: Request, res: Response, next: NextFunction) => Promise<void>;
|
|
247
249
|
upsert: (req: Request, res: Response, next: NextFunction) => Promise<void>;
|
|
250
|
+
sync: (req: Request, res: Response, next: NextFunction) => Promise<void>;
|
|
248
251
|
archive: (req: Request, res: Response, next: NextFunction) => Promise<void>;
|
|
249
252
|
delete: (req: Request, res: Response, next: NextFunction) => Promise<void>;
|
|
250
253
|
deleteArchive: (req: Request, res: Response, next: NextFunction) => void;
|
|
@@ -271,11 +274,13 @@ function execute(
|
|
|
271
274
|
|
|
272
275
|
### Middleware Methods for Express.js
|
|
273
276
|
|
|
274
|
-
get(), add(), update(), upsert(), archive(), delete(), deleteArchive() and getHistory() methods are made to be used as Express.js middlewares.
|
|
277
|
+
get(), add(), update(), upsert(), sync(), archive(), delete(), deleteArchive() and getHistory() methods are made to be used as Express.js middlewares.
|
|
275
278
|
Each method will look for data to work on in the **req.body.rows** parameter.
|
|
276
279
|
|
|
277
280
|
The upsert() method additionally requires **req.body.conflictTarget** to specify which column(s) define uniqueness.
|
|
278
281
|
|
|
282
|
+
The sync() method accepts an optional **req.body.idField** (defaults to `'id'`) and optional **req.body.filters** to scope which existing rows are considered part of the managed set.
|
|
283
|
+
|
|
279
284
|
### Schema Qualification
|
|
280
285
|
|
|
281
286
|
All SQL queries generated by Antity-pgsql use schema-qualified table names (e.g., `schema.table`). This provides:
|
|
@@ -295,6 +300,7 @@ Substacks are pre-composed middleware chains that combine normalization, validat
|
|
|
295
300
|
- **updateOneSubstack**: Combines `normalizeOne`, `validateOne`, and `update`. Use this for PUT routes with `req.body` containing a single object.
|
|
296
301
|
- **upsertArraySubstack**: Combines `normalizeArray`, `validateArray`, and `upsert`. Use this for upsert routes with `req.body.rows` containing multiple objects. Requires `req.body.conflictTarget`.
|
|
297
302
|
- **upsertOneSubstack**: Combines `normalizeOne`, `validateOne`, and `upsert`. Use this for upsert routes with `req.body` containing a single object. Requires `req.body.conflictTarget`.
|
|
303
|
+
- **syncArraySubstack**: Combines `normalizeArray`, `validateArray`, and `sync`. Use this for bulk-sync routes with `req.body.rows` containing the full desired state. Rows are inserted, updated, or deleted as needed. Accepts optional `req.body.idField` and `req.body.filters`.
|
|
298
304
|
|
|
299
305
|
Using substacks simplifies your route definitions and ensures consistent data processing.
|
|
300
306
|
|
|
@@ -303,12 +309,71 @@ Using substacks simplifies your route definitions and ensures consistent data pr
|
|
|
303
309
|
- **query.select()**: Generates a SELECT query. When the `rows` parameter is provided (not null), pagination is automatically enabled and the query includes `COUNT(*) OVER () AS total` to return the total number of rows. The total count is extracted from results and returned separately from the row data.
|
|
304
310
|
- **query.insert()**: Generates an INSERT query. Accepts an array of objects with properties matching the entity definition. Optionally appends `consumerId` and `consumerName` for history tracking. Supports `RETURNING` clause via the `rtn` parameter.
|
|
305
311
|
- **query.update()**: Generates an UPDATE query using CASE statements. Accepts an array of objects with `id` property. Optionally appends `consumerId` and `consumerName` for history tracking.
|
|
306
|
-
- **query.upsert()**: Generates an INSERT ... ON CONFLICT ... DO UPDATE query. Accepts an array of objects and a `conflictTarget` (single column name or array of column names) that defines uniqueness. If a conflict occurs on the specified column(s), the row is updated; otherwise, it is inserted. Properties are automatically included if they have both INSERT and UPDATE operations. Optionally appends `consumerId` and `consumerName` for history tracking. Supports `RETURNING` clause via the `rtn` parameter.
|
|
312
|
+
- **query.upsert()**: Generates an INSERT ... ON CONFLICT ... DO UPDATE query. (See [Upsert](#upsert-insert-or-update) section below.) Accepts an array of objects and a `conflictTarget` (single column name or array of column names) that defines uniqueness. If a conflict occurs on the specified column(s), the row is updated; otherwise, it is inserted. Properties are automatically included if they have both INSERT and UPDATE operations. Optionally appends `consumerId` and `consumerName` for history tracking. Supports `RETURNING` clause via the `rtn` parameter.
|
|
307
313
|
- **query.archive()**: Generates a simplified `UPDATE ... SET archived = true WHERE id IN (...)` query. Accepts an array of objects with `id` property. Optionally appends `consumerId` and `consumerName` for history tracking. Does not require an `archived` field in the rows — it is set directly in the SQL.
|
|
314
|
+
- **sync()**: Atomically synchronises the table with the provided rows inside a single PostgreSQL transaction. Missing rows are inserted, existing rows are updated, and rows absent from the list are deleted. Accepts optional `idField` (default `'id'`) and `filters` to restrict the scope of managed rows. Stores the result in `res.locals.rows` and a summary `{ inserted, updated, deleted }` in `res.locals.sync`.
|
|
308
315
|
- **delete()**: Deletes rows by their IDs. Expects `req.body.rows` to be an array of objects with `id` property: `[{id: 1}, {id: 2}]`
|
|
309
316
|
- **deleteArchive()**: Deletes archived rows that were archived before a specific date using a PostgreSQL SECURITY DEFINER function. Expects `req.body.date` to be a Date object.
|
|
310
317
|
- **getHistory()**: Retrieves modification history for rows from the `log.history` table. Expects `req.body.rows` to be an array of objects with `id` property. Returns all historical records for the specified entity IDs.
|
|
311
318
|
|
|
319
|
+
### Bulk Sync
|
|
320
|
+
|
|
321
|
+
The sync functionality atomically replaces the managed set of rows in a table with the supplied list. It combines insert, update, and delete in a single PostgreSQL **transaction** — either all changes succeed or none do.
|
|
322
|
+
|
|
323
|
+
#### How It Works
|
|
324
|
+
|
|
325
|
+
1. **Fetch existing IDs**: A `SELECT id FROM table` is issued, optionally scoped by `filters`.
|
|
326
|
+
2. **Diff**: Incoming rows without an ID (or with an unknown ID) are inserted; rows with a known ID are updated; existing IDs absent from the incoming list are deleted — all within the same filter scope.
|
|
327
|
+
3. **Transaction**: All three operations run inside `BEGIN` / `COMMIT`. A failure at any step triggers `ROLLBACK`.
|
|
328
|
+
4. **Result**: `res.locals.rows` contains the full synced list (with generated IDs filled in for inserts). `res.locals.sync` contains `{ inserted, updated, deleted }` counts.
|
|
329
|
+
|
|
330
|
+
#### Usage Examples
|
|
331
|
+
|
|
332
|
+
**Using the middleware:**
|
|
333
|
+
|
|
334
|
+
```javascript
|
|
335
|
+
// Route definition
|
|
336
|
+
router.put('/users/sync', ...entity.syncArraySubstack);
|
|
337
|
+
|
|
338
|
+
// Request body — send the entire desired state
|
|
339
|
+
{
|
|
340
|
+
rows: [
|
|
341
|
+
{ id: 1, name: 'John Updated', email: 'john@example.com', age: 31 }, // update
|
|
342
|
+
{ name: 'Jane New', email: 'jane@example.com', age: 25 } // insert
|
|
343
|
+
// id: 2 is absent → will be deleted
|
|
344
|
+
],
|
|
345
|
+
idField: 'id' // optional, defaults to 'id'
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
**Scoping with filters (only manage a subset of rows):**
|
|
350
|
+
|
|
351
|
+
```javascript
|
|
352
|
+
// Only sync rows where age >= 18 — rows outside this filter are left untouched
|
|
353
|
+
{
|
|
354
|
+
rows: [
|
|
355
|
+
{ id: 1, name: 'John', email: 'john@example.com', age: 30 }
|
|
356
|
+
],
|
|
357
|
+
filters: {
|
|
358
|
+
age: { value: 18, matchMode: 'gte' }
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
**Response locals after sync:**
|
|
364
|
+
|
|
365
|
+
```javascript
|
|
366
|
+
res.locals.rows // full list of synced rows (inserts have their new id)
|
|
367
|
+
res.locals.sync // { inserted: 1, updated: 1, deleted: 1 }
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
#### Important Notes
|
|
371
|
+
|
|
372
|
+
- **Atomic**: All insert / update / delete operations are wrapped in a single transaction.
|
|
373
|
+
- **Filter scope**: When `filters` are provided, only rows matching the filter are considered "managed". Rows outside the filter are never touched.
|
|
374
|
+
- **Property selection**: Insert uses `INSERT` properties; update uses `UPDATE` properties — same as the standalone `add` and `update` middlewares.
|
|
375
|
+
- **Consumer tracking**: `consumerId` and `consumerName` from `res.locals` are forwarded to inserts and updates for history tracking.
|
|
376
|
+
|
|
312
377
|
### Upsert (Insert or Update)
|
|
313
378
|
|
|
314
379
|
The upsert functionality uses PostgreSQL's `INSERT ... ON CONFLICT ... DO UPDATE` syntax to insert rows or update them if they already exist based on a unique constraint.
|
package/dist/antity-pgsql.d.ts
CHANGED
|
@@ -117,6 +117,7 @@ export declare class SQLEntity extends Entity {
|
|
|
117
117
|
get updateOneSubstack(): SubstackTuple;
|
|
118
118
|
get upsertArraySubstack(): SubstackTuple;
|
|
119
119
|
get upsertOneSubstack(): SubstackTuple;
|
|
120
|
+
get syncArraySubstack(): SubstackTuple;
|
|
120
121
|
|
|
121
122
|
query: {
|
|
122
123
|
select: (
|
|
@@ -183,6 +184,7 @@ export declare class SQLEntity extends Entity {
|
|
|
183
184
|
add(req: Request, res: Response, next: NextFunction): Promise<void>;
|
|
184
185
|
update(req: Request, res: Response, next: NextFunction): Promise<void>;
|
|
185
186
|
upsert(req: Request, res: Response, next: NextFunction): Promise<void>;
|
|
187
|
+
sync(req: Request, res: Response, next: NextFunction): Promise<void>;
|
|
186
188
|
archive(req: Request, res: Response, next: NextFunction): Promise<void>;
|
|
187
189
|
delete(req: Request, res: Response, next: NextFunction): Promise<void>;
|
|
188
190
|
deleteArchive(req: Request, res: Response, next: NextFunction): void;
|
package/dist/antity-pgsql.js
CHANGED
|
@@ -880,6 +880,74 @@ class SQLEntity extends Entity {
|
|
|
880
880
|
})
|
|
881
881
|
.catch((err) => next(err));
|
|
882
882
|
};
|
|
883
|
+
this.sync = (req, res, next) => __awaiter(this, void 0, void 0, function* () {
|
|
884
|
+
var _a;
|
|
885
|
+
const l = res.locals;
|
|
886
|
+
const rows = req.body.rows;
|
|
887
|
+
const idField = (_a = req.body.idField) !== null && _a !== void 0 ? _a : 'id';
|
|
888
|
+
const cId = l.consumerId;
|
|
889
|
+
const cName = l.consumerName;
|
|
890
|
+
if (!rows || !Array.isArray(rows)) {
|
|
891
|
+
return next({ status: 400, msg: "Missing or invalid rows array for sync operation" });
|
|
892
|
+
}
|
|
893
|
+
log.debug(`${LOGS_PREFIX}sync(rows=${rows.length}, idField=${idField}, consumerId=${cId})`);
|
|
894
|
+
const cleanedFilters = cleanFilters(req.body.filters, this.properties) || null;
|
|
895
|
+
const { conditions, args: filterArgs } = add(cleanedFilters);
|
|
896
|
+
const whereClause = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
|
|
897
|
+
const txClient = l.dbClient || (yield pool.connect());
|
|
898
|
+
let toInsert = [];
|
|
899
|
+
let toUpdate = [];
|
|
900
|
+
let idsToDelete = [];
|
|
901
|
+
try {
|
|
902
|
+
yield txClient.query('BEGIN');
|
|
903
|
+
const selectIdQuery = `SELECT ${quoteIfUppercase(idField)} FROM ${quoteIfUppercase(this._schema)}.${quoteIfUppercase(this._table)}${whereClause}`;
|
|
904
|
+
const existingDb = yield execute(selectIdQuery, filterArgs, txClient);
|
|
905
|
+
const existingIds = new Set(existingDb.rows.map(r => r[idField]));
|
|
906
|
+
const incomingIds = new Set(rows.filter(r => r[idField] != null).map(r => r[idField]));
|
|
907
|
+
toInsert = rows.filter(r => r[idField] == null || !existingIds.has(r[idField]));
|
|
908
|
+
toUpdate = rows.filter(r => r[idField] != null && existingIds.has(r[idField]));
|
|
909
|
+
idsToDelete = [...existingIds].filter(id => !incomingIds.has(id));
|
|
910
|
+
if (toInsert.length > 0) {
|
|
911
|
+
const rtn = this.ins.rtn(idField);
|
|
912
|
+
const chunks = chunk(toInsert);
|
|
913
|
+
for (const c of chunks) {
|
|
914
|
+
const { query, args } = this.ins.query(this._schema, this._table, c, cId, cName, rtn);
|
|
915
|
+
const db = yield execute(query, args, txClient);
|
|
916
|
+
const r = db.rows;
|
|
917
|
+
for (let i = 0; i < c.length; i++) {
|
|
918
|
+
c[i][idField] = r[i][idField];
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
if (toUpdate.length > 0) {
|
|
923
|
+
const chunks = chunk(toUpdate);
|
|
924
|
+
for (const c of chunks) {
|
|
925
|
+
const { query, args } = this.upd.query(this._schema, this._table, c, cId, cName);
|
|
926
|
+
yield execute(query, args, txClient);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
if (idsToDelete.length > 0) {
|
|
930
|
+
const deleteArgs = [idsToDelete, ...filterArgs];
|
|
931
|
+
const scopedWhere = conditions.length
|
|
932
|
+
? ` AND ${conditions.map(c => c.replace(/\$(\d+)/g, (_, n) => `$${parseInt(n) + 1}`)).join(' AND ')}`
|
|
933
|
+
: '';
|
|
934
|
+
const deleteQuery = `DELETE FROM ${quoteIfUppercase(this._schema)}.${quoteIfUppercase(this._table)} WHERE ${quoteIfUppercase(idField)} = ANY($1)${scopedWhere}`;
|
|
935
|
+
yield execute(deleteQuery, deleteArgs, txClient);
|
|
936
|
+
}
|
|
937
|
+
yield txClient.query('COMMIT');
|
|
938
|
+
}
|
|
939
|
+
catch (err) {
|
|
940
|
+
yield txClient.query('ROLLBACK');
|
|
941
|
+
return next(err);
|
|
942
|
+
}
|
|
943
|
+
finally {
|
|
944
|
+
if (!l.dbClient)
|
|
945
|
+
txClient.release();
|
|
946
|
+
}
|
|
947
|
+
l.rows = rows;
|
|
948
|
+
l.sync = { inserted: toInsert.length, updated: toUpdate.length, deleted: idsToDelete.length };
|
|
949
|
+
next();
|
|
950
|
+
});
|
|
883
951
|
this._table = name;
|
|
884
952
|
this._schema = schema;
|
|
885
953
|
log.info(`${LOGS_PREFIX}Creating SQLEntity: "${name}"`);
|
|
@@ -925,6 +993,9 @@ class SQLEntity extends Entity {
|
|
|
925
993
|
get upsertOneSubstack() {
|
|
926
994
|
return [this.normalizeOne, this.validateOne, this.upsert];
|
|
927
995
|
}
|
|
996
|
+
get syncArraySubstack() {
|
|
997
|
+
return [this.normalizeArray, this.validateArray, this.sync];
|
|
998
|
+
}
|
|
928
999
|
mapProps(operations, key) {
|
|
929
1000
|
let hasInsert = false;
|
|
930
1001
|
let hasUpdate = false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dwtechs/antity-pgsql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Open source library to add PostgreSQL support to @dwtechs/Antity entities.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"entities"
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"@dwtechs/winstan": "0.5.0",
|
|
41
41
|
"@dwtechs/antity": "0.16.0",
|
|
42
42
|
"@dwtechs/sparray": "0.2.1",
|
|
43
|
-
"pg": "8.
|
|
43
|
+
"pg": "8.20.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@babel/core": "7.26.0",
|