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
24 changes: 22 additions & 2 deletions src/jsonlt/_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@
from collections.abc import Sequence
from typing import TYPE_CHECKING, TypeAlias

from ._constants import MAX_INTEGER_KEY, MAX_TUPLE_ELEMENTS, MIN_INTEGER_KEY
from ._exceptions import InvalidKeyError
from ._constants import (
MAX_INTEGER_KEY,
MAX_KEY_LENGTH,
MAX_TUPLE_ELEMENTS,
MIN_INTEGER_KEY,
)
from ._exceptions import InvalidKeyError, LimitError
from ._json import utf8_byte_length

if TYPE_CHECKING:
Expand Down Expand Up @@ -343,3 +348,18 @@ def key_from_json(value: object) -> Key:
return tuple(elements)
msg = f"Cannot convert {type(value).__name__} to key"
raise TypeError(msg)


def validate_key_length(key: Key) -> None:
"""Validate that key length does not exceed the maximum.

Args:
key: The key to validate.

Raises:
LimitError: If key length exceeds MAX_KEY_LENGTH (1024 bytes).
"""
key_len = key_length(key)
if key_len > MAX_KEY_LENGTH:
msg = f"key length {key_len} bytes exceeds maximum {MAX_KEY_LENGTH}"
raise LimitError(msg)
17 changes: 7 additions & 10 deletions src/jsonlt/_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from typing import TYPE_CHECKING, ClassVar
from typing_extensions import override

from ._constants import MAX_KEY_LENGTH, MAX_RECORD_SIZE
from ._constants import MAX_RECORD_SIZE
from ._encoding import validate_no_surrogates
from ._exceptions import (
ConflictError,
Expand All @@ -25,10 +25,10 @@
from ._keys import (
Key,
KeySpecifier,
key_length,
key_specifiers_match,
normalize_key_specifier,
validate_key_arity,
validate_key_length,
)
from ._readable import ReadableMixin
from ._reader import parse_table_content, read_table_file
Expand Down Expand Up @@ -431,10 +431,7 @@ def put(self, record: "JSONObject") -> None:

# Extract and validate key
key = extract_key(record, key_specifier)
key_len = key_length(key)
if key_len > MAX_KEY_LENGTH:
msg = f"key length {key_len} bytes exceeds maximum {MAX_KEY_LENGTH}"
raise LimitError(msg)
validate_key_length(key)

# Serialize record
serialized = serialize_json(record)
Expand Down Expand Up @@ -528,6 +525,9 @@ def delete(self, key: Key) -> bool:
# Validate key arity matches specifier
validate_key_arity(key, key_specifier)

# Validate key length
validate_key_length(key)

# Build tombstone
tombstone = build_tombstone(key, key_specifier)
serialized = serialize_json(tombstone)
Expand Down Expand Up @@ -666,13 +666,12 @@ def _end_transaction(self) -> None:
"""
self._active_transaction = None

def _commit_transaction_buffer( # noqa: PLR0913
def _commit_transaction_buffer(
self,
lines: list[str],
start_state: "dict[Key, JSONObject]",
written_keys: set[Key],
buffer_updates: "dict[Key, JSONObject | None]",
start_mtime: float,
start_size: int,
*,
_retries: int = 0,
Expand All @@ -687,7 +686,6 @@ def _commit_transaction_buffer( # noqa: PLR0913
start_state: Snapshot of table state when transaction started.
written_keys: Keys that were modified in the transaction.
buffer_updates: Map of key -> record (or None for delete).
start_mtime: File mtime when transaction started.
start_size: File size when transaction started.
_retries: Internal retry counter (do not pass externally).

Expand Down Expand Up @@ -743,7 +741,6 @@ def _commit_transaction_buffer( # noqa: PLR0913
start_state,
written_keys,
buffer_updates,
start_mtime,
start_size,
_retries=_retries + 1,
)
Expand Down
26 changes: 16 additions & 10 deletions src/jsonlt/_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
from typing import TYPE_CHECKING, ClassVar
from typing_extensions import override

from ._constants import MAX_KEY_LENGTH, MAX_RECORD_SIZE
from ._constants import MAX_RECORD_SIZE
from ._encoding import validate_no_surrogates
from ._exceptions import LimitError, TransactionError
from ._json import serialize_json, utf8_byte_length
from ._keys import Key, KeySpecifier, key_length, validate_key_arity
from ._keys import Key, KeySpecifier, validate_key_arity, validate_key_length
from ._readable import ReadableMixin
from ._records import build_tombstone, extract_key, validate_record

Expand Down Expand Up @@ -52,6 +52,7 @@ class Transaction(ReadableMixin):
"""

__slots__: ClassVar[tuple[str, ...]] = (
"_buffer_serialized",
"_buffer_updates",
"_cached_sorted_keys",
"_file_mtime",
Expand All @@ -69,6 +70,7 @@ class Transaction(ReadableMixin):
_snapshot: "dict[Key, JSONObject]"
_start_state: "dict[Key, JSONObject]"
_buffer_updates: "dict[Key, JSONObject | None]"
_buffer_serialized: "dict[Key, str]"
_written_keys: set[Key]
_finalized: bool
_file_mtime: float
Expand Down Expand Up @@ -99,6 +101,7 @@ def __init__(
# reloaded state. Safe because _start_state values are never modified.
self._start_state = state.copy()
self._buffer_updates = {}
self._buffer_serialized = {}
self._written_keys = set()
self._finalized = False
# Cache file stats for skip-reload optimization at commit time
Expand Down Expand Up @@ -154,18 +157,18 @@ def put(self, record: "JSONObject") -> None:

# Extract and validate key
key = extract_key(record, self._key_specifier)
key_len = key_length(key)
if key_len > MAX_KEY_LENGTH:
msg = f"key length {key_len} bytes exceeds maximum {MAX_KEY_LENGTH}"
raise LimitError(msg)
validate_key_length(key)

# Serialize record to check size limit (we don't store the serialized form)
# Serialize record to check size limit and cache for commit
serialized = serialize_json(record)
record_bytes = utf8_byte_length(serialized)
if record_bytes > MAX_RECORD_SIZE:
msg = f"record size {record_bytes} bytes exceeds maximum {MAX_RECORD_SIZE}"
raise LimitError(msg)

# Cache serialized form before deep copy (record hasn't been modified)
self._buffer_serialized[key] = serialized

# Buffer the update (only keep latest value per key)
record_copy = copy.deepcopy(record)
self._buffer_updates[key] = record_copy
Expand Down Expand Up @@ -196,11 +199,15 @@ def delete(self, key: Key) -> bool:
# Validate key arity matches specifier
validate_key_arity(key, self._key_specifier)

# Validate key length
validate_key_length(key)

# Check if key exists in snapshot
existed = key in self._snapshot

# Buffer the delete (only keep latest state per key)
self._buffer_updates[key] = None
_ = self._buffer_serialized.pop(key, None)
self._written_keys.add(key)

# Update snapshot
Expand Down Expand Up @@ -239,8 +246,8 @@ def commit(self) -> None:
tombstone = build_tombstone(key, self._key_specifier)
lines.append(serialize_json(tombstone))
else:
# Record (put)
lines.append(serialize_json(value))
# Record (put) - use cached serialization from put()
lines.append(self._buffer_serialized[key])

# Commit via table (handles locking and conflict detection)
# Transaction is a friend class of Table - protected access is intentional
Expand All @@ -249,7 +256,6 @@ def commit(self) -> None:
self._start_state,
self._written_keys,
self._buffer_updates,
self._file_mtime,
self._file_size,
)
finally:
Expand Down
11 changes: 11 additions & 0 deletions tests/unit/test_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,17 @@ def test_delete_tuple_key_arity_mismatch_raises(self, tmp_path: "Path") -> None:
with pytest.raises(InvalidKeyError, match="key arity mismatch"):
_ = table.delete(("acme", 1, "extra")) # 3 elements, specifier has 2

def test_delete_key_length_limit_raises(self, tmp_path: "Path") -> None:
"""Delete with key exceeding 1024 bytes raises LimitError."""
table_path = tmp_path / "test.jsonlt"
table = Table(table_path, key="id")

# 1030 characters + quotes = 1032 bytes > 1024
long_key = "x" * 1030

with pytest.raises(LimitError, match="key length"):
_ = table.delete(long_key)


class TestTableClear:
def test_clear_removes_all_records(self, tmp_path: "Path") -> None:
Expand Down
14 changes: 14 additions & 0 deletions tests/unit/test_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,20 @@ def test_put_record_size_limit_raises(self, tmp_path: "Path") -> None:
):
tx.put({"id": "test", "data": large_data})

def test_delete_key_length_limit_raises(self, tmp_path: "Path") -> None:
"""Delete with key exceeding 1024 bytes raises LimitError."""
table_path = tmp_path / "test.jsonlt"
table = Table(table_path, key="id")

# 1030 characters + quotes = 1032 bytes > 1024
long_key = "x" * 1030

with (
table.transaction() as tx,
pytest.raises(LimitError, match="key length"),
):
_ = tx.delete(long_key)


class TestTransactionCommit:
def test_commit_persists_writes(self, tmp_path: "Path") -> None:
Expand Down