Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/electric-db-collection/src/electric.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,11 +374,18 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({

const compileOptions = encodeColumnName ? { encodeColumnName } : undefined

debug(
`${collectionId ? `[${collectionId}] ` : ``}createLoadSubsetDedupe: columnMapper.encode is ${encodeColumnName ? `configured` : `NOT configured`}`,
)

const loadSubset = async (opts: LoadSubsetOptions) => {
// In progressive mode, use fetchSnapshot during snapshot phase
if (isBufferingInitialSync()) {
// Progressive mode snapshot phase: fetch and apply immediately
const snapshotParams = compileSQL<T>(opts, compileOptions)
debug(
`${collectionId ? `[${collectionId}] ` : ``}loadSubset compiled WHERE: ${snapshotParams.where}`,
)
try {
const { data: rows } = await stream.fetchSnapshot(snapshotParams)

Expand Down Expand Up @@ -466,6 +473,9 @@ function createLoadSubsetDedupe<T extends Row<unknown>>({
} else {
// No cursor - standard single request
const snapshotParams = compileSQL<T>(opts, compileOptions)
debug(
`${collectionId ? `[${collectionId}] ` : ``}loadSubset compiled WHERE: ${snapshotParams.where}`,
)
await stream.requestSnapshot(snapshotParams)
}
}
Expand Down
116 changes: 115 additions & 1 deletion packages/electric-db-collection/tests/electric.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,30 @@ import {
createTransaction,
} from '@tanstack/db'
import { electricCollectionOptions, isChangeMessage } from '../src/electric'
import type { ElectricCollectionUtils } from '../src/electric'
import type {
Collection,
IR,
InsertMutationFnParams,
MutationFnParams,
PendingMutation,
Transaction,
TransactionWithMutations,
} from '@tanstack/db'
import type { ElectricCollectionUtils } from '../src/electric'
import type { Message, Row } from '@electric-sql/client'
import type { StandardSchemaV1 } from '@standard-schema/spec'

// Helper functions for creating IR expressions (same as sql-compiler.test.ts)
function val<T>(value: T): IR.BasicExpression<T> {
return { type: `val`, value } as IR.BasicExpression<T>
}
function ref(...path: Array<string>): IR.BasicExpression {
return { type: `ref`, path } as IR.BasicExpression
}
function func(name: string, args: Array<any>): IR.BasicExpression<boolean> {
return { type: `func`, name, args } as IR.BasicExpression<boolean>
}

// Mock the ShapeStream module
const mockSubscribe = vi.fn()
const mockRequestSnapshot = vi.fn()
Expand Down Expand Up @@ -2842,6 +2854,108 @@ describe(`Electric Integration`, () => {
}),
)
})

it(`should encode column names using columnMapper.encode in loadSubset WHERE clause`, async () => {
vi.clearAllMocks()

// Helper to convert camelCase to snake_case (simulating snakeCamelMapper's encode)
const camelToSnake = (str: string): string =>
str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)

const config = {
id: `column-mapper-encode-test`,
shapeOptions: {
url: `http://test-url`,
params: {
table: `test_table`,
},
// Configure columnMapper to convert camelCase to snake_case
columnMapper: {
encode: camelToSnake,
decode: (name: string) => name, // Not used in this test
},
},
syncMode: `on-demand` as const,
getKey: (item: Row) => item.id as number,
startSync: true,
}

const testCollection = createCollection(electricCollectionOptions(config))

// Send up-to-date to mark collection as ready
subscriber([
{
headers: { control: `up-to-date` },
},
])

// Call loadSubset with a WHERE clause using camelCase column names
// The column names should be encoded to snake_case
await testCollection._sync.loadSubset({
where: func(`and`, [
func(`eq`, [ref(`isArchived`), val(false)]),
func(`eq`, [ref(`isDeleted`), val(false)]),
]),
limit: 10,
})

// Verify requestSnapshot was called
expect(mockRequestSnapshot).toHaveBeenCalled()

// Get the params that were passed to requestSnapshot
const callArgs = mockRequestSnapshot.mock.calls[0]![0]

// The WHERE clause should have snake_case column names
// The format is: ("column_name" = $1)
expect(callArgs.where).toContain(`"is_archived"`)
expect(callArgs.where).toContain(`"is_deleted"`)

// Should NOT contain the original camelCase names
expect(callArgs.where).not.toContain(`"isArchived"`)
expect(callArgs.where).not.toContain(`"isDeleted"`)
})

it(`should not encode column names when columnMapper is not provided`, async () => {
vi.clearAllMocks()

const config = {
id: `no-column-mapper-test`,
shapeOptions: {
url: `http://test-url`,
params: {
table: `test_table`,
},
// No columnMapper configured
},
syncMode: `on-demand` as const,
getKey: (item: Row) => item.id as number,
startSync: true,
}

const testCollection = createCollection(electricCollectionOptions(config))

// Send up-to-date to mark collection as ready
subscriber([
{
headers: { control: `up-to-date` },
},
])

// Call loadSubset with a WHERE clause using camelCase column names
await testCollection._sync.loadSubset({
where: func(`eq`, [ref(`isArchived`), val(false)]),
limit: 10,
})

// Verify requestSnapshot was called
expect(mockRequestSnapshot).toHaveBeenCalled()

// Get the params that were passed to requestSnapshot
const callArgs = mockRequestSnapshot.mock.calls[0]![0]

// The WHERE clause should preserve camelCase names when no columnMapper
expect(callArgs.where).toContain(`"isArchived"`)
})
})

// Tests for overlapping subset queries with duplicate keys
Expand Down
Loading