@axiosleo/orm-mysql 0.15.0 → 0.15.1
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 +29 -431
- package/bin/orm-mysql.js +1 -1
- package/commands/skills.js +1 -1
- package/package.json +1 -1
- package/skills/SKILL.md +2 -0
- package/skills/crud-operations.md +2 -0
- package/skills/pagination.md +301 -0
- package/skills/where-conditions.md +44 -0
package/README.md
CHANGED
|
@@ -15,48 +15,8 @@ npm install @axiosleo/orm-mysql
|
|
|
15
15
|
|
|
16
16
|
## Usage
|
|
17
17
|
|
|
18
|
-
### Create MySQL client
|
|
19
|
-
|
|
20
|
-
```javascript
|
|
21
|
-
const { createClient } = require("@axiosleo/orm-mysql");
|
|
22
|
-
|
|
23
|
-
const client = createClient({
|
|
24
|
-
host: process.env.MYSQL_HOST,
|
|
25
|
-
port: process.env.MYSQL_PORT,
|
|
26
|
-
user: process.env.MYSQL_USER,
|
|
27
|
-
password: process.env.MYSQL_PASS,
|
|
28
|
-
database: process.env.MYSQL_DB,
|
|
29
|
-
});
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
### Initialize database handler
|
|
33
|
-
|
|
34
|
-
```javascript
|
|
35
|
-
const { QueryHandler } = require("@axiosleo/orm-mysql");
|
|
36
|
-
|
|
37
|
-
const db = new QueryHandler(client);
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
### Initialize query
|
|
41
|
-
|
|
42
18
|
```javascript
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
query.attr("id", "name", "age"); // set attributes
|
|
46
|
-
query.where("name", "Joe"); // set where condition
|
|
47
|
-
query.orWhere("age", ">", 18); // set or where condition
|
|
48
|
-
query.andWhere("age", "<", 30); // set and where condition
|
|
49
|
-
query.orderBy("age", "desc"); // set order by
|
|
50
|
-
query.limit(10); // set limit
|
|
51
|
-
query.offset(0); // set offset
|
|
52
|
-
|
|
53
|
-
let rows = await query.select(); // select
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
### Some Query Examples
|
|
57
|
-
|
|
58
|
-
```javascript
|
|
59
|
-
const { createClient, QueryHandler, Query } = require("@axiosleo/orm-mysql");
|
|
19
|
+
const { createClient, QueryHandler } = require("@axiosleo/orm-mysql");
|
|
60
20
|
|
|
61
21
|
const conn = createClient({
|
|
62
22
|
host: process.env.MYSQL_HOST,
|
|
@@ -66,416 +26,54 @@ const conn = createClient({
|
|
|
66
26
|
database: process.env.MYSQL_DB,
|
|
67
27
|
});
|
|
68
28
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
async function selectExample() {
|
|
72
|
-
const query = handler.table("users"); // init QueryOperator by table name
|
|
73
|
-
|
|
74
|
-
query.attr("id", "name", "age"); // set attributes
|
|
75
|
-
query.where("name", "Joe"); // set where condition
|
|
76
|
-
query.orWhere("age", ">", 18); // set or where condition
|
|
77
|
-
query.andWhere("age", "<", 30); // set and where condition
|
|
78
|
-
query.orderBy("age", "desc"); // set order by
|
|
79
|
-
query.limit(10); // set limit
|
|
80
|
-
query.offset(0); // set offset
|
|
29
|
+
const db = new QueryHandler(conn);
|
|
81
30
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
query.attr("id", "name", "age"); // set attributes
|
|
89
|
-
query.where("name", "Joe"); // set where condition
|
|
90
|
-
query.orWhere("age", ">", 18); // set or where condition
|
|
91
|
-
query.andWhere("age", "<", 30); // set and where condition
|
|
92
|
-
query.orderBy("age", "desc"); // set order by
|
|
93
|
-
// query.limit(10); // not supported set limit
|
|
94
|
-
// query.offset(10); // not supported set offset
|
|
95
|
-
|
|
96
|
-
let row = await query.find(); // find single row
|
|
97
|
-
}
|
|
31
|
+
// SELECT
|
|
32
|
+
const users = await db.table("users")
|
|
33
|
+
.where("age", ">", 18)
|
|
34
|
+
.orderBy("name", "asc")
|
|
35
|
+
.limit(10)
|
|
36
|
+
.select("id", "name", "age");
|
|
98
37
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
// insert
|
|
103
|
-
let row = await query.insert({
|
|
104
|
-
name: "Joe",
|
|
105
|
-
age: 18,
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
// The insert operation will be changed to the update operation if the uuid already exists
|
|
109
|
-
row = await query.keys('uuid').insert({
|
|
110
|
-
uuid: 'uuid-string', // uuid is unique index
|
|
111
|
-
name: "Joe",
|
|
112
|
-
age: 18,
|
|
113
|
-
})
|
|
114
|
-
}
|
|
38
|
+
// FIND single row
|
|
39
|
+
const user = await db.table("users").where("id", 1).find();
|
|
115
40
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
// update
|
|
120
|
-
let row = await query.where("name", "Joe").update({
|
|
121
|
-
name: "Joe",
|
|
122
|
-
age: 18,
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
// incrBy with number
|
|
126
|
-
row = await query.where("name", "Joe").incrBy("age", 1);
|
|
127
|
-
|
|
128
|
-
// incrBy with string
|
|
129
|
-
row = await query.where("name", "Joe").incrBy("age", "1");
|
|
130
|
-
|
|
131
|
-
// incrBy with Callback
|
|
132
|
-
let result = { status: "success" };
|
|
133
|
-
row = await query.where("id", 1).incrBy("error_times", () => {
|
|
134
|
-
// increase error_times if result.status is not success
|
|
135
|
-
if (result.status !== "success") {
|
|
136
|
-
return 1;
|
|
137
|
-
}
|
|
138
|
-
return 0;
|
|
139
|
-
});
|
|
140
|
-
}
|
|
41
|
+
// INSERT
|
|
42
|
+
await db.table("users").insert({ name: "Joe", age: 18 });
|
|
141
43
|
|
|
142
|
-
|
|
143
|
-
|
|
44
|
+
// UPDATE
|
|
45
|
+
await db.table("users").where("id", 1).update({ name: "Joe", age: 19 });
|
|
144
46
|
|
|
145
|
-
|
|
146
|
-
|
|
47
|
+
// DELETE
|
|
48
|
+
await db.table("users").where("id", 1).delete();
|
|
147
49
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
async function subqueryExample() {
|
|
153
|
-
const query = handler.table("users", "u");
|
|
154
|
-
const subQuery = new Query("select");
|
|
155
|
-
subQuery.table("users").having("COUNT(*)", ">", 1);
|
|
156
|
-
|
|
157
|
-
const sql = query.where("u.name", subQuery, "IN").buildSql("select").sql;
|
|
158
|
-
// SELECT * FROM `users` AS `u` WHERE `u`.`name` IN (SELECT * FROM `users` GROUP BY `u`.`name` HAVING COUNT(*) > ?)
|
|
159
|
-
}
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
### Hook
|
|
163
|
-
|
|
164
|
-
```javascript
|
|
165
|
-
const { Hook } = require("@axiosleo/orm-mysql");
|
|
166
|
-
|
|
167
|
-
// opt: 'select' | 'find' | 'insert' | 'update' | 'delete' | 'count'
|
|
168
|
-
|
|
169
|
-
Hook.pre(async (options) => {
|
|
170
|
-
debug.log('options', options);
|
|
171
|
-
}, { table: 'table_name', opt: 'insert'});
|
|
172
|
-
|
|
173
|
-
Hook.post(async (options, result) => {
|
|
174
|
-
throw new Error('some error');
|
|
175
|
-
}, { table: 'table_name', opt: 'insert' });
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
### Transaction
|
|
179
|
-
|
|
180
|
-
#### Method 1: Using Connection Pool (Recommended)
|
|
181
|
-
|
|
182
|
-
```javascript
|
|
183
|
-
const mysql = require("mysql2");
|
|
184
|
-
const { QueryHandler } = require("@axiosleo/orm-mysql");
|
|
185
|
-
|
|
186
|
-
// Create connection pool
|
|
187
|
-
const pool = mysql.createPool({
|
|
188
|
-
host: process.env.MYSQL_HOST,
|
|
189
|
-
port: process.env.MYSQL_PORT,
|
|
190
|
-
user: process.env.MYSQL_USER,
|
|
191
|
-
password: process.env.MYSQL_PASS,
|
|
192
|
-
database: process.env.MYSQL_DB,
|
|
193
|
-
connectionLimit: 10
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
const handler = new QueryHandler(pool);
|
|
197
|
-
|
|
198
|
-
// Begin transaction - automatically gets a connection from pool
|
|
199
|
-
const transaction = await handler.beginTransaction({
|
|
200
|
-
level: "RC" // READ COMMITTED
|
|
201
|
-
});
|
|
50
|
+
// COUNT
|
|
51
|
+
const total = await db.table("users").where("age", ">", 18).count();
|
|
202
52
|
|
|
53
|
+
// TRANSACTION
|
|
54
|
+
const tx = await db.beginTransaction({ level: "RC" });
|
|
203
55
|
try {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
name: "Joe",
|
|
207
|
-
age: 18,
|
|
208
|
-
});
|
|
209
|
-
const lastInsertId = row.insertId;
|
|
210
|
-
|
|
211
|
-
// Insert student info
|
|
212
|
-
await transaction.table("students").insert({
|
|
213
|
-
user_id: lastInsertId,
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
// Commit transaction - connection automatically released back to pool
|
|
217
|
-
await transaction.commit();
|
|
56
|
+
await tx.table("users").insert({ name: "Joe", age: 18 });
|
|
57
|
+
await tx.commit();
|
|
218
58
|
} catch (e) {
|
|
219
|
-
|
|
220
|
-
await transaction.rollback();
|
|
59
|
+
await tx.rollback();
|
|
221
60
|
throw e;
|
|
222
61
|
}
|
|
223
62
|
```
|
|
224
63
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
```javascript
|
|
228
|
-
const { TransactionHandler, createPromiseClient } = require("@axiosleo/orm-mysql");
|
|
229
|
-
|
|
230
|
-
const conn = await createPromiseClient({
|
|
231
|
-
host: process.env.MYSQL_HOST,
|
|
232
|
-
port: process.env.MYSQL_PORT,
|
|
233
|
-
user: process.env.MYSQL_USER,
|
|
234
|
-
password: process.env.MYSQL_PASS,
|
|
235
|
-
database: process.env.MYSQL_DB,
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
const transaction = new TransactionHandler(conn, {
|
|
239
|
-
/*
|
|
240
|
-
Transaction Isolation Levels:
|
|
241
|
-
- 'READ UNCOMMITTED' | 'RU' : Lowest isolation, may read dirty data
|
|
242
|
-
- 'READ COMMITTED' | 'RC' : Prevents dirty reads
|
|
243
|
-
- 'REPEATABLE READ' | 'RR' : MySQL default, prevents non-repeatable reads
|
|
244
|
-
- 'SERIALIZABLE' | 'S' : Highest isolation, full serialization
|
|
245
|
-
*/
|
|
246
|
-
level: "SERIALIZABLE", // 'SERIALIZABLE' as default value
|
|
247
|
-
});
|
|
248
|
-
await transaction.begin();
|
|
249
|
-
|
|
250
|
-
try {
|
|
251
|
-
// Insert user info
|
|
252
|
-
let row = await transaction.table("users").insert({
|
|
253
|
-
name: "Joe",
|
|
254
|
-
age: 18,
|
|
255
|
-
});
|
|
256
|
-
const lastInsertId = row[0].insertId;
|
|
257
|
-
|
|
258
|
-
// Insert student info
|
|
259
|
-
await transaction.table("students").insert({
|
|
260
|
-
user_id: lastInsertId,
|
|
261
|
-
});
|
|
262
|
-
await transaction.commit();
|
|
263
|
-
} catch (e) {
|
|
264
|
-
await transaction.rollback();
|
|
265
|
-
throw e;
|
|
266
|
-
}
|
|
267
|
-
```
|
|
268
|
-
|
|
269
|
-
#### Row Locking with FOR UPDATE
|
|
270
|
-
|
|
271
|
-
```javascript
|
|
272
|
-
const transaction = await handler.beginTransaction({ level: "RC" });
|
|
273
|
-
|
|
274
|
-
try {
|
|
275
|
-
// Lock rows for update
|
|
276
|
-
const product = await transaction.table("products")
|
|
277
|
-
.where("sku", "LAPTOP-001")
|
|
278
|
-
.append("FOR UPDATE") // Lock the row
|
|
279
|
-
.find();
|
|
280
|
-
|
|
281
|
-
if (product.stock < 1) {
|
|
282
|
-
throw new Error("Out of stock");
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// Update stock
|
|
286
|
-
await transaction.table("products")
|
|
287
|
-
.where("sku", "LAPTOP-001")
|
|
288
|
-
.update({ stock: product.stock - 1 });
|
|
289
|
-
|
|
290
|
-
// Create order
|
|
291
|
-
await transaction.table("orders").insert({
|
|
292
|
-
product_id: product.id,
|
|
293
|
-
quantity: 1,
|
|
294
|
-
total: product.price
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
await transaction.commit();
|
|
298
|
-
} catch (e) {
|
|
299
|
-
await transaction.rollback();
|
|
300
|
-
throw e;
|
|
301
|
-
}
|
|
302
|
-
```
|
|
303
|
-
|
|
304
|
-
#### Concurrent Transactions
|
|
305
|
-
|
|
306
|
-
When using a connection pool, multiple transactions can run concurrently without blocking each other:
|
|
307
|
-
|
|
308
|
-
```javascript
|
|
309
|
-
const pool = mysql.createPool({ /* ... */ });
|
|
310
|
-
const handler = new QueryHandler(pool);
|
|
311
|
-
|
|
312
|
-
// Run 3 transactions concurrently
|
|
313
|
-
await Promise.all([
|
|
314
|
-
(async () => {
|
|
315
|
-
const tx = await handler.beginTransaction();
|
|
316
|
-
try {
|
|
317
|
-
await tx.table("users").insert({ name: "User1" });
|
|
318
|
-
await tx.commit();
|
|
319
|
-
} catch (err) {
|
|
320
|
-
await tx.rollback();
|
|
321
|
-
throw err;
|
|
322
|
-
}
|
|
323
|
-
})(),
|
|
324
|
-
|
|
325
|
-
(async () => {
|
|
326
|
-
const tx = await handler.beginTransaction();
|
|
327
|
-
try {
|
|
328
|
-
await tx.table("users").insert({ name: "User2" });
|
|
329
|
-
await tx.commit();
|
|
330
|
-
} catch (err) {
|
|
331
|
-
await tx.rollback();
|
|
332
|
-
throw err;
|
|
333
|
-
}
|
|
334
|
-
})(),
|
|
335
|
-
|
|
336
|
-
(async () => {
|
|
337
|
-
const tx = await handler.beginTransaction();
|
|
338
|
-
try {
|
|
339
|
-
await tx.table("users").insert({ name: "User3" });
|
|
340
|
-
await tx.commit();
|
|
341
|
-
} catch (err) {
|
|
342
|
-
await tx.rollback();
|
|
343
|
-
throw err;
|
|
344
|
-
}
|
|
345
|
-
})()
|
|
346
|
-
]);
|
|
347
|
-
```
|
|
348
|
-
|
|
349
|
-
#### Best Practices
|
|
350
|
-
|
|
351
|
-
1. **Always use connection pools in production** - Prevents connection exhaustion and enables concurrent transactions
|
|
352
|
-
2. **Choose appropriate isolation level** - Balance between consistency and performance
|
|
353
|
-
3. **Use try-catch-finally** - Ensure transactions are always committed or rolled back
|
|
354
|
-
4. **Keep transactions short** - Avoid long-running operations inside transactions
|
|
355
|
-
5. **Use row locking when needed** - `FOR UPDATE` prevents concurrent modifications
|
|
356
|
-
6. **Handle errors properly** - Always rollback on errors to maintain data consistency
|
|
64
|
+
> For complete API documentation including where conditions, joins, hooks, migrations, and more, install the [AI Skills](#ai-skills) for your coding assistant, or browse the skill files in [`skills/`](skills/).
|
|
357
65
|
|
|
358
66
|
### Migration
|
|
359
67
|
|
|
360
|
-
> [Migration examples](./examples/migration/).
|
|
361
|
-
|
|
362
|
-
- Migration script example
|
|
363
|
-
|
|
364
|
-
```javascript
|
|
365
|
-
'use strict';
|
|
366
|
-
|
|
367
|
-
/**
|
|
368
|
-
* @param {import('@axiosleo/orm-mysql').MigrationInterface} migration
|
|
369
|
-
*/
|
|
370
|
-
function up(migration) {
|
|
371
|
-
migration.createTable('table1', {
|
|
372
|
-
field1: {
|
|
373
|
-
type: 'varchar',
|
|
374
|
-
length: 64,
|
|
375
|
-
allowNull: false,
|
|
376
|
-
uniqIndex: true
|
|
377
|
-
},
|
|
378
|
-
field2: {
|
|
379
|
-
type: 'VARCHAR',
|
|
380
|
-
allowNull: false
|
|
381
|
-
},
|
|
382
|
-
field3: {
|
|
383
|
-
type: 'VARCHAR',
|
|
384
|
-
comment: 'comment',
|
|
385
|
-
allowNull: false
|
|
386
|
-
},
|
|
387
|
-
});
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* @param {import('@axiosleo/orm-mysql').MigrationInterface} migration
|
|
392
|
-
*/
|
|
393
|
-
function down(migration) {
|
|
394
|
-
migration.dropTable('table1');
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
module.exports = {
|
|
398
|
-
up,
|
|
399
|
-
down
|
|
400
|
-
};
|
|
401
|
-
```
|
|
402
|
-
|
|
403
|
-
- Generate migration script
|
|
404
|
-
|
|
405
|
-
```bash
|
|
406
|
-
orm-mysql generate -h
|
|
407
|
-
|
|
408
|
-
Usage:
|
|
409
|
-
|
|
410
|
-
generate [--] [name] <dir>
|
|
411
|
-
gen
|
|
412
|
-
|
|
413
|
-
Arguments:
|
|
414
|
-
|
|
415
|
-
*name Migration name
|
|
416
|
-
dir Migration scripts directory
|
|
417
|
-
```
|
|
418
|
-
|
|
419
|
-
- Run migration
|
|
420
|
-
|
|
421
68
|
```bash
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
Description:
|
|
425
|
-
|
|
426
|
-
Migrate database
|
|
427
|
-
|
|
428
|
-
Usage:
|
|
429
|
-
|
|
430
|
-
migrate [options] [--] [action] <dir>
|
|
69
|
+
# Generate migration script
|
|
70
|
+
npx orm-mysql generate <name> [dir]
|
|
431
71
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
-d, --debug [false] debug mode
|
|
435
|
-
--host [localhost] mysql host
|
|
436
|
-
--port [3306] port number to connect to the database
|
|
437
|
-
--user [root] username for connect to the database
|
|
438
|
-
--pass password to connect to the database
|
|
439
|
-
--db database name
|
|
440
|
-
|
|
441
|
-
Arguments:
|
|
442
|
-
|
|
443
|
-
*action up or down
|
|
444
|
-
dir migration directory
|
|
72
|
+
# Run migration
|
|
73
|
+
npx orm-mysql migrate up [dir] --host=localhost --port=3306 --user=root --pass=pwd --db=mydb
|
|
445
74
|
```
|
|
446
75
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
```javascript
|
|
450
|
-
const {
|
|
451
|
-
createClient,
|
|
452
|
-
QueryHandler,
|
|
453
|
-
Query,
|
|
454
|
-
Builder
|
|
455
|
-
} = require("@axiosleo/orm-mysql");
|
|
456
|
-
|
|
457
|
-
const conn = createClient({
|
|
458
|
-
host: process.env.MYSQL_HOST,
|
|
459
|
-
port: process.env.MYSQL_PORT,
|
|
460
|
-
user: process.env.MYSQL_USER,
|
|
461
|
-
password: process.env.MYSQL_PASS,
|
|
462
|
-
database: process.env.MYSQL_DB,
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
const hanlder = new QueryHandler(conn, {
|
|
466
|
-
driver: 'custom',
|
|
467
|
-
queryHandler: (con, options) => {
|
|
468
|
-
const builder = new Builder(options);
|
|
469
|
-
return new Promise((resolve, reject) => {
|
|
470
|
-
if (options.operator === 'select') {
|
|
471
|
-
resolve([{ a: 1, b: 2 }]);
|
|
472
|
-
} else {
|
|
473
|
-
reject(new Error('some error'));
|
|
474
|
-
}
|
|
475
|
-
});
|
|
476
|
-
}
|
|
477
|
-
});
|
|
478
|
-
```
|
|
76
|
+
> [Migration examples](./examples/migration/)
|
|
479
77
|
|
|
480
78
|
## AI Skills
|
|
481
79
|
|
package/bin/orm-mysql.js
CHANGED
package/commands/skills.js
CHANGED
|
@@ -118,7 +118,7 @@ class SkillsCommand extends Command {
|
|
|
118
118
|
printer.info(`Found ${PKG_NAME}@${source.version} in node_modules`);
|
|
119
119
|
} else if (source.outdated) {
|
|
120
120
|
printer.warning(`${PKG_NAME}@${source.localVersion} is installed locally but does not include skills files.`);
|
|
121
|
-
printer.warning('Skills files are available since v0.15.
|
|
121
|
+
printer.warning('Skills files are available since v0.15.1. Please update:');
|
|
122
122
|
printer.warning(` npm install ${PKG_NAME}@latest`);
|
|
123
123
|
printer.println();
|
|
124
124
|
printer.info(`Using skills from npx ${PKG_NAME}@${source.version} instead.`);
|
package/package.json
CHANGED
package/skills/SKILL.md
CHANGED
|
@@ -98,6 +98,7 @@ await db.table("users").where("id", 1).delete();
|
|
|
98
98
|
|
|
99
99
|
// COUNT
|
|
100
100
|
const total = await db.table("users").where("age", ">", 18).count();
|
|
101
|
+
// IMPORTANT: for paginated lists, reuse the SAME builder for count() and select() -- see pagination.md
|
|
101
102
|
|
|
102
103
|
// FIND single row
|
|
103
104
|
const user = await db.table("users").where("id", 1).find();
|
|
@@ -124,6 +125,7 @@ console.log(builder.values); // [18]
|
|
|
124
125
|
| Building queries (table, join, orderBy, limit, groupBy, attr) | [query-building.md](query-building.md) |
|
|
125
126
|
| Where conditions (where, whereIn, whereLike, whereBetween...) | [where-conditions.md](where-conditions.md) |
|
|
126
127
|
| CRUD operations (select, find, count, insert, update, delete, incrBy, upsertRow) | [crud-operations.md](crud-operations.md) |
|
|
128
|
+
| Pagination (reuse the same builder for count() and select(), avoid duplicated where clauses) | [pagination.md](pagination.md) |
|
|
127
129
|
| Transactions (beginTransaction, commit, rollback, FOR UPDATE) | [transactions.md](transactions.md) |
|
|
128
130
|
|
|
129
131
|
## Hooks
|
|
@@ -39,6 +39,8 @@ const total = await db.table("users").where("status", "active").count();
|
|
|
39
39
|
// total is a number
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
For paginated list endpoints, do NOT create a separate count builder with duplicated `where` conditions. Reuse the same builder for both `count()` and `select()` -- `count()` ignores `attrs`/`limit`/`offset`/`orderBy`, and `select()` resets the operator. See [pagination.md](pagination.md) for the full pattern.
|
|
43
|
+
|
|
42
44
|
### explain(operator)
|
|
43
45
|
|
|
44
46
|
Returns the MySQL EXPLAIN result for the query.
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# Pagination
|
|
2
|
+
|
|
3
|
+
Best practices for building paginated list endpoints with `@axiosleo/orm-mysql`.
|
|
4
|
+
|
|
5
|
+
## Critical Rule
|
|
6
|
+
|
|
7
|
+
**For paginated queries, always reuse the SAME query builder for both `count()` and `select()`.**
|
|
8
|
+
NEVER construct a separate `countBuilder` and re-apply the same `where` conditions.
|
|
9
|
+
|
|
10
|
+
The `Query` instance returned by `db.table(...)` is a mutable object: each `where()`, `whereIn()`, `leftJoin()`, etc. mutates `this.options` and returns `this`. Calling `count()` on a builder does NOT consume or invalidate it -- you can chain `select()` afterwards on the very same instance.
|
|
11
|
+
|
|
12
|
+
## Anti-Pattern: Duplicating Conditions for COUNT
|
|
13
|
+
|
|
14
|
+
The following pattern is wrong. It is verbose, fragile, and a maintenance hazard: every time a filter is added or changed, both builders must be updated, and forgetting one of them silently desynchronizes `total` from the actual result set.
|
|
15
|
+
|
|
16
|
+
```javascript
|
|
17
|
+
// BAD -- do not write code like this
|
|
18
|
+
let queryBuilder = this.companyDB
|
|
19
|
+
.table(this.plansTable, 'pp')
|
|
20
|
+
.attr('pp.plan_no', 'pp.title', 'pp.plan_type', 'pp.status', 'pp.created_at')
|
|
21
|
+
.attr('u.name as created_by_name')
|
|
22
|
+
.leftJoin('users', 'pp.created_by=u.id', { alias: 'u' });
|
|
23
|
+
|
|
24
|
+
if (query.plan_no) {
|
|
25
|
+
queryBuilder = queryBuilder.where('pp.plan_no', 'like', `%${query.plan_no}%`);
|
|
26
|
+
}
|
|
27
|
+
if (query.status) {
|
|
28
|
+
queryBuilder = queryBuilder.where('pp.status', query.status);
|
|
29
|
+
}
|
|
30
|
+
if (query.plan_type) {
|
|
31
|
+
queryBuilder = queryBuilder.where('pp.plan_type', query.plan_type);
|
|
32
|
+
}
|
|
33
|
+
queryBuilder = queryBuilder.where('pp.disabled', 0);
|
|
34
|
+
|
|
35
|
+
const page = query.page || 1;
|
|
36
|
+
const pageSize = query.page_size || 10;
|
|
37
|
+
const offset = (page - 1) * pageSize;
|
|
38
|
+
|
|
39
|
+
// BAD: a second builder that re-declares the same table and re-applies every where
|
|
40
|
+
const countBuilder = this.companyDB
|
|
41
|
+
.table(this.plansTable, 'pp')
|
|
42
|
+
.where('pp.disabled', 0);
|
|
43
|
+
if (query.plan_no) {
|
|
44
|
+
countBuilder.where('pp.plan_no', 'like', `%${query.plan_no}%`);
|
|
45
|
+
}
|
|
46
|
+
if (query.status) {
|
|
47
|
+
countBuilder.where('pp.status', query.status);
|
|
48
|
+
}
|
|
49
|
+
if (query.plan_type) {
|
|
50
|
+
countBuilder.where('pp.plan_type', query.plan_type);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const total = await countBuilder.count();
|
|
54
|
+
|
|
55
|
+
const plans = await queryBuilder
|
|
56
|
+
.orderBy('pp.created_at', 'desc')
|
|
57
|
+
.limit(pageSize)
|
|
58
|
+
.offset(offset)
|
|
59
|
+
.select();
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Recommended Pattern: Reuse the Same Query Builder
|
|
63
|
+
|
|
64
|
+
Build the filters once on a single `queryBuilder`, then call `count()` followed by the chained `orderBy/limit/offset/select()` on the same instance.
|
|
65
|
+
|
|
66
|
+
```javascript
|
|
67
|
+
// GOOD
|
|
68
|
+
let queryBuilder = this.companyDB
|
|
69
|
+
.table(this.plansTable, 'pp')
|
|
70
|
+
.attr('pp.plan_no', 'pp.title', 'pp.plan_type', 'pp.status', 'pp.created_at')
|
|
71
|
+
.attr('u.name as created_by_name')
|
|
72
|
+
.leftJoin('users', 'pp.created_by=u.id', { alias: 'u' });
|
|
73
|
+
|
|
74
|
+
if (query.plan_no) {
|
|
75
|
+
queryBuilder = queryBuilder.where('pp.plan_no', 'like', `%${query.plan_no}%`);
|
|
76
|
+
}
|
|
77
|
+
if (query.status) {
|
|
78
|
+
queryBuilder = queryBuilder.where('pp.status', query.status);
|
|
79
|
+
}
|
|
80
|
+
if (query.plan_type) {
|
|
81
|
+
queryBuilder = queryBuilder.where('pp.plan_type', query.plan_type);
|
|
82
|
+
}
|
|
83
|
+
queryBuilder = queryBuilder.where('pp.disabled', 0);
|
|
84
|
+
|
|
85
|
+
const page = query.page || 1;
|
|
86
|
+
const pageSize = query.page_size || 10;
|
|
87
|
+
const offset = (page - 1) * pageSize;
|
|
88
|
+
|
|
89
|
+
const total = await queryBuilder.count();
|
|
90
|
+
|
|
91
|
+
const plans = await queryBuilder
|
|
92
|
+
.orderBy('pp.created_at', 'desc')
|
|
93
|
+
.limit(pageSize)
|
|
94
|
+
.offset(offset)
|
|
95
|
+
.select();
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Note how the filter block (`if (query.xxx) { ... }`) appears exactly once. There is no second copy to keep in sync.
|
|
99
|
+
|
|
100
|
+
## Why This Is Safe
|
|
101
|
+
|
|
102
|
+
Three implementation details from this ORM make the single-builder pattern correct:
|
|
103
|
+
|
|
104
|
+
1. **`Query.options` is mutable, methods return `this`.** Every chain call (`where`, `whereIn`, `leftJoin`, `attr`, `orderBy`, `limit`, `offset`, ...) mutates `this.options` on the same instance. There is no copy-on-write.
|
|
105
|
+
|
|
106
|
+
2. **`count()` ignores `attrs`, `limit`, `offset`, and `orderBy`.** The COUNT SQL is built by `_countOperator` in `src/builder.js`, which only consumes `tables`, `joins`, `conditions`, `groupField`, and `having`. It emits `SELECT COUNT(*) AS count FROM ...` and never reads the `attr()` list, the limit/offset, or the order-by list. So having those configured on the builder does not affect the count.
|
|
107
|
+
|
|
108
|
+
3. **`select()` overwrites `operator`.** `count()` sets `this.options.operator = 'count'`. `select()` then sets `this.options.operator = 'select'`, restoring the correct execution path. The two calls can safely happen on the same instance, in either order.
|
|
109
|
+
|
|
110
|
+
## Recommended Order
|
|
111
|
+
|
|
112
|
+
Call `count()` BEFORE chaining `orderBy`/`limit`/`offset`. Even though `count()` would ignore them anyway, this ordering keeps the read intent obvious:
|
|
113
|
+
|
|
114
|
+
```javascript
|
|
115
|
+
// 1. assemble all where / join / attr
|
|
116
|
+
// 2. take the total
|
|
117
|
+
const total = await queryBuilder.count();
|
|
118
|
+
|
|
119
|
+
// 3. then add purely "presentation" concerns and fetch the page
|
|
120
|
+
const list = await queryBuilder
|
|
121
|
+
.orderBy('pp.created_at', 'desc')
|
|
122
|
+
.limit(pageSize)
|
|
123
|
+
.offset(offset)
|
|
124
|
+
.select();
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Complete Pagination Template
|
|
128
|
+
|
|
129
|
+
A drop-in template for a typical "list with filters" endpoint:
|
|
130
|
+
|
|
131
|
+
```javascript
|
|
132
|
+
async function listPlans(query) {
|
|
133
|
+
let q = db.table('procurement_plans', 'pp')
|
|
134
|
+
.attr('pp.plan_no', 'pp.title', 'pp.plan_type', 'pp.status', 'pp.created_at')
|
|
135
|
+
.attr('u.name as created_by_name')
|
|
136
|
+
.leftJoin('users', 'pp.created_by=u.id', { alias: 'u' })
|
|
137
|
+
.where('pp.disabled', 0);
|
|
138
|
+
|
|
139
|
+
if (query.plan_no) {
|
|
140
|
+
q = q.where('pp.plan_no', 'like', `%${query.plan_no}%`);
|
|
141
|
+
}
|
|
142
|
+
if (query.status) {
|
|
143
|
+
q = q.where('pp.status', query.status);
|
|
144
|
+
}
|
|
145
|
+
if (query.plan_type) {
|
|
146
|
+
q = q.where('pp.plan_type', query.plan_type);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const page = Number(query.page) || 1;
|
|
150
|
+
const pageSize = Number(query.page_size) || 10;
|
|
151
|
+
const offset = (page - 1) * pageSize;
|
|
152
|
+
|
|
153
|
+
const total = await q.count();
|
|
154
|
+
|
|
155
|
+
const list = await q
|
|
156
|
+
.orderBy('pp.created_at', 'desc')
|
|
157
|
+
.limit(pageSize)
|
|
158
|
+
.offset(offset)
|
|
159
|
+
.select();
|
|
160
|
+
|
|
161
|
+
return { list, total, page, page_size: pageSize };
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Multi-Keyword Fuzzy Search
|
|
166
|
+
|
|
167
|
+
A common pagination filter is "search by keyword across several columns", where the user may type multiple space-separated keywords (e.g. `"foo bar"`) and expect rows matching ANY keyword in ANY of the searchable columns.
|
|
168
|
+
|
|
169
|
+
This pattern combines `whereCondition()` (for the per-keyword `column1 LIKE ? OR column2 LIKE ?` group) with logical operators between groups. Two foot-guns make it easy to write SQL that compiles but returns wrong results:
|
|
170
|
+
|
|
171
|
+
1. **Consecutive `whereCondition()` calls join with `AND` by default.** No operator is auto-inserted "between" groups except the implicit `AND`. So writing one `whereCondition()` per keyword without anything between them means every keyword must match -- not what users expect from search.
|
|
172
|
+
2. **Inserting `whereOr()` at the top level introduces an operator-precedence bug.** SQL evaluates `AND` before `OR`, so `WHERE other_filters AND (k1 group) OR (k2 group)` lets the second keyword match rows that ignore `other_filters` entirely.
|
|
173
|
+
|
|
174
|
+
### Anti-Pattern A: All keywords required (silent AND)
|
|
175
|
+
|
|
176
|
+
```javascript
|
|
177
|
+
// BAD -- generates: ... AND (plan_no LIKE %k1% OR title LIKE %k1%) AND (plan_no LIKE %k2% OR title LIKE %k2%)
|
|
178
|
+
// User searches "foo bar" but no row contains both "foo" and "bar" anywhere -- empty result.
|
|
179
|
+
if (query.keyword && query.keyword.trim()) {
|
|
180
|
+
const keywords = query.keyword.trim().split(/\s+/).filter(Boolean);
|
|
181
|
+
for (const keyword of keywords) {
|
|
182
|
+
const keywordCondition = new QueryCondition();
|
|
183
|
+
keywordCondition
|
|
184
|
+
.where('pp.plan_no', 'like', `%${keyword}%`)
|
|
185
|
+
.whereOr()
|
|
186
|
+
.where('pp.title', 'like', `%${keyword}%`);
|
|
187
|
+
queryBuilder = queryBuilder.whereCondition(keywordCondition);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Anti-Pattern B: Top-level `whereOr()` between groups (precedence bug)
|
|
193
|
+
|
|
194
|
+
```javascript
|
|
195
|
+
// BAD -- generates: ... AND (k1 group) OR (k2 group)
|
|
196
|
+
// AND binds tighter than OR, so disabled=0 / status / plan_type only constrain the first keyword group.
|
|
197
|
+
// The second keyword group OR's against everything else, returning soft-deleted or wrong-tenant rows.
|
|
198
|
+
keywords.forEach((keyword, index) => {
|
|
199
|
+
if (index !== 0) {
|
|
200
|
+
queryBuilder.whereOr();
|
|
201
|
+
}
|
|
202
|
+
const keywordCondition = new QueryCondition();
|
|
203
|
+
keywordCondition
|
|
204
|
+
.where('pp.plan_no', 'like', `%${keyword}%`)
|
|
205
|
+
.whereOr()
|
|
206
|
+
.where('pp.title', 'like', `%${keyword}%`);
|
|
207
|
+
queryBuilder = queryBuilder.whereCondition(keywordCondition);
|
|
208
|
+
});
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Recommended: Build a single `QueryCondition` and attach it once
|
|
212
|
+
|
|
213
|
+
Declare **one** `QueryCondition` outside the loop, push every keyword's `OR`-clauses into it, and attach the whole thing to the main builder with **one** `whereCondition()` call after the loop. All keyword `LIKE`s stay inside one parenthesized group, joined to the surrounding `AND` filters as a single safe sub-expression.
|
|
214
|
+
|
|
215
|
+
The diff from Anti-Pattern B is small and worth memorizing:
|
|
216
|
+
|
|
217
|
+
1. Move `const keywordCondition = new QueryCondition()` **out** of the loop.
|
|
218
|
+
2. Inside the loop, call `.whereOr()` on `keywordCondition` (the inner condition), **not** on `queryBuilder`.
|
|
219
|
+
3. Call `queryBuilder.whereCondition(keywordCondition)` **once**, after the loop, not on every iteration.
|
|
220
|
+
|
|
221
|
+
```javascript
|
|
222
|
+
// GOOD -- generates: ... AND (plan_no LIKE %k1% OR title LIKE %k1% OR plan_no LIKE %k2% OR title LIKE %k2%)
|
|
223
|
+
const { QueryCondition } = require('@axiosleo/orm-mysql');
|
|
224
|
+
|
|
225
|
+
if (query.keyword && query.keyword.trim()) {
|
|
226
|
+
const keywords = query.keyword.trim().split(/\s+/).filter(Boolean);
|
|
227
|
+
const keywordCondition = new QueryCondition();
|
|
228
|
+
keywords.forEach((keyword, index) => {
|
|
229
|
+
if (index !== 0) {
|
|
230
|
+
keywordCondition.whereOr();
|
|
231
|
+
}
|
|
232
|
+
keywordCondition
|
|
233
|
+
.where('pp.plan_no', 'like', `%${keyword}%`)
|
|
234
|
+
.whereOr()
|
|
235
|
+
.where('pp.title', 'like', `%${keyword}%`);
|
|
236
|
+
});
|
|
237
|
+
queryBuilder = queryBuilder.whereCondition(keywordCondition);
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Rules of Thumb
|
|
242
|
+
|
|
243
|
+
- One `whereCondition()` call -> one parenthesized group joined to the surrounding `WHERE` with `AND`. Safe and unambiguous.
|
|
244
|
+
- For "any-of-N" search filters, accumulate all `OR` clauses into **one** `QueryCondition` declared outside the loop, then attach it with **one** `whereCondition()` after the loop. Never call `queryBuilder.whereOr()` at the top level between `whereCondition()` calls -- that leaks the `OR` past your other `AND` filters because SQL evaluates `AND` before `OR`.
|
|
245
|
+
- The same rule applies to `count()` reuse: this composite condition is just data on `options.conditions`, so the same builder still produces a correct `COUNT(*)` with the keyword filter intact.
|
|
246
|
+
|
|
247
|
+
## Edge Case: GROUP BY
|
|
248
|
+
|
|
249
|
+
`count()` includes `GROUP BY` in the generated SQL, which means with grouping it returns one row per group rather than the total number of groups. In that case the same builder cannot be reused as-is for "how many groups are there".
|
|
250
|
+
|
|
251
|
+
Two valid approaches:
|
|
252
|
+
|
|
253
|
+
### Approach A: count via subquery
|
|
254
|
+
|
|
255
|
+
Wrap the grouped query as a subquery and count its rows. Build the inner query without `limit/offset/orderBy`, then count it:
|
|
256
|
+
|
|
257
|
+
```javascript
|
|
258
|
+
const { Query } = require('@axiosleo/orm-mysql');
|
|
259
|
+
|
|
260
|
+
const inner = new Query('select');
|
|
261
|
+
inner.table('orders', 'o')
|
|
262
|
+
.attr('o.user_id')
|
|
263
|
+
.where('o.status', 'paid')
|
|
264
|
+
.groupBy('o.user_id');
|
|
265
|
+
|
|
266
|
+
const total = await db.table(inner, 'sub').count();
|
|
267
|
+
|
|
268
|
+
const rows = await db.table('orders', 'o')
|
|
269
|
+
.attr('o.user_id', 'SUM(o.total) AS total')
|
|
270
|
+
.where('o.status', 'paid')
|
|
271
|
+
.groupBy('o.user_id')
|
|
272
|
+
.orderBy('total', 'desc')
|
|
273
|
+
.limit(pageSize)
|
|
274
|
+
.offset(offset)
|
|
275
|
+
.select();
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Approach B: dedicated count builder (only when grouping forces it)
|
|
279
|
+
|
|
280
|
+
If you genuinely need two builders because of grouping, factor the shared filters into a helper to keep them in sync:
|
|
281
|
+
|
|
282
|
+
```javascript
|
|
283
|
+
function applyFilters(q, query) {
|
|
284
|
+
q.where('o.status', 'paid');
|
|
285
|
+
if (query.user_id) q.where('o.user_id', query.user_id);
|
|
286
|
+
return q;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const groupedQ = applyFilters(
|
|
290
|
+
db.table('orders', 'o').attr('o.user_id', 'SUM(o.total) AS total').groupBy('o.user_id'),
|
|
291
|
+
query
|
|
292
|
+
);
|
|
293
|
+
const countInner = applyFilters(
|
|
294
|
+
new Query('select').table('orders', 'o').attr('o.user_id').groupBy('o.user_id'),
|
|
295
|
+
query
|
|
296
|
+
);
|
|
297
|
+
const total = await db.table(countInner, 'sub').count();
|
|
298
|
+
const list = await groupedQ.orderBy('total', 'desc').limit(pageSize).offset(offset).select();
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
For all non-grouped paginated lists, stick to the single-builder pattern at the top of this file.
|
|
@@ -131,6 +131,50 @@ query
|
|
|
131
131
|
// WHERE `status` = ? AND (`age` > ? AND `age` < ?)
|
|
132
132
|
```
|
|
133
133
|
|
|
134
|
+
### Default Joining Between Multiple `whereCondition()` Calls
|
|
135
|
+
|
|
136
|
+
Each `whereCondition()` call appends one parenthesized group to `WHERE`, joined to whatever came before with **`AND`**. There is no implicit `OR` between groups.
|
|
137
|
+
|
|
138
|
+
```javascript
|
|
139
|
+
const g1 = new QueryCondition();
|
|
140
|
+
g1.where("a", 1).whereOr().where("b", 2);
|
|
141
|
+
|
|
142
|
+
const g2 = new QueryCondition();
|
|
143
|
+
g2.where("c", 3).whereOr().where("d", 4);
|
|
144
|
+
|
|
145
|
+
query.where("status", "active")
|
|
146
|
+
.whereCondition(g1)
|
|
147
|
+
.whereCondition(g2);
|
|
148
|
+
// WHERE `status` = ? AND (`a` = ? OR `b` = ?) AND (`c` = ? OR `d` = ?)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Combining Multiple Groups with `OR`: Wrap, Don't Sprinkle
|
|
152
|
+
|
|
153
|
+
To `OR` two groups together, do **not** insert a top-level `whereOr()` between them on the main builder. SQL evaluates `AND` before `OR`, so the surrounding filters silently bind only to the first group:
|
|
154
|
+
|
|
155
|
+
```javascript
|
|
156
|
+
// BAD -- generates: WHERE `status` = ? AND (`a` = ? OR `b` = ?) OR (`c` = ? OR `d` = ?)
|
|
157
|
+
// `status` = ? only constrains the first group. The second group OR's against everything.
|
|
158
|
+
query.where("status", "active")
|
|
159
|
+
.whereCondition(g1)
|
|
160
|
+
.whereOr()
|
|
161
|
+
.whereCondition(g2);
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Instead, build a single outer `QueryCondition` containing the `OR`'d groups, then attach it once:
|
|
165
|
+
|
|
166
|
+
```javascript
|
|
167
|
+
// GOOD -- generates: WHERE `status` = ? AND ((`a` = ? OR `b` = ?) OR (`c` = ? OR `d` = ?))
|
|
168
|
+
const outer = new QueryCondition();
|
|
169
|
+
outer.whereCondition(g1).whereOr().whereCondition(g2);
|
|
170
|
+
|
|
171
|
+
query.where("status", "active").whereCondition(outer);
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Rule of thumb: `whereOr()` between two `whereCondition()` calls is safe **inside** a `QueryCondition` (it only changes that group's internal logic), but at the top level of the main query builder it leaks the `OR` past your other `AND` filters.
|
|
175
|
+
|
|
176
|
+
When the clauses you want to `OR` together are programmatically generated and share the same shape (e.g. an N-keyword fuzzy search across the same columns), skip the nested `g1`/`g2` and pour every clause directly into a single `QueryCondition` using its own `where()`/`whereOr()` calls -- then attach it to the main builder once. See [pagination.md](pagination.md) (Multi-Keyword Fuzzy Search) for the exact pattern.
|
|
177
|
+
|
|
134
178
|
## Complete Example
|
|
135
179
|
|
|
136
180
|
```javascript
|