Skip to content

Commit 2384ce9

Browse files
Gioeeclaude
andcommitted
fix: keep first duplicate column name and align columnsNames with row keys
Duplicate column names in a rowset (e.g. SELECT * over a join) were renamed starting from the first occurrence, so row.id returned the last id column instead of the first. columnsNames also returned the raw metadata names, which did not match the keys used in each row. - keep the first occurrence of a name and suffix later duplicates with _1, _2, skipping suffixes already used by another column - columnsNames now returns the same unique names used as row keys - add originalColumnsNames with the names exactly as returned by the query Fixes #282 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Whr3vp8MzsGxBLmh4jJFuG
1 parent bb3026e commit 2384ce9

2 files changed

Lines changed: 111 additions & 14 deletions

File tree

‎src/drivers/rowset.ts‎

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -47,22 +47,38 @@ export class SQLiteCloudRowset extends Array<SQLiteCloudRow> {
4747
this.#metadata = metadata
4848
this.#data = data
4949

50-
// adjust missing column names, duplicate column names, etc.
51-
const columnNames = this.columnsNames
52-
for (let i = 0; i < metadata.numberOfColumns; i++) {
53-
if (!columnNames[i]) {
54-
columnNames[i] = `column_${i}`
55-
}
56-
let j = 0
57-
while (columnNames.findIndex((name, index) => index !== i && name === columnNames[i]) >= 0) {
58-
columnNames[i] = `${columnNames[i]}_${j}`
59-
j++
60-
}
61-
}
50+
this.#columnsNames = SQLiteCloudRowset.getUniqueColumnsNames(this.originalColumnsNames, metadata.numberOfColumns)
6251

6352
for (let i = 0, start = 0; i < metadata.numberOfRows; i++, start += metadata.numberOfColumns) {
64-
this[i] = new SQLiteCloudRow(this, columnNames, data.slice(start, start + metadata.numberOfColumns))
53+
this[i] = new SQLiteCloudRow(this, this.#columnsNames, data.slice(start, start + metadata.numberOfColumns))
54+
}
55+
}
56+
57+
/**
58+
* Adjust missing and duplicate column names so they can be used as row keys.
59+
* Missing names become column_N, the first occurrence of a name is kept as is
60+
* and later duplicates are suffixed with _1, _2, etc. skipping suffixes that
61+
* are already used by another column, eg: id, id, id_1 becomes id, id_2, id_1
62+
*/
63+
private static getUniqueColumnsNames(originalNames: string[], numberOfColumns: number): string[] {
64+
const names: string[] = []
65+
for (let i = 0; i < numberOfColumns; i++) {
66+
names.push(originalNames[i] || `column_${i}`)
67+
}
68+
69+
const reserved = new Set(names)
70+
const used = new Set<string>()
71+
for (let i = 0; i < names.length; i++) {
72+
if (used.has(names[i])) {
73+
let j = 1
74+
while (reserved.has(`${names[i]}_${j}`) || used.has(`${names[i]}_${j}`)) {
75+
j++
76+
}
77+
names[i] = `${names[i]}_${j}`
78+
}
79+
used.add(names[i])
6580
}
81+
return names
6682
}
6783

6884
/** Metadata contains number of rows and columns, column names, types, etc. */
@@ -71,6 +87,9 @@ export class SQLiteCloudRowset extends Array<SQLiteCloudRow> {
7187
/** Actual data organized in rows */
7288
#data: SQLiteCloudDataTypes[]
7389

90+
/** Unique column names used as keys in each row */
91+
#columnsNames: string[]
92+
7493
/**
7594
* Rowset version is 1 for a rowset with simple column names, 2 for extended metadata
7695
* @see https://github.com/sqlitecloud/sdk/blob/master/PROTOCOL.md
@@ -89,8 +108,13 @@ export class SQLiteCloudRowset extends Array<SQLiteCloudRow> {
89108
return this.#metadata.numberOfColumns
90109
}
91110

92-
/** Array of columns names */
111+
/** Array of unique columns names, the same names used as keys in each row (missing names are replaced, duplicates are suffixed) */
93112
get columnsNames(): string[] {
113+
return [...this.#columnsNames]
114+
}
115+
116+
/** Array of columns names exactly as returned by the query, may contain duplicates */
117+
get originalColumnsNames(): string[] {
94118
return this.#metadata.columns.map(column => column.name)
95119
}
96120

‎test/rowset.test.ts‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,76 @@ describe('rowset', () => {
175175
})
176176
})
177177
})
178+
179+
describe('rowset column names', () => {
180+
function createRowset(names: (string | undefined)[], data: any[]): SQLiteCloudRowset {
181+
return new SQLiteCloudRowset(
182+
{
183+
version: 1,
184+
numberOfRows: data.length / names.length,
185+
numberOfColumns: names.length,
186+
columns: names.map(name => ({ name }) as any)
187+
},
188+
data
189+
)
190+
}
191+
192+
it('keeps the first duplicate column name and suffixes the following ones', () => {
193+
const rowset = createRowset(['id', 'id', 'id'], [1, 2, 3])
194+
expect(rowset.columnsNames).toEqual(['id', 'id_1', 'id_2'])
195+
expect(rowset.originalColumnsNames).toEqual(['id', 'id', 'id'])
196+
expect({ ...rowset[0] }).toEqual({ id: 1, id_1: 2, id_2: 3 })
197+
expect(rowset[0].getData()).toEqual([1, 2, 3])
198+
})
199+
200+
it('returns columnsNames matching the row keys', () => {
201+
const rowset = createRowset(['id', 'name', 'id', 'customer_id', 'name'], [7, 'Ada', 42, 7, 'Laptop'])
202+
expect(rowset.columnsNames).toEqual(['id', 'name', 'id_1', 'customer_id', 'name_1'])
203+
expect(rowset.columnsNames.map(name => rowset[0][name])).toEqual([7, 'Ada', 42, 7, 'Laptop'])
204+
})
205+
206+
it('skips suffixes already used by other columns', () => {
207+
const rowset = createRowset(['id', 'id', 'id_1', 'id_2', 'id'], [1, 2, 3, 4, 5])
208+
expect(rowset.columnsNames).toEqual(['id', 'id_3', 'id_1', 'id_2', 'id_4'])
209+
expect({ ...rowset[0] }).toEqual({ id: 1, id_3: 2, id_1: 3, id_2: 4, id_4: 5 })
210+
})
211+
212+
it('names missing columns by position', () => {
213+
const rowset = createRowset(['', 'a', undefined, 'column_1'], [1, 2, 3, 4])
214+
expect(rowset.columnsNames).toEqual(['column_0', 'a', 'column_2', 'column_1'])
215+
expect(rowset.originalColumnsNames).toEqual(['', 'a', undefined, 'column_1'])
216+
})
217+
218+
it('does not expose internal column names to mutation', () => {
219+
const rowset = createRowset(['id', 'id'], [1, 2])
220+
rowset.columnsNames.push('other')
221+
expect(rowset.columnsNames).toEqual(['id', 'id_1'])
222+
})
223+
224+
it('keeps unique column names after slice and filter', () => {
225+
const rowset = createRowset(['id', 'id'], [1, 2, 3, 4, 5, 6])
226+
const sliced = rowset.slice(1) as SQLiteCloudRowset
227+
expect(sliced.columnsNames).toEqual(['id', 'id_1'])
228+
expect({ ...sliced[0] }).toEqual({ id: 3, id_1: 4 })
229+
const filtered = rowset.filter(row => row.id === 5) as SQLiteCloudRowset
230+
expect(filtered.columnsNames).toEqual(['id', 'id_1'])
231+
expect({ ...filtered[0] }).toEqual({ id: 5, id_1: 6 })
232+
})
233+
234+
it('keeps the first duplicate column name for joined tables', done => {
235+
const connection = getChinookTlsConnection()
236+
connection.sendCommands(
237+
'SELECT a.AlbumId, a.Title, t.TrackId AS AlbumId, t.Name AS Title FROM albums a JOIN tracks t ON t.AlbumId = a.AlbumId WHERE t.TrackId = 6;',
238+
(error, rowset) => {
239+
expect(error).toBeNull()
240+
expect(rowset.originalColumnsNames).toEqual(['AlbumId', 'Title', 'AlbumId', 'Title'])
241+
expect(rowset.columnsNames).toEqual(['AlbumId', 'Title', 'AlbumId_1', 'Title_1'])
242+
expect({ ...rowset[0] }).toEqual({ AlbumId: 1, Title: 'For Those About To Rock We Salute You', AlbumId_1: 6, Title_1: 'Put The Finger On You' })
243+
expect(rowset.columnsNames.map(name => rowset[0][name])).toEqual(rowset[0].getData())
244+
245+
done()
246+
connection.close()
247+
}
248+
)
249+
})
250+
})

0 commit comments

Comments
 (0)