Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/seriousdb/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ def insert(self, key: str, value: str) -> tuple[str, bool]:

Raises
------
TypeError
If `key` or `value` is not a string.
ServiceUnavailableError

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thats good

If no database has been loaded.
OSError
Expand Down Expand Up @@ -137,6 +139,8 @@ def delete(self, key: str) -> str:

Raises
------
TypeError
If `key` is not a string.
ResourceNotFoundError
If `key` does not exist.
ServiceUnavailableError
Expand Down
29 changes: 14 additions & 15 deletions src/seriousdb/wal.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,23 +41,10 @@ def from_dict(data: dict) -> "WalEntry":
op = data.get("op")
try:
if op == "set":
key = data["key"]
value = data["value"]

if not isinstance(key, str):
raise TypeError("key must be a string")
if not isinstance(value, str):
raise TypeError("value must be a string")

return SetEntry(key=key, value=value)
return SetEntry(key=data["key"], value=data["value"])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thats a lot simpler, I do like it


if op == "delete":
key = data["key"]

if not isinstance(key, str):
raise TypeError("key must be a string")

return DeleteEntry(key=key)
return DeleteEntry(key=data["key"])

except KeyError as e:
raise ValueError(f"missing required field: {e}") from e
Expand All @@ -72,6 +59,13 @@ class SetEntry(WalEntry):
key: str
value: str

def __post_init__(self) -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great

"""Reject keys and values that are not strings."""
if not isinstance(self.key, str):
raise TypeError("key must be a string")
if not isinstance(self.value, str):
raise TypeError("value must be a string")

def to_dict(self) -> dict:
"""Return the JSON-serializable representation of this entry."""
return {"op": "set", "key": self.key, "value": self.value}
Expand All @@ -87,6 +81,11 @@ class DeleteEntry(WalEntry):

key: str

def __post_init__(self) -> None:
"""Reject keys that are not strings."""
if not isinstance(self.key, str):
raise TypeError("key must be a string")

def to_dict(self) -> dict:
"""Return the JSON-serializable representation of this entry."""
return {"op": "delete", "key": self.key}
Expand Down
24 changes: 24 additions & 0 deletions tests/test_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,30 @@ def boom(op):
assert reloaded.db["name"] == "Alice"


def test_rejected_write_leaves_the_wal_healthy(db_path):
"""A non-string write must be rejected, and must not lose later writes."""
cache = Cache()
cache.load(str(db_path))

assert cache.db is not None

cache.insert("a", "1")

with pytest.raises(TypeError):
cache.insert(5, "bad") # ty: ignore[invalid-argument-type]
with pytest.raises(TypeError):
cache.insert("b", 2) # ty: ignore[invalid-argument-type]

assert cache.db == {"a": "1"}

cache.insert("b", "2")

reloaded = Cache()
reloaded.load(str(db_path))

assert reloaded.db == {"a": "1", "b": "2"}


def test_replay_repairs_torn_wal_before_later_appends(db_path):
"""Remove a torn final record so later WAL appends remain valid."""
cache = Cache()
Expand Down
11 changes: 11 additions & 0 deletions tests/test_wal.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def test_replay_drops_and_truncates_a_torn_last_entry(wal, wal_path):
b'{"op": "set", "key": null, "value": "b"}\n', # Key is null
b'{"op": "set", "key": 5, "value": "b"}\n', # Key is not a string
b'{"op": "set", "key": "b", "value": [1, 2]}\n', # Value is not a string
b'{"op": "delete", "key": 5}\n', # Delete key is not a string
],
)
def test_replay_recovers_from_malformed_entry(wal, wal_path, malformed):
Expand All @@ -95,6 +96,16 @@ def test_replay_recovers_from_malformed_entry(wal, wal_path, malformed):
]


def test_entries_reject_non_string_key_or_value():

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess that enough

"""Reject a key or value that is not a string."""
with pytest.raises(TypeError):
SetEntry(key=5, value="b") # ty: ignore[invalid-argument-type]
with pytest.raises(TypeError):
SetEntry(key="b", value=[1, 2]) # ty: ignore[invalid-argument-type]
with pytest.raises(TypeError):
DeleteEntry(key=5) # ty: ignore[invalid-argument-type]


def test_append_after_repaired_truncation_stays_valid(wal, wal_path):
wal.append(SetEntry(key="a", value="1"))
wal.append(SetEntry(key="b", value="2"))
Expand Down
Loading