turbine-orm 0.47.0 → 0.48.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/cjs/powdb-introspect.js +121 -2
- package/dist/cjs/powdb.js +188 -5
- package/dist/cjs/powql.js +207 -8
- package/dist/cjs/query/warn-registry.js +2 -0
- package/dist/powdb-introspect.js +122 -3
- package/dist/powdb.d.ts +100 -3
- package/dist/powdb.js +185 -5
- package/dist/powql.d.ts +51 -0
- package/dist/powql.js +207 -8
- package/dist/query/warn-registry.d.ts +2 -0
- package/dist/query/warn-registry.js +2 -0
- package/package.json +3 -3
package/dist/cjs/powql.js
CHANGED
|
@@ -117,6 +117,16 @@ const POWQL_WRITE_ACTIONS = new Set([
|
|
|
117
117
|
'deleteMany',
|
|
118
118
|
'upsert',
|
|
119
119
|
]);
|
|
120
|
+
/**
|
|
121
|
+
* Per-pool cache of the `schema links` snapshot (fetched at most once per pool):
|
|
122
|
+
* scalar link-path query generation verifies a declared link matches the
|
|
123
|
+
* relation before compiling to it. Keyed on the pool object identity so every
|
|
124
|
+
* table interface over the same connection shares one snapshot; a WeakMap lets a
|
|
125
|
+
* discarded pool's snapshot be collected. A fetch failure caches `[]` (a missing
|
|
126
|
+
* listing means "no verifiable links" — a silent fallback to loaders, never an
|
|
127
|
+
* error).
|
|
128
|
+
*/
|
|
129
|
+
const LINK_SNAPSHOT_CACHE = new WeakMap();
|
|
120
130
|
/** Operator keys recognised inside a `WhereOperator` object. */
|
|
121
131
|
const OPERATOR_KEYS = new Set([
|
|
122
132
|
'equals',
|
|
@@ -941,10 +951,12 @@ class PowqlInterface {
|
|
|
941
951
|
// -------------------------------------------------------------------------
|
|
942
952
|
async findMany(args = {}) {
|
|
943
953
|
return this.withMiddleware('findMany', args, async () => {
|
|
944
|
-
const { rows, native, resolvedWhere, nestedPlans, residualWith } = await this.runFind(args, 'findMany');
|
|
954
|
+
const { rows, native, resolvedWhere, nestedPlans, linkPlans, residualWith } = await this.runFind(args, 'findMany');
|
|
945
955
|
const entities = this.shape(rows, native);
|
|
946
956
|
if (nestedPlans.length)
|
|
947
957
|
this.attachNestedRows(entities, nestedPlans);
|
|
958
|
+
if (linkPlans.length)
|
|
959
|
+
this.attachLinkRows(entities, linkPlans, native);
|
|
948
960
|
if (residualWith) {
|
|
949
961
|
await this.loadRelations(entities, residualWith, args.timeout, 0, { args, resolvedWhere }, args.includePii === true);
|
|
950
962
|
}
|
|
@@ -977,6 +989,7 @@ class PowqlInterface {
|
|
|
977
989
|
// key would duplicate the column's).
|
|
978
990
|
const withClause = args.with;
|
|
979
991
|
const nestedPlans = [];
|
|
992
|
+
const linkPlans = [];
|
|
980
993
|
let residualWith = withClause;
|
|
981
994
|
if (withClause && !args.distinct?.length && this.nestedProjectionsPreferred(args)) {
|
|
982
995
|
const residue = {};
|
|
@@ -985,14 +998,25 @@ class PowqlInterface {
|
|
|
985
998
|
continue;
|
|
986
999
|
const rel = this.meta.relations[relName];
|
|
987
1000
|
const plan = rel && !cols.includes(relName) ? this.planNestedRelation(relName, rel, opt, args.includePii === true) : null;
|
|
988
|
-
if (plan)
|
|
1001
|
+
if (plan) {
|
|
989
1002
|
nestedPlans.push(plan);
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
// Nested projection could not serve it: try a scalar link path (the narrow
|
|
1006
|
+
// to-one bigint/bytes case; the `schema links` snapshot is fetched lazily
|
|
1007
|
+
// inside, only when a genuine candidate reaches it), else it stays on the
|
|
1008
|
+
// loaders.
|
|
1009
|
+
const linkPlan = rel && !cols.includes(relName)
|
|
1010
|
+
? await this.planLinkPathRelation(relName, rel, opt, args.includePii === true, cols, linkPlans.length + 1)
|
|
1011
|
+
: null;
|
|
1012
|
+
if (linkPlan)
|
|
1013
|
+
linkPlans.push(linkPlan);
|
|
990
1014
|
else
|
|
991
1015
|
residue[relName] = opt; // unknown relation: the loader raises its E003
|
|
992
1016
|
}
|
|
993
1017
|
residualWith = Object.keys(residue).length ? residue : undefined;
|
|
994
1018
|
}
|
|
995
|
-
const nest = nestedPlans.length > 0;
|
|
1019
|
+
const nest = nestedPlans.length > 0 || linkPlans.length > 0;
|
|
996
1020
|
const alias = nest ? 't0' : undefined;
|
|
997
1021
|
const where = this.buildWhere(resolvedWhere, params, alias);
|
|
998
1022
|
const distinct = args.distinct?.length ? ' distinct' : '';
|
|
@@ -1014,20 +1038,23 @@ class PowqlInterface {
|
|
|
1014
1038
|
for (const plan of nestedPlans) {
|
|
1015
1039
|
parts.push(await this.buildNestedBlock(plan, 't0', aliasCtr, params, args.timeout));
|
|
1016
1040
|
}
|
|
1041
|
+
// Scalar link-path hops (flat native-typed fields; never a JSON block).
|
|
1042
|
+
for (const plan of linkPlans)
|
|
1043
|
+
parts.push(...this.linkPathFields(plan, 't0'));
|
|
1017
1044
|
projection = `{ ${parts.join(', ')} }`;
|
|
1018
1045
|
}
|
|
1019
1046
|
else {
|
|
1020
1047
|
projection = this.projection(cols);
|
|
1021
1048
|
}
|
|
1022
1049
|
const powql = `${this.qt}${nest ? ' as t0' : ''}${distinct}${filter}${order}${limitClause}${offsetClause} ${projection}`;
|
|
1023
|
-
return { powql, resolvedWhere, nestedPlans, residualWith };
|
|
1050
|
+
return { powql, resolvedWhere, nestedPlans, linkPlans, residualWith };
|
|
1024
1051
|
}
|
|
1025
1052
|
/** Build + run the findMany select; returns raw rows, the serving wire, the resolved where, and the `with` partition. */
|
|
1026
1053
|
async runFind(args, action = 'findMany') {
|
|
1027
1054
|
const params = [];
|
|
1028
|
-
const { powql, resolvedWhere, nestedPlans, residualWith } = await this.buildFind(args, params);
|
|
1055
|
+
const { powql, resolvedWhere, nestedPlans, linkPlans, residualWith } = await this.buildFind(args, params);
|
|
1029
1056
|
const { rows, native } = await this.exec(powql, params, args.timeout, action);
|
|
1030
|
-
return { rows, native, resolvedWhere, nestedPlans, residualWith };
|
|
1057
|
+
return { rows, native, resolvedWhere, nestedPlans, linkPlans, residualWith };
|
|
1031
1058
|
}
|
|
1032
1059
|
/**
|
|
1033
1060
|
* Diagnostic surface: compile the same PowQL {@link findMany} would run for
|
|
@@ -1062,12 +1089,14 @@ class PowqlInterface {
|
|
|
1062
1089
|
args = { ...args, where: expanded };
|
|
1063
1090
|
}
|
|
1064
1091
|
return this.withMiddleware('findUnique', args, async () => {
|
|
1065
|
-
const { rows, native, nestedPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
|
|
1092
|
+
const { rows, native, nestedPlans, linkPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
|
|
1066
1093
|
if (!rows.length)
|
|
1067
1094
|
return null;
|
|
1068
1095
|
const entities = this.shape(rows, native);
|
|
1069
1096
|
if (nestedPlans.length)
|
|
1070
1097
|
this.attachNestedRows(entities, nestedPlans);
|
|
1098
|
+
if (linkPlans.length)
|
|
1099
|
+
this.attachLinkRows(entities, linkPlans, native);
|
|
1071
1100
|
if (residualWith)
|
|
1072
1101
|
await this.loadRelations(entities, residualWith, args.timeout, 0, undefined, args.includePii === true);
|
|
1073
1102
|
return entities[0];
|
|
@@ -1075,12 +1104,14 @@ class PowqlInterface {
|
|
|
1075
1104
|
}
|
|
1076
1105
|
async findFirst(args = {}) {
|
|
1077
1106
|
return this.withMiddleware('findFirst', args, async () => {
|
|
1078
|
-
const { rows, native, nestedPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
|
|
1107
|
+
const { rows, native, nestedPlans, linkPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
|
|
1079
1108
|
if (!rows.length)
|
|
1080
1109
|
return null;
|
|
1081
1110
|
const entities = this.shape(rows, native);
|
|
1082
1111
|
if (nestedPlans.length)
|
|
1083
1112
|
this.attachNestedRows(entities, nestedPlans);
|
|
1113
|
+
if (linkPlans.length)
|
|
1114
|
+
this.attachLinkRows(entities, linkPlans, native);
|
|
1084
1115
|
if (residualWith)
|
|
1085
1116
|
await this.loadRelations(entities, residualWith, args.timeout, 0, undefined, args.includePii === true);
|
|
1086
1117
|
return entities[0];
|
|
@@ -1726,6 +1757,174 @@ class PowqlInterface {
|
|
|
1726
1757
|
row[plan.relName] = plan.single ? (shaped[0] ?? null) : shaped;
|
|
1727
1758
|
}
|
|
1728
1759
|
// -------------------------------------------------------------------------
|
|
1760
|
+
// Scalar to-one link paths (PowDB >= 0.19.1, `linkPaths` capability)
|
|
1761
|
+
// -------------------------------------------------------------------------
|
|
1762
|
+
/**
|
|
1763
|
+
* The `schema links` snapshot for this pool, fetched at most once and cached on
|
|
1764
|
+
* the pool identity. A fetch failure resolves to `[]` (a missing listing is a
|
|
1765
|
+
* silent fallback to loaders, never an error). Only called when the `linkPaths`
|
|
1766
|
+
* capability is on, so the listing statement is guaranteed to exist.
|
|
1767
|
+
*/
|
|
1768
|
+
linksSnapshot() {
|
|
1769
|
+
const pool = this.pool;
|
|
1770
|
+
let snap = LINK_SNAPSHOT_CACHE.get(pool);
|
|
1771
|
+
if (!snap) {
|
|
1772
|
+
snap = this.fetchLinksSnapshot();
|
|
1773
|
+
LINK_SNAPSHOT_CACHE.set(pool, snap);
|
|
1774
|
+
}
|
|
1775
|
+
return snap;
|
|
1776
|
+
}
|
|
1777
|
+
async fetchLinksSnapshot() {
|
|
1778
|
+
try {
|
|
1779
|
+
const { rows } = await this.exec('schema links', [], undefined, 'introspect');
|
|
1780
|
+
return rows.map((r) => ({
|
|
1781
|
+
owner: String(r.owner ?? ''),
|
|
1782
|
+
name: String(r.name ?? ''),
|
|
1783
|
+
target: String(r.target ?? ''),
|
|
1784
|
+
localKey: String(r.local_key ?? ''),
|
|
1785
|
+
targetKey: String(r.target_key ?? ''),
|
|
1786
|
+
cardinality: String(r.cardinality ?? ''),
|
|
1787
|
+
}));
|
|
1788
|
+
}
|
|
1789
|
+
catch {
|
|
1790
|
+
return [];
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
/** True when `name` is a bare PowQL identifier (quoting leaves it unchanged). */
|
|
1794
|
+
isBareIdent(name) {
|
|
1795
|
+
return (0, powdb_js_1.quotePowqlIdent)(name) === name;
|
|
1796
|
+
}
|
|
1797
|
+
/**
|
|
1798
|
+
* Find the declared to-one link on THIS table that matches `rel` exactly: same
|
|
1799
|
+
* target, same correlation columns (owner localKey = the FK on this table, target
|
|
1800
|
+
* targetKey = the referenced key on target), cardinality `"to-one"`. Returns the
|
|
1801
|
+
* declared link (whose NAME drives the path spelling, which may differ from the
|
|
1802
|
+
* relation's own name) or `null` for no verifiable match (→ silent loader
|
|
1803
|
+
* fallback, never an error).
|
|
1804
|
+
*/
|
|
1805
|
+
findMatchingLink(snapshot, rel) {
|
|
1806
|
+
const localKey = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey)[0];
|
|
1807
|
+
const targetKey = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey)[0];
|
|
1808
|
+
if (!localKey || !targetKey)
|
|
1809
|
+
return null;
|
|
1810
|
+
for (const l of snapshot) {
|
|
1811
|
+
if (l.cardinality === 'to-one' &&
|
|
1812
|
+
l.owner === this.table &&
|
|
1813
|
+
l.target === rel.to &&
|
|
1814
|
+
l.localKey === localKey &&
|
|
1815
|
+
l.targetKey === targetKey) {
|
|
1816
|
+
return l;
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
return null;
|
|
1820
|
+
}
|
|
1821
|
+
/**
|
|
1822
|
+
* Plan one belongsTo `with` as a scalar link-path relation, or `null` when it
|
|
1823
|
+
* must stay on the loaders (ALWAYS a silent fallback with identical output).
|
|
1824
|
+
*
|
|
1825
|
+
* SCOPED TIGHT: this fires ONLY for a to-one relation whose child projection
|
|
1826
|
+
* includes a bigint/bytes column — exactly the case a JSON nested block cannot
|
|
1827
|
+
* carry, so nested projections have already fallen back to a per-relation loader
|
|
1828
|
+
* (`planNestedRelation` returned `null` for the same shape). Cases nested
|
|
1829
|
+
* projections DO serve keep nested projections: link-bearing statements are
|
|
1830
|
+
* NEVER plan-cached upstream, so replacing a cacheable nested projection with a
|
|
1831
|
+
* link path would regress a hot path for no gain. Requires: single-column
|
|
1832
|
+
* belongsTo; no relation `with` / `where` / `distinct` / `orderBy` /
|
|
1833
|
+
* `limit` / `offset` (a scalar path has no per-hop filter/order and cannot
|
|
1834
|
+
* reproduce those — such inputs stay on the loader for exact parity); a link
|
|
1835
|
+
* name and all projected columns that are bare identifiers (a quoted segment in
|
|
1836
|
+
* a dotted link path is outside the verified spelling — fall back); and a
|
|
1837
|
+
* DECLARED link that verifiably matches (`findMatchingLink`).
|
|
1838
|
+
*/
|
|
1839
|
+
async planLinkPathRelation(relName, rel, opt, includePii, parentCols, index) {
|
|
1840
|
+
if (!this.capabilities.linkPaths)
|
|
1841
|
+
return null;
|
|
1842
|
+
if (rel.type !== 'belongsTo')
|
|
1843
|
+
return null;
|
|
1844
|
+
if ((0, schema_js_1.normalizeKeyColumns)(rel.foreignKey).length !== 1 || (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey).length !== 1) {
|
|
1845
|
+
return null;
|
|
1846
|
+
}
|
|
1847
|
+
const targetMeta = this.schema.tables[rel.to];
|
|
1848
|
+
if (targetMeta?.primaryKey.length !== 1)
|
|
1849
|
+
return null;
|
|
1850
|
+
const options = (opt === true ? {} : opt);
|
|
1851
|
+
if (options.with || options.where || options.distinct?.length)
|
|
1852
|
+
return null;
|
|
1853
|
+
if (options.orderBy || options.limit !== undefined || options.offset)
|
|
1854
|
+
return null;
|
|
1855
|
+
const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
|
|
1856
|
+
const userCols = targetQi.projectedColumns(options.select, options.omit, includePii);
|
|
1857
|
+
const byName = new Map(targetMeta.columns.map((c) => [c.name, c]));
|
|
1858
|
+
// Only adopt link paths where a JSON block genuinely cannot serve the case:
|
|
1859
|
+
// at least one projected child column is bigint/bytes. Otherwise nested
|
|
1860
|
+
// projections already handle it (and cache), so leave it to them.
|
|
1861
|
+
const hasCarrierBlockedCol = userCols.some((c) => {
|
|
1862
|
+
const ts = (byName.get(c)?.tsType ?? '').replace(/\s*\|\s*null$/i, '').trim();
|
|
1863
|
+
return ts === 'bigint' || ts === 'Uint8Array';
|
|
1864
|
+
});
|
|
1865
|
+
if (!hasCarrierBlockedCol)
|
|
1866
|
+
return null;
|
|
1867
|
+
// All cheap checks passed: NOW fetch (and cache) the link snapshot — never
|
|
1868
|
+
// before, so a query with no link-path candidate issues no `schema links`.
|
|
1869
|
+
const link = this.findMatchingLink(await this.linksSnapshot(), rel);
|
|
1870
|
+
if (!link)
|
|
1871
|
+
return null;
|
|
1872
|
+
if (!this.isBareIdent(link.name))
|
|
1873
|
+
return null;
|
|
1874
|
+
if (!userCols.every((c) => this.isBareIdent(c)))
|
|
1875
|
+
return null;
|
|
1876
|
+
// Always project the target PK for presence detection (an absent to-one yields
|
|
1877
|
+
// Empty at every hop; PK-Empty is the unambiguous "no linked row" signal). Add
|
|
1878
|
+
// it if the user's projection dropped it, and remember to strip it back off.
|
|
1879
|
+
const pkCol = targetMeta.primaryKey[0];
|
|
1880
|
+
if (!this.isBareIdent(pkCol))
|
|
1881
|
+
return null;
|
|
1882
|
+
const pkProjected = userCols.includes(pkCol);
|
|
1883
|
+
const cols = pkProjected ? userCols : [...userCols, pkCol];
|
|
1884
|
+
// Synthetic flat result keys (`l<index>_<col>`) keep the hop fields from
|
|
1885
|
+
// colliding with real parent columns or each other. Refuse the (astronomically
|
|
1886
|
+
// unlikely) case where a real parent/child column already uses the prefix.
|
|
1887
|
+
const keyPrefix = `l${index}_`;
|
|
1888
|
+
if (parentCols.some((c) => c.startsWith(keyPrefix)) || cols.some((c) => c.startsWith(keyPrefix)))
|
|
1889
|
+
return null;
|
|
1890
|
+
return { relName, linkName: link.name, targetQi, cols, pkCol, pkProjected, keyPrefix };
|
|
1891
|
+
}
|
|
1892
|
+
/** The flat `l<i>_<col>: t0.<linkName>.<col>` projection fields for one link plan. */
|
|
1893
|
+
linkPathFields(plan, parentAlias) {
|
|
1894
|
+
return plan.cols.map((c) => `${plan.keyPrefix}${c}: ${parentAlias}.${plan.linkName}.${c}`);
|
|
1895
|
+
}
|
|
1896
|
+
/**
|
|
1897
|
+
* Reconstruct each link-path relation's child entity from its flat hop fields
|
|
1898
|
+
* and attach it under the relation name — output indistinguishable from the
|
|
1899
|
+
* loader (same keys, same coercions). Presence: the target PK cell arriving
|
|
1900
|
+
* Empty (a null/dangling FK at the hop) means no linked row → `null`, matching
|
|
1901
|
+
* the loader's `matches[0] ?? null`. Otherwise the gathered snake cells go
|
|
1902
|
+
* through the SAME `rowToEntity` policy the loader uses (native micros → Date,
|
|
1903
|
+
* bigint per the int8 policy), and the PK is stripped back off if the user did
|
|
1904
|
+
* not project it. `native` is the wire that actually served the parent row.
|
|
1905
|
+
*/
|
|
1906
|
+
attachLinkRows(entities, plans, native) {
|
|
1907
|
+
for (const plan of plans) {
|
|
1908
|
+
const pkField = plan.targetQi.meta.reverseColumnMap[plan.pkCol] ?? plan.pkCol;
|
|
1909
|
+
for (const entity of entities) {
|
|
1910
|
+
const row = entity;
|
|
1911
|
+
const raw = {};
|
|
1912
|
+
for (const c of plan.cols) {
|
|
1913
|
+
raw[c] = row[`${plan.keyPrefix}${c}`];
|
|
1914
|
+
delete row[`${plan.keyPrefix}${c}`];
|
|
1915
|
+
}
|
|
1916
|
+
const child = (0, powdb_js_1.rowToEntity)(raw, plan.targetQi.meta, native);
|
|
1917
|
+
if (child[pkField] == null) {
|
|
1918
|
+
row[plan.relName] = null;
|
|
1919
|
+
continue;
|
|
1920
|
+
}
|
|
1921
|
+
if (!plan.pkProjected)
|
|
1922
|
+
delete child[pkField];
|
|
1923
|
+
row[plan.relName] = child;
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
// -------------------------------------------------------------------------
|
|
1729
1928
|
// Writes (reselect — PowDB has no RETURNING)
|
|
1730
1929
|
// -------------------------------------------------------------------------
|
|
1731
1930
|
/** Split `data` into scalar assignments; reject relation (nested-write) keys. */
|
package/dist/powdb-introspect.js
CHANGED
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
import { ValidationError } from './errors.js';
|
|
57
57
|
import { applyTableFilters } from './introspect.js';
|
|
58
58
|
import { quotePowqlIdent, requireCapability } from './powdb.js';
|
|
59
|
-
import { snakeToCamel } from './schema.js';
|
|
59
|
+
import { singularize, snakeToCamel } from './schema.js';
|
|
60
60
|
/** Coerce a wire cell to string (legacy wire cells are strings; native cells may be typed). */
|
|
61
61
|
function asString(v) {
|
|
62
62
|
return v === null || v === undefined ? '' : String(v);
|
|
@@ -125,7 +125,16 @@ export async function introspectPowdbDatabase(exec, options = {}) {
|
|
|
125
125
|
// `describe` needs the table name in bare-identifier position → quote it so
|
|
126
126
|
// a reserved-word / non-bare table name (`order`) does not become a parse
|
|
127
127
|
// error.
|
|
128
|
-
const describeRows = (await exec(`describe ${quotePowqlIdent(tableName)}`)).rows
|
|
128
|
+
const describeRows = (await exec(`describe ${quotePowqlIdent(tableName)}`)).rows
|
|
129
|
+
// PowDB >= 0.19.1 appends entity-LINK rows after the 4 column rows in
|
|
130
|
+
// `describe <T>` (a `type` cell of literally `"link"`, an Empty nullable
|
|
131
|
+
// slot, and an arrow description in the index slot). They are NOT columns,
|
|
132
|
+
// so drop them before column parsing, unconditionally (no capability check):
|
|
133
|
+
// the rows simply never appear on a pre-0.19.1 engine, and a linked database
|
|
134
|
+
// introspected without this filter would produce garbage `link`-typed
|
|
135
|
+
// columns. Declared links are read separately via `schema links` below.
|
|
136
|
+
.filter((r) => asString(r.type) !== 'link')
|
|
137
|
+
.map((r) => ({
|
|
129
138
|
column: asString(r.column),
|
|
130
139
|
type: asString(r.type),
|
|
131
140
|
nullable: asBool(r.nullable),
|
|
@@ -205,10 +214,120 @@ export async function introspectPowdbDatabase(exec, options = {}) {
|
|
|
205
214
|
allColumns,
|
|
206
215
|
primaryKey,
|
|
207
216
|
uniqueColumns,
|
|
208
|
-
//
|
|
217
|
+
// Populated below from `schema links` when the engine supports link
|
|
218
|
+
// introspection (>= 0.19.1); stays `{}` otherwise (PowDB has no declared
|
|
219
|
+
// foreign keys, so pre-link introspection reports no relations).
|
|
209
220
|
relations: {},
|
|
210
221
|
indexes,
|
|
211
222
|
};
|
|
212
223
|
}
|
|
224
|
+
// Link introspection (>= 0.19.1): populate relations from declared entity links.
|
|
225
|
+
// Behaves exactly as before (relations stay `{}`) when the capability is absent
|
|
226
|
+
// or not supplied.
|
|
227
|
+
if (options.capabilities?.linkIntrospection) {
|
|
228
|
+
await introspectPowdbLinks(exec, tables);
|
|
229
|
+
}
|
|
213
230
|
return { tables, enums: {} };
|
|
214
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Read `schema links` and populate {@link TableMetadata.relations}: the first
|
|
234
|
+
* time PowDB introspection can report relations. Each declared link becomes a
|
|
235
|
+
* relation on its OWNER, and its natural REVERSE is synthesized on the target
|
|
236
|
+
* (mirroring the SQL introspector, which always emits both sides of a FK):
|
|
237
|
+
*
|
|
238
|
+
* - `"to-one"` link `Order.user -> User`: owner gets a `belongsTo` (localKey =
|
|
239
|
+
* the FK on the owner, targetKey = the referenced key on target); the target
|
|
240
|
+
* gets a reverse `hasMany` named for the pluralized owner table.
|
|
241
|
+
* - `"to-many"` link `User.orders -> Order`: owner gets a `hasMany` (localKey =
|
|
242
|
+
* the referenced key on the owner, targetKey = the FK on the child); the
|
|
243
|
+
* target gets a reverse `belongsTo` named for the singularized owner table.
|
|
244
|
+
*
|
|
245
|
+
* Reverse synthesis is best-effort and collision-guarded: a synthesized name that
|
|
246
|
+
* would shadow a column field or an already-present relation on the target is
|
|
247
|
+
* skipped (never a hard error), so introspection can only ADD relations, never
|
|
248
|
+
* clobber. m2m junctions cannot be inferred from links and stay undetected;
|
|
249
|
+
* `defineSchema` remains the relation-complete path. Column names arrive as
|
|
250
|
+
* PowDB's emitted (snake) names and stay snake in `foreignKey`/`referenceKey`
|
|
251
|
+
* (the SQL introspector's convention); the relation NAME is camelCased to match
|
|
252
|
+
* the query field surface. Naming edge: `schema links` is the contextual listing
|
|
253
|
+
* keyword: a table literally named `links` is still read via `describe links`.
|
|
254
|
+
*/
|
|
255
|
+
async function introspectPowdbLinks(exec, tables) {
|
|
256
|
+
const linkRows = (await exec('schema links')).rows.map((r) => ({
|
|
257
|
+
owner: asString(r.owner),
|
|
258
|
+
name: asString(r.name),
|
|
259
|
+
target: asString(r.target),
|
|
260
|
+
localKey: asString(r.local_key),
|
|
261
|
+
targetKey: asString(r.target_key),
|
|
262
|
+
cardinality: asString(r.cardinality),
|
|
263
|
+
}));
|
|
264
|
+
for (const link of linkRows) {
|
|
265
|
+
const owner = tables[link.owner];
|
|
266
|
+
const target = tables[link.target];
|
|
267
|
+
// A link referencing a filtered-out (include/exclude) table is skipped: we
|
|
268
|
+
// cannot describe the target, so the relation would be unusable.
|
|
269
|
+
if (!owner || !target)
|
|
270
|
+
continue;
|
|
271
|
+
const toOne = link.cardinality === 'to-one';
|
|
272
|
+
const relName = snakeToCamel(link.name);
|
|
273
|
+
// Forward relation on the owner (the declared direction).
|
|
274
|
+
if (toOne) {
|
|
275
|
+
addRelation(owner, {
|
|
276
|
+
type: 'belongsTo',
|
|
277
|
+
name: relName,
|
|
278
|
+
from: link.owner,
|
|
279
|
+
to: link.target,
|
|
280
|
+
foreignKey: link.localKey,
|
|
281
|
+
referenceKey: link.targetKey,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
addRelation(owner, {
|
|
286
|
+
type: 'hasMany',
|
|
287
|
+
name: relName,
|
|
288
|
+
from: link.owner,
|
|
289
|
+
to: link.target,
|
|
290
|
+
foreignKey: link.targetKey, // FK on the child (target) side
|
|
291
|
+
referenceKey: link.localKey, // referenced key on the owner (parent) side
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
// Reverse relation on the target (synthesized, collision-guarded).
|
|
295
|
+
const reverseName = toOne ? pluralize(snakeToCamel(link.owner)) : singularize(snakeToCamel(link.owner));
|
|
296
|
+
if (!relationNameTaken(target, reverseName)) {
|
|
297
|
+
addRelation(target, {
|
|
298
|
+
type: toOne ? 'hasMany' : 'belongsTo',
|
|
299
|
+
name: reverseName,
|
|
300
|
+
from: link.target,
|
|
301
|
+
to: link.owner,
|
|
302
|
+
// Same two columns, roles swapped for the opposite direction.
|
|
303
|
+
foreignKey: toOne ? link.localKey : link.targetKey,
|
|
304
|
+
referenceKey: toOne ? link.targetKey : link.localKey,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/** True when `name` already names a relation OR a column field/name on `meta`. */
|
|
310
|
+
function relationNameTaken(meta, name) {
|
|
311
|
+
if (meta.relations[name])
|
|
312
|
+
return true;
|
|
313
|
+
return meta.columns.some((c) => c.field === name || c.name === name);
|
|
314
|
+
}
|
|
315
|
+
/** Add a relation to a table's relations map (keyed by its camelCase name). */
|
|
316
|
+
function addRelation(meta, rel) {
|
|
317
|
+
if (meta.relations[rel.name])
|
|
318
|
+
return; // never clobber an existing relation
|
|
319
|
+
meta.relations[rel.name] = rel;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Minimal English pluralizer for reverse-relation names (`user` → `users`,
|
|
323
|
+
* `company` → `companies`, `box` → `boxes`). Only ever produces a candidate name
|
|
324
|
+
* that {@link relationNameTaken} then vets, so an imperfect plural can at worst be
|
|
325
|
+
* skipped, never break a query.
|
|
326
|
+
*/
|
|
327
|
+
function pluralize(word) {
|
|
328
|
+
if (/[^aeiou]y$/i.test(word))
|
|
329
|
+
return `${word.slice(0, -1)}ies`;
|
|
330
|
+
if (/(s|x|z|ch|sh)$/i.test(word))
|
|
331
|
+
return `${word}es`;
|
|
332
|
+
return `${word}s`;
|
|
333
|
+
}
|
package/dist/powdb.d.ts
CHANGED
|
@@ -54,7 +54,8 @@
|
|
|
54
54
|
*/
|
|
55
55
|
import { type PgCompatPool, type PgCompatPoolClient, TurbineClient, type TurbineConfig } from './client.js';
|
|
56
56
|
import { type Dialect } from './dialect.js';
|
|
57
|
-
import type {
|
|
57
|
+
import type { PowdbExec } from './powdb-introspect.js';
|
|
58
|
+
import { type ColumnMetadata, type SchemaMetadata, type TableMetadata } from './schema.js';
|
|
58
59
|
/**
|
|
59
60
|
* Capability descriptor for PowDB. PowQL generation is owned by
|
|
60
61
|
* {@link PowqlInterface} (not the SQL `Dialect`), so this dialect exists only to
|
|
@@ -269,11 +270,29 @@ export interface PowdbCapabilities {
|
|
|
269
270
|
* the on-disk catalog to v7, so this stays FALSE in ALL_POWDB_CAPABILITIES.
|
|
270
271
|
*/
|
|
271
272
|
entityLinks: boolean;
|
|
273
|
+
/**
|
|
274
|
+
* ≥ 0.19.1: link INTROSPECTION, the `schema links` listing statement and the
|
|
275
|
+
* appended link rows in `describe <T>`. Only meaningful when probed (there is
|
|
276
|
+
* no query-generation flip behind it), so it stays FALSE in
|
|
277
|
+
* ALL_POWDB_CAPABILITIES like the other probe-only gates. Floored at the PATCH
|
|
278
|
+
* 0.19.1: the listing statement shipped there, not in 0.19.0.
|
|
279
|
+
*/
|
|
280
|
+
linkIntrospection: boolean;
|
|
281
|
+
/**
|
|
282
|
+
* ≥ 0.19.1: scalar to-one link PATHS in query generation. Floored at the PATCH
|
|
283
|
+
* 0.19.1 (never 0.19.0) because 0.19.0 had silent-wrong-results link bugs
|
|
284
|
+
* (bare-dotted-path split, wrong aggregates over links) that make traversal
|
|
285
|
+
* unsafe; 0.19.1 turned those into hard errors. This flag flips real query
|
|
286
|
+
* generation (a to-one `with` whose child carries bigint/bytes compiles to
|
|
287
|
+
* link-path projections instead of a loader), so it stays FALSE in
|
|
288
|
+
* ALL_POWDB_CAPABILITIES: it must only light up behind a real version probe.
|
|
289
|
+
*/
|
|
290
|
+
linkPaths: boolean;
|
|
272
291
|
/** Networked only: server ≥ 0.13 AND the client exposes `queryNativeRaw`. */
|
|
273
292
|
nativeRaw: boolean;
|
|
274
293
|
}
|
|
275
294
|
/** The feature-gate capability keys (everything except the version/nativeRaw metadata). */
|
|
276
|
-
type PowdbFeatureKey = 'jsonDocs' | 'docFieldIndexes' | 'introspection' | 'serverJoins' | 'nestedProjections' | 'entityLinks';
|
|
295
|
+
type PowdbFeatureKey = 'jsonDocs' | 'docFieldIndexes' | 'introspection' | 'serverJoins' | 'nestedProjections' | 'entityLinks' | 'linkIntrospection' | 'linkPaths';
|
|
277
296
|
/**
|
|
278
297
|
* Trusted-caller default: every FEATURE gate on, engine version unknown. Used
|
|
279
298
|
* for a directly-constructed {@link PowdbPool} / {@link PowdbEmbeddedPool} that
|
|
@@ -286,6 +305,11 @@ type PowdbFeatureKey = 'jsonDocs' | 'docFieldIndexes' | 'introspection' | 'serve
|
|
|
286
305
|
* `entityLinks` stays OFF for a stronger reason still: declaring a link
|
|
287
306
|
* one-way-upgrades the on-disk catalog to v7 and locks out pre-0.19 binaries,
|
|
288
307
|
* so it must only ever light up behind a real version probe.
|
|
308
|
+
* `linkIntrospection` / `linkPaths` stay OFF for the same probe-only discipline:
|
|
309
|
+
* `linkPaths` flips real query generation (a to-one `with` compiling to link
|
|
310
|
+
* projections), and `linkIntrospection` is only meaningful once genuinely
|
|
311
|
+
* probed, so both must come from a real version resolution, never a bare
|
|
312
|
+
* construction.
|
|
289
313
|
*/
|
|
290
314
|
export declare const ALL_POWDB_CAPABILITIES: PowdbCapabilities;
|
|
291
315
|
/**
|
|
@@ -368,8 +392,77 @@ export declare function quotePowqlIdent(name: string): string;
|
|
|
368
392
|
*/
|
|
369
393
|
export interface PowqlSchemaDDLOptions {
|
|
370
394
|
capabilities?: PowdbCapabilities;
|
|
395
|
+
/**
|
|
396
|
+
* Emit `link Owner.name -> Target on local = target` declarations (PowDB entity
|
|
397
|
+
* links, >= 0.19) for every single-column hasMany / hasOne / belongsTo relation
|
|
398
|
+
* in the metadata, appended after the `type` / index statements. Default
|
|
399
|
+
* `false`: links are opt-in. Composite-key relations and m2m junctions are
|
|
400
|
+
* skipped (a link is single-column only); a relation whose name collides with a
|
|
401
|
+
* column on the owner is skipped with a one-time warning (the engine hard-errors
|
|
402
|
+
* on such a collision). When `capabilities` is also supplied it must pass the
|
|
403
|
+
* `entityLinks` gate, else this throws a typed E017.
|
|
404
|
+
*
|
|
405
|
+
* ONE-WAY DOOR: the FIRST `link` a database ever executes permanently upgrades
|
|
406
|
+
* its on-disk catalog to format v7; pre-0.19 PowDB binaries / addons can then no
|
|
407
|
+
* longer open that data directory. A database that never declares a link stays
|
|
408
|
+
* on its current catalog format. The DDL is create-only (no `if not exists`, no
|
|
409
|
+
* drop spelling), so an apply layer must existence-check first (see
|
|
410
|
+
* {@link applyPowdbLinks}).
|
|
411
|
+
*/
|
|
412
|
+
emitLinks?: boolean;
|
|
371
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* One entity link Turbine wants declared for a relation: `link owner.name ->
|
|
416
|
+
* target on localKey = targetKey`. Cardinality is NOT part of the DDL (PowDB
|
|
417
|
+
* derives to-one vs to-many from whether `targetKey` is unique on `target` at
|
|
418
|
+
* declare time), so a belongsTo and its reverse hasMany both round-trip to the
|
|
419
|
+
* same shape from opposite owners.
|
|
420
|
+
*/
|
|
421
|
+
export interface PowdbDesiredLink {
|
|
422
|
+
owner: string;
|
|
423
|
+
name: string;
|
|
424
|
+
target: string;
|
|
425
|
+
localKey: string;
|
|
426
|
+
targetKey: string;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Derive the entity links Turbine would declare from a schema's relations. One
|
|
430
|
+
* link per single-column hasMany / hasOne / belongsTo relation, owned by the
|
|
431
|
+
* relation's `from` table:
|
|
432
|
+
* - belongsTo Order->User: `Order.user -> User on user_id = id`
|
|
433
|
+
* (localKey = the FK on the owner, targetKey = the referenced key on target).
|
|
434
|
+
* - hasMany User->Order: `User.orders -> Order on id = user_id`
|
|
435
|
+
* (localKey = the referenced key on the owner, targetKey = the FK on the child).
|
|
436
|
+
* Composite-key relations and m2m junctions are skipped (links are single-column;
|
|
437
|
+
* a junction cannot be inferred from links). A relation whose name collides with
|
|
438
|
+
* a column on the owner is skipped and reported through `onCollision` (the caller
|
|
439
|
+
* decides whether to warn). Pure: no capability gate, no side effects.
|
|
440
|
+
*/
|
|
441
|
+
export declare function deriveDesiredLinks(schema: SchemaMetadata, onCollision?: (owner: string, name: string) => void): PowdbDesiredLink[];
|
|
442
|
+
/** Render one {@link PowdbDesiredLink} as its create-only `link ...` DDL statement. */
|
|
443
|
+
export declare function powdbLinkStatement(link: PowdbDesiredLink): string;
|
|
372
444
|
export declare function powqlSchemaDDL(schema: SchemaMetadata, opts?: PowqlSchemaDDLOptions): string[];
|
|
445
|
+
/**
|
|
446
|
+
* Existence-checked apply of entity-link DDL against a LIVE PowDB database.
|
|
447
|
+
* Because link DDL is create-only (no `if not exists`, redeclaring is an error,
|
|
448
|
+
* and there is no drop spelling), an apply layer must diff against the live
|
|
449
|
+
* catalog first: this reads the `schema links` listing, then executes only the
|
|
450
|
+
* links that are genuinely missing.
|
|
451
|
+
*
|
|
452
|
+
* - a desired link already declared with the SAME endpoints → skipped (idempotent);
|
|
453
|
+
* - a link declared with the same owner + name but DIFFERENT endpoints → skipped
|
|
454
|
+
* with a one-time warning (never dropped/replaced: there is no drop DDL, and a
|
|
455
|
+
* silent replace would be a destructive schema change);
|
|
456
|
+
* - a name/column collision on the owner → skipped (deriveDesiredLinks warns).
|
|
457
|
+
*
|
|
458
|
+
* Requires the `entityLinks` + `linkIntrospection` capabilities when `capabilities`
|
|
459
|
+
* is supplied (the listing statement is 0.19.1+). Returns the statements executed.
|
|
460
|
+
* `exec` runs one PowQL statement (embedded `db.raw` / a networked query shim); it
|
|
461
|
+
* must return rows keyed by column name for the `schema links` read.
|
|
462
|
+
*/
|
|
463
|
+
export declare function applyPowdbLinks(exec: PowdbExec, schema: SchemaMetadata, options?: {
|
|
464
|
+
capabilities?: PowdbCapabilities;
|
|
465
|
+
}): Promise<string[]>;
|
|
373
466
|
/**
|
|
374
467
|
* Coerce a single PowDB wire string into the JS value its column type implies.
|
|
375
468
|
* Every PowDB value arrives as a string; NULL arrives as the bareword `"null"`.
|
|
@@ -626,7 +719,11 @@ export declare function encodePowqlLiteral(value: unknown): string;
|
|
|
626
719
|
* between v0.18.0 and v0.19.0 (empty git diff), and `link` was already a lexer
|
|
627
720
|
* keyword at 0.18.0. The 0.19 entity-links surface adds new STATEMENTS built from
|
|
628
721
|
* pre-existing tokens, so the tokenization / escape surface this ceiling guards is
|
|
629
|
-
* unmoved.
|
|
722
|
+
* unmoved. The 0.19.1 link-introspection / link-path round is likewise lexer-neutral:
|
|
723
|
+
* `git diff v0.19.0 v0.19.1 -- crates/query/src/lexer.rs` is empty (the bare-dotted-path
|
|
724
|
+
* hard error is parser-level, not tokenization), so this ceiling stays `'0.19'`. The
|
|
725
|
+
* guard in {@link PowdbEmbeddedPool.exec} compares major.minor only, so `'0.19'`
|
|
726
|
+
* already covers every 0.19.x patch — no bump is needed for 0.19.1.
|
|
630
727
|
*/
|
|
631
728
|
export declare const POWQL_LEXER_TESTED_CEILING = "0.19";
|
|
632
729
|
/**
|