@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 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 query = db.table('<table-name>');
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 hanlder = new QueryHandler(conn);
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
- let rows = await query.select(); // select
83
- }
84
-
85
- async function findExample() {
86
- const query = handler.table("users"); // init QueryOperator by table name
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
- async function insertExample() {
100
- const query = handler.table("users");
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
- async function updateExample() {
117
- const query = handler.table("users");
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
- async function deleteExample() {
143
- const query = handler.table("users");
44
+ // UPDATE
45
+ await db.table("users").where("id", 1).update({ name: "Joe", age: 19 });
144
46
 
145
- // delete with conditions
146
- let result = await query.where("name", "Joe").delete();
47
+ // DELETE
48
+ await db.table("users").where("id", 1).delete();
147
49
 
148
- // delete by id
149
- result = await query.delete(1);
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
- // Insert user info
205
- let row = await transaction.table("users").insert({
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
- // Rollback transaction - connection automatically released back to pool
220
- await transaction.rollback();
59
+ await tx.rollback();
221
60
  throw e;
222
61
  }
223
62
  ```
224
63
 
225
- #### Method 2: Using TransactionHandler Directly
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
- orm-mysql migrate -h
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
- Options:
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
- ### Custom query driver
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
@@ -9,7 +9,7 @@ const app = new App({
9
9
  name: 'MySQL ORM CLI',
10
10
  desc: 'migrate, model, seed, etc.',
11
11
  bin: 'orm-mysql',
12
- version: '0.15.0',
12
+ version: '0.15.1',
13
13
  commands_dir: path.join(__dirname, '../commands'),
14
14
  });
15
15
 
@@ -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.0. Please update:');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiosleo/orm-mysql",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "MySQL ORM tool",
5
5
  "keywords": [
6
6
  "mysql",
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