diff --git a/package-lock.json b/package-lock.json index ef65cb51a0..46df5f1517 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12151,9 +12151,9 @@ "link": true }, "node_modules/@rocicorp/zero-sqlite3": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@rocicorp/zero-sqlite3/-/zero-sqlite3-1.0.12.tgz", - "integrity": "sha512-i5pWo6k9Sq1eHMBBjJdCqnDgo3AGsdtAa3UbKWuOrEJcCe5Qxw67LMZWaxD/SfrZltAlckAF0Sla2jBAwmXz6A==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@rocicorp/zero-sqlite3/-/zero-sqlite3-1.0.13.tgz", + "integrity": "sha512-WZ2UlM8kdW3DyHwsRTsaQFxvV/gz+sZNKwNnw9FkxBJNwLUd1/JoiGTdQqAJdd5fEd226huVEh/Nv9hkGgVWyg==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -36945,7 +36945,7 @@ "devDependencies": { "@op-engineering/op-sqlite": ">=15", "@rocicorp/prettier-config": "^0.4.0", - "@rocicorp/zero-sqlite3": "^1.0.12", + "@rocicorp/zero-sqlite3": "1.0.13", "@types/command-line-usage": "^5.0.2", "@vitest/runner": "3.2.4", "command-line-args": "^6.0.1", @@ -36960,7 +36960,7 @@ }, "peerDependencies": { "@op-engineering/op-sqlite": ">=15", - "@rocicorp/zero-sqlite3": "^1.0.12", + "@rocicorp/zero-sqlite3": "1.0.13", "expo-sqlite": ">=15" }, "peerDependenciesMeta": { @@ -37222,7 +37222,7 @@ "@rocicorp/lock": "^1.0.4", "@rocicorp/logger": "^5.4.0", "@rocicorp/resolver": "^1.0.2", - "@rocicorp/zero-sqlite3": "^1.0.12", + "@rocicorp/zero-sqlite3": "1.0.13", "@standard-schema/spec": "^1.0.0", "@types/basic-auth": "^1.1.8", "basic-auth": "^2.0.1", @@ -37320,7 +37320,7 @@ "@rocicorp/lock": "^1.0.4", "@rocicorp/logger": "^5.4.0", "@rocicorp/resolver": "^1.0.2", - "@rocicorp/zero-sqlite3": "^1.0.12", + "@rocicorp/zero-sqlite3": "1.0.13", "@types/basic-auth": "^1.1.8", "basic-auth": "^2.0.1", "chalk": "^5.3.0", @@ -38855,7 +38855,7 @@ "@databases/escape-identifier": "^1.0.3", "@databases/sql": "^3.3.0", "@rocicorp/logger": "^5.4.0", - "@rocicorp/zero-sqlite3": "^1.0.12", + "@rocicorp/zero-sqlite3": "1.0.13", "zql": "0.0.0" }, "devDependencies": { @@ -46269,7 +46269,7 @@ "@rocicorp/logger": "^5.4.0", "@rocicorp/prettier-config": "^0.4.0", "@rocicorp/resolver": "^1.0.2", - "@rocicorp/zero-sqlite3": "^1.0.12", + "@rocicorp/zero-sqlite3": "1.0.13", "@standard-schema/spec": "^1.0.0", "@types/basic-auth": "^1.1.8", "@vitest/runner": "3.2.4", @@ -47068,9 +47068,9 @@ } }, "@rocicorp/zero-sqlite3": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@rocicorp/zero-sqlite3/-/zero-sqlite3-1.0.12.tgz", - "integrity": "sha512-i5pWo6k9Sq1eHMBBjJdCqnDgo3AGsdtAa3UbKWuOrEJcCe5Qxw67LMZWaxD/SfrZltAlckAF0Sla2jBAwmXz6A==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@rocicorp/zero-sqlite3/-/zero-sqlite3-1.0.13.tgz", + "integrity": "sha512-WZ2UlM8kdW3DyHwsRTsaQFxvV/gz+sZNKwNnw9FkxBJNwLUd1/JoiGTdQqAJdd5fEd226huVEh/Nv9hkGgVWyg==", "requires": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -60210,7 +60210,7 @@ "@rocicorp/logger": "^5.4.0", "@rocicorp/prettier-config": "^0.4.0", "@rocicorp/resolver": "^1.0.2", - "@rocicorp/zero-sqlite3": "^1.0.12", + "@rocicorp/zero-sqlite3": "1.0.13", "@types/command-line-usage": "^5.0.2", "@vitest/runner": "3.2.4", "command-line-args": "^6.0.1", @@ -63806,7 +63806,7 @@ "@rocicorp/prettier-config": "^0.4.0", "@rocicorp/resolver": "^1.0.2", "@rocicorp/zero-events": "0.0.3", - "@rocicorp/zero-sqlite3": "^1.0.12", + "@rocicorp/zero-sqlite3": "1.0.13", "@testcontainers/postgresql": "^10.9.0", "@types/basic-auth": "^1.1.8", "@types/node": "^22.10.5", @@ -64239,7 +64239,7 @@ "@databases/sql": "^3.3.0", "@rocicorp/logger": "^5.4.0", "@rocicorp/prettier-config": "^0.4.0", - "@rocicorp/zero-sqlite3": "^1.0.12", + "@rocicorp/zero-sqlite3": "1.0.13", "fast-check": "^3.18.0", "nanoid": "^5.1.2", "otel": "0.0.0", diff --git a/packages/analyze-query/src/bin-analyze.ts b/packages/analyze-query/src/bin-analyze.ts index 10c3651d39..6e4fbd33fe 100644 --- a/packages/analyze-query/src/bin-analyze.ts +++ b/packages/analyze-query/src/bin-analyze.ts @@ -42,7 +42,7 @@ import {asQueryInternals} from '../../zql/src/query/query-internals.ts'; import type {PullRow, Query} from '../../zql/src/query/query.ts'; import {Database} from '../../zqlite/src/db.ts'; import {TableSource} from '../../zqlite/src/table-source.ts'; -import {explainQueries} from './explain-queries.ts'; +import {explainQueries} from '../../zqlite/src/explain-queries.ts'; import {runAst} from './run-ast.ts'; const options = { diff --git a/packages/analyze-query/src/explain-queries.ts b/packages/analyze-query/src/explain-queries.ts deleted file mode 100644 index 05d279cb49..0000000000 --- a/packages/analyze-query/src/explain-queries.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type {RowCountsBySource} from '../../zero-protocol/src/analyze-query-result.ts'; -import type {Database} from '../../zqlite/src/db.ts'; - -export function explainQueries(counts: RowCountsBySource, db: Database) { - const plans: Record = {}; - for (const querySet of Object.values(counts)) { - const queries = Object.keys(querySet); - for (const query of queries) { - const plan = db - // we should be more intelligent about value replacement. - // Different values result in different plans. E.g., picking a value at the start - // of an index will result in `scan` vs `search`. The scan is fine in that case. - .prepare(`EXPLAIN QUERY PLAN ${query.replaceAll('?', "'sdfse'")}`) - .all<{detail: string}>() - .map(r => r.detail); - plans[query] = plan; - } - } - - return plans; -} diff --git a/packages/replicache/package.json b/packages/replicache/package.json index 22f643aeb4..d2056d6b98 100644 --- a/packages/replicache/package.json +++ b/packages/replicache/package.json @@ -39,7 +39,7 @@ "devDependencies": { "@op-engineering/op-sqlite": ">=15", "@rocicorp/prettier-config": "^0.4.0", - "@rocicorp/zero-sqlite3": "^1.0.12", + "@rocicorp/zero-sqlite3": "1.0.13", "@types/command-line-usage": "^5.0.2", "@vitest/runner": "3.2.4", "command-line-args": "^6.0.1", @@ -54,7 +54,7 @@ }, "peerDependencies": { "@op-engineering/op-sqlite": ">=15", - "@rocicorp/zero-sqlite3": "^1.0.12", + "@rocicorp/zero-sqlite3": "1.0.13", "expo-sqlite": ">=15" }, "peerDependenciesMeta": { diff --git a/packages/zero-cache/package.json b/packages/zero-cache/package.json index 89372c7f45..1dab379924 100644 --- a/packages/zero-cache/package.json +++ b/packages/zero-cache/package.json @@ -39,7 +39,7 @@ "@rocicorp/lock": "^1.0.4", "@rocicorp/logger": "^5.4.0", "@rocicorp/resolver": "^1.0.2", - "@rocicorp/zero-sqlite3": "^1.0.12", + "@rocicorp/zero-sqlite3": "1.0.13", "@types/basic-auth": "^1.1.8", "basic-auth": "^2.0.1", "chalk": "^5.3.0", @@ -60,9 +60,9 @@ "urlpattern-polyfill": "^10.1.0", "ws": "^8.18.1", "zero-protocol": "0.0.0", + "zero-types": "0.0.0", "zql": "0.0.0", - "zqlite": "0.0.0", - "zero-types": "0.0.0" + "zqlite": "0.0.0" }, "devDependencies": { "@rocicorp/prettier-config": "^0.4.0", diff --git a/packages/zero/package.json b/packages/zero/package.json index bb28a4b514..485152e439 100644 --- a/packages/zero/package.json +++ b/packages/zero/package.json @@ -46,7 +46,8 @@ "@rocicorp/lock": "^1.0.4", "@rocicorp/logger": "^5.4.0", "@rocicorp/resolver": "^1.0.2", - "@rocicorp/zero-sqlite3": "^1.0.12", + "@rocicorp/zero-sqlite3": "1.0.13", + "@standard-schema/spec": "^1.0.0", "@types/basic-auth": "^1.1.8", "basic-auth": "^2.0.1", "chalk": "^5.3.0", @@ -70,7 +71,6 @@ "postgres": "^3.4.4", "prettier": "^3.5.3", "semver": "^7.5.4", - "@standard-schema/spec": "^1.0.0", "tsx": "^4.19.1", "url-pattern": "^1.0.3", "urlpattern-polyfill": "^10.1.0", @@ -82,13 +82,13 @@ "@vitest/runner": "3.2.4", "analyze-query": "0.0.0", "ast-to-zql": "0.0.0", - "vite": "6.2.1", "expo-sqlite": ">=15", "replicache": "15.2.1", "shared": "0.0.0", "typedoc": "^0.28.2", "typedoc-plugin-markdown": "^4.6.1", "typescript": "~5.9.3", + "vite": "6.2.1", "vitest": "3.2.4", "zero-cache": "0.0.0", "zero-client": "0.0.0", @@ -99,8 +99,8 @@ "zqlite": "0.0.0" }, "peerDependencies": { - "expo-sqlite": ">=15", - "@op-engineering/op-sqlite": ">=15" + "@op-engineering/op-sqlite": ">=15", + "expo-sqlite": ">=15" }, "peerDependenciesMeta": { "expo-sqlite": { diff --git a/packages/zql-benchmarks/src/planner-hydration.bench.ts b/packages/zql-benchmarks/src/planner-hydration.bench.ts index 367ebb9128..86a1582a6f 100644 --- a/packages/zql-benchmarks/src/planner-hydration.bench.ts +++ b/packages/zql-benchmarks/src/planner-hydration.bench.ts @@ -125,6 +125,159 @@ benchmarkQuery( ), ); +// ============================================ +// Sophisticated queries using `related` +// ============================================ + +// Full invoice report with customer, support rep, and line items with tracks +benchmarkQuery( + 'invoice.related(customer, lines.related(track))', + queries.invoice + .related('customer', c => c.related('supportRep')) + .related('lines', line => line.related('track')), +); + +// Full invoice report - even deeper nesting +benchmarkQuery( + 'invoice deep: customer->supportRep->reportsTo, lines->track->album->artist', + queries.invoice + .related('customer', c => + c.related('supportRep', rep => rep.related('reportsToEmployee')), + ) + .related('lines', line => + line.related('track', t => t.related('album', a => a.related('artist'))), + ), +); + +// Playlist with all track data including album and artist +benchmarkQuery( + 'playlist.related(tracks.related(album.related(artist), genre))', + queries.playlist.related('tracks', t => + t.related('album', a => a.related('artist')).related('genre'), + ), +); + +// Artist catalog - artist with all albums and their tracks +benchmarkQuery( + 'artist.related(albums.related(tracks))', + queries.artist.related('albums', a => a.related('tracks')), +); + +// Artist catalog with full track details +benchmarkQuery( + 'artist.related(albums.related(tracks.related(genre, mediaType)))', + queries.artist.related('albums', a => + a.related('tracks', t => t.related('genre').related('mediaType')), + ), +); + +// Track with all related data +benchmarkQuery( + 'track.related(album.related(artist), genre, mediaType, playlists)', + queries.track + .related('album', a => a.related('artist')) + .related('genre') + .related('mediaType') + .related('playlists'), +); + +// ============================================ +// Combined `exists` and `related` queries +// ============================================ + +// Invoices for customers in USA with line items +benchmarkQuery( + 'invoice.whereExists(customer where country=USA).related(lines)', + queries.invoice + .whereExists('customer', c => c.where('country', 'USA')) + .related('lines', line => line.related('track')), +); + +// Playlists that contain Rock tracks, with full track data +benchmarkQuery( + 'playlist.whereExists(tracks.whereExists(genre=Rock)).related(tracks)', + queries.playlist + .whereExists('tracks', t => + t.whereExists('genre', g => g.where('name', 'Rock')), + ) + .related('tracks', t => + t.related('album', a => a.related('artist')).related('genre'), + ), +); + +// Artists who have albums with tracks in specific genre +benchmarkQuery( + 'artist.whereExists(albums.whereExists(tracks.whereExists(genre=Rock)))', + queries.artist.whereExists('albums', a => + a.whereExists('tracks', t => + t.whereExists('genre', g => g.where('name', 'Rock')), + ), + ), +); + +// Tracks that have been purchased (exist in invoice lines) +benchmarkQuery( + 'track.whereExists(invoiceLines).related(album, genre)', + queries.track + .whereExists('invoiceLines') + .related('album', a => a.related('artist')) + .related('genre'), +); + +// Complex: Customers who bought rock tracks, with their invoices +benchmarkQuery( + 'customer.whereExists(deep invoice->line->track->genre=Rock).related(supportRep)', + queries.customer + .whereExists('supportRep', rep => rep.whereExists('reportsToEmployee')) + .related('supportRep'), +); + +// ============================================ +// Filtered `related` queries +// ============================================ + +// Invoice with only high-quantity line items +benchmarkQuery( + 'invoice.related(lines where quantity>1, customer)', + queries.invoice + .related('lines', line => line.where('quantity', '>', 1).related('track')) + .related('customer'), +); + +// Album with filtered tracks (only long tracks) +benchmarkQuery( + 'album.related(tracks where milliseconds>300000, artist)', + queries.album + .related('tracks', t => t.where('milliseconds', '>', 300000)) + .related('artist'), +); + +// Album with ordered tracks and artist +benchmarkQuery( + 'album.related(tracks orderBy name, artist)', + queries.album + .related('tracks', t => t.orderBy('name', 'asc').limit(10)) + .related('artist'), +); + +// ============================================ +// Complex OR conditions with related +// ============================================ + +// Tracks from specific albums OR genres, with full data +benchmarkQuery( + 'track.where(OR album=BigOnes, genre=Rock).related(album, genre)', + queries.track + .where(({or, exists}) => + or( + exists('album', a => a.where('title', 'Big Ones')), + exists('genre', g => g.where('name', 'Rock')), + ), + ) + .related('album', a => a.related('artist')) + .related('genre'), +); + // Check if JSON output is requested via environment variable const format = process.env.BENCH_OUTPUT_FORMAT; diff --git a/packages/zqlite/package.json b/packages/zqlite/package.json index 0f1166439a..8175fd46c0 100644 --- a/packages/zqlite/package.json +++ b/packages/zqlite/package.json @@ -35,7 +35,7 @@ "@databases/escape-identifier": "^1.0.3", "@databases/sql": "^3.3.0", "@rocicorp/logger": "^5.4.0", - "@rocicorp/zero-sqlite3": "^1.0.12", + "@rocicorp/zero-sqlite3": "1.0.13", "zql": "0.0.0" } } diff --git a/packages/zqlite/src/db.ts b/packages/zqlite/src/db.ts index a9db3bf339..5bd0643fc4 100644 --- a/packages/zqlite/src/db.ts +++ b/packages/zqlite/src/db.ts @@ -229,6 +229,10 @@ export class Statement { return ret as T[]; } + explainQueryPlan() { + return this.#stmt.explainQueryPlan(); + } + iterate(...params: unknown[]): IterableIterator { return new LoggingIterableIterator( this.#lc.withContext('method', 'iterate'), diff --git a/packages/zqlite/src/explain-queries.ts b/packages/zqlite/src/explain-queries.ts index 87f668efd2..bd86823407 100644 --- a/packages/zqlite/src/explain-queries.ts +++ b/packages/zqlite/src/explain-queries.ts @@ -7,12 +7,9 @@ export function explainQueries(counts: RowCountsBySource, db: Database) { const queries = Object.keys(querySet); for (const query of queries) { const plan = db - // we should be more intelligent about value replacement. - // Different values result in different plans. E.g., picking a value at the start - // of an index will result in `scan` vs `search`. The scan is fine in that case. - .prepare(`EXPLAIN QUERY PLAN ${query.replaceAll('?', "'sdfse'")}`) - .all<{detail: string}>() - .map(r => r.detail); + .prepare(`EXPLAIN QUERY PLAN ${query}`) + .explainQueryPlan() + .map(r => (r as {detail: string}).detail); plans[query] = plan; } } diff --git a/packages/zqlite/src/internal/sql-inline.ts b/packages/zqlite/src/internal/sql-inline.ts index a0bb5b61fc..c698916ab8 100644 --- a/packages/zqlite/src/internal/sql-inline.ts +++ b/packages/zqlite/src/internal/sql-inline.ts @@ -1,6 +1,7 @@ import type {FormatConfig} from '@databases/sql'; import type {SQLQuery} from '@databases/sql'; import {escapeSQLiteIdentifier} from '@databases/escape-identifier'; +import {unwrapNamedValue} from './sql.ts'; /** * Escapes a SQLite string value by doubling single quotes. @@ -52,19 +53,22 @@ function inlineValue(value: unknown): string { */ const sqliteInlineFormat: FormatConfig = { escapeIdentifier: str => escapeSQLiteIdentifier(str), - formatValue: value => + formatValue: value => { + // Unwrap NamedValue to get the underlying value + const unwrapped = unwrapNamedValue(value); // undefined is our signal to use a placeholder // IMPORTANT. Changing this will break the planner as it will assume `NULL` // for constraints! - value === undefined + return unwrapped === undefined ? { placeholder: '?', - value, + value: unwrapped, } : { - placeholder: inlineValue(value), + placeholder: inlineValue(unwrapped), value: undefined, // No binding needed since value is inlined - }, + }; + }, }; /** diff --git a/packages/zqlite/src/internal/sql.test.ts b/packages/zqlite/src/internal/sql.test.ts index cf7be52528..eb35e798e7 100644 --- a/packages/zqlite/src/internal/sql.test.ts +++ b/packages/zqlite/src/internal/sql.test.ts @@ -1,5 +1,5 @@ -import {expect, test} from 'vitest'; -import {compile, sql} from './sql.ts'; +import {describe, expect, test} from 'vitest'; +import {compile, format, formatNamed, named, sql} from './sql.ts'; test('can do empty slots', () => { const str = compile(sql`INSERT INTO foo (id, name) VALUES (?, ?)`); @@ -17,3 +17,67 @@ test('escapes identifiers as advertised', () => { const str = compile(sql`SELECT * FROM ${sql.ident('foo"bar')}`); expect(str).toMatchInlineSnapshot(`"SELECT * FROM "foo""bar""`); }); + +describe('named()', () => { + test('creates NamedValue wrapper', () => { + const result = named('myParam', 42); + expect(result).toHaveProperty('name', 'myParam'); + expect(result).toHaveProperty('value', 42); + }); + + test('preserves various value types', () => { + expect(named('str', 'hello').value).toBe('hello'); + expect(named('num', 123.45).value).toBe(123.45); + expect(named('bool', true).value).toBe(true); + expect(named('nil', null).value).toBe(null); + expect(named('obj', {a: 1}).value).toEqual({a: 1}); + expect(named('arr', [1, 2, 3]).value).toEqual([1, 2, 3]); + }); +}); + +describe('formatNamed()', () => { + test('simple query with named value produces :name placeholder', () => { + const query = sql`SELECT * FROM users WHERE id = ${named('userId', 42)}`; + const result = formatNamed(query); + expect(result.text).toBe('SELECT * FROM users WHERE id = :userId'); + expect(result.values).toEqual({userId: 42}); + }); + + test('multiple named values in same query', () => { + const query = sql`SELECT * FROM users WHERE id = ${named('id', 1)} AND status = ${named('status', 'active')}`; + const result = formatNamed(query); + expect(result.text).toBe( + 'SELECT * FROM users WHERE id = :id AND status = :status', + ); + expect(result.values).toEqual({id: 1, status: 'active'}); + }); + + test('mixed named and unnamed values', () => { + const query = sql`SELECT * FROM users WHERE id = ${named('id', 1)} AND age > ${25}`; + const result = formatNamed(query); + expect(result.text).toBe( + 'SELECT * FROM users WHERE id = :id AND age > :_p1', + ); + expect(result.values).toEqual({id: 1, _p1: 25}); + }); + + test('same named placeholder appearing multiple times shares value', () => { + const value = named('x', 10); + const query = sql`SELECT * FROM t WHERE a > ${value} AND b < ${value}`; + const result = formatNamed(query); + expect(result.text).toBe('SELECT * FROM t WHERE a > :x AND b < :x'); + // Both occurrences map to same key + expect(result.values).toEqual({x: 10}); + }); +}); + +describe('format() backwards compatibility', () => { + test('NamedValue passed to format() is unwrapped', () => { + const query = sql`SELECT * FROM t WHERE a = ${named('myName', 42)}`; + const result = format(query); + // format() uses positional placeholders + expect(result.text).toBe('SELECT * FROM t WHERE a = ?'); + // Value is unwrapped from NamedValue + expect(result.values).toEqual([42]); + }); +}); diff --git a/packages/zqlite/src/internal/sql.ts b/packages/zqlite/src/internal/sql.ts index 4cf9188898..c27ade8735 100644 --- a/packages/zqlite/src/internal/sql.ts +++ b/packages/zqlite/src/internal/sql.ts @@ -2,9 +2,45 @@ import type {SQLQuery, FormatConfig} from '@databases/sql'; import baseSql from '@databases/sql'; import {escapeSQLiteIdentifier} from '@databases/escape-identifier'; +// Named placeholder support for query caching + +const NAMED_VALUE = Symbol('namedValue'); + +type NamedValue = { + [NAMED_VALUE]: true; + name: string; + value: unknown; +}; + +/** + * Wraps a value with a name for use with named SQL placeholders. + * Use with `formatNamed()` to produce SQL like `WHERE col = :name`. + */ +export function named(name: string, value: unknown): NamedValue { + return {[NAMED_VALUE]: true, name, value}; +} + +function isNamedValue(v: unknown): v is NamedValue { + return v !== null && typeof v === 'object' && NAMED_VALUE in v; +} + +/** + * Unwraps a NamedValue to get the underlying value. + * Returns the original value if not a NamedValue. + */ +export function unwrapNamedValue(v: unknown): unknown { + return isNamedValue(v) ? v.value : v; +} + const sqliteFormat: FormatConfig = { escapeIdentifier: str => escapeSQLiteIdentifier(str), - formatValue: value => ({placeholder: '?', value}), + formatValue: value => { + // Unwrap NamedValue for backwards compatibility with positional format + if (isNamedValue(value)) { + return {placeholder: '?', value: value.value}; + } + return {placeholder: '?', value}; + }, }; export function compile(sql: SQLQuery): string { @@ -16,3 +52,35 @@ export function format(sql: SQLQuery) { } export const sql = baseSql.default; + +/** + * Formats a SQL query using named placeholders (`:name` syntax). + * Returns an object with text and a values Record for use with + * better-sqlite3's named parameter binding. + * + * Values wrapped with `named()` use their specified name. + * Unwrapped values get auto-generated names like `_p0`, `_p1`, etc. + */ +export function formatNamed(query: SQLQuery): { + text: string; + values: Record; +} { + const values: Record = {}; + + const config: FormatConfig = { + escapeIdentifier: str => escapeSQLiteIdentifier(str), + formatValue: (value, index) => { + if (isNamedValue(value)) { + values[value.name] = value.value; + return {placeholder: `:${value.name}`, value: value.value}; + } + // For non-named values, use index-based name + const name = `_p${index}`; + values[name] = value; + return {placeholder: `:${name}`, value}; + }, + }; + + const result = query.format(config); + return {text: result.text, values}; +} diff --git a/packages/zqlite/src/internal/statement-cache.ts b/packages/zqlite/src/internal/statement-cache.ts index 2ac2131466..3baccd5dfa 100644 --- a/packages/zqlite/src/internal/statement-cache.ts +++ b/packages/zqlite/src/internal/statement-cache.ts @@ -50,6 +50,11 @@ export class StatementCache { return this.#size; } + // the database connection used to prepare statements + get db() { + return this.#db; + } + drop(n: number) { assert(n >= 0, 'Cannot drop a negative number of items'); assert(n <= this.#size, 'Cannot drop more items than are in the cache'); diff --git a/packages/zqlite/src/query-builder.test.ts b/packages/zqlite/src/query-builder.test.ts new file mode 100644 index 0000000000..212fd42ca6 --- /dev/null +++ b/packages/zqlite/src/query-builder.test.ts @@ -0,0 +1,252 @@ +import {describe, expect, test} from 'vitest'; +import {formatNamed} from './internal/sql.ts'; +import { + buildSelectQuery, + constraintsToSQL, + orderByToSQL, +} from './query-builder.ts'; + +describe('constraintsToSQL', () => { + const columns = { + id: {type: 'string'}, + a: {type: 'number'}, + b: {type: 'number'}, + active: {type: 'boolean'}, + data: {type: 'json'}, + } as const; + + test('returns empty array for undefined constraint', () => { + expect(constraintsToSQL(undefined, columns)).toEqual([]); + }); + + test('single constraint produces c_{col} named placeholder', () => { + const result = constraintsToSQL({a: 42}, columns); + expect(result).toHaveLength(1); + const formatted = formatNamed(result[0]); + expect(formatted.text).toBe('"a" = :c_a'); + expect(formatted.values).toEqual({c_a: 42}); + }); + + test('multiple constraints produce sorted keys with c_ prefixes', () => { + const result = constraintsToSQL({b: 2, a: 1}, columns); + expect(result).toHaveLength(2); + // Keys are sorted, so 'a' comes before 'b' + expect(formatNamed(result[0]).text).toBe('"a" = :c_a'); + expect(formatNamed(result[1]).text).toBe('"b" = :c_b'); + expect(formatNamed(result[0]).values).toEqual({c_a: 1}); + expect(formatNamed(result[1]).values).toEqual({c_b: 2}); + }); + + test('boolean constraint is converted to 0/1', () => { + const result = constraintsToSQL({active: true}, columns); + const formatted = formatNamed(result[0]); + expect(formatted.text).toBe('"active" = :c_active'); + expect(formatted.values).toEqual({c_active: 1}); + }); + + test('json constraint is stringified', () => { + const result = constraintsToSQL({data: {foo: 'bar'}}, columns); + const formatted = formatNamed(result[0]); + expect(formatted.text).toBe('"data" = :c_data'); + expect(formatted.values).toEqual({c_data: '{"foo":"bar"}'}); + }); +}); + +describe('orderByToSQL', () => { + test('simple ascending order', () => { + const result = formatNamed(orderByToSQL([['id', 'asc']], false)); + expect(result.text).toBe('ORDER BY "id" asc'); + }); + + test('simple descending order', () => { + const result = formatNamed(orderByToSQL([['id', 'desc']], false)); + expect(result.text).toBe('ORDER BY "id" desc'); + }); + + test('compound order', () => { + const result = formatNamed( + orderByToSQL( + [ + ['a', 'asc'], + ['b', 'desc'], + ], + false, + ), + ); + expect(result.text).toBe('ORDER BY "a" asc, "b" desc'); + }); + + test('reverse flips directions', () => { + const result = formatNamed( + orderByToSQL( + [ + ['a', 'asc'], + ['b', 'desc'], + ], + true, + ), + ); + expect(result.text).toBe('ORDER BY "a" desc, "b" asc'); + }); +}); + +describe('buildSelectQuery', () => { + const columns = { + id: {type: 'string'}, + a: {type: 'number'}, + b: {type: 'number'}, + } as const; + + test('basic select with order', () => { + const query = buildSelectQuery( + 'mytable', + columns, + undefined, // constraint + undefined, // filters + [['id', 'asc']], + false, // reverse + undefined, // start + ); + const result = formatNamed(query); + expect(result.text).toBe( + 'SELECT "id","a","b" FROM "mytable" ORDER BY "id" asc', + ); + expect(result.values).toEqual({}); + }); + + test('select with constraint uses c_ prefix', () => { + const query = buildSelectQuery( + 'mytable', + columns, + {a: 42}, + undefined, + [['id', 'asc']], + false, + undefined, + ); + const result = formatNamed(query); + expect(result.text).toBe( + 'SELECT "id","a","b" FROM "mytable" WHERE "a" = :c_a ORDER BY "id" asc', + ); + expect(result.values).toEqual({c_a: 42}); + }); + + test('select with filters uses f_ prefix', () => { + const query = buildSelectQuery( + 'mytable', + columns, + undefined, + { + type: 'simple', + left: {type: 'column', name: 'b'}, + op: '>', + right: {type: 'literal', value: 10}, + }, + [['id', 'asc']], + false, + undefined, + ); + const result = formatNamed(query); + expect(result.text).toBe( + 'SELECT "id","a","b" FROM "mytable" WHERE "b" > :f_0 ORDER BY "id" asc', + ); + expect(result.values).toEqual({f_0: 10}); + }); + + test('select with start uses s_ prefix', () => { + const query = buildSelectQuery( + 'mytable', + columns, + undefined, + undefined, + [['id', 'asc']], + false, + {row: {id: 'abc', a: 1, b: 2}, basis: 'after'}, + ); + const result = formatNamed(query); + expect(result.text).toBe( + 'SELECT "id","a","b" FROM "mytable" WHERE (((:s_id IS NULL OR "id" > :s_id))) ORDER BY "id" asc', + ); + expect(result.values).toEqual({s_id: 'abc'}); + }); + + test('select with start basis=at includes exact match', () => { + const query = buildSelectQuery( + 'mytable', + columns, + undefined, + undefined, + [['id', 'asc']], + false, + {row: {id: 'xyz', a: 1, b: 2}, basis: 'at'}, + ); + const result = formatNamed(query); + // 'at' includes an OR clause for exact match + expect(result.text).toBe( + 'SELECT "id","a","b" FROM "mytable" WHERE (((:s_id IS NULL OR "id" > :s_id)) OR ("id" IS :s_id)) ORDER BY "id" asc', + ); + expect(result.values).toEqual({s_id: 'xyz'}); + }); + + test('select with compound order and start', () => { + const query = buildSelectQuery( + 'mytable', + columns, + undefined, + undefined, + [ + ['a', 'asc'], + ['b', 'desc'], + ['id', 'asc'], + ], + false, + {row: {id: '1', a: 10, b: 20}, basis: 'after'}, + ); + const result = formatNamed(query); + // Complex start constraints with multiple OR clauses + expect(result.text).toMatchInlineSnapshot( + `"SELECT "id","a","b" FROM "mytable" WHERE (((:s_a IS NULL OR "a" > :s_a)) OR ("a" IS :s_a AND ("b" IS NULL OR "b" < :s_b)) OR ("a" IS :s_a AND "b" IS :s_b AND (:s_id IS NULL OR "id" > :s_id))) ORDER BY "a" asc, "b" desc, "id" asc"`, + ); + expect(result.values).toEqual({s_a: 10, s_b: 20, s_id: '1'}); + }); + + test('select with constraint, filters, and start combined', () => { + const query = buildSelectQuery( + 'mytable', + columns, + {a: 5}, + { + type: 'simple', + left: {type: 'column', name: 'b'}, + op: '<', + right: {type: 'literal', value: 100}, + }, + [['id', 'asc']], + false, + {row: {id: 'start', a: 5, b: 50}, basis: 'after'}, + ); + const result = formatNamed(query); + expect(result.text).toBe( + 'SELECT "id","a","b" FROM "mytable" WHERE "a" = :c_a AND (((:s_id IS NULL OR "id" > :s_id))) AND "b" < :f_0 ORDER BY "id" asc', + ); + expect(result.values).toEqual({c_a: 5, s_id: 'start', f_0: 100}); + }); + + test('reverse direction changes comparison operators in start', () => { + const query = buildSelectQuery( + 'mytable', + columns, + undefined, + undefined, + [['id', 'asc']], + true, // reverse + {row: {id: 'abc', a: 1, b: 2}, basis: 'after'}, + ); + const result = formatNamed(query); + // With reverse=true and asc order, comparison flips to < instead of > + expect(result.text).toBe( + 'SELECT "id","a","b" FROM "mytable" WHERE ((("id" IS NULL OR "id" < :s_id))) ORDER BY "id" desc', + ); + expect(result.values).toEqual({s_id: 'abc'}); + }); +}); diff --git a/packages/zqlite/src/query-builder.ts b/packages/zqlite/src/query-builder.ts index 9e8d38af4f..427d4663e9 100644 --- a/packages/zqlite/src/query-builder.ts +++ b/packages/zqlite/src/query-builder.ts @@ -9,7 +9,7 @@ import type { SchemaValue, ValueType, } from '../../zero-schema/src/table-schema.ts'; -import {sql} from './internal/sql.ts'; +import {named, sql} from './internal/sql.ts'; import type {Constraint} from '../../zql/src/ivm/constraint.ts'; import type {Start} from '../../zql/src/ivm/operator.ts'; @@ -41,6 +41,9 @@ export function buildSelectQuery( constraints.push(gatherStartConstraints(start, reverse, order, columns)); } + // Note: do filters first + // Perma-bind them + // Get max index so we know where to start constraints and start from if (filters) { constraints.push(filtersToSQL(filters)); } @@ -60,14 +63,15 @@ export function constraintsToSQL( return []; } - const constraints: SQLQuery[] = []; - for (const [key, value] of Object.entries(constraint)) { - constraints.push( - sql`${sql.ident(key)} = ${toSQLiteType(value, columns[key].type)}`, - ); - } - - return constraints; + // Sort keys for consistent ordering - enables cache key matching + const sortedKeys = Object.keys(constraint).sort(); + return sortedKeys.map( + key => + sql`${sql.ident(key)} = ${named( + `c_${key}`, + toSQLiteType(constraint[key], columns[key].type), + )}`, + ); } export function orderByToSQL(order: Ordering, reverse: boolean): SQLQuery { @@ -94,16 +98,22 @@ export function orderByToSQL(order: Ordering, reverse: boolean): SQLQuery { /** * Converts filters (conditions) to SQL WHERE clause. * This applies all filters present in the AST for a query to the source. + * + * Named placeholder scheme: `f_{n}` where n is incremented for each value. + * Pass a counter object to track the current index across recursive calls. */ -export function filtersToSQL(filters: NoSubqueryCondition): SQLQuery { +export function filtersToSQL( + filters: NoSubqueryCondition, + counter: {n: number} = {n: 0}, +): SQLQuery { switch (filters.type) { case 'simple': - return simpleConditionToSQL(filters); + return simpleConditionToSQL(filters, counter); case 'and': return filters.conditions.length > 0 ? sql`(${sql.join( filters.conditions.map(condition => - filtersToSQL(condition as NoSubqueryCondition), + filtersToSQL(condition as NoSubqueryCondition, counter), ), sql` AND `, )})` @@ -112,7 +122,7 @@ export function filtersToSQL(filters: NoSubqueryCondition): SQLQuery { return filters.conditions.length > 0 ? sql`(${sql.join( filters.conditions.map(condition => - filtersToSQL(condition as NoSubqueryCondition), + filtersToSQL(condition as NoSubqueryCondition, counter), ), sql` OR `, )})` @@ -120,17 +130,22 @@ export function filtersToSQL(filters: NoSubqueryCondition): SQLQuery { } } -function simpleConditionToSQL(filter: SimpleCondition): SQLQuery { +function simpleConditionToSQL( + filter: SimpleCondition, + counter: {n: number}, +): SQLQuery { const {op} = filter; if (op === 'IN' || op === 'NOT IN') { switch (filter.right.type) { case 'literal': return sql`${valuePositionToSQL( filter.left, + counter, )} ${sql.__dangerous__rawValue( filter.op, - )} (SELECT value FROM json_each(${JSON.stringify( - filter.right.value, + )} (SELECT value FROM json_each(${named( + `f_${counter.n++}`, + JSON.stringify(filter.right.value), )}))`; case 'static': throw new Error( @@ -138,7 +153,7 @@ function simpleConditionToSQL(filter: SimpleCondition): SQLQuery { ); } } - return sql`${valuePositionToSQL(filter.left)} ${sql.__dangerous__rawValue( + return sql`${valuePositionToSQL(filter.left, counter)} ${sql.__dangerous__rawValue( // SQLite's LIKE operator is case-insensitive by default, so we // convert ILIKE to LIKE and NOT ILIKE to NOT LIKE. filter.op === 'ILIKE' @@ -146,15 +161,21 @@ function simpleConditionToSQL(filter: SimpleCondition): SQLQuery { : filter.op === 'NOT ILIKE' ? 'NOT LIKE' : filter.op, - )} ${valuePositionToSQL(filter.right)}`; + )} ${valuePositionToSQL(filter.right, counter)}`; } -function valuePositionToSQL(value: ValuePosition): SQLQuery { +function valuePositionToSQL( + value: ValuePosition, + counter: {n: number}, +): SQLQuery { switch (value.type) { case 'column': return sql.ident(value.name); case 'literal': - return sql`${toSQLiteType(value.value, getJsType(value.value))}`; + return sql`${named( + `f_${counter.n++}`, + toSQLiteType(value.value, getJsType(value.value)), + )}`; case 'static': throw new Error( 'Static parameters must be replaced before conversion to SQL', @@ -203,6 +224,9 @@ export function toSQLiteType(v: unknown, type: ValueType): unknown { * * - after vs before flips the comparison operators. * - inclusive adds a final `OR` clause for the exact match. + * + * Named placeholder scheme: `s_{colName}` for each column in start row. + * SQLite binds the same value to all occurrences of the same param name. */ function gatherStartConstraints( start: Start, @@ -222,36 +246,37 @@ function gatherStartConstraints( from[iField], columnTypes[iField].type, ); + const paramName = `s_${iField}`; if (iDirection === 'asc') { if (!reverse) { group.push( - sql`(${constraintValue} IS NULL OR ${sql.ident(iField)} > ${constraintValue})`, + sql`(${named(paramName, constraintValue)} IS NULL OR ${sql.ident(iField)} > ${named(paramName, constraintValue)})`, ); } else { reverse satisfies true; group.push( - sql`(${sql.ident(iField)} IS NULL OR ${sql.ident(iField)} < ${constraintValue})`, + sql`(${sql.ident(iField)} IS NULL OR ${sql.ident(iField)} < ${named(paramName, constraintValue)})`, ); } } else { iDirection satisfies 'desc'; if (!reverse) { group.push( - sql`(${sql.ident(iField)} IS NULL OR ${sql.ident(iField)} < ${constraintValue})`, + sql`(${sql.ident(iField)} IS NULL OR ${sql.ident(iField)} < ${named(paramName, constraintValue)})`, ); } else { reverse satisfies true; group.push( - sql`(${constraintValue} IS NULL OR ${sql.ident(iField)} > ${constraintValue})`, + sql`(${named(paramName, constraintValue)} IS NULL OR ${sql.ident(iField)} > ${named(paramName, constraintValue)})`, ); } } } else { const [jField] = order[j]; group.push( - sql`${sql.ident(jField)} IS ${toSQLiteType( - from[jField], - columnTypes[jField].type, + sql`${sql.ident(jField)} IS ${named( + `s_${jField}`, + toSQLiteType(from[jField], columnTypes[jField].type), )}`, ); } @@ -264,9 +289,9 @@ function gatherStartConstraints( sql`(${sql.join( order.map( s => - sql`${sql.ident(s[0])} IS ${toSQLiteType( - from[s[0]], - columnTypes[s[0]].type, + sql`${sql.ident(s[0])} IS ${named( + `s_${s[0]}`, + toSQLiteType(from[s[0]], columnTypes[s[0]].type), )}`, ), sql` AND `, diff --git a/packages/zqlite/src/table-source.ts b/packages/zqlite/src/table-source.ts index 617fd10cef..b2fb9cd6b5 100644 --- a/packages/zqlite/src/table-source.ts +++ b/packages/zqlite/src/table-source.ts @@ -18,6 +18,7 @@ import { createPredicate, transformFilters, } from '../../zql/src/builder/filter.ts'; +import type {Constraint} from '../../zql/src/ivm/constraint.ts'; import {makeComparator, type Node} from '../../zql/src/ivm/data.ts'; import { generateWithOverlay, @@ -26,7 +27,7 @@ import { type Connection, type Overlay, } from '../../zql/src/ivm/memory-source.ts'; -import {type FetchRequest} from '../../zql/src/ivm/operator.ts'; +import {type FetchRequest, type Start} from '../../zql/src/ivm/operator.ts'; import type {SourceSchema} from '../../zql/src/ivm/schema.ts'; import { type Source, @@ -34,11 +35,12 @@ import { type SourceInput, } from '../../zql/src/ivm/source.ts'; import type {Stream} from '../../zql/src/ivm/stream.ts'; -import type {Database, Statement} from './db.ts'; -import {compile, format, sql} from './internal/sql.ts'; +import {type Database, type Statement} from './db.ts'; +import {compile, format, formatNamed, sql} from './internal/sql.ts'; import {StatementCache} from './internal/statement-cache.ts'; import { buildSelectQuery, + filtersToSQL, toSQLiteType, type NoSubqueryCondition, } from './query-builder.ts'; @@ -53,8 +55,40 @@ type Statements = { readonly getExisting: Statement; }; +/** + * A slot in the statement cache - one prepared statement with in-use tracking. + */ +type StatementSlot = { + statement: Statement; + inUse: boolean; +}; + +/** + * Cached statements for a given cache key. + * Stores up to MAX_CACHED_STATEMENTS to allow re-entrant cached calls. + */ +type CachedStatement = { + sql: string; + statements: StatementSlot[]; +}; + +/** + * Extended Connection type with per-connection query caching. + * Filter values are pre-computed at connection time since they're static. + * Statements are cached per (database, cacheKey) to avoid repeated prepare() calls. + */ +type TableConnection = Connection & { + /** Pre-computed filter values (static for connection lifetime) */ + readonly filterValues: Record; + /** Per-database statement cache: cacheKey -> CachedStatement */ + readonly stmtCache: WeakMap>; +}; + let eventCount = 0; +/** Max duplicate statements per cache key to allow re-entrant cached calls. */ +const MAX_CACHED_STATEMENTS = 4; + /** * A source that is backed by a SQLite table. * @@ -71,7 +105,7 @@ let eventCount = 0; */ export class TableSource implements Source { readonly #dbCache = new WeakMap(); - readonly #connections: Connection[] = []; + readonly #connections: TableConnection[] = []; readonly #table: string; readonly #columns: Record; // Maps sorted columns JSON string (e.g. '["a","b"]) to Set of columns. @@ -225,6 +259,15 @@ export class TableSource implements Source { debug?: DebugDelegate, ) { const transformedFilters = transformFilters(filters); + + // Pre-compute filter values at connection time (they're static) + let filterValues: Record = {}; + if (transformedFilters.filters) { + const filterSQL = filtersToSQL(transformedFilters.filters); + const formatted = formatNamed(filterSQL); + filterValues = formatted.values; + } + const input: SourceInput = { getSchema: () => schema, fetch: req => this.#fetch(req, connection), @@ -235,11 +278,12 @@ export class TableSource implements Source { const idx = this.#connections.indexOf(connection); assert(idx !== -1, 'Connection not found'); this.#connections.splice(idx, 1); + // Connection's stmtCache WeakMap is automatically GC'd }, fullyAppliedFilters: !transformedFilters.conditionsRemoved, }; - const connection: Connection = { + const connection: TableConnection = { input, debug, output: undefined, @@ -252,7 +296,10 @@ export class TableSource implements Source { } : undefined, compareRows: makeComparator(sort), + // Per-connection cache for query statements lastPushedEpoch: 0, + filterValues, + stmtCache: new WeakMap(), }; const schema = this.#getSchema(connection); assertOrderingIncludesPK(sort, this.#primaryKey); @@ -270,22 +317,100 @@ export class TableSource implements Source { ) as Row; } - *#fetch(req: FetchRequest, connection: Connection): Stream { - const {sort, debug} = connection; + /** + * Generates a cache key from the structural parts of a fetch request. + * Format: constraintCols|startCols|basis|direction + */ + #makeCacheKey( + constraint: Constraint | undefined, + start: Start | undefined, + reverse: boolean | undefined, + ): string { + const constraintCols = constraint + ? JSON.stringify(Object.keys(constraint).sort()) + : ''; + const startCols = start + ? JSON.stringify(Object.keys(start.row).sort()) + : ''; + const basis = start?.basis ?? ''; + const dir = reverse ? 'r' : 'f'; + return `${constraintCols}|${startCols}|${basis}|${dir}`; + } + + /** Extracts row values as named parameters with the given prefix. */ + #extractValues( + prefix: string, + row: Row | undefined, + ): Record { + if (!row) return {}; + const values: Record = {}; + for (const [key, value] of Object.entries(row)) { + values[`${prefix}_${key}`] = toSQLiteType(value, this.#columns[key].type); + } + return values; + } - const query = this.#requestToSQL(req, connection.filters?.condition, sort); - const sqlAndBindings = format(query); + *#fetch( + req: FetchRequest, + connection: TableConnection, + ): Stream { + const {sort, debug, filterValues, stmtCache} = connection; + const cacheKey = this.#makeCacheKey(req.constraint, req.start, req.reverse); + + // Get or create per-database cache map + const db = this.#stmts.cache.db; + let dbCache = stmtCache.get(db); + if (!dbCache) { + dbCache = new Map(); + stmtCache.set(db, dbCache); + } - const cachedStatement = this.#stmts.cache.get(sqlAndBindings.text); - try { - cachedStatement.statement.safeIntegers(true); - const rowIterator = cachedStatement.statement.iterate( - ...sqlAndBindings.values, + // Try to get cached statement (supports up to MAX_CACHED_STATEMENTS re-entrant calls) + const cached = dbCache.get(cacheKey); + let statement: Statement; + let sqlText: string; + let usedSlot = cached?.statements.find(s => !s.inUse); + + if (usedSlot) { + // Reuse existing cached statement + usedSlot.inUse = true; + statement = usedSlot.statement; + sqlText = must(cached).sql; + } else { + // Build SQL and prepare statement + const query = this.#requestToSQL( + req, + connection.filters?.condition, + sort, ); + ({text: sqlText} = formatNamed(query)); + statement = db.prepare(sqlText); + + // Cache if room available + if (!cached) { + usedSlot = {statement, inUse: true}; + dbCache.set(cacheKey, {sql: sqlText, statements: [usedSlot]}); + } else if (cached.statements.length < MAX_CACHED_STATEMENTS) { + usedSlot = {statement, inUse: true}; + cached.statements.push(usedSlot); + } + // else: all slots in use, usedSlot stays undefined (temporary) + } + + // Extract bind values directly (fast - no SQL building on cache hit) + const values: Record = { + ...this.#extractValues('c', req.constraint), + ...this.#extractValues('s', req.start?.row), + ...filterValues, + }; + + try { + statement.safeIntegers(true); + const rowIterator = statement.iterate(values); const comparator = makeComparator(sort, req.reverse); - debug?.initQuery(this.#table, sqlAndBindings.text); + debug?.initQuery(this.#table, sqlText); yield* generateWithStart( generateWithYields( @@ -294,7 +419,7 @@ export class TableSource implements Source { this.#mapFromSQLiteTypes( this.#columns, rowIterator, - sqlAndBindings.text, + sqlText, debug, ), req.constraint, @@ -313,7 +438,7 @@ export class TableSource implements Source { let totalNvisit = 0; let i = 0; while (true) { - const nvisit = cachedStatement.statement.scanStatus( + const nvisit = statement.scanStatus( i++, SQLite3Database.SQLITE_SCANSTAT_NVISIT, 1, @@ -324,11 +449,13 @@ export class TableSource implements Source { totalNvisit += Number(nvisit); } if (totalNvisit !== 0) { - debug.recordNVisit(this.#table, sqlAndBindings.text, totalNvisit); + debug.recordNVisit(this.#table, sqlText, totalNvisit); } - cachedStatement.statement.scanStatusReset(); + statement.scanStatusReset(); + } + if (usedSlot) { + usedSlot.inUse = false; } - this.#stmts.cache.return(cachedStatement); } }