Database Integration
Express.js Intermediate/Advanced
Chapter 2 of 8 · Database Integration
Database Integration
Every real Express application eventually needs to read and write persistent data. This chapter covers integrating MySQL with Express using the mysql2 package — the fastest, most actively maintained MySQL driver for Node.js. The three things that matter most: always use a connection pool (not a single connection), always use parameterized queries (never string-concat SQL), and always release connections back to the pool when you're done with them.
mysql2 vs mysql
mysql (legacy)
Callback-based only. Requires wrapping in util.promisify for async/await. No built-in prepared statements — uses client-side escaping (still safe but different semantics). Actively receives only security fixes.
mysql2 (use this)
Native Promise support via mysql2/promise. Server-side prepared statements via execute() — the SQL is parsed once, parameters are sent separately, SQL injection is structurally impossible. Faster than mysql, drop-in compatible API.
Connection Pools
A single connection can handle only one query at a time. Under real load — multiple requests arriving simultaneously — they would queue behind each other. A pool maintains multiple open connections and assigns them to incoming queries on demand, then returns them when done. Creating a connection is expensive (TCP handshake + MySQL auth). A pool reuses them.
| Option | Default | Notes |
|---|---|---|
| connectionLimit | 10 | Total open connections to MySQL. A good starting point — raise under sustained high traffic. |
| waitForConnections | true | If false, throws immediately when pool is full instead of queuing. |
| queueLimit | 0 | Max queued requests. 0 = unlimited. Set a number (e.g. 100) to shed load instead of building an infinite queue. |
| idleTimeout | 60000 | Idle connections are closed after this many ms. Keeps the pool healthy. |
| enableKeepAlive | false | Set true + keepAliveInitialDelay: 10000 to prevent MySQL's 8-hour timeout from dropping long-lived connections. |
execute() vs query()
This is the most important distinction in the whole chapter. pool.execute() sends the SQL template and the parameters to MySQL in separate messages. MySQL parses the SQL first, then receives the parameters as raw binary data — they are never treated as SQL, making injection structurally impossible. pool.query() sends a complete string after client-side escaping. Both work, but execute() is safer and faster (MySQL caches the parsed statement).
✅ execute() — always use for user data
⚠ query() — safe only for static SQL
Basic CRUD Pattern
What execute() returns
Always an array of two elements: [rows, fields] for SELECT, [ResultSetHeader, fields] for INSERT/UPDATE/DELETE. The ResultSetHeader has insertId (last auto-increment ID), affectedRows, and changedRows. You almost always destructure just the first: const [rows] = await pool.execute(...).
Mapping MySQL Errors to HTTP Status Codes
MySQL throws errors with a code string property. Your service or error handler should catch these and map them to meaningful HTTP responses before they bubble up as 500s.
| MySQL code | HTTP status | When it happens |
|---|---|---|
| ER_DUP_ENTRY | 409 Conflict | INSERT or UPDATE violates a UNIQUE constraint (duplicate email, SKU, etc.) |
| ER_NO_REFERENCED_ROW_2 | 422 Unprocessable | INSERT/UPDATE references a foreign key that doesn't exist (e.g. invalid category_id) |
| ER_ROW_IS_REFERENCED_2 | 409 Conflict | DELETE would orphan child rows that reference this record |
| ER_DATA_TOO_LONG | 422 Unprocessable | Value exceeds column length — usually a bug in validation, but can come from user input |
| ER_BAD_NULL_ERROR | 422 Unprocessable | NULL inserted into a NOT NULL column without a default |
| ECONNREFUSED | 503 Service Unavailable | MySQL server is down or unreachable — always log this, never expose to clients |
Transactions
A transaction groups multiple SQL statements so they either all succeed together or all roll back together. You need a transaction any time you are making changes that must be atomic — a bank transfer, decrementing stock while creating an order, or inserting related rows into two tables simultaneously.
⚠ Always release() in a finally block
If you forget conn.release(), the connection is permanently checked out from the pool. After connectionLimit requests that hit this path, the pool is exhausted and every subsequent request hangs forever (or until queueLimit is exceeded). The bug is silent until the pool runs out — and it will run out. Put release() in finally {} and it cannot be skipped.
Query Helpers
Rather than writing the same execute-and-destructure boilerplate on every model, you can wrap common patterns in helper functions once. These are thin utilities — not an ORM — so you keep full control over your SQL.
Wiring into MVC
Quick Reference
| Operation | API |
|---|---|
| pool.execute(sql, params) | Prepared statement — always use for user data. Returns [rows, fields]. |
| pool.query(sql) | Plain string query — only for static SQL with no user input. |
| result.insertId | The auto-increment ID of the row just inserted. |
| result.affectedRows | Rows affected by UPDATE or DELETE. 0 means the WHERE matched nothing. |
| pool.getConnection() | Borrow a specific connection from the pool (needed for transactions). |
| conn.beginTransaction() | Start a transaction on the borrowed connection. |
| conn.commit() | Commit all changes made since beginTransaction(). |
| conn.rollback() | Undo all changes since beginTransaction(). |
| conn.release() | Return the connection to the pool. Goes in finally{} — never skip it. |
| err.code | MySQL error code string (e.g. 'ER_DUP_ENTRY'). Check this in catch blocks to map to HTTP status codes. |
Coding Challenges
Challenge 1 — Products CRUD with Real MySQL
Create a products table (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL UNIQUE, price DECIMAL(10,2) NOT NULL, stock INT DEFAULT 0). Write a ProductModel module using mysql2/promise that implements findAll(), findById(id), create({name, price, stock}), update(id, fields) (only update supplied fields using a dynamic SET clause — filter against an allowlist of ['name','price','stock']), and remove(id). Write integration tests using a dedicated test database (express_test). In beforeAll: create the table. In afterAll: drop the table and call pool.end() to close all connections (required for Jest to exit cleanly). In beforeEach: truncate the table and reset the auto-increment counter. Tests: findAll returns empty array initially; create returns the new record with insertId; findById returns null for a missing id; update only changes supplied fields; remove returns true on success and false for a missing id.
Challenge 2 — Query Helpers & Error Mapping
Write a src/db/helpers.js module exporting query(sql, params), queryOne(sql, params), and run(sql, params) as described in the chapter. Write a src/utils/dbErrors.js module with a handleDbError(err) function that maps ER_DUP_ENTRY → 409, ER_NO_REFERENCED_ROW_2 → 422, ER_ROW_IS_REFERENCED_2 → 409, and leaves other errors unchanged. Rewrite the ProductModel from Challenge 1 to use the helpers (each model function becomes a one-liner). Add a categories table with a foreign key from products.category_id to categories.id. Write integration tests verifying: creating a product with a duplicate name returns 409 with a message containing "Duplicate"; creating a product with a non-existent category_id returns 422; deleting a category that has products returns 409; unknown MySQL errors pass through unchanged.
Challenge 3 — Transactions: Stock Reservation
Add an orders table and an order_items table. Write a createOrder(items) function where items is an array of {productId, quantity}. The function must run as a single transaction: for each item, SELECT the product with FOR UPDATE (to lock the row against concurrent modifications), check that stock >= quantity (throw 422 with a clear message if not), then UPDATE products SET stock = stock - quantity. After all items are processed, INSERT a row into orders and one row per item into order_items. Commit on success, rollback on any error, and always release the connection. Write integration tests: a successful order reduces product stock; an order with insufficient stock for any item rolls back all stock changes (verify no stock was decremented); an order referencing a non-existent product throws 404 and rolls back; ordering from two items where the second is out of stock leaves the first item's stock unchanged.