@protobase/query 0.17.0 → 0.18.0
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.
- package/dist/compile-compare.js +7 -4
- package/dist/explain.js +1 -2
- package/dist/index-coverage.d.ts +34 -0
- package/dist/index-coverage.js +106 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/prune-filter.d.ts +5 -0
- package/dist/table-indexes.d.ts +28 -0
- package/dist/table-indexes.js +58 -0
- package/package.json +2 -2
- package/dist/explain-guard.d.ts +0 -21
- package/dist/explain-guard.js +0 -42
package/dist/compile-compare.js
CHANGED
|
@@ -11,10 +11,13 @@ const operators = {
|
|
|
11
11
|
">": ">",
|
|
12
12
|
">=": ">="
|
|
13
13
|
};
|
|
14
|
-
/** `=` and `!=` on text with a `*` in the value
|
|
15
|
-
const
|
|
14
|
+
/** `=` and `!=` on text with a `*` in the value: a pattern, which compiles to (NOT) ILIKE. */
|
|
15
|
+
const isPatternCompare = ({ field, op, value }) => field.type === "text" && (op === "=" || op === "!=") && value.kind === "string" && value.value.includes("*");
|
|
16
|
+
/** A pattern becomes (NOT) ILIKE with the rest escaped. */
|
|
17
|
+
const compileCompare = (node) => {
|
|
18
|
+
const { field, op, value } = node;
|
|
16
19
|
const col = columnRef(field);
|
|
17
|
-
if (
|
|
20
|
+
if (isPatternCompare(node) && value.kind === "string") {
|
|
18
21
|
const pattern = wildcardPattern(value.value);
|
|
19
22
|
return op === "=" ? sql`${col} ilike ${pattern}` : sql`${col} not ilike ${pattern}`;
|
|
20
23
|
}
|
|
@@ -22,4 +25,4 @@ const compileCompare = ({ field, op, value }) => {
|
|
|
22
25
|
};
|
|
23
26
|
const compileIn = ({ field, values }) => sql`${columnRef(field)} in (${sql.join(values.map((value) => paramFor(value, field)))})`;
|
|
24
27
|
//#endregion
|
|
25
|
-
export { compileCompare, compileIn };
|
|
28
|
+
export { compileCompare, compileIn, isPatternCompare };
|
package/dist/explain.js
CHANGED
|
@@ -5,6 +5,5 @@ const explainPlan = async (db, compiled) => {
|
|
|
5
5
|
const output = (await db.executeQuery(CompiledQuery.raw(`EXPLAIN (VERBOSE, FORMAT JSON) ${compiled.sql}`, [...compiled.parameters]))).rows[0]["QUERY PLAN"];
|
|
6
6
|
return (typeof output === "string" ? JSON.parse(output) : output)[0].Plan;
|
|
7
7
|
};
|
|
8
|
-
const walkPlan = (node) => [node, ...(node.Plans ?? []).flatMap(walkPlan)];
|
|
9
8
|
//#endregion
|
|
10
|
-
export { explainPlan
|
|
9
|
+
export { explainPlan };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Scope } from "./scope.js";
|
|
2
|
+
import { TableIndexes } from "./table-indexes.js";
|
|
3
|
+
import { CheckedFilter, ResourceModel, SortSpec } from "@protobase/schema";
|
|
4
|
+
//#region src/index-coverage.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* The kind of index a column needs for how it is read: `btree` for comparisons, lists of values, null checks and sorts;
|
|
7
|
+
* `trigram` (pg_trgm, GIN or GiST) for patterns, regex, `similar` and text search; `digits` a trigram index on the
|
|
8
|
+
* column's digits (`regexp_replace(column, '[^0-9]', '', 'g')`) for a `digitsEnd` search field; `gin` for JSON.
|
|
9
|
+
*/
|
|
10
|
+
export type IndexNeed = 'btree' | 'trigram' | 'digits' | 'gin';
|
|
11
|
+
/** Where a column is read: the caller's filter, the access rule's row filter, the sort, or a facet's grouping. */
|
|
12
|
+
export type ColumnUse = 'filter' | 'access' | 'sort' | 'facet';
|
|
13
|
+
/** A column read without an index that serves it, and a statement that adds one. */
|
|
14
|
+
export type UncoveredColumn = {
|
|
15
|
+
field: string;
|
|
16
|
+
column: string;
|
|
17
|
+
use: ColumnUse;
|
|
18
|
+
need: IndexNeed;
|
|
19
|
+
suggestion: string;
|
|
20
|
+
};
|
|
21
|
+
/** What a read does with the table: the filter it keeps rows by, the order it walks them in, a field it groups by. */
|
|
22
|
+
export type CoverageQuery = {
|
|
23
|
+
filter?: CheckedFilter;
|
|
24
|
+
sort?: SortSpec;
|
|
25
|
+
groupBy?: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The columns a read of the resource would read without an index serving them; empty when every one is served. The
|
|
29
|
+
* filter's and the access row filter's columns each need an index of the kind their operator uses, the sort's first
|
|
30
|
+
* field and a grouped field a btree index. Tenant, soft delete and `=` terms pin columns, so a later key of a btree
|
|
31
|
+
* index serves under them. A plain view or a foreign table has no indexes of its own and is not judged.
|
|
32
|
+
*/
|
|
33
|
+
export declare const uncoveredColumns: (model: ResourceModel, table: TableIndexes, query: CoverageQuery, scope?: Scope) => UncoveredColumn[];
|
|
34
|
+
//#endregion
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { isPatternCompare } from "./compile-compare.js";
|
|
2
|
+
import { fullModelOf } from "./scope.js";
|
|
3
|
+
//#region src/index-coverage.ts
|
|
4
|
+
const trigramClasses = ["gin_trgm_ops", "gist_trgm_ops"];
|
|
5
|
+
const quoted = (name) => `"${name.replaceAll("\"", "\"\"")}"`;
|
|
6
|
+
const tableName = (model) => {
|
|
7
|
+
const { schema, name } = model.table;
|
|
8
|
+
return schema ? `${quoted(schema)}.${quoted(name)}` : quoted(name);
|
|
9
|
+
};
|
|
10
|
+
const suggestion = (model, column, need) => {
|
|
11
|
+
const table = tableName(model);
|
|
12
|
+
if (need === "trigram") return `create index on ${table} using gin (${quoted(column)} gin_trgm_ops)`;
|
|
13
|
+
if (need === "digits") return `create index on ${table} using gin ((regexp_replace(${quoted(column)}, '[^0-9]', '', 'g')) gin_trgm_ops)`;
|
|
14
|
+
if (need === "gin") return `create index on ${table} using gin (${quoted(column)})`;
|
|
15
|
+
return `create index on ${table} (${quoted(column)})`;
|
|
16
|
+
};
|
|
17
|
+
/** The columns a filter pins to one value or a list of values in every row it keeps: `=`, `in` and `is null` under AND. */
|
|
18
|
+
const pinnedColumns = (filter) => {
|
|
19
|
+
if (!filter) return [];
|
|
20
|
+
if (filter.kind === "and") return filter.args.flatMap(pinnedColumns);
|
|
21
|
+
if (filter.kind === "compare") return filter.op === "=" && !isPatternCompare(filter) ? [filter.field.column] : [];
|
|
22
|
+
if (filter.kind === "in" || filter.kind === "isNull") return [filter.field.column];
|
|
23
|
+
return [];
|
|
24
|
+
};
|
|
25
|
+
const btreeServes = (index, column, pinned) => {
|
|
26
|
+
if (index.method !== "btree" || index.partial) return false;
|
|
27
|
+
const position = index.keys.findIndex((key) => key.column === column);
|
|
28
|
+
return position >= 0 && index.keys.slice(0, position).every((key) => key.column !== null && pinned.has(key.column));
|
|
29
|
+
};
|
|
30
|
+
const mentions = (definition, column) => {
|
|
31
|
+
const name = column.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
32
|
+
return new RegExp(`(^|[^\\w"])("${name}"|${name})([^\\w"]|$)`).test(definition);
|
|
33
|
+
};
|
|
34
|
+
const serves = (index, column, need, pinned) => {
|
|
35
|
+
if (index.partial) return false;
|
|
36
|
+
switch (need) {
|
|
37
|
+
case "btree": return btreeServes(index, column, pinned);
|
|
38
|
+
case "trigram": return index.keys.some((key) => key.column === column && trigramClasses.includes(key.opclass));
|
|
39
|
+
case "digits": return index.keys.some((key) => key.column === null && trigramClasses.includes(key.opclass) && key.definition.includes("regexp_replace") && mentions(key.definition, column));
|
|
40
|
+
case "gin": return index.method === "gin" && index.keys.some((key) => key.column === column);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const need = (ctx, field, kind, pinned, also) => ctx.indexes.some((index) => serves(index, field.column, kind, pinned) || also !== void 0 && serves(index, field.column, also, pinned)) ? [] : [{
|
|
44
|
+
field: field.name,
|
|
45
|
+
column: field.column,
|
|
46
|
+
use: ctx.use,
|
|
47
|
+
need: kind,
|
|
48
|
+
suggestion: suggestion(ctx.model, field.column, kind)
|
|
49
|
+
}];
|
|
50
|
+
const compareNeed = (ctx, node, pinned) => {
|
|
51
|
+
if (isPatternCompare(node)) return need(ctx, node.field, "trigram", pinned);
|
|
52
|
+
return need(ctx, node.field, "btree", pinned, node.op === "=" && node.field.type === "text" ? "trigram" : void 0);
|
|
53
|
+
};
|
|
54
|
+
const searchNeeds = (ctx, pinned) => (ctx.model.search ?? []).flatMap((name) => {
|
|
55
|
+
const field = ctx.model.fields[name];
|
|
56
|
+
if (!field) throw new Error(`Not implemented: search field "${name}" is not a field of "${ctx.model.name}", which the model builder refuses`);
|
|
57
|
+
return need(ctx, field, ctx.model.searchMatch?.[name] === "digitsEnd" ? "digits" : "trigram", pinned);
|
|
58
|
+
});
|
|
59
|
+
const walk = (ctx, node, pinned, negated) => {
|
|
60
|
+
switch (node.kind) {
|
|
61
|
+
case "and": return node.args.flatMap((arg) => walk(ctx, arg, pinned, negated));
|
|
62
|
+
case "or": return node.args.flatMap((arg) => walk(ctx, arg, negated ? pinned : /* @__PURE__ */ new Set([...pinned, ...pinnedColumns(arg)]), negated));
|
|
63
|
+
case "not": return walk(ctx, node.arg, pinned, true);
|
|
64
|
+
case "compare": return compareNeed(ctx, node, pinned);
|
|
65
|
+
case "in":
|
|
66
|
+
case "isNull": return need(ctx, node.field, "btree", pinned);
|
|
67
|
+
case "present": return need(ctx, node.field, node.field.type === "json" ? "gin" : "btree", pinned);
|
|
68
|
+
case "has": return need(ctx, node.field, "gin", pinned);
|
|
69
|
+
case "regex":
|
|
70
|
+
case "similar": return need(ctx, node.field, "trigram", pinned);
|
|
71
|
+
case "search": return searchNeeds(ctx, pinned);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const unique = (columns) => columns.filter((column, i) => columns.findIndex((other) => other.column === column.column && other.need === column.need) === i);
|
|
75
|
+
/**
|
|
76
|
+
* The columns a read of the resource would read without an index serving them; empty when every one is served. The
|
|
77
|
+
* filter's and the access row filter's columns each need an index of the kind their operator uses, the sort's first
|
|
78
|
+
* field and a grouped field a btree index. Tenant, soft delete and `=` terms pin columns, so a later key of a btree
|
|
79
|
+
* index serves under them. A plain view or a foreign table has no indexes of its own and is not judged.
|
|
80
|
+
*/
|
|
81
|
+
const uncoveredColumns = (model, table, query, scope) => {
|
|
82
|
+
if (table.kind !== "table") return [];
|
|
83
|
+
const full = fullModelOf(model, scope);
|
|
84
|
+
const columnOf = (name) => name && Object.hasOwn(full.fields, name) ? [full.fields[name].column] : name ? [name] : [];
|
|
85
|
+
const pinned = /* @__PURE__ */ new Set([
|
|
86
|
+
...columnOf(full.tenant ?? model.tenant),
|
|
87
|
+
...columnOf(full.softDelete ?? model.softDelete),
|
|
88
|
+
...pinnedColumns(scope?.rowFilter),
|
|
89
|
+
...pinnedColumns(query.filter)
|
|
90
|
+
]);
|
|
91
|
+
const ctx = (use, of = model) => ({
|
|
92
|
+
model: of,
|
|
93
|
+
indexes: table.indexes,
|
|
94
|
+
use
|
|
95
|
+
});
|
|
96
|
+
const sortField = query.sort?.[0] && model.fields[query.sort[0][0]];
|
|
97
|
+
const groupField = query.groupBy !== void 0 ? model.fields[query.groupBy] : void 0;
|
|
98
|
+
return unique([
|
|
99
|
+
...scope?.rowFilter ? walk(ctx("access", full), scope.rowFilter, pinned, false) : [],
|
|
100
|
+
...query.filter ? walk(ctx("filter"), query.filter, pinned, false) : [],
|
|
101
|
+
...sortField ? need(ctx("sort"), sortField, "btree", pinned) : [],
|
|
102
|
+
...groupField ? need(ctx("facet"), groupField, "btree", pinned) : []
|
|
103
|
+
]);
|
|
104
|
+
};
|
|
105
|
+
//#endregion
|
|
106
|
+
export { uncoveredColumns };
|
package/dist/index.d.ts
CHANGED
|
@@ -10,8 +10,10 @@ import { FacetCount, FacetOptions, facetCounts } from "./facet-counts.js";
|
|
|
10
10
|
import { Granularity, SeriesOptions, SeriesPoint, series } from "./series.js";
|
|
11
11
|
import { Histogram, HistogramBucket, histogram } from "./histogram.js";
|
|
12
12
|
import { MapBounds, MapBucket, MapBuckets, MapPosition, cellsAcross, checkMapPosition, mapBuckets, mapCellPixels, maxMapBuckets, maxMapZoom } from "./map-buckets.js";
|
|
13
|
+
import { withoutField } from "./prune-filter.js";
|
|
13
14
|
import { ScrollMode, scrollMode } from "./scroll-mode.js";
|
|
14
|
-
import {
|
|
15
|
+
import { IndexKey, TableIndex, TableIndexes, tableIndexes } from "./table-indexes.js";
|
|
16
|
+
import { ColumnUse, CoverageQuery, IndexNeed, UncoveredColumn, uncoveredColumns } from "./index-coverage.js";
|
|
15
17
|
import { QueryError, QueryErrorCode } from "./fields/query-error.js";
|
|
16
18
|
import { SeekRequest, seekToPosition } from "./seek-to-position.js";
|
|
17
|
-
export { type CompileOptions, type Db, type ExtraColumn, type FacetCount, type FacetOptions, type Granularity, type
|
|
19
|
+
export { type ColumnUse, type CompileOptions, type CoverageQuery, type Db, type ExtraColumn, type FacetCount, type FacetOptions, type Granularity, type Histogram, type HistogramBucket, type IndexKey, type IndexNeed, type ListOptions, type ListPlan, type ListResult, type MapBounds, type MapBucket, type MapBuckets, type MapPosition, QueryError, type QueryErrorCode, type RelatedRowsQuery, type Scope, type ScrollMode, type SeekRequest, type SeriesOptions, type SeriesPoint, type TableIndex, type TableIndexes, type UncoveredColumn, buildListQuery, buildRelatedRows, cellsAcross, checkMapPosition, compileFilter, countUpTo, estimateCount, exactCount, facetCounts, histogram, listQuery, mapBuckets, mapCellPixels, maxLimit, maxMapBuckets, maxMapZoom, relatedRows, scrollMode, seekToPosition, series, tableIndexes, uncoveredColumns, withoutField };
|
package/dist/index.js
CHANGED
|
@@ -5,11 +5,13 @@ import { buildRelatedRows, relatedRows } from "./related-rows.js";
|
|
|
5
5
|
import { estimateCount } from "./estimate-count.js";
|
|
6
6
|
import { exactCount } from "./exact-count.js";
|
|
7
7
|
import { countUpTo } from "./count-up-to.js";
|
|
8
|
+
import { withoutField } from "./prune-filter.js";
|
|
8
9
|
import { facetCounts } from "./facet-counts.js";
|
|
9
10
|
import { series } from "./series.js";
|
|
10
11
|
import { histogram } from "./histogram.js";
|
|
11
12
|
import { cellsAcross, checkMapPosition, mapBuckets, mapCellPixels, maxMapBuckets, maxMapZoom } from "./map-buckets.js";
|
|
12
13
|
import { scrollMode } from "./scroll-mode.js";
|
|
13
|
-
import {
|
|
14
|
+
import { tableIndexes } from "./table-indexes.js";
|
|
15
|
+
import { uncoveredColumns } from "./index-coverage.js";
|
|
14
16
|
import { seekToPosition } from "./seek-to-position.js";
|
|
15
|
-
export { QueryError, buildListQuery, buildRelatedRows, cellsAcross, checkMapPosition, compileFilter, countUpTo, estimateCount, exactCount,
|
|
17
|
+
export { QueryError, buildListQuery, buildRelatedRows, cellsAcross, checkMapPosition, compileFilter, countUpTo, estimateCount, exactCount, facetCounts, histogram, listQuery, mapBuckets, mapCellPixels, maxLimit, maxMapBuckets, maxMapZoom, relatedRows, scrollMode, seekToPosition, series, tableIndexes, uncoveredColumns, withoutField };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { CheckedFilter } from "@protobase/schema";
|
|
2
|
+
//#region src/prune-filter.d.ts
|
|
3
|
+
/** Removes every condition on `field`, dropping groups that end up empty (disjunctive faceting). Text search stays. */
|
|
4
|
+
export declare const withoutField: (node: CheckedFilter | undefined, field: string) => CheckedFilter | undefined;
|
|
5
|
+
//#endregion
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Db } from "./db.js";
|
|
2
|
+
import { ResourceModel } from "@protobase/schema";
|
|
3
|
+
//#region src/table-indexes.d.ts
|
|
4
|
+
/** One key of an index: its column, or null for an expression, which `definition` spells out. */
|
|
5
|
+
export type IndexKey = {
|
|
6
|
+
column: string | null;
|
|
7
|
+
definition: string;
|
|
8
|
+
opclass: string;
|
|
9
|
+
};
|
|
10
|
+
/** A valid index of a table: its access method (`btree`, `gin`, ...), whether it has a WHERE, and its keys in order. */
|
|
11
|
+
export type TableIndex = {
|
|
12
|
+
name: string;
|
|
13
|
+
method: string;
|
|
14
|
+
partial: boolean;
|
|
15
|
+
keys: IndexKey[];
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* What the database holds for a resource's table: `table` for anything that carries indexes (a table, a partitioned
|
|
19
|
+
* table or a materialized view), `other` for a plain view or a foreign table, whose indexes live elsewhere, and
|
|
20
|
+
* `missing` when the name resolves to nothing.
|
|
21
|
+
*/
|
|
22
|
+
export type TableIndexes = {
|
|
23
|
+
kind: 'table' | 'other' | 'missing';
|
|
24
|
+
indexes: TableIndex[];
|
|
25
|
+
};
|
|
26
|
+
/** Reads the valid indexes of the resource's table from the catalog, key by key (INCLUDE columns are no keys). */
|
|
27
|
+
export declare const tableIndexes: (db: Db, model: ResourceModel) => Promise<TableIndexes>;
|
|
28
|
+
//#endregion
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { quoteIdent } from "./db.js";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
//#region src/table-indexes.ts
|
|
4
|
+
const qualifiedName = (model) => {
|
|
5
|
+
const { schema, name } = model.table;
|
|
6
|
+
return schema ? `${quoteIdent(schema)}.${quoteIdent(name)}` : quoteIdent(name);
|
|
7
|
+
};
|
|
8
|
+
const indexedKinds = [
|
|
9
|
+
"r",
|
|
10
|
+
"p",
|
|
11
|
+
"m"
|
|
12
|
+
];
|
|
13
|
+
/** Reads the valid indexes of the resource's table from the catalog, key by key (INCLUDE columns are no keys). */
|
|
14
|
+
const tableIndexes = async (db, model) => {
|
|
15
|
+
const qualified = qualifiedName(model);
|
|
16
|
+
const relkind = (await sql`select relkind from pg_class where oid = to_regclass(${qualified})`.execute(db)).rows[0]?.relkind;
|
|
17
|
+
if (relkind === void 0) return {
|
|
18
|
+
kind: "missing",
|
|
19
|
+
indexes: []
|
|
20
|
+
};
|
|
21
|
+
if (!indexedKinds.includes(relkind)) return {
|
|
22
|
+
kind: "other",
|
|
23
|
+
indexes: []
|
|
24
|
+
};
|
|
25
|
+
const keys = await sql`
|
|
26
|
+
select ic.relname as name, am.amname as method, (i.indpred is not null) as partial, a.attname::text as column,
|
|
27
|
+
pg_get_indexdef(i.indexrelid, k.ord::int, true) as definition, oc.opcname::text as opclass
|
|
28
|
+
from pg_index i
|
|
29
|
+
join pg_class ic on ic.oid = i.indexrelid
|
|
30
|
+
join pg_am am on am.oid = ic.relam
|
|
31
|
+
cross join lateral unnest(i.indkey::int2[], i.indclass::oid[]) with ordinality k(attnum, opclass, ord)
|
|
32
|
+
left join pg_attribute a on a.attrelid = i.indrelid and a.attnum = k.attnum and k.attnum > 0
|
|
33
|
+
join pg_opclass oc on oc.oid = k.opclass
|
|
34
|
+
where i.indrelid = to_regclass(${qualified}) and i.indisvalid and i.indisready and k.ord <= i.indnkeyatts
|
|
35
|
+
order by ic.relname, k.ord
|
|
36
|
+
`.execute(db);
|
|
37
|
+
const byName = /* @__PURE__ */ new Map();
|
|
38
|
+
for (const { name, method, partial, column, definition, opclass } of keys.rows) {
|
|
39
|
+
const index = byName.get(name) ?? {
|
|
40
|
+
name,
|
|
41
|
+
method,
|
|
42
|
+
partial,
|
|
43
|
+
keys: []
|
|
44
|
+
};
|
|
45
|
+
index.keys.push({
|
|
46
|
+
column,
|
|
47
|
+
definition,
|
|
48
|
+
opclass
|
|
49
|
+
});
|
|
50
|
+
byName.set(name, index);
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
kind: "table",
|
|
54
|
+
indexes: [...byName.values()]
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
//#endregion
|
|
58
|
+
export { tableIndexes };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/query",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Kysely list, filter, count and aggregate queries over Protobase resources.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"access": "public"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@protobase/schema": "0.
|
|
32
|
+
"@protobase/schema": "0.18.0",
|
|
33
33
|
"kysely": "^0.29.6"
|
|
34
34
|
}
|
|
35
35
|
}
|
package/dist/explain-guard.d.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { Db } from "./db.js";
|
|
2
|
-
import { Compilable, CompiledQuery } from "kysely";
|
|
3
|
-
//#region src/explain-guard.d.ts
|
|
4
|
-
export type GuardOptions = {
|
|
5
|
-
/** A Seq Scan on a table with more rows than this is a finding. */
|
|
6
|
-
seqScanRows?: number;
|
|
7
|
-
/** A Sort over more rows than this is a finding. */
|
|
8
|
-
sortRows?: number;
|
|
9
|
-
};
|
|
10
|
-
export type GuardFinding = {
|
|
11
|
-
kind: 'seqScan' | 'sort';
|
|
12
|
-
relation?: string;
|
|
13
|
-
rows: number;
|
|
14
|
-
};
|
|
15
|
-
export type GuardReport = {
|
|
16
|
-
ok: boolean;
|
|
17
|
-
findings: GuardFinding[];
|
|
18
|
-
};
|
|
19
|
-
/** Plans the query (never executes it) and reports expensive nodes (Seq Scan and Parallel Seq Scan). Pass a Kysely query or a compiled one. */
|
|
20
|
-
export declare const explainGuard: (db: Db, query: Compilable<unknown> | CompiledQuery, options?: GuardOptions) => Promise<GuardReport>;
|
|
21
|
-
//#endregion
|
package/dist/explain-guard.js
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { quoteIdent } from "./db.js";
|
|
2
|
-
import { explainPlan, walkPlan } from "./explain.js";
|
|
3
|
-
import { toNumber } from "./numbers.js";
|
|
4
|
-
import { sql } from "kysely";
|
|
5
|
-
//#region src/explain-guard.ts
|
|
6
|
-
const relationRows = async (db, schema, relation, planRows) => {
|
|
7
|
-
const qualified = schema ? `${quoteIdent(schema)}.${quoteIdent(relation)}` : quoteIdent(relation);
|
|
8
|
-
const result = await sql`
|
|
9
|
-
select reltuples::float8 as reltuples from pg_class where oid = to_regclass(${qualified})
|
|
10
|
-
`.execute(db);
|
|
11
|
-
const reltuples = toNumber(result.rows[0]?.reltuples ?? -1);
|
|
12
|
-
return reltuples >= 0 ? reltuples : planRows;
|
|
13
|
-
};
|
|
14
|
-
/** Plans the query (never executes it) and reports expensive nodes (Seq Scan and Parallel Seq Scan). Pass a Kysely query or a compiled one. */
|
|
15
|
-
const explainGuard = async (db, query, options = {}) => {
|
|
16
|
-
const { seqScanRows = 1e4, sortRows = 1e5 } = options;
|
|
17
|
-
const compiled = "sql" in query ? query : query.compile();
|
|
18
|
-
const nodes = walkPlan(await explainPlan(db, compiled));
|
|
19
|
-
const findings = [];
|
|
20
|
-
for (const node of nodes) {
|
|
21
|
-
const type = node["Node Type"];
|
|
22
|
-
const relation = node["Relation Name"];
|
|
23
|
-
if (type.endsWith("Seq Scan") && relation) {
|
|
24
|
-
const rows = await relationRows(db, node.Schema, relation, node["Plan Rows"]);
|
|
25
|
-
if (rows > seqScanRows) findings.push({
|
|
26
|
-
kind: "seqScan",
|
|
27
|
-
relation,
|
|
28
|
-
rows
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
if ((type === "Sort" || type === "Incremental Sort") && node["Plan Rows"] > sortRows) findings.push({
|
|
32
|
-
kind: "sort",
|
|
33
|
-
rows: node["Plan Rows"]
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
return {
|
|
37
|
-
ok: findings.length === 0,
|
|
38
|
-
findings
|
|
39
|
-
};
|
|
40
|
-
};
|
|
41
|
-
//#endregion
|
|
42
|
-
export { explainGuard };
|