diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..b5093d0 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,12 @@ +FROM oven/bun:debian + +# Config Bun +ENV PATH="~/.bun/bin:${PATH}" +RUN ln -s /usr/local/bin/bun /usr/local/bin/node + +# Update packages +RUN if [ "debian" == "alpine" ] ; then apk update ; else apt-get update ; fi + +# Install Git +RUN if [ "debian" == "alpine" ] ; then apk add git ; else apt-get install -y git ; fi + diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..7d4815c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,19 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/marcosgomesneto/bun-devcontainers/tree/main/src/basic-bun +{ + "name": "Bun", + "dockerFile": "Dockerfile", + // Configure tool-specific properties. + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "oven.bun-vscode" + ] + } +}, +"features": { + "ghcr.io/devcontainers/features/node:1": {} +} +} \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json index cab0da1..4ac72bd 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,7 @@ { - "recommendations": ["kavod-io.vscode-jest-test-adapter", "esbenp.prettier-vscode", "github.vscode-github-actions", "hbenl.vscode-test-explorer"] + "recommendations": [ + "esbenp.prettier-vscode", + "github.vscode-github-actions", + "hbenl.vscode-test-explorer" + ] } diff --git a/package-lock.json b/package-lock.json index 532e01d..4405025 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@sqlitecloud/drivers", - "version": "1.0.330", + "version": "1.0.336", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sqlitecloud/drivers", - "version": "1.0.330", + "version": "1.0.336", "license": "MIT", "dependencies": { "@craftzdog/react-native-buffer": "^6.0.5", @@ -12125,17 +12125,6 @@ "version": "1.0.0", "license": "ISC" }, - "node_modules/fsevents": { - "version": "2.3.3", - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "license": "MIT", diff --git a/package.json b/package.json index bb49a6d..8653318 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sqlitecloud/drivers", - "version": "1.0.333", + "version": "1.0.339", "description": "SQLiteCloud drivers for Typescript/Javascript in edge, web and node clients", "main": "./lib/index.js", "types": "./lib/index.d.ts", diff --git a/src/drivers/connection-tls.ts b/src/drivers/connection-tls.ts index 617f042..db444ef 100644 --- a/src/drivers/connection-tls.ts +++ b/src/drivers/connection-tls.ts @@ -2,7 +2,7 @@ * connection-tls.ts - connection via tls socket and sqlitecloud protocol */ -import { type SQLiteCloudConfig, SQLiteCloudError, type ErrorCallback, type ResultsCallback } from './types' +import { type SQLiteCloudConfig, SQLiteCloudError, type ErrorCallback, type ResultsCallback, SQLiteCloudCommand } from './types' import { SQLiteCloudConnection } from './connection' import { getInitializationCommands } from './utilities' import { @@ -23,8 +23,6 @@ import { Buffer } from 'buffer' import * as tls from 'tls' -import fs from 'fs' - /** * Implementation of SQLiteCloudConnection that connects to the database using specific tls APIs * that connect to native sockets or tls sockets and communicates via raw, binary protocol. @@ -104,13 +102,17 @@ export class SQLiteCloudTlsConnection extends SQLiteCloudConnection { } /** Will send a command immediately (no queueing), return the rowset/result or throw an error */ - transportCommands(commands: string, callback?: ResultsCallback): this { + transportCommands(commands: string | SQLiteCloudCommand, callback?: ResultsCallback): this { // connection needs to be established? if (!this.socket) { callback?.call(this, new SQLiteCloudError('Connection not established', { errorCode: 'ERR_CONNECTION_NOT_ESTABLISHED' })) return this } + if (typeof commands === 'string') { + commands = { query: commands } as SQLiteCloudCommand + } + // reset buffer and rowset chunks, define response callback this.buffer = Buffer.alloc(0) this.startedOn = new Date() @@ -148,7 +150,7 @@ export class SQLiteCloudTlsConnection extends SQLiteCloudConnection { // buffer to accumulate incoming data until an whole command is received and can be parsed private buffer: Buffer = Buffer.alloc(0) private startedOn: Date = new Date() - private executingCommands?: string + private executingCommands?: SQLiteCloudCommand // callback to be called when a command is finished processing private processCallback?: ResultsCallback diff --git a/src/drivers/connection-ws.ts b/src/drivers/connection-ws.ts index 3c0f4b9..ac03362 100644 --- a/src/drivers/connection-ws.ts +++ b/src/drivers/connection-ws.ts @@ -2,7 +2,7 @@ * transport-ws.ts - handles low level communication with sqlitecloud server via socket.io websocket */ -import { SQLiteCloudConfig, SQLiteCloudError, ErrorCallback, ResultsCallback } from './types' +import { SQLiteCloudConfig, SQLiteCloudError, ErrorCallback, ResultsCallback, SQLiteCloudCommand, SQLiteCloudDataTypes } from './types' import { SQLiteCloudRowset } from './rowset' import { SQLiteCloudConnection } from './connection' import { io, Socket } from 'socket.io-client' @@ -41,14 +41,18 @@ export class SQLiteCloudWebsocketConnection extends SQLiteCloudConnection { } /** Will send a command immediately (no queueing), return the rowset/result or throw an error */ - transportCommands(commands: string, callback?: ResultsCallback): this { + transportCommands(commands: string | SQLiteCloudCommand, callback?: ResultsCallback): this { // connection needs to be established? if (!this.socket) { callback?.call(this, new SQLiteCloudError('Connection not established', { errorCode: 'ERR_CONNECTION_NOT_ESTABLISHED' })) return this + } + + if (typeof commands === 'string') { + commands = { query: commands } } - this.socket.emit('v1/sql', { sql: commands, row: 'array' }, (response: any) => { + this.socket.emit('GET /v2/weblite/sql', { sql: commands.query, bind: commands.parameters, row: 'array' }, (response: any) => { if (response?.error) { const error = new SQLiteCloudError(response.error.detail, { ...response.error }) callback?.call(this, error) diff --git a/src/drivers/connection.ts b/src/drivers/connection.ts index 00cd46e..1b23699 100644 --- a/src/drivers/connection.ts +++ b/src/drivers/connection.ts @@ -2,8 +2,8 @@ * connection.ts - base abstract class for sqlitecloud server connections */ -import { SQLiteCloudConfig, SQLiteCloudError, ErrorCallback, ResultsCallback } from './types' -import { validateConfiguration, prepareSql } from './utilities' +import { SQLiteCloudConfig, SQLiteCloudError, ErrorCallback, ResultsCallback, SQLiteCloudCommand } from './types' +import { validateConfiguration } from './utilities' import { OperationsQueue } from './queue' import { anonimizeCommand, getUpdateResults } from './utilities' @@ -62,7 +62,7 @@ export abstract class SQLiteCloudConnection { protected abstract connectTransport(config: SQLiteCloudConfig, callback?: ErrorCallback): this /** Send a command, return the rowset/result or throw an error */ - protected abstract transportCommands(commands: string, callback?: ResultsCallback): this + protected abstract transportCommands(commands: string | SQLiteCloudCommand, callback?: ResultsCallback): this /** Will log to console if verbose mode is enabled */ protected log(message: string, ...optionalParams: any[]): void { @@ -85,7 +85,7 @@ export abstract class SQLiteCloudConnection { } /** Will enquee a command to be executed and callback with the resulting rowset/result/error */ - public sendCommands(commands: string, callback?: ResultsCallback): this { + public sendCommands(commands: string | SQLiteCloudCommand, callback?: ResultsCallback): this { this.operations.enqueue(done => { if (!this.connected) { const error = new SQLiteCloudError('Connection not established', { errorCode: 'ERR_CONNECTION_NOT_ESTABLISHED' }) @@ -108,32 +108,33 @@ export abstract class SQLiteCloudConnection { * using backticks and parameters in ${parameter} format. These parameters * will be properly escaped and quoted like when using a prepared statement. * @param sql A sql string or a template string in `backticks` format + * A SQLiteCloudCommand when the query is defined with question marks and bindings. * @returns An array of rows in case of selections or an object with * metadata in case of insert, update, delete. */ - public async sql(sql: TemplateStringsArray | string, ...values: any[]): Promise { - let preparedSql = '' + public async sql(sql: TemplateStringsArray | string | SQLiteCloudCommand, ...values: any[]): Promise { + let commands = { query: '' } as SQLiteCloudCommand // sql is a TemplateStringsArray, the 'raw' property is specific to TemplateStringsArray if (Array.isArray(sql) && 'raw' in sql) { + let query = '' sql.forEach((string, i) => { - preparedSql += string + (i < values.length ? '?' : '') + // TemplateStringsArray splits the string before each variable + // used in the template. Add the question mark + // to the end of the string for the number of used variables. + query += string + (i < values.length ? '?' : '') }) - preparedSql = prepareSql(preparedSql, ...values) + commands = { query, parameters: values } + } else if (typeof sql === 'string') { + commands = { query: sql, parameters: values } + } else if (typeof sql === 'object') { + commands = sql as SQLiteCloudCommand } else { - if (typeof sql === 'string') { - if (values?.length > 0) { - preparedSql = prepareSql(sql, ...values) - } else { - preparedSql = sql - } - } else { - throw new Error('Invalid sql') - } + throw new Error('Invalid sql') } return new Promise((resolve, reject) => { - this.sendCommands(preparedSql, (error, results) => { + this.sendCommands(commands, (error, results) => { if (error) { reject(error) } else { diff --git a/src/drivers/database.ts b/src/drivers/database.ts index c67bb28..2d09236 100644 --- a/src/drivers/database.ts +++ b/src/drivers/database.ts @@ -12,13 +12,13 @@ import { SQLiteCloudConnection } from './connection' import { SQLiteCloudRowset } from './rowset' -import { SQLiteCloudConfig, SQLiteCloudError, RowCountCallback, SQLiteCloudArrayType } from './types' -import { prepareSql, popCallback } from './utilities' -import { Statement } from './statement' +import { SQLiteCloudConfig, SQLiteCloudError, RowCountCallback, SQLiteCloudArrayType, SQLiteCloudCommand } from './types' +import { popCallback } from './utilities' import { ErrorCallback, ResultsCallback, RowCallback, RowsCallback } from './types' import EventEmitter from 'eventemitter3' import { isBrowser } from './utilities' import { PubSub } from './pubsub' +import { Statement } from './statement' // Uses eventemitter3 instead of node events for browser compatibility // https://github.com/primus/eventemitter3 @@ -204,12 +204,12 @@ export class Database extends EventEmitter { public run(sql: string, params: any, callback?: ResultsCallback): this public run(sql: string, ...params: any[]): this { const { args, callback } = popCallback(params) - const preparedSql = args?.length > 0 ? prepareSql(sql, ...args) : sql + const command: SQLiteCloudCommand = { query: sql, parameters: args } this.getConnection((error, connection) => { if (error || !connection) { this.handleError(null, error as Error, callback) } else { - connection.sendCommands(preparedSql, (error, results) => { + connection.sendCommands(command, (error, results) => { if (error) { this.handleError(connection, error, callback) } else { @@ -237,12 +237,12 @@ export class Database extends EventEmitter { public get(sql: string, params: any, callback?: RowCallback): this public get(sql: string, ...params: any[]): this { const { args, callback } = popCallback(params) - const preparedSql = args?.length > 0 ? prepareSql(sql, ...args) : sql + const command: SQLiteCloudCommand = { query: sql, parameters: args } this.getConnection((error, connection) => { if (error || !connection) { this.handleError(null, error as Error, callback) } else { - connection.sendCommands(preparedSql, (error, results) => { + connection.sendCommands(command, (error, results) => { if (error) { this.handleError(connection, error, callback) } else { @@ -275,12 +275,12 @@ export class Database extends EventEmitter { public all(sql: string, params: any, callback?: RowsCallback): this public all(sql: string, ...params: any[]): this { const { args, callback } = popCallback(params) - const preparedSql = args?.length > 0 ? prepareSql(sql, ...args) : sql + const command: SQLiteCloudCommand = { query: sql, parameters: args } this.getConnection((error, connection) => { if (error || !connection) { this.handleError(null, error as Error, callback) } else { - connection.sendCommands(preparedSql, (error, results) => { + connection.sendCommands(command, (error, results) => { if (error) { this.handleError(connection, error, callback) } else { @@ -316,12 +316,12 @@ export class Database extends EventEmitter { // extract optional parameters and one or two callbacks const { args, callback, complete } = popCallback(params) - const preparedSql = args?.length > 0 ? prepareSql(sql, ...args) : sql + const command: SQLiteCloudCommand = { query: sql, parameters: args } this.getConnection((error, connection) => { if (error || !connection) { this.handleError(null, error as Error, callback) } else { - connection.sendCommands(preparedSql, (error, rowset) => { + connection.sendCommands(command, (error, rowset) => { if (error) { this.handleError(connection, error, callback) } else { @@ -352,8 +352,7 @@ export class Database extends EventEmitter { * they are bound to the prepared statement before calling the callback. */ public prepare(sql: string, ...params: any[]): Statement { - const { args, callback } = popCallback(params) - return new Statement(this, sql, ...args, callback) + return new Statement(this, sql, ...params) } /** @@ -444,26 +443,25 @@ export class Database extends EventEmitter { * @returns An array of rows in case of selections or an object with * metadata in case of insert, update, delete. */ - - public async sql(sql: TemplateStringsArray | string, ...values: any[]): Promise { - let preparedSql = '' + public async sql(sql: TemplateStringsArray | string | SQLiteCloudCommand, ...values: any[]): Promise { + let commands = { query: '' } as SQLiteCloudCommand // sql is a TemplateStringsArray, the 'raw' property is specific to TemplateStringsArray if (Array.isArray(sql) && 'raw' in sql) { + let query = '' sql.forEach((string, i) => { - preparedSql += string + (i < values.length ? '?' : '') + // TemplateStringsArray splits the string before each variable + // used in the template. Add the question mark + // to the end of the string for the number of used variables. + query += string + (i < values.length ? '?' : '') }) - preparedSql = prepareSql(preparedSql, ...values) + commands = { query, parameters: values } + } else if (typeof sql === 'string') { + commands = { query: sql, parameters: values } + } else if (typeof sql === 'object') { + commands = sql as SQLiteCloudCommand } else { - if (typeof sql === 'string') { - if (values?.length > 0) { - preparedSql = prepareSql(sql, ...values) - } else { - preparedSql = sql - } - } else { - throw new Error('Invalid sql') - } + throw new Error('Invalid sql') } return new Promise((resolve, reject) => { @@ -471,7 +469,7 @@ export class Database extends EventEmitter { if (error || !connection) { reject(error) } else { - connection.sendCommands(preparedSql, (error, results) => { + connection.sendCommands(commands, (error, results) => { if (error) { reject(error) } else { diff --git a/src/drivers/protocol.ts b/src/drivers/protocol.ts index e7b5c01..bfbfd7f 100644 --- a/src/drivers/protocol.ts +++ b/src/drivers/protocol.ts @@ -2,7 +2,7 @@ // protocol.ts - low level protocol handling for SQLiteCloud transport // -import { SQLiteCloudError, type SQLCloudRowsetMetadata, type SQLiteCloudDataTypes } from './types' +import { SQLiteCloudCommand, SQLiteCloudError, type SQLCloudRowsetMetadata, type SQLiteCloudDataTypes } from './types' import { SQLiteCloudRowset } from './rowset' // explicitly importing buffer library to allow cross-platform support by replacing it @@ -335,7 +335,60 @@ export function popData(buffer: Buffer): { data: SQLiteCloudDataTypes | SQLiteCl } /** Format a command to be sent via SCSP protocol */ -export function formatCommand(command: string): string { - const commandLength = Buffer.byteLength(command, 'utf-8') - return `+${commandLength} ${command}` +export function formatCommand(command: SQLiteCloudCommand): string { + if (command.parameters && command.parameters.length > 0) { + // by SCSP the string paramenters in the array are zero-terminated + return serializeCommand([command.query, ...(command.parameters || [])], true) + } + return serializeData(command.query, false) +} + +function serializeCommand(data: any[], zeroString: boolean = false): string { + const n = data.length + let serializedData = `${n} ` + + for (let i = 0; i < n; i++) { + // the first string is the sql and it must be zero-terminated + const zs = i == 0 || zeroString + serializedData += serializeData(data[i], zs) + } + + const header = `${CMD_ARRAY}${serializedData.length} ` + return header + serializedData +} + +function serializeData(data: any, zeroString: boolean = false): string { + if (typeof data === 'string') { + let cmd = CMD_STRING + if (zeroString) { + cmd = CMD_ZEROSTRING + data += '\0' + } + + const header = `${cmd}${Buffer.byteLength(data, 'utf-8')} ` + return header + data + } + + if (typeof data === 'number') { + if (Number.isInteger(data)) { + return `${CMD_INT}${data} ` + } else { + return `${CMD_FLOAT}${data} ` + } + } + + if (Buffer.isBuffer(data)) { + const header = `${CMD_BLOB}${data.length} ` + return header + data.toString('utf-8') + } + + if (data === null || data === undefined) { + return `${CMD_NULL} ` + } + + if (Array.isArray(data)) { + return serializeCommand(data, zeroString) + } + + throw new Error(`Unsupported data type for serialization: ${typeof data}`) } diff --git a/src/drivers/statement.ts b/src/drivers/statement.ts index 6afb248..3c4def0 100644 --- a/src/drivers/statement.ts +++ b/src/drivers/statement.ts @@ -2,23 +2,22 @@ * statement.ts */ -import { popCallback, prepareSql } from './utilities' +import { popCallback } from './utilities' import { Database } from './database' -import { ErrorCallback, RowCallback, RowsCallback, RowCountCallback, ResultsCallback } from './types' +import { ErrorCallback, RowCallback, RowsCallback, RowCountCallback, ResultsCallback, SQLiteCloudCommand } from './types' /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -/** A statement generated by Database.prepare used to prepare SQL with ? bindings */ +/** + * A statement generated by Database.prepare used to prepare SQL with ? bindings. + * + * SCSP protocol does not support named placeholders yet. + */ export class Statement { constructor(database: Database, sql: string, ...params: any[]) { - const { args, callback } = popCallback(params) this._database = database this._sql = sql - if (args?.length > 0) { - this.bind(...args, callback) - } else { - callback?.call(this, null) - } + this.bind(...params) } /** Statement belongs to this database */ @@ -28,29 +27,24 @@ export class Statement { private _sql: string /** The SQL statement with binding values applied */ - private _preparedSql?: string + private _preparedSql: SQLiteCloudCommand = { query: '' } /** * Binds parameters to the prepared statement and calls the callback when done * or when an error occurs. The function returns the Statement object to allow * for function chaining. The first and only argument to the callback is null - * when binding was successful, otherwise it is the error object. Binding parameters - * with this function completely resets the statement object and row cursor and - * removes all previously bound parameters, if any. Currently bound parameters - * are escaped client side and turned into literals before being executed on the server. + * when binding was successful. Binding parameters with this function completely + * resets the statement object and row cursor and removes all previously bound + * parameters, if any. + * + * In SQLiteCloud the statement is prepared on the database server and binding errors + * are raised on execution time. */ public bind(...params: any[]): this { const { args, callback } = popCallback(params) - try { - this._preparedSql = prepareSql(this._sql, ...args) - if (callback) { - callback.call(this, null) - } - } catch (error) { - this._preparedSql = undefined - if (callback) { - callback.call(this, error as Error) - } + this._preparedSql = { query: this._sql, parameters: args } + if (callback) { + callback.call(this, null) } return this } @@ -69,17 +63,17 @@ export class Statement { const { args, callback } = popCallback(params || []) if (args?.length > 0) { // apply new bindings then execute - this.bind(...args, (error: Error) => { - if (error) { - callback?.call(this, error) - } else { - this._database.run(this._preparedSql || '', callback) - } + this.bind(...args, () => { + const query = this._preparedSql.query || '' + const parametes: any = [...this._preparedSql.parameters ?? []] + this._database.run(query, ...parametes, callback) }) } else { // execute prepared sql with same bindings - this._database.run(this._preparedSql || '', callback) - } + const query = this._preparedSql.query || '' + const parametes: any = [...this._preparedSql.parameters ?? []] + this._database.run(query, ...parametes, callback) + } return this } @@ -98,17 +92,17 @@ export class Statement { const { args, callback } = popCallback(params || []) if (args?.length > 0) { // apply new bindings then execute - this.bind(...args, (error: Error) => { - if (error) { - callback?.call(this, error) - } else { - this._database.get(this._preparedSql || '', callback) - } + this.bind(...args, () => { + const query = this._preparedSql.query || '' + const parametes: any = [...this._preparedSql.parameters ?? []] + this._database.get(query, ...parametes, callback) }) } else { // execute prepared sql with same bindings - this._database.get(this._preparedSql || '', callback) - } + const query = this._preparedSql.query || '' + const parametes: any = [...this._preparedSql.parameters ?? []] + this._database.get(query, ...parametes, callback) + } return this } @@ -124,17 +118,17 @@ export class Statement { const { args, callback } = popCallback(params || []) if (args?.length > 0) { // apply new bindings then execute - this.bind(...args, (error: Error) => { - if (error) { - callback?.call(this, error) - } else { - this._database.all(this._preparedSql || '', callback) - } + this.bind(...args, () => { + const query = this._preparedSql.query || '' + const parametes: any = [...this._preparedSql.parameters ?? []] + this._database.all(query, ...parametes, callback) }) } else { // execute prepared sql with same bindings - this._database.all(this._preparedSql || '', callback) - } + const query = this._preparedSql.query || '' + const parametes: any = [...this._preparedSql.parameters ?? []] + this._database.all(query, ...parametes, callback) + } return this } @@ -150,17 +144,17 @@ export class Statement { const { args, callback, complete } = popCallback>(params) if (args?.length > 0) { // apply new bindings then execute - this.bind(...args, (error: Error) => { - if (error) { - callback?.call(this, error) - } else { - this._database.each(this._preparedSql || '', callback, complete as RowCountCallback) - } + this.bind(...args, () => { + const query = this._preparedSql.query || '' + const parametes: any = [...this._preparedSql.parameters ?? [], ...[callback, complete as RowCountCallback]] + this._database.each(query, ...parametes) }) } else { // execute prepared sql with same bindings - this._database.each(this._preparedSql || '', callback, complete as RowCountCallback) - } + const query = this._preparedSql.query || '' + const parametes: any = [...this._preparedSql.parameters ?? [], ...[callback, complete as RowCountCallback]] + this._database.each(query, ...parametes) + } return this } diff --git a/src/drivers/types.ts b/src/drivers/types.ts index 74ee70d..6a1c7a3 100644 --- a/src/drivers/types.ts +++ b/src/drivers/types.ts @@ -105,6 +105,11 @@ export interface SQLCloudRowsetMetadata { /** Basic types that can be returned by SQLiteCloud APIs */ export type SQLiteCloudDataTypes = string | number | bigint | boolean | Record | Buffer | null | undefined +export interface SQLiteCloudCommand { + query: string + parameters?: SQLiteCloudDataTypes[] +} + /** Custom error reported by SQLiteCloud drivers */ export class SQLiteCloudError extends Error { constructor(message: string, args?: Partial) { diff --git a/src/drivers/utilities.ts b/src/drivers/utilities.ts index 59b4d61..6996d49 100644 --- a/src/drivers/utilities.ts +++ b/src/drivers/utilities.ts @@ -86,81 +86,20 @@ export function getInitializationCommands(config: SQLiteCloudConfig): string { return commands } -/** Takes a generic value and escapes it so it can replace ? as a binding in a prepared SQL statement */ -export function escapeSqlParameter(param: SQLiteCloudDataTypes): string { - if (param === null || param === undefined) { - return 'NULL' - } - - if (typeof param === 'string') { - // replace single quote with two single quotes - param = param.replace(/'/g, "''") - return `'${param}'` - } +/** Sanitizes an SQLite identifier (e.g., table name, column name). */ +export function sanitizeSQLiteIdentifier(identifier: any): string { + const trimmed = identifier.trim() - if (typeof param === 'number' || typeof param === 'bigint') { - return param.toString() + // it's not empty + if (trimmed.length === 0) { + throw new Error('Identifier cannot be empty.') } - if (typeof param === 'boolean') { - return param ? '1' : '0' - } - - // serialize buffer as X'...' hex encoded string - if (Buffer.isBuffer(param)) { - return `X'${param.toString('hex')}'` - } - - if (typeof param === 'object') { - // serialize json then escape single quotes - let json = JSON.stringify(param) - json = json.replace(/'/g, "''") - return `'${json}'` - } - - throw new SQLiteCloudError(`Unsupported parameter type: ${typeof param}`) -} - -/** Take a sql statement and replaces ? or $named parameters that are properly serialized and escaped. */ -export function prepareSql(sql: string, ...params: (SQLiteCloudDataTypes | SQLiteCloudDataTypes[])[]): string { - // parameters where passed as an array of parameters? - if (params?.length === 1 && Array.isArray(params[0])) { - params = params[0] - } - - // replace ? or ?idx parameters passed as args or as an array - let parameterIndex = 1 - let preparedSql = sql.replace(/\?(\d+)?/g, (match: string, matchIndex: string) => { - const index = matchIndex ? parseInt(matchIndex) : parameterIndex - parameterIndex++ - - let sqlParameter: SQLiteCloudDataTypes - if (params[0] && typeof params[0] === 'object' && !(params[0] instanceof Buffer)) { - sqlParameter = params[0][index] as SQLiteCloudDataTypes - } - if (!sqlParameter) { - if (index > params.length) { - throw new SQLiteCloudError('Not enough parameters') - } - sqlParameter = params[index - 1] as SQLiteCloudDataTypes - } - - return sqlParameter !== null && sqlParameter !== undefined ? escapeSqlParameter(sqlParameter) : 'NULL' - }) - - // replace $named or :named parameters passed as an object - if (params?.length === 1 && params[0] && typeof params[0] === 'object') { - const namedParams = params[0] as Record - for (const [paramKey, param] of Object.entries(namedParams)) { - const firstChar = paramKey.charAt(0) - if (firstChar == '$' || firstChar == ':' || firstChar == '@') { - const escapedParam = escapeSqlParameter(param) - preparedSql = preparedSql.replace(new RegExp(`\\${paramKey}`, 'g'), escapedParam) - } - } - } + // escape double quotes + const escaped = trimmed.replace(/"/g, '""') - return preparedSql + // Wrap in double quotes for safety + return `"${escaped}"` } /** Converts results of an update or insert call into a more meaning full result set */ diff --git a/src/index.ts b/src/index.ts index f9fc479..3eb0db0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,6 @@ // connection-ws does not want/need to load on node and is loaded dynamically by Database export { Database } from './drivers/database' -export { Statement } from './drivers/statement' export { SQLiteCloudConnection } from './drivers/connection' export { type SQLiteCloudConfig, @@ -19,5 +18,5 @@ export { type SQLiteCloudDataTypes } from './drivers/types' export { SQLiteCloudRowset, SQLiteCloudRow } from './drivers/rowset' -export { escapeSqlParameter, prepareSql, parseconnectionstring, validateConfiguration, getInitializationCommands } from './drivers/utilities' +export { parseconnectionstring, validateConfiguration, getInitializationCommands, sanitizeSQLiteIdentifier } from './drivers/utilities' export * as protocol from './drivers/protocol' diff --git a/test/compare.test.ts b/test/compare.test.ts index d8b828e..cf605f3 100644 --- a/test/compare.test.ts +++ b/test/compare.test.ts @@ -8,7 +8,6 @@ import { getChinookDatabase, getTestingDatabase } from './shared' // https://github.com/TryGhost/node-sqlite3/wiki/API import sqlite3 from 'sqlite3' import { join } from 'path' -import { error } from 'console' const INSERT_SQL = "INSERT INTO people (name, hobby, age) VALUES ('Fantozzi Ugo', 'Competitive unicorn farting', 42); " const TESTING_DATABASE_FILE = join(__dirname, 'assets/testing.db') diff --git a/test/connection-tls.test.ts b/test/connection-tls.test.ts index df44af6..f09a694 100644 --- a/test/connection-tls.test.ts +++ b/test/connection-tls.test.ts @@ -2,7 +2,7 @@ * connection-tls.test.ts - test low level communication protocol with tls sockets and raw commands */ -import { SQLiteCloudError, SQLiteCloudRowset } from '../src/index' +import { SQLiteCloudConnection, SQLiteCloudError, SQLiteCloudRowset } from '../src/index' import { SQLiteCloudTlsConnection } from '../src/drivers/connection-tls' import { anonimizeCommand } from '../src/drivers/utilities' import { @@ -1027,3 +1027,29 @@ describe('anonimizeCommand', () => { expect(anonimized).toBe('+62 AUTH USER ****** SOMETHING notreallyapassword; USE DATABASE chinook.sqlite; ') }) }) + +// TODO: enable this test when this isssue is fixed: https://github.com/sqlitecloud/core/issues/165 +// it('should send unicode database name as array', done => { +// new Promise((resolve, reject) => { +// const connection = new SQLiteCloudTlsConnection({ connectionstring: CHINOOK_DATABASE_URL }, error => { +// if (error) { +// expect(error).toBeNull() +// reject(error) +// done(error) +// } +// resolve(connection) +// }) +// }).then((value) => { +// const connection = value as SQLiteCloudTlsConnection; +// connection.transportCommands({ query: 'USE DATABASE ?;', parameters: ['🚀.sqlite'] }, (error, result) => { +// if (error) { +// expect(error).toBeNull() +// } else { +// expect(result).toBeDefined() +// } + +// connection.close() +// done(error) +// }) +// }) +// }) diff --git a/test/connection-ws.test.ts b/test/connection-ws.test.ts index c655e37..77f9c0e 100644 --- a/test/connection-ws.test.ts +++ b/test/connection-ws.test.ts @@ -14,7 +14,7 @@ import { WARN_SPEED_MS, EXPECT_SPEED_MS } from './shared' -import { error } from 'console' +import { SQLiteCloudCommand } from '../src/drivers/types' describe('connection-ws', () => { let chinook: SQLiteCloudConnection @@ -377,6 +377,24 @@ describe('connection-ws', () => { done() }) }) + + it('should select without bindings', done => { + const command = { query: 'SELECT * FROM albums' } as SQLiteCloudCommand + chinook.sendCommands(command, (error, results) => { + expect(error).toBeNull() + expect(results.numberOfColumns).toBeGreaterThan(0) + done() + }) + }) + + it('should select with bindings', done => { + const command: SQLiteCloudCommand = { query: 'SELECT * FROM albums WHERE albumId = ?', parameters: [1] } + chinook.sendCommands(command, (error, results) => { + expect(error).toBeNull() + expect(results.numberOfColumns).toBe(3) + done() + }) + }) }) describe('connection stress testing', () => { diff --git a/test/database.test.ts b/test/database.test.ts index cdf1d8a..c7cb618 100644 --- a/test/database.test.ts +++ b/test/database.test.ts @@ -2,9 +2,11 @@ * database.test.ts - test driver api */ -import { SQLiteCloudRowset, SQLiteCloudRow, SQLiteCloudError } from '../src/index' -import { getTestingDatabase, getTestingDatabaseAsync, getChinookDatabase, removeDatabase, removeDatabaseAsync, LONG_TIMEOUT } from './shared' +import { SQLiteCloudRowset, SQLiteCloudRow, SQLiteCloudError, sanitizeSQLiteIdentifier } from '../src/index' +import { getTestingDatabase, getTestingDatabaseAsync, getChinookDatabase, removeDatabase, removeDatabaseAsync, LONG_TIMEOUT, getChinookWebsocketConnection } from './shared' import { RowCountCallback } from '../src/drivers/types' +import { expect, describe, it } from '@jest/globals' +import { Database } from 'sqlite3' // // utility methods to setup and destroy temporary test databases @@ -139,6 +141,23 @@ describe('Database.all', () => { LONG_TIMEOUT ) + it('select with empty space after semi-colon', done => { + const chinook = getChinookDatabase() + chinook.all('SELECT 1; ', (err: Error, rows: SQLiteCloudRowset) => { + expect(err).toBeNull() + expect(rows).toBeDefined() + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ + '1': 1 + }) + + chinook.close(error => { + expect(error).toBeNull() + done() + }) + }) + }) + it('select with parameters', done => { const chinook = getChinookDatabase() chinook.all('SELECT * FROM tracks WHERE composer = ?', 'AC/DC', (error, rows) => { @@ -390,7 +409,7 @@ describe('Database.sql (async)', () => { let database try { database = await getTestingDatabaseAsync() - const updateSql = "UPDATE people SET name = 'Charlie Brown' WHERE id = 3; UPDATE people SET name = 'David Bowie' WHERE id = 4; " + const updateSql = "UPDATE people SET name = 'Charlie Brown' WHERE id = 3; UPDATE people SET name = 'David Bowie' WHERE id = 4;" let results = await database.sql(updateSql) expect(results).toMatchObject({ lastID: 20, @@ -405,6 +424,23 @@ describe('Database.sql (async)', () => { LONG_TIMEOUT ) + it('simple insert with empty space after semi-colon', async () => { + let database + try { + database = await getTestingDatabaseAsync() + const insertSql = "INSERT INTO people (name, hobby, age) VALUES ('Barnaby Bumblecrump', 'Rubber Duck Dressing', 42); " + let results = await database.sql(insertSql) + expect(results).toMatchObject({ + lastID: 21, + changes: 1, + totalChanges: 21, + finalized: 1 + }) + } finally { + await removeDatabaseAsync(database) + } + }) + it( 'should insert and respond with metadata', async () => { @@ -425,4 +461,73 @@ describe('Database.sql (async)', () => { }, LONG_TIMEOUT ) + + it('should select and return boolean value', async () => { + let database + try { + database = await getTestingDatabaseAsync() + let results = await database.sql`SELECT true` + expect(results).toMatchObject([ + { + true: 1 + } + ]) + } finally { + await removeDatabaseAsync(database) + } + }) + + describe('should sanitize identifiers', () => { + it('should sanitize database name and run the query', async () => { + const database = await getTestingDatabaseAsync() + + const databaseName = sanitizeSQLiteIdentifier('people.sqlite') + await expect(database.sql(`USE DATABASE ${databaseName}`)).resolves.toBe('OK') + }) + + it('should sanitize table name and run the query', async () => { + const database = await getTestingDatabaseAsync() + + const table = sanitizeSQLiteIdentifier('people') + await expect(database.sql(`USE DATABASE people.sqlite; SELECT id FROM ${table} LIMIT 1`)).resolves.toMatchObject([{ id: 1 }]) + }) + + it('should sanitize SQL Injection as table name', async () => { + const database = await getTestingDatabaseAsync() + + const databaseName = sanitizeSQLiteIdentifier('people.sqlite; SELECT * FROM people; -- ') + await expect(database.sql(`USE DATABASE ${databaseName}`)).rejects.toThrow( + 'Database name contains invalid characters (people.sqlite; SELECT * FROM people; --).' + ) + + const table = sanitizeSQLiteIdentifier('people; -- ') + await expect(database.sql(`SELECT * FROM ${table} WHERE people = 1`)).rejects.toThrow('no such table: people; --') + }) + }) + + it('should throw exception when using table name as binding', async () => { + const database = await getTestingDatabaseAsync() + const table = 'people' + await expect(database.sql`USE DATABASE people.sqlite; SELECT * FROM ${table}`).rejects.toThrow('near "?": syntax error') + }) + + it('should built in commands accept bindings', async () => { + const database = await getTestingDatabaseAsync() + + let databaseName = 'people.sqlite' + await expect(database.sql`USE DATABASE ${databaseName}`).resolves.toBe('OK') + + databaseName = 'people.sqlite; SELECT * FROM people' + await expect(database.sql`USE DATABASE ${databaseName}`).rejects.toThrow('Database name contains invalid characters (people.sqlite; SELECT * FROM people).') + + let key = 'logo_level' + let value = 'debug' + await expect(database.sql`SET KEY ${key} TO ${value}`).resolves.toBe('OK') + + key = 'logo_level' + value = 'debug; DROP TABLE people' + await expect(database.sql`SET KEY ${key} TO ${value}`).resolves.toBe('OK') + const result = await database.sql`SELECT * FROM people` + expect(result.length).toBeGreaterThan(0) + }) }) diff --git a/test/protocol.test.ts b/test/protocol.test.ts index c332c0d..cf80f2b 100644 --- a/test/protocol.test.ts +++ b/test/protocol.test.ts @@ -2,7 +2,8 @@ // protocol.test.ts // -import { parseRowsetChunks } from '../src/drivers/protocol' +import { formatCommand, parseRowsetChunks } from '../src/drivers/protocol' +import { SQLiteCloudCommand } from '../src/drivers/types' // response sent by the server when we TEST ROWSET_CHUNK const CHUNKED_RESPONSE = Buffer.from( @@ -29,3 +30,27 @@ describe('parseRowsetChunks', () => { expect(rowset[146]['key']).toBe('PRIMARY') }) }) + +const testCases = [ + { query: "SELECT 'hello world'", parameters: [], expected: "+20 SELECT 'hello world'" }, + { + query: 'SELECT ?, ?, ?, ?, ?', + parameters: ['world', 123, 3.14, null, Buffer.from('hello')], + expected: '=57 6 !21 SELECT ?, ?, ?, ?, ?\x00!6 world\x00:123 ,3.14 _ $5 hello', + }, + { + query: 'SELECT ?', + parameters: ["'hello world'"], + expected: "=32 2 !9 SELECT ?\x00!14 'hello world'\x00", + }, +] + +describe('Format command', () => { + testCases.forEach(({ query, parameters, expected }) => { + it(`should serialize ${JSON.stringify([query, ...parameters])}`, () => { + const command: SQLiteCloudCommand = { query, parameters } + const serialized = formatCommand(command) + expect(serialized).toEqual(expected) + }) + }) +}) diff --git a/test/shared.ts b/test/shared.ts index fba7e14..ace81ef 100644 --- a/test/shared.ts +++ b/test/shared.ts @@ -231,7 +231,7 @@ export async function clearTestingDatabasesAsync() { for (let i = 0; i < databases.length; i++) { const databaseName = databases[i]['name'] if (testingPattern.test(databaseName)) { - const result = await chinook.sql`REMOVE DATABASE ${databaseName};` + const result = await chinook.sql(`REMOVE DATABASE ${databaseName};`) console.assert(result) numDeleted++ } diff --git a/test/statement.test.ts b/test/statement.test.ts index 6133c07..2fa07ae 100644 --- a/test/statement.test.ts +++ b/test/statement.test.ts @@ -3,23 +3,10 @@ */ import { SQLiteCloudRowset } from '../src' -import { RowCallback, RowCountCallback, SQLiteCloudError } from '../src/drivers/types' +import { RowCountCallback, SQLiteCloudError } from '../src/drivers/types' import { getChinookDatabase, getTestingDatabase } from './shared' describe('Database.prepare', () => { - it('without incorrect bindings', done => { - const chinook = getChinookDatabase() - expect(chinook).toBeDefined() - - // two bindings, but only one is provided... - const statement = chinook.prepare('SELECT * FROM tracks WHERE albumId = ? and trackId = ?;', 1, (err: Error, results: any) => { - expect(err).toBeInstanceOf(SQLiteCloudError) - - chinook.close() - done() - }) - }) - it('without initial bindings', done => { const chinook = getChinookDatabase() expect(chinook).toBeDefined() @@ -79,27 +66,18 @@ describe('Database.prepare', () => { it('Statement.bind', done => { const chinook = getChinookDatabase() expect(chinook).toBeDefined() - const statement = chinook.prepare('SELECT * FROM tracks WHERE albumId = ?;', (err: Error, results: any) => { - expect(err).toBeNull() - }) - - statement.bind(3, (error: Error) => { - expect(error).toBeNull() + const statement = chinook.prepare('SELECT * FROM tracks WHERE albumId = ?;') + statement.bind(3, () => { statement.all((error, rows) => { expect(error).toBeNull() expect(rows).toBeDefined() expect(rows).toHaveLength(3) expect(rows).toBeInstanceOf(SQLiteCloudRowset) - // missing binding - statement.bind((error: Error) => { - expect(error).toBeInstanceOf(SQLiteCloudError) - - chinook.close(error => { - expect(error).toBeNull() - done() - }) + chinook.close(error => { + expect(error).toBeNull() + done() }) }) }) @@ -132,6 +110,26 @@ it('Statement.all', done => { }) }) +it('Statement.all withtout bindings', done => { + const chinook = getChinookDatabase() + expect(chinook).toBeDefined() + const statement = chinook.prepare('SELECT * FROM tracks WHERE albumId = 3;', (err: Error, results: any) => { + expect(err).toBeNull() + }) + + statement.all((error, rows) => { + expect(error).toBeNull() + expect(rows).toBeDefined() + expect(rows).toHaveLength(3) + expect(rows).toBeInstanceOf(SQLiteCloudRowset) + + chinook.close(error => { + expect(error).toBeNull() + done() + }) + }) +}) + it('Statement.each', done => { const chinook = getChinookDatabase() expect(chinook).toBeDefined() @@ -163,6 +161,36 @@ it('Statement.each', done => { statement.each(4, rowCallback, completeCallback) }) +it('Statement.each without bindings', done => { + const chinook = getChinookDatabase() + expect(chinook).toBeDefined() + + let rowCount = 0 + + const rowCallback = (error: Error | null, row: any) => { + rowCount += 1 + expect(error).toBeNull() + expect(row).toBeDefined() + expect(row).toMatchObject({}) + } + + const completeCallback: RowCountCallback = (error, numberOfRows) => { + expect(error).toBeNull() + expect(rowCount).toBe(8) + expect(numberOfRows).toBe(8) + chinook.close(error => { + expect(error).toBeNull() + done() + }) + } + + const statement = chinook.prepare('SELECT * FROM tracks WHERE albumId = 4;') + + // album 4 has 8 rows + statement.each(4, rowCallback, completeCallback) +}) + + it('Statement.get', done => { const chinook = getChinookDatabase() expect(chinook).toBeDefined() @@ -182,6 +210,23 @@ it('Statement.get', done => { }) }) +it('Statement.get without bindings', done => { + const chinook = getChinookDatabase() + expect(chinook).toBeDefined() + const statement = chinook.prepare('SELECT * FROM tracks;') + + statement.get((error, rows) => { + expect(error).toBeNull() + expect(rows).toBeDefined() + + chinook.close(error => { + expect(error).toBeNull() + done() + }) + }) +}) + + it('Statement.run', done => { const chinook = getChinookDatabase() expect(chinook).toBeDefined() @@ -202,12 +247,12 @@ it('Statement.run', done => { }) }) -it('Statement.run - with insert results', done => { +it('Statement.run - insert', done => { // create simple "people" database that we can write in... const database = getTestingDatabase(error => { expect(error).toBeNull() - const statement = database.prepare('INSERT INTO people (name, hobby, age) VALUES (?, ?, ?); ') + const statement = database.prepare('INSERT INTO people (name, hobby, age) VALUES (?, ?, ?);') // @ts-ignore statement.run('John Wayne', 73, 'Horse Riding', (error, results) => { @@ -222,3 +267,81 @@ it('Statement.run - with insert results', done => { }) }) }) + +it('Statement.run - insert with empty space after semicolon returns null', done => { + // create simple "people" database that we can write in... + const database = getTestingDatabase(error => { + expect(error).toBeNull() + + const statement = database.prepare('INSERT INTO people (name, hobby, age) VALUES (?, ?, ?); ') + + // @ts-ignore + statement.run('John Wayne', 73, 'Horse Riding', (error, results) => { + expect(results).toBeNull() + + done() + }) + }) +}) + + +it('Statement.run - update', done => { + const database = getTestingDatabase(error => { + expect(error).toBeNull() + + const statement = database.prepare('UPDATE people SET name= ? WHERE id = ?;') + + // @ts-ignore + statement.run('John Wayne', 1, (error, results) => { + expect(results.changes).toBe(1) + + done() + }) + }) +}) + +it('Statement.run - update with empty space after semicolon returns null', done => { + const database = getTestingDatabase(error => { + expect(error).toBeNull() + + const statement = database.prepare('UPDATE people SET name= ? WHERE id = ?; ') + + // @ts-ignore + statement.run('John Wayne', 1, (error, results) => { + expect(results).toBeNull() + + done() + }) + }) +}) + +it('Statement.run - delete', done => { + const database = getTestingDatabase(error => { + expect(error).toBeNull() + + const statement = database.prepare('DELETE FROM people WHERE id = ?;') + + // @ts-ignore + statement.run(1, (error, results) => { + expect(results.changes).toBe(1) + + done() + }) + }) +}) + +it('Statement.run - delete with empty space after semicolon returns null', done => { + const database = getTestingDatabase(error => { + expect(error).toBeNull() + + const statement = database.prepare('DELETE FROM people WHERE id = ?; ') + + // @ts-ignore + statement.run(1, (error, results) => { + expect(results).toBeNull() + + done() + }) + }) +}) + diff --git a/test/stress.test.ts b/test/stress.test.ts index 4ef5f21..a28c200 100644 --- a/test/stress.test.ts +++ b/test/stress.test.ts @@ -134,7 +134,7 @@ describe('stress testing', () => { const table = 'tracks' for (let i = 0; i < SEQUENCE_TEST_SIZE; i++) { - const results = await chinook.sql`SELECT * FROM ${table} ORDER BY RANDOM() LIMIT 12` + const results = await chinook.sql(`SELECT * FROM ${table} ORDER BY RANDOM() LIMIT 12`) expect(results).toHaveLength(12) expect(Object.keys(results[0])).toEqual(['TrackId', 'Name', 'AlbumId', 'MediaTypeId', 'GenreId', 'Composer', 'Milliseconds', 'Bytes', 'UnitPrice']) } diff --git a/test/utilities.test.ts b/test/utilities.test.ts index 217629c..c524db7 100644 --- a/test/utilities.test.ts +++ b/test/utilities.test.ts @@ -3,92 +3,11 @@ // import { SQLiteCloudError } from '../src/index' -import { prepareSql, parseconnectionstring } from '../src/drivers/utilities' +import { parseconnectionstring, sanitizeSQLiteIdentifier } from '../src/drivers/utilities' import { getTestingDatabaseName } from './shared' import { expect, describe, it } from '@jest/globals' -describe('prepareSql', () => { - it('should replace single ? parameter', () => { - const sql = prepareSql('SELECT * FROM users WHERE name = ?', 'John') - expect(sql).toBe("SELECT * FROM users WHERE name = 'John'") - }) - - it('should replace multiple ? parameter', () => { - const sql = prepareSql('SELECT * FROM users WHERE name = ? AND last_name = ?', 'John', 'Doe') - expect(sql).toBe("SELECT * FROM users WHERE name = 'John' AND last_name = 'Doe'") - }) - - it('should replace multiple ? parameter passed as array', () => { - const sql = prepareSql('SELECT * FROM users WHERE name = ? AND last_name = ?', ['John', 'Doe']) - expect(sql).toBe("SELECT * FROM users WHERE name = 'John' AND last_name = 'Doe'") - }) - - it('should replace ?2 parameter with index key', () => { - const sql = prepareSql('UPDATE tbl SET name = ?2 WHERE id = ?', [2, 'bar']) - expect(sql).toBe("UPDATE tbl SET name = 'bar' WHERE id = 'bar'") - }) - - it('should replace ?5 parameter with index key in object', () => { - // ?5 will resolve to key '5' in the object, ? will resolve to key '2' - const sql = prepareSql('UPDATE tbl SET name = ?5 WHERE id = ?', { 2: 42, 5: 'bar' }) - expect(sql).toBe("UPDATE tbl SET name = 'bar' WHERE id = 42") - }) - - it("should replace string ? parameter containing ' character", () => { - const sql = prepareSql('SELECT * FROM phone WHERE name = ?', "Jack's phone") - expect(sql).toBe("SELECT * FROM phone WHERE name = 'Jack''s phone'") - }) - - it('should handle ? parameter with sql injection threat', () => { - const sql = prepareSql('SELECT * FROM phone WHERE name = ?', "Jack's phone; DROP TABLE phone;") - expect(sql).toBe("SELECT * FROM phone WHERE name = 'Jack''s phone; DROP TABLE phone;'") - }) - - it('should replace integer ? parameter', () => { - const sql = prepareSql('SELECT * FROM users WHERE age < ?', 32) - expect(sql).toBe('SELECT * FROM users WHERE age < 32') - }) - - it('should replace float ? parameter', () => { - const sql = prepareSql('SELECT * FROM pies WHERE diameter < ?', Math.PI) - expect(sql).toBe(`SELECT * FROM pies WHERE diameter < ${Math.PI}`) - }) - - it('should replace null ? parameter', () => { - const sql = prepareSql('SELECT * FROM pies WHERE diameter = ?', null) - expect(sql).toBe('SELECT * FROM pies WHERE diameter = NULL') - }) - - it('should replace json ? parameter', () => { - const sql = prepareSql('update users set profile = ? WHERE id = ?', { first: 'John', last: 'Doe' }, 1) - expect(sql).toBe('update users set profile = \'{"first":"John","last":"Doe"}\' WHERE id = 1') - }) - - it('should replace buffer ? parameter', () => { - const buffer = Buffer.from('Hello World!') - const sql = prepareSql('UPDATE users SET details = ? WHERE id = ?', buffer, 1) - expect(sql).toBe("UPDATE users SET details = X'48656c6c6f20576f726c6421' WHERE id = 1") - }) - - it('should throw if ? parameter is missing', () => { - expect(() => { - prepareSql('SELECT * FROM users WHERE name = ? AND last_name = ?', 'John' /** missing last_name parameter */) - }).toThrow(SQLiteCloudError) - }) - - it('should replace multiple $named parameters', () => { - const sql = prepareSql('SELECT * FROM users WHERE first = $first AND last = $last', { $first: 'John', $last: 'Doe' }) - expect(sql).toBe("SELECT * FROM users WHERE first = 'John' AND last = 'Doe'") - }) - - it('should treat 0 as zero and not null', () => { - const zero: number = 0 - const sql = prepareSql("SELECT ? AS 'number'", zero) - expect(sql).toBe("SELECT 0 AS 'number'") - }) -}) - describe('parseconnectionstring', () => { it('should parse connection string', () => { const connectionstring = 'sqlitecloud://user:password@host:1234/database?option1=xxx&option2=yyy' @@ -227,3 +146,23 @@ describe('getTestingDatabaseName', () => { expect(database).toBeTruthy() }) }) + +describe('sanitizeSQLiteIdentifier()', () => { + it('should trim and escape valid identifier', () => { + const identifier = ' valid_identifier ' + const sanitized = sanitizeSQLiteIdentifier(identifier) + expect(sanitized).toBe('"valid_identifier"') + }) + + it('valid indentifier', () => { + const identifier = "a_colName1" + const sanitized = sanitizeSQLiteIdentifier(identifier) + expect(sanitized).toBe('"a_colName1"') + }) + + it('should double quotes for sql injection', () => { + const identifier = ' chinook.sql; DROP TABLE "albums" ' + const sanitized = sanitizeSQLiteIdentifier(identifier) + expect(sanitized).toBe('"chinook.sql; DROP TABLE \"\"albums\"\""') + }) +})