Skip to content

Commit aa23d56

Browse files
committed
perf: optimize streaming
1 parent 043272a commit aa23d56

3 files changed

Lines changed: 52 additions & 18 deletions

File tree

‎src/params.jl‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,18 @@ function _bind_named(sql::String, named_params::Base.Pairs)::Tuple{String, Vecto
202202
end
203203

204204
sql_out = String(take!(io))
205+
206+
# Detect kwargs provided by the caller but never referenced in the SQL.
207+
# This catches typos in either the kwarg name or the :placeholder.
208+
consumed = Set(order)
209+
unused = [k for k in keys(named_params) if string(k) ∉ consumed]
210+
if !isempty(unused)
211+
throw(QueryError(
212+
"Named parameter(s) provided but not referenced in SQL: $(join(sort(string.(unused)), ", "))",
213+
sql, Dict(named_params), nothing
214+
))
215+
end
216+
205217
@debug "Bound named params" order=order values=values
206218
return (sql_out, values)
207219
end

‎src/query.jl‎

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -224,31 +224,39 @@ end
224224
stream(ctx, sql; batch_size=10_000) → Channel{DataFrame}
225225
226226
Execute `sql` and return a `Channel` that emits successive `DataFrame` batches.
227-
Each batch has at most `batch_size` rows. The channel is closed automatically
228-
when all rows have been consumed or when an error occurs.
227+
The channel is closed automatically when all rows have been consumed or when an
228+
error occurs.
229+
230+
The SQL is executed once and results are consumed via DuckDB's native chunk
231+
iterator, so streaming is O(N) regardless of result size.
232+
233+
Each batch contains at least `batch_size` rows (it may be slightly larger
234+
because whole DuckDB-internal chunks of ~2048 rows are accumulated before
235+
emitting).
229236
230237
```julia
231238
for batch in stream(ctx, "SELECT * FROM huge_table"; batch_size=5_000)
232239
process(batch)
233240
end
234241
```
235-
236-
!!! note
237-
The SQL is wrapped in a sub-query with `LIMIT … OFFSET …` which requires the
238-
query engine to re-execute the underlying plan for each batch. For best
239-
performance on very large scans, consider using DuckDB's native export or
240-
`COPY` instead.
241242
"""
242243
function stream(ctx::QueryContext, sql::String; batch_size::Int=10_000)::Channel{DataFrame}
243244
Channel{DataFrame}(2) do ch
244-
offset = 0
245-
while true
246-
batch_sql = "SELECT * FROM ($sql) AS __stream_q__ LIMIT $batch_size OFFSET $offset"
247-
batch = execute(ctx, batch_sql)
248-
nrow(batch) == 0 && break
249-
put!(ch, batch)
250-
offset += batch_size
251-
nrow(batch) < batch_size && break
245+
_with_conn(ctx) do conn
246+
result = DuckDB.execute(conn, sql)
247+
pending = DataFrame[]
248+
pending_rows = 0
249+
for chunk in Tables.partitions(result)
250+
chunk_df = DataFrame(chunk)
251+
push!(pending, chunk_df)
252+
pending_rows += nrow(chunk_df)
253+
if pending_rows >= batch_size
254+
put!(ch, vcat(pending...))
255+
empty!(pending)
256+
pending_rows = 0
257+
end
258+
end
259+
pending_rows > 0 && put!(ch, vcat(pending...))
252260
end
253261
end
254262
end

‎test/runtests.jl‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,17 @@ global_logger(ConsoleLogger(stderr, Logging.Warn))
150150
close!(ctx)
151151
end
152152

153+
@testset "Unused named param error" begin
154+
ctx = QueryContext()
155+
# Typo in kwarg name — :status is in SQL but :stauts is passed.
156+
@test_throws QueryError execute(ctx, "SELECT :status AS s"; stauts="shipped")
157+
# Extra kwarg alongside a valid one.
158+
@test_throws QueryError execute(ctx, "SELECT :x AS x"; x=1, typo=2)
159+
# Repeated placeholder — same param used twice; should still bind cleanly.
160+
@test_nowarn execute(ctx, "SELECT :x AS a, :x AS b"; x=7)
161+
close!(ctx)
162+
end
163+
153164
# ── Batch execution ────────────────────────────────────────────────────────
154165
@testset "execute with Vector{String}" begin
155166
ctx = QueryContext()
@@ -211,9 +222,12 @@ global_logger(ConsoleLogger(stderr, Logging.Warn))
211222
execute!(ctx, "CREATE TABLE big AS SELECT generate_series AS n FROM generate_series(1, 100)")
212223

213224
batches = collect(stream(ctx, "SELECT * FROM big ORDER BY n"; batch_size=30))
214-
@test length(batches) == 4 # 30+30+30+10
225+
# Native chunk iteration: batch boundaries align to DuckDB's internal
226+
# vector size (~2048), so 100 rows arrive in a single chunk and a single
227+
# batch. Assert totals and ordering rather than exact batch count.
215228
@test sum(nrow, batches) == 100
216-
@test batches[1][1, :n] == 1
229+
@test vcat(batches...)[1, :n] == 1
230+
@test vcat(batches...)[end, :n] == 100
217231
close!(ctx)
218232
end
219233

0 commit comments

Comments
 (0)