dirsql 0.0.23__tar.gz → 0.0.24__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.0.23 → dirsql-0.0.24}/PKG-INFO +1 -1
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/core/src/differ.rs +71 -31
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/python/src/lib.rs +15 -6
- dirsql-0.0.24/packages/python/tests/integration/test_binding.py +266 -0
- dirsql-0.0.24/packages/python/tests/integration/test_docs_gaps.py +374 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/pyproject.toml +1 -1
- {dirsql-0.0.23 → dirsql-0.0.24}/Cargo.lock +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/Cargo.toml +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/README.md +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/core/Cargo.toml +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/core/README.md +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/core/benches/db_bench.rs +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/core/benches/differ_bench.rs +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/core/benches/matcher_bench.rs +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/core/benches/scanner_bench.rs +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/core/src/config.rs +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/core/src/db.rs +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/core/src/lib.rs +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/core/src/matcher.rs +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/core/src/parser.rs +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/core/src/scanner.rs +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/core/src/watcher.rs +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/python/Cargo.toml +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/python/README.md +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/python/tests/__init__.py +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/python/tests/conftest.py +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/python/tests/integration/__init__.py +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/python/tests/integration/test_async_dirsql.py +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/python/tests/integration/test_dirsql.py +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/python/tests/integration/test_docs_examples.py +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/packages/python/tests/integration/test_from_config.py +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/python/dirsql/__init__.py +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/python/dirsql/_async.py +0 -0
- {dirsql-0.0.23 → dirsql-0.0.24}/python/dirsql/test_async.py +0 -0
|
@@ -4,20 +4,26 @@ use std::path::PathBuf;
|
|
|
4
4
|
use crate::db::Value;
|
|
5
5
|
|
|
6
6
|
/// Events produced by comparing old and new file content.
|
|
7
|
+
///
|
|
8
|
+
/// All variants include the source file's relative path (`file_path`) so
|
|
9
|
+
/// downstream consumers can attribute row events to their originating file.
|
|
7
10
|
#[derive(Debug, Clone, PartialEq)]
|
|
8
11
|
pub enum RowEvent {
|
|
9
12
|
Insert {
|
|
10
13
|
table: String,
|
|
11
14
|
row: HashMap<String, Value>,
|
|
15
|
+
file_path: String,
|
|
12
16
|
},
|
|
13
17
|
Update {
|
|
14
18
|
table: String,
|
|
15
19
|
old_row: HashMap<String, Value>,
|
|
16
20
|
new_row: HashMap<String, Value>,
|
|
21
|
+
file_path: String,
|
|
17
22
|
},
|
|
18
23
|
Delete {
|
|
19
24
|
table: String,
|
|
20
25
|
row: HashMap<String, Value>,
|
|
26
|
+
file_path: String,
|
|
21
27
|
},
|
|
22
28
|
Error {
|
|
23
29
|
file_path: PathBuf,
|
|
@@ -30,7 +36,7 @@ pub enum RowEvent {
|
|
|
30
36
|
/// - `table`: the target table name
|
|
31
37
|
/// - `old`: previous row content (None if file is new)
|
|
32
38
|
/// - `new`: current row content (None if file was deleted)
|
|
33
|
-
/// - `file_path`: the file path
|
|
39
|
+
/// - `file_path`: the relative file path to attach to each emitted event
|
|
34
40
|
///
|
|
35
41
|
/// For multi-row files (JSONL), uses line-index-based identity:
|
|
36
42
|
/// - Unchanged lines produce no events
|
|
@@ -43,7 +49,7 @@ pub fn diff(
|
|
|
43
49
|
table: &str,
|
|
44
50
|
old: Option<&[HashMap<String, Value>]>,
|
|
45
51
|
new: Option<&[HashMap<String, Value>]>,
|
|
46
|
-
|
|
52
|
+
file_path: &str,
|
|
47
53
|
) -> Vec<RowEvent> {
|
|
48
54
|
match (old, new) {
|
|
49
55
|
(None, None) => Vec::new(),
|
|
@@ -52,6 +58,7 @@ pub fn diff(
|
|
|
52
58
|
.map(|r| RowEvent::Insert {
|
|
53
59
|
table: table.to_string(),
|
|
54
60
|
row: r.clone(),
|
|
61
|
+
file_path: file_path.to_string(),
|
|
55
62
|
})
|
|
56
63
|
.collect(),
|
|
57
64
|
(Some(old_rows), None) => old_rows
|
|
@@ -59,9 +66,10 @@ pub fn diff(
|
|
|
59
66
|
.map(|r| RowEvent::Delete {
|
|
60
67
|
table: table.to_string(),
|
|
61
68
|
row: r.clone(),
|
|
69
|
+
file_path: file_path.to_string(),
|
|
62
70
|
})
|
|
63
71
|
.collect(),
|
|
64
|
-
(Some(old_rows), Some(new_rows)) => diff_rows(table, old_rows, new_rows),
|
|
72
|
+
(Some(old_rows), Some(new_rows)) => diff_rows(table, old_rows, new_rows, file_path),
|
|
65
73
|
}
|
|
66
74
|
}
|
|
67
75
|
|
|
@@ -70,10 +78,11 @@ fn diff_rows(
|
|
|
70
78
|
table: &str,
|
|
71
79
|
old_rows: &[HashMap<String, Value>],
|
|
72
80
|
new_rows: &[HashMap<String, Value>],
|
|
81
|
+
file_path: &str,
|
|
73
82
|
) -> Vec<RowEvent> {
|
|
74
83
|
// If file shrunk, do full replace
|
|
75
84
|
if new_rows.len() < old_rows.len() {
|
|
76
|
-
return full_replace(table, old_rows, new_rows);
|
|
85
|
+
return full_replace(table, old_rows, new_rows, file_path);
|
|
77
86
|
}
|
|
78
87
|
|
|
79
88
|
// Compare overlapping rows line by line
|
|
@@ -90,7 +99,7 @@ fn diff_rows(
|
|
|
90
99
|
// For multi-row files, if more than half of overlapping rows changed, full replace.
|
|
91
100
|
// Single-row files (overlap == 1) never trigger full replace -- they use Update.
|
|
92
101
|
if overlap > 1 && changed * 2 > overlap {
|
|
93
|
-
return full_replace(table, old_rows, new_rows);
|
|
102
|
+
return full_replace(table, old_rows, new_rows, file_path);
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
// Emit Update events for changed lines
|
|
@@ -100,6 +109,7 @@ fn diff_rows(
|
|
|
100
109
|
table: table.to_string(),
|
|
101
110
|
old_row: old_rows[i].clone(),
|
|
102
111
|
new_row: new_rows[i].clone(),
|
|
112
|
+
file_path: file_path.to_string(),
|
|
103
113
|
});
|
|
104
114
|
}
|
|
105
115
|
}
|
|
@@ -109,6 +119,7 @@ fn diff_rows(
|
|
|
109
119
|
events.push(RowEvent::Insert {
|
|
110
120
|
table: table.to_string(),
|
|
111
121
|
row: row.clone(),
|
|
122
|
+
file_path: file_path.to_string(),
|
|
112
123
|
});
|
|
113
124
|
}
|
|
114
125
|
|
|
@@ -120,18 +131,21 @@ fn full_replace(
|
|
|
120
131
|
table: &str,
|
|
121
132
|
old_rows: &[HashMap<String, Value>],
|
|
122
133
|
new_rows: &[HashMap<String, Value>],
|
|
134
|
+
file_path: &str,
|
|
123
135
|
) -> Vec<RowEvent> {
|
|
124
136
|
let mut events = Vec::with_capacity(old_rows.len() + new_rows.len());
|
|
125
137
|
for row in old_rows {
|
|
126
138
|
events.push(RowEvent::Delete {
|
|
127
139
|
table: table.to_string(),
|
|
128
140
|
row: row.clone(),
|
|
141
|
+
file_path: file_path.to_string(),
|
|
129
142
|
});
|
|
130
143
|
}
|
|
131
144
|
for row in new_rows {
|
|
132
145
|
events.push(RowEvent::Insert {
|
|
133
146
|
table: table.to_string(),
|
|
134
147
|
row: row.clone(),
|
|
148
|
+
file_path: file_path.to_string(),
|
|
135
149
|
});
|
|
136
150
|
}
|
|
137
151
|
events
|
|
@@ -166,12 +180,16 @@ mod tests {
|
|
|
166
180
|
];
|
|
167
181
|
let events = diff("users", None, Some(&rows), "users.jsonl");
|
|
168
182
|
assert_eq!(events.len(), 2);
|
|
169
|
-
assert!(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
183
|
+
assert!(matches!(
|
|
184
|
+
&events[0],
|
|
185
|
+
RowEvent::Insert { table, row, file_path }
|
|
186
|
+
if table == "users" && row["name"] == text("alice") && file_path == "users.jsonl"
|
|
187
|
+
));
|
|
188
|
+
assert!(matches!(
|
|
189
|
+
&events[1],
|
|
190
|
+
RowEvent::Insert { table, row, file_path }
|
|
191
|
+
if table == "users" && row["name"] == text("bob") && file_path == "users.jsonl"
|
|
192
|
+
));
|
|
175
193
|
}
|
|
176
194
|
|
|
177
195
|
// --- All deletes (file deleted) ---
|
|
@@ -181,12 +199,16 @@ mod tests {
|
|
|
181
199
|
let rows = vec![row(&[("id", text("1"))]), row(&[("id", text("2"))])];
|
|
182
200
|
let events = diff("items", Some(&rows), None, "items.jsonl");
|
|
183
201
|
assert_eq!(events.len(), 2);
|
|
184
|
-
assert!(
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
202
|
+
assert!(matches!(
|
|
203
|
+
&events[0],
|
|
204
|
+
RowEvent::Delete { table, row, file_path }
|
|
205
|
+
if table == "items" && row["id"] == text("1") && file_path == "items.jsonl"
|
|
206
|
+
));
|
|
207
|
+
assert!(matches!(
|
|
208
|
+
&events[1],
|
|
209
|
+
RowEvent::Delete { table, row, file_path }
|
|
210
|
+
if table == "items" && row["id"] == text("2") && file_path == "items.jsonl"
|
|
211
|
+
));
|
|
190
212
|
}
|
|
191
213
|
|
|
192
214
|
// --- No changes ---
|
|
@@ -214,10 +236,14 @@ mod tests {
|
|
|
214
236
|
];
|
|
215
237
|
let events = diff("t", Some(&old), Some(&new), "t.jsonl");
|
|
216
238
|
assert_eq!(events.len(), 1);
|
|
217
|
-
assert!(
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
239
|
+
assert!(matches!(
|
|
240
|
+
&events[0],
|
|
241
|
+
RowEvent::Update { table, old_row, new_row, file_path }
|
|
242
|
+
if table == "t"
|
|
243
|
+
&& old_row["val"] == text("b")
|
|
244
|
+
&& new_row["val"] == text("B")
|
|
245
|
+
&& file_path == "t.jsonl"
|
|
246
|
+
));
|
|
221
247
|
}
|
|
222
248
|
|
|
223
249
|
// --- Append new lines ---
|
|
@@ -232,12 +258,16 @@ mod tests {
|
|
|
232
258
|
];
|
|
233
259
|
let events = diff("t", Some(&old), Some(&new), "t.jsonl");
|
|
234
260
|
assert_eq!(events.len(), 2);
|
|
235
|
-
assert!(
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
261
|
+
assert!(matches!(
|
|
262
|
+
&events[0],
|
|
263
|
+
RowEvent::Insert { table, row, file_path }
|
|
264
|
+
if table == "t" && row["id"] == int(2) && file_path == "t.jsonl"
|
|
265
|
+
));
|
|
266
|
+
assert!(matches!(
|
|
267
|
+
&events[1],
|
|
268
|
+
RowEvent::Insert { table, row, file_path }
|
|
269
|
+
if table == "t" && row["id"] == int(3) && file_path == "t.jsonl"
|
|
270
|
+
));
|
|
241
271
|
}
|
|
242
272
|
|
|
243
273
|
// --- Full replace on shrink ---
|
|
@@ -263,6 +293,12 @@ mod tests {
|
|
|
263
293
|
.collect();
|
|
264
294
|
assert_eq!(deletes.len(), 3);
|
|
265
295
|
assert_eq!(inserts.len(), 1);
|
|
296
|
+
assert!(events.iter().all(|e| match e {
|
|
297
|
+
RowEvent::Insert { file_path, .. }
|
|
298
|
+
| RowEvent::Update { file_path, .. }
|
|
299
|
+
| RowEvent::Delete { file_path, .. } => file_path == "t.jsonl",
|
|
300
|
+
RowEvent::Error { .. } => false,
|
|
301
|
+
}));
|
|
266
302
|
}
|
|
267
303
|
|
|
268
304
|
// --- Full replace on heavy modification ---
|
|
@@ -304,10 +340,14 @@ mod tests {
|
|
|
304
340
|
let new = vec![row(&[("title", text("Final"))])];
|
|
305
341
|
let events = diff("docs", Some(&old), Some(&new), "doc.json");
|
|
306
342
|
assert_eq!(events.len(), 1);
|
|
307
|
-
assert!(
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
343
|
+
assert!(matches!(
|
|
344
|
+
&events[0],
|
|
345
|
+
RowEvent::Update { table, old_row, new_row, file_path }
|
|
346
|
+
if table == "docs"
|
|
347
|
+
&& old_row["title"] == text("Draft")
|
|
348
|
+
&& new_row["title"] == text("Final")
|
|
349
|
+
&& file_path == "doc.json"
|
|
350
|
+
));
|
|
311
351
|
}
|
|
312
352
|
|
|
313
353
|
// --- Single-row file: no change ---
|
|
@@ -711,10 +711,14 @@ mod python {
|
|
|
711
711
|
fn row_event_to_py(
|
|
712
712
|
py: Python<'_>,
|
|
713
713
|
event: &differ::RowEvent,
|
|
714
|
-
|
|
714
|
+
_rel_path: &str,
|
|
715
715
|
) -> PyResult<PyRowEvent> {
|
|
716
716
|
match event {
|
|
717
|
-
differ::RowEvent::Insert {
|
|
717
|
+
differ::RowEvent::Insert {
|
|
718
|
+
table,
|
|
719
|
+
row,
|
|
720
|
+
file_path,
|
|
721
|
+
} => {
|
|
718
722
|
let dict = value_row_to_py_dict(py, row)?;
|
|
719
723
|
Ok(PyRowEvent {
|
|
720
724
|
table: table.clone(),
|
|
@@ -722,13 +726,14 @@ mod python {
|
|
|
722
726
|
row: Some(dict),
|
|
723
727
|
old_row: None,
|
|
724
728
|
error: None,
|
|
725
|
-
file_path: Some(
|
|
729
|
+
file_path: Some(file_path.clone()),
|
|
726
730
|
})
|
|
727
731
|
}
|
|
728
732
|
differ::RowEvent::Update {
|
|
729
733
|
table,
|
|
730
734
|
old_row,
|
|
731
735
|
new_row,
|
|
736
|
+
file_path,
|
|
732
737
|
} => {
|
|
733
738
|
let new_dict = value_row_to_py_dict(py, new_row)?;
|
|
734
739
|
let old_dict = value_row_to_py_dict(py, old_row)?;
|
|
@@ -738,10 +743,14 @@ mod python {
|
|
|
738
743
|
row: Some(new_dict),
|
|
739
744
|
old_row: Some(old_dict),
|
|
740
745
|
error: None,
|
|
741
|
-
file_path: Some(
|
|
746
|
+
file_path: Some(file_path.clone()),
|
|
742
747
|
})
|
|
743
748
|
}
|
|
744
|
-
differ::RowEvent::Delete {
|
|
749
|
+
differ::RowEvent::Delete {
|
|
750
|
+
table,
|
|
751
|
+
row,
|
|
752
|
+
file_path,
|
|
753
|
+
} => {
|
|
745
754
|
let dict = value_row_to_py_dict(py, row)?;
|
|
746
755
|
Ok(PyRowEvent {
|
|
747
756
|
table: table.clone(),
|
|
@@ -749,7 +758,7 @@ mod python {
|
|
|
749
758
|
row: Some(dict),
|
|
750
759
|
old_row: None,
|
|
751
760
|
error: None,
|
|
752
|
-
file_path: Some(
|
|
761
|
+
file_path: Some(file_path.clone()),
|
|
753
762
|
})
|
|
754
763
|
}
|
|
755
764
|
differ::RowEvent::Error { file_path, error } => Ok(PyRowEvent {
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Integration tests for the Python SDK binding layer.
|
|
2
|
+
|
|
3
|
+
These tests exercise the async Python wrapper in ``dirsql._async`` in
|
|
4
|
+
isolation by mocking the Rust core (``dirsql._dirsql.DirSQL`` / its
|
|
5
|
+
``from_config`` classmethod). They verify the SDK's binding glue --
|
|
6
|
+
offloading to threads, ready()/error propagation, lazy watcher startup,
|
|
7
|
+
event iteration, config-based construction, and kwarg forwarding --
|
|
8
|
+
without touching the real PyO3-backed engine.
|
|
9
|
+
|
|
10
|
+
Core behaviour (SQL semantics, scanning, diffing, watching) is covered
|
|
11
|
+
by the Rust core's own unit tests and by the local-only e2e suite.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from dirsql import _async as async_mod
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _FakeRustDirSQL:
|
|
22
|
+
"""Test double for the PyO3 ``DirSQL`` class.
|
|
23
|
+
|
|
24
|
+
Records constructor args and method calls so tests can assert the
|
|
25
|
+
binding layer passes them through untouched.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
instances: list = []
|
|
29
|
+
|
|
30
|
+
def __init__(self, root, *, tables, ignore=None):
|
|
31
|
+
self.root = root
|
|
32
|
+
self.tables = tables
|
|
33
|
+
self.ignore = ignore
|
|
34
|
+
self.queries: list[str] = []
|
|
35
|
+
self.query_results: list = [{"ok": 1}]
|
|
36
|
+
self.started = False
|
|
37
|
+
self.poll_calls: list[int] = []
|
|
38
|
+
# Scripted event batches; each poll returns the next batch.
|
|
39
|
+
self.poll_batches: list[list] = []
|
|
40
|
+
_FakeRustDirSQL.instances.append(self)
|
|
41
|
+
|
|
42
|
+
# Class-level from_config so we can swap it with a callable that
|
|
43
|
+
# returns a fresh instance (mirrors the real classmethod shape).
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_config(cls, path):
|
|
46
|
+
inst = object.__new__(cls)
|
|
47
|
+
inst.root = None
|
|
48
|
+
inst.tables = None
|
|
49
|
+
inst.ignore = None
|
|
50
|
+
inst.queries = []
|
|
51
|
+
inst.query_results = [{"from_config": path}]
|
|
52
|
+
inst.started = False
|
|
53
|
+
inst.poll_calls = []
|
|
54
|
+
inst.poll_batches = []
|
|
55
|
+
inst.config_path = path
|
|
56
|
+
cls.instances.append(inst)
|
|
57
|
+
return inst
|
|
58
|
+
|
|
59
|
+
def query(self, sql):
|
|
60
|
+
self.queries.append(sql)
|
|
61
|
+
return self.query_results
|
|
62
|
+
|
|
63
|
+
def _start_watcher(self):
|
|
64
|
+
self.started = True
|
|
65
|
+
|
|
66
|
+
def _poll_events(self, timeout_ms):
|
|
67
|
+
self.poll_calls.append(timeout_ms)
|
|
68
|
+
if self.poll_batches:
|
|
69
|
+
return self.poll_batches.pop(0)
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.fixture(autouse=True)
|
|
74
|
+
def _reset_instances():
|
|
75
|
+
_FakeRustDirSQL.instances = []
|
|
76
|
+
yield
|
|
77
|
+
_FakeRustDirSQL.instances = []
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@pytest.fixture
|
|
81
|
+
def mock_core(monkeypatch):
|
|
82
|
+
"""Replace the Rust-backed ``_RustDirSQL`` alias in ``dirsql._async``."""
|
|
83
|
+
monkeypatch.setattr(async_mod, "_RustDirSQL", _FakeRustDirSQL)
|
|
84
|
+
return _FakeRustDirSQL
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def describe_binding_layer():
|
|
88
|
+
def describe_async_offloading():
|
|
89
|
+
# Feature: async-by-default API. See docs/guide/async.md and
|
|
90
|
+
# packages/python/README.md ("DirSQL is async by default").
|
|
91
|
+
@pytest.mark.asyncio
|
|
92
|
+
async def it_offloads_init_via_to_thread(mock_core, monkeypatch):
|
|
93
|
+
calls: list[str] = []
|
|
94
|
+
real_to_thread = asyncio.to_thread
|
|
95
|
+
|
|
96
|
+
async def spy(func, *args, **kwargs):
|
|
97
|
+
calls.append(getattr(func, "__name__", repr(func)))
|
|
98
|
+
return await real_to_thread(func, *args, **kwargs)
|
|
99
|
+
|
|
100
|
+
monkeypatch.setattr(async_mod.asyncio, "to_thread", spy)
|
|
101
|
+
|
|
102
|
+
db = async_mod.DirSQL("/root", tables=["t"])
|
|
103
|
+
await db.ready()
|
|
104
|
+
|
|
105
|
+
assert any("DirSQL" in c or "FakeRustDirSQL" in c for c in calls), calls
|
|
106
|
+
|
|
107
|
+
@pytest.mark.asyncio
|
|
108
|
+
async def it_offloads_query_via_to_thread(mock_core, monkeypatch):
|
|
109
|
+
db = async_mod.DirSQL("/root", tables=["t"])
|
|
110
|
+
await db.ready()
|
|
111
|
+
|
|
112
|
+
calls: list[str] = []
|
|
113
|
+
real_to_thread = asyncio.to_thread
|
|
114
|
+
|
|
115
|
+
async def spy(func, *args, **kwargs):
|
|
116
|
+
calls.append(getattr(func, "__name__", repr(func)))
|
|
117
|
+
return await real_to_thread(func, *args, **kwargs)
|
|
118
|
+
|
|
119
|
+
monkeypatch.setattr(async_mod.asyncio, "to_thread", spy)
|
|
120
|
+
|
|
121
|
+
await db.query("SELECT 1")
|
|
122
|
+
assert "query" in calls
|
|
123
|
+
|
|
124
|
+
def describe_ready():
|
|
125
|
+
# Feature: ready() awaits initial scan and surfaces init errors.
|
|
126
|
+
# See docs/guide/async.md and packages/python/README.md.
|
|
127
|
+
@pytest.mark.asyncio
|
|
128
|
+
async def it_surfaces_init_exceptions(monkeypatch):
|
|
129
|
+
class Boom(_FakeRustDirSQL):
|
|
130
|
+
def __init__(self, *a, **kw):
|
|
131
|
+
raise RuntimeError("init failed")
|
|
132
|
+
|
|
133
|
+
monkeypatch.setattr(async_mod, "_RustDirSQL", Boom)
|
|
134
|
+
|
|
135
|
+
db = async_mod.DirSQL("/root", tables=["t"])
|
|
136
|
+
with pytest.raises(RuntimeError, match="init failed"):
|
|
137
|
+
await db.ready()
|
|
138
|
+
|
|
139
|
+
@pytest.mark.asyncio
|
|
140
|
+
async def it_is_safe_to_call_repeatedly(mock_core):
|
|
141
|
+
db = async_mod.DirSQL("/root", tables=["t"])
|
|
142
|
+
await db.ready()
|
|
143
|
+
await db.ready()
|
|
144
|
+
await db.ready()
|
|
145
|
+
# Only one underlying instance should have been constructed.
|
|
146
|
+
assert len(_FakeRustDirSQL.instances) == 1
|
|
147
|
+
|
|
148
|
+
@pytest.mark.asyncio
|
|
149
|
+
async def it_re_raises_init_error_on_every_ready_call(monkeypatch):
|
|
150
|
+
class Boom(_FakeRustDirSQL):
|
|
151
|
+
def __init__(self, *a, **kw):
|
|
152
|
+
raise ValueError("bad config")
|
|
153
|
+
|
|
154
|
+
monkeypatch.setattr(async_mod, "_RustDirSQL", Boom)
|
|
155
|
+
db = async_mod.DirSQL("/root", tables=["t"])
|
|
156
|
+
with pytest.raises(ValueError):
|
|
157
|
+
await db.ready()
|
|
158
|
+
with pytest.raises(ValueError):
|
|
159
|
+
await db.ready()
|
|
160
|
+
|
|
161
|
+
def describe_query():
|
|
162
|
+
# Feature: query() passes SQL to the engine. See
|
|
163
|
+
# docs/guide/querying.md and packages/python/README.md.
|
|
164
|
+
@pytest.mark.asyncio
|
|
165
|
+
async def it_passes_sql_through_untouched(mock_core):
|
|
166
|
+
db = async_mod.DirSQL("/root", tables=["t"])
|
|
167
|
+
await db.ready()
|
|
168
|
+
|
|
169
|
+
sql = "SELECT name, age FROM users WHERE age > 30 -- comment"
|
|
170
|
+
result = await db.query(sql)
|
|
171
|
+
|
|
172
|
+
assert _FakeRustDirSQL.instances[0].queries == [sql]
|
|
173
|
+
assert result == [{"ok": 1}]
|
|
174
|
+
|
|
175
|
+
def describe_watch():
|
|
176
|
+
# Feature: watch() is an async iterator of RowEvent. See
|
|
177
|
+
# docs/guide/watching.md and packages/python/README.md.
|
|
178
|
+
@pytest.mark.asyncio
|
|
179
|
+
async def it_lazily_starts_watcher_on_first_iteration(mock_core):
|
|
180
|
+
db = async_mod.DirSQL("/root", tables=["t"])
|
|
181
|
+
await db.ready()
|
|
182
|
+
|
|
183
|
+
stream = db.watch()
|
|
184
|
+
assert _FakeRustDirSQL.instances[0].started is False
|
|
185
|
+
|
|
186
|
+
# Queue a single event so __anext__ returns.
|
|
187
|
+
_FakeRustDirSQL.instances[0].poll_batches = [["evt-1"]]
|
|
188
|
+
|
|
189
|
+
event = await stream.__anext__()
|
|
190
|
+
assert event == "evt-1"
|
|
191
|
+
assert _FakeRustDirSQL.instances[0].started is True
|
|
192
|
+
|
|
193
|
+
@pytest.mark.asyncio
|
|
194
|
+
async def it_drains_buffered_events_before_polling_again(mock_core):
|
|
195
|
+
db = async_mod.DirSQL("/root", tables=["t"])
|
|
196
|
+
await db.ready()
|
|
197
|
+
|
|
198
|
+
fake = _FakeRustDirSQL.instances[0]
|
|
199
|
+
fake.poll_batches = [["a", "b", "c"]]
|
|
200
|
+
|
|
201
|
+
stream = db.watch()
|
|
202
|
+
assert await stream.__anext__() == "a"
|
|
203
|
+
assert await stream.__anext__() == "b"
|
|
204
|
+
assert await stream.__anext__() == "c"
|
|
205
|
+
# Only one poll happened; the rest came from the buffer.
|
|
206
|
+
assert len(fake.poll_calls) == 1
|
|
207
|
+
assert fake.poll_calls[0] == 200
|
|
208
|
+
|
|
209
|
+
@pytest.mark.asyncio
|
|
210
|
+
async def it_polls_until_events_arrive(mock_core):
|
|
211
|
+
db = async_mod.DirSQL("/root", tables=["t"])
|
|
212
|
+
await db.ready()
|
|
213
|
+
|
|
214
|
+
fake = _FakeRustDirSQL.instances[0]
|
|
215
|
+
fake.poll_batches = [[], [], ["late"]]
|
|
216
|
+
|
|
217
|
+
stream = db.watch()
|
|
218
|
+
event = await stream.__anext__()
|
|
219
|
+
assert event == "late"
|
|
220
|
+
assert len(fake.poll_calls) == 3
|
|
221
|
+
|
|
222
|
+
def describe_from_config():
|
|
223
|
+
# Feature: DirSQL.from_config(path) classmethod. See
|
|
224
|
+
# docs/guide/config.md and packages/python/README.md.
|
|
225
|
+
@pytest.mark.asyncio
|
|
226
|
+
async def it_delegates_to_rust_from_config(mock_core):
|
|
227
|
+
db = async_mod.DirSQL.from_config("/some/.dirsql.toml")
|
|
228
|
+
await db.ready()
|
|
229
|
+
|
|
230
|
+
inst = _FakeRustDirSQL.instances[-1]
|
|
231
|
+
assert inst.config_path == "/some/.dirsql.toml"
|
|
232
|
+
|
|
233
|
+
result = await db.query("SELECT 1")
|
|
234
|
+
assert result == [{"from_config": "/some/.dirsql.toml"}]
|
|
235
|
+
|
|
236
|
+
@pytest.mark.asyncio
|
|
237
|
+
async def it_surfaces_config_load_errors(monkeypatch):
|
|
238
|
+
class Boom(_FakeRustDirSQL):
|
|
239
|
+
@classmethod
|
|
240
|
+
def from_config(cls, path):
|
|
241
|
+
raise FileNotFoundError(path)
|
|
242
|
+
|
|
243
|
+
monkeypatch.setattr(async_mod, "_RustDirSQL", Boom)
|
|
244
|
+
db = async_mod.DirSQL.from_config("/missing.toml")
|
|
245
|
+
with pytest.raises(FileNotFoundError):
|
|
246
|
+
await db.ready()
|
|
247
|
+
|
|
248
|
+
def describe_ignore_kwarg():
|
|
249
|
+
# Feature: ignore patterns. See docs/guide/tables.md and
|
|
250
|
+
# packages/python/README.md (ignore= kwarg on DirSQL).
|
|
251
|
+
@pytest.mark.asyncio
|
|
252
|
+
async def it_forwards_ignore_to_core(mock_core):
|
|
253
|
+
ignore = ["**/node_modules/**", ".git"]
|
|
254
|
+
db = async_mod.DirSQL("/root", tables=["t"], ignore=ignore)
|
|
255
|
+
await db.ready()
|
|
256
|
+
|
|
257
|
+
inst = _FakeRustDirSQL.instances[0]
|
|
258
|
+
assert inst.root == "/root"
|
|
259
|
+
assert inst.tables == ["t"]
|
|
260
|
+
assert inst.ignore == ignore
|
|
261
|
+
|
|
262
|
+
@pytest.mark.asyncio
|
|
263
|
+
async def it_defaults_ignore_to_none(mock_core):
|
|
264
|
+
db = async_mod.DirSQL("/root", tables=["t"])
|
|
265
|
+
await db.ready()
|
|
266
|
+
assert _FakeRustDirSQL.instances[0].ignore is None
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Gap-filling tests for features documented in docs/ but previously untested.
|
|
2
|
+
|
|
3
|
+
Each test cites the canonical doc location (docs page + section) that it covers.
|
|
4
|
+
These were identified by the TESTS_AUDIT.md pass for bead dirsql-9ng
|
|
5
|
+
(Tests follow docs: 1:1 mapping between documented features and tests).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import tempfile
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from dirsql import DirSQL, Table
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def config_dir():
|
|
19
|
+
with tempfile.TemporaryDirectory() as d:
|
|
20
|
+
yield d
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _write(path, content):
|
|
24
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
25
|
+
with open(path, "w") as f:
|
|
26
|
+
f.write(content)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# docs/guide/tables.md -- "Supported value types" -> bytes -> BLOB
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def describe_tables_guide_bytes_to_blob():
|
|
35
|
+
@pytest.mark.asyncio
|
|
36
|
+
async def it_maps_python_bytes_to_sqlite_blob(tmp_dir):
|
|
37
|
+
"""Docs (guide/tables.md "Supported value types"): Python `bytes` -> SQLite BLOB.
|
|
38
|
+
|
|
39
|
+
Round-trip: extract returns a dict whose value is bytes, query returns bytes.
|
|
40
|
+
"""
|
|
41
|
+
with open(os.path.join(tmp_dir, "marker.json"), "w") as f:
|
|
42
|
+
f.write("{}")
|
|
43
|
+
|
|
44
|
+
payload = b"\x00\x01\x02\xff\xfe"
|
|
45
|
+
|
|
46
|
+
db = DirSQL(
|
|
47
|
+
tmp_dir,
|
|
48
|
+
tables=[
|
|
49
|
+
Table(
|
|
50
|
+
ddl="CREATE TABLE blobs (name TEXT, data BLOB)",
|
|
51
|
+
glob="*.json",
|
|
52
|
+
extract=lambda path, content: [{"name": "bin", "data": payload}],
|
|
53
|
+
),
|
|
54
|
+
],
|
|
55
|
+
)
|
|
56
|
+
await db.ready()
|
|
57
|
+
results = await db.query("SELECT * FROM blobs")
|
|
58
|
+
assert len(results) == 1
|
|
59
|
+
assert results[0]["name"] == "bin"
|
|
60
|
+
# Python bytes round-trip through SQLite BLOB.
|
|
61
|
+
assert results[0]["data"] == payload
|
|
62
|
+
assert isinstance(results[0]["data"], (bytes, bytearray))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# docs/guide/config.md -- "Supported Formats" (.tsv/.ndjson/.toml/.yaml/.yml/.md)
|
|
67
|
+
# and "Strict Mode" (strict = true)
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def describe_from_config_formats_gap():
|
|
72
|
+
@pytest.mark.asyncio
|
|
73
|
+
async def it_loads_tsv_files_via_config(config_dir):
|
|
74
|
+
"""Docs (guide/config.md "Supported Formats"): .tsv format is tab-separated."""
|
|
75
|
+
_write(
|
|
76
|
+
os.path.join(config_dir, "data.tsv"),
|
|
77
|
+
"name\tcount\napples\t10\noranges\t20\n",
|
|
78
|
+
)
|
|
79
|
+
_write(
|
|
80
|
+
os.path.join(config_dir, ".dirsql.toml"),
|
|
81
|
+
"""\
|
|
82
|
+
[[table]]
|
|
83
|
+
ddl = "CREATE TABLE produce (name TEXT, count TEXT)"
|
|
84
|
+
glob = "*.tsv"
|
|
85
|
+
""",
|
|
86
|
+
)
|
|
87
|
+
db = DirSQL.from_config(os.path.join(config_dir, ".dirsql.toml"))
|
|
88
|
+
await db.ready()
|
|
89
|
+
results = await db.query("SELECT * FROM produce ORDER BY name")
|
|
90
|
+
assert len(results) == 2
|
|
91
|
+
assert results[0]["name"] == "apples"
|
|
92
|
+
assert results[0]["count"] == "10"
|
|
93
|
+
assert results[1]["name"] == "oranges"
|
|
94
|
+
|
|
95
|
+
@pytest.mark.asyncio
|
|
96
|
+
async def it_loads_ndjson_files_via_config(config_dir):
|
|
97
|
+
"""Docs (guide/config.md "Supported Formats"): .ndjson aliases JSONL (one row per line)."""
|
|
98
|
+
_write(
|
|
99
|
+
os.path.join(config_dir, "events.ndjson"),
|
|
100
|
+
json.dumps({"type": "click", "count": 5})
|
|
101
|
+
+ "\n"
|
|
102
|
+
+ json.dumps({"type": "view", "count": 100})
|
|
103
|
+
+ "\n",
|
|
104
|
+
)
|
|
105
|
+
_write(
|
|
106
|
+
os.path.join(config_dir, ".dirsql.toml"),
|
|
107
|
+
"""\
|
|
108
|
+
[[table]]
|
|
109
|
+
ddl = "CREATE TABLE events (type TEXT, count INTEGER)"
|
|
110
|
+
glob = "*.ndjson"
|
|
111
|
+
""",
|
|
112
|
+
)
|
|
113
|
+
db = DirSQL.from_config(os.path.join(config_dir, ".dirsql.toml"))
|
|
114
|
+
await db.ready()
|
|
115
|
+
results = await db.query("SELECT * FROM events ORDER BY type")
|
|
116
|
+
assert len(results) == 2
|
|
117
|
+
assert results[0]["type"] == "click"
|
|
118
|
+
assert results[0]["count"] == 5
|
|
119
|
+
|
|
120
|
+
@pytest.mark.asyncio
|
|
121
|
+
async def it_loads_toml_files_via_config(config_dir):
|
|
122
|
+
"""Docs (guide/config.md "Supported Formats"): .toml format is one row per file."""
|
|
123
|
+
_write(
|
|
124
|
+
os.path.join(config_dir, "config", "app.toml"),
|
|
125
|
+
'name = "myapp"\nversion = "1.2"\n',
|
|
126
|
+
)
|
|
127
|
+
_write(
|
|
128
|
+
os.path.join(config_dir, ".dirsql.toml"),
|
|
129
|
+
"""\
|
|
130
|
+
[[table]]
|
|
131
|
+
ddl = "CREATE TABLE app (name TEXT, version TEXT)"
|
|
132
|
+
glob = "config/*.toml"
|
|
133
|
+
""",
|
|
134
|
+
)
|
|
135
|
+
db = DirSQL.from_config(os.path.join(config_dir, ".dirsql.toml"))
|
|
136
|
+
await db.ready()
|
|
137
|
+
results = await db.query("SELECT * FROM app")
|
|
138
|
+
assert len(results) == 1
|
|
139
|
+
assert results[0]["name"] == "myapp"
|
|
140
|
+
assert results[0]["version"] == "1.2"
|
|
141
|
+
|
|
142
|
+
@pytest.mark.asyncio
|
|
143
|
+
@pytest.mark.parametrize("ext", ["yaml", "yml"])
|
|
144
|
+
async def it_loads_yaml_files_via_config(config_dir, ext):
|
|
145
|
+
"""Docs (guide/config.md "Supported Formats"): .yaml/.yml mapping = 1 row."""
|
|
146
|
+
_write(
|
|
147
|
+
os.path.join(config_dir, f"data.{ext}"),
|
|
148
|
+
"name: widget\nprice: 9.99\n",
|
|
149
|
+
)
|
|
150
|
+
_write(
|
|
151
|
+
os.path.join(config_dir, ".dirsql.toml"),
|
|
152
|
+
f"""\
|
|
153
|
+
[[table]]
|
|
154
|
+
ddl = "CREATE TABLE items (name TEXT, price REAL)"
|
|
155
|
+
glob = "*.{ext}"
|
|
156
|
+
""",
|
|
157
|
+
)
|
|
158
|
+
db = DirSQL.from_config(os.path.join(config_dir, ".dirsql.toml"))
|
|
159
|
+
await db.ready()
|
|
160
|
+
results = await db.query("SELECT * FROM items")
|
|
161
|
+
assert len(results) == 1
|
|
162
|
+
assert results[0]["name"] == "widget"
|
|
163
|
+
assert results[0]["price"] == pytest.approx(9.99)
|
|
164
|
+
|
|
165
|
+
@pytest.mark.asyncio
|
|
166
|
+
async def it_loads_markdown_with_frontmatter_via_config(config_dir):
|
|
167
|
+
"""Docs (guide/config.md "Supported Formats"): .md uses YAML frontmatter + body column."""
|
|
168
|
+
_write(
|
|
169
|
+
os.path.join(config_dir, "posts", "hello.md"),
|
|
170
|
+
"---\ntitle: Hello\nauthor: Alice\n---\nBody text here.\n",
|
|
171
|
+
)
|
|
172
|
+
_write(
|
|
173
|
+
os.path.join(config_dir, ".dirsql.toml"),
|
|
174
|
+
"""\
|
|
175
|
+
[[table]]
|
|
176
|
+
ddl = "CREATE TABLE posts (title TEXT, author TEXT, body TEXT)"
|
|
177
|
+
glob = "posts/*.md"
|
|
178
|
+
""",
|
|
179
|
+
)
|
|
180
|
+
db = DirSQL.from_config(os.path.join(config_dir, ".dirsql.toml"))
|
|
181
|
+
await db.ready()
|
|
182
|
+
results = await db.query("SELECT * FROM posts")
|
|
183
|
+
assert len(results) == 1
|
|
184
|
+
assert results[0]["title"] == "Hello"
|
|
185
|
+
assert results[0]["author"] == "Alice"
|
|
186
|
+
assert "Body text here." in (results[0]["body"] or "")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def describe_from_config_strict_mode_gap():
|
|
190
|
+
@pytest.mark.asyncio
|
|
191
|
+
async def it_raises_on_extra_keys_when_strict_true(config_dir):
|
|
192
|
+
"""Docs (guide/config.md "Strict Mode"): `strict = true` errors on extra keys."""
|
|
193
|
+
_write(
|
|
194
|
+
os.path.join(config_dir, "items", "a.json"),
|
|
195
|
+
json.dumps({"name": "apple", "color": "red"}),
|
|
196
|
+
)
|
|
197
|
+
_write(
|
|
198
|
+
os.path.join(config_dir, ".dirsql.toml"),
|
|
199
|
+
"""\
|
|
200
|
+
[[table]]
|
|
201
|
+
ddl = "CREATE TABLE items (name TEXT)"
|
|
202
|
+
glob = "items/*.json"
|
|
203
|
+
strict = true
|
|
204
|
+
""",
|
|
205
|
+
)
|
|
206
|
+
db = DirSQL.from_config(os.path.join(config_dir, ".dirsql.toml"))
|
|
207
|
+
with pytest.raises(Exception):
|
|
208
|
+
await db.ready()
|
|
209
|
+
|
|
210
|
+
@pytest.mark.asyncio
|
|
211
|
+
async def it_allows_exact_match_when_strict_true(config_dir):
|
|
212
|
+
"""Docs (guide/config.md "Strict Mode"): strict mode passes on exact key match."""
|
|
213
|
+
_write(
|
|
214
|
+
os.path.join(config_dir, "items", "a.json"),
|
|
215
|
+
json.dumps({"name": "apple", "color": "red"}),
|
|
216
|
+
)
|
|
217
|
+
_write(
|
|
218
|
+
os.path.join(config_dir, ".dirsql.toml"),
|
|
219
|
+
"""\
|
|
220
|
+
[[table]]
|
|
221
|
+
ddl = "CREATE TABLE items (name TEXT, color TEXT)"
|
|
222
|
+
glob = "items/*.json"
|
|
223
|
+
strict = true
|
|
224
|
+
""",
|
|
225
|
+
)
|
|
226
|
+
db = DirSQL.from_config(os.path.join(config_dir, ".dirsql.toml"))
|
|
227
|
+
await db.ready()
|
|
228
|
+
results = await db.query("SELECT * FROM items")
|
|
229
|
+
assert len(results) == 1
|
|
230
|
+
assert results[0]["name"] == "apple"
|
|
231
|
+
assert results[0]["color"] == "red"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
# docs/guide/watching.md -- "How diffing works" positional row identity
|
|
236
|
+
# and RowEvent.file_path relative-path assertion
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def describe_watching_guide_positional_identity_gap():
|
|
241
|
+
@pytest.mark.asyncio
|
|
242
|
+
async def it_emits_delete_for_shrinking_file_positionally(tmp_dir):
|
|
243
|
+
"""Docs (guide/watching.md "How diffing works"): row identity by position.
|
|
244
|
+
|
|
245
|
+
"If a file previously produced 3 rows and now produces 2, the first two
|
|
246
|
+
rows are compared for updates and the third is emitted as a delete."
|
|
247
|
+
"""
|
|
248
|
+
import asyncio
|
|
249
|
+
|
|
250
|
+
path = os.path.join(tmp_dir, "rows.jsonl")
|
|
251
|
+
with open(path, "w") as f:
|
|
252
|
+
for i in range(3):
|
|
253
|
+
f.write(json.dumps({"idx": i, "name": f"row-{i}"}) + "\n")
|
|
254
|
+
|
|
255
|
+
db = DirSQL(
|
|
256
|
+
tmp_dir,
|
|
257
|
+
tables=[
|
|
258
|
+
Table(
|
|
259
|
+
ddl="CREATE TABLE rows (idx INTEGER, name TEXT)",
|
|
260
|
+
glob="*.jsonl",
|
|
261
|
+
extract=lambda path, content: [
|
|
262
|
+
json.loads(line) for line in content.splitlines() if line
|
|
263
|
+
],
|
|
264
|
+
),
|
|
265
|
+
],
|
|
266
|
+
)
|
|
267
|
+
await db.ready()
|
|
268
|
+
|
|
269
|
+
# Sanity: 3 rows present initially
|
|
270
|
+
pre = await db.query("SELECT * FROM rows ORDER BY idx")
|
|
271
|
+
assert len(pre) == 3
|
|
272
|
+
|
|
273
|
+
events = []
|
|
274
|
+
|
|
275
|
+
done = asyncio.Event()
|
|
276
|
+
|
|
277
|
+
async def collect():
|
|
278
|
+
async for event in db.watch():
|
|
279
|
+
events.append(event)
|
|
280
|
+
# Drain until we stop seeing new events for a moment: wait until
|
|
281
|
+
# we've collected enough to reason about the shrink (either
|
|
282
|
+
# positional: 1 delete; or full-replace: 3 deletes + 2 inserts).
|
|
283
|
+
if len(events) >= 5 or (
|
|
284
|
+
any(e.action == "delete" for e in events)
|
|
285
|
+
and len([e for e in events if e.action == "insert"]) >= 2
|
|
286
|
+
):
|
|
287
|
+
done.set()
|
|
288
|
+
break
|
|
289
|
+
|
|
290
|
+
task = asyncio.create_task(collect())
|
|
291
|
+
await asyncio.sleep(0.3)
|
|
292
|
+
|
|
293
|
+
# Shrink from 3 -> 2 rows (drop the third)
|
|
294
|
+
with open(path, "w") as f:
|
|
295
|
+
for i in range(2):
|
|
296
|
+
f.write(json.dumps({"idx": i, "name": f"row-{i}"}) + "\n")
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
await asyncio.wait_for(task, timeout=5.0)
|
|
300
|
+
except asyncio.TimeoutError:
|
|
301
|
+
# Take whatever we have; at least one delete is required by docs.
|
|
302
|
+
task.cancel()
|
|
303
|
+
|
|
304
|
+
delete_events = [e for e in events if e.action == "delete"]
|
|
305
|
+
assert delete_events, "expected at least one delete event when file shrinks"
|
|
306
|
+
# Docs promise positional identity: the third (idx=2) row should be deleted.
|
|
307
|
+
# The current implementation does a full-replace on shrink instead
|
|
308
|
+
# (see packages/core/src/differ.rs::diff_rows). That is a doc/impl
|
|
309
|
+
# divergence surfaced in TESTS_AUDIT.md, not fixed here.
|
|
310
|
+
# What we *can* assert without contradicting either side: among the
|
|
311
|
+
# delete events the dropped row (idx=2, name=row-2) must appear.
|
|
312
|
+
deleted_names = {e.row.get("name") for e in delete_events if e.row}
|
|
313
|
+
assert "row-2" in deleted_names, (
|
|
314
|
+
f"expected a delete for row-2 (dropped positionally); got {deleted_names!r}"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# DB should now reflect only 2 rows.
|
|
318
|
+
post = await db.query("SELECT * FROM rows ORDER BY idx")
|
|
319
|
+
assert len(post) == 2
|
|
320
|
+
assert [r["idx"] for r in post] == [0, 1]
|
|
321
|
+
|
|
322
|
+
@pytest.mark.asyncio
|
|
323
|
+
async def it_sets_file_path_as_relative_path_on_events(tmp_dir):
|
|
324
|
+
"""Docs (guide/watching.md event payloads): `file_path` is relative to root.
|
|
325
|
+
|
|
326
|
+
All examples in watching.md show relative paths (e.g., "comments/abc/index.json")
|
|
327
|
+
rather than absolute paths.
|
|
328
|
+
"""
|
|
329
|
+
import asyncio
|
|
330
|
+
|
|
331
|
+
os.makedirs(os.path.join(tmp_dir, "nested", "dir"), exist_ok=True)
|
|
332
|
+
|
|
333
|
+
db = DirSQL(
|
|
334
|
+
tmp_dir,
|
|
335
|
+
tables=[
|
|
336
|
+
Table(
|
|
337
|
+
ddl="CREATE TABLE items (name TEXT)",
|
|
338
|
+
glob="**/*.json",
|
|
339
|
+
extract=lambda path, content: [json.loads(content)],
|
|
340
|
+
),
|
|
341
|
+
],
|
|
342
|
+
)
|
|
343
|
+
await db.ready()
|
|
344
|
+
|
|
345
|
+
events = []
|
|
346
|
+
|
|
347
|
+
async def collect():
|
|
348
|
+
async for event in db.watch():
|
|
349
|
+
events.append(event)
|
|
350
|
+
if len(events) >= 1:
|
|
351
|
+
break
|
|
352
|
+
|
|
353
|
+
task = asyncio.create_task(collect())
|
|
354
|
+
await asyncio.sleep(0.3)
|
|
355
|
+
|
|
356
|
+
rel_path = os.path.join("nested", "dir", "new.json")
|
|
357
|
+
with open(os.path.join(tmp_dir, rel_path), "w") as f:
|
|
358
|
+
json.dump({"name": "relative"}, f)
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
await asyncio.wait_for(task, timeout=5.0)
|
|
362
|
+
except asyncio.TimeoutError:
|
|
363
|
+
pytest.fail("Timed out waiting for event")
|
|
364
|
+
|
|
365
|
+
assert len(events) >= 1
|
|
366
|
+
ev = events[0]
|
|
367
|
+
assert ev.file_path is not None
|
|
368
|
+
# Must be relative (never starts with the absolute root), and must match
|
|
369
|
+
# the relative path we wrote.
|
|
370
|
+
assert not os.path.isabs(ev.file_path), (
|
|
371
|
+
f"file_path should be relative, got absolute: {ev.file_path!r}"
|
|
372
|
+
)
|
|
373
|
+
# Normalize separators for portability.
|
|
374
|
+
assert ev.file_path.replace("\\", "/") == rel_path.replace("\\", "/")
|
|
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
|