Skip to content

Commit e0ca4b4

Browse files
authored
feat: add equality and hash methods for Table and Transaction (#16)
* docs: update changelog * feat: add equality and hash methods for Table and Transaction Implement __eq__ and __hash__ for both Table and Transaction classes: - Table: value-based equality comparing resolved path, key_specifier, and record state. Auto-reloads both tables before comparison. - Transaction: equality based on parent table identity (is), finalized status, and snapshot state. - Both classes raise TypeError on hash (mutable objects). Includes comprehensive tests and documentation updates.
1 parent a51a249 commit e0ca4b4

6 files changed

Lines changed: 277 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
99

1010
### Added
1111

12+
- `Table.from_records()` and `Table.from_file()` factory methods for convenient table initialization
1213
- Dictionary-like access for `Table` and `Transaction` (`table[key]`, `table[key] = record`, `del table[key]`, `pop`, `popitem`, `setdefault`, `update`)
14+
- Value-based equality for `Table` and `Transaction` (`==` compares path, key specifier, and records)
1315

1416
## [0.1.0] - 2025-12-31
1517

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,23 @@ print(len(table))
149149

150150
Methods like `pop()`, `setdefault()`, and `update()` also work. The `keys()`, `values()`, and `items()` methods return sorted lists rather than views to maintain JSONLT's deterministic key ordering.
151151

152+
## Equality
153+
154+
Tables support value-based equality comparison:
155+
156+
```python
157+
table1 = Table("users.jsonlt", key="id")
158+
table2 = Table("users.jsonlt", key="id")
159+
160+
# Equal if same path, key specifier, and records
161+
if table1 == table2:
162+
print("Tables have identical content")
163+
```
164+
165+
Two tables are equal when they have the same resolved path, key specifier, and record state. Transactions are equal when they reference the same parent table instance and have identical snapshot state.
166+
167+
Tables and transactions are mutable and therefore not hashable (cannot be used as dictionary keys or in sets).
168+
152169
## Finding records
153170

154171
```python

src/jsonlt/_table.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -976,3 +976,33 @@ def _apply_buffer_updates(
976976
def __repr__(self) -> str:
977977
"""Return a string representation of the table."""
978978
return f"Table({self._path!r}, key={self._key_specifier!r})"
979+
980+
@override
981+
def __eq__(self, other: object) -> bool:
982+
"""Value equality based on path, key_specifier, and current state.
983+
984+
Two tables are equal if they have the same resolved path, key specifier,
985+
and identical record state. Triggers auto-reload on self before comparison
986+
to ensure current disk state is reflected.
987+
988+
Args:
989+
other: Object to compare with.
990+
991+
Returns:
992+
True if equal, False otherwise. Returns NotImplemented for non-Tables.
993+
"""
994+
if not isinstance(other, Table):
995+
return NotImplemented
996+
self._maybe_reload()
997+
other._maybe_reload()
998+
return (
999+
self._path.resolve() == other._path.resolve()
1000+
and self._key_specifier == other._key_specifier
1001+
and self._state == other._state
1002+
)
1003+
1004+
@override
1005+
def __hash__(self) -> int:
1006+
"""Table is mutable and not hashable."""
1007+
msg = f"unhashable type: '{type(self).__name__}'"
1008+
raise TypeError(msg)

src/jsonlt/_transaction.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,3 +317,31 @@ def __repr__(self) -> str:
317317
return (
318318
f"Transaction({self._table._path!r}, key={self._key_specifier!r}, {status})" # pyright: ignore[reportPrivateUsage] # noqa: SLF001
319319
)
320+
321+
@override
322+
def __eq__(self, other: object) -> bool:
323+
"""Equality based on parent table identity, finalized status, snapshot.
324+
325+
Two transactions are equal if they reference the same parent Table
326+
instance, have the same finalized status, and have identical snapshot
327+
state (which includes any buffered writes).
328+
329+
Args:
330+
other: Object to compare with.
331+
332+
Returns:
333+
True if equal, False otherwise. Returns NotImplemented for others.
334+
"""
335+
if not isinstance(other, Transaction):
336+
return NotImplemented
337+
return (
338+
self._table is other._table
339+
and self._finalized == other._finalized
340+
and self._snapshot == other._snapshot
341+
)
342+
343+
@override
344+
def __hash__(self) -> int:
345+
"""Transaction is mutable and not hashable."""
346+
msg = f"unhashable type: '{type(self).__name__}'"
347+
raise TypeError(msg)

tests/unit/test_table.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1449,3 +1449,81 @@ def test_update_with_none_is_noop(self, make_table: "Callable[..., Table]") -> N
14491449
table.update(None)
14501450

14511451
assert table.count() == 0
1452+
1453+
1454+
class TestTableEquality:
1455+
def test_equal_tables_same_path_and_state(self, tmp_path: "Path") -> None:
1456+
table_path = tmp_path / "test.jsonlt"
1457+
_ = table_path.write_text('{"id": "alice", "v": 1}\n')
1458+
1459+
table1 = Table(table_path, key="id")
1460+
table2 = Table(table_path, key="id")
1461+
1462+
assert table1 == table2
1463+
1464+
def test_not_equal_different_paths(self, tmp_path: "Path") -> None:
1465+
path1 = tmp_path / "test1.jsonlt"
1466+
path2 = tmp_path / "test2.jsonlt"
1467+
_ = path1.write_text('{"id": "alice", "v": 1}\n')
1468+
_ = path2.write_text('{"id": "alice", "v": 1}\n')
1469+
1470+
table1 = Table(path1, key="id")
1471+
table2 = Table(path2, key="id")
1472+
1473+
assert table1 != table2
1474+
1475+
def test_not_equal_different_key_specifier(self, tmp_path: "Path") -> None:
1476+
table_path = tmp_path / "test.jsonlt"
1477+
_ = table_path.write_text('{"id": "alice", "name": "Alice"}\n')
1478+
1479+
table1 = Table(table_path, key="id")
1480+
table2 = Table(table_path, key="name")
1481+
1482+
assert table1 != table2
1483+
1484+
def test_not_equal_different_state(self, tmp_path: "Path") -> None:
1485+
path1 = tmp_path / "test1.jsonlt"
1486+
path2 = tmp_path / "test2.jsonlt"
1487+
_ = path1.write_text('{"id": "alice", "v": 1}\n')
1488+
_ = path2.write_text('{"id": "alice", "v": 2}\n')
1489+
1490+
table1 = Table(path1, key="id")
1491+
table2 = Table(path2, key="id")
1492+
1493+
assert table1 != table2
1494+
1495+
def test_equal_empty_tables(self, tmp_path: "Path") -> None:
1496+
table_path = tmp_path / "test.jsonlt"
1497+
1498+
table1 = Table(table_path, key="id")
1499+
table2 = Table(table_path, key="id")
1500+
1501+
assert table1 == table2
1502+
1503+
def test_eq_with_non_table_returns_false(
1504+
self, make_table: "Callable[..., Table]"
1505+
) -> None:
1506+
table = make_table()
1507+
1508+
result = table == "string"
1509+
1510+
assert result is False
1511+
1512+
def test_equal_relative_vs_absolute_path(
1513+
self, tmp_path: "Path", monkeypatch: "pytest.MonkeyPatch"
1514+
) -> None:
1515+
table_path = tmp_path / "test.jsonlt"
1516+
_ = table_path.write_text('{"id": "alice", "v": 1}\n')
1517+
1518+
# Change to tmp_path directory to create a relative path
1519+
monkeypatch.chdir(tmp_path)
1520+
table1 = Table(table_path, key="id")
1521+
table2 = Table("test.jsonlt", key="id")
1522+
1523+
assert table1 == table2
1524+
1525+
def test_table_is_not_hashable(self, make_table: "Callable[..., Table]") -> None:
1526+
table = make_table()
1527+
1528+
with pytest.raises(TypeError, match="unhashable type"):
1529+
_ = hash(table)

tests/unit/test_transaction.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1302,3 +1302,125 @@ def test_update_with_none_is_noop(self, make_table: "Callable[..., Table]") -> N
13021302
with table.transaction() as tx:
13031303
tx.update(None)
13041304
assert tx.count() == 0
1305+
1306+
1307+
class TestTransactionEquality:
1308+
def test_equal_transactions_same_table_same_snapshot(
1309+
self, make_table: "Callable[..., Table]"
1310+
) -> None:
1311+
table = make_table()
1312+
1313+
# Since only one transaction can be active at a time per table,
1314+
# we verify equality by comparing a transaction to itself using
1315+
# a reference. This ensures __eq__ returns True for same instance.
1316+
tx = table.transaction()
1317+
try:
1318+
tx_ref = tx
1319+
assert tx == tx_ref
1320+
finally:
1321+
tx.abort()
1322+
1323+
def test_not_equal_different_parent_tables(self, tmp_path: "Path") -> None:
1324+
path1 = tmp_path / "test1.jsonlt"
1325+
path2 = tmp_path / "test2.jsonlt"
1326+
_ = path1.write_text('{"id": "alice", "v": 1}\n')
1327+
_ = path2.write_text('{"id": "alice", "v": 1}\n')
1328+
1329+
table1 = Table(path1, key="id")
1330+
table2 = Table(path2, key="id")
1331+
1332+
tx1 = table1.transaction()
1333+
tx2 = table2.transaction()
1334+
try:
1335+
# Different table instances, so not equal
1336+
assert tx1 != tx2
1337+
finally:
1338+
tx1.abort()
1339+
tx2.abort()
1340+
1341+
def test_not_equal_different_buffered_writes(self, tmp_path: "Path") -> None:
1342+
path1 = tmp_path / "test1.jsonlt"
1343+
path2 = tmp_path / "test2.jsonlt"
1344+
_ = path1.write_text('{"id": "alice", "v": 1}\n')
1345+
_ = path2.write_text('{"id": "alice", "v": 1}\n')
1346+
1347+
table1 = Table(path1, key="id")
1348+
table2 = Table(path2, key="id")
1349+
1350+
tx1 = table1.transaction()
1351+
tx2 = table2.transaction()
1352+
try:
1353+
# Make different writes
1354+
tx1.put({"id": "bob", "v": 1})
1355+
tx2.put({"id": "carol", "v": 1})
1356+
1357+
assert tx1 != tx2
1358+
finally:
1359+
tx1.abort()
1360+
tx2.abort()
1361+
1362+
def test_equal_with_same_buffered_writes(self, tmp_path: "Path") -> None:
1363+
path1 = tmp_path / "test1.jsonlt"
1364+
path2 = tmp_path / "test2.jsonlt"
1365+
_ = path1.write_text('{"id": "alice", "v": 1}\n')
1366+
_ = path2.write_text('{"id": "alice", "v": 1}\n')
1367+
1368+
table1 = Table(path1, key="id")
1369+
table2 = Table(path2, key="id")
1370+
1371+
tx1 = table1.transaction()
1372+
tx2 = table2.transaction()
1373+
try:
1374+
# Make identical writes
1375+
tx1.put({"id": "bob", "v": 2})
1376+
tx2.put({"id": "bob", "v": 2})
1377+
1378+
# They still differ because they have different parent tables
1379+
assert tx1 != tx2
1380+
finally:
1381+
tx1.abort()
1382+
tx2.abort()
1383+
1384+
def test_eq_with_non_transaction_returns_false(
1385+
self, make_table: "Callable[..., Table]"
1386+
) -> None:
1387+
table = make_table()
1388+
1389+
tx = table.transaction()
1390+
try:
1391+
result = tx == "string"
1392+
assert result is False
1393+
finally:
1394+
tx.abort()
1395+
1396+
def test_finalized_vs_active_not_equal(self, tmp_path: "Path") -> None:
1397+
path1 = tmp_path / "test1.jsonlt"
1398+
path2 = tmp_path / "test2.jsonlt"
1399+
_ = path1.write_text('{"id": "alice", "v": 1}\n')
1400+
_ = path2.write_text('{"id": "alice", "v": 1}\n')
1401+
1402+
table1 = Table(path1, key="id")
1403+
table2 = Table(path2, key="id")
1404+
1405+
tx1 = table1.transaction()
1406+
tx2 = table2.transaction()
1407+
1408+
# Commit one, leave the other active
1409+
tx1.commit()
1410+
try:
1411+
# finalized vs active should not be equal
1412+
assert tx1 != tx2
1413+
finally:
1414+
tx2.abort()
1415+
1416+
def test_transaction_is_not_hashable(
1417+
self, make_table: "Callable[..., Table]"
1418+
) -> None:
1419+
table = make_table()
1420+
1421+
tx = table.transaction()
1422+
try:
1423+
with pytest.raises(TypeError, match="unhashable type"):
1424+
_ = hash(tx)
1425+
finally:
1426+
tx.abort()

0 commit comments

Comments
 (0)