ReviewOS

stacks/bun-query-builder

select() stringifies SQL fragment objects to '[object Object]' instead of unwrapping

#1016
Closed glennmichael123 opened this 22 days ago · 0 comments
22 days ago

Summary

.select(sql\COUNT(*) as c`)(and.select(['col', sql`...`])) does not unwrap the tagged-template SQL fragment object. The fragment falls through .join(', ')and stringifies to[object Object], which SQLite then rejects with no such column: object Object`.

Before #1012 landed, the single-fragment form was silently dropped by the !columns.length early-return — fragments are objects with no .length, so the guard treated the call as a no-op and the query ran as SELECT *. That meant every call site using .select(sql\COUNT(*) as count`)was returning all columns instead of the count, then reading.countoff the row asundefined, then Number(undefined) || 0masked it as 0. The framework's own_likeable.likeCount() (storage/framework/core/orm/src/traits/likeable.ts`) was returning 0 for every row across the codebase and nobody noticed.

After #1012, fragments correctly fall into the normalised-array path — but .join(', ') doesn't know how to unwrap them, so the latent bug surfaces as a 500.

Reproducer

import { db, sql } from '@stacksjs/database'

// Crashes with `no such column: object Object`
await db.selectFrom('judge_reviews_likes')
  .select(sql\`count(*) as count\`)
  .where('judge_review_id', '=', 12)
  .executeTakeFirst()

// Also crashes (mixed array)
await db.selectFrom('judge_reviews_likes')
  .select(['judge_review_id', sql\`COUNT(*) as c\`])
  .where('judge_review_id', 'in', [10, 11, 12])
  .groupBy('judge_review_id')
  .execute()

// Works (plain-string literal)
await db.selectFrom('judge_reviews_likes')
  .select('count(*) as count')
  .where('judge_review_id', '=', 12)
  .executeTakeFirst()

Error

``` SQLiteError: no such column: object Object at prepare (bun:sqlite:345:37) at query (bun-query-builder/dist/src/index.js:11768:33) at execute (...) ```

Compiled SQL:

```sql SELECT [object Object] FROM judge_reviews_likes WHERE judge_review_id = ? ```

Root cause

client.ts:2809-2828 (after the #1012 normalisation fix):

```ts select(columns: string | string[]) { if (!columns) return this as any const cols = Array.isArray(columns) ? columns : [columns] if (cols.length === 0) return this as any const fromIndex = text.indexOf(' FROM ') if (fromIndex !== -1) { text = `SELECT ${cols.join(', ')}${text.substring(fromIndex)}` } // ... } ```

cols.join(', ') calls String(c) on each element. A bun sql\...`fragment is an opaque object that lacks a usefultoString(), so it serialises to [object Object]`.

The DELETE / UPDATE builders' whereIn() already handles fragment objects via (values as any).toSQL() (client.ts:3876, 3888). .select() should do the same.

Suggested fix

Normalise each fragment element to its raw SQL text before joining:

```ts const renderCol = (c: string | { toSQL: () => any }) => { if (typeof c === 'string') return c if (c && typeof (c as any).toSQL === 'function') return String((c as any).toSQL()) // Sometimes the bun sql tag returns objects that need .raw or other shape — // fall back to a clear error rather than a silent [object Object]. throw new TypeError(`select() received a non-string column that has no .toSQL() method: ${Object.prototype.toString.call(c)}`) } const rendered = cols.map(renderCol) text = `SELECT ${rendered.join(', ')}${...}` ```

Same approach for addSelect() if it has the same path.

Why it matters

This is the canonical way to write a counting or aggregate select in a query builder:

```ts .select(sql`COUNT(*) as count`) .select(['user_id', sql`MAX(created_at) as last_at`]) ```

The framework's own likeable trait uses this pattern. Forcing every caller to fall back to plain-string literals works (and dodges the bug) but loses the safety promise that the sql tag is meant to provide.

Workaround in the meantime

Use plain-string select for aggregates carrying no user input:

```ts .select('COUNT(*) as count') .select(['user_id', 'MAX(created_at) as last_at']) ```

Caught while implementing a like-counter feature on a Stacks app — the framework's JudgeReview._likeable.likeCount() and our own helper both used the fragment form.

Environment

  • bun-query-builder vendored via stacks pantry
  • Bun 1.3.x, SQLite driver
  • Fourth in the where/select API-parity family with #1012, #1013, #1015

Sign in to comment on this issue.