mirror of
https://github.com/lancedb/lancedb.git
synced 2026-08-18 12:08:35 +00:00
feat: add generated column create job spec
This commit is contained in:
@@ -6,9 +6,11 @@
|
||||
//! This module defines transport and metadata value types only. It does not
|
||||
//! provide catalogs, job execution, query planning, or generated-column runtime.
|
||||
|
||||
mod create_generated_column;
|
||||
mod definition;
|
||||
mod registration;
|
||||
|
||||
pub use create_generated_column::CreateGeneratedColumnJobSpec;
|
||||
pub use definition::{FunctionCapability, FunctionDefinition, PythonFunctionDefinition};
|
||||
pub use registration::RegisterFunctionJobSpec;
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
//! Immutable CreateGeneratedColumnJobSpec create-generated-column Job operation
|
||||
//! input (FF-009).
|
||||
//!
|
||||
//! This type is Job operation input only. It does not allocate output fields,
|
||||
//! construct [`super::GeneratedColumnDefinition`], mutate tables, or execute
|
||||
//! Jobs.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use serde::de::Error as DeError;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use super::{Function, FunctionCall, invalid_input};
|
||||
use crate::Result;
|
||||
|
||||
const FORMAT_VERSION_V1: u32 = 1;
|
||||
|
||||
/// Immutable Job operation input for creating a generated column (format
|
||||
/// version 1).
|
||||
///
|
||||
/// Semantic fields are exactly `column_name` and [`FunctionCall`]. Wire keys
|
||||
/// are exactly `format_version`, `column_name`, and `function_call`.
|
||||
///
|
||||
/// Construction via [`Self::try_new`] validates the call against a catalog
|
||||
/// [`Function`]. Structural deserialize does not; execution consumers must
|
||||
/// call [`Self::validate_against`].
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct CreateGeneratedColumnJobSpec {
|
||||
column_name: String,
|
||||
function_call: FunctionCall,
|
||||
}
|
||||
|
||||
impl CreateGeneratedColumnJobSpec {
|
||||
/// Create a create-generated-column Job operation input.
|
||||
///
|
||||
/// Rejects an empty `column_name`. Requires
|
||||
/// [`FunctionCall::validate_against`] to succeed for `function` before
|
||||
/// returning (exact Function ID, parameter name/order, argument count, and
|
||||
/// Arrow type equality).
|
||||
pub fn try_new(
|
||||
column_name: impl Into<String>,
|
||||
function: &Function,
|
||||
call: FunctionCall,
|
||||
) -> Result<Self> {
|
||||
let column_name = column_name.into();
|
||||
if column_name.is_empty() {
|
||||
return Err(invalid_input(
|
||||
"CreateGeneratedColumnJobSpec column_name must be non-empty",
|
||||
));
|
||||
}
|
||||
call.validate_against(function)?;
|
||||
Ok(Self {
|
||||
column_name,
|
||||
function_call: call,
|
||||
})
|
||||
}
|
||||
|
||||
/// Wire format version (always 1 for this type).
|
||||
pub fn format_version(&self) -> u32 {
|
||||
FORMAT_VERSION_V1
|
||||
}
|
||||
|
||||
/// Target generated column name.
|
||||
pub fn column_name(&self) -> &str {
|
||||
&self.column_name
|
||||
}
|
||||
|
||||
/// Embedded function call.
|
||||
pub fn function_call(&self) -> &FunctionCall {
|
||||
&self.function_call
|
||||
}
|
||||
|
||||
/// Validate the embedded call against a catalog [`Function`].
|
||||
///
|
||||
/// Structural decode does not perform this check. Execution consumers must
|
||||
/// call this before using the call.
|
||||
pub fn validate_against(&self, function: &Function) -> Result<()> {
|
||||
self.function_call.validate_against(function)
|
||||
}
|
||||
|
||||
fn to_wire(&self) -> CreateGeneratedColumnJobSpecWire {
|
||||
CreateGeneratedColumnJobSpecWire {
|
||||
format_version: FORMAT_VERSION_V1,
|
||||
column_name: self.column_name.clone(),
|
||||
function_call: self.function_call.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_wire(wire: CreateGeneratedColumnJobSpecWire) -> Result<Self> {
|
||||
if wire.format_version != FORMAT_VERSION_V1 {
|
||||
return Err(invalid_input(format!(
|
||||
"unsupported CreateGeneratedColumnJobSpec format_version {}",
|
||||
wire.format_version
|
||||
)));
|
||||
}
|
||||
if wire.column_name.is_empty() {
|
||||
return Err(invalid_input(
|
||||
"CreateGeneratedColumnJobSpec column_name must be non-empty",
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
column_name: wire.column_name,
|
||||
function_call: wire.function_call,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for CreateGeneratedColumnJobSpec {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let field_ids: Vec<_> = self
|
||||
.function_call
|
||||
.arguments()
|
||||
.iter()
|
||||
.filter_map(|(_, argument)| argument.field_id())
|
||||
.collect();
|
||||
f.debug_struct("CreateGeneratedColumnJobSpec")
|
||||
.field("column_name", &self.column_name)
|
||||
.field("function_id", &self.function_call.function_id().as_str())
|
||||
.field("argument_count", &self.function_call.arguments().len())
|
||||
.field("field_ids", &field_ids)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// Do not derive Debug: nested FunctionCall may carry typed literal payloads on
|
||||
// the trusted create-generated-column wire.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CreateGeneratedColumnJobSpecWire {
|
||||
format_version: u32,
|
||||
column_name: String,
|
||||
function_call: FunctionCall,
|
||||
}
|
||||
|
||||
impl Serialize for CreateGeneratedColumnJobSpec {
|
||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
self.to_wire().serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for CreateGeneratedColumnJobSpec {
|
||||
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let wire = CreateGeneratedColumnJobSpecWire::deserialize(deserializer)?;
|
||||
Self::from_wire(wire).map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
//! Contract tests for CreateGeneratedColumnJobSpec (FF-009 / generated-column create Job).
|
||||
//!
|
||||
//! These tests pin the intended public surface under [`lancedb::function`] for
|
||||
//! create-generated-column Job operation input only. They intentionally fail to
|
||||
//! compile until that API exists.
|
||||
//!
|
||||
//! Rejection cases are judged by `Result` structure (`is_err` / `is_ok`), never
|
||||
//! by diagnostic message substrings. Catalog execution, materialization, and
|
||||
//! Job lifecycle are out of scope.
|
||||
//!
|
||||
//! Intended minimal public API (exact names pinned here):
|
||||
//! - [`CreateGeneratedColumnJobSpec::try_new`]`(column_name, &function, call) -> Result<Self>`
|
||||
//! - [`CreateGeneratedColumnJobSpec::format_version`] / [`column_name`] /
|
||||
//! [`function_call`]
|
||||
//! - [`CreateGeneratedColumnJobSpec::validate_against`]`(&Function) -> Result<()>`
|
||||
//!
|
||||
//! Strict wire v1 outer object keys are exactly `format_version`, `column_name`,
|
||||
//! and `function_call`. Sophon JobMetadata owns table/version/lifecycle fields.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use arrow_array::{ArrayRef, Int32Array, StringArray};
|
||||
use arrow_schema::{DataType, Field, Schema};
|
||||
use lancedb::Result;
|
||||
use lancedb::function::{
|
||||
CreateGeneratedColumnJobSpec, Function, FunctionArgument, FunctionCall, FunctionId,
|
||||
FunctionOutput, FunctionParameter, FunctionSignature,
|
||||
};
|
||||
use lancedb::ipc::schema_to_ipc_file;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Distinctive UTF-8 sentinel used only for Debug redaction vs trusted wire.
|
||||
const LITERAL_SENTINEL: &str = "REDTEST_LITERAL_SENTINEL_π_🔒_v1";
|
||||
|
||||
fn sample_signature() -> Result<FunctionSignature> {
|
||||
FunctionSignature::try_new(
|
||||
vec![
|
||||
FunctionParameter::new("x", DataType::Int32),
|
||||
FunctionParameter::new("label", DataType::Utf8),
|
||||
],
|
||||
FunctionOutput::new(DataType::Int32, true),
|
||||
)
|
||||
}
|
||||
|
||||
fn sample_function() -> Result<Function> {
|
||||
let id = FunctionId::try_new("fn.exact.generated-column.create")?;
|
||||
Ok(Function::new(id, sample_signature()?))
|
||||
}
|
||||
|
||||
fn other_function_same_signature() -> Result<Function> {
|
||||
let id = FunctionId::try_new("fn.other.exact.id")?;
|
||||
Ok(Function::new(id, sample_signature()?))
|
||||
}
|
||||
|
||||
fn field_arg(field_id: i32, data_type: DataType) -> Result<FunctionArgument> {
|
||||
FunctionArgument::try_field(field_id, data_type)
|
||||
}
|
||||
|
||||
fn int_literal(value: Option<i32>) -> Result<FunctionArgument> {
|
||||
FunctionArgument::try_literal(Arc::new(Int32Array::from(vec![value])) as ArrayRef)
|
||||
}
|
||||
|
||||
fn utf8_literal(value: Option<&str>) -> Result<FunctionArgument> {
|
||||
FunctionArgument::try_literal(Arc::new(StringArray::from(vec![value])) as ArrayRef)
|
||||
}
|
||||
|
||||
/// Representative call: stable field-ID binding plus typed literal binding.
|
||||
fn sample_mixed_call(function: &Function) -> Result<FunctionCall> {
|
||||
FunctionCall::try_new(
|
||||
function,
|
||||
vec![
|
||||
("x".to_string(), field_arg(7, DataType::Int32)?),
|
||||
("label".to_string(), utf8_literal(Some("ok"))?),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn sample_literal_sentinel_call(function: &Function) -> Result<FunctionCall> {
|
||||
FunctionCall::try_new(
|
||||
function,
|
||||
vec![
|
||||
("x".to_string(), int_literal(Some(42))?),
|
||||
("label".to_string(), utf8_literal(Some(LITERAL_SENTINEL))?),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn sample_spec() -> Result<CreateGeneratedColumnJobSpec> {
|
||||
let function = sample_function()?;
|
||||
let call = sample_mixed_call(&function)?;
|
||||
CreateGeneratedColumnJobSpec::try_new("score_normalized", &function, call)
|
||||
}
|
||||
|
||||
fn assert_json_object_keys_exact(value: &Value, expected: &[&str]) {
|
||||
let object = value
|
||||
.as_object()
|
||||
.unwrap_or_else(|| panic!("expected JSON object, got {value}"));
|
||||
let keys: BTreeSet<&str> = object.keys().map(|k| k.as_str()).collect();
|
||||
let expected: BTreeSet<&str> = expected.iter().copied().collect();
|
||||
assert_eq!(
|
||||
keys, expected,
|
||||
"JSON object key set must match exactly (iteration order is not a contract); got {keys:?}, expected {expected:?} in {value}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Outer-object keys forbidden on CreateGeneratedColumnJobSpec wire.
|
||||
///
|
||||
/// Exact key matching on the outer spec object only. Nested FunctionCall keys
|
||||
/// such as `function_id`, `field_id`, `parameter`, and `data_type_ipc` are
|
||||
/// legitimate and must not be treated as forbidden by recursive scans.
|
||||
const FORBIDDEN_OUTER_SPEC_KEYS: &[&str] = &[
|
||||
"name",
|
||||
"function_name",
|
||||
"function",
|
||||
"signature",
|
||||
"parameters",
|
||||
"output",
|
||||
"output_type",
|
||||
"data_type",
|
||||
"data_type_ipc",
|
||||
"output_field_id",
|
||||
"dependency_epoch",
|
||||
"materialized_epoch",
|
||||
"table",
|
||||
"table_name",
|
||||
"table_ref",
|
||||
"source_table_version",
|
||||
"version",
|
||||
"artifact",
|
||||
"artifact_digest",
|
||||
"digest",
|
||||
"storage",
|
||||
"storage_location",
|
||||
"location",
|
||||
"worker",
|
||||
"executor",
|
||||
"environment",
|
||||
"scheduler",
|
||||
"replica",
|
||||
"placement",
|
||||
"id",
|
||||
"job_id",
|
||||
"jobId",
|
||||
"state",
|
||||
"status",
|
||||
"attempt",
|
||||
"attempt_id",
|
||||
"timestamp",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"lineage",
|
||||
"idempotency_key",
|
||||
"retry_key",
|
||||
"idempotency",
|
||||
"commit_token",
|
||||
"retry",
|
||||
];
|
||||
|
||||
fn assert_outer_forbidden_keys_absent(value: &Value) {
|
||||
assert_json_object_keys_exact(value, &["format_version", "column_name", "function_call"]);
|
||||
let object = value
|
||||
.as_object()
|
||||
.unwrap_or_else(|| panic!("expected JSON object, got {value}"));
|
||||
for key in object.keys() {
|
||||
assert!(
|
||||
!FORBIDDEN_OUTER_SPEC_KEYS.contains(&key.as_str()),
|
||||
"CreateGeneratedColumnJobSpec wire must not contain forbidden outer key `{key}`: {value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal RFC 4648 base64 encoder for test-only type IPC fixtures.
|
||||
fn base64_encode(input: &[u8]) -> String {
|
||||
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
|
||||
for chunk in input.chunks(3) {
|
||||
let b0 = chunk[0] as u32;
|
||||
let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
|
||||
let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
|
||||
let triple = (b0 << 16) | (b1 << 8) | b2;
|
||||
out.push(TABLE[((triple >> 18) & 0x3F) as usize] as char);
|
||||
out.push(TABLE[((triple >> 12) & 0x3F) as usize] as char);
|
||||
if chunk.len() > 1 {
|
||||
out.push(TABLE[((triple >> 6) & 0x3F) as usize] as char);
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
if chunk.len() > 2 {
|
||||
out.push(TABLE[(triple & 0x3F) as usize] as char);
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Minimal RFC 4648 base64 decoder for test-only wire inspection.
|
||||
fn base64_decode(input: &str) -> std::result::Result<Vec<u8>, String> {
|
||||
fn decode_char(c: u8) -> std::result::Result<u8, String> {
|
||||
match c {
|
||||
b'A'..=b'Z' => Ok(c - b'A'),
|
||||
b'a'..=b'z' => Ok(c - b'a' + 26),
|
||||
b'0'..=b'9' => Ok(c - b'0' + 52),
|
||||
b'+' => Ok(62),
|
||||
b'/' => Ok(63),
|
||||
_ => Err(format!("invalid base64 byte: {c}")),
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = input.as_bytes();
|
||||
if !bytes.len().is_multiple_of(4) {
|
||||
return Err("base64 length must be a multiple of 4".into());
|
||||
}
|
||||
let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
|
||||
for chunk in bytes.chunks(4) {
|
||||
let pad = chunk.iter().filter(|&&b| b == b'=').count();
|
||||
let c0 = decode_char(chunk[0])?;
|
||||
let c1 = decode_char(chunk[1])?;
|
||||
let c2 = if chunk[2] == b'=' {
|
||||
0
|
||||
} else {
|
||||
decode_char(chunk[2])?
|
||||
};
|
||||
let c3 = if chunk[3] == b'=' {
|
||||
0
|
||||
} else {
|
||||
decode_char(chunk[3])?
|
||||
};
|
||||
let triple = ((c0 as u32) << 18) | ((c1 as u32) << 12) | ((c2 as u32) << 6) | (c3 as u32);
|
||||
out.push(((triple >> 16) & 0xFF) as u8);
|
||||
if pad < 2 {
|
||||
out.push(((triple >> 8) & 0xFF) as u8);
|
||||
}
|
||||
if pad < 1 {
|
||||
out.push((triple & 0xFF) as u8);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn schema_only_type_ipc_b64(data_type: DataType) -> Result<String> {
|
||||
let schema = Schema::new(vec![Field::new("", data_type, true)]);
|
||||
Ok(base64_encode(&schema_to_ipc_file(&schema)?))
|
||||
}
|
||||
|
||||
fn bytes_contain(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.any(|window| window == needle)
|
||||
}
|
||||
|
||||
fn first_literal_ipc(spec_json: &Value) -> String {
|
||||
let args = spec_json
|
||||
.pointer("/function_call/arguments")
|
||||
.and_then(Value::as_array)
|
||||
.expect("function_call.arguments array");
|
||||
for arg in args {
|
||||
let value = arg
|
||||
.get("value")
|
||||
.and_then(Value::as_object)
|
||||
.expect("argument.value object");
|
||||
if value.get("kind").and_then(Value::as_str) == Some("literal") {
|
||||
return value
|
||||
.get("ipc")
|
||||
.and_then(Value::as_str)
|
||||
.expect("literal value.ipc string")
|
||||
.to_string();
|
||||
}
|
||||
}
|
||||
panic!("expected at least one literal argument with ipc");
|
||||
}
|
||||
|
||||
fn structurally_decoded_call_with_mutated_json(
|
||||
mutate: impl FnOnce(&mut Value),
|
||||
) -> Result<FunctionCall> {
|
||||
let function = sample_function()?;
|
||||
let call = sample_mixed_call(&function)?;
|
||||
let mut call_json = serde_json::to_value(&call).expect("serialize FunctionCall");
|
||||
mutate(&mut call_json);
|
||||
Ok(serde_json::from_value(call_json).expect("structural FunctionCall decode"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_round_trip_pins_column_name_and_nested_function_call() -> Result<()> {
|
||||
let function = sample_function()?;
|
||||
let call = sample_mixed_call(&function)?;
|
||||
let spec = CreateGeneratedColumnJobSpec::try_new("score_normalized", &function, call)?;
|
||||
|
||||
assert_eq!(spec.format_version(), 1);
|
||||
assert_eq!(spec.column_name(), "score_normalized");
|
||||
assert_eq!(
|
||||
spec.function_call().function_id().as_str(),
|
||||
"fn.exact.generated-column.create"
|
||||
);
|
||||
assert_eq!(spec.function_call().arguments().len(), 2);
|
||||
assert_eq!(spec.function_call().arguments()[0].0, "x");
|
||||
assert_eq!(spec.function_call().arguments()[0].1.field_id(), Some(7));
|
||||
assert_eq!(spec.function_call().arguments()[1].0, "label");
|
||||
assert_eq!(
|
||||
spec.function_call().arguments()[1].1.data_type(),
|
||||
&DataType::Utf8
|
||||
);
|
||||
|
||||
let json = serde_json::to_value(&spec).expect("serialize create spec");
|
||||
assert_eq!(json["format_version"], 1);
|
||||
assert_eq!(
|
||||
json["column_name"],
|
||||
Value::String("score_normalized".into())
|
||||
);
|
||||
assert_eq!(
|
||||
json["function_call"]["function_id"],
|
||||
Value::String("fn.exact.generated-column.create".into())
|
||||
);
|
||||
assert_json_object_keys_exact(&json, &["format_version", "column_name", "function_call"]);
|
||||
assert_json_object_keys_exact(&json["function_call"], &["function_id", "arguments"]);
|
||||
|
||||
let restored: CreateGeneratedColumnJobSpec =
|
||||
serde_json::from_value(json.clone()).expect("deserialize create spec");
|
||||
assert_eq!(restored.format_version(), 1);
|
||||
assert_eq!(restored.column_name(), "score_normalized");
|
||||
assert_eq!(
|
||||
restored.function_call().function_id().as_str(),
|
||||
spec.function_call().function_id().as_str()
|
||||
);
|
||||
assert_eq!(
|
||||
restored.function_call().arguments().len(),
|
||||
spec.function_call().arguments().len()
|
||||
);
|
||||
assert_eq!(
|
||||
restored.function_call().arguments()[0].1.field_id(),
|
||||
spec.function_call().arguments()[0].1.field_id()
|
||||
);
|
||||
assert_eq!(
|
||||
restored.function_call().arguments()[1].1.data_type(),
|
||||
spec.function_call().arguments()[1].1.data_type()
|
||||
);
|
||||
|
||||
// Same value serializes byte-identically.
|
||||
let encoded_a = serde_json::to_string(&spec).expect("encode a");
|
||||
let encoded_b = serde_json::to_string(&spec).expect("encode b");
|
||||
assert_eq!(encoded_a, encoded_b);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&restored).expect("re-serialize restored"),
|
||||
json
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructor_validates_exact_function_identity_parameter_order_and_types() -> Result<()> {
|
||||
let function = sample_function()?;
|
||||
let matching_call = sample_mixed_call(&function)?;
|
||||
assert!(
|
||||
CreateGeneratedColumnJobSpec::try_new("score_normalized", &function, matching_call).is_ok(),
|
||||
"matching Function and FunctionCall must construct"
|
||||
);
|
||||
|
||||
// Exact Function identity mismatch: call pinned to another Function ID.
|
||||
let other = other_function_same_signature()?;
|
||||
let other_call = sample_mixed_call(&other)?;
|
||||
assert!(
|
||||
CreateGeneratedColumnJobSpec::try_new("score_normalized", &function, other_call).is_err(),
|
||||
"Function ID mismatch must be rejected by constructor"
|
||||
);
|
||||
|
||||
// Public FunctionCall::try_new cannot produce a malformed call; use structural
|
||||
// JSON fixtures for parameter name/order and Arrow type mismatches.
|
||||
let reordered_call = structurally_decoded_call_with_mutated_json(|call_json| {
|
||||
call_json["arguments"]
|
||||
.as_array_mut()
|
||||
.expect("arguments")
|
||||
.swap(0, 1);
|
||||
})?;
|
||||
assert!(
|
||||
CreateGeneratedColumnJobSpec::try_new("score_normalized", &function, reordered_call)
|
||||
.is_err(),
|
||||
"reordered parameter bindings must be rejected by constructor"
|
||||
);
|
||||
|
||||
let renamed_call = structurally_decoded_call_with_mutated_json(|call_json| {
|
||||
call_json["arguments"][0]["parameter"] = Value::String("renamed".into());
|
||||
})?;
|
||||
assert!(
|
||||
CreateGeneratedColumnJobSpec::try_new("score_normalized", &function, renamed_call).is_err(),
|
||||
"parameter name mismatch must be rejected by constructor"
|
||||
);
|
||||
|
||||
let type_mismatch_call = structurally_decoded_call_with_mutated_json(|call_json| {
|
||||
call_json["arguments"][0]["value"]["data_type_ipc"] =
|
||||
Value::String(schema_only_type_ipc_b64(DataType::Utf8).expect("type ipc"));
|
||||
})?;
|
||||
assert!(
|
||||
CreateGeneratedColumnJobSpec::try_new("score_normalized", &function, type_mismatch_call)
|
||||
.is_err(),
|
||||
"Arrow type mismatch must be rejected by constructor"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_name_unknown_outer_field_and_unknown_version_fail_closed() -> Result<()> {
|
||||
let function = sample_function()?;
|
||||
let call = sample_mixed_call(&function)?;
|
||||
|
||||
assert!(
|
||||
CreateGeneratedColumnJobSpec::try_new("", &function, call).is_err(),
|
||||
"empty column_name must be rejected by constructor"
|
||||
);
|
||||
|
||||
let spec = sample_spec()?;
|
||||
let json = serde_json::to_value(&spec).expect("serialize create spec");
|
||||
|
||||
let mut empty_name = json.clone();
|
||||
empty_name["column_name"] = Value::String("".into());
|
||||
assert!(
|
||||
serde_json::from_value::<CreateGeneratedColumnJobSpec>(empty_name).is_err(),
|
||||
"decode must reject empty column_name"
|
||||
);
|
||||
|
||||
let mut unknown_field = json.clone();
|
||||
unknown_field
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("unexpected_field".into(), Value::Bool(true));
|
||||
assert!(
|
||||
serde_json::from_value::<CreateGeneratedColumnJobSpec>(unknown_field).is_err(),
|
||||
"unknown outer field must fail closed"
|
||||
);
|
||||
|
||||
let mut unknown_version = json.clone();
|
||||
unknown_version["format_version"] = Value::from(2);
|
||||
assert!(
|
||||
serde_json::from_value::<CreateGeneratedColumnJobSpec>(unknown_version).is_err(),
|
||||
"format_version other than 1 must fail closed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outer_wire_excludes_forbidden_operation_and_lifecycle_fields() -> Result<()> {
|
||||
let function = sample_function()?;
|
||||
let field_and_literal = sample_mixed_call(&function)?;
|
||||
let mixed =
|
||||
CreateGeneratedColumnJobSpec::try_new("score_normalized", &function, field_and_literal)?;
|
||||
let mixed_json = serde_json::to_value(&mixed).expect("serialize mixed spec");
|
||||
assert_outer_forbidden_keys_absent(&mixed_json);
|
||||
|
||||
// Nested call may carry exact Function ID and field_id; outer must not.
|
||||
assert!(mixed_json.get("function_id").is_none());
|
||||
assert!(mixed_json.get("field_id").is_none());
|
||||
assert!(mixed_json.get("output_field_id").is_none());
|
||||
assert!(mixed_json.get("dependency_epoch").is_none());
|
||||
assert!(mixed_json.get("materialized_epoch").is_none());
|
||||
assert_eq!(
|
||||
mixed_json["function_call"]["function_id"],
|
||||
Value::String("fn.exact.generated-column.create".into())
|
||||
);
|
||||
assert_eq!(
|
||||
mixed_json["function_call"]["arguments"][0]["value"]["field_id"],
|
||||
Value::from(7)
|
||||
);
|
||||
|
||||
let literal_only = sample_literal_sentinel_call(&function)?;
|
||||
let literal_spec =
|
||||
CreateGeneratedColumnJobSpec::try_new("label_copy", &function, literal_only)?;
|
||||
let literal_json = serde_json::to_value(&literal_spec).expect("serialize literal spec");
|
||||
assert_outer_forbidden_keys_absent(&literal_json);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_decode_then_validate_against_matching_and_mismatching_catalog_function() -> Result<()>
|
||||
{
|
||||
let function = sample_function()?;
|
||||
let call = sample_mixed_call(&function)?;
|
||||
let spec = CreateGeneratedColumnJobSpec::try_new("score_normalized", &function, call)?;
|
||||
let json = serde_json::to_value(&spec).expect("serialize create spec");
|
||||
|
||||
let decoded: CreateGeneratedColumnJobSpec =
|
||||
serde_json::from_value(json).expect("structural decode without catalog");
|
||||
assert_eq!(decoded.column_name(), "score_normalized");
|
||||
assert_eq!(
|
||||
decoded.function_call().function_id().as_str(),
|
||||
function.id().as_str()
|
||||
);
|
||||
|
||||
decoded.validate_against(&function)?;
|
||||
|
||||
let other = other_function_same_signature()?;
|
||||
assert!(
|
||||
decoded.validate_against(&other).is_err(),
|
||||
"validate_against must reject a catalog Function with a different exact ID"
|
||||
);
|
||||
|
||||
// Structurally decoded call with reordered bindings must fail validate_against.
|
||||
let mut mutated = serde_json::to_value(&spec).expect("serialize for mutation");
|
||||
mutated["function_call"]["arguments"]
|
||||
.as_array_mut()
|
||||
.expect("arguments")
|
||||
.swap(0, 1);
|
||||
let reordered: CreateGeneratedColumnJobSpec =
|
||||
serde_json::from_value(mutated).expect("structural decode of reordered call");
|
||||
assert!(
|
||||
reordered.validate_against(&function).is_err(),
|
||||
"reordered nested bindings must fail validate_against"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_redacts_literal_payload_while_trusted_wire_retains_it() -> Result<()> {
|
||||
let function = sample_function()?;
|
||||
let call = sample_literal_sentinel_call(&function)?;
|
||||
let spec = CreateGeneratedColumnJobSpec::try_new("label_copy", &function, call)?;
|
||||
|
||||
// Trusted getters retain the typed literal payload.
|
||||
let label = &spec.function_call().arguments()[1].1;
|
||||
let array = label.literal_array().expect("label literal array");
|
||||
let values = array
|
||||
.as_any()
|
||||
.downcast_ref::<StringArray>()
|
||||
.expect("utf8 literal");
|
||||
assert_eq!(values.value(0), LITERAL_SENTINEL);
|
||||
|
||||
let json = serde_json::to_value(&spec).expect("serialize create spec");
|
||||
// Both literals are present; the sentinel lives in the Utf8 label binding.
|
||||
assert!(!first_literal_ipc(&json).is_empty());
|
||||
let label_ipc = json["function_call"]["arguments"][1]["value"]["ipc"]
|
||||
.as_str()
|
||||
.expect("label literal ipc");
|
||||
let ipc_bytes = base64_decode(label_ipc).expect("literal ipc base64");
|
||||
assert!(
|
||||
bytes_contain(&ipc_bytes, LITERAL_SENTINEL.as_bytes()),
|
||||
"trusted wire IPC payload must retain the UTF-8 literal sentinel"
|
||||
);
|
||||
|
||||
let debug = format!("{spec:?}");
|
||||
assert!(
|
||||
debug.contains("label_copy"),
|
||||
"Debug may show column_name: {debug}"
|
||||
);
|
||||
assert!(
|
||||
debug.contains("fn.exact.generated-column.create"),
|
||||
"Debug may show exact Function ID: {debug}"
|
||||
);
|
||||
assert!(
|
||||
!debug.contains(LITERAL_SENTINEL),
|
||||
"CreateGeneratedColumnJobSpec Debug must not reveal literal payload: {debug}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user