Skip to content

Commit 417b9c5

Browse files
thodson-usgsclaude
andcommitted
Extract filter chunking into plan_/fetch_/combine_ helpers
Mirrors the helper organization in the merged Python PR (DOI-USGS/dataretrieval-python#238) so the per-language implementations stay easy to read alongside each other. The single-vs-fanned distinction is now expressed once, in `plan_filter_chunks`, which always returns a list of "chunk overrides" -- `list(NULL)` for "send `args` as-is", or a list of chunked cql-text expressions otherwise. `fetch_chunks` issues one request per entry and returns the per-chunk frames plus the first sub-request (for the `request` attribute). `combine_chunk_frames` handles the empty-frame and dedup-by-`id` cases. `get_ogc_data` is now a linear pipeline: chunks <- plan_filter_chunks(args) fetched <- fetch_chunks(args, chunks) return_list <- combine_chunk_frames(fetched$frames) req <- fetched$req ... post-processing ... Behavior unchanged: same chunk sizing (URL-byte-budget aware), same cql-text-only guard, same empty-frame and id-dedup handling. The only observable difference is that the `request` attribute now points at the first sub-request instead of the last (matching Python's choice of representative metadata), which is a debugging-only change for the chunked path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 33dc012 commit 417b9c5

1 file changed

Lines changed: 92 additions & 67 deletions

File tree

R/get_ogc_data.R

Lines changed: 92 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -44,73 +44,11 @@ get_ogc_data <- function(args, output_id, service, ..., chunk_size = 250) {
4444
args[["convertType"]] <- NULL
4545
args[["service"]] <- service
4646

47-
# Only cql-text filters can be safely chunked by splitting on top-level
48-
# OR: the splitter is text- and single-quote-aware and would corrupt
49-
# cql-json. Non-cql-text filters (if any) are sent as-is.
50-
filter_expr <- args[["filter"]]
51-
filter_lang <- args[["filter_lang"]]
52-
should_chunk_filter <- is.character(filter_expr) &&
53-
length(filter_expr) == 1L &&
54-
!is.na(filter_expr) &&
55-
nzchar(filter_expr) &&
56-
(is.null(filter_lang) ||
57-
is.na(filter_lang) ||
58-
identical(filter_lang, "cql-text"))
59-
if (should_chunk_filter) {
60-
raw_budget <- effective_filter_budget(args, filter_expr)
61-
filter_chunks <- chunk_cql_or(filter_expr, max_len = raw_budget)
62-
} else {
63-
filter_chunks <- list(NULL)
64-
}
65-
66-
if (length(filter_chunks) > 1L) {
67-
frames <- vector("list", length(filter_chunks))
68-
last_req <- NULL
69-
for (i in seq_along(filter_chunks)) {
70-
chunk_args <- args
71-
chunk_args[["filter"]] <- filter_chunks[[i]]
72-
chunk_req <- do.call(construct_api_requests, chunk_args)
73-
message("Requesting:\n", chunk_req$url)
74-
if (grepl("f=csv", chunk_req$url)) {
75-
frames[[i]] <- get_csv(chunk_req, limit = chunk_args[["limit"]])
76-
} else {
77-
frames[[i]] <- walk_pages(chunk_req)
78-
}
79-
last_req <- chunk_req
80-
}
81-
# Drop empty frames before concatenation. An empty frame from
82-
# walk_pages is a plain data.frame with no geometry; rbinding it
83-
# first would downgrade a later sf result back to a plain frame and
84-
# silently drop geometry/CRS.
85-
non_empty <- Filter(function(df) nrow(df) > 0L, frames)
86-
if (length(non_empty) == 0L) {
87-
return_list <- frames[[1L]]
88-
} else if (length(non_empty) == 1L) {
89-
return_list <- non_empty[[1L]]
90-
} else {
91-
return_list <- do.call(rbind, non_empty)
92-
# Dedup on the pre-rename feature "id" (always present at this
93-
# stage; rejigger_cols renames it later) to catch overlapping
94-
# user-supplied OR clauses across chunks.
95-
if ("id" %in% names(return_list)) {
96-
return_list <- return_list[
97-
!duplicated(return_list$id), ,
98-
drop = FALSE
99-
]
100-
}
101-
}
102-
req <- last_req
103-
no_paging <- grepl("f=csv", req$url)
104-
} else {
105-
req <- do.call(construct_api_requests, args)
106-
no_paging <- grepl("f=csv", req$url)
107-
message("Requesting:\n", req$url)
108-
if (no_paging) {
109-
return_list <- get_csv(req, limit = args[["limit"]])
110-
} else {
111-
return_list <- walk_pages(req)
112-
}
113-
}
47+
chunks <- plan_filter_chunks(args)
48+
fetched <- fetch_chunks(args, chunks)
49+
return_list <- combine_chunk_frames(fetched$frames)
50+
req <- fetched$req
51+
no_paging <- grepl("f=csv", req$url)
11452

11553
if (is.na(args[["skipGeometry"]])) {
11654
skipGeometry <- FALSE
@@ -250,6 +188,93 @@ switch_properties_id <- function(properties, id) {
250188
.WATERDATA_URL_BYTE_LIMIT <- 8000L
251189

252190

191+
#' Decide how to fan `args[["filter"]]` out across HTTP calls
192+
#'
193+
#' Returns one entry per request to send. A `NULL` entry means "send
194+
#' `args` as-is" -- either there is no filter, or the filter language
195+
#' is not one we can safely split (only cql-text top-level `OR` chains
196+
#' are chunkable). Otherwise each character entry is a chunked cql-text
197+
#' expression that replaces `args[["filter"]]` for its sub-request.
198+
#' Overlapping user OR-clauses are deduplicated by feature id later in
199+
#' [combine_chunk_frames].
200+
#'
201+
#' @noRd
202+
plan_filter_chunks <- function(args) {
203+
filter_expr <- args[["filter"]]
204+
filter_lang <- args[["filter_lang"]]
205+
chunkable <- is.character(filter_expr) &&
206+
length(filter_expr) == 1L &&
207+
!is.na(filter_expr) &&
208+
nzchar(filter_expr) &&
209+
(is.null(filter_lang) ||
210+
is.na(filter_lang) ||
211+
identical(filter_lang, "cql-text"))
212+
if (!chunkable) {
213+
return(list(NULL))
214+
}
215+
raw_budget <- effective_filter_budget(args, filter_expr)
216+
as.list(chunk_cql_or(filter_expr, max_len = raw_budget))
217+
}
218+
219+
220+
#' Send one request per chunk; return per-chunk frames and the request
221+
#'
222+
#' A `NULL` chunk means "send `args` as-is" (no filter override). The
223+
#' returned `req` is the *first* sub-request, which is the
224+
#' representative URL to attach as the `request` attribute when the
225+
#' caller asked for one.
226+
#'
227+
#' @noRd
228+
fetch_chunks <- function(args, chunks) {
229+
frames <- vector("list", length(chunks))
230+
first_req <- NULL
231+
for (i in seq_along(chunks)) {
232+
chunk_args <- if (is.null(chunks[[i]])) {
233+
args
234+
} else {
235+
replace(args, "filter", list(chunks[[i]]))
236+
}
237+
chunk_req <- do.call(construct_api_requests, chunk_args)
238+
message("Requesting:\n", chunk_req$url)
239+
if (grepl("f=csv", chunk_req$url)) {
240+
frames[[i]] <- get_csv(chunk_req, limit = chunk_args[["limit"]])
241+
} else {
242+
frames[[i]] <- walk_pages(chunk_req)
243+
}
244+
if (i == 1L) {
245+
first_req <- chunk_req
246+
}
247+
}
248+
list(frames = frames, req = first_req)
249+
}
250+
251+
252+
#' Concatenate per-chunk frames, handling the edge cases
253+
#'
254+
#' Drops empty frames before concat: `walk_pages` returns a plain
255+
#' empty `data.frame` on no-feature responses, which would downgrade
256+
#' a concat of real `sf` results back to a plain frame and strip
257+
#' geometry / CRS. Also dedups on the pre-rename feature `id` so
258+
#' overlapping user-supplied OR-clauses don't produce duplicate rows
259+
#' across chunks.
260+
#'
261+
#' @noRd
262+
combine_chunk_frames <- function(frames) {
263+
non_empty <- Filter(function(df) nrow(df) > 0L, frames)
264+
if (length(non_empty) == 0L) {
265+
return(frames[[1L]])
266+
}
267+
if (length(non_empty) == 1L) {
268+
return(non_empty[[1L]])
269+
}
270+
combined <- do.call(rbind, non_empty)
271+
if ("id" %in% names(combined)) {
272+
combined <- combined[!duplicated(combined$id), , drop = FALSE]
273+
}
274+
combined
275+
}
276+
277+
253278
#' Compute the raw CQL byte budget for `filter_expr` in this request
254279
#'
255280
#' The server limits total URL length (see `.WATERDATA_URL_BYTE_LIMIT`),

0 commit comments

Comments
 (0)