From fe28e00d533a5f4e4b39ad612ca87a639ae8f05a Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Tue, 5 Aug 2025 16:04:11 -0700 Subject: [PATCH] feat: add Bun.SQL API with initial SQLite support --- packages/bun-types/index.d.ts | 1 + packages/bun-types/sql.d.ts | 692 ++++++++++++++ packages/bun-types/sqlite.d.ts | 120 +-- src/codegen/bundle-modules.ts | 2 +- src/js/bun/sql.ts | 619 +++++++++--- src/js/private.d.ts | 23 + test/integration/bun-types/fixture/sql.ts | 9 + .../bun-types/fixture/utilities.ts | 3 +- test/js/sql/sqlite-sql.test.ts | 878 ++++++++++++++++++ 9 files changed, 2133 insertions(+), 214 deletions(-) create mode 100644 packages/bun-types/sql.d.ts create mode 100644 test/js/sql/sqlite-sql.test.ts diff --git a/packages/bun-types/index.d.ts b/packages/bun-types/index.d.ts index c5b488ba22..870e2ae463 100644 --- a/packages/bun-types/index.d.ts +++ b/packages/bun-types/index.d.ts @@ -21,6 +21,7 @@ /// /// /// +/// /// diff --git a/packages/bun-types/sql.d.ts b/packages/bun-types/sql.d.ts new file mode 100644 index 0000000000..933d1af415 --- /dev/null +++ b/packages/bun-types/sql.d.ts @@ -0,0 +1,692 @@ +import type * as BunSQLite from "bun:sqlite"; + +declare module "bun" { + namespace SQL { + class UnsupportedAdapterError extends Error { + public readonly options: Bun.SQL.Options; + public constructor(options: Bun.SQL.Options); + } + + type AwaitPromisesArray>> = { + [K in keyof T]: Awaited; + }; + + type ContextCallbackResult = T extends Array> ? AwaitPromisesArray : Awaited; + type ContextCallback = (sql: SQL) => Promise; + + interface SQLiteOptions extends BunSQLite.DatabaseOptions { + adapter?: "sqlite"; + + /** + * Specify the path to the database file + * + * Examples: + * + * - `sqlite://:memory:` + * - `sqlite://./path/to/database.db` + * - `sqlite:///Users/bun/projects/my-app/database.db` + * - `./dev.db` + * - `:memory:` + * + * @default ":memory:" + */ + filename?: URL | string | undefined; + } + + interface PostgresOptions { + /** + * Connection URL (can be string or URL object) + */ + url?: URL | string | undefined; + + /** + * Database server hostname + * @default "localhost" + */ + host?: string | undefined; + + /** + * Database server hostname (alias for host) + * @deprecated Prefer {@link host} + * @default "localhost" + */ + hostname?: string | undefined; + + /** + * Database server port number + * @default 5432 + */ + port?: number | string | undefined; + + /** + * Database user for authentication + * @default "postgres" + */ + username?: string | undefined; + + /** + * Database user for authentication (alias for username) + * @deprecated Prefer {@link username} + * @default "postgres" + */ + user?: string | undefined; + + /** + * Database password for authentication + * @default "" + */ + password?: string | (() => MaybePromise) | undefined; + + /** + * Database password for authentication (alias for password) + * @deprecated Prefer {@link password} + * @default "" + */ + pass?: string | (() => MaybePromise) | undefined; + + /** + * Name of the database to connect to + * @default The username value + */ + database?: string | undefined; + + /** + * Name of the database to connect to (alias for database) + * @deprecated Prefer {@link database} + * @default The username value + */ + db?: string | undefined; + + /** + * Database adapter/driver to use + * @default "postgres" + */ + adapter?: "postgres"; + + /** + * Maximum time in seconds to wait for connection to become available + * @default 0 (no timeout) + */ + idleTimeout?: number | undefined; + + /** + * Maximum time in seconds to wait for connection to become available (alias for idleTimeout) + * @deprecated Prefer {@link idleTimeout} + * @default 0 (no timeout) + */ + idle_timeout?: number | undefined; + + /** + * Maximum time in seconds to wait when establishing a connection + * @default 30 + */ + connectionTimeout?: number | undefined; + + /** + * Maximum time in seconds to wait when establishing a connection (alias for connectionTimeout) + * @deprecated Prefer {@link connectionTimeout} + * @default 30 + */ + connection_timeout?: number | undefined; + + /** + * Maximum time in seconds to wait when establishing a connection (alias for connectionTimeout) + * @deprecated Prefer {@link connectionTimeout} + * @default 30 + */ + connectTimeout?: number | undefined; + + /** + * Maximum time in seconds to wait when establishing a connection (alias for connectionTimeout) + * @deprecated Prefer {@link connectionTimeout} + * @default 30 + */ + connect_timeout?: number | undefined; + + /** + * Maximum lifetime in seconds of a connection + * @default 0 (no maximum lifetime) + */ + maxLifetime?: number | undefined; + + /** + * Maximum lifetime in seconds of a connection (alias for maxLifetime) + * @deprecated Prefer {@link maxLifetime} + * @default 0 (no maximum lifetime) + */ + max_lifetime?: number | undefined; + + /** + * Whether to use TLS/SSL for the connection + * @default false + */ + tls?: TLSOptions | boolean | undefined; + + /** + * Whether to use TLS/SSL for the connection (alias for tls) + * @default false + */ + ssl?: TLSOptions | boolean | undefined; + + // `.path` is currently unsupported in Bun, the implementation is incomplete. + // + // /** + // * Unix domain socket path for connection + // * @default "" + // */ + // path?: string | undefined; + + /** + * Callback function executed when a connection is established + */ + onconnect?: ((client: SQL) => void) | undefined; + + /** + * Callback function executed when a connection is closed + */ + onclose?: ((client: SQL) => void) | undefined; + + /** + * Postgres client runtime configuration options + * + * @see https://www.postgresql.org/docs/current/runtime-config-client.html + */ + connection?: Record | undefined; + + /** + * Maximum number of connections in the pool + * @default 10 + */ + max?: number | undefined; + + /** + * By default values outside i32 range are returned as strings. If this is true, values outside i32 range are returned as BigInts. + * @default false + */ + bigint?: boolean | undefined; + + /** + * Automatic creation of prepared statements + * @default true + */ + prepare?: boolean | undefined; + } + + /** + * Configuration options for SQL client connection and behavior + * + * @example + * ```ts + * const config: Bun.SQL.Options = { + * host: 'localhost', + * port: 5432, + * user: 'dbuser', + * password: 'secretpass', + * database: 'myapp', + * idleTimeout: 30, + * max: 20, + * onconnect: (client) => { + * console.log('Connected to database'); + * } + * }; + * ``` + */ + type Options = SQLiteOptions | PostgresOptions; + + /** + * Represents a SQL query that can be executed, with additional control methods + * Extends Promise to allow for async/await usage + */ + interface Query extends Promise { + /** + * Indicates if the query is currently executing + */ + active: boolean; + + /** + * Indicates if the query has been cancelled + */ + cancelled: boolean; + + /** + * Cancels the executing query + */ + cancel(): Query; + + /** + * Executes the query as a simple query, no parameters are allowed but can execute multiple commands separated by semicolons + */ + simple(): Query; + + /** + * Executes the query + */ + execute(): Query; + + /** + * Returns the raw query result + */ + raw(): Query; + + /** + * Returns only the values from the query result + */ + values(): Query; + } + + /** + * Callback function type for transaction contexts + * @param sql Function to execute SQL queries within the transaction + */ + type TransactionContextCallback = ContextCallback; + + /** + * Callback function type for savepoint contexts + * @param sql Function to execute SQL queries within the savepoint + */ + type SavepointContextCallback = ContextCallback; + + /** + * SQL.Helper represents a parameter or serializable + * value inside of a query. + * + * @example + * ```ts + * const helper = sql(users, 'id'); + * await sql`insert into users ${helper}`; + * ``` + */ + interface Helper { + readonly value: T[]; + readonly columns: (keyof T)[]; + } + } + + interface SQL extends AsyncDisposable { + /** + * Executes a SQL query using template literals + * @example + * ```ts + * const [user] = await sql`select * from users where id = ${1}`; + * ``` + */ + (strings: TemplateStringsArray, ...values: unknown[]): SQL.Query; + + /** + * Execute a SQL query using a string + * + * @example + * ```ts + * const users = await sql`SELECT * FROM users WHERE id = ${1}`; + * ``` + */ + (string: string): SQL.Query; + + /** + * Helper function for inserting an object into a query + * + * @example + * ```ts + * // Insert an object + * const result = await sql`insert into users ${sql(users)} returning *`; + * + * // Or pick specific columns + * const result = await sql`insert into users ${sql(users, "id", "name")} returning *`; + * + * // Or a single object + * const result = await sql`insert into users ${sql(user)} returning *`; + * ``` + */ + (obj: T | T[] | readonly T[]): SQL.Helper; // Contributor note: This is the same as the signature below with the exception of the columns and the Pick + + /** + * Helper function for inserting an object into a query, supporting specific columns + * + * @example + * ```ts + * // Insert an object + * const result = await sql`insert into users ${sql(users)} returning *`; + * + * // Or pick specific columns + * const result = await sql`insert into users ${sql(users, "id", "name")} returning *`; + * + * // Or a single object + * const result = await sql`insert into users ${sql(user)} returning *`; + * ``` + */ + ( + obj: T | T[] | readonly T[], + ...columns: readonly Keys[] + ): SQL.Helper>; // Contributor note: This is the same as the signature above with the exception of this signature tracking keys + + /** + * Helper function for inserting any serializable value into a query + * + * @example + * ```ts + * const result = await sql`SELECT * FROM users WHERE id IN ${sql([1, 2, 3])}`; + * ``` + */ + (value: T): SQL.Helper; + } + + /** + * Main SQL client interface providing connection and transaction management + */ + class SQL { + /** + * Creates a new SQL client instance + * + * @param connectionString - The connection string for the SQL client + * + * @example + * ```ts + * const sql = new SQL("postgres://localhost:5432/mydb"); + * const sql = new SQL(new URL("postgres://localhost:5432/mydb")); + * ``` + */ + constructor(connectionString: string | URL); + + /** + * Creates a new SQL client instance with options + * + * @param connectionString - The connection string for the SQL client + * @param options - The options for the SQL client + * + * @example + * ```ts + * const sql = new SQL("postgres://localhost:5432/mydb", { idleTimeout: 1000 }); + * ``` + */ + constructor(connectionString: string | URL, options: Omit); + + /** + * Creates a new SQL client instance with options + * + * @param options - The options for the SQL client + * + * @example + * ```ts + * const sql = new SQL({ url: "postgres://localhost:5432/mydb", idleTimeout: 1000 }); + * ``` + */ + constructor(options?: SQL.Options); + + /** + * Current client options + */ + options: SQL.Options; + + /** + * Commits a distributed transaction also know as prepared transaction in postgres or XA transaction in MySQL + * + * @param name - The name of the distributed transaction + * + * @example + * ```ts + * await sql.commitDistributed("my_distributed_transaction"); + * ``` + */ + commitDistributed(name: string): Promise; + + /** + * Rolls back a distributed transaction also know as prepared transaction in postgres or XA transaction in MySQL + * + * @param name - The name of the distributed transaction + * + * @example + * ```ts + * await sql.rollbackDistributed("my_distributed_transaction"); + * ``` + */ + rollbackDistributed(name: string): Promise; + + /** Waits for the database connection to be established + * + * @example + * ```ts + * await sql.connect(); + * ``` + */ + connect(): Promise; + + /** + * Closes the database connection with optional timeout in seconds. If timeout is 0, it will close immediately, if is not provided it will wait for all queries to finish before closing. + * + * @param options - The options for the close + * + * @example + * ```ts + * await sql.close({ timeout: 1 }); + * ``` + */ + close(options?: { timeout?: number }): Promise; + + /** + * Closes the database connection with optional timeout in seconds. If timeout is 0, it will close immediately, if is not provided it will wait for all queries to finish before closing. + * This is an alias of {@link SQL.close} + * + * @param options - The options for the close + * + * @example + * ```ts + * await sql.end({ timeout: 1 }); + * ``` + */ + end(options?: { timeout?: number }): Promise; + + /** + * Flushes any pending operations + * + * @example + * ```ts + * sql.flush(); + * ``` + */ + flush(): void; + + /** + * The reserve method pulls out a connection from the pool, and returns a client that wraps the single connection. + * + * This can be used for running queries on an isolated connection. + * Calling reserve in a reserved Sql will return a new reserved connection, not the same connection (behavior matches postgres package). + * + * @example + * ```ts + * const reserved = await sql.reserve(); + * await reserved`select * from users`; + * await reserved.release(); + * // with in a production scenario would be something more like + * const reserved = await sql.reserve(); + * try { + * // ... queries + * } finally { + * await reserved.release(); + * } + * + * // Bun supports Symbol.dispose and Symbol.asyncDispose + * { + * // always release after context (safer) + * using reserved = await sql.reserve() + * await reserved`select * from users` + * } + * ``` + */ + reserve(): Promise; + + /** + * Begins a new transaction. + * + * Will reserve a connection for the transaction and supply a scoped sql instance for all transaction uses in the callback function. sql.begin will resolve with the returned value from the callback function. + * BEGIN is automatically sent with the optional options, and if anything fails ROLLBACK will be called so the connection can be released and execution can continue. + * @example + * const [user, account] = await sql.begin(async sql => { + * const [user] = await sql` + * insert into users ( + * name + * ) values ( + * 'Murray' + * ) + * returning * + * ` + * const [account] = await sql` + * insert into accounts ( + * user_id + * ) values ( + * ${ user.user_id } + * ) + * returning * + * ` + * return [user, account] + * }) + */ + begin(fn: SQL.TransactionContextCallback): Promise>; + + /** + * Begins a new transaction with options. + * + * Will reserve a connection for the transaction and supply a scoped sql instance for all transaction uses in the callback function. sql.begin will resolve with the returned value from the callback function. + * BEGIN is automatically sent with the optional options, and if anything fails ROLLBACK will be called so the connection can be released and execution can continue. + * @example + * const [user, account] = await sql.begin("read write", async sql => { + * const [user] = await sql` + * insert into users ( + * name + * ) values ( + * 'Murray' + * ) + * returning * + * ` + * const [account] = await sql` + * insert into accounts ( + * user_id + * ) values ( + * ${ user.user_id } + * ) + * returning * + * ` + * return [user, account] + * }) + */ + begin(options: string, fn: SQL.TransactionContextCallback): Promise>; + + /** + * Alternative method to begin a transaction. + * + * Will reserve a connection for the transaction and supply a scoped sql instance for all transaction uses in the callback function. sql.transaction will resolve with the returned value from the callback function. + * BEGIN is automatically sent with the optional options, and if anything fails ROLLBACK will be called so the connection can be released and execution can continue. + * @alias begin + * @example + * const [user, account] = await sql.transaction(async sql => { + * const [user] = await sql` + * insert into users ( + * name + * ) values ( + * 'Murray' + * ) + * returning * + * ` + * const [account] = await sql` + * insert into accounts ( + * user_id + * ) values ( + * ${ user.user_id } + * ) + * returning * + * ` + * return [user, account] + * }) + */ + transaction(fn: SQL.TransactionContextCallback): Promise>; + + /** + * Alternative method to begin a transaction with options + * Will reserve a connection for the transaction and supply a scoped sql instance for all transaction uses in the callback function. sql.transaction will resolve with the returned value from the callback function. + * BEGIN is automatically sent with the optional options, and if anything fails ROLLBACK will be called so the connection can be released and execution can continue. + * + * @alias {@link begin} + * + * @example + * const [user, account] = await sql.transaction("read write", async sql => { + * const [user] = await sql` + * insert into users ( + * name + * ) values ( + * 'Murray' + * ) + * returning * + * ` + * const [account] = await sql` + * insert into accounts ( + * user_id + * ) values ( + * ${ user.user_id } + * ) + * returning * + * ` + * return [user, account] + * }); + */ + transaction(options: string, fn: SQL.TransactionContextCallback): Promise>; + + /** + * Begins a distributed transaction + * Also know as Two-Phase Commit, in a distributed transaction, Phase 1 involves the coordinator preparing nodes by ensuring data is written and ready to commit, while Phase 2 finalizes with nodes committing or rolling back based on the coordinator's decision, ensuring durability and releasing locks. + * In PostgreSQL and MySQL distributed transactions persist beyond the original session, allowing privileged users or coordinators to commit/rollback them, ensuring support for distributed transactions, recovery, and administrative tasks. + * beginDistributed will automatic rollback if any exception are not caught, and you can commit and rollback later if everything goes well. + * PostgreSQL natively supports distributed transactions using PREPARE TRANSACTION, while MySQL uses XA Transactions, and MSSQL also supports distributed/XA transactions. However, in MSSQL, distributed transactions are tied to the original session, the DTC coordinator, and the specific connection. + * These transactions are automatically committed or rolled back following the same rules as regular transactions, with no option for manual intervention from other sessions, in MSSQL distributed transactions are used to coordinate transactions using Linked Servers. + * + * @example + * await sql.beginDistributed("numbers", async sql => { + * await sql`create table if not exists numbers (a int)`; + * await sql`insert into numbers values(1)`; + * }); + * // later you can call + * await sql.commitDistributed("numbers"); + * // or await sql.rollbackDistributed("numbers"); + */ + beginDistributed( + name: string, + fn: SQL.TransactionContextCallback, + ): Promise>; + + /** Alternative method to begin a distributed transaction + * @alias {@link beginDistributed} + */ + distributed(name: string, fn: SQL.TransactionContextCallback): Promise>; + + /**If you know what you're doing, you can use unsafe to pass any string you'd like. + * Please note that this can lead to SQL injection if you're not careful. + * You can also nest sql.unsafe within a safe sql expression. This is useful if only part of your fraction has unsafe elements. + * @example + * const result = await sql.unsafe(`select ${danger} from users where id = ${dragons}`) + */ + unsafe(string: string, values?: any[]): SQL.Query; + + /** + * Reads a file and uses the contents as a query. + * Optional parameters can be used if the file includes $1, $2, etc + * @example + * const result = await sql.file("query.sql", [1, 2, 3]); + */ + file(filename: string, values?: any[]): SQL.Query; + } + + /** + * SQL client + */ + const sql: SQL; + + /** + * SQL client for PostgreSQL + * + * @deprecated Prefer {@link Bun.sql} + */ + const postgres: SQL; + + /** + * Represents a savepoint within a transaction + */ + interface SavepointSQL extends SQL {} +} diff --git a/packages/bun-types/sqlite.d.ts b/packages/bun-types/sqlite.d.ts index 0c79d22779..8da72e2cc4 100644 --- a/packages/bun-types/sqlite.d.ts +++ b/packages/bun-types/sqlite.d.ts @@ -24,6 +24,66 @@ * | `null` | `NULL` | */ declare module "bun:sqlite" { + /** + * Options for {@link Database} + */ + export interface DatabaseOptions { + /** + * Open the database as read-only (no write operations, no create). + * + * Equivalent to {@link constants.SQLITE_OPEN_READONLY} + */ + readonly?: boolean; + + /** + * Allow creating a new database + * + * Equivalent to {@link constants.SQLITE_OPEN_CREATE} + */ + create?: boolean; + + /** + * Open the database as read-write + * + * Equivalent to {@link constants.SQLITE_OPEN_READWRITE} + */ + readwrite?: boolean; + + /** + * When set to `true`, integers are returned as `bigint` types. + * + * When set to `false`, integers are returned as `number` types and truncated to 52 bits. + * + * @default false + * @since v1.1.14 + */ + safeIntegers?: boolean; + + /** + * When set to `false` or `undefined`: + * - Queries missing bound parameters will NOT throw an error + * - Bound named parameters in JavaScript need to exactly match the SQL query. + * + * @example + * ```ts + * const db = new Database(":memory:", { strict: false }); + * db.run("INSERT INTO foo (name) VALUES ($name)", { $name: "foo" }); + * ``` + * + * When set to `true`: + * - Queries missing bound parameters will throw an error + * - Bound named parameters in JavaScript no longer need to be `$`, `:`, or `@`. The SQL query will remain prefixed. + * + * @example + * ```ts + * const db = new Database(":memory:", { strict: true }); + * db.run("INSERT INTO foo (name) VALUES ($name)", { name: "foo" }); + * ``` + * @since v1.1.14 + */ + strict?: boolean; + } + /** * A SQLite3 database * @@ -63,65 +123,7 @@ declare module "bun:sqlite" { * @param filename The filename of the database to open. Pass an empty string (`""`) or `":memory:"` or undefined for an in-memory database. * @param options defaults to `{readwrite: true, create: true}`. If a number, then it's treated as `SQLITE_OPEN_*` constant flags. */ - constructor( - filename?: string, - options?: - | number - | { - /** - * Open the database as read-only (no write operations, no create). - * - * Equivalent to {@link constants.SQLITE_OPEN_READONLY} - */ - readonly?: boolean; - /** - * Allow creating a new database - * - * Equivalent to {@link constants.SQLITE_OPEN_CREATE} - */ - create?: boolean; - /** - * Open the database as read-write - * - * Equivalent to {@link constants.SQLITE_OPEN_READWRITE} - */ - readwrite?: boolean; - - /** - * When set to `true`, integers are returned as `bigint` types. - * - * When set to `false`, integers are returned as `number` types and truncated to 52 bits. - * - * @default false - * @since v1.1.14 - */ - safeIntegers?: boolean; - - /** - * When set to `false` or `undefined`: - * - Queries missing bound parameters will NOT throw an error - * - Bound named parameters in JavaScript need to exactly match the SQL query. - * - * @example - * ```ts - * const db = new Database(":memory:", { strict: false }); - * db.run("INSERT INTO foo (name) VALUES ($name)", { $name: "foo" }); - * ``` - * - * When set to `true`: - * - Queries missing bound parameters will throw an error - * - Bound named parameters in JavaScript no longer need to be `$`, `:`, or `@`. The SQL query will remain prefixed. - * - * @example - * ```ts - * const db = new Database(":memory:", { strict: true }); - * db.run("INSERT INTO foo (name) VALUES ($name)", { name: "foo" }); - * ``` - * @since v1.1.14 - */ - strict?: boolean; - }, - ); + constructor(filename?: string, options?: number | DatabaseOptions); /** * This is an alias of `new Database()` diff --git a/src/codegen/bundle-modules.ts b/src/codegen/bundle-modules.ts index 2998f6a78c..6834915c76 100644 --- a/src/codegen/bundle-modules.ts +++ b/src/codegen/bundle-modules.ts @@ -212,7 +212,7 @@ const out = Bun.spawnSync({ cmd: config_cli, cwd: process.cwd(), env: process.env, - stdio: ["pipe", "pipe", "pipe"], + stdio: ["ignore", "pipe", "pipe"], }); if (out.exitCode !== 0) { console.error(out.stderr.toString()); diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index f4f92050cb..dc34cfca47 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -1,5 +1,3 @@ -import type * as BunTypes from "bun"; - const enum QueryStatus { active = 1 << 1, cancelled = 1 << 2, @@ -107,7 +105,7 @@ enum SQLQueryFlags { notTagged = 1 << 4, } -function getQueryHandle(query) { +function getQueryHandle(query: Query) { let handle = query[_handle]; if (!handle) { try { @@ -251,7 +249,11 @@ function detectCommand(query: string): SQLCommand { return command; } -function normalizeQuery(strings, values, binding_idx = 1) { +function normalizeQuery( + strings: string | TemplateStringsArray, + values: unknown[], + binding_idx = 1, +): [string, unknown[]] { if (typeof strings === "string") { // identifier or unsafe query return [strings, values || []]; @@ -427,26 +429,168 @@ function normalizeQuery(strings, values, binding_idx = 1) { return [query, binding_values]; } -class Query extends PublicPromise { - [_resolve]; - [_reject]; +interface DatabaseAdapter { + normalizeQuery(strings: string | TemplateStringsArray, values: unknown[]): [string, unknown[]]; + createQueryHandle(sqlString: string, values: unknown[], flags: number, poolSize: number): any; + + connect(onConnected: (err: null, connection: Connection) => void, reserved?: boolean): void; + connect(onConnected: (err: Error, connection: null) => void, reserved?: boolean): void; + + release(connection: Connection, connectingEvent?: boolean): void; + close(options?: { timeout?: number }): Promise; + flush(): void; + isConnected(): boolean; + + init(options: Options): void; +} + +class PostgresAdapterImpl + implements DatabaseAdapter +{ + private pool: PostgresConnectionPool | null = null; + private options: Bun.SQL.__internal.DefinedPostgresOptions; + + init(options: Bun.SQL.__internal.DefinedPostgresOptions): void { + this.options = options; + this.pool = new PostgresConnectionPool(options); + } + + normalizeQuery(strings: string | TemplateStringsArray, values: unknown[]): [string, unknown[]] { + return normalizeQuery(strings, values); + } + + createQueryHandle(sqlString: string, values: unknown[], flags: number, poolSize: number): any { + if (!(flags & SQLQueryFlags.allowUnsafeTransaction)) { + if (poolSize !== 1) { + const upperCaseSqlString = sqlString.toUpperCase().trim(); + if (upperCaseSqlString.startsWith("BEGIN") || upperCaseSqlString.startsWith("START TRANSACTION")) { + throw $ERR_POSTGRES_UNSAFE_TRANSACTION("Only use sql.begin, sql.reserved or max: 1"); + } + } + } + + return createQuery( + sqlString, + values, + new SQLResultArray(), + undefined, + !!(flags & SQLQueryFlags.bigint), + !!(flags & SQLQueryFlags.simple), + ); + } + + connect(onConnected: (err: Error | null, connection: any) => void, reserved: boolean = false): void { + if (!this.pool) throw new Error("Adapter not initialized"); + this.pool.connect(onConnected, reserved); + } + + release(connection: any, connectingEvent: boolean = false): void { + if (!this.pool) throw new Error("Adapter not initialized"); + this.pool.release(connection, connectingEvent); + } + + async close(options?: { timeout?: number }): Promise { + if (!this.pool) throw new Error("Adapter not initialized"); + return this.pool.close(options); + } + + flush(): void { + if (!this.pool) throw new Error("Adapter not initialized"); + this.pool.flush(); + } + + isConnected(): boolean { + if (!this.pool) return false; + return this.pool.isConnected(); + } +} + +// SQLite adapter implementation +class SQLiteAdapterImpl implements DatabaseAdapter { + private connection: any = null; + private options: any; + + init(options: any): void { + this.options = options; + // SQLite doesn't need connection pooling like PostgreSQL + // Initialize single connection here when ready + } + + normalizeQuery(strings: any, values: any): [string, any[]] { + throw new Error("SQLite queries not yet implemented"); + } + + createQueryHandle(sqlString: string, values: any[], flags: number, poolSize: number): any { + // SQLite-specific query creation - placeholder for now + // TODO: Implement SQLite query creation when sqlite.zig is ready + throw new Error("SQLite queries not yet implemented"); + } + + connect(onConnected: (err: Error | null, connection: any) => void, reserved: boolean = false): void { + // SQLite doesn't typically need connection pooling + if (this.connection) { + onConnected(null, this.connection); + } else { + onConnected(new Error("SQLite connection not initialized"), null); + } + } + + release(connection: any, connectingEvent: boolean = false): void { + // SQLite doesn't need to release connections back to a pool + } + + async close(options?: { timeout?: number }): Promise { + // Close the SQLite database connection + if (this.connection) { + // TODO: Implement SQLite close + this.connection = null; + } + } + + flush(): void { + // SQLite flush implementation if needed + } + + isConnected(): boolean { + return !!this.connection; + } +} + +class Query extends PublicPromise { + [_resolve]: (value: T) => void; + [_reject]: (reason?: any) => void; [_handle]; [_handler]; [_queryStatus] = 0; [_strings]; [_values]; + [_poolSize]: number; + [_flags]: number; + [_results]: any; + + private adapter: DatabaseAdapter; [Symbol.for("nodejs.util.inspect.custom")]() { const status = this[_queryStatus]; - const active = (status & QueryStatus.active) != 0; - const cancelled = (status & QueryStatus.cancelled) != 0; - const executed = (status & QueryStatus.executed) != 0; - const error = (status & QueryStatus.error) != 0; - return `PostgresQuery { ${active ? "active" : ""} ${cancelled ? "cancelled" : ""} ${executed ? "executed" : ""} ${error ? "error" : ""} }`; + + let query = ""; + if ((status & QueryStatus.active) != 0) query += "active "; + if ((status & QueryStatus.cancelled) != 0) query += "cancelled "; + if ((status & QueryStatus.executed) != 0) query += "executed "; + if ((status & QueryStatus.error) != 0) query += "error "; + + return `Query { ${query} }`; } - constructor(strings, values, flags, poolSize, handler) { - var resolve_, reject_; + constructor( + strings: string | TemplateStringsArray, + values: any[], + flags: number, + poolSize: number, + handler: (query: Query, handle: any) => any, + adapter: DatabaseAdapter, + ) { + let resolve_: (value: T) => void, reject_: (reason?: any) => void; super((resolve, reject) => { resolve_ = resolve; reject_ = reject; @@ -458,8 +602,8 @@ class Query extends PublicPromise { strings = escapeIdentifier(strings); } } - this[_resolve] = resolve_; - this[_reject] = reject_; + this[_resolve] = resolve_!; + this[_reject] = reject_!; this[_handle] = null; this[_handler] = handler; this[_queryStatus] = 0; @@ -469,6 +613,21 @@ class Query extends PublicPromise { this[_flags] = flags; this[_results] = null; + this.adapter = adapter; + } + + private getQueryHandle(): ReturnType { + let handle = this[_handle]; + if (!handle) { + try { + const [sqlString, final_values] = this.adapter.normalizeQuery(this[_strings], this[_values]); + this[_handle] = handle = this.adapter.createQueryHandle(sqlString, final_values, this[_flags], this[_poolSize]); + } catch (err) { + this[_queryStatus] |= QueryStatus.error | QueryStatus.invalidHandle; + this.reject(err); + } + } + return handle; } async [_run](async: boolean) { @@ -483,7 +642,7 @@ class Query extends PublicPromise { } this[_queryStatus] |= QueryStatus.executed; - const handle = getQueryHandle(this); + const handle = this.getQueryHandle(); if (!handle) return this; if (async) { @@ -520,19 +679,19 @@ class Query extends PublicPromise { return (this[_queryStatus] & QueryStatus.cancelled) !== 0; } - resolve(x) { + resolve(x: T) { this[_queryStatus] &= ~QueryStatus.active; - const handle = getQueryHandle(this); + const handle = this.getQueryHandle(); if (!handle) return this; handle.done(); return this[_resolve](x); } - reject(x) { + reject(x: any) { this[_queryStatus] &= ~QueryStatus.active; this[_queryStatus] |= QueryStatus.error; if (!(this[_queryStatus] & QueryStatus.invalidHandle)) { - const handle = getQueryHandle(this); + const handle = this.getQueryHandle(); if (!handle) return this[_reject](x); handle.done(); } @@ -548,7 +707,7 @@ class Query extends PublicPromise { this[_queryStatus] |= QueryStatus.cancelled; if (status & QueryStatus.executed) { - const handle = getQueryHandle(this); + const handle = this.getQueryHandle(); handle.cancel(); } @@ -561,7 +720,7 @@ class Query extends PublicPromise { } raw() { - const handle = getQueryHandle(this); + const handle = this.getQueryHandle(); if (!handle) return this; handle.setMode(SQLQueryResultMode.raw); return this; @@ -573,7 +732,7 @@ class Query extends PublicPromise { } values() { - const handle = getQueryHandle(this); + const handle = this.getQueryHandle(); if (!handle) return this; handle.setMode(SQLQueryResultMode.values); return this; @@ -607,6 +766,7 @@ class Query extends PublicPromise { return super.finally.$apply(this, arguments); } } + Object.defineProperty(Query, Symbol.species, { value: PublicPromise }); Object.defineProperty(Query, Symbol.toStringTag, { value: "Query" }); init( @@ -708,8 +868,8 @@ enum PooledConnectionFlags { preReserved = 1 << 2, } -class PooledConnection { - pool: ConnectionPool; +class PooledPostgresConnection { + pool: PostgresConnectionPool; connection: $ZigGeneratedClasses.PostgresSQLConnection | null = null; state: PooledConnectionState = PooledConnectionState.pending; storedError: Error | null = null; @@ -773,7 +933,7 @@ class PooledConnection { this.pool.release(this, true); } - constructor(connectionInfo, pool: ConnectionPool) { + constructor(connectionInfo, pool: PostgresConnectionPool) { this.state = PooledConnectionState.pending; this.pool = pool; this.connectionInfo = connectionInfo; @@ -846,11 +1006,12 @@ class PooledConnection { return true; } } -class ConnectionPool { - connectionInfo: any; - connections: PooledConnection[]; - readyConnections: Set; +class PostgresConnectionPool { + options: Bun.SQL.__internal.DefinedPostgresOptions; + + connections: Array; + readyConnections: Set; waitingQueue: Array<(err: Error | null, result: any) => void> = []; reservedQueue: Array<(err: Error | null, result: any) => void> = []; @@ -858,9 +1019,10 @@ class ConnectionPool { closed: boolean = false; totalQueries: number = 0; onAllQueriesFinished: (() => void) | null = null; - constructor(connectionInfo) { - this.connectionInfo = connectionInfo; - this.connections = new Array(connectionInfo.max); + + constructor(options: Bun.SQL.__internal.DefinedPostgresOptions) { + this.options = options; + this.connections = new Array(options.max); this.readyConnections = new Set(); } @@ -896,7 +1058,7 @@ class ConnectionPool { } } - release(connection: PooledConnection, connectingEvent: boolean = false) { + release(connection: PooledPostgresConnection, connectingEvent: boolean = false) { if (!connectingEvent) { connection.queryCount--; this.totalQueries--; @@ -960,6 +1122,9 @@ class ConnectionPool { const pollSize = this.connections.length; for (let i = 0; i < pollSize; i++) { const connection = this.connections[i]; + if (!connection) { + continue; + } if (connection.state !== PooledConnectionState.closed) { // some connection is connecting or connected return true; @@ -984,6 +1149,9 @@ class ConnectionPool { const pollSize = this.connections.length; for (let i = 0; i < pollSize; i++) { const connection = this.connections[i]; + if (!connection) { + continue; + } if (connection.state === PooledConnectionState.connected) { return true; } @@ -999,6 +1167,9 @@ class ConnectionPool { const pollSize = this.connections.length; for (let i = 0; i < pollSize; i++) { const connection = this.connections[i]; + if (!connection) { + continue; + } if (connection.state === PooledConnectionState.connected) { connection.connection?.flush(); } @@ -1023,6 +1194,9 @@ class ConnectionPool { const pollSize = this.connections.length; for (let i = 0; i < pollSize; i++) { const connection = this.connections[i]; + if (!connection) { + continue; + } switch (connection.state) { case PooledConnectionState.pending: { @@ -1101,7 +1275,10 @@ class ConnectionPool { * @param {function} onConnected - The callback function to be called when the connection is established. * @param {boolean} reserved - Whether the connection is reserved, if is reserved the connection will not be released until release is called, if not release will only decrement the queryCount counter */ - connect(onConnected: (err: Error | null, result: any) => void, reserved: boolean = false) { + connect( + onConnected: (err: Error | null, result: PooledPostgresConnection | null) => void, + reserved: boolean = false, + ) { if (this.closed) { return onConnected(connectionClosedError(), null); } @@ -1118,6 +1295,9 @@ class ConnectionPool { const pollSize = this.connections.length; for (let i = 0; i < pollSize; i++) { const connection = this.connections[i]; + if (!connection) { + continue; + } // we need a new connection and we have some connections that can retry if (connection.state === PooledConnectionState.closed) { if (connection.retry()) { @@ -1165,18 +1345,18 @@ class ConnectionPool { this.poolStarted = true; const pollSize = this.connections.length; // pool is always at least 1 connection - const firstConnection = new PooledConnection(this.connectionInfo, this); + const firstConnection = new PooledPostgresConnection(this.options, this); this.connections[0] = firstConnection; if (reserved) { firstConnection.flags |= PooledConnectionFlags.preReserved; // lets pre reserve the first connection } for (let i = 1; i < pollSize; i++) { - this.connections[i] = new PooledConnection(this.connectionInfo, this); + this.connections[i] = new PooledPostgresConnection(this.options, this); } return; } if (reserved) { - let connectionWithLeastQueries: PooledConnection | null = null; + let connectionWithLeastQueries: PooledPostgresConnection | null = null; let leastQueries = Infinity; for (const connection of this.readyConnections) { if (connection.flags & PooledConnectionFlags.preReserved || connection.flags & PooledConnectionFlags.reserved) @@ -1305,38 +1485,94 @@ class SQLHelper { } } -function decodeIfValid(value) { +function decodeIfValid(value: string | null | undefined) { if (value) { return decodeURIComponent(value); } + return null; } -function loadOptions(o: Bun.SQL.Options) { - var hostname, - port, - username, - password, - database, + +/** Finds what is definitely a valid sqlite string, where there is no ambiguity with sqlite and another database adapter */ +function parseDefinitelySqliteUrl(value: string | URL): string | null { + const str = value instanceof URL ? value.toString() : value; + + // ':memory:' is a sqlite url + if (str === ":memory:" || str === "sqlite://:memory:" || str === "sqlite:memory") return ":memory:"; + + if (str.startsWith("sqlite://")) return new URL(str).pathname; + if (str.startsWith("sqlite:")) return str.slice(7); // "sqlite:".length + + // We can't guarantee this is exclusively an sqlite url here + // even if it *could* be + return null; +} + +function parseOptions( + stringOrUrlOrOptions: Bun.SQL.Options | string | URL | undefined, + definitelyOptionsButMaybeEmpty: Bun.SQL.Options, +): Bun.SQL.__internal.DefinedOptions { + let [stringOrUrl, options]: [string | URL | null, Bun.SQL.Options] = + typeof stringOrUrlOrOptions === "string" || stringOrUrlOrOptions instanceof URL + ? [stringOrUrlOrOptions, definitelyOptionsButMaybeEmpty] + : stringOrUrlOrOptions + ? [null, { ...stringOrUrlOrOptions, ...definitelyOptionsButMaybeEmpty }] + : [null, definitelyOptionsButMaybeEmpty]; + + if (options.adapter === undefined && stringOrUrl !== null) { + const sqliteUrl = parseDefinitelySqliteUrl(stringOrUrl); + + if (sqliteUrl !== null) { + return { + ...options, + adapter: "sqlite", + filename: sqliteUrl, + }; + } + } + + if (options.adapter === "sqlite") { + return { + ...options, + adapter: "sqlite", + filename: options.filename || stringOrUrl || ":memory:", + }; + } + + if (options.adapter !== undefined && options.adapter !== "postgres" && options.adapter !== "postgresql") { + options.adapter satisfies never; // This will type error if we support a new adapter in the future, which will let us know to update this check + throw new UnsupportedAdapterError(options); + } + + // TODO: Better typing for these vars + let hostname: any, + port: number, + username: string, + password: string, + database: any, tls, - url, - query, - adapter, - idleTimeout, - connectionTimeout, - maxLifetime, - onconnect, - onclose, - max, - bigint, - path; + url: URL, + query: string, + adapter: NonNullable, + idleTimeout: number | null, + connectionTimeout: number | null, + maxLifetime: number | null, + onconnect: (client: Bun.SQL) => void, + onclose: (client: Bun.SQL) => void, + max: number | null, + bigint: any, + path: string | string[]; + let prepare = true; const env = Bun.env || {}; var sslMode: SSLMode = SSLMode.disable; - if (o === undefined || (typeof o === "string" && o.length === 0)) { + if (stringOrUrl === undefined || (typeof stringOrUrl === "string" && stringOrUrl.length === 0)) { let urlString = env.POSTGRES_URL || env.DATABASE_URL || env.PGURL || env.PG_URL; + if (!urlString) { urlString = env.TLS_POSTGRES_DATABASE_URL || env.TLS_DATABASE_URL; + if (urlString) { sslMode = SSLMode.require; } @@ -1344,31 +1580,29 @@ function loadOptions(o: Bun.SQL.Options) { if (urlString) { url = new URL(urlString); - o = {}; } - } else if (o && typeof o === "object") { - if (o instanceof URL) { - url = o; - } else if (o?.url) { - const _url = o.url; + } else if (stringOrUrl && typeof stringOrUrl === "object") { + if (stringOrUrl instanceof URL) { + url = stringOrUrl; + } else if (options?.url) { + const _url = options.url; if (typeof _url === "string") { url = new URL(_url); } else if (_url && typeof _url === "object" && _url instanceof URL) { url = _url; } } - if (o?.tls) { + if (options?.tls) { sslMode = SSLMode.require; - tls = o.tls; + tls = options.tls; } - } else if (typeof o === "string") { - url = new URL(o); + } else if (typeof stringOrUrl === "string") { + url = new URL(stringOrUrl); } - o ||= {}; query = ""; if (url) { - ({ hostname, port, username, password, adapter } = o); + ({ hostname, port, username, password, adapter } = options); // object overrides url hostname ||= url.hostname; port ||= url.port; @@ -1396,20 +1630,22 @@ function loadOptions(o: Bun.SQL.Options) { } query = query.trim(); } - hostname ||= o.hostname || o.host || env.PGHOST || "localhost"; + hostname ||= options.hostname || options.host || env.PGHOST || "localhost"; - port ||= Number(o.port || env.PGPORT || 5432); + port ||= Number(options.port || env.PGPORT || 5432); - path ||= o.path || ""; + path ||= options.path || ""; // add /.s.PGSQL.${port} if it doesn't exist if (path && path?.indexOf("/.s.PGSQL.") === -1) { path = `${path}/.s.PGSQL.${port}`; } - username ||= o.username || o.user || env.PGUSERNAME || env.PGUSER || env.USER || env.USERNAME || "postgres"; - database ||= o.database || o.db || decodeIfValid((url?.pathname ?? "").slice(1)) || env.PGDATABASE || username; - password ||= o.password || o.pass || env.PGPASSWORD || ""; - const connection = o.connection; + username ||= + options.username || options.user || env.PGUSERNAME || env.PGUSER || env.USER || env.USERNAME || "postgres"; + database ||= + options.database || options.db || decodeIfValid((url?.pathname ?? "").slice(1)) || env.PGDATABASE || username; + password ||= options.password || options.pass || env.PGPASSWORD || ""; + const connection = options.connection; if (connection && $isObject(connection)) { for (const key in connection) { if (connection[key] !== undefined) { @@ -1417,26 +1653,26 @@ function loadOptions(o: Bun.SQL.Options) { } } } - tls ||= o.tls || o.ssl; - adapter ||= o.adapter || "postgres"; - max = o.max; + tls ||= options.tls || options.ssl; + adapter ||= options.adapter || "postgres"; + max = options.max; - idleTimeout ??= o.idleTimeout; - idleTimeout ??= o.idle_timeout; - connectionTimeout ??= o.connectionTimeout; - connectionTimeout ??= o.connection_timeout; - connectionTimeout ??= o.connectTimeout; - connectionTimeout ??= o.connect_timeout; - maxLifetime ??= o.maxLifetime; - maxLifetime ??= o.max_lifetime; - bigint ??= o.bigint; + idleTimeout ??= options.idleTimeout; + idleTimeout ??= options.idle_timeout; + connectionTimeout ??= options.connectionTimeout; + connectionTimeout ??= options.connection_timeout; + connectionTimeout ??= options.connectTimeout; + connectionTimeout ??= options.connect_timeout; + maxLifetime ??= options.maxLifetime; + maxLifetime ??= options.max_lifetime; + bigint ??= options.bigint; // we need to explicitly set prepare to false if it is false - if (o.prepare === false) { + if (options.prepare === false) { prepare = false; } - onconnect ??= o.onconnect; - onclose ??= o.onclose; + onconnect ??= options.onconnect; + onclose ??= options.onclose; if (onconnect !== undefined) { if (!$isCallable(onconnect)) { throw $ERR_INVALID_ARG_TYPE("onconnect", "function", onconnect); @@ -1509,31 +1745,40 @@ function loadOptions(o: Bun.SQL.Options) { throw $ERR_INVALID_ARG_VALUE("port", port, "must be a non-negative integer between 1 and 65535"); } - switch (adapter) { - case "postgres": - case "postgresql": - adapter = "postgres"; - break; - default: - throw new Error(`Unsupported adapter: ${adapter}. Only \"postgres\" is supported for now`); - } - const ret: any = { hostname, port, username, password, database, tls, query, sslMode, adapter, prepare, bigint }; + const ret: Bun.SQL.__internal.DefinedPostgresOptions = { + adapter: "postgres", + hostname, + port, + username, + password, + database, + tls, + prepare, + bigint, + sslMode, + query, + max: max || 10, + }; + if (idleTimeout != null) { ret.idleTimeout = idleTimeout; } + if (connectionTimeout != null) { ret.connectionTimeout = connectionTimeout; } + if (maxLifetime != null) { ret.maxLifetime = maxLifetime; } + if (onconnect !== undefined) { ret.onconnect = onconnect; } + if (onclose !== undefined) { ret.onclose = onclose; } - ret.max = max || 10; return ret; } @@ -1549,14 +1794,49 @@ function assertValidTransactionName(name: string) { } } -function SQL(o, e = {}) { - if (typeof o === "string" || o instanceof URL) { - o = { ...e, url: o }; - } - var connectionInfo = loadOptions(o); - var pool = new ConnectionPool(connectionInfo); +class UnsupportedAdapterError extends Error { + public options: Bun.SQL.Options; - function onQueryDisconnected(err) { + constructor(options: Bun.SQL.Options) { + super(`Unsupported adapter: ${options.adapter}. Supported adapters: "postgres", "sqlite"`); + this.options = options; + } +} + +function createPool(options: Bun.SQL.__internal.DefinedOptions) { + switch (options.adapter) { + case "postgres": { + return new PostgresConnectionPool(options); + } + + case "sqlite": { + return new SQLiteConnectionPool(options); + } + + default: { + options satisfies never; + throw new UnsupportedAdapterError(options); + } + } +} + +const SQL: typeof Bun.SQL = function SQL( + stringOrUrlOrOptions: Bun.SQL.Options | string | undefined = undefined, + definitelyOptionsButMaybeEmpty: Bun.SQL.Options = {}, +): Bun.SQL { + const resolvedOptions = parseOptions(stringOrUrlOrOptions, definitelyOptionsButMaybeEmpty); + + switch (resolvedOptions.adapter) { + case "postgres": + break; + default: { + throw new UnsupportedAdapterError(resolvedOptions); + } + } + + const pool = new PostgresConnectionPool(resolvedOptions); + + function onQueryDisconnected(this: PooledPostgresConnection, err) { // connection closed mid query this will not be called if the query finishes first const query = this; if (err) { @@ -1584,6 +1864,7 @@ function SQL(o, e = {}) { pooledConnection.bindQuery(query, onQueryDisconnected.bind(query)); handle.run(pooledConnection.connection, query); } + function queryFromPoolHandler(query, handle, err) { if (err) { // fail to create query @@ -1596,13 +1877,14 @@ function SQL(o, e = {}) { pool.connect(onQueryConnected.bind(query, handle)); } + function queryFromPool(strings, values) { try { return new Query( strings, values, - connectionInfo.bigint ? SQLQueryFlags.bigint : SQLQueryFlags.none, - connectionInfo.max, + resolvedOptions.bigint ? SQLQueryFlags.bigint : SQLQueryFlags.none, + resolvedOptions.max, queryFromPoolHandler, ); } catch (err) { @@ -1612,11 +1894,11 @@ function SQL(o, e = {}) { function unsafeQuery(strings, values) { try { - let flags = connectionInfo.bigint ? SQLQueryFlags.bigint | SQLQueryFlags.unsafe : SQLQueryFlags.unsafe; + let flags = resolvedOptions.bigint ? SQLQueryFlags.bigint | SQLQueryFlags.unsafe : SQLQueryFlags.unsafe; if ((values?.length ?? 0) === 0) { flags |= SQLQueryFlags.simple; } - return new Query(strings, values, flags, connectionInfo.max, queryFromPoolHandler); + return new Query(strings, values, flags, resolvedOptions.max, queryFromPoolHandler); } catch (err) { return Promise.reject(err); } @@ -1646,10 +1928,10 @@ function SQL(o, e = {}) { const query = new Query( strings, values, - connectionInfo.bigint + resolvedOptions.bigint ? SQLQueryFlags.allowUnsafeTransaction | SQLQueryFlags.bigint : SQLQueryFlags.allowUnsafeTransaction, - connectionInfo.max, + resolvedOptions.max, queryFromTransactionHandler.bind(pooledConnection, transactionQueries), ); transactionQueries.add(query); @@ -1660,7 +1942,7 @@ function SQL(o, e = {}) { } function unsafeQueryFromTransaction(strings, values, pooledConnection, transactionQueries) { try { - let flags = connectionInfo.bigint + let flags = resolvedOptions.bigint ? SQLQueryFlags.allowUnsafeTransaction | SQLQueryFlags.unsafe | SQLQueryFlags.bigint : SQLQueryFlags.allowUnsafeTransaction | SQLQueryFlags.unsafe; @@ -1671,7 +1953,7 @@ function SQL(o, e = {}) { strings, values, flags, - connectionInfo.max, + resolvedOptions.max, queryFromTransactionHandler.bind(pooledConnection, transactionQueries), ); transactionQueries.add(query); @@ -1710,13 +1992,14 @@ function SQL(o, e = {}) { const onClose = onTransactionDisconnected.bind(state); pooledConnection.onClose(onClose); - function reserved_sql(strings, ...values) { + function reserved_sql(strings: string | TemplateStringsArray, ...values: unknown[]) { if ( state.connectionState & ReservedConnectionState.closed || !(state.connectionState & ReservedConnectionState.acceptQueries) ) { return Promise.reject(connectionClosedError()); } + if ($isArray(strings)) { // detect if is tagged template if (!$isArray((strings as unknown as TemplateStringsArray).raw)) { @@ -1725,19 +2008,21 @@ function SQL(o, e = {}) { } else if (typeof strings === "object" && !(strings instanceof Query) && !(strings instanceof SQLHelper)) { return new SQLHelper([strings], values); } + // we use the same code path as the transaction sql return queryFromTransaction(strings, values, pooledConnection, state.queries); } - reserved_sql.unsafe = (string, args = []) => { + + reserved_sql.unsafe = (string: string, args: unknown[] = []) => { return unsafeQueryFromTransaction(string, args, pooledConnection, state.queries); }; - reserved_sql.file = async (path: string, args = []) => { + + reserved_sql.file = async (path: string, args: unknown[] = []) => { return await Bun.file(path) .text() - .then(text => { - return unsafeQueryFromTransaction(text, args, pooledConnection, state.queries); - }); + .then(text => unsafeQueryFromTransaction(text, args, pooledConnection, state.queries)); }; + reserved_sql.connect = () => { if (state.connectionState & ReservedConnectionState.closed) { return Promise.reject(connectionClosedError()); @@ -1746,7 +2031,7 @@ function SQL(o, e = {}) { }; reserved_sql.commitDistributed = async function (name: string) { - const adapter = connectionInfo.adapter; + const adapter = resolvedOptions.adapter; assertValidTransactionName(name); switch (adapter) { case "postgres": @@ -1758,12 +2043,12 @@ function SQL(o, e = {}) { case "sqlite": throw Error(`SQLite dont support distributed transactions.`); default: - throw Error(`Unsupported adapter: ${adapter}.`); + throw new UnsupportedAdapterError(resolvedOptions); } }; reserved_sql.rollbackDistributed = async function (name: string) { assertValidTransactionName(name); - const adapter = connectionInfo.adapter; + const adapter = resolvedOptions.adapter; switch (adapter) { case "postgres": return await reserved_sql.unsafe(`ROLLBACK PREPARED '${name}'`); @@ -1774,7 +2059,7 @@ function SQL(o, e = {}) { case "sqlite": throw Error(`SQLite dont support distributed transactions.`); default: - throw Error(`Unsupported adapter: ${adapter}.`); + throw new UnsupportedAdapterError(resolvedOptions); } }; @@ -1952,7 +2237,7 @@ function SQL(o, e = {}) { let savepoints = 0; let transactionSavepoints = new Set(); - const adapter = connectionInfo.adapter; + const adapter = resolvedOptions.adapter; let BEGIN_COMMAND: string = "BEGIN"; let ROLLBACK_COMMAND: string = "ROLLBACK"; let COMMIT_COMMAND: string = "COMMIT"; @@ -1995,7 +2280,7 @@ function SQL(o, e = {}) { pool.release(pooledConnection); // TODO: use ERR_ - return reject(new Error(`Unsupported adapter: ${adapter}.`)); + return reject(new UnsupportedAdapterError(resolvedOptions)); } } else { // normal transaction @@ -2027,7 +2312,7 @@ function SQL(o, e = {}) { default: pool.release(pooledConnection); // TODO: use ERR_ - return reject(new Error(`Unsupported adapter: ${adapter}.`)); + return reject(new UnsupportedAdapterError(resolvedOptions)); } } const onClose = onTransactionDisconnected.bind(state); @@ -2090,7 +2375,7 @@ function SQL(o, e = {}) { case "sqlite": throw Error(`SQLite dont support distributed transactions.`); default: - throw Error(`Unsupported adapter: ${adapter}.`); + throw new UnsupportedAdapterError(resolvedOptions); } }; transaction_sql.rollbackDistributed = async function (name: string) { @@ -2105,7 +2390,7 @@ function SQL(o, e = {}) { case "sqlite": throw Error(`SQLite dont support distributed transactions.`); default: - throw Error(`Unsupported adapter: ${adapter}.`); + throw new UnsupportedAdapterError(resolvedOptions); } }; // begin is not allowed on a transaction we need to use savepoint() instead @@ -2275,7 +2560,8 @@ function SQL(o, e = {}) { } } } - function sql(strings, ...values) { + + function sql(strings: TemplateStringsArray | object, ...values: unknown[]) { if ($isArray(strings)) { // detect if is tagged template if (!$isArray((strings as unknown as TemplateStringsArray).raw)) { @@ -2288,9 +2574,10 @@ function SQL(o, e = {}) { return queryFromPool(strings, values); } - sql.unsafe = (string, args = []) => { + sql.unsafe = (string: string, args = []) => { return unsafeQuery(string, args); }; + sql.file = async (path: string, args = []) => { return await Bun.file(path) .text() @@ -2298,6 +2585,7 @@ function SQL(o, e = {}) { return unsafeQuery(text, args); }); }; + sql.reserve = () => { if (pool.closed) { return Promise.reject(connectionClosedError()); @@ -2307,12 +2595,14 @@ function SQL(o, e = {}) { pool.connect(onReserveConnected.bind(promiseWithResolvers), true); return promiseWithResolvers.promise; }; + sql.rollbackDistributed = async function (name: string) { if (pool.closed) { throw connectionClosedError(); } + assertValidTransactionName(name); - const adapter = connectionInfo.adapter; + const adapter = resolvedOptions.adapter; switch (adapter) { case "postgres": return await sql.unsafe(`ROLLBACK PREPARED '${name}'`); @@ -2331,8 +2621,10 @@ function SQL(o, e = {}) { if (pool.closed) { throw connectionClosedError(); } + assertValidTransactionName(name); - const adapter = connectionInfo.adapter; + const adapter = resolvedOptions.adapter; + switch (adapter) { case "postgres": return await sql.unsafe(`COMMIT PREPARED '${name}'`); @@ -2351,6 +2643,7 @@ function SQL(o, e = {}) { if (pool.closed) { return Promise.reject(connectionClosedError()); } + let callback = fn; if (typeof name !== "string") { @@ -2360,7 +2653,9 @@ function SQL(o, e = {}) { if (!$isCallable(callback)) { return Promise.reject($ERR_INVALID_ARG_VALUE("fn", callback, "must be a function")); } + const { promise, resolve, reject } = Promise.withResolvers(); + // lets just reuse the same code path as the transaction begin pool.connect(onTransactionConnected.bind(null, callback, name, resolve, reject, false, true), true); return promise; @@ -2370,6 +2665,7 @@ function SQL(o, e = {}) { if (pool.closed) { return Promise.reject(connectionClosedError()); } + let callback = fn; let options: string | undefined = options_or_fn as unknown as string; if ($isCallable(options_or_fn)) { @@ -2378,13 +2674,17 @@ function SQL(o, e = {}) { } else if (typeof options_or_fn !== "string") { return Promise.reject($ERR_INVALID_ARG_VALUE("options", options_or_fn, "must be a string")); } + if (!$isCallable(callback)) { return Promise.reject($ERR_INVALID_ARG_VALUE("fn", callback, "must be a function")); } + const { promise, resolve, reject } = Promise.withResolvers(); pool.connect(onTransactionConnected.bind(null, callback, options, resolve, reject, false, false), true); + return promise; }; + sql.connect = () => { if (pool.closed) { return Promise.reject(connectionClosedError()); @@ -2394,13 +2694,18 @@ function SQL(o, e = {}) { return Promise.resolve(sql); } - let { resolve, reject, promise } = Promise.withResolvers(); - const onConnected = (err, connection) => { + const { resolve, reject, promise } = Promise.withResolvers(); + + const onConnected = (err: unknown, connection: PooledPostgresConnection | null) => { if (err) { return reject(err); } + // we are just measuring the connection here lets release it - pool.release(connection); + if (connection) { + pool.release(connection); + } + resolve(sql); }; @@ -2416,17 +2721,20 @@ function SQL(o, e = {}) { sql[Symbol.asyncDispose] = () => sql.close(); sql.flush = () => pool.flush(); - sql.options = connectionInfo; + sql.options = resolvedOptions; sql.transaction = sql.begin; sql.distributed = sql.beginDistributed; sql.end = sql.close; - return sql; -} -var lazyDefaultSQL: InstanceType; + return sql satisfies Bun.SQL; +}; -function resetDefaultSQL(sql) { +SQL.UnsupportedAdapterError = UnsupportedAdapterError; + +var lazyDefaultSQL: Bun.SQL; + +function resetDefaultSQL(sql: Bun.SQL) { lazyDefaultSQL = sql; // this will throw "attempt to assign to readonly property" // Object.assign(defaultSQLObject, lazyDefaultSQL); @@ -2439,28 +2747,33 @@ function ensureDefaultSQL() { } } -var defaultSQLObject: InstanceType = function sql(strings, ...values) { +const defaultSQLObject: Bun.SQL = function sql(strings, ...values) { if (new.target) { return SQL(strings); } + if (!lazyDefaultSQL) { resetDefaultSQL(SQL(undefined)); } + return lazyDefaultSQL(strings, ...values); -} as typeof BunTypes.SQL; +} as Bun.SQL; defaultSQLObject.reserve = (...args) => { ensureDefaultSQL(); return lazyDefaultSQL.reserve(...args); }; + defaultSQLObject.commitDistributed = (...args) => { ensureDefaultSQL(); return lazyDefaultSQL.commitDistributed(...args); }; + defaultSQLObject.rollbackDistributed = (...args) => { ensureDefaultSQL(); return lazyDefaultSQL.rollbackDistributed(...args); }; + defaultSQLObject.distributed = defaultSQLObject.beginDistributed = (...args) => { ensureDefaultSQL(); return lazyDefaultSQL.beginDistributed(...args); @@ -2481,19 +2794,21 @@ defaultSQLObject.file = (filename: string, ...args) => { return lazyDefaultSQL.file(filename, ...args); }; -defaultSQLObject.transaction = defaultSQLObject.begin = function (...args: Parameters) { +defaultSQLObject.transaction = defaultSQLObject.begin = function (...args) { ensureDefaultSQL(); return lazyDefaultSQL.begin(...args); -} as (typeof BunTypes.SQL)["begin"]; +}; -defaultSQLObject.end = defaultSQLObject.close = (...args: Parameters) => { +defaultSQLObject.end = defaultSQLObject.close = (...args) => { ensureDefaultSQL(); return lazyDefaultSQL.close(...args); }; -defaultSQLObject.flush = (...args: Parameters) => { + +defaultSQLObject.flush = (...args) => { ensureDefaultSQL(); return lazyDefaultSQL.flush(...args); }; + //define lazy properties defineProperties(defaultSQLObject, { options: { @@ -2510,12 +2825,10 @@ defineProperties(defaultSQLObject, { }, }); -var exportsObject = { +export default { sql: defaultSQLObject, default: defaultSQLObject, SQL, Query, postgres: SQL, }; - -export default exportsObject; diff --git a/src/js/private.d.ts b/src/js/private.d.ts index 83085b2656..adcd940996 100644 --- a/src/js/private.d.ts +++ b/src/js/private.d.ts @@ -10,6 +10,29 @@ type BunWatchListener = (event: WatchEventType, filename: T | undefined) => v */ declare function $bundleError(...message: any[]): never; +declare module "bun" { + namespace SQL.__internal { + type Define = T & { + [Key in K | "adapter"]: NonNullable; + } & {}; + + /** + * Represents the result of the `parseOptions()` function in the sqlite path + */ + type DefinedSQLiteOptions = Define; + + /** + * Represents the result of the `parseOptions()` function in the postgres path + */ + type DefinedPostgresOptions = Define & { + sslMode: 0 | 1 | 2 | 3 | 4; // keep in sync with SSLMode enum in src/js/sql.ts + query: string; + }; + + type DefinedOptions = DefinedSQLiteOptions | DefinedPostgresOptions; + } +} + interface BunFSWatcher { /** * Stop watching for changes on the given `BunFSWatcher`. Once stopped, the `BunFSWatcher` object is no longer usable. diff --git a/test/integration/bun-types/fixture/sql.ts b/test/integration/bun-types/fixture/sql.ts index 20aab93e96..9568b76fe4 100644 --- a/test/integration/bun-types/fixture/sql.ts +++ b/test/integration/bun-types/fixture/sql.ts @@ -19,6 +19,9 @@ import { expectAssignable, expectType } from "./utilities"; await postgres`select * from users where id = ${id}`; } +expectType().extends(); +expectType().is(); + { const postgres = new Bun.SQL(); postgres("ok"); @@ -265,3 +268,9 @@ sql([1, 2, 3], "notAKey"); expectType>(); expectType>(); expectType>(); + +// check some types exist +expectType>; +expectType; +expectType; +expectType>; diff --git a/test/integration/bun-types/fixture/utilities.ts b/test/integration/bun-types/fixture/utilities.ts index df761817af..609a9188f7 100644 --- a/test/integration/bun-types/fixture/utilities.ts +++ b/test/integration/bun-types/fixture/utilities.ts @@ -12,7 +12,8 @@ export function expectType(): { * expectType().is(); // pass * ``` */ - is(...args: IfEquals extends true ? [] : [expected: X, butGot: T]): void; + is(...args: IfEquals extends true ? [] : [expected: X, but_got: T]): void; + extends(...args: T extends X ? [] : [expected: T, but_got: X]): void; }; export function expectType(arg: T): { /** diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts new file mode 100644 index 0000000000..ce05bfd5fa --- /dev/null +++ b/test/js/sql/sqlite-sql.test.ts @@ -0,0 +1,878 @@ +import { SQL } from "bun"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { tempDirWithFiles } from "harness"; +import { rm, stat } from "node:fs/promises"; +import path from "path"; + +describe("Bun.sql SQLite support", () => { + describe("Connection & Initialization", () => { + test("should connect to in-memory SQLite database", async () => { + const sql = new SQL("sqlite://:memory:"); + expect(sql).toBeDefined(); + expect(sql.options.adapter).toBe("sqlite"); + await sql.close(); + }); + + test("should connect to file-based SQLite database", async () => { + const dir = tempDirWithFiles("sqlite-db-test", {}); + const dbPath = path.join(dir, "test.db"); + + const sql = new SQL(`sqlite://${dbPath}`); + expect(sql).toBeDefined(); + expect(sql.options.adapter).toBe("sqlite"); + expect(sql.options.filename).toBe(dbPath); + + await sql.close(); + await rm(dir, { recursive: true }); + }); + + test("should handle connection with options object", async () => { + const sql = new SQL({ + adapter: "sqlite", + filename: ":memory:", + }); + + expect(sql.options.adapter).toBe("sqlite"); + expect(sql.options.filename).toBe(":memory:"); + + await sql`CREATE TABLE test (id INTEGER)`; + await sql`INSERT INTO test VALUES (1)`; + + const result = await sql`SELECT * FROM test`; + expect(result).toHaveLength(1); + + await sql.close(); + }); + + test("should create database file if it doesn't exist", async () => { + const dir = tempDirWithFiles("sqlite-create-test", {}); + const dbPath = path.join(dir, "new.db"); + + const sql = new SQL(`sqlite://${dbPath}`); + await sql`CREATE TABLE test (id INTEGER)`; + + const stats = await stat(dbPath); + expect(stats.isFile()).toBe(true); + + await sql.close(); + await rm(dir, { recursive: true }); + }); + + test("should work with relative paths", async () => { + const dir = tempDirWithFiles("sqlite-test", {}); + const sql = new SQL({ + adapter: "sqlite", + filename: path.join(dir, "test.db"), + }); + + await sql`CREATE TABLE test (id INTEGER)`; + const stats = await stat(path.join(dir, "test.db")); + expect(stats.isFile()).toBe(true); + + await sql.close(); + await rm(dir, { recursive: true }); + }); + }); + + describe("Data Types & Values", () => { + let sql: SQL; + + beforeAll(async () => { + sql = new SQL("sqlite://:memory:"); + }); + + afterAll(async () => { + await sql?.close(); + }); + + test("handles NULL values", async () => { + await sql`CREATE TABLE nulls (id INTEGER, value TEXT)`; + await sql`INSERT INTO nulls (id, value) VALUES (1, ${null})`; + + const result = await sql`SELECT * FROM nulls`; + expect(result[0].value).toBeNull(); + }); + + test("handles INTEGER values", async () => { + const values = [0, 1, -1, 2147483647, -2147483648]; + await sql`CREATE TABLE integers (value INTEGER)`; + + for (const val of values) { + await sql`INSERT INTO integers VALUES (${val})`; + } + + const results = await sql`SELECT * FROM integers`; + expect(results.map(r => r.value)).toEqual(values); + }); + + test("handles REAL values", async () => { + const values = [0.0, 1.1, -1.1, 3.14159, Number.MAX_SAFE_INTEGER + 0.1]; + await sql`CREATE TABLE reals (value REAL)`; + + for (const val of values) { + await sql`INSERT INTO reals VALUES (${val})`; + } + + const results = await sql`SELECT * FROM reals`; + results.forEach((r, i) => { + expect(r.value).toBeCloseTo(values[i], 10); + }); + }); + + test("handles TEXT values", async () => { + const values = ["", "hello", "hello world", "unicode: 你好 🌍", "'quotes'", '"double quotes"']; + await sql`CREATE TABLE texts (value TEXT)`; + + for (const val of values) { + await sql`INSERT INTO texts VALUES (${val})`; + } + + const results = await sql`SELECT * FROM texts`; + expect(results.map(r => r.value)).toEqual(values); + }); + + test("handles BLOB values", async () => { + const buffer = Buffer.from([0x00, 0x01, 0x02, 0x03, 0xff]); + await sql`CREATE TABLE blobs (value BLOB)`; + await sql`INSERT INTO blobs VALUES (${buffer})`; + + const result = await sql`SELECT * FROM blobs`; + expect(Buffer.from(result[0].value)).toEqual(buffer); + }); + + test("handles boolean values (stored as INTEGER)", async () => { + await sql`CREATE TABLE bools (value INTEGER)`; + await sql`INSERT INTO bools VALUES (${true}), (${false})`; + + const results = await sql`SELECT * FROM bools`; + expect(results[0].value).toBe(1); + expect(results[1].value).toBe(0); + }); + + test("handles Date values (stored as TEXT)", async () => { + const date = new Date("2024-01-01T12:00:00Z"); + await sql`CREATE TABLE dates (value TEXT)`; + await sql`INSERT INTO dates VALUES (${date.toISOString()})`; + + const result = await sql`SELECT * FROM dates`; + expect(new Date(result[0].value)).toEqual(date); + }); + + test("handles JSON values (stored as TEXT)", async () => { + const jsonData = { name: "Test", values: [1, 2, 3], nested: { key: "value" } }; + await sql`CREATE TABLE json_data (value TEXT)`; + await sql`INSERT INTO json_data VALUES (${JSON.stringify(jsonData)})`; + + const result = await sql`SELECT * FROM json_data`; + expect(JSON.parse(result[0].value)).toEqual(jsonData); + }); + }); + + describe("Query Execution", () => { + let sql: SQL; + + beforeAll(async () => { + sql = new SQL("sqlite://:memory:"); + }); + + afterAll(async () => { + await sql?.close(); + }); + + test("CREATE TABLE", async () => { + const result = await sql`CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT UNIQUE, + age INTEGER CHECK (age >= 0), + created_at TEXT DEFAULT CURRENT_TIMESTAMP + )`; + + expect(result.command).toBe("CREATE"); + }); + + test("INSERT with RETURNING", async () => { + await sql`CREATE TABLE items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)`; + + const result = await sql`INSERT INTO items (name) VALUES (${"Item1"}) RETURNING *`; + expect(result).toHaveLength(1); + expect(result[0].id).toBe(1); + expect(result[0].name).toBe("Item1"); + expect(result.command).toBe("INSERT"); + }); + + test("UPDATE with affected rows", async () => { + await sql`CREATE TABLE products (id INTEGER PRIMARY KEY, price REAL)`; + await sql`INSERT INTO products VALUES (1, 10.0), (2, 20.0), (3, 30.0)`; + + const result = await sql`UPDATE products SET price = price * 1.1 WHERE price < 25`; + expect(result.count).toBe(2); + expect(result.command).toBe("UPDATE"); + }); + + test("DELETE with affected rows", async () => { + await sql`CREATE TABLE tasks (id INTEGER PRIMARY KEY, done INTEGER)`; + await sql`INSERT INTO tasks VALUES (1, 0), (2, 1), (3, 0), (4, 1)`; + + const result = await sql`DELETE FROM tasks WHERE done = 1`; + expect(result.count).toBe(2); + expect(result.command).toBe("DELETE"); + }); + + test("SELECT with various clauses", async () => { + await sql`CREATE TABLE scores (id INTEGER, player TEXT, score INTEGER, team TEXT)`; + await sql`INSERT INTO scores VALUES + (1, 'Alice', 100, 'Red'), + (2, 'Bob', 85, 'Blue'), + (3, 'Charlie', 95, 'Red'), + (4, 'Diana', 110, 'Blue')`; + + // ORDER BY + const ordered = await sql`SELECT * FROM scores ORDER BY score DESC`; + expect(ordered[0].player).toBe("Diana"); + + // WHERE + const filtered = await sql`SELECT * FROM scores WHERE score > ${90}`; + expect(filtered).toHaveLength(3); + + // GROUP BY with aggregate + const grouped = await sql` + SELECT team, COUNT(*) as count, AVG(score) as avg_score + FROM scores + GROUP BY team + `; + expect(grouped).toHaveLength(2); + + // LIMIT and OFFSET + const limited = await sql`SELECT * FROM scores ORDER BY score DESC LIMIT 2 OFFSET 1`; + expect(limited).toHaveLength(2); + expect(limited[0].player).toBe("Alice"); + }); + + test("handles multiple statements with unsafe", async () => { + const result = await sql.unsafe(` + CREATE TABLE multi1 (id INTEGER); + CREATE TABLE multi2 (id INTEGER); + INSERT INTO multi1 VALUES (1); + INSERT INTO multi2 VALUES (2); + SELECT * FROM multi1; + SELECT * FROM multi2; + `); + + // SQLite returns the last result + expect(result).toHaveLength(1); + expect(result[0].id).toBe(2); + }); + }); + + describe("Parameterized Queries", () => { + let sql: SQL; + + beforeAll(async () => { + sql = new SQL("sqlite://:memory:"); + await sql`CREATE TABLE params_test (id INTEGER, text_val TEXT, num_val REAL)`; + }); + + afterAll(async () => { + await sql?.close(); + }); + + test("converts PostgreSQL $N style to SQLite ? style", async () => { + // The SQL template tag internally uses $N style, should be converted to ? + await sql`INSERT INTO params_test VALUES (${1}, ${"test"}, ${3.14})`; + + const result = await sql`SELECT * FROM params_test WHERE id = ${1}`; + expect(result[0].text_val).toBe("test"); + expect(result[0].num_val).toBeCloseTo(3.14); + }); + + test("handles many parameters", async () => { + const values = Array.from({ length: 20 }, (_, i) => i); + const columns = values.map(i => `col${i} INTEGER`).join(", "); + const tableName = "many_params"; + + await sql.unsafe(`CREATE TABLE ${tableName} (${columns})`); + + const placeholders = values.map(() => "?").join(", "); + await sql.unsafe(`INSERT INTO ${tableName} VALUES (${placeholders})`, values); + + const result = await sql.unsafe(`SELECT * FROM ${tableName}`); + expect(Object.values(result[0])).toEqual(values); + }); + + test("escapes special characters in parameters", async () => { + const specialStrings = [ + "'; DROP TABLE users; --", + '" OR "1"="1', + "\\'; DROP TABLE users; --", + "\x00\x01\x02", + "Robert'); DROP TABLE Students;--", + ]; + + for (const str of specialStrings) { + await sql`INSERT INTO params_test (id, text_val) VALUES (${100}, ${str})`; + const result = await sql`SELECT text_val FROM params_test WHERE id = ${100}`; + expect(result[0].text_val).toBe(str); + await sql`DELETE FROM params_test WHERE id = ${100}`; + } + }); + }); + + describe("Transactions", () => { + let sql: SQL; + + beforeEach(async () => { + sql = new SQL("sqlite://:memory:"); + await sql`CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance REAL)`; + await sql`INSERT INTO accounts VALUES (1, 1000), (2, 500)`; + }); + + afterEach(async () => { + await sql?.close(); + }); + + test("successful transaction commits", async () => { + const result = await sql.begin(async tx => { + await tx`UPDATE accounts SET balance = balance - 100 WHERE id = 1`; + await tx`UPDATE accounts SET balance = balance + 100 WHERE id = 2`; + return "success"; + }); + + expect(result).toBe("success"); + + const accounts = await sql`SELECT * FROM accounts ORDER BY id`; + expect(accounts[0].balance).toBe(900); + expect(accounts[1].balance).toBe(600); + }); + + test("failed transaction rolls back", async () => { + try { + await sql.begin(async tx => { + await tx`UPDATE accounts SET balance = balance - 2000 WHERE id = 1`; + await tx`UPDATE accounts SET balance = balance + 2000 WHERE id = 2`; + throw new Error("Insufficient funds"); + }); + } catch (err) { + expect(err.message).toBe("Insufficient funds"); + } + + const accounts = await sql`SELECT * FROM accounts ORDER BY id`; + expect(accounts[0].balance).toBe(1000); + expect(accounts[1].balance).toBe(500); + }); + + test("nested transactions (savepoints)", async () => { + await sql.begin(async tx => { + await tx`UPDATE accounts SET balance = balance - 100 WHERE id = 1`; + + try { + await tx.savepoint(async sp => { + await sp`UPDATE accounts SET balance = balance - 200 WHERE id = 1`; + throw new Error("Inner transaction failed"); + }); + } catch (err) { + // Inner transaction rolled back, outer continues + } + + await tx`UPDATE accounts SET balance = balance + 100 WHERE id = 2`; + }); + + const accounts = await sql`SELECT * FROM accounts ORDER BY id`; + expect(accounts[0].balance).toBe(900); // Only first update applied + expect(accounts[1].balance).toBe(600); + }); + + test("read-only transactions", async () => { + const result = await sql.begin("read", async tx => { + const accounts = await tx`SELECT * FROM accounts`; + + // This should fail in a read-only transaction + try { + await tx`UPDATE accounts SET balance = 0`; + expect(true).toBe(false); // Should not reach here + } catch (err) { + expect(err.message).toContain("readonly"); + } + + return accounts; + }); + + expect(result).toHaveLength(2); + }); + + test("deferred vs immediate transactions", async () => { + // SQLite supports DEFERRED, IMMEDIATE, and EXCLUSIVE transaction modes + await sql.begin("deferred", async tx => { + await tx`SELECT * FROM accounts`; // Acquires shared lock + await tx`UPDATE accounts SET balance = balance + 1`; // Upgrades to exclusive lock + }); + + await sql.begin("immediate", async tx => { + // Acquires reserved lock immediately + await tx`UPDATE accounts SET balance = balance + 1`; + }); + + const accounts = await sql`SELECT * FROM accounts WHERE id = 1`; + expect(accounts[0].balance).toBe(1002); + }); + }); + + describe("SQLite-specific features", () => { + let sql: SQL; + + beforeAll(async () => { + sql = new SQL("sqlite://:memory:"); + }); + + afterAll(async () => { + await sql?.close(); + }); + + test("PRAGMA statements", async () => { + // Get SQLite version + const version = await sql`PRAGMA compile_options`; + expect(version.length).toBeGreaterThan(0); + + // Check journal mode + const journalMode = await sql`PRAGMA journal_mode`; + expect(journalMode[0].journal_mode).toBeDefined(); + + // Set and check synchronous mode + await sql`PRAGMA synchronous = NORMAL`; + const syncMode = await sql`PRAGMA synchronous`; + expect(syncMode[0].synchronous).toBe(1); + }); + + test("AUTOINCREMENT behavior", async () => { + await sql`CREATE TABLE auto_test ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + value TEXT + )`; + + await sql`INSERT INTO auto_test (value) VALUES ('first')`; + await sql`INSERT INTO auto_test (value) VALUES ('second')`; + await sql`DELETE FROM auto_test WHERE id = 2`; + await sql`INSERT INTO auto_test (value) VALUES ('third')`; + + const results = await sql`SELECT * FROM auto_test ORDER BY id`; + expect(results[0].id).toBe(1); + expect(results[1].id).toBe(3); // AUTOINCREMENT doesn't reuse IDs + }); + + test("last_insert_rowid()", async () => { + await sql`CREATE TABLE rowid_test (id INTEGER PRIMARY KEY, value TEXT)`; + await sql`INSERT INTO rowid_test (value) VALUES ('test')`; + + const result = await sql`SELECT last_insert_rowid() as id`; + expect(result[0].id).toBe(1); + }); + + test("changes() function", async () => { + await sql`CREATE TABLE changes_test (id INTEGER, value TEXT)`; + await sql`INSERT INTO changes_test VALUES (1, 'a'), (2, 'b'), (3, 'c')`; + + await sql`UPDATE changes_test SET value = 'updated' WHERE id > 1`; + const changes = await sql`SELECT changes() as count`; + expect(changes[0].count).toBe(2); + }); + + test("ATTACH DATABASE", async () => { + const dir = tempDirWithFiles("sqlite-attach-test", {}); + const attachPath = path.join(dir, "attached.db"); + + await sql`ATTACH DATABASE ${attachPath} AS attached`; + await sql`CREATE TABLE attached.other_table (id INTEGER)`; + await sql`INSERT INTO attached.other_table VALUES (1)`; + + const result = await sql`SELECT * FROM attached.other_table`; + expect(result).toHaveLength(1); + + await sql`DETACH DATABASE attached`; + await rm(dir, { recursive: true }); + }); + + test("Common Table Expressions (CTEs)", async () => { + await sql`CREATE TABLE employees (id INTEGER, name TEXT, manager_id INTEGER)`; + await sql`INSERT INTO employees VALUES + (1, 'CEO', NULL), + (2, 'VP1', 1), + (3, 'VP2', 1), + (4, 'Manager1', 2), + (5, 'Manager2', 3)`; + + const result = await sql` + WITH RECURSIVE org_chart AS ( + SELECT id, name, manager_id, 0 as level + FROM employees + WHERE manager_id IS NULL + UNION ALL + SELECT e.id, e.name, e.manager_id, oc.level + 1 + FROM employees e + JOIN org_chart oc ON e.manager_id = oc.id + ) + SELECT * FROM org_chart ORDER BY level, id + `; + + expect(result).toHaveLength(5); + expect(result[0].level).toBe(0); + expect(result[result.length - 1].level).toBe(2); + }); + + test("Full-text search (FTS5)", async () => { + // Check if FTS5 is available + try { + await sql`CREATE VIRTUAL TABLE docs USING fts5(title, content)`; + + await sql`INSERT INTO docs VALUES + ('First Document', 'This is the content of the first document'), + ('Second Document', 'This document contains different content'), + ('Third Document', 'Another document with unique text')`; + + const results = await sql`SELECT * FROM docs WHERE docs MATCH 'content'`; + expect(results).toHaveLength(2); + + await sql`DROP TABLE docs`; + } catch (err) { + // FTS5 might not be available in all SQLite builds + console.log("FTS5 not available:", err.message); + } + }); + + test("JSON functions", async () => { + // SQLite JSON1 extension functions + await sql`CREATE TABLE json_test (id INTEGER, data TEXT)`; + + const jsonData = { name: "Test", values: [1, 2, 3] }; + await sql`INSERT INTO json_test VALUES (1, ${JSON.stringify(jsonData)})`; + + // Extract JSON values + const name = await sql`SELECT json_extract(data, '$.name') as name FROM json_test`; + expect(name[0].name).toBe("Test"); + + const arrayLength = await sql`SELECT json_array_length(data, '$.values') as len FROM json_test`; + expect(arrayLength[0].len).toBe(3); + }); + }); + + describe("SQL helpers", () => { + let sql: SQL; + + beforeAll(async () => { + sql = new SQL("sqlite://:memory:"); + }); + + afterAll(async () => { + await sql.close(); + }); + + test("bulk insert with sql() helper", async () => { + await sql`CREATE TABLE bulk_test (id INTEGER, name TEXT, value REAL)`; + + const data = [ + { id: 1, name: "Item1", value: 10.5 }, + { id: 2, name: "Item2", value: 20.5 }, + { id: 3, name: "Item3", value: 30.5 }, + ]; + + await sql`INSERT INTO bulk_test ${sql(data)}`; + + const results = await sql`SELECT * FROM bulk_test ORDER BY id`; + expect(results).toHaveLength(3); + expect(results[0].name).toBe("Item1"); + }); + + test("unsafe with parameters", async () => { + await sql`CREATE TABLE unsafe_test (id INTEGER, value TEXT)`; + + const query = "INSERT INTO unsafe_test VALUES (?, ?)"; + await sql.unsafe(query, [1, "test"]); + + const selectQuery = "SELECT * FROM unsafe_test WHERE id = ?"; + const results = await sql.unsafe(selectQuery, [1]); + expect(results[0].value).toBe("test"); + }); + + test("file execution", async () => { + const dir = tempDirWithFiles("sql-files", { + "schema.sql": ` + CREATE TABLE file_test ( + id INTEGER PRIMARY KEY, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + INSERT INTO file_test (id) VALUES (1), (2), (3); + `, + "query.sql": `SELECT COUNT(*) as count FROM file_test`, + }); + + await sql.file(path.join(dir, "schema.sql")); + + const result = await sql.file(path.join(dir, "query.sql")); + expect(result[0].count).toBe(3); + }); + + test("file with parameters", async () => { + const dir = tempDirWithFiles("sql-params", { + "query.sql": `SELECT ? as param1, ? as param2`, + }); + + const result = await sql.file(path.join(dir, "query.sql"), ["value1", "value2"]); + expect(result[0].param1).toBe("value1"); + expect(result[0].param2).toBe("value2"); + }); + }); + + describe("Error handling", () => { + let sql: SQL; + + beforeAll(async () => { + sql = new SQL("sqlite://:memory:"); + }); + + afterAll(async () => { + await sql.close(); + }); + + test("syntax errors", async () => { + try { + await sql`SELCT * FROM nonexistent`; + expect(true).toBe(false); + } catch (err) { + expect(err.message).toContain("syntax error"); + } + }); + + test("constraint violations", async () => { + await sql`CREATE TABLE constraints ( + id INTEGER PRIMARY KEY, + value TEXT NOT NULL, + unique_val TEXT UNIQUE + )`; + + // NOT NULL violation + try { + await sql`INSERT INTO constraints (id, value) VALUES (1, ${null})`; + expect(true).toBe(false); + } catch (err) { + expect(err.message).toContain("NOT NULL"); + } + + // UNIQUE violation + await sql`INSERT INTO constraints VALUES (1, 'test', 'unique')`; + try { + await sql`INSERT INTO constraints VALUES (2, 'test2', 'unique')`; + expect(true).toBe(false); + } catch (err) { + expect(err.message).toContain("UNIQUE"); + } + }); + + test("foreign key violations", async () => { + await sql`PRAGMA foreign_keys = ON`; + + await sql`CREATE TABLE parent (id INTEGER PRIMARY KEY)`; + await sql`CREATE TABLE child ( + id INTEGER PRIMARY KEY, + parent_id INTEGER, + FOREIGN KEY (parent_id) REFERENCES parent(id) + )`; + + await sql`INSERT INTO parent VALUES (1)`; + await sql`INSERT INTO child VALUES (1, 1)`; // Should work + + try { + await sql`INSERT INTO child VALUES (2, 999)`; // Non-existent parent + expect(true).toBe(false); + } catch (err) { + expect(err.message).toContain("FOREIGN KEY"); + } + }); + }); + + describe("Connection management", () => { + test("close() prevents further queries", async () => { + const sql = new SQL("sqlite://:memory:"); + await sql`CREATE TABLE test (id INTEGER)`; + await sql.close(); + + try { + await sql`SELECT * FROM test`; + expect(true).toBe(false); + } catch (err) { + expect(err.message).toContain("closed"); + } + }); + + test("reserve returns same instance for SQLite", async () => { + const sql = new SQL("sqlite://:memory:"); + + const reserved1 = await sql.reserve(); + const reserved2 = await sql.reserve(); + + expect(reserved1).toBe(sql); + expect(reserved2).toBe(sql); + + await sql.close(); + }); + + test("distributed transactions throw for SQLite", async () => { + const sql = new SQL("sqlite://:memory:"); + + expect(() => sql.beginDistributed("test-tx", async () => {})).toThrow( + "SQLite doesn't support distributed transactions", + ); + + expect(() => sql.commitDistributed("test-tx")).toThrow("SQLite doesn't support distributed transactions"); + + expect(() => sql.rollbackDistributed("test-tx")).toThrow("SQLite doesn't support distributed transactions"); + + await sql.close(); + }); + }); + + describe("Performance & Edge Cases", () => { + test("handles large datasets", async () => { + const sql = new SQL("sqlite://:memory:"); + + await sql`CREATE TABLE large (id INTEGER PRIMARY KEY, data TEXT)`; + + // Insert many rows + const rowCount = 1000; + const data = Buffer.alloc(100, "x").toString(); // 100 character string + + await sql.begin(async tx => { + for (let i = 0; i < rowCount; i++) { + await tx`INSERT INTO large VALUES (${i}, ${data})`; + } + }); + + const count = await sql`SELECT COUNT(*) as count FROM large`; + expect(count[0].count).toBe(rowCount); + + await sql.close(); + }); + + test("handles many columns", async () => { + const sql = new SQL("sqlite://:memory:"); + + const columnCount = 100; + const columns = Array.from({ length: columnCount }, (_, i) => `col${i} INTEGER`).join(", "); + + await sql.unsafe(`CREATE TABLE wide (${columns})`); + + const values = Array.from({ length: columnCount }, (_, i) => i); + const placeholders = values.map(() => "?").join(", "); + + await sql.unsafe(`INSERT INTO wide VALUES (${placeholders})`, values); + + const result = await sql`SELECT * FROM wide`; + expect(Object.keys(result[0])).toHaveLength(columnCount); + + await sql.close(); + }); + + test("handles concurrent queries", async () => { + const sql = new SQL("sqlite://:memory:"); + + await sql`CREATE TABLE concurrent (id INTEGER PRIMARY KEY, value INTEGER)`; + + // SQLite serializes queries, but they should all complete + const promises = Array.from({ length: 10 }, (_, i) => sql`INSERT INTO concurrent VALUES (${i}, ${i * 10})`); + + await Promise.all(promises); + + const count = await sql`SELECT COUNT(*) as count FROM concurrent`; + expect(count[0].count).toBe(10); + + await sql.close(); + }); + + test("handles empty results", async () => { + const sql = new SQL("sqlite://:memory:"); + + await sql`CREATE TABLE empty (id INTEGER)`; + const results = await sql`SELECT * FROM empty`; + + expect(results).toHaveLength(0); + expect(results.command).toBe("SELECT"); + expect(results.count).toBe(0); + + await sql.close(); + }); + + test("handles special table names", async () => { + const sql = new SQL("sqlite://:memory:"); + + // Table names that need quoting + const specialNames = [ + "table-with-dash", + "table.with.dots", + "table with spaces", + "123numeric", + "SELECT", // Reserved keyword + ]; + + for (const name of specialNames) { + await sql.unsafe(`CREATE TABLE "${name}" (id INTEGER)`); + await sql.unsafe(`INSERT INTO "${name}" VALUES (1)`); + const result = await sql.unsafe(`SELECT * FROM "${name}"`); + expect(result).toHaveLength(1); + await sql.unsafe(`DROP TABLE "${name}"`); + } + + await sql.close(); + }); + }); + + describe("WAL mode and concurrency", () => { + test("can enable WAL mode", async () => { + const dir = tempDirWithFiles("sqlite-wal-test", {}); + const dbPath = path.join(dir, "wal-test.db"); + const sql = new SQL(`sqlite://${dbPath}`); + + await sql`PRAGMA journal_mode = WAL`; + const mode = await sql`PRAGMA journal_mode`; + expect(mode[0].journal_mode).toBe("wal"); + + // WAL mode creates additional files + await sql`CREATE TABLE wal_test (id INTEGER)`; + await sql`INSERT INTO wal_test VALUES (1)`; + + const walPath = `${dbPath}-wal`; + const shmPath = `${dbPath}-shm`; + + const walStats = await stat(walPath); + expect(walStats.isFile()).toBe(true); + expect(walStats.size).toBeGreaterThan(0); + + const shmStats = await stat(shmPath); + expect(shmStats.isFile()).toBe(true); + expect(shmStats.size).toBeGreaterThan(0); + + await sql.close(); + await rm(dir, { recursive: true }); + }); + }); + + describe("Memory and resource management", () => { + test("properly releases resources on close", async () => { + const sql = new SQL("sqlite://:memory:"); + + await sql`CREATE TABLE resource_test (id INTEGER, data TEXT)`; + + // Insert some data + for (let i = 0; i < 100; i++) { + await sql`INSERT INTO resource_test VALUES (${i}, ${"x".repeat(1000)})`; + } + + await sql.close(); + + // Further operations should fail + try { + await sql`SELECT * FROM resource_test`; + expect(true).toBe(false); + } catch (err) { + expect(err.message).toContain("closed"); + } + }); + }); +});