@dwtechs/antity-pgsql 0.13.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 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);
@@ -190,6 +191,9 @@ class SQLEntity {
190
191
  get addOneSubstack(): SubstackTuple;
191
192
  get updateArraySubstack(): SubstackTuple;
192
193
  get updateOneSubstack(): SubstackTuple;
194
+ get upsertArraySubstack(): SubstackTuple;
195
+ get upsertOneSubstack(): SubstackTuple;
196
+ get syncArraySubstack(): SubstackTuple;
193
197
 
194
198
  query: {
195
199
  select: (
@@ -223,6 +227,15 @@ class SQLEntity {
223
227
  query: string;
224
228
  args: unknown[];
225
229
  };
230
+ upsert: (
231
+ rows: Record<string, unknown>[],
232
+ conflictTarget: string | string[],
233
+ consumerId?: number | string,
234
+ consumerName?: string,
235
+ rtn?: string) => {
236
+ query: string;
237
+ args: unknown[];
238
+ };
226
239
  delete: (ids: number[]) => {
227
240
  query: string;
228
241
  args: number[];
@@ -233,6 +246,8 @@ class SQLEntity {
233
246
  get: (req: Request, res: Response, next: NextFunction) => void;
234
247
  add: (req: Request, res: Response, next: NextFunction) => Promise<void>;
235
248
  update: (req: Request, res: Response, next: NextFunction) => Promise<void>;
249
+ upsert: (req: Request, res: Response, next: NextFunction) => Promise<void>;
250
+ sync: (req: Request, res: Response, next: NextFunction) => Promise<void>;
236
251
  archive: (req: Request, res: Response, next: NextFunction) => Promise<void>;
237
252
  delete: (req: Request, res: Response, next: NextFunction) => Promise<void>;
238
253
  deleteArchive: (req: Request, res: Response, next: NextFunction) => void;
@@ -259,9 +274,13 @@ function execute(
259
274
 
260
275
  ### Middleware Methods for Express.js
261
276
 
262
- get(), add(), update(), 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.
263
278
  Each method will look for data to work on in the **req.body.rows** parameter.
264
279
 
280
+ The upsert() method additionally requires **req.body.conflictTarget** to specify which column(s) define uniqueness.
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
+
265
284
  ### Schema Qualification
266
285
 
267
286
  All SQL queries generated by Antity-pgsql use schema-qualified table names (e.g., `schema.table`). This provides:
@@ -279,17 +298,183 @@ Substacks are pre-composed middleware chains that combine normalization, validat
279
298
  - **addOneSubstack**: Combines `normalizeOne`, `validateOne`, and `add`. Use this for POST routes with `req.body` containing a single object.
280
299
  - **updateArraySubstack**: Combines `normalizeArray`, `validateArray`, and `update`. Use this for PUT routes with `req.body.rows` containing multiple objects.
281
300
  - **updateOneSubstack**: Combines `normalizeOne`, `validateOne`, and `update`. Use this for PUT routes with `req.body` containing a single object.
301
+ - **upsertArraySubstack**: Combines `normalizeArray`, `validateArray`, and `upsert`. Use this for upsert routes with `req.body.rows` containing multiple objects. Requires `req.body.conflictTarget`.
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`.
282
304
 
283
305
  Using substacks simplifies your route definitions and ensures consistent data processing.
284
306
 
285
307
  ### Query Methods
286
308
 
287
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.
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.
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.
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.
288
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`.
289
315
  - **delete()**: Deletes rows by their IDs. Expects `req.body.rows` to be an array of objects with `id` property: `[{id: 1}, {id: 2}]`
290
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.
291
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.
292
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
+
377
+ ### Upsert (Insert or Update)
378
+
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.
380
+
381
+ #### How It Works
382
+
383
+ 1. **Conflict Target**: You specify which column(s) define uniqueness (e.g., `'id'`, `'email'`, or `['name', 'email']`)
384
+ 2. **Property Selection**: Properties are automatically included if they have **both** `INSERT` and `UPDATE` in their `operations` array
385
+ 3. **On Conflict**: When a conflict occurs, all columns except the conflict target are updated
386
+
387
+ #### Usage Examples
388
+
389
+ **Using the middleware with a single conflict target:**
390
+
391
+ ```javascript
392
+ // Route definition
393
+ router.post('/users/upsert', ...entity.upsertArraySubstack);
394
+
395
+ // Request body
396
+ {
397
+ rows: [
398
+ { id: 1, name: 'John Updated', email: 'john@example.com' },
399
+ { name: 'Jane New', email: 'jane@example.com' }
400
+ ],
401
+ conflictTarget: 'id'
402
+ }
403
+ ```
404
+
405
+ **Using email as conflict target:**
406
+
407
+ ```javascript
408
+ // If a user with this email exists, update their name; otherwise, insert
409
+ {
410
+ rows: [
411
+ { name: 'John', email: 'john@example.com', age: 30 }
412
+ ],
413
+ conflictTarget: 'email'
414
+ }
415
+ ```
416
+
417
+ **Using multiple columns as conflict target:**
418
+
419
+ ```javascript
420
+ // Unique constraint on combination of name and email
421
+ {
422
+ rows: [
423
+ { name: 'John', email: 'john@example.com', age: 30 }
424
+ ],
425
+ conflictTarget: ['name', 'email']
426
+ }
427
+ ```
428
+
429
+ **Using the query generator directly:**
430
+
431
+ ```javascript
432
+ const { query, args } = entity.query.upsert(
433
+ [{ id: 1, name: 'John', email: 'john@example.com' }],
434
+ 'id',
435
+ 1, // consumerId (optional)
436
+ 'admin', // consumerName (optional)
437
+ 'RETURNING id' // return clause (optional)
438
+ );
439
+ // Generates:
440
+ // INSERT INTO public.users (name, email, "consumerId", "consumerName")
441
+ // VALUES ($1, $2, $3, $4)
442
+ // ON CONFLICT (id) DO UPDATE SET
443
+ // name = EXCLUDED.name,
444
+ // email = EXCLUDED.email,
445
+ // "consumerId" = EXCLUDED."consumerId",
446
+ // "consumerName" = EXCLUDED."consumerName"
447
+ // RETURNING id
448
+ ```
449
+
450
+ #### Property Configuration for Upsert
451
+
452
+ Properties are automatically included in upsert if they have both INSERT and UPDATE operations:
453
+
454
+ ```javascript
455
+ {
456
+ key: 'name',
457
+ operations: ['SELECT', 'INSERT', 'UPDATE'] // Included in upsert
458
+ }
459
+
460
+ {
461
+ key: 'id',
462
+ operations: ['SELECT', 'UPDATE'] // NOT included (no INSERT)
463
+ }
464
+
465
+ {
466
+ key: 'createdAt',
467
+ operations: ['SELECT', 'INSERT'] // NOT included (no UPDATE)
468
+ }
469
+ ```
470
+
471
+ #### Important Notes
472
+
473
+ - **Conflict Target Required**: The `conflictTarget` parameter must specify an existing unique constraint or primary key
474
+ - **Mixed Rows**: You can upsert rows with and without IDs in the same request if your conflict target handles it (e.g., using `SERIAL` primary key)
475
+ - **Atomic Operation**: Unlike separate insert/update calls, upsert is a single atomic database operation
476
+ - **Concurrent Safety**: Prevents race conditions when multiple requests try to create the same record
477
+
293
478
  ### Filters
294
479
 
295
480
  Filters support two formats for maximum flexibility:
@@ -98,6 +98,7 @@ export declare class SQLEntity extends Entity {
98
98
  private sel: unknown;
99
99
  private ins: unknown;
100
100
  private upd: unknown;
101
+ private ups: unknown;
101
102
  private arc: unknown;
102
103
 
103
104
  constructor(name: string, properties: Property[], schema?: string);
@@ -114,6 +115,9 @@ export declare class SQLEntity extends Entity {
114
115
  get addOneSubstack(): SubstackTuple;
115
116
  get updateArraySubstack(): SubstackTuple;
116
117
  get updateOneSubstack(): SubstackTuple;
118
+ get upsertArraySubstack(): SubstackTuple;
119
+ get upsertOneSubstack(): SubstackTuple;
120
+ get syncArraySubstack(): SubstackTuple;
117
121
 
118
122
  query: {
119
123
  select: (
@@ -155,6 +159,17 @@ export declare class SQLEntity extends Entity {
155
159
  args: unknown[];
156
160
  };
157
161
 
162
+ upsert: (
163
+ rows: Record<string, unknown>[],
164
+ conflictTarget: string | string[],
165
+ consumerId?: number | string,
166
+ consumerName?: string,
167
+ rtn?: string
168
+ ) => {
169
+ query: string;
170
+ args: unknown[];
171
+ };
172
+
158
173
  delete: (ids: number[]) => {
159
174
  query: string;
160
175
  args: number[];
@@ -168,6 +183,8 @@ export declare class SQLEntity extends Entity {
168
183
  get(req: Request, res: Response, next: NextFunction): void;
169
184
  add(req: Request, res: Response, next: NextFunction): Promise<void>;
170
185
  update(req: Request, res: Response, next: NextFunction): Promise<void>;
186
+ upsert(req: Request, res: Response, next: NextFunction): Promise<void>;
187
+ sync(req: Request, res: Response, next: NextFunction): Promise<void>;
171
188
  archive(req: Request, res: Response, next: NextFunction): Promise<void>;
172
189
  delete(req: Request, res: Response, next: NextFunction): Promise<void>;
173
190
  deleteArchive(req: Request, res: Response, next: NextFunction): void;
@@ -372,6 +372,75 @@ class Update {
372
372
  }
373
373
  }
374
374
 
375
+ class Upsert {
376
+ constructor() {
377
+ this._props = [];
378
+ this._quotedProps = [];
379
+ this._nbProps = 0;
380
+ this._cols = "";
381
+ }
382
+ addProp(prop) {
383
+ this._props.push(prop);
384
+ this._quotedProps.push(quoteIfUppercase(prop));
385
+ this._nbProps++;
386
+ this._cols = this._quotedProps.join(", ");
387
+ }
388
+ query(schema, table, rows, conflictTarget, consumerId, consumerName, rtn = "") {
389
+ if (!conflictTarget ||
390
+ (Array.isArray(conflictTarget) && conflictTarget.length === 0) ||
391
+ (typeof conflictTarget === 'string' && conflictTarget.trim() === '')) {
392
+ throw new Error('conflictTarget must be provided for upsert operation');
393
+ }
394
+ const propsToUse = [...this._props];
395
+ const quotedPropsToUse = [...this._quotedProps];
396
+ let nbProps = this._nbProps;
397
+ let cols = this._cols;
398
+ if (consumerId !== undefined && consumerName !== undefined) {
399
+ propsToUse.push("consumerId", "consumerName");
400
+ quotedPropsToUse.push(`"consumerId"`, `"consumerName"`);
401
+ nbProps += 2;
402
+ cols += `, "consumerId", "consumerName"`;
403
+ }
404
+ const conflictColumns = Array.isArray(conflictTarget)
405
+ ? conflictTarget.map(col => quoteIfUppercase(col)).join(", ")
406
+ : quoteIfUppercase(conflictTarget);
407
+ let query = `INSERT INTO ${quoteIfUppercase(schema)}.${quoteIfUppercase(table)} (${cols}) VALUES `;
408
+ const args = [];
409
+ let i = 0;
410
+ for (const row of rows) {
411
+ if (consumerId !== undefined && consumerName !== undefined) {
412
+ row.consumerId = consumerId;
413
+ row.consumerName = consumerName;
414
+ }
415
+ query += `${$i(nbProps, i)}, `;
416
+ for (const prop of propsToUse) {
417
+ args.push(row[prop]);
418
+ }
419
+ i += nbProps;
420
+ }
421
+ query = query.slice(0, -2);
422
+ query += ` ON CONFLICT (${conflictColumns}) DO UPDATE SET `;
423
+ const conflictTargetArray = Array.isArray(conflictTarget) ? conflictTarget : [conflictTarget];
424
+ const updateCols = quotedPropsToUse.filter((_, idx) => {
425
+ const propName = propsToUse[idx];
426
+ return !conflictTargetArray.includes(propName);
427
+ });
428
+ for (const col of updateCols) {
429
+ query += `${col} = EXCLUDED.${col}, `;
430
+ }
431
+ query = query.slice(0, -2);
432
+ if (rtn)
433
+ query += ` ${rtn}`;
434
+ return { query, args };
435
+ }
436
+ rtn(prop) {
437
+ return `RETURNING ${quoteIfUppercase(prop)}`;
438
+ }
439
+ execute(query, args, client) {
440
+ return execute(query, args, client);
441
+ }
442
+ }
443
+
375
444
  var __awaiter$2 = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {
376
445
  function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
377
446
  return new (P || (P = Promise))(function (resolve, reject) {
@@ -613,6 +682,7 @@ class SQLEntity extends Entity {
613
682
  this.sel = new Select();
614
683
  this.ins = new Insert();
615
684
  this.upd = new Update();
685
+ this.ups = new Upsert();
616
686
  this.arc = new Archive();
617
687
  this.query = {
618
688
  select: (first = 0, rows = null, sortField = null, sortOrder = null, filters = null) => {
@@ -624,6 +694,9 @@ class SQLEntity extends Entity {
624
694
  insert: (rows, consumerId, consumerName, rtn = "") => {
625
695
  return this.ins.query(this.schema, this.table, rows, consumerId, consumerName, rtn);
626
696
  },
697
+ upsert: (rows, conflictTarget, consumerId, consumerName, rtn = "") => {
698
+ return this.ups.query(this.schema, this.table, rows, conflictTarget, consumerId, consumerName, rtn);
699
+ },
627
700
  delete: (ids) => {
628
701
  return queryById(this.schema, this.table, ids);
629
702
  },
@@ -706,6 +779,39 @@ class SQLEntity extends Entity {
706
779
  l.rows = r;
707
780
  next();
708
781
  });
782
+ this.upsert = (req, res, next) => __awaiter(this, void 0, void 0, function* () {
783
+ const l = res.locals;
784
+ const rows = req.body.rows;
785
+ const conflictTarget = req.body.conflictTarget;
786
+ const dbClient = l.dbClient || null;
787
+ const cId = l.consumerId;
788
+ const cName = l.consumerName;
789
+ if (!conflictTarget) {
790
+ return next({ status: 400, msg: "Missing conflictTarget for upsert operation" });
791
+ }
792
+ if (!rows || !Array.isArray(rows) || rows.length === 0) {
793
+ return next({ status: 400, msg: "Missing or empty rows array for upsert operation" });
794
+ }
795
+ log.debug(`${LOGS_PREFIX}upsert(rows=${rows.length}, conflictTarget=${conflictTarget}, consumerId=${cId})`);
796
+ const rtn = this.ups.rtn("id");
797
+ const chunks = chunk(rows);
798
+ for (const c of chunks) {
799
+ const { query, args } = this.ups.query(this._schema, this._table, c, conflictTarget, cId, cName, rtn);
800
+ let db;
801
+ try {
802
+ db = yield execute(query, args, dbClient);
803
+ }
804
+ catch (err) {
805
+ return next(err);
806
+ }
807
+ const r = db.rows;
808
+ for (let i = 0; i < c.length; i++) {
809
+ c[i].id = r[i].id;
810
+ }
811
+ }
812
+ l.rows = flatten(chunks);
813
+ next();
814
+ });
709
815
  this.archive = (req, res, next) => __awaiter(this, void 0, void 0, function* () {
710
816
  const l = res.locals;
711
817
  let r = req.body.rows;
@@ -774,6 +880,74 @@ class SQLEntity extends Entity {
774
880
  })
775
881
  .catch((err) => next(err));
776
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
+ });
777
951
  this._table = name;
778
952
  this._schema = schema;
779
953
  log.info(`${LOGS_PREFIX}Creating SQLEntity: "${name}"`);
@@ -813,7 +987,18 @@ class SQLEntity extends Entity {
813
987
  get updateOneSubstack() {
814
988
  return [this.normalizeOne, this.validateOne, this.update];
815
989
  }
990
+ get upsertArraySubstack() {
991
+ return [this.normalizeArray, this.validateArray, this.upsert];
992
+ }
993
+ get upsertOneSubstack() {
994
+ return [this.normalizeOne, this.validateOne, this.upsert];
995
+ }
996
+ get syncArraySubstack() {
997
+ return [this.normalizeArray, this.validateArray, this.sync];
998
+ }
816
999
  mapProps(operations, key) {
1000
+ let hasInsert = false;
1001
+ let hasUpdate = false;
817
1002
  for (const o of operations) {
818
1003
  switch (o) {
819
1004
  case "SELECT":
@@ -821,12 +1006,17 @@ class SQLEntity extends Entity {
821
1006
  break;
822
1007
  case "UPDATE":
823
1008
  this.upd.addProp(key);
1009
+ hasUpdate = true;
824
1010
  break;
825
1011
  case "INSERT":
826
1012
  this.ins.addProp(key);
1013
+ hasInsert = true;
827
1014
  break;
828
1015
  }
829
1016
  }
1017
+ if (hasInsert && hasUpdate) {
1018
+ this.ups.addProp(key);
1019
+ }
830
1020
  }
831
1021
  }
832
1022
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dwtechs/antity-pgsql",
3
- "version": "0.13.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.13.1"
43
+ "pg": "8.20.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@babel/core": "7.26.0",