also looking at this
fix(client): choose WHERE vs AND from builder state, not a text search
#1118
2 files
+235
-27
| @@ -6966,6 +6966,31 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 6966 | 6966 | |
| 6967 | 6967 | let sqlText = `UPDATE ${quoteId(String(table))}` |
| 6968 | 6968 | |
| 6969 | /** | |
| 6970 | * Whether this builder has already emitted a predicate — which is the | |
| 6971 | * thing the keyword actually depends on. | |
| 6972 | * | |
| 6973 | * It used to be inferred by testing the whole statement for | |
| 6974 | * `/\bWHERE\b/`, and plenty of text that is not this builder's predicate | |
| 6975 | * satisfies that: a subquery inside `set()`, a table named `where`, a | |
| 6976 | * column named `where`. The first real predicate then came out as `AND` | |
| 6977 | * and fused onto the SET expression: | |
| 6978 | * | |
| 6979 | * SET "flag" = (SELECT ... WHERE x.id > 3) AND "id" = ? | |
| 6980 | * | |
| 6981 | * which leaves the UPDATE with no WHERE at all. Postgres rejects that | |
| 6982 | * only when the types happen not to line up; where the SET target is | |
| 6983 | * boolean, or on SQLite, it runs against every row and writes the | |
| 6984 | * predicate into the value. See #1113. | |
| 6985 | * | |
| 6986 | * A boolean cannot be fooled by any of that. | |
| 6987 | */ | |
| 6988 | let hasPredicate = false | |
| 6989 | const appendPredicate = (predicate: string): void => { | |
| 6990 | sqlText = `${sqlText} ${hasPredicate ? 'AND' : 'WHERE'} ${predicate}` | |
| 6991 | hasPredicate = true | |
| 6992 | } | |
| 6993 | ||
| 6969 | 6994 | return { |
| 6970 | 6995 | set(values) { |
| 6971 | 6996 | const keys = Object.keys(values) |
| @@ -6992,9 +7017,6 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 6992 | 7017 | return this |
| 6993 | 7018 | }, |
| 6994 | 7019 | where(expr: any, op?: string, value?: any) { |
| 6995 | // Helper to get the correct keyword (WHERE for first condition, AND for subsequent) | |
| 6996 | const getWhereKeyword = () => SQL_PATTERNS.WHERE.test(sqlText) ? 'AND' : 'WHERE' | |
| 6997 | ||
| 6998 | 7020 | // Handle 3-arg format: where('column', '=', value) |
| 6999 | 7021 | if (op !== undefined && (typeof expr === 'string' || isRawExpression(expr) || isBoundSqlExpression(expr))) { |
| 7000 | 7022 | const safeOperator = assertSafeWhereOperator(op, 'updateTable.where') |
| @@ -7020,14 +7042,14 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 7020 | 7042 | if (safeOperator.toLowerCase() === 'in' || safeOperator.toLowerCase() === 'not in') { |
| 7021 | 7043 | const values = Array.isArray(value) ? value : [value] |
| 7022 | 7044 | const placeholders = getPlaceholders(values.length, params.length + 1) |
| 7023 | sqlText = `${sqlText} ${getWhereKeyword()} ${left} ${safeOperator.toUpperCase()} (${placeholders})` | |
| 7045 | appendPredicate(`${left} ${safeOperator.toUpperCase()} (${placeholders})`) | |
| 7024 | 7046 | params.push(...values) |
| 7025 | 7047 | built = _sql.unsafe(sqlText, params) |
| 7026 | 7048 | return this |
| 7027 | 7049 | } |
| 7028 | 7050 | |
| 7029 | 7051 | const paramIndex = params.length + 1 |
| 7030 | sqlText = `${sqlText} ${getWhereKeyword()} ${left} ${safeOperator} ${getPlaceholder(paramIndex)}` | |
| 7052 | appendPredicate(`${left} ${safeOperator} ${getPlaceholder(paramIndex)}`) | |
| 7031 | 7053 | params.push(value) |
| 7032 | 7054 | built = _sql.unsafe(sqlText, params) |
| 7033 | 7055 | return this |
| @@ -7041,14 +7063,14 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 7041 | 7063 | if (safeOperator.toLowerCase() === 'in' || safeOperator.toLowerCase() === 'not in') { |
| 7042 | 7064 | const values = Array.isArray(val) ? val : [val] |
| 7043 | 7065 | const placeholders = getPlaceholders(values.length, params.length + 1) |
| 7044 | sqlText = `${sqlText} ${getWhereKeyword()} ${quoteId(String(col))} ${safeOperator.toUpperCase()} (${placeholders})` | |
| 7066 | appendPredicate(`${quoteId(String(col))} ${safeOperator.toUpperCase()} (${placeholders})`) | |
| 7045 | 7067 | params.push(...values) |
| 7046 | 7068 | built = _sql.unsafe(sqlText, params) |
| 7047 | 7069 | return this |
| 7048 | 7070 | } |
| 7049 | 7071 | |
| 7050 | 7072 | const paramIndex = params.length + 1 |
| 7051 | sqlText = `${sqlText} ${getWhereKeyword()} ${quoteId(String(col))} ${safeOperator} ${getPlaceholder(paramIndex)}` | |
| 7073 | appendPredicate(`${quoteId(String(col))} ${safeOperator} ${getPlaceholder(paramIndex)}`) | |
| 7052 | 7074 | params.push(val) |
| 7053 | 7075 | built = _sql.unsafe(sqlText, params) |
| 7054 | 7076 | return this |
| @@ -7063,13 +7085,13 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 7063 | 7085 | // expression is an object and would otherwise be read as the column |
| 7064 | 7086 | // map `{ sql: ... }`. See #1101. |
| 7065 | 7087 | if (isRawExpression(expr)) { |
| 7066 | sqlText = `${sqlText} ${getWhereKeyword()} ${expr.raw}` | |
| 7088 | appendPredicate(expr.raw) | |
| 7067 | 7089 | built = _sql.unsafe(sqlText, params) |
| 7068 | 7090 | return this |
| 7069 | 7091 | } |
| 7070 | 7092 | if (isBoundSqlExpression(expr)) { |
| 7071 | 7093 | const rendered = renderBoundSqlExpression(expr, params.length + 1) |
| 7072 | sqlText = `${sqlText} ${getWhereKeyword()} ${rendered.text}` | |
| 7094 | appendPredicate(rendered.text) | |
| 7073 | 7095 | params.push(...rendered.parameters) |
| 7074 | 7096 | built = _sql.unsafe(sqlText, params) |
| 7075 | 7097 | return this |
| @@ -7087,7 +7109,7 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 7087 | 7109 | conditions[i] = `${quoteId(keys[i])} = ${getPlaceholder(baseIdx + i + 1)}` |
| 7088 | 7110 | params.push((expr as any)[keys[i]]) |
| 7089 | 7111 | } |
| 7090 | sqlText = `${sqlText} ${getWhereKeyword()} ${conditions.join(' AND ')}` | |
| 7112 | appendPredicate(conditions.join(' AND ')) | |
| 7091 | 7113 | built = _sql.unsafe(sqlText, params) |
| 7092 | 7114 | return this |
| 7093 | 7115 | } |
| @@ -7104,14 +7126,12 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 7104 | 7126 | throw new TypeError(`[query-builder] updateTable.where(): expected a condition, got ${expr === null ? 'null' : typeof expr}. Refusing to run an UPDATE with no WHERE — drop the where() call if updating every row is intended.`) |
| 7105 | 7127 | }, |
| 7106 | 7128 | whereNull(column: string) { |
| 7107 | const keyword = SQL_PATTERNS.WHERE.test(sqlText) ? 'AND' : 'WHERE' | |
| 7108 | sqlText = `${sqlText} ${keyword} ${quoteId(String(column))} IS NULL` | |
| 7129 | appendPredicate(`${quoteId(String(column))} IS NULL`) | |
| 7109 | 7130 | built = _sql.unsafe(sqlText, params) |
| 7110 | 7131 | return this |
| 7111 | 7132 | }, |
| 7112 | 7133 | whereNotNull(column: string) { |
| 7113 | const keyword = SQL_PATTERNS.WHERE.test(sqlText) ? 'AND' : 'WHERE' | |
| 7114 | sqlText = `${sqlText} ${keyword} ${quoteId(String(column))} IS NOT NULL` | |
| 7134 | appendPredicate(`${quoteId(String(column))} IS NOT NULL`) | |
| 7115 | 7135 | built = _sql.unsafe(sqlText, params) |
| 7116 | 7136 | return this |
| 7117 | 7137 | }, |
| @@ -7237,13 +7257,21 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 7237 | 7257 | const delParams: any[] = [] |
| 7238 | 7258 | let whereCondition: any = null |
| 7239 | 7259 | |
| 7240 | // First .where() emits ` WHERE `; subsequent calls emit ` AND `. | |
| 7260 | // First predicate emits ` WHERE `; subsequent ones emit ` AND `. | |
| 7241 | 7261 | // Without this, chained `.where('a', '=', 1).where('b', '=', 2)` |
| 7242 | 7262 | // compiled to `... WHERE a = ? WHERE b = ?` and SQLite 500'd |
| 7243 | // with `near "WHERE": syntax error`. Mirrors the same helper in | |
| 7244 | // updateTable() at line ~5454. | |
| 7263 | // with `near "WHERE": syntax error`. | |
| 7245 | 7264 | // See https://github.com/stacksjs/bun-query-builder/issues/1015 |
| 7246 | const getWhereKeyword = () => SQL_PATTERNS.WHERE.test(sqlText) ? 'AND' : 'WHERE' | |
| 7265 | // | |
| 7266 | // Tracked as builder state rather than read back out of the statement: | |
| 7267 | // `DELETE FROM "where"` matches /\bWHERE\b/ too, and inferring it from | |
| 7268 | // the text turned the first predicate into `AND` — `DELETE FROM "where" | |
| 7269 | // AND "id" = ?`. Mirrors updateTable(). See #1113. | |
| 7270 | let hasPredicate = false | |
| 7271 | const appendPredicate = (predicate: string): void => { | |
| 7272 | sqlText += ` ${hasPredicate ? 'AND' : 'WHERE'} ${predicate}` | |
| 7273 | hasPredicate = true | |
| 7274 | } | |
| 7247 | 7275 | |
| 7248 | 7276 | const ensureDelBuilt = () => { |
| 7249 | 7277 | if (built === null) { |
| @@ -7271,14 +7299,14 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 7271 | 7299 | if (safeOperator.toLowerCase() === 'in' || safeOperator.toLowerCase() === 'not in') { |
| 7272 | 7300 | const values = Array.isArray(value) ? value : [value] |
| 7273 | 7301 | const placeholders = getPlaceholders(values.length, delParams.length + 1) |
| 7274 | sqlText += ` ${getWhereKeyword()} ${quoteId(expr)} ${safeOperator.toUpperCase()} (${placeholders})` | |
| 7302 | appendPredicate(`${quoteId(expr)} ${safeOperator.toUpperCase()} (${placeholders})`) | |
| 7275 | 7303 | delParams.push(...values) |
| 7276 | 7304 | built = null |
| 7277 | 7305 | return this |
| 7278 | 7306 | } |
| 7279 | 7307 | |
| 7280 | 7308 | const paramIndex = delParams.length + 1 |
| 7281 | sqlText += ` ${getWhereKeyword()} ${quoteId(expr)} ${safeOperator} ${getPlaceholder(paramIndex)}` | |
| 7309 | appendPredicate(`${quoteId(expr)} ${safeOperator} ${getPlaceholder(paramIndex)}`) | |
| 7282 | 7310 | delParams.push(value) |
| 7283 | 7311 | built = null |
| 7284 | 7312 | return this |
| @@ -7291,14 +7319,14 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 7291 | 7319 | if (safeOperator.toLowerCase() === 'in' || safeOperator.toLowerCase() === 'not in') { |
| 7292 | 7320 | const values = Array.isArray(val) ? val : [val] |
| 7293 | 7321 | const placeholders = getPlaceholders(values.length, delParams.length + 1) |
| 7294 | sqlText += ` ${getWhereKeyword()} ${quoteId(String(col))} ${safeOperator.toUpperCase()} (${placeholders})` | |
| 7322 | appendPredicate(`${quoteId(String(col))} ${safeOperator.toUpperCase()} (${placeholders})`) | |
| 7295 | 7323 | delParams.push(...values) |
| 7296 | 7324 | built = null |
| 7297 | 7325 | return this |
| 7298 | 7326 | } |
| 7299 | 7327 | |
| 7300 | 7328 | const paramIndex = delParams.length + 1 |
| 7301 | sqlText += ` ${getWhereKeyword()} ${quoteId(String(col))} ${safeOperator} ${getPlaceholder(paramIndex)}` | |
| 7329 | appendPredicate(`${quoteId(String(col))} ${safeOperator} ${getPlaceholder(paramIndex)}`) | |
| 7302 | 7330 | delParams.push(val) |
| 7303 | 7331 | built = null |
| 7304 | 7332 | return this |
| @@ -7311,13 +7339,13 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 7311 | 7339 | // `where(raw('id = 1'))` with "invalid input syntax for type |
| 7312 | 7340 | // boolean". The signature accepts SqlFragment, so it has to work. |
| 7313 | 7341 | if (isRawExpression(expr)) { |
| 7314 | sqlText += ` ${getWhereKeyword()} ${expr.raw}` | |
| 7342 | appendPredicate(expr.raw) | |
| 7315 | 7343 | built = null |
| 7316 | 7344 | return this |
| 7317 | 7345 | } |
| 7318 | 7346 | if (isBoundSqlExpression(expr)) { |
| 7319 | 7347 | const rendered = renderBoundSqlExpression(expr, delParams.length + 1) |
| 7320 | sqlText += ` ${getWhereKeyword()} ${rendered.text}` | |
| 7348 | appendPredicate(rendered.text) | |
| 7321 | 7349 | delParams.push(...rendered.parameters) |
| 7322 | 7350 | built = null |
| 7323 | 7351 | return this |
| @@ -7334,7 +7362,7 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 7334 | 7362 | conditions.push(`${quoteId(key)} = ${getPlaceholder(paramIndex)}`) |
| 7335 | 7363 | delParams.push((expr as any)[key]) |
| 7336 | 7364 | } |
| 7337 | sqlText += ` ${getWhereKeyword()} ${conditions.join(' AND ')}` | |
| 7365 | appendPredicate(conditions.join(' AND ')) | |
| 7338 | 7366 | built = null |
| 7339 | 7367 | return this |
| 7340 | 7368 | } |
| @@ -7350,12 +7378,12 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 7350 | 7378 | throw new TypeError(`[query-builder] deleteFrom.where(): expected a condition, got ${expr === null ? 'null' : typeof expr}. Refusing to run a DELETE with no WHERE — drop the where() call if deleting every row is intended.`) |
| 7351 | 7379 | }, |
| 7352 | 7380 | whereNull(column: string) { |
| 7353 | sqlText += ` ${getWhereKeyword()} ${quoteId(String(column))} IS NULL` | |
| 7381 | appendPredicate(`${quoteId(String(column))} IS NULL`) | |
| 7354 | 7382 | built = null |
| 7355 | 7383 | return this |
| 7356 | 7384 | }, |
| 7357 | 7385 | whereNotNull(column: string) { |
| 7358 | sqlText += ` ${getWhereKeyword()} ${quoteId(String(column))} IS NOT NULL` | |
| 7386 | appendPredicate(`${quoteId(String(column))} IS NOT NULL`) | |
| 7359 | 7387 | built = null |
| 7360 | 7388 | return this |
| 7361 | 7389 | }, |