palimpsest_sql/limits.rs
1// Copyright 2026 Thousand Birds Inc.
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Resource bounds applied to inbound SQL.
5//!
6//! v1 enforces two limits, both at parse/lower time:
7//!
8//! * `max_input_bytes` — how big the SQL string can be. Stops a runaway
9//! client from forcing the parser to chew through megabytes of text.
10//! * `max_mir_nodes` — how big the lowered MIR can be. Stops cleverly
11//! short queries (deep set-op chains, big CTE webs) from expanding
12//! into a graph the planner has to walk N² over.
13//!
14//! Both are advisory: callers explicitly invoke
15//! [`enforce_input_size`] / [`enforce_graph_size`] (or use the
16//! `*_with_limits` helpers in [`lower`](crate::lower)). The default
17//! limits are deliberately generous enough for the conformance suite to
18//! pass unmodified.
19
20use crate::SqlError;
21
22/// Resource bounds applied to inbound SQL, surfaced to the gRPC layer
23/// so it can refuse oversized queries before parsing.
24#[derive(Debug, Clone, Copy)]
25pub struct QueryLimits {
26 /// Maximum byte length of the SQL input.
27 pub max_input_bytes: usize,
28 /// Maximum node count in the lowered MIR.
29 pub max_mir_nodes: usize,
30}
31
32impl QueryLimits {
33 /// Default budget: 64 KiB of SQL, 256 MIR nodes. Set generously
34 /// enough that real-world dashboards do not bump into them.
35 pub const DEFAULT: Self = Self {
36 max_input_bytes: 64 * 1024,
37 max_mir_nodes: 256,
38 };
39}
40
41impl Default for QueryLimits {
42 fn default() -> Self {
43 Self::DEFAULT
44 }
45}
46
47/// Returns [`SqlError::QueryTooLarge`] when `sql.len()` exceeds
48/// `limits.max_input_bytes`.
49///
50/// # Errors
51/// As above.
52pub const fn enforce_input_size(sql: &str, limits: QueryLimits) -> Result<(), SqlError> {
53 let len = sql.len();
54 if len > limits.max_input_bytes {
55 Err(SqlError::QueryTooLarge {
56 len,
57 limit: limits.max_input_bytes,
58 })
59 } else {
60 Ok(())
61 }
62}
63
64/// Returns [`SqlError::QueryTooComplex`] when `nodes` exceeds
65/// `limits.max_mir_nodes`.
66///
67/// # Errors
68/// As above.
69pub const fn enforce_graph_size(nodes: usize, limits: QueryLimits) -> Result<(), SqlError> {
70 if nodes > limits.max_mir_nodes {
71 Err(SqlError::QueryTooComplex {
72 nodes,
73 limit: limits.max_mir_nodes,
74 })
75 } else {
76 Ok(())
77 }
78}
79
80#[cfg(test)]
81mod tests {
82 use super::{enforce_graph_size, enforce_input_size, QueryLimits};
83 use crate::SqlError;
84
85 #[test]
86 fn input_at_limit_passes() {
87 let limits = QueryLimits {
88 max_input_bytes: 4,
89 max_mir_nodes: 8,
90 };
91 assert!(enforce_input_size("abcd", limits).is_ok());
92 }
93
94 #[test]
95 fn input_above_limit_rejects() {
96 let limits = QueryLimits {
97 max_input_bytes: 3,
98 max_mir_nodes: 8,
99 };
100 match enforce_input_size("abcd", limits) {
101 Err(SqlError::QueryTooLarge { len: 4, limit: 3 }) => {}
102 other => panic!("expected QueryTooLarge, got {other:?}"),
103 }
104 }
105
106 #[test]
107 fn graph_above_limit_rejects() {
108 let limits = QueryLimits {
109 max_input_bytes: 1024,
110 max_mir_nodes: 5,
111 };
112 match enforce_graph_size(10, limits) {
113 Err(SqlError::QueryTooComplex {
114 nodes: 10,
115 limit: 5,
116 }) => {}
117 other => panic!("expected QueryTooComplex, got {other:?}"),
118 }
119 }
120}