@@ -0,0 +1,39 @@
|
||||
name: Verify
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CI: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
- name: Select declared npm version
|
||||
run: npm install --global npm@11.17.0
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Audit runtime dependencies
|
||||
run: npm audit --omit=dev --audit-level=moderate
|
||||
- name: Check, test, and build
|
||||
run: npm run check
|
||||
- name: Install browser engines
|
||||
run: npx playwright install --with-deps chromium firefox webkit
|
||||
- name: Browser tests
|
||||
run: npm run test:browser
|
||||
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0 - 2026-09-02
|
||||
|
||||
- Add raw-input inference evidence, a visible bounded query AST and a local
|
||||
DuckDB-WASM adapter for broader analytical SQL without remote execution.
|
||||
|
||||
## 0.1.0 - 2026-09-01
|
||||
|
||||
- Add bounded JSON/CSV/NDJSON/XML parsing, SQL-like and path query engines.
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
|
||||
Query Tools is a GPL-3.0-or-later, local-first workbench for bounded JSON, CSV,
|
||||
NDJSON and static XML. It provides an intentionally small SQL-like row language
|
||||
and JSONPath-like value language, result table/tree/JSON views, explain plans,
|
||||
and JSONPath-like value language, parsed SQL/path syntax trees, result
|
||||
table/tree/JSON views, explain plans,
|
||||
local saved queries, and JSON/CSV/NDJSON export. No input is uploaded and no
|
||||
query is evaluated as code.
|
||||
query is evaluated as application JavaScript.
|
||||
|
||||
CSV import has explicit `none`, `safe`, and `aggressive` inference modes and
|
||||
retains every raw cell beside inferred-value evidence. JSON import records raw
|
||||
unsafe-integer and precision-risk number lexemes. A separately selected
|
||||
DuckDB-WASM mode runs bounded, read-only `SELECT` queries in a disposable local
|
||||
worker using same-origin bundled MVP WASM assets; external access and automatic
|
||||
extension loading/installing are disabled.
|
||||
|
||||
SQL subset: `SELECT`, AND-connected `WHERE` comparisons/`CONTAINS`, `GROUP BY`,
|
||||
`COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `ORDER BY`, and `LIMIT`. Path subset:
|
||||
@@ -13,6 +21,15 @@ not compatibility with full SQL, JSONPath, or JMESPath.
|
||||
|
||||
Limits: 2 MiB source, 10,000 rows/path values, 200 fields, 200,000 structured
|
||||
nodes, depth 32, and 20,000 query characters. XML DTD/entities are rejected.
|
||||
DuckDB mode accepts at most 10,000 rows, returns at most 1,000 rows, uses a 128
|
||||
MiB database memory limit, and has a 30-second startup ceiling followed by a
|
||||
ten-second query deadline. It is not a database connection and does not expose
|
||||
files, network resources, extensions, DDL, DML, `COPY`, or `PRAGMA`.
|
||||
|
||||
Static hosting must serve `.wasm` files as `application/wasm`, cache hashed
|
||||
assets immutably, permit `'wasm-unsafe-eval'` for same-origin scripts, and allow
|
||||
same-origin/blob workers while retaining `connect-src 'self'`. The production
|
||||
browser test verifies those headers against the locally bundled DuckDB asset.
|
||||
|
||||
Run `npm ci`, then `npm run check`, `npm run test:browser`, and
|
||||
`npm run package:release -- --force`. Copyright © 2026 Albrecht Degering.
|
||||
|
||||
+16
-1
@@ -1,3 +1,18 @@
|
||||
# Security
|
||||
|
||||
Report vulnerabilities privately through the Gitea repository owner. Never attach confidential datasets publicly. Inputs are untrusted, bounded, rendered inertly, and never executed; XML DTD/entities and dangerous JSON keys are rejected.
|
||||
Report vulnerabilities privately through the Gitea repository owner. Never
|
||||
attach confidential datasets publicly. Inputs are untrusted, bounded, rendered
|
||||
inertly, and never evaluated as application code; XML DTD/entities and dangerous
|
||||
JSON keys are rejected.
|
||||
|
||||
The built-in query languages use fixed parsers and interpreters without `eval`
|
||||
or dynamic function construction. DuckDB mode accepts one parsed `SELECT`
|
||||
statement, rejects semicolons and mutation, extension, attachment, file and
|
||||
configuration keywords, then executes against a bounded in-memory table in a
|
||||
disposable locally bundled WASM worker. External access and extension
|
||||
installation/loading are disabled; startup/query deadlines terminate the worker
|
||||
and stale results cannot replace newer state.
|
||||
|
||||
Deployment must serve the bundled WASM with `application/wasm` and a CSP that
|
||||
allows same-origin WASM and workers while keeping `connect-src 'self'`. Browser
|
||||
release tests verify those headers and fail on third-party requests.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Source identity
|
||||
|
||||
- Project: Query Tools
|
||||
- Version: 0.1.0
|
||||
- Version: 0.2.0
|
||||
- Repository: https://git.add-ideas.de/lotobo/query-tools
|
||||
- Licence: GPL-3.0-or-later
|
||||
- Toolbox id: `de.add-ideas.query-tools`
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# Third-party notices
|
||||
|
||||
Runtime dependencies are bundled locally: React/React DOM (MIT), @xmldom/xmldom (MIT), and add·ideas Toolbox Contract/Shell/Helpers (GPL-3.0-or-later). Exact texts are generated into `LICENSES/npm-runtime-licenses.txt`.
|
||||
Runtime dependencies are bundled locally: React/React DOM (MIT),
|
||||
@xmldom/xmldom (MIT), DuckDB-WASM and Apache Arrow JavaScript (MIT), add·ideas
|
||||
Toolbox Contract/Shell 0.3.0 (Apache-2.0), and Toolbox Helpers 0.2.0
|
||||
(GPL-3.0-or-later). No DuckDB asset is fetched at runtime. Exact texts are
|
||||
generated into `LICENSES/npm-runtime-licenses.txt`.
|
||||
|
||||
+19
-1
@@ -1,3 +1,21 @@
|
||||
# Architecture
|
||||
|
||||
`core/data.ts` performs bounded static parsing into JSON-compatible values. `core/query.ts` tokenizes a fixed grammar, interprets field paths and returns an explain plan; it never compiles or evaluates code. React retains the last valid dataset/result after errors. Relative assets and a same-origin service worker support nested offline deployment.
|
||||
`core/data.ts` performs bounded static parsing into JSON-compatible values while
|
||||
retaining raw numeric and cell-inference evidence. `core/query.ts` tokenizes the
|
||||
fixed SQL-like and path grammars, exposes their bounded syntax trees, interprets
|
||||
field paths and returns an explain plan; it never compiles or evaluates code.
|
||||
|
||||
`core/duckdb.ts` is a separately selected adapter for the locally bundled
|
||||
single-threaded DuckDB-WASM MVP build. A lexical security gate accepts exactly
|
||||
one `SELECT` statement and rejects semicolons plus mutation, attachment,
|
||||
extension, file and configuration keywords before DuckDB performs its complete
|
||||
SQL parse. The adapter serializes at most 10,000 bounded rows into an in-memory
|
||||
table, disables external access and extension auto-install/auto-load, caps
|
||||
database memory at 128 MiB, limits results to 1,000 rows and terminates the
|
||||
disposable worker after completion, cancellation or a hard deadline.
|
||||
|
||||
React retains the last valid dataset/result after errors and suppresses stale
|
||||
asynchronous results. Relative assets and a same-origin service worker support
|
||||
nested offline deployment. The static host serves hashed WASM as
|
||||
`application/wasm` with immutable caching and grants only the CSP allowances
|
||||
needed for same-origin WASM and workers.
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
# Privacy and security
|
||||
|
||||
Data stays in browser memory. Saved query text uses localStorage only. There is no telemetry, remote schema resolution, query execution endpoint, URL fetch, eval or dynamic function construction. Exports require an explicit action. Limits are documented in README.
|
||||
Data stays in browser memory. Saved query text uses localStorage only. There is
|
||||
no telemetry, remote schema resolution, query execution endpoint, URL fetch,
|
||||
`eval` or dynamic function construction. Exports require an explicit action.
|
||||
|
||||
DuckDB mode loads the bounded dataset into an in-memory table inside a
|
||||
disposable worker using same-origin bundled JavaScript and WASM. External access,
|
||||
extension installation and extension auto-loading are disabled. The app exposes
|
||||
no database connection or file/network query surface; completion, cancellation
|
||||
and hard deadlines terminate the worker. The host CSP retains
|
||||
`connect-src 'self'`, and browser tests fail on third-party requests. Input,
|
||||
result, memory and time limits are documented in README.
|
||||
|
||||
Generated
+308
-21
@@ -1,23 +1,24 @@
|
||||
{
|
||||
"name": "query-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "query-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3",
|
||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||
"@add-ideas/toolbox-contract": "0.3.0",
|
||||
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||
"@duckdb/duckdb-wasm": "^1.32.0",
|
||||
"@xmldom/xmldom": "0.9.12",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
@@ -43,24 +44,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-contract": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz",
|
||||
"integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==",
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
|
||||
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-helpers": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz",
|
||||
"integrity": "sha512-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==",
|
||||
"license": "GPL-3.0-or-later"
|
||||
"version": "0.2.0",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-shell-react": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.3/toolbox-shell-react-0.2.3.tgz",
|
||||
"integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==",
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
|
||||
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3"
|
||||
"@add-ideas/toolbox-contract": "0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18 <20",
|
||||
@@ -68,13 +70,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-testkit": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz",
|
||||
"integrity": "sha512-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==",
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
|
||||
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3"
|
||||
"@add-ideas/toolbox-contract": "0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"toolbox-check": "dist/cli.js"
|
||||
@@ -541,6 +543,15 @@
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@duckdb/duckdb-wasm": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@duckdb/duckdb-wasm/-/duckdb-wasm-1.32.0.tgz",
|
||||
"integrity": "sha512-IewXTNYEjsZCPE9weUWgtjGxUlMRo7qhX0GF6tq/KjK8bnY+RAl4cyUdYUfcdzbyb4b9ZxPC+FOsCcxgaKFWMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"apache-arrow": "^17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
|
||||
@@ -1116,6 +1127,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
@@ -1225,6 +1245,18 @@
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/command-line-args": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz",
|
||||
"integrity": "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/command-line-usage": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.4.tgz",
|
||||
"integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -1739,6 +1771,41 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/apache-arrow": {
|
||||
"version": "17.0.0",
|
||||
"resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-17.0.0.tgz",
|
||||
"integrity": "sha512-X0p7auzdnGuhYMVKYINdQssS4EcKec9TCXyez/qtJt32DrIMGbzqiaMiQ0X6fQlQpw8Fl0Qygcv4dfRAr5Gu9Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.11",
|
||||
"@types/command-line-args": "^5.2.3",
|
||||
"@types/command-line-usage": "^5.0.4",
|
||||
"@types/node": "^20.13.0",
|
||||
"command-line-args": "^5.2.1",
|
||||
"command-line-usage": "^7.0.1",
|
||||
"flatbuffers": "^24.3.25",
|
||||
"json-bignum": "^0.0.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"bin": {
|
||||
"arrow2csv": "bin/arrow2csv.cjs"
|
||||
}
|
||||
},
|
||||
"node_modules/apache-arrow/node_modules/@types/node": {
|
||||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/apache-arrow/node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
@@ -1749,6 +1816,15 @@
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/array-back": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz",
|
||||
"integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -1870,6 +1946,118 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk-template": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz",
|
||||
"integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk-template?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/command-line-args": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz",
|
||||
"integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"array-back": "^3.1.0",
|
||||
"find-replace": "^3.0.0",
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"typical": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/command-line-usage": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.4.tgz",
|
||||
"integrity": "sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"array-back": "^6.2.2",
|
||||
"chalk-template": "^0.4.0",
|
||||
"table-layout": "^4.1.1",
|
||||
"typical": "^7.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/command-line-usage/node_modules/array-back": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.3.tgz",
|
||||
"integrity": "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.17"
|
||||
}
|
||||
},
|
||||
"node_modules/command-line-usage/node_modules/typical": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz",
|
||||
"integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.17"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -2301,6 +2489,18 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/find-replace": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz",
|
||||
"integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"array-back": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
@@ -2332,6 +2532,12 @@
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/flatbuffers": {
|
||||
"version": "24.12.23",
|
||||
"resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-24.12.23.tgz",
|
||||
"integrity": "sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
|
||||
@@ -2390,6 +2596,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/hermes-estree": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||
@@ -2558,6 +2773,14 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/json-bignum": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/json-bignum/-/json-bignum-0.0.3.tgz",
|
||||
"integrity": "sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/json-buffer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
@@ -2905,6 +3128,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.camelcase": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -3432,6 +3661,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
@@ -3439,6 +3680,28 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/table-layout": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz",
|
||||
"integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"array-back": "^6.2.2",
|
||||
"wordwrapjs": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.17"
|
||||
}
|
||||
},
|
||||
"node_modules/table-layout/node_modules/array-back": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.3.tgz",
|
||||
"integrity": "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.17"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -3542,6 +3805,12 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -3593,6 +3862,15 @@
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typical": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz",
|
||||
"integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||
@@ -3925,6 +4203,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wordwrapjs": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz",
|
||||
"integrity": "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.17"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
|
||||
+6
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "query-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Query structured local data safely in the browser.",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"author": "Albrecht Degering",
|
||||
@@ -39,15 +39,16 @@
|
||||
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
||||
},
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3",
|
||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||
"@add-ideas/toolbox-contract": "0.3.0",
|
||||
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||
"@duckdb/duckdb-wasm": "^1.32.0",
|
||||
"@xmldom/xmldom": "0.9.12",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
|
||||
+20
-2
@@ -12,7 +12,25 @@ export default defineConfig({
|
||||
timeout: 180000,
|
||||
},
|
||||
projects: [
|
||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
||||
{
|
||||
name: "chromium",
|
||||
testIgnore: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
testIgnore: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
{
|
||||
name: "webkit",
|
||||
testIgnore: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
{
|
||||
name: "mobile-chromium",
|
||||
testMatch: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Pixel 5"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0 - 2026-09-02
|
||||
|
||||
- Add raw-input inference evidence, a visible bounded query AST and a local
|
||||
DuckDB-WASM adapter for broader analytical SQL without remote execution.
|
||||
|
||||
## 0.1.0 - 2026-09-01
|
||||
|
||||
- Add bounded JSON/CSV/NDJSON/XML parsing, SQL-like and path query engines.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+19
-2
@@ -2,9 +2,17 @@
|
||||
|
||||
Query Tools is a GPL-3.0-or-later, local-first workbench for bounded JSON, CSV,
|
||||
NDJSON and static XML. It provides an intentionally small SQL-like row language
|
||||
and JSONPath-like value language, result table/tree/JSON views, explain plans,
|
||||
and JSONPath-like value language, parsed SQL/path syntax trees, result
|
||||
table/tree/JSON views, explain plans,
|
||||
local saved queries, and JSON/CSV/NDJSON export. No input is uploaded and no
|
||||
query is evaluated as code.
|
||||
query is evaluated as application JavaScript.
|
||||
|
||||
CSV import has explicit `none`, `safe`, and `aggressive` inference modes and
|
||||
retains every raw cell beside inferred-value evidence. JSON import records raw
|
||||
unsafe-integer and precision-risk number lexemes. A separately selected
|
||||
DuckDB-WASM mode runs bounded, read-only `SELECT` queries in a disposable local
|
||||
worker using same-origin bundled MVP WASM assets; external access and automatic
|
||||
extension loading/installing are disabled.
|
||||
|
||||
SQL subset: `SELECT`, AND-connected `WHERE` comparisons/`CONTAINS`, `GROUP BY`,
|
||||
`COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `ORDER BY`, and `LIMIT`. Path subset:
|
||||
@@ -13,6 +21,15 @@ not compatibility with full SQL, JSONPath, or JMESPath.
|
||||
|
||||
Limits: 2 MiB source, 10,000 rows/path values, 200 fields, 200,000 structured
|
||||
nodes, depth 32, and 20,000 query characters. XML DTD/entities are rejected.
|
||||
DuckDB mode accepts at most 10,000 rows, returns at most 1,000 rows, uses a 128
|
||||
MiB database memory limit, and has a 30-second startup ceiling followed by a
|
||||
ten-second query deadline. It is not a database connection and does not expose
|
||||
files, network resources, extensions, DDL, DML, `COPY`, or `PRAGMA`.
|
||||
|
||||
Static hosting must serve `.wasm` files as `application/wasm`, cache hashed
|
||||
assets immutably, permit `'wasm-unsafe-eval'` for same-origin scripts, and allow
|
||||
same-origin/blob workers while retaining `connect-src 'self'`. The production
|
||||
browser test verifies those headers against the locally bundled DuckDB asset.
|
||||
|
||||
Run `npm ci`, then `npm run check`, `npm run test:browser`, and
|
||||
`npm run package:release -- --force`. Copyright © 2026 Albrecht Degering.
|
||||
|
||||
+16
-1
@@ -1,3 +1,18 @@
|
||||
# Security
|
||||
|
||||
Report vulnerabilities privately through the Gitea repository owner. Never attach confidential datasets publicly. Inputs are untrusted, bounded, rendered inertly, and never executed; XML DTD/entities and dangerous JSON keys are rejected.
|
||||
Report vulnerabilities privately through the Gitea repository owner. Never
|
||||
attach confidential datasets publicly. Inputs are untrusted, bounded, rendered
|
||||
inertly, and never evaluated as application code; XML DTD/entities and dangerous
|
||||
JSON keys are rejected.
|
||||
|
||||
The built-in query languages use fixed parsers and interpreters without `eval`
|
||||
or dynamic function construction. DuckDB mode accepts one parsed `SELECT`
|
||||
statement, rejects semicolons and mutation, extension, attachment, file and
|
||||
configuration keywords, then executes against a bounded in-memory table in a
|
||||
disposable locally bundled WASM worker. External access and extension
|
||||
installation/loading are disabled; startup/query deadlines terminate the worker
|
||||
and stale results cannot replace newer state.
|
||||
|
||||
Deployment must serve the bundled WASM with `application/wasm` and a CSP that
|
||||
allows same-origin WASM and workers while keeping `connect-src 'self'`. Browser
|
||||
release tests verify those headers and fail on third-party requests.
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# Source identity
|
||||
|
||||
- Project: Query Tools
|
||||
- Version: 0.1.0
|
||||
- Version: 0.2.0
|
||||
- Repository: https://git.add-ideas.de/lotobo/query-tools
|
||||
- Licence: GPL-3.0-or-later
|
||||
- Toolbox id: `de.add-ideas.query-tools`
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# Third-party notices
|
||||
|
||||
Runtime dependencies are bundled locally: React/React DOM (MIT), @xmldom/xmldom (MIT), and add·ideas Toolbox Contract/Shell/Helpers (GPL-3.0-or-later). Exact texts are generated into `LICENSES/npm-runtime-licenses.txt`.
|
||||
Runtime dependencies are bundled locally: React/React DOM (MIT),
|
||||
@xmldom/xmldom (MIT), DuckDB-WASM and Apache Arrow JavaScript (MIT), add·ideas
|
||||
Toolbox Contract/Shell 0.3.0 (Apache-2.0), and Toolbox Helpers 0.2.0
|
||||
(GPL-3.0-or-later). No DuckDB asset is fetched at runtime. Exact texts are
|
||||
generated into `LICENSES/npm-runtime-licenses.txt`.
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
# Architecture
|
||||
|
||||
`core/data.ts` performs bounded static parsing into JSON-compatible values. `core/query.ts` tokenizes a fixed grammar, interprets field paths and returns an explain plan; it never compiles or evaluates code. React retains the last valid dataset/result after errors. Relative assets and a same-origin service worker support nested offline deployment.
|
||||
`core/data.ts` performs bounded static parsing into JSON-compatible values while
|
||||
retaining raw numeric and cell-inference evidence. `core/query.ts` tokenizes the
|
||||
fixed SQL-like and path grammars, exposes their bounded syntax trees, interprets
|
||||
field paths and returns an explain plan; it never compiles or evaluates code.
|
||||
|
||||
`core/duckdb.ts` is a separately selected adapter for the locally bundled
|
||||
single-threaded DuckDB-WASM MVP build. A lexical security gate accepts exactly
|
||||
one `SELECT` statement and rejects semicolons plus mutation, attachment,
|
||||
extension, file and configuration keywords before DuckDB performs its complete
|
||||
SQL parse. The adapter serializes at most 10,000 bounded rows into an in-memory
|
||||
table, disables external access and extension auto-install/auto-load, caps
|
||||
database memory at 128 MiB, limits results to 1,000 rows and terminates the
|
||||
disposable worker after completion, cancellation or a hard deadline.
|
||||
|
||||
React retains the last valid dataset/result after errors and suppresses stale
|
||||
asynchronous results. Relative assets and a same-origin service worker support
|
||||
nested offline deployment. The static host serves hashed WASM as
|
||||
`application/wasm` with immutable caching and grants only the CSP allowances
|
||||
needed for same-origin WASM and workers.
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
# Privacy and security
|
||||
|
||||
Data stays in browser memory. Saved query text uses localStorage only. There is no telemetry, remote schema resolution, query execution endpoint, URL fetch, eval or dynamic function construction. Exports require an explicit action. Limits are documented in README.
|
||||
Data stays in browser memory. Saved query text uses localStorage only. There is
|
||||
no telemetry, remote schema resolution, query execution endpoint, URL fetch,
|
||||
`eval` or dynamic function construction. Exports require an explicit action.
|
||||
|
||||
DuckDB mode loads the bounded dataset into an in-memory table inside a
|
||||
disposable worker using same-origin bundled JavaScript and WASM. External access,
|
||||
extension installation and extension auto-loading are disabled. The app exposes
|
||||
no database connection or file/network query surface; completion, cancellation
|
||||
and hard deadlines terminate the worker. The host CSP retains
|
||||
`connect-src 'self'`, and browser tests fail on third-party requests. Input,
|
||||
result, memory and time limits are documented in README.
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = "query-tools-v0.1.0",
|
||||
const CACHE = "query-tools-v0.2.0",
|
||||
APP = [
|
||||
"./",
|
||||
"./index.html",
|
||||
|
||||
+18
-1
@@ -3,7 +3,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.query-tools",
|
||||
"name": "Query Tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Query structured data locally.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
@@ -21,6 +21,23 @@
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"io": {
|
||||
"accepts": [
|
||||
{ "mediaType": "application/json", "extensions": [".json"] },
|
||||
{
|
||||
"mediaType": "application/x-ndjson",
|
||||
"extensions": [".ndjson", ".jsonl"]
|
||||
},
|
||||
{ "mediaType": "text/csv", "extensions": [".csv"] },
|
||||
{ "mediaType": "application/xml", "extensions": [".xml"] }
|
||||
],
|
||||
"produces": [
|
||||
{ "mediaType": "application/json", "extensions": [".json"] },
|
||||
{ "mediaType": "application/x-ndjson", "extensions": [".ndjson"] },
|
||||
{ "mediaType": "text/csv", "extensions": [".csv"] }
|
||||
]
|
||||
},
|
||||
"capabilities": { "required": [], "optional": ["workers", "webassembly"] },
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": true,
|
||||
|
||||
@@ -42,7 +42,9 @@ for (const [location, locked] of Object.entries(lock.packages ?? {}).sort(
|
||||
"--- " +
|
||||
candidate +
|
||||
" ---\n" +
|
||||
(await readFile(path.join(packageDirectory, candidate), "utf8")),
|
||||
(
|
||||
await readFile(path.join(packageDirectory, candidate), "utf8")
|
||||
).replace(/\r\n?/gu, "\n"),
|
||||
);
|
||||
} catch {
|
||||
/* directory */
|
||||
|
||||
@@ -14,13 +14,14 @@ const root = path.resolve(
|
||||
[".js", "text/javascript; charset=utf-8"],
|
||||
[".json", "application/json; charset=utf-8"],
|
||||
[".webmanifest", "application/manifest+json; charset=utf-8"],
|
||||
[".wasm", "application/wasm"],
|
||||
[".svg", "image/svg+xml"],
|
||||
[".md", "text/markdown; charset=utf-8"],
|
||||
[".txt", "text/plain; charset=utf-8"],
|
||||
]),
|
||||
headers = {
|
||||
"Content-Security-Policy":
|
||||
"default-src 'self';base-uri 'self';object-src 'none';frame-ancestors 'none';form-action 'self';script-src 'self';style-src 'self' 'unsafe-inline';img-src 'self' data: blob:;connect-src 'self';worker-src 'self' blob:;manifest-src 'self'",
|
||||
"default-src 'self';base-uri 'self';object-src 'none';frame-ancestors 'none';form-action 'self';script-src 'self' 'wasm-unsafe-eval';style-src 'self' 'unsafe-inline';img-src 'self' data: blob:;connect-src 'self';worker-src 'self' blob:;manifest-src 'self'",
|
||||
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
import {
|
||||
isObject,
|
||||
parseData,
|
||||
type CsvInferenceMode,
|
||||
type DataDocument,
|
||||
type DataFormat,
|
||||
} from "../core/data";
|
||||
import { runDuckDbQuery } from "../core/duckdb";
|
||||
import { runQuery, type QueryResult } from "../core/query";
|
||||
const SAMPLE = JSON.stringify(
|
||||
[
|
||||
@@ -26,22 +28,31 @@ const SQL =
|
||||
export function Workbench() {
|
||||
const [source, setSource] = useState(SAMPLE),
|
||||
[format, setFormat] = useState<DataFormat>("json"),
|
||||
[csvInference, setCsvInference] = useState<CsvInferenceMode>("safe"),
|
||||
[inferNulls, setInferNulls] = useState(false),
|
||||
[rejectUnsafeNumbers, setRejectUnsafeNumbers] = useState(false),
|
||||
[document, setDocument] = useState<DataDocument>(() =>
|
||||
parseData(SAMPLE, "json"),
|
||||
),
|
||||
[language, setLanguage] = useState<"sql" | "path">("sql"),
|
||||
[engine, setEngine] = useState<"focused" | "duckdb-wasm">("focused"),
|
||||
[query, setQuery] = useState(SQL),
|
||||
[result, setResult] = useState<QueryResult>(() =>
|
||||
runQuery(parseData(SAMPLE, "json").root, SQL, "sql"),
|
||||
),
|
||||
[status, setStatus] = useState("Sample data parsed and queried locally."),
|
||||
[view, setView] = useState<"table" | "tree" | "json">("table"),
|
||||
[running, setRunning] = useState(false),
|
||||
[saved, setSaved] = useState<
|
||||
{ name: string; language: "sql" | "path"; query: string }[]
|
||||
>(() => loadSaved());
|
||||
function parse() {
|
||||
try {
|
||||
const next = parseData(source, format);
|
||||
const next = parseData(source, format, {
|
||||
csvInference,
|
||||
inferNulls,
|
||||
rejectUnsafeJsonNumbers: rejectUnsafeNumbers,
|
||||
});
|
||||
setDocument(next);
|
||||
setStatus(
|
||||
`Parsed ${next.rows.length.toLocaleString()} rows and ${next.fields.length} fields locally. Existing result remains until Run.`,
|
||||
@@ -50,15 +61,24 @@ export function Workbench() {
|
||||
setStatus(`${message(error)} The last valid dataset remains active.`);
|
||||
}
|
||||
}
|
||||
function run() {
|
||||
async function run() {
|
||||
if (running) return;
|
||||
setRunning(true);
|
||||
try {
|
||||
const next = runQuery(document.root, query, language);
|
||||
const next =
|
||||
language === "sql" && engine === "duckdb-wasm"
|
||||
? await runDuckDbQuery(document.rows, query, {
|
||||
onProgress: setStatus,
|
||||
})
|
||||
: runQuery(document.root, query, language);
|
||||
setResult(next);
|
||||
setStatus(
|
||||
`Query scanned ${next.scanned.toLocaleString()} values and returned ${next.rows.length.toLocaleString()} result rows.`,
|
||||
);
|
||||
} catch (error) {
|
||||
setStatus(`${message(error)} The last valid result remains visible.`);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
async function open(file: File) {
|
||||
@@ -170,6 +190,42 @@ export function Workbench() {
|
||||
<option value="xml">XML</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="actions inference-controls">
|
||||
<label>
|
||||
CSV inference
|
||||
<select
|
||||
value={csvInference}
|
||||
disabled={format !== "csv"}
|
||||
onChange={(event) =>
|
||||
setCsvInference(event.target.value as CsvInferenceMode)
|
||||
}
|
||||
>
|
||||
<option value="none">Text only</option>
|
||||
<option value="safe">Safe numbers + booleans</option>
|
||||
<option value="aggressive">Aggressive numeric</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="check-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inferNulls}
|
||||
disabled={format !== "csv"}
|
||||
onChange={(event) => setInferNulls(event.target.checked)}
|
||||
/>
|
||||
Infer literal null
|
||||
</label>
|
||||
<label className="check-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rejectUnsafeNumbers}
|
||||
disabled={format !== "json" && format !== "ndjson"}
|
||||
onChange={(event) =>
|
||||
setRejectUnsafeNumbers(event.target.checked)
|
||||
}
|
||||
/>
|
||||
Reject risky JSON numbers
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Source
|
||||
<textarea
|
||||
@@ -186,6 +242,22 @@ export function Workbench() {
|
||||
<summary>Source fields</summary>
|
||||
<p>{document.fields.join(" · ") || "Scalar input"}</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Loss and inference evidence</summary>
|
||||
<p>
|
||||
{document.evidence.csv
|
||||
? `${document.evidence.csv.cells.filter((cell) => cell.inferred !== "string").length} inferred cells; all ${document.evidence.csv.cells.length} raw cells retained in memory.`
|
||||
: "No CSV cell inference was applied."}
|
||||
</p>
|
||||
<p>
|
||||
{document.evidence.jsonNumbers.length
|
||||
? `${document.evidence.jsonNumbers.length} risky JSON number token(s): ${document.evidence.jsonNumbers
|
||||
.slice(0, 6)
|
||||
.map((item) => item.raw)
|
||||
.join(" · ")}`
|
||||
: "No unsafe-integer or high-precision JSON number lexemes detected."}
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
<section className="panel editor">
|
||||
<div className="heading">
|
||||
@@ -201,6 +273,7 @@ export function Workbench() {
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as "sql" | "path";
|
||||
setLanguage(next);
|
||||
if (next === "path") setEngine("focused");
|
||||
setQuery(next === "sql" ? SQL : "$[*][?(@.score >= 80)].name");
|
||||
}}
|
||||
>
|
||||
@@ -208,6 +281,23 @@ export function Workbench() {
|
||||
<option value="path">JSONPath-like values</option>
|
||||
</select>
|
||||
</label>
|
||||
{language === "sql" && (
|
||||
<label>
|
||||
Execution engine
|
||||
<select
|
||||
value={engine}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value as typeof engine;
|
||||
setEngine(next);
|
||||
if (next === "duckdb-wasm")
|
||||
setQuery("SELECT * FROM data LIMIT 100");
|
||||
}}
|
||||
>
|
||||
<option value="focused">Focused parsed subset</option>
|
||||
<option value="duckdb-wasm">DuckDB-WASM (local worker)</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
Query
|
||||
<textarea
|
||||
@@ -218,8 +308,12 @@ export function Workbench() {
|
||||
/>
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button className="primary" onClick={run}>
|
||||
Run query
|
||||
<button
|
||||
className="primary"
|
||||
disabled={running}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{running ? "Running…" : "Run query"}
|
||||
</button>
|
||||
<button onClick={save}>Save locally</button>
|
||||
</div>
|
||||
@@ -251,10 +345,16 @@ export function Workbench() {
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Parsed query evidence</summary>
|
||||
<pre>{stableStringify(result.ast, 2)}</pre>
|
||||
</details>
|
||||
<p className="boundary">
|
||||
This is an intentionally documented subset, not full SQL, JSONPath
|
||||
or JMESPath. It has no joins, recursion, functions beyond the five
|
||||
aggregates, regex, eval or implicit network access.
|
||||
Focused mode is an intentionally documented subset, not full SQL,
|
||||
JSONPath or JMESPath. DuckDB mode is a locally bundled, disposable
|
||||
MVP worker: one read-only SELECT, no extension loading or external
|
||||
access, 128 MiB database memory, 1,000 result rows, a 30-second
|
||||
startup ceiling and a 10-second query deadline.
|
||||
</p>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
+182
-40
@@ -6,19 +6,66 @@ import {
|
||||
import { DOMParser, type Element as XmlElement } from "@xmldom/xmldom";
|
||||
|
||||
export type DataFormat = "json" | "csv" | "ndjson" | "xml";
|
||||
export type CsvInferenceMode = "none" | "safe" | "aggressive";
|
||||
export interface ParseDataOptions {
|
||||
csvInference?: CsvInferenceMode;
|
||||
inferNulls?: boolean;
|
||||
trimBeforeInference?: boolean;
|
||||
rejectUnsafeJsonNumbers?: boolean;
|
||||
}
|
||||
export interface CsvCellEvidence {
|
||||
row: number;
|
||||
column: number;
|
||||
field: string;
|
||||
raw: string;
|
||||
value: JsonValue;
|
||||
inferred: "string" | "number" | "boolean" | "null";
|
||||
}
|
||||
export interface JsonNumberEvidence {
|
||||
offset: number;
|
||||
raw: string;
|
||||
parsed: number;
|
||||
risk: "unsafe-integer" | "precision-risk";
|
||||
}
|
||||
export interface DataEvidence {
|
||||
csv?: {
|
||||
headers: string[];
|
||||
rawRows: string[][];
|
||||
cells: CsvCellEvidence[];
|
||||
inference: {
|
||||
csvInference: CsvInferenceMode;
|
||||
inferNulls: boolean;
|
||||
trimBeforeInference: boolean;
|
||||
};
|
||||
};
|
||||
jsonNumbers: JsonNumberEvidence[];
|
||||
}
|
||||
export interface DataDocument {
|
||||
format: DataFormat;
|
||||
root: JsonValue;
|
||||
rows: JsonValue[];
|
||||
fields: string[];
|
||||
evidence: DataEvidence;
|
||||
}
|
||||
const MAX_CHARS = 2 * 1024 * 1024,
|
||||
MAX_ROWS = 10_000,
|
||||
MAX_FIELDS = 200;
|
||||
|
||||
export function parseData(source: string, format: DataFormat): DataDocument {
|
||||
export function parseData(
|
||||
source: string,
|
||||
format: DataFormat,
|
||||
options: ParseDataOptions = {},
|
||||
): DataDocument {
|
||||
if (source.length > MAX_CHARS)
|
||||
throw new Error("Input exceeds the 2 MiB limit.");
|
||||
const evidence: DataEvidence = {
|
||||
jsonNumbers:
|
||||
format === "json" || format === "ndjson" ? scanJsonNumbers(source) : [],
|
||||
};
|
||||
if (options.rejectUnsafeJsonNumbers && evidence.jsonNumbers.length)
|
||||
throw new RangeError(
|
||||
`JSON contains ${evidence.jsonNumbers.length} number token${evidence.jsonNumbers.length === 1 ? "" : "s"} that cannot be accepted without precision risk.`,
|
||||
);
|
||||
let root: JsonValue;
|
||||
if (format === "json")
|
||||
root = safeJsonParse(source, {
|
||||
@@ -27,8 +74,11 @@ export function parseData(source: string, format: DataFormat): DataDocument {
|
||||
maxNodes: 200_000,
|
||||
});
|
||||
else if (format === "ndjson") root = parseNdjson(source);
|
||||
else if (format === "csv") root = parseCsvData(source);
|
||||
else root = parseXml(source);
|
||||
else if (format === "csv") {
|
||||
const csv = parseCsvData(source, options);
|
||||
root = csv.root;
|
||||
evidence.csv = csv.evidence;
|
||||
} else root = parseXml(source);
|
||||
const rows = normalizeRows(root);
|
||||
if (rows.length > MAX_ROWS)
|
||||
throw new Error("Dataset exceeds 10,000 query rows.");
|
||||
@@ -39,29 +89,137 @@ export function parseData(source: string, format: DataFormat): DataDocument {
|
||||
];
|
||||
if (fields.length > MAX_FIELDS)
|
||||
throw new Error("Dataset exposes more than 200 top-level fields.");
|
||||
return { format, root, rows, fields: fields.sort() };
|
||||
return { format, root, rows, fields: fields.sort(), evidence };
|
||||
}
|
||||
|
||||
function parseCsvData(source: string): JsonValue {
|
||||
function parseCsvData(
|
||||
source: string,
|
||||
options: ParseDataOptions,
|
||||
): { root: JsonValue; evidence: NonNullable<DataEvidence["csv"]> } {
|
||||
const table = parseCsv(source, {
|
||||
maxRows: MAX_ROWS + 1,
|
||||
maxColumns: MAX_FIELDS,
|
||||
maxFieldChars: 100_000,
|
||||
});
|
||||
if (!table.length) return [];
|
||||
const inference = {
|
||||
csvInference: options.csvInference ?? "safe",
|
||||
inferNulls: options.inferNulls ?? false,
|
||||
trimBeforeInference: options.trimBeforeInference ?? true,
|
||||
};
|
||||
if (!table.length)
|
||||
return {
|
||||
root: [],
|
||||
evidence: { headers: [], rawRows: [], cells: [], inference },
|
||||
};
|
||||
const headers = table[0]!.map(
|
||||
(value, index) => value.trim() || `column_${index + 1}`,
|
||||
);
|
||||
if (new Set(headers).size !== headers.length)
|
||||
throw new Error("CSV headers must be unique.");
|
||||
return table
|
||||
.slice(1)
|
||||
.filter((row) => row.some(Boolean))
|
||||
.map((row) =>
|
||||
Object.fromEntries(
|
||||
headers.map((header, index) => [header, inferScalar(row[index] ?? "")]),
|
||||
),
|
||||
) as JsonValue;
|
||||
const rawRows = table.slice(1).filter((row) => row.some(Boolean));
|
||||
const cells: CsvCellEvidence[] = [];
|
||||
const root = rawRows.map((row, rowIndex) =>
|
||||
Object.fromEntries(
|
||||
headers.map((field, column) => {
|
||||
const raw = row[column] ?? "";
|
||||
const interpreted = inferCsvScalar(raw, inference);
|
||||
cells.push({
|
||||
row: rowIndex + 1,
|
||||
column: column + 1,
|
||||
field,
|
||||
raw,
|
||||
value: interpreted.value,
|
||||
inferred: interpreted.kind,
|
||||
});
|
||||
return [field, interpreted.value];
|
||||
}),
|
||||
),
|
||||
) as JsonValue;
|
||||
return {
|
||||
root,
|
||||
evidence: {
|
||||
headers: [...headers],
|
||||
rawRows: rawRows.map((row) => [...row]),
|
||||
cells,
|
||||
inference,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function inferCsvScalar(
|
||||
raw: string,
|
||||
options: {
|
||||
csvInference: CsvInferenceMode;
|
||||
inferNulls: boolean;
|
||||
trimBeforeInference: boolean;
|
||||
},
|
||||
): { value: JsonValue; kind: CsvCellEvidence["inferred"] } {
|
||||
if (options.csvInference === "none") return { value: raw, kind: "string" };
|
||||
const candidate = options.trimBeforeInference ? raw.trim() : raw;
|
||||
if (/^(?:true|false)$/iu.test(candidate))
|
||||
return { value: candidate.toLowerCase() === "true", kind: "boolean" };
|
||||
if (options.inferNulls && /^null$/iu.test(candidate))
|
||||
return { value: null, kind: "null" };
|
||||
if (/^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/iu.test(candidate)) {
|
||||
const number = Number(candidate);
|
||||
const integerToken = /^-?\d+$/u.test(candidate);
|
||||
if (
|
||||
Number.isFinite(number) &&
|
||||
(options.csvInference === "aggressive" ||
|
||||
!integerToken ||
|
||||
Number.isSafeInteger(number))
|
||||
)
|
||||
return { value: number, kind: "number" };
|
||||
}
|
||||
return { value: raw, kind: "string" };
|
||||
}
|
||||
|
||||
/** Identifies JSON number lexemes whose browser Number conversion loses evidence. */
|
||||
export function scanJsonNumbers(source: string): JsonNumberEvidence[] {
|
||||
const results: JsonNumberEvidence[] = [];
|
||||
let index = 0;
|
||||
let inString = false;
|
||||
while (index < source.length) {
|
||||
const char = source[index]!;
|
||||
if (inString) {
|
||||
if (char === "\\") index += 2;
|
||||
else {
|
||||
if (char === '"') inString = false;
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/u.exec(
|
||||
source.slice(index),
|
||||
);
|
||||
if (!match) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const raw = match[0];
|
||||
const parsed = Number(raw);
|
||||
const integer = !/[.eE]/u.test(raw);
|
||||
const mantissa = raw.split(/[eE]/u, 1)[0] ?? raw;
|
||||
const significantDigits = mantissa
|
||||
.replace(/^-|\./gu, "")
|
||||
.replace(/^0+/u, "")
|
||||
.replace(/0+$/u, "").length;
|
||||
const risk = integer
|
||||
? Number.isSafeInteger(parsed)
|
||||
? undefined
|
||||
: "unsafe-integer"
|
||||
: !Number.isFinite(parsed) || significantDigits > 15
|
||||
? "precision-risk"
|
||||
: undefined;
|
||||
if (risk) results.push({ offset: index, raw, parsed, risk });
|
||||
index += raw.length;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function parseNdjson(source: string): JsonValue {
|
||||
@@ -108,34 +266,29 @@ function parseXml(source: string): JsonValue {
|
||||
const attribute = element.attributes.item(index);
|
||||
if (attribute) output[`@${attribute.name}`] = attribute.value;
|
||||
}
|
||||
const children = [
|
||||
...Array.from({ length: element.childNodes.length }, (_, index) =>
|
||||
element.childNodes.item(index),
|
||||
),
|
||||
].filter((node): node is XmlElement =>
|
||||
const allChildren = Array.from(
|
||||
{ length: element.childNodes.length },
|
||||
(_, index) => element.childNodes.item(index),
|
||||
);
|
||||
const children = allChildren.filter((node): node is XmlElement =>
|
||||
Boolean(node && node.nodeType === 1),
|
||||
);
|
||||
for (const child of children) {
|
||||
const value = convert(child, depth + 1),
|
||||
key = child.nodeName;
|
||||
const existing = output[key];
|
||||
output[key] =
|
||||
const value = convert(child, depth + 1);
|
||||
const existing = output[child.nodeName];
|
||||
output[child.nodeName] =
|
||||
existing === undefined
|
||||
? value
|
||||
: Array.isArray(existing)
|
||||
? [...existing, value]
|
||||
: [existing, value];
|
||||
}
|
||||
const text = [
|
||||
...Array.from({ length: element.childNodes.length }, (_, index) =>
|
||||
element.childNodes.item(index),
|
||||
),
|
||||
]
|
||||
const text = allChildren
|
||||
.filter((node) => node?.nodeType === 3 || node?.nodeType === 4)
|
||||
.map((node) => node?.nodeValue ?? "")
|
||||
.join("")
|
||||
.trim();
|
||||
if (!Object.keys(output).length) return inferScalar(text);
|
||||
if (!Object.keys(output).length) return text;
|
||||
if (text) output["#text"] = text;
|
||||
return output;
|
||||
};
|
||||
@@ -155,14 +308,3 @@ export function normalizeRows(value: JsonValue): JsonValue[] {
|
||||
export function isObject(value: JsonValue): value is Record<string, JsonValue> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
function inferScalar(value: string): JsonValue {
|
||||
const trimmed = value.trim();
|
||||
if (/^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/iu.test(trimmed)) {
|
||||
const number = Number(trimmed);
|
||||
if (Number.isFinite(number)) return number;
|
||||
}
|
||||
if (/^(?:true|false)$/iu.test(trimmed))
|
||||
return trimmed.toLowerCase() === "true";
|
||||
if (/^null$/iu.test(trimmed)) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { stableStringify, type JsonValue } from "@add-ideas/toolbox-helpers";
|
||||
import type { AsyncDuckDBConnection } from "@duckdb/duckdb-wasm";
|
||||
import type { DuckDbQueryAst, QueryResult } from "./query";
|
||||
|
||||
export const DUCKDB_RESULT_LIMIT = 1_000;
|
||||
export const DUCKDB_DEADLINE_MS = 10_000;
|
||||
export const DUCKDB_STARTUP_DEADLINE_MS = 30_000;
|
||||
export const DUCKDB_MEMORY_LIMIT = "128MB";
|
||||
|
||||
const forbidden = new Set([
|
||||
"ALTER",
|
||||
"ATTACH",
|
||||
"CALL",
|
||||
"COPY",
|
||||
"CREATE",
|
||||
"DELETE",
|
||||
"DETACH",
|
||||
"DROP",
|
||||
"EXPORT",
|
||||
"IMPORT",
|
||||
"INSERT",
|
||||
"INSTALL",
|
||||
"LOAD",
|
||||
"PRAGMA",
|
||||
"RESET",
|
||||
"SET",
|
||||
"TRUNCATE",
|
||||
"UPDATE",
|
||||
"VACUUM",
|
||||
]);
|
||||
|
||||
export interface DuckDbOptions {
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
startupTimeoutMs?: number;
|
||||
onProgress?: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the security-relevant shell of a DuckDB query. The database performs
|
||||
* the complete SQL parse; this gate guarantees a single read-only SELECT and
|
||||
* records every token used for that decision as evidence.
|
||||
*/
|
||||
export function parseDuckDbQuery(source: string): DuckDbQueryAst {
|
||||
if (!source.trim()) throw new SyntaxError("DuckDB SQL cannot be empty.");
|
||||
if (source.length > 20_000)
|
||||
throw new RangeError("DuckDB SQL exceeds 20,000 characters.");
|
||||
const tokens: DuckDbQueryAst["tokens"] = [];
|
||||
let index = 0;
|
||||
while (index < source.length) {
|
||||
const char = source[index]!;
|
||||
if (/\s/u.test(char)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (source.startsWith("--", index)) {
|
||||
const end = source.indexOf("\n", index + 2);
|
||||
index = end < 0 ? source.length : end + 1;
|
||||
continue;
|
||||
}
|
||||
if (source.startsWith("/*", index)) {
|
||||
const end = source.indexOf("*/", index + 2);
|
||||
if (end < 0) throw new SyntaxError("Unclosed DuckDB SQL comment.");
|
||||
index = end + 2;
|
||||
continue;
|
||||
}
|
||||
const offset = index;
|
||||
if (char === "'" || char === '"') {
|
||||
const quote = char;
|
||||
index += 1;
|
||||
let value = quote;
|
||||
let closed = false;
|
||||
while (index < source.length) {
|
||||
const next = source[index++]!;
|
||||
value += next;
|
||||
if (next === quote) {
|
||||
if (source[index] === quote) value += source[index++]!;
|
||||
else {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!closed) throw new SyntaxError("Unclosed DuckDB SQL quote.");
|
||||
tokens.push({ kind: "quoted", value, offset });
|
||||
continue;
|
||||
}
|
||||
const word = /^[A-Za-z_][A-Za-z0-9_$]*/u.exec(source.slice(index));
|
||||
if (word) {
|
||||
tokens.push({ kind: "word", value: word[0], offset });
|
||||
index += word[0].length;
|
||||
continue;
|
||||
}
|
||||
if (char === ";")
|
||||
throw new SyntaxError(
|
||||
"DuckDB mode accepts exactly one statement without a semicolon.",
|
||||
);
|
||||
tokens.push({ kind: "symbol", value: char, offset });
|
||||
index += 1;
|
||||
}
|
||||
const words = tokens
|
||||
.filter((token) => token.kind === "word")
|
||||
.map((token) => token.value.toUpperCase());
|
||||
if (words[0] !== "SELECT")
|
||||
throw new SyntaxError(
|
||||
"DuckDB mode accepts a single SELECT statement only.",
|
||||
);
|
||||
const blocked = words.find((word) => forbidden.has(word));
|
||||
if (blocked)
|
||||
throw new SyntaxError(
|
||||
`DuckDB keyword ${blocked} is disabled in local read-only mode.`,
|
||||
);
|
||||
return { type: "duckdb-query", statement: "SELECT", tokens };
|
||||
}
|
||||
|
||||
export async function runDuckDbQuery(
|
||||
rows: readonly JsonValue[],
|
||||
source: string,
|
||||
options: DuckDbOptions = {},
|
||||
): Promise<QueryResult> {
|
||||
if (rows.length > 10_000)
|
||||
throw new RangeError("DuckDB input exceeds 10,000 rows.");
|
||||
const ast = parseDuckDbQuery(source);
|
||||
const timeoutMs = options.timeoutMs ?? DUCKDB_DEADLINE_MS;
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs < 100 || timeoutMs > 30_000)
|
||||
throw new RangeError("DuckDB deadline must be 100–30,000 milliseconds.");
|
||||
const startupTimeoutMs =
|
||||
options.startupTimeoutMs ?? DUCKDB_STARTUP_DEADLINE_MS;
|
||||
if (
|
||||
!Number.isFinite(startupTimeoutMs) ||
|
||||
startupTimeoutMs < 1_000 ||
|
||||
startupTimeoutMs > 60_000
|
||||
)
|
||||
throw new RangeError(
|
||||
"DuckDB startup deadline must be 1,000–60,000 milliseconds.",
|
||||
);
|
||||
const text = stableStringify(rows, undefined, {
|
||||
maxTextChars: 2 * 1024 * 1024,
|
||||
maxDepth: 32,
|
||||
maxNodes: 200_000,
|
||||
});
|
||||
options.signal?.throwIfAborted();
|
||||
options.onProgress?.("Starting the local DuckDB worker…");
|
||||
const [duckdb, moduleAsset, workerAsset] = await Promise.all([
|
||||
import("@duckdb/duckdb-wasm"),
|
||||
import("@duckdb/duckdb-wasm/dist/duckdb-mvp.wasm?url"),
|
||||
import("@duckdb/duckdb-wasm/dist/duckdb-browser-mvp.worker.js?url"),
|
||||
]);
|
||||
options.signal?.throwIfAborted();
|
||||
const duckdbMvpModule = moduleAsset.default;
|
||||
const duckdbMvpWorker = workerAsset.default;
|
||||
const worker = new Worker(duckdbMvpWorker);
|
||||
const database = new duckdb.AsyncDuckDB(new duckdb.VoidLogger(), worker);
|
||||
let connection: AsyncDuckDBConnection | undefined;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let rejectDeadline: ((reason: Error) => void) | undefined;
|
||||
const deadline = new Promise<never>((_resolve, reject) => {
|
||||
rejectDeadline = reject;
|
||||
});
|
||||
const armDeadline = (phase: "startup" | "query", milliseconds: number) => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
void connection?.cancelSent().catch(() => undefined);
|
||||
void database.terminate();
|
||||
rejectDeadline?.(
|
||||
new DOMException(
|
||||
`DuckDB ${phase} exceeded its ${milliseconds.toLocaleString()} ms deadline.`,
|
||||
"TimeoutError",
|
||||
),
|
||||
);
|
||||
}, milliseconds);
|
||||
};
|
||||
armDeadline("startup", startupTimeoutMs);
|
||||
const abort = () => {
|
||||
void connection?.cancelSent().catch(() => undefined);
|
||||
void database.terminate();
|
||||
};
|
||||
options.signal?.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
const execute = async (): Promise<QueryResult> => {
|
||||
await database.instantiate(duckdbMvpModule);
|
||||
await database.open({
|
||||
path: ":memory:",
|
||||
maximumThreads: 1,
|
||||
allowUnsignedExtensions: false,
|
||||
arrowLosslessConversion: true,
|
||||
query: {
|
||||
castBigIntToDouble: false,
|
||||
castDecimalToDouble: false,
|
||||
castTimestampToDate: true,
|
||||
queryPollingInterval: 20,
|
||||
},
|
||||
});
|
||||
options.signal?.throwIfAborted();
|
||||
options.onProgress?.("Loading the bounded dataset into DuckDB…");
|
||||
await database.registerFileText("toolbox-input.json", text);
|
||||
connection = await database.connect();
|
||||
await connection.insertJSONFromPath("toolbox-input.json", {
|
||||
name: "data",
|
||||
create: true,
|
||||
});
|
||||
await connection.query(`SET memory_limit='${DUCKDB_MEMORY_LIMIT}'`);
|
||||
await connection.query("SET enable_external_access=false");
|
||||
await connection.query("SET autoinstall_known_extensions=false");
|
||||
await connection.query("SET autoload_known_extensions=false");
|
||||
options.onProgress?.("Running one read-only SELECT…");
|
||||
armDeadline("query", timeoutMs);
|
||||
const result = await connection.query(
|
||||
`SELECT * FROM (${source}) AS toolbox_result LIMIT ${DUCKDB_RESULT_LIMIT + 1}`,
|
||||
);
|
||||
const rawRows = result.toArray();
|
||||
if (rawRows.length > DUCKDB_RESULT_LIMIT)
|
||||
throw new RangeError(
|
||||
`DuckDB result exceeds ${DUCKDB_RESULT_LIMIT.toLocaleString()} rows; add a LIMIT or a narrower filter.`,
|
||||
);
|
||||
const evidence: string[] = [];
|
||||
const output = rawRows.map((row: unknown) =>
|
||||
jsonValue(
|
||||
typeof (row as { toJSON?: () => unknown }).toJSON === "function"
|
||||
? (row as { toJSON: () => unknown }).toJSON()
|
||||
: row,
|
||||
evidence,
|
||||
),
|
||||
);
|
||||
return {
|
||||
value: output,
|
||||
rows: output,
|
||||
scanned: rows.length,
|
||||
ast,
|
||||
engine: "duckdb-wasm",
|
||||
explain: [
|
||||
"Instantiate the locally bundled single-threaded DuckDB-WASM MVP worker.",
|
||||
`Load ${rows.length.toLocaleString()} bounded JSON row${rows.length === 1 ? "" : "s"} into table data.`,
|
||||
`Disable external access and extension auto-install/auto-load; cap memory at ${DUCKDB_MEMORY_LIMIT}.`,
|
||||
`Initialize within ${startupTimeoutMs.toLocaleString()} ms, then execute one parsed read-only SELECT with a ${timeoutMs.toLocaleString()} ms hard worker deadline.`,
|
||||
`Materialize at most ${DUCKDB_RESULT_LIMIT.toLocaleString()} rows${evidence.length ? `; ${[...new Set(evidence)].join(" ")}` : "."}`,
|
||||
],
|
||||
};
|
||||
};
|
||||
return await Promise.race([execute(), deadline]);
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
options.signal?.removeEventListener("abort", abort);
|
||||
await connection?.close().catch(() => undefined);
|
||||
await database.terminate().catch(() => undefined);
|
||||
worker.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
function jsonValue(value: unknown, evidence: string[], depth = 0): JsonValue {
|
||||
if (depth > 32) throw new RangeError("DuckDB result exceeds depth 32.");
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean")
|
||||
return value;
|
||||
if (typeof value === "number")
|
||||
return Number.isFinite(value) ? value : String(value);
|
||||
if (typeof value === "bigint") {
|
||||
evidence.push("64-bit integers were rendered as exact decimal strings.");
|
||||
return value.toString();
|
||||
}
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (value instanceof Uint8Array) {
|
||||
evidence.push("Binary values were rendered as hexadecimal strings.");
|
||||
return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join(
|
||||
"",
|
||||
);
|
||||
}
|
||||
if (Array.isArray(value))
|
||||
return value.map((item) => jsonValue(item, evidence, depth + 1));
|
||||
if (value && typeof value === "object")
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [
|
||||
key,
|
||||
jsonValue(item, evidence, depth + 1),
|
||||
]),
|
||||
);
|
||||
return String(value);
|
||||
}
|
||||
+460
-198
@@ -6,18 +6,75 @@ export interface QueryResult {
|
||||
rows: JsonValue[];
|
||||
explain: string[];
|
||||
scanned: number;
|
||||
ast: QueryAst;
|
||||
engine: "focused" | "duckdb-wasm";
|
||||
}
|
||||
type Operator = "=" | "!=" | ">" | ">=" | "<" | "<=" | "CONTAINS";
|
||||
interface Condition {
|
||||
path: string;
|
||||
|
||||
export type QueryAst = SqlQueryAst | PathQueryAst | DuckDbQueryAst;
|
||||
export type Operator = "=" | "!=" | ">" | ">=" | "<" | "<=" | "CONTAINS";
|
||||
|
||||
export interface ConditionAst {
|
||||
type: "condition";
|
||||
path: string[];
|
||||
operator: Operator;
|
||||
value: JsonValue;
|
||||
}
|
||||
interface SelectItem {
|
||||
kind: "field" | "aggregate" | "all";
|
||||
path?: string;
|
||||
fn?: "COUNT" | "SUM" | "AVG" | "MIN" | "MAX";
|
||||
alias: string;
|
||||
|
||||
export type SelectAst =
|
||||
| { type: "all"; alias: "*" }
|
||||
| { type: "field"; path: string[]; alias: string }
|
||||
| {
|
||||
type: "aggregate";
|
||||
fn: "COUNT" | "SUM" | "AVG" | "MIN" | "MAX";
|
||||
path?: string[];
|
||||
alias: string;
|
||||
};
|
||||
|
||||
export interface SqlQueryAst {
|
||||
type: "sql-query";
|
||||
select: SelectAst[];
|
||||
where: ConditionAst[];
|
||||
groupBy: string[][];
|
||||
orderBy?: { path: string[]; direction: "ASC" | "DESC" };
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export type PathSegmentAst =
|
||||
| { type: "property"; key: string; quoted: boolean }
|
||||
| { type: "index"; index: number }
|
||||
| { type: "wildcard" }
|
||||
| { type: "filter"; condition: ConditionAst };
|
||||
|
||||
export interface PathQueryAst {
|
||||
type: "path-query";
|
||||
segments: PathSegmentAst[];
|
||||
}
|
||||
|
||||
export interface DuckDbQueryAst {
|
||||
type: "duckdb-query";
|
||||
statement: "SELECT";
|
||||
tokens: Array<{
|
||||
kind: "word" | "quoted" | "symbol";
|
||||
value: string;
|
||||
offset: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
type TokenKind =
|
||||
| "identifier"
|
||||
| "number"
|
||||
| "string"
|
||||
| "comma"
|
||||
| "dot"
|
||||
| "left"
|
||||
| "right"
|
||||
| "star"
|
||||
| "operator"
|
||||
| "eof";
|
||||
interface Token {
|
||||
kind: TokenKind;
|
||||
value: string;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export function runQuery(
|
||||
@@ -27,111 +84,397 @@ export function runQuery(
|
||||
): QueryResult {
|
||||
if (query.length > 20_000)
|
||||
throw new Error("Query exceeds 20,000 characters.");
|
||||
return language === "sql"
|
||||
? runSql(normalizeRows(root), query)
|
||||
: runPath(root, query);
|
||||
const ast = language === "sql" ? parseSqlQuery(query) : parsePathQuery(query);
|
||||
return ast.type === "sql-query"
|
||||
? executeSql(normalizeRows(root), ast)
|
||||
: executePath(root, ast);
|
||||
}
|
||||
|
||||
function runSql(input: JsonValue[], source: string): QueryResult {
|
||||
const match =
|
||||
/^\s*SELECT\s+([\s\S]+?)(?:\s+WHERE\s+([\s\S]+?))?(?:\s+GROUP\s+BY\s+([\s\S]+?))?(?:\s+ORDER\s+BY\s+([A-Za-z_$][\w$.-]*)(?:\s+(ASC|DESC))?)?(?:\s+LIMIT\s+(\d+))?\s*$/iu.exec(
|
||||
source,
|
||||
export function parseSqlQuery(source: string): SqlQueryAst {
|
||||
if (source.length > 20_000)
|
||||
throw new Error("Query exceeds 20,000 characters.");
|
||||
return new SqlParser(tokenizeSql(source)).parse();
|
||||
}
|
||||
|
||||
class SqlParser {
|
||||
#index = 0;
|
||||
readonly #tokens: Token[];
|
||||
constructor(tokens: Token[]) {
|
||||
this.#tokens = tokens;
|
||||
}
|
||||
|
||||
parse(): SqlQueryAst {
|
||||
this.#keyword("SELECT");
|
||||
const select: SelectAst[] = [];
|
||||
do select.push(this.#selectItem());
|
||||
while (this.#take("comma"));
|
||||
|
||||
const where: ConditionAst[] = [];
|
||||
if (this.#takeKeyword("WHERE")) {
|
||||
do where.push(this.#condition());
|
||||
while (this.#takeKeyword("AND"));
|
||||
}
|
||||
const groupBy: string[][] = [];
|
||||
if (this.#takeKeyword("GROUP")) {
|
||||
this.#keyword("BY");
|
||||
do groupBy.push(this.#path());
|
||||
while (this.#take("comma"));
|
||||
}
|
||||
let orderBy: SqlQueryAst["orderBy"];
|
||||
if (this.#takeKeyword("ORDER")) {
|
||||
this.#keyword("BY");
|
||||
const path = this.#path();
|
||||
const direction = this.#takeKeyword("DESC") ? "DESC" : "ASC";
|
||||
if (direction === "ASC") this.#takeKeyword("ASC");
|
||||
orderBy = { path, direction };
|
||||
}
|
||||
let limit = 1_000;
|
||||
if (this.#takeKeyword("LIMIT")) {
|
||||
const token = this.#expect("number", "LIMIT requires an integer");
|
||||
if (!/^\d+$/u.test(token.value)) this.#fail("LIMIT requires an integer");
|
||||
limit = Number(token.value);
|
||||
if (!Number.isInteger(limit) || limit < 0 || limit > 10_000)
|
||||
this.#fail("LIMIT must be between 0 and 10,000");
|
||||
}
|
||||
this.#expect("eof", "Unexpected SQL after the supported query");
|
||||
return {
|
||||
type: "sql-query",
|
||||
select,
|
||||
where,
|
||||
groupBy,
|
||||
...(orderBy ? { orderBy } : {}),
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
#selectItem(): SelectAst {
|
||||
if (this.#take("star")) return { type: "all", alias: "*" };
|
||||
const first = this.#expect("identifier", "Invalid SELECT expression");
|
||||
if (this.#take("left")) {
|
||||
const fn = first.value.toUpperCase();
|
||||
if (!["COUNT", "SUM", "AVG", "MIN", "MAX"].includes(fn))
|
||||
this.#fail(
|
||||
`Invalid SELECT expression: unsupported aggregate ${first.value}`,
|
||||
);
|
||||
const path = this.#take("star") ? undefined : this.#path();
|
||||
this.#expect("right", "Aggregate call needs a closing parenthesis");
|
||||
const alias = this.#takeKeyword("AS")
|
||||
? this.#expect("identifier", "AS requires an alias").value
|
||||
: `${fn.toLowerCase()}_${path?.join(".") ?? "all"}`;
|
||||
return {
|
||||
type: "aggregate",
|
||||
fn: fn as Extract<SelectAst, { type: "aggregate" }>["fn"],
|
||||
...(path ? { path } : {}),
|
||||
alias,
|
||||
};
|
||||
}
|
||||
const path = this.#path(first);
|
||||
const alias = this.#takeKeyword("AS")
|
||||
? this.#expect("identifier", "AS requires an alias").value
|
||||
: path.join(".");
|
||||
return { type: "field", path, alias };
|
||||
}
|
||||
|
||||
#condition(): ConditionAst {
|
||||
const path = this.#path();
|
||||
const token = this.#peek();
|
||||
let operator: Operator;
|
||||
if (token.kind === "operator") {
|
||||
operator = token.value as Operator;
|
||||
this.#index += 1;
|
||||
} else if (
|
||||
token.kind === "identifier" &&
|
||||
token.value.toUpperCase() === "CONTAINS"
|
||||
) {
|
||||
operator = "CONTAINS";
|
||||
this.#index += 1;
|
||||
} else this.#fail("WHERE requires a supported comparison operator");
|
||||
return { type: "condition", path, operator, value: this.#literal() };
|
||||
}
|
||||
|
||||
#literal(): JsonValue {
|
||||
const token = this.#peek();
|
||||
if (token.kind === "string") {
|
||||
this.#index += 1;
|
||||
return token.value;
|
||||
}
|
||||
if (token.kind === "number") {
|
||||
this.#index += 1;
|
||||
const number = Number(token.value);
|
||||
if (!Number.isFinite(number)) this.#fail("Number literal is not finite");
|
||||
return number;
|
||||
}
|
||||
if (token.kind === "identifier") {
|
||||
const keyword = token.value.toUpperCase();
|
||||
if (keyword === "TRUE" || keyword === "FALSE" || keyword === "NULL") {
|
||||
this.#index += 1;
|
||||
return keyword === "NULL" ? null : keyword === "TRUE";
|
||||
}
|
||||
}
|
||||
this.#fail("Literal must be quoted text, number, boolean or null");
|
||||
}
|
||||
|
||||
#path(first?: Token): string[] {
|
||||
const path = [
|
||||
(first ?? this.#expect("identifier", "Expected a field path")).value,
|
||||
];
|
||||
while (this.#take("dot"))
|
||||
path.push(this.#expect("identifier", "Expected a name after dot").value);
|
||||
return path;
|
||||
}
|
||||
|
||||
#keyword(value: string): void {
|
||||
if (!this.#takeKeyword(value)) this.#fail(`Expected ${value}`);
|
||||
}
|
||||
#takeKeyword(value: string): boolean {
|
||||
const token = this.#peek();
|
||||
if (token.kind !== "identifier" || token.value.toUpperCase() !== value)
|
||||
return false;
|
||||
this.#index += 1;
|
||||
return true;
|
||||
}
|
||||
#take(kind: TokenKind): boolean {
|
||||
if (this.#peek().kind !== kind) return false;
|
||||
this.#index += 1;
|
||||
return true;
|
||||
}
|
||||
#expect(kind: TokenKind, message: string): Token {
|
||||
const token = this.#peek();
|
||||
if (token.kind !== kind) this.#fail(message);
|
||||
this.#index += 1;
|
||||
return token;
|
||||
}
|
||||
#peek(): Token {
|
||||
return this.#tokens[this.#index] ?? this.#tokens.at(-1)!;
|
||||
}
|
||||
#fail(message: string): never {
|
||||
const token = this.#peek();
|
||||
throw new SyntaxError(`${message} at character ${token.offset + 1}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function tokenizeSql(source: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let index = 0;
|
||||
const push = (kind: TokenKind, value: string, offset = index) =>
|
||||
tokens.push({ kind, value, offset });
|
||||
while (index < source.length) {
|
||||
if (/\s/u.test(source[index]!)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const offset = index;
|
||||
const char = source[index]!;
|
||||
const punctuation: Partial<Record<string, TokenKind>> = {
|
||||
",": "comma",
|
||||
".": "dot",
|
||||
"(": "left",
|
||||
")": "right",
|
||||
"*": "star",
|
||||
};
|
||||
if (punctuation[char]) {
|
||||
push(punctuation[char]!, char, offset);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const operator = /^(?:>=|<=|!=|=|>|<)/u.exec(source.slice(index));
|
||||
if (operator) {
|
||||
push("operator", operator[0], offset);
|
||||
index += operator[0].length;
|
||||
continue;
|
||||
}
|
||||
const number = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?/iu.exec(
|
||||
source.slice(index),
|
||||
);
|
||||
if (!match)
|
||||
throw new Error(
|
||||
"Expected SELECT fields [WHERE …] [GROUP BY …] [ORDER BY field ASC|DESC] [LIMIT n].",
|
||||
if (number) {
|
||||
push("number", number[0], offset);
|
||||
index += number[0].length;
|
||||
continue;
|
||||
}
|
||||
const identifier = /^[A-Za-z_$][\w$-]*/u.exec(source.slice(index));
|
||||
if (identifier) {
|
||||
push("identifier", identifier[0], offset);
|
||||
index += identifier[0].length;
|
||||
continue;
|
||||
}
|
||||
if (char === "'" || char === '"') {
|
||||
const quote = char;
|
||||
index += 1;
|
||||
let value = "";
|
||||
let closed = false;
|
||||
while (index < source.length) {
|
||||
const next = source[index++]!;
|
||||
if (next === quote) {
|
||||
if (source[index] === quote) {
|
||||
value += quote;
|
||||
index += 1;
|
||||
} else {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
} else if (next === "\\") {
|
||||
const escaped = source[index++];
|
||||
if (escaped === undefined || !["\\", "'", '"'].includes(escaped))
|
||||
throw new SyntaxError(
|
||||
`Unsupported string escape at character ${index}.`,
|
||||
);
|
||||
value += escaped;
|
||||
} else value += next;
|
||||
}
|
||||
if (!closed)
|
||||
throw new SyntaxError(`Unclosed string at character ${offset + 1}.`);
|
||||
push("string", value, offset);
|
||||
continue;
|
||||
}
|
||||
throw new SyntaxError(
|
||||
`Unsupported SQL token “${char}” at character ${offset + 1}.`,
|
||||
);
|
||||
const selects = splitComma(match[1]!).map(parseSelect),
|
||||
conditions = match[2]
|
||||
? match[2].split(/\s+AND\s+/iu).map(parseCondition)
|
||||
: [];
|
||||
const groups = match[3] ? splitComma(match[3]).map(validatePath) : [];
|
||||
const orderPath = match[4],
|
||||
descending = match[5]?.toUpperCase() === "DESC";
|
||||
const limit = match[6] ? Number(match[6]) : 1_000;
|
||||
if (!Number.isInteger(limit) || limit < 0 || limit > 10_000)
|
||||
throw new Error("LIMIT must be between 0 and 10,000.");
|
||||
}
|
||||
tokens.push({ kind: "eof", value: "", offset: source.length });
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function parsePathQuery(source: string): PathQueryAst {
|
||||
if (source.length > 20_000)
|
||||
throw new Error("Query exceeds 20,000 characters.");
|
||||
let index = 0;
|
||||
const fail = (message: string): never => {
|
||||
throw new SyntaxError(`${message} at character ${index + 1}.`);
|
||||
};
|
||||
if (source[index] !== "$") fail("Path queries start with $");
|
||||
index += 1;
|
||||
const segments: PathSegmentAst[] = [];
|
||||
while (index < source.length) {
|
||||
if (source[index] === ".") {
|
||||
index += 1;
|
||||
const match = /^[A-Za-z_$][\w$-]*/u.exec(source.slice(index));
|
||||
if (!match)
|
||||
throw new SyntaxError(
|
||||
`Unsupported path: expected a property name after dot at character ${index + 1}.`,
|
||||
);
|
||||
segments.push({ type: "property", key: match[0], quoted: false });
|
||||
index += match[0].length;
|
||||
continue;
|
||||
}
|
||||
const rest = source.slice(index);
|
||||
const quoted = /^\[['"]([^'"\]]+)['"]\]/u.exec(rest);
|
||||
if (quoted) {
|
||||
segments.push({ type: "property", key: quoted[1]!, quoted: true });
|
||||
index += quoted[0].length;
|
||||
continue;
|
||||
}
|
||||
const arrayIndex = /^\[(\d+)\]/u.exec(rest);
|
||||
if (arrayIndex) {
|
||||
const value = Number(arrayIndex[1]);
|
||||
if (!Number.isSafeInteger(value)) fail("Array index is too large");
|
||||
segments.push({ type: "index", index: value });
|
||||
index += arrayIndex[0].length;
|
||||
continue;
|
||||
}
|
||||
if (rest.startsWith("[*]")) {
|
||||
segments.push({ type: "wildcard" });
|
||||
index += 3;
|
||||
continue;
|
||||
}
|
||||
const filter =
|
||||
/^\[\?\(@\.([A-Za-z_$][\w$.-]*)\s*(>=|<=|!=|=|>|<)\s*((?:'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?|true|false|null))\s*\)\]/iu.exec(
|
||||
rest,
|
||||
);
|
||||
if (filter) {
|
||||
const literalAst = parseSqlQuery(`SELECT x WHERE x = ${filter[3]}`);
|
||||
segments.push({
|
||||
type: "filter",
|
||||
condition: {
|
||||
type: "condition",
|
||||
path: validatePath(filter[1]!),
|
||||
operator: filter[2]! as Operator,
|
||||
value: literalAst.where[0]!.value,
|
||||
},
|
||||
});
|
||||
index += filter[0].length;
|
||||
continue;
|
||||
}
|
||||
fail(`Unsupported path token near “${rest.slice(0, 30)}”`);
|
||||
}
|
||||
return { type: "path-query", segments };
|
||||
}
|
||||
|
||||
function executeSql(input: JsonValue[], ast: SqlQueryAst): QueryResult {
|
||||
const aggregate = ast.select.some((item) => item.type === "aggregate");
|
||||
if (
|
||||
ast.select.some(
|
||||
(item) =>
|
||||
item.type === "field" &&
|
||||
!ast.groupBy.some((path) => samePath(path, item.path)),
|
||||
) &&
|
||||
(aggregate || ast.groupBy.length)
|
||||
)
|
||||
throw new Error("Non-aggregate SELECT fields must appear in GROUP BY.");
|
||||
let rows = input.filter((row) =>
|
||||
conditions.every((condition) => evaluateCondition(row, condition)),
|
||||
ast.where.every((condition) => evaluateCondition(row, condition)),
|
||||
);
|
||||
const aggregate = selects.some((item) => item.kind === "aggregate");
|
||||
if (aggregate || groups.length) {
|
||||
if (
|
||||
selects.some(
|
||||
(item) => item.kind === "field" && !groups.includes(item.path!),
|
||||
)
|
||||
)
|
||||
throw new Error("Non-aggregate SELECT fields must appear in GROUP BY.");
|
||||
if (aggregate || ast.groupBy.length) {
|
||||
const buckets = new Map<string, JsonValue[]>();
|
||||
for (const row of rows) {
|
||||
const key = stableStringify(groups.map((path) => getPath(row, path)));
|
||||
const key = stableStringify(
|
||||
ast.groupBy.map((path) => getPath(row, path)),
|
||||
);
|
||||
const bucket = buckets.get(key) ?? [];
|
||||
bucket.push(row);
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
rows = [...buckets.values()].map((bucket) =>
|
||||
projectAggregate(bucket, selects),
|
||||
projectAggregate(bucket, ast.select),
|
||||
);
|
||||
} else rows = rows.map((row) => project(row, selects));
|
||||
if (orderPath)
|
||||
} else rows = rows.map((row) => project(row, ast.select));
|
||||
if (ast.orderBy)
|
||||
rows.sort(
|
||||
(a, b) =>
|
||||
compare(getPath(a, orderPath), getPath(b, orderPath)) *
|
||||
(descending ? -1 : 1),
|
||||
compare(getPath(a, ast.orderBy!.path), getPath(b, ast.orderBy!.path)) *
|
||||
(ast.orderBy!.direction === "DESC" ? -1 : 1),
|
||||
);
|
||||
rows = rows.slice(0, limit);
|
||||
rows = rows.slice(0, ast.limit);
|
||||
return {
|
||||
value: rows,
|
||||
rows,
|
||||
scanned: input.length,
|
||||
ast,
|
||||
engine: "focused",
|
||||
explain: [
|
||||
`Scan at most ${input.length.toLocaleString()} in-memory rows.`,
|
||||
conditions.length
|
||||
? `Apply ${conditions.length} AND-connected predicate${conditions.length === 1 ? "" : "s"}.`
|
||||
ast.where.length
|
||||
? `Apply ${ast.where.length} parsed AND predicate${ast.where.length === 1 ? "" : "s"}.`
|
||||
: "No filter predicate.",
|
||||
aggregate || groups.length
|
||||
? `Build bounded groups on ${groups.join(", ") || "one global bucket"}; compute aggregates.`
|
||||
: `Project ${selects.length} selection${selects.length === 1 ? "" : "s"}.`,
|
||||
orderPath
|
||||
? `Stable-sort by ${orderPath} ${descending ? "descending" : "ascending"}.`
|
||||
aggregate || ast.groupBy.length
|
||||
? `Build bounded groups on ${ast.groupBy.map((path) => path.join(".")).join(", ") || "one global bucket"}; compute aggregates.`
|
||||
: `Project ${ast.select.length} parsed selection${ast.select.length === 1 ? "" : "s"}.`,
|
||||
ast.orderBy
|
||||
? `Stable-sort by ${ast.orderBy.path.join(".")} ${ast.orderBy.direction.toLowerCase()}.`
|
||||
: "Preserve source order.",
|
||||
`Return at most ${limit.toLocaleString()} rows.`,
|
||||
`Return at most ${ast.limit.toLocaleString()} rows.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function runPath(root: JsonValue, source: string): QueryResult {
|
||||
let rest = source.trim();
|
||||
if (!rest.startsWith("$")) throw new Error("Path queries start with $.");
|
||||
rest = rest.slice(1);
|
||||
function executePath(root: JsonValue, ast: PathQueryAst): QueryResult {
|
||||
let values: JsonValue[] = [root];
|
||||
const explain = ["Start at the document root ($)."];
|
||||
while (rest) {
|
||||
let match: RegExpExecArray | null;
|
||||
if ((match = /^\.([A-Za-z_$][\w$-]*)/u.exec(rest))) {
|
||||
const key = match[1]!;
|
||||
for (const segment of ast.segments) {
|
||||
if (segment.type === "property") {
|
||||
values = values.flatMap((value) =>
|
||||
isObject(value) && value[key] !== undefined ? [value[key]!] : [],
|
||||
);
|
||||
rest = rest.slice(match[0].length);
|
||||
explain.push(`Select property ${key}.`);
|
||||
} else if ((match = /^\[['"]([^'"\]]+)['"]\]/u.exec(rest))) {
|
||||
const key = match[1]!;
|
||||
values = values.flatMap((value) =>
|
||||
isObject(value) && value[key] !== undefined ? [value[key]!] : [],
|
||||
);
|
||||
rest = rest.slice(match[0].length);
|
||||
explain.push(`Select quoted property ${key}.`);
|
||||
} else if ((match = /^\[(\d+)\]/u.exec(rest))) {
|
||||
const index = Number(match[1]);
|
||||
values = values.flatMap((value) =>
|
||||
Array.isArray(value) && value[index] !== undefined
|
||||
? [value[index]!]
|
||||
isObject(value) && value[segment.key] !== undefined
|
||||
? [value[segment.key]!]
|
||||
: [],
|
||||
);
|
||||
rest = rest.slice(match[0].length);
|
||||
explain.push(`Select array index ${index}.`);
|
||||
} else if ((match = /^\[\*\]/u.exec(rest))) {
|
||||
explain.push(`Select property ${segment.key}.`);
|
||||
} else if (segment.type === "index") {
|
||||
values = values.flatMap((value) =>
|
||||
Array.isArray(value) && value[segment.index] !== undefined
|
||||
? [value[segment.index]!]
|
||||
: [],
|
||||
);
|
||||
explain.push(`Select array index ${segment.index}.`);
|
||||
} else if (segment.type === "wildcard") {
|
||||
values = values.flatMap((value) =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
@@ -139,112 +482,60 @@ function runPath(root: JsonValue, source: string): QueryResult {
|
||||
? Object.values(value)
|
||||
: [],
|
||||
);
|
||||
rest = rest.slice(match[0].length);
|
||||
explain.push("Expand one wildcard level.");
|
||||
} else if (
|
||||
(match =
|
||||
/^\[\?\(@\.([A-Za-z_$][\w$.-]*)\s*(>=|<=|!=|=|>|<)\s*([^\]]+)\)\]/u.exec(
|
||||
rest,
|
||||
))
|
||||
) {
|
||||
const condition = {
|
||||
path: validatePath(match[1]!),
|
||||
operator: match[2]! as Operator,
|
||||
value: parseLiteral(match[3]!.trim()),
|
||||
};
|
||||
} else {
|
||||
values = values.flatMap((value) =>
|
||||
Array.isArray(value)
|
||||
? value.filter((item) => evaluateCondition(item, condition))
|
||||
? value.filter((item) => evaluateCondition(item, segment.condition))
|
||||
: [],
|
||||
);
|
||||
rest = rest.slice(match[0].length);
|
||||
explain.push(
|
||||
`Filter an array with ${condition.path} ${condition.operator} literal.`,
|
||||
);
|
||||
} else
|
||||
throw new Error(
|
||||
`Unsupported path token near “${rest.slice(0, 30)}”. Supported: .name, ['name'], [n], [*], [?(@.field op literal)].`,
|
||||
`Filter an array with ${segment.condition.path.join(".")} ${segment.condition.operator} literal.`,
|
||||
);
|
||||
}
|
||||
if (values.length > 10_000)
|
||||
throw new Error("Path expansion exceeds 10,000 values.");
|
||||
}
|
||||
const value: JsonValue = values.length === 1 ? values[0]! : values;
|
||||
return { value, rows: normalizeRows(value), scanned: values.length, explain };
|
||||
return {
|
||||
value,
|
||||
rows: normalizeRows(value),
|
||||
scanned: values.length,
|
||||
explain,
|
||||
ast,
|
||||
engine: "focused",
|
||||
};
|
||||
}
|
||||
|
||||
function parseSelect(source: string): SelectItem {
|
||||
const value = source.trim();
|
||||
if (value === "*") return { kind: "all", alias: "*" };
|
||||
const aggregate =
|
||||
/^(COUNT|SUM|AVG|MIN|MAX)\s*\(\s*(\*|[A-Za-z_$][\w$.-]*)\s*\)(?:\s+AS\s+([A-Za-z_$][\w$-]*))?$/iu.exec(
|
||||
value,
|
||||
);
|
||||
if (aggregate) {
|
||||
const fn = aggregate[1]!.toUpperCase() as SelectItem["fn"];
|
||||
const path = aggregate[2] === "*" ? undefined : validatePath(aggregate[2]!);
|
||||
return {
|
||||
kind: "aggregate",
|
||||
fn,
|
||||
path,
|
||||
alias: aggregate[3] ?? `${fn!.toLowerCase()}_${path ?? "all"}`,
|
||||
};
|
||||
}
|
||||
const field = /^([A-Za-z_$][\w$.-]*)(?:\s+AS\s+([A-Za-z_$][\w$-]*))?$/iu.exec(
|
||||
value,
|
||||
);
|
||||
if (!field) throw new Error(`Invalid SELECT expression: ${value}`);
|
||||
return {
|
||||
kind: "field",
|
||||
path: validatePath(field[1]!),
|
||||
alias: field[2] ?? field[1]!,
|
||||
};
|
||||
}
|
||||
function parseCondition(source: string): Condition {
|
||||
const match =
|
||||
/^([A-Za-z_$][\w$.-]*)\s*(>=|<=|!=|=|>|<|CONTAINS)\s*([\s\S]+)$/iu.exec(
|
||||
source.trim(),
|
||||
);
|
||||
if (!match) throw new Error(`Invalid WHERE predicate: ${source}`);
|
||||
return {
|
||||
path: validatePath(match[1]!),
|
||||
operator: match[2]!.toUpperCase() as Operator,
|
||||
value: parseLiteral(match[3]!.trim()),
|
||||
};
|
||||
}
|
||||
function parseLiteral(source: string): JsonValue {
|
||||
if (
|
||||
(source.startsWith("'") && source.endsWith("'")) ||
|
||||
(source.startsWith('"') && source.endsWith('"'))
|
||||
)
|
||||
return source.slice(1, -1).replace(/\\(['"\\])/gu, "$1");
|
||||
if (/^-?(?:\d+\.?\d*|\.\d+)$/u.test(source)) return Number(source);
|
||||
if (/^(?:true|false)$/iu.test(source)) return source.toLowerCase() === "true";
|
||||
if (/^null$/iu.test(source)) return null;
|
||||
throw new Error(
|
||||
`Literal must be quoted text, number, boolean or null: ${source}`,
|
||||
);
|
||||
}
|
||||
function validatePath(path: string): string {
|
||||
function validatePath(path: string): string[] {
|
||||
if (!/^[A-Za-z_$][\w$-]*(?:\.[A-Za-z_$][\w$-]*)*$/u.test(path))
|
||||
throw new Error(`Invalid field path: ${path}`);
|
||||
return path;
|
||||
return path.split(".");
|
||||
}
|
||||
function getPath(value: JsonValue, path: string): JsonValue | undefined {
|
||||
function samePath(left: string[], right: string[]): boolean {
|
||||
return (
|
||||
left.length === right.length && left.every((part, i) => part === right[i])
|
||||
);
|
||||
}
|
||||
function getPath(
|
||||
value: JsonValue,
|
||||
path: readonly string[],
|
||||
): JsonValue | undefined {
|
||||
let current: JsonValue | undefined = value;
|
||||
for (const part of path.split(".")) {
|
||||
for (const part of path) {
|
||||
if (!current || !isObject(current)) return undefined;
|
||||
current = current[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
function evaluateCondition(row: JsonValue, condition: Condition): boolean {
|
||||
const left = getPath(row, condition.path),
|
||||
right = condition.value;
|
||||
function evaluateCondition(row: JsonValue, condition: ConditionAst): boolean {
|
||||
const left = getPath(row, condition.path);
|
||||
const right = condition.value;
|
||||
if (condition.operator === "CONTAINS")
|
||||
return typeof left === "string" && left.includes(String(right));
|
||||
const order = compare(left, right);
|
||||
if (condition.operator === "=") return equal(left, right);
|
||||
if (condition.operator === "!=") return !equal(left, right);
|
||||
if (condition.operator === "=") return left === right;
|
||||
if (condition.operator === "!=") return left !== right;
|
||||
return condition.operator === ">"
|
||||
? order > 0
|
||||
: condition.operator === ">="
|
||||
@@ -253,9 +544,6 @@ function evaluateCondition(row: JsonValue, condition: Condition): boolean {
|
||||
? order < 0
|
||||
: order <= 0;
|
||||
}
|
||||
function equal(a: JsonValue | undefined, b: JsonValue): boolean {
|
||||
return a === b;
|
||||
}
|
||||
function compare(a: JsonValue | undefined, b: JsonValue | undefined): number {
|
||||
if (a === b) return 0;
|
||||
if (a === undefined || a === null) return -1;
|
||||
@@ -263,21 +551,21 @@ function compare(a: JsonValue | undefined, b: JsonValue | undefined): number {
|
||||
if (typeof a === "number" && typeof b === "number") return a - b;
|
||||
return String(a).localeCompare(String(b), undefined, { numeric: true });
|
||||
}
|
||||
function project(row: JsonValue, selects: SelectItem[]): JsonValue {
|
||||
if (selects.length === 1 && selects[0]!.kind === "all") return row;
|
||||
function project(row: JsonValue, selects: SelectAst[]): JsonValue {
|
||||
if (selects.length === 1 && selects[0]!.type === "all") return row;
|
||||
const output: Record<string, JsonValue> = {};
|
||||
for (const item of selects)
|
||||
if (item.kind === "field")
|
||||
output[item.alias] = getPath(row, item.path!) ?? null;
|
||||
else if (item.kind === "all" && isObject(row)) Object.assign(output, row);
|
||||
if (item.type === "field")
|
||||
output[item.alias] = getPath(row, item.path) ?? null;
|
||||
else if (item.type === "all" && isObject(row)) Object.assign(output, row);
|
||||
return output;
|
||||
}
|
||||
function projectAggregate(rows: JsonValue[], selects: SelectItem[]): JsonValue {
|
||||
function projectAggregate(rows: JsonValue[], selects: SelectAst[]): JsonValue {
|
||||
const output: Record<string, JsonValue> = {};
|
||||
for (const item of selects) {
|
||||
if (item.kind === "field")
|
||||
output[item.alias] = getPath(rows[0] ?? null, item.path!) ?? null;
|
||||
else if (item.kind === "aggregate") {
|
||||
if (item.type === "field")
|
||||
output[item.alias] = getPath(rows[0] ?? null, item.path) ?? null;
|
||||
else if (item.type === "aggregate") {
|
||||
const values = item.path
|
||||
? rows
|
||||
.map((row) => getPath(row, item.path!))
|
||||
@@ -305,29 +593,3 @@ function projectAggregate(rows: JsonValue[], selects: SelectItem[]): JsonValue {
|
||||
}
|
||||
return output;
|
||||
}
|
||||
function splitComma(source: string): string[] {
|
||||
const values: string[] = [];
|
||||
let current = "",
|
||||
depth = 0,
|
||||
quote = "";
|
||||
for (const char of source) {
|
||||
if (quote) {
|
||||
current += char;
|
||||
if (char === quote) quote = "";
|
||||
} else if (char === "'" || char === '"') {
|
||||
quote = char;
|
||||
current += char;
|
||||
} else if (char === "(") {
|
||||
depth++;
|
||||
current += char;
|
||||
} else if (char === ")") {
|
||||
depth--;
|
||||
current += char;
|
||||
} else if (char === "," && depth === 0) {
|
||||
values.push(current);
|
||||
current = "";
|
||||
} else current += char;
|
||||
}
|
||||
if (current.trim()) values.push(current);
|
||||
return values;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.query-tools",
|
||||
"name": "Query Tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Query structured data locally.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
@@ -21,6 +21,44 @@
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"io": {
|
||||
"accepts": [
|
||||
{
|
||||
"mediaType": "application/json",
|
||||
"extensions": [".json"]
|
||||
},
|
||||
{
|
||||
"mediaType": "application/x-ndjson",
|
||||
"extensions": [".ndjson", ".jsonl"]
|
||||
},
|
||||
{
|
||||
"mediaType": "text/csv",
|
||||
"extensions": [".csv"]
|
||||
},
|
||||
{
|
||||
"mediaType": "application/xml",
|
||||
"extensions": [".xml"]
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
{
|
||||
"mediaType": "application/json",
|
||||
"extensions": [".json"]
|
||||
},
|
||||
{
|
||||
"mediaType": "application/x-ndjson",
|
||||
"extensions": [".ndjson"]
|
||||
},
|
||||
{
|
||||
"mediaType": "text/csv",
|
||||
"extensions": [".csv"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"required": [],
|
||||
"optional": ["workers", "webassembly"]
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": true,
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
export const APP_VERSION = "0.2.0";
|
||||
|
||||
@@ -41,6 +41,40 @@ test("retains results on an error and exports", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "JSON", exact: true }).click();
|
||||
expect((await pending).suggestedFilename()).toBe("query-result.json");
|
||||
});
|
||||
test("runs the locally bundled bounded DuckDB worker in Chromium", async ({
|
||||
page,
|
||||
browserName,
|
||||
request,
|
||||
}) => {
|
||||
test.skip(browserName !== "chromium", "Targeted DuckDB-WASM Chromium flow");
|
||||
const external = await local(page);
|
||||
await page.goto("/deep/nested/query/");
|
||||
await page.getByLabel("Execution engine").selectOption("duckdb-wasm");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Query", exact: true })
|
||||
.fill(
|
||||
"SELECT team, avg(score) AS average FROM data GROUP BY team ORDER BY team",
|
||||
);
|
||||
const wasmResponse = page.waitForResponse((response) =>
|
||||
response.url().endsWith(".wasm"),
|
||||
);
|
||||
await page.getByRole("button", { name: "Run query" }).click();
|
||||
await expect(page.getByRole("status")).toContainText(
|
||||
"returned 2 result rows",
|
||||
{
|
||||
timeout: 60_000,
|
||||
},
|
||||
);
|
||||
await expect(page.getByRole("cell", { name: "blue" })).toBeVisible();
|
||||
const wasmUrl = (await wasmResponse).url();
|
||||
const wasm = await request.get(wasmUrl);
|
||||
expect(wasm.headers()["content-type"]).toBe("application/wasm");
|
||||
expect(wasm.headers()["cache-control"]).toContain("immutable");
|
||||
expect(wasm.headers()["content-security-policy"]).toContain(
|
||||
"'wasm-unsafe-eval'",
|
||||
);
|
||||
expect(external).toEqual([]);
|
||||
});
|
||||
test("shell PWA headers, offline reload, and theme", async ({
|
||||
page,
|
||||
context,
|
||||
@@ -76,6 +110,6 @@ test("shell PWA headers, offline reload, and theme", async ({
|
||||
const manifest = await request.get("/deep/nested/query/toolbox-app.json");
|
||||
await expect(manifest.json()).resolves.toMatchObject({
|
||||
id: "de.add-ideas.query-tools",
|
||||
version: "0.1.0",
|
||||
version: "0.2.0",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("keeps the primary workspace inside a narrow viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/deep/nested/query/");
|
||||
await expect(page.locator("main").first()).toBeVisible();
|
||||
await expect(
|
||||
page.locator("main .loading, main .workbench-loading"),
|
||||
).toHaveCount(0);
|
||||
|
||||
const widths = await page.evaluate(() => ({
|
||||
content: document.documentElement.scrollWidth,
|
||||
viewport: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(widths.viewport).toBeLessThanOrEqual(430);
|
||||
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
|
||||
});
|
||||
+36
-1
@@ -2,12 +2,47 @@ import { describe, expect, it } from "vitest";
|
||||
import { parseData } from "../../src/core/data";
|
||||
describe("data parser", () => {
|
||||
it("parses CSV inference and NDJSON", () => {
|
||||
expect(parseData("name,n\nAda,3", "csv").rows[0]).toEqual({
|
||||
const csv = parseData("name,n\nAda,3", "csv");
|
||||
expect(csv.rows[0]).toEqual({
|
||||
name: "Ada",
|
||||
n: 3,
|
||||
});
|
||||
expect(csv.evidence.csv).toMatchObject({
|
||||
rawRows: [["Ada", "3"]],
|
||||
cells: expect.arrayContaining([
|
||||
expect.objectContaining({ field: "n", raw: "3", inferred: "number" }),
|
||||
]),
|
||||
});
|
||||
expect(parseData('{"a":1}\n{"a":2}', "ndjson").rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("makes inference explicit and preserves unsafe-number evidence", () => {
|
||||
expect(
|
||||
parseData("id,empty\n9007199254740993,null", "csv", {
|
||||
csvInference: "safe",
|
||||
inferNulls: false,
|
||||
}).rows[0],
|
||||
).toEqual({ id: "9007199254740993", empty: "null" });
|
||||
const json = parseData(
|
||||
'{"id":9007199254740993,"decimal":0.12345678901234567,"exact":"9007199254740993"}',
|
||||
"json",
|
||||
);
|
||||
expect(json.evidence.jsonNumbers).toEqual([
|
||||
expect.objectContaining({
|
||||
raw: "9007199254740993",
|
||||
risk: "unsafe-integer",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
raw: "0.12345678901234567",
|
||||
risk: "precision-risk",
|
||||
}),
|
||||
]);
|
||||
expect(() =>
|
||||
parseData('{"id":9007199254740993}', "json", {
|
||||
rejectUnsafeJsonNumbers: true,
|
||||
}),
|
||||
).toThrow(/precision risk/iu);
|
||||
});
|
||||
it("converts static XML and rejects entities", () => {
|
||||
expect(
|
||||
parseData(
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseDuckDbQuery } from "../../src/core/duckdb";
|
||||
|
||||
describe("DuckDB query boundary", () => {
|
||||
it("records a single SELECT token stream", () => {
|
||||
expect(
|
||||
parseDuckDbQuery("SELECT team, avg(score) FROM data GROUP BY team"),
|
||||
).toMatchObject({
|
||||
type: "duckdb-query",
|
||||
statement: "SELECT",
|
||||
tokens: expect.arrayContaining([
|
||||
{ kind: "word", value: "data", offset: 29 },
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"INSTALL httpfs",
|
||||
"SELECT * FROM data; DROP TABLE data",
|
||||
"COPY data TO 'x.csv'",
|
||||
"PRAGMA version",
|
||||
])("rejects non-read-only statement %s", (source) => {
|
||||
expect(() => parseDuckDbQuery(source)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseData } from "../../src/core/data";
|
||||
import { runQuery } from "../../src/core/query";
|
||||
import { parsePathQuery, parseSqlQuery, runQuery } from "../../src/core/query";
|
||||
describe("query engine", () => {
|
||||
const root = parseData(
|
||||
'[{"team":"a","n":2},{"team":"a","n":4},{"team":"b","n":9}]',
|
||||
@@ -32,4 +32,31 @@ describe("query engine", () => {
|
||||
/Unsupported path/iu,
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes parsed SQL and path ASTs", () => {
|
||||
expect(
|
||||
parseSqlQuery(
|
||||
"SELECT team AS group_name WHERE n >= 2 ORDER BY n DESC LIMIT 4",
|
||||
),
|
||||
).toMatchObject({
|
||||
type: "sql-query",
|
||||
select: [{ type: "field", path: ["team"], alias: "group_name" }],
|
||||
where: [{ type: "condition", path: ["n"], operator: ">=", value: 2 }],
|
||||
orderBy: { path: ["n"], direction: "DESC" },
|
||||
limit: 4,
|
||||
});
|
||||
expect(parsePathQuery("$[*][?(@.n >= 4)].team").segments).toEqual([
|
||||
{ type: "wildcard" },
|
||||
{
|
||||
type: "filter",
|
||||
condition: {
|
||||
type: "condition",
|
||||
path: ["n"],
|
||||
operator: ">=",
|
||||
value: 4,
|
||||
},
|
||||
},
|
||||
{ type: "property", key: "team", quoted: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user