-module(sqlode@query_analyzer@column_inferencer). -compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]). -define(FILEPATH, "src/sqlode/query_analyzer/column_inferencer.gleam"). -export([extract_values_tables/1, infer_columns_from_tokens/3, infer_columns_from_tokens_scoped/4, extract_derived_tables/3, extract_cte_tables/3, infer_result_columns/6]). -export_type([extracted_column/0, function_class/0, top_level_pattern/0, case_collector_mode/0]). -if(?OTP_RELEASE >= 27). -define(MODULEDOC(Str), -moduledoc(Str)). -define(DOC(Str), -doc(Str)). -else. -define(MODULEDOC(Str), -compile([])). -define(DOC(Str), -compile([])). -endif. -type extracted_column() :: {extracted_column, binary(), gleam@option:option(binary()), gleam@option:option(binary()), gleam@option:option(list(sqlode@lexer:token()))}. -type function_class() :: fn_count | fn_aggregate_inner | fn_avg | fn_window_int | fn_window_float | fn_window_first_arg | fn_coalesce | fn_greatest_least | fn_string | fn_length | fn_math | fn_datetime | fn_nullif | fn_unknown. -type top_level_pattern() :: pat_bool | pat_concat | pat_arithmetic | pat_json | pat_json_text | pat_none. -type case_collector_mode() :: scanning | in_then | in_else. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 118). -spec has_compound_keyword_loop(list(sqlode@lexer:token()), integer()) -> boolean(). has_compound_keyword_loop(Tokens, Depth) -> case Tokens of [] -> false; [l_paren | Rest] -> has_compound_keyword_loop(Rest, Depth + 1); [r_paren | Rest@1] -> has_compound_keyword_loop(Rest@1, case Depth > 0 of true -> Depth - 1; false -> 0 end); [{keyword, K} | Rest@2] when Depth =:= 0 -> case K of <<"union"/utf8>> -> true; <<"except"/utf8>> -> true; <<"intersect"/utf8>> -> true; _ -> has_compound_keyword_loop(Rest@2, 0) end; [_ | Rest@3] -> has_compound_keyword_loop(Rest@3, Depth) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 113). ?DOC(" Check if tokens contain UNION/EXCEPT/INTERSECT (compound query).\n"). -spec has_compound_keyword(list(sqlode@lexer:token())) -> boolean(). has_compound_keyword(Tokens) -> has_compound_keyword_loop(Tokens, 0). -file("src/sqlode/query_analyzer/column_inferencer.gleam", 137). ?DOC(" Check if any select item contains an embed expression.\n"). -spec has_embed_items(list(sqlode@query_ir:select_item())) -> boolean(). has_embed_items(Items) -> gleam@list:any(Items, fun(Item) -> case Item of {expression_item, Expr_tokens, _} -> gleam@list:any(Expr_tokens, fun(Tok) -> case Tok of {ident, Name} -> string:lowercase(Name) =:= <<"embed"/utf8>>; _ -> false end end); _ -> false end end). -file("src/sqlode/query_analyzer/column_inferencer.gleam", 153). ?DOC(" Extract table names from IR FROM items and JOIN clauses.\n"). -spec extract_ir_table_names( list(sqlode@query_ir:from_item()), list(sqlode@query_ir:join_clause()) ) -> list(binary()). extract_ir_table_names(From_items, Joins) -> From_names = gleam@list:filter_map(From_items, fun(Item) -> case Item of {table_ref, _, {some, A}} -> {ok, A}; {table_ref, Name, none} -> {ok, Name}; {subquery_ref, _, {some, A@1}} -> {ok, A@1}; {subquery_ref, _, none} -> {error, nil} end end), Join_names = gleam@list:map(Joins, fun(J) -> case erlang:element(3, J) of {some, A@2} -> A@2; none -> erlang:element(2, J) end end), lists:append(From_names, Join_names). -file("src/sqlode/query_analyzer/column_inferencer.gleam", 252). ?DOC(" Strip trailing AS from expression tokens.\n"). -spec strip_trailing_alias(list(sqlode@lexer:token())) -> list(sqlode@lexer:token()). strip_trailing_alias(Tokens) -> case lists:reverse(Tokens) of [{ident, _}, {keyword, <<"as"/utf8>>} | Rest] -> lists:reverse(Rest); [{quoted_ident, _}, {keyword, <<"as"/utf8>>} | Rest@1] -> lists:reverse(Rest@1); _ -> Tokens end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 261). ?DOC(" Derive a column name from expression tokens (last ident, or table.col pattern).\n"). -spec derive_column_name_from_tokens(list(sqlode@lexer:token())) -> binary(). derive_column_name_from_tokens(Tokens) -> case Tokens of [{ident, Name}] -> Name; [{quoted_ident, Name@1}] -> Name@1; [{ident, _}, dot, {ident, Col}] -> Col; [{ident, _}, dot, {quoted_ident, Col@1}] -> Col@1; _ -> _pipe = Tokens, _pipe@1 = gleam@list:filter_map(_pipe, fun(Tok) -> case Tok of {ident, N} -> {ok, N}; {quoted_ident, N@1} -> {ok, N@1}; _ -> {error, nil} end end), _pipe@2 = gleam@list:last(_pipe@1), gleam@result:unwrap(_pipe@2, <<"?column?"/utf8>>) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 283). ?DOC(" Derive a source table from a simple table.column reference.\n"). -spec derive_source_table_from_tokens(list(sqlode@lexer:token())) -> gleam@option:option(binary()). derive_source_table_from_tokens(Tokens) -> case Tokens of [{ident, Table}, dot, {ident, _}] -> {some, Table}; [{ident, Table@1}, dot, {quoted_ident, _}] -> {some, Table@1}; _ -> none end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 178). ?DOC( " Convert IR SelectItems into ExtractedColumn values for\n" " resolve_select_columns.\n" ). -spec ir_select_items_to_extracted( list(sqlode@query_ir:select_item()), list(binary()) ) -> list(extracted_column()). ir_select_items_to_extracted(Items, _) -> gleam@list:flat_map(Items, fun(Item) -> case Item of {star_item, none} -> [{extracted_column, <<"*"/utf8>>, none, none, none}]; {star_item, {some, Prefix}} -> [{extracted_column, <>, {some, Prefix}, none, none}]; {expression_item, [star], _} -> [{extracted_column, <<"*"/utf8>>, none, none, none}]; {expression_item, [{ident, T}, dot, star], _} -> [{extracted_column, <>, {some, T}, none, none}]; {expression_item, Expr_tokens, {some, Alias}} -> Stripped = strip_trailing_alias(Expr_tokens), Source = derive_source_table_from_tokens(Stripped), [{extracted_column, Alias, Source, none, {some, Stripped}}]; {expression_item, Expr_tokens@1, none} -> Name = derive_column_name_from_tokens(Expr_tokens@1), Source@1 = derive_source_table_from_tokens(Expr_tokens@1), [{extracted_column, Name, Source@1, none, case Source@1 of {some, _} -> none; none -> {some, Expr_tokens@1} end}] end end). -file("src/sqlode/query_analyzer/column_inferencer.gleam", 387). ?DOC( " Pick the scope the resolver should use: the inner FROM tables if any,\n" " else the outer FROM tables (for correlated subqueries without their\n" " own FROM). Returning [] preserves the pre-scope \"empty -> Ok([])\"\n" " behaviour so callers can still detect \"no columns produced\".\n" ). -spec effective_tables(list(binary()), list(binary())) -> list(binary()). effective_tables(Inner_tables, Outer_tables) -> case Inner_tables of [] -> Outer_tables; _ -> Inner_tables end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 478). ?DOC( " Parse the explicit column list from `WITH name(c1, c2, ...) AS (...)`.\n" " Non-ident items are skipped; empty list means no rename.\n" ). -spec parse_cte_column_list(list(sqlode@lexer:token())) -> list(binary()). parse_cte_column_list(Tokens) -> _pipe = sqlode@query_analyzer@token_utils:split_on_commas(Tokens), gleam@list:filter_map(_pipe, fun(Group) -> case Group of [{ident, N}] -> {ok, string:lowercase(N)}; [{quoted_ident, N@1}] -> {ok, string:lowercase(N@1)}; _ -> {error, nil} end end). -file("src/sqlode/query_analyzer/column_inferencer.gleam", 501). -spec rename_columns(list(sqlode@model:column()), list(binary())) -> list(sqlode@model:column()). rename_columns(Columns, Names) -> case {Columns, Names} of {[], _} -> []; {Cols, []} -> Cols; {[Col | Rest_cols], [Name | Rest_names]} -> [{column, Name, erlang:element(3, Col), erlang:element(4, Col)} | rename_columns(Rest_cols, Rest_names)] end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 491). ?DOC( " Rename columns using the explicit column list, in order. Extra body\n" " columns keep their original names; extra explicit names are ignored.\n" ). -spec apply_explicit_column_names(list(sqlode@model:column()), list(binary())) -> list(sqlode@model:column()). apply_explicit_column_names(Columns, Names) -> case Names of [] -> Columns; _ -> rename_columns(Columns, Names) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 519). -spec augment_catalog_with(sqlode@model:catalog(), list(sqlode@model:table())) -> sqlode@model:catalog(). augment_catalog_with(Catalog, Vtables) -> case Vtables of [] -> Catalog; _ -> {catalog, lists:append(erlang:element(2, Catalog), Vtables), erlang:element(3, Catalog)} end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 567). -spec parse_values_alias(list(sqlode@lexer:token())) -> gleam@option:option({binary(), list(binary()), list(sqlode@lexer:token())}). parse_values_alias(Tokens) -> After_as = case Tokens of [{keyword, <<"as"/utf8>>} | Rest] -> Rest; _ -> Tokens end, case After_as of [{ident, Alias}, l_paren | Rest_after_lp] -> {Col_tokens, After_cols} = sqlode@query_analyzer@token_utils:collect_paren_contents( Rest_after_lp ), case parse_cte_column_list(Col_tokens) of [] -> none; Names -> {some, {string:lowercase(Alias), Names, After_cols}} end; [{quoted_ident, Alias@1}, l_paren | Rest_after_lp@1] -> {Col_tokens@1, After_cols@1} = sqlode@query_analyzer@token_utils:collect_paren_contents( Rest_after_lp@1 ), case parse_cte_column_list(Col_tokens@1) of [] -> none; Names@1 -> {some, {string:lowercase(Alias@1), Names@1, After_cols@1}} end; _ -> none end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 626). -spec number_scalar_type(binary()) -> sqlode@model:scalar_type(). number_scalar_type(N) -> case (gleam_stdlib:contains_string(N, <<"."/utf8>>) orelse gleam_stdlib:contains_string( N, <<"e"/utf8>> )) orelse gleam_stdlib:contains_string(N, <<"E"/utf8>>) of true -> float_type; false -> int_type end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 609). -spec infer_literal_type_with_nullability(list(sqlode@lexer:token())) -> {ok, {sqlode@model:scalar_type(), boolean()}} | {error, nil}. infer_literal_type_with_nullability(Tokens) -> case Tokens of [{number_lit, N}] -> {ok, {number_scalar_type(N), false}}; [{operator, <<"-"/utf8>>}, {number_lit, N@1}] -> {ok, {number_scalar_type(N@1), false}}; [{operator, <<"+"/utf8>>}, {number_lit, N@2}] -> {ok, {number_scalar_type(N@2), false}}; [{string_lit, _}] -> {ok, {string_type, false}}; [{keyword, <<"true"/utf8>>}] -> {ok, {bool_type, false}}; [{keyword, <<"false"/utf8>>}] -> {ok, {bool_type, false}}; [{keyword, <<"null"/utf8>>}] -> {ok, {string_type, true}}; _ -> {error, nil} end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 595). -spec infer_values_first_row_types(list(sqlode@lexer:token())) -> {ok, list({sqlode@model:scalar_type(), boolean()})} | {error, nil}. infer_values_first_row_types(Values_body) -> case Values_body of [{keyword, <<"values"/utf8>>}, l_paren | Rest_after_lp] -> {First_row, _} = sqlode@query_analyzer@token_utils:collect_paren_contents( Rest_after_lp ), Items = sqlode@query_analyzer@token_utils:split_on_commas(First_row), gleam@list:try_map(Items, fun infer_literal_type_with_nullability/1); _ -> {error, nil} end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 637). -spec build_values_columns( list(binary()), list({sqlode@model:scalar_type(), boolean()}) ) -> list(sqlode@model:column()). build_values_columns(Names, Types) -> case {Names, Types} of {[], _} -> []; {_, []} -> []; {[Name | Rest_names], [{St, Nullable} | Rest_types]} -> [{column, Name, St, Nullable} | build_values_columns(Rest_names, Rest_types)] end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 541). -spec find_values_tables(list(sqlode@lexer:token()), list(sqlode@model:table())) -> list(sqlode@model:table()). find_values_tables(Tokens, Acc) -> case Tokens of [] -> lists:reverse(Acc); [l_paren, {keyword, <<"values"/utf8>>} | Rest] -> {Values_body, After_rp} = sqlode@query_analyzer@token_utils:collect_paren_contents( [{keyword, <<"values"/utf8>>} | Rest] ), case parse_values_alias(After_rp) of {some, {Alias, Col_names, Remaining}} -> case infer_values_first_row_types(Values_body) of {ok, Types} -> Columns = build_values_columns(Col_names, Types), Table = {table, Alias, Columns}, find_values_tables(Remaining, [Table | Acc]); {error, _} -> find_values_tables(Remaining, Acc) end; none -> find_values_tables(After_rp, Acc) end; [_ | Rest@1] -> find_values_tables(Rest@1, Acc) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 537). ?DOC( " Find every `(VALUES ...) AS alias(c1, c2, ...)` in the token stream\n" " and build a virtual Table for each. Column types come from the first\n" " row's literals; rows with unsupported expressions are skipped silently\n" " (the resolver will surface any downstream issue).\n" ). -spec extract_values_tables(list(sqlode@lexer:token())) -> list(sqlode@model:table()). extract_values_tables(Tokens) -> find_values_tables(Tokens, []). -file("src/sqlode/query_analyzer/column_inferencer.gleam", 732). ?DOC( " Parse `AS alias(c1, c2, ...)` after a derived-table subquery. The\n" " column list is optional: `AS alias` alone is valid (column names\n" " then come from the body). Returns the alias, any explicit names,\n" " and the tokens following both.\n" ). -spec parse_derived_alias(list(sqlode@lexer:token())) -> gleam@option:option({binary(), list(binary()), list(sqlode@lexer:token())}). parse_derived_alias(Tokens) -> After_as = case Tokens of [{keyword, <<"as"/utf8>>} | Rest] -> Rest; _ -> Tokens end, case After_as of [{ident, Alias}, l_paren | Rest_after_lp] -> {Col_tokens, After_cols} = sqlode@query_analyzer@token_utils:collect_paren_contents( Rest_after_lp ), Names = parse_cte_column_list(Col_tokens), {some, {string:lowercase(Alias), Names, After_cols}}; [{quoted_ident, Alias@1}, l_paren | Rest_after_lp@1] -> {Col_tokens@1, After_cols@1} = sqlode@query_analyzer@token_utils:collect_paren_contents( Rest_after_lp@1 ), Names@1 = parse_cte_column_list(Col_tokens@1), {some, {string:lowercase(Alias@1), Names@1, After_cols@1}}; [{ident, Alias@2} | Rest@1] -> {some, {string:lowercase(Alias@2), [], Rest@1}}; [{quoted_ident, Alias@3} | Rest@2] -> {some, {string:lowercase(Alias@3), [], Rest@2}}; _ -> none end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 774). ?DOC(" Find tokens after the RETURNING keyword until end of statement.\n"). -spec tok_find_returning_tokens(list(sqlode@lexer:token())) -> gleam@option:option(list(sqlode@lexer:token())). tok_find_returning_tokens(Tokens) -> case Tokens of [] -> none; [{keyword, <<"returning"/utf8>>} | Rest] -> Filtered = gleam@list:filter(Rest, fun(Tok) -> case Tok of semicolon -> false; _ -> true end end), case Filtered of [] -> none; _ -> {some, Filtered} end; [_ | Rest@1] -> tok_find_returning_tokens(Rest@1) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1211). -spec classify_function(binary()) -> function_class(). classify_function(Lowered_name) -> case Lowered_name of <<"count"/utf8>> -> fn_count; <<"sum"/utf8>> -> fn_aggregate_inner; <<"min"/utf8>> -> fn_aggregate_inner; <<"max"/utf8>> -> fn_aggregate_inner; <<"avg"/utf8>> -> fn_avg; <<"row_number"/utf8>> -> fn_window_int; <<"rank"/utf8>> -> fn_window_int; <<"dense_rank"/utf8>> -> fn_window_int; <<"ntile"/utf8>> -> fn_window_int; <<"percent_rank"/utf8>> -> fn_window_float; <<"cume_dist"/utf8>> -> fn_window_float; <<"lag"/utf8>> -> fn_window_first_arg; <<"lead"/utf8>> -> fn_window_first_arg; <<"first_value"/utf8>> -> fn_window_first_arg; <<"last_value"/utf8>> -> fn_window_first_arg; <<"nth_value"/utf8>> -> fn_window_first_arg; <<"coalesce"/utf8>> -> fn_coalesce; <<"greatest"/utf8>> -> fn_greatest_least; <<"least"/utf8>> -> fn_greatest_least; <<"replace"/utf8>> -> fn_string; <<"lower"/utf8>> -> fn_string; <<"upper"/utf8>> -> fn_string; <<"trim"/utf8>> -> fn_string; <<"ltrim"/utf8>> -> fn_string; <<"rtrim"/utf8>> -> fn_string; <<"substr"/utf8>> -> fn_string; <<"substring"/utf8>> -> fn_string; <<"concat"/utf8>> -> fn_string; <<"reverse"/utf8>> -> fn_string; <<"lpad"/utf8>> -> fn_string; <<"rpad"/utf8>> -> fn_string; <<"left"/utf8>> -> fn_string; <<"right"/utf8>> -> fn_string; <<"repeat"/utf8>> -> fn_string; <<"initcap"/utf8>> -> fn_string; <<"translate"/utf8>> -> fn_string; <<"to_char"/utf8>> -> fn_string; <<"format"/utf8>> -> fn_string; <<"quote_literal"/utf8>> -> fn_string; <<"quote_ident"/utf8>> -> fn_string; <<"md5"/utf8>> -> fn_string; <<"encode"/utf8>> -> fn_string; <<"decode"/utf8>> -> fn_string; <<"length"/utf8>> -> fn_length; <<"char_length"/utf8>> -> fn_length; <<"character_length"/utf8>> -> fn_length; <<"octet_length"/utf8>> -> fn_length; <<"bit_length"/utf8>> -> fn_length; <<"position"/utf8>> -> fn_length; <<"strpos"/utf8>> -> fn_length; <<"ascii"/utf8>> -> fn_length; <<"abs"/utf8>> -> fn_math; <<"round"/utf8>> -> fn_math; <<"floor"/utf8>> -> fn_math; <<"ceil"/utf8>> -> fn_math; <<"ceiling"/utf8>> -> fn_math; <<"mod"/utf8>> -> fn_math; <<"power"/utf8>> -> fn_math; <<"sqrt"/utf8>> -> fn_math; <<"sign"/utf8>> -> fn_math; <<"trunc"/utf8>> -> fn_math; <<"log"/utf8>> -> fn_math; <<"ln"/utf8>> -> fn_math; <<"exp"/utf8>> -> fn_math; <<"random"/utf8>> -> fn_math; <<"pi"/utf8>> -> fn_math; <<"degrees"/utf8>> -> fn_math; <<"radians"/utf8>> -> fn_math; <<"div"/utf8>> -> fn_math; <<"now"/utf8>> -> fn_datetime; <<"current_timestamp"/utf8>> -> fn_datetime; <<"current_date"/utf8>> -> fn_datetime; <<"current_time"/utf8>> -> fn_datetime; <<"date"/utf8>> -> fn_datetime; <<"time"/utf8>> -> fn_datetime; <<"timestamp"/utf8>> -> fn_datetime; <<"date_trunc"/utf8>> -> fn_datetime; <<"date_part"/utf8>> -> fn_datetime; <<"extract"/utf8>> -> fn_datetime; <<"age"/utf8>> -> fn_datetime; <<"make_date"/utf8>> -> fn_datetime; <<"make_time"/utf8>> -> fn_datetime; <<"make_timestamp"/utf8>> -> fn_datetime; <<"to_timestamp"/utf8>> -> fn_datetime; <<"to_date"/utf8>> -> fn_datetime; <<"clock_timestamp"/utf8>> -> fn_datetime; <<"statement_timestamp"/utf8>> -> fn_datetime; <<"timeofday"/utf8>> -> fn_datetime; <<"localtime"/utf8>> -> fn_datetime; <<"localtimestamp"/utf8>> -> fn_datetime; <<"nullif"/utf8>> -> fn_nullif; <<"ifnull"/utf8>> -> fn_nullif; <<"nvl"/utf8>> -> fn_nullif; _ -> fn_unknown end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1298). -spec is_datetime_identifier(binary()) -> boolean(). is_datetime_identifier(Lowered) -> case Lowered of <<"now"/utf8>> -> true; <<"current_timestamp"/utf8>> -> true; <<"current_date"/utf8>> -> true; <<"current_time"/utf8>> -> true; <<"localtime"/utf8>> -> true; <<"localtimestamp"/utf8>> -> true; _ -> false end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1310). -spec infer_datetime_return_type(binary()) -> sqlode@model:scalar_type(). infer_datetime_return_type(Lowered) -> case Lowered of <<"current_date"/utf8>> -> date_type; <<"date"/utf8>> -> date_type; <<"make_date"/utf8>> -> date_type; <<"to_date"/utf8>> -> date_type; <<"current_time"/utf8>> -> time_type; <<"time"/utf8>> -> time_type; <<"make_time"/utf8>> -> time_type; <<"localtime"/utf8>> -> time_type; <<"date_part"/utf8>> -> float_type; <<"extract"/utf8>> -> float_type; <<"to_char"/utf8>> -> string_type; _ -> date_time_type end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1433). -spec is_unary_context(gleam@option:option(sqlode@lexer:token())) -> boolean(). is_unary_context(Prev) -> case Prev of none -> true; {some, l_paren} -> true; {some, {operator, _}} -> true; {some, {keyword, _}} -> true; {some, comma} -> true; _ -> false end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1398). -spec split_arithmetic_loop( list(sqlode@lexer:token()), integer(), gleam@option:option(sqlode@lexer:token()), list(sqlode@lexer:token()), list(list(sqlode@lexer:token())) ) -> list(list(sqlode@lexer:token())). split_arithmetic_loop(Tokens, Depth, Prev, Current, Acc) -> case Tokens of [] -> [lists:reverse(Current) | Acc]; [l_paren = T | Rest] -> split_arithmetic_loop( Rest, Depth + 1, {some, T}, [T | Current], Acc ); [r_paren = T@1 | Rest@1] -> split_arithmetic_loop( Rest@1, Depth - 1, {some, T@1}, [T@1 | Current], Acc ); [{operator, Op} = T@2 | Rest@2] when (Depth =:= 0) andalso ((((Op =:= <<"+"/utf8>>) orelse (Op =:= <<"-"/utf8>>)) orelse (Op =:= <<"*"/utf8>>)) orelse (Op =:= <<"/"/utf8>>)) -> case is_unary_context(Prev) of true -> split_arithmetic_loop( Rest@2, Depth, {some, T@2}, [T@2 | Current], Acc ); false -> split_arithmetic_loop( Rest@2, Depth, {some, T@2}, [], [lists:reverse(Current) | Acc] ) end; [star = T@3 | Rest@3] when Depth =:= 0 -> split_arithmetic_loop( Rest@3, Depth, {some, T@3}, [], [lists:reverse(Current) | Acc] ); [T@4 | Rest@4] -> split_arithmetic_loop( Rest@4, Depth, {some, T@4}, [T@4 | Current], Acc ) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1390). ?DOC( " Split tokens at top-level `+`, `-`, `*`, `/` operators, respecting\n" " parentheses and skipping unary `-` / `+` that appear at the start of\n" " the expression or directly after another operator/keyword/`(`.\n" ). -spec split_arithmetic_operands(list(sqlode@lexer:token())) -> list(list(sqlode@lexer:token())). split_arithmetic_operands(Tokens) -> _pipe = split_arithmetic_loop(Tokens, 0, none, [], []), _pipe@1 = lists:reverse(_pipe), gleam@list:filter(_pipe@1, fun(Operand) -> Operand /= [] end). -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1510). -spec merge_scalar_types( gleam@option:option(sqlode@model:scalar_type()), gleam@option:option(sqlode@model:scalar_type()) ) -> {ok, gleam@option:option(sqlode@model:scalar_type())} | {error, nil}. merge_scalar_types(A, B) -> case {A, B} of {none, Other} -> {ok, Other}; {Other@1, none} -> {ok, Other@1}; {{some, int_type}, {some, int_type}} -> {ok, {some, int_type}}; {{some, float_type}, {some, float_type}} -> {ok, {some, float_type}}; {{some, int_type}, {some, float_type}} -> {ok, {some, float_type}}; {{some, float_type}, {some, int_type}} -> {ok, {some, float_type}}; {{some, X}, {some, Y}} -> case X =:= Y of true -> {ok, {some, X}}; false -> {error, nil} end end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1493). -spec unify_types_loop( list({gleam@option:option(sqlode@model:scalar_type()), boolean()}), gleam@option:option(sqlode@model:scalar_type()), boolean() ) -> {ok, {gleam@option:option(sqlode@model:scalar_type()), boolean()}} | {error, nil}. unify_types_loop(Items, Acc_type, Acc_nullable) -> case Items of [] -> {ok, {Acc_type, Acc_nullable}}; [{Next_type, Next_nullable} | Rest] -> Merged_nullable = Acc_nullable orelse Next_nullable, case merge_scalar_types(Acc_type, Next_type) of {ok, Merged_type} -> unify_types_loop(Rest, Merged_type, Merged_nullable); {error, nil} -> {error, nil} end end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1477). ?DOC( " Combine a list of operand types into a single result type+nullability.\n" " Numeric operands promote `Int` + `Float` to `Float`. Any other type\n" " mismatch returns `Error(Nil)` so the caller can surface it as an\n" " unsupported expression. `Ok(None)` means every operand was `NULL` and\n" " no concrete type could be inferred.\n" ). -spec unify_types_nullable( list({gleam@option:option(sqlode@model:scalar_type()), boolean()}) ) -> {ok, gleam@option:option({sqlode@model:scalar_type(), boolean()})} | {error, nil}. unify_types_nullable(Items) -> case Items of [] -> {ok, none}; [{T, Nullable} | Rest] -> _pipe = unify_types_loop(Rest, T, Nullable), gleam@result:map(_pipe, fun(Pair) -> case erlang:element(1, Pair) of {some, Scalar_type} -> {some, {Scalar_type, erlang:element(2, Pair)}}; none -> none end end) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1531). ?DOC( " Like `resolve_column_type_from_tokens` but also returns the column's\n" " nullability so callers can propagate `NULL`-ness through expressions.\n" ). -spec resolve_column_type_nullable_from_tokens( list(sqlode@lexer:token()), sqlode@model:catalog(), list(binary()) ) -> gleam@option:option({sqlode@model:scalar_type(), boolean()}). resolve_column_type_nullable_from_tokens(Tokens, Catalog, Table_names) -> case Tokens of [{ident, Table}, dot, {ident, Col}] -> case sqlode@query_analyzer@context:find_column(Catalog, Table, Col) of {some, Column} -> {some, {erlang:element(3, Column), erlang:element(4, Column)}}; none -> none end; [{ident, Col@1}] -> case sqlode@query_analyzer@context:find_column_in_tables( Catalog, Table_names, Col@1 ) of {ok, {some, {_, Column@1}}} -> {some, {erlang:element(3, Column@1), erlang:element(4, Column@1)}}; _ -> none end; _ -> none end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1594). -spec classify_top_level_token(sqlode@lexer:token()) -> top_level_pattern(). classify_top_level_token(Token) -> case Token of {operator, Op} when (((((((((((Op =:= <<"="/utf8>>) orelse (Op =:= <<"!="/utf8>>)) orelse (Op =:= <<"<>"/utf8>>)) orelse (Op =:= <<"<"/utf8>>)) orelse (Op =:= <<">"/utf8>>)) orelse (Op =:= <<"<="/utf8>>)) orelse (Op =:= <<">="/utf8>>)) orelse (Op =:= <<"@>"/utf8>>)) orelse (Op =:= <<"<@"/utf8>>)) orelse (Op =:= <<"?|"/utf8>>)) orelse (Op =:= <<"?&"/utf8>>)) orelse (Op =:= <<"&&"/utf8>>) -> pat_bool; {keyword, Kw} when ((((((((Kw =:= <<"and"/utf8>>) orelse (Kw =:= <<"or"/utf8>>)) orelse (Kw =:= <<"between"/utf8>>)) orelse (Kw =:= <<"in"/utf8>>)) orelse (Kw =:= <<"like"/utf8>>)) orelse (Kw =:= <<"ilike"/utf8>>)) orelse (Kw =:= <<"is"/utf8>>)) orelse (Kw =:= <<"not"/utf8>>)) orelse (Kw =:= <<"exists"/utf8>>) -> pat_bool; {operator, Op@1} when (Op@1 =:= <<"->>"/utf8>>) orelse (Op@1 =:= <<"#>>"/utf8>>) -> pat_json_text; {operator, Op@2} when (Op@2 =:= <<"->"/utf8>>) orelse (Op@2 =:= <<"#>"/utf8>>) -> pat_json; {operator, <<"||"/utf8>>} -> pat_concat; {operator, Op@3} when (((Op@3 =:= <<"+"/utf8>>) orelse (Op@3 =:= <<"-"/utf8>>)) orelse (Op@3 =:= <<"*"/utf8>>)) orelse (Op@3 =:= <<"/"/utf8>>) -> pat_arithmetic; star -> pat_arithmetic; _ -> pat_none end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1555). -spec find_pattern_loop( list(sqlode@lexer:token()), integer(), top_level_pattern() ) -> top_level_pattern(). find_pattern_loop(Tokens, Depth, Found) -> case Tokens of [] -> Found; [l_paren | Rest] -> find_pattern_loop(Rest, Depth + 1, Found); [r_paren | Rest@1] -> find_pattern_loop(Rest@1, Depth - 1, Found); [Token | Rest@2] when Depth =:= 0 -> case classify_top_level_token(Token) of pat_bool -> pat_bool; pat_json -> find_pattern_loop(Rest@2, Depth, case Found of pat_none -> pat_json; _ -> Found end); pat_json_text -> find_pattern_loop(Rest@2, Depth, case Found of pat_none -> pat_json_text; _ -> Found end); pat_concat -> find_pattern_loop(Rest@2, Depth, case Found of pat_none -> pat_concat; _ -> Found end); pat_arithmetic -> find_pattern_loop(Rest@2, Depth, case Found of pat_none -> pat_arithmetic; _ -> Found end); pat_none -> find_pattern_loop(Rest@2, Depth, Found) end; [_ | Rest@3] -> find_pattern_loop(Rest@3, Depth, Found) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1551). -spec find_top_level_pattern(list(sqlode@lexer:token())) -> top_level_pattern(). find_top_level_pattern(Tokens) -> find_pattern_loop(Tokens, 0, pat_none). -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1640). ?DOC(" Get the first comma-separated group of tokens (top-level only).\n"). -spec tok_first_comma_group(list(sqlode@lexer:token())) -> list(sqlode@lexer:token()). tok_first_comma_group(Tokens) -> case sqlode@query_analyzer@token_utils:split_on_commas(Tokens) of [First | _] -> First; [] -> [] end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1648). ?DOC(" Find the type name after AS in CAST tokens (e.g., [expr, AS, type_name]).\n"). -spec tok_find_as_type(list(sqlode@lexer:token())) -> gleam@option:option(binary()). tok_find_as_type(Tokens) -> case Tokens of [] -> none; [{keyword, <<"as"/utf8>>} | Rest] -> Type_text = begin _pipe = Rest, _pipe@1 = gleam@list:filter_map(_pipe, fun(T) -> case T of {ident, N} -> {ok, N}; {keyword, K} -> {ok, K}; _ -> {error, nil} end end), gleam@string:join(_pipe@1, <<" "/utf8>>) end, case Type_text of <<""/utf8>> -> none; _ -> {some, string:lowercase(Type_text)} end; [_ | Rest@1] -> tok_find_as_type(Rest@1) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1858). -spec append_if_in_branch( sqlode@lexer:token(), list(sqlode@lexer:token()), boolean() ) -> list(sqlode@lexer:token()). append_if_in_branch(Token, Acc, In_branch) -> case In_branch of true -> [Token | Acc]; false -> Acc end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1869). -spec flush_current_branch( case_collector_mode(), list(sqlode@lexer:token()), list(list(sqlode@lexer:token())), gleam@option:option(list(sqlode@lexer:token())) ) -> {list(list(sqlode@lexer:token())), gleam@option:option(list(sqlode@lexer:token()))}. flush_current_branch(Mode, Acc, Then_branches_rev, Else_branch) -> case Mode of in_then -> {[lists:reverse(Acc) | Then_branches_rev], Else_branch}; in_else -> {Then_branches_rev, {some, lists:reverse(Acc)}}; scanning -> {Then_branches_rev, Else_branch} end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1740). -spec collect_case_loop( list(sqlode@lexer:token()), integer(), integer(), case_collector_mode(), list(sqlode@lexer:token()), list(list(sqlode@lexer:token())), gleam@option:option(list(sqlode@lexer:token())) ) -> {list(list(sqlode@lexer:token())), gleam@option:option(list(sqlode@lexer:token()))}. collect_case_loop( Tokens, Paren_depth, Case_depth, Mode, Acc, Then_branches_rev, Else_branch ) -> case Tokens of [] -> {Then_branches_rev@1, Else_branch@1} = flush_current_branch( Mode, Acc, Then_branches_rev, Else_branch ), {lists:reverse(Then_branches_rev@1), Else_branch@1}; [First | Rest] -> In_branch = case Mode of scanning -> false; _ -> true end, At_top = (Paren_depth =:= 0) andalso (Case_depth =:= 0), case First of l_paren -> collect_case_loop( Rest, Paren_depth + 1, Case_depth, Mode, append_if_in_branch(First, Acc, In_branch), Then_branches_rev, Else_branch ); r_paren -> collect_case_loop( Rest, Paren_depth - 1, Case_depth, Mode, append_if_in_branch(First, Acc, In_branch), Then_branches_rev, Else_branch ); {keyword, <<"case"/utf8>>} -> collect_case_loop( Rest, Paren_depth, Case_depth + 1, Mode, append_if_in_branch(First, Acc, In_branch), Then_branches_rev, Else_branch ); {keyword, <<"end"/utf8>>} when At_top -> {Then_branches_rev@2, Else_branch@2} = flush_current_branch( Mode, Acc, Then_branches_rev, Else_branch ), {lists:reverse(Then_branches_rev@2), Else_branch@2}; {keyword, <<"end"/utf8>>} -> collect_case_loop( Rest, Paren_depth, Case_depth - 1, Mode, append_if_in_branch(First, Acc, In_branch), Then_branches_rev, Else_branch ); {keyword, <<"when"/utf8>>} when At_top -> {Then_branches_rev@3, Else_branch@3} = flush_current_branch( Mode, Acc, Then_branches_rev, Else_branch ), collect_case_loop( Rest, Paren_depth, Case_depth, scanning, [], Then_branches_rev@3, Else_branch@3 ); {keyword, <<"then"/utf8>>} when At_top -> collect_case_loop( Rest, Paren_depth, Case_depth, in_then, [], Then_branches_rev, Else_branch ); {keyword, <<"else"/utf8>>} when At_top -> {Then_branches_rev@4, Else_branch@4} = flush_current_branch( Mode, Acc, Then_branches_rev, Else_branch ), collect_case_loop( Rest, Paren_depth, Case_depth, in_else, [], Then_branches_rev@4, Else_branch@4 ); _ -> collect_case_loop( Rest, Paren_depth, Case_depth, Mode, append_if_in_branch(First, Acc, In_branch), Then_branches_rev, Else_branch ) end end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1734). ?DOC( " Walk tokens that start right after the outer `CASE` keyword, returning\n" " the raw token list of each top-level THEN branch and the ELSE branch\n" " (if any). Independent counters track parentheses and nested CASE\n" " expressions so that `WHEN`/`THEN`/`ELSE`/`END` are only treated as\n" " boundaries at the outermost level.\n" ). -spec collect_case_branches(list(sqlode@lexer:token())) -> {list(list(sqlode@lexer:token())), gleam@option:option(list(sqlode@lexer:token()))}. collect_case_branches(Tokens) -> collect_case_loop(Tokens, 0, 0, scanning, [], [], none). -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1883). ?DOC(" Resolve a column type from a token list (handles table.column and bare column).\n"). -spec resolve_column_type_from_tokens( list(sqlode@lexer:token()), sqlode@model:catalog(), list(binary()) ) -> gleam@option:option(sqlode@model:scalar_type()). resolve_column_type_from_tokens(Tokens, Catalog, Table_names) -> case Tokens of [{ident, Table}, dot, {ident, Col}] -> case sqlode@query_analyzer@context:find_column(Catalog, Table, Col) of {some, Column} -> {some, erlang:element(3, Column)}; none -> none end; [{ident, Col@1}] -> case sqlode@query_analyzer@context:find_column_in_tables( Catalog, Table_names, Col@1 ) of {ok, {some, {_, Column@1}}} -> {some, erlang:element(3, Column@1)}; _ -> none end; [star] -> none; _ -> none end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1916). -spec tok_skip_cte_defs(list(sqlode@lexer:token())) -> list(sqlode@lexer:token()). tok_skip_cte_defs(Tokens) -> case Tokens of [] -> []; [{keyword, Kw} | _] when (((Kw =:= <<"select"/utf8>>) orelse (Kw =:= <<"insert"/utf8>>)) orelse (Kw =:= <<"update"/utf8>>)) orelse (Kw =:= <<"delete"/utf8>>) -> Tokens; [l_paren | Rest] -> Remaining = sqlode@query_analyzer@token_utils:skip_parens(Rest, 1), tok_skip_cte_defs(Remaining); [_ | Rest@1] -> tok_skip_cte_defs(Rest@1) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1909). ?DOC(" Strip CTE: skip everything from WITH to the main SELECT/INSERT/UPDATE/DELETE.\n"). -spec tok_strip_cte(list(sqlode@lexer:token())) -> list(sqlode@lexer:token()). tok_strip_cte(Tokens) -> case Tokens of [{keyword, <<"with"/utf8>>} | Rest] -> tok_skip_cte_defs(Rest); _ -> Tokens end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1935). -spec tok_strip_compound_loop( list(sqlode@lexer:token()), integer(), list(sqlode@lexer:token()) ) -> list(sqlode@lexer:token()). tok_strip_compound_loop(Tokens, Depth, Acc) -> case Tokens of [] -> lists:reverse(Acc); [l_paren | Rest] -> tok_strip_compound_loop(Rest, Depth + 1, [l_paren | Acc]); [r_paren | Rest@1] -> tok_strip_compound_loop(Rest@1, Depth - 1, [r_paren | Acc]); [{keyword, Kw} | _] when Depth =:= 0 -> case ((Kw =:= <<"union"/utf8>>) orelse (Kw =:= <<"intersect"/utf8>>)) orelse (Kw =:= <<"except"/utf8>>) of true -> lists:reverse(Acc); false -> case Tokens of [Token | Rest@2] -> tok_strip_compound_loop( Rest@2, Depth, [Token | Acc] ); _ -> lists:reverse(Acc) end end; [Token@1 | Rest@3] -> tok_strip_compound_loop(Rest@3, Depth, [Token@1 | Acc]) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1931). ?DOC(" Strip compound operators (UNION, INTERSECT, EXCEPT) at depth 0.\n"). -spec tok_strip_compound(list(sqlode@lexer:token())) -> list(sqlode@lexer:token()). tok_strip_compound(Tokens) -> tok_strip_compound_loop(Tokens, 0, []). -file("src/sqlode/query_analyzer/column_inferencer.gleam", 2001). -spec tok_split_compound_branches_loop( list(sqlode@lexer:token()), integer(), list(sqlode@lexer:token()), list(list(sqlode@lexer:token())) ) -> list(list(sqlode@lexer:token())). tok_split_compound_branches_loop(Tokens, Depth, Current, Acc) -> case Tokens of [] -> case Current of [] -> lists:reverse(Acc); _ -> lists:reverse([lists:reverse(Current) | Acc]) end; [l_paren | Rest] -> tok_split_compound_branches_loop( Rest, Depth + 1, [l_paren | Current], Acc ); [r_paren | Rest@1] -> tok_split_compound_branches_loop( Rest@1, Depth - 1, [r_paren | Current], Acc ); [{keyword, Kw} | Rest@2] when Depth =:= 0 -> case ((Kw =:= <<"union"/utf8>>) orelse (Kw =:= <<"intersect"/utf8>>)) orelse (Kw =:= <<"except"/utf8>>) of true -> Rest2 = case Rest@2 of [{keyword, <<"all"/utf8>>} | R] -> R; _ -> Rest@2 end, Branch = lists:reverse(Current), tok_split_compound_branches_loop( Rest2, 0, [], [Branch | Acc] ); false -> tok_split_compound_branches_loop( Rest@2, Depth, [{keyword, Kw} | Current], Acc ) end; [Token | Rest@3] -> tok_split_compound_branches_loop( Rest@3, Depth, [Token | Current], Acc ) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1995). ?DOC(" Split tokens on top-level compound operators (UNION, INTERSECT, EXCEPT).\n"). -spec tok_split_compound_branches(list(sqlode@lexer:token())) -> list(list(sqlode@lexer:token())). tok_split_compound_branches(Tokens) -> tok_split_compound_branches_loop(Tokens, 0, [], []). -file("src/sqlode/query_analyzer/column_inferencer.gleam", 2116). -spec tok_skip_keyword(list(sqlode@lexer:token()), binary()) -> list(sqlode@lexer:token()). tok_skip_keyword(Tokens, Kw) -> case Tokens of [{keyword, K} | Rest] when K =:= Kw -> Rest; _ -> Tokens end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 2067). -spec tok_nullable_loop(list(sqlode@lexer:token()), list(binary()), boolean()) -> {list(binary()), boolean()}. tok_nullable_loop(Tokens, Acc, Primary_nullable) -> case Tokens of [] -> {Acc, Primary_nullable}; [{keyword, <<"left"/utf8>>} | Rest] -> Rest2 = tok_skip_keyword(Rest, <<"outer"/utf8>>), case Rest2 of [{keyword, <<"join"/utf8>>} | After_join] -> {Name, Remaining} = sqlode@query_analyzer@token_utils:read_table_name( After_join ), case Name of {some, N} -> tok_nullable_loop( Remaining, [N | Acc], Primary_nullable ); none -> tok_nullable_loop(Remaining, Acc, Primary_nullable) end; _ -> tok_nullable_loop(Rest, Acc, Primary_nullable) end; [{keyword, <<"full"/utf8>>} | Rest@1] -> Rest2@1 = tok_skip_keyword(Rest@1, <<"outer"/utf8>>), case Rest2@1 of [{keyword, <<"join"/utf8>>} | After_join@1] -> {Name@1, Remaining@1} = sqlode@query_analyzer@token_utils:read_table_name( After_join@1 ), case Name@1 of {some, N@1} -> tok_nullable_loop(Remaining@1, [N@1 | Acc], true); none -> tok_nullable_loop(Remaining@1, Acc, true) end; _ -> tok_nullable_loop(Rest@1, Acc, Primary_nullable) end; [{keyword, <<"right"/utf8>>} | Rest@2] -> Rest2@2 = tok_skip_keyword(Rest@2, <<"outer"/utf8>>), case Rest2@2 of [{keyword, <<"join"/utf8>>} | After_join@2] -> tok_nullable_loop(After_join@2, Acc, true); _ -> tok_nullable_loop(Rest@2, Acc, Primary_nullable) end; [_ | Rest@3] -> tok_nullable_loop(Rest@3, Acc, Primary_nullable) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 2051). ?DOC(" Extract nullable tables from LEFT/RIGHT/FULL JOIN keywords.\n"). -spec tok_extract_nullable_tables(list(sqlode@lexer:token()), list(binary())) -> list(binary()). tok_extract_nullable_tables(Tokens, All_table_names) -> Primary = case All_table_names of [P | _] -> {some, P}; [] -> none end, {Nullable_joined, Primary_nullable} = tok_nullable_loop(Tokens, [], false), case {Primary_nullable, Primary} of {true, {some, Name}} -> [Name | Nullable_joined]; {_, _} -> Nullable_joined end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 2161). ?DOC(" Collect tokens until top-level FROM (depth 0).\n"). -spec tok_collect_until_from( list(sqlode@lexer:token()), integer(), list(sqlode@lexer:token()) ) -> gleam@option:option(list(sqlode@lexer:token())). tok_collect_until_from(Tokens, Depth, Acc) -> case Tokens of [] -> case Acc of [] -> none; _ -> {some, lists:reverse(Acc)} end; [{keyword, <<"from"/utf8>>} | _] when Depth =:= 0 -> case Acc of [] -> none; _ -> {some, lists:reverse(Acc)} end; [l_paren | Rest] -> tok_collect_until_from(Rest, Depth + 1, [l_paren | Acc]); [r_paren | Rest@1] -> tok_collect_until_from(Rest@1, Depth - 1, [r_paren | Acc]); [Token | Rest@2] -> tok_collect_until_from(Rest@2, Depth, [Token | Acc]) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 2136). ?DOC(" Find tokens between SELECT and top-level FROM.\n"). -spec tok_find_select_to_from(list(sqlode@lexer:token())) -> gleam@option:option(list(sqlode@lexer:token())). tok_find_select_to_from(Tokens) -> case Tokens of [] -> none; [{keyword, <<"select"/utf8>>} | Rest] -> Rest2 = case Rest of [{keyword, <<"distinct"/utf8>>}, {keyword, <<"on"/utf8>>}, l_paren | After_on] -> sqlode@query_analyzer@token_utils:skip_parens(After_on, 1); [{keyword, <<"distinct"/utf8>>} | R] -> R; [{keyword, <<"all"/utf8>>} | R@1] -> R@1; _ -> Rest end, tok_collect_until_from(Rest2, 0, []); [_ | Rest@1] -> tok_find_select_to_from(Rest@1) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1987). -spec count_branch_columns(list(sqlode@lexer:token())) -> integer(). count_branch_columns(Branch) -> case tok_find_select_to_from(Branch) of {some, Col_tokens} -> erlang:length( sqlode@query_analyzer@token_utils:split_on_commas(Col_tokens) ); none -> 0 end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1962). ?DOC(" Validate that all compound query branches have the same column count.\n"). -spec validate_compound_column_counts(binary(), list(sqlode@lexer:token())) -> {ok, nil} | {error, sqlode@query_analyzer@context:analysis_error()}. validate_compound_column_counts(Query_name, Tokens) -> Branches = tok_split_compound_branches(Tokens), case Branches of [] -> {ok, nil}; [_] -> {ok, nil}; [First | Rest] -> First_count = count_branch_columns(First), gleam@list:try_each( Rest, fun(Branch) -> Branch_count = count_branch_columns(Branch), case Branch_count =:= First_count of true -> {ok, nil}; false -> {error, {compound_column_count_mismatch, Query_name, First_count, Branch_count}} end end ) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 2212). ?DOC(" Reconstruct sqlode.embed(table) call without extra spaces.\n"). -spec tok_reconstruct_macro_call(list(sqlode@lexer:token())) -> binary(). tok_reconstruct_macro_call(Tokens) -> _pipe = Tokens, _pipe@1 = gleam@list:map(_pipe, fun(T) -> case T of {ident, N} -> N; dot -> <<"."/utf8>>; l_paren -> <<"("/utf8>>; r_paren -> <<")"/utf8>>; {keyword, K} -> K; _ -> <<""/utf8>> end end), erlang:list_to_binary(_pipe@1). -file("src/sqlode/query_analyzer/column_inferencer.gleam", 2292). -spec tok_find_last_as_idx( list(sqlode@lexer:token()), integer(), gleam@option:option(integer()), integer() ) -> gleam@option:option(integer()). tok_find_last_as_idx(Tokens, Depth, Last, Idx) -> case Tokens of [] -> Last; [l_paren | Rest] -> tok_find_last_as_idx(Rest, Depth + 1, Last, Idx + 1); [r_paren | Rest@1] -> tok_find_last_as_idx(Rest@1, Depth - 1, Last, Idx + 1); [{keyword, <<"as"/utf8>>} | Rest@2] when Depth =:= 0 -> tok_find_last_as_idx(Rest@2, Depth, {some, Idx}, Idx + 1); [_ | Rest@3] -> tok_find_last_as_idx(Rest@3, Depth, Last, Idx + 1) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 2282). ?DOC(" Find last top-level AS in token list, split into (before, after).\n"). -spec tok_split_on_last_as(list(sqlode@lexer:token())) -> gleam@option:option({list(sqlode@lexer:token()), list(sqlode@lexer:token())}). tok_split_on_last_as(Tokens) -> Last_idx = tok_find_last_as_idx(Tokens, 0, none, 0), case Last_idx of none -> none; {some, Pos} -> {some, {gleam@list:take(Tokens, Pos), gleam@list:drop(Tokens, Pos + 1)}} end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 2310). -spec tok_tokens_to_text(list(sqlode@lexer:token())) -> binary(). tok_tokens_to_text(Tokens) -> sqlode@lexer:tokens_to_string( Tokens, {token_render_options, false, false, none} ). -file("src/sqlode/query_analyzer/column_inferencer.gleam", 2228). ?DOC(" Parse a non-embed column item.\n"). -spec tok_parse_regular_column(list(sqlode@lexer:token())) -> extracted_column(). tok_parse_regular_column(Tokens) -> case tok_split_on_last_as(Tokens) of {some, {Expr_tokens, Alias_tokens}} -> Alias_name = tok_tokens_to_text(Alias_tokens), Expr_text = tok_tokens_to_text(Expr_tokens), Table = case Expr_tokens of [{ident, T}, dot, {ident, _}] -> {some, string:lowercase(T)}; _ -> none end, {extracted_column, Alias_name, Table, {some, Expr_text}, {some, Expr_tokens}}; none -> case Tokens of [{ident, Table@1}, dot, {ident, Col}] -> {extracted_column, Col, {some, string:lowercase(Table@1)}, none, none}; [{ident, Name}] -> {extracted_column, Name, none, none, none}; [star] -> {extracted_column, <<"*"/utf8>>, none, none, none}; _ -> Text = tok_tokens_to_text(Tokens), {extracted_column, Text, none, {some, Text}, {some, Tokens}} end end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 2186). ?DOC(" Parse a column item from tokens into ExtractedColumn.\n"). -spec tok_parse_column_item(list(sqlode@lexer:token())) -> extracted_column(). tok_parse_column_item(Tokens) -> case Tokens of [{ident, Name}, dot, {ident, Fn_name}, l_paren | _] -> case (string:lowercase(Name) =:= <<"sqlode"/utf8>>) andalso (string:lowercase( Fn_name ) =:= <<"embed"/utf8>>) of true -> Text = tok_reconstruct_macro_call(Tokens), {extracted_column, Text, none, none, none}; false -> tok_parse_regular_column(Tokens) end; _ -> tok_parse_regular_column(Tokens) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 760). ?DOC(" Extract RETURNING columns from tokens using token-based analysis.\n"). -spec tok_extract_returning_columns(list(sqlode@lexer:token())) -> gleam@option:option(list(extracted_column())). tok_extract_returning_columns(Tokens) -> case tok_find_returning_tokens(Tokens) of {some, Col_tokens} -> {some, begin _pipe = sqlode@query_analyzer@token_utils:split_on_commas( Col_tokens ), gleam@list:map(_pipe, fun tok_parse_column_item/1) end}; none -> none end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 2124). ?DOC(" Extract SELECT columns from tokens → List(ExtractedColumn).\n"). -spec tok_extract_select_columns(list(sqlode@lexer:token())) -> list(extracted_column()). tok_extract_select_columns(Tokens) -> case tok_find_select_to_from(Tokens) of {some, Col_tokens} -> _pipe = sqlode@query_analyzer@token_utils:split_on_commas( Col_tokens ), gleam@list:map(_pipe, fun tok_parse_column_item/1); none -> [] end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 690). -spec handle_derived( binary(), list(sqlode@lexer:token()), sqlode@model:catalog(), list(sqlode@model:table()) ) -> {ok, list(sqlode@model:table())} | {error, sqlode@query_analyzer@context:analysis_error()}. handle_derived(Query_name, Tokens_after_select_kw, Catalog, Acc) -> {Body, After_rp} = sqlode@query_analyzer@token_utils:collect_paren_contents( [{keyword, <<"select"/utf8>>} | Tokens_after_select_kw] ), case parse_derived_alias(After_rp) of {some, {Alias, Explicit_names, Remaining}} -> gleam@result:'try'( infer_columns_from_tokens(Query_name, Body, Catalog), fun(Cols) -> Body_columns = gleam@list:filter_map( Cols, fun(Item) -> case Item of {scalar_result, Rc} -> {ok, {column, erlang:element(2, Rc), erlang:element(3, Rc), erlang:element(4, Rc)}}; _ -> {error, nil} end end ), Columns = apply_explicit_column_names( Body_columns, Explicit_names ), Table = {table, Alias, Columns}, find_derived_tables( Query_name, Remaining, Catalog, [Table | Acc] ) end ); none -> find_derived_tables(Query_name, After_rp, Catalog, Acc) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 294). ?DOC( " Token-based column inference, exposed so callers (notably the CTE\n" " virtual-table builder in `query_analyzer`) can reuse the SELECT\n" " resolver without re-lexing or constructing a synthetic ParsedQuery.\n" ). -spec infer_columns_from_tokens( binary(), list(sqlode@lexer:token()), sqlode@model:catalog() ) -> {ok, list(sqlode@model:result_item())} | {error, sqlode@query_analyzer@context:analysis_error()}. infer_columns_from_tokens(Query_name, Tokens, Catalog) -> infer_columns_from_tokens_scoped(Query_name, Tokens, Catalog, []). -file("src/sqlode/query_analyzer/column_inferencer.gleam", 311). ?DOC( " Same as `infer_columns_from_tokens`, but when the inner query has no\n" " FROM clause of its own, fall back to `outer_tables` so correlated\n" " references like `SELECT (SELECT books.id)` can still be resolved\n" " against the enclosing query's FROM list.\n" "\n" " Every entry point also runs VALUES and derived-table discovery over\n" " its own token scope and augments the catalog before resolution, so\n" " nested subqueries pick up sibling virtual tables without extra work\n" " from the caller.\n" ). -spec infer_columns_from_tokens_scoped( binary(), list(sqlode@lexer:token()), sqlode@model:catalog(), list(binary()) ) -> {ok, list(sqlode@model:result_item())} | {error, sqlode@query_analyzer@context:analysis_error()}. infer_columns_from_tokens_scoped(Query_name, Tokens, Catalog, Outer_tables) -> gleam@result:'try'( augment_with_subquery_tables(Query_name, Tokens, Catalog), fun(Augmented) -> case tok_extract_returning_columns(Tokens) of {some, Returning_cols} -> Table_names = sqlode@query_analyzer@token_utils:extract_table_names( Tokens ), Nullable_tables = tok_extract_nullable_tables( Tokens, Table_names ), case effective_tables(Table_names, Outer_tables) of [] -> {ok, []}; [Primary | _] = Tables -> resolve_select_columns( Query_name, Returning_cols, Augmented, Primary, Tables, Nullable_tables ) end; none -> Main_tokens = tok_strip_cte(Tokens), gleam@result:'try'( validate_compound_column_counts(Query_name, Main_tokens), fun(_) -> Main_tokens2 = tok_strip_compound(Main_tokens), Table_names@1 = sqlode@query_analyzer@token_utils:extract_table_names( Main_tokens2 ), Nullable_tables@1 = tok_extract_nullable_tables( Main_tokens2, Table_names@1 ), case effective_tables(Table_names@1, Outer_tables) of [] -> {ok, []}; [Primary@1 | _] = Tables@1 -> Select_columns = tok_extract_select_columns( Main_tokens2 ), resolve_select_columns( Query_name, Select_columns, Augmented, Primary@1, Tables@1, Nullable_tables@1 ) end end ) end end ). -file("src/sqlode/query_analyzer/column_inferencer.gleam", 368). -spec augment_with_subquery_tables( binary(), list(sqlode@lexer:token()), sqlode@model:catalog() ) -> {ok, sqlode@model:catalog()} | {error, sqlode@query_analyzer@context:analysis_error()}. augment_with_subquery_tables(Query_name, Tokens, Catalog) -> Values = extract_values_tables(Tokens), With_values = augment_catalog_with(Catalog, Values), gleam@result:'try'( extract_derived_tables(Query_name, Tokens, With_values), fun(Derived) -> {ok, augment_catalog_with(With_values, Derived)} end ). -file("src/sqlode/query_analyzer/column_inferencer.gleam", 657). ?DOC( " Find every derived table in the token stream — `FROM (SELECT ...)`,\n" " `JOIN (SELECT ...)`, `JOIN LATERAL (SELECT ...)`, and comma-LATERAL\n" " `, LATERAL (SELECT ...)` — and build a virtual Table for each. Each\n" " body is resolved against `catalog` via `infer_columns_from_tokens`,\n" " so nested CTEs / VALUES / derived tables compose naturally. An\n" " explicit `AS alias(c1, c2)` column list overrides the body's names.\n" ). -spec extract_derived_tables( binary(), list(sqlode@lexer:token()), sqlode@model:catalog() ) -> {ok, list(sqlode@model:table())} | {error, sqlode@query_analyzer@context:analysis_error()}. extract_derived_tables(Query_name, Tokens, Catalog) -> find_derived_tables(Query_name, Tokens, Catalog, []). -file("src/sqlode/query_analyzer/column_inferencer.gleam", 665). -spec find_derived_tables( binary(), list(sqlode@lexer:token()), sqlode@model:catalog(), list(sqlode@model:table()) ) -> {ok, list(sqlode@model:table())} | {error, sqlode@query_analyzer@context:analysis_error()}. find_derived_tables(Query_name, Tokens, Catalog, Acc) -> case Tokens of [] -> {ok, lists:reverse(Acc)}; [{keyword, <<"from"/utf8>>}, l_paren, {keyword, <<"select"/utf8>>} | Rest] -> handle_derived(Query_name, Rest, Catalog, Acc); [{keyword, <<"join"/utf8>>}, {keyword, <<"lateral"/utf8>>}, l_paren, {keyword, <<"select"/utf8>>} | Rest@1] -> handle_derived(Query_name, Rest@1, Catalog, Acc); [{keyword, <<"join"/utf8>>}, l_paren, {keyword, <<"select"/utf8>>} | Rest@2] -> handle_derived(Query_name, Rest@2, Catalog, Acc); [{keyword, <<"lateral"/utf8>>}, l_paren, {keyword, <<"select"/utf8>>} | Rest@3] -> handle_derived(Query_name, Rest@3, Catalog, Acc); [_ | Rest@4] -> find_derived_tables(Query_name, Rest@4, Catalog, Acc) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 417). -spec parse_cte_defs( binary(), list(sqlode@lexer:token()), sqlode@model:catalog(), list(sqlode@model:table()) ) -> {ok, list(sqlode@model:table())} | {error, sqlode@query_analyzer@context:analysis_error()}. parse_cte_defs(Query_name, Tokens, Catalog, Acc) -> case Tokens of [{ident, Name} | Rest_after_name] -> {Explicit_names, After_optional_cols} = case Rest_after_name of [l_paren | After_lp] -> {Col_tokens, After_cols} = sqlode@query_analyzer@token_utils:collect_paren_contents( After_lp ), Names = parse_cte_column_list(Col_tokens), {Names, After_cols}; _ -> {[], Rest_after_name} end, case After_optional_cols of [{keyword, <<"as"/utf8>>}, l_paren | After_as_lp] -> {Body, After_body} = sqlode@query_analyzer@token_utils:collect_paren_contents( After_as_lp ), Augmented = augment_catalog_with(Catalog, Acc), gleam@result:'try'( infer_columns_from_tokens(Query_name, Body, Augmented), fun(Cols) -> Body_columns = gleam@list:filter_map( Cols, fun(Item) -> case Item of {scalar_result, Rc} -> {ok, {column, erlang:element(2, Rc), erlang:element(3, Rc), erlang:element(4, Rc)}}; _ -> {error, nil} end end ), Columns = apply_explicit_column_names( Body_columns, Explicit_names ), Table = {table, string:lowercase(Name), Columns}, New_acc = [Table | Acc], case After_body of [comma | More] -> parse_cte_defs( Query_name, More, Catalog, New_acc ); _ -> {ok, lists:reverse(New_acc)} end end ); _ -> {ok, lists:reverse(Acc)} end; _ -> {ok, lists:reverse(Acc)} end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 403). ?DOC( " Extract CTE definitions from a query's tokens and return the\n" " resulting virtual tables. Each `name AS (body)` (or\n" " `name(c1, c2) AS (body)`) becomes a Table whose columns come from\n" " running infer_columns_from_tokens on the body. RECURSIVE CTEs use\n" " the anchor (first) branch via the existing tok_strip_compound, so\n" " recursive self-references are not analysed.\n" ). -spec extract_cte_tables( binary(), list(sqlode@lexer:token()), sqlode@model:catalog() ) -> {ok, list(sqlode@model:table())} | {error, sqlode@query_analyzer@context:analysis_error()}. extract_cte_tables(Query_name, Tokens, Catalog) -> case Tokens of [{keyword, <<"with"/utf8>>}, {keyword, <<"recursive"/utf8>>} | Rest] -> parse_cte_defs(Query_name, Rest, Catalog, []); [{keyword, <<"with"/utf8>>} | Rest@1] -> parse_cte_defs(Query_name, Rest@1, Catalog, []); _ -> {ok, []} end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1447). ?DOC( " Infer a single operand's type+nullability. A `NULL` literal contributes\n" " no type but does make the result nullable, so we return `Option(Type)`\n" " so the caller can unify with other operands.\n" ). -spec infer_atom_type( list(sqlode@lexer:token()), sqlode@model:catalog(), list(binary()), binary() ) -> {ok, {gleam@option:option(sqlode@model:scalar_type()), boolean()}} | {error, sqlode@query_analyzer@context:analysis_error()}. infer_atom_type(Tokens, Catalog, Table_names, Query_name) -> case Tokens of [{keyword, <<"null"/utf8>>}] -> {ok, {none, true}}; _ -> case resolve_column_type_nullable_from_tokens( Tokens, Catalog, Table_names ) of {some, {T, Nullable}} -> {ok, {{some, T}, Nullable}}; none -> _pipe = infer_expression_type_from_tokens( Tokens, Catalog, Table_names, Query_name ), gleam@result:map( _pipe, fun(Pair) -> {{some, erlang:element(1, Pair)}, erlang:element(2, Pair)} end ) end end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 981). -spec infer_expression_type_from_tokens( list(sqlode@lexer:token()), sqlode@model:catalog(), list(binary()), binary() ) -> {ok, {sqlode@model:scalar_type(), boolean()}} | {error, sqlode@query_analyzer@context:analysis_error()}. infer_expression_type_from_tokens(Tokens, Catalog, Table_names, Query_name) -> case Tokens of [{keyword, <<"cast"/utf8>>}, l_paren | Rest] -> {Inner_tokens, _} = sqlode@query_analyzer@token_utils:collect_paren_contents( Rest ), case tok_find_as_type(Inner_tokens) of {some, Type_name} -> case sqlode@model:parse_sql_type(Type_name) of {ok, Scalar_type} -> {ok, {Scalar_type, true}}; {error, _} -> {error, {unsupported_expression, Query_name, tok_tokens_to_text(Tokens)}} end; none -> {error, {unsupported_expression, Query_name, tok_tokens_to_text(Tokens)}} end; [{keyword, <<"exists"/utf8>>}, l_paren | _] -> {ok, {bool_type, false}}; [l_paren, {keyword, <<"select"/utf8>>} | Rest@1] -> {Inner, _} = sqlode@query_analyzer@token_utils:collect_paren_contents( [{keyword, <<"select"/utf8>>} | Rest@1] ), case infer_columns_from_tokens_scoped( Query_name, Inner, Catalog, Table_names ) of {ok, [{scalar_result, Col} | _]} -> {ok, {erlang:element(3, Col), true}}; _ -> {error, {unsupported_expression, Query_name, tok_tokens_to_text(Tokens)}} end; [{keyword, <<"not"/utf8>>} | Rest@2] -> infer_expression_type_from_tokens( Rest@2, Catalog, Table_names, Query_name ); [{keyword, <<"case"/utf8>>} | Rest@3] -> infer_case_type_from_tokens( Rest@3, Catalog, Table_names, Query_name ); [{keyword, <<"replace"/utf8>>}, l_paren | _] -> {ok, {string_type, false}}; [{ident, Fn_name}, l_paren | Rest@4] -> Lowered = string:lowercase(Fn_name), case classify_function(Lowered) of fn_count -> {ok, {int_type, false}}; fn_avg -> {ok, {float_type, true}}; fn_aggregate_inner -> {Inner_tokens@1, _} = sqlode@query_analyzer@token_utils:collect_paren_contents( Rest@4 ), First_arg = tok_first_comma_group(Inner_tokens@1), case resolve_column_type_from_tokens( First_arg, Catalog, Table_names ) of {some, T} -> {ok, {T, true}}; none -> {ok, {int_type, true}} end; fn_window_int -> {ok, {int_type, false}}; fn_window_float -> {ok, {float_type, false}}; fn_window_first_arg -> {Inner_tokens@2, _} = sqlode@query_analyzer@token_utils:collect_paren_contents( Rest@4 ), First_arg@1 = tok_first_comma_group(Inner_tokens@2), case resolve_column_type_from_tokens( First_arg@1, Catalog, Table_names ) of {some, T@1} -> {ok, {T@1, true}}; none -> _pipe = infer_expression_type_from_tokens( First_arg@1, Catalog, Table_names, Query_name ), gleam@result:map( _pipe, fun(Pair) -> {erlang:element(1, Pair), true} end ) end; fn_coalesce -> {Inner_tokens@3, _} = sqlode@query_analyzer@token_utils:collect_paren_contents( Rest@4 ), First_arg@2 = tok_first_comma_group(Inner_tokens@3), case resolve_column_type_from_tokens( First_arg@2, Catalog, Table_names ) of {some, T@2} -> {ok, {T@2, false}}; none -> _pipe@1 = infer_expression_type_from_tokens( First_arg@2, Catalog, Table_names, Query_name ), gleam@result:map( _pipe@1, fun(Pair@1) -> {erlang:element(1, Pair@1), false} end ) end; fn_greatest_least -> {Inner_tokens@4, _} = sqlode@query_analyzer@token_utils:collect_paren_contents( Rest@4 ), First_arg@3 = tok_first_comma_group(Inner_tokens@4), case resolve_column_type_from_tokens( First_arg@3, Catalog, Table_names ) of {some, T@3} -> {ok, {T@3, true}}; none -> _pipe@2 = infer_expression_type_from_tokens( First_arg@3, Catalog, Table_names, Query_name ), gleam@result:map( _pipe@2, fun(Pair@2) -> {erlang:element(1, Pair@2), true} end ) end; fn_string -> {ok, {string_type, false}}; fn_length -> {ok, {int_type, false}}; fn_math -> {Inner_tokens@5, _} = sqlode@query_analyzer@token_utils:collect_paren_contents( Rest@4 ), First_arg@4 = tok_first_comma_group(Inner_tokens@5), case resolve_column_type_from_tokens( First_arg@4, Catalog, Table_names ) of {some, T@4} -> {ok, {T@4, false}}; none -> {ok, {float_type, false}} end; fn_datetime -> {ok, {infer_datetime_return_type(Lowered), false}}; fn_nullif -> {Inner_tokens@6, _} = sqlode@query_analyzer@token_utils:collect_paren_contents( Rest@4 ), First_arg@5 = tok_first_comma_group(Inner_tokens@6), case resolve_column_type_from_tokens( First_arg@5, Catalog, Table_names ) of {some, T@5} -> {ok, {T@5, true}}; none -> _pipe@3 = infer_expression_type_from_tokens( First_arg@5, Catalog, Table_names, Query_name ), gleam@result:map( _pipe@3, fun(Pair@3) -> {erlang:element(1, Pair@3), true} end ) end; fn_unknown -> infer_by_scanning(Tokens, Catalog, Table_names, Query_name) end; [{ident, Fn_name@1}] -> Lowered@1 = string:lowercase(Fn_name@1), case is_datetime_identifier(Lowered@1) of true -> {ok, {infer_datetime_return_type(Lowered@1), false}}; false -> {error, {unsupported_expression, Query_name, tok_tokens_to_text(Tokens)}} end; [{string_lit, _}] -> {ok, {string_type, false}}; [{number_lit, N}] -> case gleam_stdlib:contains_string(N, <<"."/utf8>>) of true -> {ok, {float_type, false}}; false -> {ok, {int_type, false}} end; [{keyword, <<"true"/utf8>>}] -> {ok, {bool_type, false}}; [{keyword, <<"false"/utf8>>}] -> {ok, {bool_type, false}}; [{keyword, <<"null"/utf8>>}] -> {error, {unsupported_expression, Query_name, tok_tokens_to_text(Tokens)}}; _ -> infer_by_scanning(Tokens, Catalog, Table_names, Query_name) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1333). -spec infer_by_scanning( list(sqlode@lexer:token()), sqlode@model:catalog(), list(binary()), binary() ) -> {ok, {sqlode@model:scalar_type(), boolean()}} | {error, sqlode@query_analyzer@context:analysis_error()}. infer_by_scanning(Tokens, Catalog, Table_names, Query_name) -> case find_top_level_pattern(Tokens) of pat_bool -> {ok, {bool_type, false}}; pat_concat -> {ok, {string_type, false}}; pat_arithmetic -> infer_arithmetic_from_tokens( Tokens, Catalog, Table_names, Query_name ); pat_json -> {ok, {json_type, true}}; pat_json_text -> {ok, {string_type, true}}; pat_none -> {error, {unsupported_expression, Query_name, tok_tokens_to_text(Tokens)}} end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1360). ?DOC( " Infer the type of an arithmetic expression by looking at each operand\n" " instead of assuming `IntType`. Integer/float mixing promotes to\n" " `FloatType`; any operand being nullable makes the result nullable.\n" ). -spec infer_arithmetic_from_tokens( list(sqlode@lexer:token()), sqlode@model:catalog(), list(binary()), binary() ) -> {ok, {sqlode@model:scalar_type(), boolean()}} | {error, sqlode@query_analyzer@context:analysis_error()}. infer_arithmetic_from_tokens(Tokens, Catalog, Table_names, Query_name) -> Operands = split_arithmetic_operands(Tokens), gleam@result:'try'( gleam@list:try_map( Operands, fun(Operand_tokens) -> infer_atom_type( Operand_tokens, Catalog, Table_names, Query_name ) end ), fun(Atom_types) -> case unify_types_nullable(Atom_types) of {ok, {some, Result_type}} -> {ok, Result_type}; {ok, none} -> {error, {unsupported_expression, Query_name, tok_tokens_to_text(Tokens)}}; {error, nil} -> {error, {unsupported_expression, Query_name, tok_tokens_to_text(Tokens)}} end end ). -file("src/sqlode/query_analyzer/column_inferencer.gleam", 796). -spec resolve_select_columns( binary(), list(extracted_column()), sqlode@model:catalog(), binary(), list(binary()), list(binary()) ) -> {ok, list(sqlode@model:result_item())} | {error, sqlode@query_analyzer@context:analysis_error()}. resolve_select_columns( Query_name, Columns, Catalog, Primary_table, All_tables, Nullable_tables ) -> case Columns of [{extracted_column, <<"*"/utf8>>, _, _, _}] -> Result_columns = begin _pipe = All_tables, gleam@list:flat_map( _pipe, fun(Table_name) -> case gleam@list:find( erlang:element(2, Catalog), fun(T) -> erlang:element(2, T) =:= Table_name end ) of {ok, Table} -> gleam@list:map( erlang:element(3, Table), fun(Col) -> {scalar_result, {result_column, erlang:element(2, Col), erlang:element(3, Col), erlang:element(4, Col) orelse gleam@list:contains( Nullable_tables, Table_name ), {some, Table_name}}} end ); {error, _} -> [] end end ) end, case Result_columns of [] -> {error, {table_not_found, Query_name, Primary_table}}; _ -> {ok, Result_columns} end; _ -> _pipe@5 = gleam@list:try_map( Columns, fun(Extracted) -> Trimmed = gleam@string:trim(erlang:element(2, Extracted)), Lowered = string:lowercase(Trimmed), case gleam_stdlib:string_starts_with( Lowered, <<"sqlode.embed("/utf8>> ) of true -> Embed_name = begin _pipe@1 = Lowered, _pipe@2 = gleam@string:replace( _pipe@1, <<"sqlode.embed("/utf8>>, <<""/utf8>> ), _pipe@3 = gleam@string:replace( _pipe@2, <<")"/utf8>>, <<""/utf8>> ), gleam@string:trim(_pipe@3) end, case begin _pipe@4 = erlang:element(2, Catalog), gleam@list:find( _pipe@4, fun(Table@1) -> erlang:element(2, Table@1) =:= Embed_name end ) end of {ok, Table@2} -> {ok, [{embedded_result, {embedded_column, Embed_name, Embed_name, erlang:element(3, Table@2)}}]}; {error, _} -> {error, {table_not_found, Query_name, Embed_name}} end; false -> Normalized_name = string:lowercase(Trimmed), Qualified_lookup_column = case erlang:element( 5, Extracted ) of {some, [{ident, _}, dot, {ident, C}]} -> {some, C}; _ -> none end, case erlang:element(3, Extracted) of {some, Table@3} -> Catalog_column = case sqlode@query_analyzer@context:find_column( Catalog, Table@3, Normalized_name ) of {some, C@1} -> {some, C@1}; none -> case Qualified_lookup_column of {some, Name} -> sqlode@query_analyzer@context:find_column( Catalog, Table@3, Name ); none -> none end end, case Catalog_column of {some, Column} -> {ok, [{scalar_result, {result_column, Normalized_name, erlang:element( 3, Column ), erlang:element( 4, Column ) orelse gleam@list:contains( Nullable_tables, Table@3 ), {some, Table@3}}}]}; none -> case erlang:element(5, Extracted) of {some, Expr_tokens} -> gleam@result:'try'( infer_expression_type_from_tokens( Expr_tokens, Catalog, All_tables, Query_name ), fun(_use0) -> {Scalar_type, Nullable} = _use0, {ok, [{scalar_result, {result_column, Normalized_name, Scalar_type, Nullable, none}}]} end ); none -> {error, {column_not_found, Query_name, Table@3, Normalized_name}} end end; none -> case sqlode@query_analyzer@context:find_column_in_tables( Catalog, All_tables, Normalized_name ) of {ok, {some, {Found_table, Column@1}}} -> {ok, [{scalar_result, {result_column, erlang:element( 2, Column@1 ), erlang:element( 3, Column@1 ), erlang:element( 4, Column@1 ) orelse gleam@list:contains( Nullable_tables, Found_table ), {some, Found_table}}}]}; {error, Matching_tables} -> {error, {ambiguous_column_name, Query_name, Normalized_name, Matching_tables}}; {ok, none} -> case erlang:element(5, Extracted) of {some, Expr_tokens@1} -> gleam@result:'try'( infer_expression_type_from_tokens( Expr_tokens@1, Catalog, All_tables, Query_name ), fun(_use0@1) -> {Scalar_type@1, Nullable@1} = _use0@1, {ok, [{scalar_result, {result_column, Normalized_name, Scalar_type@1, Nullable@1, none}}]} end ); none -> {error, {column_not_found, Query_name, Primary_table, Normalized_name}} end end end end end ), gleam@result:map(_pipe@5, fun lists:append/1) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 52). ?DOC( " Use the structured IR to drive column inference when available.\n" " For SelectStatement, we extract columns from `select_items` and\n" " table names from `from`/`joins`, delegating expression type\n" " inference to the existing token-based helpers.\n" " For other statement types, fall back to token-based inference.\n" ). -spec infer_columns_from_ir( binary(), list(sqlode@lexer:token()), sqlode@query_ir:sql_statement(), sqlode@model:catalog() ) -> {ok, list(sqlode@model:result_item())} | {error, sqlode@query_analyzer@context:analysis_error()}. infer_columns_from_ir(Query_name, Tokens, Statement, Catalog) -> case Statement of {select_statement, Items, From_items, Join_clauses, _, _, _, _, _} -> case has_compound_keyword(Tokens) of true -> infer_columns_from_tokens(Query_name, Tokens, Catalog); false -> Ir_tables = extract_ir_table_names(From_items, Join_clauses), case Ir_tables of [] -> infer_columns_from_tokens( Query_name, Tokens, Catalog ); _ -> case has_embed_items(Items) of true -> infer_columns_from_tokens( Query_name, Tokens, Catalog ); false -> gleam@result:'try'( augment_with_subquery_tables( Query_name, Tokens, Catalog ), fun(Augmented) -> Nullable_tables = tok_extract_nullable_tables( Tokens, Ir_tables ), Select_columns = ir_select_items_to_extracted( Items, Ir_tables ), case Select_columns of [] -> infer_columns_from_tokens( Query_name, Tokens, Catalog ); _ -> resolve_select_columns( Query_name, Select_columns, Augmented, case Ir_tables of [First | _] -> First; [] -> <<""/utf8>> end, Ir_tables, Nullable_tables ) end end ) end end end; _ -> infer_columns_from_tokens(Query_name, Tokens, Catalog) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 24). -spec infer_result_columns( sqlode@query_analyzer@context:analyzer_context(), sqlode@model:engine(), sqlode@model:parsed_query(), list(sqlode@lexer:token()), sqlode@query_ir:sql_statement(), sqlode@model:catalog() ) -> {ok, list(sqlode@model:result_item())} | {error, sqlode@query_analyzer@context:analysis_error()}. infer_result_columns(_, _, Query, Tokens, Statement, Catalog) -> case erlang:element(4, Query) of query_exec -> {ok, []}; query_exec_result -> {ok, []}; query_exec_rows -> {ok, []}; query_exec_last_id -> {ok, []}; query_batch_exec -> {ok, []}; query_copy_from -> {ok, []}; query_one -> infer_columns_from_ir( erlang:element(2, Query), Tokens, Statement, Catalog ); query_many -> infer_columns_from_ir( erlang:element(2, Query), Tokens, Statement, Catalog ); query_batch_one -> infer_columns_from_ir( erlang:element(2, Query), Tokens, Statement, Catalog ); query_batch_many -> infer_columns_from_ir( erlang:element(2, Query), Tokens, Statement, Catalog ) end. -file("src/sqlode/query_analyzer/column_inferencer.gleam", 1677). ?DOC( " Infer the type of a CASE expression by inspecting every THEN branch and\n" " the ELSE clause (if present), unifying the branch types, and honoring\n" " the nullability of each branch. `tokens` must start right after the\n" " outer `CASE` keyword. Nested CASE expressions and parenthesized\n" " sub-expressions inside branches are handled by tracking independent\n" " `case_depth` and `paren_depth` counters.\n" ). -spec infer_case_type_from_tokens( list(sqlode@lexer:token()), sqlode@model:catalog(), list(binary()), binary() ) -> {ok, {sqlode@model:scalar_type(), boolean()}} | {error, sqlode@query_analyzer@context:analysis_error()}. infer_case_type_from_tokens(Tokens, Catalog, Table_names, Query_name) -> {Then_branches, Else_branch} = collect_case_branches(Tokens), case Then_branches of [] -> {error, {unsupported_expression, Query_name, tok_tokens_to_text(Tokens)}}; _ -> Branch_tokens = case Else_branch of {some, E} -> lists:append(Then_branches, [E]); none -> Then_branches end, gleam@result:'try'( gleam@list:try_map( Branch_tokens, fun(Branch) -> infer_atom_type( Branch, Catalog, Table_names, Query_name ) end ), fun(Atom_types) -> case unify_types_nullable(Atom_types) of {ok, {some, {Scalar_type, Branches_nullable}}} -> Nullable = case Else_branch of {some, _} -> Branches_nullable; none -> true end, {ok, {Scalar_type, Nullable}}; {ok, none} -> {error, {unsupported_expression, Query_name, tok_tokens_to_text(Tokens)}}; {error, nil} -> {error, {unsupported_expression, Query_name, tok_tokens_to_text(Tokens)}} end end ) end.