@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.
@@ -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