From c3616bc853e27f7be240b61f19fcfceb1b911d1a Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:02:55 +0500 Subject: [PATCH] fix(driver-sql): an unknown $select column must not zero the result set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit find() swallowed any "no such column" error into an empty array. A projected $select naming a column the table lacks (e.g. a generic list view auto- requesting status/due_date/image on an object without them) then made the WHOLE query return zero rows — reading to the UI as "no records exist" while the data was actually there: a silent data-loss footgun. When the failure came from the projection, retry once with SELECT * so the real rows still come back (the phantom field is simply absent from each row). Non-projection errors (unknown table, etc.) still surface as before. The engine's unknown-field filter is the first line of defense, but it only fires when the object's schema is populated in the registry; this driver backstop holds even when it isn't (notably the cloud multi-tenant runtime). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plugins/driver-sql/src/sql-driver.test.ts | 21 +++++ packages/plugins/driver-sql/src/sql-driver.ts | 79 +++++++++++++------ 2 files changed, 75 insertions(+), 25 deletions(-) diff --git a/packages/plugins/driver-sql/src/sql-driver.test.ts b/packages/plugins/driver-sql/src/sql-driver.test.ts index 0d76cfc836..10c2cf24a3 100644 --- a/packages/plugins/driver-sql/src/sql-driver.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver.test.ts @@ -49,6 +49,27 @@ describe('SqlDriver (SQLite Integration)', () => { expect(results.map((r: any) => r.name)).toEqual(['Alice', 'Charlie']); }); + it('returns rows when $select names an unknown column (retries with SELECT *)', async () => { + // A generic list view auto-requests view-binding fields (status, due_date, + // image, ...) the object may not have. The unknown column must NOT zero + // the whole result — the real rows still come back, minus the phantom field. + const results = await driver.find('users', { + fields: ['id', 'name', 'status', 'due_date', 'image'], + orderBy: [{ field: 'name', order: 'asc' }], + }); + + expect(results.length).toBe(4); + expect(results.map((r: any) => r.name)).toEqual(['Alice', 'Bob', 'Charlie', 'Dave']); + // The phantom columns are simply absent (the table never had them). + expect((results[0] as any).status).toBeUndefined(); + }); + + it('still surfaces non-column errors (e.g. unknown table) instead of empty', async () => { + await expect( + driver.find('no_such_table', { fields: ['id'] }), + ).rejects.toThrow(); + }); + it('should apply simple AND/OR logic', async () => { const results = await driver.find('users', { where: { diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index 758a92ab22..919bcf3965 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -362,8 +362,34 @@ export class SqlDriver implements IDataDriver { // =================================== async find(object: string, query: QueryAST, options?: DriverOptions): Promise { - const builder = this.getBuilder(object, options); - this.applyTenantScope(builder, object, options); + // Build everything EXCEPT the SELECT list, so the unknown-column retry + // below can rebuild without re-deriving where/order/pagination. + const buildBase = () => { + const b = this.getBuilder(object, options); + this.applyTenantScope(b, object, options); + + // WHERE + if (query.where) { + this.applyFilters(b, query.where); + } + + // ORDER BY + if (query.orderBy && Array.isArray(query.orderBy)) { + for (const item of query.orderBy) { + if (item.field) { + b.orderBy(this.mapSortField(item.field), item.order || 'asc'); + } + } + } + + // PAGINATION + if (query.offset !== undefined) b.offset(query.offset); + if (query.limit !== undefined) b.limit(query.limit); + + return b; + }; + + const builder = buildBase(); // SELECT if (query.fields) { @@ -372,36 +398,39 @@ export class SqlDriver implements IDataDriver { builder.select('*'); } - // WHERE - if (query.where) { - this.applyFilters(builder, query.where); - } - - // ORDER BY - if (query.orderBy && Array.isArray(query.orderBy)) { - for (const item of query.orderBy) { - if (item.field) { - builder.orderBy(this.mapSortField(item.field), item.order || 'asc'); - } - } - } - - // PAGINATION - if (query.offset !== undefined) builder.offset(query.offset); - if (query.limit !== undefined) builder.limit(query.limit); - let results: any[]; try { results = await builder; } catch (error: any) { - if ( + const isUnknownColumn = error.message && (error.message.includes('no such column') || - (error.message.includes('column') && error.message.includes('does not exist'))) - ) { - return []; + (error.message.includes('column') && error.message.includes('does not exist'))); + if (isUnknownColumn) { + // A `$select` projection naming a column the table lacks (e.g. a + // generic list view auto-requesting `status`/`due_date`/`image` on an + // object without them) makes the WHOLE query fail. Swallowing that + // into an empty result — the old behavior — reads to the UI as "no + // records exist" even though rows are there: a silent data-loss + // footgun. When the failure came from the projection, retry once + // selecting all columns so the real rows still come back; the unknown + // field is simply absent from each row (it never existed). The + // engine's unknown-field filter is the first line of defense, but it + // only fires when the object's schema is populated in the registry — + // this driver backstop holds even when it isn't (notably the cloud + // multi-tenant runtime, where the projection otherwise zeroes the list). + if (query.fields) { + try { + results = await buildBase().select('*'); + } catch { + return []; + } + } else { + return []; + } + } else { + throw error; } - throw error; } if (!Array.isArray(results)) {