dirsql 0.3.55__tar.gz → 0.3.57__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dirsql-0.3.55 → dirsql-0.3.57}/Cargo.lock +1 -1
- {dirsql-0.3.55 → dirsql-0.3.57}/PKG-INFO +1 -1
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/Cargo.toml +1 -1
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/e2e-attestation.json +2 -2
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/db.rs +459 -3
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/persist.rs +110 -7
- {dirsql-0.3.55 → dirsql-0.3.57}/Cargo.toml +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/README.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/__init__.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/_async.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/_dirsql.pyi +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/cli/__init__.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/cli/binary_path.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/cli/interpret/__init__.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/cli/is_windows.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/cli/main.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/cli/resolve_config_extensions.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/py.typed +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/resolve_config_extensions.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/dirsql/resolve_extension.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/.claude/CLAUDE.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/.vitepress/config.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/.vitepress/theme/index.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/.vitepress/theme/lang.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/AGENTS.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/api/index.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/cli/config.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/cli/http-api.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/cli/index.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/cli/init.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/cli/server.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/getting-started.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/guide/async.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/guide/crdt.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/guide/persistence.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/guide/querying.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/guide/tables.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/guide/watching.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/index.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/migrations.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/package.json +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/playwright.config.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/pnpm-lock.yaml +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/pnpm-workspace.yaml +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/tests/integration/home.spec.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/tests/integration/language-flag.spec.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/tests/integration/sidebar.spec.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/tests/unit/config.test.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/tests/unit/lang.test.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/docs/vitest.config.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/README.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/conftest.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/.claude/CLAUDE.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/.vitepress/config.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/.vitepress/theme/index.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/AGENTS.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/api/index.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/cli/config.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/cli/http-api.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/cli/index.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/cli/init.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/cli/server.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/getting-started.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/guide/async.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/guide/crdt.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/guide/persistence.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/guide/querying.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/guide/tables.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/guide/watching.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/index.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/migrations.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/package.json +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/playwright.config.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/pnpm-lock.yaml +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/pnpm-workspace.yaml +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/tests/integration/home.spec.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/tests/integration/sidebar.spec.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/tests/unit/config.test.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/tests/unit/lang.test.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/vitest.config.ts +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/src/lib.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/tests/__init__.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/tests/binding/__init__.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/tests/conftest.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/tests/e2e/__init__.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/tests/integration/__init__.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/python/tests/smoke/__init__.py +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/Cargo.toml +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/README.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/benches/db_bench.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/benches/differ_bench.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/benches/matcher_bench.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/benches/scanner_bench.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/api/index.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/cli/config.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/cli/http-api.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/cli/index.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/cli/init.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/cli/server.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/getting-started.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/guide/async.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/guide/crdt.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/guide/persistence.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/guide/querying.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/guide/tables.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/guide/watching.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/index.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/docs/migrations.md +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/bin/dirsql.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/cli/init.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/cli/mod.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/cli/router.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/cli/serialize.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/cli/server.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/command.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/config.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/differ.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/lib.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/matcher.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/scanner.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/packages/rust/src/watcher.rs +0 -0
- {dirsql-0.3.55 → dirsql-0.3.57}/pyproject.toml +0 -0
|
@@ -4,7 +4,7 @@ name = "dirsql-py-ext"
|
|
|
4
4
|
# pypi/maturin handler can rewrite it via `write-version` before
|
|
5
5
|
# `maturin build`. `pyproject.toml` declares `dynamic = ["version"]`
|
|
6
6
|
# and maturin reads this field. Mirrors `packages/rust/Cargo.toml`.
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.57"
|
|
8
8
|
edition.workspace = true
|
|
9
9
|
publish = false
|
|
10
10
|
readme = "README.md"
|
|
@@ -51,6 +51,37 @@ pub fn validate_identifier(s: &str) -> Result<()> {
|
|
|
51
51
|
|
|
52
52
|
pub type Result<T> = std::result::Result<T, DbError>;
|
|
53
53
|
|
|
54
|
+
/// Name of the internal row-bookkeeping table (issue #359, epic #358).
|
|
55
|
+
///
|
|
56
|
+
/// Maps every inserted user row back to the file that produced it —
|
|
57
|
+
/// `(table_name, file_path, row_index, rowid_ref)` — mirroring the injected
|
|
58
|
+
/// `_dirsql_file_path` / `_dirsql_row_index` tracking columns. Stage 1 keeps
|
|
59
|
+
/// the injected columns authoritative and *dual-writes* this table in the same
|
|
60
|
+
/// SQLite transaction as each row write, so a later release can drop the
|
|
61
|
+
/// columns and read ownership from here instead.
|
|
62
|
+
pub const INTERNAL_ROWS_TABLE: &str = "_dirsql_internal_rows";
|
|
63
|
+
|
|
64
|
+
/// Create the internal `_dirsql_internal_rows` bookkeeping table and its
|
|
65
|
+
/// by-file index if they don't already exist. Idempotent, so it is safe to
|
|
66
|
+
/// call on every `Db` construction and on every persistent-cache open.
|
|
67
|
+
///
|
|
68
|
+
/// A **real** table (not virtual): it lives in the persisted cache and is
|
|
69
|
+
/// written inside the same transaction as the row inserts/deletes it
|
|
70
|
+
/// describes, which is what gives the mapping crash-atomicity with the user
|
|
71
|
+
/// rows.
|
|
72
|
+
pub fn ensure_internal_rows_table(conn: &Connection) -> rusqlite::Result<()> {
|
|
73
|
+
conn.execute_batch(
|
|
74
|
+
"CREATE TABLE IF NOT EXISTS _dirsql_internal_rows (
|
|
75
|
+
table_name TEXT NOT NULL,
|
|
76
|
+
file_path TEXT NOT NULL,
|
|
77
|
+
row_index INTEGER NOT NULL,
|
|
78
|
+
rowid_ref INTEGER NOT NULL
|
|
79
|
+
);
|
|
80
|
+
CREATE INDEX IF NOT EXISTS _dirsql_internal_rows_by_file
|
|
81
|
+
ON _dirsql_internal_rows(table_name, file_path);",
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
54
85
|
pub struct Db {
|
|
55
86
|
conn: Connection,
|
|
56
87
|
}
|
|
@@ -58,6 +89,7 @@ pub struct Db {
|
|
|
58
89
|
impl Db {
|
|
59
90
|
pub fn new() -> Result<Self> {
|
|
60
91
|
let conn = Connection::open_in_memory()?;
|
|
92
|
+
ensure_internal_rows_table(&conn)?;
|
|
61
93
|
Ok(Self { conn })
|
|
62
94
|
}
|
|
63
95
|
|
|
@@ -65,6 +97,7 @@ impl Db {
|
|
|
65
97
|
/// cache path; in-memory mode is the default.
|
|
66
98
|
pub fn open(path: &Path) -> Result<Self> {
|
|
67
99
|
let conn = Connection::open(path)?;
|
|
100
|
+
ensure_internal_rows_table(&conn)?;
|
|
68
101
|
Ok(Self { conn })
|
|
69
102
|
}
|
|
70
103
|
|
|
@@ -120,6 +153,18 @@ impl Db {
|
|
|
120
153
|
}
|
|
121
154
|
let table = parse_table_name(ddl).ok_or_else(|| DbError::DdlParse(ddl.to_string()))?;
|
|
122
155
|
validate_identifier(&table)?;
|
|
156
|
+
// A `WITHOUT ROWID` table has no rowid, so `last_insert_rowid()` cannot
|
|
157
|
+
// identify the inserted row and the `_dirsql_internal_rows.rowid_ref`
|
|
158
|
+
// mapping (epic #358) would be meaningless. Stage 1 only warns; the hard
|
|
159
|
+
// rejection lands in stage 3. The injected columns stay authoritative
|
|
160
|
+
// here, so the table still works today.
|
|
161
|
+
if is_without_rowid_ddl(ddl) {
|
|
162
|
+
eprintln!(
|
|
163
|
+
"dirsql: table `{table}` is declared WITHOUT ROWID; internal row \
|
|
164
|
+
bookkeeping relies on rowid and WITHOUT ROWID tables will be \
|
|
165
|
+
rejected in a future release"
|
|
166
|
+
);
|
|
167
|
+
}
|
|
123
168
|
let augmented = inject_tracking_columns(ddl)?;
|
|
124
169
|
self.conn.execute(&augmented, [])?;
|
|
125
170
|
Ok(())
|
|
@@ -232,18 +277,122 @@ impl Db {
|
|
|
232
277
|
|
|
233
278
|
let param_refs: Vec<&dyn rusqlite::types::ToSql> =
|
|
234
279
|
params.iter().map(|p| p.as_ref()).collect();
|
|
235
|
-
|
|
280
|
+
|
|
281
|
+
// Dual-write (epic #358): the user-row insert and its
|
|
282
|
+
// `_dirsql_internal_rows` mapping row commit in ONE transaction, so a
|
|
283
|
+
// crash between them can never leave a row without its mapping (or vice
|
|
284
|
+
// versa). `last_insert_rowid()` is read *after* the user insert and
|
|
285
|
+
// *before* the mapping insert, so it captures the user row's rowid
|
|
286
|
+
// (including a user-declared `INTEGER PRIMARY KEY` rowid alias).
|
|
287
|
+
let tx = self.conn.unchecked_transaction()?;
|
|
288
|
+
tx.execute(&sql, param_refs.as_slice())?;
|
|
289
|
+
let rowid = tx.last_insert_rowid();
|
|
290
|
+
tx.execute(
|
|
291
|
+
"INSERT INTO _dirsql_internal_rows (table_name, file_path, row_index, rowid_ref) \
|
|
292
|
+
VALUES (?1, ?2, ?3, ?4)",
|
|
293
|
+
rusqlite::params![table, file_path, row_index as i64, rowid],
|
|
294
|
+
)?;
|
|
295
|
+
tx.commit()?;
|
|
236
296
|
Ok(())
|
|
237
297
|
}
|
|
238
298
|
|
|
239
299
|
/// Delete all rows that were produced by a given file path.
|
|
300
|
+
///
|
|
301
|
+
/// The user-row deletes and the matching `_dirsql_internal_rows` mapping
|
|
302
|
+
/// deletes commit in ONE transaction (epic #358), so the mapping never
|
|
303
|
+
/// outlives the rows it describes.
|
|
304
|
+
///
|
|
305
|
+
/// Stage 2 (epic #358): row ownership is read from the mapping, not the
|
|
306
|
+
/// injected `_dirsql_file_path` column (now write-only). The user rows to
|
|
307
|
+
/// drop are those whose `rowid` the mapping attributes to `file_path` under
|
|
308
|
+
/// `table`; the mapping rows are then removed in the same transaction.
|
|
240
309
|
pub fn delete_rows_by_file(&self, table: &str, file_path: &str) -> Result<usize> {
|
|
241
310
|
validate_identifier(table)?;
|
|
242
|
-
let
|
|
243
|
-
let
|
|
311
|
+
let tx = self.conn.unchecked_transaction()?;
|
|
312
|
+
let sql = format!(
|
|
313
|
+
"DELETE FROM {} WHERE rowid IN \
|
|
314
|
+
(SELECT rowid_ref FROM _dirsql_internal_rows \
|
|
315
|
+
WHERE table_name = ?1 AND file_path = ?2)",
|
|
316
|
+
table
|
|
317
|
+
);
|
|
318
|
+
let count = tx.execute(&sql, rusqlite::params![table, file_path])?;
|
|
319
|
+
tx.execute(
|
|
320
|
+
"DELETE FROM _dirsql_internal_rows WHERE table_name = ?1 AND file_path = ?2",
|
|
321
|
+
rusqlite::params![table, file_path],
|
|
322
|
+
)?;
|
|
323
|
+
tx.commit()?;
|
|
244
324
|
Ok(count)
|
|
245
325
|
}
|
|
246
326
|
|
|
327
|
+
/// Debug/test equivalence guard for the dual-write mirror (epic #358).
|
|
328
|
+
///
|
|
329
|
+
/// Asserts that the `_dirsql_internal_rows` mapping for `table` exactly
|
|
330
|
+
/// matches the column-derived tracking state of the live user rows: every
|
|
331
|
+
/// user row's `(rowid, _dirsql_file_path, _dirsql_row_index)` triple must
|
|
332
|
+
/// have one corresponding mapping row `(rowid_ref, file_path, row_index)`,
|
|
333
|
+
/// and vice versa. Returns [`DbError::SchemaMismatch`] describing the drift
|
|
334
|
+
/// otherwise. While the injected columns remain authoritative (stages 1–2),
|
|
335
|
+
/// this is the guard that the new bookkeeping never diverges.
|
|
336
|
+
///
|
|
337
|
+
/// Not valid for `WITHOUT ROWID` tables (they have no `rowid` column).
|
|
338
|
+
pub fn check_row_mapping_equivalence(&self, table: &str) -> Result<()> {
|
|
339
|
+
validate_identifier(table)?;
|
|
340
|
+
let mut column_state = self.column_tracking_triples(table)?;
|
|
341
|
+
let mut mapping_state = self.mapping_triples(table)?;
|
|
342
|
+
column_state.sort();
|
|
343
|
+
mapping_state.sort();
|
|
344
|
+
if column_state != mapping_state {
|
|
345
|
+
return Err(DbError::SchemaMismatch(format!(
|
|
346
|
+
"row mapping drift for table {table}: \
|
|
347
|
+
column-derived {column_state:?} != mapping-derived {mapping_state:?}"
|
|
348
|
+
)));
|
|
349
|
+
}
|
|
350
|
+
Ok(())
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/// Column-derived tracking triples for `table`: `(rowid,
|
|
354
|
+
/// _dirsql_file_path, _dirsql_row_index)` for every live user row.
|
|
355
|
+
fn column_tracking_triples(&self, table: &str) -> Result<Vec<(i64, String, i64)>> {
|
|
356
|
+
let sql = format!(
|
|
357
|
+
"SELECT rowid, _dirsql_file_path, _dirsql_row_index FROM {}",
|
|
358
|
+
table
|
|
359
|
+
);
|
|
360
|
+
let mut stmt = self.conn.prepare(&sql)?;
|
|
361
|
+
let rows = stmt.query_map([], |row| {
|
|
362
|
+
Ok((
|
|
363
|
+
row.get::<_, i64>(0)?,
|
|
364
|
+
row.get::<_, String>(1)?,
|
|
365
|
+
row.get::<_, i64>(2)?,
|
|
366
|
+
))
|
|
367
|
+
})?;
|
|
368
|
+
let mut out = Vec::new();
|
|
369
|
+
for row in rows {
|
|
370
|
+
out.push(row?);
|
|
371
|
+
}
|
|
372
|
+
Ok(out)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/// Mapping-derived tracking triples for `table`: `(rowid_ref, file_path,
|
|
376
|
+
/// row_index)` from `_dirsql_internal_rows`.
|
|
377
|
+
fn mapping_triples(&self, table: &str) -> Result<Vec<(i64, String, i64)>> {
|
|
378
|
+
let mut stmt = self.conn.prepare(
|
|
379
|
+
"SELECT rowid_ref, file_path, row_index FROM _dirsql_internal_rows \
|
|
380
|
+
WHERE table_name = ?1",
|
|
381
|
+
)?;
|
|
382
|
+
let rows = stmt.query_map([table], |row| {
|
|
383
|
+
Ok((
|
|
384
|
+
row.get::<_, i64>(0)?,
|
|
385
|
+
row.get::<_, String>(1)?,
|
|
386
|
+
row.get::<_, i64>(2)?,
|
|
387
|
+
))
|
|
388
|
+
})?;
|
|
389
|
+
let mut out = Vec::new();
|
|
390
|
+
for row in rows {
|
|
391
|
+
out.push(row?);
|
|
392
|
+
}
|
|
393
|
+
Ok(out)
|
|
394
|
+
}
|
|
395
|
+
|
|
247
396
|
/// Query the database, returning rows as a list of column-name -> value maps.
|
|
248
397
|
///
|
|
249
398
|
/// Rejects any statement that SQLite itself classifies as a write
|
|
@@ -497,6 +646,16 @@ fn is_virtual_table_ddl(ddl: &str) -> bool {
|
|
|
497
646
|
normalized.to_uppercase().contains("CREATE VIRTUAL TABLE")
|
|
498
647
|
}
|
|
499
648
|
|
|
649
|
+
/// True if `ddl` declares a `WITHOUT ROWID` table. Such tables have no rowid,
|
|
650
|
+
/// so `last_insert_rowid()` cannot identify an inserted row and the
|
|
651
|
+
/// `_dirsql_internal_rows.rowid_ref` mapping (epic #358) is meaningless. Stage
|
|
652
|
+
/// 1 warns; stage 3 rejects. Whitespace is normalized so `WITHOUT ROWID`
|
|
653
|
+
/// and newline-separated forms are still detected.
|
|
654
|
+
fn is_without_rowid_ddl(ddl: &str) -> bool {
|
|
655
|
+
let normalized = ddl.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
656
|
+
normalized.to_uppercase().contains("WITHOUT ROWID")
|
|
657
|
+
}
|
|
658
|
+
|
|
500
659
|
impl From<rusqlite::types::Value> for Value {
|
|
501
660
|
fn from(v: rusqlite::types::Value) -> Self {
|
|
502
661
|
match v {
|
|
@@ -1323,4 +1482,301 @@ mod tests {
|
|
|
1323
1482
|
"CREATE TABLE IF NOT EXISTS x (a TEXT)"
|
|
1324
1483
|
));
|
|
1325
1484
|
}
|
|
1485
|
+
|
|
1486
|
+
// --- _dirsql_internal_rows mapping (epic #358, stage 1) ---
|
|
1487
|
+
|
|
1488
|
+
/// Read the raw mapping rows for a table, ordered for stable assertions.
|
|
1489
|
+
fn mapping_rows(db: &Db, table: &str) -> Vec<(String, i64, i64)> {
|
|
1490
|
+
let mut stmt = db
|
|
1491
|
+
.conn
|
|
1492
|
+
.prepare(
|
|
1493
|
+
"SELECT file_path, row_index, rowid_ref FROM _dirsql_internal_rows \
|
|
1494
|
+
WHERE table_name = ?1 ORDER BY rowid_ref",
|
|
1495
|
+
)
|
|
1496
|
+
.unwrap();
|
|
1497
|
+
stmt.query_map([table], |r| {
|
|
1498
|
+
Ok((
|
|
1499
|
+
r.get::<_, String>(0)?,
|
|
1500
|
+
r.get::<_, i64>(1)?,
|
|
1501
|
+
r.get::<_, i64>(2)?,
|
|
1502
|
+
))
|
|
1503
|
+
})
|
|
1504
|
+
.unwrap()
|
|
1505
|
+
.map(|r| r.unwrap())
|
|
1506
|
+
.collect()
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
#[test]
|
|
1510
|
+
fn ensure_internal_rows_table_is_idempotent() {
|
|
1511
|
+
let db = Db::new().unwrap();
|
|
1512
|
+
// Db::new already created it; calling again must not error.
|
|
1513
|
+
ensure_internal_rows_table(&db.conn).unwrap();
|
|
1514
|
+
ensure_internal_rows_table(&db.conn).unwrap();
|
|
1515
|
+
// The table and its by-file index both exist.
|
|
1516
|
+
let table_count: i64 = db
|
|
1517
|
+
.conn
|
|
1518
|
+
.query_row(
|
|
1519
|
+
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
|
|
1520
|
+
[INTERNAL_ROWS_TABLE],
|
|
1521
|
+
|r| r.get(0),
|
|
1522
|
+
)
|
|
1523
|
+
.unwrap();
|
|
1524
|
+
assert_eq!(table_count, 1);
|
|
1525
|
+
let index_count: i64 = db
|
|
1526
|
+
.conn
|
|
1527
|
+
.query_row(
|
|
1528
|
+
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='_dirsql_internal_rows_by_file'",
|
|
1529
|
+
[],
|
|
1530
|
+
|r| r.get(0),
|
|
1531
|
+
)
|
|
1532
|
+
.unwrap();
|
|
1533
|
+
assert_eq!(index_count, 1);
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
#[test]
|
|
1537
|
+
fn insert_row_records_mapping() {
|
|
1538
|
+
let db = Db::new().unwrap();
|
|
1539
|
+
db.create_table("CREATE TABLE t (id TEXT)").unwrap();
|
|
1540
|
+
db.insert_row(
|
|
1541
|
+
"t",
|
|
1542
|
+
&HashMap::from([("id".into(), Value::Text("a".into()))]),
|
|
1543
|
+
"f.jsonl",
|
|
1544
|
+
3,
|
|
1545
|
+
)
|
|
1546
|
+
.unwrap();
|
|
1547
|
+
|
|
1548
|
+
let rows = mapping_rows(&db, "t");
|
|
1549
|
+
assert_eq!(rows.len(), 1);
|
|
1550
|
+
assert_eq!(rows[0].0, "f.jsonl");
|
|
1551
|
+
assert_eq!(rows[0].1, 3);
|
|
1552
|
+
// The captured rowid_ref points at the user row.
|
|
1553
|
+
let user_rowid: i64 = db
|
|
1554
|
+
.conn
|
|
1555
|
+
.query_row("SELECT rowid FROM t", [], |r| r.get(0))
|
|
1556
|
+
.unwrap();
|
|
1557
|
+
assert_eq!(rows[0].2, user_rowid);
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
#[test]
|
|
1561
|
+
fn insert_row_captures_user_declared_rowid_alias() {
|
|
1562
|
+
// A user-declared `INTEGER PRIMARY KEY` is a rowid alias: the inserted
|
|
1563
|
+
// value becomes the rowid, and `last_insert_rowid()` must capture it.
|
|
1564
|
+
let db = Db::new().unwrap();
|
|
1565
|
+
db.create_table("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
|
1566
|
+
.unwrap();
|
|
1567
|
+
db.insert_row(
|
|
1568
|
+
"t",
|
|
1569
|
+
&HashMap::from([
|
|
1570
|
+
("id".into(), Value::Integer(42)),
|
|
1571
|
+
("name".into(), Value::Text("x".into())),
|
|
1572
|
+
]),
|
|
1573
|
+
"f.json",
|
|
1574
|
+
0,
|
|
1575
|
+
)
|
|
1576
|
+
.unwrap();
|
|
1577
|
+
|
|
1578
|
+
let rows = mapping_rows(&db, "t");
|
|
1579
|
+
assert_eq!(rows.len(), 1);
|
|
1580
|
+
assert_eq!(rows[0].2, 42, "rowid_ref must be the user-declared rowid");
|
|
1581
|
+
db.check_row_mapping_equivalence("t").unwrap();
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
#[test]
|
|
1585
|
+
fn delete_rows_by_file_removes_mapping() {
|
|
1586
|
+
let db = Db::new().unwrap();
|
|
1587
|
+
db.create_table("CREATE TABLE t (id TEXT)").unwrap();
|
|
1588
|
+
for (i, file) in ["a.jsonl", "a.jsonl", "b.jsonl"].iter().enumerate() {
|
|
1589
|
+
db.insert_row(
|
|
1590
|
+
"t",
|
|
1591
|
+
&HashMap::from([("id".into(), Value::Text(i.to_string()))]),
|
|
1592
|
+
file,
|
|
1593
|
+
i,
|
|
1594
|
+
)
|
|
1595
|
+
.unwrap();
|
|
1596
|
+
}
|
|
1597
|
+
db.delete_rows_by_file("t", "a.jsonl").unwrap();
|
|
1598
|
+
|
|
1599
|
+
// Only b.jsonl's mapping survives, in lockstep with the user rows.
|
|
1600
|
+
let rows = mapping_rows(&db, "t");
|
|
1601
|
+
assert_eq!(rows.len(), 1);
|
|
1602
|
+
assert_eq!(rows[0].0, "b.jsonl");
|
|
1603
|
+
db.check_row_mapping_equivalence("t").unwrap();
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
#[test]
|
|
1607
|
+
fn delete_rows_by_file_reads_mapping_not_columns() {
|
|
1608
|
+
// Stage 2 (epic #358): delete-by-file resolves ownership through the
|
|
1609
|
+
// mapping, not the now-write-only `_dirsql_file_path` column. Corrupt
|
|
1610
|
+
// the column so it disagrees with the mapping and confirm the delete
|
|
1611
|
+
// still follows the mapping.
|
|
1612
|
+
let db = Db::new().unwrap();
|
|
1613
|
+
db.create_table("CREATE TABLE t (id TEXT)").unwrap();
|
|
1614
|
+
db.insert_row(
|
|
1615
|
+
"t",
|
|
1616
|
+
&HashMap::from([("id".into(), Value::Text("a".into()))]),
|
|
1617
|
+
"real.json",
|
|
1618
|
+
0,
|
|
1619
|
+
)
|
|
1620
|
+
.unwrap();
|
|
1621
|
+
// Desync the write-only column from the mapping.
|
|
1622
|
+
db.conn
|
|
1623
|
+
.execute("UPDATE t SET _dirsql_file_path = 'wrong.json'", [])
|
|
1624
|
+
.unwrap();
|
|
1625
|
+
|
|
1626
|
+
// Deleting by the mapping's file removes the row; deleting by the
|
|
1627
|
+
// stale column value is a no-op.
|
|
1628
|
+
assert_eq!(db.delete_rows_by_file("t", "wrong.json").unwrap(), 0);
|
|
1629
|
+
assert_eq!(db.delete_rows_by_file("t", "real.json").unwrap(), 1);
|
|
1630
|
+
let remaining: i64 = db
|
|
1631
|
+
.conn
|
|
1632
|
+
.query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
|
|
1633
|
+
.unwrap();
|
|
1634
|
+
assert_eq!(remaining, 0);
|
|
1635
|
+
db.check_row_mapping_equivalence("t").unwrap();
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
#[test]
|
|
1639
|
+
fn delete_rows_by_file_is_scoped_to_its_table() {
|
|
1640
|
+
// The mapping subquery is keyed on (table_name, file_path): a delete on
|
|
1641
|
+
// one table must not touch another table's rows for the same file path,
|
|
1642
|
+
// even when they share a rowid.
|
|
1643
|
+
let db = Db::new().unwrap();
|
|
1644
|
+
db.create_table("CREATE TABLE t1 (id TEXT)").unwrap();
|
|
1645
|
+
db.create_table("CREATE TABLE t2 (id TEXT)").unwrap();
|
|
1646
|
+
db.insert_row(
|
|
1647
|
+
"t1",
|
|
1648
|
+
&HashMap::from([("id".into(), Value::Text("x".into()))]),
|
|
1649
|
+
"shared.json",
|
|
1650
|
+
0,
|
|
1651
|
+
)
|
|
1652
|
+
.unwrap();
|
|
1653
|
+
db.insert_row(
|
|
1654
|
+
"t2",
|
|
1655
|
+
&HashMap::from([("id".into(), Value::Text("y".into()))]),
|
|
1656
|
+
"shared.json",
|
|
1657
|
+
0,
|
|
1658
|
+
)
|
|
1659
|
+
.unwrap();
|
|
1660
|
+
|
|
1661
|
+
assert_eq!(db.delete_rows_by_file("t1", "shared.json").unwrap(), 1);
|
|
1662
|
+
let t2_rows: i64 = db
|
|
1663
|
+
.conn
|
|
1664
|
+
.query_row("SELECT COUNT(*) FROM t2", [], |r| r.get(0))
|
|
1665
|
+
.unwrap();
|
|
1666
|
+
assert_eq!(t2_rows, 1, "t2's row for the same path must be untouched");
|
|
1667
|
+
db.check_row_mapping_equivalence("t1").unwrap();
|
|
1668
|
+
db.check_row_mapping_equivalence("t2").unwrap();
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
#[test]
|
|
1672
|
+
fn failed_row_insert_leaves_no_mapping_row() {
|
|
1673
|
+
// Transactional coupling, forward direction: when the user-row insert
|
|
1674
|
+
// fails, its mapping row must not be written either.
|
|
1675
|
+
let db = Db::new().unwrap();
|
|
1676
|
+
db.create_table("CREATE TABLE t (id TEXT UNIQUE)").unwrap();
|
|
1677
|
+
db.insert_row(
|
|
1678
|
+
"t",
|
|
1679
|
+
&HashMap::from([("id".into(), Value::Text("dup".into()))]),
|
|
1680
|
+
"a.json",
|
|
1681
|
+
0,
|
|
1682
|
+
)
|
|
1683
|
+
.unwrap();
|
|
1684
|
+
// Second insert of the same UNIQUE value fails.
|
|
1685
|
+
let err = db
|
|
1686
|
+
.insert_row(
|
|
1687
|
+
"t",
|
|
1688
|
+
&HashMap::from([("id".into(), Value::Text("dup".into()))]),
|
|
1689
|
+
"b.json",
|
|
1690
|
+
0,
|
|
1691
|
+
)
|
|
1692
|
+
.unwrap_err();
|
|
1693
|
+
assert!(matches!(err, DbError::Sqlite(_)), "got: {err}");
|
|
1694
|
+
|
|
1695
|
+
let rows = mapping_rows(&db, "t");
|
|
1696
|
+
assert_eq!(
|
|
1697
|
+
rows.len(),
|
|
1698
|
+
1,
|
|
1699
|
+
"the failed insert must not add a mapping row"
|
|
1700
|
+
);
|
|
1701
|
+
assert_eq!(rows[0].0, "a.json");
|
|
1702
|
+
db.check_row_mapping_equivalence("t").unwrap();
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
#[test]
|
|
1706
|
+
fn failed_mapping_insert_rolls_back_row_insert() {
|
|
1707
|
+
// Transactional coupling, reverse direction: if the mapping insert
|
|
1708
|
+
// fails, the user-row insert must roll back too (nothing left behind).
|
|
1709
|
+
let db = Db::new().unwrap();
|
|
1710
|
+
db.create_table("CREATE TABLE t (id TEXT)").unwrap();
|
|
1711
|
+
// Remove the mapping table so the second statement in the transaction
|
|
1712
|
+
// errors with "no such table".
|
|
1713
|
+
db.conn
|
|
1714
|
+
.execute("DROP TABLE _dirsql_internal_rows", [])
|
|
1715
|
+
.unwrap();
|
|
1716
|
+
let err = db
|
|
1717
|
+
.insert_row(
|
|
1718
|
+
"t",
|
|
1719
|
+
&HashMap::from([("id".into(), Value::Text("x".into()))]),
|
|
1720
|
+
"a.json",
|
|
1721
|
+
0,
|
|
1722
|
+
)
|
|
1723
|
+
.unwrap_err();
|
|
1724
|
+
assert!(matches!(err, DbError::Sqlite(_)), "got: {err}");
|
|
1725
|
+
|
|
1726
|
+
let count: i64 = db
|
|
1727
|
+
.conn
|
|
1728
|
+
.query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
|
|
1729
|
+
.unwrap();
|
|
1730
|
+
assert_eq!(count, 0, "the user row must have rolled back");
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
#[test]
|
|
1734
|
+
fn check_row_mapping_equivalence_detects_drift() {
|
|
1735
|
+
let db = Db::new().unwrap();
|
|
1736
|
+
db.create_table("CREATE TABLE t (id TEXT)").unwrap();
|
|
1737
|
+
db.insert_row(
|
|
1738
|
+
"t",
|
|
1739
|
+
&HashMap::from([("id".into(), Value::Text("a".into()))]),
|
|
1740
|
+
"f.json",
|
|
1741
|
+
0,
|
|
1742
|
+
)
|
|
1743
|
+
.unwrap();
|
|
1744
|
+
db.check_row_mapping_equivalence("t").unwrap();
|
|
1745
|
+
|
|
1746
|
+
// Corrupt the mapping directly, bypassing the dual-write path.
|
|
1747
|
+
db.conn
|
|
1748
|
+
.execute("DELETE FROM _dirsql_internal_rows", [])
|
|
1749
|
+
.unwrap();
|
|
1750
|
+
let err = db.check_row_mapping_equivalence("t").unwrap_err();
|
|
1751
|
+
assert!(matches!(err, DbError::SchemaMismatch(_)), "got: {err}");
|
|
1752
|
+
assert!(err.to_string().contains("row mapping drift"));
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
#[test]
|
|
1756
|
+
fn check_row_mapping_equivalence_rejects_bad_table_name() {
|
|
1757
|
+
let db = Db::new().unwrap();
|
|
1758
|
+
let err = db.check_row_mapping_equivalence("bad name").unwrap_err();
|
|
1759
|
+
assert!(matches!(err, DbError::InvalidIdentifier(_)), "got: {err}");
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
#[test]
|
|
1763
|
+
fn create_table_allows_without_rowid_and_warns() {
|
|
1764
|
+
// Stage 1 only warns (via eprintln); the table is still created.
|
|
1765
|
+
let db = Db::new().unwrap();
|
|
1766
|
+
db.create_table("CREATE TABLE t (id TEXT PRIMARY KEY) WITHOUT ROWID")
|
|
1767
|
+
.unwrap();
|
|
1768
|
+
let rows = db.query("SELECT * FROM t").unwrap();
|
|
1769
|
+
assert_eq!(rows.len(), 0);
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
#[test]
|
|
1773
|
+
fn is_without_rowid_ddl_detects_variants() {
|
|
1774
|
+
assert!(is_without_rowid_ddl(
|
|
1775
|
+
"CREATE TABLE t (id TEXT PRIMARY KEY) WITHOUT ROWID"
|
|
1776
|
+
));
|
|
1777
|
+
assert!(is_without_rowid_ddl(
|
|
1778
|
+
"create table t (id text primary key)\n without rowid"
|
|
1779
|
+
));
|
|
1780
|
+
assert!(!is_without_rowid_ddl("CREATE TABLE t (id TEXT)"));
|
|
1781
|
+
}
|
|
1326
1782
|
}
|
|
@@ -19,7 +19,12 @@ use crate::Table;
|
|
|
19
19
|
|
|
20
20
|
/// Sidecar schema version. Bumped on any breaking change to the layout of
|
|
21
21
|
/// `_dirsql_files` / `_dirsql_meta`.
|
|
22
|
-
|
|
22
|
+
///
|
|
23
|
+
/// `2` (epic #358, stage 1): adds the `_dirsql_internal_rows` bookkeeping
|
|
24
|
+
/// table. A cache written by an older build lacks the mapping, so the version
|
|
25
|
+
/// mismatch forces a penalty-free full rebuild on first startup after upgrade,
|
|
26
|
+
/// which populates the mapping from scratch.
|
|
27
|
+
pub const SCHEMA_VERSION: &str = "2";
|
|
23
28
|
|
|
24
29
|
/// Bumped whenever any built-in parser changes its row-shape contract. A
|
|
25
30
|
/// mismatch forces a full rebuild.
|
|
@@ -372,12 +377,22 @@ pub fn drop_user_tables(conn: &Connection) -> rusqlite::Result<()> {
|
|
|
372
377
|
conn.execute(&format!("DROP TABLE IF EXISTS \"{name}\""), [])?;
|
|
373
378
|
}
|
|
374
379
|
conn.execute("DELETE FROM _dirsql_files", [])?;
|
|
380
|
+
// Wipe the row mapping too (epic #358): a cold rebuild re-ingests every
|
|
381
|
+
// file, and `insert_row` repopulates the mapping, so any stale rows here
|
|
382
|
+
// must go or they would duplicate / orphan the fresh state.
|
|
383
|
+
conn.execute("DELETE FROM _dirsql_internal_rows", [])?;
|
|
375
384
|
Ok(())
|
|
376
385
|
}
|
|
377
386
|
|
|
378
|
-
/// Look up the
|
|
379
|
-
///
|
|
380
|
-
///
|
|
387
|
+
/// Look up the row store's rows for one file, ordered by row index. Used on
|
|
388
|
+
/// warm start to rebuild the in-memory `file_rows` cache that the watcher's
|
|
389
|
+
/// diffing path requires.
|
|
390
|
+
///
|
|
391
|
+
/// Stage 2 (epic #358): ownership and ordering are read from the
|
|
392
|
+
/// `_dirsql_internal_rows` mapping (joined on `rowid`), not the injected
|
|
393
|
+
/// `_dirsql_file_path` / `_dirsql_row_index` columns (now write-only). User
|
|
394
|
+
/// columns are qualified with the table alias so a user column named like a
|
|
395
|
+
/// mapping column stays unambiguous.
|
|
381
396
|
pub fn read_rows_for_file(
|
|
382
397
|
conn: &Connection,
|
|
383
398
|
table: &str,
|
|
@@ -386,17 +401,19 @@ pub fn read_rows_for_file(
|
|
|
386
401
|
) -> rusqlite::Result<Vec<HashMap<String, crate::db::Value>>> {
|
|
387
402
|
let mut col_list = user_columns
|
|
388
403
|
.iter()
|
|
389
|
-
.map(|c| format!("
|
|
404
|
+
.map(|c| format!("t.\"{c}\""))
|
|
390
405
|
.collect::<Vec<_>>()
|
|
391
406
|
.join(", ");
|
|
392
407
|
if col_list.is_empty() {
|
|
393
408
|
col_list = "1".to_string();
|
|
394
409
|
}
|
|
395
410
|
let sql = format!(
|
|
396
|
-
"SELECT {col_list} FROM \"{table}\"
|
|
411
|
+
"SELECT {col_list} FROM \"{table}\" AS t \
|
|
412
|
+
JOIN _dirsql_internal_rows AS m ON m.rowid_ref = t.rowid \
|
|
413
|
+
WHERE m.table_name = ?1 AND m.file_path = ?2 ORDER BY m.row_index"
|
|
397
414
|
);
|
|
398
415
|
let mut stmt = conn.prepare(&sql)?;
|
|
399
|
-
let rows = stmt.query_map(params![rel_path], |row| {
|
|
416
|
+
let rows = stmt.query_map(params![table, rel_path], |row| {
|
|
400
417
|
let mut map = HashMap::new();
|
|
401
418
|
for (i, name) in user_columns.iter().enumerate() {
|
|
402
419
|
let v: rusqlite::types::Value = row.get(i)?;
|
|
@@ -530,9 +547,26 @@ mod tests {
|
|
|
530
547
|
fn drop_user_tables_clears_user_data_and_files_index() {
|
|
531
548
|
let conn = Connection::open_in_memory().unwrap();
|
|
532
549
|
create_sidecar_tables(&conn).unwrap();
|
|
550
|
+
// The internal row mapping (epic #358) is created by `Db::open` in
|
|
551
|
+
// production; declare it inline here since this test uses a raw
|
|
552
|
+
// connection (calling the `db` helper would break unit isolation).
|
|
553
|
+
conn.execute(
|
|
554
|
+
"CREATE TABLE _dirsql_internal_rows (
|
|
555
|
+
table_name TEXT NOT NULL, file_path TEXT NOT NULL,
|
|
556
|
+
row_index INTEGER NOT NULL, rowid_ref INTEGER NOT NULL)",
|
|
557
|
+
[],
|
|
558
|
+
)
|
|
559
|
+
.unwrap();
|
|
533
560
|
conn.execute("CREATE TABLE rows (x TEXT)", []).unwrap();
|
|
534
561
|
conn.execute("INSERT INTO rows (x) VALUES ('a')", [])
|
|
535
562
|
.unwrap();
|
|
563
|
+
// A stale mapping row that a cold rebuild must wipe.
|
|
564
|
+
conn.execute(
|
|
565
|
+
"INSERT INTO _dirsql_internal_rows (table_name, file_path, row_index, rowid_ref) \
|
|
566
|
+
VALUES ('rows', 'a.csv', 0, 1)",
|
|
567
|
+
[],
|
|
568
|
+
)
|
|
569
|
+
.unwrap();
|
|
536
570
|
let stat = FileStat {
|
|
537
571
|
size: 1,
|
|
538
572
|
mtime_ns: 1,
|
|
@@ -554,6 +588,13 @@ mod tests {
|
|
|
554
588
|
.unwrap();
|
|
555
589
|
assert_eq!(table_count, 0);
|
|
556
590
|
assert!(read_cached_files(&conn).unwrap().is_empty());
|
|
591
|
+
// The internal row mapping is wiped too, ready for a fresh re-ingest.
|
|
592
|
+
let mapping_count: i64 = conn
|
|
593
|
+
.query_row("SELECT COUNT(*) FROM _dirsql_internal_rows", [], |r| {
|
|
594
|
+
r.get(0)
|
|
595
|
+
})
|
|
596
|
+
.unwrap();
|
|
597
|
+
assert_eq!(mapping_count, 0);
|
|
557
598
|
// Meta is preserved by drop_user_tables -- we replace it explicitly.
|
|
558
599
|
let m = read_meta(&conn).unwrap();
|
|
559
600
|
assert_eq!(m.get("x"), Some(&"y".to_string()));
|
|
@@ -640,6 +681,18 @@ mod tests {
|
|
|
640
681
|
assert_eq!(system_time_to_ns(None), 0);
|
|
641
682
|
}
|
|
642
683
|
|
|
684
|
+
/// Declare the internal mapping table on a raw connection (the `db` helper
|
|
685
|
+
/// would break unit isolation). Stage 2 reads through this table.
|
|
686
|
+
fn create_internal_rows_table(conn: &Connection) {
|
|
687
|
+
conn.execute(
|
|
688
|
+
"CREATE TABLE _dirsql_internal_rows (
|
|
689
|
+
table_name TEXT NOT NULL, file_path TEXT NOT NULL,
|
|
690
|
+
row_index INTEGER NOT NULL, rowid_ref INTEGER NOT NULL)",
|
|
691
|
+
[],
|
|
692
|
+
)
|
|
693
|
+
.unwrap();
|
|
694
|
+
}
|
|
695
|
+
|
|
643
696
|
#[test]
|
|
644
697
|
fn read_rows_for_file_handles_empty_user_columns() {
|
|
645
698
|
// With no user columns the SELECT list collapses to `1`, so the
|
|
@@ -650,11 +703,19 @@ mod tests {
|
|
|
650
703
|
[],
|
|
651
704
|
)
|
|
652
705
|
.unwrap();
|
|
706
|
+
create_internal_rows_table(&conn);
|
|
653
707
|
conn.execute(
|
|
654
708
|
"INSERT INTO rows (_dirsql_file_path, _dirsql_row_index) VALUES ('f.csv', 0), ('f.csv', 1)",
|
|
655
709
|
[],
|
|
656
710
|
)
|
|
657
711
|
.unwrap();
|
|
712
|
+
// Map the two inserted rows (rowids 1, 2) to f.csv.
|
|
713
|
+
conn.execute(
|
|
714
|
+
"INSERT INTO _dirsql_internal_rows (table_name, file_path, row_index, rowid_ref) \
|
|
715
|
+
VALUES ('rows', 'f.csv', 0, 1), ('rows', 'f.csv', 1, 2)",
|
|
716
|
+
[],
|
|
717
|
+
)
|
|
718
|
+
.unwrap();
|
|
658
719
|
let rows = read_rows_for_file(&conn, "rows", "f.csv", &[]).unwrap();
|
|
659
720
|
assert_eq!(rows.len(), 2);
|
|
660
721
|
assert!(rows[0].is_empty());
|
|
@@ -668,17 +729,59 @@ mod tests {
|
|
|
668
729
|
[],
|
|
669
730
|
)
|
|
670
731
|
.unwrap();
|
|
732
|
+
create_internal_rows_table(&conn);
|
|
671
733
|
conn.execute(
|
|
672
734
|
"INSERT INTO rows (col, _dirsql_file_path, _dirsql_row_index) VALUES ('a', 'f.csv', 0), ('b', 'f.csv', 1), ('c', 'g.csv', 0)",
|
|
673
735
|
[],
|
|
674
736
|
)
|
|
675
737
|
.unwrap();
|
|
738
|
+
// rowids 1, 2 belong to f.csv; rowid 3 to g.csv.
|
|
739
|
+
conn.execute(
|
|
740
|
+
"INSERT INTO _dirsql_internal_rows (table_name, file_path, row_index, rowid_ref) \
|
|
741
|
+
VALUES ('rows', 'f.csv', 0, 1), ('rows', 'f.csv', 1, 2), ('rows', 'g.csv', 0, 3)",
|
|
742
|
+
[],
|
|
743
|
+
)
|
|
744
|
+
.unwrap();
|
|
676
745
|
let rows = read_rows_for_file(&conn, "rows", "f.csv", &["col".to_string()]).unwrap();
|
|
677
746
|
assert_eq!(rows.len(), 2);
|
|
678
747
|
assert_eq!(rows[0]["col"], crate::db::Value::Text("a".into()));
|
|
679
748
|
assert_eq!(rows[1]["col"], crate::db::Value::Text("b".into()));
|
|
680
749
|
}
|
|
681
750
|
|
|
751
|
+
#[test]
|
|
752
|
+
fn read_rows_for_file_reads_mapping_not_columns() {
|
|
753
|
+
// Stage 2: ownership + ordering come from the mapping, not the
|
|
754
|
+
// write-only injected columns. Store a row whose `_dirsql_file_path` /
|
|
755
|
+
// `_dirsql_row_index` columns DISAGREE with the mapping, and confirm
|
|
756
|
+
// the read follows the mapping.
|
|
757
|
+
let conn = Connection::open_in_memory().unwrap();
|
|
758
|
+
conn.execute(
|
|
759
|
+
"CREATE TABLE rows (col TEXT, _dirsql_file_path TEXT, _dirsql_row_index INTEGER)",
|
|
760
|
+
[],
|
|
761
|
+
)
|
|
762
|
+
.unwrap();
|
|
763
|
+
create_internal_rows_table(&conn);
|
|
764
|
+
// Column says wrong.csv / index 9; mapping says right.csv / index 0.
|
|
765
|
+
conn.execute(
|
|
766
|
+
"INSERT INTO rows (col, _dirsql_file_path, _dirsql_row_index) VALUES ('a', 'wrong.csv', 9)",
|
|
767
|
+
[],
|
|
768
|
+
)
|
|
769
|
+
.unwrap();
|
|
770
|
+
conn.execute(
|
|
771
|
+
"INSERT INTO _dirsql_internal_rows (table_name, file_path, row_index, rowid_ref) \
|
|
772
|
+
VALUES ('rows', 'right.csv', 0, 1)",
|
|
773
|
+
[],
|
|
774
|
+
)
|
|
775
|
+
.unwrap();
|
|
776
|
+
// Querying the mapping's file returns the row...
|
|
777
|
+
let hit = read_rows_for_file(&conn, "rows", "right.csv", &["col".to_string()]).unwrap();
|
|
778
|
+
assert_eq!(hit.len(), 1);
|
|
779
|
+
assert_eq!(hit[0]["col"], crate::db::Value::Text("a".into()));
|
|
780
|
+
// ...and the column's (stale) file returns nothing.
|
|
781
|
+
let miss = read_rows_for_file(&conn, "rows", "wrong.csv", &["col".to_string()]).unwrap();
|
|
782
|
+
assert!(miss.is_empty());
|
|
783
|
+
}
|
|
784
|
+
|
|
682
785
|
// --- rusqlite error propagation (`?` arms) ---
|
|
683
786
|
//
|
|
684
787
|
// Each sidecar accessor runs against a `_dirsql_*` table. Calling it on a
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dirsql-0.3.55 → dirsql-0.3.57}/packages/python/docs/tests/integration/language-flag.spec.ts
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|