@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.
@@ -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();
@@ -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 {
@@ -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 value === "number")
172
- parsed = value;
173
- else if (typeof value === "boolean")
174
- parsed = value ? 1 : 0;
175
- else if (typeof value === "string") {
176
- const text = value.trim();
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: ${value}`);
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 text = value.trim().toLowerCase();
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 (const name of result.columns) {
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 = asExternalQueryValue(value);
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
- // || binds loosest, matching PostgreSQL: concatenation applies to whole arithmetic terms.
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 ([">=", "<=", "!=", "<>", "||"].includes(pair)) {
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. */
@@ -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);
@@ -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.4",
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",
@@ -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",