Skip to content

fix(drizzle): remap object rows to TS field names for all dialects#227

Open
meyer1994 wants to merge 1 commit into
unjs:mainfrom
meyer1994:main
Open

fix(drizzle): remap object rows to TS field names for all dialects#227
meyer1994 wants to merge 1 commit into
unjs:mainfrom
meyer1994:main

Conversation

@meyer1994

@meyer1994 meyer1994 commented May 21, 2026

Copy link
Copy Markdown

Fixes #196

I encountered this issue while trying to make drizzle db0 integration work with better-auth drizzle adapter

db0 connectors return object rows ({ foo_bar: 1 }) instead of the positional array rows drizzle's internals expect. Previously the fields parameter was discarded and isResponseInArrayMode() returned false, so drizzle's remapping logic was never triggered and queries returned raw snake_case column names instead of the camelCase TS
property names defined in the schema.

Changes:

  • Add _utils.ts with shared rowToArray and mapRow helpers that translate db0 object rows into drizzle's expected formats
  • Fix all three dialects (SQLite, PostgreSQL, MySQL): store fields, set isResponseInArrayMode() to true, and apply the row mappers in all(), get(), execute(), and values()
  • Add column name remapping test suites for all three dialects covering select, where eq(), get(), insert().returning(), and full key assertions

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Improved column name remapping across MySQL, PostgreSQL, and SQLite integrations to properly map database column names to object property names in query results.
  • Tests

    • Added comprehensive test suites validating column name remapping behavior for all supported database drivers.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR fixes Drizzle column name remapping in db0 by adding field-aware row transformation utilities and threading them through the MySQL, PostgreSQL, and SQLite session layers. Result rows now properly convert database column names (e.g., foo_bar) to their mapped TypeScript field names (e.g., fooBar), aligning with native Drizzle behavior.

Changes

Drizzle Column Name Remapping

Layer / File(s) Summary
Row-to-field mapping utilities
src/integrations/drizzle/_utils.ts
rowToArray extracts field values from rows using Column.name or SQL.Aliased.fieldAlias, returning an ordered array. mapRow builds nested result objects by extracting values, normalizing nulls, applying type conversions, and writing along each field's path.
MySQL session result mapping
src/integrations/drizzle/mysql/_session.ts
DB0MySqlPreparedQuery.execute now uses rowToArray and mapRow to apply field-aware result transformation, converting database column names to mapped property names in returned rows.
PostgreSQL session result mapping
src/integrations/drizzle/postgres/_session.ts
Both execute() and all() apply field-aware row mapping via rowToArray for custom mappers and mapRow for default results. isResponseInArrayMode() now returns true to reflect the new array-oriented response handling.
SQLite session result mapping
src/integrations/drizzle/sqlite/_session.ts
prepareQuery now passes through isResponseInArrayMode, and the prepared query stores it alongside fields. Methods all(), get(), and values() conditionally apply rowToArray and mapRow to transform rows based on field mappings.
Integration test coverage
test/integrations/drizzle/mysql.test.ts, test/integrations/drizzle/pg.test.ts, test/integrations/drizzle/sqlite.test.ts
New test suites for all three database types verify that columns mapped from snake_case database names to camelCase Drizzle property names are properly converted in select, where, get, full-row iteration, and insert().returning() results.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 Whiskers twitching with joy

Snake_case rows hopped away,
CamelCase keys now lead the way,
Drizzle fields mapped just right,
Remapping dance, pure delight! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: fixing Drizzle integration to remap object rows to TypeScript field names across all dialects (SQLite, PostgreSQL, MySQL).
Linked Issues check ✅ Passed The PR comprehensively addresses issue #196 by implementing row mapping utilities and applying them across all dialects, ensuring Drizzle column name remapping works correctly with db0.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the column name remapping issue: utility functions, dialect session updates, and comprehensive test suites validating the fix.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/integrations/drizzle/_utils.ts`:
- Around line 8-10: The current fields.map callback in
src/integrations/drizzle/_utils.ts uses row[field.name] for Column lookups,
which loses values for joined/duplicate-name projections; update the mapping to
derive the result key from dialect-aware select aliases instead of falling back
to field.name by (1) using the alias provided by SQL.Aliased when present, (2)
consulting the SQLDialect-aware aliasing strategy used by the connector (e.g., a
helper that builds column select aliases per SQLDialect) to compute the lookup
key for Column instances instead of field.name, and (3) ensuring mapRow (or the
code path that builds select projections) generates and exposes those aliases on
the row objects so fields.map can use that key reliably across dialects and
avoid nulling duplicate-named columns.

In `@src/integrations/drizzle/sqlite/_session.ts`:
- Around line 200-208: The values() method unconditionally dereferences
this.fields and throws for raw prepared queries lacking selected-field metadata;
replicate the no-field short-circuit used by all() and get() by returning rows
mapped to Object.values (or equivalent row array) when this.fields is
null/undefined. Update values() (and its use of rowToArray) to check this.fields
first—if absent, convert each row to an array of values in insertion order;
otherwise continue using rowToArray(this.fields, row) so existing behavior for
known fields remains unchanged.

In `@test/integrations/drizzle/mysql.test.ts`:
- Around line 115-125: The test is flaky because drizzleDb.select().from(events)
returns rows in unspecified order; make the assertion deterministic by adding an
ORDER BY to the query so the first row will always be the one with fooBar === 1
(e.g., call .orderBy(events.fooBar) or another stable column like events.id or
events.createdAt), then keep the assertions against res[0]; update the test case
("select returns camelCase keys, not snake_case") to use the ordered query so
res[0].fooBar consistently equals 1.

In `@test/integrations/drizzle/pg.test.ts`:
- Around line 144-154: The test relies on implicit row ordering; change the
query built by drizzleDb.select().from(events) to include an explicit ORDER BY
on a stable column (e.g., events.fooBar or events.createdAt) so the row with
fooBar = 1 is deterministically first; update the call to use the library's
orderBy API (and specify ascending or descending as needed) and adjust the
assertion if you choose the opposite sort direction.

In `@test/integrations/drizzle/sqlite.test.ts`:
- Around line 201-211: The test relies on an unordered result so change the
query in the test that calls drizzleDb.select().from(events).all() to include an
explicit order to make the assertion deterministic; update the select call (in
the test case `"select returns camelCase keys, not snake_case"`) to add an
.orderBy(...) on a stable column (e.g., events.id or events.createdAt) so res[0]
will always be the row with fooBar = 1 and the subsequent property assertions
remain valid.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bde52cd6-bc86-41f5-a0d9-f7669b17aa8a

📥 Commits

Reviewing files that changed from the base of the PR and between 60202ee and 199a683.

📒 Files selected for processing (7)
  • src/integrations/drizzle/_utils.ts
  • src/integrations/drizzle/mysql/_session.ts
  • src/integrations/drizzle/postgres/_session.ts
  • src/integrations/drizzle/sqlite/_session.ts
  • test/integrations/drizzle/mysql.test.ts
  • test/integrations/drizzle/pg.test.ts
  • test/integrations/drizzle/sqlite.test.ts

Comment on lines +8 to +10
return fields.map(({ field }) => {
if (is(field, Column)) return row[field.name];
if (is(field, SQL.Aliased)) return row[field.fieldAlias];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

field.name is not a safe lookup key for shared object-row mapping.

These helpers assume every Column comes back under row[field.name]. That still breaks joined or duplicate-name projections (users.id + posts.id, etc.): the object row can only retain one key, so one value is lost before remapping, and mapRow() then silently turns the missing lookup into null. Because this utility is shared by all three connectors, the fix needs dialect-aware select aliases / result keys rather than a backend-agnostic field.name fallback.

As per coding guidelines, src/**/*.ts: Database dialect behavior must adjust SQL generation per SQLDialect (e.g., RETURNING support varies by backend).

Also applies to: 22-29

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/integrations/drizzle/_utils.ts` around lines 8 - 10, The current
fields.map callback in src/integrations/drizzle/_utils.ts uses row[field.name]
for Column lookups, which loses values for joined/duplicate-name projections;
update the mapping to derive the result key from dialect-aware select aliases
instead of falling back to field.name by (1) using the alias provided by
SQL.Aliased when present, (2) consulting the SQLDialect-aware aliasing strategy
used by the connector (e.g., a helper that builds column select aliases per
SQLDialect) to compute the lookup key for Column instances instead of
field.name, and (3) ensuring mapRow (or the code path that builds select
projections) generates and exposes those aliases on the row objects so
fields.map can use that key reliably across dialects and avoid nulling
duplicate-named columns.

Comment on lines 200 to +208
async values<T extends any[] = unknown[]>(
placeholderValues?: Record<string, unknown>,
): Promise<T[]> {
const params = fillPlaceholders(this.query.params, placeholderValues ?? {});
const placeholders = placeholderValues ?? {};
const params: any[] = fillPlaceholders(this.query.params, placeholders);
this.logger.logQuery(this.query.sql, params);
const rows = await this.stmt.all(...(params as any[]));
// db0 Statement doesn't have a values() method, so convert object rows to arrays
return (rows as Record<string, unknown>[]).map(
(row) => Object.values(row) as T,
);

const rows = (await this.stmt.all(...params)) as Record<string, unknown>[];
return rows.map((row) => rowToArray(this.fields!, row) as T);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

values() lost the no-field fallback.

all() and get() still short-circuit when fields is absent, but values() unconditionally dereferences this.fields!. Any raw prepared query that reaches .values() without selected-field metadata will now throw here instead of returning row values.

Suggested fix
   async values<T extends any[] = unknown[]>(
     placeholderValues?: Record<string, unknown>,
   ): Promise<T[]> {
     const placeholders = placeholderValues ?? {};
     const params: any[] = fillPlaceholders(this.query.params, placeholders);
     this.logger.logQuery(this.query.sql, params);

     const rows = (await this.stmt.all(...params)) as Record<string, unknown>[];
-    return rows.map((row) => rowToArray(this.fields!, row) as T);
+    if (!this.fields) {
+      return rows.map((row) => Object.values(row) as T);
+    }
+
+    return rows.map((row) => rowToArray(this.fields, row) as T);
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async values<T extends any[] = unknown[]>(
placeholderValues?: Record<string, unknown>,
): Promise<T[]> {
const params = fillPlaceholders(this.query.params, placeholderValues ?? {});
const placeholders = placeholderValues ?? {};
const params: any[] = fillPlaceholders(this.query.params, placeholders);
this.logger.logQuery(this.query.sql, params);
const rows = await this.stmt.all(...(params as any[]));
// db0 Statement doesn't have a values() method, so convert object rows to arrays
return (rows as Record<string, unknown>[]).map(
(row) => Object.values(row) as T,
);
const rows = (await this.stmt.all(...params)) as Record<string, unknown>[];
return rows.map((row) => rowToArray(this.fields!, row) as T);
async values<T extends any[] = unknown[]>(
placeholderValues?: Record<string, unknown>,
): Promise<T[]> {
const placeholders = placeholderValues ?? {};
const params: any[] = fillPlaceholders(this.query.params, placeholders);
this.logger.logQuery(this.query.sql, params);
const rows = (await this.stmt.all(...params)) as Record<string, unknown>[];
if (!this.fields) {
return rows.map((row) => Object.values(row) as T);
}
return rows.map((row) => rowToArray(this.fields, row) as T);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/integrations/drizzle/sqlite/_session.ts` around lines 200 - 208, The
values() method unconditionally dereferences this.fields and throws for raw
prepared queries lacking selected-field metadata; replicate the no-field
short-circuit used by all() and get() by returning rows mapped to Object.values
(or equivalent row array) when this.fields is null/undefined. Update values()
(and its use of rowToArray) to check this.fields first—if absent, convert each
row to an array of values in insertion order; otherwise continue using
rowToArray(this.fields, row) so existing behavior for known fields remains
unchanged.

Comment on lines +115 to +125
it("select returns camelCase keys, not snake_case", async () => {
const res = await drizzleDb.select().from(events);
expect(res.length).toBe(2);
expect(res[0]).toHaveProperty("fooBar");
expect(res[0]).toHaveProperty("createdAt");
expect(res[0]).toHaveProperty("userFullName");
expect(res[0]).not.toHaveProperty("foo_bar");
expect(res[0]).not.toHaveProperty("created_at");
expect(res[0]).not.toHaveProperty("user_full_name");
expect(res[0].fooBar).toBe(1);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Make the first-row assertion deterministic.

Line 116 selects rows without ordering, but Line 124 asserts res[0].fooBar === 1. Row order is not guaranteed without ORDER BY, so this can become flaky.

Suggested fix
-      const res = await drizzleDb.select().from(events);
+      const res = await drizzleDb.select().from(events).orderBy(events.id);
       expect(res.length).toBe(2);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("select returns camelCase keys, not snake_case", async () => {
const res = await drizzleDb.select().from(events);
expect(res.length).toBe(2);
expect(res[0]).toHaveProperty("fooBar");
expect(res[0]).toHaveProperty("createdAt");
expect(res[0]).toHaveProperty("userFullName");
expect(res[0]).not.toHaveProperty("foo_bar");
expect(res[0]).not.toHaveProperty("created_at");
expect(res[0]).not.toHaveProperty("user_full_name");
expect(res[0].fooBar).toBe(1);
});
it("select returns camelCase keys, not snake_case", async () => {
const res = await drizzleDb.select().from(events).orderBy(events.id);
expect(res.length).toBe(2);
expect(res[0]).toHaveProperty("fooBar");
expect(res[0]).toHaveProperty("createdAt");
expect(res[0]).toHaveProperty("userFullName");
expect(res[0]).not.toHaveProperty("foo_bar");
expect(res[0]).not.toHaveProperty("created_at");
expect(res[0]).not.toHaveProperty("user_full_name");
expect(res[0].fooBar).toBe(1);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/integrations/drizzle/mysql.test.ts` around lines 115 - 125, The test is
flaky because drizzleDb.select().from(events) returns rows in unspecified order;
make the assertion deterministic by adding an ORDER BY to the query so the first
row will always be the one with fooBar === 1 (e.g., call .orderBy(events.fooBar)
or another stable column like events.id or events.createdAt), then keep the
assertions against res[0]; update the test case ("select returns camelCase keys,
not snake_case") to use the ordered query so res[0].fooBar consistently equals
1.

Comment on lines +144 to +154
it("select returns camelCase keys, not snake_case", async () => {
const res = await drizzleDb.select().from(events);
expect(res.length).toBe(2);
expect(res[0]).toHaveProperty("fooBar");
expect(res[0]).toHaveProperty("createdAt");
expect(res[0]).toHaveProperty("userFullName");
expect(res[0]).not.toHaveProperty("foo_bar");
expect(res[0]).not.toHaveProperty("created_at");
expect(res[0]).not.toHaveProperty("user_full_name");
expect(res[0].fooBar).toBe(1);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid depending on implicit row order.

Line 145 fetches rows without ORDER BY, but Line 153 expects the first row to be the fooBar = 1 row. That assumption is unstable across PostgreSQL implementations.

Suggested fix
-    const res = await drizzleDb.select().from(events);
+    const res = await drizzleDb.select().from(events).orderBy(events.id);
     expect(res.length).toBe(2);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("select returns camelCase keys, not snake_case", async () => {
const res = await drizzleDb.select().from(events);
expect(res.length).toBe(2);
expect(res[0]).toHaveProperty("fooBar");
expect(res[0]).toHaveProperty("createdAt");
expect(res[0]).toHaveProperty("userFullName");
expect(res[0]).not.toHaveProperty("foo_bar");
expect(res[0]).not.toHaveProperty("created_at");
expect(res[0]).not.toHaveProperty("user_full_name");
expect(res[0].fooBar).toBe(1);
});
it("select returns camelCase keys, not snake_case", async () => {
const res = await drizzleDb.select().from(events).orderBy(events.id);
expect(res.length).toBe(2);
expect(res[0]).toHaveProperty("fooBar");
expect(res[0]).toHaveProperty("createdAt");
expect(res[0]).toHaveProperty("userFullName");
expect(res[0]).not.toHaveProperty("foo_bar");
expect(res[0]).not.toHaveProperty("created_at");
expect(res[0]).not.toHaveProperty("user_full_name");
expect(res[0].fooBar).toBe(1);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/integrations/drizzle/pg.test.ts` around lines 144 - 154, The test relies
on implicit row ordering; change the query built by
drizzleDb.select().from(events) to include an explicit ORDER BY on a stable
column (e.g., events.fooBar or events.createdAt) so the row with fooBar = 1 is
deterministically first; update the call to use the library's orderBy API (and
specify ascending or descending as needed) and adjust the assertion if you
choose the opposite sort direction.

Comment on lines +201 to +211
it("select returns camelCase keys, not snake_case", async () => {
const res = await drizzleDb.select().from(events).all();
expect(res.length).toBe(2);
expect(res[0]).toHaveProperty("fooBar");
expect(res[0]).toHaveProperty("createdAt");
expect(res[0]).toHaveProperty("userFullName");
expect(res[0]).not.toHaveProperty("foo_bar");
expect(res[0]).not.toHaveProperty("created_at");
expect(res[0]).not.toHaveProperty("user_full_name");
expect(res[0].fooBar).toBe(1);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Stabilize this assertion with explicit ordering.

Line 202 returns an unordered result set, while Line 210 assumes res[0] is the row with fooBar = 1. Add an order clause to keep this test deterministic.

Suggested fix
-    const res = await drizzleDb.select().from(events).all();
+    const res = await drizzleDb.select().from(events).orderBy(events.id).all();
     expect(res.length).toBe(2);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("select returns camelCase keys, not snake_case", async () => {
const res = await drizzleDb.select().from(events).all();
expect(res.length).toBe(2);
expect(res[0]).toHaveProperty("fooBar");
expect(res[0]).toHaveProperty("createdAt");
expect(res[0]).toHaveProperty("userFullName");
expect(res[0]).not.toHaveProperty("foo_bar");
expect(res[0]).not.toHaveProperty("created_at");
expect(res[0]).not.toHaveProperty("user_full_name");
expect(res[0].fooBar).toBe(1);
});
it("select returns camelCase keys, not snake_case", async () => {
const res = await drizzleDb.select().from(events).orderBy(events.id).all();
expect(res.length).toBe(2);
expect(res[0]).toHaveProperty("fooBar");
expect(res[0]).toHaveProperty("createdAt");
expect(res[0]).toHaveProperty("userFullName");
expect(res[0]).not.toHaveProperty("foo_bar");
expect(res[0]).not.toHaveProperty("created_at");
expect(res[0]).not.toHaveProperty("user_full_name");
expect(res[0].fooBar).toBe(1);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/integrations/drizzle/sqlite.test.ts` around lines 201 - 211, The test
relies on an unordered result so change the query in the test that calls
drizzleDb.select().from(events).all() to include an explicit order to make the
assertion deterministic; update the select call (in the test case `"select
returns camelCase keys, not snake_case"`) to add an .orderBy(...) on a stable
column (e.g., events.id or events.createdAt) so res[0] will always be the row
with fooBar = 1 and the subsequent property assertions remain valid.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Drizzle column name conversion gets lots resulting in wrong keys

1 participant