Skip to content

Commit e5bc861

Browse files
committed
Add configurable MAX_ROWS to cap fetched data per table scan
Replace MAX_PAGES with MAX_ROWS (default 1000) as the pagination cap. When no SQL LIMIT is specified, each table scan fetches up to max_rows before stopping. The current page is always completed, so actual row count may slightly exceed the cap. SQL LIMIT is still respected when DataFusion pushes it down (simple queries). For JOINs where LIMIT can't be pushed, max_rows ensures both sides fetch enough data for meaningful results. Configurable via: --max-rows 5000 (CLI flag, one-off override) SQLIZE_MAX_ROWS=5000 (env var, persistent override) Default: 1000
1 parent 908485c commit e5bc861

5 files changed

Lines changed: 62 additions & 43 deletions

File tree

crates/sqlize-core/src/datafusion/exec.rs

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,6 @@ use crate::exec::pagination;
2020

2121
use super::arrow_convert::json_response_to_batch;
2222

23-
/// Maximum number of HTTP requests (pages) per table scan.
24-
/// At ~30 rows per page (GitHub default), this yields ~1500 rows.
25-
const MAX_PAGES: usize = 50;
26-
2723
/// A custom DataFusion `ExecutionPlan` that fetches data from a REST API.
2824
/// Returns a lazy stream that fetches one page per `poll_next()`.
2925
#[derive(Debug)]
@@ -33,7 +29,7 @@ pub struct ApiTableExec {
3329
projected_schema: SchemaRef,
3430
params: HashMap<String, String>,
3531
projection: Option<Vec<usize>>,
36-
limit: Option<usize>,
32+
max_rows: usize,
3733
auth: AuthConfig,
3834
client: reqwest::Client,
3935
properties: PlanProperties,
@@ -45,7 +41,7 @@ impl ApiTableExec {
4541
full_schema: SchemaRef,
4642
params: HashMap<String, String>,
4743
projection: Option<Vec<usize>>,
48-
limit: Option<usize>,
44+
max_rows: usize,
4945
auth: AuthConfig,
5046
client: reqwest::Client,
5147
) -> Self {
@@ -73,7 +69,7 @@ impl ApiTableExec {
7369
projected_schema,
7470
params,
7571
projection,
76-
limit,
72+
max_rows,
7773
auth,
7874
client,
7975
properties,
@@ -129,7 +125,7 @@ impl ExecutionPlan for ApiTableExec {
129125
let projected_schema = self.projected_schema.clone();
130126
let params = self.params.clone();
131127
let projection = self.projection.clone();
132-
let limit = self.limit;
128+
let max_rows = self.max_rows;
133129
let auth = self.auth.clone();
134130
let client = self.client.clone();
135131

@@ -150,8 +146,7 @@ impl ExecutionPlan for ApiTableExec {
150146
next_url: Option<String>,
151147
is_first_page: bool,
152148
total_rows: usize,
153-
pages_fetched: usize,
154-
row_limit: Option<usize>,
149+
max_rows: usize,
155150
table: VirtualTable,
156151
full_schema: SchemaRef,
157152
params: HashMap<String, String>,
@@ -165,8 +160,7 @@ impl ExecutionPlan for ApiTableExec {
165160
next_url: Some(first_url),
166161
is_first_page: true,
167162
total_rows: 0,
168-
pages_fetched: 0,
169-
row_limit: limit,
163+
max_rows,
170164
table,
171165
full_schema,
172166
params,
@@ -179,21 +173,13 @@ impl ExecutionPlan for ApiTableExec {
179173
let stream = futures::stream::unfold(initial_state, |mut state| async move {
180174
let url = state.next_url.take()?;
181175

182-
if state.pages_fetched >= MAX_PAGES {
183-
tracing::warn!(
184-
table = %state.table.name,
185-
pages = state.pages_fetched,
186-
"reached MAX_PAGES limit, stopping pagination"
187-
);
176+
// Stop if we've already fetched enough rows.
177+
// We check >= rather than > so that the current page that pushed us
178+
// over the limit is still returned (the caller's LIMIT will trim).
179+
if state.total_rows >= state.max_rows {
188180
return None;
189181
}
190182

191-
if let Some(limit) = state.row_limit {
192-
if state.total_rows >= limit {
193-
return None;
194-
}
195-
}
196-
197183
let (body, headers) = match fetch_page(
198184
&state.client,
199185
&state.auth,
@@ -230,7 +216,6 @@ impl ExecutionPlan for ApiTableExec {
230216
};
231217

232218
state.total_rows += batch.num_rows();
233-
state.pages_fetched += 1;
234219

235220
// Determine next page
236221
let ctx = pagination::PageContext {

crates/sqlize-core/src/datafusion/mod.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,40 +15,44 @@ use crate::exec::AuthConfig;
1515
use self::arrow_convert::batches_to_result_set;
1616
use self::schema::ApiSchemaProvider;
1717

18+
/// Default maximum rows fetched per table scan when no SQL LIMIT is specified.
19+
/// Overridable via `SQLIZE_MAX_ROWS` env var or `--max-rows` CLI flag.
20+
pub const DEFAULT_MAX_ROWS: usize = 1000;
21+
1822
/// The main entry point for executing SQL against REST APIs via DataFusion.
1923
///
2024
/// Wraps a DataFusion `SessionContext` with registered API table providers.
2125
/// Supports multiple specs (schemas) for federated queries.
2226
pub struct SqlizeContext {
2327
ctx: SessionContext,
28+
max_rows: usize,
2429
}
2530

2631
impl Default for SqlizeContext {
2732
fn default() -> Self {
28-
Self::new()
33+
Self::new(DEFAULT_MAX_ROWS)
2934
}
3035
}
3136

3237
impl SqlizeContext {
33-
pub fn new() -> Self {
38+
pub fn new(max_rows: usize) -> Self {
3439
let config = SessionConfig::new()
3540
.with_information_schema(false)
3641
.with_default_catalog_and_schema("sqlize", "default");
3742
let ctx = SessionContext::new_with_config(config);
38-
Self { ctx }
43+
Self { ctx, max_rows }
3944
}
4045

4146
/// Register a spec's tables under the given schema name.
42-
/// If `is_default` is true, sets this schema as the default so bare table
43-
/// names resolve to it.
4447
pub fn register_spec(
4548
&self,
4649
schema_name: &str,
4750
catalog: &Catalog,
4851
auth: AuthConfig,
4952
client: reqwest::Client,
5053
) -> Result<()> {
51-
let schema_provider = Arc::new(ApiSchemaProvider::new(catalog, auth, client));
54+
let schema_provider =
55+
Arc::new(ApiSchemaProvider::new(catalog, auth, client, self.max_rows));
5256

5357
let df_catalog = self
5458
.ctx

crates/sqlize-core/src/datafusion/provider.rs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pub struct ApiTableProvider {
2424
schema: SchemaRef,
2525
auth: AuthConfig,
2626
client: reqwest::Client,
27+
max_rows: usize,
2728
}
2829

2930
impl fmt::Debug for ApiTableProvider {
@@ -35,13 +36,19 @@ impl fmt::Debug for ApiTableProvider {
3536
}
3637

3738
impl ApiTableProvider {
38-
pub fn new(table: VirtualTable, auth: AuthConfig, client: reqwest::Client) -> Self {
39+
pub fn new(
40+
table: VirtualTable,
41+
auth: AuthConfig,
42+
client: reqwest::Client,
43+
max_rows: usize,
44+
) -> Self {
3945
let schema = virtual_table_to_schema(&table);
4046
Self {
4147
table,
4248
schema,
4349
auth,
4450
client,
51+
max_rows,
4552
}
4653
}
4754
}
@@ -113,12 +120,15 @@ impl TableProvider for ApiTableProvider {
113120
}
114121
}
115122

123+
// Use DataFusion's pushed-down limit if available, otherwise use max_rows.
124+
let effective_limit = limit.unwrap_or(self.max_rows);
125+
116126
let exec = ApiTableExec::new(
117127
self.table.clone(),
118128
self.schema.clone(),
119129
params,
120130
projection.cloned(),
121-
limit,
131+
effective_limit,
122132
self.auth.clone(),
123133
self.client.clone(),
124134
);
@@ -136,11 +146,7 @@ fn classify_filter(table: &VirtualTable, expr: &Expr) -> TableProviderFilterPush
136146
ColumnRole::PathParam | ColumnRole::QueryParam => {
137147
TableProviderFilterPushDown::Exact
138148
}
139-
ColumnRole::QueryParamAndResponse => {
140-
// DataFusion should also evaluate this locally since the
141-
// API may return items that don't match (e.g., paginated results)
142-
TableProviderFilterPushDown::Inexact
143-
}
149+
ColumnRole::QueryParamAndResponse => TableProviderFilterPushDown::Inexact,
144150
_ => TableProviderFilterPushDown::Unsupported,
145151
};
146152
}

crates/sqlize-core/src/datafusion/schema.rs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,16 @@ pub struct ApiSchemaProvider {
1818
tables: HashMap<String, VirtualTable>,
1919
auth: AuthConfig,
2020
client: reqwest::Client,
21+
max_rows: usize,
2122
}
2223

2324
impl ApiSchemaProvider {
24-
pub fn new(catalog: &Catalog, auth: AuthConfig, client: reqwest::Client) -> Self {
25+
pub fn new(
26+
catalog: &Catalog,
27+
auth: AuthConfig,
28+
client: reqwest::Client,
29+
max_rows: usize,
30+
) -> Self {
2531
let tables: HashMap<String, VirtualTable> = catalog
2632
.tables()
2733
.map(|t| (t.name.as_str().to_owned(), t.clone()))
@@ -30,6 +36,7 @@ impl ApiSchemaProvider {
3036
tables,
3137
auth,
3238
client,
39+
max_rows,
3340
}
3441
}
3542
}
@@ -52,8 +59,12 @@ impl SchemaProvider for ApiSchemaProvider {
5259
) -> datafusion::common::Result<Option<Arc<dyn TableProvider>>> {
5360
match self.tables.get(name) {
5461
Some(vt) => {
55-
let provider =
56-
ApiTableProvider::new(vt.clone(), self.auth.clone(), self.client.clone());
62+
let provider = ApiTableProvider::new(
63+
vt.clone(),
64+
self.auth.clone(),
65+
self.client.clone(),
66+
self.max_rows,
67+
);
5768
Ok(Some(Arc::new(provider)))
5869
}
5970
None => Ok(None),

crates/sqlize/src/main.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use clap::{Parser, Subcommand};
88

99
use rmcp::ServiceExt;
1010
use sqlize_core::catalog::Catalog;
11-
use sqlize_core::datafusion::SqlizeContext;
11+
use sqlize_core::datafusion::{DEFAULT_MAX_ROWS, SqlizeContext};
1212
use sqlize_core::exec::AuthConfig;
1313
use sqlize_core::spec::SpecInfo;
1414

@@ -29,6 +29,10 @@ struct Cli {
2929
/// Output format for query results
3030
#[arg(short, long, default_value = "table", global = true)]
3131
format: repl::OutputFormat,
32+
33+
/// Max rows fetched per table scan when no SQL LIMIT is specified
34+
#[arg(long, global = true)]
35+
max_rows: Option<usize>,
3236
}
3337

3438
#[derive(Subcommand)]
@@ -172,7 +176,16 @@ async fn main() -> anyhow::Result<()> {
172176
}
173177

174178
let client = sqlize_core::exec::Client::new();
175-
let sqlize_ctx = Arc::new(SqlizeContext::new());
179+
180+
// Resolve max_rows: CLI flag > env var > default
181+
let max_rows = cli.max_rows.unwrap_or_else(|| {
182+
std::env::var("SQLIZE_MAX_ROWS")
183+
.ok()
184+
.and_then(|s| s.parse().ok())
185+
.unwrap_or(DEFAULT_MAX_ROWS)
186+
});
187+
188+
let sqlize_ctx = Arc::new(SqlizeContext::new(max_rows));
176189
let is_single_spec = specs.len() == 1;
177190

178191
// Register each spec as a named schema

0 commit comments

Comments
 (0)