Skip to content

Commit 1f94ef4

Browse files
bakeyclaude
andcommitted
fix(udf): PR #193 round-2 — merge-safe packs pin, view-string + list-literal support, chunk arg dedup
- packs pin: main gained the feishu pack while this branch was in flight, so the roster assertion would have gone red on the merged tree. Absorbed main; the test now asserts sortedness independently of the roster (survives future pack additions) plus the explicit completeness list including feishu. - json_pack: Utf8View accepted for keys and values (DataFusion 52 carries computed string expressions as view types — the filters.rs precedent); a const-folded make_array literal (ScalarValue::List) now encodes through the same utf8_list_value conversion as list columns, honoring the documented "column or literal" contract. - json_pack coverage: every timestamp granularity pinned by value (seconds ×1000 checked, micro/nano floor, pre-epoch sign), the seconds-overflow refusal, List<Utf8> with null element and null list, the make_array literal spelling, non-Utf8 list elements refused with the type named, and view-typed keys/values byte-exact. - chunking: the ~55 duplicated argument-parsing lines collapse into one parse_chunk_args shared by chunk and chunk_parts, so the two UDFs' argument contracts cannot drift; the shared text reader also accepts Utf8View columns and scalars. - style: register_json_pack_udf imported via `use` in server config.rs/server.rs instead of inline crate paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c75e863 commit 1f94ef4

5 files changed

Lines changed: 297 additions & 86 deletions

File tree

crates/server/src/config.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use skardi::sources::providers::seekdb::register_seekdb_tables;
2020
use skardi::sources::providers::sqlite::register_sqlite_tables;
2121
use skardi::sources::providers::sqlx::postgres::register_postgres_tables;
2222
use skardi::sources::sql_validator::{AdhocSqlPolicy, SqlValidatorConfig, validate_sql};
23+
use skardi::util::json_pack::register_json_pack_udf;
2324
use std::collections::HashMap;
2425
use std::path::Path;
2526
use std::path::PathBuf;
@@ -445,7 +446,7 @@ pub async fn load_server_config(args: CliArgs) -> Result<ServerConfig> {
445446
register_chunk_udf(&mut session_ctx);
446447
// Register json_pack UDF (SQL-side JSON encoding; the etl generator's
447448
// metadata/frontmatter serialization boundary)
448-
skardi::util::json_pack::register_json_pack_udf(&mut session_ctx);
449+
register_json_pack_udf(&mut session_ctx);
449450

450451
// This auth layer is used only for SQL planning and is discarded after current function returns.
451452
// The live auth layer is built separately in setup_app_state.

crates/server/src/server.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use skardi::engine::datafusion::DataFusionEngine;
88
use skardi::jobs::{JobExecutor, JobStore, SqliteJobStore};
99
use skardi::sources::DataSourceType;
1010
use skardi::sources::sql_validator::AdhocSqlPolicy;
11+
use skardi::util::json_pack::register_json_pack_udf;
1112
use std::collections::HashMap;
1213
use std::path::PathBuf;
1314
use std::sync::{Arc, RwLock};
@@ -190,7 +191,7 @@ pub async fn setup_app_state(config: ServerConfig) -> Result<AppState> {
190191
register_chunk_udf(&mut session_ctx);
191192
// Register json_pack UDF (SQL-side JSON encoding; the etl generator's
192193
// metadata/frontmatter serialization boundary)
193-
skardi::util::json_pack::register_json_pack_udf(&mut session_ctx);
194+
register_json_pack_udf(&mut session_ctx);
194195

195196
// Build auth layer and register auth.users / auth.sessions on the runtime SessionContext.
196197
let auth_layer = AuthLayer::build(&AuthMode::from_env()).await?;
@@ -594,6 +595,8 @@ spec:
594595
ctx_file: None,
595596
semantics_path: None,
596597
port: 8080,
598+
query_audit_db: None,
599+
query_audit_retention_days: None,
597600
};
598601
let config = crate::config::load_server_config(args)
599602
.await

crates/skardi/src/model/chunking/mod.rs

Lines changed: 64 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
use std::sync::Arc;
2727

2828
use arrow::array::{
29-
Array, ArrayRef, Int32Builder, ListBuilder, StringArray, StringBuilder, StructBuilder,
29+
Array, ArrayRef, Int32Builder, ListBuilder, StringArray, StringBuilder, StringViewArray,
30+
StructBuilder,
3031
};
3132
use arrow::datatypes::{DataType, Field, Fields};
3233
use datafusion::common::Result as DfResult;
@@ -142,35 +143,7 @@ impl ScalarUDFImpl for ChunkingUDF {
142143

143144
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> DfResult<ColumnarValue> {
144145
let args = args.args;
145-
146-
if args.len() < 3 || args.len() > 4 {
147-
return Err(DataFusionError::Execution(format!(
148-
"chunk expects 3 or 4 arguments (mode, text, size [, overlap]); got {}",
149-
args.len()
150-
)));
151-
}
152-
153-
let mode = read_scalar_string("chunk", &args[0], "mode")?;
154-
let size = read_scalar_usize("chunk", &args[2], "size")?;
155-
if size == 0 {
156-
return Err(DataFusionError::Execution(
157-
"chunk: 'size' must be > 0".to_string(),
158-
));
159-
}
160-
let overlap = if args.len() == 4 {
161-
read_scalar_usize("chunk", &args[3], "overlap")?
162-
} else {
163-
0
164-
};
165-
// text-splitter's ChunkConfig::with_overlap also rejects this; the explicit
166-
// check exists so the error names both values instead of a generic message.
167-
if overlap >= size {
168-
return Err(DataFusionError::Execution(format!(
169-
"chunk: 'overlap' ({overlap}) must be strictly less than 'size' ({size})"
170-
)));
171-
}
172-
173-
let texts = read_text_column("chunk", &args[1], "text")?;
146+
let (mode, texts, size, overlap) = parse_chunk_args("chunk", &args)?;
174147

175148
let array: ArrayRef = match mode.as_str() {
176149
"character" => {
@@ -257,33 +230,7 @@ impl ScalarUDFImpl for ChunkPartsUDF {
257230

258231
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> DfResult<ColumnarValue> {
259232
let args = args.args;
260-
261-
if args.len() < 3 || args.len() > 4 {
262-
return Err(DataFusionError::Execution(format!(
263-
"chunk_parts expects 3 or 4 arguments (mode, text, size [, overlap]); got {}",
264-
args.len()
265-
)));
266-
}
267-
268-
let mode = read_scalar_string("chunk_parts", &args[0], "mode")?;
269-
let size = read_scalar_usize("chunk_parts", &args[2], "size")?;
270-
if size == 0 {
271-
return Err(DataFusionError::Execution(
272-
"chunk_parts: 'size' must be > 0".to_string(),
273-
));
274-
}
275-
let overlap = if args.len() == 4 {
276-
read_scalar_usize("chunk_parts", &args[3], "overlap")?
277-
} else {
278-
0
279-
};
280-
if overlap >= size {
281-
return Err(DataFusionError::Execution(format!(
282-
"chunk_parts: 'overlap' ({overlap}) must be strictly less than 'size' ({size})"
283-
)));
284-
}
285-
286-
let texts = read_text_column("chunk_parts", &args[1], "text")?;
233+
let (mode, texts, size, overlap) = parse_chunk_args("chunk_parts", &args)?;
287234

288235
let array: ArrayRef = match mode.as_str() {
289236
"character" => {
@@ -349,6 +296,46 @@ where
349296
// Argument decoding helpers
350297
// =============================================================================
351298

299+
/// Parse the shared `(mode, text, size [, overlap])` argument contract —
300+
/// one implementation for `chunk` and `chunk_parts`, so the two UDFs'
301+
/// argument semantics cannot drift (identical arity, literal rules, and
302+
/// bounds; only the element type downstream differs). `udf` names the
303+
/// caller in every diagnostic.
304+
fn parse_chunk_args<'a>(
305+
udf: &str,
306+
args: &'a [ColumnarValue],
307+
) -> DfResult<(String, Vec<Option<&'a str>>, usize, usize)> {
308+
if args.len() < 3 || args.len() > 4 {
309+
return Err(DataFusionError::Execution(format!(
310+
"{udf} expects 3 or 4 arguments (mode, text, size [, overlap]); got {}",
311+
args.len()
312+
)));
313+
}
314+
315+
let mode = read_scalar_string(udf, &args[0], "mode")?;
316+
let size = read_scalar_usize(udf, &args[2], "size")?;
317+
if size == 0 {
318+
return Err(DataFusionError::Execution(format!(
319+
"{udf}: 'size' must be > 0"
320+
)));
321+
}
322+
let overlap = if args.len() == 4 {
323+
read_scalar_usize(udf, &args[3], "overlap")?
324+
} else {
325+
0
326+
};
327+
// text-splitter's ChunkConfig::with_overlap also rejects this; the explicit
328+
// check exists so the error names both values instead of a generic message.
329+
if overlap >= size {
330+
return Err(DataFusionError::Execution(format!(
331+
"{udf}: 'overlap' ({overlap}) must be strictly less than 'size' ({size})"
332+
)));
333+
}
334+
335+
let texts = read_text_column(udf, &args[1], "text")?;
336+
Ok((mode, texts, size, overlap))
337+
}
338+
352339
fn read_scalar_string(udf: &str, arg: &ColumnarValue, name: &str) -> DfResult<String> {
353340
match arg {
354341
ColumnarValue::Scalar(ScalarValue::Utf8(Some(s)))
@@ -397,7 +384,22 @@ fn read_text_column<'a>(
397384
name: &str,
398385
) -> DfResult<Vec<Option<&'a str>>> {
399386
match arg {
387+
// Utf8View included alongside the classic layouts: DataFusion 52
388+
// carries computed string expressions as view arrays/scalars (the
389+
// same reality open_connector/filters.rs handles), and a text
390+
// column fed through a CAST or concat must not fail the split.
400391
ColumnarValue::Array(arr) => {
392+
if let Some(view_arr) = arr.as_any().downcast_ref::<StringViewArray>() {
393+
return Ok((0..view_arr.len())
394+
.map(|i| {
395+
if view_arr.is_null(i) {
396+
None
397+
} else {
398+
Some(view_arr.value(i))
399+
}
400+
})
401+
.collect());
402+
}
401403
let str_arr = arr.as_any().downcast_ref::<StringArray>().ok_or_else(|| {
402404
DataFusionError::Execution(format!("{udf}: '{name}' must be a Utf8 column"))
403405
})?;
@@ -412,10 +414,11 @@ fn read_text_column<'a>(
412414
.collect())
413415
}
414416
ColumnarValue::Scalar(ScalarValue::Utf8(Some(s)))
415-
| ColumnarValue::Scalar(ScalarValue::LargeUtf8(Some(s))) => Ok(vec![Some(s.as_str())]),
416-
ColumnarValue::Scalar(ScalarValue::Utf8(None) | ScalarValue::LargeUtf8(None)) => {
417-
Ok(vec![None])
418-
}
417+
| ColumnarValue::Scalar(ScalarValue::LargeUtf8(Some(s)))
418+
| ColumnarValue::Scalar(ScalarValue::Utf8View(Some(s))) => Ok(vec![Some(s.as_str())]),
419+
ColumnarValue::Scalar(
420+
ScalarValue::Utf8(None) | ScalarValue::LargeUtf8(None) | ScalarValue::Utf8View(None),
421+
) => Ok(vec![None]),
419422
_ => Err(DataFusionError::Execution(format!(
420423
"{udf}: '{name}' must be Utf8"
421424
))),

crates/skardi/src/sources/providers/open_connector/source_pack.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,16 @@ mod tests {
244244
// backing map is unordered, so the sort here is load-bearing.
245245
let registry = SourcePackRegistry::builtins().expect("embedded assets parse");
246246
let names: Vec<&str> = registry.packs().map(|p| p.name).collect();
247-
assert_eq!(names, vec!["github", "mock", "notion", "slack"]);
247+
// Sortedness, asserted independently of the roster so THIS pin
248+
// survives future pack additions untouched…
249+
assert!(
250+
names.windows(2).all(|w| w[0] < w[1]),
251+
"packs() must iterate name-sorted with no duplicates: {names:?}"
252+
);
253+
// …and completeness as an explicit roster, the one line a new pack
254+
// must extend (a stale list here means the generator's coverage
255+
// listing silently omits the newcomer).
256+
assert_eq!(names, vec!["feishu", "github", "mock", "notion", "slack"]);
248257
}
249258

250259
#[test]

0 commit comments

Comments
 (0)