Skip to content
Merged
8 changes: 8 additions & 0 deletions .changeset/beige-actors-flash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@powersync/react-native': minor
---

Introduced `executeRaw` member to `RNQSDBAdapter` to match `DBAdapter` interface.
It handles SQLite query results differently to `execute` - to preserve all columns, preventing duplicate column names from being overwritten.

The implementation for RNQS will currently fall back to `execute`, preserving current behavior. Users requiring this functionality should migrate to `@powersync/op-sqlite`.
5 changes: 5 additions & 0 deletions .changeset/eleven-cups-rescue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@powersync/drizzle-driver': minor
---

Using `executeRaw` internally for queries instead of `execute`. This function processes SQLite query results differently to preserve all columns, preventing duplicate column names from being overwritten.
8 changes: 8 additions & 0 deletions .changeset/fair-squids-chew.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@powersync/op-sqlite': minor
'@powersync/common': minor
'@powersync/node': minor
'@powersync/web': minor
---

Introduced `executeRaw`, which processes SQLite query results differently to preserve all columns, preventing duplicate column names from being overwritten.
5 changes: 5 additions & 0 deletions packages/common/src/client/AbstractPowerSyncDatabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,11 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
return this.database.execute(sql, parameters);
}

async executeRaw(sql: string, parameters?: any[]) {
await this.waitForReady();
return this.database.executeRaw(sql, parameters);
}

/**
* Execute a write query (INSERT/UPDATE/DELETE) multiple times with each parameter set
* and optionally return results.
Expand Down
16 changes: 16 additions & 0 deletions packages/common/src/db/DBAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,21 @@ export interface DBGetUtils {
export interface LockContext extends DBGetUtils {
/** Execute a single write statement. */
execute: (query: string, params?: any[] | undefined) => Promise<QueryResult>;
/**
* Execute a single write statement and return raw results.
* Unlike `execute`, which returns an object with structured key-value pairs,
* `executeRaw` returns a nested array of raw values, where each row is
* represented as an array of column values without field names.
*
* Example result:
*
* ```[ [ '1', 'list 1', '33', 'Post content', '1' ] ]```
*
* Where as `execute`'s `rows._array` would have been:
*
* ```[ { id: '33', name: 'list 1', content: 'Post content', list_id: '1' } ]```
*/
executeRaw: (query: string, params?: any[] | undefined) => Promise<any[][]>;
}

export interface Transaction extends LockContext {
Expand Down Expand Up @@ -95,6 +110,7 @@ export interface DBLockOptions {
export interface DBAdapter extends BaseObserverInterface<DBAdapterListener>, DBGetUtils {
close: () => void | Promise<void>;
execute: (query: string, params?: any[]) => Promise<QueryResult>;
executeRaw: (query: string, params?: any[]) => Promise<any[][]>;
executeBatch: (query: string, params?: any[][]) => Promise<QueryResult>;
name: string;
readLock: <T>(fn: (tx: LockContext) => Promise<T>, options?: DBLockOptions) => Promise<T>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,12 @@ export class PowerSyncSQLitePreparedQuery<
}

const rows = (await this.values(placeholderValues)) as unknown[][];
const valueRows = rows.map((row) => Object.values(row));

if (customResultMapper) {
const mapped = customResultMapper(valueRows) as T['all'];
const mapped = customResultMapper(rows) as T['all'];
return mapped;
}
return valueRows.map((row) => mapResultRow(fields!, row, (this as any).joinsNotNullableMap));
return rows.map((row) => mapResultRow(fields!, row, (this as any).joinsNotNullableMap));
}

async get(placeholderValues?: Record<string, unknown>): Promise<T['get']> {
Expand All @@ -80,18 +80,17 @@ export class PowerSyncSQLitePreparedQuery<
}

if (customResultMapper) {
const valueRows = rows.map((row) => Object.values(row));
return customResultMapper(valueRows) as T['get'];
return customResultMapper(rows) as T['get'];
}

return mapResultRow(fields!, Object.values(row), joinsNotNullableMap);
return mapResultRow(fields!, row, joinsNotNullableMap);
}

async values(placeholderValues?: Record<string, unknown>): Promise<T['values']> {
const params = fillPlaceholders(this.query.params, placeholderValues ?? {});
this.logger.logQuery(this.query.sql, params);
const rs = await this.db.execute(this.query.sql, params);
return rs.rows?._array ?? [];

return await this.db.executeRaw(this.query.sql, params);
}

isResponseInArrayMode(): boolean {
Expand Down
5 changes: 3 additions & 2 deletions packages/drizzle-driver/tests/sqlite/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,10 @@ describe('PowerSyncSQLitePreparedQuery', () => {
const preparedQuery = new PowerSyncSQLitePreparedQuery(powerSyncDb, query, loggerMock, undefined, 'all', false);

const values = await preparedQuery.values();

expect(values).toEqual([
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' }
['1', 'Alice'],
['2', 'Bob']
]);
});
});
120 changes: 120 additions & 0 deletions packages/drizzle-driver/tests/sqlite/relationship.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { AbstractPowerSyncDatabase, column, Schema, Table } from '@powersync/common';
import { PowerSyncDatabase } from '@powersync/web';
import { eq, relations } from 'drizzle-orm';
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import * as SUT from '../../src/sqlite/PowerSyncSQLiteDatabase';

const users = new Table({
name: column.text
});

const posts = new Table({
content: column.text,
title: column.text,
user_id: column.text
});

const drizzleUsers = sqliteTable('users', {
id: text('id').primaryKey().notNull(),
name: text('name').notNull()
});

const drizzlePosts = sqliteTable('posts', {
id: text('id').primaryKey().notNull(),
content: text('content').notNull(),
title: text('title').notNull(),
user_id: text('user_id')
.notNull()
.references(() => drizzleUsers.id)
});

const usersRelations = relations(drizzleUsers, ({ one, many }) => ({
posts: many(drizzlePosts)
}));

const postsRelations = relations(drizzlePosts, ({ one }) => ({
user: one(drizzleUsers, {
fields: [drizzlePosts.user_id],
references: [drizzleUsers.id]
})
}));

const PsSchema = new Schema({ users, posts });
const DrizzleSchema = { users: drizzleUsers, posts: drizzlePosts, usersRelations, postsRelations };

describe('Relationship tests', () => {
let powerSyncDb: AbstractPowerSyncDatabase;
let db: SUT.PowerSyncSQLiteDatabase<typeof DrizzleSchema>;

beforeEach(async () => {
powerSyncDb = new PowerSyncDatabase({
database: {
dbFilename: 'test.db'
},
schema: PsSchema
});
db = SUT.wrapPowerSyncWithDrizzle(powerSyncDb, { schema: DrizzleSchema, logger: { logQuery: () => {} } });

await powerSyncDb.init();

await db.insert(drizzleUsers).values({ id: '1', name: 'Alice' });
await db.insert(drizzlePosts).values({ id: '33', content: 'Post content', title: 'Post title', user_id: '1' });
});

afterEach(async () => {
await powerSyncDb?.disconnectAndClear();
});

it('should retrieve a user with posts', async () => {
const result = await db.query.users.findMany({ with: { posts: true } });

expect(result).toEqual([
{ id: '1', name: 'Alice', posts: [{ id: '33', content: 'Post content', title: 'Post title', user_id: '1' }] }
]);
});

it('should retrieve a post with its user', async () => {
const result = await db.query.posts.findMany({ with: { user: true } });

expect(result).toEqual([
{
id: '33',
content: 'Post content',
title: 'Post title',
user_id: '1',
user: { id: '1', name: 'Alice' }
}
]);
});

it('should return a user and posts using leftJoin', async () => {
const result = await db
.select()
.from(drizzleUsers)
.leftJoin(drizzlePosts, eq(drizzleUsers.id, drizzlePosts.user_id));

expect(result[0].users).toEqual({ id: '1', name: 'Alice' });
expect(result[0].posts).toEqual({ id: '33', content: 'Post content', title: 'Post title', user_id: '1' });
});

it('should return a user and posts using rightJoin', async () => {
const result = await db
.select()
.from(drizzleUsers)
.rightJoin(drizzlePosts, eq(drizzleUsers.id, drizzlePosts.user_id));

expect(result[0].users).toEqual({ id: '1', name: 'Alice' });
expect(result[0].posts).toEqual({ id: '33', content: 'Post content', title: 'Post title', user_id: '1' });
});

it('should return a user and posts using fullJoin', async () => {
const result = await db
.select()
.from(drizzleUsers)
.fullJoin(drizzlePosts, eq(drizzleUsers.id, drizzlePosts.user_id));

expect(result[0].users).toEqual({ id: '1', name: 'Alice' });
expect(result[0].posts).toEqual({ id: '33', content: 'Post content', title: 'Post title', user_id: '1' });
});
});
2 changes: 2 additions & 0 deletions packages/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@
},
"devDependencies": {
"@types/async-lock": "^1.4.0",
"drizzle-orm": "^0.35.2",
"@powersync/drizzle-driver": "workspace:*",
"rollup": "4.14.3",
"typescript": "^5.5.3",
"vitest": "^3.0.5"
Expand Down
1 change: 1 addition & 0 deletions packages/node/src/db/AsyncDatabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface AsyncDatabaseOpener {

export interface AsyncDatabase {
execute: (query: string, params: any[]) => Promise<ProxiedQueryResult>;
executeRaw: (query: string, params: any[]) => Promise<any[][]>;
executeBatch: (query: string, params: any[][]) => Promise<ProxiedQueryResult>;
close: () => Promise<void>;
// Collect table updates made since the last call to collectCommittedUpdates.
Expand Down
7 changes: 6 additions & 1 deletion packages/node/src/db/BetterSQLite3DBAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export class BetterSQLite3DBAdapter extends BaseObserver<DBAdapterListener> impl
if (isCommonJsModule) {
worker = workerFactory(path.resolve(__dirname, 'DefaultWorker.cjs'), { name: workerName });
} else {
worker = workerFactory(new URL('./DefaultWorker.js', import.meta.url), { name: workerName});
worker = workerFactory(new URL('./DefaultWorker.js', import.meta.url), { name: workerName });
}

const listeners = new WeakMap<EventListenerOrEventListenerObject, (e: any) => void>();
Expand Down Expand Up @@ -231,6 +231,7 @@ export class BetterSQLite3DBAdapter extends BaseObserver<DBAdapterListener> impl
await connection.execute('BEGIN');
const result = await fn({
execute: (query, params) => connection.execute(query, params),
executeRaw: (query, params) => connection.executeRaw(query, params),
executeBatch: (query, params) => connection.executeBatch(query, params),
get: (query, params) => connection.get(query, params),
getAll: (query, params) => connection.getAll(query, params),
Expand Down Expand Up @@ -267,6 +268,10 @@ export class BetterSQLite3DBAdapter extends BaseObserver<DBAdapterListener> impl
return this.writeLock((ctx) => ctx.execute(query, params));
}

executeRaw(query: string, params?: any[] | undefined): Promise<any[][]> {
return this.writeLock((ctx) => ctx.executeRaw(query, params));
}

executeBatch(query: string, params?: any[][]): Promise<QueryResult> {
return this.writeTransaction((ctx) => ctx.executeBatch(query, params));
}
Expand Down
4 changes: 4 additions & 0 deletions packages/node/src/db/RemoteConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export class RemoteConnection implements LockContext {
return RemoteConnection.wrapQueryResult(result);
}

async executeRaw(query: string, params?: any[] | undefined): Promise<any[][]> {
return await this.database.executeRaw(query, params ?? []);
}

async getAll<T>(sql: string, parameters?: any[]): Promise<T[]> {
const res = await this.execute(sql, parameters);
return res.rows?._array ?? [];
Expand Down
11 changes: 11 additions & 0 deletions packages/node/src/db/SqliteWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,17 @@ class BlockingAsyncDatabase implements AsyncDatabase {
}
}

async executeRaw(query: string, params: any[]) {
const stmt = this.db.prepare(query);

if (stmt.reader) {
return stmt.raw().all(params);
} else {
stmt.raw().run(params);
return [];
}
}

async executeBatch(query: string, params: any[][]) {
params = params ?? [];

Expand Down
Loading