SQL from an application

Send SQL from an application without being robbed, and without making a hundred round trips.

21of 22
≈ 13Min read
6Parts

Never build SQL out of strings

The value ended the string and became part of the query. Everything after -- is a comment, so the quote never had to balance. The same trick with ; drop table or union select password from users is how databases leak.

the oldest bug on the web, still shipping
// the query
const sql = "select * from orders where status = '" + status + "'";

// what somebody types
status = "x' or 1 = 1 --";

// what the database receives
select * from orders where status = 'x' or 1 = 1 --'

Placeholders are not string interpolation

The driver sends the SQL and the values separately. The database parses the query once, with a hole in it, and then puts the value in the hole — as a value, whatever characters it contains. x' or 1 = 1 -- is then simply a status that does not exist, and the query returns nothing.

This is also why a placeholder cannot be a table or column name: those are part of the query, not values. If a column name really must be dynamic, check it against a list you wrote — never against user input.

the value travels beside the query, never inside it
// node-postgres
const { rows } = await db.query('select * from orders where status = $1', [status]);

// better-sqlite3 / mysql2 / sqlite
const rows = db.prepare('select * from orders where status = ?').all(status);
An ORM is not a shield

Every ORM has a raw-query escape hatch, and template literals make it a one-character mistake. Prisma's $queryRawUnsafe, Sequelize's sequelize.query with interpolation, a hand-built where clause in a search filter — these are where injection lives in modern code.

An IN list needs the right number of holes

built from the count, not from the values
const holes = ids.map(() => '?').join(', ');
const rows = db.prepare(`select * from orders where id in (${holes})`).all(...ids);

// postgres does it in one placeholder
await db.query('select * from orders where id = any($1)', [ids]);

N+1: the query in a loop

Each query is fast; a hundred of them is a hundred round trips, and on a remote database the latency alone is the page. This is the single most common performance bug in applications that use an ORM, because the loop looks innocent and the SQL is invisible.

one page, one hundred and one queries
const customers = await db.query('select * from customers');           // 1

for (const c of customers.rows) {
  c.orders = await db.query('select * from orders where customer_id = $1', [c.id]);   // N
}
one round trip, grouped in memory
const customers = await db.query('select * from customers');
const ids       = customers.rows.map((c) => c.id);

const orders = await db.query('select * from orders where customer_id = any($1)', [ids]);

const byCustomer = new Map();
for (const o of orders.rows) {
  if (!byCustomer.has(o.customer_id)) byCustomer.set(o.customer_id, []);
  byCustomer.get(o.customer_id).push(o);
}

Connections are a pool, not a variable

Opening a connection costs milliseconds and a server-side process; a pool keeps a few open and hands them out. A transaction must run on one client — pool.query twice may use two different connections, and your begin will be on the wrong one.

The credentials come from the environment, never from the source file. That is not only about secrets in git: it is how the same build runs against staging and production.

open once, lend out, give back
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });

const client = await pool.connect();
try {
  await client.query('begin');
  await client.query('update accounts set balance = balance - $1 where id = $2', [500, 1]);
  await client.query('update accounts set balance = balance + $1 where id = $2', [500, 2]);
  await client.query('commit');
} catch (err) {
  await client.query('rollback');
  throw err;
} finally {
  client.release();                 // the finally is the whole point
}

Migrations

The schema is code. Every change is a numbered file that is applied once, in order, recorded in a table, and never edited afterwards — if it shipped, the fix is a new migration. That is the whole discipline, and every tool (Prisma Migrate, Knex, Flyway, Alembic, Rails) is a variation on it.

Expand and contract is how a change goes out without downtime: add the new column, deploy code that writes both, backfill, deploy code that reads the new one, then drop the old one in a later release. Four boring steps instead of one exciting evening.

Practice