From 92e025d03cb5b7ede2f4d5463972ee2d562e36e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ClodoCap=C3=A9o?= <159788250+ClodoCapeo@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:38:44 +0200 Subject: [PATCH 1/3] fix(descriptor): bound LIMIT to prevent unbounded SELECT A descriptor with limit=None emitted no LIMIT clause at all, letting any of the 6 consuming services run an unbounded SELECT against production through the nominal query path (mass exfiltration / DoS). limit now has an upper bound (DEFAULT_MAX_LIMIT=1000) enforced by pydantic (le=), and the compiler applies that same ceiling whenever limit is omitted instead of emitting no clause. Refs #6 Agent-Role: forge Agent-Thread: queryme-v023-limit-bound Work-Unit: QUERYME-V023-LIMIT-BOUND Issue: 6 --- pyproject.toml | 2 +- src/queryme/compiler.py | 12 ++++++++---- src/queryme/descriptor.py | 11 +++++++++-- tests/test_compiler.py | 14 ++++++++++---- tests/test_descriptor.py | 11 +++++++++++ uv.lock | 2 +- 6 files changed, 40 insertions(+), 12 deletions(-) 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..2038beb 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 DEFAULT_MAX_LIMIT, Operator, QueryDescriptor from queryme.schema import ColumnType, SchemaDescriptor, TableDef from queryme.validator import ValidationIssue, validate_against_schema @@ -169,9 +169,13 @@ 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 — always emitted. An explicit descriptor.limit is already + # bounded by DEFAULT_MAX_LIMIT (descriptor validation) ; an omitted + # one falls back to the same ceiling so a query can never run + # unbounded against a service's database. + stmt = stmt.limit( + descriptor.limit if descriptor.limit is not None else DEFAULT_MAX_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..00c8c04 100644 --- a/src/queryme/descriptor.py +++ b/src/queryme/descriptor.py @@ -18,6 +18,13 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator +#: Hard ceiling on rows a single query can return. Applied both as the +#: upper bound on an explicit ``limit`` (rejected past this, not +#: clamped — a descriptor that asks for more than the ceiling is wrong, +#: not merely generous) and as the value the compiler substitutes when +#: ``limit`` is omitted, so ``None`` can never mean "no LIMIT clause". +DEFAULT_MAX_LIMIT = 1000 + # --------------------------------------------------------------------------- # Operators # --------------------------------------------------------------------------- @@ -159,8 +166,8 @@ 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. ``None`` limit = compiler applies DEFAULT_MAX_LIMIT. + limit: int | None = Field(default=None, ge=0, le=DEFAULT_MAX_LIMIT) offset: int | None = Field(default=None, ge=0) @model_validator(mode="after") diff --git a/tests/test_compiler.py b/tests/test_compiler.py index 184d2a5..486a02d 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 ( + DEFAULT_MAX_LIMIT, + JoinClause, + OrderClause, + QueryDescriptor, + WhereClause, +) from queryme.schema import ColumnDef, RelationDef, SchemaDescriptor, TableDef @@ -88,7 +94,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" in sql def test_compile_where_equality_with_value() -> None: @@ -267,11 +273,11 @@ def test_compile_limit_and_offset() -> None: assert "OFFSET 20" in sql -def test_compile_omits_limit_offset_when_unset() -> None: +def test_compile_applies_default_max_limit_when_unset() -> None: schema = _truth_schema() descriptor = QueryDescriptor(table="matches", select=["id"]) sql = _sql(compile_query(descriptor, schema)) - assert "LIMIT" not in sql + assert f"LIMIT {DEFAULT_MAX_LIMIT}" in sql assert "OFFSET" not in sql diff --git a/tests/test_descriptor.py b/tests/test_descriptor.py index fabe83b..fbd6b65 100644 --- a/tests/test_descriptor.py +++ b/tests/test_descriptor.py @@ -6,6 +6,7 @@ from pydantic import ValidationError from queryme.descriptor import ( + DEFAULT_MAX_LIMIT, JoinClause, OrderClause, QueryDescriptor, @@ -99,3 +100,13 @@ def test_query_descriptor_rejects_unknown_field() -> None: def test_negative_limit_rejected() -> None: with pytest.raises(ValidationError): QueryDescriptor(table="matches", select=["id"], limit=-1) + + +def test_limit_above_default_max_rejected() -> None: + with pytest.raises(ValidationError): + QueryDescriptor(table="matches", select=["id"], limit=DEFAULT_MAX_LIMIT + 1) + + +def test_limit_at_default_max_accepted() -> None: + descriptor = QueryDescriptor(table="matches", select=["id"], limit=DEFAULT_MAX_LIMIT) + assert descriptor.limit == DEFAULT_MAX_LIMIT 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" }, From d2b552015b89cdd565a7950141a053266f08d1f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ClodoCap=C3=A9o?= <159788250+ClodoCapeo@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:43:42 +0200 Subject: [PATCH 2/3] chore(checkpoint): persist work before standby Persist the current work unit before returning control to Eleven. Agent-Role: Forge Agent-Thread: queryme-v023-limit-bound Work-Unit: QUERYME-V023-LIMIT-BOUND Issue: 6 Evidence: checkpoint-pre-standby --- .checkpoint-comment.md | 6 ++++++ .pr-body.md | 47 ++++++++++++++++++++++++++++++++++++++++++ .report.md | 31 ++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 .checkpoint-comment.md create mode 100644 .pr-body.md create mode 100644 .report.md diff --git a/.checkpoint-comment.md b/.checkpoint-comment.md new file mode 100644 index 0000000..cb64fce --- /dev/null +++ b/.checkpoint-comment.md @@ -0,0 +1,6 @@ +AGENT_CHECKPOINT + +Role: forge · Work-Unit: QUERYME-V023-LIMIT-BOUND +Branch forge/queryme-v0.2.3-limit-bound pushed, commit 92e025d. +lint/typecheck/tests verts localement (ruff, mypy strict, pytest 45/45). +Ouverture PR contre main. diff --git a/.pr-body.md b/.pr-body.md new file mode 100644 index 0000000..897f734 --- /dev/null +++ b/.pr-body.md @@ -0,0 +1,47 @@ +## Summary + +`limit: int | None` on `QueryDescriptor` only had a lower bound (`ge=0`). When +`limit` was `None`, `compile_query()` emitted no `LIMIT` clause at all — +a valid descriptor produced `SELECT ... FROM ` with no bound against a +consuming service's production database. All 6 current consumers (Blue, +ZabTruth, ZabRanking, ZabAuth, ZabCam, ZabCanvas) go through this nominal +path, so this was mass-exfiltration/DoS-by-default, not an edge case. + +Fix, on `v0.2.3` (`v0.2.2` stays untouched — RC-40 of ADR 001 is frozen): + +- `descriptor.py`: new `DEFAULT_MAX_LIMIT = 1000` constant. `limit` gains + `le=DEFAULT_MAX_LIMIT` — a descriptor asking for more is **rejected** + (`ValidationError`), not silently clamped, matching the fail-closed style + already used by `validate_against_schema()`. +- `compiler.py`: `LIMIT` is now always emitted. Explicit `limit` is used as-is + (already bounded by the validator); an omitted `limit` falls back to + `DEFAULT_MAX_LIMIT` instead of producing no clause. + +1000 is a starting ceiling, not derived from an existing QueryMe convention — +none exists yet in this library. It's above ZabCanvas's own +`MAX_SCENE_PAGE_SIZE` (200, clamped) since QueryMe serves generic +cross-table queries, not just paginated UI listings; open to Bastion/Eleven +adjusting it before v0.2.3 ships. + +## Tests + +Ran inside the branch worktree (`uv sync && uv run ruff check . && uv run +mypy src && uv run pytest`): + +- `ruff check .` → All checks passed! +- `mypy src` (strict) → Success: no issues found in 5 source files +- `pytest` → 45 passed (2 pre-existing tests updated: `LIMIT` is now always + present in the compiled SQL; 3 new tests added — default-max applied when + `limit` is unset, explicit `limit` above the ceiling rejected, `limit` + exactly at the ceiling accepted) + +## Écarts / hypothèses + +- Ceiling value (1000) is a judgment call, not a documented convention — + flagged above, Bastion clearance requested on this choice per the bail. +- No migration of the 6 consumer services — out of scope per the bail + (separate work once v0.2.3 is published). +- `v0.2.3` tag not created yet — left for Keeper/Vigil once this PR merges, + per the bail (`v0.2.2` must never move). + +Refs #6 diff --git a/.report.md b/.report.md new file mode 100644 index 0000000..75efb51 --- /dev/null +++ b/.report.md @@ -0,0 +1,31 @@ +AGENT_REPORT + +Role: forge · Thread: queryme-v023-limit-bound · Work-Unit: QUERYME-V023-LIMIT-BOUND +Résultat: READY +PR: https://github.com/ZabLaboratory/QueryMe/pull/13 (forge/queryme-v0.2.3-limit-bound → main) +Commit: 92e025d03cb5b7ede2f4d5463972ee2d562e36e8 (signé SSH, trailers Agent-Role/Agent-Thread/Work-Unit/Issue) + +Fix: DEFAULT_MAX_LIMIT=1000 dans descriptor.py (le= sur limit, rejet explicite au-dessus, +pas de clamp silencieux) ; compiler.py émet toujours LIMIT, applique DEFAULT_MAX_LIMIT +quand limit est None. version bump 0.2.2 → 0.2.3 (pyproject.toml). v0.2.2 non touché. + +Critères → preuves: +- descriptor+compiler patchés → src/queryme/descriptor.py, compiler.py (diff PR #13) +- tests limit=None borné + limit>max rejeté + limit=max accepté → tests/test_descriptor.py, + test_compiler.py (2 tests existants mis à jour, 3 nouveaux) +- CI verte → gh pr checks 13 : 7/7 pass (lint, typecheck, tests, pip-audit, trufflehog, + lockfile-check, CODEOWNERS) +- pas de PR de migration consommateurs → hors scope, non fait +- pas de tag v0.2.3 → laissé à Keeper/Vigil post-merge (bail) + +Risques: aucun régressé — 45/45 tests pytest passent, aucune modif hors descriptor.py/ +compiler.py/pyproject.toml/tests. + +Écarts/hypothèses posées (non tranchées): +- Valeur de la borne = 1000, jugement Forge (pas de convention QueryMe existante ; + ZabCanvas clampe à 200 mais pour un usage UI paginé différent) — clearance Bastion + demandée dans le corps de la PR. +- Sémantique = rejet explicite (ValidationError) au-dessus du plafond, pas de clamp + silencieux — choisi comme le plus sûr, à confirmer par Bastion. + +Statut: READY — en attente clearance Bastion (borne) puis décision Eleven. Pas mergé. From 0e18987599c6b0a49c11ad43ec18494745b52af3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ClodoCap=C3=A9o?= <159788250+ClodoCapeo@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:50:15 +0200 Subject: [PATCH 3/3] fix(descriptor): make limit required, fail-closed on out-of-bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bastion review of the LIMIT-bound patch (PR #13) found the original fix still allowed silent truncation: limit=None fell back to a default ceiling with no error, the same failure mode ADR 001 SS3.3 already rejects for descriptors that exceed the plafond (cancel entirely, never return a partial/truncated result — a truncated read looks complete, e.g. a grants/revocation enumeration cut at the ceiling reads as 'this right doesn't exist'). limit is now a required field (was: optional with a silent default). DEFAULT_MAX_LIMIT renamed MAX_LIMIT since there is no default anymore. Every caller must state its own bound at v0.2.3 bump time ; mypy strict + pydantic make the omission a compile-time/validation error, not a runtime surprise. compiler.py also re-checks the bound directly (model_construct() bypasses field validators, so this is the last real gate before SQL is emitted). Removed 3 agent-protocol staging files (.checkpoint-comment.md, .pr-body.md, .report.md) that leaked into this public repo's history via an earlier auto-checkpoint — one of them named the 6 affected production services in a vulnerability write-up before any were patched. Added matching .gitignore entries to prevent recurrence. Refs #6 Agent-Role: forge Agent-Thread: queryme-v023-limit-bound Work-Unit: QUERYME-V023-LIMIT-BOUND Issue: 6 --- .checkpoint-comment.md | 6 ----- .gitignore | 7 ++++++ .pr-body.md | 47 --------------------------------------- .report.md | 31 -------------------------- src/queryme/compiler.py | 23 ++++++++++++------- src/queryme/descriptor.py | 19 +++++++++------- src/queryme/validator.py | 1 + tests/test_compiler.py | 41 +++++++++++++++++++++++++--------- tests/test_descriptor.py | 25 +++++++++++++-------- tests/test_validator.py | 12 ++++++++-- 10 files changed, 90 insertions(+), 122 deletions(-) delete mode 100644 .checkpoint-comment.md delete mode 100644 .pr-body.md delete mode 100644 .report.md diff --git a/.checkpoint-comment.md b/.checkpoint-comment.md deleted file mode 100644 index cb64fce..0000000 --- a/.checkpoint-comment.md +++ /dev/null @@ -1,6 +0,0 @@ -AGENT_CHECKPOINT - -Role: forge · Work-Unit: QUERYME-V023-LIMIT-BOUND -Branch forge/queryme-v0.2.3-limit-bound pushed, commit 92e025d. -lint/typecheck/tests verts localement (ruff, mypy strict, pytest 45/45). -Ouverture PR contre main. 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/.pr-body.md b/.pr-body.md deleted file mode 100644 index 897f734..0000000 --- a/.pr-body.md +++ /dev/null @@ -1,47 +0,0 @@ -## Summary - -`limit: int | None` on `QueryDescriptor` only had a lower bound (`ge=0`). When -`limit` was `None`, `compile_query()` emitted no `LIMIT` clause at all — -a valid descriptor produced `SELECT ... FROM
` with no bound against a -consuming service's production database. All 6 current consumers (Blue, -ZabTruth, ZabRanking, ZabAuth, ZabCam, ZabCanvas) go through this nominal -path, so this was mass-exfiltration/DoS-by-default, not an edge case. - -Fix, on `v0.2.3` (`v0.2.2` stays untouched — RC-40 of ADR 001 is frozen): - -- `descriptor.py`: new `DEFAULT_MAX_LIMIT = 1000` constant. `limit` gains - `le=DEFAULT_MAX_LIMIT` — a descriptor asking for more is **rejected** - (`ValidationError`), not silently clamped, matching the fail-closed style - already used by `validate_against_schema()`. -- `compiler.py`: `LIMIT` is now always emitted. Explicit `limit` is used as-is - (already bounded by the validator); an omitted `limit` falls back to - `DEFAULT_MAX_LIMIT` instead of producing no clause. - -1000 is a starting ceiling, not derived from an existing QueryMe convention — -none exists yet in this library. It's above ZabCanvas's own -`MAX_SCENE_PAGE_SIZE` (200, clamped) since QueryMe serves generic -cross-table queries, not just paginated UI listings; open to Bastion/Eleven -adjusting it before v0.2.3 ships. - -## Tests - -Ran inside the branch worktree (`uv sync && uv run ruff check . && uv run -mypy src && uv run pytest`): - -- `ruff check .` → All checks passed! -- `mypy src` (strict) → Success: no issues found in 5 source files -- `pytest` → 45 passed (2 pre-existing tests updated: `LIMIT` is now always - present in the compiled SQL; 3 new tests added — default-max applied when - `limit` is unset, explicit `limit` above the ceiling rejected, `limit` - exactly at the ceiling accepted) - -## Écarts / hypothèses - -- Ceiling value (1000) is a judgment call, not a documented convention — - flagged above, Bastion clearance requested on this choice per the bail. -- No migration of the 6 consumer services — out of scope per the bail - (separate work once v0.2.3 is published). -- `v0.2.3` tag not created yet — left for Keeper/Vigil once this PR merges, - per the bail (`v0.2.2` must never move). - -Refs #6 diff --git a/.report.md b/.report.md deleted file mode 100644 index 75efb51..0000000 --- a/.report.md +++ /dev/null @@ -1,31 +0,0 @@ -AGENT_REPORT - -Role: forge · Thread: queryme-v023-limit-bound · Work-Unit: QUERYME-V023-LIMIT-BOUND -Résultat: READY -PR: https://github.com/ZabLaboratory/QueryMe/pull/13 (forge/queryme-v0.2.3-limit-bound → main) -Commit: 92e025d03cb5b7ede2f4d5463972ee2d562e36e8 (signé SSH, trailers Agent-Role/Agent-Thread/Work-Unit/Issue) - -Fix: DEFAULT_MAX_LIMIT=1000 dans descriptor.py (le= sur limit, rejet explicite au-dessus, -pas de clamp silencieux) ; compiler.py émet toujours LIMIT, applique DEFAULT_MAX_LIMIT -quand limit est None. version bump 0.2.2 → 0.2.3 (pyproject.toml). v0.2.2 non touché. - -Critères → preuves: -- descriptor+compiler patchés → src/queryme/descriptor.py, compiler.py (diff PR #13) -- tests limit=None borné + limit>max rejeté + limit=max accepté → tests/test_descriptor.py, - test_compiler.py (2 tests existants mis à jour, 3 nouveaux) -- CI verte → gh pr checks 13 : 7/7 pass (lint, typecheck, tests, pip-audit, trufflehog, - lockfile-check, CODEOWNERS) -- pas de PR de migration consommateurs → hors scope, non fait -- pas de tag v0.2.3 → laissé à Keeper/Vigil post-merge (bail) - -Risques: aucun régressé — 45/45 tests pytest passent, aucune modif hors descriptor.py/ -compiler.py/pyproject.toml/tests. - -Écarts/hypothèses posées (non tranchées): -- Valeur de la borne = 1000, jugement Forge (pas de convention QueryMe existante ; - ZabCanvas clampe à 200 mais pour un usage UI paginé différent) — clearance Bastion - demandée dans le corps de la PR. -- Sémantique = rejet explicite (ValidationError) au-dessus du plafond, pas de clamp - silencieux — choisi comme le plus sûr, à confirmer par Bastion. - -Statut: READY — en attente clearance Bastion (borne) puis décision Eleven. Pas mergé. diff --git a/src/queryme/compiler.py b/src/queryme/compiler.py index 2038beb..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 DEFAULT_MAX_LIMIT, 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,13 +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 — always emitted. An explicit descriptor.limit is already - # bounded by DEFAULT_MAX_LIMIT (descriptor validation) ; an omitted - # one falls back to the same ceiling so a query can never run - # unbounded against a service's database. - stmt = stmt.limit( - descriptor.limit if descriptor.limit is not None else DEFAULT_MAX_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 00c8c04..ccefa3e 100644 --- a/src/queryme/descriptor.py +++ b/src/queryme/descriptor.py @@ -18,12 +18,14 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator -#: Hard ceiling on rows a single query can return. Applied both as the -#: upper bound on an explicit ``limit`` (rejected past this, not -#: clamped — a descriptor that asks for more than the ceiling is wrong, -#: not merely generous) and as the value the compiler substitutes when -#: ``limit`` is omitted, so ``None`` can never mean "no LIMIT clause". -DEFAULT_MAX_LIMIT = 1000 +#: 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 @@ -166,8 +168,9 @@ class QueryDescriptor(BaseModel): # ORDER BY (stacked) order: list[OrderClause] = Field(default_factory=list) - # LIMIT / OFFSET. ``None`` limit = compiler applies DEFAULT_MAX_LIMIT. - limit: int | None = Field(default=None, ge=0, le=DEFAULT_MAX_LIMIT) + # 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 486a02d..f236fd9 100644 --- a/tests/test_compiler.py +++ b/tests/test_compiler.py @@ -15,7 +15,7 @@ from queryme.compiler import CompilationError, compile_query from queryme.descriptor import ( - DEFAULT_MAX_LIMIT, + MAX_LIMIT, JoinClause, OrderClause, QueryDescriptor, @@ -87,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 @@ -94,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" in sql + assert "LIMIT 50" in sql def test_compile_where_equality_with_value() -> None: @@ -103,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 @@ -125,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 @@ -136,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 @@ -147,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 @@ -160,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 @@ -174,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 @@ -194,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 @@ -226,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 @@ -241,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 @@ -255,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 @@ -273,14 +284,6 @@ def test_compile_limit_and_offset() -> None: assert "OFFSET 20" in sql -def test_compile_applies_default_max_limit_when_unset() -> None: - schema = _truth_schema() - descriptor = QueryDescriptor(table="matches", select=["id"]) - sql = _sql(compile_query(descriptor, schema)) - assert f"LIMIT {DEFAULT_MAX_LIMIT}" in sql - assert "OFFSET" not in sql - - # ── Failure path ─────────────────────────────────────────────────────────── @@ -289,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) @@ -300,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.""" @@ -320,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 fbd6b65..e067cf3 100644 --- a/tests/test_descriptor.py +++ b/tests/test_descriptor.py @@ -6,7 +6,7 @@ from pydantic import ValidationError from queryme.descriptor import ( - DEFAULT_MAX_LIMIT, + MAX_LIMIT, JoinClause, OrderClause, QueryDescriptor, @@ -18,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() @@ -85,6 +86,7 @@ def test_query_descriptor_rejects_duplicate_join() -> None: JoinClause(table="players", on=("player_id", "id")), ], select=["champion"], + limit=50, ) @@ -93,20 +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_default_max_rejected() -> None: +def test_limit_above_max_rejected() -> None: with pytest.raises(ValidationError): - QueryDescriptor(table="matches", select=["id"], limit=DEFAULT_MAX_LIMIT + 1) + QueryDescriptor(table="matches", select=["id"], limit=MAX_LIMIT + 1) -def test_limit_at_default_max_accepted() -> None: - descriptor = QueryDescriptor(table="matches", select=["id"], limit=DEFAULT_MAX_LIMIT) - assert descriptor.limit == DEFAULT_MAX_LIMIT +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)