UNPKG

@itwin/core-backend

Version:
385 lines • 18.1 kB
"use strict"; /*--------------------------------------------------------------------------------------------- * 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