feat: add selection validation with optimistic locking
Implements Step 5.3 - selection validation for optimistic locking: - SelectionContext: holds client's filter, resolved_ids, roster_hash, ts_utc - SelectionDiff: shows added/removed animals on mismatch - SelectionValidationResult: validation result with diff if applicable - validate_selection(): re-resolves at ts_utc, compares hashes, returns diff - SelectionMismatchError: exception for unconfirmed mismatches Tests cover: hash match, mismatch detection, diff correctness, confirmed bypass, from_location_id in hash comparison. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# ABOUTME: Selection system for resolving animal sets from filters.
|
||||
# ABOUTME: Provides parser, AST, resolver, and hash for animal selection contexts.
|
||||
# ABOUTME: Provides parser, AST, resolver, hash, and validation for animal selection contexts.
|
||||
|
||||
from animaltrack.selection.ast import FieldFilter, FilterAST
|
||||
from animaltrack.selection.hash import compute_roster_hash
|
||||
@@ -10,15 +10,27 @@ from animaltrack.selection.resolver import (
|
||||
resolve_filter,
|
||||
resolve_selection,
|
||||
)
|
||||
from animaltrack.selection.validation import (
|
||||
SelectionContext,
|
||||
SelectionDiff,
|
||||
SelectionMismatchError,
|
||||
SelectionValidationResult,
|
||||
validate_selection,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"FieldFilter",
|
||||
"FilterAST",
|
||||
"ParseError",
|
||||
"SelectionContext",
|
||||
"SelectionDiff",
|
||||
"SelectionMismatchError",
|
||||
"SelectionResolverError",
|
||||
"SelectionResult",
|
||||
"SelectionValidationResult",
|
||||
"compute_roster_hash",
|
||||
"parse_filter",
|
||||
"resolve_filter",
|
||||
"resolve_selection",
|
||||
"validate_selection",
|
||||
]
|
||||
|
||||
149
src/animaltrack/selection/validation.py
Normal file
149
src/animaltrack/selection/validation.py
Normal file
@@ -0,0 +1,149 @@
|
||||
# ABOUTME: Selection validation with optimistic locking for animal operations.
|
||||
# ABOUTME: Re-resolves at ts_utc, compares hashes, returns diff on mismatch.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from animaltrack.selection.hash import compute_roster_hash
|
||||
from animaltrack.selection.parser import parse_filter
|
||||
from animaltrack.selection.resolver import resolve_filter
|
||||
|
||||
|
||||
@dataclass
|
||||
class SelectionContext:
|
||||
"""Context for validating an animal selection.
|
||||
|
||||
Contains client's filter, resolved IDs, and hash for comparison.
|
||||
"""
|
||||
|
||||
filter: str # DSL filter string
|
||||
resolved_ids: list[str] # Client's resolved animal IDs
|
||||
roster_hash: str # Client's computed hash
|
||||
ts_utc: int # Point-in-time for resolution
|
||||
from_location_id: str | None # For move operations (included in hash)
|
||||
confirmed: bool = False # Override on mismatch
|
||||
resolver_version: str = "v1" # Fixed version string
|
||||
|
||||
|
||||
@dataclass
|
||||
class SelectionDiff:
|
||||
"""Difference between client and server resolved selections.
|
||||
|
||||
Used to inform client what changed since their resolution.
|
||||
"""
|
||||
|
||||
added: list[str] # IDs in server resolution but not client
|
||||
removed: list[str] # IDs in client but not server resolution
|
||||
server_count: int # Server's resolved count
|
||||
client_count: int # Client's resolved count
|
||||
|
||||
|
||||
@dataclass
|
||||
class SelectionValidationResult:
|
||||
"""Result of selection validation.
|
||||
|
||||
valid=True means the selection can proceed (match or confirmed).
|
||||
valid=False means mismatch detected and not confirmed.
|
||||
"""
|
||||
|
||||
valid: bool # True if match or confirmed
|
||||
resolved_ids: list[str] # IDs to use for event
|
||||
roster_hash: str # Hash to use
|
||||
diff: SelectionDiff | None # None if match, populated if mismatch
|
||||
|
||||
|
||||
class SelectionMismatchError(Exception):
|
||||
"""Raised when selection validation fails and not confirmed.
|
||||
|
||||
Contains the validation result with diff for client to display.
|
||||
"""
|
||||
|
||||
def __init__(self, result: SelectionValidationResult) -> None:
|
||||
self.result = result
|
||||
super().__init__("Selection mismatch detected")
|
||||
|
||||
|
||||
def _compute_diff(
|
||||
client_ids: list[str],
|
||||
server_ids: list[str],
|
||||
) -> SelectionDiff:
|
||||
"""Compute difference between client and server resolved IDs.
|
||||
|
||||
Args:
|
||||
client_ids: IDs from client's resolution.
|
||||
server_ids: IDs from server's resolution.
|
||||
|
||||
Returns:
|
||||
SelectionDiff with added, removed, and counts.
|
||||
"""
|
||||
client_set = set(client_ids)
|
||||
server_set = set(server_ids)
|
||||
|
||||
added = sorted(server_set - client_set)
|
||||
removed = sorted(client_set - server_set)
|
||||
|
||||
return SelectionDiff(
|
||||
added=added,
|
||||
removed=removed,
|
||||
server_count=len(server_ids),
|
||||
client_count=len(client_ids),
|
||||
)
|
||||
|
||||
|
||||
def validate_selection(
|
||||
db: Any,
|
||||
context: SelectionContext,
|
||||
) -> SelectionValidationResult:
|
||||
"""Validate client selection against server resolution at ts_utc.
|
||||
|
||||
Re-resolves the filter at ts_utc, computes roster hash, and compares
|
||||
with client's hash. Returns valid=True if hashes match or if
|
||||
confirmed=True. Returns valid=False with diff if mismatch and not
|
||||
confirmed.
|
||||
|
||||
Args:
|
||||
db: Database connection.
|
||||
context: SelectionContext with client's filter, IDs, and hash.
|
||||
|
||||
Returns:
|
||||
SelectionValidationResult with validation status and diff if applicable.
|
||||
"""
|
||||
# Parse and resolve filter at ts_utc
|
||||
filter_ast = parse_filter(context.filter)
|
||||
resolution = resolve_filter(db, filter_ast, context.ts_utc)
|
||||
|
||||
# Compute server's hash (including from_location_id if provided)
|
||||
server_hash = compute_roster_hash(
|
||||
resolution.animal_ids,
|
||||
context.from_location_id,
|
||||
)
|
||||
|
||||
# Compare hashes
|
||||
if server_hash == context.roster_hash:
|
||||
# Match - proceed with client's IDs
|
||||
return SelectionValidationResult(
|
||||
valid=True,
|
||||
resolved_ids=context.resolved_ids,
|
||||
roster_hash=context.roster_hash,
|
||||
diff=None,
|
||||
)
|
||||
|
||||
# Mismatch - compute diff
|
||||
diff = _compute_diff(context.resolved_ids, resolution.animal_ids)
|
||||
|
||||
if context.confirmed:
|
||||
# Client confirmed mismatch - trust their IDs
|
||||
return SelectionValidationResult(
|
||||
valid=True,
|
||||
resolved_ids=context.resolved_ids,
|
||||
roster_hash=context.roster_hash,
|
||||
diff=diff,
|
||||
)
|
||||
|
||||
# Mismatch not confirmed - return invalid with server's resolution
|
||||
return SelectionValidationResult(
|
||||
valid=False,
|
||||
resolved_ids=resolution.animal_ids,
|
||||
roster_hash=server_hash,
|
||||
diff=diff,
|
||||
)
|
||||
Reference in New Issue
Block a user