mirror of
https://github.com/lancedb/lancedb.git
synced 2026-08-18 03:58:26 +00:00
feat: resolve generated column functions by id
This commit is contained in:
@@ -14,8 +14,8 @@ use crate::data::scannable::{PeekedScannable, Scannable, estimate_write_partitio
|
||||
use crate::expr::expr_to_sql_string;
|
||||
use crate::function::schema_admission::reject_caller_authored_generated_column_schema_on_overwrite;
|
||||
use crate::function::{
|
||||
ChangeGeneratedColumnJobSpec, CreateGeneratedColumnJobSpec, GeneratedColumnBindingSnapshot,
|
||||
RefreshGeneratedColumnJobSpec,
|
||||
ChangeGeneratedColumnJobSpec, CreateGeneratedColumnJobSpec, Function, FunctionId,
|
||||
GeneratedColumnBindingSnapshot, RefreshGeneratedColumnJobSpec,
|
||||
};
|
||||
use crate::index::Index;
|
||||
use crate::index::IndexStatistics;
|
||||
@@ -2195,6 +2195,20 @@ impl<S: HttpSend> BaseTable for RemoteTable<S> {
|
||||
))))
|
||||
}
|
||||
|
||||
async fn resolve_function_for_generated_column(
|
||||
&self,
|
||||
function_id: &FunctionId,
|
||||
) -> Result<Function> {
|
||||
// Database-scoped exact-ID catalog lookup: delegate to the canonical
|
||||
// helper. Do not apply branch, table, version, freshness, schema cache,
|
||||
// or mutable-handle logic.
|
||||
super::function::lookup_function(
|
||||
&self.client,
|
||||
super::function::FunctionLookupSelector::by_function_id(function_id),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn create_branch(
|
||||
&self,
|
||||
name: &str,
|
||||
@@ -10529,6 +10543,240 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// resolve_function_for_generated_column table-side exact-ID lookup (RED)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const TABLE_RESOLVE_FUNCTION_ID: &str = "fn.exact.table-resolve.handle";
|
||||
const TABLE_RESOLVE_SERVER_MESSAGE_MARKER: &str = "SERVER_TABLE_RESOLVE_DIAGNOSTIC_MARKER id=fn.exact.table-resolve.handle table=my_table branch=exp";
|
||||
|
||||
fn sample_table_resolve_function() -> crate::function::Function {
|
||||
use crate::function::{
|
||||
Function, FunctionId, FunctionOutput, FunctionParameter, FunctionSignature,
|
||||
};
|
||||
|
||||
let id = FunctionId::try_new(TABLE_RESOLVE_FUNCTION_ID).expect("valid FunctionId");
|
||||
let signature = FunctionSignature::try_new(
|
||||
vec![
|
||||
FunctionParameter::new("text", DataType::Utf8),
|
||||
FunctionParameter::new("limit", DataType::Int32),
|
||||
],
|
||||
FunctionOutput::new(DataType::Utf8, true),
|
||||
)
|
||||
.expect("valid FunctionSignature");
|
||||
Function::new(id, signature)
|
||||
}
|
||||
|
||||
fn table_resolve_success_body(function: &crate::function::Function) -> String {
|
||||
json!({
|
||||
"function": serde_json::to_value(function).expect("serialize Function wire"),
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn assert_table_resolve_id_lookup_request(request: &reqwest::Request, expected_id: &str) {
|
||||
assert_eq!(request.method(), &reqwest::Method::POST);
|
||||
assert_eq!(
|
||||
request.url().path(),
|
||||
"/v1/functions/lookup",
|
||||
"exact catalog lookup must use the canonical database-scoped route"
|
||||
);
|
||||
let actual = request_body_json(request);
|
||||
assert_eq!(
|
||||
actual,
|
||||
json!({ "function_id": expected_id }),
|
||||
"table-side exact lookup body must be exactly {{\"function_id\":...}}"
|
||||
);
|
||||
for forbidden in [
|
||||
"name",
|
||||
"branch",
|
||||
"table",
|
||||
"table_ref",
|
||||
"version",
|
||||
"source_table_version",
|
||||
"identifier",
|
||||
] {
|
||||
assert!(
|
||||
actual.get(forbidden).is_none(),
|
||||
"catalog lookup must not send `{forbidden}`: {actual}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_exact_resolved_function(
|
||||
actual: &crate::function::Function,
|
||||
expected: &crate::function::Function,
|
||||
) {
|
||||
assert_eq!(actual.id(), expected.id());
|
||||
assert_eq!(actual.signature(), expected.signature());
|
||||
}
|
||||
|
||||
fn table_resolve_error_chain_text(err: &Error) -> String {
|
||||
let mut text = format!("{err}\n{err:?}");
|
||||
let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
|
||||
while let Some(e) = current {
|
||||
text.push('\n');
|
||||
text.push_str(&e.to_string());
|
||||
text.push('\n');
|
||||
text.push_str(&format!("{e:?}"));
|
||||
current = e.source();
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn assert_table_resolve_payload_free(err: &Error) {
|
||||
let text = table_resolve_error_chain_text(err);
|
||||
assert!(
|
||||
!text.contains(TABLE_RESOLVE_SERVER_MESSAGE_MARKER),
|
||||
"server diagnostic marker must be absent from error/debug/source chain: {text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains(TABLE_RESOLVE_FUNCTION_ID),
|
||||
"FunctionId must be absent from error/debug/source chain: {text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("my_table"),
|
||||
"table identifier must be absent from error/debug/source chain: {text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("branch=exp"),
|
||||
"branch identity marker must be absent from error/debug/source chain: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Remote table exact-ID resolve posts the canonical route/body and returns
|
||||
/// the immutable Function value.
|
||||
#[tokio::test]
|
||||
async fn resolve_function_for_generated_column_posts_exact_id_body_and_returns_function() {
|
||||
use crate::function::FunctionId;
|
||||
|
||||
let expected = sample_table_resolve_function();
|
||||
let body = table_resolve_success_body(&expected);
|
||||
let paths = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
||||
let paths_ref = paths.clone();
|
||||
let table = Table::new_with_handler("my_table", move |request| {
|
||||
let path = request.url().path().to_string();
|
||||
paths_ref.lock().unwrap().push(path.clone());
|
||||
assert_table_resolve_id_lookup_request(&request, TABLE_RESOLVE_FUNCTION_ID);
|
||||
http::Response::builder()
|
||||
.status(200)
|
||||
.body(body.clone())
|
||||
.unwrap()
|
||||
});
|
||||
let id = FunctionId::try_new(TABLE_RESOLVE_FUNCTION_ID).expect("valid FunctionId");
|
||||
|
||||
let function = table
|
||||
.resolve_function_for_generated_column(&id)
|
||||
.await
|
||||
.expect("table-side exact-ID resolve must succeed");
|
||||
assert_exact_resolved_function(&function, &expected);
|
||||
assert_eq!(function.id().as_str(), TABLE_RESOLVE_FUNCTION_ID);
|
||||
let wire = serde_json::to_value(&function).expect("Function serializes");
|
||||
assert!(
|
||||
wire.get("name").is_none(),
|
||||
"returned Function wire must not invent a catalog name: {wire}"
|
||||
);
|
||||
assert_eq!(
|
||||
*paths.lock().unwrap(),
|
||||
vec!["/v1/functions/lookup".to_string()],
|
||||
"resolve must issue exactly one canonical lookup and no table route"
|
||||
);
|
||||
}
|
||||
|
||||
/// Branch-scoped table handles keep catalog lookup database-scoped: no
|
||||
/// branch/table identity enters the canonical lookup body or route.
|
||||
#[tokio::test]
|
||||
async fn resolve_function_for_generated_column_branch_stays_database_scoped() {
|
||||
use crate::function::FunctionId;
|
||||
use lance::dataset::refs::Ref;
|
||||
|
||||
let expected = sample_table_resolve_function();
|
||||
let body = table_resolve_success_body(&expected);
|
||||
let lookup_hits = Arc::new(AtomicUsize::new(0));
|
||||
let lookup_hits_ref = lookup_hits.clone();
|
||||
let table =
|
||||
Table::new_with_handler("my_table", move |request| match request.url().path() {
|
||||
"/v1/table/my_table/branches/create/" => http::Response::builder()
|
||||
.status(200)
|
||||
.body("{}".to_string())
|
||||
.unwrap(),
|
||||
"/v1/functions/lookup" => {
|
||||
lookup_hits_ref.fetch_add(1, Ordering::SeqCst);
|
||||
assert_table_resolve_id_lookup_request(&request, TABLE_RESOLVE_FUNCTION_ID);
|
||||
http::Response::builder()
|
||||
.status(200)
|
||||
.body(body.clone())
|
||||
.unwrap()
|
||||
}
|
||||
path => panic!("unexpected path for branch-scoped resolve: {path}"),
|
||||
});
|
||||
let branch = table
|
||||
.create_branch("exp", Ref::Version(None, None))
|
||||
.await
|
||||
.expect("create branch");
|
||||
assert_eq!(branch.current_branch(), Some("exp".to_string()));
|
||||
let id = FunctionId::try_new(TABLE_RESOLVE_FUNCTION_ID).expect("valid FunctionId");
|
||||
|
||||
let function = branch
|
||||
.resolve_function_for_generated_column(&id)
|
||||
.await
|
||||
.expect("branch table resolve must stay database-scoped");
|
||||
assert_exact_resolved_function(&function, &expected);
|
||||
assert_eq!(
|
||||
lookup_hits.load(Ordering::SeqCst),
|
||||
1,
|
||||
"branch resolve must hit the canonical lookup once"
|
||||
);
|
||||
}
|
||||
|
||||
/// Explicit stable error_code surfaces sanitized Error::Function; no payload
|
||||
/// or selector values may leak through the table-side delegation.
|
||||
#[tokio::test]
|
||||
async fn resolve_function_for_generated_column_explicit_not_found_is_sanitized_function_error()
|
||||
{
|
||||
use crate::function::FunctionId;
|
||||
|
||||
let body = json!({
|
||||
"error_code": "name_or_function_not_found",
|
||||
"message": TABLE_RESOLVE_SERVER_MESSAGE_MARKER,
|
||||
"looks_like": "definition_validation_failure",
|
||||
})
|
||||
.to_string();
|
||||
let table = Table::new_with_handler("my_table", move |request| {
|
||||
assert_table_resolve_id_lookup_request(&request, TABLE_RESOLVE_FUNCTION_ID);
|
||||
http::Response::builder()
|
||||
.status(404)
|
||||
.body(body.clone())
|
||||
.unwrap()
|
||||
});
|
||||
let id = FunctionId::try_new(TABLE_RESOLVE_FUNCTION_ID).expect("valid FunctionId");
|
||||
|
||||
let err = table
|
||||
.resolve_function_for_generated_column(&id)
|
||||
.await
|
||||
.expect_err("missing Function must fail");
|
||||
match &err {
|
||||
Error::Function { code, message } => {
|
||||
assert_eq!(code.as_str(), "name_or_function_not_found");
|
||||
assert_ne!(code.as_str(), "definition_validation_failure");
|
||||
assert!(
|
||||
!message.contains(TABLE_RESOLVE_SERVER_MESSAGE_MARKER),
|
||||
"Function error message must be sanitized, got {message}"
|
||||
);
|
||||
assert!(
|
||||
!message.contains(TABLE_RESOLVE_FUNCTION_ID),
|
||||
"Function error message must not echo FunctionId, got {message}"
|
||||
);
|
||||
assert_eq!(
|
||||
message, "function lookup failed",
|
||||
"table-side delegation must preserve the canonical sanitized lookup message"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Error::Function, got {other:?}"),
|
||||
}
|
||||
assert_table_resolve_payload_free(&err);
|
||||
}
|
||||
|
||||
/// Test that schema cache expires after 30 seconds TTL
|
||||
#[tokio::test]
|
||||
async fn test_schema_cache_invalidation_after_ttl() {
|
||||
|
||||
@@ -56,8 +56,8 @@ use crate::embeddings::{EmbeddingDefinition, EmbeddingRegistry, MemoryRegistry};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::function::schema_admission::reject_caller_authored_generated_column_schema_on_overwrite;
|
||||
use crate::function::{
|
||||
ChangeGeneratedColumnJobSpec, CreateGeneratedColumnJobSpec, GeneratedColumnBindingSnapshot,
|
||||
GeneratedColumnStatus, RefreshGeneratedColumnJobSpec,
|
||||
ChangeGeneratedColumnJobSpec, CreateGeneratedColumnJobSpec, Function, FunctionId,
|
||||
GeneratedColumnBindingSnapshot, GeneratedColumnStatus, RefreshGeneratedColumnJobSpec,
|
||||
};
|
||||
use crate::index::IndexStatistics;
|
||||
use crate::index::{Index, IndexBuilder};
|
||||
@@ -690,6 +690,23 @@ pub trait BaseTable: std::fmt::Display + std::fmt::Debug + Send + Sync {
|
||||
message: "submit_refresh_generated_column is not supported on this table type".into(),
|
||||
})
|
||||
}
|
||||
/// Resolve an immutable [`Function`] by exact opaque [`FunctionId`] for
|
||||
/// generated-column SDK authoring.
|
||||
///
|
||||
/// Hidden table-side exact-ID catalog seam. Database-scoped even on branch
|
||||
/// table handles. Default returns [`Error::NotSupported`] so existing
|
||||
/// third-party [`BaseTable`] implementations keep compiling and Native/local
|
||||
/// tables have no catalog fallback.
|
||||
#[doc(hidden)]
|
||||
async fn resolve_function_for_generated_column(
|
||||
&self,
|
||||
_function_id: &FunctionId,
|
||||
) -> Result<Function> {
|
||||
Err(Error::NotSupported {
|
||||
message: "resolve_function_for_generated_column is not supported on this table type"
|
||||
.into(),
|
||||
})
|
||||
}
|
||||
/// Count the number of rows in this table.
|
||||
async fn count_rows(&self, filter: Option<Filter>) -> Result<usize>;
|
||||
/// Create a physical plan for the query.
|
||||
@@ -1306,6 +1323,23 @@ impl Table {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Resolve an immutable [`Function`] by exact opaque [`FunctionId`] for
|
||||
/// generated-column SDK authoring.
|
||||
///
|
||||
/// Hidden table-side exact-ID catalog seam used by FF-010/FF-011 constructors
|
||||
/// to validate Function identity and signature before submit. Database-scoped
|
||||
/// even when invoked on a branch table handle. Not name lookup, not a Job,
|
||||
/// and not a public catalog inventory API.
|
||||
#[doc(hidden)]
|
||||
pub async fn resolve_function_for_generated_column(
|
||||
&self,
|
||||
function_id: &FunctionId,
|
||||
) -> Result<Function> {
|
||||
self.inner
|
||||
.resolve_function_for_generated_column(function_id)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Count the number of rows in this dataset.
|
||||
///
|
||||
/// # Arguments
|
||||
|
||||
Reference in New Issue
Block a user