1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
//! Integration tests for `ReadTx::query` — snapshot-isolated Cypher reads.
//!
//! ## What SparrowDB snapshot isolation covers today
//!
//! SparrowDB uses a Single-Writer-Multiple-Reader (SWMR) model.
//! The `snapshot_txn_id` captured at `begin_read` time is used for **property
//! value** isolation via an in-memory MVCC version chain: `set_node_col` writes
//! go through the version chain and a `ReadTx` pinned before the write will see
//! the old value via `ReadTx::get_node`.
//!
//! **Structural changes** (new nodes written via `CREATE`) are flushed straight
//! to the on-disk node store on commit and are therefore immediately visible to
//! all readers, including `ReadTx` handles opened before the write. This is a
//! known architectural constraint: snapshot isolation of node creation requires
//! a per-node visibility bitmap which is not yet implemented.
//!
//! The `ReadTx::query` Cypher path reads from disk and therefore sees all
//! committed nodes regardless of the `snapshot_txn_id`. The snapshot_txn_id
//! provides isolation only for property-value updates (`set_node_col`) read via
//! the MVCC version chain.
//!
//! Tests in this file cover:
//! 1. `ReadTx::query` returns correct rows for a stable (non-mutated) dataset.
//! 2. `ReadTx::query` is re-usable on the same handle.
//! 3. Mutation / DDL statements return `Error::ReadOnly`.
//! 4. Multiple concurrent `ReadTx` handles work without blocking.
//! 5. A `ReadTx` opened after a commit sees the committed data.
use sparrowdb::GraphDb;
use sparrowdb_common::Error;
use sparrowdb_execution::Value as ExecValue;
fn open_db() -> (tempfile::TempDir, GraphDb) {
let dir = tempfile::tempdir().expect("tempdir");
let db = sparrowdb::open(dir.path()).expect("open db");
(dir, db)
}
// ── Test 1: basic read-only query ────────────────────────────────────────────
/// A `ReadTx::query` over a stable dataset returns the committed rows.
#[test]
fn readtx_query_basic() {
let (_dir, db) = open_db();
db.execute("CREATE (n:Person {name: 1, age: 30})")
.expect("create Alice");
db.execute("CREATE (n:Person {name: 2, age: 25})")
.expect("create Bob");
let tx = db.begin_read().expect("begin_read");
let r = tx
.query("MATCH (n:Person) RETURN n.age")
.expect("query persons");
assert_eq!(r.rows.len(), 2, "should see both Alice and Bob");
}
// ── Test 2: new ReadTx after commit sees committed data ───────────────────────
/// A `ReadTx` opened after a write commit sees the new data.
#[test]
fn readtx_opened_after_commit_sees_new_data() {
let (_dir, db) = open_db();
db.execute("CREATE (n:Person {name: 1, age: 30})")
.expect("create Alice");
let snap1 = db.begin_read().expect("begin_read after Alice");
assert_eq!(
snap1
.query("MATCH (n:Person) RETURN n.age")
.expect("query")
.rows
.len(),
1,
"first snapshot sees Alice"
);
db.execute("CREATE (n:Person {name: 2, age: 25})")
.expect("create Bob");
let snap2 = db.begin_read().expect("begin_read after Bob");
assert!(
snap2.snapshot_txn_id > snap1.snapshot_txn_id,
"newer snapshot should have higher txn_id"
);
let r = snap2
.query("MATCH (n:Person) RETURN n.age")
.expect("query after Bob");
assert_eq!(r.rows.len(), 2, "second snapshot sees both Alice and Bob");
}
// ── Test 3: returned values have the right type ───────────────────────────────
#[test]
fn readtx_query_value_types() {
let (_dir, db) = open_db();
db.execute("CREATE (n:Person {name: 1, age: 42})")
.expect("create node");
let tx = db.begin_read().expect("begin_read");
let r = tx
.query("MATCH (n:Person) RETURN n.age")
.expect("query age");
assert_eq!(r.rows.len(), 1);
assert_eq!(
r.rows[0][0],
ExecValue::Int64(42),
"age should be Int64(42)"
);
}
// ── Test 4: mutation statements are rejected ─────────────────────────────────
/// Submitting a CREATE to ReadTx::query must return `Error::ReadOnly`.
#[test]
fn readtx_query_rejects_create() {
let (_dir, db) = open_db();
let tx = db.begin_read().expect("begin_read");
let err = tx
.query("CREATE (n:Person {name: 99})")
.expect_err("CREATE must fail in ReadTx");
assert!(
matches!(err, Error::ReadOnly),
"expected ReadOnly, got: {err:?}"
);
}
/// Submitting a MATCH … SET to ReadTx::query must return `Error::ReadOnly`.
#[test]
fn readtx_query_rejects_set() {
let (_dir, db) = open_db();
db.execute("CREATE (n:Person {name: 1, age: 30})")
.expect("seed data");
let tx = db.begin_read().expect("begin_read");
let err = tx
.query("MATCH (n:Person) SET n.age = 99")
.expect_err("SET must fail in ReadTx");
assert!(
matches!(err, Error::ReadOnly),
"expected ReadOnly, got: {err:?}"
);
}
/// Submitting CHECKPOINT to ReadTx::query must return `Error::ReadOnly`.
#[test]
fn readtx_query_rejects_checkpoint() {
let (_dir, db) = open_db();
let tx = db.begin_read().expect("begin_read");
let err = tx
.query("CHECKPOINT")
.expect_err("CHECKPOINT must fail in ReadTx");
assert!(
matches!(err, Error::ReadOnly),
"expected ReadOnly, got: {err:?}"
);
}
/// Submitting MERGE to ReadTx::query must return `Error::ReadOnly`.
#[test]
fn readtx_query_rejects_merge() {
let (_dir, db) = open_db();
let tx = db.begin_read().expect("begin_read");
let err = tx
.query("MERGE (n:Person {name: 1})")
.expect_err("MERGE must fail in ReadTx");
assert!(
matches!(err, Error::ReadOnly),
"expected ReadOnly, got: {err:?}"
);
}
// ── Test 5: empty-db read returns zero rows (no panic) ───────────────────────
#[test]
fn readtx_query_empty_db() {
let (_dir, db) = open_db();
let tx = db.begin_read().expect("begin_read");
let r = tx
.query("MATCH (n:Person) RETURN n.name")
.expect("query empty db");
assert_eq!(r.rows.len(), 0);
}
// ── Test 6: multiple concurrent ReadTx instances ─────────────────────────────
/// Multiple `ReadTx` handles coexist and query without blocking each other.
#[test]
fn readtx_query_concurrent_readers() {
let (_dir, db) = open_db();
db.execute("CREATE (n:Person {name: 1, age: 30})")
.expect("create person");
let tx1 = db.begin_read().expect("tx1");
let tx2 = db.begin_read().expect("tx2");
let tx3 = db.begin_read().expect("tx3");
let r1 = tx1
.query("MATCH (n:Person) RETURN n.age")
.expect("tx1 query");
let r2 = tx2
.query("MATCH (n:Person) RETURN n.age")
.expect("tx2 query");
let r3 = tx3
.query("MATCH (n:Person) RETURN n.age")
.expect("tx3 query");
assert_eq!(r1.rows.len(), 1);
assert_eq!(r2.rows.len(), 1);
assert_eq!(r3.rows.len(), 1);
}
// ── Test 7: ReadTx can be re-used for multiple queries ───────────────────────
#[test]
fn readtx_query_reusable() {
let (_dir, db) = open_db();
db.execute("CREATE (n:Person {name: 1, age: 30})")
.expect("create person");
let tx = db.begin_read().expect("begin_read");
// Query the same ReadTx twice — should not error or panic.
let r1 = tx
.query("MATCH (n:Person) RETURN n.name")
.expect("first query");
let r2 = tx
.query("MATCH (n:Person) RETURN n.age")
.expect("second query");
assert_eq!(r1.rows.len(), 1);
assert_eq!(r2.rows.len(), 1);
}
// ── Test 8: coexistence with an active WriteTx ───────────────────────────────
/// A ReadTx can query while a WriteTx is open (uncommitted), and sees only
/// the committed state at the time of `begin_read`.
#[test]
fn readtx_query_coexists_with_open_writetx() {
let (_dir, db) = open_db();
db.execute("CREATE (n:Person {name: 1, age: 30})")
.expect("seed Alice");
// Open a ReadTx.
let read = db.begin_read().expect("begin_read");
// Open a WriteTx and leave it uncommitted.
let write = db.begin_write().expect("begin_write");
// ReadTx::query must succeed even while the writer is open.
let r = read
.query("MATCH (n:Person) RETURN n.age")
.expect("query while writer is open");
assert_eq!(r.rows.len(), 1, "Alice should be visible");
// Drop the writer without committing.
drop(write);
}