SQL с нуля: чтение и изменение строк
Научитесь читать SELECT, WHERE, INSERT, UPDATE, DELETE, NULL, параметры, сортировку и aggregation до сложных тем БД.
Временная шкала
Разбираем: SQL с нуля: чтение и изменение строк
Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.
Представьте таблицу как строгую электронную ведомость. Столбцы заранее описывают, какие данные разрешены, строки хранят отдельные объекты, а SQL-команда формулирует, что нужно получить или изменить. В отличие от массива JavaScript, база может одновременно обслуживать много процессов и сама проверяет часть правил.
SQL — декларативный язык: вы описываете желаемый результат, а PostgreSQL выбирает способ его получить. Команда состоит из clauses: SELECT выбирает выражения результата, FROM задаёт источник, WHERE фильтрует строки, GROUP BY образует группы, HAVING фильтрует группы, ORDER BY сортирует, LIMIT ограничивает ответ. INSERT, UPDATE и DELETE изменяют данные; RETURNING сразу возвращает изменённые строки.
Термины этого эксперимента
Сначала поймите слова — затем порядок выполнения.
Table / row / column
Table хранит однотипные сущности, row — одну запись, column — именованное поле с определённым data type.
Statement
Законченная SQL-команда: SELECT, INSERT, UPDATE, DELETE или CREATE TABLE. Обычно завершается точкой с запятой.
Clause
Часть statement со своей задачей: FROM задаёт источник, WHERE — условие, ORDER BY — сортировку.
Expression
Вычисляемый фрагмент: price * stock, lower(email), count(*) или сравнение price <= $1.
NULL
Отсутствующее или неизвестное значение. NULL не равен даже NULL; для проверки используют IS NULL и IS NOT NULL.
Parameter $1
Placeholder значения, которое driver передаёт отдельно от SQL. Номер соответствует позиции в values array.
Alias AS
Временное имя column, expression или table внутри результата запроса: name AS product_name.
Result set
Набор строк, возвращённый запросом. node-postgres помещает его в result.rows.
Что происходит по шагам
Каждый шаг соответствует наблюдаемому состоянию runtime.
- 01Опишите table
CREATE TABLE задаёт columns, data types, defaults и constraints.
- 02Добавьте rows
INSERT INTO перечисляет целевые columns, VALUES передаёт данные, RETURNING показывает созданные строки.
- 03Прочитайте rows
SELECT формирует columns результата, FROM выбирает table, WHERE оставляет подходящие rows.
- 04Упорядочьте ответ
ORDER BY сортирует, LIMIT ограничивает количество, OFFSET пропускает начало набора.
- 05Измените безопасно
UPDATE использует SET и обязательно осмысленный WHERE; RETURNING показывает фактический outcome.
- 06Сгруппируйте
Aggregate functions считают значения, GROUP BY создаёт группы, HAVING фильтрует уже готовые группы.
- 07Удалите осознанно
DELETE FROM без WHERE затронет всю table, поэтому сначала проверяют тот же predicate через SELECT.
Где результат требует оговорки
Эти детали объясняют, почему похожий код иногда даёт другой trace.
Синтаксический и логический порядок отличаются
SELECT записан первым, но логически FROM и WHERE определяют входные rows раньше формирования SELECT list. Это объясняет часть ограничений aliases.
SQL keywords не обязаны быть uppercase
PostgreSQL понимает select и SELECT одинаково. Верхний регистр — соглашение для визуального отделения keywords от identifiers.
NULL создаёт трёхзначную логику
Сравнение с NULL обычно даёт UNKNOWN, а WHERE оставляет только TRUE. Поэтому column = NULL не находит отсутствующие значения.
Parameters защищают values, не identifiers
$1 подходит значению email или price, но не имени table, column или направлению сортировки. Такие части выбирают из allowlist.
LIMIT без ORDER BY нестабилен
Без явно заданного порядка база может вернуть любые подходящие rows. Физический порядок table не является API-контрактом.
UPDATE и DELETE сообщают масштаб
Проверяйте result.rowCount и RETURNING. Ноль строк часто является бизнес-событием, а неожиданно большое число — защитным сигналом.
Сначала разберитесь, какие части Node участвуют в выполнении.
Затем уберите служебные детали и рассмотрите только главную идею.
После этого сопоставьте модель с кодом, который создаёт live trace.
Минимальная модель без служебного кода
const result = await db.query(
`SELECT
id,
name AS product_name,
price * stock AS inventory_value
FROM products
WHERE category = $1
AND price <= $2
ORDER BY price DESC
LIMIT $3`,
['books', 3500, 10],
);
console.log(result.rows);Полный код, который выполняет сценарий
Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.
Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.
import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import pg from 'pg';
const { Pool } = pg;
const STATEMENT_TIMEOUT_MS = 8_000;
const CONNECTION_TIMEOUT_MS = 1_500;
function databaseUrl() {
return process.env.DATABASE_URL?.trim() || null;
}
function safeIdentifier(prefix) {
return `${prefix}_${randomUUID().replaceAll('-', '').slice(0, 12)}`;
}
function planReport(result) {
const raw = result.rows[0]['QUERY PLAN'];
const report = Array.isArray(raw) ? raw[0] : JSON.parse(raw)[0];
const nodes = [];
function visit(node, depth = 0) {
nodes.push({
depth,
type: node['Node Type'],
relation: node['Relation Name'] ?? null,
index: node['Index Name'] ?? null,
estimatedRows: node['Plan Rows'],
actualRows: node['Actual Rows'],
loops: node['Actual Loops'],
});
for (const child of node.Plans ?? []) visit(child, depth + 1);
}
visit(report.Plan);
return {
nodes,
executionMs: Number(report['Execution Time'] ?? 0),
planningMs: Number(report['Planning Time'] ?? 0),
};
}
function planLine(plan) {
return plan.nodes
.map((node) => {
const target = node.index ?? node.relation;
return `${' '.repeat(node.depth)}${node.type}${target ? ` [${target}]` : ''}`;
})
.join(' → ');
}
async function configureClient(client) {
await client.query(`SET statement_timeout = '${STATEMENT_TIMEOUT_MS}ms'`);
await client.query(
`SET idle_in_transaction_session_timeout = '${STATEMENT_TIMEOUT_MS}ms'`,
);
await client.query("SET lock_timeout = '2500ms'");
}
async function rollbackQuietly(client) {
if (!client) return;
try {
await client.query('ROLLBACK');
} catch {
// The client may not currently be in a transaction.
}
}
async function withDatabaseLab(emit, run) {
const connectionString = databaseUrl();
if (!connectionString) {
emit(
'postgres',
'skip',
'PostgreSQL не подключён: задайте DATABASE_URL или запустите проект через Docker Compose',
);
return false;
}
const pool = new Pool({
connectionString,
max: 4,
connectionTimeoutMillis: CONNECTION_TIMEOUT_MS,
idleTimeoutMillis: 5_000,
allowExitOnIdle: true,
application_name: 'node-loop-lab',
});
const schema = safeIdentifier('node_loop_lab');
try {
const versionResult = await pool.query(
"SELECT current_setting('server_version') AS version",
);
emit(
'postgres',
'connect',
`Подключён PostgreSQL ${versionResult.rows[0].version}; создаём изолированную схему ${schema}`,
);
await pool.query(`CREATE SCHEMA ${schema}`);
await run({ pool, schema });
return true;
} catch (error) {
const safeMessage =
error?.code === 'ECONNREFUSED'
? 'соединение отклонено'
: error?.code
? `SQLSTATE ${error.code}`
: 'ошибка подключения';
emit('postgres', 'error', `Сценарий PostgreSQL остановлен: ${safeMessage}`);
return false;
} finally {
try {
await pool.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`);
emit('cleanup', 'drop', 'Учебная схема удалена; постоянные данные не создавались');
} catch {
// Connection failures can make cleanup impossible; the schema name is unique
// and contains no user data.
}
await pool.end().catch(() => {});
}
}
export async function databaseSqlBasics(emit) {
await withDatabaseLab(emit, async ({ pool, schema }) => {
const client = await pool.connect();
try {
await configureClient(client);
await client.query(`
CREATE TABLE ${schema}.products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
category text NOT NULL,
price numeric(10, 2) NOT NULL CHECK (price >= 0),
stock integer NOT NULL DEFAULT 0 CHECK (stock >= 0),
description text,
active boolean NOT NULL DEFAULT true
)
`);
emit(
'ddl',
'create-table',
'CREATE TABLE описал columns, data types, defaults и constraints таблицы products',
);
const inserted = await client.query(
`INSERT INTO ${schema}.products
(name, category, price, stock, description)
VALUES
($1, $2, $3, $4, $5),
($6, $7, $8, $9, $10),
($11, $12, $13, $14, $15)
RETURNING id, name, price`,
[
'Node.js Handbook',
'books',
2400,
8,
'Runtime and backend foundations',
'NestJS Patterns',
'books',
3100,
5,
null,
'Mechanical Keyboard',
'hardware',
7800,
2,
'USB keyboard',
],
);
emit(
'insert',
'rows',
`INSERT добавил ${inserted.rowCount} строки; RETURNING вернул generated id без отдельного SELECT`,
);
const selected = await client.query(
`SELECT
id,
name AS product_name,
price,
stock,
price * stock AS inventory_value
FROM ${schema}.products
WHERE category = $1
AND price <= $2
AND active IS TRUE
ORDER BY price DESC
LIMIT $3`,
['books', 3500, 10],
);
emit(
'select',
'filter',
`SELECT → FROM → WHERE → ORDER BY → LIMIT вернул: ${selected.rows
.map((row) => row.product_name)
.join(', ')}`,
);
const nullWrong = await client.query(
`SELECT count(*)::int AS count
FROM ${schema}.products
WHERE description = NULL`,
);
const nullRight = await client.query(
`SELECT count(*)::int AS count
FROM ${schema}.products
WHERE description IS NULL`,
);
emit(
'null',
'comparison',
`description = NULL нашёл ${nullWrong.rows[0].count}; IS NULL нашёл ${nullRight.rows[0].count}`,
);
const updated = await client.query(
`UPDATE ${schema}.products
SET stock = stock - $1
WHERE name = $2
AND stock >= $1
RETURNING id, name, stock`,
[2, 'Node.js Handbook'],
);
emit(
'update',
'returning',
`UPDATE изменил stock и вернул новое значение=${updated.rows[0].stock}`,
);
const grouped = await client.query(
`SELECT
category,
count(*)::int AS product_count,
round(avg(price), 2) AS average_price,
sum(stock)::int AS total_stock
FROM ${schema}.products
GROUP BY category
HAVING count(*) >= $1
ORDER BY category`,
[1],
);
emit(
'aggregate',
'group-by',
`GROUP BY создал ${grouped.rowCount} группы; HAVING фильтрует уже агрегированные группы`,
);
const deleted = await client.query(
`DELETE FROM ${schema}.products
WHERE active IS FALSE
RETURNING id`,
);
emit(
'delete',
'safe-delete',
`DELETE с WHERE удалил строк=${deleted.rowCount}; без WHERE удалились бы все строки`,
);
} finally {
await rollbackQuietly(client);
client.release();
}
});
}
export async function databaseConstraintsAndAcid(emit) {
await withDatabaseLab(emit, async ({ pool, schema }) => {
const client = await pool.connect();
try {
await configureClient(client);
emit(
'ddl',
'schema',
'Создаём PRIMARY KEY, UNIQUE, CHECK и FOREIGN KEY как правила целостности внутри БД',
);
await client.query(`
CREATE TABLE ${schema}.customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
name text NOT NULL CHECK (char_length(name) >= 2)
);
CREATE TABLE ${schema}.orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL
REFERENCES ${schema}.customers(id) ON DELETE RESTRICT,
amount numeric(12, 2) NOT NULL CHECK (amount > 0),
status text NOT NULL DEFAULT 'new'
CHECK (status IN ('new', 'paid', 'cancelled'))
);
`);
const customer = await client.query(
`INSERT INTO ${schema}.customers (email, name)
VALUES ($1, $2)
RETURNING id`,
['learner@example.com', 'Learner'],
);
emit(
'query',
'parameters',
'Параметры $1/$2 переданы отдельно от SQL: значения не становятся частью синтаксиса запроса',
);
try {
await client.query(
`INSERT INTO ${schema}.orders (customer_id, amount)
VALUES ($1, $2)`,
[customer.rows[0].id, -50],
);
} catch (error) {
emit(
'constraint',
'check',
`CHECK отклонил отрицательную сумму: SQLSTATE ${error.code}`,
);
}
await client.query('BEGIN');
await client.query(
`INSERT INTO ${schema}.orders (customer_id, amount, status)
VALUES ($1, $2, $3)`,
[customer.rows[0].id, 1250, 'paid'],
);
const inside = await client.query(
`SELECT count(*)::int AS count FROM ${schema}.orders`,
);
await client.query('ROLLBACK');
const after = await client.query(
`SELECT count(*)::int AS count FROM ${schema}.orders`,
);
emit(
'transaction',
'rollback',
`Внутри транзакции строк=${inside.rows[0].count}; после ROLLBACK строк=${after.rows[0].count}`,
);
emit(
'acid',
'model',
'ACID: constraints поддерживают consistency, транзакция даёт atomicity, WAL/disk — durability, а isolation управляет видимостью параллельных изменений',
);
} finally {
await rollbackQuietly(client);
client.release();
}
});
}
export async function databaseIndexesAndExplain(emit) {
await withDatabaseLab(emit, async ({ pool, schema }) => {
const client = await pool.connect();
try {
await configureClient(client);
emit(
'dataset',
'seed',
'Создаём 40 000 событий с коррелированным временем, tenant_id, status и массивом tags',
);
await client.query(`
CREATE TABLE ${schema}.events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id integer NOT NULL,
created_at timestamptz NOT NULL,
status text NOT NULL,
tags text[] NOT NULL
);
INSERT INTO ${schema}.events (tenant_id, created_at, status, tags)
SELECT
(g % 100) + 1,
now() - (g * interval '1 second'),
CASE WHEN g % 5 = 0 THEN 'failed' ELSE 'processed' END,
ARRAY[
CASE WHEN g % 3 = 0 THEN 'api' ELSE 'worker' END,
CASE WHEN g % 7 = 0 THEN 'priority' ELSE 'normal' END
]
FROM generate_series(1, 40000) AS g;
ANALYZE ${schema}.events;
`);
const query = `
SELECT id, created_at, status
FROM ${schema}.events
WHERE tenant_id = 37
AND created_at >= now() - interval '6 hours'
ORDER BY created_at DESC
`;
const before = planReport(
await client.query(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query}`),
);
emit(
'planner',
'before-index',
`До составного индекса: ${planLine(before)}; execution=${before.executionMs.toFixed(2)} ms`,
);
await client.query(`
CREATE INDEX events_tenant_created_btree
ON ${schema}.events USING btree (tenant_id, created_at DESC)
INCLUDE (status);
CREATE INDEX events_status_hash
ON ${schema}.events USING hash (status);
CREATE INDEX events_created_brin
ON ${schema}.events USING brin (created_at);
CREATE INDEX events_tags_gin
ON ${schema}.events USING gin (tags);
ANALYZE ${schema}.events;
`);
const after = planReport(
await client.query(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query}`),
);
emit(
'planner',
'after-index',
`После B-tree: ${planLine(after)}; execution=${after.executionMs.toFixed(2)} ms`,
);
const sizes = await client.query(
`
SELECT c.relname, pg_relation_size(c.oid)::bigint AS bytes
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE n.nspname = $1 AND c.relkind = 'i'
ORDER BY bytes DESC
`,
[schema],
);
emit(
'indexes',
'size',
`Размеры индексов: ${sizes.rows
.map((row) => `${row.relname}=${Math.round(Number(row.bytes) / 1024)} KiB`)
.join(', ')}`,
);
emit(
'optimizer',
'decision',
'Индекс не является приказом: planner выбирает Seq Scan, Index Scan или Bitmap Scan по статистике, селективности и стоимости',
);
} finally {
client.release();
}
});
}
export async function databaseTransactionsAndLocks(emit) {
await withDatabaseLab(emit, async ({ pool, schema }) => {
await pool.query(`
CREATE TABLE ${schema}.accounts (
id integer PRIMARY KEY,
balance integer NOT NULL CHECK (balance >= 0),
version integer NOT NULL DEFAULT 0
);
INSERT INTO ${schema}.accounts (id, balance) VALUES (1, 1000);
`);
const first = await pool.connect();
const second = await pool.connect();
try {
await Promise.all([configureClient(first), configureClient(second)]);
await first.query('BEGIN ISOLATION LEVEL READ COMMITTED');
const rcBefore = await first.query(
`SELECT balance FROM ${schema}.accounts WHERE id = 1`,
);
await second.query(
`UPDATE ${schema}.accounts SET balance = 1100 WHERE id = 1`,
);
const rcAfter = await first.query(
`SELECT balance FROM ${schema}.accounts WHERE id = 1`,
);
await first.query('ROLLBACK');
emit(
'isolation',
'read-committed',
`READ COMMITTED: первый SELECT=${rcBefore.rows[0].balance}, второй SELECT=${rcAfter.rows[0].balance}`,
);
await pool.query(
`UPDATE ${schema}.accounts SET balance = 1000, version = 0 WHERE id = 1`,
);
await first.query('BEGIN ISOLATION LEVEL REPEATABLE READ');
const rrBefore = await first.query(
`SELECT balance FROM ${schema}.accounts WHERE id = 1`,
);
await second.query(
`UPDATE ${schema}.accounts SET balance = 1100 WHERE id = 1`,
);
const rrAfter = await first.query(
`SELECT balance FROM ${schema}.accounts WHERE id = 1`,
);
await first.query('ROLLBACK');
emit(
'isolation',
'repeatable-read',
`REPEATABLE READ: первый SELECT=${rrBefore.rows[0].balance}, второй SELECT=${rrAfter.rows[0].balance}`,
);
await pool.query(
`UPDATE ${schema}.accounts SET balance = 1000, version = 0 WHERE id = 1`,
);
await first.query('BEGIN');
await first.query(
`SELECT balance FROM ${schema}.accounts WHERE id = 1 FOR UPDATE`,
);
await second.query('BEGIN');
let secondAcquired = false;
const waitStarted = performance.now();
const secondLock = second
.query(
`SELECT balance FROM ${schema}.accounts WHERE id = 1 FOR UPDATE`,
)
.then((result) => {
secondAcquired = true;
return result;
});
await new Promise((resolve) => setTimeout(resolve, 120));
emit(
'lock',
'wait',
`SELECT FOR UPDATE: вторая транзакция ждёт блокировку=${!secondAcquired}`,
);
await first.query(
`UPDATE ${schema}.accounts SET balance = balance - 100 WHERE id = 1`,
);
await first.query('COMMIT');
await secondLock;
const waitedMs = performance.now() - waitStarted;
await second.query(
`UPDATE ${schema}.accounts SET balance = balance - 200 WHERE id = 1`,
);
await second.query('COMMIT');
const pessimistic = await pool.query(
`SELECT balance FROM ${schema}.accounts WHERE id = 1`,
);
emit(
'lock',
'pessimistic',
`Пессимистичная блокировка ждала ${waitedMs.toFixed(0)} ms; итоговый balance=${pessimistic.rows[0].balance}`,
);
await pool.query(
`UPDATE ${schema}.accounts SET balance = 1000, version = 0 WHERE id = 1`,
);
const snapshotA = await first.query(
`SELECT balance, version FROM ${schema}.accounts WHERE id = 1`,
);
const snapshotB = await second.query(
`SELECT balance, version FROM ${schema}.accounts WHERE id = 1`,
);
const updateA = await first.query(
`UPDATE ${schema}.accounts
SET balance = $1, version = version + 1
WHERE id = 1 AND version = $2`,
[snapshotA.rows[0].balance - 100, snapshotA.rows[0].version],
);
const updateB = await second.query(
`UPDATE ${schema}.accounts
SET balance = $1, version = version + 1
WHERE id = 1 AND version = $2`,
[snapshotB.rows[0].balance - 200, snapshotB.rows[0].version],
);
emit(
'lock',
'optimistic',
`Оптимистичная версия: update A=${updateA.rowCount}, stale update B=${updateB.rowCount}; 0 означает конфликт`,
);
} finally {
await Promise.all([rollbackQuietly(first), rollbackQuietly(second)]);
first.release();
second.release();
}
});
}
export async function databaseJoinsAndMaterializedViews(emit) {
await withDatabaseLab(emit, async ({ pool, schema }) => {
const client = await pool.connect();
try {
await configureClient(client);
emit(
'dataset',
'seed',
'Создаём 2 000 клиентов и 30 000 заказов для JOIN и агрегирования',
);
await client.query(`
CREATE TABLE ${schema}.customers (
id integer PRIMARY KEY,
name text NOT NULL,
active boolean NOT NULL
);
CREATE TABLE ${schema}.orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id integer NOT NULL REFERENCES ${schema}.customers(id),
amount numeric(12, 2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO ${schema}.customers (id, name, active)
SELECT g, 'customer-' || g, g % 5 <> 0
FROM generate_series(1, 2000) AS g;
INSERT INTO ${schema}.orders (customer_id, amount, created_at)
SELECT
(g % 2000) + 1,
((g % 5000) + 100)::numeric / 10,
now() - (g * interval '1 minute')
FROM generate_series(1, 30000) AS g;
CREATE INDEX orders_customer_id_idx
ON ${schema}.orders (customer_id);
ANALYZE ${schema}.customers;
ANALYZE ${schema}.orders;
`);
const joinPlan = planReport(
await client.query(`
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT c.id, c.name, sum(o.amount) AS total
FROM ${schema}.customers AS c
JOIN ${schema}.orders AS o ON o.customer_id = c.id
WHERE c.active
GROUP BY c.id, c.name
ORDER BY total DESC
LIMIT 20
`),
);
emit(
'join',
'plan',
`JOIN plan: ${planLine(joinPlan)}; execution=${joinPlan.executionMs.toFixed(2)} ms`,
);
const sampleCustomers = await client.query(
`SELECT id FROM ${schema}.customers ORDER BY id LIMIT 20`,
);
const nPlusOneStarted = performance.now();
for (const customer of sampleCustomers.rows) {
await client.query(
`SELECT count(*) FROM ${schema}.orders WHERE customer_id = $1`,
[customer.id],
);
}
const nPlusOneMs = performance.now() - nPlusOneStarted;
const oneQueryStarted = performance.now();
await client.query(`
SELECT c.id, count(o.id)
FROM ${schema}.customers AS c
LEFT JOIN ${schema}.orders AS o ON o.customer_id = c.id
WHERE c.id <= 20
GROUP BY c.id
`);
const oneQueryMs = performance.now() - oneQueryStarted;
emit(
'query-shape',
'n-plus-one',
`N+1: 21 round trips=${nPlusOneMs.toFixed(2)} ms; один JOIN=1 round trip=${oneQueryMs.toFixed(2)} ms`,
);
await client.query(`
CREATE MATERIALIZED VIEW ${schema}.customer_totals AS
SELECT customer_id, count(*)::int AS orders_count, sum(amount) AS total
FROM ${schema}.orders
GROUP BY customer_id;
CREATE UNIQUE INDEX customer_totals_customer_id_idx
ON ${schema}.customer_totals (customer_id);
`);
const before = await client.query(
`SELECT total FROM ${schema}.customer_totals WHERE customer_id = $1`,
[42],
);
await client.query(
`INSERT INTO ${schema}.orders (customer_id, amount) VALUES ($1, $2)`,
[42, 999],
);
const stale = await client.query(
`SELECT total FROM ${schema}.customer_totals WHERE customer_id = $1`,
[42],
);
await client.query(`REFRESH MATERIALIZED VIEW ${schema}.customer_totals`);
const refreshed = await client.query(
`SELECT total FROM ${schema}.customer_totals WHERE customer_id = $1`,
[42],
);
emit(
'materialized-view',
'refresh',
`Materialized View: было=${before.rows[0].total}, до REFRESH=${stale.rows[0].total}, после=${refreshed.rows[0].total}`,
);
emit(
'sql',
'control',
'Raw SQL здесь параметризован и видим; ORM полезен, пока команда проверяет сгенерированный SQL, планы, N+1 и границы транзакций',
);
} finally {
client.release();
}
});
}
Именно вызовы emit(...) превращаются в строки live trace. await и Promise удерживают HTTP-поток открытым до завершения сценария.
Практические шаблоны, которые можно подсмотреть
Сравнивайте цель, код и оговорки — не запоминайте синтаксис без модели.
SELECT: выбрать columns
Получить только id, name и вычисленную стоимость остатка.
SELECT
id,
name,
price * stock AS inventory_value
FROM products;- Запятая разделяет expressions в SELECT list.
- FROM указывает table-источник.
- AS задаёт имя вычисленного поля результата.
WHERE: отфильтровать rows
Найти активные книги не дороже переданного значения.
SELECT id, name, price
FROM products
WHERE category = $1
AND price <= $2
AND active IS TRUE;- $1 и $2 приходят из values array driver-а.
- AND требует истинности всех условий.
- Строки SQL заключают в одинарные кавычки, identifiers — обычно без них.
INSERT: добавить row
Создать product и сразу получить сгенерированный id.
INSERT INTO products (name, price, stock)
VALUES ($1, $2, $3)
RETURNING id, name, price, stock;- Порядок VALUES соответствует списку columns.
- RETURNING возвращает уже записанную row.
UPDATE: изменить подходящие rows
Атомарно уменьшить stock, только если товара достаточно.
UPDATE products
SET stock = stock - $1
WHERE id = $2
AND stock >= $1
RETURNING id, stock;- SET описывает новое значение.
- Правая stock — текущее значение row.
- Нулевой rowCount означает, что условие не прошло.
DELETE: удалить явно
Удалить только неактивные products и увидеть их id.
DELETE FROM products
WHERE active IS FALSE
RETURNING id;- Сначала выполните SELECT с тем же WHERE.
- Без WHERE команда удалит все rows.
ORDER BY, LIMIT и OFFSET
Получить вторую страницу дорогих products.
SELECT id, name, price
FROM products
ORDER BY price DESC, id ASC
LIMIT $1
OFFSET $2;- DESC — по убыванию, ASC — по возрастанию.
- id даёт стабильный tie-breaker.
- Большой OFFSET со временем становится дорогим; позже изучите keyset pagination.
GROUP BY и aggregate functions
Посчитать количество и среднюю цену в каждой category.
SELECT
category,
count(*) AS product_count,
round(avg(price), 2) AS average_price
FROM products
GROUP BY category
HAVING count(*) >= $1
ORDER BY category;- count и avg получают много rows и возвращают одно значение на группу.
- HAVING фильтрует groups; WHERE фильтровал бы rows до aggregation.
NULL: проверить отсутствие
Найти products без description.
SELECT id, name
FROM products
WHERE description IS NULL;- Не используйте description = NULL.
- Для обратной проверки существует IS NOT NULL.
- NULL отличается от пустой строки.
Как учебная ошибка превращается в инцидент
Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.
Фильтр каталога склеивает пользовательский ввод с SQL
Nest repository строит список products по query parameters. Category и sort кажутся обычными строками, поэтому разработчик вставляет их прямо в template literal.
Значение category может изменить синтаксис запроса, а произвольный sort превращается в неконтролируемый identifier. SELECT * также незаметно меняет API-контракт при добавлении columns.
@Injectable()
export class ProductsRepository {
async find(query: ProductQueryDto) {
const sql = `
SELECT *
FROM products
WHERE category = '${query.category}'
ORDER BY ${query.sort}
LIMIT ${query.limit}
`;
return (await this.db.query(sql)).rows;
}
}Template literal смешивает SQL grammar и недоверенные values. Driver не может отличить данные от operators, quotes или identifiers, потому что получает уже готовую строку.
const SORT_COLUMNS = {
price: 'price',
name: 'name',
newest: 'created_at',
} as const;
@Injectable()
export class ProductsRepository {
async find(query: ProductQueryDto) {
const sortColumn =
SORT_COLUMNS[query.sort] ?? SORT_COLUMNS.newest;
const limit = Math.min(query.limit ?? 20, 100);
const result = await this.db.query(
`SELECT id, name, category, price, stock
FROM products
WHERE category = $1
ORDER BY ${sortColumn} DESC, id DESC
LIMIT $2`,
[query.category, limit],
);
return result.rows;
}
}Category и limit передаются как protocol parameters. Имя column нельзя передать через $1, поэтому оно выбирается только из локального allowlist. Явный SELECT list фиксирует shape результата.
Что делают непривычные вызовы из обоих фрагментов кода.
SELECT id, name, ...- Явный SELECT list определяет columns и shape каждой row в result.rows.
template literal ${value}- JavaScript подставляет текст до отправки в PostgreSQL; недоверенное значение становится частью SQL grammar.
$1 / $2- Protocol placeholders для values; node-postgres связывает их с элементами отдельного values array.
SORT_COLUMNS allowlist- Локальное отображение разрешённых API-значений в реальные column identifiers; произвольный ввод в SQL не попадает.
Math.min(limit, 100)- Ставит верхнюю границу размера ответа, даже если DTO передал очень большое положительное число.
result.rows- Массив rows, который вернул pg driver; keys объектов соответствуют именам или aliases SELECT list.
Популярные заблуждения
Миф слева, корректная модель справа.
SQL выполняется буквально сверху вниз.
Parser строит statement, затем clauses имеют логический порядок, а planner выбирает физический plan.
SELECT * удобен и поэтому подходит production API.
Явный список columns стабилизирует контракт, уменьшает передачу данных и показывает зависимости кода.
Строковая интерполяция безопасна после ручного escaping.
Values передают параметрами driver-а; identifiers выбирают из заранее разрешённого списка.
WHERE description = NULL найдёт пустые descriptions.
Нужен IS NULL; NULL означает unknown/absent, а не строку и не обычное значение.
DELETE удаляет только одну строку.
DELETE затрагивает все rows, удовлетворяющие WHERE, а без WHERE — всю table.
Ответьте своими словами
Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.
- Какую роль отдельно выполняют SELECT, FROM и WHERE?
- Почему $1 нельзя заключать в кавычки внутри SQL?
- Чем WHERE отличается от HAVING?
- Почему LIMIT желательно использовать вместе с ORDER BY?
- Что вернёт UPDATE, если его WHERE не нашёл ни одной row?
- Почему NULL проверяется через IS NULL?
- Что произойдёт с DELETE без WHERE?