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)