@minnowdb/core 0.6.4 → 0.6.5
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/engine/database.js +20 -4
- package/dist/engine/optimizer.js +2 -1
- package/dist/engine/query.js +95 -33
- package/dist/engine/sql-domains.d.ts +9 -0
- package/dist/engine/sql-domains.js +23 -0
- package/dist/engine/sql-json.d.ts +12 -0
- package/dist/engine/sql-json.js +40 -0
- package/dist/plan/model.d.ts +4 -0
- package/package.json +1 -1
- package/postgres-feature-profile.json +11 -1
- package/sql-feature-matrix.json +24 -0
package/dist/engine/database.js
CHANGED
|
@@ -8847,28 +8847,44 @@ export class MinnowDatabase {
|
|
|
8847
8847
|
const inputs = await this.#prepareBlockInputs(block, snapshot, visibility, memory, realTables, typedSchemas, extraInputs, cacheResults, allowSpill, forceSpill, spillPageRows, signal);
|
|
8848
8848
|
throwIfAborted(signal);
|
|
8849
8849
|
const prepared = createPreparedColumnarQuery(block, inputs, memory.createChild(), ftsStats === undefined ? {} : { ftsStats });
|
|
8850
|
+
// The columnar preparation only knows vector kinds, so a plain domain column projects with a
|
|
8851
|
+
// null domain. Catalog-backed inference fills those in — at miss time, because executing the
|
|
8852
|
+
// block registered its nested synthetic sources in typedSchemas — so a substituted scalar or
|
|
8853
|
+
// IN subquery literal carries its domain to the outer result (T694).
|
|
8854
|
+
const withCatalogColumnDomains = (result) => {
|
|
8855
|
+
if (!result.columnDomains.includes(null))
|
|
8856
|
+
return result;
|
|
8857
|
+
try {
|
|
8858
|
+
const inferred = inferResultColumnDomains(block, typedSchemas);
|
|
8859
|
+
result.columnDomains = result.columnDomains.map((domain, index) => domain ?? inferred[index] ?? null);
|
|
8860
|
+
}
|
|
8861
|
+
catch {
|
|
8862
|
+
// A shape this schema registry cannot type keeps its expression-level domains.
|
|
8863
|
+
}
|
|
8864
|
+
return result;
|
|
8865
|
+
};
|
|
8850
8866
|
try {
|
|
8851
8867
|
if (!allowSpill || memory.usage.budgetBytes === Number.MAX_SAFE_INTEGER) {
|
|
8852
8868
|
const result = prepared.execute();
|
|
8853
8869
|
throwIfAborted(signal);
|
|
8854
|
-
return result;
|
|
8870
|
+
return withCatalogColumnDomains(result);
|
|
8855
8871
|
}
|
|
8856
8872
|
if (!forceSpill) {
|
|
8857
8873
|
try {
|
|
8858
8874
|
const result = prepared.execute();
|
|
8859
8875
|
throwIfAborted(signal);
|
|
8860
|
-
return result;
|
|
8876
|
+
return withCatalogColumnDomains(result);
|
|
8861
8877
|
}
|
|
8862
8878
|
catch (error) {
|
|
8863
8879
|
if (!(error instanceof QueryMemoryBudgetError))
|
|
8864
8880
|
throw error;
|
|
8865
8881
|
}
|
|
8866
8882
|
}
|
|
8867
|
-
return await prepared.executeAsync({
|
|
8883
|
+
return withCatalogColumnDomains(await prepared.executeAsync({
|
|
8868
8884
|
spillStore: this.#leasedSpillStore(),
|
|
8869
8885
|
...(spillPageRows === undefined ? {} : { spillPageRows }),
|
|
8870
8886
|
...(signal === undefined ? {} : { signal }),
|
|
8871
|
-
});
|
|
8887
|
+
}));
|
|
8872
8888
|
}
|
|
8873
8889
|
finally {
|
|
8874
8890
|
prepared.close();
|
package/dist/engine/optimizer.js
CHANGED
|
@@ -2181,7 +2181,8 @@ function foldExpression(expression) {
|
|
|
2181
2181
|
? { kind: targetWord }
|
|
2182
2182
|
: expression.name === "JSON_QUERY" ||
|
|
2183
2183
|
expression.name === "JSON_OBJECT" ||
|
|
2184
|
-
expression.name === "JSON_ARRAY"
|
|
2184
|
+
expression.name === "JSON_ARRAY" ||
|
|
2185
|
+
expression.name === "MINNOW_JSON_GET"
|
|
2185
2186
|
? { kind: "json" }
|
|
2186
2187
|
: undefined;
|
|
2187
2188
|
return {
|
package/dist/engine/query.js
CHANGED
|
@@ -6,10 +6,10 @@ import { cachedQueryTerms, ftsBm25Row, FtsStatsAccumulator, ftsMatchTruth, rende
|
|
|
6
6
|
import { QueryMemoryContext } from "./memory.js";
|
|
7
7
|
import { buildSortKeyColumn, sortKeyIndexes } from "./sort-keys.js";
|
|
8
8
|
import { stringArgument } from "./sql-semantics.js";
|
|
9
|
-
import { jsonAtPath, jsonConstructor, jsonIsValid, parseJsonPath } from "./sql-json.js";
|
|
9
|
+
import { jsonArrowStep, jsonAtPath, jsonConstructor, jsonIsValid, parseJsonPath, } from "./sql-json.js";
|
|
10
10
|
import { optimizePlan } from "./optimizer.js";
|
|
11
11
|
import { compareSqlValues as compareValues, compileLikePattern, compileSimilarPattern, encodeSqlEqualityValue, roundSqlNumber, } from "./sql-semantics.js";
|
|
12
|
-
import { arrayDomainValue, boundedJsonText, collatedDomainValue, dateDomainValue, exactNumericBinary, exactNumericValue, externalSqlDomainValue, intervalDomainValue, isDateDomainValue, isExactNumeric, isSqlDomainValue, jsonDomainValue, normalizeSqlDomainValue, preservedJsonDomainValue, protectedSqlTextValue, timeDomainValue, uuidDomainValue, } from "./sql-domains.js";
|
|
12
|
+
import { arrayDomainValue, boundedJsonText, collatedDomainValue, dateDomainValue, exactNumericBinary, exactNumericValue, externalSqlDomainColumnValue, externalSqlDomainValue, intervalDomainValue, isDateDomainValue, isExactNumeric, isSqlDomainValue, jsonDomainValue, normalizeSqlDomainValue, preservedJsonDomainValue, protectedSqlTextValue, timeDomainValue, uuidDomainValue, } from "./sql-domains.js";
|
|
13
13
|
import { columnarTableFromRows, prepareVectorQuery, } from "./vector.js";
|
|
14
14
|
/** Domain metadata for an execution path that has no catalog-backed type information. */
|
|
15
15
|
export function unknownColumnDomains(columns) {
|
|
@@ -62,6 +62,8 @@ export const scalarFunctionNames = new Set([
|
|
|
62
62
|
"JSON_ARRAY",
|
|
63
63
|
"IS_JSON",
|
|
64
64
|
"ARRAY",
|
|
65
|
+
"MINNOW_JSON_GET",
|
|
66
|
+
"MINNOW_JSON_GET_TEXT",
|
|
65
67
|
"MINNOW_TUPLE_KEY",
|
|
66
68
|
"MINNOW_COLLATE",
|
|
67
69
|
"NEXTVAL",
|
|
@@ -167,16 +169,20 @@ function castValue(value, target) {
|
|
|
167
169
|
return protectedSqlTextValue(dateIsoString(value));
|
|
168
170
|
}
|
|
169
171
|
if (target === "number" || target === "number-integer") {
|
|
172
|
+
// Externalize first, exactly as the string and datetime targets do: a NUMERIC (or other
|
|
173
|
+
// domain) value is an internally tagged string, and CAST(numeric_column AS DOUBLE
|
|
174
|
+
// PRECISION) must read its decimal text, not fail on the tag (T703).
|
|
175
|
+
const external = externalSqlDomainValue(value);
|
|
170
176
|
let parsed;
|
|
171
|
-
if (typeof
|
|
172
|
-
parsed =
|
|
173
|
-
else if (typeof
|
|
174
|
-
parsed =
|
|
175
|
-
else if (typeof
|
|
176
|
-
const text =
|
|
177
|
+
if (typeof external === "number")
|
|
178
|
+
parsed = external;
|
|
179
|
+
else if (typeof external === "boolean")
|
|
180
|
+
parsed = external ? 1 : 0;
|
|
181
|
+
else if (typeof external === "string") {
|
|
182
|
+
const text = external.trim();
|
|
177
183
|
const candidate = text === "" ? Number.NaN : Number(text);
|
|
178
184
|
if (!Number.isFinite(candidate)) {
|
|
179
|
-
throw new TypeError(`Cannot cast this string to a number: ${
|
|
185
|
+
throw new TypeError(`Cannot cast this string to a number: ${text}`);
|
|
180
186
|
}
|
|
181
187
|
parsed = candidate;
|
|
182
188
|
}
|
|
@@ -201,12 +207,13 @@ function castValue(value, target) {
|
|
|
201
207
|
throw new TypeError(`Only 0 and 1 cast to boolean, got ${String(value)}`);
|
|
202
208
|
}
|
|
203
209
|
if (typeof value === "string") {
|
|
204
|
-
const
|
|
210
|
+
const external = externalSqlDomainValue(value);
|
|
211
|
+
const text = typeof external === "string" ? external.trim().toLowerCase() : "";
|
|
205
212
|
if (text === "true" || text === "t" || text === "1")
|
|
206
213
|
return true;
|
|
207
214
|
if (text === "false" || text === "f" || text === "0")
|
|
208
215
|
return false;
|
|
209
|
-
throw new TypeError(`Cannot cast this string to a boolean: ${value}`);
|
|
216
|
+
throw new TypeError(`Cannot cast this string to a boolean: ${typeof external === "string" ? external : value}`);
|
|
210
217
|
}
|
|
211
218
|
}
|
|
212
219
|
if (target === "datetime") {
|
|
@@ -396,6 +403,27 @@ export function scalarFunctionValue(name, values) {
|
|
|
396
403
|
// JSON_QUERY returns JSON text, so a selected string keeps its quotes.
|
|
397
404
|
return preservedJsonDomainValue(JSON.stringify(found.value));
|
|
398
405
|
}
|
|
406
|
+
case "MINNOW_JSON_GET": {
|
|
407
|
+
if (values[1] === null || values[1] === undefined)
|
|
408
|
+
return null;
|
|
409
|
+
const found = jsonArrowStep(first, values[1], "->");
|
|
410
|
+
if (!found.found)
|
|
411
|
+
return null;
|
|
412
|
+
// -> returns a JSON value: a selected string keeps its quotes, a JSON null is the
|
|
413
|
+
// one-character document "null" rather than SQL NULL, exactly as PostgreSQL has it.
|
|
414
|
+
return preservedJsonDomainValue(JSON.stringify(found.value));
|
|
415
|
+
}
|
|
416
|
+
case "MINNOW_JSON_GET_TEXT": {
|
|
417
|
+
if (values[1] === null || values[1] === undefined)
|
|
418
|
+
return null;
|
|
419
|
+
const found = jsonArrowStep(first, values[1], "->>");
|
|
420
|
+
// ->> returns text: strings unquoted, other scalars as their JSON rendering, objects
|
|
421
|
+
// and arrays serialized, and a JSON null as SQL NULL.
|
|
422
|
+
if (!found.found || found.value === null || found.value === undefined)
|
|
423
|
+
return null;
|
|
424
|
+
const value = found.value;
|
|
425
|
+
return protectedSqlTextValue(typeof value === "string" ? value : JSON.stringify(value));
|
|
426
|
+
}
|
|
399
427
|
case "LPAD":
|
|
400
428
|
case "RPAD": {
|
|
401
429
|
if (values[1] === null || values[1] === undefined)
|
|
@@ -1314,6 +1342,22 @@ export function evaluateJoinedRowExpression(expression, rows) {
|
|
|
1314
1342
|
export function evaluateRowExpression(expression, alias, row) {
|
|
1315
1343
|
return asQueryValue(evaluate(expression, { [alias]: row }));
|
|
1316
1344
|
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Wraps one value from an executed subquery block as a substituted literal. Block results are
|
|
1347
|
+
* internal: domain values keep their tags and protected text keeps its wrapper. The literal must
|
|
1348
|
+
* say so — an unmarked string literal is re-protected as user text at evaluation, which both
|
|
1349
|
+
* leaks the internal tag into the outer result and makes equality against internal column
|
|
1350
|
+
* values never match (T694: `WHERE amount = (SELECT amount ...)` on a NUMERIC column returned
|
|
1351
|
+
* no rows). Carrying the block's column domain also keeps the outer result's domain metadata.
|
|
1352
|
+
*/
|
|
1353
|
+
function substitutedResultLiteral(value, sqlDomain) {
|
|
1354
|
+
return {
|
|
1355
|
+
kind: "literal",
|
|
1356
|
+
value: value ?? null,
|
|
1357
|
+
...(typeof value === "string" ? { internalSqlValue: true } : {}),
|
|
1358
|
+
...(sqlDomain === null || sqlDomain === undefined ? {} : { sqlDomain }),
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1317
1361
|
/**
|
|
1318
1362
|
* Clones a plan and returns its subquery sites in post-order: executing each step's block and
|
|
1319
1363
|
* substituting its result leaves the returned plan free of subquery nodes. A scalar subquery must
|
|
@@ -1338,10 +1382,7 @@ export function subqueryResolutionSteps(plan) {
|
|
|
1338
1382
|
if (result.rows.length > 1) {
|
|
1339
1383
|
throw new TypeError(`A scalar subquery returned ${String(result.rows.length)} rows`);
|
|
1340
1384
|
}
|
|
1341
|
-
replace(
|
|
1342
|
-
kind: "literal",
|
|
1343
|
-
value: result.rows[0]?.[result.columns[0] ?? ""] ?? null,
|
|
1344
|
-
});
|
|
1385
|
+
replace(substitutedResultLiteral(result.rows[0]?.[result.columns[0] ?? ""], result.columnDomains[0]));
|
|
1345
1386
|
},
|
|
1346
1387
|
});
|
|
1347
1388
|
return;
|
|
@@ -1412,10 +1453,7 @@ export function subqueryResolutionSteps(plan) {
|
|
|
1412
1453
|
}
|
|
1413
1454
|
expression.right = {
|
|
1414
1455
|
kind: "list",
|
|
1415
|
-
items: result.rows.map((row) => (
|
|
1416
|
-
kind: "literal",
|
|
1417
|
-
value: row[result.columns[0] ?? ""] ?? null,
|
|
1418
|
-
})),
|
|
1456
|
+
items: result.rows.map((row) => substitutedResultLiteral(row[result.columns[0] ?? ""], result.columnDomains[0])),
|
|
1419
1457
|
};
|
|
1420
1458
|
},
|
|
1421
1459
|
});
|
|
@@ -1458,10 +1496,7 @@ export function subqueryResolutionSteps(plan) {
|
|
|
1458
1496
|
}
|
|
1459
1497
|
predicate.right = {
|
|
1460
1498
|
kind: "list",
|
|
1461
|
-
items: result.rows.map((row) => (
|
|
1462
|
-
kind: "literal",
|
|
1463
|
-
value: row[result.columns[0] ?? ""] ?? null,
|
|
1464
|
-
})),
|
|
1499
|
+
items: result.rows.map((row) => substitutedResultLiteral(row[result.columns[0] ?? ""], result.columnDomains[0])),
|
|
1465
1500
|
};
|
|
1466
1501
|
},
|
|
1467
1502
|
});
|
|
@@ -1886,7 +1921,8 @@ export function inferBlockSchema(plan, schemas) {
|
|
|
1886
1921
|
if (expression.name === "JSON_ARRAYAGG" ||
|
|
1887
1922
|
expression.name === "JSON_QUERY" ||
|
|
1888
1923
|
expression.name === "JSON_OBJECT" ||
|
|
1889
|
-
expression.name === "JSON_ARRAY"
|
|
1924
|
+
expression.name === "JSON_ARRAY" ||
|
|
1925
|
+
expression.name === "MINNOW_JSON_GET") {
|
|
1890
1926
|
return { kind: "json" };
|
|
1891
1927
|
}
|
|
1892
1928
|
if (expression.name === "GEN_RANDOM_UUID")
|
|
@@ -2025,6 +2061,8 @@ export function inferBlockSchema(plan, schemas) {
|
|
|
2025
2061
|
expression.name === "JSON_OBJECT" ||
|
|
2026
2062
|
expression.name === "JSON_ARRAY" ||
|
|
2027
2063
|
expression.name === "ARRAY" ||
|
|
2064
|
+
expression.name === "MINNOW_JSON_GET" ||
|
|
2065
|
+
expression.name === "MINNOW_JSON_GET_TEXT" ||
|
|
2028
2066
|
expression.name === "MINNOW_TUPLE_KEY" ||
|
|
2029
2067
|
expression.name === "MINNOW_COLLATE" ||
|
|
2030
2068
|
expression.name === "GEN_RANDOM_UUID") {
|
|
@@ -4478,9 +4516,6 @@ function asQueryValue(value) {
|
|
|
4478
4516
|
return null;
|
|
4479
4517
|
throw new TypeError("Query produced an unsupported value");
|
|
4480
4518
|
}
|
|
4481
|
-
function asExternalQueryValue(value) {
|
|
4482
|
-
return asQueryValue(externalSqlDomainValue(value));
|
|
4483
|
-
}
|
|
4484
4519
|
const alreadyExternalResults = new WeakSet();
|
|
4485
4520
|
function markExternalizationState(result, outputNeedsExternalization) {
|
|
4486
4521
|
if (outputNeedsExternalization === false)
|
|
@@ -4505,11 +4540,12 @@ export function externalizeQueryResult(result) {
|
|
|
4505
4540
|
// Ordinary primitive results are already public values. Most queries never touch one of the
|
|
4506
4541
|
// tagged PostgreSQL domains, so keep their row objects and avoid rebuilding a large result
|
|
4507
4542
|
// set merely to discover that every value is unchanged.
|
|
4508
|
-
for (
|
|
4543
|
+
for (let position = 0; position < result.columns.length; position += 1) {
|
|
4544
|
+
const name = result.columns[position] ?? "";
|
|
4509
4545
|
const value = row[name];
|
|
4510
4546
|
if (value !== undefined && !isSqlDomainValue(value))
|
|
4511
4547
|
continue;
|
|
4512
|
-
const external =
|
|
4548
|
+
const external = asQueryValue(externalSqlDomainColumnValue(value, result.columnDomains[position]));
|
|
4513
4549
|
if (external === value)
|
|
4514
4550
|
continue;
|
|
4515
4551
|
if (output === row)
|
|
@@ -6896,17 +6932,36 @@ class Parser {
|
|
|
6896
6932
|
continue;
|
|
6897
6933
|
}
|
|
6898
6934
|
const operator = this.#peek().text;
|
|
6899
|
-
// ||
|
|
6935
|
+
// || and the JSON arrows share PostgreSQL's loosest "any other operator" level, applying
|
|
6936
|
+
// left-to-right to whole arithmetic terms: `'a' || d ->> 'k'` is `('a' || d) ->> 'k'`.
|
|
6900
6937
|
const precedence = operator === "*" || operator === "/" || operator === "%"
|
|
6901
6938
|
? 20
|
|
6902
6939
|
: operator === "+" || operator === "-"
|
|
6903
6940
|
? 10
|
|
6904
|
-
: operator === "||"
|
|
6941
|
+
: operator === "||" || operator === "->" || operator === "->>"
|
|
6905
6942
|
? 5
|
|
6906
6943
|
: -1;
|
|
6907
6944
|
if (precedence < minimumPrecedence)
|
|
6908
6945
|
break;
|
|
6909
6946
|
this.#index += 1;
|
|
6947
|
+
if (operator === "->" || operator === "->>") {
|
|
6948
|
+
const key = this.#additive(precedence + 1);
|
|
6949
|
+
// A key fixed at compile time fails here rather than per row, like SQL/JSON paths.
|
|
6950
|
+
if (key.kind === "literal" && typeof key.value === "number") {
|
|
6951
|
+
if (!Number.isInteger(key.value)) {
|
|
6952
|
+
throw new TypeError(`${operator} array positions are integers`);
|
|
6953
|
+
}
|
|
6954
|
+
}
|
|
6955
|
+
else if (key.kind === "literal" && typeof key.value === "boolean") {
|
|
6956
|
+
throw new TypeError(`${operator} keys are member names or array positions`);
|
|
6957
|
+
}
|
|
6958
|
+
left = {
|
|
6959
|
+
kind: "call",
|
|
6960
|
+
name: operator === "->" ? "MINNOW_JSON_GET" : "MINNOW_JSON_GET_TEXT",
|
|
6961
|
+
arguments: [left, key],
|
|
6962
|
+
};
|
|
6963
|
+
continue;
|
|
6964
|
+
}
|
|
6910
6965
|
// `placed_at + INTERVAL '1 month'`. An interval is not a value any column can hold, so it
|
|
6911
6966
|
// never becomes an expression of its own: it is read here, where the thing it applies to is
|
|
6912
6967
|
// already in hand, and folds into the date arithmetic DATE_ADD performs.
|
|
@@ -7380,7 +7435,9 @@ class Parser {
|
|
|
7380
7435
|
const name = (upper === "ANY_VALUE" ? "MIN" : (functionSpellings.get(upper) ?? upper));
|
|
7381
7436
|
if (name === "MINNOW_TUPLE_KEY" ||
|
|
7382
7437
|
name === "MINNOW_COLLATE" ||
|
|
7383
|
-
name === "MINNOW_SINGLE_VALUE"
|
|
7438
|
+
name === "MINNOW_SINGLE_VALUE" ||
|
|
7439
|
+
name === "MINNOW_JSON_GET" ||
|
|
7440
|
+
name === "MINNOW_JSON_GET_TEXT") {
|
|
7384
7441
|
throw new TypeError(`Unsupported function: ${identifier}`);
|
|
7385
7442
|
}
|
|
7386
7443
|
if (!aggregateNames.has(name) && !scalarFunctionNames.has(name))
|
|
@@ -9022,7 +9079,12 @@ function tokenize(sql) {
|
|
|
9022
9079
|
index += 1;
|
|
9023
9080
|
continue;
|
|
9024
9081
|
}
|
|
9025
|
-
if (
|
|
9082
|
+
if (pair === "->" && sql[index + 2] === ">") {
|
|
9083
|
+
push({ kind: "operator", text: "->>", start: index, end: index + 3 });
|
|
9084
|
+
index += 3;
|
|
9085
|
+
continue;
|
|
9086
|
+
}
|
|
9087
|
+
if ([">=", "<=", "!=", "<>", "||", "->"].includes(pair)) {
|
|
9026
9088
|
push({ kind: "operator", text: pair, start: index, end: index + 2 });
|
|
9027
9089
|
index += 2;
|
|
9028
9090
|
continue;
|
|
@@ -38,5 +38,14 @@ export declare function enumDomainCompare(left: unknown, right: unknown): number
|
|
|
38
38
|
export declare function normalizeSqlDomainValue(domain: SqlDomain, value: unknown): string | null;
|
|
39
39
|
export declare function collatedDomainValue(value: unknown, collation: unknown): string | null;
|
|
40
40
|
export declare function collatedDomainCompare(left: unknown, right: unknown): number | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Renders one result value at the JavaScript boundary using its column's logical domain.
|
|
43
|
+
* A NUMERIC column with a declared scale displays PostgreSQL-style at exactly that scale:
|
|
44
|
+
* the physical encoding is canonical (trailing fractional zeros stripped), so the declared
|
|
45
|
+
* scale is restored by padding — never rounding — and a value carrying more fractional
|
|
46
|
+
* digits than the declaration keeps every digit it has. Every other domain, and every value
|
|
47
|
+
* without one, renders exactly as externalSqlDomainValue.
|
|
48
|
+
*/
|
|
49
|
+
export declare function externalSqlDomainColumnValue(value: unknown, domain: SqlDomain | null | undefined): unknown;
|
|
41
50
|
export declare function externalSqlDomainValue(value: unknown): unknown;
|
|
42
51
|
export declare function isSqlDomainValue(value: unknown): value is string;
|
|
@@ -621,6 +621,29 @@ function collatorFor(locale, displayName) {
|
|
|
621
621
|
collators.set(locale, created);
|
|
622
622
|
return created;
|
|
623
623
|
}
|
|
624
|
+
/**
|
|
625
|
+
* Renders one result value at the JavaScript boundary using its column's logical domain.
|
|
626
|
+
* A NUMERIC column with a declared scale displays PostgreSQL-style at exactly that scale:
|
|
627
|
+
* the physical encoding is canonical (trailing fractional zeros stripped), so the declared
|
|
628
|
+
* scale is restored by padding — never rounding — and a value carrying more fractional
|
|
629
|
+
* digits than the declaration keeps every digit it has. Every other domain, and every value
|
|
630
|
+
* without one, renders exactly as externalSqlDomainValue.
|
|
631
|
+
*/
|
|
632
|
+
export function externalSqlDomainColumnValue(value, domain) {
|
|
633
|
+
if (domain?.kind === "numeric" &&
|
|
634
|
+
domain.scale !== undefined &&
|
|
635
|
+
domain.scale > 0 &&
|
|
636
|
+
typeof value === "string" &&
|
|
637
|
+
value.startsWith(NUMERIC)) {
|
|
638
|
+
const text = value.slice(NUMERIC.length);
|
|
639
|
+
const dot = text.indexOf(".");
|
|
640
|
+
const fractionDigits = dot === -1 ? 0 : text.length - dot - 1;
|
|
641
|
+
if (fractionDigits >= domain.scale)
|
|
642
|
+
return text;
|
|
643
|
+
return (dot === -1 ? `${text}.` : text) + "0".repeat(domain.scale - fractionDigits);
|
|
644
|
+
}
|
|
645
|
+
return externalSqlDomainValue(value);
|
|
646
|
+
}
|
|
624
647
|
export function externalSqlDomainValue(value) {
|
|
625
648
|
if (typeof value !== "string")
|
|
626
649
|
return value;
|
|
@@ -15,6 +15,18 @@ export declare function jsonAtPath(document: unknown, path: unknown, caller: str
|
|
|
15
15
|
found: boolean;
|
|
16
16
|
value?: unknown;
|
|
17
17
|
};
|
|
18
|
+
/**
|
|
19
|
+
* One step of PostgreSQL's `->`/`->>` access. A text key selects an object member; an integer
|
|
20
|
+
* key selects an array element, counting from the end when negative. The behaviour follows
|
|
21
|
+
* PostgreSQL's `json` type: a document of the wrong shape for the key selects nothing (NULL)
|
|
22
|
+
* rather than jsonb's scalar-as-one-element-array reading. Unlike the SQL/JSON functions, whose
|
|
23
|
+
* standard ON ERROR default swallows malformed documents, PostgreSQL's operators only exist on
|
|
24
|
+
* values already parsed as json, so a document that is not JSON is an error here.
|
|
25
|
+
*/
|
|
26
|
+
export declare function jsonArrowStep(document: unknown, key: unknown, caller: string): {
|
|
27
|
+
found: boolean;
|
|
28
|
+
value?: unknown;
|
|
29
|
+
};
|
|
18
30
|
/** Whether a value is JSON text of the requested shape (T825). */
|
|
19
31
|
export declare function jsonIsValid(document: unknown, kind: string): boolean;
|
|
20
32
|
/** A SQL value as its JSON counterpart: datetimes serialize as ISO text, like every cast. */
|
package/dist/engine/sql-json.js
CHANGED
|
@@ -75,6 +75,46 @@ export function jsonAtPath(document, path, caller) {
|
|
|
75
75
|
}
|
|
76
76
|
return { found: true, value: current };
|
|
77
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* One step of PostgreSQL's `->`/`->>` access. A text key selects an object member; an integer
|
|
80
|
+
* key selects an array element, counting from the end when negative. The behaviour follows
|
|
81
|
+
* PostgreSQL's `json` type: a document of the wrong shape for the key selects nothing (NULL)
|
|
82
|
+
* rather than jsonb's scalar-as-one-element-array reading. Unlike the SQL/JSON functions, whose
|
|
83
|
+
* standard ON ERROR default swallows malformed documents, PostgreSQL's operators only exist on
|
|
84
|
+
* values already parsed as json, so a document that is not JSON is an error here.
|
|
85
|
+
*/
|
|
86
|
+
export function jsonArrowStep(document, key, caller) {
|
|
87
|
+
const source = boundedJsonDocument(document, caller);
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(source);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new TypeError(`${caller} requires a JSON document`);
|
|
94
|
+
}
|
|
95
|
+
const step = externalSqlDomainValue(key);
|
|
96
|
+
if (typeof step === "number") {
|
|
97
|
+
if (!Number.isInteger(step)) {
|
|
98
|
+
throw new TypeError(`${caller} array positions are integers`);
|
|
99
|
+
}
|
|
100
|
+
if (!Array.isArray(parsed))
|
|
101
|
+
return { found: false };
|
|
102
|
+
const index = step < 0 ? parsed.length + step : step;
|
|
103
|
+
if (index < 0 || index >= parsed.length)
|
|
104
|
+
return { found: false };
|
|
105
|
+
return { found: true, value: parsed[index] };
|
|
106
|
+
}
|
|
107
|
+
if (typeof step === "string") {
|
|
108
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
109
|
+
return { found: false };
|
|
110
|
+
}
|
|
111
|
+
const members = parsed;
|
|
112
|
+
if (!Object.hasOwn(members, step))
|
|
113
|
+
return { found: false };
|
|
114
|
+
return { found: true, value: members[step] };
|
|
115
|
+
}
|
|
116
|
+
throw new TypeError(`${caller} keys are member names or array positions`);
|
|
117
|
+
}
|
|
78
118
|
/** Whether a value is JSON text of the requested shape (T825). */
|
|
79
119
|
export function jsonIsValid(document, kind) {
|
|
80
120
|
document = externalSqlDomainValue(document);
|
package/dist/plan/model.d.ts
CHANGED
|
@@ -18,6 +18,10 @@ export type AggregateName = "COUNT" | "SUM" | "AVG" | "MIN" | "MAX" | "JSON_ARRA
|
|
|
18
18
|
/** Optimizer-only aggregate that enforces scalar-subquery cardinality. */
|
|
19
19
|
| "MINNOW_SINGLE_VALUE";
|
|
20
20
|
export type ScalarFunctionName = "ROUND" | "COALESCE" | "DATE_TRUNC" | "DATE_ADD" | "UPPER" | "LOWER" | "LENGTH" | "ABS" | "TRIM" | "LTRIM" | "RTRIM" | "SUBSTR" | "REPLACE" | "INSTR" | "NULLIF" | "GREATEST" | "LEAST" | "FLOOR" | "CEIL" | "MOD" | "POWER" | "SQRT" | "EXTRACT" | "CAST" | "OCTET_LENGTH" | "LPAD" | "RPAD" | "OVERLAY" | "CURRENT_DATE" | "CURRENT_TIMESTAMP" | "LOCALTIME" | "GROUPING" | "JSON_VALUE" | "JSON_QUERY" | "JSON_EXISTS" | "JSON_OBJECT" | "JSON_ARRAY" | "IS_JSON" | "ARRAY"
|
|
21
|
+
/** Parser-produced `->` JSON member/element access returning a JSON value. */
|
|
22
|
+
| "MINNOW_JSON_GET"
|
|
23
|
+
/** Parser-produced `->>` JSON member/element access returning text. */
|
|
24
|
+
| "MINNOW_JSON_GET_TEXT"
|
|
21
25
|
/** Optimizer-only, prefix-free equality key for hashable multi-column decorrelation. */
|
|
22
26
|
| "MINNOW_TUPLE_KEY"
|
|
23
27
|
/** Parser-produced wrapper carrying one explicit collation through ordering/comparison. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@minnowdb/core",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"description": "A columnar SQL database for the browser: PostgreSQL-style SQL over durable IndexedDB or OPFS data, with no server or WebAssembly module.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Eric Wilhite",
|
|
@@ -137,7 +137,7 @@
|
|
|
137
137
|
{
|
|
138
138
|
"id": "type.exact-numeric",
|
|
139
139
|
"classification": "different",
|
|
140
|
-
"reason": "Minnow preserves exact decimals at the JavaScript boundary as strings; PGlite's default decoder returns this NUMERIC value as a number."
|
|
140
|
+
"reason": "Minnow preserves exact decimals at the JavaScript boundary as strings; PGlite's default decoder returns this NUMERIC value as a number. A declared scale renders at exactly that scale as PostgreSQL does; a bare NUMERIC column, a derived arithmetic result, and a value cast or concatenated to text render canonically, without the trailing fractional zeros PostgreSQL preserves. A non-terminating AVG quotient renders at Minnow's internal precision, which keeps more fractional digits than PostgreSQL's rounding; the value agrees to every digit PostgreSQL renders."
|
|
141
141
|
},
|
|
142
142
|
{
|
|
143
143
|
"id": "type.json-jsonb",
|
|
@@ -164,6 +164,16 @@
|
|
|
164
164
|
"classification": "different",
|
|
165
165
|
"reason": "The JSON value agrees, but Minnow returns compact JSON text while PostgreSQL's text rendering includes spaces."
|
|
166
166
|
},
|
|
167
|
+
{
|
|
168
|
+
"id": "json.arrow",
|
|
169
|
+
"classification": "different",
|
|
170
|
+
"reason": "The JSON value agrees, but Minnow returns compact JSON text at the JavaScript boundary while PostgreSQL clients commonly decode the json result natively. Minnow's parameters carry JavaScript types, so an integer parameter key selects an array element directly where an untyped PostgreSQL placeholder resolves to the text-key operator and needs an explicit ::int cast. Minnow's JSON values also compare as their canonical text, so -> results are valid comparison operands where PostgreSQL has no json = json operator."
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"id": "json.arrow-untyped",
|
|
174
|
+
"classification": "extension",
|
|
175
|
+
"reason": "PostgreSQL requires a json-typed document for -> and ->>; Minnow also accepts ordinary JSON text directly, as its SQL/JSON functions do."
|
|
176
|
+
},
|
|
167
177
|
{
|
|
168
178
|
"id": "json.object",
|
|
169
179
|
"classification": "different",
|
package/sql-feature-matrix.json
CHANGED
|
@@ -1077,6 +1077,30 @@
|
|
|
1077
1077
|
"example": "SELECT JSON_ARRAY(1, NULL, 2) AS document",
|
|
1078
1078
|
"notes": "Defaults to NULL ON NULL. The explicit NULL/ABSENT clause is not supported. The constructor returns JSON text; cast or store it as JSON/JSONB for domain validation and JSONB canonicalization."
|
|
1079
1079
|
},
|
|
1080
|
+
{
|
|
1081
|
+
"id": "json.arrow",
|
|
1082
|
+
"status": "supported",
|
|
1083
|
+
"example": "SELECT CAST('{\"a\": {\"b\": [5, 6]}}' AS JSON) -> 'a' -> 'b' -> 1 AS element",
|
|
1084
|
+
"notes": "PostgreSQL's -> member/element access, chainable and usable anywhere an expression is. A text key selects an object member; an integer key selects an array element. The result is a JSON value, so a selected string keeps its quotes and a selected JSON null stays the document null. Behaviour follows the json type: a document of the wrong shape for the key selects NULL, without jsonb's scalar-as-one-element-array reading. A document that is not JSON is an error, matching the operators' typed-json requirement."
|
|
1085
|
+
},
|
|
1086
|
+
{
|
|
1087
|
+
"id": "json.arrow-text",
|
|
1088
|
+
"status": "supported",
|
|
1089
|
+
"example": "SELECT CAST('{\"items\": [\"x\", \"y\"]}' AS JSON) -> 'items' ->> 0 AS first_item",
|
|
1090
|
+
"notes": "->> returns text: strings unquoted, other scalars as their JSON rendering, objects and arrays serialized as compact JSON text, and a selected JSON null as SQL NULL."
|
|
1091
|
+
},
|
|
1092
|
+
{
|
|
1093
|
+
"id": "json.arrow-index",
|
|
1094
|
+
"status": "supported",
|
|
1095
|
+
"example": "SELECT CAST('[\"x\", \"y\", \"z\"]' AS JSON) ->> -1 AS last_item",
|
|
1096
|
+
"notes": "Negative element positions count from the end of the array; positions out of range in either direction select NULL."
|
|
1097
|
+
},
|
|
1098
|
+
{
|
|
1099
|
+
"id": "json.arrow-untyped",
|
|
1100
|
+
"status": "supported",
|
|
1101
|
+
"example": "SELECT '{\"a\": {\"b\": [\"x\", \"y\"]}}' -> 'a' -> 'b' ->> 1 AS second_item",
|
|
1102
|
+
"notes": "Like Minnow's SQL/JSON functions, the arrows accept ordinary JSON text and stored JSON/JSONB values directly; PostgreSQL requires a json-typed document to resolve the operator."
|
|
1103
|
+
},
|
|
1080
1104
|
{
|
|
1081
1105
|
"id": "ddl.create-table-if-not-exists",
|
|
1082
1106
|
"status": "supported",
|