Skip to content

Commit fd60bce

Browse files
bakeyclaude
andauthored
feat(sources): Open Connector UDTFs, security policy, and observability (#165)
* feat(sources): Open Connector UDTFs, security policy, and observability Milestone 4 of the Open Connector integration: the interactive SQL surface, per docs/superpowers/specs/2026-07-11-open-connector-integration-tasks.md. - open_connector_query(gateway, 'pack.table', resource_json[, alias]): runs a built-in source-pack table without a persistent YAML binding, compiling into the exact scan a bound table uses — same stable schema, filter allowlist, fingerprint gate, safety bounds, and the same per-gateway scan cache. Plans against registration-time discovery, so an undiscovered action is a targeted planning error (planning never performs network I/O). - open_connector_scan(gateway, action_id, input_json, row_path[, alias]): executes an explicitly allowlisted raw read action once (new PaginationStrategy::SinglePage; always live, no filter pushdown), with a deterministic row type derived at planning time from the discovered output schema (raw_schema.rs) — primitives typed, ["T","null"] unions nullable, everything else opaque JSON; indeterminate shapes fail with an error recommending a source pack. - Security, default-deny and pre-HTTP: discovery gains a read_only flag; raw actions require allowlist membership AND an explicit read-only classification (mutating vs unclassified rejected with distinct errors before any request); tests pin that YAML bindings cannot override pack action/row_path/pagination/columns. - Observability: scan completion/failure tracing events with gateway, binding, table, action, cache hit, pages, rows, and duration — never tokens, inputs, or bodies. - Engine refactor to enable dynamic schemas: OpenConnectorExec takes an owned ScanTarget instead of &'static SourcePackTable; RowConverter accepts owned ColumnSpecs. - Wiring: register_open_connector_tables publishes a GatewayHandle into a shared OpenConnectorGateways map (server OptimizerRegistry / CLI), mirroring the DatasetRegistry pattern; UDTFs registered on both front-ends. - Docs: docs/open-connector.md guide, README supported-sources entry, milestone checklist updated. Verification: 147 open_connector tests (UDTF/YAML parity, shared-cache replay with zero new requests, single-POST raw scan, pre-HTTP security rejections asserted via recorded gateway traffic, federated CSV join); full skardi/server/CLI suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sources): emit Open Connector scan-completion event with the final batch A satisfied downstream LIMIT drops the scan stream without another poll (DataFusion's LimitStream clears its input on the batch that fills the fetch), so the completion event — previously emitted only from the early-return branch of the NEXT next_page call — never fired for LIMIT-satisfied scans. The docs' every-scan / exactly-once claims did not hold for the most common query shape. Log eagerly instead, with the final batch, wherever the scan is known complete (guarded by completion_logged): - live path: after the row counter includes the final batch, whenever `done` is set (covers LIMIT-satisfied AND short-final-page exhaustion, which had the same latent dependence on one more poll); - cache-replay path: when the replay queue drains (cached LIMIT queries replay under the same never-polled-again consumer). Logging after the row-count update keeps the event's `rows` field accurate — logging inside the LIMIT branch itself would under-count by the final batch. Regression tests drive ScanState directly against the mock gateway and assert completion_logged flips exactly on the final batch for all three shapes (LIMIT-satisfied, non-empty terminal page, cache replay), plus accurate row totals. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(sources): state the Open Connector failure-event contents accurately The scan-failure event's comment, docs/open-connector.md, and the milestone checklist claimed errors "carry JSON kinds only" / "response bodies are never logged". That is true for conversion and row-path failures, but ActionExecutionFailed deliberately quotes a bounded (<=512-char) snippet of the gateway's *error* response (the milestone-2 diagnostic for terminal provider failures, which can echo request identifiers such as owner/repo), and PaginationLoop carries the offending cursor. Align the three claims with reality instead of filtering the event: the same error display also reaches the SQL client and the front-ends' registration logs, so redacting one log site would not make the promise true — it would only strip the diagnostic where operators look first. The design spec's actual commitment (no tokens, credentials, authorization headers, or full sensitive inputs) was never violated and is now what the comment, guide, and checklist say. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(sources): pin Open Connector scan events with a tracing capture The completion/failure-event invariants were asserted only through ScanState's completion_logged flag — nothing verified that the events are actually emitted, exactly once, with the documented fields. Add a test-only tracing capture to testutil (a minimal Subscriber recording level/message/fields into a shared vec, installed as a thread-local default — #[tokio::test] bodies run single-threaded, so parallel tests stay isolated) and four event-level tests that consume the real execute() stream: - LIMIT-satisfied scan: one batch polled, stream dropped without a further poll (the LimitStream shape) → exactly one INFO completion event with gateway/binding/table/action, cache_hit=false, pages=1, rows=1, duration_ms; - empty scan: polled to None (and once past it) → exactly one completion with rows=0; - cache replay: two full scans → exactly two completions, the second cache_hit=true / pages=0 / rows=3 (the done-branch re-log is guarded, so draining the queue and polling to None stays at one); - terminal 5xx on execute → exactly one WARN failure event with the scan identity, pages_fetched=0, and the HTTP status in the error — and no completion event. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sources): convert JSON null to SQL NULL in opaque Json columns The FieldType::Json arm stringified cells directly, so a present JSON null (Some(&Value::Null) — RowPath::extract returns the value when the key exists) became the 4-char string "null" instead of Arrow null, ignoring the column's nullability. Every other arm already routes present nulls through collect_cells (nullable -> Arrow null, non-nullable -> targeted per-column failure). This got hot with open_connector_scan: raw schemas type every object/array/wide-union field as nullable Json, and provider nulls (assignee: null, user: null) are ubiquitous — WHERE x IS NULL matched nothing while x = 'null' matched. Route the Json arm through collect_cells like the rest: JSON null is SQL NULL for nullable columns and a targeted ConversionFailed (found: "null") for non-nullable ones, instead of surfacing later as a batch-level nullability error. Tests: unit coverage for present-null vs absent-key (both Arrow null) and the non-nullable failure; an end-to-end raw-scan test pinning the SQL semantics (IS NULL matches the provider null, = 'null' does not). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(sources): pin the fetch-time semantics of pages_fetched A review suggested moving the increment after the post-fetch deadline check so a page that lands right at the deadline is not counted in the failure event. Declined: pages_fetched measures gateway traffic (requests actually made, rate-limit budget actually spent), not pages emitted — a failed scan emits nothing at all, so an emission reading of the field is meaningless there, and moving the increment would under-report real gateway load in timeout diagnostics while staying inconsistent with the extraction/conversion failure paths right below. Document the intent at the increment so the placement reads as a decision, not an accident. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(sources): document the registration-snapshot staleness window The raw-scan security gates (allowlist + read-only classification) read metadata discovered at registration; planning never re-contacts the gateway by design. An upstream action that turns mutating after registration therefore keeps passing the Skardi-side gate until the next restart or configuration reload. Spell that window out in the UDTF module docs and the security-model section of the guide — the same snapshot covers executability and contract fingerprints — and name Open Connector's own action policies as the live, independent boundary during it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli): thread UDTF gateways through the ClickHouse test call sites Merging main brought in the ClickHouse provider (#157), whose two register_source tests still destructured new_session_context() as a 2-tuple; this branch extended it to also return the Open Connector gateway map. Update the new call sites to the 3-tuple and pass the map. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 98364e1 commit fd60bce

19 files changed

Lines changed: 2678 additions & 140 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ For end-to-end walkthroughs — RAG, recommendations, an agent-native wiki, a si
330330
| S3 / GCS / Azure | Read | No | CSV, Parquet, Lance from object stores | [docs/S3_USAGE.md](docs/S3_USAGE.md) |
331331
| Apache Iceberg | Read | No | Schema evolution, partition pruning | [docs/iceberg/](docs/iceberg/) |
332332
| InfluxDB 3 | Read | No | Time-series measurements over Arrow Flight SQL | [docs/influxdb/](docs/influxdb/) |
333+
| Open Connector | Read | Yes | SaaS resources as stable SQL tables via a self-hosted [Open Connector](https://github.com/oomol-lab/open-connector) gateway; `open_connector_query` / `open_connector_scan` UDTFs, filter + limit pushdown, bounded TTL cache (foundation + synthetic pack today; provider packs rolling out) | [docs/open-connector.md](docs/open-connector.md) |
333334
| Documents | Read | No | PDF/Office/ODF/image -> per-page markdown, tables, images (local directories; `documents` feature) | [docs/documents.md](docs/documents.md) |
334335

335336
---

crates/cli/src/main.rs

Lines changed: 64 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ use skardi::sources::providers::{
5252
lance::register_lance_table,
5353
mongo::register_mongo_tables,
5454
mysql::register_mysql_tables,
55-
open_connector::{OpenConnectorConfig, register_open_connector_tables},
55+
open_connector::{
56+
OpenConnectorConfig, OpenConnectorGateways, register_open_connector_tables,
57+
register_open_connector_udtfs,
58+
},
5659
sqlite::{
5760
register_sqlite_fts_udtf, register_sqlite_knn_udtf, register_sqlite_tables,
5861
register_vec_to_binary_udf,
@@ -479,9 +482,10 @@ impl UrlTableFactory for SkardiUrlTableFactory {
479482
}
480483

481484
/// Create a new SessionContext with custom URL table support (built-in files + Lance)
482-
/// and the `lance_knn` / `pg_knn` UDTFs registered.
483-
fn new_session_context() -> (SessionContext, DatasetRegistry) {
485+
/// and the `lance_knn` / `pg_knn` / Open Connector UDTFs registered.
486+
fn new_session_context() -> (SessionContext, DatasetRegistry, OpenConnectorGateways) {
484487
let dataset_registry: DatasetRegistry = Arc::new(RwLock::new(HashMap::new()));
488+
let open_connector_gateways = OpenConnectorGateways::default();
485489
let session_store = SessionStore::new();
486490
let factory = Arc::new(SkardiUrlTableFactory::new(
487491
session_store,
@@ -516,6 +520,9 @@ fn new_session_context() -> (SessionContext, DatasetRegistry) {
516520
register_sqlite_knn_udtf(&ctx, Arc::clone(&dataset_registry));
517521
register_sqlite_fts_udtf(&ctx, Arc::clone(&dataset_registry));
518522
register_vec_to_binary_udf(&mut ctx);
523+
// Open Connector UDTFs plan against the gateway state that
524+
// register_open_connector_tables fills in during ctx registration.
525+
register_open_connector_udtfs(&ctx, Arc::clone(&open_connector_gateways));
519526

520527
// Embedding UDFs (gated by feature flags, lazy model loading on first call).
521528
#[cfg(feature = "onnx")]
@@ -544,7 +551,7 @@ fn new_session_context() -> (SessionContext, DatasetRegistry) {
544551
registry.register_chunk_udf(&mut ctx);
545552
}
546553

547-
(ctx, dataset_registry)
554+
(ctx, dataset_registry, open_connector_gateways)
548555
}
549556

550557
/// Resolve a path string: if relative (and not remote), resolve against cwd.
@@ -733,13 +740,19 @@ async fn load_and_register_all(
733740
ctx_path: &Path,
734741
session_ctx: &mut SessionContext,
735742
dataset_registry: &DatasetRegistry,
743+
open_connector_gateways: &OpenConnectorGateways,
736744
) -> Result<LocalContextConfig> {
737745
let config = read_context_file(ctx_path)?;
738746

739747
for source in &config.data_sources {
740-
register_source(session_ctx, source, dataset_registry)
741-
.await
742-
.with_context(|| format!("Failed to register data source '{}'", source.name))?;
748+
register_source(
749+
session_ctx,
750+
source,
751+
dataset_registry,
752+
open_connector_gateways,
753+
)
754+
.await
755+
.with_context(|| format!("Failed to register data source '{}'", source.name))?;
743756
}
744757

745758
Ok(config)
@@ -774,6 +787,7 @@ async fn register_source(
774787
session_ctx: &mut SessionContext,
775788
source: &LocalDataSource,
776789
dataset_registry: &DatasetRegistry,
790+
open_connector_gateways: &OpenConnectorGateways,
777791
) -> Result<()> {
778792
let source_type = source.source_type.to_lowercase();
779793

@@ -957,6 +971,7 @@ async fn register_source(
957971
source.open_connector.as_ref(),
958972
source.is_read_write(),
959973
source.hierarchy_level,
974+
Some(open_connector_gateways),
960975
)
961976
.await
962977
.with_context(|| format!("Failed to register Open Connector '{}'", source.name))?;
@@ -1222,8 +1237,9 @@ async fn show_schema(
12221237
table_filter: Option<&str>,
12231238
out: &mut dyn Write,
12241239
) -> Result<()> {
1225-
let (mut session_ctx, dataset_registry) = new_session_context();
1226-
let config = load_and_register_all(ctx_path, &mut session_ctx, &dataset_registry).await?;
1240+
let (mut session_ctx, dataset_registry, oc_gateways) = new_session_context();
1241+
let config =
1242+
load_and_register_all(ctx_path, &mut session_ctx, &dataset_registry, &oc_gateways).await?;
12271243

12281244
// Build the semantics registry from the same inputs the server uses:
12291245
// - the ctx-inline `description` field on each data source (fallback), and
@@ -1368,12 +1384,13 @@ fn source_name_for<'a>(
13681384
/// data sources first. If no context file is found, run the query in a bare session with
13691385
/// URL table support (allowing direct file/lance paths in SQL).
13701386
async fn run_query(ctx_override: Option<PathBuf>, sql: &str) -> Result<()> {
1371-
let (mut session_ctx, dataset_registry) = new_session_context();
1387+
let (mut session_ctx, dataset_registry, oc_gateways) = new_session_context();
13721388

13731389
// Try to load context file, but don't fail if not found when no explicit --ctx was given
13741390
match resolve_ctx_path(ctx_override.as_deref()) {
13751391
Some(ctx_path) if ctx_path.exists() => {
1376-
load_and_register_all(&ctx_path, &mut session_ctx, &dataset_registry).await?;
1392+
load_and_register_all(&ctx_path, &mut session_ctx, &dataset_registry, &oc_gateways)
1393+
.await?;
13771394
}
13781395
Some(ctx_path) if ctx_override.is_some() => {
13791396
anyhow::bail!("Context file not found: {}", ctx_path.display());
@@ -1525,9 +1542,9 @@ async fn run_pipeline_with_params(
15251542
.with_context(|| format!("Failed to render SQL for pipeline '{}'", pipeline_name))?;
15261543

15271544
// 3. Build a SessionContext with ctx data sources registered, then execute.
1528-
let (mut session_ctx, dataset_registry) = new_session_context();
1545+
let (mut session_ctx, dataset_registry, oc_gateways) = new_session_context();
15291546
if let Some(p) = &ctx_path_for_load {
1530-
load_and_register_all(p, &mut session_ctx, &dataset_registry).await?;
1547+
load_and_register_all(p, &mut session_ctx, &dataset_registry, &oc_gateways).await?;
15311548
}
15321549

15331550
auto_register_object_stores_from_sql(&session_ctx, &sql)?;
@@ -2879,10 +2896,15 @@ spec:
28792896

28802897
#[tokio::test]
28812898
async fn errors_without_connection_string() {
2882-
let (mut session_ctx, registry) = new_session_context();
2883-
let err = register_source(&mut session_ctx, &dynamodb_source(None), &registry)
2884-
.await
2885-
.unwrap_err();
2899+
let (mut session_ctx, registry, oc_gateways) = new_session_context();
2900+
let err = register_source(
2901+
&mut session_ctx,
2902+
&dynamodb_source(None),
2903+
&registry,
2904+
&oc_gateways,
2905+
)
2906+
.await
2907+
.unwrap_err();
28862908
let msg = format!("{err:?}");
28872909
assert!(
28882910
msg.contains("connection_string (endpoint URL) required"),
@@ -2892,9 +2914,9 @@ spec:
28922914

28932915
#[tokio::test]
28942916
async fn errors_without_options() {
2895-
let (mut session_ctx, registry) = new_session_context();
2917+
let (mut session_ctx, registry, oc_gateways) = new_session_context();
28962918
let source = dynamodb_source(Some("http://localhost:8000"));
2897-
let err = register_source(&mut session_ctx, &source, &registry)
2919+
let err = register_source(&mut session_ctx, &source, &registry, &oc_gateways)
28982920
.await
28992921
.unwrap_err();
29002922
let msg = format!("{err:?}");
@@ -2927,10 +2949,15 @@ spec:
29272949

29282950
#[tokio::test]
29292951
async fn errors_without_connection_string() {
2930-
let (mut session_ctx, registry) = new_session_context();
2931-
let err = register_source(&mut session_ctx, &clickhouse_source(None), &registry)
2932-
.await
2933-
.unwrap_err();
2952+
let (mut session_ctx, registry, oc_gateways) = new_session_context();
2953+
let err = register_source(
2954+
&mut session_ctx,
2955+
&clickhouse_source(None),
2956+
&registry,
2957+
&oc_gateways,
2958+
)
2959+
.await
2960+
.unwrap_err();
29342961
let msg = format!("{err:?}");
29352962
assert!(
29362963
msg.contains("connection_string required"),
@@ -2943,10 +2970,10 @@ spec:
29432970
// The provider is the single enforcement point for the read-only
29442971
// invariant — the CLI must reject read_write exactly like the
29452972
// server's UnsupportedWriteMode.
2946-
let (mut session_ctx, registry) = new_session_context();
2973+
let (mut session_ctx, registry, oc_gateways) = new_session_context();
29472974
let mut source = clickhouse_source(Some("http://127.0.0.1:1"));
29482975
source.access_mode = Some("read_write".to_string());
2949-
let err = register_source(&mut session_ctx, &source, &registry)
2976+
let err = register_source(&mut session_ctx, &source, &registry, &oc_gateways)
29502977
.await
29512978
.unwrap_err();
29522979
let msg = format!("{err:?}");
@@ -2988,9 +3015,9 @@ bindings:
29883015

29893016
#[tokio::test]
29903017
async fn errors_without_connection_string() {
2991-
let (mut session_ctx, registry) = new_session_context();
3018+
let (mut session_ctx, registry, oc_gateways) = new_session_context();
29923019
let source = open_connector_source(None, Some(VALID_CONFIG));
2993-
let err = register_source(&mut session_ctx, &source, &registry)
3020+
let err = register_source(&mut session_ctx, &source, &registry, &oc_gateways)
29943021
.await
29953022
.unwrap_err();
29963023
let msg = format!("{err:?}");
@@ -3004,11 +3031,11 @@ bindings:
30043031
async fn errors_with_table_hierarchy() {
30053032
// hierarchy_level defaults to Table; the CLI must reject it with
30063033
// a clear message, not the provider's wrapped error.
3007-
let (mut session_ctx, registry) = new_session_context();
3034+
let (mut session_ctx, registry, oc_gateways) = new_session_context();
30083035
let mut source =
30093036
open_connector_source(Some("http://localhost:3000"), Some(VALID_CONFIG));
30103037
source.hierarchy_level = HierarchyLevel::Table;
3011-
let err = register_source(&mut session_ctx, &source, &registry)
3038+
let err = register_source(&mut session_ctx, &source, &registry, &oc_gateways)
30123039
.await
30133040
.unwrap_err();
30143041
let msg = format!("{err:?}");
@@ -3020,9 +3047,9 @@ bindings:
30203047

30213048
#[tokio::test]
30223049
async fn errors_without_typed_config() {
3023-
let (mut session_ctx, registry) = new_session_context();
3050+
let (mut session_ctx, registry, oc_gateways) = new_session_context();
30243051
let source = open_connector_source(Some("http://localhost:3000"), None);
3025-
let err = register_source(&mut session_ctx, &source, &registry)
3052+
let err = register_source(&mut session_ctx, &source, &registry, &oc_gateways)
30263053
.await
30273054
.unwrap_err();
30283055
let msg = format!("{err:?}");
@@ -3038,11 +3065,11 @@ bindings:
30383065
// The provider is the single enforcement point for the
30393066
// read-only invariant — the CLI must reject read_write exactly
30403067
// like the server's UnsupportedWriteMode.
3041-
let (mut session_ctx, registry) = new_session_context();
3068+
let (mut session_ctx, registry, oc_gateways) = new_session_context();
30423069
let mut source =
30433070
open_connector_source(Some("http://localhost:3000"), Some(VALID_CONFIG));
30443071
source.access_mode = Some("read_write".to_string());
3045-
let err = register_source(&mut session_ctx, &source, &registry)
3072+
let err = register_source(&mut session_ctx, &source, &registry, &oc_gateways)
30463073
.await
30473074
.unwrap_err();
30483075
let msg = format!("{err:?}");
@@ -3051,11 +3078,11 @@ bindings:
30513078

30523079
#[tokio::test]
30533080
async fn errors_when_typed_config_on_wrong_type() {
3054-
let (mut session_ctx, registry) = new_session_context();
3081+
let (mut session_ctx, registry, oc_gateways) = new_session_context();
30553082
let mut source =
30563083
open_connector_source(Some("http://localhost:3000"), Some(VALID_CONFIG));
30573084
source.source_type = "csv".to_string();
3058-
let err = register_source(&mut session_ctx, &source, &registry)
3085+
let err = register_source(&mut session_ctx, &source, &registry, &oc_gateways)
30593086
.await
30603087
.unwrap_err();
30613088
let msg = format!("{err:?}");
@@ -3069,11 +3096,11 @@ bindings:
30693096
async fn errors_when_token_env_missing() {
30703097
// With the config valid, the next failure is the unset runtime
30713098
// token — before any network call to the (unroutable) gateway.
3072-
let (mut session_ctx, registry) = new_session_context();
3099+
let (mut session_ctx, registry, oc_gateways) = new_session_context();
30733100
let config =
30743101
VALID_CONFIG.replace("OPEN_CONNECTOR_TOKEN", "SKARDI_CLI_TEST_OC_TOKEN_UNSET");
30753102
let source = open_connector_source(Some("http://127.0.0.1:1"), Some(config.as_str()));
3076-
let err = register_source(&mut session_ctx, &source, &registry)
3103+
let err = register_source(&mut session_ctx, &source, &registry, &oc_gateways)
30773104
.await
30783105
.unwrap_err();
30793106
let msg = format!("{err:?}");

crates/server/src/config.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1421,13 +1421,15 @@ async fn register_data_source(
14211421
// `validate_data_sources` already guarantees the config block is
14221422
// present and access is read-only; the provider re-checks both so
14231423
// the CLI path (which has no such validation layer) is covered.
1424+
let oc_gateways = optimizer_registry.map(|r| r.open_connector_gateways());
14241425
register_open_connector_tables(
14251426
session_ctx,
14261427
&source.name,
14271428
connection_string,
14281429
source.open_connector.as_ref(),
14291430
source.access_mode.is_read_write(),
14301431
source.hierarchy_level,
1432+
oc_gateways.as_ref(),
14311433
)
14321434
.await
14331435
.map_err(|e| {

crates/server/src/optimizer_registry.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ use datafusion::prelude::SessionContext;
1313
use lance::dataset::Dataset;
1414
use skardi::sources::providers::lance::{register_lance_fts_udtf, register_lance_knn_udtf};
1515
use skardi::sources::providers::mongo::fts_table_function::register_mongo_fts_udtf;
16+
use skardi::sources::providers::open_connector::{
17+
OpenConnectorGateways, register_open_connector_udtfs,
18+
};
1619
use skardi::sources::providers::seekdb::{register_seekdb_fts_udtf, register_seekdb_knn_udtf};
1720
use skardi::sources::providers::sqlite::{
1821
register_sqlite_fts_udtf, register_sqlite_knn_udtf, register_vec_to_binary_udf,
@@ -35,13 +38,18 @@ pub struct OptimizerRegistry {
3538
/// Stores both Lance datasets and Postgres entries, used by
3639
/// lance_knn, lance_fts, pg_knn, and pg_fts table functions.
3740
dataset_registry: DatasetRegistry,
41+
/// Open Connector gateway state indexed by gateway (data source) name,
42+
/// filled during data-source registration and used by the
43+
/// open_connector_query / open_connector_scan table functions.
44+
open_connector_gateways: OpenConnectorGateways,
3845
}
3946

4047
impl OptimizerRegistry {
4148
/// Create a new registry
4249
pub fn new() -> Self {
4350
Self {
4451
dataset_registry: Arc::new(RwLock::new(HashMap::new())),
52+
open_connector_gateways: OpenConnectorGateways::default(),
4553
}
4654
}
4755

@@ -109,6 +117,13 @@ impl OptimizerRegistry {
109117
self.register_seekdb_functions(ctx)?;
110118
}
111119

120+
// Register Open Connector table functions
121+
if source_types.contains(&DataSourceType::OpenConnector) {
122+
tracing::info!("Registering Open Connector table functions");
123+
register_open_connector_udtfs(ctx, self.open_connector_gateways());
124+
tracing::info!("✓ Registered open_connector_query and open_connector_scan");
125+
}
126+
112127
Ok(())
113128
}
114129

@@ -204,6 +219,12 @@ impl OptimizerRegistry {
204219
Arc::clone(&self.dataset_registry)
205220
}
206221

222+
/// Get a clone of the Open Connector gateway map to share with
223+
/// `register_open_connector_tables` and the Open Connector UDTFs.
224+
pub fn open_connector_gateways(&self) -> OpenConnectorGateways {
225+
Arc::clone(&self.open_connector_gateways)
226+
}
227+
207228
/// Alias for `datasets()` — used when passing to `register_postgres_tables`.
208229
pub fn pg_knn_pools(&self) -> DatasetRegistry {
209230
self.datasets()
@@ -256,6 +277,44 @@ mod tests {
256277
assert!(result.is_ok());
257278
}
258279

280+
#[tokio::test]
281+
async fn test_register_udfs_with_open_connector_source() {
282+
use crate::config::DataSource;
283+
use std::path::PathBuf;
284+
285+
let registry = OptimizerRegistry::new();
286+
let mut ctx = SessionContext::new();
287+
let data_sources = vec![DataSource {
288+
name: "saas".to_string(),
289+
source_type: DataSourceType::OpenConnector,
290+
path: PathBuf::new(),
291+
connection_string: Some("http://open-connector:3000".to_string()),
292+
schema: None,
293+
options: None,
294+
access_mode: crate::config::AccessMode::default(),
295+
enable_cache: false,
296+
hierarchy_level: Default::default(),
297+
description: None,
298+
open_connector: None,
299+
}];
300+
301+
registry
302+
.register_udfs(&mut ctx, &data_sources)
303+
.expect("register open connector UDTFs");
304+
305+
// The functions are registered; with no gateway handle published
306+
// (this test never ran data-source registration) planning fails with
307+
// the targeted gateway error rather than "function not found".
308+
let err = ctx
309+
.sql("SELECT * FROM open_connector_query('saas', 'mock.items', '{}')")
310+
.await
311+
.expect_err("no gateway state registered");
312+
assert!(
313+
err.to_string().contains("gateway 'saas' is not registered"),
314+
"unexpected error: {err}"
315+
);
316+
}
317+
259318
#[tokio::test]
260319
async fn test_register_udfs_with_csv_only() {
261320
use crate::config::DataSource;

0 commit comments

Comments
 (0)