@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 +121 -1
- package/dist/antity-pgsql.d.ts +15 -0
- package/dist/antity-pgsql.js +119 -0
- package/package.json +1 -1
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:
|
package/dist/antity-pgsql.d.ts
CHANGED
|
@@ -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;
|
package/dist/antity-pgsql.js
CHANGED
|
@@ -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
|
|