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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
19 changes: 15 additions & 4 deletions src/queryme/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down
14 changes: 12 additions & 2 deletions src/queryme/descriptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions src/queryme/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"unknown_select_column",
"duplicate_join",
"empty_select",
"limit_out_of_bounds",
]


Expand Down
47 changes: 36 additions & 11 deletions tests/test_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -81,14 +87,15 @@ 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
assert "FROM matches" in sql
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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 ───────────────────────────────────────────────────────────


Expand All @@ -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)
Expand All @@ -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."""
Expand All @@ -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]
Expand Down
24 changes: 21 additions & 3 deletions tests/test_descriptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pydantic import ValidationError

from queryme.descriptor import (
MAX_LIMIT,
JoinClause,
OrderClause,
QueryDescriptor,
Expand All @@ -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()
Expand Down Expand Up @@ -84,6 +86,7 @@ def test_query_descriptor_rejects_duplicate_join() -> None:
JoinClause(table="players", on=("player_id", "id")),
],
select=["champion"],
limit=50,
)


Expand All @@ -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
Loading
Loading