feat: Support Expo SQLite Next

* Add ExpoDriverFactory to create ExpoLegacyDriver

* Add driver for new Expo SQLite API

---------

Co-authored-by: Ruben Grimm <pmk1c@users.noreply.github.com>
Co-authored-by: Lucian Mocanu <alumni@users.noreply.github.com>
This commit is contained in:
Ruben Grimm 2025-04-01 23:27:34 +02:00 committed by GitHub
parent 3d79786a92
commit 7b242e1698
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 474 additions and 307 deletions

View File

@ -10,7 +10,7 @@ import { NativescriptDriver } from "./nativescript/NativescriptDriver"
import { SqljsDriver } from "./sqljs/SqljsDriver"
import { MysqlDriver } from "./mysql/MysqlDriver"
import { PostgresDriver } from "./postgres/PostgresDriver"
import { ExpoDriver } from "./expo/ExpoDriver"
import { ExpoDriverFactory } from "./expo/ExpoDriverFactory"
import { AuroraMysqlDriver } from "./aurora-mysql/AuroraMysqlDriver"
import { AuroraPostgresDriver } from "./aurora-postgres/AuroraPostgresDriver"
import { Driver } from "./Driver"
@ -59,7 +59,7 @@ export class DriverFactory {
case "mongodb":
return new MongoDriver(connection)
case "expo":
return new ExpoDriver(connection)
return new ExpoDriverFactory(connection).create()
case "aurora-mysql":
return new AuroraMysqlDriver(connection)
case "aurora-postgres":

View File

@ -3,90 +3,32 @@ import { ExpoConnectionOptions } from "./ExpoConnectionOptions"
import { ExpoQueryRunner } from "./ExpoQueryRunner"
import { QueryRunner } from "../../query-runner/QueryRunner"
import { DataSource } from "../../data-source/DataSource"
import { ReplicationMode } from "../types/ReplicationMode"
export class ExpoDriver extends AbstractSqliteDriver {
options: ExpoConnectionOptions
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
constructor(connection: DataSource) {
super(connection)
this.database = this.options.database
// load sqlite package
this.sqlite = this.options.driver
}
// -------------------------------------------------------------------------
// Public Methods
// -------------------------------------------------------------------------
/**
* Closes connection with database.
*/
async disconnect(): Promise<void> {
return new Promise<void>((ok, fail) => {
try {
this.queryRunner = undefined
this.databaseConnection._db.close()
this.databaseConnection = undefined
ok()
} catch (error) {
fail(error)
}
})
this.queryRunner = undefined
await this.databaseConnection.closeAsync()
this.databaseConnection = undefined
}
/**
* Creates a query runner used to execute database queries.
*/
createQueryRunner(mode: ReplicationMode): QueryRunner {
createQueryRunner(): QueryRunner {
if (!this.queryRunner) this.queryRunner = new ExpoQueryRunner(this)
return this.queryRunner
}
// -------------------------------------------------------------------------
// Protected Methods
// -------------------------------------------------------------------------
/**
* Creates connection with the database.
*/
protected createDatabaseConnection() {
return new Promise<void>((ok, fail) => {
try {
const databaseConnection = this.sqlite.openDatabase(
this.options.database,
)
/*
// we need to enable foreign keys in sqlite to make sure all foreign key related features
// working properly. this also makes onDelete work with sqlite.
*/
databaseConnection.transaction(
(tsx: any) => {
tsx.executeSql(
`PRAGMA foreign_keys = ON`,
[],
(t: any, result: any) => {
ok(databaseConnection)
},
(t: any, err: any) => {
fail({ transaction: t, error: err })
},
)
},
(err: any) => {
fail(err)
},
)
} catch (error) {
fail(error)
}
})
protected async createDatabaseConnection() {
this.databaseConnection = await this.sqlite.openDatabaseAsync(
this.options.database,
)
await this.databaseConnection.runAsync("PRAGMA foreign_keys = ON")
return this.databaseConnection
}
}

View File

@ -0,0 +1,23 @@
import { DataSource } from "../../data-source"
import { ExpoDriver } from "./ExpoDriver"
import { ExpoLegacyDriver } from "./legacy/ExpoLegacyDriver"
export class ExpoDriverFactory {
connection: DataSource
constructor(connection: DataSource) {
this.connection = connection
}
create(): ExpoDriver | ExpoLegacyDriver {
if (this.isLegacyDriver) {
return new ExpoLegacyDriver(this.connection)
}
return new ExpoDriver(this.connection)
}
private get isLegacyDriver(): boolean {
return !("openDatabaseAsync" in this.connection.options.driver)
}
}

View File

@ -1,49 +1,14 @@
import { QueryRunnerAlreadyReleasedError } from "../../error/QueryRunnerAlreadyReleasedError"
import { QueryFailedError } from "../../error/QueryFailedError"
import { AbstractSqliteQueryRunner } from "../sqlite-abstract/AbstractSqliteQueryRunner"
import { TransactionNotStartedError } from "../../error/TransactionNotStartedError"
import { ExpoDriver } from "./ExpoDriver"
import { Broadcaster } from "../../subscriber/Broadcaster"
import { QueryResult } from "../../query-runner/QueryResult"
import { BroadcasterResult } from "../../subscriber/BroadcasterResult"
// Needed to satisfy the Typescript compiler
interface IResultSet {
insertId: number | undefined
rowsAffected: number
rows: {
length: number
item: (idx: number) => any
_array: any[]
}
}
interface ITransaction {
executeSql: (
sql: string,
args: any[] | undefined,
ok: (tsx: ITransaction, resultSet: IResultSet) => void,
fail: (tsx: ITransaction, err: any) => void,
) => void
}
/**
* Runs queries on a single sqlite database connection.
*/
export class ExpoQueryRunner extends AbstractSqliteQueryRunner {
/**
* Database driver used by connection.
*/
driver: ExpoDriver
/**
* Database transaction object
*/
private transaction?: ITransaction
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
constructor(driver: ExpoDriver) {
super()
this.driver = driver
@ -51,111 +16,14 @@ export class ExpoQueryRunner extends AbstractSqliteQueryRunner {
this.broadcaster = new Broadcaster(this)
}
/**
* Starts transaction. Within Expo, all database operations happen in a
* transaction context, so issuing a `BEGIN TRANSACTION` command is
* redundant and will result in the following error:
*
* `Error: Error code 1: cannot start a transaction within a transaction`
*
* Instead, we keep track of a `Transaction` object in `this.transaction`
* and continue using the same object until we wish to commit the
* transaction.
*/
async startTransaction(): Promise<void> {
this.isTransactionActive = true
try {
await this.broadcaster.broadcast("BeforeTransactionStart")
} catch (err) {
this.isTransactionActive = false
throw err
}
this.transactionDepth += 1
await this.broadcaster.broadcast("AfterTransactionStart")
}
/**
* Commits transaction.
* Error will be thrown if transaction was not started.
* Since Expo will automatically commit the transaction once all the
* callbacks of the transaction object have been completed, "committing" a
* transaction in this driver's context means that we delete the transaction
* object and set the stage for the next transaction.
*/
async commitTransaction(): Promise<void> {
if (
!this.isTransactionActive &&
typeof this.transaction === "undefined"
)
throw new TransactionNotStartedError()
await this.broadcaster.broadcast("BeforeTransactionCommit")
this.transaction = undefined
this.isTransactionActive = false
this.transactionDepth -= 1
await this.broadcaster.broadcast("AfterTransactionCommit")
}
/**
* Rollbacks transaction.
* Error will be thrown if transaction was not started.
* This method's functionality is identical to `commitTransaction()` because
* the transaction lifecycle is handled within the Expo transaction object.
* Issuing separate statements for `COMMIT` or `ROLLBACK` aren't necessary.
*/
async rollbackTransaction(): Promise<void> {
if (
!this.isTransactionActive &&
typeof this.transaction === "undefined"
)
throw new TransactionNotStartedError()
await this.broadcaster.broadcast("BeforeTransactionRollback")
this.transaction = undefined
this.isTransactionActive = false
this.transactionDepth -= 1
await this.broadcaster.broadcast("AfterTransactionRollback")
}
/**
* Called before migrations are run.
*/
async beforeMigration(): Promise<void> {
const databaseConnection = await this.connect()
return new Promise((ok, fail) => {
databaseConnection.exec(
[{ sql: "PRAGMA foreign_keys = OFF", args: [] }],
false,
(err: any) => (err ? fail(err) : ok()),
)
})
await this.query("PRAGMA foreign_keys = OFF")
}
/**
* Called after migrations are run.
*/
async afterMigration(): Promise<void> {
const databaseConnection = await this.connect()
return new Promise((ok, fail) => {
databaseConnection.exec(
[{ sql: "PRAGMA foreign_keys = ON", args: [] }],
false,
(err: any) => (err ? fail(err) : ok()),
)
})
await this.query("PRAGMA foreign_keys = ON")
}
/**
* Executes a given SQL query.
*/
async query(
query: string,
parameters?: any[],
@ -163,117 +31,79 @@ export class ExpoQueryRunner extends AbstractSqliteQueryRunner {
): Promise<any> {
if (this.isReleased) throw new QueryRunnerAlreadyReleasedError()
return new Promise<any>(async (ok, fail) => {
const databaseConnection = await this.connect()
const broadcasterResult = new BroadcasterResult()
const databaseConnection = await this.connect()
const broadcasterResult = new BroadcasterResult()
this.driver.connection.logger.logQuery(query, parameters, this)
this.broadcaster.broadcastBeforeQueryEvent(
this.driver.connection.logger.logQuery(query, parameters, this)
this.broadcaster.broadcastBeforeQueryEvent(
broadcasterResult,
query,
parameters,
)
const queryStartTime = +new Date()
const statement = await databaseConnection.prepareAsync(query)
try {
const rawResult = await statement.executeAsync(parameters)
const maxQueryExecutionTime =
this.driver.options.maxQueryExecutionTime
const queryEndTime = +new Date()
const queryExecutionTime = queryEndTime - queryStartTime
this.broadcaster.broadcastAfterQueryEvent(
broadcasterResult,
query,
parameters,
true,
queryExecutionTime,
rawResult,
undefined,
)
await broadcasterResult.wait()
const queryStartTime = +new Date()
// All Expo SQL queries are executed in a transaction context
databaseConnection.transaction(
async (transaction: ITransaction) => {
if (typeof this.transaction === "undefined") {
await this.startTransaction()
this.transaction = transaction
}
this.transaction.executeSql(
query,
parameters,
async (t: ITransaction, raw: IResultSet) => {
// log slow queries if maxQueryExecution time is set
const maxQueryExecutionTime =
this.driver.options.maxQueryExecutionTime
const queryEndTime = +new Date()
const queryExecutionTime =
queryEndTime - queryStartTime
if (
maxQueryExecutionTime &&
queryExecutionTime > maxQueryExecutionTime
) {
this.driver.connection.logger.logQuerySlow(
queryExecutionTime,
query,
parameters,
this,
)
}
this.broadcaster.broadcastAfterQueryEvent(
broadcasterResult,
query,
parameters,
true,
queryExecutionTime,
raw,
undefined,
)
await broadcasterResult.wait()
const result = new QueryResult()
result.affected = rawResult.changes
result.records = await rawResult.getAllAsync()
result.raw = query.startsWith("INSERT INTO")
? rawResult.lastInsertRowId
: result.records
if (
maxQueryExecutionTime &&
queryExecutionTime > maxQueryExecutionTime
) {
this.driver.connection.logger.logQuerySlow(
queryExecutionTime,
query,
parameters,
this,
)
}
const result = new QueryResult()
if (raw?.hasOwnProperty("rowsAffected")) {
result.affected = raw.rowsAffected
}
if (raw?.hasOwnProperty("rows")) {
const resultSet = []
for (let i = 0; i < raw.rows.length; i++) {
resultSet.push(raw.rows.item(i))
}
result.raw = resultSet
result.records = resultSet
}
// return id of inserted row, if query was insert statement.
if (query.startsWith("INSERT INTO")) {
result.raw = raw.insertId
}
if (useStructuredResult) {
ok(result)
} else {
ok(result.raw)
}
},
async (t: ITransaction, err: any) => {
this.driver.connection.logger.logQueryError(
err,
query,
parameters,
this,
)
this.broadcaster.broadcastAfterQueryEvent(
broadcasterResult,
query,
parameters,
false,
undefined,
undefined,
err,
)
await broadcasterResult.wait()
fail(new QueryFailedError(query, parameters, err))
},
)
},
async (err: any) => {
await this.rollbackTransaction()
fail(err)
},
() => {
this.isTransactionActive = false
this.transaction = undefined
},
return useStructuredResult ? result : result.raw
} catch (err) {
this.driver.connection.logger.logQueryError(
err,
query,
parameters,
this,
)
})
this.broadcaster.broadcastAfterQueryEvent(
broadcasterResult,
query,
parameters,
false,
0,
undefined,
err,
)
await broadcasterResult.wait()
throw new QueryFailedError(query, parameters, err)
} finally {
await statement.finalizeAsync()
}
}
}

View File

@ -0,0 +1,93 @@
import { AbstractSqliteDriver } from "../../sqlite-abstract/AbstractSqliteDriver"
import { ExpoConnectionOptions } from "../ExpoConnectionOptions"
import { ExpoLegacyQueryRunner } from "./ExpoLegacyQueryRunner"
import { QueryRunner } from "../../../query-runner/QueryRunner"
import { DataSource } from "../../../data-source/DataSource"
import { ReplicationMode } from "../../types/ReplicationMode"
export class ExpoLegacyDriver extends AbstractSqliteDriver {
options: ExpoConnectionOptions
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
constructor(connection: DataSource) {
super(connection)
this.database = this.options.database
// load sqlite package
this.sqlite = this.options.driver
}
// -------------------------------------------------------------------------
// Public Methods
// -------------------------------------------------------------------------
/**
* Closes connection with database.
*/
async disconnect(): Promise<void> {
return new Promise<void>((ok, fail) => {
try {
this.queryRunner = undefined
this.databaseConnection._db.close()
this.databaseConnection = undefined
ok()
} catch (error) {
fail(error)
}
})
}
/**
* Creates a query runner used to execute database queries.
*/
createQueryRunner(mode: ReplicationMode): QueryRunner {
if (!this.queryRunner)
this.queryRunner = new ExpoLegacyQueryRunner(this)
return this.queryRunner
}
// -------------------------------------------------------------------------
// Protected Methods
// -------------------------------------------------------------------------
/**
* Creates connection with the database.
*/
protected createDatabaseConnection() {
return new Promise<void>((ok, fail) => {
try {
const databaseConnection = this.sqlite.openDatabase(
this.options.database,
)
/*
// we need to enable foreign keys in sqlite to make sure all foreign key related features
// working properly. this also makes onDelete work with sqlite.
*/
databaseConnection.transaction(
(tsx: any) => {
tsx.executeSql(
`PRAGMA foreign_keys = ON`,
[],
(t: any, result: any) => {
ok(databaseConnection)
},
(t: any, err: any) => {
fail({ transaction: t, error: err })
},
)
},
(err: any) => {
fail(err)
},
)
} catch (error) {
fail(error)
}
})
}
}

View File

@ -0,0 +1,279 @@
import { QueryRunnerAlreadyReleasedError } from "../../../error/QueryRunnerAlreadyReleasedError"
import { QueryFailedError } from "../../../error/QueryFailedError"
import { AbstractSqliteQueryRunner } from "../../sqlite-abstract/AbstractSqliteQueryRunner"
import { TransactionNotStartedError } from "../../../error/TransactionNotStartedError"
import { ExpoLegacyDriver } from "./ExpoLegacyDriver"
import { Broadcaster } from "../../../subscriber/Broadcaster"
import { QueryResult } from "../../../query-runner/QueryResult"
import { BroadcasterResult } from "../../../subscriber/BroadcasterResult"
// Needed to satisfy the Typescript compiler
interface IResultSet {
insertId: number | undefined
rowsAffected: number
rows: {
length: number
item: (idx: number) => any
_array: any[]
}
}
interface ITransaction {
executeSql: (
sql: string,
args: any[] | undefined,
ok: (tsx: ITransaction, resultSet: IResultSet) => void,
fail: (tsx: ITransaction, err: any) => void,
) => void
}
/**
* Runs queries on a single sqlite database connection.
*/
export class ExpoLegacyQueryRunner extends AbstractSqliteQueryRunner {
/**
* Database driver used by connection.
*/
driver: ExpoLegacyDriver
/**
* Database transaction object
*/
private transaction?: ITransaction
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
constructor(driver: ExpoLegacyDriver) {
super()
this.driver = driver
this.connection = driver.connection
this.broadcaster = new Broadcaster(this)
}
/**
* Starts transaction. Within Expo, all database operations happen in a
* transaction context, so issuing a `BEGIN TRANSACTION` command is
* redundant and will result in the following error:
*
* `Error: Error code 1: cannot start a transaction within a transaction`
*
* Instead, we keep track of a `Transaction` object in `this.transaction`
* and continue using the same object until we wish to commit the
* transaction.
*/
async startTransaction(): Promise<void> {
this.isTransactionActive = true
try {
await this.broadcaster.broadcast("BeforeTransactionStart")
} catch (err) {
this.isTransactionActive = false
throw err
}
this.transactionDepth += 1
await this.broadcaster.broadcast("AfterTransactionStart")
}
/**
* Commits transaction.
* Error will be thrown if transaction was not started.
* Since Expo will automatically commit the transaction once all the
* callbacks of the transaction object have been completed, "committing" a
* transaction in this driver's context means that we delete the transaction
* object and set the stage for the next transaction.
*/
async commitTransaction(): Promise<void> {
if (
!this.isTransactionActive &&
typeof this.transaction === "undefined"
)
throw new TransactionNotStartedError()
await this.broadcaster.broadcast("BeforeTransactionCommit")
this.transaction = undefined
this.isTransactionActive = false
this.transactionDepth -= 1
await this.broadcaster.broadcast("AfterTransactionCommit")
}
/**
* Rollbacks transaction.
* Error will be thrown if transaction was not started.
* This method's functionality is identical to `commitTransaction()` because
* the transaction lifecycle is handled within the Expo transaction object.
* Issuing separate statements for `COMMIT` or `ROLLBACK` aren't necessary.
*/
async rollbackTransaction(): Promise<void> {
if (
!this.isTransactionActive &&
typeof this.transaction === "undefined"
)
throw new TransactionNotStartedError()
await this.broadcaster.broadcast("BeforeTransactionRollback")
this.transaction = undefined
this.isTransactionActive = false
this.transactionDepth -= 1
await this.broadcaster.broadcast("AfterTransactionRollback")
}
/**
* Called before migrations are run.
*/
async beforeMigration(): Promise<void> {
const databaseConnection = await this.connect()
return new Promise((ok, fail) => {
databaseConnection.exec(
[{ sql: "PRAGMA foreign_keys = OFF", args: [] }],
false,
(err: any) => (err ? fail(err) : ok()),
)
})
}
/**
* Called after migrations are run.
*/
async afterMigration(): Promise<void> {
const databaseConnection = await this.connect()
return new Promise((ok, fail) => {
databaseConnection.exec(
[{ sql: "PRAGMA foreign_keys = ON", args: [] }],
false,
(err: any) => (err ? fail(err) : ok()),
)
})
}
/**
* Executes a given SQL query.
*/
async query(
query: string,
parameters?: any[],
useStructuredResult = false,
): Promise<any> {
if (this.isReleased) throw new QueryRunnerAlreadyReleasedError()
return new Promise<any>(async (ok, fail) => {
const databaseConnection = await this.connect()
const broadcasterResult = new BroadcasterResult()
this.driver.connection.logger.logQuery(query, parameters, this)
this.broadcaster.broadcastBeforeQueryEvent(
broadcasterResult,
query,
parameters,
)
const queryStartTime = +new Date()
// All Expo SQL queries are executed in a transaction context
databaseConnection.transaction(
async (transaction: ITransaction) => {
if (typeof this.transaction === "undefined") {
await this.startTransaction()
this.transaction = transaction
}
this.transaction.executeSql(
query,
parameters,
async (t: ITransaction, raw: IResultSet) => {
// log slow queries if maxQueryExecution time is set
const maxQueryExecutionTime =
this.driver.options.maxQueryExecutionTime
const queryEndTime = +new Date()
const queryExecutionTime =
queryEndTime - queryStartTime
this.broadcaster.broadcastAfterQueryEvent(
broadcasterResult,
query,
parameters,
true,
queryExecutionTime,
raw,
undefined,
)
await broadcasterResult.wait()
if (
maxQueryExecutionTime &&
queryExecutionTime > maxQueryExecutionTime
) {
this.driver.connection.logger.logQuerySlow(
queryExecutionTime,
query,
parameters,
this,
)
}
const result = new QueryResult()
if (raw?.hasOwnProperty("rowsAffected")) {
result.affected = raw.rowsAffected
}
if (raw?.hasOwnProperty("rows")) {
let resultSet = []
for (let i = 0; i < raw.rows.length; i++) {
resultSet.push(raw.rows.item(i))
}
result.raw = resultSet
result.records = resultSet
}
// return id of inserted row, if query was insert statement.
if (query.startsWith("INSERT INTO")) {
result.raw = raw.insertId
}
if (useStructuredResult) {
ok(result)
} else {
ok(result.raw)
}
},
async (t: ITransaction, err: any) => {
this.driver.connection.logger.logQueryError(
err,
query,
parameters,
this,
)
this.broadcaster.broadcastAfterQueryEvent(
broadcasterResult,
query,
parameters,
false,
undefined,
undefined,
err,
)
await broadcasterResult.wait()
fail(new QueryFailedError(query, parameters, err))
},
)
},
async (err: any) => {
await this.rollbackTransaction()
fail(err)
},
() => {
this.isTransactionActive = false
this.transaction = undefined
},
)
})
}
}