Skip to content

Commit fc67799

Browse files
committed
fix(react-native): honor declared OP-SQLite envelopes
1 parent 8c8d818 commit fc67799

3 files changed

Lines changed: 91 additions & 8 deletions

File tree

packages/react-native-db-sqlite-persistence/src/op-sqlite-driver.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,23 @@ function toRowArray(rowsValue: unknown): Array<unknown> | null {
181181
return null
182182
}
183183

184+
function isRowCarrier(rowsValue: unknown): boolean {
185+
if (Array.isArray(rowsValue)) {
186+
return true
187+
}
188+
189+
if (!isObjectRecord(rowsValue)) {
190+
return false
191+
}
192+
193+
const rowsObject = rowsValue as OpSQLiteRowListLike
194+
return (
195+
Array.isArray(rowsObject._array) ||
196+
(typeof rowsObject.length === `number` &&
197+
typeof rowsObject.item === `function`)
198+
)
199+
}
200+
184201
function isStatementResultEnvelope(value: Record<string, unknown>): boolean {
185202
if (!Object.keys(value).every((key) => STATEMENT_RESULT_KEYS.has(key))) {
186203
return false
@@ -189,14 +206,17 @@ function isStatementResultEnvelope(value: Record<string, unknown>): boolean {
189206
// Bare arrays are also a supported row carrier. A legitimate row can contain
190207
// only write-marker aliases, so markers alone cannot prove that an array is a
191208
// statement wrapper; wrappers need an actual row structure.
209+
const hasRowCarrier =
210+
isRowCarrier(value.rows) ||
211+
isRowCarrier(value.resultRows) ||
212+
Array.isArray(value.results)
192213
const hasStructuralCarrier =
193-
toRowArray(value.rows) !== null ||
194-
toRowArray(value.resultRows) !== null ||
214+
hasRowCarrier ||
195215
Array.isArray(value.rawRows) ||
196-
Array.isArray(value.columnNames) ||
197-
Array.isArray(value.results)
216+
Array.isArray(value.columnNames)
198217

199218
return (
219+
hasRowCarrier ||
200220
(hasWriteResultMarker(value) && hasStructuralCarrier) ||
201221
(Array.isArray(value.rawRows) && Array.isArray(value.columnNames))
202222
)

packages/react-native-db-sqlite-persistence/tests/helpers/op-sqlite-test-db.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ function formatWriteResult(
9494
return {
9595
rowsAffected,
9696
insertId,
97+
rows: [],
9798
}
9899
default:
99100
return {

packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,13 @@ async function withColumnarDriver<T>(
109109
* their names match envelope fields, including direct rows made only of write
110110
* marker aliases. When OP-SQLite supplies object rows and raw columnar rows
111111
* together, the already-decoded object rows are authoritative. Malformed or
112-
* undocumented result carriers reject. A bare array whose first entry can be
112+
* incomplete result carriers reject. A bare array whose first entry can be
113113
* both a row and a statement envelope has no self-describing interpretation;
114-
* its driver must declare `rows` or `statement-results` mode.
114+
* its driver must declare `rows` or `statement-results` mode. The declared
115+
* mode, rather than reserved field names, decides markerless `{ rows }`
116+
* wrappers.
115117
* Source: SQLiteDriver's public query contract and op-sqlite's documented
116-
* execute `{ rows, columnNames }` and executeAsync
118+
* required `QueryResult.rows`, execute `{ rows, columnNames }`, and executeAsync
117119
* `{ rawRows, columnNames, rowsAffected }` envelopes.
118120
* Domain: deterministic object-row wrappers and columnar rows, including
119121
* empty, asymmetric/reordered multirow, legal reserved-looking SQL aliases,
@@ -262,6 +264,37 @@ it(`requires an explicit mode for an ambiguous bare result array`, async () => {
262264
).resolves.toEqual([`nested`])
263265
})
264266

267+
it(`uses the declared mode for a markerless statement-array envelope`, async () => {
268+
const ambiguous = [{ rows: [{ id: `nested-row` }] }]
269+
270+
await expect(queryInjectedResult(ambiguous)).rejects.toThrow(
271+
`ambiguous bare result array`,
272+
)
273+
await expect(queryInjectedResult(ambiguous, `rows`)).resolves.toEqual(
274+
ambiguous,
275+
)
276+
await expect(
277+
queryInjectedResult(ambiguous, `statement-results`),
278+
).resolves.toEqual([{ id: `nested-row` }])
279+
})
280+
281+
it(`materializes a declared statement row list exactly once`, async () => {
282+
const expected = [{ id: `first` }, { id: `second` }]
283+
const requestedIndexes: Array<number> = []
284+
const rows = {
285+
length: expected.length,
286+
item: (index: number) => {
287+
requestedIndexes.push(index)
288+
return expected[index]
289+
},
290+
}
291+
292+
await expect(
293+
queryInjectedResult([{ rowsAffected: 0, rows }], `statement-results`),
294+
).resolves.toEqual(expected)
295+
expect(requestedIndexes).toEqual([0, 1])
296+
})
297+
265298
it(`reads op-sqlite execute rows when columnNames metadata is also present`, async () => {
266299
await expect(
267300
queryInjectedResult({
@@ -701,7 +734,7 @@ const malformedColumnarResults: ReadonlyArray<{
701734
result: { mysteryRows: [[`1`]] },
702735
},
703736
{
704-
name: `undocumented res carrier`,
737+
name: `optional res field without the required rows carrier`,
705738
result: { rowsAffected: 0, res: [{ id: `legacy` }] },
706739
},
707740
{
@@ -770,6 +803,35 @@ it(`supports exactly one results wrapper`, async () => {
770803
expect(queryExecutions).toBe(1)
771804
})
772805

806+
it(`rejects a multi-statement results wrapper instead of dropping results`, async () => {
807+
await expect(
808+
queryInjectedResult({
809+
results: [
810+
{ rows: [{ id: `first-statement` }] },
811+
{ rows: [{ id: `second-statement` }] },
812+
],
813+
}),
814+
).rejects.toThrow(`invalid nested results carrier`)
815+
})
816+
817+
it(`models OP-SQLite writes with an empty rows carrier`, async () => {
818+
const dbPath = createTempSqlitePath()
819+
const database = createOpSQLiteTestDatabase({
820+
filename: dbPath,
821+
resultShape: `execute-async-columnar`,
822+
})
823+
activeCleanupFns.push(() => Promise.resolve(database.close()))
824+
const executeAsync = database.executeAsync
825+
if (!executeAsync) {
826+
throw new Error(`columnar fixture must expose executeAsync`)
827+
}
828+
829+
await executeAsync(`CREATE TABLE write_receipt (id INTEGER PRIMARY KEY)`)
830+
await expect(
831+
executeAsync(`INSERT INTO write_receipt (id) VALUES (?)`, [1]),
832+
).resolves.toMatchObject({ rowsAffected: 1, rows: [] })
833+
})
834+
773835
it.each([
774836
{
775837
name: `second results wrapper`,

0 commit comments

Comments
 (0)