@itwin/core-backend
Version:
iTwin.js backend components
385 lines • 18.1 kB
JavaScript
;
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
/** @packageDocumentation
* @module SQLiteDb
*/
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
if (value !== null && value !== void 0) {
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
var dispose, inner;
if (async) {
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
dispose = value[Symbol.asyncDispose];
}
if (dispose === void 0) {
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
dispose = value[Symbol.dispose];
if (async) inner = dispose;
}
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
env.stack.push({ value: value, dispose: dispose, async: async });
}
else if (async) {
env.stack.push({ async: true });
}
return value;
};
var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
return function (env) {
function fail(e) {
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
env.hasError = true;
}
var r, s = 0;
function next() {
while (r = env.stack.pop()) {
try {
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
if (r.dispose) {
var result = r.dispose.call(r.value);
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
}
else s |= 1;
}
catch (e) {
fail(e);
}
}
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
if (env.hasError) throw env.error;
}
return next();
};
})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
var e = new Error(message);
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
});
Object.defineProperty(exports, "__esModule", { value: true });
exports.VersionedSqliteDb = exports.SQLiteDb = void 0;
const fs = require("fs");
const path_1 = require("path");
const semver = require("semver");
const core_bentley_1 = require("@itwin/core-bentley");
const core_common_1 = require("@itwin/core-common");
const CloudSqlite_1 = require("./CloudSqlite");
const NativePlatform_1 = require("./internal/NativePlatform");
const IModelJsFs_1 = require("./IModelJsFs");
const SqliteStatement_1 = require("./SqliteStatement");
const Symbols_1 = require("./internal/Symbols");
// cspell:ignore savepoint julianday rowid
/* eslint-disable @typescript-eslint/unified-signatures */
/**
* A "generic" SQLiteDb. This class may be used to access local files or databases in a cloud container.
* @public
*/
class SQLiteDb {
/** @internal */
[Symbols_1._nativeDb] = new NativePlatform_1.IModelNative.platform.SQLiteDb();
_sqliteStatementCache = new SqliteStatement_1.StatementCache();
/** @internal */
static createBlobIO() {
return new NativePlatform_1.IModelNative.platform.BlobIO();
}
/** alias for closeDb.
* @deprecated in 4.0 - will not be removed until after 2026-06-13. Use [[closeDb]]
*/
dispose() {
this.closeDb();
}
createDb(dbName, container, params) {
this[Symbols_1._nativeDb].createDb(dbName, container, params);
}
openDb(dbName, openMode, container) {
this[Symbols_1._nativeDb].openDb(dbName, openMode, container);
}
/** Close SQLiteDb.
* @param saveChanges if true, call `saveChanges` before closing db. Otherwise unsaved changes are abandoned.
*/
closeDb(saveChanges) {
if (saveChanges && this.isOpen)
this.saveChanges();
this._sqliteStatementCache.clear();
this[Symbols_1._nativeDb].closeDb();
}
/** Returns true if this SQLiteDb is open */
get isOpen() { return this[Symbols_1._nativeDb].isOpen(); }
/** Returns true if this SQLiteDb is open readonly */
get isReadonly() { return this[Symbols_1._nativeDb].isReadonly(); }
/** Create a new table in this database. */
createTable(args) {
const timestampCol = args.addTimestamp ? ",lastMod TIMESTAMP NOT NULL DEFAULT(julianday('now'))" : "";
const constraints = args.constraints ? `,${args.constraints}` : "";
this.executeSQL(`CREATE TABLE ${args.tableName}(${args.columns}${timestampCol}${constraints})`);
if (args.addTimestamp)
this.executeSQL(`CREATE TRIGGER ${args.tableName}_timestamp AFTER UPDATE ON ${args.tableName} WHEN old.lastMod=new.lastMod AND old.lastMod != julianday('now') BEGIN UPDATE ${args.tableName} SET lastMod=julianday('now') WHERE rowid=new.rowid; END`);
}
/**
* Get the last modified date for a row in a table of this database.
* @note the table must have been created with `addTimestamp: true`
*/
readLastModTime(tableName, rowId) {
return this.withSqliteStatement(`SELECT lastMod from ${tableName} WHERE rowid=?`, (stmt) => {
stmt.bindInteger(1, rowId);
return stmt.getValueDate(0);
});
}
/**
* Open a database, perform an operation, then close the database.
*
* Details:
* - if database is open, throw an error
* - open a database
* - call a function with the database opened. If it is async, await its return.
* - if function throws, abandon all changes, close database, and rethrow
* - save all changes
* - close the database
* @return value from operation
*/
withOpenDb(args, operation) {
if (this.isOpen)
core_common_1.SqliteError.throwError("already-open", "database is already open", args.dbName);
const save = () => this.closeDb(true), abandon = () => this.closeDb(false);
this.openDb(args.dbName, args.openMode ?? core_bentley_1.OpenMode.Readonly, args.container);
try {
const result = operation();
result instanceof Promise ? result.then(save, abandon) : save();
return result;
}
catch (e) {
abandon();
throw e;
}
}
/** The cloud container backing this SQLite database, if any.
* @beta
*/
get cloudContainer() {
return this[Symbols_1._nativeDb].cloudContainer;
}
/** Returns the Id of the most-recently-inserted row in this database, per [sqlite3_last_insert_rowid](https://www.sqlite.org/c3ref/last_insert_rowid.html). */
getLastInsertRowId() {
return this[Symbols_1._nativeDb].getLastInsertRowId();
}
/**
* Perform an operation on a database in a CloudContainer with the write lock held.
*
* Details:
* - acquire the write lock on a CloudContainer
* - call `withOpenDb` with openMode `ReadWrite`
* - upload changes
* - release the write lock
* @param args arguments to lock the container and open the database
* @param operation an operation performed on the database with the write lock held.
* @return value from operation
* @internal
*/
async withLockedContainer(args, operation) {
return CloudSqlite_1.CloudSqlite.withWriteLock(args, async () => this.withOpenDb({ ...args, openMode: args.openMode ?? core_bentley_1.OpenMode.ReadWrite }, operation));
}
/** vacuum this database
* @see https://www.sqlite.org/lang_vacuum.html
*/
vacuum(args) {
this[Symbols_1._nativeDb].vacuum(args);
}
/** Commit the outermost transaction, writing changes to the file. Then, restart the default transaction. */
saveChanges() {
this[Symbols_1._nativeDb].saveChanges();
}
/** Abandon (cancel) the outermost transaction, discarding all changes since last save. Then, restart the default transaction. */
abandonChanges() {
this[Symbols_1._nativeDb].abandonChanges();
}
/**
* Use a prepared SQL statement, potentially from the statement cache. If the requested statement doesn't exist
* in the statement cache, a new statement is prepared. After the callback completes, the statement is reset and saved
* in the statement cache so it can be reused in the future. Use this method for SQL statements that will be
* reused often and are expensive to prepare. The statement cache holds the most recently used statements, discarding
* the oldest statements as it fills. For statements you don't intend to reuse, instead use [[withSqliteStatement]].
* @param sql The SQLite SQL statement to execute
* @param callback the callback to invoke on the prepared statement
* @returns the value returned by `callback`.
*/
withPreparedSqliteStatement(sql, callback) {
const stmt = this._sqliteStatementCache.findAndRemove(sql) ?? this.prepareSqliteStatement(sql);
const release = () => this._sqliteStatementCache.addOrDispose(stmt);
try {
const val = callback(stmt);
val instanceof Promise ? val.then(release, release) : release();
return val;
}
catch (err) {
release();
throw err;
}
}
/**
* Prepare and execute a callback on a SQL statement. After the callback completes the statement is disposed.
* Use this method for SQL statements are either not expected to be reused, or are not expensive to prepare.
* For statements that will be reused often, instead use [[withPreparedSqliteStatement]].
* @param sql The SQLite SQL statement to execute
* @param callback the callback to invoke on the prepared statement
* @returns the value returned by `callback`.
*/
withSqliteStatement(sql, callback) {
const stmt = this.prepareSqliteStatement(sql);
const release = () => stmt[Symbol.dispose]();
try {
const val = callback(stmt);
val instanceof Promise ? val.then(release, release) : release();
return val;
}
catch (err) {
release();
throw err;
}
}
/**
* Perform an operation on this database within a [savepoint](https://www.sqlite.org/lang_savepoint.html). If the operation completes successfully, the
* changes remain in the current transaction. If the operation throws an exception, the savepoint is rolled back
* and all changes to the database from this method are reversed, leaving the transaction exactly as it was before this method.
*/
withSavePoint(savePointName, operation) {
if (this.isReadonly)
core_common_1.SqliteError.throwError("readonly", "database is readonly", this[Symbols_1._nativeDb].getFilePath());
this.executeSQL(`SAVEPOINT ${savePointName}`);
try {
operation();
this.executeSQL(`RELEASE ${savePointName}`);
}
catch (e) {
this.executeSQL(`ROLLBACK TO ${savePointName}`);
throw e;
}
}
/** Prepare an SQL statement.
* @param sql The SQLite SQL statement to prepare
* @param logErrors Determine if errors are logged or not
* @internal
*/
prepareSqliteStatement(sql, logErrors = true) {
const stmt = new SqliteStatement_1.SqliteStatement(sql);
stmt.prepare(this[Symbols_1._nativeDb], logErrors);
return stmt;
}
/** execute an SQL statement */
executeSQL(sql) {
const env_1 = { stack: [], error: void 0, hasError: false };
try {
const stmt = __addDisposableResource(env_1, this.prepareSqliteStatement(sql), false);
return stmt.step();
}
catch (e_1) {
env_1.error = e_1;
env_1.hasError = true;
}
finally {
__disposeResources(env_1);
}
}
}
exports.SQLiteDb = SQLiteDb;
/**
* Abstract base class for a SQLite database that has [[SQLiteDb.RequiredVersionRanges]] stored in it.
* This class provides version checking when the database is opened, to guarantee that a valid version of software is
* always used for access.
*
* Notes:
* - This class may be used either to access a local file, or one stored in a cloud container.
* - Subclasses must provide a `myVersion` member indicating the version of its software, and implement the `createDDL` member to create its
* tables.
* @beta
*/
class VersionedSqliteDb extends SQLiteDb {
static _versionProps = { namespace: "SQLiteDb", name: "versions" };
/**
* Change the "versions required to open this database" property stored in this database. After this call,
* versions of software that don't meet the supplied ranges will fail.
* @param versions the new versions required for reading and writing this database.
* @note the database must be opened for write access.
*/
setRequiredVersions(versions) {
// NOTE: It might look tempting to just stringify the supplied `versions` object, but we only include required members - there may be others.
this[Symbols_1._nativeDb].saveFileProperty(VersionedSqliteDb._versionProps, JSON.stringify({ readVersion: versions.readVersion, writeVersion: versions.writeVersion }));
}
/** Get the required version ranges necessary to open this VersionedSqliteDb. */
getRequiredVersions() {
const checkIsString = (value) => {
if (typeof value !== "string")
core_common_1.SqliteError.throwError("invalid-versions-property", `CloudDb has invalid "versions" property`, this[Symbols_1._nativeDb].getFilePath());
return value;
};
const versionJson = checkIsString(this[Symbols_1._nativeDb].queryFileProperty(VersionedSqliteDb._versionProps, true));
const versionRanges = JSON.parse(versionJson);
checkIsString(versionRanges.readVersion);
checkIsString(versionRanges.writeVersion);
return versionRanges;
}
/**
* Create a new database file for the subclass of VersionedSqliteDb.
* @note The required versions are saved as [[myVersion]] or newer for both read and write.
*/
static createNewDb(fileName, setupArgs) {
const db = new this(); // "as any" necessary because VersionedSqliteDb is abstract
IModelJsFs_1.IModelJsFs.recursiveMkDirSync((0, path_1.dirname)(fileName));
if (fs.existsSync(fileName))
fs.unlinkSync(fileName);
db.createDb(fileName);
db.createDDL(setupArgs);
const minVer = `^${db.myVersion}`;
db.setRequiredVersions({ readVersion: minVer, writeVersion: minVer });
db.closeDb(true);
}
/**
* Verify that this version of the software meets the required version range (as appropriate, read or write) stored in the database.
* Throws otherwise.
*/
verifyVersions() {
const versions = this.getRequiredVersions();
const isReadonly = this.isReadonly; // so we can tell read/write after the file is closed.
const range = isReadonly ? versions.readVersion : versions.writeVersion;
if (semver.satisfies(this.myVersion, range))
return;
this.closeDb();
const tooNew = semver.gtr(this.myVersion, range);
core_common_1.SqliteError.throwError("incompatible-version", `requires ${tooNew ? "older" : "newer"} version of ${this.constructor.name} for ${isReadonly ? "read" : "write"}`, this[Symbols_1._nativeDb].getFilePath());
}
/**
* Open this database and verify that this version of the software meets the required version range (as appropriate, read or write) stored in the database.
* Throws otherwise.
* @see [[SqliteDb.openDb]] for argument types
*/
openDb(dbName, openMode, container) {
super.openDb(dbName, openMode, container);
this.verifyVersions();
}
async upgradeSchema(arg) {
// can't use "this" because it checks for version, which we don't want here
return (arg.lockContainer) ?
super.withLockedContainer({ dbName: arg.dbName, ...arg.lockContainer }, async () => arg.upgradeFn) :
super.withOpenDb({ ...arg, openMode: core_bentley_1.OpenMode.ReadWrite }, arg.upgradeFn);
}
}
exports.VersionedSqliteDb = VersionedSqliteDb;
/** @public */
(function (SQLiteDb) {
/** Default transaction mode for SQLiteDbs.
* @see https://www.sqlite.org/lang_transaction.html
*/
let DefaultTxnMode;
(function (DefaultTxnMode) {
/** no default transaction is started. You must use BEGIN/COMMIT or SQLite will use implicit transactions */
DefaultTxnMode[DefaultTxnMode["None"] = 0] = "None";
/** A deferred transaction is started when the file is first opened. This is the default. */
DefaultTxnMode[DefaultTxnMode["Deferred"] = 1] = "Deferred";
/** An immediate transaction is started when the file is first opened. */
DefaultTxnMode[DefaultTxnMode["Immediate"] = 2] = "Immediate";
/** An exclusive transaction is started when the file is first opened. */
DefaultTxnMode[DefaultTxnMode["Exclusive"] = 3] = "Exclusive";
})(DefaultTxnMode = SQLiteDb.DefaultTxnMode || (SQLiteDb.DefaultTxnMode = {}));
})(SQLiteDb || (exports.SQLiteDb = SQLiteDb = {}));
//# sourceMappingURL=SQLiteDb.js.map