src/query-optimizers is a set of opt-in performance tools: EXPLAIN plansProrm,Model or the dialect pipeline automatically — you construct what you wantimport { QueryOptimizer } from 'ts-prorm-orm';
const optimizer = new QueryOptimizer(prorm.getDialectInstance(), {
enableExplain: true,
slowQueryOptions: { threshold: 250 },
});ExplainPlanGenerator,QueryHintManager, SlowQueryLogger, BatchQueryOptimizer,PreparedStatementCache — if you would rather not take the facade.orders tableid, customer_id, status, total, created_at). The plans shown are realbenchmark: true, or feed aSlowQueryLogger, and rank by time. Optimise what theimport { QueryOptimizer } from 'ts-prorm-orm';
const optimizer = new QueryOptimizer(prorm.getDialectInstance(), { enableExplain: true });
const sql = 'SELECT * FROM orders WHERE customer_id = 42 ORDER BY created_at DESC';
const { plan, recommendations } = await optimizer.analyzeQuery(sql);
console.log(plan.parsedPlan);
// [ { operation: 'SCAN', table: 'orders', details: { detail: 'SCAN orders' } },
// { operation: 'OPERATION', details: { detail: 'USE TEMP B-TREE FOR ORDER BY' } } ]
for (const r of recommendations) console.log(r.severity, r.type, r.message, r.table);
// HIGH INDEX Full table scan detected. ordersSCAN orders is a full table scan, and the ORDER BY is sorted in aconst after = await optimizer.analyzeQuery(sql);
console.log(after.plan.plan.rows[0].detail);
// SEARCH orders USING INDEX idx_orders_customer_created (customer_id=?)
console.log(after.recommendations.length); // 0SEARCH … USING INDEX replaces the scan, and the temporary sort is gonecreated_at order.const plan = await optimizer.explainQuery(
'SELECT * FROM users WHERE email = \'a@example.com\'',
{ analyze: true },
);
const { plan: p, recommendations } = await optimizer.analyzeQuery(sql);
for (const r of recommendations) {
console.log(r.severity, r.type, r.message); // HIGH INDEX "Full table scan detected."
}ExplainPlanGenerator sends forSELECT * FROM orders WHERE customer_id = 7:{ analyze: true } throws SQLite has no EXPLAIN ANALYZE; use a plain explain
(EXPLAIN QUERY PLAN). and any format but text throws.{ analyze: true } uses SET STATISTICS PROFILE ON / OFF instead, and thetext and xml.EXPLAIN PLAN SET STATEMENT_ID = '<id>' FOR <sql> followed bySELECT PLAN_TABLE_OUTPUT FROM TABLE(DBMS_XPLAN.DISPLAY(NULL, '<id>', 'TYPICAL')).[!IMPORTANT] SQL Server and Oracle need queryOnSession()
Their EXPLAIN is several statements that must share a connection. The
generator runs them through the dialect'squeryOnSession(statements: string[]); the SQL Server and Oracle dialects do
not implement it yet, soexplain()there throws[ORM] EXPLAIN on mssql needs several statements on one connection (…).
[!WARNING] analyze: trueruns the statementEXPLAIN ANALYZEexecutes what it explains. For a write (INSERT,UPDATE,DELETE, DDL,CALL,SELECT … INTO) the generator refuses
unless it can wrap the statement in a transaction that is rolled back, or
you pass{ allowWrite: true }and accept that the write is applied.
| Dialect | Flagged as a problem |
|---|---|
| MySQL / MariaDB | type: ALL scans, missing possible_keys/key, Using filesort, Using temporary |
| PostgreSQL | Seq Scan nodes anywhere in the plan tree, nodes estimating PlanRows > 10000 |
| SQLite | SCAN steps (as opposed to SEARCH, which means an index was used) |
type (INDEX, OPTIMIZATION, REWRITE,STATISTICS) and a severity (HIGH, MEDIUM, LOW). They are heuristics[!NOTE] enableExplaindefaults toprocess.env.NODE_ENV === 'development'. Set it
explicitly if you want plans in production.
import { FORCE_INDEX } from 'ts-prorm-orm';
const sql = optimizer.applyHints('SELECT * FROM users WHERE email = ?', [
FORCE_INDEX('idx_users_email', 'users'),
]);QueryHint: { type: HintType, value?, tableName? }. TheFORCE_INDEX(index, table?),USE_INDEX(...), INDEX_HINT.IGNORE(...), JOIN_FIXED_ORDER, MERGE_HASH,NO_MERGE, and on a QueryHintManager the methods forceIndex(),useIndex(), ignoreIndex(), maxExecutionTime(seconds), sqlBigResult(),sqlSmallResult() and sqlNoCache().| Dialect | Supported hints | Rendered as |
|---|---|---|
| MySQL / MariaDB | FORCE_INDEX, USE_INDEX, IGNORE_INDEX, JOIN_FIXED_ORDER, MAX_EXECUTION_TIME, SQL_BIG_RESULT, SQL_SMALL_RESULT, SQL_BUFFER_RESULT, SQL_CACHE, SQL_NO_CACHE, INDEX_MERGE | a /*+ … */ comment after SELECT |
| PostgreSQL | MAX_EXECUTION_TIME, SEQ_SCAN, NO_SEQ_SCAN, INDEX_SCAN, NO_INDEX_SCAN | a /*+ … */ comment, read only by the pg_hint_plan extension |
| SQLite | OPTIMIZE, QUERY_PLAN | a /*+ … */ comment |
| SQL Server | NESTED_LOOP, MERGE_HASH, NO_MERGE | an OPTION (…) clause at the end |
| Oracle | NESTED_LOOP, MERGE_HASH, NO_MERGE | /*+ USE_NL … USE_HASH … NO_MERGE */ |
| Everything else | none | the SQL unchanged |
NO_SEQ_SCAN — applied to the sameimport { QueryHintManager, FORCE_INDEX, JOIN_FIXED_ORDER, MERGE_HASH, HintType } from 'ts-prorm-orm';
const hints = new QueryHintManager(prorm.getDialectInstance());
const sql = hints.applyHints(
'SELECT o.id, c.email FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.status = ?',
[
FORCE_INDEX('idx_orders_status', 'orders'),
hints.maxExecutionTime(5),
JOIN_FIXED_ORDER,
{ type: HintType.NESTED_LOOP },
MERGE_HASH,
{ type: HintType.NO_SEQ_SCAN, tableName: 'orders' },
],
);[!WARNING] Check that the engine took the hint
A hint comment the engine does not understand is ignored, not rejected. Run
the hinted statement through EXPLAIN before relying on it.
In particular:maxExecutionTime(seconds)passes the number straight into MySQL'sMAX_EXECUTION_TIME(n), which MySQL reads as milliseconds; PostgreSQL's
rendering multiplies by 1000.PostgreSQL ignores comments unless pg_hint_planis installed, and aSET LOCALinside a comment sets nothing. For a real timeout runSET LOCAL statement_timeoutinside a transaction.Oracle's USE_NL/USE_HASHtake a table; passtableNameon the hint.
setEnabled(false) turns hint rendering off globally without changing callSlowQueryLogger is fed manually — it does not hook the query pipeline. If youoptimizer.executeQuery() it is called for you; otherwise calllogQuery() yourself after timing a statement.import { createSlowQueryLogger } from 'ts-prorm-orm';
const slow = createSlowQueryLogger({
threshold: 500, // ms; below this nothing is recorded
logParams: true,
maxEntries: 1000, // ring buffer
alertThreshold: 10, // 10 slow queries…
alertWindow: 60_000, // …within a minute fires 'alert'
});
slow.on('slowQuery', (entry) => metrics.increment('db.slow', { sql: entry.sql }));
slow.on('alert', (entries) => pageOncall(entries));
const started = Date.now();
const rows = await prorm.query(sql);
slow.logQuery({ sql, duration: Date.now() - started, dialect: prorm.getDialect() });| Method | Returns |
|---|---|
getStatistics() | count, avg/min/max, p50/p95/p99 duration |
getSlowQueries() | entries over the threshold |
getQueriesByType() | grouped by SELECT/INSERT/… |
getQueriesOnTable(name) | entries whose SQL mentions the table |
findSimilarQueries() | grouped after replacing literals with ? |
exportLogs() | JSON dump |
logging option instead — it is wired into the pipeline.BatchQueryOptimizer chunks large arrays and emits one statement per chunkimport { optimizeBatchInsert } from 'ts-prorm-orm';
const result = await optimizeBatchInsert(dialect, 'users', records, {
chunkSize: 1000,
skipDuplicates: true,
onProgress: ({ processed, total }) => console.log(`${processed}/${total}`),
});
result.success.length; result.failed.length; result.batches; result.duration;| Operation | SQL per chunk | Default chunk |
|---|---|---|
optimizeBatchInsert | INSERT INTO t (…) VALUES (…), (…), … | 1000 |
optimizeBatchUpdate | UPDATE t SET col = CASE … END WHERE id IN (…) | 500 |
optimizeBatchDelete | DELETE FROM t WHERE id IN (…) | 500 |
$1, @p1, :1, ?) — never interpolated literals. If a chunk'sstopOnError: true rethrows instead,skipDuplicates swallows duplicate-key failures during the fallback.import { BatchQueryOptimizer } from 'ts-prorm-orm';
const batch = new BatchQueryOptimizer(prorm.getDialectInstance());
await batch.optimizeBatchInsert('orders', [
{ customer_id: 7, total: 120 },
{ customer_id: 9, total: 45.5 },
]);
await batch.optimizeBatchDelete('orders', [3, 4, 5]);optimizeBatchUpdate keys its CASE by the identifier column. Updating ordershipped and order 2 to cancelled sends one statement:[!NOTE]
Before 4.9.2 theCASEwasCASE WHEN ? THEN ? …, which never compared
anything withid(WHEN 1is simply true), so every row in theINlist
took the first record's values while the call reported success. Rows
written byoptimizeBatchUpdateon an older release may need checking.
Model.bulkCreate() and Model.bulkUpdate()prepared-statement-cache.ts and both are process-localPreparedStatementCachenormalizeSql: true), so the same query with different literalsparams appends them to the key instead.maxSize (default 500), TTL expiry at 1 hour,prune().getStats() gives size, hit rate, total execution time, evictions, and theQueryResultCacheinvalidateTable(name) evictsts-prorm-orm/dist/query-optimizers/prepared-statement-cache if you want itoptimizer.getQueryResultCache().executeQuery() runs one statement through hints → result cache → statementimport { FORCE_INDEX } from 'ts-prorm-orm';
const { result, fromCache, duration } = await optimizer.executeQuery(sql, {
useCache: true,
usePreparedStatement: true,
hint: [FORCE_INDEX('idx_users_email', 'users')],
});const first = await optimizer.executeQuery('SELECT COUNT(*) AS n FROM orders', { useCache: true });
const again = await optimizer.executeQuery('SELECT COUNT(*) AS n FROM orders', { useCache: true });
first.fromCache; // false
again.fromCache; // true
optimizer.invalidateTableCache('orders'); // 1 — the entry mentioning "orders"[!WARNING]
The result cache is only consulted when the call supplies noparams—
a parameterised call always goes to the database.paramsare bound to the
statement as its positional replacements
(executeQuery('SELECT * FROM orders WHERE id = ?', { params: [2] })), on
both the plain and theusePreparedStatementpath. Before 4.9.2 they only
keyed the caches and the log, and the statement ran with nothing bound.Nothing invalidates the result cache for you: a write through the model API
orprorm.query()leaves cached reads in place until their TTL or aninvalidateTableCache()call.
| Housekeeping | Effect |
|---|---|
getStats() | merged stats from the slow-query logger and both caches |
clearCaches() | empty both caches |
pruneCaches() | drop expired entries only |
invalidateTableCache(table) | evict result-cache entries mentioning the table |
query-optimizers — internals, file by file