diff --git a/src/domain/desktop/appExportPython.ts b/src/domain/desktop/appExportPython.ts index 40753258bf..f0b7a6e699 100644 --- a/src/domain/desktop/appExportPython.ts +++ b/src/domain/desktop/appExportPython.ts @@ -184,11 +184,11 @@ def _table(db, name: str, primary_key: list, encoding: str) -> dict: def _sqlite(path: Path) -> dict: """The database's ordinary tables, in schema order, up to REA's table and row limits.""" with contextlib.closing(sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True, timeout=SQLITE_BUSY_S)) as db: - kinds = dict(db.execute("select name, type from pragma_table_list where schema = 'main'")) + kinds = dict(db.execute("select name, type from temp.pragma_table_list where schema = 'main'")) names = [name for (name,) in db.execute("select name from sqlite_schema where type = 'table'") if kinds.get(name) == "table"] keys = {name: [key for (key,) in db.execute( - "select name from pragma_table_info(?, 'main') where pk > 0 order by pk", (name,))] + "select name from temp.pragma_table_info(?, 'main') where pk > 0 order by pk", (name,))] for name in names[:MAX_TABLES]} encoding = {"UTF-8": "utf-8", "UTF-16le": "utf-16-le", "UTF-16be": "utf-16-be"}[ db.execute("PRAGMA encoding").fetchone()[0]] diff --git a/tests/boundary/providers/desktopExportSqliteMetadata.test.ts b/tests/boundary/providers/desktopExportSqliteMetadata.test.ts new file mode 100644 index 0000000000..191c63ac94 --- /dev/null +++ b/tests/boundary/providers/desktopExportSqliteMetadata.test.ts @@ -0,0 +1,125 @@ +import { execFileSync } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +import { expect, it } from "vitest"; + +import { + PYTHON_HELPERS, + PYTHON_IMPORTS, +} from "../../../src/domain/desktop/appExportPython.js"; +import { PYTHON_TASK_EVALUATOR } from "../../../src/domain/desktop/appTaskPython.js"; +import { inspectSqliteDatabaseSnapshot } from "../../../src/sqlite/SqliteDatabaseInspection.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const collisions = [ + ["table", "pragma_table_list"], + ["table", "pragma_table_info"], + ["view", "pragma_table_list"], + ["view", "pragma_table_info"], +] as const; + +it.each( + (["UTF-8", "UTF-16le", "UTF-16be"] as const).flatMap((encoding) => + collisions.map(([kind, name]) => [encoding, kind, name] as const), + ), +)( + "reads exported %s SQLite state when a %s shadows %s", + async (encoding, kind, name) => { + const root = await createTestTempDirectory("desktop-sqlite-metadata-"); + const path = join(root, "state.db"); + const db = new DatabaseSync(path); + try { + db.exec(` + PRAGMA encoding = '${encoding}'; + CREATE TABLE notes(id INTEGER, part TEXT, "body "" text" TEXT, PRIMARY KEY(part, id)); + INSERT INTO notes VALUES(7, 'a', 'retained'); + ${ + kind === "table" + ? `CREATE TABLE ${name}(name TEXT, type TEXT, schema TEXT); + INSERT INTO ${name} VALUES('notes', 'view', 'main');` + : `CREATE VIEW ${name} AS SELECT missing_function() AS name, 'table' AS type, 'main' AS schema;` + } + `); + db.prepare("INSERT INTO notes VALUES(NULL, ?, ?)").run( + "a", + "\ufeffretained 😀\ufeff", + ); + } finally { + db.close(); + } + const before = await readFile(path); + const imports = PYTHON_IMPORTS.split("\n") + .filter((line) => !line.includes("agentenv_protocol")) + .join("\n"); + const program = join(root, "read.py"); + await writeFile( + program, + `${imports}${PYTHON_HELPERS}${PYTHON_TASK_EVALUATOR} +views = _views(Path(sys.argv[1]), ["state.db"]) +checks = [{"id": "row", "kind": "row_present", "file": "state.db", "table": "notes", "where": {"id": 7}}] +missing = [{**checks[0], "where": {"id": 8}}] +print(json.dumps({"views": views, "results": evaluate(checks + missing, views)})) +`, + ); + const output = JSON.parse( + execFileSync( + process.platform === "win32" ? "python" : "python3", + ["-I", "-B", program, root], + { encoding: "utf8" }, + ), + ) as { + views: Record; + results: { result: boolean }[]; + }; + + expect(output.views["state.db"]).toMatchObject({ + format: "sqlite", + truncated: false, + view: { + tables_truncated: false, + }, + }); + expect(output.results.map(({ result }) => result)).toEqual([true, false]); + expect(output.views["state.db"]?.view.tables).toEqual({ + notes: { + columns: ["id", "part", 'body " text'], + primary_key: ["part", "id"], + rows: [ + [7, "a", "retained"], + [null, "a", "\ufeffretained 😀\ufeff"], + ], + truncated: false, + }, + ...(kind === "table" + ? { + [name]: { + columns: ["name", "type", "schema"], + primary_key: [], + rows: [["notes", "view", "main"]], + truncated: false, + }, + } + : {}), + }); + expect( + inspectSqliteDatabaseSnapshot(path, { path, table: "notes" }).rows, + ).toMatchObject({ + columns: ["id", "part", 'body " text'], + values: [ + [ + { type: "integer", value: "7" }, + { type: "text", value: "a" }, + { type: "text", value: "retained" }, + ], + [ + { type: "null" }, + { type: "text", value: "a" }, + { type: "text", value: "\ufeffretained 😀\ufeff" }, + ], + ], + }); + expect((await readFile(path)).equals(before)).toBe(true); + }, +);