@@ -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) ]
6567pub 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) ]
249308fn 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