diff --git a/src/js/internal/sql/mysql.ts b/src/js/internal/sql/mysql.ts index 4786bfc84570..11c112cb53b6 100644 --- a/src/js/internal/sql/mysql.ts +++ b/src/js/internal/sql/mysql.ts @@ -1,11 +1,16 @@ import type { MySQLErrorOptions } from "internal/sql/errors"; import type { Query } from "./query"; -import type { ArrayType, DatabaseAdapter, SQLArrayParameter, SQLHelper, SQLResultArray, SSLMode } from "./shared"; -const { SQLHelper, SSLMode, SQLResultArray, buildDefinedColumnsAndQuery } = require("internal/sql/shared"); +import type { ArrayType, DatabaseAdapter, SQLArrayParameter, SQLCommand, SQLResultArray, SSLMode } from "./shared"; +const { + SQLResultArray, + BasePooledConnection, + BaseSQLAdapter, + createPooledConnectionHandle, + getHelperCommandFromDetect, +} = require("internal/sql/shared"); const { - Query, SQLQueryFlags, - symbols: { _strings, _values, _results, _handle }, + symbols: { _results, _handle }, } = require("internal/sql/query"); const { MySQLError } = require("internal/sql/errors"); @@ -114,441 +119,65 @@ export interface MySQLDotZig { ) => $ZigGeneratedClasses.MySQLQuery; } -const enum SQLCommand { - insert = 0, - update = 1, - updateSet = 2, - where = 3, - in = 4, - none = -1, -} -export type { SQLCommand }; - -function commandToString(command: SQLCommand): string { - switch (command) { - case SQLCommand.insert: - return "INSERT"; - case SQLCommand.updateSet: - case SQLCommand.update: - return "UPDATE"; - case SQLCommand.in: - case SQLCommand.where: - return "WHERE"; - default: - return ""; - } -} - -function detectCommand(query: string): SQLCommand { - const text = query.toLowerCase().trim(); - const text_len = text.length; - - let token = ""; - let command = SQLCommand.none; - let quoted = false; - // we need to reverse search so we find the closest command to the parameter - for (let i = text_len - 1; i >= 0; i--) { - const char = text[i]; - switch (char) { - case " ": // Space - case "\n": // Line feed - case "\t": // Tab character - case "\r": // Carriage return - case "\f": // Form feed - case "\v": { - switch (token) { - case "insert": { - return SQLCommand.insert; - } - case "update": { - return SQLCommand.update; - } - case "where": { - return SQLCommand.where; - } - case "set": { - return SQLCommand.updateSet; - } - case "in": { - return SQLCommand.in; - } - default: { - token = ""; - continue; - } - } - } - default: { - // skip quoted commands - if (char === '"') { - quoted = !quoted; - continue; - } - if (!quoted) { - token = char + token; - } - } - } - } - if (token) { - switch (token) { - case "insert": - return SQLCommand.insert; - case "update": - return SQLCommand.update; - case "where": - return SQLCommand.where; - case "set": - return SQLCommand.updateSet; - case "in": - case "any": - case "all": - return SQLCommand.in; - default: - return SQLCommand.none; - } - } - return command; -} -const enum PooledConnectionState { - pending = 0, - connected = 1, - closed = 2, -} - -const enum PooledConnectionFlags { - /// canBeConnected is used to indicate that at least one time we were able to connect to the database - canBeConnected = 1 << 0, - /// reserved is used to indicate that the connection is currently reserved - reserved = 1 << 1, - /// preReserved is used to indicate that the connection will be reserved in the future when queryCount drops to 0 - preReserved = 1 << 2, -} - -function onQueryFinish(this: PooledMySQLConnection, onClose: (err: Error) => void) { - this.queries.delete(onClose); - this.adapter.release(this); -} - -function closeNT(onClose: (err: Error) => void, err: Error | null) { - onClose(err as Error); -} -class PooledMySQLConnection { - private static async createConnection( - options: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions, - onConnected: (err: Error | null, connection: $ZigGeneratedClasses.MySQLConnection) => void, - onClose: (err: Error | null) => void, - ): Promise<$ZigGeneratedClasses.MySQLConnection | null> { - const { - hostname, - port, - username, - tls, - query, - database, - sslMode, - idleTimeout = 0, - connectionTimeout = 30 * 1000, - maxLifetime = 0, - prepare = true, - path, - allowPublicKeyRetrieval = false, - } = options; - - let password: Bun.MaybePromise | string | undefined | (() => Bun.MaybePromise) = options.password; - - try { - if (typeof password === "function") { - password = password(); - } - - if (password && $isPromise(password)) { - password = await password; - } - - return createMySQLConnection( - hostname, - Number(port), - username || "", - password || "", - database || "", - // > The default value for sslmode is prefer. As is shown in the table, this - // makes no sense from a security point of view, and it only promises - // performance overhead if possible. It is only provided as the default for - // backward compatibility, and is not recommended in secure deployments. - sslMode || SSLMode.disable, - tls || null, - query || "", - path || "", - onConnected, - onClose, - idleTimeout, - connectionTimeout, - maxLifetime, - !prepare, - !!allowPublicKeyRetrieval, - ); - } catch (e) { - process.nextTick(closeNT, onClose, e); - return null; - } - } - - adapter: MySQLAdapter; - connection: $ZigGeneratedClasses.MySQLConnection | null = null; - state: PooledConnectionState = PooledConnectionState.pending; - storedError: Error | null = null; - queries: Set<(err: Error) => void> = new Set(); - onFinish: ((err: Error | null) => void) | null = null; - connectionInfo: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions; - flags: number = 0; - /// queryCount is used to indicate the number of queries using the connection, if a connection is reserved or if its a transaction queryCount will be 1 independently of the number of queries - queryCount: number = 0; - /// when the current connect cycle started; 0 when not connecting. Connect - /// failures (server not yet accepting connections) are retried until - /// connectionTimeout elapses from this point. - connectStartedAt: number = 0; - connectAttempts: number = 0; - retryTimer: ReturnType | null = null; - - #onConnected(err, connection) { - if (err) { - err = wrapError(err); - } else { - this.connection = connection; - } - - const connectionInfo = this.connectionInfo; - if (connectionInfo?.onconnect) { - connectionInfo.onconnect(err); - } - this.storedError = err; +class PooledMySQLConnection extends BasePooledConnection<$ZigGeneratedClasses.MySQLConnection> { + protected handleConnected(err: any, connection?: $ZigGeneratedClasses.MySQLConnection) { if (!err) { - this.connectStartedAt = 0; - this.flags |= PooledConnectionFlags.canBeConnected; - } - this.state = err ? PooledConnectionState.closed : PooledConnectionState.connected; - const onFinish = this.onFinish; - if (onFinish) { - this.queryCount = 0; - this.flags &= ~PooledConnectionFlags.reserved; - this.flags &= ~PooledConnectionFlags.preReserved; - - // pool is closed, lets finish the connection - // pool is closed, lets finish the connection - if (err) { - onFinish(err); - } else { - this.connection?.close(); - } - return; - } - this.adapter.release(this, true); - } - - #onClose(err) { - if (err) { - err = wrapError(err); - } - this.connection = null; - this.storedError = err; - if (this.#shouldRetryConnecting(err)) { - // The server is not accepting connections yet (e.g. still starting - // up). Keep the slot pending and retry with backoff instead of - // failing the queries that are waiting for a connection. The user's - // onclose callback only fires when the slot actually closes. - this.connectAttempts++; - const delay = Math.min(20 * 2 ** this.connectAttempts, 1000); - this.retryTimer = setTimeout(PooledMySQLConnection.#retryTimerFired, delay, this); - return; - } - // this connect cycle is over; a later retry() starts a fresh one - this.connectStartedAt = 0; - this.#finishClose(err); - } - - static #retryTimerFired(self: PooledMySQLConnection) { - self.retryTimer = null; - // conditions may have changed during the backoff (pool closing, waiters - // gone, retry budget elapsed), so re-check before dialing - if (self.#canKeepRetrying()) { - self.#startConnection(); - } else { - self.#finishClose(self.storedError); - } - } - - #finishClose(err) { - const connectionInfo = this.connectionInfo; - if (connectionInfo?.onclose) { - connectionInfo.onclose(err); + this.connection = connection!; } - this.state = PooledConnectionState.closed; - this.storedError = err; - - // remove from ready connections if its there - this.adapter.readyConnections.delete(this); - const queries = new Set(this.queries); - this.queries?.clear?.(); - this.queryCount = 0; - this.flags &= ~PooledConnectionFlags.reserved; - - // notify all queries that the connection is closed - for (const onClose of queries) { - onClose(err); - } - const onFinish = this.onFinish; - if (onFinish) { - onFinish(err); - } - - this.adapter.release(this, true); + super.handleConnected(err); } - constructor(connectionInfo: Bun.SQL.__internal.DefinedMySQLOptions, adapter: MySQLAdapter) { - this.state = PooledConnectionState.pending; - this.adapter = adapter; - this.connectionInfo = connectionInfo; - this.#startConnection(); + protected startConnection() { + createPooledConnectionHandle( + createMySQLConnection, + this.connectionInfo, + this.handleConnected.bind(this), + this.handleClose.bind(this), + true, + ); } - #startConnection() { - if (this.connectStartedAt === 0) { - this.connectStartedAt = Date.now(); - this.connectAttempts = 0; - } - PooledMySQLConnection.createConnection(this.connectionInfo, this.#onConnected.bind(this), this.#onClose.bind(this)); + protected wrapError(error: any): Error { + return wrapError(error); } - /// Connect failures (ERR_MYSQL_CONNECTION_FAILED) mean the server - /// accepted the TCP connection but closed it before the handshake - /// completed — typically it is still starting up, or an intermediary - /// (like a container port proxy) is up before the database is. Those are - /// retried until connectionTimeout elapses, as long as queries are - /// waiting on the pool. Refused connections - /// (ERR_MYSQL_CONNECTION_REFUSED) fail fast: nothing is listening, - /// and probes/healthchecks rely on the immediate error. Real server errors (authentication, - /// handshake errors) and closes of established connections are not - /// retried here. - #shouldRetryConnecting(err: Error | null): boolean { - // connect failures come from the native layer as options objects that - // wrapError turned into MySQLError instances with a typed code - if (!(err instanceof MySQLError) || err.code !== "ERR_MYSQL_CONNECTION_FAILED") { - return false; - } - return this.#canKeepRetrying(); - } - - #canKeepRetrying(): boolean { - if (this.adapter.closed || this.onFinish !== null) { - return false; - } - // only retry while queries are actually waiting for a connection - if (this.adapter.waitingQueue.length === 0 && this.adapter.reservedQueue.length === 0) { - return false; - } - // an explicit connectionTimeout of 0 disables the connect timer, and with - // it the retry budget - const connectionTimeout = this.connectionInfo.connectionTimeout ?? 30 * 1000; - if (connectionTimeout <= 0) { - return false; - } - return this.connectStartedAt !== 0 && Date.now() - this.connectStartedAt < connectionTimeout; - } - - /// Returns true if a scheduled connect retry was cancelled — in that case - /// nothing is in flight and no onClose/onConnected callback will fire. - cancelRetry(): boolean { - if (this.retryTimer !== null) { - clearTimeout(this.retryTimer); - this.retryTimer = null; - return true; + protected isNonRetryableError(code: string | undefined): boolean { + switch (code) { + case "ERR_MYSQL_PASSWORD_REQUIRED": + case "ERR_MYSQL_MISSING_AUTH_DATA": + case "ERR_MYSQL_FAILED_TO_ENCRYPT_PASSWORD": + case "ERR_MYSQL_INVALID_PUBLIC_KEY": + case "ERR_MYSQL_UNSUPPORTED_PROTOCOL_VERSION": + case "ERR_MYSQL_UNSUPPORTED_AUTH_PLUGIN": + case "ERR_MYSQL_AUTHENTICATION_FAILED": + // we can't retry these are authentication errors + return true; + default: + return false; } - return false; } - onClose(onClose: (err: Error) => void) { - this.queries.add(onClose); - } - - bindQuery(query: Query, onClose: (err: Error) => void) { - this.queries.add(onClose); - query.finally(onQueryFinish.bind(this, onClose)); - } - - #doRetry() { - if (this.adapter.closed) { - return; - } - // reset error and state - this.storedError = null; - this.connectStartedAt = 0; - this.state = PooledConnectionState.pending; - // retry connection - this.#startConnection(); - } - close() { - try { - if (this.state === PooledConnectionState.connected) { - this.connection?.close(); - } - } catch {} - } - flush() { - this.connection?.flush(); - } - retry() { - // if pool is closed, we can't retry - if (this.adapter.closed) { - return false; - } - // we need to reconnect - // lets use a retry strategy - - // we can only retry if one day we are able to connect - if (this.flags & PooledConnectionFlags.canBeConnected) { - this.#doRetry(); - } else { - // analyse type of error to see if we can retry - switch (this.storedError?.code) { - case "ERR_MYSQL_PASSWORD_REQUIRED": - case "ERR_MYSQL_MISSING_AUTH_DATA": - case "ERR_MYSQL_FAILED_TO_ENCRYPT_PASSWORD": - case "ERR_MYSQL_INVALID_PUBLIC_KEY": - case "ERR_MYSQL_UNSUPPORTED_PROTOCOL_VERSION": - case "ERR_MYSQL_UNSUPPORTED_AUTH_PLUGIN": - case "ERR_MYSQL_AUTHENTICATION_FAILED": - // we can't retry these are authentication errors - return false; - default: - // we can retry - this.#doRetry(); - } - } - return true; + /// Connect failures (ERR_MYSQL_CONNECTION_FAILED) mean the server accepted + /// the TCP connection but closed it before the handshake completed, + /// typically because it is still starting up or an intermediary (like a + /// container port proxy) is up before the database is. Those are retried + /// until connectionTimeout elapses, as long as queries are waiting on the + /// pool. Refused connections (ERR_MYSQL_CONNECTION_REFUSED) fail fast: + /// nothing is listening, and probes/healthchecks rely on the immediate + /// error. Real server errors (authentication, handshake errors) and closes + /// of established connections are not retried here. + protected isConnectFailureError(err: Error | null): boolean { + return err instanceof MySQLError && (err as any).code === "ERR_MYSQL_CONNECTION_FAILED"; } } class MySQLAdapter + extends BaseSQLAdapter implements DatabaseAdapter { - public readonly connectionInfo: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions; - - public readonly connections: PooledMySQLConnection[]; - public readonly readyConnections: Set = new Set(); - - public waitingQueue: Array<(err: Error | null, result: any) => void> = []; - public reservedQueue: Array<(err: Error | null, result: any) => void> = []; - - public poolStarted: boolean = false; - public closed: boolean = false; - public totalQueries: number = 0; - public onAllQueriesFinished: (() => void) | null = null; - - constructor(connectionInfo: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions) { - this.connectionInfo = connectionInfo; - this.connections = new Array(connectionInfo.max); + protected createPooledConnection(): PooledMySQLConnection { + return new PooledMySQLConnection(this.connectionInfo, this); } escapeIdentifier(str: string) { @@ -575,25 +204,12 @@ class MySQLAdapter code: "ERR_MYSQL_INVALID_TRANSACTION_STATE", }); } - supportsReservedConnections() { - return true; - } - - getConnectionForQuery(pooledConnection: PooledMySQLConnection) { - return pooledConnection.connection; - } - - attachConnectionCloseHandler(connection: PooledMySQLConnection, handler: () => void): void { - if (connection.onClose) { - connection.onClose(handler); - } + unsafeTransactionError() { + return new MySQLError("Only use sql.begin, sql.reserved or max: 1", { + code: "ERR_MYSQL_UNSAFE_TRANSACTION", + }); } - detachConnectionCloseHandler(connection: PooledMySQLConnection, handler: () => void): void { - if (connection.queries) { - connection.queries.delete(handler); - } - } array(_values: any[], _typeNameOrID?: number | ArrayType): SQLArrayParameter { throw new Error("MySQL doesn't support arrays"); } @@ -629,28 +245,6 @@ class MySQLAdapter }; } - validateTransactionOptions(options: string): { valid: boolean; error?: string } { - // The string is interpolated into `START TRANSACTION ${options}`, so refuse anything - // that could terminate the statement or start a new one. - if (!/^[A-Za-z ,]*$/.test(options)) { - return { - valid: false, - error: "Transaction options can only contain letters, spaces, and commas.", - }; - } - return { valid: true }; - } - - validateDistributedTransactionName(name: string): { valid: boolean; error?: string } { - if (name.indexOf("'") !== -1) { - return { - valid: false, - error: "Distributed transaction name cannot contain single quotes.", - }; - } - return { valid: true }; - } - getCommitDistributedSQL(name: string): string { const validation = this.validateDistributedTransactionName(name); if (!validation.valid) { @@ -668,16 +262,7 @@ class MySQLAdapter } createQueryHandle(sql: string, values: unknown[], flags: number) { - if (!(flags & SQLQueryFlags.allowUnsafeTransaction)) { - if (this.connectionInfo.max !== 1) { - const upperCaseSqlString = sql.toUpperCase().trim(); - if (upperCaseSqlString.startsWith("BEGIN") || upperCaseSqlString.startsWith("START TRANSACTION")) { - throw new MySQLError("Only use sql.begin, sql.reserved or max: 1", { - code: "ERR_MYSQL_UNSAFE_TRANSACTION", - }); - } - } - } + this.checkUnsafeTransaction(sql, flags); return createMySQLQuery( sql, @@ -689,571 +274,15 @@ class MySQLAdapter ); } - maxDistribution() { - if (!this.waitingQueue.length) return 0; - const result = Math.ceil((this.waitingQueue.length + this.totalQueries) / this.connections.length); - return result ? result : 1; - } - - flushConcurrentQueries() { - const maxDistribution = this.maxDistribution(); - if (maxDistribution === 0) { - return; - } - - while (true) { - const nonReservedConnections = Array.from(this.readyConnections).filter( - c => !(c.flags & PooledConnectionFlags.preReserved) && c.queryCount < maxDistribution, - ); - if (nonReservedConnections.length === 0) { - return; - } - const orderedConnections = nonReservedConnections.sort((a, b) => a.queryCount - b.queryCount); - for (const connection of orderedConnections) { - const pending = this.waitingQueue.shift(); - if (!pending) { - return; - } - connection.queryCount++; - this.totalQueries++; - pending(null, connection); - } - } - } - - release(connection: PooledMySQLConnection, connectingEvent: boolean = false) { - if (!connectingEvent) { - connection.queryCount--; - this.totalQueries--; - } - const currentQueryCount = connection.queryCount; - if (currentQueryCount == 0) { - connection.flags &= ~PooledConnectionFlags.reserved; - connection.flags &= ~PooledConnectionFlags.preReserved; - } - if (this.onAllQueriesFinished) { - // we are waiting for all queries to finish, lets check if we can call it - if (!this.hasPendingQueries()) { - this.onAllQueriesFinished(); - } - } - - if (connection.state !== PooledConnectionState.connected) { - // connection is not ready - if (connection.storedError) { - // this connection got a error but maybe we can wait for another - - if (this.hasConnectionsAvailable()) { - return; - } - - const waitingQueue = this.waitingQueue; - const reservedQueue = this.reservedQueue; - - this.waitingQueue = []; - this.reservedQueue = []; - // we have no connections available so lets fails - for (const pending of waitingQueue) { - pending(connection.storedError, connection); - } - for (const pending of reservedQueue) { - pending(connection.storedError, connection); - } - // draining the queues may have been the last pending work — a - // graceful close() is waiting on this callback - if (this.onAllQueriesFinished && !this.hasPendingQueries()) { - this.onAllQueriesFinished(); - } - } - return; - } - - if (currentQueryCount == 0) { - // ok we can actually bind reserved queries to it - const pendingReserved = this.reservedQueue.shift(); - if (pendingReserved) { - connection.flags |= PooledConnectionFlags.reserved; - connection.queryCount++; - this.totalQueries++; - // we have a connection waiting for a reserved connection lets prioritize it - pendingReserved(connection.storedError, connection); - return; - } - } - this.readyConnections.add(connection); - this.flushConcurrentQueries(); - } - - hasConnectionsAvailable() { - if (this.readyConnections.size > 0) return true; - if (this.poolStarted) { - const pollSize = this.connections.length; - for (let i = 0; i < pollSize; i++) { - const connection = this.connections[i]; - if (connection.state !== PooledConnectionState.closed) { - // some connection is connecting or connected - return true; - } - } - } - return false; - } - - hasPendingQueries() { - if (this.waitingQueue.length > 0 || this.reservedQueue.length > 0) return true; - if (this.poolStarted) { - return this.totalQueries > 0; - } - return false; - } - isConnected() { - if (this.readyConnections.size > 0) { - return true; - } - if (this.poolStarted) { - const pollSize = this.connections.length; - for (let i = 0; i < pollSize; i++) { - const connection = this.connections[i]; - if (connection.state === PooledConnectionState.connected) { - return true; - } - } - } - return false; - } - flush() { - if (this.closed) { - return; - } - if (this.poolStarted) { - const pollSize = this.connections.length; - for (let i = 0; i < pollSize; i++) { - const connection = this.connections[i]; - if (connection.state === PooledConnectionState.connected) { - connection.connection?.flush(); - } - } - } - } - - async #close() { - let pending; - while ((pending = this.waitingQueue.shift())) { - pending(this.connectionClosedError(), null); - } - while (this.reservedQueue.length > 0) { - const pendingReserved = this.reservedQueue.shift(); - if (pendingReserved) { - pendingReserved(this.connectionClosedError(), null); - } - } - - const promises: Array> = []; - - if (this.poolStarted) { - this.poolStarted = false; - const pollSize = this.connections.length; - for (let i = 0; i < pollSize; i++) { - const connection = this.connections[i]; - switch (connection.state) { - case PooledConnectionState.pending: - { - if (connection.cancelRetry()) { - // a connect retry was scheduled; nothing is in flight so - // there is no onClose/onConnected to wait for - connection.state = PooledConnectionState.closed; - break; - } - const { promise, resolve } = Promise.withResolvers(); - connection.onFinish = resolve; - promises.push(promise); - connection.connection?.close(); - } - break; - - case PooledConnectionState.connected: - { - const { promise, resolve } = Promise.withResolvers(); - connection.onFinish = resolve; - promises.push(promise); - connection.connection?.close(); - } - break; - } - // clean connection reference - // @ts-ignore - this.connections[i] = null; - } - } - - this.readyConnections.clear(); - this.waitingQueue.length = 0; - return Promise.all(promises); - } - - async close(options?: { timeout?: number }) { - if (this.closed) { - return; - } - - let timeout = options?.timeout; - if (timeout) { - timeout = Number(timeout); - if (timeout > 2 ** 31 || timeout < 0 || timeout !== timeout) { - throw $ERR_INVALID_ARG_VALUE("options.timeout", timeout, "must be a non-negative integer less than 2^31"); - } - - this.closed = true; - if (timeout === 0 || !this.hasPendingQueries()) { - // close immediately - await this.#close(); - return; - } - - const { promise, resolve } = Promise.withResolvers(); - const timer = setTimeout(() => { - // timeout is reached, lets close and probably fail some queries - this.#close().finally(resolve); - }, timeout * 1000); - timer.unref(); // dont block the event loop - - this.onAllQueriesFinished = () => { - clearTimeout(timer); - // everything is closed, lets close the pool - this.#close().finally(resolve); - }; - - return promise; - } else { - this.closed = true; - if (!this.hasPendingQueries()) { - // close immediately - await this.#close(); - return; - } - - // gracefully close the pool - const { promise, resolve } = Promise.withResolvers(); - - this.onAllQueriesFinished = () => { - // everything is closed, lets close the pool - this.#close().finally(resolve); - }; - - return promise; - } + getHelperCommand(query: string): SQLCommand { + return getHelperCommandFromDetect(query, true); } - /** - * @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) { - if (this.closed) { - return onConnected(this.connectionClosedError(), null); - } - - if (this.readyConnections.size === 0) { - // no connection ready lets make some - let retry_in_progress = false; - let all_closed = true; - let storedError: Error | null = null; - - if (this.poolStarted) { - // we already started the pool - // lets check if some connection is available to retry - const pollSize = this.connections.length; - for (let i = 0; i < pollSize; i++) { - const connection = this.connections[i]; - // we need a new connection and we have some connections that can retry - if (connection.state === PooledConnectionState.closed) { - if (connection.retry()) { - // lets wait for connection to be released - if (!retry_in_progress) { - // avoid adding to the queue twice, we wanna to retry every available pool connection - retry_in_progress = true; - if (reserved) { - // we are not sure what connection will be available so we dont pre reserve - this.reservedQueue.push(onConnected); - } else { - this.waitingQueue.push(onConnected); - } - } - } else { - // we have some error, lets grab it and fail if unable to start a connection - storedError = connection.storedError; - } - } else { - // we have some pending or open connections - all_closed = false; - } - } - if (!all_closed && !retry_in_progress) { - // is possible to connect because we have some working connections, or we are just without network for some reason - // wait for connection to be released or fail - if (reserved) { - // we are not sure what connection will be available so we dont pre reserve - this.reservedQueue.push(onConnected); - } else { - this.waitingQueue.push(onConnected); - } - } else if (!retry_in_progress) { - // impossible to connect or retry - onConnected(storedError ?? this.connectionClosedError(), null); - } - return; - } - // we never started the pool, lets start it - if (reserved) { - this.reservedQueue.push(onConnected); - } else { - this.waitingQueue.push(onConnected); - } - this.poolStarted = true; - const pollSize = this.connections.length; - // pool is always at least 1 connection - const firstConnection = new PooledMySQLConnection(this.connectionInfo, 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 PooledMySQLConnection(this.connectionInfo, this); - } - return; - } - if (reserved) { - let connectionWithLeastQueries: PooledMySQLConnection | null = null; - let leastQueries = Infinity; - for (const connection of this.readyConnections) { - if (connection.flags & PooledConnectionFlags.preReserved || connection.flags & PooledConnectionFlags.reserved) - continue; - const queryCount = connection.queryCount; - if (queryCount > 0) { - if (queryCount < leastQueries) { - leastQueries = queryCount; - connectionWithLeastQueries = connection; - } - continue; - } - connection.flags |= PooledConnectionFlags.reserved; - connection.queryCount++; - this.totalQueries++; - this.readyConnections.delete(connection); - onConnected(null, connection); - return; - } - - if (connectionWithLeastQueries) { - // lets mark the connection with the least queries as preReserved if any - connectionWithLeastQueries.flags |= PooledConnectionFlags.preReserved; - } - - // no connection available to be reserved lets wait for a connection to be released - this.reservedQueue.push(onConnected); - } else { - this.waitingQueue.push(onConnected); - this.flushConcurrentQueries(); - } - } - - normalizeQuery(strings: string | TemplateStringsArray, values: unknown[], binding_idx = 1): [string, unknown[]] { - if (typeof strings === "string") { - // identifier or unsafe query - return [strings, values || []]; - } - - if (!$isArray(strings)) { - // we should not hit this path - throw new SyntaxError("Invalid query: SQL Fragment cannot be executed or was misused"); - } - - const str_len = strings.length; - if (str_len === 0) { - return ["", []]; - } - - let binding_values: any[] = []; - let query = ""; - - for (let i = 0; i < str_len; i++) { - const string = strings[i]; - - if (typeof string === "string") { - query += string; - - if (values.length > i) { - const value = values[i]; - - if (value instanceof Query) { - const q = value as Query; - const [sub_query, sub_values] = this.normalizeQuery(q[_strings], q[_values], binding_idx); - - query += sub_query; - for (let j = 0; j < sub_values.length; j++) { - binding_values.push(sub_values[j]); - } - binding_idx += sub_values.length; - } else if (value instanceof SQLHelper) { - const command = detectCommand(query); - // only selectIn, insert, update, updateSet are allowed - if (command === SQLCommand.none || command === SQLCommand.where) { - throw new SyntaxError("Helpers are only allowed for INSERT, UPDATE and IN commands"); - } - const { columns, value: items } = value as SQLHelper; - const columnCount = columns.length; - if (columnCount === 0 && command !== SQLCommand.in) { - throw new SyntaxError(`Cannot ${commandToString(command)} with no columns`); - } - const lastColumnIndex = columns.length - 1; - - if (command === SQLCommand.insert) { - // - // insert into users ${sql(users)} or insert into users ${sql(user)} - // - - // Build column list while determining which columns have at least one defined value - const { definedColumns, columnsSql } = buildDefinedColumnsAndQuery( - columns, - items, - this.escapeIdentifier.bind(this), - ); - - const definedColumnCount = definedColumns.length; - if (definedColumnCount === 0) { - throw new SyntaxError("Insert needs to have at least one column with a defined value"); - } - const lastDefinedColumnIndex = definedColumnCount - 1; - - query += columnsSql; - if ($isArray(items)) { - const itemsCount = items.length; - const lastItemIndex = itemsCount - 1; - for (let j = 0; j < itemsCount; j++) { - query += "("; - const item = items[j]; - for (let k = 0; k < definedColumnCount; k++) { - const column = definedColumns[k]; - const columnValue = item[column]; - query += `?${k < lastDefinedColumnIndex ? ", " : ""}`; - // If this item has undefined for a column that other items defined, use null - binding_values.push(typeof columnValue === "undefined" ? null : columnValue); - } - if (j < lastItemIndex) { - query += "),"; - } else { - query += ") "; // the user can add RETURNING * or RETURNING id - } - } - } else { - query += "("; - const item = items; - for (let j = 0; j < definedColumnCount; j++) { - const column = definedColumns[j]; - const columnValue = item[column]; - query += `?${j < lastDefinedColumnIndex ? ", " : ""}`; - binding_values.push(columnValue); - } - query += ") "; // the user can add RETURNING * or RETURNING id - } - } else if (command === SQLCommand.in) { - // SELECT * FROM users WHERE id IN (${sql([1, 2, 3])}) - if (!$isArray(items)) { - throw new SyntaxError("An array of values is required for WHERE IN helper"); - } - const itemsCount = items.length; - const lastItemIndex = itemsCount - 1; - query += "("; - for (let j = 0; j < itemsCount; j++) { - query += `?${j < lastItemIndex ? ", " : ""}`; - if (columnCount > 0) { - // we must use a key from a object - if (columnCount > 1) { - // we should not pass multiple columns here - throw new SyntaxError("Cannot use WHERE IN helper with multiple columns"); - } - // SELECT * FROM users WHERE id IN (${sql(users, "id")}) - const value = items[j]; - if (typeof value === "undefined") { - binding_values.push(null); - } else { - const value_from_key = value[columns[0]]; - - if (typeof value_from_key === "undefined") { - binding_values.push(null); - } else { - binding_values.push(value_from_key); - } - } - } else { - const value = items[j]; - if (typeof value === "undefined") { - binding_values.push(null); - } else { - binding_values.push(value); - } - } - } - query += ") "; // more conditions can be added after this - } else { - // UPDATE users SET ${sql({ name: "John", age: 31 })} WHERE id = 1 - let item; - if ($isArray(items)) { - if (items.length > 1) { - throw new SyntaxError("Cannot use array of objects for UPDATE"); - } - item = items[0]; - } else { - item = items; - } - // no need to include if is updateSet or upsert - const isUpsert = query.trimEnd().endsWith("ON DUPLICATE KEY UPDATE"); - if (command === SQLCommand.update && !isUpsert) { - query += " SET "; - } - let hasValues = false; - for (let i = 0; i < columnCount; i++) { - const column = columns[i]; - const columnValue = item[column]; - if (typeof columnValue === "undefined") { - // skip undefined values, this is the expected behavior in JS - continue; - } - hasValues = true; - query += `${this.escapeIdentifier(column)} = ?${i < lastColumnIndex ? ", " : ""}`; - binding_values.push(columnValue); - } - if (query.endsWith(", ")) { - // we got an undefined value at the end, lets remove the last comma - query = query.substring(0, query.length - 2); - } - if (!hasValues) { - throw new SyntaxError("Update needs to have at least one column"); - } - query += " "; // the user can add where clause after this - } - } else { - //TODO: handle sql.array parameters - query += `? `; - if (typeof value === "undefined") { - binding_values.push(null); - } else { - binding_values.push(value); - } - } - } - } else { - throw new SyntaxError("Invalid query: SQL Fragment cannot be executed or was misused"); - } - } - - return [query, binding_values]; + isUpsertUpdate(query: string): boolean { + return query.trimEnd().endsWith("ON DUPLICATE KEY UPDATE"); } } export default { MySQLAdapter, - commandToString, - detectCommand, - SQLCommand, }; diff --git a/src/js/internal/sql/postgres.ts b/src/js/internal/sql/postgres.ts index 655f2509f189..d423d9a58b0b 100644 --- a/src/js/internal/sql/postgres.ts +++ b/src/js/internal/sql/postgres.ts @@ -1,17 +1,18 @@ import type { PostgresErrorOptions } from "internal/sql/errors"; import type { Query } from "./query"; -import type { ArrayType, DatabaseAdapter, SQLArrayParameter, SQLHelper, SQLResultArray, SSLMode } from "./shared"; +import type { ArrayType, DatabaseAdapter, SQLArrayParameter, SQLCommand, SQLResultArray, SSLMode } from "./shared"; const { - SQLHelper, - SSLMode, SQLResultArray, SQLArrayParameter, - buildDefinedColumnsAndQuery, + BasePooledConnection, + BaseSQLAdapter, + createPooledConnectionHandle, + getHelperCommandFromDetect, + pushBindParam, } = require("internal/sql/shared"); const { - Query, SQLQueryFlags, - symbols: { _strings, _values, _flags, _results, _handle }, + symbols: { _results, _handle }, } = require("internal/sql/query"); function isTypedArray(value: any) { // Buffer should be treated as a normal object @@ -346,417 +347,58 @@ export interface PostgresDotZig { ) => $ZigGeneratedClasses.PostgresSQLQuery; } -const enum SQLCommand { - insert = 0, - update = 1, - updateSet = 2, - where = 3, - in = 4, - none = -1, -} -export type { SQLCommand }; - -function commandToString(command: SQLCommand): string { - switch (command) { - case SQLCommand.insert: - return "INSERT"; - case SQLCommand.updateSet: - case SQLCommand.update: - return "UPDATE"; - case SQLCommand.in: - case SQLCommand.where: - return "WHERE"; - default: - return ""; - } -} - -function detectCommand(query: string): SQLCommand { - const text = query.toLowerCase().trim(); - const text_len = text.length; - - let token = ""; - let command = SQLCommand.none; - let quoted = false; - // we need to reverse search so we find the closest command to the parameter - for (let i = text_len - 1; i >= 0; i--) { - const char = text[i]; - switch (char) { - case " ": // Space - case "\n": // Line feed - case "\t": // Tab character - case "\r": // Carriage return - case "\f": // Form feed - case "\v": { - switch (token) { - case "insert": { - return SQLCommand.insert; - } - case "update": { - return SQLCommand.update; - } - case "where": { - return SQLCommand.where; - } - case "set": { - return SQLCommand.updateSet; - } - case "in": { - return SQLCommand.in; - } - default: { - token = ""; - continue; - } - } - } - default: { - // skip quoted commands - if (char === '"') { - quoted = !quoted; - continue; - } - if (!quoted) { - token = char + token; - } - } - } - } - if (token) { - switch (token) { - case "insert": - return SQLCommand.insert; - case "update": - return SQLCommand.update; - case "where": - return SQLCommand.where; - case "set": - return SQLCommand.updateSet; - case "in": - return SQLCommand.in; - default: - return SQLCommand.none; - } - } - return command; -} - -const enum PooledConnectionState { - pending = 0, - connected = 1, - closed = 2, -} - -const enum PooledConnectionFlags { - /// canBeConnected is used to indicate that at least one time we were able to connect to the database - canBeConnected = 1 << 0, - /// reserved is used to indicate that the connection is currently reserved - reserved = 1 << 1, - /// preReserved is used to indicate that the connection will be reserved in the future when queryCount drops to 0 - preReserved = 1 << 2, -} - -function onQueryFinish(this: PooledPostgresConnection, onClose: (err: Error) => void) { - this.queries.delete(onClose); - this.adapter.release(this); -} - -class PooledPostgresConnection { - private static async createConnection( - options: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions, - onConnected: (err: Error | null, connection: $ZigGeneratedClasses.PostgresSQLConnection) => void, - onClose: (err: Error | null) => void, - ): Promise<$ZigGeneratedClasses.PostgresSQLConnection | null> { - const { - hostname, - port, - username, - tls, - query, - database, - sslMode, - idleTimeout = 0, - connectionTimeout = 30 * 1000, - maxLifetime = 0, - prepare = true, - path, - } = options; - - let password: Bun.MaybePromise | string | undefined | (() => Bun.MaybePromise) = options.password; - - try { - if (typeof password === "function") { - password = password(); - } - - if (password && $isPromise(password)) { - password = await password; - } - - return createPostgresConnection( - hostname, - Number(port), - username || "", - password || "", - database || "", - // > The default value for sslmode is prefer. As is shown in the table, this - // makes no sense from a security point of view, and it only promises - // performance overhead if possible. It is only provided as the default for - // backward compatibility, and is not recommended in secure deployments. - sslMode || SSLMode.disable, - tls || null, - query || "", - path || "", - onConnected, - onClose, - idleTimeout, - connectionTimeout, - maxLifetime, - !prepare, - ); - } catch (e) { - onClose(e as Error); - return null; - } - } - - adapter: PostgresAdapter; - connection: $ZigGeneratedClasses.PostgresSQLConnection | null = null; - state: PooledConnectionState = PooledConnectionState.pending; - storedError: Error | null = null; - queries: Set<(err: Error) => void> = new Set(); - onFinish: ((err: Error | null) => void) | null = null; - connectionInfo: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions; - flags: number = 0; - /// queryCount is used to indicate the number of queries using the connection, if a connection is reserved or if its a transaction queryCount will be 1 independently of the number of queries - queryCount: number = 0; - /// when the current connect cycle started; 0 when not connecting. Connect - /// failures (server not yet accepting connections) are retried until - /// connectionTimeout elapses from this point. - connectStartedAt: number = 0; - connectAttempts: number = 0; - retryTimer: ReturnType | null = null; - - #onConnected(err, _) { - if (err) { - err = wrapPostgresError(err); - } - const connectionInfo = this.connectionInfo; - if (connectionInfo?.onconnect) { - connectionInfo.onconnect(err); - } - this.storedError = err; - if (!err) { - this.connectStartedAt = 0; - this.flags |= PooledConnectionFlags.canBeConnected; - } - this.state = err ? PooledConnectionState.closed : PooledConnectionState.connected; - const onFinish = this.onFinish; - if (onFinish) { - this.queryCount = 0; - this.flags &= ~PooledConnectionFlags.reserved; - this.flags &= ~PooledConnectionFlags.preReserved; - - // pool is closed, lets finish the connection - // pool is closed, lets finish the connection - if (err) { - onFinish(err); - } else { - this.connection?.close(); - } - return; - } - this.adapter.release(this, true); - } - - #onClose(err) { - if (err) { - err = wrapPostgresError(err); - } - this.connection = null; - this.storedError = err; - if (this.#shouldRetryConnecting(err)) { - // The server is not accepting connections yet (e.g. still starting - // up). Keep the slot pending and retry with backoff instead of - // failing the queries that are waiting for a connection. The user's - // onclose callback only fires when the slot actually closes. - this.connectAttempts++; - const delay = Math.min(20 * 2 ** this.connectAttempts, 1000); - this.retryTimer = setTimeout(PooledPostgresConnection.#retryTimerFired, delay, this); - return; - } - // this connect cycle is over; a later retry() starts a fresh one - this.connectStartedAt = 0; - this.#finishClose(err); - } - - static #retryTimerFired(self: PooledPostgresConnection) { - self.retryTimer = null; - // conditions may have changed during the backoff (pool closing, waiters - // gone, retry budget elapsed), so re-check before dialing - if (self.#canKeepRetrying()) { - self.#startConnection(); - } else { - self.#finishClose(self.storedError); - } - } - - #finishClose(err) { - const connectionInfo = this.connectionInfo; - if (connectionInfo?.onclose) { - connectionInfo.onclose(err); - } - this.state = PooledConnectionState.closed; - this.storedError = err; - - // remove from ready connections if its there - this.adapter.readyConnections?.delete(this); - const queries = new Set(this.queries); - this.queries?.clear?.(); - this.queryCount = 0; - this.flags &= ~PooledConnectionFlags.reserved; - - // notify all queries that the connection is closed - for (const onClose of queries) { - onClose(err); - } - const onFinish = this.onFinish; - if (onFinish) { - onFinish(err); - } - - this.adapter.release(this, true); +class PooledPostgresConnection extends BasePooledConnection<$ZigGeneratedClasses.PostgresSQLConnection> { + protected async startConnection() { + this.connection = await createPooledConnectionHandle( + createPostgresConnection, + this.connectionInfo, + this.handleConnected.bind(this), + this.handleClose.bind(this), + false, + ); } - constructor(connectionInfo: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions, adapter: PostgresAdapter) { - this.state = PooledConnectionState.pending; - this.adapter = adapter; - this.connectionInfo = connectionInfo; - this.#startConnection(); + protected wrapError(error: any): Error { + return wrapPostgresError(error); } - async #startConnection() { - if (this.connectStartedAt === 0) { - this.connectStartedAt = Date.now(); - this.connectAttempts = 0; + protected isNonRetryableError(code: string | undefined): boolean { + switch (code) { + case "ERR_POSTGRES_UNSUPPORTED_AUTHENTICATION_METHOD": + case "ERR_POSTGRES_UNKNOWN_AUTHENTICATION_METHOD": + case "ERR_POSTGRES_TLS_NOT_AVAILABLE": + case "ERR_POSTGRES_TLS_UPGRADE_FAILED": + case "ERR_POSTGRES_INVALID_SERVER_SIGNATURE": + case "ERR_POSTGRES_INVALID_SERVER_KEY": + case "ERR_POSTGRES_AUTHENTICATION_FAILED_PBKDF2": + // we can't retry these are authentication errors + return true; + default: + return false; } - this.connection = await PooledPostgresConnection.createConnection( - this.connectionInfo, - this.#onConnected.bind(this), - this.#onClose.bind(this), - ); } /// Connect failures (ERR_POSTGRES_CONNECTION_FAILED) mean the server /// accepted the TCP connection but closed it before the handshake - /// completed — typically it is still starting up, or an intermediary + /// completed, typically because it is still starting up or an intermediary /// (like a container port proxy) is up before the database is. Those are - /// retried until connectionTimeout elapses, as long as queries are - /// waiting on the pool. Refused connections - /// (ERR_POSTGRES_CONNECTION_REFUSED) fail fast: nothing is listening, - /// and probes/healthchecks rely on the immediate error. Real server errors (authentication, - /// ErrorResponse during startup) and closes of established connections are - /// not retried here. - #shouldRetryConnecting(err: Error | null): boolean { - // connect failures come from the native layer as options objects that - // wrapPostgresError turned into PostgresError instances with a typed code - if (!(err instanceof PostgresError) || err.code !== "ERR_POSTGRES_CONNECTION_FAILED") { - return false; - } - return this.#canKeepRetrying(); - } - - #canKeepRetrying(): boolean { - if (this.adapter.closed || this.onFinish !== null) { - return false; - } - // only retry while queries are actually waiting for a connection - if (this.adapter.waitingQueue.length === 0 && this.adapter.reservedQueue.length === 0) { - return false; - } - // an explicit connectionTimeout of 0 disables the connect timer, and with - // it the retry budget - const connectionTimeout = this.connectionInfo.connectionTimeout ?? 30 * 1000; - if (connectionTimeout <= 0) { - return false; - } - return this.connectStartedAt !== 0 && Date.now() - this.connectStartedAt < connectionTimeout; - } - - /// Returns true if a scheduled connect retry was cancelled — in that case - /// nothing is in flight and no onClose/onConnected callback will fire. - cancelRetry(): boolean { - if (this.retryTimer !== null) { - clearTimeout(this.retryTimer); - this.retryTimer = null; - return true; - } - return false; - } - - onClose(onClose: (err: Error) => void) { - this.queries.add(onClose); - } - - bindQuery(query: Query, onClose: (err: Error) => void) { - this.queries.add(onClose); - query.finally(onQueryFinish.bind(this, onClose)); - } - - #doRetry() { - if (this.adapter.closed) { - return; - } - // reset error and state - this.storedError = null; - this.connectStartedAt = 0; - this.state = PooledConnectionState.pending; - // retry connection - this.#startConnection(); - } - close() { - try { - if (this.state === PooledConnectionState.connected) { - this.connection?.close(); - } - } catch {} - } - flush() { - this.connection?.flush(); - } - retry() { - // if pool is closed, we can't retry - if (this.adapter.closed) { - return false; - } - // we need to reconnect - // lets use a retry strategy - - // we can only retry if one day we are able to connect - if (this.flags & PooledConnectionFlags.canBeConnected) { - this.#doRetry(); - } else { - // analyse type of error to see if we can retry - switch (this.storedError?.code) { - case "ERR_POSTGRES_UNSUPPORTED_AUTHENTICATION_METHOD": - case "ERR_POSTGRES_UNKNOWN_AUTHENTICATION_METHOD": - case "ERR_POSTGRES_TLS_NOT_AVAILABLE": - case "ERR_POSTGRES_TLS_UPGRADE_FAILED": - case "ERR_POSTGRES_INVALID_SERVER_SIGNATURE": - case "ERR_POSTGRES_INVALID_SERVER_KEY": - case "ERR_POSTGRES_AUTHENTICATION_FAILED_PBKDF2": - // we can't retry these are authentication errors - return false; - default: - // we can retry - this.#doRetry(); - } - } - return true; + /// retried until connectionTimeout elapses, as long as queries are waiting + /// on the pool. Refused connections (ERR_POSTGRES_CONNECTION_REFUSED) fail + /// fast: nothing is listening, and probes/healthchecks rely on the + /// immediate error. Real server errors (authentication, ErrorResponse + /// during startup) and closes of established connections are not retried + /// here. + protected isConnectFailureError(err: Error | null): boolean { + return err instanceof PostgresError && (err as any).code === "ERR_POSTGRES_CONNECTION_FAILED"; } } class PostgresAdapter + extends BaseSQLAdapter< + PooledPostgresConnection, + $ZigGeneratedClasses.PostgresSQLConnection, + $ZigGeneratedClasses.PostgresSQLQuery + > implements DatabaseAdapter< PooledPostgresConnection, @@ -764,23 +406,8 @@ class PostgresAdapter $ZigGeneratedClasses.PostgresSQLQuery > { - public readonly connectionInfo: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions; - - public readonly connections: PooledPostgresConnection[]; - public readonly readyConnections: Set; - - public waitingQueue: Array<(err: Error | null, result: any) => void> = []; - public reservedQueue: Array<(err: Error | null, result: any) => void> = []; - - public poolStarted: boolean = false; - public closed: boolean = false; - public totalQueries: number = 0; - public onAllQueriesFinished: (() => void) | null = null; - - constructor(connectionInfo: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions) { - this.connectionInfo = connectionInfo; - this.connections = new Array(connectionInfo.max); - this.readyConnections = new Set(); + protected createPooledConnection(): PooledPostgresConnection { + return new PooledPostgresConnection(this.connectionInfo, this); } escapeIdentifier(str: string) { @@ -807,26 +434,10 @@ class PostgresAdapter code: "ERR_POSTGRES_INVALID_TRANSACTION_STATE", }); } - supportsReservedConnections() { - return true; - } - - getConnectionForQuery(pooledConnection: PooledPostgresConnection) { - return pooledConnection.connection; - } - - attachConnectionCloseHandler(connection: PooledPostgresConnection, handler: () => void): void { - // PostgreSQL pooled connections support onClose handlers - if (connection.onClose) { - connection.onClose(handler); - } - } - - detachConnectionCloseHandler(connection: PooledPostgresConnection, handler: () => void): void { - // PostgreSQL pooled connections track queries - if (connection.queries) { - connection.queries.delete(handler); - } + unsafeTransactionError() { + return new PostgresError("Only use sql.begin, sql.reserved or max: 1", { + code: "ERR_POSTGRES_UNSAFE_TRANSACTION", + }); } array(values: any[], typeNameOrID?: number | ArrayType): SQLArrayParameter { @@ -866,28 +477,6 @@ class PostgresAdapter }; } - validateTransactionOptions(options: string): { valid: boolean; error?: string } { - // The string is interpolated into `BEGIN ${options}`, so refuse anything that - // could terminate the statement or start a new one. - if (!/^[A-Za-z ,]*$/.test(options)) { - return { - valid: false, - error: "Transaction options can only contain letters, spaces, and commas.", - }; - } - return { valid: true }; - } - - validateDistributedTransactionName(name: string): { valid: boolean; error?: string } { - if (name.indexOf("'") !== -1) { - return { - valid: false, - error: "Distributed transaction name cannot contain single quotes.", - }; - } - return { valid: true }; - } - getCommitDistributedSQL(name: string): string { const validation = this.validateDistributedTransactionName(name); if (!validation.valid) { @@ -905,16 +494,7 @@ class PostgresAdapter } createQueryHandle(sql: string, values: unknown[], flags: number) { - if (!(flags & SQLQueryFlags.allowUnsafeTransaction)) { - if (this.connectionInfo.max !== 1) { - const upperCaseSqlString = sql.toUpperCase().trim(); - if (upperCaseSqlString.startsWith("BEGIN") || upperCaseSqlString.startsWith("START TRANSACTION")) { - throw new PostgresError("Only use sql.begin, sql.reserved or max: 1", { - code: "ERR_POSTGRES_UNSAFE_TRANSACTION", - }); - } - } - } + this.checkUnsafeTransaction(sql, flags); return createPostgresQuery( sql, @@ -926,578 +506,23 @@ class PostgresAdapter ); } - maxDistribution() { - if (!this.waitingQueue.length) return 0; - const result = Math.ceil((this.waitingQueue.length + this.totalQueries) / this.connections.length); - return result ? result : 1; - } - - flushConcurrentQueries() { - const maxDistribution = this.maxDistribution(); - if (maxDistribution === 0) { - return; - } - - while (true) { - const nonReservedConnections = Array.from(this.readyConnections || []).filter( - c => !(c.flags & PooledConnectionFlags.preReserved) && c.queryCount < maxDistribution, - ); - if (nonReservedConnections.length === 0) { - return; - } - const orderedConnections = nonReservedConnections.sort((a, b) => a.queryCount - b.queryCount); - for (const connection of orderedConnections) { - const pending = this.waitingQueue.shift(); - if (!pending) { - return; - } - connection.queryCount++; - this.totalQueries++; - pending(null, connection); - } - } - } - - release(connection: PooledPostgresConnection, connectingEvent: boolean = false) { - if (!connectingEvent) { - connection.queryCount--; - this.totalQueries--; - } - const currentQueryCount = connection.queryCount; - if (currentQueryCount == 0) { - connection.flags &= ~PooledConnectionFlags.reserved; - connection.flags &= ~PooledConnectionFlags.preReserved; - } - if (this.onAllQueriesFinished) { - // we are waiting for all queries to finish, lets check if we can call it - if (!this.hasPendingQueries()) { - this.onAllQueriesFinished(); - } - } - - if (connection.state !== PooledConnectionState.connected) { - // connection is not ready - if (connection.storedError) { - // this connection got a error but maybe we can wait for another - - if (this.hasConnectionsAvailable()) { - return; - } - - const waitingQueue = this.waitingQueue; - const reservedQueue = this.reservedQueue; - - this.waitingQueue = []; - this.reservedQueue = []; - // we have no connections available so lets fails - for (const pending of waitingQueue) { - pending(connection.storedError, connection); - } - for (const pending of reservedQueue) { - pending(connection.storedError, connection); - } - // draining the queues may have been the last pending work — a - // graceful close() is waiting on this callback - if (this.onAllQueriesFinished && !this.hasPendingQueries()) { - this.onAllQueriesFinished(); - } - } - return; - } - - if (currentQueryCount == 0) { - // ok we can actually bind reserved queries to it - const pendingReserved = this.reservedQueue.shift(); - if (pendingReserved) { - connection.flags |= PooledConnectionFlags.reserved; - connection.queryCount++; - this.totalQueries++; - // we have a connection waiting for a reserved connection lets prioritize it - pendingReserved(connection.storedError, connection); - return; - } - } - this.readyConnections.add(connection); - this.flushConcurrentQueries(); - } - - hasConnectionsAvailable() { - if (this.readyConnections?.size > 0) return true; - if (this.poolStarted) { - const pollSize = this.connections.length; - for (let i = 0; i < pollSize; i++) { - const connection = this.connections[i]; - if (connection && connection.state !== PooledConnectionState.closed) { - // some connection is connecting or connected - return true; - } - } - } - return false; - } - - hasPendingQueries() { - if (this.waitingQueue.length > 0 || this.reservedQueue.length > 0) return true; - if (this.poolStarted) { - return this.totalQueries > 0; - } - return false; - } - isConnected() { - if (this.readyConnections?.size > 0) { - return true; - } - if (this.poolStarted) { - const pollSize = this.connections.length; - for (let i = 0; i < pollSize; i++) { - const connection = this.connections[i]; - if (connection.state === PooledConnectionState.connected) { - return true; - } - } - } - return false; - } - flush() { - if (this.closed) { - return; - } - if (this.poolStarted) { - const pollSize = this.connections.length; - for (let i = 0; i < pollSize; i++) { - const connection = this.connections[i]; - if (connection.state === PooledConnectionState.connected) { - connection.connection?.flush(); - } - } - } - } - - async #close() { - let pending; - while ((pending = this.waitingQueue.shift())) { - pending(this.connectionClosedError(), null); - } - while (this.reservedQueue.length > 0) { - const pendingReserved = this.reservedQueue.shift(); - if (pendingReserved) { - pendingReserved(this.connectionClosedError(), null); - } - } - - const promises: Array> = []; - - if (this.poolStarted) { - this.poolStarted = false; - const pollSize = this.connections.length; - for (let i = 0; i < pollSize; i++) { - const connection = this.connections[i]; - switch (connection.state) { - case PooledConnectionState.pending: - { - if (connection.cancelRetry()) { - // a connect retry was scheduled; nothing is in flight so - // there is no onClose/onConnected to wait for - connection.state = PooledConnectionState.closed; - break; - } - const { promise, resolve } = Promise.withResolvers(); - connection.onFinish = resolve; - promises.push(promise); - connection.connection?.close(); - } - break; - - case PooledConnectionState.connected: - { - const { promise, resolve } = Promise.withResolvers(); - connection.onFinish = resolve; - promises.push(promise); - connection.connection?.close(); - } - break; - } - // clean connection reference - // @ts-ignore - this.connections[i] = null; - } - } - - this.readyConnections.clear(); - this.waitingQueue.length = 0; - return Promise.all(promises); - } - - async close(options?: { timeout?: number }): Promise { - if (this.closed) { - return; - } - - let timeout = options?.timeout; - if (timeout) { - timeout = Number(timeout); - if (timeout > 2 ** 31 || timeout < 0 || timeout !== timeout) { - throw $ERR_INVALID_ARG_VALUE("options.timeout", timeout, "must be a non-negative integer less than 2^31"); - } - - this.closed = true; - if (timeout === 0 || !this.hasPendingQueries()) { - // close immediately - await this.#close(); - return; - } - - const { promise, resolve } = Promise.withResolvers(); - const timer = setTimeout(() => { - // timeout is reached, lets close and probably fail some queries - this.#close().finally(resolve); - }, timeout * 1000); - timer.unref(); // dont block the event loop - - this.onAllQueriesFinished = () => { - clearTimeout(timer); - // everything is closed, lets close the pool - this.#close().finally(resolve); - }; - - return promise; - } else { - this.closed = true; - if (!this.hasPendingQueries()) { - // close immediately - await this.#close(); - return; - } - - // gracefully close the pool - const { promise, resolve } = Promise.withResolvers(); - - this.onAllQueriesFinished = () => { - // everything is closed, lets close the pool - this.#close().finally(resolve); - }; - - return promise; - } + getHelperCommand(query: string): SQLCommand { + return getHelperCommandFromDetect(query, false); } - /** - * @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) { - if (this.closed) { - return onConnected(this.connectionClosedError(), null); - } - - if (!this.readyConnections || this.readyConnections.size === 0) { - // no connection ready lets make some - let retry_in_progress = false; - let all_closed = true; - let storedError: Error | null = null; - - if (this.poolStarted) { - // we already started the pool - // lets check if some connection is available to retry - const pollSize = this.connections.length; - for (let i = 0; i < pollSize; i++) { - const connection = this.connections[i]; - // we need a new connection and we have some connections that can retry - if (connection.state === PooledConnectionState.closed) { - if (connection.retry()) { - // lets wait for connection to be released - if (!retry_in_progress) { - // avoid adding to the queue twice, we wanna to retry every available pool connection - retry_in_progress = true; - if (reserved) { - // we are not sure what connection will be available so we dont pre reserve - this.reservedQueue.push(onConnected); - } else { - this.waitingQueue.push(onConnected); - } - } - } else { - // we have some error, lets grab it and fail if unable to start a connection - storedError = connection.storedError; - } - } else { - // we have some pending or open connections - all_closed = false; - } - } - if (!all_closed && !retry_in_progress) { - // is possible to connect because we have some working connections, or we are just without network for some reason - // wait for connection to be released or fail - if (reserved) { - // we are not sure what connection will be available so we dont pre reserve - this.reservedQueue.push(onConnected); - } else { - this.waitingQueue.push(onConnected); - } - } else if (!retry_in_progress) { - // impossible to connect or retry - onConnected(storedError ?? this.connectionClosedError(), null); - } - return; - } - // we never started the pool, lets start it - if (reserved) { - this.reservedQueue.push(onConnected); - } else { - this.waitingQueue.push(onConnected); - } - this.poolStarted = true; - const pollSize = this.connections.length; - // pool is always at least 1 connection - const firstConnection = new PooledPostgresConnection(this.connectionInfo, 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 PooledPostgresConnection(this.connectionInfo, this); - } - return; - } - if (reserved) { - let connectionWithLeastQueries: PooledPostgresConnection | null = null; - let leastQueries = Infinity; - for (const connection of this.readyConnections || []) { - if (connection.flags & PooledConnectionFlags.preReserved || connection.flags & PooledConnectionFlags.reserved) - continue; - const queryCount = connection.queryCount; - if (queryCount > 0) { - if (queryCount < leastQueries) { - leastQueries = queryCount; - connectionWithLeastQueries = connection; - } - continue; - } - connection.flags |= PooledConnectionFlags.reserved; - connection.queryCount++; - this.totalQueries++; - this.readyConnections?.delete(connection); - onConnected(null, connection); - return; - } - - if (connectionWithLeastQueries) { - // lets mark the connection with the least queries as preReserved if any - connectionWithLeastQueries.flags |= PooledConnectionFlags.preReserved; - } - - // no connection available to be reserved lets wait for a connection to be released - this.reservedQueue.push(onConnected); - } else { - this.waitingQueue.push(onConnected); - this.flushConcurrentQueries(); - } + placeholder(index: number): string { + return "$" + index; } - normalizeQuery(strings: string | TemplateStringsArray, values: unknown[], binding_idx = 1): [string, unknown[]] { - // This function handles array values in single fields: - // - JSON/JSONB are the only field types that can be arrays themselves, so we serialize them - // - SQL array field types (e.g., INTEGER[], TEXT[]) require the sql.array() helper - // - All other types are handled natively - - if (typeof strings === "string") { - // identifier or unsafe query - return [strings, values || []]; - } - - if (!$isArray(strings)) { - // we should not hit this path - throw new SyntaxError("Invalid query: SQL Fragment cannot be executed or was misused"); + bindParam(value: unknown, binding_values: unknown[], index: number): string { + if (value instanceof SQLArrayParameter) { + binding_values.push(value.serializedValues); + return `$${index}::${value.arrayType}[] `; } - - const str_len = strings.length; - if (str_len === 0) { - return ["", []]; - } - - let binding_values: any[] = []; - let query = ""; - - for (let i = 0; i < str_len; i++) { - const string = strings[i]; - - if (typeof string === "string") { - query += string; - - if (values.length > i) { - const value = values[i]; - - if (value instanceof Query) { - const q = value as Query; - const [sub_query, sub_values] = this.normalizeQuery(q[_strings], q[_values], binding_idx); - - query += sub_query; - for (let j = 0; j < sub_values.length; j++) { - binding_values.push(sub_values[j]); - } - binding_idx += sub_values.length; - } else if (value instanceof SQLHelper) { - const command = detectCommand(query); - // only selectIn, insert, update, updateSet are allowed - if (command === SQLCommand.none || command === SQLCommand.where) { - throw new SyntaxError("Helpers are only allowed for INSERT, UPDATE and IN commands"); - } - const { columns, value: items } = value as SQLHelper; - const columnCount = columns.length; - if (columnCount === 0 && command !== SQLCommand.in) { - throw new SyntaxError(`Cannot ${commandToString(command)} with no columns`); - } - const lastColumnIndex = columns.length - 1; - - if (command === SQLCommand.insert) { - // - // insert into users ${sql(users)} or insert into users ${sql(user)} - // - - // Build column list while determining which columns have at least one defined value - const { definedColumns, columnsSql } = buildDefinedColumnsAndQuery( - columns, - items, - this.escapeIdentifier.bind(this), - ); - - const definedColumnCount = definedColumns.length; - if (definedColumnCount === 0) { - throw new SyntaxError("Insert needs to have at least one column with a defined value"); - } - const lastDefinedColumnIndex = definedColumnCount - 1; - - query += columnsSql; - if ($isArray(items)) { - const itemsCount = items.length; - const lastItemIndex = itemsCount - 1; - for (let j = 0; j < itemsCount; j++) { - query += "("; - const item = items[j]; - for (let k = 0; k < definedColumnCount; k++) { - const column = definedColumns[k]; - const columnValue = item[column]; - query += `$${binding_idx++}${k < lastDefinedColumnIndex ? ", " : ""}`; - // If this item has undefined for a column that other items defined, use null - binding_values.push(typeof columnValue === "undefined" ? null : columnValue); - } - if (j < lastItemIndex) { - query += "),"; - } else { - query += ") "; // the user can add RETURNING * or RETURNING id - } - } - } else { - query += "("; - const item = items; - for (let j = 0; j < definedColumnCount; j++) { - const column = definedColumns[j]; - const columnValue = item[column]; - query += `$${binding_idx++}${j < lastDefinedColumnIndex ? ", " : ""}`; - binding_values.push(columnValue); - } - query += ") "; // the user can add RETURNING * or RETURNING id - } - } else if (command === SQLCommand.in) { - // SELECT * FROM users WHERE id IN (${sql([1, 2, 3])}) - if (!$isArray(items)) { - throw new SyntaxError("An array of values is required for WHERE IN helper"); - } - const itemsCount = items.length; - const lastItemIndex = itemsCount - 1; - query += "("; - for (let j = 0; j < itemsCount; j++) { - query += `$${binding_idx++}${j < lastItemIndex ? ", " : ""}`; - if (columnCount > 0) { - // we must use a key from a object - if (columnCount > 1) { - // we should not pass multiple columns here - throw new SyntaxError("Cannot use WHERE IN helper with multiple columns"); - } - // SELECT * FROM users WHERE id IN (${sql(users, "id")}) - const value = items[j]; - if (typeof value === "undefined") { - binding_values.push(null); - } else { - const value_from_key = value[columns[0]]; - - if (typeof value_from_key === "undefined") { - binding_values.push(null); - } else { - binding_values.push(value_from_key); - } - } - } else { - const value = items[j]; - if (typeof value === "undefined") { - binding_values.push(null); - } else { - binding_values.push(value); - } - } - } - query += ") "; // more conditions can be added after this - } else { - // UPDATE users SET ${sql({ name: "John", age: 31 })} WHERE id = 1 - let item; - if ($isArray(items)) { - if (items.length > 1) { - throw new SyntaxError("Cannot use array of objects for UPDATE"); - } - item = items[0]; - } else { - item = items; - } - // no need to include if is updateSet - if (command === SQLCommand.update) { - query += " SET "; - } - let hasValues = false; - for (let i = 0; i < columnCount; i++) { - const column = columns[i]; - const columnValue = item[column]; - if (typeof columnValue === "undefined") { - // skip undefined values, this is the expected behavior in JS - continue; - } - hasValues = true; - query += `${this.escapeIdentifier(column)} = $${binding_idx++}${i < lastColumnIndex ? ", " : ""}`; - binding_values.push(columnValue); - } - if (query.endsWith(", ")) { - // we got an undefined value at the end, lets remove the last comma - query = query.substring(0, query.length - 2); - } - if (!hasValues) { - throw new SyntaxError("Update needs to have at least one column"); - } - // the user can add where clause after this - query += " "; - } - } else if (value instanceof SQLArrayParameter) { - query += `$${binding_idx++}::${value.arrayType}[] `; - binding_values.push(value.serializedValues); - } else { - query += `$${binding_idx++} `; - if (typeof value === "undefined") { - binding_values.push(null); - } else { - binding_values.push(value); - } - } - } - } else { - throw new SyntaxError("Invalid query: SQL Fragment cannot be executed or was misused"); - } - } - - return [query, binding_values]; + return pushBindParam(this, value, binding_values, index); } } export default { PostgresAdapter, - SQLCommand, - commandToString, - detectCommand, }; diff --git a/src/js/internal/sql/shared.ts b/src/js/internal/sql/shared.ts index 51301ef8c57b..ef74f68f5de4 100644 --- a/src/js/internal/sql/shared.ts +++ b/src/js/internal/sql/shared.ts @@ -1,4 +1,11 @@ +import type { Query as QueryType } from "./query"; + const PublicArray = globalThis.Array; +const { + Query, + SQLQueryFlags, + symbols: { _strings, _values }, +} = require("internal/sql/query"); declare global { interface NumberConstructor { @@ -218,6 +225,1137 @@ function buildDefinedColumnsAndQuery( return { definedColumns, columnsSql }; } +const enum SQLCommand { + insert = 0, + update = 1, + updateSet = 2, + where = 3, + in = 4, + none = -1, +} +export type { SQLCommand }; + +function commandToString(command: SQLCommand): string { + switch (command) { + case SQLCommand.insert: + return "INSERT"; + case SQLCommand.updateSet: + case SQLCommand.update: + return "UPDATE"; + case SQLCommand.in: + case SQLCommand.where: + return "WHERE"; + default: + return ""; + } +} + +function detectCommand(query: string, anyAndAllMeanIn: boolean): SQLCommand { + const text = query.toLowerCase().trim(); + const text_len = text.length; + + let token = ""; + let command = SQLCommand.none; + let quoted = false; + // we need to reverse search so we find the closest command to the parameter + for (let i = text_len - 1; i >= 0; i--) { + const char = text[i]; + switch (char) { + case " ": // Space + case "\n": // Line feed + case "\t": // Tab character + case "\r": // Carriage return + case "\f": // Form feed + case "\v": { + switch (token) { + case "insert": { + return SQLCommand.insert; + } + case "update": { + return SQLCommand.update; + } + case "where": { + return SQLCommand.where; + } + case "set": { + return SQLCommand.updateSet; + } + case "in": { + return SQLCommand.in; + } + default: { + token = ""; + continue; + } + } + } + default: { + // skip quoted commands + if (char === '"') { + quoted = !quoted; + continue; + } + if (!quoted) { + token = char + token; + } + } + } + } + if (token) { + switch (token) { + case "insert": + return SQLCommand.insert; + case "update": + return SQLCommand.update; + case "where": + return SQLCommand.where; + case "set": + return SQLCommand.updateSet; + case "in": + return SQLCommand.in; + case "any": + case "all": + // MySQL treats a leading ANY/ALL token like IN; Postgres does not. + return anyAndAllMeanIn ? SQLCommand.in : SQLCommand.none; + default: + return SQLCommand.none; + } + } + return command; +} + +function getHelperCommandFromDetect(query: string, anyAndAllMeanIn: boolean): SQLCommand { + const command = detectCommand(query, anyAndAllMeanIn); + // only selectIn, insert, update, updateSet are allowed + if (command === SQLCommand.none || command === SQLCommand.where) { + throw new SyntaxError("Helpers are only allowed for INSERT, UPDATE and IN commands"); + } + return command; +} + +/** + * The driver-specific hooks consumed by the shared {@link normalizeQuery}. + * Methods stay on the adapter prototype so per-query cost is a monomorphic + * method call. + */ +interface QueryNormalizationAdapter { + escapeIdentifier(name: string): string; + /** Returns the placeholder for the given 1-based binding index ("?" or "$N"). */ + placeholder(index: number): string; + /** Pushes a plain bound value and returns its SQL fragment (always consumes one binding index). */ + bindParam(value: unknown, binding_values: unknown[], index: number): string; + /** Detects the SQL command preceding a helper, throwing if helpers are not allowed there. */ + getHelperCommand(query: string): SQLCommand; + /** Whether the UPDATE helper should omit the SET keyword (MySQL upsert). */ + isUpsertUpdate(query: string): boolean; + throwIfUpdateEmpty(query: string, hasValues: boolean): void; +} + +function pushBindParam( + adapter: QueryNormalizationAdapter, + value: unknown, + binding_values: unknown[], + index: number, +): string { + if (typeof value === "undefined") { + binding_values.push(null); + } else { + binding_values.push(value); + } + return adapter.placeholder(index) + " "; +} + +// This function handles array values in single fields: +// - JSON/JSONB are the only field types that can be arrays themselves, so we serialize them +// - SQL array field types (e.g., INTEGER[], TEXT[]) require the sql.array() helper +// - All other types are handled natively +function normalizeQuery( + adapter: QueryNormalizationAdapter, + strings: string | TemplateStringsArray, + values: unknown[], + binding_idx = 1, +): [string, unknown[]] { + if (typeof strings === "string") { + // identifier or unsafe query + return [strings, values || []]; + } + + if (!$isArray(strings)) { + // we should not hit this path + throw new SyntaxError("Invalid query: SQL Fragment cannot be executed or was misused"); + } + + const str_len = strings.length; + if (str_len === 0) { + return ["", []]; + } + + let binding_values: any[] = []; + let query = ""; + + for (let i = 0; i < str_len; i++) { + const string = strings[i]; + + if (typeof string === "string") { + query += string; + + if (values.length > i) { + const value = values[i]; + + if (value instanceof Query) { + const q = value as QueryType; + const [sub_query, sub_values] = normalizeQuery(adapter, q[_strings], q[_values], binding_idx); + + query += sub_query; + for (let j = 0; j < sub_values.length; j++) { + binding_values.push(sub_values[j]); + } + binding_idx += sub_values.length; + } else if (value instanceof SQLHelper) { + const command = adapter.getHelperCommand(query); + const { columns, value: items } = value as SQLHelper; + const columnCount = columns.length; + if (columnCount === 0 && command !== SQLCommand.in) { + throw new SyntaxError(`Cannot ${commandToString(command)} with no columns`); + } + const lastColumnIndex = columns.length - 1; + + if (command === SQLCommand.insert) { + // + // insert into users ${sql(users)} or insert into users ${sql(user)} + // + + // Build column list while determining which columns have at least one defined value + const { definedColumns, columnsSql } = buildDefinedColumnsAndQuery( + columns, + items, + adapter.escapeIdentifier.bind(adapter), + ); + + const definedColumnCount = definedColumns.length; + if (definedColumnCount === 0) { + throw new SyntaxError("Insert needs to have at least one column with a defined value"); + } + const lastDefinedColumnIndex = definedColumnCount - 1; + + query += columnsSql; + if ($isArray(items)) { + const itemsCount = items.length; + const lastItemIndex = itemsCount - 1; + for (let j = 0; j < itemsCount; j++) { + query += "("; + const item = items[j]; + for (let k = 0; k < definedColumnCount; k++) { + const column = definedColumns[k]; + const columnValue = item[column]; + query += `${adapter.placeholder(binding_idx++)}${k < lastDefinedColumnIndex ? ", " : ""}`; + // If this item has undefined for a column that other items defined, use null + binding_values.push(typeof columnValue === "undefined" ? null : columnValue); + } + if (j < lastItemIndex) { + query += "),"; + } else { + query += ") "; // the user can add RETURNING * or RETURNING id + } + } + } else { + query += "("; + const item = items; + for (let j = 0; j < definedColumnCount; j++) { + const column = definedColumns[j]; + const columnValue = item[column]; + query += `${adapter.placeholder(binding_idx++)}${j < lastDefinedColumnIndex ? ", " : ""}`; + binding_values.push(columnValue); + } + query += ") "; // the user can add RETURNING * or RETURNING id + } + } else if (command === SQLCommand.in) { + // SELECT * FROM users WHERE id IN (${sql([1, 2, 3])}) + if (!$isArray(items)) { + throw new SyntaxError("An array of values is required for WHERE IN helper"); + } + const itemsCount = items.length; + const lastItemIndex = itemsCount - 1; + query += "("; + for (let j = 0; j < itemsCount; j++) { + query += `${adapter.placeholder(binding_idx++)}${j < lastItemIndex ? ", " : ""}`; + if (columnCount > 0) { + // we must use a key from a object + if (columnCount > 1) { + // we should not pass multiple columns here + throw new SyntaxError("Cannot use WHERE IN helper with multiple columns"); + } + // SELECT * FROM users WHERE id IN (${sql(users, "id")}) + const value = items[j]; + if (typeof value === "undefined") { + binding_values.push(null); + } else { + const value_from_key = value[columns[0]]; + + if (typeof value_from_key === "undefined") { + binding_values.push(null); + } else { + binding_values.push(value_from_key); + } + } + } else { + const value = items[j]; + if (typeof value === "undefined") { + binding_values.push(null); + } else { + binding_values.push(value); + } + } + } + query += ") "; // more conditions can be added after this + } else { + // UPDATE users SET ${sql({ name: "John", age: 31 })} WHERE id = 1 + let item; + if ($isArray(items)) { + if (items.length > 1) { + throw new SyntaxError("Cannot use array of objects for UPDATE"); + } + item = items[0]; + } else { + item = items; + } + // no need to include SET if is updateSet or upsert + if (command === SQLCommand.update && !adapter.isUpsertUpdate(query)) { + query += " SET "; + } + let hasValues = false; + for (let i = 0; i < columnCount; i++) { + const column = columns[i]; + const columnValue = item[column]; + if (typeof columnValue === "undefined") { + // skip undefined values, this is the expected behavior in JS + continue; + } + hasValues = true; + query += `${adapter.escapeIdentifier(column as string)} = ${adapter.placeholder(binding_idx++)}${i < lastColumnIndex ? ", " : ""}`; + binding_values.push(columnValue); + } + if (query.endsWith(", ")) { + // we got an undefined value at the end, lets remove the last comma + query = query.substring(0, query.length - 2); + } + adapter.throwIfUpdateEmpty(query, hasValues); + // the user can add where clause after this + query += " "; + } + } else { + query += adapter.bindParam(value, binding_values, binding_idx++); + } + } + } else { + throw new SyntaxError("Invalid query: SQL Fragment cannot be executed or was misused"); + } + } + + return [query, binding_values]; +} + +const enum PooledConnectionState { + pending = 0, + connected = 1, + closed = 2, +} + +const enum PooledConnectionFlags { + /// canBeConnected is used to indicate that at least one time we were able to connect to the database + canBeConnected = 1 << 0, + /// reserved is used to indicate that the connection is currently reserved + reserved = 1 << 1, + /// preReserved is used to indicate that the connection will be reserved in the future when queryCount drops to 0 + preReserved = 1 << 2, +} +export type { PooledConnectionState }; + +function onQueryFinish(this: BasePooledConnection, onClose: (err: Error) => void) { + this.queries.delete(onClose); + this.adapter.release(this); +} + +abstract class BasePooledConnection { + adapter: BaseSQLAdapter; + connection: ConnectionHandle | null = null; + state: PooledConnectionState = PooledConnectionState.pending; + storedError: Error | null = null; + queries: Set<(err: Error) => void> = new Set(); + onFinish: ((err: Error | null) => void) | null = null; + connectionInfo: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions; + flags: number = 0; + /// queryCount is used to indicate the number of queries using the connection, if a connection is reserved or if its a transaction queryCount will be 1 independently of the number of queries + queryCount: number = 0; + /// when the current connect cycle started; 0 when not connecting. Connect + /// failures (server not yet accepting connections) are retried until + /// connectionTimeout elapses from this point. + connectStartedAt: number = 0; + connectAttempts: number = 0; + retryTimer: ReturnType | null = null; + + constructor( + connectionInfo: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions, + adapter: BaseSQLAdapter, + ) { + this.adapter = adapter; + this.connectionInfo = connectionInfo; + this.#beginConnecting(); + } + + /** Starts (or restarts) the driver-specific native connection. */ + protected abstract startConnection(): void; + /** Wraps a driver error options object into the driver's Error class. */ + protected abstract wrapError(error: any): Error; + /** Whether the given error code is an authentication-style error that retrying cannot fix. */ + protected abstract isNonRetryableError(code: string | undefined): boolean; + /** + * Whether the error is a connect failure (the server accepted the + * connection but closed it before the handshake completed) that a backoff + * retry can fix. + */ + protected abstract isConnectFailureError(err: Error | null): boolean; + + #beginConnecting() { + // a fresh connect cycle (not a backoff retry) starts the retry budget + if (this.connectStartedAt === 0) { + this.connectStartedAt = Date.now(); + this.connectAttempts = 0; + } + this.startConnection(); + } + + protected handleConnected(err: any) { + if (err) { + err = this.wrapError(err); + } + const connectionInfo = this.connectionInfo; + if (connectionInfo?.onconnect) { + connectionInfo.onconnect(err); + } + this.storedError = err; + if (!err) { + this.connectStartedAt = 0; + this.flags |= PooledConnectionFlags.canBeConnected; + } + this.state = err ? PooledConnectionState.closed : PooledConnectionState.connected; + const onFinish = this.onFinish; + if (onFinish) { + this.queryCount = 0; + this.flags &= ~PooledConnectionFlags.reserved; + this.flags &= ~PooledConnectionFlags.preReserved; + + // pool is closed, lets finish the connection + if (err) { + onFinish(err); + } else { + this.connection?.close(); + } + return; + } + this.adapter.release(this, true); + } + + protected handleClose(err: any) { + if (err) { + err = this.wrapError(err); + } + this.connection = null; + this.storedError = err; + if (this.#shouldRetryConnecting(err)) { + // The server is not accepting connections yet (e.g. still starting + // up). Keep the slot pending and retry with backoff instead of + // failing the queries that are waiting for a connection. The user's + // onclose callback only fires when the slot actually closes. + this.connectAttempts++; + const delay = Math.min(20 * 2 ** this.connectAttempts, 1000); + this.retryTimer = setTimeout(BasePooledConnection.#retryTimerFired, delay, this); + return; + } + // this connect cycle is over; a later retry() starts a fresh one + this.connectStartedAt = 0; + this.#finishClose(err); + } + + static #retryTimerFired(self: BasePooledConnection) { + self.retryTimer = null; + // conditions may have changed during the backoff (pool closing, waiters + // gone, retry budget elapsed), so re-check before dialing + if (self.#canKeepRetrying()) { + self.#beginConnecting(); + } else { + self.#finishClose(self.storedError); + } + } + + #shouldRetryConnecting(err: any): boolean { + // connect failures come from the native layer as options objects that + // wrapError turned into the driver's Error class with a typed code + if (!this.isConnectFailureError(err)) { + return false; + } + return this.#canKeepRetrying(); + } + + #canKeepRetrying(): boolean { + if (this.adapter.closed || this.onFinish !== null) { + return false; + } + // only retry while queries are actually waiting for a connection + if (this.adapter.waitingQueue.length === 0 && this.adapter.reservedQueue.length === 0) { + return false; + } + // an explicit connectionTimeout of 0 disables the connect timer, and with + // it the retry budget + const connectionTimeout = this.connectionInfo.connectionTimeout ?? 30 * 1000; + if (connectionTimeout <= 0) { + return false; + } + return this.connectStartedAt !== 0 && Date.now() - this.connectStartedAt < connectionTimeout; + } + + /// Returns true if a scheduled connect retry was cancelled; in that case + /// nothing is in flight and no onClose/onConnected callback will fire. + cancelRetry(): boolean { + if (this.retryTimer !== null) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + return true; + } + return false; + } + + #finishClose(err: any) { + const connectionInfo = this.connectionInfo; + if (connectionInfo?.onclose) { + connectionInfo.onclose(err); + } + this.state = PooledConnectionState.closed; + this.storedError = err; + + // remove from ready connections if its there + this.adapter.readyConnections.delete(this); + const queries = new Set(this.queries); + this.queries?.clear?.(); + this.queryCount = 0; + this.flags &= ~PooledConnectionFlags.reserved; + + // notify all queries that the connection is closed + for (const onClose of queries) { + onClose(err); + } + const onFinish = this.onFinish; + if (onFinish) { + onFinish(err); + } + + this.adapter.release(this, true); + } + + onClose(onClose: (err: Error) => void) { + this.queries.add(onClose); + } + + bindQuery(query: QueryType, onClose: (err: Error) => void) { + this.queries.add(onClose); + query.finally(onQueryFinish.bind(this, onClose)); + } + + protected doRetry() { + if (this.adapter.closed) { + return; + } + // reset error and state + this.storedError = null; + this.connectStartedAt = 0; + this.state = PooledConnectionState.pending; + // retry connection + this.#beginConnecting(); + } + close() { + try { + if (this.state === PooledConnectionState.connected) { + this.connection?.close(); + } + } catch {} + } + flush() { + this.connection?.flush(); + } + retry() { + // if pool is closed, we can't retry + if (this.adapter.closed) { + return false; + } + // we need to reconnect + // lets use a retry strategy + + // we can only retry if one day we are able to connect + if (this.flags & PooledConnectionFlags.canBeConnected) { + this.doRetry(); + } else if (this.isNonRetryableError((this.storedError as any)?.code)) { + // we can't retry these are authentication errors + return false; + } else { + // we can retry + this.doRetry(); + } + return true; + } +} + +function closeNT(onClose: (err: Error) => void, err: Error | null) { + onClose(err as Error); +} + +/** + * Resolves the password (which may be a function and/or a promise) and calls + * the driver's native createConnection with the normalized pool options. + * Extra trailing arguments past `useUnnamedPreparedStatements` (MySQL's + * `allowPublicKeyRetrieval`) are ignored by drivers that don't take them. + */ +async function createPooledConnectionHandle( + nativeCreateConnection: (...args: any[]) => ConnectionHandle, + options: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions, + onConnected: (err: Error | null, connection: ConnectionHandle) => void, + onClose: (err: Error | null) => void, + // MySQL defers synchronous creation failures to the next tick; Postgres + // reports them synchronously. Each driver keeps its pre-existing timing. + deferSyncCloseError: boolean, +): Promise { + const { + hostname, + port, + username, + tls, + query, + database, + sslMode, + idleTimeout = 0, + connectionTimeout = 30 * 1000, + maxLifetime = 0, + prepare = true, + path, + allowPublicKeyRetrieval = false, + } = options; + + let password: Bun.MaybePromise | string | undefined | (() => Bun.MaybePromise) = options.password; + + try { + if (typeof password === "function") { + password = password(); + } + + if (password && $isPromise(password)) { + password = await password; + } + + return nativeCreateConnection( + hostname, + Number(port), + username || "", + password || "", + database || "", + // > The default value for sslmode is prefer. As is shown in the table, this + // makes no sense from a security point of view, and it only promises + // performance overhead if possible. It is only provided as the default for + // backward compatibility, and is not recommended in secure deployments. + sslMode || SSLMode.disable, + tls || null, + query || "", + path || "", + onConnected, + onClose, + idleTimeout, + connectionTimeout, + maxLifetime, + !prepare, + !!allowPublicKeyRetrieval, + ); + } catch (e) { + if (deferSyncCloseError) { + process.nextTick(closeNT, onClose, e); + } else { + onClose(e as Error); + } + return null; + } +} + +abstract class BaseSQLAdapter + implements DatabaseAdapter +{ + public readonly connectionInfo: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions; + + public readonly connections: PooledConnection[]; + public readonly readyConnections: Set = new Set(); + + public waitingQueue: Array<(err: Error | null, result: any) => void> = []; + public reservedQueue: Array<(err: Error | null, result: any) => void> = []; + + public poolStarted: boolean = false; + public closed: boolean = false; + public totalQueries: number = 0; + public onAllQueriesFinished: (() => void) | null = null; + + constructor(connectionInfo: Bun.SQL.__internal.DefinedPostgresOrMySQLOptions) { + this.connectionInfo = connectionInfo; + this.connections = new Array(connectionInfo.max); + } + + protected abstract createPooledConnection(): PooledConnection; + abstract createQueryHandle(sql: string, values: unknown[], flags: number): QueryHandle; + abstract array(values: any[], typeNameOrID?: number | ArrayType): SQLArrayParameter; + abstract getTransactionCommands(options?: string): TransactionCommands; + abstract getDistributedTransactionCommands(name: string): TransactionCommands | null; + abstract getCommitDistributedSQL(name: string): string; + abstract getRollbackDistributedSQL(name: string): string; + abstract escapeIdentifier(name: string): string; + abstract connectionClosedError(): Error; + abstract notTaggedCallError(): Error; + abstract queryCancelledError(): Error; + abstract invalidTransactionStateError(message: string): Error; + abstract unsafeTransactionError(): Error; + abstract getHelperCommand(query: string): SQLCommand; + + placeholder(_index: number): string { + return "?"; + } + + bindParam(value: unknown, binding_values: unknown[], index: number): string { + return pushBindParam(this, value, binding_values, index); + } + + isUpsertUpdate(_query: string): boolean { + return false; + } + + throwIfUpdateEmpty(_query: string, hasValues: boolean): void { + if (!hasValues) { + throw new SyntaxError("Update needs to have at least one column"); + } + } + + normalizeQuery(strings: string | TemplateStringsArray, values: unknown[], binding_idx = 1): [string, unknown[]] { + return normalizeQuery(this, strings, values, binding_idx); + } + + protected checkUnsafeTransaction(sql: string, flags: number) { + if (!(flags & SQLQueryFlags.allowUnsafeTransaction)) { + if (this.connectionInfo.max !== 1) { + const upperCaseSqlString = sql.toUpperCase().trim(); + if (upperCaseSqlString.startsWith("BEGIN") || upperCaseSqlString.startsWith("START TRANSACTION")) { + throw this.unsafeTransactionError(); + } + } + } + } + + supportsReservedConnections() { + return true; + } + + getConnectionForQuery(pooledConnection: PooledConnection) { + return pooledConnection.connection; + } + + attachConnectionCloseHandler(connection: PooledConnection, handler: () => void): void { + if (connection.onClose) { + connection.onClose(handler); + } + } + + detachConnectionCloseHandler(connection: PooledConnection, handler: () => void): void { + if (connection.queries) { + connection.queries.delete(handler); + } + } + + validateTransactionOptions(options: string): { valid: boolean; error?: string } { + // The string is interpolated into the BEGIN/START TRANSACTION statement, so refuse + // anything that could terminate the statement or start a new one. + if (!/^[A-Za-z ,]*$/.test(options)) { + return { + valid: false, + error: "Transaction options can only contain letters, spaces, and commas.", + }; + } + return { valid: true }; + } + + validateDistributedTransactionName(name: string): { valid: boolean; error?: string } { + if (name.indexOf("'") !== -1) { + return { + valid: false, + error: "Distributed transaction name cannot contain single quotes.", + }; + } + return { valid: true }; + } + + maxDistribution() { + if (!this.waitingQueue.length) return 0; + const result = Math.ceil((this.waitingQueue.length + this.totalQueries) / this.connections.length); + return result ? result : 1; + } + + flushConcurrentQueries() { + const maxDistribution = this.maxDistribution(); + if (maxDistribution === 0) { + return; + } + + while (true) { + const nonReservedConnections = Array.from(this.readyConnections).filter( + c => !(c.flags & PooledConnectionFlags.preReserved) && c.queryCount < maxDistribution, + ); + if (nonReservedConnections.length === 0) { + return; + } + const orderedConnections = nonReservedConnections.sort((a, b) => a.queryCount - b.queryCount); + for (const connection of orderedConnections) { + const pending = this.waitingQueue.shift(); + if (!pending) { + return; + } + connection.queryCount++; + this.totalQueries++; + pending(null, connection); + } + } + } + + release(connection: PooledConnection, connectingEvent: boolean = false) { + if (!connectingEvent) { + connection.queryCount--; + this.totalQueries--; + } + const currentQueryCount = connection.queryCount; + if (currentQueryCount == 0) { + connection.flags &= ~PooledConnectionFlags.reserved; + connection.flags &= ~PooledConnectionFlags.preReserved; + } + if (this.onAllQueriesFinished) { + // we are waiting for all queries to finish, lets check if we can call it + if (!this.hasPendingQueries()) { + this.onAllQueriesFinished(); + } + } + + if (connection.state !== PooledConnectionState.connected) { + // connection is not ready + if (connection.storedError) { + // this connection got a error but maybe we can wait for another + + if (this.hasConnectionsAvailable()) { + return; + } + + const waitingQueue = this.waitingQueue; + const reservedQueue = this.reservedQueue; + + this.waitingQueue = []; + this.reservedQueue = []; + // we have no connections available so lets fails + for (const pending of waitingQueue) { + pending(connection.storedError, connection); + } + for (const pending of reservedQueue) { + pending(connection.storedError, connection); + } + // draining the queues may have been the last pending work; a + // graceful close() is waiting on this callback + if (this.onAllQueriesFinished && !this.hasPendingQueries()) { + this.onAllQueriesFinished(); + } + } + return; + } + + if (currentQueryCount == 0) { + // ok we can actually bind reserved queries to it + const pendingReserved = this.reservedQueue.shift(); + if (pendingReserved) { + connection.flags |= PooledConnectionFlags.reserved; + connection.queryCount++; + this.totalQueries++; + // we have a connection waiting for a reserved connection lets prioritize it + pendingReserved(connection.storedError, connection); + return; + } + } + this.readyConnections.add(connection); + this.flushConcurrentQueries(); + } + + hasConnectionsAvailable() { + if (this.readyConnections?.size > 0) return true; + if (this.poolStarted) { + const pollSize = this.connections.length; + for (let i = 0; i < pollSize; i++) { + const connection = this.connections[i]; + // The slot can still be an unassigned hole while the pool is starting + // and a synchronous creation failure re-enters via release(). + if (connection && connection.state !== PooledConnectionState.closed) { + // some connection is connecting or connected + return true; + } + } + } + return false; + } + + hasPendingQueries() { + if (this.waitingQueue.length > 0 || this.reservedQueue.length > 0) return true; + if (this.poolStarted) { + return this.totalQueries > 0; + } + return false; + } + isConnected() { + if (this.readyConnections.size > 0) { + return true; + } + if (this.poolStarted) { + const pollSize = this.connections.length; + for (let i = 0; i < pollSize; i++) { + const connection = this.connections[i]; + if (connection.state === PooledConnectionState.connected) { + return true; + } + } + } + return false; + } + flush() { + if (this.closed) { + return; + } + if (this.poolStarted) { + const pollSize = this.connections.length; + for (let i = 0; i < pollSize; i++) { + const connection = this.connections[i]; + if (connection.state === PooledConnectionState.connected) { + connection.connection?.flush(); + } + } + } + } + + async #close() { + let pending; + while ((pending = this.waitingQueue.shift())) { + pending(this.connectionClosedError(), null); + } + while (this.reservedQueue.length > 0) { + const pendingReserved = this.reservedQueue.shift(); + if (pendingReserved) { + pendingReserved(this.connectionClosedError(), null); + } + } + + const promises: Array> = []; + + if (this.poolStarted) { + this.poolStarted = false; + const pollSize = this.connections.length; + for (let i = 0; i < pollSize; i++) { + const connection = this.connections[i]; + switch (connection.state) { + case PooledConnectionState.pending: + case PooledConnectionState.connected: { + // cancelRetry only returns true while a connect retry is parked + // in a backoff timer; nothing is in flight then, so there is no + // onClose/onConnected to wait for + if (connection.cancelRetry()) { + connection.state = PooledConnectionState.closed; + break; + } + const { promise, resolve } = Promise.withResolvers(); + connection.onFinish = resolve; + promises.push(promise); + connection.connection?.close(); + break; + } + } + // clean connection reference + // @ts-ignore + this.connections[i] = null; + } + } + + this.readyConnections.clear(); + this.waitingQueue.length = 0; + return Promise.all(promises); + } + + async close(options?: { timeout?: number }): Promise { + if (this.closed) { + return; + } + + let timeout = options?.timeout; + if (timeout) { + timeout = Number(timeout); + if (timeout > 2 ** 31 || timeout < 0 || timeout !== timeout) { + throw $ERR_INVALID_ARG_VALUE("options.timeout", timeout, "must be a non-negative integer less than 2^31"); + } + + this.closed = true; + if (timeout === 0 || !this.hasPendingQueries()) { + // close immediately + await this.#close(); + return; + } + + const { promise, resolve } = Promise.withResolvers(); + const timer = setTimeout(() => { + // timeout is reached, lets close and probably fail some queries + this.#close().finally(resolve); + }, timeout * 1000); + timer.unref(); // dont block the event loop + + this.onAllQueriesFinished = () => { + clearTimeout(timer); + // everything is closed, lets close the pool + this.#close().finally(resolve); + }; + + return promise; + } else { + this.closed = true; + if (!this.hasPendingQueries()) { + // close immediately + await this.#close(); + return; + } + + // gracefully close the pool + const { promise, resolve } = Promise.withResolvers(); + + this.onAllQueriesFinished = () => { + // everything is closed, lets close the pool + this.#close().finally(resolve); + }; + + return promise; + } + } + + /** + * @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) { + if (this.closed) { + return onConnected(this.connectionClosedError(), null); + } + + if (this.readyConnections.size === 0) { + // no connection ready lets make some + let retry_in_progress = false; + let all_closed = true; + let storedError: Error | null = null; + + if (this.poolStarted) { + // we already started the pool + // lets check if some connection is available to retry + const pollSize = this.connections.length; + for (let i = 0; i < pollSize; i++) { + const connection = this.connections[i]; + // we need a new connection and we have some connections that can retry + if (connection.state === PooledConnectionState.closed) { + if (connection.retry()) { + // lets wait for connection to be released + if (!retry_in_progress) { + // avoid adding to the queue twice, we wanna to retry every available pool connection + retry_in_progress = true; + if (reserved) { + // we are not sure what connection will be available so we dont pre reserve + this.reservedQueue.push(onConnected); + } else { + this.waitingQueue.push(onConnected); + } + } + } else { + // we have some error, lets grab it and fail if unable to start a connection + storedError = connection.storedError; + } + } else { + // we have some pending or open connections + all_closed = false; + } + } + if (!all_closed && !retry_in_progress) { + // is possible to connect because we have some working connections, or we are just without network for some reason + // wait for connection to be released or fail + if (reserved) { + // we are not sure what connection will be available so we dont pre reserve + this.reservedQueue.push(onConnected); + } else { + this.waitingQueue.push(onConnected); + } + } else if (!retry_in_progress) { + // impossible to connect or retry + onConnected(storedError ?? this.connectionClosedError(), null); + } + return; + } + // we never started the pool, lets start it + if (reserved) { + this.reservedQueue.push(onConnected); + } else { + this.waitingQueue.push(onConnected); + } + this.poolStarted = true; + const pollSize = this.connections.length; + // pool is always at least 1 connection + const firstConnection = this.createPooledConnection(); + 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] = this.createPooledConnection(); + } + return; + } + if (reserved) { + let connectionWithLeastQueries: PooledConnection | null = null; + let leastQueries = Infinity; + for (const connection of this.readyConnections) { + if (connection.flags & PooledConnectionFlags.preReserved || connection.flags & PooledConnectionFlags.reserved) + continue; + const queryCount = connection.queryCount; + if (queryCount > 0) { + if (queryCount < leastQueries) { + leastQueries = queryCount; + connectionWithLeastQueries = connection; + } + continue; + } + connection.flags |= PooledConnectionFlags.reserved; + connection.queryCount++; + this.totalQueries++; + this.readyConnections.delete(connection); + onConnected(null, connection); + return; + } + + if (connectionWithLeastQueries) { + // lets mark the connection with the least queries as preReserved if any + connectionWithLeastQueries.flags |= PooledConnectionFlags.preReserved; + } + + // no connection available to be reserved lets wait for a connection to be released + this.reservedQueue.push(onConnected); + } else { + this.waitingQueue.push(onConnected); + this.flushConcurrentQueries(); + } + } +} + const SQLITE_MEMORY = ":memory:"; const SQLITE_MEMORY_VARIANTS: string[] = [":memory:", "sqlite://:memory:", "sqlite:memory"]; @@ -975,6 +2113,12 @@ export default { normalizeSSLMode, SQLResultArray, SQLArrayParameter, + getHelperCommandFromDetect, + pushBindParam, + normalizeQuery, + BasePooledConnection, + BaseSQLAdapter, + createPooledConnectionHandle, // @ts-expect-error we're exporting a const enum which works in our builtins // generator but not in typescript officially SSLMode, diff --git a/src/js/internal/sql/sqlite.ts b/src/js/internal/sql/sqlite.ts index a7061616bd30..4b2c0f14fb51 100644 --- a/src/js/internal/sql/sqlite.ts +++ b/src/js/internal/sql/sqlite.ts @@ -1,13 +1,16 @@ import type * as BunSQLiteModule from "bun:sqlite"; import type { BaseQueryHandle, Query, SQLQueryResultMode } from "./query"; -import type { ArrayType, DatabaseAdapter, OnConnected, SQLArrayParameter, SQLHelper, SQLResultArray } from "./shared"; - -const { SQLHelper, SQLResultArray, buildDefinedColumnsAndQuery } = require("internal/sql/shared"); -const { - Query, - SQLQueryResultMode, - symbols: { _strings, _values }, -} = require("internal/sql/query"); +import type { + ArrayType, + DatabaseAdapter, + OnConnected, + SQLCommand as SharedSQLCommand, + SQLArrayParameter, + SQLResultArray, +} from "./shared"; + +const { SQLResultArray, normalizeQuery, pushBindParam } = require("internal/sql/shared"); +const { SQLQueryResultMode } = require("internal/sql/query"); const { SQLiteError } = require("internal/sql/errors"); let lazySQLiteModule: typeof BunSQLiteModule; @@ -377,204 +380,38 @@ class SQLiteAdapter implements DatabaseAdapter i) { - const value = values[i]; + // only selectIn, insert, update, updateSet are allowed + if (command === SQLCommand.none || command === SQLCommand.where) { + throw new SyntaxError("Helpers are only allowed for INSERT, UPDATE and WHERE IN commands"); + } + // the local SQLCommand enum is numerically identical to the shared one + return command as unknown as SharedSQLCommand; + } - if (value instanceof Query) { - const q = value as Query; - const [sub_query, sub_values] = this.normalizeQuery(q[_strings], q[_values], binding_idx); + isUpsertUpdate(_query: string): boolean { + return false; + } - query += sub_query; - for (let j = 0; j < sub_values.length; j++) { - binding_values.push(sub_values[j]); - } - binding_idx += sub_values.length; - } else if (value instanceof SQLHelper) { - // when partial is true we stop on the first command we find - const { command } = parseSQLQuery(query, true); - - // only selectIn, insert, update, updateSet are allowed - if (command === SQLCommand.none || command === SQLCommand.where) { - throw new SyntaxError("Helpers are only allowed for INSERT, UPDATE and WHERE IN commands"); - } - const { columns, value: items } = value as SQLHelper; - const columnCount = columns.length; - if (columnCount === 0 && command !== SQLCommand.in) { - throw new SyntaxError(`Cannot ${commandToString(command)} with no columns`); - } - const lastColumnIndex = columns.length - 1; - - if (command === SQLCommand.insert) { - // - // insert into users ${sql(users)} or insert into users ${sql(user)} - // - - // Build column list while determining which columns have at least one defined value - const { definedColumns, columnsSql } = buildDefinedColumnsAndQuery( - columns, - items, - this.escapeIdentifier.bind(this), - ); - - const definedColumnCount = definedColumns.length; - if (definedColumnCount === 0) { - throw new SyntaxError("Insert needs to have at least one column with a defined value"); - } - const lastDefinedColumnIndex = definedColumnCount - 1; - - query += columnsSql; - if ($isArray(items)) { - const itemsCount = items.length; - const lastItemIndex = itemsCount - 1; - for (let j = 0; j < itemsCount; j++) { - query += "("; - const item = items[j]; - for (let k = 0; k < definedColumnCount; k++) { - const column = definedColumns[k]; - const columnValue = item[column]; - // SQLite uses ? for placeholders, not $1, $2, etc. - query += `?${k < lastDefinedColumnIndex ? ", " : ""}`; - // If this item has undefined for a column that other items defined, use null - binding_values.push(typeof columnValue === "undefined" ? null : columnValue); - } - if (j < lastItemIndex) { - query += "),"; - } else { - query += ") "; // the user can add RETURNING * or RETURNING id - } - } - } else { - query += "("; - const item = items; - for (let j = 0; j < definedColumnCount; j++) { - const column = definedColumns[j]; - const columnValue = item[column]; - // SQLite uses ? for placeholders - query += `?${j < lastDefinedColumnIndex ? ", " : ""}`; - binding_values.push(columnValue); - } - query += ") "; // the user can add RETURNING * or RETURNING id - } - } else if (command === SQLCommand.in) { - // SELECT * FROM users WHERE id IN (${sql([1, 2, 3])}) - if (!$isArray(items)) { - throw new SyntaxError("An array of values is required for WHERE IN helper"); - } - const itemsCount = items.length; - const lastItemIndex = itemsCount - 1; - query += "("; - for (let j = 0; j < itemsCount; j++) { - // SQLite uses ? for placeholders - query += `?${j < lastItemIndex ? ", " : ""}`; - if (columnCount > 0) { - // we must use a key from a object - if (columnCount > 1) { - // we should not pass multiple columns here - throw new SyntaxError("Cannot use WHERE IN helper with multiple columns"); - } - // SELECT * FROM users WHERE id IN (${sql(users, "id")}) - const value = items[j]; - if (typeof value === "undefined") { - binding_values.push(null); - } else { - const value_from_key = value[columns[0]]; - - if (typeof value_from_key === "undefined") { - binding_values.push(null); - } else { - binding_values.push(value_from_key); - } - } - } else { - const value = items[j]; - if (typeof value === "undefined") { - binding_values.push(null); - } else { - binding_values.push(value); - } - } - } - query += ") "; // more conditions can be added after this - } else { - // UPDATE users SET ${sql({ name: "John", age: 31 })} WHERE id = 1 - let item; - if ($isArray(items)) { - if (items.length > 1) { - throw new SyntaxError("Cannot use array of objects for UPDATE"); - } - item = items[0]; - } else { - item = items; - } - // no need to include if is updateSet - if (command === SQLCommand.update) { - query += " SET "; - } - for (let i = 0; i < columnCount; i++) { - const column = columns[i]; - const columnValue = item[column]; - if (typeof columnValue === "undefined") { - // skip undefined values, this is the expected behavior in JS - continue; - } - // SQLite uses ? for placeholders - query += `${this.escapeIdentifier(column)} = ?${i < lastColumnIndex ? ", " : ""}`; - if (typeof columnValue === "undefined") { - binding_values.push(null); - } else { - binding_values.push(columnValue); - } - } - if (query.endsWith(", ")) { - // we got an undefined value at the end, lets remove the last comma - query = query.substring(0, query.length - 2); - } - if (query.endsWith("SET ")) { - throw new SyntaxError("Update needs to have at least one column"); - } - // the user can add where clause after this - query += " "; - } - } else { - // SQLite uses ? for placeholders - query += `? `; - if (typeof value === "undefined") { - binding_values.push(null); - } else { - binding_values.push(value); - } - } - } - } else { - throw new SyntaxError("Invalid query: SQL Fragment cannot be executed or was misused"); - } + throwIfUpdateEmpty(query: string, _hasValues: boolean): void { + if (query.endsWith("SET ")) { + throw new SyntaxError("Update needs to have at least one column"); } - - return [query, binding_values]; } connect(onConnected: OnConnected, reserved?: boolean) { diff --git a/src/sql/lib.rs b/src/sql/lib.rs index 23d7875262ba..f3ec0b3b5e46 100644 --- a/src/sql/lib.rs +++ b/src/sql/lib.rs @@ -7,13 +7,20 @@ pub mod shared { pub mod connection_flags; #[path = "Data.rs"] pub mod data; + #[path = "QueryStatus.rs"] + pub mod query_status; #[path = "SQLQueryResultMode.rs"] pub mod sql_query_result_mode; + #[path = "StackReader.rs"] + pub mod stack_reader; + #[path = "StatementStatus.rs"] + pub mod statement_status; pub use column_identifier::ColumnIdentifier; pub use connection_flags::ConnectionFlags; pub use data::Data; pub use sql_query_result_mode::SQLQueryResultMode; + pub use stack_reader::StackReader; } pub mod mysql { @@ -31,8 +38,6 @@ pub mod mysql { pub mod mysql_request; #[path = "MySQLTypes.rs"] pub mod mysql_types; - #[path = "QueryStatus.rs"] - pub mod query_status; #[path = "SSLMode.rs"] pub mod ssl_mode; #[path = "StatusFlags.rs"] @@ -105,7 +110,7 @@ pub mod mysql { pub use handshake_response41::HandshakeResponse41; pub use handshake_v10::HandshakeV10; pub use local_infile_request::LocalInfileRequest; - pub use new_reader::{Decode, NewReader, NewReaderOf, ReadableInt, ReaderContext}; + pub use new_reader::{Decode, NewReader, ReadableInt, ReaderContext}; pub use new_writer::{NewWriter, NewWriterWrap, Packet, WriterContext, write_wrap}; pub use ok_packet::OKPacket; pub use packet_header::PacketHeader; @@ -118,11 +123,12 @@ pub mod mysql { pub use crate::mysql::mysql_types::FieldType; } + pub use crate::shared::query_status; + pub use crate::shared::query_status::Status as QueryStatus; pub use auth_method::AuthMethod; pub use capabilities::Capabilities; pub use connection_state::ConnectionState; pub use mysql_query_result::MySQLQueryResult; - pub use query_status::Status as QueryStatus; pub use ssl_mode::SSLMode; pub use status_flags::{StatusFlag, StatusFlags}; pub use tls_status::TLSStatus; diff --git a/src/sql/mysql/Capabilities.rs b/src/sql/mysql/Capabilities.rs index 7d91fc866d6e..f93e7794cc93 100644 --- a/src/sql/mysql/Capabilities.rs +++ b/src/sql/mysql/Capabilities.rs @@ -1,84 +1,94 @@ // MySQL capability flags // // Modeled as a plain struct of `bool` fields (NOT a `packed struct(u32)`-style -// bitfield), with explicit bit-value constants and -// hand-unrolled field loops for `to_int`/`from_int`/`Display`. Field names stay -// SCREAMING_SNAKE_CASE because `Display` emits them verbatim. +// bitfield). The `capabilities!` macro takes the single (field, bit) list and +// generates the pub-field struct plus `to_int`/`from_int`/`Display`. +// Field names stay SCREAMING_SNAKE_CASE because `Display` emits them verbatim. // (non_snake_case / non_upper_case_globals allowed at crate root.) use core::fmt; -#[derive(Default, Clone, Copy, PartialEq, Eq)] -pub struct Capabilities { - pub CLIENT_LONG_PASSWORD: bool, - pub CLIENT_FOUND_ROWS: bool, - pub CLIENT_LONG_FLAG: bool, - pub CLIENT_CONNECT_WITH_DB: bool, - pub CLIENT_NO_SCHEMA: bool, - pub CLIENT_COMPRESS: bool, - pub CLIENT_ODBC: bool, - pub CLIENT_LOCAL_FILES: bool, - pub CLIENT_IGNORE_SPACE: bool, - pub CLIENT_PROTOCOL_41: bool, - pub CLIENT_INTERACTIVE: bool, - pub CLIENT_SSL: bool, - pub CLIENT_IGNORE_SIGPIPE: bool, - pub CLIENT_TRANSACTIONS: bool, - pub CLIENT_RESERVED: bool, - pub CLIENT_SECURE_CONNECTION: bool, - pub CLIENT_MULTI_STATEMENTS: bool, - pub CLIENT_MULTI_RESULTS: bool, - pub CLIENT_PS_MULTI_RESULTS: bool, - pub CLIENT_PLUGIN_AUTH: bool, - pub CLIENT_CONNECT_ATTRS: bool, - pub CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA: bool, - pub CLIENT_CAN_HANDLE_EXPIRED_PASSWORDS: bool, - pub CLIENT_SESSION_TRACK: bool, - pub CLIENT_DEPRECATE_EOF: bool, - pub CLIENT_OPTIONAL_RESULTSET_METADATA: bool, - pub CLIENT_ZSTD_COMPRESSION_ALGORITHM: bool, - pub CLIENT_QUERY_ATTRIBUTES: bool, - pub MULTI_FACTOR_AUTHENTICATION: bool, - pub CLIENT_CAPABILITY_EXTENSION: bool, - pub CLIENT_SSL_VERIFY_SERVER_CERT: bool, - pub CLIENT_REMEMBER_OPTIONS: bool, +macro_rules! capabilities { + ($($field:ident = $bit:expr,)*) => { + #[derive(Default, Clone, Copy, PartialEq, Eq)] + pub struct Capabilities { + $(pub $field: bool,)* + } + + impl Capabilities { + pub fn to_int(self) -> u32 { + let mut value: u32 = 0; + $( + if self.$field { + value |= $bit; + } + )* + value + } + + pub fn from_int(flags: u32) -> Capabilities { + Capabilities { + $($field: (flags & $bit) != 0,)* + } + } + } + + impl fmt::Display for Capabilities { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut first = true; + $( + if self.$field { + if !first { + f.write_str(", ")?; + } + first = false; + f.write_str(stringify!($field))?; + } + )* + let _ = first; + Ok(()) + } + } + }; } -impl Capabilities { - // Constants with correct shift values from MySQL protocol - const _CLIENT_LONG_PASSWORD: u32 = 1; // 1 << 0 - const _CLIENT_FOUND_ROWS: u32 = 2; // 1 << 1 - const _CLIENT_LONG_FLAG: u32 = 4; // 1 << 2 - const _CLIENT_CONNECT_WITH_DB: u32 = 8; // 1 << 3 - const _CLIENT_NO_SCHEMA: u32 = 16; // 1 << 4 - const _CLIENT_COMPRESS: u32 = 32; // 1 << 5 - const _CLIENT_ODBC: u32 = 64; // 1 << 6 - const _CLIENT_LOCAL_FILES: u32 = 128; // 1 << 7 - const _CLIENT_IGNORE_SPACE: u32 = 256; // 1 << 8 - const _CLIENT_PROTOCOL_41: u32 = 512; // 1 << 9 - const _CLIENT_INTERACTIVE: u32 = 1024; // 1 << 10 - const _CLIENT_SSL: u32 = 2048; // 1 << 11 - const _CLIENT_IGNORE_SIGPIPE: u32 = 4096; // 1 << 12 - const _CLIENT_TRANSACTIONS: u32 = 8192; // 1 << 13 - const _CLIENT_RESERVED: u32 = 16384; // 1 << 14 - const _CLIENT_SECURE_CONNECTION: u32 = 32768; // 1 << 15 - const _CLIENT_MULTI_STATEMENTS: u32 = 65536; // 1 << 16 - const _CLIENT_MULTI_RESULTS: u32 = 131072; // 1 << 17 - const _CLIENT_PS_MULTI_RESULTS: u32 = 262144; // 1 << 18 - const _CLIENT_PLUGIN_AUTH: u32 = 524288; // 1 << 19 - const _CLIENT_CONNECT_ATTRS: u32 = 1048576; // 1 << 20 - const _CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA: u32 = 2097152; // 1 << 21 - const _CLIENT_CAN_HANDLE_EXPIRED_PASSWORDS: u32 = 4194304; // 1 << 22 - const _CLIENT_SESSION_TRACK: u32 = 8388608; // 1 << 23 - const _CLIENT_DEPRECATE_EOF: u32 = 16777216; // 1 << 24 - const _CLIENT_OPTIONAL_RESULTSET_METADATA: u32 = 33554432; // 1 << 25 - const _CLIENT_ZSTD_COMPRESSION_ALGORITHM: u32 = 67108864; // 1 << 26 - const _CLIENT_QUERY_ATTRIBUTES: u32 = 134217728; // 1 << 27 - const _MULTI_FACTOR_AUTHENTICATION: u32 = 268435456; // 1 << 28 - const _CLIENT_CAPABILITY_EXTENSION: u32 = 536870912; // 1 << 29 - const _CLIENT_SSL_VERIFY_SERVER_CERT: u32 = 1073741824; // 1 << 30 - const _CLIENT_REMEMBER_OPTIONS: u32 = 2147483648; // 1 << 31 +// Bit values from the MySQL protocol. +capabilities! { + CLIENT_LONG_PASSWORD = 1 << 0, + CLIENT_FOUND_ROWS = 1 << 1, + CLIENT_LONG_FLAG = 1 << 2, + CLIENT_CONNECT_WITH_DB = 1 << 3, + CLIENT_NO_SCHEMA = 1 << 4, + CLIENT_COMPRESS = 1 << 5, + CLIENT_ODBC = 1 << 6, + CLIENT_LOCAL_FILES = 1 << 7, + CLIENT_IGNORE_SPACE = 1 << 8, + CLIENT_PROTOCOL_41 = 1 << 9, + CLIENT_INTERACTIVE = 1 << 10, + CLIENT_SSL = 1 << 11, + CLIENT_IGNORE_SIGPIPE = 1 << 12, + CLIENT_TRANSACTIONS = 1 << 13, + CLIENT_RESERVED = 1 << 14, + CLIENT_SECURE_CONNECTION = 1 << 15, + CLIENT_MULTI_STATEMENTS = 1 << 16, + CLIENT_MULTI_RESULTS = 1 << 17, + CLIENT_PS_MULTI_RESULTS = 1 << 18, + CLIENT_PLUGIN_AUTH = 1 << 19, + CLIENT_CONNECT_ATTRS = 1 << 20, + CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 1 << 21, + CLIENT_CAN_HANDLE_EXPIRED_PASSWORDS = 1 << 22, + CLIENT_SESSION_TRACK = 1 << 23, + CLIENT_DEPRECATE_EOF = 1 << 24, + CLIENT_OPTIONAL_RESULTSET_METADATA = 1 << 25, + CLIENT_ZSTD_COMPRESSION_ALGORITHM = 1 << 26, + CLIENT_QUERY_ATTRIBUTES = 1 << 27, + MULTI_FACTOR_AUTHENTICATION = 1 << 28, + CLIENT_CAPABILITY_EXTENSION = 1 << 29, + CLIENT_SSL_VERIFY_SERVER_CERT = 1 << 30, + CLIENT_REMEMBER_OPTIONS = 1 << 31, +} +impl Capabilities { pub fn reject(&mut self) { self.CLIENT_ZSTD_COMPRESSION_ALGORITHM = false; self.MULTI_FACTOR_AUTHENTICATION = false; @@ -95,154 +105,6 @@ impl Capabilities { self.CLIENT_QUERY_ATTRIBUTES = false; } - pub fn to_int(self) -> u32 { - let mut value: u32 = 0; - - // Keep in sync with the Capabilities field list above. - if self.CLIENT_LONG_PASSWORD { - value |= Self::_CLIENT_LONG_PASSWORD; - } - if self.CLIENT_FOUND_ROWS { - value |= Self::_CLIENT_FOUND_ROWS; - } - if self.CLIENT_LONG_FLAG { - value |= Self::_CLIENT_LONG_FLAG; - } - if self.CLIENT_CONNECT_WITH_DB { - value |= Self::_CLIENT_CONNECT_WITH_DB; - } - if self.CLIENT_NO_SCHEMA { - value |= Self::_CLIENT_NO_SCHEMA; - } - if self.CLIENT_COMPRESS { - value |= Self::_CLIENT_COMPRESS; - } - if self.CLIENT_ODBC { - value |= Self::_CLIENT_ODBC; - } - if self.CLIENT_LOCAL_FILES { - value |= Self::_CLIENT_LOCAL_FILES; - } - if self.CLIENT_IGNORE_SPACE { - value |= Self::_CLIENT_IGNORE_SPACE; - } - if self.CLIENT_PROTOCOL_41 { - value |= Self::_CLIENT_PROTOCOL_41; - } - if self.CLIENT_INTERACTIVE { - value |= Self::_CLIENT_INTERACTIVE; - } - if self.CLIENT_SSL { - value |= Self::_CLIENT_SSL; - } - if self.CLIENT_IGNORE_SIGPIPE { - value |= Self::_CLIENT_IGNORE_SIGPIPE; - } - if self.CLIENT_TRANSACTIONS { - value |= Self::_CLIENT_TRANSACTIONS; - } - if self.CLIENT_RESERVED { - value |= Self::_CLIENT_RESERVED; - } - if self.CLIENT_SECURE_CONNECTION { - value |= Self::_CLIENT_SECURE_CONNECTION; - } - if self.CLIENT_MULTI_STATEMENTS { - value |= Self::_CLIENT_MULTI_STATEMENTS; - } - if self.CLIENT_MULTI_RESULTS { - value |= Self::_CLIENT_MULTI_RESULTS; - } - if self.CLIENT_PS_MULTI_RESULTS { - value |= Self::_CLIENT_PS_MULTI_RESULTS; - } - if self.CLIENT_PLUGIN_AUTH { - value |= Self::_CLIENT_PLUGIN_AUTH; - } - if self.CLIENT_CONNECT_ATTRS { - value |= Self::_CLIENT_CONNECT_ATTRS; - } - if self.CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA { - value |= Self::_CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA; - } - if self.CLIENT_CAN_HANDLE_EXPIRED_PASSWORDS { - value |= Self::_CLIENT_CAN_HANDLE_EXPIRED_PASSWORDS; - } - if self.CLIENT_SESSION_TRACK { - value |= Self::_CLIENT_SESSION_TRACK; - } - if self.CLIENT_DEPRECATE_EOF { - value |= Self::_CLIENT_DEPRECATE_EOF; - } - if self.CLIENT_OPTIONAL_RESULTSET_METADATA { - value |= Self::_CLIENT_OPTIONAL_RESULTSET_METADATA; - } - if self.CLIENT_ZSTD_COMPRESSION_ALGORITHM { - value |= Self::_CLIENT_ZSTD_COMPRESSION_ALGORITHM; - } - if self.CLIENT_QUERY_ATTRIBUTES { - value |= Self::_CLIENT_QUERY_ATTRIBUTES; - } - if self.MULTI_FACTOR_AUTHENTICATION { - value |= Self::_MULTI_FACTOR_AUTHENTICATION; - } - if self.CLIENT_CAPABILITY_EXTENSION { - value |= Self::_CLIENT_CAPABILITY_EXTENSION; - } - if self.CLIENT_SSL_VERIFY_SERVER_CERT { - value |= Self::_CLIENT_SSL_VERIFY_SERVER_CERT; - } - if self.CLIENT_REMEMBER_OPTIONS { - value |= Self::_CLIENT_REMEMBER_OPTIONS; - } - - value - } - - pub fn from_int(flags: u32) -> Capabilities { - // Keep in sync with the Capabilities field list above. - Capabilities { - CLIENT_LONG_PASSWORD: (Self::_CLIENT_LONG_PASSWORD & flags) != 0, - CLIENT_FOUND_ROWS: (Self::_CLIENT_FOUND_ROWS & flags) != 0, - CLIENT_LONG_FLAG: (Self::_CLIENT_LONG_FLAG & flags) != 0, - CLIENT_CONNECT_WITH_DB: (Self::_CLIENT_CONNECT_WITH_DB & flags) != 0, - CLIENT_NO_SCHEMA: (Self::_CLIENT_NO_SCHEMA & flags) != 0, - CLIENT_COMPRESS: (Self::_CLIENT_COMPRESS & flags) != 0, - CLIENT_ODBC: (Self::_CLIENT_ODBC & flags) != 0, - CLIENT_LOCAL_FILES: (Self::_CLIENT_LOCAL_FILES & flags) != 0, - CLIENT_IGNORE_SPACE: (Self::_CLIENT_IGNORE_SPACE & flags) != 0, - CLIENT_PROTOCOL_41: (Self::_CLIENT_PROTOCOL_41 & flags) != 0, - CLIENT_INTERACTIVE: (Self::_CLIENT_INTERACTIVE & flags) != 0, - CLIENT_SSL: (Self::_CLIENT_SSL & flags) != 0, - CLIENT_IGNORE_SIGPIPE: (Self::_CLIENT_IGNORE_SIGPIPE & flags) != 0, - CLIENT_TRANSACTIONS: (Self::_CLIENT_TRANSACTIONS & flags) != 0, - CLIENT_RESERVED: (Self::_CLIENT_RESERVED & flags) != 0, - CLIENT_SECURE_CONNECTION: (Self::_CLIENT_SECURE_CONNECTION & flags) != 0, - CLIENT_MULTI_STATEMENTS: (Self::_CLIENT_MULTI_STATEMENTS & flags) != 0, - CLIENT_MULTI_RESULTS: (Self::_CLIENT_MULTI_RESULTS & flags) != 0, - CLIENT_PS_MULTI_RESULTS: (Self::_CLIENT_PS_MULTI_RESULTS & flags) != 0, - CLIENT_PLUGIN_AUTH: (Self::_CLIENT_PLUGIN_AUTH & flags) != 0, - CLIENT_CONNECT_ATTRS: (Self::_CLIENT_CONNECT_ATTRS & flags) != 0, - CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA: (Self::_CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA - & flags) - != 0, - CLIENT_CAN_HANDLE_EXPIRED_PASSWORDS: (Self::_CLIENT_CAN_HANDLE_EXPIRED_PASSWORDS - & flags) - != 0, - CLIENT_SESSION_TRACK: (Self::_CLIENT_SESSION_TRACK & flags) != 0, - CLIENT_DEPRECATE_EOF: (Self::_CLIENT_DEPRECATE_EOF & flags) != 0, - CLIENT_OPTIONAL_RESULTSET_METADATA: (Self::_CLIENT_OPTIONAL_RESULTSET_METADATA & flags) - != 0, - CLIENT_ZSTD_COMPRESSION_ALGORITHM: (Self::_CLIENT_ZSTD_COMPRESSION_ALGORITHM & flags) - != 0, - CLIENT_QUERY_ATTRIBUTES: (Self::_CLIENT_QUERY_ATTRIBUTES & flags) != 0, - MULTI_FACTOR_AUTHENTICATION: (Self::_MULTI_FACTOR_AUTHENTICATION & flags) != 0, - CLIENT_CAPABILITY_EXTENSION: (Self::_CLIENT_CAPABILITY_EXTENSION & flags) != 0, - CLIENT_SSL_VERIFY_SERVER_CERT: (Self::_CLIENT_SSL_VERIFY_SERVER_CERT & flags) != 0, - CLIENT_REMEMBER_OPTIONS: (Self::_CLIENT_REMEMBER_OPTIONS & flags) != 0, - } - } - /// Returns the intersection of two capability sets (AND). /// Per MySQL protocol, the client should only request capabilities /// that the server also advertises. @@ -264,55 +126,3 @@ impl Capabilities { } } } - -impl fmt::Display for Capabilities { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut first = true; - // Keep in sync with the Capabilities field list above. - macro_rules! emit { - ($field:ident) => { - if self.$field { - if !first { - f.write_str(", ")?; - } - first = false; - f.write_str(stringify!($field))?; - } - }; - } - emit!(CLIENT_LONG_PASSWORD); - emit!(CLIENT_FOUND_ROWS); - emit!(CLIENT_LONG_FLAG); - emit!(CLIENT_CONNECT_WITH_DB); - emit!(CLIENT_NO_SCHEMA); - emit!(CLIENT_COMPRESS); - emit!(CLIENT_ODBC); - emit!(CLIENT_LOCAL_FILES); - emit!(CLIENT_IGNORE_SPACE); - emit!(CLIENT_PROTOCOL_41); - emit!(CLIENT_INTERACTIVE); - emit!(CLIENT_SSL); - emit!(CLIENT_IGNORE_SIGPIPE); - emit!(CLIENT_TRANSACTIONS); - emit!(CLIENT_RESERVED); - emit!(CLIENT_SECURE_CONNECTION); - emit!(CLIENT_MULTI_STATEMENTS); - emit!(CLIENT_MULTI_RESULTS); - emit!(CLIENT_PS_MULTI_RESULTS); - emit!(CLIENT_PLUGIN_AUTH); - emit!(CLIENT_CONNECT_ATTRS); - emit!(CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA); - emit!(CLIENT_CAN_HANDLE_EXPIRED_PASSWORDS); - emit!(CLIENT_SESSION_TRACK); - emit!(CLIENT_DEPRECATE_EOF); - emit!(CLIENT_OPTIONAL_RESULTSET_METADATA); - emit!(CLIENT_ZSTD_COMPRESSION_ALGORITHM); - emit!(CLIENT_QUERY_ATTRIBUTES); - emit!(MULTI_FACTOR_AUTHENTICATION); - emit!(CLIENT_CAPABILITY_EXTENSION); - emit!(CLIENT_SSL_VERIFY_SERVER_CERT); - emit!(CLIENT_REMEMBER_OPTIONS); - let _ = first; - Ok(()) - } -} diff --git a/src/sql/mysql/protocol/NewReader.rs b/src/sql/mysql/protocol/NewReader.rs index f4790d47c6af..81e5c1d96e95 100644 --- a/src/sql/mysql/protocol/NewReader.rs +++ b/src/sql/mysql/protocol/NewReader.rs @@ -122,8 +122,6 @@ impl NewReader { /// MySQL's u24/i24 are NOT routed through this trait — see `int_u24`/`int_i24`. pub use bun_core::NativeEndianInt as ReadableInt; -pub type NewReaderOf = NewReader; - impl From for NewReader { fn from(wrapped: C) -> Self { Self { wrapped } @@ -142,11 +140,4 @@ pub trait Decode: Sized { ) -> Result<(), AnyMySQLError> { self.decode_internal(context.into()) } - - fn decode_allocator( - &mut self, - context: impl Into>, - ) -> Result<(), AnyMySQLError> { - self.decode_internal(context.into()) - } } diff --git a/src/sql/mysql/protocol/StackReader.rs b/src/sql/mysql/protocol/StackReader.rs index d996366948db..57415004c5d2 100644 --- a/src/sql/mysql/protocol/StackReader.rs +++ b/src/sql/mysql/protocol/StackReader.rs @@ -1,116 +1,40 @@ -use core::cell::Cell; - -use bun_core::strings; - use super::any_mysql_error::Error as AnyMySQLError; use super::new_reader::{NewReader, ReaderContext}; use crate::shared::data::Data; +use crate::shared::stack_reader::{ShortRead, WrapReader}; -#[derive(Clone, Copy)] -pub struct StackReader<'a> { - pub buffer: &'a [u8], - pub offset: &'a Cell, - pub message_start: &'a Cell, -} - -impl<'a> StackReader<'a> { - pub fn mark_message_start(&self) { - self.message_start.set(self.offset.get()); - } - - pub fn set_offset_from_start(&self, offset: usize) { - self.offset.set(self.message_start.get() + offset); - } - - pub fn ensure_capacity(&self, length: usize) -> bool { - self.offset - .get() - .checked_add(length) - .is_some_and(|end| self.buffer.len() >= end) - } - - pub fn init( - buffer: &'a [u8], - offset: &'a Cell, - message_start: &'a Cell, - ) -> NewReader> { - NewReader { - wrapped: StackReader { - buffer, - offset, - message_start, - }, - } - } +pub use crate::shared::stack_reader::StackReader; - pub fn peek(&self) -> &'a [u8] { - &self.buffer[self.offset.get()..] - } - - pub fn skip(&self, count: isize) { - if count < 0 { - let abs_count = count.unsigned_abs(); - if abs_count > self.offset.get() { - self.offset.set(0); - return; - } - self.offset.set(self.offset.get() - abs_count); - return; - } - - let ucount: usize = usize::try_from(count).expect("int cast"); - if self.offset.get() + ucount > self.buffer.len() { - self.offset.set(self.buffer.len()); - return; - } - - self.offset.set(self.offset.get() + ucount); - } - - pub fn read(&self, count: usize) -> Result { - let offset = self.offset.get(); - if !self.ensure_capacity(count) { - return Err(AnyMySQLError::ShortRead); - } - - self.skip(isize::try_from(count).expect("int cast")); - Ok(Data::Temporary(bun_ptr::RawSlice::new( - &self.buffer[offset..self.offset.get()], - ))) - } - - pub fn read_z(&self) -> Result { - let remaining = self.peek(); - if let Some(zero) = strings::index_of_char(remaining, 0) { - let zero = zero as usize; - self.skip(isize::try_from(zero + 1).expect("int cast")); - return Ok(Data::Temporary(bun_ptr::RawSlice::new(&remaining[0..zero]))); - } +impl ShortRead for AnyMySQLError { + const SHORT_READ: Self = AnyMySQLError::ShortRead; +} - Err(AnyMySQLError::ShortRead) +impl<'a> WrapReader<'a> for NewReader> { + fn wrap(reader: StackReader<'a>) -> Self { + NewReader { wrapped: reader } } } impl<'a> ReaderContext for StackReader<'a> { fn mark_message_start(self) { - Self::mark_message_start(&self) + StackReader::mark_message_start(&self) } fn peek(&self) -> &[u8] { - Self::peek(self) + StackReader::peek(self) } fn skip(self, count: isize) { - Self::skip(&self, count) + StackReader::skip(&self, count) } fn ensure_capacity(self, count: usize) -> bool { - Self::ensure_capacity(&self, count) + StackReader::ensure_capacity(&self, count) } fn read(self, count: usize) -> Result { - Self::read(&self, count) + StackReader::read(&self, count) } fn read_z(self) -> Result { - Self::read_z(&self) + StackReader::read_z(&self) } fn set_offset_from_start(self, offset: usize) { - Self::set_offset_from_start(&self, offset) + StackReader::set_offset_from_start(&self, offset) } } diff --git a/src/sql/postgres/protocol/ErrorResponse.rs b/src/sql/postgres/protocol/ErrorResponse.rs index 0930a1601a76..3f7ac272cf23 100644 --- a/src/sql/postgres/protocol/ErrorResponse.rs +++ b/src/sql/postgres/protocol/ErrorResponse.rs @@ -41,6 +41,20 @@ impl ErrorResponse { ) -> Result { Self::decode_internal(NewReader { wrapped: context }) } + + /// `NoticeResponse` decode: a declared length below 4 decodes as an empty + /// notice instead of failing, unlike `ErrorResponse`. + pub fn decode_notice_internal( + mut reader: NewReader, + ) -> Result { + let remaining_bytes = reader.length()?.saturating_sub(4); + if remaining_bytes > 0 { + return Ok(Self { + messages: FieldMessage::decode_list::(reader)?, + }); + } + Ok(Self::default()) + } } // `to_js` lives on an extension trait in the `bun_sql_jsc` crate. diff --git a/src/sql/postgres/protocol/FieldMessage.rs b/src/sql/postgres/protocol/FieldMessage.rs index 47967a306abb..43fae887007b 100644 --- a/src/sql/postgres/protocol/FieldMessage.rs +++ b/src/sql/postgres/protocol/FieldMessage.rs @@ -29,6 +29,13 @@ pub enum FieldMessage { impl fmt::Display for FieldMessage { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.payload()) + } +} + +impl FieldMessage { + /// Every variant carries a single `bun.String` payload. + pub fn payload(&self) -> &String { match self { FieldMessage::Severity(s) | FieldMessage::LocalizedSeverity(s) @@ -47,12 +54,10 @@ impl fmt::Display for FieldMessage { | FieldMessage::Constraint(s) | FieldMessage::File(s) | FieldMessage::Line(s) - | FieldMessage::Routine(s) => write!(f, "{s}"), + | FieldMessage::Routine(s) => s, } } -} -impl FieldMessage { pub fn decode_list( mut reader: NewReader, ) -> Result, AnyPostgresError> { diff --git a/src/sql/postgres/protocol/NoticeResponse.rs b/src/sql/postgres/protocol/NoticeResponse.rs index 07fb345f33ed..6ba4dfb3bb3a 100644 --- a/src/sql/postgres/protocol/NoticeResponse.rs +++ b/src/sql/postgres/protocol/NoticeResponse.rs @@ -1,35 +1,5 @@ -use super::field_message::FieldMessage; -use super::new_reader::NewReader; -use crate::postgres::AnyPostgresError; - -#[derive(Default)] -pub struct NoticeResponse { - pub messages: Vec, -} - -// Vec drops each element (FieldMessage's Drop) and the buffer -// automatically, so no explicit Drop body is needed. - -impl NoticeResponse { - pub fn decode_internal( - mut reader: NewReader, - ) -> Result { - let mut remaining_bytes = reader.length()?; - remaining_bytes = remaining_bytes.saturating_sub(4); - - if remaining_bytes > 0 { - return Ok(Self { - messages: FieldMessage::decode_list::(reader)?, - }); - } - Ok(Self::default()) - } - - pub fn decode( - context: Container, - ) -> Result { - Self::decode_internal(NewReader { wrapped: context }) - } -} - -// `to_js` lives as an extension-trait method in the bun_sql_jsc crate. +/// NoticeResponse has the same wire format as ErrorResponse — a length-prefixed +/// list of field messages — so it reuses the same type. Notices decode via +/// `decode_notice_internal`, which tolerates a declared length below 4 +/// (decoding as empty) where `ErrorResponse` fails. +pub type NoticeResponse = crate::postgres::protocol::error_response::ErrorResponse; diff --git a/src/sql/postgres/protocol/StackReader.rs b/src/sql/postgres/protocol/StackReader.rs index 411f746af09e..9babc82d3e35 100644 --- a/src/sql/postgres/protocol/StackReader.rs +++ b/src/sql/postgres/protocol/StackReader.rs @@ -1,101 +1,39 @@ use crate::postgres::any_postgres_error::AnyPostgresError; use crate::postgres::protocol::new_reader::{NewReader, ReaderContext}; use crate::shared::data::Data; -use bun_core::strings; +use crate::shared::stack_reader::{ShortRead, WrapReader}; -pub struct StackReader<'a> { - pub buffer: &'a [u8], - pub offset: &'a mut usize, - pub message_start: &'a mut usize, -} - -impl<'a> StackReader<'a> { - pub fn mark_message_start(&mut self) { - *self.message_start = *self.offset; - } - - pub fn ensure_length(&self, length: usize) -> bool { - self.buffer.len() >= (*self.offset + length) - } - - pub fn init( - buffer: &'a [u8], - offset: &'a mut usize, - message_start: &'a mut usize, - ) -> NewReader> { - NewReader { - wrapped: StackReader { - buffer, - offset, - message_start, - }, - } - } - - pub fn peek(&self) -> &[u8] { - &self.buffer[*self.offset..] - } +pub use crate::shared::stack_reader::StackReader; - pub fn skip(&mut self, count: usize) { - if *self.offset + count > self.buffer.len() { - *self.offset = self.buffer.len(); - return; - } - - *self.offset += count; - } - - pub fn ensure_capacity(&self, count: usize) -> bool { - self.buffer.len() >= (*self.offset + count) - } - - pub fn read(&mut self, count: usize) -> Result { - let offset = *self.offset; - if !self.ensure_capacity(count) { - return Err(AnyPostgresError::ShortRead); - } - - self.skip(count); - // Copy the &'a [u8] out before slicing so the returned Data borrows 'a, - // not &mut self. - let buffer: &'a [u8] = self.buffer; - Ok(Data::Temporary(bun_ptr::RawSlice::new( - &buffer[offset..*self.offset], - ))) - } - - pub fn read_z(&mut self) -> Result { - // Inline `peek()` so `remaining` borrows 'a (via the Copy &'a [u8]) - // instead of &self, allowing `self.skip()` below. - let buffer: &'a [u8] = self.buffer; - let remaining = &buffer[*self.offset..]; - if let Some(zero) = strings::index_of_char(remaining, 0) { - let zero = zero as usize; - self.skip(zero + 1); - return Ok(Data::Temporary(bun_ptr::RawSlice::new(&remaining[0..zero]))); - } +impl ShortRead for AnyPostgresError { + const SHORT_READ: Self = AnyPostgresError::ShortRead; +} - Err(AnyPostgresError::ShortRead) +impl<'a> WrapReader<'a> for NewReader> { + fn wrap(reader: StackReader<'a>) -> Self { + NewReader { wrapped: reader } } } impl<'a> ReaderContext for StackReader<'a> { fn mark_message_start(&mut self) { - Self::mark_message_start(self) + StackReader::mark_message_start(self) } fn peek(&self) -> &[u8] { - Self::peek(self) + StackReader::peek(self) } fn skip(&mut self, count: usize) { - Self::skip(self, count) + // The shared reader's signed skip clamps to the buffer end, matching + // the old unsigned behavior even when `count` exceeds `isize::MAX`. + StackReader::skip(self, isize::try_from(count).unwrap_or(isize::MAX)) } fn ensure_length(&mut self, count: usize) -> bool { - Self::ensure_length(self, count) + StackReader::ensure_capacity(self, count) } fn read(&mut self, count: usize) -> Result { - Self::read(self, count) + StackReader::read(self, count) } fn read_z(&mut self) -> Result { - Self::read_z(self) + StackReader::read_z(self) } } diff --git a/src/sql/mysql/QueryStatus.rs b/src/sql/shared/QueryStatus.rs similarity index 100% rename from src/sql/mysql/QueryStatus.rs rename to src/sql/shared/QueryStatus.rs diff --git a/src/sql/shared/StackReader.rs b/src/sql/shared/StackReader.rs new file mode 100644 index 000000000000..1919622b9bc1 --- /dev/null +++ b/src/sql/shared/StackReader.rs @@ -0,0 +1,116 @@ +use core::cell::Cell; + +use bun_core::strings; + +use super::data::Data; + +/// Supplies the protocol error enum's "buffer exhausted" variant for +/// [`StackReader`]'s fallible reads. +pub trait ShortRead { + const SHORT_READ: Self; +} + +/// Wraps a [`StackReader`] in the protocol's reader type ([`StackReader::init`]). +pub trait WrapReader<'a>: Sized { + fn wrap(reader: StackReader<'a>) -> Self; +} + +/// Accepts either `&Cell` or `&mut usize` as a cursor slot in +/// [`StackReader::init`]. +pub trait IntoCursor<'a> { + fn into_cursor(self) -> &'a Cell; +} + +impl<'a> IntoCursor<'a> for &'a Cell { + fn into_cursor(self) -> &'a Cell { + self + } +} + +impl<'a> IntoCursor<'a> for &'a mut usize { + fn into_cursor(self) -> &'a Cell { + Cell::from_mut(self) + } +} + +/// Cursor over a borrowed wire buffer. `Cell`-based so copies share the +/// offset and callers can read the cursor back after a short read. +#[derive(Clone, Copy)] +pub struct StackReader<'a> { + pub buffer: &'a [u8], + pub offset: &'a Cell, + pub message_start: &'a Cell, +} + +impl<'a> StackReader<'a> { + pub fn init>( + buffer: &'a [u8], + offset: impl IntoCursor<'a>, + message_start: impl IntoCursor<'a>, + ) -> R { + R::wrap(StackReader { + buffer, + offset: offset.into_cursor(), + message_start: message_start.into_cursor(), + }) + } + + pub fn mark_message_start(&self) { + self.message_start.set(self.offset.get()); + } + + pub fn set_offset_from_start(&self, offset: usize) { + self.offset.set(self.message_start.get() + offset); + } + + pub fn ensure_capacity(&self, length: usize) -> bool { + self.offset + .get() + .checked_add(length) + .is_some_and(|end| self.buffer.len() >= end) + } + + pub fn peek(&self) -> &'a [u8] { + &self.buffer[self.offset.get()..] + } + + /// Clamps to `[0, buffer.len()]` in both directions. + pub fn skip(&self, count: isize) { + let offset = self.offset.get(); + if count < 0 { + self.offset.set(offset.saturating_sub(count.unsigned_abs())); + return; + } + + let ucount = count.unsigned_abs(); + if offset + ucount > self.buffer.len() { + self.offset.set(self.buffer.len()); + return; + } + + self.offset.set(offset + ucount); + } + + pub fn read(&self, count: usize) -> Result { + let offset = self.offset.get(); + if !self.ensure_capacity(count) { + return Err(E::SHORT_READ); + } + + self.offset.set(offset + count); + Ok(Data::Temporary(bun_ptr::RawSlice::new( + &self.buffer[offset..offset + count], + ))) + } + + pub fn read_z(&self) -> Result { + let remaining = self.peek(); + if let Some(zero) = strings::index_of_char(remaining, 0) { + let zero = zero as usize; + self.skip(isize::try_from(zero + 1).expect("int cast")); + return Ok(Data::Temporary(bun_ptr::RawSlice::new(&remaining[0..zero]))); + } + + Err(E::SHORT_READ) + } +} diff --git a/src/sql/shared/StatementStatus.rs b/src/sql/shared/StatementStatus.rs new file mode 100644 index 000000000000..bce4a693f311 --- /dev/null +++ b/src/sql/shared/StatementStatus.rs @@ -0,0 +1,13 @@ +#[derive(Copy, Clone, Eq, PartialEq)] +pub enum Status { + Pending, + Parsing, + Prepared, + Failed, +} + +impl Status { + pub fn is_running(self) -> bool { + self == Status::Parsing + } +} diff --git a/src/sql_jsc/lib.rs b/src/sql_jsc/lib.rs index 4d25a4a93b40..4a47a8066a87 100644 --- a/src/sql_jsc/lib.rs +++ b/src/sql_jsc/lib.rs @@ -20,6 +20,8 @@ pub mod shared { #[path = "CachedStructure.rs"] pub mod cached_structure; + pub mod connection_ctor_args; + pub mod datetime_text; #[path = "ObjectIterator.rs"] @@ -28,6 +30,8 @@ pub mod shared { #[path = "QueryBindingIterator.rs"] pub mod query_binding_iterator; + pub mod query_ctor_args; + #[path = "SQLDataCell.rs"] pub mod sql_data_cell; diff --git a/src/sql_jsc/mysql/JSMySQLConnection.rs b/src/sql_jsc/mysql/JSMySQLConnection.rs index 03063446695a..a16b70dafac9 100644 --- a/src/sql_jsc/mysql/JSMySQLConnection.rs +++ b/src/sql_jsc/mysql/JSMySQLConnection.rs @@ -4,11 +4,11 @@ use core::ffi::c_void; use crate::jsc::{ CallFrame, EventLoopSqlExt as _, EventLoopTimer, EventLoopTimerState, EventLoopTimerTag, GlobalRef, HasAutoFlush, JSGlobalObject, JSValue, JsCell, JsRef, JsResult, KeepAlive, - VirtualMachine, VirtualMachineSqlExt as _, api::server_config::SSLConfig, - codegen::js_mysql_connection as js, webcore::AutoFlusher, + VirtualMachine, VirtualMachineSqlExt as _, codegen::js_mysql_connection as js, + webcore::AutoFlusher, }; use crate::shared::CachedStructure; -use bun_boringssl_sys as boringssl; +use crate::shared::connection_ctor_args::{self, ConnectionCtorArgs}; use bun_core::strings; use bun_core::{TimespecMockMode, timespec}; use bun_ptr::{AsCtxPtr, BackRef, ParentRef}; @@ -470,75 +470,15 @@ impl JSMySQLConnection { // no other live borrow in this scope. let vm = global_object.bun_vm().as_mut(); let arguments = callframe.arguments(); - let hostname_str = bun_core::OwnedString::new(arguments[0].to_bun_string(global_object)?); - let port = arguments[1].coerce::(global_object)?; - - let username_str = bun_core::OwnedString::new(arguments[2].to_bun_string(global_object)?); - let password_str = bun_core::OwnedString::new(arguments[3].to_bun_string(global_object)?); - let database_str = bun_core::OwnedString::new(arguments[4].to_bun_string(global_object)?); - // TODO: update this to match MySQL. - let ssl_mode: SSLMode = match arguments[5].to_int32() { - 0 => SSLMode::Disable, - 1 => SSLMode::Prefer, - 2 => SSLMode::Require, - 3 => SSLMode::VerifyCa, - 4 => SSLMode::VerifyFull, - _ => SSLMode::Disable, + let Some(args) = ConnectionCtorArgs::::parse(global_object, &mut *vm, arguments)? + else { + return Ok(JSValue::ZERO); }; - - let tls_object = arguments[6]; - - let mut tls_config: SSLConfig = SSLConfig::default(); - let mut secure: Option<*mut uws::SslCtx> = None; - if ssl_mode != SSLMode::Disable { - tls_config = if tls_object.is_boolean() && tls_object.to_boolean() { - SSLConfig::default() - } else if tls_object.is_object() { - match SSLConfig::from_js(&mut *vm, global_object, tls_object) { - Ok(Some(c)) => c, - Ok(None) => SSLConfig::default(), - Err(_) => return Ok(JSValue::ZERO), - } - } else { - return Err(global_object - .throw_invalid_arguments(format_args!("tls must be a boolean or an object"))); - }; - - if global_object.has_exception() { - drop(tls_config); - return Ok(JSValue::ZERO); - } - - // We always request the cert so we can verify it and also we manually - // abort the connection if the hostname doesn't match. Built here so - // CA/cert errors throw synchronously, applied later by upgradeToTLS. - // Goes through the per-VM weak `SSLContextCache` so every pooled - // connection / reconnect shares one `SSL_CTX*` per distinct config. - let mut err = uws::create_bun_socket_error_t::none; - secure = vm - .ssl_ctx_cache() - .get_or_create_opts(&tls_config.as_usockets_for_client_verification(), &mut err); - if secure.is_none() { - drop(tls_config); - return Err( - global_object.throw_value(crate::jsc::create_bun_socket_error_to_js( - err, - global_object, - )), - ); - } - } // Covers `try arguments[7/8].toBunString()` and the null-byte rejection // below. Ownership passes to `MySQLConnection.init` once `Box::new` // succeeds — we null the locals at that point so the connect-fail path // (which `deref()`s the connection) doesn't double-free. - let tls_guard = scopeguard::guard((secure, tls_config), |(s, cfg)| { - if let Some(s) = s { - // SAFETY: secure was created by ssl_ctx_cache; we own one ref until transferred. - unsafe { boringssl::SSL_CTX_free(s) }; - } - drop(cfg); - }); + let tls_guard = connection_ctor_args::guard_tls(args.secure, args.tls_config); let options_str = bun_core::OwnedString::new(arguments[7].to_bun_string(global_object)?); let path_str = bun_core::OwnedString::new(arguments[8].to_bun_string(global_object)?); @@ -546,9 +486,9 @@ impl JSMySQLConnection { // `init` takes `Box<[u8]>` per field (each separately owned), so we // copy each string into its own allocation. `options_buf` becomes an // empty box. - let username: Box<[u8]> = Box::from(username_str.to_utf8_without_ref().slice()); - let password: Box<[u8]> = Box::from(password_str.to_utf8_without_ref().slice()); - let database: Box<[u8]> = Box::from(database_str.to_utf8_without_ref().slice()); + let username: Box<[u8]> = Box::from(args.username_str.to_utf8_without_ref().slice()); + let password: Box<[u8]> = Box::from(args.password_str.to_utf8_without_ref().slice()); + let database: Box<[u8]> = Box::from(args.database_str.to_utf8_without_ref().slice()); let options: Box<[u8]> = Box::from(options_str.to_utf8_without_ref().slice()); let path: Box<[u8]> = Box::from(path_str.to_utf8_without_ref().slice()); let options_buf: Box<[u8]> = Box::default(); @@ -595,7 +535,7 @@ impl JSMySQLConnection { options_buf, tls_config, secure, - ssl_mode, + args.ssl_mode, allow_public_key_retrieval, )), auto_flusher: JsCell::new(AutoFlusher::default()), @@ -616,7 +556,7 @@ impl JSMySQLConnection { let this = ParentRef::from(core::ptr::NonNull::new(ptr).expect("heap::into_raw non-null")); { - let hostname = hostname_str.to_utf8(); + let hostname = args.hostname_str.to_utf8(); // MySQL always opens plain TCP first; STARTTLS adopts into the TLS // group after the SSLRequest exchange. @@ -636,7 +576,7 @@ impl JSMySQLConnection { uws::DispatchKind::Mysql, None, hostname.slice(), - port, + args.port, ptr, false, ) diff --git a/src/sql_jsc/mysql/JSMySQLQuery.rs b/src/sql_jsc/mysql/JSMySQLQuery.rs index efd430326452..bfdb3025ea48 100644 --- a/src/sql_jsc/mysql/JSMySQLQuery.rs +++ b/src/sql_jsc/mysql/JSMySQLQuery.rs @@ -6,6 +6,7 @@ use crate::jsc::{ self as jsc, CallFrame, JSGlobalObject, JSGlobalObjectSqlExt as _, JSValue, JsRef, JsResult, VirtualMachine, VirtualMachineSqlExt as _, }; +use crate::shared::query_ctor_args::QueryCtorArgs; use bun_jsc::JsCell; use bun_ptr::{AsCtxPtr, BackRef, ParentRef}; use bun_sql::mysql::MySQLQueryResult; @@ -95,43 +96,14 @@ impl JSMySQLQuery { global_this: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { - let arguments = callframe.arguments(); - let mut args = jsc::call_frame::ArgumentsSlice::init(global_this.sql_vm(), arguments); - // defer args.deinit() — handled by Drop - let Some(query) = args.next_eat() else { - return Err(global_this.throw(format_args!("query must be a string"))); - }; - let Some(values) = args.next_eat() else { - return Err(global_this.throw(format_args!("values must be an array"))); - }; - - if !query.is_string() { - return Err(global_this.throw(format_args!("query must be a string"))); - } - - if values.js_type() != jsc::JSType::Array { - return Err(global_this.throw(format_args!("values must be an array"))); - } - - let pending_value: JSValue = args.next_eat().unwrap_or(JSValue::UNDEFINED); - let columns: JSValue = args.next_eat().unwrap_or(JSValue::UNDEFINED); - let js_bigint: JSValue = args.next_eat().unwrap_or(JSValue::FALSE); - let js_simple: JSValue = args.next_eat().unwrap_or(JSValue::FALSE); - - let bigint = js_bigint.is_boolean() && js_bigint.as_boolean(); - let simple = js_simple.is_boolean() && js_simple.as_boolean(); - if simple { - if values.get_length(global_this)? > 0 { - return Err(global_this - .throw_invalid_arguments(format_args!("simple query cannot have parameters"))); - } - if query.get_length(global_this)? >= i32::MAX as u64 { - return Err(global_this.throw_invalid_arguments(format_args!("query is too long"))); - } - } - if !pending_value.js_type().is_array_like() { - return Err(global_this.throw_invalid_argument_type("query", "pendingValue", "Array")); - } + let QueryCtorArgs { + query, + values, + pending_value, + columns, + bigint, + simple, + } = QueryCtorArgs::parse(global_this, callframe.arguments())?; let this_ptr = bun_core::heap::into_raw(Box::new(Self { this_value: JsCell::new(JsRef::empty()), diff --git a/src/sql_jsc/mysql/MySQLStatement.rs b/src/sql_jsc/mysql/MySQLStatement.rs index ea0756d50016..95914708d6c3 100644 --- a/src/sql_jsc/mysql/MySQLStatement.rs +++ b/src/sql_jsc/mysql/MySQLStatement.rs @@ -1,15 +1,13 @@ use core::cell::Cell; use crate::jsc::{JSGlobalObject, JSValue}; -use bun_collections::StringHashMap; use crate::mysql::protocol::Signature; use crate::shared::CachedStructure; -use crate::shared::sql_data_cell::Flags as DataCellFlags; +use crate::shared::sql_data_cell::{Flags as DataCellFlags, dedupe_columns}; use bun_sql::mysql::protocol::column_definition41::ColumnDefinition41; use bun_sql::mysql::protocol::error_packet::ErrorPacket; -use bun_sql::shared::ColumnIdentifier; pub use bun_sql::mysql::mysql_param::Param; @@ -88,13 +86,7 @@ impl Default for ExecutionFlags { } } -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum Status { - Pending, - Parsing, - Prepared, - Failed, -} +pub use bun_sql::shared::statement_status::Status; impl MySQLStatement { /// Set the initial intrusive @@ -124,49 +116,8 @@ impl MySQLStatement { self.execution_flags .remove(ExecutionFlags::NEEDS_DUPLICATE_CHECK); - let mut seen_numbers: Vec = Vec::new(); - let mut seen_fields: StringHashMap<()> = StringHashMap::default(); - seen_fields.reserve(self.columns.len()); - - // iterate backwards - let mut remaining = self.columns.len(); - let mut flags = DataCellFlags::default(); - while remaining > 0 { - remaining -= 1; - let field: &mut ColumnDefinition41 = &mut self.columns[remaining]; - match &field.name_or_index { - ColumnIdentifier::Name(name) => { - // reshaped for borrowck — compute `found_existing` before - // mutating `field.name_or_index`. - let found_existing = seen_fields - .get_or_put(name.slice()) - .expect("OOM") - .found_existing; - if found_existing { - field.name_or_index = ColumnIdentifier::Duplicate; - flags.insert(DataCellFlags::HAS_DUPLICATE_COLUMNS); - } - - flags.insert(DataCellFlags::HAS_NAMED_COLUMNS); - } - ColumnIdentifier::Index(index) => { - let index = *index; - if seen_numbers.contains(&index) { - field.name_or_index = ColumnIdentifier::Duplicate; - flags.insert(DataCellFlags::HAS_DUPLICATE_COLUMNS); - } else { - seen_numbers.push(index); - } - - flags.insert(DataCellFlags::HAS_INDEXED_COLUMNS); - } - ColumnIdentifier::Duplicate => { - flags.insert(DataCellFlags::HAS_DUPLICATE_COLUMNS); - } - } - } - - self.fields_flags = flags; + self.fields_flags = + dedupe_columns(self.columns.iter_mut().rev().map(|c| &mut c.name_or_index)); } // Returning `&CachedStructure` diff --git a/src/sql_jsc/postgres.rs b/src/sql_jsc/postgres.rs index b3ef8eec8059..eb20c6d831d0 100644 --- a/src/sql_jsc/postgres.rs +++ b/src/sql_jsc/postgres.rs @@ -85,9 +85,6 @@ pub mod types { pub mod protocol { #[path = "error_response_jsc.rs"] pub mod error_response_jsc; - - #[path = "notice_response_jsc.rs"] - pub mod notice_response_jsc; } // Re-exports of base-crate protocol/types modules. diff --git a/src/sql_jsc/postgres/PostgresSQLConnection.rs b/src/sql_jsc/postgres/PostgresSQLConnection.rs index eee26fd2279a..dfe570124b44 100644 --- a/src/sql_jsc/postgres/PostgresSQLConnection.rs +++ b/src/sql_jsc/postgres/PostgresSQLConnection.rs @@ -31,6 +31,7 @@ use crate::postgres::postgres_sql_query::{self, Status as QueryStatus}; use crate::postgres::postgres_sql_statement::{Error as StatementError, Status as StatementStatus}; use crate::postgres::sasl::SASLStatus; use crate::shared::CachedStructure as PostgresCachedStructure; +use crate::shared::connection_ctor_args::{self, ConnectionCtorArgs}; use bun_sql::postgres::AnyPostgresError; use bun_sql::postgres::PostgresErrorOptions; use bun_sql::postgres::PostgresProtocol as protocol; @@ -1078,77 +1079,15 @@ pub(crate) fn call(global_object: &JSGlobalObject, callframe: &CallFrame) -> JsR // `&mut self` helpers like `ssl_ctx_cache()` / `postgres_socket_group()`. let vm = global_object.bun_vm().as_mut(); let arguments = callframe.arguments(); - let hostname_str = bun_core::OwnedString::new(arguments[0].to_bun_string(global_object)?); - let port = arguments[1].coerce::(global_object)?; - - let username_str = bun_core::OwnedString::new(arguments[2].to_bun_string(global_object)?); - let password_str = bun_core::OwnedString::new(arguments[3].to_bun_string(global_object)?); - let database_str = bun_core::OwnedString::new(arguments[4].to_bun_string(global_object)?); - let ssl_mode: SSLMode = match arguments[5].to_int32() { - 0 => SSLMode::Disable, - 1 => SSLMode::Prefer, - 2 => SSLMode::Require, - 3 => SSLMode::VerifyCa, - 4 => SSLMode::VerifyFull, - _ => SSLMode::Disable, + let Some(args) = ConnectionCtorArgs::::parse(global_object, &mut *vm, arguments)? + else { + return Ok(JSValue::ZERO); }; - - let tls_object = arguments[6]; - - let mut tls_config: jsc::api::ServerConfig::SSLConfig = Default::default(); - let mut secure: Option<*mut uws::SslCtx> = None; - if ssl_mode != SSLMode::Disable { - tls_config = if tls_object.is_boolean() && tls_object.to_boolean() { - Default::default() - } else if tls_object.is_object() { - match jsc::api::ServerConfig::SSLConfig::from_js(&mut *vm, global_object, tls_object) { - Ok(opt) => opt.unwrap_or_default(), - Err(_) => return Ok(JSValue::ZERO), - } - } else { - return Err(global_object - .throw_invalid_arguments(format_args!("tls must be a boolean or an object"))); - }; - - if global_object.has_exception() { - drop(tls_config); - return Ok(JSValue::ZERO); - } - - // We always request the cert so we can verify it and also we manually - // abort the connection if the hostname doesn't match. Built here (not - // at STARTTLS time) so cert/CA errors throw synchronously. Goes - // through the per-VM weak `SSLContextCache` so every connection in the - // pool — and every reconnect — shares one `SSL_CTX*` per distinct - // config instead of building a fresh one per `PostgresSQLConnection`. - let mut err: uws::create_bun_socket_error_t = uws::create_bun_socket_error_t::none; - secure = vm - .ssl_ctx_cache() - .get_or_create_opts(&tls_config.as_usockets_for_client_verification(), &mut err); - if secure.is_none() { - drop(tls_config); - return Err( - global_object.throw_value(crate::jsc::create_bun_socket_error_to_js( - err, - global_object, - )), - ); - } - } // Covers `try arguments[7/8].toBunString()` and the null-byte rejection // below. Ownership passes into `ptr.*` once allocated — `into_inner` // recovers them just before the Box is built so the connect-fail path's // `ptr.deinit()` is the sole cleanup. - // guard owns `(secure, tls_config)` by value. Do NOT - // `drop_in_place` a stack local that Rust would also auto-drop on unwind — - // that double-frees. The closure's `_tls_config` is dropped exactly once by - // normal scope-exit drop here. - let errdefer_guard = scopeguard::guard((secure, tls_config), |(secure, _tls_config)| { - if let Some(s) = secure { - // SAFETY: SSL_CTX_free is safe to call on a valid SSL_CTX*. - unsafe { BoringSSL::c::SSL_CTX_free(s) }; - } - }); + let errdefer_guard = connection_ctor_args::guard_tls(args.secure, args.tls_config); // `StringBuilder::append` takes `&mut self` and returns a borrow // of the backing buffer, so successive appends can't keep their `&[u8]` @@ -1168,11 +1107,11 @@ pub(crate) fn call(global_object: &JSGlobalObject, callframe: &CallFrame) -> JsR let options_buf: Box<[u8]> = 'brk: { let mut b = bun_core::StringBuilder::default(); - b.cap += username_str.utf8_byte_length() + b.cap += args.username_str.utf8_byte_length() + 1 - + password_str.utf8_byte_length() + + args.password_str.utf8_byte_length() + 1 - + database_str.utf8_byte_length() + + args.database_str.utf8_byte_length() + 1 + options_str.utf8_byte_length() + 1 @@ -1180,15 +1119,15 @@ pub(crate) fn call(global_object: &JSGlobalObject, callframe: &CallFrame) -> JsR + 1; let _ = b.allocate(); - let u = username_str.to_utf8_without_ref(); + let u = args.username_str.to_utf8_without_ref(); username = bun_ptr::RawSlice::new(b.append(u.slice())); drop(u); - let p = password_str.to_utf8_without_ref(); + let p = args.password_str.to_utf8_without_ref(); password = bun_ptr::RawSlice::new(b.append(p.slice())); drop(p); - let d = database_str.to_utf8_without_ref(); + let d = args.database_str.to_utf8_without_ref(); database = bun_ptr::RawSlice::new(b.append(d.slice())); drop(d); @@ -1269,12 +1208,12 @@ pub(crate) fn call(global_object: &JSGlobalObject, callframe: &CallFrame) -> JsR authentication_state: JsCell::new(AuthenticationState::Pending), secure, tls_config, - tls_status: Cell::new(if ssl_mode != SSLMode::Disable { + tls_status: Cell::new(if args.ssl_mode != SSLMode::Disable { TLSStatus::Pending } else { TLSStatus::None }), - ssl_mode, + ssl_mode: args.ssl_mode, idle_timeout_interval_ms: u32::try_from(idle_timeout).expect("int cast"), connection_timeout_ms: u32::try_from(connection_timeout).expect("int cast"), flags: Cell::new(if use_unnamed_prepared_statements { @@ -1298,7 +1237,7 @@ pub(crate) fn call(global_object: &JSGlobalObject, callframe: &CallFrame) -> JsR let this = ParentRef::from(core::ptr::NonNull::new(ptr).expect("heap::into_raw non-null")); { - let hostname = hostname_str.to_utf8(); + let hostname = args.hostname_str.to_utf8(); // Postgres always opens plain TCP first (SSLRequest happens in-band), // so even `ssl_mode != .disable` lands in the TCP group; `setupTLS()` @@ -1320,7 +1259,7 @@ pub(crate) fn call(global_object: &JSGlobalObject, callframe: &CallFrame) -> JsR uws::SocketKind::Postgres, None, hostname.slice(), - port, + args.port, ptr, false, ) @@ -3013,7 +2952,7 @@ impl PostgresSQLConnection { } MessageType::NoticeResponse => { debug!("UNSUPPORTED NoticeResponse"); - let _resp = protocol::NoticeResponse::decode_internal(reader.reborrow())?; + let _resp = protocol::NoticeResponse::decode_notice_internal(reader.reborrow())?; // _resp dropped at scope end } MessageType::NotificationResponse => { diff --git a/src/sql_jsc/postgres/PostgresSQLQuery.rs b/src/sql_jsc/postgres/PostgresSQLQuery.rs index ac655840ca96..c2c3b45768b6 100644 --- a/src/sql_jsc/postgres/PostgresSQLQuery.rs +++ b/src/sql_jsc/postgres/PostgresSQLQuery.rs @@ -4,6 +4,7 @@ use core::mem; use crate::jsc::{ CallFrame, JSGlobalObject, JSValue, JsError, JsRef, JsResult, VirtualMachineSqlExt as _, }; +use crate::shared::query_ctor_args::QueryCtorArgs; use bun_core::String as BunString; use bun_jsc::JsCell; use bun_ptr::AsCtxPtr; @@ -107,28 +108,7 @@ impl Default for Flags { } } -#[repr(u8)] -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum Status { - /// The query was just enqueued, statement status can be checked for more details - Pending, - /// The query is being bound to the statement - Binding, - /// The query is running - Running, - /// The query is waiting for a partial response - PartialResponse, - /// The query was successful - Success, - /// The query failed - Fail, -} - -impl Status { - pub fn is_running(self) -> bool { - (self as u8) > (Status::Pending as u8) && (self as u8) < (Status::Success as u8) - } -} +pub use bun_sql::shared::query_status::Status; impl PostgresSQLQuery { // `ref_()`/`deref()` provided by `#[derive(CellRefCounted)]`. @@ -382,44 +362,14 @@ impl PostgresSQLQuery { // Registered directly as `createQuery` via // `put_host_functions!` in `postgres.rs`, so no exported symbol is needed. pub fn call(global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult { - let arguments = callframe.arguments(); - let mut args = - crate::jsc::call_frame::ArgumentsSlice::init(global_this.bun_vm(), arguments); - // ArgumentsSlice has Drop. - let Some(query) = args.next_eat() else { - return Err(global_this.throw(format_args!("query must be a string"))); - }; - let Some(values) = args.next_eat() else { - return Err(global_this.throw(format_args!("values must be an array"))); - }; - - if !query.is_string() { - return Err(global_this.throw(format_args!("query must be a string"))); - } - - if values.js_type() != crate::jsc::JSType::Array { - return Err(global_this.throw(format_args!("values must be an array"))); - } - - let pending_value: JSValue = args.next_eat().unwrap_or(JSValue::UNDEFINED); - let columns: JSValue = args.next_eat().unwrap_or(JSValue::UNDEFINED); - let js_bigint: JSValue = args.next_eat().unwrap_or(JSValue::FALSE); - let js_simple: JSValue = args.next_eat().unwrap_or(JSValue::FALSE); - - let bigint = js_bigint.is_boolean() && js_bigint.as_boolean(); - let simple = js_simple.is_boolean() && js_simple.as_boolean(); - if simple { - if values.get_length(global_this)? > 0 { - return Err(global_this - .throw_invalid_arguments(format_args!("simple query cannot have parameters"))); - } - if query.get_length(global_this)? >= i32::MAX as u64 { - return Err(global_this.throw_invalid_arguments(format_args!("query is too long"))); - } - } - if !pending_value.js_type().is_array_like() { - return Err(global_this.throw_invalid_argument_type("query", "pendingValue", "Array")); - } + let QueryCtorArgs { + query, + values, + pending_value, + columns, + bigint, + simple, + } = QueryCtorArgs::parse(global_this, callframe.arguments())?; let ptr = bun_core::heap::into_raw(Box::new(PostgresSQLQuery::default())); diff --git a/src/sql_jsc/postgres/PostgresSQLStatement.rs b/src/sql_jsc/postgres/PostgresSQLStatement.rs index 3dafeec82453..a2c4647baba2 100644 --- a/src/sql_jsc/postgres/PostgresSQLStatement.rs +++ b/src/sql_jsc/postgres/PostgresSQLStatement.rs @@ -1,17 +1,15 @@ use core::cell::Cell; use crate::jsc::{JSGlobalObject, JSValue, JsResult}; -use bun_collections::StringHashMap; use crate::postgres::error_jsc::postgres_error_to_js; use crate::postgres::signature::Signature; use crate::shared::cached_structure::CachedStructure as PostgresCachedStructure; -use crate::shared::sql_data_cell::Flags as DataCellFlags; +use crate::shared::sql_data_cell::{Flags as DataCellFlags, dedupe_columns}; use bun_sql::postgres::any_postgres_error::AnyPostgresError; use bun_sql::postgres::postgres_protocol as protocol; use bun_sql::postgres::postgres_types::int4; -use bun_sql::shared::ColumnIdentifier; bun_core::declare_scope!(Postgres, visible); @@ -71,19 +69,7 @@ impl Error { } } -#[derive(Copy, Clone, Eq, PartialEq)] -pub enum Status { - Pending, - Parsing, - Prepared, - Failed, -} - -impl Status { - pub fn is_running(self) -> bool { - self == Status::Parsing - } -} +pub use bun_sql::shared::statement_status::Status; impl PostgresSQLStatement { /// Set the initial intrusive @@ -103,52 +89,8 @@ impl PostgresSQLStatement { } self.needs_duplicate_check = false; - let mut seen_numbers: Vec = Vec::new(); - let mut seen_fields: StringHashMap<()> = StringHashMap::default(); - seen_fields.reserve(self.fields.len()); - - // iterate backwards - let mut remaining = self.fields.len(); - let mut flags = DataCellFlags::default(); - while remaining > 0 { - remaining -= 1; - let field: &mut protocol::FieldDescription = &mut self.fields[remaining]; - match &field.name_or_index { - ColumnIdentifier::Name(name) => { - // Note: reshaped for borrowck — compute `found_existing` - // before mutating `field.name_or_index`. - // StringHashMap - // clones to an owned `Box<[u8]>` key. Fine for a transient - // dedup set. - let found_existing = seen_fields - .get_or_put(name.slice()) - .expect("OOM") - .found_existing; - if found_existing { - field.name_or_index = ColumnIdentifier::Duplicate; - flags.insert(DataCellFlags::HAS_DUPLICATE_COLUMNS); - } - - flags.insert(DataCellFlags::HAS_NAMED_COLUMNS); - } - ColumnIdentifier::Index(index) => { - let index = *index; - if seen_numbers.contains(&index) { - field.name_or_index = ColumnIdentifier::Duplicate; - flags.insert(DataCellFlags::HAS_DUPLICATE_COLUMNS); - } else { - seen_numbers.push(index); - } - - flags.insert(DataCellFlags::HAS_INDEXED_COLUMNS); - } - ColumnIdentifier::Duplicate => { - flags.insert(DataCellFlags::HAS_DUPLICATE_COLUMNS); - } - } - } - - self.fields_flags = flags; + self.fields_flags = + dedupe_columns(self.fields.iter_mut().rev().map(|f| &mut f.name_or_index)); } // Note: returning diff --git a/src/sql_jsc/postgres/protocol/error_response_jsc.rs b/src/sql_jsc/postgres/protocol/error_response_jsc.rs index bedf0fd59e6c..2813335ed897 100644 --- a/src/sql_jsc/postgres/protocol/error_response_jsc.rs +++ b/src/sql_jsc/postgres/protocol/error_response_jsc.rs @@ -7,15 +7,11 @@ use bun_sql::postgres::protocol::field_message::FieldMessage; use crate::postgres::error_jsc::create_postgres_error; use bun_sql::postgres::any_postgres_error::PostgresErrorOptions; -use super::notice_response_jsc::field_message_payload; - pub(crate) fn to_js(this: &ErrorResponse, global_object: &JSGlobalObject) -> JSValue { let mut b = StringBuilder::default(); for msg in this.messages.iter() { - // Every - // FieldMessage variant carries a single bun.String payload. - b.cap += field_message_payload(msg).utf8_byte_length() + 1; + b.cap += msg.payload().utf8_byte_length() + 1; } let _ = b.allocate(); diff --git a/src/sql_jsc/postgres/protocol/notice_response_jsc.rs b/src/sql_jsc/postgres/protocol/notice_response_jsc.rs deleted file mode 100644 index 6aa8cbf31e30..000000000000 --- a/src/sql_jsc/postgres/protocol/notice_response_jsc.rs +++ /dev/null @@ -1,28 +0,0 @@ -use bun_sql::postgres::protocol::field_message::FieldMessage; - -/// Every `FieldMessage` variant -/// carries a single `bun.String` payload, so an exhaustive match collapses to -/// the single binding. The match lives here (not as a `payload()` accessor on -/// `bun_sql::FieldMessage`) because this is its only consumer. -pub(crate) fn field_message_payload(msg: &FieldMessage) -> &bun_core::String { - match msg { - FieldMessage::Severity(s) - | FieldMessage::LocalizedSeverity(s) - | FieldMessage::Code(s) - | FieldMessage::Message(s) - | FieldMessage::Detail(s) - | FieldMessage::Hint(s) - | FieldMessage::Position(s) - | FieldMessage::InternalPosition(s) - | FieldMessage::Internal(s) - | FieldMessage::Where(s) - | FieldMessage::Schema(s) - | FieldMessage::Table(s) - | FieldMessage::Column(s) - | FieldMessage::Datatype(s) - | FieldMessage::Constraint(s) - | FieldMessage::File(s) - | FieldMessage::Line(s) - | FieldMessage::Routine(s) => s, - } -} diff --git a/src/sql_jsc/shared/SQLDataCell.rs b/src/sql_jsc/shared/SQLDataCell.rs index dd84b7104854..225f3244ec0b 100644 --- a/src/sql_jsc/shared/SQLDataCell.rs +++ b/src/sql_jsc/shared/SQLDataCell.rs @@ -2,8 +2,10 @@ use core::ptr; use core::slice; use crate::jsc::{ExternColumnIdentifier, JSGlobalObject, JSType, JSValue, JsError, JsResult}; +use bun_collections::StringHashMap; +use bun_core::UnwrapOrOom as _; use bun_core::wtf::WTFStringImpl; -use bun_sql::shared::Data; +use bun_sql::shared::{ColumnIdentifier, Data}; // Note: This entire type is passed by pointer // across FFI to C++ (`JSC__constructObjectFromDataCell`). Field layout is @@ -381,6 +383,55 @@ bitflags::bitflags! { } } +/// Rewrites repeated column identifiers to [`ColumnIdentifier::Duplicate`] and +/// accumulates the column-set [`Flags`]. Callers pass the columns in reverse +/// order so the LAST occurrence of a repeated name/index keeps its identifier. +pub fn dedupe_columns<'a>( + columns: impl ExactSizeIterator, +) -> Flags { + let mut seen_numbers: Vec = Vec::new(); + // StringHashMap clones to an owned `Box<[u8]>` key. Fine for a transient + // dedup set. + let mut seen_fields: StringHashMap<()> = StringHashMap::default(); + seen_fields.reserve(columns.len()); + + let mut flags = Flags::default(); + for name_or_index in columns { + match &*name_or_index { + ColumnIdentifier::Name(name) => { + // reshaped for borrowck — compute `found_existing` before + // mutating `*name_or_index`. + let found_existing = seen_fields + .get_or_put(name.slice()) + .unwrap_or_oom() + .found_existing; + if found_existing { + *name_or_index = ColumnIdentifier::Duplicate; + flags.insert(Flags::HAS_DUPLICATE_COLUMNS); + } + + flags.insert(Flags::HAS_NAMED_COLUMNS); + } + ColumnIdentifier::Index(index) => { + let index = *index; + if seen_numbers.contains(&index) { + *name_or_index = ColumnIdentifier::Duplicate; + flags.insert(Flags::HAS_DUPLICATE_COLUMNS); + } else { + seen_numbers.push(index); + } + + flags.insert(Flags::HAS_INDEXED_COLUMNS); + } + ColumnIdentifier::Duplicate => { + flags.insert(Flags::HAS_DUPLICATE_COLUMNS); + } + } + } + + flags +} + // Declared inline rather than in a dedicated `*_sys` crate: this is the only // extern this crate calls and its sole consumer is the wrapper above. unsafe extern "C" { diff --git a/src/sql_jsc/shared/connection_ctor_args.rs b/src/sql_jsc/shared/connection_ctor_args.rs new file mode 100644 index 000000000000..248234002ccc --- /dev/null +++ b/src/sql_jsc/shared/connection_ctor_args.rs @@ -0,0 +1,136 @@ +//! Shared connection-constructor prologue for the Postgres and MySQL +//! `createConnection(hostname, port, username, password, database, sslMode, +//! tls, ...)` host functions, through the per-VM `SSL_CTX*` cache lookup. + +use crate::jsc::{ + JSGlobalObject, JSValue, JsResult, VirtualMachine, VirtualMachineSqlExt as _, + api::server_config::SSLConfig, +}; +use bun_uws as uws; + +pub(crate) trait SslModeArg: Copy + PartialEq { + /// Wire order of the JS-side enum; index 0 is `Disable`. + const MODES: [Self; 5]; +} + +macro_rules! impl_ssl_mode_arg { + ($ty:ty) => { + impl SslModeArg for $ty { + const MODES: [Self; 5] = [ + Self::Disable, + Self::Prefer, + Self::Require, + Self::VerifyCa, + Self::VerifyFull, + ]; + } + }; +} +// Both drivers use the same five postgres-shaped modes: the JS side +// (`normalizeSSLMode` in src/js/internal/sql/shared.ts) normalizes each +// driver's accepted ssl-mode spellings to this one wire enum, so MySQL's +// native ssl-mode vocabulary never crosses this boundary. +impl_ssl_mode_arg!(bun_sql::mysql::ssl_mode::SSLMode); +impl_ssl_mode_arg!(bun_sql::postgres::SSLMode); + +type GuardState = (Option<*mut uws::SslCtx>, SSLConfig); +pub(crate) type TlsGuard = scopeguard::ScopeGuard; + +/// Errdefer over `(secure, tls_config)`: frees the cached `SSL_CTX*` +/// reference and drops the config unless disarmed via +/// `ScopeGuard::into_inner` once ownership transfers into the connection. +pub(crate) fn guard_tls(secure: Option<*mut uws::SslCtx>, tls_config: SSLConfig) -> TlsGuard { + fn free((secure, _tls_config): GuardState) { + if let Some(s) = secure { + // SAFETY: `secure` holds one `ssl_ctx_cache` reference owned by the caller. + unsafe { bun_boringssl_sys::SSL_CTX_free(s) }; + } + } + scopeguard::guard((secure, tls_config), free as fn(GuardState)) +} + +pub(crate) struct ConnectionCtorArgs { + pub hostname_str: bun_core::OwnedString, + pub port: i32, + pub username_str: bun_core::OwnedString, + pub password_str: bun_core::OwnedString, + pub database_str: bun_core::OwnedString, + pub ssl_mode: M, + pub tls_config: SSLConfig, + /// `SSL_CTX*` holding one reference the caller must release on every + /// early exit (via [`guard_tls`]) until it transfers into the connection. + pub secure: Option<*mut uws::SslCtx>, +} + +impl ConnectionCtorArgs { + /// Parses `arguments[0..=6]`. Returns `Ok(None)` when a JS exception is + /// already pending and the caller should `return Ok(JSValue::ZERO)`. + pub(crate) fn parse( + global_object: &JSGlobalObject, + vm: &mut VirtualMachine, + arguments: &[JSValue], + ) -> JsResult> { + let hostname_str = bun_core::OwnedString::new(arguments[0].to_bun_string(global_object)?); + let port = arguments[1].coerce::(global_object)?; + let username_str = bun_core::OwnedString::new(arguments[2].to_bun_string(global_object)?); + let password_str = bun_core::OwnedString::new(arguments[3].to_bun_string(global_object)?); + let database_str = bun_core::OwnedString::new(arguments[4].to_bun_string(global_object)?); + let modes = M::MODES; + let ssl_mode = usize::try_from(arguments[5].to_int32()) + .ok() + .and_then(|i| modes.get(i)) + .copied() + .unwrap_or(modes[0]); + + let tls_object = arguments[6]; + let mut tls_config = SSLConfig::default(); + let mut secure: Option<*mut uws::SslCtx> = None; + if ssl_mode != modes[0] { + tls_config = if tls_object.is_boolean() && tls_object.to_boolean() { + SSLConfig::default() + } else if tls_object.is_object() { + match SSLConfig::from_js(&mut *vm, global_object, tls_object) { + Ok(opt) => opt.unwrap_or_default(), + Err(_) => return Ok(None), + } + } else { + return Err(global_object + .throw_invalid_arguments(format_args!("tls must be a boolean or an object"))); + }; + + if global_object.has_exception() { + return Ok(None); + } + + // We always request the cert so we can verify it and manually + // abort if the hostname doesn't match. Built here (not at STARTTLS + // time) so cert/CA errors throw synchronously; the per-VM weak + // `SSLContextCache` shares one `SSL_CTX*` per distinct config + // across pooled connections and reconnects. + let mut err = uws::create_bun_socket_error_t::none; + secure = vm + .ssl_ctx_cache() + .get_or_create_opts(&tls_config.as_usockets_for_client_verification(), &mut err); + if secure.is_none() { + drop(tls_config); + return Err( + global_object.throw_value(crate::jsc::create_bun_socket_error_to_js( + err, + global_object, + )), + ); + } + } + + Ok(Some(Self { + hostname_str, + port, + username_str, + password_str, + database_str, + ssl_mode, + tls_config, + secure, + })) + } +} diff --git a/src/sql_jsc/shared/query_ctor_args.rs b/src/sql_jsc/shared/query_ctor_args.rs new file mode 100644 index 000000000000..15378826b991 --- /dev/null +++ b/src/sql_jsc/shared/query_ctor_args.rs @@ -0,0 +1,64 @@ +//! Shared `createQuery(query, values, pendingValue?, columns?, bigint?, +//! simple?)` constructor-argument parsing/validation used by both the +//! Postgres and MySQL query constructors. + +use crate::jsc::{JSGlobalObject, JSGlobalObjectSqlExt as _, JSType, JSValue, JsResult}; + +pub(crate) struct QueryCtorArgs { + pub query: JSValue, + pub values: JSValue, + pub pending_value: JSValue, + pub columns: JSValue, + pub bigint: bool, + pub simple: bool, +} + +impl QueryCtorArgs { + pub(crate) fn parse(global_this: &JSGlobalObject, arguments: &[JSValue]) -> JsResult { + let mut args = + crate::jsc::call_frame::ArgumentsSlice::init(global_this.sql_vm(), arguments); + let Some(query) = args.next_eat() else { + return Err(global_this.throw(format_args!("query must be a string"))); + }; + let Some(values) = args.next_eat() else { + return Err(global_this.throw(format_args!("values must be an array"))); + }; + + if !query.is_string() { + return Err(global_this.throw(format_args!("query must be a string"))); + } + + if values.js_type() != JSType::Array { + return Err(global_this.throw(format_args!("values must be an array"))); + } + + let pending_value: JSValue = args.next_eat().unwrap_or(JSValue::UNDEFINED); + let columns: JSValue = args.next_eat().unwrap_or(JSValue::UNDEFINED); + let js_bigint: JSValue = args.next_eat().unwrap_or(JSValue::FALSE); + let js_simple: JSValue = args.next_eat().unwrap_or(JSValue::FALSE); + + let bigint = js_bigint.is_boolean() && js_bigint.as_boolean(); + let simple = js_simple.is_boolean() && js_simple.as_boolean(); + if simple { + if values.get_length(global_this)? > 0 { + return Err(global_this + .throw_invalid_arguments(format_args!("simple query cannot have parameters"))); + } + if query.get_length(global_this)? >= i32::MAX as u64 { + return Err(global_this.throw_invalid_arguments(format_args!("query is too long"))); + } + } + if !pending_value.js_type().is_array_like() { + return Err(global_this.throw_invalid_argument_type("query", "pendingValue", "Array")); + } + + Ok(Self { + query, + values, + pending_value, + columns, + bigint, + simple, + }) + } +} diff --git a/test/js/sql/postgres-multi-statement-fields.test.ts b/test/js/sql/postgres-multi-statement-fields.test.ts index 40cd4f5f097b..ff1223525d54 100644 --- a/test/js/sql/postgres-multi-statement-fields.test.ts +++ b/test/js/sql/postgres-multi-statement-fields.test.ts @@ -111,11 +111,34 @@ test("simple query with multiple statements uses each RowDescription's column na } }); -// NotificationResponse ('A', sent by NOTIFY) and unknown async messages can arrive -// between result sets. The protocol reader must consume exactly the message body so -// the following messages stay correctly framed. +// NotificationResponse ('A', sent by NOTIFY), NoticeResponse ('N', sent by +// RAISE NOTICE and server chatter like "relation exists, skipping") and unknown +// async messages can arrive between result sets. The protocol reader must +// consume exactly the message body so the following messages stay correctly +// framed. for (const [name, asyncMessage] of [ ["NotificationResponse", pkt("A", Buffer.concat([int32(4321), cstr("some_channel"), cstr("some payload")]))], + // NoticeResponse shares ErrorResponse's field-list format: repeated + // (field-type byte + cstring), closed by a single zero byte. It must be + // decoded and discarded without failing the query. + [ + "NoticeResponse", + pkt( + "N", + Buffer.concat([ + Buffer.from("S"), + cstr("NOTICE"), + Buffer.from("C"), + cstr("00000"), + Buffer.from("M"), + cstr("relation exists, skipping"), + Buffer.from([0]), + ]), + ), + ], + // Degenerate notice with declared length 4 and no field list at all; unlike + // ErrorResponse, notice decoding treats this as an empty notice. + ["empty NoticeResponse", pkt("N", Buffer.alloc(0))], // 'v' = NegotiateProtocolVersion, which the client does not handle explicitly ["unknown message type", pkt("v", Buffer.concat([int32(0), int32(0)]))], ] as const) {