@dwtechs/antity-pgsql 0.13.0 → 0.14.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
@@ -190,6 +190,8 @@ class SQLEntity {
190
190
  get addOneSubstack(): SubstackTuple;
191
191
  get updateArraySubstack(): SubstackTuple;
192
192
  get updateOneSubstack(): SubstackTuple;
193
+ get upsertArraySubstack(): SubstackTuple;
194
+ get upsertOneSubstack(): SubstackTuple;
193
195
 
194
196
  query: {
195
197
  select: (
@@ -223,6 +225,15 @@ class SQLEntity {
223
225
  query: string;
224
226
  args: unknown[];
225
227
  };
228
+ upsert: (
229
+ rows: Record<string, unknown>[],
230
+ conflictTarget: string | string[],
231
+ consumerId?: number | string,
232
+ consumerName?: string,
233
+ rtn?: string) => {
234
+ query: string;
235
+ args: unknown[];
236
+ };
226
237
  delete: (ids: number[]) => {
227
238
  query: string;
228
239
  args: number[];
@@ -233,6 +244,7 @@ class SQLEntity {
233
244
  get: (req: Request, res: Response, next: NextFunction) => void;
234
245
  add: (req: Request, res: Response, next: NextFunction) => Promise<void>;
235
246
  update: (req: Request, res: Response, next: NextFunction) => Promise<void>;
247
+ upsert: (req: Request, res: Response, next: NextFunction) => Promise<void>;
236
248
  archive: (req: Request, res: Response, next: NextFunction) => Promise<void>;
237
249
  delete: (req: Request, res: Response, next: NextFunction) => Promise<void>;
238
250
  deleteArchive: (req: Request, res: Response, next: NextFunction) => void;
@@ -259,9 +271,11 @@ function execute(
259
271
 
260
272
  ### Middleware Methods for Express.js
261
273
 
262
- get(), add(), update(), archive(), delete(), deleteArchive() and getHistory() methods are made to be used as Express.js middlewares.
274
+ get(), add(), update(), upsert(), archive(), delete(), deleteArchive() and getHistory() methods are made to be used as Express.js middlewares.
263
275
  Each method will look for data to work on in the **req.body.rows** parameter.
264
276
 
277
+ The upsert() method additionally requires **req.body.conflictTarget** to specify which column(s) define uniqueness.
278
+
265
279
  ### Schema Qualification
266
280
 
267
281
  All SQL queries generated by Antity-pgsql use schema-qualified table names (e.g., `schema.table`). This provides:
@@ -279,17 +293,123 @@ Substacks are pre-composed middleware chains that combine normalization, validat
279
293
  - **addOneSubstack**: Combines `normalizeOne`, `validateOne`, and `add`. Use this for POST routes with `req.body` containing a single object.
280
294
  - **updateArraySubstack**: Combines `normalizeArray`, `validateArray`, and `update`. Use this for PUT routes with `req.body.rows` containing multiple objects.
281
295
  - **updateOneSubstack**: Combines `normalizeOne`, `validateOne`, and `update`. Use this for PUT routes with `req.body` containing a single object.
296
+ - **upsertArraySubstack**: Combines `normalizeArray`, `validateArray`, and `upsert`. Use this for upsert routes with `req.body.rows` containing multiple objects. Requires `req.body.conflictTarget`.
297
+ - **upsertOneSubstack**: Combines `normalizeOne`, `validateOne`, and `upsert`. Use this for upsert routes with `req.body` containing a single object. Requires `req.body.conflictTarget`.
282
298
 
283
299
  Using substacks simplifies your route definitions and ensures consistent data processing.
284
300
 
285
301
  ### Query Methods
286
302
 
287
303
  - **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
+ - **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
+ - **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.
288
307
  - **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.
289
308
  - **delete()**: Deletes rows by their IDs. Expects `req.body.rows` to be an array of objects with `id` property: `[{id: 1}, {id: 2}]`
290
309
  - **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
310
  - **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
311
 
312
+ ### Upsert (Insert or Update)
313
+
314
+ 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.
315
+
316
+ #### How It Works
317
+
318
+ 1. **Conflict Target**: You specify which column(s) define uniqueness (e.g., `'id'`, `'email'`, or `['name', 'email']`)
319
+ 2. **Property Selection**: Properties are automatically included if they have **both** `INSERT` and `UPDATE` in their `operations` array
320
+ 3. **On Conflict**: When a conflict occurs, all columns except the conflict target are updated
321
+
322
+ #### Usage Examples
323
+
324
+ **Using the middleware with a single conflict target:**
325
+
326
+ ```javascript
327
+ // Route definition
328
+ router.post('/users/upsert', ...entity.upsertArraySubstack);
329
+
330
+ // Request body
331
+ {
332
+ rows: [
333
+ { id: 1, name: 'John Updated', email: 'john@example.com' },
334
+ { name: 'Jane New', email: 'jane@example.com' }
335
+ ],
336
+ conflictTarget: 'id'
337
+ }
338
+ ```
339
+
340
+ **Using email as conflict target:**
341
+
342
+ ```javascript
343
+ // If a user with this email exists, update their name; otherwise, insert
344
+ {
345
+ rows: [
346
+ { name: 'John', email: 'john@example.com', age: 30 }
347
+ ],
348
+ conflictTarget: 'email'
349
+ }
350
+ ```
351
+
352
+ **Using multiple columns as conflict target:**
353
+
354
+ ```javascript
355
+ // Unique constraint on combination of name and email
356
+ {
357
+ rows: [
358
+ { name: 'John', email: 'john@example.com', age: 30 }
359
+ ],
360
+ conflictTarget: ['name', 'email']
361
+ }
362
+ ```
363
+
364
+ **Using the query generator directly:**
365
+
366
+ ```javascript
367
+ const { query, args } = entity.query.upsert(
368
+ [{ id: 1, name: 'John', email: 'john@example.com' }],
369
+ 'id',
370
+ 1, // consumerId (optional)
371
+ 'admin', // consumerName (optional)
372
+ 'RETURNING id' // return clause (optional)
373
+ );
374
+ // Generates:
375
+ // INSERT INTO public.users (name, email, "consumerId", "consumerName")
376
+ // VALUES ($1, $2, $3, $4)
377
+ // ON CONFLICT (id) DO UPDATE SET
378
+ // name = EXCLUDED.name,
379
+ // email = EXCLUDED.email,
380
+ // "consumerId" = EXCLUDED."consumerId",
381
+ // "consumerName" = EXCLUDED."consumerName"
382
+ // RETURNING id
383
+ ```
384
+
385
+ #### Property Configuration for Upsert
386
+
387
+ Properties are automatically included in upsert if they have both INSERT and UPDATE operations:
388
+
389
+ ```javascript
390
+ {
391
+ key: 'name',
392
+ operations: ['SELECT', 'INSERT', 'UPDATE'] // Included in upsert
393
+ }
394
+
395
+ {
396
+ key: 'id',
397
+ operations: ['SELECT', 'UPDATE'] // NOT included (no INSERT)
398
+ }
399
+
400
+ {
401
+ key: 'createdAt',
402
+ operations: ['SELECT', 'INSERT'] // NOT included (no UPDATE)
403
+ }
404
+ ```
405
+
406
+ #### Important Notes
407
+
408
+ - **Conflict Target Required**: The `conflictTarget` parameter must specify an existing unique constraint or primary key
409
+ - **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)
410
+ - **Atomic Operation**: Unlike separate insert/update calls, upsert is a single atomic database operation
411
+ - **Concurrent Safety**: Prevents race conditions when multiple requests try to create the same record
412
+
293
413
  ### Filters
294
414
 
295
415
  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,8 @@ 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;
117
120
 
118
121
  query: {
119
122
  select: (
@@ -155,6 +158,17 @@ export declare class SQLEntity extends Entity {
155
158
  args: unknown[];
156
159
  };
157
160
 
161
+ upsert: (
162
+ rows: Record<string, unknown>[],
163
+ conflictTarget: string | string[],
164
+ consumerId?: number | string,
165
+ consumerName?: string,
166
+ rtn?: string
167
+ ) => {
168
+ query: string;
169
+ args: unknown[];
170
+ };
171
+
158
172
  delete: (ids: number[]) => {
159
173
  query: string;
160
174
  args: number[];
@@ -168,6 +182,7 @@ export declare class SQLEntity extends Entity {
168
182
  get(req: Request, res: Response, next: NextFunction): void;
169
183
  add(req: Request, res: Response, next: NextFunction): Promise<void>;
170
184
  update(req: Request, res: Response, next: NextFunction): Promise<void>;
185
+ upsert(req: Request, res: Response, next: NextFunction): Promise<void>;
171
186
  archive(req: Request, res: Response, next: NextFunction): Promise<void>;
172
187
  delete(req: Request, res: Response, next: NextFunction): Promise<void>;
173
188
  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;
@@ -813,7 +919,15 @@ class SQLEntity extends Entity {
813
919
  get updateOneSubstack() {
814
920
  return [this.normalizeOne, this.validateOne, this.update];
815
921
  }
922
+ get upsertArraySubstack() {
923
+ return [this.normalizeArray, this.validateArray, this.upsert];
924
+ }
925
+ get upsertOneSubstack() {
926
+ return [this.normalizeOne, this.validateOne, this.upsert];
927
+ }
816
928
  mapProps(operations, key) {
929
+ let hasInsert = false;
930
+ let hasUpdate = false;
817
931
  for (const o of operations) {
818
932
  switch (o) {
819
933
  case "SELECT":
@@ -821,12 +935,17 @@ class SQLEntity extends Entity {
821
935
  break;
822
936
  case "UPDATE":
823
937
  this.upd.addProp(key);
938
+ hasUpdate = true;
824
939
  break;
825
940
  case "INSERT":
826
941
  this.ins.addProp(key);
942
+ hasInsert = true;
827
943
  break;
828
944
  }
829
945
  }
946
+ if (hasInsert && hasUpdate) {
947
+ this.ups.addProp(key);
948
+ }
830
949
  }
831
950
  }
832
951
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dwtechs/antity-pgsql",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Open source library to add PostgreSQL support to @dwtechs/Antity entities.",
5
5
  "keywords": [
6
6
  "entities"