Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hacky implementation to test use of prepared statements #486

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions packages/core/src/QueryManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export class QueryManager {
async submit(request, result) {
try {
const { query, type, cache = false, record = true, options } = request;
const sql = query ? `${query}` : null;
const params = [];
let sql = query ? query.toString(params) : null;

// update recorders
if (record) {
Expand All @@ -58,9 +59,9 @@ export class QueryManager {
// issue query, potentially cache result
const t0 = performance.now();
if (this._logQueries) {
this._logger.debug('Query', { type, sql, ...options });
this._logger.debug('Query', { type, sql, params, ...options });
}
const data = await this.db.query({ type, sql, ...options });
const data = await this.db.query({ type, sql, params: params.length ? params : undefined, ...options });
if (cache) this.clientCache.set(sql, data);
this._logger.debug(`Request: ${(performance.now() - t0).toFixed(1)}`);
result.fulfill(data);
Expand Down
2 changes: 1 addition & 1 deletion packages/duckdb/bin/run-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
import { DuckDB, dataServer } from '../src/index.js';

// the database to connect to, default is main memory
const dbPath = process.argv[2] || ':memory:';
const dbPath = process.argv[2] || './test.db';

dataServer(new DuckDB(dbPath), { rest: true, socket: true });
6 changes: 4 additions & 2 deletions packages/duckdb/src/Cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import path from 'node:path';
const DEFAULT_CACHE_DIR = '.cache';
const DEFAULT_TTL = 1000 * 60 * 60 * 24 * 7; // 7 days

export function cacheKey(hashable, type) {
return createHash('sha256').update(hashable).digest('hex') + '.' + type;
export function cacheKey(hashable, type, params) {
const hash = createHash('sha256').update(hashable);
if (params) hash.update(params.join(","));
return hash.digest('hex') + '.' + type;
}

class CacheEntry {
Expand Down
11 changes: 9 additions & 2 deletions packages/duckdb/src/DuckDB.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export class DuckDB {
this.db = new duckdb.Database(path, config);
this.con = this.db.connect();
this.exec(initStatements);
this.preparedStatements = new Map;
}

close() {
Expand All @@ -35,7 +36,13 @@ export class DuckDB {
}

prepare(sql) {
return new DuckDBStatement(this.con.prepare(sql));
let statement = this.preparedStatements.get(sql);
if (statement) {
return statement
}
statement = new DuckDBStatement(this.con.prepare(sql));
this.preparedStatements.set(sql, statement);
return statement;
}

exec(sql) {
Expand Down Expand Up @@ -114,7 +121,7 @@ export class DuckDBStatement {

arrowBuffer(params) {
return new Promise((resolve, reject) => {
this.con.arrowIPCAll(...params, (err, result) => {
this.statement.arrowIPCAll(...params, (err, result) => {
if (err) {
reject(err);
} else {
Expand Down
25 changes: 18 additions & 7 deletions packages/duckdb/src/data-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,14 @@ export function queryHandler(db, queryCache) {

// retrieve query result
async function retrieve(query, get) {
const { sql, type, persist } = query;
const key = cacheKey(sql, type);
const { sql, type, persist, params } = query;
const key = cacheKey(sql, type, params);
let result = queryCache?.get(key);

if (result) {
console.log('CACHE HIT');
} else {
result = await get(sql);
result = await get(params ?? sql);
if (persist) {
queryCache?.set(key, result, { persist });
}
Expand All @@ -102,8 +102,13 @@ export function queryHandler(db, queryCache) {
}

try {
const { sql, type = 'json' } = query;
console.log(`> ${type.toUpperCase()}${sql ? ' ' + sql : ''}`);
const { sql, type = 'json', params } = query;
console.log(`> ${type.toUpperCase()}${sql ? ' ' + sql : ''}`, params);

let statement;
if (params) {
statement = db.prepare(sql);
}

// process query and return result
switch (type) {
Expand All @@ -112,13 +117,19 @@ export function queryHandler(db, queryCache) {
await db.exec(sql);
res.done();
break;
case 'prepare':
// Prepare the query for later execution
await db.prepare(sql);
res.done();
break;
case 'arrow':
// Apache Arrow response format
res.arrow(await retrieve(query, sql => db.arrowBuffer(sql)));
console.log(statement, params)
res.arrow(await retrieve(query, statement?.arrowBuffer.bind(statement) ?? db.arrowBuffer.bind(db)));
break;
case 'json':
// JSON response format
res.json(await retrieve(query, sql => db.query(sql)));
res.json(await retrieve(query, statement?.query.bind(statement) ?? db.query.bind(db)));
break;
case 'create-bundle':
// Create a named bundle of precomputed resources
Expand Down
20 changes: 20 additions & 0 deletions packages/duckdb/test/duckdb-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,24 @@ describe('DuckDB', () => {
await db.exec('DROP TABLE json');
});
});

describe('prepare', () => {
it('can run a prepared statement', async () => {
const statement = db.prepare('SELECT ?+? AS foo');
const res0 = await statement.query([1,2]);
assert.deepEqual(res0, [{foo: 3}]);

const res1 = await statement.query([2,3]);
assert.deepEqual(res1, [{foo: 5}]);
});

it('can run a prepared arrow statement', async () => {
const statement = db.prepare('SELECT ?+? AS foo');
const res0 = await statement.arrowBuffer([1,2]);
assert.deepEqual(res0, [{foo: 3}]);

const res1 = await statement.arrowBuffer([2,3]);
assert.deepEqual(res1, [{foo: 5}]);
});
});
});
10 changes: 5 additions & 5 deletions packages/sql/src/Query.js
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ export class Query {
return q;
}

toString() {
toString(params = []) {
const {
with: cte, select, distinct, from, sample, where, groupby,
having, window, qualify, orderby, limit, offset
Expand All @@ -421,7 +421,7 @@ export class Query {

// WITH
if (cte.length) {
const list = cte.map(({ as, query })=> `"${as}" AS (${query})`);
const list = cte.map(({ as, query })=> `"${as.toString(params)}" AS (${query})`);
sql.push(`WITH ${list.join(', ')}`);
}

Expand All @@ -444,7 +444,7 @@ export class Query {

// WHERE
if (where.length) {
const clauses = where.map(String).filter(x => x).join(' AND ');
const clauses = where.map(c => c.toString(params)).filter(x => x).join(' AND ');
if (clauses) sql.push(`WHERE ${clauses}`);
}

Expand All @@ -463,13 +463,13 @@ export class Query {

// HAVING
if (having.length) {
const clauses = having.map(String).filter(x => x).join(' AND ');
const clauses = having.map(c => c.toString(params)).filter(x => x).join(' AND ');
if (clauses) sql.push(`HAVING ${clauses}`);
}

// WINDOW
if (window.length) {
const windows = window.map(({ as, expr }) => `"${as}" AS (${expr})`);
const windows = window.map(({ as, expr }) => `"${as.toString(params)}" AS (${expr})`);
sql.push(`WINDOW ${windows.join(', ')}`);
}

Expand Down
21 changes: 14 additions & 7 deletions packages/sql/src/expression.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,13 @@ export class SQLExpression {
* @param {string[]} [columns=[]] The column dependencies
* @param {object} [props] Additional properties for this expression.
*/
constructor(parts, columns, props) {
constructor(parts, columns, queryParams, props) {
this._expr = Array.isArray(parts) ? parts : [parts];
this._deps = columns || [];
this.annotate(props);

this.params = queryParams;

const params = this._expr.filter(part => isParamLike(part));
if (params.length > 0) {
/** @type {ParamLike[]} */
Expand Down Expand Up @@ -108,9 +110,13 @@ export class SQLExpression {
* Generate a SQL code string corresponding to this expression.
* @returns {string} A SQL code string.
*/
toString() {
toString(params) {
console.log(this?.params)
if (params) {
params.push(...(this?.params ?? []));
}
return this._expr
.map(p => isParamLike(p) && !isSQLExpression(p) ? literalToSQL(p.value) : p)
.map(p => isParamLike(p) && !isSQLExpression(p) ? literalToSQL(p.value, params) : p)
.join('');
}

Expand All @@ -134,7 +140,7 @@ function update(expr, callbacks) {
}
}

export function parseSQL(strings, exprs) {
export function parseSQL(strings, exprs, params) {
const spans = [strings[0]];
const cols = new Set;
const n = exprs.length;
Expand All @@ -146,7 +152,7 @@ export function parseSQL(strings, exprs) {
if (Array.isArray(e?.columns)) {
e.columns.forEach(col => cols.add(col));
}
spans[k] += typeof e === 'string' ? e : literalToSQL(e);
spans[k] += typeof e === 'string' ? e : literalToSQL(e, params);
}
const s = strings[++i];
if (isParamLike(spans[k])) {
Expand All @@ -165,6 +171,7 @@ export function parseSQL(strings, exprs) {
* references), or parameterized values.
*/
export function sql(strings, ...exprs) {
const { spans, cols } = parseSQL(strings, exprs);
return new SQLExpression(spans, cols);
const params = [];
const { spans, cols } = parseSQL(strings, exprs, params);
return new SQLExpression(spans, cols, params);
}
2 changes: 1 addition & 1 deletion packages/sql/src/literal.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ import { literalToSQL } from './to-sql.js';

export const literal = value => ({
value,
toString: () => literalToSQL(value)
toString: (params) => literalToSQL(value, params)
});
2 changes: 1 addition & 1 deletion packages/sql/src/load/create.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export function create(name, query, {
replace = false,
temp = true,
temp = false,
view = false
} = {}) {
return 'CREATE'
Expand Down
1 change: 1 addition & 0 deletions packages/sql/src/operators.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ function rangeOp(op, a, range, exclusive) {
const expr = !range ? sql``
: exclusive ? sql`${prefix}(${range[0]} <= ${a} AND ${a} < ${range[1]})`
: sql`(${a} ${op} ${range[0]} AND ${range[1]})`;
expr.params = [...(range[0].params ?? []), ... (range[1].params ?? [])];
return expr.annotate({ op, visit, field: a, range });
}

Expand Down
22 changes: 20 additions & 2 deletions packages/sql/src/to-sql.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,29 @@ export function toSQL(value) {
* @param {*} value The literal value.
* @returns {string} A SQL string.
*/
export function literalToSQL(value) {
export function literalToSQL(value, params) {
switch (typeof value) {
case 'boolean':
if (params) {
params.push(value);
return '?';
}
return value ? 'TRUE' : 'FALSE';
case 'string':
if (params) {
params.push(value);
return '?';
}
return `'${value.replace(`'`, `''`)}'`;
case 'number':
return Number.isFinite(value) ? String(value) : 'NULL';
if (Number.isFinite(value)) {
if (params) {
params.push(value);
return '?';
}
return String(value)
}
return 'NULL';
default:
if (value == null) {
return 'NULL';
Expand All @@ -46,6 +61,9 @@ export function literalToSQL(value) {
return `'${value.source}'`;
} else {
// otherwise rely on string coercion
if (params && value.toSQL) {
return value.toSQL(params)
}
return String(value);
}
}
Expand Down
7 changes: 7 additions & 0 deletions packages/sql/test/literal-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,11 @@ describe('literal', () => {
assert.strictEqual(stringWithQuotes.value, `don't`);
assert.strictEqual(String(stringWithQuotes), `'don''t'`);
});
it(`supports toSQL`, () => {
const numberExpr = literal(1);

const params = [];
assert.strictEqual(numberExpr.toSQL(params), `?`);
assert.deepStrictEqual(params, [1]);
})
});
17 changes: 17 additions & 0 deletions packages/sql/test/query-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,23 @@ describe('Query', () => {
.toString(),
query
);

assert.strictEqual(gt(bar, 50).toSQL([]), `"bar" > ?`);

const params = [];
assert.strictEqual(
Query
.select(foo)
.from('data')
.where(gt(bar, 50), lt(bar, 100))
.toSQL(params),
[
'SELECT "foo"',
'FROM "data"',
'WHERE ("bar" > ?) AND ("bar" < ?)'
].join(' ')
);
assert.deepStrictEqual(params, [50, 100]);
});

it('selects ordered rows', () => {
Expand Down
Loading