1use super::spec::{BoundProbe, CountBound, IntBound, PartitionSpec};
27use crate::auth_catalog::AuthCatalog;
28use crate::error::{CliError, CliResult};
29use serde_json::Value;
30
31pub async fn resolve_bounds(spec: &PartitionSpec, auth: &AuthCatalog) -> CliResult<PartitionSpec> {
34 Ok(match spec {
35 PartitionSpec::Integer {
36 from,
37 to,
38 chunk_size,
39 bounds,
40 to_unbounded,
41 } => match to {
42 IntBound::Literal(_) => spec.clone(),
43 IntBound::Discovered(p) => {
44 let raw = probe_value(p, auth).await?;
45 let v = as_i64(&raw, &p.value_path)?;
46 if v < *from {
47 return Err(CliError::Config(format!(
48 "partition: the discovered upper bound ({v}) is below `from` ({from}) — \
49 the probe `{}` returned an empty or unexpected result",
50 p.value_path
51 )));
52 }
53 PartitionSpec::Integer {
54 from: *from,
55 to: IntBound::Literal(v),
56 chunk_size: *chunk_size,
57 bounds: *bounds,
58 to_unbounded: Some(to_unbounded.unwrap_or(true)),
63 }
64 }
65 },
66 PartitionSpec::Offset { total, chunk_size } => match total {
67 CountBound::Literal(_) => spec.clone(),
68 CountBound::Discovered(p) => {
69 let raw = probe_value(p, auth).await?;
70 let v = as_u64(&raw, &p.value_path)?;
71 PartitionSpec::Offset {
72 total: CountBound::Literal(v),
73 chunk_size: *chunk_size,
74 }
75 }
76 },
77 PartitionSpec::Timestamp { .. } => spec.clone(),
78 })
79}
80
81pub fn needs_probe(spec: &PartitionSpec) -> bool {
84 matches!(
85 spec,
86 PartitionSpec::Integer {
87 to: IntBound::Discovered(_),
88 ..
89 } | PartitionSpec::Offset {
90 total: CountBound::Discovered(_),
91 ..
92 }
93 )
94}
95
96pub fn probe_implies_unbounded(spec: &PartitionSpec) -> bool {
98 matches!(
99 spec,
100 PartitionSpec::Integer {
101 to: IntBound::Discovered(_),
102 ..
103 }
104 )
105}
106
107async fn probe_value(p: &BoundProbe, auth: &AuthCatalog) -> CliResult<Value> {
109 let source = crate::registry::build_source(
110 &p.from_source.kind,
111 p.from_source.config.clone(),
112 auth,
113 None,
114 )
115 .await
116 .map_err(|e| {
117 CliError::Config(format!(
118 "partition bound probe: building source failed: {e}"
119 ))
120 })?;
121
122 let records = source.fetch_all().await.map_err(|e| {
123 CliError::Config(format!(
124 "partition bound probe: fetching the bound failed: {e}"
125 ))
126 })?;
127
128 let first = records.first().ok_or_else(|| {
131 CliError::Config(format!(
132 "partition bound probe: source '{}' returned no records, so the bound could not \
133 be determined. A `MAX(id)` over an empty table returns NULL rather than a row — \
134 give the range an explicit `to` if the source can legitimately be empty",
135 p.from_source.kind
136 ))
137 })?;
138
139 let found = faucet_core::util::extract_records(first, Some(&p.value_path)).map_err(|e| {
142 CliError::Config(format!(
143 "partition bound probe: value_path '{}' is not valid JSONPath: {e}",
144 p.value_path
145 ))
146 })?;
147 match found.as_slice() {
148 [] => Err(CliError::Config(format!(
149 "partition bound probe: value_path '{}' matched nothing in the probe's first \
150 record ({})",
151 p.value_path,
152 crate::secrets::registry::redact(&first.to_string())
153 ))),
154 [one] => Ok(one.clone()),
155 many => Err(CliError::Config(format!(
156 "partition bound probe: value_path '{}' matched {} values; it must select exactly \
157 one",
158 p.value_path,
159 many.len()
160 ))),
161 }
162}
163
164fn as_i64(v: &Value, path: &str) -> CliResult<i64> {
168 if v.is_null() {
169 return Err(CliError::Config(format!(
170 "partition bound probe: '{path}' is null — `MAX(id)` over an empty table returns \
171 NULL. Give the range an explicit `to`, or ensure the probe cannot match zero rows"
172 )));
173 }
174 v.as_i64()
175 .or_else(|| v.as_str().and_then(|s| s.trim().parse::<i64>().ok()))
176 .ok_or_else(|| {
177 CliError::Config(format!(
178 "partition bound probe: '{path}' is not an integer (got {v})"
179 ))
180 })
181}
182
183fn as_u64(v: &Value, path: &str) -> CliResult<u64> {
184 if v.is_null() {
185 return Err(CliError::Config(format!(
186 "partition bound probe: '{path}' is null — give the range an explicit `total`"
187 )));
188 }
189 v.as_u64()
190 .or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
191 .ok_or_else(|| {
192 CliError::Config(format!(
193 "partition bound probe: '{path}' is not a non-negative integer (got {v})"
194 ))
195 })
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use serde_json::json;
202
203 fn probe() -> BoundProbe {
204 BoundProbe {
205 from_source: crate::config::ConnectorSpec {
206 kind: "csv".into(),
207 config: json!({"path": "./x.csv"}),
208 transforms: None,
209 inherit_transforms: true,
210 status: None,
211 tags: Vec::new(),
212 complete_for: None,
213 },
214 value_path: "$.max_id".into(),
215 }
216 }
217
218 #[test]
219 fn needs_probe_only_when_a_bound_is_discovered() {
220 let literal = PartitionSpec::Integer {
221 from: 0,
222 to: IntBound::Literal(10),
223 chunk_size: 5,
224 bounds: crate::chunking::Bounds::Inclusive,
225 to_unbounded: None,
226 };
227 assert!(!needs_probe(&literal));
228
229 let discovered = PartitionSpec::Integer {
230 from: 0,
231 to: IntBound::Discovered(probe()),
232 chunk_size: 5,
233 bounds: crate::chunking::Bounds::Inclusive,
234 to_unbounded: None,
235 };
236 assert!(needs_probe(&discovered));
237 assert!(probe_implies_unbounded(&discovered));
238 assert!(!probe_implies_unbounded(&literal));
239
240 assert!(!needs_probe(&PartitionSpec::Timestamp {
242 from: "a".into(),
243 to: "b".into(),
244 chunk_size: "1d".into(),
245 timezone: None,
246 }));
247 }
248
249 #[test]
250 fn parses_numbers_and_numeric_strings() {
251 assert_eq!(as_i64(&json!(42), "$.x").unwrap(), 42);
252 assert_eq!(as_i64(&json!(" 42 "), "$.x").unwrap(), 42);
254 assert_eq!(as_u64(&json!(7), "$.x").unwrap(), 7);
255 assert_eq!(as_u64(&json!("7"), "$.x").unwrap(), 7);
256 }
257
258 #[test]
259 fn null_is_refused_with_the_empty_table_explanation() {
260 let err = as_i64(&json!(null), "$.max_id").unwrap_err().to_string();
263 assert!(err.contains("null"), "{err}");
264 assert!(err.contains("empty table"), "explains why: {err}");
265 assert!(as_u64(&json!(null), "$.total").is_err());
266 }
267
268 #[test]
269 fn non_numeric_is_refused_rather_than_defaulted() {
270 for v in [json!("abc"), json!(true), json!({"a": 1}), json!([1])] {
271 assert!(as_i64(&v, "$.x").is_err(), "{v} must not parse");
272 assert!(as_u64(&v, "$.x").is_err(), "{v} must not parse");
273 }
274 assert_eq!(as_i64(&json!(-5), "$.x").unwrap(), -5);
276 assert!(as_u64(&json!(-5), "$.x").is_err());
277 }
278
279 #[tokio::test]
280 async fn a_probe_below_from_is_refused() {
281 let dir = tempfile::tempdir().unwrap();
284 let csv = dir.path().join("p.csv");
285 std::fs::write(&csv, "max_id\n-3\n").unwrap();
286 let mut p = probe();
287 p.from_source.config = json!({ "path": csv.to_str().unwrap() });
288
289 let spec = PartitionSpec::Integer {
290 from: 0,
291 to: IntBound::Discovered(p),
292 chunk_size: 5,
293 bounds: crate::chunking::Bounds::Inclusive,
294 to_unbounded: None,
295 };
296 let err = resolve_bounds(&spec, &AuthCatalog::default())
297 .await
298 .unwrap_err()
299 .to_string();
300 assert!(err.contains("below `from`"), "{err}");
301 }
302
303 #[tokio::test]
304 async fn a_probed_bound_defaults_the_open_tail_on() {
305 let dir = tempfile::tempdir().unwrap();
308 let csv = dir.path().join("p.csv");
309 std::fs::write(&csv, "max_id\n99\n").unwrap();
310 let mut p = probe();
311 p.from_source.config = json!({ "path": csv.to_str().unwrap() });
312
313 let mk = |explicit: Option<bool>| PartitionSpec::Integer {
314 from: 0,
315 to: IntBound::Discovered(p.clone()),
316 chunk_size: 50,
317 bounds: crate::chunking::Bounds::Inclusive,
318 to_unbounded: explicit,
319 };
320 let unset = resolve_bounds(&mk(None), &AuthCatalog::default())
321 .await
322 .unwrap();
323 match unset {
324 PartitionSpec::Integer { to_unbounded, .. } => {
325 assert_eq!(
326 to_unbounded,
327 Some(true),
328 "unset must default ON when probed"
329 )
330 }
331 o => panic!("{o:?}"),
332 }
333 let forced = resolve_bounds(&mk(Some(false)), &AuthCatalog::default())
335 .await
336 .unwrap();
337 match forced {
338 PartitionSpec::Integer { to_unbounded, .. } => assert_eq!(to_unbounded, Some(false)),
339 o => panic!("{o:?}"),
340 }
341 }
342
343 #[tokio::test]
344 async fn a_literal_bound_never_defaults_the_open_tail_on() {
345 let spec = PartitionSpec::Integer {
346 from: 0,
347 to: IntBound::Literal(10),
348 chunk_size: 5,
349 bounds: crate::chunking::Bounds::Inclusive,
350 to_unbounded: None,
351 };
352 let out = resolve_bounds(&spec, &AuthCatalog::default())
353 .await
354 .unwrap();
355 match out {
356 PartitionSpec::Integer { to_unbounded, .. } => assert_eq!(to_unbounded, None),
357 o => panic!("{o:?}"),
358 }
359 }
360
361 #[tokio::test]
362 async fn a_literal_spec_is_returned_unchanged_without_any_probing() {
363 let spec = PartitionSpec::Offset {
364 total: CountBound::Literal(10),
365 chunk_size: 5,
366 };
367 let out = resolve_bounds(&spec, &AuthCatalog::default())
368 .await
369 .unwrap();
370 assert_eq!(out, spec);
371 }
372
373 #[tokio::test]
374 async fn a_discovered_bound_is_resolved_from_the_probe_source() {
375 let dir = tempfile::tempdir().unwrap();
376 let csv = dir.path().join("p.csv");
377 std::fs::write(&csv, "max_id\n99\n").unwrap();
378 let mut p = probe();
379 p.from_source.config = json!({ "path": csv.to_str().unwrap() });
380
381 let spec = PartitionSpec::Integer {
382 from: 0,
383 to: IntBound::Discovered(p),
384 chunk_size: 50,
385 bounds: crate::chunking::Bounds::Inclusive,
386 to_unbounded: Some(true),
387 };
388 let out = resolve_bounds(&spec, &AuthCatalog::default())
389 .await
390 .unwrap();
391 match out {
392 PartitionSpec::Integer { to, .. } => assert_eq!(to, IntBound::Literal(99)),
393 other => panic!("expected an integer spec, got {other:?}"),
394 }
395 }
396
397 #[tokio::test]
398 async fn an_empty_probe_result_is_actionable_not_zero() {
399 let dir = tempfile::tempdir().unwrap();
400 let csv = dir.path().join("p.csv");
401 std::fs::write(&csv, "max_id\n").unwrap(); let mut p = probe();
403 p.from_source.config = json!({ "path": csv.to_str().unwrap() });
404
405 let spec = PartitionSpec::Integer {
406 from: 0,
407 to: IntBound::Discovered(p),
408 chunk_size: 5,
409 bounds: crate::chunking::Bounds::Inclusive,
410 to_unbounded: None,
411 };
412 let err = resolve_bounds(&spec, &AuthCatalog::default())
413 .await
414 .unwrap_err()
415 .to_string();
416 assert!(err.contains("no records"), "{err}");
417 assert!(err.contains("explicit `to`"), "suggests the fix: {err}");
418 }
419}