diff --git a/.gitignore b/.gitignore index c1de9b9..c07776e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,10 @@ build/ .ruff_cache/ .coverage htmlcov/ + +# Agent protocol staging files (AGENT_START/CHECKPOINT/REPORT drafts) — +# never meant to enter the repo, may carry pre-disclosure detail. +.checkpoint-comment.md +.report.md +.pr-body*.md + diff --git a/pyproject.toml b/pyproject.toml index 4f1de3c..6c75db8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "queryme" -version = "0.2.2" +version = "0.2.3" description = "Shared query toolkit for the Zablab platform — descriptor schema, validator, compiler. Consumed by every DB-bearing microservice." requires-python = ">=3.11" dependencies = [ diff --git a/src/queryme/compiler.py b/src/queryme/compiler.py index d9044fd..19f8573 100644 --- a/src/queryme/compiler.py +++ b/src/queryme/compiler.py @@ -37,7 +37,7 @@ from sqlalchemy.sql import ColumnElement from sqlalchemy.types import TypeEngine -from queryme.descriptor import Operator, QueryDescriptor +from queryme.descriptor import MAX_LIMIT, Operator, QueryDescriptor from queryme.schema import ColumnType, SchemaDescriptor, TableDef from queryme.validator import ValidationIssue, validate_against_schema @@ -169,9 +169,20 @@ def compile_query( col = _resolve_column(o.column, tables) stmt = stmt.order_by(asc(col) if o.direction == "asc" else desc(col)) - # LIMIT / OFFSET — emitted only when explicitly set. - if descriptor.limit is not None: - stmt = stmt.limit(descriptor.limit) + # LIMIT — required on the descriptor and re-checked here : the + # field validator is skipped by pydantic's ``model_construct()``, + # so this is the last gate before a query ever reaches SQL. + if not 0 <= descriptor.limit <= MAX_LIMIT: + raise CompilationError( + [ + ValidationIssue( + code="limit_out_of_bounds", + message=f"limit {descriptor.limit} is outside [0, {MAX_LIMIT}]", + path="limit", + ) + ] + ) + stmt = stmt.limit(descriptor.limit) if descriptor.offset is not None: stmt = stmt.offset(descriptor.offset) diff --git a/src/queryme/descriptor.py b/src/queryme/descriptor.py index a1a036b..ccefa3e 100644 --- a/src/queryme/descriptor.py +++ b/src/queryme/descriptor.py @@ -18,6 +18,15 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator +#: Hard ceiling on rows a single query can return. ``limit`` is a +#: required field bounded by this constant — there is no silent +#: fallback. A caller that can't state a bound gets a rejected +#: descriptor, not a truncated result: per ADR 001 §3.3, a result +#: silently cut short is more dangerous than a refusal, because it +#: reads as complete (e.g. a grants/revocation enumeration truncated +#: at the ceiling looks like "this right doesn't exist"). +MAX_LIMIT = 1000 + # --------------------------------------------------------------------------- # Operators # --------------------------------------------------------------------------- @@ -159,8 +168,9 @@ class QueryDescriptor(BaseModel): # ORDER BY (stacked) order: list[OrderClause] = Field(default_factory=list) - # LIMIT / OFFSET. ``None`` = no clause emitted. - limit: int | None = Field(default=None, ge=0) + # LIMIT / OFFSET. ``limit`` is required — every caller must state + # its own bound, capped at MAX_LIMIT. + limit: int = Field(ge=0, le=MAX_LIMIT) offset: int | None = Field(default=None, ge=0) @model_validator(mode="after") diff --git a/src/queryme/validator.py b/src/queryme/validator.py index 9eba848..1ad958e 100644 --- a/src/queryme/validator.py +++ b/src/queryme/validator.py @@ -31,6 +31,7 @@ "unknown_select_column", "duplicate_join", "empty_select", + "limit_out_of_bounds", ] diff --git a/tests/test_compiler.py b/tests/test_compiler.py index 184d2a5..f236fd9 100644 --- a/tests/test_compiler.py +++ b/tests/test_compiler.py @@ -14,7 +14,13 @@ from sqlalchemy.dialects import postgresql from queryme.compiler import CompilationError, compile_query -from queryme.descriptor import JoinClause, OrderClause, QueryDescriptor, WhereClause +from queryme.descriptor import ( + MAX_LIMIT, + JoinClause, + OrderClause, + QueryDescriptor, + WhereClause, +) from queryme.schema import ColumnDef, RelationDef, SchemaDescriptor, TableDef @@ -81,6 +87,7 @@ def test_compile_simple_select() -> None: descriptor = QueryDescriptor( table="matches", select=["id", "blue_team", "red_team"], + limit=50, ) sql = _sql(compile_query(descriptor, schema)) assert "SELECT matches.id, matches.blue_team, matches.red_team" in sql @@ -88,7 +95,7 @@ def test_compile_simple_select() -> None: assert "WHERE" not in sql assert "JOIN" not in sql assert "ORDER BY" not in sql - assert "LIMIT" not in sql + assert "LIMIT 50" in sql def test_compile_where_equality_with_value() -> None: @@ -97,6 +104,7 @@ def test_compile_where_equality_with_value() -> None: table="matches", select=["id"], where=[WhereClause(column="patch", op="=", value="14.7")], + limit=50, ) sql = _sql(compile_query(descriptor, schema)) assert "WHERE matches.patch = '14.7'" in sql @@ -119,6 +127,7 @@ def test_compile_comparison_operators(op: str, value: object, needle: str) -> No table="matches", select=["id"], where=[WhereClause(column=column, op=op, value=value)], # type: ignore[arg-type] + limit=50, ) sql = _sql(compile_query(descriptor, schema)) assert needle in sql @@ -130,6 +139,7 @@ def test_compile_in_operator() -> None: table="match_players", select=["champion"], where=[WhereClause(column="role", op="IN", value=["mid", "top"])], + limit=50, ) sql = _sql(compile_query(descriptor, schema)) assert "match_players.role IN ('mid', 'top')" in sql @@ -141,6 +151,7 @@ def test_compile_like_operator() -> None: table="players", select=["summoner_name"], where=[WhereClause(column="summoner_name", op="LIKE", value="A%")], + limit=50, ) sql = _sql(compile_query(descriptor, schema)) # PG dialect doubles the % in literal_binds output (param-marker @@ -154,6 +165,7 @@ def test_compile_is_null_operator() -> None: table="matches", select=["id"], where=[WhereClause(column="patch", op="IS NULL")], + limit=50, ) sql = _sql(compile_query(descriptor, schema)) assert "matches.patch IS NULL" in sql @@ -168,6 +180,7 @@ def test_compile_multiple_where_are_and_joined() -> None: WhereClause(column="role", op="=", value="mid"), WhereClause(column="side", op="=", value="blue"), ], + limit=50, ) sql = _sql(compile_query(descriptor, schema)) assert " AND " in sql @@ -188,6 +201,7 @@ def test_compile_inner_join() -> None: ], select=["champion", "role"], where=[WhereClause(column="match_id", op="=", value="abc")], + limit=50, ) sql = _sql(compile_query(descriptor, schema)) assert "JOIN players ON match_players.player_id = players.id" in sql @@ -220,6 +234,7 @@ def test_compile_chained_joins_resolve_against_previously_joined() -> None: ), ], select=["blue_team"], + limit=50, ) sql = _sql(compile_query(descriptor, schema)) assert "JOIN match_players ON matches.id = match_players.match_id" in sql @@ -235,6 +250,7 @@ def test_compile_qualified_where_column() -> None: where=[ WhereClause(column="players.summoner_name", op="LIKE", value="Faker%"), ], + limit=50, ) sql = _sql(compile_query(descriptor, schema)) assert "players.summoner_name LIKE 'Faker%%'" in sql @@ -249,6 +265,7 @@ def test_compile_order_by_multiple() -> None: OrderClause(column="side"), OrderClause(column="role", direction="desc"), ], + limit=50, ) sql = _sql(compile_query(descriptor, schema)) assert "ORDER BY match_players.side ASC, match_players.role DESC" in sql @@ -267,14 +284,6 @@ def test_compile_limit_and_offset() -> None: assert "OFFSET 20" in sql -def test_compile_omits_limit_offset_when_unset() -> None: - schema = _truth_schema() - descriptor = QueryDescriptor(table="matches", select=["id"]) - sql = _sql(compile_query(descriptor, schema)) - assert "LIMIT" not in sql - assert "OFFSET" not in sql - - # ── Failure path ─────────────────────────────────────────────────────────── @@ -283,6 +292,7 @@ def test_compile_raises_compilation_error_on_validation_failure() -> None: descriptor = QueryDescriptor( table="match_players", select=["ghost_field"], + limit=50, ) with pytest.raises(CompilationError) as exc_info: compile_query(descriptor, schema) @@ -294,12 +304,26 @@ def test_compile_raises_compilation_error_on_validation_failure() -> None: def test_compile_raises_with_unknown_table() -> None: schema = _truth_schema() - descriptor = QueryDescriptor(table="ghosts", select=["id"]) + descriptor = QueryDescriptor(table="ghosts", select=["id"], limit=50) with pytest.raises(CompilationError) as exc_info: compile_query(descriptor, schema) assert exc_info.value.issues[0].code == "unknown_table" +def test_compile_rejects_limit_out_of_bounds_bypassing_field_validation() -> None: + """``model_construct`` skips field validators — the compiler is the + last gate before a query reaches SQL, so it must re-check the bound + even on a descriptor built this way.""" + schema = _truth_schema() + descriptor = QueryDescriptor.model_construct( + table="matches", select=["id"], where=[], joins=[], order=[], + limit=MAX_LIMIT + 1, offset=None, + ) + with pytest.raises(CompilationError) as exc_info: + compile_query(descriptor, schema) + assert exc_info.value.issues[0].code == "limit_out_of_bounds" + + def test_compile_returned_select_is_executable_shape() -> None: """selected_columns reflects descriptor.select + each join.select in that order — the service relies on this to zip rows back to dicts.""" @@ -314,6 +338,7 @@ def test_compile_returned_select_is_executable_shape() -> None: ) ], select=["champion", "role", "side"], + limit=50, ) stmt = compile_query(descriptor, schema) names = [c.name for c in stmt.selected_columns] diff --git a/tests/test_descriptor.py b/tests/test_descriptor.py index fabe83b..e067cf3 100644 --- a/tests/test_descriptor.py +++ b/tests/test_descriptor.py @@ -6,6 +6,7 @@ from pydantic import ValidationError from queryme.descriptor import ( + MAX_LIMIT, JoinClause, OrderClause, QueryDescriptor, @@ -17,13 +18,14 @@ def test_query_descriptor_minimal_round_trip() -> None: payload = { "table": "match_players", "select": ["champion", "role", "side"], + "limit": 50, } descriptor = QueryDescriptor.model_validate(payload) - # Default lists are empty, optional ints stay None. + # Default lists are empty, offset stays optional. assert descriptor.where == [] assert descriptor.joins == [] assert descriptor.order == [] - assert descriptor.limit is None + assert descriptor.limit == 50 assert descriptor.offset is None # Round-trip keeps the explicit fields intact. redumped = descriptor.model_dump() @@ -84,6 +86,7 @@ def test_query_descriptor_rejects_duplicate_join() -> None: JoinClause(table="players", on=("player_id", "id")), ], select=["champion"], + limit=50, ) @@ -92,10 +95,25 @@ def test_query_descriptor_rejects_unknown_field() -> None: immediately rather than being silently discarded.""" with pytest.raises(ValidationError): QueryDescriptor.model_validate( - {"table": "matches", "select": ["id"], "groupby": ["side"]} + {"table": "matches", "select": ["id"], "limit": 50, "groupby": ["side"]} ) +def test_limit_is_required() -> None: + with pytest.raises(ValidationError): + QueryDescriptor(table="matches", select=["id"]) + + def test_negative_limit_rejected() -> None: with pytest.raises(ValidationError): QueryDescriptor(table="matches", select=["id"], limit=-1) + + +def test_limit_above_max_rejected() -> None: + with pytest.raises(ValidationError): + QueryDescriptor(table="matches", select=["id"], limit=MAX_LIMIT + 1) + + +def test_limit_at_max_accepted() -> None: + descriptor = QueryDescriptor(table="matches", select=["id"], limit=MAX_LIMIT) + assert descriptor.limit == MAX_LIMIT diff --git a/tests/test_validator.py b/tests/test_validator.py index 4ea7739..1a631ac 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -59,6 +59,7 @@ def test_valid_query_returns_no_issues() -> None: ) ], select=["champion", "role", "side"], + limit=50, ) assert validate_against_schema(descriptor, schema) == [] @@ -70,6 +71,7 @@ def test_qualified_column_resolves() -> None: joins=[JoinClause(table="players", on=("player_id", "id"))], where=[WhereClause(column="players.summoner_name", op="LIKE", value="A%")], select=["champion"], + limit=50, ) assert validate_against_schema(descriptor, schema) == [] @@ -79,7 +81,7 @@ def test_qualified_column_resolves() -> None: def test_unknown_table_short_circuits() -> None: schema = _truth_schema() - descriptor = QueryDescriptor(table="nonexistent", select=["id"]) + descriptor = QueryDescriptor(table="nonexistent", select=["id"], limit=50) issues = validate_against_schema(descriptor, schema) assert len(issues) == 1 assert issues[0].code == "unknown_table" @@ -91,6 +93,7 @@ def test_unknown_select_column_reported() -> None: descriptor = QueryDescriptor( table="match_players", select=["champion", "ghost_field"], + limit=50, ) issues = validate_against_schema(descriptor, schema) codes = {(i.code, i.path) for i in issues} @@ -99,7 +102,7 @@ def test_unknown_select_column_reported() -> None: def test_empty_select_reported() -> None: schema = _truth_schema() - descriptor = QueryDescriptor(table="match_players") + descriptor = QueryDescriptor(table="match_players", limit=50) issues = validate_against_schema(descriptor, schema) assert any(i.code == "empty_select" for i in issues) @@ -114,6 +117,7 @@ def test_join_with_select_satisfies_empty_select_rule() -> None: joins=[ JoinClause(table="players", on=("player_id", "id"), select=["summoner_name"]), ], + limit=50, ) issues = validate_against_schema(descriptor, schema) assert all(i.code != "empty_select" for i in issues) @@ -125,6 +129,7 @@ def test_unknown_join_table_reported() -> None: table="match_players", joins=[JoinClause(table="ghosts", on=("player_id", "id"))], select=["champion"], + limit=50, ) issues = validate_against_schema(descriptor, schema) assert any(i.code == "unknown_join_table" and i.path == "joins[0].table" for i in issues) @@ -136,6 +141,7 @@ def test_unknown_join_column_reported() -> None: table="match_players", joins=[JoinClause(table="players", on=("nope", "wrong"))], select=["champion"], + limit=50, ) issues = validate_against_schema(descriptor, schema) paths = {i.path for i in issues} @@ -149,6 +155,7 @@ def test_unknown_where_column_reported() -> None: table="match_players", where=[WhereClause(column="missing", op="=", value=1)], select=["champion"], + limit=50, ) issues = validate_against_schema(descriptor, schema) assert any(i.code == "unknown_column" and i.path == "where[0].column" for i in issues) @@ -160,6 +167,7 @@ def test_unknown_order_column_reported() -> None: table="match_players", select=["champion"], order=[{"column": "ghost"}], # type: ignore[list-item] + limit=50, ) issues = validate_against_schema(descriptor, schema) assert any(i.code == "unknown_order_column" for i in issues) diff --git a/uv.lock b/uv.lock index 0247370..3df7c52 100644 --- a/uv.lock +++ b/uv.lock @@ -992,7 +992,7 @@ wheels = [ [[package]] name = "queryme" -version = "0.2.2" +version = "0.2.3" source = { editable = "." } dependencies = [ { name = "pydantic" },