@axiosleo/orm-mysql 0.14.4 → 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 +57 -416
- package/bin/orm-mysql.js +1 -1
- package/commands/skills.js +223 -0
- package/package.json +1 -1
- package/skills/SKILL.md +159 -0
- package/skills/crud-operations.md +225 -0
- package/skills/pagination.md +301 -0
- package/skills/query-building.md +159 -0
- package/skills/transactions.md +171 -0
- package/skills/where-conditions.md +190 -0
|
@@ -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.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Query Building
|
|
2
|
+
|
|
3
|
+
The `Query` class provides the fluent API for constructing SQL queries. You get a `Query`-based instance (actually `QueryOperator`) from `QueryHandler.table()`.
|
|
4
|
+
|
|
5
|
+
```javascript
|
|
6
|
+
const query = db.table("users"); // returns QueryOperator (extends Query)
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Table Selection
|
|
10
|
+
|
|
11
|
+
### Single table
|
|
12
|
+
|
|
13
|
+
```javascript
|
|
14
|
+
db.table("users");
|
|
15
|
+
db.table("users", "u"); // with alias
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
### Multiple tables
|
|
19
|
+
|
|
20
|
+
```javascript
|
|
21
|
+
db.tables(
|
|
22
|
+
{ table: "users", alias: "u" },
|
|
23
|
+
{ table: "orders", alias: "o" }
|
|
24
|
+
);
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Selecting Columns with attr()
|
|
28
|
+
|
|
29
|
+
```javascript
|
|
30
|
+
// Select specific columns
|
|
31
|
+
query.attr("id", "name", "email");
|
|
32
|
+
|
|
33
|
+
// Using sub-query as attribute
|
|
34
|
+
const { Query } = require("@axiosleo/orm-mysql");
|
|
35
|
+
|
|
36
|
+
query.attr(
|
|
37
|
+
"id",
|
|
38
|
+
"name",
|
|
39
|
+
() => {
|
|
40
|
+
const sub = new Query("select");
|
|
41
|
+
sub.table("orders").attr("COUNT(*)").where("orders.user_id", "users.id");
|
|
42
|
+
return sub;
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Calling `attr()` with no arguments clears all previously set attributes.
|
|
48
|
+
|
|
49
|
+
## Pagination
|
|
50
|
+
|
|
51
|
+
### limit and offset
|
|
52
|
+
|
|
53
|
+
```javascript
|
|
54
|
+
query.limit(10); // LIMIT 10
|
|
55
|
+
query.offset(20); // OFFSET 20
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### page (shorthand)
|
|
59
|
+
|
|
60
|
+
```javascript
|
|
61
|
+
query.page(10); // LIMIT 10 OFFSET 0
|
|
62
|
+
query.page(10, 2); // LIMIT 10 OFFSET 2
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Sorting
|
|
66
|
+
|
|
67
|
+
```javascript
|
|
68
|
+
query.orderBy("created_at", "desc");
|
|
69
|
+
query.orderBy("name", "asc");
|
|
70
|
+
// Multiple orderBy calls are cumulative
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Grouping and Aggregation
|
|
74
|
+
|
|
75
|
+
```javascript
|
|
76
|
+
query.groupBy("status");
|
|
77
|
+
query.groupBy("status", "category"); // multiple fields
|
|
78
|
+
|
|
79
|
+
// HAVING clause (requires groupBy)
|
|
80
|
+
query.groupBy("status").having("COUNT(*)", ">", 5);
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Setting Data
|
|
84
|
+
|
|
85
|
+
### set() -- for insert/update data
|
|
86
|
+
|
|
87
|
+
```javascript
|
|
88
|
+
query.set({ name: "Joe", age: 25 });
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### keys() -- specify columns for INSERT with ON DUPLICATE KEY UPDATE
|
|
92
|
+
|
|
93
|
+
```javascript
|
|
94
|
+
query.keys("uuid").insert({
|
|
95
|
+
uuid: "abc-123",
|
|
96
|
+
name: "Joe",
|
|
97
|
+
age: 25,
|
|
98
|
+
});
|
|
99
|
+
// If uuid already exists, the insert becomes an update
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Joins
|
|
103
|
+
|
|
104
|
+
### leftJoin / rightJoin / innerJoin (preferred)
|
|
105
|
+
|
|
106
|
+
```javascript
|
|
107
|
+
query.table("users", "u")
|
|
108
|
+
.leftJoin("orders", "u.id = orders.user_id", { alias: "o" })
|
|
109
|
+
.attr("u.id", "u.name", "o.total")
|
|
110
|
+
.select();
|
|
111
|
+
|
|
112
|
+
query.table("users", "u")
|
|
113
|
+
.rightJoin("orders", "u.id = orders.user_id")
|
|
114
|
+
.select();
|
|
115
|
+
|
|
116
|
+
query.table("users", "u")
|
|
117
|
+
.innerJoin("roles", "u.role_id = roles.id", { alias: "r" })
|
|
118
|
+
.select();
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### join() -- generic form
|
|
122
|
+
|
|
123
|
+
```javascript
|
|
124
|
+
query.join({
|
|
125
|
+
table: "orders",
|
|
126
|
+
table_alias: "o",
|
|
127
|
+
self_column: "id",
|
|
128
|
+
foreign_column: "user_id",
|
|
129
|
+
join_type: "left",
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Sub-query as join table
|
|
134
|
+
|
|
135
|
+
```javascript
|
|
136
|
+
const { Query } = require("@axiosleo/orm-mysql");
|
|
137
|
+
|
|
138
|
+
const subQuery = new Query("select");
|
|
139
|
+
subQuery.table("orders").attr("user_id", "SUM(total) AS order_total").groupBy("user_id");
|
|
140
|
+
|
|
141
|
+
query.table("users", "u")
|
|
142
|
+
.leftJoin(subQuery, "u.id = sub.user_id", { alias: "sub" })
|
|
143
|
+
.select();
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Complete Example
|
|
147
|
+
|
|
148
|
+
```javascript
|
|
149
|
+
const users = await db.table("users", "u")
|
|
150
|
+
.attr("u.id", "u.name", "u.email", "o.total")
|
|
151
|
+
.leftJoin("orders", "u.id = orders.user_id", { alias: "o" })
|
|
152
|
+
.where("u.status", "active")
|
|
153
|
+
.whereBetween("u.created_at", ["2024-01-01", "2024-12-31"])
|
|
154
|
+
.groupBy("u.id")
|
|
155
|
+
.having("COUNT(o.id)", ">", 0)
|
|
156
|
+
.orderBy("u.name", "asc")
|
|
157
|
+
.page(20, 0)
|
|
158
|
+
.select();
|
|
159
|
+
```
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Transactions
|
|
2
|
+
|
|
3
|
+
## Isolation Levels
|
|
4
|
+
|
|
5
|
+
| Shorthand | Full Name | Description |
|
|
6
|
+
|-----------|-----------|-------------|
|
|
7
|
+
| `RU` | `READ UNCOMMITTED` | Lowest isolation, may read dirty data |
|
|
8
|
+
| `RC` | `READ COMMITTED` | Prevents dirty reads |
|
|
9
|
+
| `RR` | `REPEATABLE READ` | MySQL default, prevents non-repeatable reads |
|
|
10
|
+
| `S` | `SERIALIZABLE` | Highest isolation, full serialization |
|
|
11
|
+
|
|
12
|
+
## Method 1: Pool + beginTransaction (Recommended)
|
|
13
|
+
|
|
14
|
+
Use `QueryHandler.beginTransaction()` with a connection pool. The transaction automatically gets its own connection from the pool and releases it on commit/rollback.
|
|
15
|
+
|
|
16
|
+
```javascript
|
|
17
|
+
const { createPool, QueryHandler } = require("@axiosleo/orm-mysql");
|
|
18
|
+
|
|
19
|
+
const pool = createPool({
|
|
20
|
+
host: "localhost",
|
|
21
|
+
port: 3306,
|
|
22
|
+
user: "root",
|
|
23
|
+
password: "password",
|
|
24
|
+
database: "my_db",
|
|
25
|
+
connectionLimit: 10,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const db = new QueryHandler(pool);
|
|
29
|
+
|
|
30
|
+
const tx = await db.beginTransaction({ level: "RC" });
|
|
31
|
+
try {
|
|
32
|
+
const result = await tx.table("users").insert({ name: "Joe", age: 25 });
|
|
33
|
+
const userId = result.insertId;
|
|
34
|
+
|
|
35
|
+
await tx.table("profiles").insert({ user_id: userId, bio: "Hello" });
|
|
36
|
+
|
|
37
|
+
await tx.commit();
|
|
38
|
+
} catch (err) {
|
|
39
|
+
await tx.rollback();
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Method 2: TransactionHandler Directly
|
|
45
|
+
|
|
46
|
+
For more control, create a `TransactionHandler` with a promise connection.
|
|
47
|
+
|
|
48
|
+
```javascript
|
|
49
|
+
const { TransactionHandler, createPromiseClient } = require("@axiosleo/orm-mysql");
|
|
50
|
+
|
|
51
|
+
const conn = await createPromiseClient({
|
|
52
|
+
host: "localhost",
|
|
53
|
+
port: 3306,
|
|
54
|
+
user: "root",
|
|
55
|
+
password: "password",
|
|
56
|
+
database: "my_db",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const tx = new TransactionHandler(conn, { level: "SERIALIZABLE" });
|
|
60
|
+
await tx.begin();
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await tx.table("users").insert({ name: "Joe", age: 25 });
|
|
64
|
+
await tx.commit();
|
|
65
|
+
} catch (err) {
|
|
66
|
+
await tx.rollback();
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Row Locking
|
|
72
|
+
|
|
73
|
+
`TransactionOperator` (returned by `tx.table()`) extends `QueryOperator` with `append()` for SQL suffixes.
|
|
74
|
+
|
|
75
|
+
### FOR UPDATE
|
|
76
|
+
|
|
77
|
+
Locks selected rows, preventing other transactions from reading or modifying them.
|
|
78
|
+
|
|
79
|
+
```javascript
|
|
80
|
+
const tx = await db.beginTransaction({ level: "RC" });
|
|
81
|
+
try {
|
|
82
|
+
const product = await tx.table("products")
|
|
83
|
+
.where("sku", "LAPTOP-001")
|
|
84
|
+
.append("FOR UPDATE")
|
|
85
|
+
.find();
|
|
86
|
+
|
|
87
|
+
if (product.stock < 1) {
|
|
88
|
+
throw new Error("Out of stock");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
await tx.table("products")
|
|
92
|
+
.where("sku", "LAPTOP-001")
|
|
93
|
+
.update({ stock: product.stock - 1 });
|
|
94
|
+
|
|
95
|
+
await tx.table("orders").insert({
|
|
96
|
+
product_id: product.id,
|
|
97
|
+
quantity: 1,
|
|
98
|
+
total: product.price,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await tx.commit();
|
|
102
|
+
} catch (err) {
|
|
103
|
+
await tx.rollback();
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### LOCK IN SHARE MODE
|
|
109
|
+
|
|
110
|
+
Allows other transactions to read but not modify the locked rows.
|
|
111
|
+
|
|
112
|
+
```javascript
|
|
113
|
+
const rows = await tx.table("products")
|
|
114
|
+
.where("category", "electronics")
|
|
115
|
+
.append("LOCK IN SHARE MODE")
|
|
116
|
+
.select();
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## TransactionHandler API
|
|
120
|
+
|
|
121
|
+
| Method | Description |
|
|
122
|
+
|--------|-------------|
|
|
123
|
+
| `begin()` | Start the transaction |
|
|
124
|
+
| `commit()` | Commit and release the connection |
|
|
125
|
+
| `rollback()` | Rollback and release the connection |
|
|
126
|
+
| `table(name, alias?)` | Returns `TransactionOperator` for the table |
|
|
127
|
+
| `query(options)` | Execute raw SQL within the transaction |
|
|
128
|
+
| `execute(sql, values)` | Execute parameterized SQL |
|
|
129
|
+
| `lastInsertId(alias?)` | Get the last auto-increment ID |
|
|
130
|
+
| `upsert(table, data, condition)` | Insert or update within the transaction |
|
|
131
|
+
|
|
132
|
+
## Concurrent Transactions
|
|
133
|
+
|
|
134
|
+
With a connection pool, multiple transactions run on separate connections without blocking each other.
|
|
135
|
+
|
|
136
|
+
```javascript
|
|
137
|
+
const db = new QueryHandler(pool);
|
|
138
|
+
|
|
139
|
+
await Promise.all([
|
|
140
|
+
(async () => {
|
|
141
|
+
const tx = await db.beginTransaction();
|
|
142
|
+
try {
|
|
143
|
+
await tx.table("users").insert({ name: "User1" });
|
|
144
|
+
await tx.commit();
|
|
145
|
+
} catch (err) {
|
|
146
|
+
await tx.rollback();
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
})(),
|
|
150
|
+
|
|
151
|
+
(async () => {
|
|
152
|
+
const tx = await db.beginTransaction();
|
|
153
|
+
try {
|
|
154
|
+
await tx.table("users").insert({ name: "User2" });
|
|
155
|
+
await tx.commit();
|
|
156
|
+
} catch (err) {
|
|
157
|
+
await tx.rollback();
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
})(),
|
|
161
|
+
]);
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Best Practices
|
|
165
|
+
|
|
166
|
+
1. **Use connection pools in production** -- prevents connection exhaustion
|
|
167
|
+
2. **Always wrap in try/catch** -- ensure rollback on errors
|
|
168
|
+
3. **Keep transactions short** -- avoid long-running operations inside transactions
|
|
169
|
+
4. **Choose the right isolation level** -- `RC` is a good default for most cases
|
|
170
|
+
5. **Use row locking when needed** -- `FOR UPDATE` prevents concurrent modification conflicts
|
|
171
|
+
6. **Prefer `beginTransaction()` over manual `TransactionHandler`** -- automatic connection management
|