dirsql 0.3.56__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.
Files changed (124) hide show
  1. {dirsql-0.3.56 → dirsql-0.3.57}/Cargo.lock +1 -1
  2. {dirsql-0.3.56 → dirsql-0.3.57}/PKG-INFO +1 -1
  3. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/Cargo.toml +1 -1
  4. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/e2e-attestation.json +2 -2
  5. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/src/db.rs +77 -2
  6. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/src/persist.rs +76 -6
  7. {dirsql-0.3.56 → dirsql-0.3.57}/Cargo.toml +0 -0
  8. {dirsql-0.3.56 → dirsql-0.3.57}/README.md +0 -0
  9. {dirsql-0.3.56 → dirsql-0.3.57}/dirsql/__init__.py +0 -0
  10. {dirsql-0.3.56 → dirsql-0.3.57}/dirsql/_async.py +0 -0
  11. {dirsql-0.3.56 → dirsql-0.3.57}/dirsql/_dirsql.pyi +0 -0
  12. {dirsql-0.3.56 → dirsql-0.3.57}/dirsql/cli/__init__.py +0 -0
  13. {dirsql-0.3.56 → dirsql-0.3.57}/dirsql/cli/binary_path.py +0 -0
  14. {dirsql-0.3.56 → dirsql-0.3.57}/dirsql/cli/interpret/__init__.py +0 -0
  15. {dirsql-0.3.56 → dirsql-0.3.57}/dirsql/cli/is_windows.py +0 -0
  16. {dirsql-0.3.56 → dirsql-0.3.57}/dirsql/cli/main.py +0 -0
  17. {dirsql-0.3.56 → dirsql-0.3.57}/dirsql/cli/resolve_config_extensions.py +0 -0
  18. {dirsql-0.3.56 → dirsql-0.3.57}/dirsql/py.typed +0 -0
  19. {dirsql-0.3.56 → dirsql-0.3.57}/dirsql/resolve_config_extensions.py +0 -0
  20. {dirsql-0.3.56 → dirsql-0.3.57}/dirsql/resolve_extension.py +0 -0
  21. {dirsql-0.3.56 → dirsql-0.3.57}/docs/.claude/CLAUDE.md +0 -0
  22. {dirsql-0.3.56 → dirsql-0.3.57}/docs/.vitepress/config.ts +0 -0
  23. {dirsql-0.3.56 → dirsql-0.3.57}/docs/.vitepress/theme/index.ts +0 -0
  24. {dirsql-0.3.56 → dirsql-0.3.57}/docs/.vitepress/theme/lang.ts +0 -0
  25. {dirsql-0.3.56 → dirsql-0.3.57}/docs/AGENTS.md +0 -0
  26. {dirsql-0.3.56 → dirsql-0.3.57}/docs/api/index.md +0 -0
  27. {dirsql-0.3.56 → dirsql-0.3.57}/docs/cli/config.md +0 -0
  28. {dirsql-0.3.56 → dirsql-0.3.57}/docs/cli/http-api.md +0 -0
  29. {dirsql-0.3.56 → dirsql-0.3.57}/docs/cli/index.md +0 -0
  30. {dirsql-0.3.56 → dirsql-0.3.57}/docs/cli/init.md +0 -0
  31. {dirsql-0.3.56 → dirsql-0.3.57}/docs/cli/server.md +0 -0
  32. {dirsql-0.3.56 → dirsql-0.3.57}/docs/getting-started.md +0 -0
  33. {dirsql-0.3.56 → dirsql-0.3.57}/docs/guide/async.md +0 -0
  34. {dirsql-0.3.56 → dirsql-0.3.57}/docs/guide/crdt.md +0 -0
  35. {dirsql-0.3.56 → dirsql-0.3.57}/docs/guide/persistence.md +0 -0
  36. {dirsql-0.3.56 → dirsql-0.3.57}/docs/guide/querying.md +0 -0
  37. {dirsql-0.3.56 → dirsql-0.3.57}/docs/guide/tables.md +0 -0
  38. {dirsql-0.3.56 → dirsql-0.3.57}/docs/guide/watching.md +0 -0
  39. {dirsql-0.3.56 → dirsql-0.3.57}/docs/index.md +0 -0
  40. {dirsql-0.3.56 → dirsql-0.3.57}/docs/migrations.md +0 -0
  41. {dirsql-0.3.56 → dirsql-0.3.57}/docs/package.json +0 -0
  42. {dirsql-0.3.56 → dirsql-0.3.57}/docs/playwright.config.ts +0 -0
  43. {dirsql-0.3.56 → dirsql-0.3.57}/docs/pnpm-lock.yaml +0 -0
  44. {dirsql-0.3.56 → dirsql-0.3.57}/docs/pnpm-workspace.yaml +0 -0
  45. {dirsql-0.3.56 → dirsql-0.3.57}/docs/tests/integration/home.spec.ts +0 -0
  46. {dirsql-0.3.56 → dirsql-0.3.57}/docs/tests/integration/language-flag.spec.ts +0 -0
  47. {dirsql-0.3.56 → dirsql-0.3.57}/docs/tests/integration/sidebar.spec.ts +0 -0
  48. {dirsql-0.3.56 → dirsql-0.3.57}/docs/tests/unit/config.test.ts +0 -0
  49. {dirsql-0.3.56 → dirsql-0.3.57}/docs/tests/unit/lang.test.ts +0 -0
  50. {dirsql-0.3.56 → dirsql-0.3.57}/docs/vitest.config.ts +0 -0
  51. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/README.md +0 -0
  52. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/conftest.py +0 -0
  53. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/.claude/CLAUDE.md +0 -0
  54. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/.vitepress/config.ts +0 -0
  55. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/.vitepress/theme/index.ts +0 -0
  56. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
  57. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/AGENTS.md +0 -0
  58. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/api/index.md +0 -0
  59. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/cli/config.md +0 -0
  60. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/cli/http-api.md +0 -0
  61. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/cli/index.md +0 -0
  62. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/cli/init.md +0 -0
  63. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/cli/server.md +0 -0
  64. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/getting-started.md +0 -0
  65. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/guide/async.md +0 -0
  66. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/guide/crdt.md +0 -0
  67. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/guide/persistence.md +0 -0
  68. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/guide/querying.md +0 -0
  69. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/guide/tables.md +0 -0
  70. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/guide/watching.md +0 -0
  71. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/index.md +0 -0
  72. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/migrations.md +0 -0
  73. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/package.json +0 -0
  74. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/playwright.config.ts +0 -0
  75. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/pnpm-lock.yaml +0 -0
  76. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/pnpm-workspace.yaml +0 -0
  77. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/tests/integration/home.spec.ts +0 -0
  78. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
  79. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/tests/integration/sidebar.spec.ts +0 -0
  80. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/tests/unit/config.test.ts +0 -0
  81. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/tests/unit/lang.test.ts +0 -0
  82. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/docs/vitest.config.ts +0 -0
  83. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/src/lib.rs +0 -0
  84. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/tests/__init__.py +0 -0
  85. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/tests/binding/__init__.py +0 -0
  86. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/tests/conftest.py +0 -0
  87. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/tests/e2e/__init__.py +0 -0
  88. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/tests/integration/__init__.py +0 -0
  89. {dirsql-0.3.56 → dirsql-0.3.57}/packages/python/tests/smoke/__init__.py +0 -0
  90. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/Cargo.toml +0 -0
  91. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/README.md +0 -0
  92. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/benches/db_bench.rs +0 -0
  93. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/benches/differ_bench.rs +0 -0
  94. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/benches/matcher_bench.rs +0 -0
  95. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/benches/scanner_bench.rs +0 -0
  96. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/docs/api/index.md +0 -0
  97. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/docs/cli/config.md +0 -0
  98. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/docs/cli/http-api.md +0 -0
  99. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/docs/cli/index.md +0 -0
  100. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/docs/cli/init.md +0 -0
  101. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/docs/cli/server.md +0 -0
  102. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/docs/getting-started.md +0 -0
  103. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/docs/guide/async.md +0 -0
  104. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/docs/guide/crdt.md +0 -0
  105. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/docs/guide/persistence.md +0 -0
  106. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/docs/guide/querying.md +0 -0
  107. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/docs/guide/tables.md +0 -0
  108. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/docs/guide/watching.md +0 -0
  109. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/docs/index.md +0 -0
  110. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/docs/migrations.md +0 -0
  111. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/src/bin/dirsql.rs +0 -0
  112. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/src/cli/init.rs +0 -0
  113. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/src/cli/mod.rs +0 -0
  114. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/src/cli/router.rs +0 -0
  115. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/src/cli/serialize.rs +0 -0
  116. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/src/cli/server.rs +0 -0
  117. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/src/command.rs +0 -0
  118. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/src/config.rs +0 -0
  119. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/src/differ.rs +0 -0
  120. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/src/lib.rs +0 -0
  121. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/src/matcher.rs +0 -0
  122. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/src/scanner.rs +0 -0
  123. {dirsql-0.3.56 → dirsql-0.3.57}/packages/rust/src/watcher.rs +0 -0
  124. {dirsql-0.3.56 → dirsql-0.3.57}/pyproject.toml +0 -0
@@ -500,7 +500,7 @@ dependencies = [
500
500
 
501
501
  [[package]]
502
502
  name = "dirsql-py-ext"
503
- version = "0.3.56"
503
+ version = "0.3.57"
504
504
  dependencies = [
505
505
  "dirsql",
506
506
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirsql
3
- Version: 0.3.56
3
+ Version: 0.3.57
4
4
  Requires-Dist: pytest>=8 ; extra == 'dev'
5
5
  Requires-Dist: pytest-describe>=2 ; extra == 'dev'
6
6
  Requires-Dist: pytest-asyncio>=0.23 ; extra == 'dev'
@@ -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.56"
7
+ version = "0.3.57"
8
8
  edition.workspace = true
9
9
  publish = false
10
10
  readme = "README.md"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "command": "uv run python -m pytest tests/e2e/ -x -q",
3
- "ran_at": 1783101421,
3
+ "ran_at": 1783105847,
4
4
  "exit_code": 0,
5
- "commit": "557de9e56c14139d4d35b6ba0aed13c601497d4d"
5
+ "commit": "d94d40b0448b03171f2e8ffd60635575da1353d5"
6
6
  }
@@ -301,11 +301,21 @@ impl Db {
301
301
  /// The user-row deletes and the matching `_dirsql_internal_rows` mapping
302
302
  /// deletes commit in ONE transaction (epic #358), so the mapping never
303
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.
304
309
  pub fn delete_rows_by_file(&self, table: &str, file_path: &str) -> Result<usize> {
305
310
  validate_identifier(table)?;
306
311
  let tx = self.conn.unchecked_transaction()?;
307
- let sql = format!("DELETE FROM {} WHERE _dirsql_file_path = ?1", table);
308
- let count = tx.execute(&sql, [file_path])?;
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])?;
309
319
  tx.execute(
310
320
  "DELETE FROM _dirsql_internal_rows WHERE table_name = ?1 AND file_path = ?2",
311
321
  rusqlite::params![table, file_path],
@@ -1593,6 +1603,71 @@ mod tests {
1593
1603
  db.check_row_mapping_equivalence("t").unwrap();
1594
1604
  }
1595
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
+
1596
1671
  #[test]
1597
1672
  fn failed_row_insert_leaves_no_mapping_row() {
1598
1673
  // Transactional coupling, forward direction: when the user-row insert
@@ -384,9 +384,15 @@ pub fn drop_user_tables(conn: &Connection) -> rusqlite::Result<()> {
384
384
  Ok(())
385
385
  }
386
386
 
387
- /// Look up the existing `dirsql_*` tracking column rows for one file. Used
388
- /// on warm start to rebuild the in-memory `file_rows` cache that the
389
- /// watcher's diffing path requires.
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.
390
396
  pub fn read_rows_for_file(
391
397
  conn: &Connection,
392
398
  table: &str,
@@ -395,17 +401,19 @@ pub fn read_rows_for_file(
395
401
  ) -> rusqlite::Result<Vec<HashMap<String, crate::db::Value>>> {
396
402
  let mut col_list = user_columns
397
403
  .iter()
398
- .map(|c| format!("\"{c}\""))
404
+ .map(|c| format!("t.\"{c}\""))
399
405
  .collect::<Vec<_>>()
400
406
  .join(", ");
401
407
  if col_list.is_empty() {
402
408
  col_list = "1".to_string();
403
409
  }
404
410
  let sql = format!(
405
- "SELECT {col_list} FROM \"{table}\" WHERE _dirsql_file_path = ?1 ORDER BY _dirsql_row_index"
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"
406
414
  );
407
415
  let mut stmt = conn.prepare(&sql)?;
408
- let rows = stmt.query_map(params![rel_path], |row| {
416
+ let rows = stmt.query_map(params![table, rel_path], |row| {
409
417
  let mut map = HashMap::new();
410
418
  for (i, name) in user_columns.iter().enumerate() {
411
419
  let v: rusqlite::types::Value = row.get(i)?;
@@ -673,6 +681,18 @@ mod tests {
673
681
  assert_eq!(system_time_to_ns(None), 0);
674
682
  }
675
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
+
676
696
  #[test]
677
697
  fn read_rows_for_file_handles_empty_user_columns() {
678
698
  // With no user columns the SELECT list collapses to `1`, so the
@@ -683,11 +703,19 @@ mod tests {
683
703
  [],
684
704
  )
685
705
  .unwrap();
706
+ create_internal_rows_table(&conn);
686
707
  conn.execute(
687
708
  "INSERT INTO rows (_dirsql_file_path, _dirsql_row_index) VALUES ('f.csv', 0), ('f.csv', 1)",
688
709
  [],
689
710
  )
690
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();
691
719
  let rows = read_rows_for_file(&conn, "rows", "f.csv", &[]).unwrap();
692
720
  assert_eq!(rows.len(), 2);
693
721
  assert!(rows[0].is_empty());
@@ -701,17 +729,59 @@ mod tests {
701
729
  [],
702
730
  )
703
731
  .unwrap();
732
+ create_internal_rows_table(&conn);
704
733
  conn.execute(
705
734
  "INSERT INTO rows (col, _dirsql_file_path, _dirsql_row_index) VALUES ('a', 'f.csv', 0), ('b', 'f.csv', 1), ('c', 'g.csv', 0)",
706
735
  [],
707
736
  )
708
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();
709
745
  let rows = read_rows_for_file(&conn, "rows", "f.csv", &["col".to_string()]).unwrap();
710
746
  assert_eq!(rows.len(), 2);
711
747
  assert_eq!(rows[0]["col"], crate::db::Value::Text("a".into()));
712
748
  assert_eq!(rows[1]["col"], crate::db::Value::Text("b".into()));
713
749
  }
714
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
+
715
785
  // --- rusqlite error propagation (`?` arms) ---
716
786
  //
717
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