import pytest
import minigdb
@pytest.fixture
def db(tmp_path):
handle = minigdb.open_at(str(tmp_path))
yield handle
handle.close()
def test_empty_graph_returns_no_rows(db):
rows = db.query('MATCH (n:Person) RETURN n.name')
assert rows == []
def test_insert_and_match_single_node(db):
db.query('INSERT (:Person {name: "Alice", age: 30})')
rows = db.query('MATCH (n:Person) RETURN n.name, n.age')
assert len(rows) == 1
assert rows[0]['n.name'] == 'Alice'
assert rows[0]['n.age'] == 30
def test_insert_multiple_nodes(db):
db.query('INSERT (:Person {name: "Alice"})')
db.query('INSERT (:Person {name: "Bob"})')
db.query('INSERT (:Person {name: "Carol"})')
rows = db.query('MATCH (n:Person) RETURN n.name ORDER BY n.name')
assert [r['n.name'] for r in rows] == ['Alice', 'Bob', 'Carol']
def test_where_filter(db):
db.query('INSERT (:Person {name: "Alice", age: 30})')
db.query('INSERT (:Person {name: "Bob", age: 20})')
rows = db.query('MATCH (n:Person) WHERE n.age > 25 RETURN n.name')
assert len(rows) == 1
assert rows[0]['n.name'] == 'Alice'
def test_limit_and_offset(db):
for name in ['A', 'B', 'C', 'D']:
db.query(f'INSERT (:X {{name: "{name}"}})')
rows = db.query('MATCH (n:X) RETURN n.name ORDER BY n.name LIMIT 2')
assert [r['n.name'] for r in rows] == ['A', 'B']
rows = db.query('MATCH (n:X) RETURN n.name ORDER BY n.name LIMIT 2 OFFSET 2')
assert [r['n.name'] for r in rows] == ['C', 'D']
def test_insert_and_traverse_edge(db):
db.query('INSERT (:Person {name: "Alice"})')
db.query('INSERT (:Person {name: "Bob"})')
db.query('MATCH (a:Person {name:"Alice"}), (b:Person {name:"Bob"}) INSERT (a)-[:KNOWS]->(b)')
rows = db.query('MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a.name, b.name')
assert len(rows) == 1
assert rows[0]['a.name'] == 'Alice'
assert rows[0]['b.name'] == 'Bob'
def test_set_property(db):
db.query('INSERT (:Person {name: "Alice", age: 30})')
db.query('MATCH (n:Person {name:"Alice"}) SET n.age = 31')
rows = db.query('MATCH (n:Person) RETURN n.age')
assert rows[0]['n.age'] == 31
def test_delete_node(db):
db.query('INSERT (:Person {name: "Alice"})')
db.query('INSERT (:Person {name: "Bob"})')
db.query('MATCH (n:Person {name:"Bob"}) DETACH DELETE n')
rows = db.query('MATCH (n:Person) RETURN n.name')
assert len(rows) == 1
assert rows[0]['n.name'] == 'Alice'
def test_value_types(db):
db.query('INSERT (:T {b: true, i: 42, f: 3.14, s: "hello", n: null})')
rows = db.query('MATCH (x:T) RETURN x.b, x.i, x.f, x.s, x.n')
assert len(rows) == 1
r = rows[0]
assert r['x.b'] is True
assert r['x.i'] == 42
assert isinstance(r['x.f'], float)
assert abs(r['x.f'] - 3.14) < 1e-9
assert r['x.s'] == 'hello'
assert r['x.n'] is None
def test_list_value(db):
db.query('INSERT (:Person {name: "Alice"})')
db.query('INSERT (:Person {name: "Bob"})')
rows = db.query('MATCH (n:Person) RETURN collect(n.name) AS names ORDER BY names')
names = rows[0]['names']
assert isinstance(names, list)
assert set(names) == {'Alice', 'Bob'}
def test_count_star(db):
for name in ['Alice', 'Bob', 'Carol']:
db.query(f'INSERT (:Person {{name: "{name}"}})')
rows = db.query('MATCH (n:Person) RETURN count(*)')
assert rows[0]['count(*)'] == 3
def test_count_star_empty(db):
rows = db.query('MATCH (n:Person) RETURN count(*)')
assert rows[0]['count(*)'] == 0
def test_avg_min_max(db):
db.query('INSERT (:N {v: 10})')
db.query('INSERT (:N {v: 20})')
db.query('INSERT (:N {v: 30})')
rows = db.query('MATCH (n:N) RETURN avg(n.v), min(n.v), max(n.v)')
assert rows[0]['avg(n.v)'] == pytest.approx(20.0)
assert rows[0]['min(n.v)'] == 10
assert rows[0]['max(n.v)'] == 30
def test_commit_makes_changes_visible(db):
db.begin()
db.query('INSERT (:Person {name: "Alice"})')
db.query('INSERT (:Person {name: "Bob"})')
db.commit()
rows = db.query('MATCH (n:Person) RETURN n.name ORDER BY n.name')
assert [r['n.name'] for r in rows] == ['Alice', 'Bob']
def test_rollback_discards_changes(db):
db.query('INSERT (:Person {name: "Alice"})')
db.begin()
db.query('INSERT (:Person {name: "Ghost"})')
db.rollback() rows = db.query('MATCH (n:Person) RETURN n.name')
assert len(rows) == 1
assert rows[0]['n.name'] == 'Alice'
def test_nested_begin_raises(db):
db.begin()
with pytest.raises(RuntimeError, match="Transaction already open"):
db.begin()
db.rollback()
def test_commit_without_begin_raises(db):
with pytest.raises(RuntimeError, match="No open transaction"):
db.commit()
def test_rollback_without_begin_raises(db):
with pytest.raises(RuntimeError, match="No open transaction"):
db.rollback()
def test_context_manager_closes_on_exit(tmp_path):
with minigdb.open_at(str(tmp_path)) as db:
db.query('INSERT (:Person {name: "Alice"})')
with minigdb.open_at(str(tmp_path)) as db:
rows = db.query('MATCH (n:Person) RETURN n.name')
assert rows[0]['n.name'] == 'Alice'
def test_context_manager_rollback_on_open_txn(tmp_path):
with minigdb.open_at(str(tmp_path)) as db:
db.query('INSERT (:Person {name: "Alice"})')
db.begin()
db.query('INSERT (:Person {name: "Ghost"})')
with minigdb.open_at(str(tmp_path)) as db:
rows = db.query('MATCH (n:Person) RETURN n.name')
assert len(rows) == 1
assert rows[0]['n.name'] == 'Alice'
def test_parse_error_raises_runtime_error(db):
with pytest.raises(RuntimeError):
db.query('NOT VALID GQL %%%')
def test_open_at_invalid_path_raises(tmp_path):
blocker = tmp_path / "blocker"
blocker.write_text("I am a file")
with pytest.raises(OSError):
minigdb.open_at(str(blocker / "subdir"))
def test_data_survives_reopen(tmp_path):
with minigdb.open_at(str(tmp_path)) as db:
db.query('INSERT (:Person {name: "Alice", age: 30})')
with minigdb.open_at(str(tmp_path)) as db:
rows = db.query('MATCH (n:Person) RETURN n.name, n.age')
assert rows[0]['n.name'] == 'Alice'
assert rows[0]['n.age'] == 30
def test_wal_replay_after_reopen(tmp_path):
with minigdb.open_at(str(tmp_path)) as db:
for name in ['A', 'B', 'C']:
db.query(f'INSERT (:X {{name: "{name}"}})')
with minigdb.open_at(str(tmp_path)) as db:
rows = db.query('MATCH (n:X) RETURN n.name ORDER BY n.name')
assert [r['n.name'] for r in rows] == ['A', 'B', 'C']
def test_open_empty_name_raises():
with pytest.raises(RuntimeError, match="1–64"):
minigdb.open('')
def test_open_bad_chars_raises():
with pytest.raises(RuntimeError, match="alphanumeric"):
minigdb.open('bad name!')
def test_open_too_long_raises():
with pytest.raises(RuntimeError, match="1–64"):
minigdb.open('x' * 65)