-module(sqlode@query_analyzer@token_utils). -compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]). -define(FILEPATH, "src/sqlode/query_analyzer/token_utils.gleam"). -export([skip_parens/2, read_table_name/1, strip_leading_with/1, extract_placeholders/1, collect_paren_contents/1, read_subquery_alias/1, extract_table_names/1, split_on_commas/1, strip_insert_or_action/1, find_insert_parts/1, find_equality_patterns/1, find_quantified_patterns/1, find_in_patterns/1, find_type_casts/1, parse_placeholder_index/1, find_set_patterns/1, structure_tokens/1]). -export_type([insert_parts/0, equality_match/0, type_cast/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 insert_parts() :: {insert_parts, binary(), list(binary()), list(list(sqlode@lexer:token()))}. -type equality_match() :: {equality_match, binary(), gleam@option:option(binary()), binary()}. -type type_cast() :: {type_cast, binary(), binary()}. -file("src/sqlode/query_analyzer/token_utils.gleam", 155). ?DOC(" Skip tokens until all parentheses at the given depth are closed.\n"). -spec skip_parens(list(sqlode@lexer:token()), integer()) -> list(sqlode@lexer:token()). skip_parens(Tokens, Depth) -> case Depth =< 0 of true -> Tokens; false -> case Tokens of [] -> []; [l_paren | Rest] -> skip_parens(Rest, Depth + 1); [r_paren | Rest@1] -> skip_parens(Rest@1, Depth - 1); [_ | Rest@2] -> skip_parens(Rest@2, Depth) end end. -file("src/sqlode/query_analyzer/token_utils.gleam", 103). ?DOC( " Read a table name from the current token position, handling schema-qualified\n" " names (schema.table) and subqueries in parentheses.\n" ). -spec read_table_name(list(sqlode@lexer:token())) -> {gleam@option:option(binary()), list(sqlode@lexer:token())}. read_table_name(Tokens) -> case Tokens of [{ident, _}, dot, {ident, Name} | Rest] -> {{some, string:lowercase(Name)}, Rest}; [{ident, Name@1} | Rest@1] -> {{some, string:lowercase(Name@1)}, Rest@1}; [{quoted_ident, Name@2} | Rest@2] -> {{some, string:lowercase(Name@2)}, Rest@2}; [l_paren | Rest@3] -> Remaining = skip_parens(Rest@3, 1), {none, Remaining}; _ -> {none, Tokens} end. -file("src/sqlode/query_analyzer/token_utils.gleam", 140). -spec skip_with_body(list(sqlode@lexer:token())) -> list(sqlode@lexer:token()). skip_with_body(Tokens) -> case Tokens of [] -> []; [{keyword, Kw} | _] = T when (((Kw =:= <<"select"/utf8>>) orelse (Kw =:= <<"insert"/utf8>>)) orelse (Kw =:= <<"update"/utf8>>)) orelse (Kw =:= <<"delete"/utf8>>) -> T; [l_paren | Rest] -> Remaining = skip_parens(Rest, 1), skip_with_body(Remaining); [_ | Rest@1] -> skip_with_body(Rest@1) end. -file("src/sqlode/query_analyzer/token_utils.gleam", 131). ?DOC( " Strip a leading `WITH [RECURSIVE] cte_defs` clause so downstream\n" " passes see only the tokens of the main statement. Each CTE\n" " definition lives inside parentheses so we skip them along with\n" " the name / column-list preamble between commas. If the statement\n" " does not begin with WITH, the tokens are returned unchanged.\n" "\n" " This is the same strip that the column inferencer uses for\n" " result-column scoping; exposing it here lets the parameter\n" " inferencer reuse the identical boundary when it decides which\n" " tables are visible to a top-level WHERE/ON predicate.\n" ). -spec strip_leading_with(list(sqlode@lexer:token())) -> list(sqlode@lexer:token()). strip_leading_with(Tokens) -> case Tokens of [{keyword, <<"with"/utf8>>}, {keyword, <<"recursive"/utf8>>} | Rest] -> skip_with_body(Rest); [{keyword, <<"with"/utf8>>} | Rest@1] -> skip_with_body(Rest@1); _ -> Tokens end. -file("src/sqlode/query_analyzer/token_utils.gleam", 173). ?DOC(" Extract all placeholder token strings from a token list.\n"). -spec extract_placeholders(list(sqlode@lexer:token())) -> list(binary()). extract_placeholders(Tokens) -> gleam@list:filter_map(Tokens, fun(Token) -> case Token of {placeholder, P} -> {ok, P}; _ -> {error, nil} end end). -file("src/sqlode/query_analyzer/token_utils.gleam", 191). -spec collect_paren_loop( list(sqlode@lexer:token()), integer(), list(sqlode@lexer:token()) ) -> {list(sqlode@lexer:token()), list(sqlode@lexer:token())}. collect_paren_loop(Tokens, Depth, Acc) -> case Depth =< 0 of true -> {lists:reverse(Acc), Tokens}; false -> case Tokens of [] -> {lists:reverse(Acc), []}; [l_paren | Rest] -> collect_paren_loop(Rest, Depth + 1, [l_paren | Acc]); [r_paren | Rest@1] -> case Depth =:= 1 of true -> {lists:reverse(Acc), Rest@1}; false -> collect_paren_loop( Rest@1, Depth - 1, [r_paren | Acc] ) end; [Token | Rest@2] -> collect_paren_loop(Rest@2, Depth, [Token | Acc]) end end. -file("src/sqlode/query_analyzer/token_utils.gleam", 185). ?DOC( " Collect tokens inside the next parenthesized group.\n" " Expects tokens starting right after the opening LParen.\n" " Returns #(inner_tokens, remaining_tokens_after_RParen).\n" ). -spec collect_paren_contents(list(sqlode@lexer:token())) -> {list(sqlode@lexer:token()), list(sqlode@lexer:token())}. collect_paren_contents(Tokens) -> collect_paren_loop(Tokens, 1, []). -file("src/sqlode/query_analyzer/token_utils.gleam", 79). ?DOC( " Read an optional AS followed by an identifier alias, optionally\n" " followed by a parenthesised column list. Returns the alias and the\n" " token stream positioned after the alias (and column list, if any).\n" ). -spec read_subquery_alias(list(sqlode@lexer:token())) -> {gleam@option:option(binary()), list(sqlode@lexer:token())}. read_subquery_alias(Tokens) -> After_as = case Tokens of [{keyword, <<"as"/utf8>>} | Rest] -> Rest; _ -> Tokens end, case After_as of [{ident, Name}, l_paren | Rest_after_lp] -> {_, After_cols} = collect_paren_contents(Rest_after_lp), {{some, string:lowercase(Name)}, After_cols}; [{quoted_ident, Name@1}, l_paren | Rest_after_lp@1] -> {_, After_cols@1} = collect_paren_contents(Rest_after_lp@1), {{some, string:lowercase(Name@1)}, After_cols@1}; [{ident, Name@2} | Rest@1] -> {{some, string:lowercase(Name@2)}, Rest@1}; [{quoted_ident, Name@3} | Rest@2] -> {{some, string:lowercase(Name@3)}, Rest@2}; _ -> {none, After_as} end. -file("src/sqlode/query_analyzer/token_utils.gleam", 15). -spec table_names_loop(list(sqlode@lexer:token()), list(binary())) -> list(binary()). table_names_loop(Tokens, Acc) -> case Tokens of [] -> lists:reverse(Acc); [{keyword, <<"from"/utf8>>}, l_paren | Rest] -> Remaining = skip_parens(Rest, 1), case read_subquery_alias(Remaining) of {{some, N}, After_alias} -> table_names_loop(After_alias, [N | Acc]); {none, _} -> table_names_loop(Remaining, Acc) end; [{keyword, Kw} | Rest@1] when ((Kw =:= <<"from"/utf8>>) orelse (Kw =:= <<"into"/utf8>>)) orelse (Kw =:= <<"update"/utf8>>) -> {Name, Remaining@1} = read_table_name(Rest@1), case Name of {some, N@1} -> table_names_loop(Remaining@1, [N@1 | Acc]); none -> table_names_loop(Rest@1, Acc) end; [{keyword, <<"join"/utf8>>}, {keyword, <<"lateral"/utf8>>}, l_paren | Rest@2] -> Remaining@2 = skip_parens(Rest@2, 1), case read_subquery_alias(Remaining@2) of {{some, N@2}, After_alias@1} -> table_names_loop(After_alias@1, [N@2 | Acc]); {none, _} -> table_names_loop(Remaining@2, Acc) end; [{keyword, <<"join"/utf8>>}, l_paren | Rest@3] -> Remaining@3 = skip_parens(Rest@3, 1), case read_subquery_alias(Remaining@3) of {{some, N@3}, After_alias@2} -> table_names_loop(After_alias@2, [N@3 | Acc]); {none, _} -> table_names_loop(Remaining@3, Acc) end; [{keyword, <<"lateral"/utf8>>}, l_paren | Rest@4] -> Remaining@4 = skip_parens(Rest@4, 1), case read_subquery_alias(Remaining@4) of {{some, N@4}, After_alias@3} -> table_names_loop(After_alias@3, [N@4 | Acc]); {none, _} -> table_names_loop(Remaining@4, Acc) end; [{keyword, <<"join"/utf8>>} | Rest@5] -> {Name@1, Remaining@5} = read_table_name(Rest@5), case Name@1 of {some, N@5} -> table_names_loop(Remaining@5, [N@5 | Acc]); none -> table_names_loop(Rest@5, Acc) end; [_ | Rest@6] -> table_names_loop(Rest@6, Acc) end. -file("src/sqlode/query_analyzer/token_utils.gleam", 10). ?DOC(" Extract all table names referenced in a token list (FROM, INTO, UPDATE, JOIN).\n"). -spec extract_table_names(list(sqlode@lexer:token())) -> list(binary()). extract_table_names(Tokens) -> _pipe = table_names_loop(Tokens, []), gleam@list:unique(_pipe). -file("src/sqlode/query_analyzer/token_utils.gleam", 218). -spec split_commas_loop( list(sqlode@lexer:token()), integer(), list(sqlode@lexer:token()), list(list(sqlode@lexer:token())) ) -> list(list(sqlode@lexer:token())). split_commas_loop(Tokens, Depth, Current, Acc) -> case Tokens of [] -> case Current of [] -> lists:reverse(Acc); _ -> lists:reverse([lists:reverse(Current) | Acc]) end; [comma | Rest] when Depth =:= 0 -> case Current of [] -> split_commas_loop(Rest, 0, [], Acc); _ -> split_commas_loop( Rest, 0, [], [lists:reverse(Current) | Acc] ) end; [l_paren | Rest@1] -> split_commas_loop(Rest@1, Depth + 1, [l_paren | Current], Acc); [r_paren | Rest@2] -> split_commas_loop(Rest@2, Depth - 1, [r_paren | Current], Acc); [Token | Rest@3] -> split_commas_loop(Rest@3, Depth, [Token | Current], Acc) end. -file("src/sqlode/query_analyzer/token_utils.gleam", 214). ?DOC(" Split tokens on top-level commas (depth 0).\n"). -spec split_on_commas(list(sqlode@lexer:token())) -> list(list(sqlode@lexer:token())). split_on_commas(Tokens) -> split_commas_loop(Tokens, 0, [], []). -file("src/sqlode/query_analyzer/token_utils.gleam", 291). -spec is_insert_or_action_token(sqlode@lexer:token()) -> boolean(). is_insert_or_action_token(Token) -> case Token of {keyword, <<"replace"/utf8>>} -> true; {keyword, <<"rollback"/utf8>>} -> true; {keyword, <<"ignore"/utf8>>} -> true; {keyword, <<"abort"/utf8>>} -> true; {keyword, <<"fail"/utf8>>} -> true; {ident, T} -> ((((((((T =:= <<"ignore"/utf8>>) orelse (T =:= <<"IGNORE"/utf8>>)) orelse (T =:= <<"Ignore"/utf8>>)) orelse (T =:= <<"abort"/utf8>>)) orelse (T =:= <<"ABORT"/utf8>>)) orelse (T =:= <<"Abort"/utf8>>)) orelse (T =:= <<"fail"/utf8>>)) orelse (T =:= <<"FAIL"/utf8>>)) orelse (T =:= <<"Fail"/utf8>>); _ -> false end. -file("src/sqlode/query_analyzer/token_utils.gleam", 280). ?DOC( " Strip a leading SQLite-specific `OR ` qualifier\n" " from the token stream that follows `INSERT`. Returns the input\n" " unchanged when the next token is not `OR`. The five conflict\n" " actions per are\n" " REPLACE, ROLLBACK, ABORT, FAIL, IGNORE — REPLACE and ROLLBACK\n" " are reserved keywords, the other three lex as `Ident` tokens\n" " (case-preserving). (#478)\n" ). -spec strip_insert_or_action(list(sqlode@lexer:token())) -> list(sqlode@lexer:token()). strip_insert_or_action(Tokens) -> case Tokens of [{keyword, <<"or"/utf8>>}, Action | Rest] -> case is_insert_or_action_token(Action) of true -> Rest; false -> Tokens end; _ -> Tokens end. -file("src/sqlode/query_analyzer/token_utils.gleam", 357). -spec skip_to_values(list(sqlode@lexer:token())) -> gleam@option:option(list(sqlode@lexer:token())). skip_to_values(Tokens) -> case Tokens of [] -> none; [{keyword, <<"values"/utf8>>} | Rest] -> {some, Rest}; [_ | Rest@1] -> skip_to_values(Rest@1) end. -file("src/sqlode/query_analyzer/token_utils.gleam", 317). -spec parse_insert_after_into(list(sqlode@lexer:token())) -> gleam@option:option(insert_parts()). parse_insert_after_into(Tokens) -> {Table_name_opt, Rest} = read_table_name(Tokens), case Table_name_opt of none -> none; {some, Table_name} -> case Rest of [l_paren | After_lparen] -> {Col_tokens, After_cols} = collect_paren_contents( After_lparen ), Columns = begin _pipe = split_on_commas(Col_tokens), gleam@list:filter_map(_pipe, fun(Group) -> case Group of [{ident, Name}] -> {ok, sqlode@naming:normalize_identifier( Name )}; [{quoted_ident, Name@1}] -> {ok, sqlode@naming:normalize_identifier( Name@1 )}; _ -> {error, nil} end end) end, case skip_to_values(After_cols) of none -> none; {some, After_values_kw} -> case After_values_kw of [l_paren | After_vlparen] -> {Val_tokens, _} = collect_paren_contents( After_vlparen ), Values = split_on_commas(Val_tokens), {some, {insert_parts, Table_name, Columns, Values}}; _ -> none end end; _ -> none end end. -file("src/sqlode/query_analyzer/token_utils.gleam", 260). -spec find_insert_loop(list(sqlode@lexer:token())) -> gleam@option:option(insert_parts()). find_insert_loop(Tokens) -> case Tokens of [] -> none; [{keyword, <<"insert"/utf8>>} | Rest] -> case strip_insert_or_action(Rest) of [{keyword, <<"into"/utf8>>} | After_into] -> parse_insert_after_into(After_into); _ -> find_insert_loop(Rest) end; [_ | Rest@1] -> find_insert_loop(Rest@1) end. -file("src/sqlode/query_analyzer/token_utils.gleam", 256). ?DOC(" Find INSERT INTO table (columns) VALUES (values) structure in tokens.\n"). -spec find_insert_parts(list(sqlode@lexer:token())) -> gleam@option:option(insert_parts()). find_insert_parts(Tokens) -> find_insert_loop(Tokens). -file("src/sqlode/query_analyzer/token_utils.gleam", 383). -spec find_equality_loop(list(sqlode@lexer:token()), list(equality_match())) -> list(equality_match()). find_equality_loop(Tokens, Acc) -> case Tokens of [] -> Acc; [{ident, T}, dot, {ident, C}, {operator, Op}, {placeholder, P} | Rest] when ((((((Op =:= <<"="/utf8>>) orelse (Op =:= <<"!="/utf8>>)) orelse (Op =:= <<"<>"/utf8>>)) orelse (Op =:= <<"<"/utf8>>)) orelse (Op =:= <<">"/utf8>>)) orelse (Op =:= <<"<="/utf8>>)) orelse (Op =:= <<">="/utf8>>) -> find_equality_loop( Rest, [{equality_match, sqlode@naming:normalize_identifier(C), {some, string:lowercase(T)}, P} | Acc] ); [{ident, T@1}, dot, {quoted_ident, C@1}, {operator, Op@1}, {placeholder, P@1} | Rest@1] when ((((((Op@1 =:= <<"="/utf8>>) orelse (Op@1 =:= <<"!="/utf8>>)) orelse (Op@1 =:= <<"<>"/utf8>>)) orelse (Op@1 =:= <<"<"/utf8>>)) orelse (Op@1 =:= <<">"/utf8>>)) orelse (Op@1 =:= <<"<="/utf8>>)) orelse (Op@1 =:= <<">="/utf8>>) -> find_equality_loop( Rest@1, [{equality_match, sqlode@naming:normalize_identifier(C@1), {some, string:lowercase(T@1)}, P@1} | Acc] ); [{ident, C@2}, {operator, Op@2}, {placeholder, P@2} | Rest@2] when ((((((Op@2 =:= <<"="/utf8>>) orelse (Op@2 =:= <<"!="/utf8>>)) orelse (Op@2 =:= <<"<>"/utf8>>)) orelse (Op@2 =:= <<"<"/utf8>>)) orelse (Op@2 =:= <<">"/utf8>>)) orelse (Op@2 =:= <<"<="/utf8>>)) orelse (Op@2 =:= <<">="/utf8>>) -> find_equality_loop( Rest@2, [{equality_match, sqlode@naming:normalize_identifier(C@2), none, P@2} | Acc] ); [{quoted_ident, C@3}, {operator, Op@3}, {placeholder, P@3} | Rest@3] when ((((((Op@3 =:= <<"="/utf8>>) orelse (Op@3 =:= <<"!="/utf8>>)) orelse (Op@3 =:= <<"<>"/utf8>>)) orelse (Op@3 =:= <<"<"/utf8>>)) orelse (Op@3 =:= <<">"/utf8>>)) orelse (Op@3 =:= <<"<="/utf8>>)) orelse (Op@3 =:= <<">="/utf8>>) -> find_equality_loop( Rest@3, [{equality_match, sqlode@naming:normalize_identifier(C@3), none, P@3} | Acc] ); [{ident, T@2}, dot, {ident, C@4}, {keyword, Kw}, {placeholder, P@4} | Rest@4] when (Kw =:= <<"like"/utf8>>) orelse (Kw =:= <<"ilike"/utf8>>) -> find_equality_loop( Rest@4, [{equality_match, sqlode@naming:normalize_identifier(C@4), {some, string:lowercase(T@2)}, P@4} | Acc] ); [{ident, C@5}, {keyword, Kw@1}, {placeholder, P@5} | Rest@5] when (Kw@1 =:= <<"like"/utf8>>) orelse (Kw@1 =:= <<"ilike"/utf8>>) -> find_equality_loop( Rest@5, [{equality_match, sqlode@naming:normalize_identifier(C@5), none, P@5} | Acc] ); [_ | Rest@6] -> find_equality_loop(Rest@6, Acc) end. -file("src/sqlode/query_analyzer/token_utils.gleam", 378). ?DOC(" Find all column [op] placeholder patterns in tokens.\n"). -spec find_equality_patterns(list(sqlode@lexer:token())) -> list(equality_match()). find_equality_patterns(Tokens) -> _pipe = find_equality_loop(Tokens, []), lists:reverse(_pipe). -file("src/sqlode/query_analyzer/token_utils.gleam", 536). -spec find_quantified_loop(list(sqlode@lexer:token()), list(equality_match())) -> list(equality_match()). find_quantified_loop(Tokens, Acc) -> case Tokens of [] -> Acc; [{ident, T}, dot, {ident, C}, {operator, _}, {keyword, Q}, l_paren, {placeholder, P}, r_paren | Rest] when ((Q =:= <<"any"/utf8>>) orelse (Q =:= <<"all"/utf8>>)) orelse (Q =:= <<"some"/utf8>>) -> find_quantified_loop( Rest, [{equality_match, sqlode@naming:normalize_identifier(C), {some, string:lowercase(T)}, P} | Acc] ); [{ident, C@1}, {operator, _}, {keyword, Q@1}, l_paren, {placeholder, P@1}, r_paren | Rest@1] when ((Q@1 =:= <<"any"/utf8>>) orelse (Q@1 =:= <<"all"/utf8>>)) orelse (Q@1 =:= <<"some"/utf8>>) -> find_quantified_loop( Rest@1, [{equality_match, sqlode@naming:normalize_identifier(C@1), none, P@1} | Acc] ); [_ | Rest@2] -> find_quantified_loop(Rest@2, Acc) end. -file("src/sqlode/query_analyzer/token_utils.gleam", 529). ?DOC( " Find all `column op ANY|ALL|SOME (placeholder)` patterns. This\n" " complements `find_in_patterns` for the PostgreSQL-style quantified\n" " comparison syntax `t.id = ANY(sqlode.slice(team_ids))` used by\n" " fixture 2 in Issue #393.\n" ). -spec find_quantified_patterns(list(sqlode@lexer:token())) -> list(equality_match()). find_quantified_patterns(Tokens) -> _pipe = find_quantified_loop(Tokens, []), lists:reverse(_pipe). -file("src/sqlode/query_analyzer/token_utils.gleam", 588). -spec find_in_loop(list(sqlode@lexer:token()), list(equality_match())) -> list(equality_match()). find_in_loop(Tokens, Acc) -> case Tokens of [] -> Acc; [{ident, T}, dot, {ident, C}, {keyword, <<"in"/utf8>>}, l_paren, {placeholder, P}, r_paren | Rest] -> find_in_loop( Rest, [{equality_match, sqlode@naming:normalize_identifier(C), {some, string:lowercase(T)}, P} | Acc] ); [{ident, C@1}, {keyword, <<"in"/utf8>>}, l_paren, {placeholder, P@1}, r_paren | Rest@1] -> find_in_loop( Rest@1, [{equality_match, sqlode@naming:normalize_identifier(C@1), none, P@1} | Acc] ); [{quoted_ident, C@2}, {keyword, <<"in"/utf8>>}, l_paren, {placeholder, P@2}, r_paren | Rest@2] -> find_in_loop( Rest@2, [{equality_match, sqlode@naming:normalize_identifier(C@2), none, P@2} | Acc] ); [_ | Rest@3] -> find_in_loop(Rest@3, Acc) end. -file("src/sqlode/query_analyzer/token_utils.gleam", 520). ?DOC(" Find all column IN (placeholder) patterns in tokens.\n"). -spec find_in_patterns(list(sqlode@lexer:token())) -> list(equality_match()). find_in_patterns(Tokens) -> _pipe = find_in_loop(Tokens, []), lists:reverse(_pipe). -file("src/sqlode/query_analyzer/token_utils.gleam", 668). -spec find_type_cast_loop(list(sqlode@lexer:token()), list(type_cast())) -> list(type_cast()). find_type_cast_loop(Tokens, Acc) -> case Tokens of [] -> Acc; [{placeholder, P}, {operator, <<"::"/utf8>>}, {ident, T} | Rest] -> find_type_cast_loop( Rest, [{type_cast, P, string:lowercase(T)} | Acc] ); [{placeholder, P@1}, {operator, <<"::"/utf8>>}, {keyword, T@1} | Rest@1] -> find_type_cast_loop(Rest@1, [{type_cast, P@1, T@1} | Acc]); [_ | Rest@2] -> find_type_cast_loop(Rest@2, Acc) end. -file("src/sqlode/query_analyzer/token_utils.gleam", 663). ?DOC(" Find all $N::type patterns in tokens (PostgreSQL only).\n"). -spec find_type_casts(list(sqlode@lexer:token())) -> list(type_cast()). find_type_casts(Tokens) -> _pipe = find_type_cast_loop(Tokens, []), lists:reverse(_pipe). -file("src/sqlode/query_analyzer/token_utils.gleam", 691). ?DOC(" Parse a placeholder string like \"$3\" into its integer index.\n"). -spec parse_placeholder_index(binary()) -> {ok, integer()} | {error, nil}. parse_placeholder_index(Placeholder) -> _pipe = Placeholder, _pipe@1 = gleam@string:replace(_pipe, <<"$"/utf8>>, <<""/utf8>>), _pipe@2 = gleam_stdlib:parse_int(_pipe@1), _pipe@3 = gleam@option:from_result(_pipe@2), gleam@option:to_result(_pipe@3, nil). -file("src/sqlode/query_analyzer/token_utils.gleam", 720). -spec scan_set_assignments(list(sqlode@lexer:token()), list(equality_match())) -> list(equality_match()). scan_set_assignments(Tokens, Acc) -> case Tokens of [] -> Acc; [{keyword, Kw} | _] when ((Kw =:= <<"where"/utf8>>) orelse (Kw =:= <<"returning"/utf8>>)) orelse (Kw =:= <<"from"/utf8>>) -> Acc; [{ident, C}, {operator, <<"="/utf8>>}, {placeholder, P} | Rest] -> scan_set_assignments( Rest, [{equality_match, sqlode@naming:normalize_identifier(C), none, P} | Acc] ); [{quoted_ident, C@1}, {operator, <<"="/utf8>>}, {placeholder, P@1} | Rest@1] -> scan_set_assignments( Rest@1, [{equality_match, sqlode@naming:normalize_identifier(C@1), none, P@1} | Acc] ); [_ | Rest@2] -> scan_set_assignments(Rest@2, Acc) end. -file("src/sqlode/query_analyzer/token_utils.gleam", 709). -spec find_set_clause(list(sqlode@lexer:token()), list(equality_match())) -> list(equality_match()). find_set_clause(Tokens, Acc) -> case Tokens of [] -> Acc; [{keyword, <<"set"/utf8>>} | Rest] -> scan_set_assignments(Rest, Acc); [_ | Rest@1] -> find_set_clause(Rest@1, Acc) end. -file("src/sqlode/query_analyzer/token_utils.gleam", 704). ?DOC(" Find all column = placeholder patterns in SET clauses.\n"). -spec find_set_patterns(list(sqlode@lexer:token())) -> list(equality_match()). find_set_patterns(Tokens) -> _pipe = find_set_clause(Tokens, []), lists:reverse(_pipe). -file("src/sqlode/query_analyzer/token_utils.gleam", 779). -spec skip_cte_body(list(sqlode@lexer:token())) -> list(sqlode@lexer:token()). skip_cte_body(Tokens) -> case Tokens of [] -> []; [{keyword, <<"select"/utf8>>} | _] = T -> T; [{keyword, <<"insert"/utf8>>} | _] = T@1 -> T@1; [{keyword, <<"update"/utf8>>} | _] = T@2 -> T@2; [{keyword, <<"delete"/utf8>>} | _] = T@3 -> T@3; [l_paren | Rest] -> After = skip_parens(Rest, 1), skip_cte_body(After); [_ | Rest@1] -> skip_cte_body(Rest@1) end. -file("src/sqlode/query_analyzer/token_utils.gleam", 772). -spec strip_leading_cte(list(sqlode@lexer:token())) -> list(sqlode@lexer:token()). strip_leading_cte(Tokens) -> case Tokens of [{keyword, <<"with"/utf8>>} | Rest] -> skip_cte_body(Rest); _ -> Tokens end. -file("src/sqlode/query_analyzer/token_utils.gleam", 860). -spec extract_alias_from_item(list(sqlode@lexer:token())) -> gleam@option:option(binary()). extract_alias_from_item(Tokens) -> case lists:reverse(Tokens) of [{ident, Name}, {keyword, <<"as"/utf8>>} | _] -> {some, sqlode@naming:normalize_identifier(Name)}; [{quoted_ident, Name@1}, {keyword, <<"as"/utf8>>} | _] -> {some, sqlode@naming:normalize_identifier(Name@1)}; _ -> none end. -file("src/sqlode/query_analyzer/token_utils.gleam", 845). -spec parse_select_items(list(sqlode@lexer:token())) -> list(sqlode@query_ir:select_item()). parse_select_items(Tokens) -> Groups = split_on_commas(Tokens), gleam@list:map(Groups, fun(Group) -> case Group of [{operator, <<"*"/utf8>>}] -> {star_item, none}; [{ident, T}, dot, {operator, <<"*"/utf8>>}] -> {star_item, {some, string:lowercase(T)}}; _ -> Alias = extract_alias_from_item(Group), {expression_item, Group, Alias} end end). -file("src/sqlode/query_analyzer/token_utils.gleam", 889). -spec parse_from_items(list(sqlode@lexer:token())) -> list(sqlode@query_ir:from_item()). parse_from_items(Tokens) -> Groups = split_on_commas(Tokens), gleam@list:filter_map(Groups, fun(Group) -> case Group of [{ident, Name}] -> {ok, {table_ref, string:lowercase(Name), none}}; [{quoted_ident, Name@1}] -> {ok, {table_ref, string:lowercase(Name@1), none}}; [{ident, _}, dot, {ident, Name@2}] -> {ok, {table_ref, string:lowercase(Name@2), none}}; [{ident, Name@3}, {keyword, <<"as"/utf8>>}, {ident, A}] -> {ok, {table_ref, string:lowercase(Name@3), {some, string:lowercase(A)}}}; [{ident, Name@4}, {ident, A@1}] -> {ok, {table_ref, string:lowercase(Name@4), {some, string:lowercase(A@1)}}}; _ -> {error, nil} end end). -file("src/sqlode/query_analyzer/token_utils.gleam", 958). -spec skip_join_keywords(list(sqlode@lexer:token())) -> list(sqlode@lexer:token()). skip_join_keywords(Tokens) -> case Tokens of [{keyword, Kw} | Rest] when ((((((Kw =:= <<"outer"/utf8>>) orelse (Kw =:= <<"inner"/utf8>>)) orelse (Kw =:= <<"cross"/utf8>>)) orelse (Kw =:= <<"natural"/utf8>>)) orelse (Kw =:= <<"left"/utf8>>)) orelse (Kw =:= <<"right"/utf8>>)) orelse (Kw =:= <<"full"/utf8>>) -> skip_join_keywords(Rest); _ -> Tokens end. -file("src/sqlode/query_analyzer/token_utils.gleam", 998). -spec read_optional_alias(list(sqlode@lexer:token())) -> {gleam@option:option(binary()), list(sqlode@lexer:token())}. read_optional_alias(Tokens) -> case Tokens of [{keyword, <<"as"/utf8>>}, {ident, A} | Rest] -> {{some, string:lowercase(A)}, Rest}; [{keyword, <<"as"/utf8>>}, {quoted_ident, A@1} | Rest@1] -> {{some, string:lowercase(A@1)}, Rest@1}; [{ident, A@2} | Rest@2] when ((((((((A@2 =/= <<"on"/utf8>>) andalso (A@2 =/= <<"where"/utf8>>)) andalso (A@2 =/= <<"group"/utf8>>)) andalso (A@2 =/= <<"order"/utf8>>)) andalso (A@2 =/= <<"limit"/utf8>>)) andalso (A@2 =/= <<"join"/utf8>>)) andalso (A@2 =/= <<"left"/utf8>>)) andalso (A@2 =/= <<"right"/utf8>>)) andalso (A@2 =/= <<"inner"/utf8>>) -> {{some, string:lowercase(A@2)}, Rest@2}; _ -> {none, Tokens} end. -file("src/sqlode/query_analyzer/token_utils.gleam", 1108). -spec collect_until_kw_loop( list(sqlode@lexer:token()), list(binary()), integer(), list(sqlode@lexer:token()) ) -> {list(sqlode@lexer:token()), list(sqlode@lexer:token())}. collect_until_kw_loop(Tokens, Stop_keywords, Depth, Acc) -> case Tokens of [] -> {lists:reverse(Acc), []}; [{keyword, Kw} | _] when Depth =:= 0 -> case gleam@list:contains(Stop_keywords, Kw) of true -> {lists:reverse(Acc), Tokens}; false -> case Tokens of [T | Rest] -> collect_until_kw_loop( Rest, Stop_keywords, Depth, [T | Acc] ); _ -> {lists:reverse(Acc), []} end end; [l_paren | Rest@1] -> collect_until_kw_loop( Rest@1, Stop_keywords, Depth + 1, [l_paren | Acc] ); [r_paren | Rest@2] -> collect_until_kw_loop( Rest@2, Stop_keywords, Depth - 1, [r_paren | Acc] ); [T@1 | Rest@3] -> collect_until_kw_loop(Rest@3, Stop_keywords, Depth, [T@1 | Acc]) end. -file("src/sqlode/query_analyzer/token_utils.gleam", 1101). ?DOC(" Collect tokens until one of the stop keywords is found at depth 0.\n"). -spec collect_until_keyword(list(sqlode@lexer:token()), list(binary())) -> {list(sqlode@lexer:token()), list(sqlode@lexer:token())}. collect_until_keyword(Tokens, Stop_keywords) -> collect_until_kw_loop(Tokens, Stop_keywords, 0, []). -file("src/sqlode/query_analyzer/token_utils.gleam", 973). -spec parse_single_join(list(sqlode@lexer:token())) -> {gleam@option:option(sqlode@query_ir:join_clause()), list(sqlode@lexer:token())}. parse_single_join(Tokens) -> {Table_name_opt, Rest} = read_table_name(Tokens), case Table_name_opt of none -> {none, Rest}; {some, Name} -> {Alias, Rest2} = read_optional_alias(Rest), {On_tokens, Rest3} = case Rest2 of [{keyword, <<"on"/utf8>>} | After_on] -> {On_toks, After} = collect_until_keyword( After_on, [<<"join"/utf8>>, <<"left"/utf8>>, <<"right"/utf8>>, <<"inner"/utf8>>, <<"outer"/utf8>>, <<"cross"/utf8>>, <<"full"/utf8>>, <<"natural"/utf8>>, <<"where"/utf8>>, <<"group"/utf8>>, <<"having"/utf8>>, <<"order"/utf8>>, <<"limit"/utf8>>, <<"union"/utf8>>, <<"intersect"/utf8>>, <<"except"/utf8>>] ), {{some, On_toks}, After}; _ -> {none, Rest2} end, {{some, {join_clause, Name, Alias, On_tokens}}, Rest3} end. -file("src/sqlode/query_analyzer/token_utils.gleam", 920). -spec parse_joins_loop( list(sqlode@lexer:token()), list(sqlode@query_ir:join_clause()) ) -> {list(sqlode@query_ir:join_clause()), list(sqlode@lexer:token())}. parse_joins_loop(Tokens, Acc) -> case Tokens of [{keyword, Kw} | Rest] when (((((((Kw =:= <<"join"/utf8>>) orelse (Kw =:= <<"left"/utf8>>)) orelse (Kw =:= <<"right"/utf8>>)) orelse (Kw =:= <<"inner"/utf8>>)) orelse (Kw =:= <<"outer"/utf8>>)) orelse (Kw =:= <<"cross"/utf8>>)) orelse (Kw =:= <<"full"/utf8>>)) orelse (Kw =:= <<"natural"/utf8>>) -> After_join_kw = skip_join_keywords(Rest), case After_join_kw of [{keyword, <<"join"/utf8>>} | After_join] -> {Clause, Remaining} = parse_single_join(After_join), case Clause of {some, J} -> parse_joins_loop(Remaining, [J | Acc]); none -> parse_joins_loop(Remaining, Acc) end; _ -> {Clause@1, Remaining@1} = parse_single_join(After_join_kw), case Clause@1 of {some, J@1} -> parse_joins_loop(Remaining@1, [J@1 | Acc]); none -> parse_joins_loop(Remaining@1, Acc) end end; _ -> {lists:reverse(Acc), Tokens} end. -file("src/sqlode/query_analyzer/token_utils.gleam", 914). -spec parse_join_clauses(list(sqlode@lexer:token())) -> {list(sqlode@query_ir:join_clause()), list(sqlode@lexer:token())}. parse_join_clauses(Tokens) -> parse_joins_loop(Tokens, []). -file("src/sqlode/query_analyzer/token_utils.gleam", 870). -spec parse_from_clause(list(sqlode@lexer:token())) -> {list(sqlode@query_ir:from_item()), list(sqlode@query_ir:join_clause()), list(sqlode@lexer:token())}. parse_from_clause(Tokens) -> case Tokens of [{keyword, <<"from"/utf8>>} | Rest] -> {From_tokens, After_from} = collect_until_keyword( Rest, [<<"where"/utf8>>, <<"group"/utf8>>, <<"having"/utf8>>, <<"order"/utf8>>, <<"limit"/utf8>>, <<"union"/utf8>>, <<"intersect"/utf8>>, <<"except"/utf8>>, <<"join"/utf8>>, <<"left"/utf8>>, <<"right"/utf8>>, <<"inner"/utf8>>, <<"outer"/utf8>>, <<"cross"/utf8>>, <<"full"/utf8>>, <<"natural"/utf8>>, <<"lateral"/utf8>>] ), From_items = parse_from_items(From_tokens), {Joins, After_joins} = parse_join_clauses(After_from), {From_items, Joins, After_joins}; _ -> {[], [], Tokens} end. -file("src/sqlode/query_analyzer/token_utils.gleam", 1141). ?DOC(" Extract a clause that starts with `keyword` and ends before any of `stop_keywords`.\n"). -spec extract_clause(list(sqlode@lexer:token()), binary(), list(binary())) -> {gleam@option:option(list(sqlode@lexer:token())), list(sqlode@lexer:token())}. extract_clause(Tokens, Keyword, Stop_keywords) -> case Tokens of [{keyword, Kw} | Rest] when Kw =:= Keyword -> After_kw = case {Kw, Rest} of {<<"group"/utf8>>, [{keyword, <<"by"/utf8>>} | R]} -> R; {<<"order"/utf8>>, [{keyword, <<"by"/utf8>>} | R@1]} -> R@1; {_, _} -> Rest end, {Clause_tokens, Remaining} = collect_until_keyword( After_kw, Stop_keywords ), case Clause_tokens of [] -> {none, Remaining}; _ -> {{some, Clause_tokens}, Remaining} end; _ -> {none, Tokens} end. -file("src/sqlode/query_analyzer/token_utils.gleam", 797). -spec structure_select(list(sqlode@lexer:token())) -> sqlode@query_ir:sql_statement(). structure_select(Tokens) -> After_select = case Tokens of [{keyword, <<"select"/utf8>>}, {keyword, <<"distinct"/utf8>>} | Rest] -> Rest; [{keyword, <<"select"/utf8>>} | Rest@1] -> Rest@1; _ -> Tokens end, {Select_tokens, Rest_after_select} = collect_until_keyword( After_select, [<<"from"/utf8>>, <<"where"/utf8>>, <<"group"/utf8>>, <<"having"/utf8>>, <<"order"/utf8>>, <<"limit"/utf8>>, <<"union"/utf8>>, <<"intersect"/utf8>>, <<"except"/utf8>>] ), Select_items = parse_select_items(Select_tokens), {From_items, Joins, Rest_after_from} = parse_from_clause(Rest_after_select), {Where_tokens, Rest_after_where} = extract_clause( Rest_after_from, <<"where"/utf8>>, [<<"group"/utf8>>, <<"having"/utf8>>, <<"order"/utf8>>, <<"limit"/utf8>>, <<"union"/utf8>>, <<"intersect"/utf8>>, <<"except"/utf8>>] ), {Group_by_tokens, Rest_after_group} = extract_clause( Rest_after_where, <<"group"/utf8>>, [<<"having"/utf8>>, <<"order"/utf8>>, <<"limit"/utf8>>, <<"union"/utf8>>, <<"intersect"/utf8>>, <<"except"/utf8>>] ), {Having_tokens, Rest_after_having} = extract_clause( Rest_after_group, <<"having"/utf8>>, [<<"order"/utf8>>, <<"limit"/utf8>>, <<"union"/utf8>>, <<"intersect"/utf8>>, <<"except"/utf8>>] ), {Order_by_tokens, Rest_after_order} = extract_clause( Rest_after_having, <<"order"/utf8>>, [<<"limit"/utf8>>, <<"union"/utf8>>, <<"intersect"/utf8>>, <<"except"/utf8>>] ), {Limit_tokens, _} = extract_clause( Rest_after_order, <<"limit"/utf8>>, [<<"union"/utf8>>, <<"intersect"/utf8>>, <<"except"/utf8>>] ), {select_statement, Select_items, From_items, Joins, Where_tokens, Group_by_tokens, Having_tokens, Order_by_tokens, Limit_tokens}. -file("src/sqlode/query_analyzer/token_utils.gleam", 1165). -spec skip_to_keyword(list(sqlode@lexer:token()), binary()) -> gleam@option:option(list(sqlode@lexer:token())). skip_to_keyword(Tokens, Keyword) -> case Tokens of [] -> none; [{keyword, Kw} | Rest] when Kw =:= Keyword -> {some, Rest}; [_ | Rest@1] -> skip_to_keyword(Rest@1, Keyword) end. -file("src/sqlode/query_analyzer/token_utils.gleam", 1176). -spec extract_returning_tokens(list(sqlode@lexer:token())) -> gleam@option:option(list(sqlode@lexer:token())). extract_returning_tokens(Tokens) -> case skip_to_keyword(Tokens, <<"returning"/utf8>>) of {some, After_returning} -> case After_returning of [] -> none; Toks -> {some, Toks} end; none -> none end. -file("src/sqlode/query_analyzer/token_utils.gleam", 1027). -spec structure_insert(list(sqlode@lexer:token())) -> sqlode@query_ir:sql_statement(). structure_insert(Tokens) -> case find_insert_parts(Tokens) of {some, Parts} -> Returning = extract_returning_tokens(Tokens), {insert_statement, erlang:element(2, Parts), erlang:element(3, Parts), erlang:element(4, Parts), Returning}; none -> {unstructured_statement, Tokens} end. -file("src/sqlode/query_analyzer/token_utils.gleam", 1044). -spec structure_update(list(sqlode@lexer:token())) -> sqlode@query_ir:sql_statement(). structure_update(Tokens) -> case Tokens of [{keyword, <<"update"/utf8>>} | Rest] -> {Table_name_opt, After_table} = read_table_name(Rest), case Table_name_opt of none -> {unstructured_statement, Tokens}; {some, Name} -> {Set_tokens, After_set} = case skip_to_keyword( After_table, <<"set"/utf8>> ) of {some, After_set_kw} -> collect_until_keyword( After_set_kw, [<<"where"/utf8>>, <<"returning"/utf8>>, <<"from"/utf8>>] ); none -> {[], After_table} end, {Where_tokens, _} = extract_clause( After_set, <<"where"/utf8>>, [<<"returning"/utf8>>] ), Returning = extract_returning_tokens(Tokens), {update_statement, Name, Set_tokens, Where_tokens, Returning} end; _ -> {unstructured_statement, Tokens} end. -file("src/sqlode/query_analyzer/token_utils.gleam", 1076). -spec structure_delete(list(sqlode@lexer:token())) -> sqlode@query_ir:sql_statement(). structure_delete(Tokens) -> case Tokens of [{keyword, <<"delete"/utf8>>}, {keyword, <<"from"/utf8>>} | Rest] -> {Table_name_opt, After_table} = read_table_name(Rest), case Table_name_opt of none -> {unstructured_statement, Tokens}; {some, Name} -> {Where_tokens, _} = extract_clause( After_table, <<"where"/utf8>>, [<<"returning"/utf8>>] ), Returning = extract_returning_tokens(Tokens), {delete_statement, Name, Where_tokens, Returning} end; _ -> {unstructured_statement, Tokens} end. -file("src/sqlode/query_analyzer/token_utils.gleam", 761). ?DOC( " Build a `SqlStatement` from a token list. This function identifies the\n" " statement kind and decomposes it into its major clauses. Sub-expressions\n" " (WHERE predicates, etc.) remain as raw token lists — this is\n" " intentionally a *thin* IR that avoids building a full expression AST.\n" ). -spec structure_tokens(list(sqlode@lexer:token())) -> sqlode@query_ir:sql_statement(). structure_tokens(Tokens) -> Stripped = strip_leading_cte(Tokens), case Stripped of [{keyword, <<"select"/utf8>>} | _] -> structure_select(Stripped); [{keyword, <<"insert"/utf8>>} | _] -> structure_insert(Stripped); [{keyword, <<"update"/utf8>>} | _] -> structure_update(Stripped); [{keyword, <<"delete"/utf8>>} | _] -> structure_delete(Stripped); _ -> {unstructured_statement, Stripped} end.