diff --git a/common/api/core-backend.api.md b/common/api/core-backend.api.md index cbd5abe91dfc..76723b9c6b5c 100644 --- a/common/api/core-backend.api.md +++ b/common/api/core-backend.api.md @@ -1792,11 +1792,13 @@ export class ECDb implements Disposable { [Symbol.dispose](): void; constructor(); abandonChanges(): void; + attachDb(fileName: string, alias: string): void; // @internal clearStatementCache(): void; closeDb(): void; createDb(pathName: string): void; createQueryReader(ecsql: string, params?: QueryBinder, config?: QueryOptions): ECSqlReader; + detachDb(alias: string): void; // @deprecated (undocumented) dispose(): void; // @internal @@ -3169,6 +3171,7 @@ export abstract class IModelDb extends IModel { }); abandonChanges(): void; acquireSchemaLock(): Promise; + attachDb(fileName: string, alias: string): void; // @internal protected beforeClose(): void; // @internal @@ -3201,6 +3204,7 @@ export abstract class IModelDb extends IModel { deleteFileProperty(prop: FilePropertyProps): void; // @beta deleteSettingDictionary(name: string): void; + detachDb(alias: string): void; // @beta elementGeometryCacheOperation(requestProps: ElementGeometryCacheOperationRequestProps): BentleyStatus; // @beta diff --git a/common/changes/@itwin/core-backend/affanK-attach-detach-file_2025-01-08-18-59.json b/common/changes/@itwin/core-backend/affanK-attach-detach-file_2025-01-08-18-59.json new file mode 100644 index 000000000000..a40b2aeae358 --- /dev/null +++ b/common/changes/@itwin/core-backend/affanK-attach-detach-file_2025-01-08-18-59.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/core-backend", + "comment": "Allow attach/detach db", + "type": "none" + } + ], + "packageName": "@itwin/core-backend" +} \ No newline at end of file diff --git a/core/backend/src/ECDb.ts b/core/backend/src/ECDb.ts index 9ec524395c44..1fd439ebde75 100644 --- a/core/backend/src/ECDb.ts +++ b/core/backend/src/ECDb.ts @@ -57,6 +57,22 @@ export class ECDb implements Disposable { this._nativeDb.dispose(); this._nativeDb = undefined; } + /** + * Attach an ECDb file to this connection and load and register its schemas. + * @param fileName ECDb file name + * @param alias identifier for the attached file. This identifer is used a tablespace executing ECSQL queries. + */ + public attachDb(fileName: string, alias: string): void { + this[_nativeDb].attachDb(fileName, alias); + } + /** + * Detach the attached file from this connection. The attached file is closed and its schemas are unregistered. + * @param alias identifer that was used in the call to [[attachDb]] + */ + public detachDb(alias: string): void { + this.clearStatementCache(); + this[_nativeDb].detachDb(alias); + } /** @deprecated in 5.0 Use [Symbol.dispose] instead. */ public dispose(): void { diff --git a/core/backend/src/IModelDb.ts b/core/backend/src/IModelDb.ts index 77ec47112d6e..6937de08fbc5 100644 --- a/core/backend/src/IModelDb.ts +++ b/core/backend/src/IModelDb.ts @@ -365,7 +365,22 @@ export abstract class IModelDb extends IModel { }); } } - + /** + * Attach an iModel file to this connection and load and register its schemas. + * @param fileName IModel file name + * @param alias identifier for the attached file. This identifer is used a tablespace executing ECSQL queries. + */ + public attachDb(fileName: string, alias: string): void { + this[_nativeDb].attachDb(fileName, alias); + } + /** + * Detach the attached file from this connection. The attached file is closed and its schemas are unregistered. + * @param alias identifer that was used in the call to [[attachDb]] + */ + public detachDb(alias: string): void { + this.clearCaches(); + this[_nativeDb].detachDb(alias); + } /** Close this IModel, if it is currently open, and save changes if it was opened in ReadWrite mode. */ public close(): void { if (!this.isOpen) diff --git a/core/backend/src/test/AttachDb.test.ts b/core/backend/src/test/AttachDb.test.ts new file mode 100644 index 000000000000..70fcbb6858b7 --- /dev/null +++ b/core/backend/src/test/AttachDb.test.ts @@ -0,0 +1,162 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ + +import { expect } from "chai"; +import { SnapshotDb } from "../IModelDb"; +import { IModelTestUtils } from "./IModelTestUtils"; + +describe("Attach/Detach Db", () => { + it("attach simulation db", async () => { + const masterFile = IModelTestUtils.resolveAssetFile("sim-master.bim"); + const simulationFile = IModelTestUtils.resolveAssetFile("sim-attach.ecdb"); + + const master = SnapshotDb.openFile(masterFile); + master.attachDb(simulationFile, "SimDb"); + + const ecsql = ` + SELECT ts.TimeFromStart [Time From Start (s)], + p.UserLabel [Pipe with Max Flow], + MAX(ltvrr.Flow) [Max Flow (L/s)] + FROM + SimDb.simrescore.TimeStep ts + INNER JOIN SimDb.stmswrres.BasicFlowResultRecord ltvrr ON ts.ECInstanceId = ltvrr.TimeStep.Id + INNER JOIN swrhyd.Pipe p ON p.ECInstanceId = ltvrr.ElementId + GROUP BY + ts.ECInstanceId + HAVING + MAX(ltvrr.Flow) > 1`; + + const reader = master.createQueryReader(ecsql); + const rows = []; + while (await reader.step()) { + rows.push(reader.current.toRow()); + } + const expected = [ + { + 'Time From Start (s)': 0, + 'Pipe with Max Flow': 'CO-4', + 'Max Flow (L/s)': 66.14359584163114 + }, + { + 'Time From Start (s)': 3600, + 'Pipe with Max Flow': 'CO-4', + 'Max Flow (L/s)': 78.33925707748288 + }, + { + 'Time From Start (s)': 7200, + 'Pipe with Max Flow': 'CO-3', + 'Max Flow (L/s)': 85.32875334207684 + }, + { + 'Time From Start (s)': 10800, + 'Pipe with Max Flow': 'CO-3', + 'Max Flow (L/s)': 73.66891955141061 + }, + { + 'Time From Start (s)': 14400, + 'Pipe with Max Flow': 'CO-3', + 'Max Flow (L/s)': 98.03457129303487 + }, + { + 'Time From Start (s)': 18000, + 'Pipe with Max Flow': 'CO-3', + 'Max Flow (L/s)': 79.73721449443349 + }, + { + 'Time From Start (s)': 21600, + 'Pipe with Max Flow': 'CO-4', + 'Max Flow (L/s)': 91.99697459812566 + }, + { + 'Time From Start (s)': 25200, + 'Pipe with Max Flow': 'CO-3', + 'Max Flow (L/s)': 97.3640516154515 + }, + { + 'Time From Start (s)': 28800, + 'Pipe with Max Flow': 'CO-3', + 'Max Flow (L/s)': 82.92153917380564 + }, + { + 'Time From Start (s)': 32400, + 'Pipe with Max Flow': 'CO-3', + 'Max Flow (L/s)': 93.43596024800935 + }, + { + 'Time From Start (s)': 36000, + 'Pipe with Max Flow': 'CO-1', + 'Max Flow (L/s)': 93.38944851040705 + }, + { + 'Time From Start (s)': 39600, + 'Pipe with Max Flow': 'CO-2', + 'Max Flow (L/s)': 96.89678313985426 + }, + { + 'Time From Start (s)': 43200, + 'Pipe with Max Flow': 'CO-4', + 'Max Flow (L/s)': 68.37554676909588 + }, + { + 'Time From Start (s)': 46800, + 'Pipe with Max Flow': 'CO-4', + 'Max Flow (L/s)': 40.71067873689955 + }, + { + 'Time From Start (s)': 50400, + 'Pipe with Max Flow': 'CO-2', + 'Max Flow (L/s)': 94.95603088826243 + }, + { + 'Time From Start (s)': 54000, + 'Pipe with Max Flow': 'CO-3', + 'Max Flow (L/s)': 90.30742518949977 + }, + { + 'Time From Start (s)': 57600, + 'Pipe with Max Flow': 'CO-2', + 'Max Flow (L/s)': 96.32532799368296 + }, + { + 'Time From Start (s)': 61200, + 'Pipe with Max Flow': 'CO-3', + 'Max Flow (L/s)': 98.7241157161529 + }, + { + 'Time From Start (s)': 64800, + 'Pipe with Max Flow': 'CO-1', + 'Max Flow (L/s)': 76.65530275985837 + }, + { + 'Time From Start (s)': 68400, + 'Pipe with Max Flow': 'CO-2', + 'Max Flow (L/s)': 61.81109058119374 + }, + { + 'Time From Start (s)': 72000, + 'Pipe with Max Flow': 'CO-3', + 'Max Flow (L/s)': 98.37332278792479 + }, + { + 'Time From Start (s)': 75600, + 'Pipe with Max Flow': 'CO-3', + 'Max Flow (L/s)': 70.55434454594996 + }, + { + 'Time From Start (s)': 79200, + 'Pipe with Max Flow': 'CO-2', + 'Max Flow (L/s)': 84.03731418990937 + }, + { + 'Time From Start (s)': 82800, + 'Pipe with Max Flow': 'CO-2', + 'Max Flow (L/s)': 96.3267817742375 + } + ]; + expect(rows).to.deep.equal(expected); + master.close(); + }); + +}); diff --git a/core/backend/src/test/assets/sim-attach.ecdb b/core/backend/src/test/assets/sim-attach.ecdb new file mode 100644 index 000000000000..eb218852777a Binary files /dev/null and b/core/backend/src/test/assets/sim-attach.ecdb differ diff --git a/core/backend/src/test/assets/sim-master.bim b/core/backend/src/test/assets/sim-master.bim new file mode 100644 index 000000000000..50678206d3fa Binary files /dev/null and b/core/backend/src/test/assets/sim-master.bim differ diff --git a/core/backend/src/test/ecdb/ECDb.test.ts b/core/backend/src/test/ecdb/ECDb.test.ts index e937db906a5f..0d8bcf04308b 100644 --- a/core/backend/src/test/ecdb/ECDb.test.ts +++ b/core/backend/src/test/ecdb/ECDb.test.ts @@ -9,6 +9,7 @@ import { DbResult, Id64, Id64String, Logger } from "@itwin/core-bentley"; import { ECDb, ECDbOpenMode, ECSqlInsertResult, ECSqlStatement, IModelJsFs, SqliteStatement, SqliteValue, SqliteValueType } from "../../core-backend"; import { KnownTestLocations } from "../KnownTestLocations"; import { ECDbTestHelper } from "./ECDbTestHelper"; +import { QueryOptionsBuilder } from "@itwin/core-common"; describe("ECDb", () => { const outDir = KnownTestLocations.outputDir; @@ -59,7 +60,206 @@ describe("ECDb", () => { } }); + it("attach/detach newer profile version", async () => { + const fileName1 = "source_file.ecdb"; + const ecdbPath1: string = path.join(outDir, fileName1); + using testECDb = ECDbTestHelper.createECDb(outDir, fileName1, + ` + + + + + `); + assert.isTrue(testECDb.isOpen); + testECDb.withPreparedStatement("INSERT INTO test.Person(Name,Age) VALUES('Mary', 45)", (stmt: ECSqlStatement) => { + const res: ECSqlInsertResult = stmt.stepForInsert(); + assert.equal(res.status, DbResult.BE_SQLITE_DONE); + assert.isDefined(res.id); + assert.isTrue(Id64.isValidId64(res.id!)); + return res.id!; + }); + // override profile version to 55.0.0 which is currently not supported + testECDb.withSqliteStatement(` + UPDATE be_Prop SET + StrData = '{"major":55,"minor":0,"sub1":0,"sub2":0}' + WHERE Namespace = 'ec_Db' AND Name = 'SchemaVersion'`, + (stmt: SqliteStatement) => { stmt.step(); }); + testECDb.saveChanges(); + + const runDbListPragmaUsingStatement = (ecdb: ECDb) => { + return ecdb.withPreparedStatement("PRAGMA db_list", (stmt: ECSqlStatement) => { + const result: { alias: string, filename: string, profile: string }[] = []; + while (stmt.step() === DbResult.BE_SQLITE_ROW) { + result.push(stmt.getRow()); + } + return result; + }); + } + const runDbListPragmaCCQ = async (ecdb: ECDb) => { + const reader = ecdb.createQueryReader("PRAGMA db_list"); + const result: { alias: string, filename: string, profile: string }[] = []; + while (await reader.step()) { + result.push(reader.current.toRow()); + } + return result; + } + using testECDb0 = ECDbTestHelper.createECDb(outDir, "file2.ecdb"); + // following call will not fail but unknow ECDb profile will cause it to be attach as SQLite. + testECDb0.attachDb(ecdbPath1, "source"); + expect(() => testECDb0.withPreparedStatement("SELECT Name, Age FROM source.test.Person", () => { })).to.throw("ECClass 'source.test.Person' does not exist or could not be loaded."); + expect(runDbListPragmaUsingStatement(testECDb0)).deep.equals([ + { + sno: 0, + alias: "main", + fileName: path.join(outDir, "file2.ecdb"), + profile: "ECDb" + }, + { + sno: 1, + alias: "source", + fileName: path.join(outDir, "source_file.ecdb"), + profile: "SQLite" + } + ]); + testECDb0.detachDb("source"); + expect(runDbListPragmaUsingStatement(testECDb0)).deep.equals([ + { + sno: 0, + alias: "main", + fileName: path.join(outDir, "file2.ecdb"), + profile: "ECDb" + }, + ]); + expect(() => testECDb0.withPreparedStatement("SELECT Name, Age FROM source.test.Person", () => { })).to.throw("ECClass 'source.test.Person' does not exist or could not be loaded."); + + using testECDb1 = ECDbTestHelper.createECDb(outDir, "file4.ecdb"); + testECDb1.attachDb(ecdbPath1, "source"); + const reader1 = testECDb1.createQueryReader("SELECT Name, Age FROM source.test.Person"); + let expectThrow = false; + try { + await reader1.step(); + } catch (err) { + if (err instanceof Error) { + assert.equal(err.message, "ECClass 'source.test.Person' does not exist or could not be loaded."); + expectThrow = true; + } + } + assert.isTrue(expectThrow); + expect(await runDbListPragmaCCQ(testECDb1)).deep.equals([ + { + sno: 0, + alias: "main", + fileName: path.join(outDir, "file4.ecdb"), + profile: "ECDb" + }, + { + sno: 1, + alias: "source", + fileName: path.join(outDir, "source_file.ecdb"), + profile: "SQLite" + } + ]); + testECDb1.detachDb("source"); + expect(await runDbListPragmaCCQ(testECDb1)).deep.equals([ + { + sno: 0, + alias: "main", + fileName: path.join(outDir, "file4.ecdb"), + profile: "ECDb" + }, + ]); + }); + it("attach/detach file & db_list pragma", async () => { + const fileName1 = "source_file.ecdb"; + const ecdbPath1: string = path.join(outDir, fileName1); + using testECDb = ECDbTestHelper.createECDb(outDir, fileName1, + ` + + + + + `); + assert.isTrue(testECDb.isOpen); + testECDb.withPreparedStatement("INSERT INTO test.Person(Name,Age) VALUES('Mary', 45)", (stmt: ECSqlStatement) => { + const res: ECSqlInsertResult = stmt.stepForInsert(); + assert.equal(res.status, DbResult.BE_SQLITE_DONE); + assert.isDefined(res.id); + assert.isTrue(Id64.isValidId64(res.id!)); + return res.id!; + }); + testECDb.saveChanges(); + + const runDbListPragma = (ecdb: ECDb) => { + return ecdb.withPreparedStatement("PRAGMA db_list", (stmt: ECSqlStatement) => { + const result: { alias: string, filename: string, profile: string }[] = []; + while (stmt.step() === DbResult.BE_SQLITE_ROW) { + result.push(stmt.getRow()); + } + return result; + }); + } + using testECDb0 = ECDbTestHelper.createECDb(outDir, "file2.ecdb"); + testECDb0.attachDb(ecdbPath1, "source"); + testECDb0.withPreparedStatement("SELECT Name, Age FROM source.test.Person", (stmt: ECSqlStatement) => { + assert.equal(stmt.step(), DbResult.BE_SQLITE_ROW); + const row = stmt.getRow(); + assert.equal(row.name, "Mary"); + assert.equal(row.age, 45); + }); + expect(runDbListPragma(testECDb0)).deep.equals([ + { + sno: 0, + alias: "main", + fileName: path.join(outDir, "file2.ecdb"), + profile: "ECDb" + }, + { + sno: 1, + alias: "source", + fileName: path.join(outDir, "source_file.ecdb"), + profile: "ECDb" + } + ]); + testECDb0.detachDb("source"); + expect(runDbListPragma(testECDb0)).deep.equals([ + { + sno: 0, + alias: "main", + fileName: path.join(outDir, "file2.ecdb"), + profile: "ECDb" + }, + ]); + expect(() => testECDb0.withPreparedStatement("SELECT Name, Age FROM source.test.Person", () => { })).to.throw("ECClass 'source.test.Person' does not exist or could not be loaded."); + + using testECDb1 = ECDbTestHelper.createECDb(outDir, "file3.ecdb"); + testECDb1.attachDb(ecdbPath1, "source"); + const reader1 = testECDb1.createQueryReader("SELECT Name, Age FROM source.test.Person", undefined, new QueryOptionsBuilder().setUsePrimaryConnection(true).getOptions()); + assert.equal(await reader1.step(), true); + assert.equal(reader1.current.name, "Mary"); + assert.equal(reader1.current.age, 45); + testECDb1.detachDb("source"); + + + using testECDb2 = ECDbTestHelper.createECDb(outDir, "file4.ecdb"); + testECDb2.attachDb(ecdbPath1, "source"); + const reader2 = testECDb2.createQueryReader("SELECT Name, Age FROM source.test.Person"); + assert.equal(await reader2.step(), true); + assert.equal(reader2.current.name, "Mary"); + assert.equal(reader2.current.age, 45); + testECDb2.detachDb("source"); + const reader3 = testECDb2.createQueryReader("SELECT Name, Age FROM source.test.Person"); + let expectThrow = false; + try { + await reader3.step(); + } catch (err) { + if (err instanceof Error) { + assert.equal(err.message, "ECClass 'source.test.Person' does not exist or could not be loaded."); + expectThrow = true; + } + } + assert.isTrue(expectThrow); + }); it("should be able to import a schema", () => { const fileName = "schemaimport.ecdb"; const ecdbPath: string = path.join(outDir, fileName); diff --git a/docs/changehistory/NextVersion.md b/docs/changehistory/NextVersion.md index 6eb42a71d394..0f23774d1ca7 100644 --- a/docs/changehistory/NextVersion.md +++ b/docs/changehistory/NextVersion.md @@ -54,6 +54,7 @@ Table of contents: - [TypeScript configuration changes](#typescript-configuration-changes) - [`target`](#target) - [`useDefineForClassFields`](#usedefineforclassfields) + - [Attach/detach db](#attachdetach-db) ## Selection set @@ -639,3 +640,31 @@ class MyElement extends Element { ... } ``` + +## Attach/detach db + +Allow the attachment of an ECDb/IModel to a connection and running ECSQL that combines data from both databases. + +Example of attaching a snapshot to a master file and running a query that combines data from both databases: +```ts + const master = SnapshotDb.openFile(masterFile); + master.attachDb(simulationFile, "SimDb"); + + const ecsql = ` + SELECT ts.TimeFromStart [Time From Start (s)], + p.UserLabel [Pipe with Max Flow], + MAX(ltvrr.Flow) [Max Flow (L/s)] + FROM + SimDb.simrescore.TimeStep ts + INNER JOIN SimDb.stmswrres.BasicFlowResultRecord ltvrr ON ts.ECInstanceId = ltvrr.TimeStep.Id + INNER JOIN swrhyd.Pipe p ON p.ECInstanceId = ltvrr.ElementId + GROUP BY + ts.ECInstanceId + HAVING + MAX(ltvrr.Flow) > 1`; + + const reader = master.createQueryReader(ecsql); + while (await reader.step()) { + // ... + } +``` \ No newline at end of file diff --git a/full-stack-tests/backend/src/integration/CloudSqlite.test.ts b/full-stack-tests/backend/src/integration/CloudSqlite.test.ts index 4cbe62724f21..93342dbf308c 100644 --- a/full-stack-tests/backend/src/integration/CloudSqlite.test.ts +++ b/full-stack-tests/backend/src/integration/CloudSqlite.test.ts @@ -649,4 +649,3 @@ describe("CloudSqlite", () => { BlobContainer.service = service; }); }); -