Skip to content

Commit f21cf2c

Browse files
ahrzbclaude
authored andcommitted
feat: frontend extern binding + width-k WideOut lanes (WIP salvage, compiles + 189 rust tests green)
Salvaged from the interrupted background run: unknown-fn -> ExternCall binding in the frontend, fold/lower/plan wiring, wide-output lane expansion with WideOut reassembly specs. pyo3 udfs= surface still to come. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1e5b135 commit f21cf2c

7 files changed

Lines changed: 590 additions & 24 deletions

File tree

packages/confit/src/duckdb/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -696,7 +696,7 @@ impl DuckDBInferFn {
696696

697697
use super::specializer::PrepareError;
698698
let prepared = match prepare_opaque(
699-
&sql, &row_table, &in_cols, &opaque, &structs, &catalog, many,
699+
&sql, &row_table, &in_cols, &opaque, &structs, &catalog, many, &[],
700700
) {
701701
Ok(p) => p,
702702
// Unsupported/unparseable SQL might still be a static-tables-only

packages/confit/src/specializer/fold.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,20 @@ pub fn fold(e: SExpr) -> SExpr {
5454
match kind {
5555
SKind::Col(_) | SKind::StaticCol { .. } | SKind::Lit(_) | SKind::NullOf
5656
| SKind::JoinHit(_) => e(kind),
57+
// Opaque call: fold the args, never the call itself.
58+
SKind::ExternCall {
59+
site,
60+
ext,
61+
args,
62+
ret,
63+
whole,
64+
} => e(SKind::ExternCall {
65+
site,
66+
ext,
67+
args: args.into_iter().map(fold).collect(),
68+
ret,
69+
whole,
70+
}),
5771
SKind::IntToFloat(inner) => {
5872
let inner = fold(*inner);
5973
match as_const(&inner) {

packages/confit/src/specializer/frontend.rs

Lines changed: 236 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,11 @@ fn unsup(what: impl Into<String>) -> PrepareError {
5959
PrepareError::Unsupported(what.into())
6060
}
6161

62-
/// SQL text + the dynamic table's name/schema + the static-table catalog ->
63-
/// bound relational tree, the equi-joins in FROM order, and the derived
64-
/// output schema.
62+
/// SQL text + the dynamic table's name/schema + the static-table catalog
63+
/// (+ declared UDF externs) -> bound relational tree, the equi-joins in
64+
/// FROM order, the derived output schema, and the width-k UDF output
65+
/// fields (DRAFT-22).
66+
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
6567
pub fn frontend(
6668
sql: &str,
6769
this_name: &str,
@@ -70,7 +72,17 @@ pub fn frontend(
7072
structs: &[super::plan::StructCol],
7173
statics: &[StaticTable],
7274
many: bool,
73-
) -> Result<(Rel, Vec<JoinSpec>, Vec<Col>, Vec<super::ir::ReSpec>), PrepareError> {
75+
udfs: &[super::ir::ExternSpec],
76+
) -> Result<
77+
(
78+
Rel,
79+
Vec<JoinSpec>,
80+
Vec<Col>,
81+
Vec<super::ir::ReSpec>,
82+
Vec<super::WideOut>,
83+
),
84+
PrepareError,
85+
> {
7486
// GenericDialect, not DuckDbDialect: measured as a strict superset for
7587
// the forms we serve (adds ^@, * ILIKE, * RENAME) and matches the oracle
7688
// path in datafusion/plan.rs — see pins-wave5/sqlparser-spike.json.
@@ -132,10 +144,11 @@ pub fn frontend(
132144
}
133145

134146
let (binder, joins, leftover_where) =
135-
bind_from(select, this_name, in_cols, opaque, structs, statics, many)?;
147+
bind_from(select, this_name, in_cols, opaque, structs, statics, many, udfs)?;
136148

137149
let mut out_cols = Vec::new();
138150
let mut exprs = Vec::new();
151+
let mut wide_outs: Vec<super::WideOut> = Vec::new();
139152
let push_item = |out_cols: &mut Vec<Col>,
140153
exprs: &mut Vec<SExpr>,
141154
name: String,
@@ -151,9 +164,41 @@ pub fn frontend(
151164
exprs.push(e);
152165
Ok(())
153166
};
167+
// A bare width-k UDF item expands to its whole-validity lane plus k
168+
// component lanes; the WideOut records how the boundary reassembles
169+
// them into ONE `list | None` field (DRAFT-22 step 2).
170+
let push_wide = |out_cols: &mut Vec<Col>,
171+
exprs: &mut Vec<SExpr>,
172+
wide_outs: &mut Vec<super::WideOut>,
173+
base: String,
174+
lanes: Vec<(String, SExpr)>|
175+
-> Result<(), PrepareError> {
176+
let first = out_cols.len() as u32;
177+
let width = (lanes.len() - 1) as u32;
178+
for (name, ex) in lanes {
179+
out_cols.push(Col {
180+
name,
181+
ty: super::ir::ColTy {
182+
ty: ex.ty,
183+
nullable: ex.nullable,
184+
},
185+
});
186+
exprs.push(ex);
187+
}
188+
wide_outs.push(super::WideOut {
189+
name: base,
190+
first,
191+
width,
192+
});
193+
Ok(())
194+
};
154195
for item in &select.projection {
155196
match item {
156197
SelectItem::UnnamedExpr(e) => {
198+
if let Some(lanes) = binder.wide_extern_lanes(e, &default_name(e))? {
199+
push_wide(&mut out_cols, &mut exprs, &mut wide_outs, default_name(e), lanes)?;
200+
continue;
201+
}
157202
// COLUMNS('re') expands like a filtered star, keeping the
158203
// bare column names (wave-B pins).
159204
if let Some(cols) = binder.expand_columns_item(e)? {
@@ -170,6 +215,18 @@ pub fn frontend(
170215
}
171216
}
172217
SelectItem::ExprWithAlias { expr, alias } => {
218+
if let Some(lanes) = binder.wide_extern_lanes(expr, &alias.value)? {
219+
// No lateral-alias registration: the assembled field is
220+
// a list, which no scalar expression can reference.
221+
push_wide(
222+
&mut out_cols,
223+
&mut exprs,
224+
&mut wide_outs,
225+
alias.value.clone(),
226+
lanes,
227+
)?;
228+
continue;
229+
}
173230
// An alias on COLUMNS stamps EVERY expansion (duplicates
174231
// feed the dedup rename — measured).
175232
if let Some(cols) = binder.expand_columns_item(expr)? {
@@ -240,12 +297,14 @@ pub fn frontend(
240297
joins,
241298
out_cols,
242299
binder.regexes.into_inner(),
300+
wide_outs,
243301
))
244302
}
245303

246304
/// Parse and bind the FROM clause: the dynamic table, then zero or more
247305
/// equi-joins to static tables. Returns the fully-scoped binder (every join
248306
/// visible) and the join specs in FROM order.
307+
#[allow(clippy::too_many_arguments)]
249308
fn bind_from<'a>(
250309
select: &sqlparser::ast::Select,
251310
this_name: &str,
@@ -254,6 +313,7 @@ fn bind_from<'a>(
254313
structs: &'a [super::plan::StructCol],
255314
statics: &'a [StaticTable],
256315
many: bool,
316+
udfs: &'a [super::ir::ExternSpec],
257317
) -> Result<(Binder<'a>, Vec<JoinSpec>, Option<SqlExpr>), PrepareError> {
258318
// Plain scalar columns occupy in_cols[..n_plain]; struct leaf lanes
259319
// follow and are addressable ONLY through their struct paths.
@@ -344,6 +404,8 @@ fn bind_from<'a>(
344404
.collect(),
345405
bound_aliases: std::cell::RefCell::new(Vec::new()),
346406
regexes: std::cell::RefCell::new(Vec::new()),
407+
udfs,
408+
sites: std::cell::Cell::new(0),
347409
};
348410
let mut specs: Vec<JoinSpec> = Vec::new();
349411

@@ -1032,6 +1094,12 @@ struct Binder<'a> {
10321094
/// Program regex table under construction (wave-B); indices are baked
10331095
/// into ReMatch/ReExtract/ReReplace nodes.
10341096
regexes: std::cell::RefCell<Vec<super::ir::ReSpec>>,
1097+
/// Declared UDF externs (DRAFT-22): an unknown function matching one
1098+
/// binds as an opaque ecall instead of the named refusal.
1099+
udfs: &'a [super::ir::ExternSpec],
1100+
/// Call-site counter: the k+1 lanes of one width-k call share a site
1101+
/// so lowering executes the callable once per row.
1102+
sites: std::cell::Cell<u32>,
10351103
}
10361104

10371105
/// The bound subject of a regex op must be VARCHAR (no implicit casts —
@@ -3211,6 +3279,138 @@ impl Binder<'_> {
32113279
/// The v0 builtin catalogue. Everything here follows the measured pins
32123280
/// in docs/superpowers/specs/2026-07-26-stretch4-builtin-pins.md; names
32133281
/// not listed reject as clean unsupported.
3282+
/// The declared UDF matching `name` (case-insensitive), if any.
3283+
fn find_udf(&self, name: &str) -> Option<(u32, &super::ir::ExternSpec)> {
3284+
self.udfs
3285+
.iter()
3286+
.enumerate()
3287+
.find(|(_, u)| u.name.eq_ignore_ascii_case(name))
3288+
.map(|(i, u)| (i as u32, u))
3289+
}
3290+
3291+
fn fresh_site(&self) -> u32 {
3292+
let s = self.sites.get();
3293+
self.sites.set(s + 1);
3294+
s
3295+
}
3296+
3297+
/// Bind and type-check a UDF call's arguments against its declared
3298+
/// params. Bare NULLs adopt the param type; an i64 argument against a
3299+
/// declared f64 promotes exactly like DuckDB's implicit cast; anything
3300+
/// else refuses by name.
3301+
fn bind_udf_args(
3302+
&self,
3303+
f: &sqlparser::ast::Function,
3304+
spec: &super::ir::ExternSpec,
3305+
) -> Result<Vec<SExpr>, PrepareError> {
3306+
use sqlparser::ast::{FunctionArg, FunctionArgExpr, FunctionArguments};
3307+
let FunctionArguments::List(list) = &f.args else {
3308+
return Err(unsup(format!(
3309+
"function {} without an argument list",
3310+
f.name
3311+
)));
3312+
};
3313+
if !list.clauses.is_empty() || list.duplicate_treatment.is_some() {
3314+
return Err(unsup(format!("function {} argument clauses", f.name)));
3315+
}
3316+
let mut raw: Vec<&SqlExpr> = Vec::with_capacity(list.args.len());
3317+
for a in &list.args {
3318+
match a {
3319+
FunctionArg::Unnamed(FunctionArgExpr::Expr(e)) => raw.push(e),
3320+
_ => return Err(unsup(format!("function {} argument form", f.name))),
3321+
}
3322+
}
3323+
if raw.len() != spec.params.len() {
3324+
return Err(PrepareError::Bind(format!(
3325+
"udf '{}' takes {} argument(s), the call passes {}",
3326+
spec.name,
3327+
spec.params.len(),
3328+
raw.len()
3329+
)));
3330+
}
3331+
let mut out = Vec::with_capacity(raw.len());
3332+
for (i, (arg, &pt)) in raw.iter().zip(&spec.params).enumerate() {
3333+
let bound = match self.expr_or_null(arg)? {
3334+
None => null_of(pt),
3335+
Some(e) => fold(e),
3336+
};
3337+
let bound = match (bound.ty, pt) {
3338+
(a, b) if a == b => bound,
3339+
(Ty::I64, Ty::F64) => promote_f64(bound),
3340+
(a, b) => {
3341+
return Err(PrepareError::Bind(format!(
3342+
"udf '{}' argument {} is {}, declared {}",
3343+
spec.name,
3344+
i + 1,
3345+
a.name(),
3346+
b.name()
3347+
)))
3348+
}
3349+
};
3350+
out.push(bound);
3351+
}
3352+
Ok(out)
3353+
}
3354+
3355+
/// A bare width-k (k >= 2) UDF call as a projection item expands to a
3356+
/// whole-validity lane plus k nullable component lanes sharing one call
3357+
/// site; `None` for anything else (width-1 calls stay ordinary scalar
3358+
/// expressions). Lane names carry U+0001 (reserved at the SQL gate, so
3359+
/// no user column can collide).
3360+
fn wide_extern_lanes(
3361+
&self,
3362+
e: &SqlExpr,
3363+
base: &str,
3364+
) -> Result<Option<Vec<(String, SExpr)>>, PrepareError> {
3365+
let mut inner = e;
3366+
while let SqlExpr::Nested(i) = inner {
3367+
inner = i;
3368+
}
3369+
let SqlExpr::Function(f) = inner else {
3370+
return Ok(None);
3371+
};
3372+
let Some((ext, spec)) = self.find_udf(&f.name.to_string()) else {
3373+
return Ok(None);
3374+
};
3375+
if spec.rets.len() < 2 {
3376+
return Ok(None);
3377+
}
3378+
let args = self.bind_udf_args(f, spec)?;
3379+
let site = self.fresh_site();
3380+
let mut lanes = Vec::with_capacity(1 + spec.rets.len());
3381+
lanes.push((
3382+
format!("{base}\u{1}valid"),
3383+
SExpr {
3384+
kind: SKind::ExternCall {
3385+
site,
3386+
ext,
3387+
args: args.clone(),
3388+
ret: 0,
3389+
whole: true,
3390+
},
3391+
ty: Ty::I1,
3392+
nullable: false,
3393+
},
3394+
));
3395+
for (j, &rt) in spec.rets.iter().enumerate() {
3396+
lanes.push((
3397+
format!("{base}\u{1}{j}"),
3398+
SExpr {
3399+
kind: SKind::ExternCall {
3400+
site,
3401+
ext,
3402+
args: args.clone(),
3403+
ret: j as u32,
3404+
whole: false,
3405+
},
3406+
ty: rt,
3407+
nullable: true,
3408+
},
3409+
));
3410+
}
3411+
Ok(Some(lanes))
3412+
}
3413+
32143414
fn function(&self, f: &sqlparser::ast::Function) -> Result<SExpr, PrepareError> {
32153415
use sqlparser::ast::{FunctionArg, FunctionArgExpr, FunctionArguments};
32163416
let name = f.name.to_string().to_lowercase();
@@ -4237,10 +4437,37 @@ impl Binder<'_> {
42374437
nullable,
42384438
})
42394439
}
4240-
_ => Err(unsup(format!(
4241-
"function {} (not in the v0 catalogue)",
4242-
f.name
4243-
))),
4440+
_ => {
4441+
// Declared UDF externs (DRAFT-22): width-1 is an ordinary
4442+
// scalar expression; width-k is bare-item-only (handled in
4443+
// the projection loop), so reaching it here is refused.
4444+
if let Some((ext, spec)) = self.find_udf(&f.name.to_string()) {
4445+
if spec.rets.len() != 1 {
4446+
return Err(unsup(format!(
4447+
"width-{} udf '{}' used as a scalar expression \
4448+
(multi-output transformer calls must be bare SELECT items)",
4449+
spec.rets.len(),
4450+
spec.name
4451+
)));
4452+
}
4453+
let args = self.bind_udf_args(f, spec)?;
4454+
return Ok(SExpr {
4455+
kind: SKind::ExternCall {
4456+
site: self.fresh_site(),
4457+
ext,
4458+
args,
4459+
ret: 0,
4460+
whole: false,
4461+
},
4462+
ty: spec.rets[0],
4463+
nullable: true,
4464+
});
4465+
}
4466+
Err(unsup(format!(
4467+
"function {} (not in the v0 catalogue)",
4468+
f.name
4469+
)))
4470+
}
42444471
}
42454472
}
42464473

0 commit comments

Comments
 (0)