diff --git a/drizzle-orm/src/utils.ts b/drizzle-orm/src/utils.ts index 6f7659485f..84802be4b0 100644 --- a/drizzle-orm/src/utils.ts +++ b/drizzle-orm/src/utils.ts @@ -47,10 +47,10 @@ export function mapResultRow( const objectName = path[0]!; if (!(objectName in nullifyMap)) { nullifyMap[objectName] = value === null ? getTableName(field.table) : false; - } else if ( - typeof nullifyMap[objectName] === 'string' && nullifyMap[objectName] !== getTableName(field.table) - ) { - nullifyMap[objectName] = false; + } else if (typeof nullifyMap[objectName] === 'string') { + if (value !== null || nullifyMap[objectName] !== getTableName(field.table)) { + nullifyMap[objectName] = false; + } } } } diff --git a/drizzle-orm/tests/utils.test.ts b/drizzle-orm/tests/utils.test.ts new file mode 100644 index 0000000000..0e012e9f86 --- /dev/null +++ b/drizzle-orm/tests/utils.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from 'vitest'; +import { pgTable, text } from '~/pg-core/index.ts'; +import { mapResultRow, orderSelectedFields } from '~/utils.ts'; + +const orgTable = pgTable('org', { + id: text('id'), + name: text('name'), + slug: text('slug'), +}); + +const orgBrandingTable = pgTable('org_branding', { + orgId: text('org_id'), + logo: text('logo'), + panelBackground: text('panel_background_colour'), +}); + +describe('mapResultRow', () => { + test('nested partial select: first column null, subsequent columns non-null should not nullify nested object', () => { + // Reproduces: https://github.com/drizzle-team/drizzle-orm/issues/1603 + // When first selected column of a left-joined nested object is null but later + // columns are non-null, the entire nested object should NOT be nullified. + const fields = orderSelectedFields({ + name: orgTable.name, + slug: orgTable.slug, + branding: { + logo: orgBrandingTable.logo, // null in DB + panelBackground: orgBrandingTable.panelBackground, // "#1a8cff" in DB + }, + }); + + // Simulate a row where logo is null but panelBackground has a value + // Fields order: name, slug, branding.logo, branding.panelBackground + const row = ['Test org', 'test-org', null, '#1a8cff']; + + // joinsNotNullableMap: 'org' table is always present (inner), 'org_branding' is left-joined (nullable) + const joinsNotNullableMap = { org: true, org_branding: false }; + + const result = mapResultRow(fields, row, joinsNotNullableMap); + + expect(result).toEqual({ + name: 'Test org', + slug: 'test-org', + branding: { + logo: null, + panelBackground: '#1a8cff', + }, + }); + }); + + test('nested partial select: all columns null from left join should nullify nested object', () => { + const fields = orderSelectedFields({ + name: orgTable.name, + slug: orgTable.slug, + branding: { + logo: orgBrandingTable.logo, + panelBackground: orgBrandingTable.panelBackground, + }, + }); + + // Simulate a row where both branding columns are null (no matching left join row) + const row = ['Test org', 'test-org', null, null]; + + const joinsNotNullableMap = { org: true, org_branding: false }; + + const result = mapResultRow(fields, row, joinsNotNullableMap); + + expect(result).toEqual({ + name: 'Test org', + slug: 'test-org', + branding: null, + }); + }); + + test('nested partial select: first column non-null, second null should not nullify nested object', () => { + const fields = orderSelectedFields({ + name: orgTable.name, + slug: orgTable.slug, + branding: { + panelBackground: orgBrandingTable.panelBackground, // "#1a8cff" + logo: orgBrandingTable.logo, // null + }, + }); + + const row = ['Test org', 'test-org', '#1a8cff', null]; + + const joinsNotNullableMap = { org: true, org_branding: false }; + + const result = mapResultRow(fields, row, joinsNotNullableMap); + + expect(result).toEqual({ + name: 'Test org', + slug: 'test-org', + branding: { + panelBackground: '#1a8cff', + logo: null, + }, + }); + }); +});