-module(sqlode@schema_parser). -compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]). -define(FILEPATH, "src/sqlode/schema_parser.gleam"). -export([parse_files_with_engine/2, parse_files/1, error_to_string/1, warning_to_string/1]). -export_type([parse_error/0, schema_warning/0, parsed_schema/0, view_column/0, statement_result/0, unknown_statement_kind/0, d_d_l_action/0, inline_enum_detection/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 parse_error() :: {invalid_create_table, binary(), binary()} | {invalid_column, binary(), binary(), binary()} | {unsupported_mysql_ddl, binary(), binary()}. -type schema_warning() :: {unresolvable_view_column, binary()}. -type parsed_schema() :: {parsed_schema, list(sqlode@model:table()), list(sqlode@model:enum_def()), list(schema_warning())}. -type view_column() :: {view_column, binary(), list(sqlode@lexer:token())}. -type statement_result() :: {create_table_result, sqlode@model:table(), list(sqlode@model:enum_def())} | {d_d_l_applied, d_d_l_action()} | ignored. -type unknown_statement_kind() :: {destructive_d_d_l, d_d_l_action()} | silently_ignored | {unsupported_mysql, binary()}. -type d_d_l_action() :: {drop_table, binary()} | {drop_view, binary()} | {drop_type, binary()} | {alter_drop_column, binary(), binary()} | {alter_rename_table, binary(), binary()} | {alter_rename_column, binary(), binary(), binary()} | {alter_column_type, binary(), binary(), list(sqlode@lexer:token())} | {alter_modify_column, binary(), binary(), list(sqlode@lexer:token()), boolean()} | {alter_change_column, binary(), binary(), binary(), list(sqlode@lexer:token()), boolean()} | {alter_column_set_not_null, binary(), binary()} | {alter_column_drop_not_null, binary(), binary()}. -type inline_enum_detection() :: {inline_enum, sqlode@model:scalar_type(), sqlode@model:enum_def()}. -file("src/sqlode/schema_parser.gleam", 175). ?DOC(" Split token list on Semicolon tokens into a list of statements.\n"). -spec split_token_statements( list(sqlode@lexer:token()), list(sqlode@lexer:token()), list(list(sqlode@lexer:token())) ) -> list(list(sqlode@lexer:token())). split_token_statements(Tokens, Current, Acc) -> case Tokens of [] -> case Current of [] -> lists:reverse(Acc); _ -> lists:reverse([lists:reverse(Current) | Acc]) end; [semicolon | Rest] -> case Current of [] -> split_token_statements(Rest, [], Acc); _ -> split_token_statements( Rest, [], [lists:reverse(Current) | Acc] ) end; [Token | Rest@1] -> split_token_statements(Rest@1, [Token | Current], Acc) end. -file("src/sqlode/schema_parser.gleam", 202). ?DOC( " Check if a token list represents a CREATE TYPE ... AS ENUM statement.\n" " `type` arrives as `Ident(_)` (case-preserving) because the lexer no\n" " longer treats `type` as a keyword (#479) — `type` is a common\n" " column name and reserving it tripped legitimate SELECT queries.\n" " The case-insensitive guard is needed because `Ident` preserves the\n" " original spelling, unlike `Keyword` which the lexer normalises to\n" " lowercase.\n" ). -spec is_create_enum_tokens(list(sqlode@lexer:token())) -> boolean(). is_create_enum_tokens(Tokens) -> case Tokens of [{keyword, <<"create"/utf8>>}, {ident, T}, _, {keyword, <<"as"/utf8>>}, {keyword, <<"enum"/utf8>>} | _] when ((T =:= <<"type"/utf8>>) orelse (T =:= <<"TYPE"/utf8>>)) orelse (T =:= <<"Type"/utf8>>) -> true; _ -> false end. -file("src/sqlode/schema_parser.gleam", 246). ?DOC(" Extract string literal values from inside ENUM parentheses.\n"). -spec extract_enum_values(list(sqlode@lexer:token()), list(binary())) -> list(binary()). extract_enum_values(Tokens, Acc) -> case Tokens of [] -> lists:reverse(Acc); [{string_lit, Value} | Rest] -> extract_enum_values(Rest, [Value | Acc]); [_ | Rest@1] -> extract_enum_values(Rest@1, Acc) end. -file("src/sqlode/schema_parser.gleam", 219). ?DOC(" Parse a CREATE TYPE name AS ENUM (...) from tokens, handling escaped quotes correctly.\n"). -spec parse_create_enum_from_tokens(list(sqlode@lexer:token())) -> {ok, sqlode@model:enum_def()} | {error, nil}. parse_create_enum_from_tokens(Tokens) -> case Tokens of [{keyword, <<"create"/utf8>>}, {ident, T}, Name_token, {keyword, <<"as"/utf8>>}, {keyword, <<"enum"/utf8>>} | Rest] when ((T =:= <<"type"/utf8>>) orelse (T =:= <<"TYPE"/utf8>>)) orelse (T =:= <<"Type"/utf8>>) -> Name = case Name_token of {ident, N} -> string:lowercase(N); {quoted_ident, N@1} -> string:lowercase(N@1); _ -> <<""/utf8>> end, Values = extract_enum_values(Rest, []), {ok, {enum_def, Name, Values, postgres_enum}}; _ -> {error, nil} end. -file("src/sqlode/schema_parser.gleam", 258). -spec is_create_view_tokens(list(sqlode@lexer:token())) -> boolean(). is_create_view_tokens(Tokens) -> case Tokens of [{keyword, <<"create"/utf8>>}, {keyword, <<"or"/utf8>>}, {keyword, <<"replace"/utf8>>}, {keyword, <<"view"/utf8>>} | _] -> true; [{keyword, <<"create"/utf8>>}, {keyword, <<"view"/utf8>>} | _] -> true; _ -> false end. -file("src/sqlode/schema_parser.gleam", 313). -spec extract_view_name_result(list(sqlode@lexer:token())) -> gleam@option:option({binary(), list(sqlode@lexer:token())}). extract_view_name_result(Tokens) -> Remaining = case Tokens of [{keyword, <<"create"/utf8>>}, {keyword, <<"or"/utf8>>}, {keyword, <<"replace"/utf8>>}, {keyword, <<"view"/utf8>>} | Rest] -> Rest; [{keyword, <<"create"/utf8>>}, {keyword, <<"view"/utf8>>} | Rest@1] -> Rest@1; _ -> [] end, case Remaining of [{ident, N} | Rest@2] -> {some, {string:lowercase(N), Rest@2}}; [{quoted_ident, N@1} | Rest@3] -> {some, {string:lowercase(N@1), Rest@3}}; _ -> none end. -file("src/sqlode/schema_parser.gleam", 334). -spec extract_after_select_result(list(sqlode@lexer:token())) -> gleam@option:option(list(sqlode@lexer:token())). extract_after_select_result(Tokens) -> case Tokens of [{keyword, <<"select"/utf8>>} | Rest] -> case Rest of [] -> none; _ -> {some, Rest} end; _ -> none end. -file("src/sqlode/schema_parser.gleam", 382). -spec skip_to_keyword(list(sqlode@lexer:token()), binary()) -> list(sqlode@lexer:token()). skip_to_keyword(Tokens, Keyword) -> case Tokens of [] -> []; [{keyword, K} | Rest] when K =:= Keyword -> Rest; [_ | Rest@1] -> skip_to_keyword(Rest@1, Keyword) end. -file("src/sqlode/schema_parser.gleam", 393). -spec split_at_from( list(sqlode@lexer:token()), integer(), list(sqlode@lexer:token()) ) -> {list(sqlode@lexer:token()), list(sqlode@lexer:token())}. split_at_from(Tokens, Depth, Acc) -> case Tokens of [] -> {lists:reverse(Acc), []}; [l_paren | Rest] -> split_at_from(Rest, Depth + 1, [l_paren | Acc]); [r_paren | Rest@1] -> split_at_from(Rest@1, Depth - 1, [r_paren | Acc]); [{keyword, <<"from"/utf8>>} | Rest@2] when Depth =:= 0 -> {lists:reverse(Acc), Rest@2}; [Token | Rest@3] -> split_at_from(Rest@3, Depth, [Token | Acc]) end. -file("src/sqlode/schema_parser.gleam", 528). -spec infer_cast_type(list(sqlode@lexer:token())) -> gleam@option:option({sqlode@model:scalar_type(), boolean()}). infer_cast_type(Tokens) -> case Tokens of [] -> none; [{keyword, <<"as"/utf8>>}, {ident, Type_name} | _] -> case sqlode@model:parse_sql_type(Type_name) of {ok, Scalar_type} -> {some, {Scalar_type, true}}; {error, _} -> none end; [{keyword, <<"as"/utf8>>}, {keyword, Type_name} | _] -> case sqlode@model:parse_sql_type(Type_name) of {ok, Scalar_type} -> {some, {Scalar_type, true}}; {error, _} -> none end; [_ | Rest] -> infer_cast_type(Rest) end. -file("src/sqlode/schema_parser.gleam", 543). -spec tok_split_select_columns( list(sqlode@lexer:token()), integer(), list(sqlode@lexer:token()), list(list(sqlode@lexer:token())) ) -> list(list(sqlode@lexer:token())). tok_split_select_columns(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 -> tok_split_select_columns( Rest, 0, [], [lists:reverse(Current) | Acc] ); [l_paren | Rest@1] -> tok_split_select_columns( Rest@1, Depth + 1, [l_paren | Current], Acc ); [r_paren | Rest@2] -> tok_split_select_columns( Rest@2, Depth - 1, [r_paren | Current], Acc ); [Token | Rest@3] -> tok_split_select_columns(Rest@3, Depth, [Token | Current], Acc) end. -file("src/sqlode/schema_parser.gleam", 409). -spec extract_view_columns(list(sqlode@lexer:token())) -> list(view_column()). extract_view_columns(Tokens) -> Groups = tok_split_select_columns(Tokens, 0, [], []), gleam@list:filter_map( Groups, fun(Col_tokens) -> Reversed = lists:reverse(Col_tokens), case Reversed of [{ident, Alias}, {keyword, <<"as"/utf8>>} | Rest] -> {ok, {view_column, Alias, lists:reverse(Rest)}}; [{quoted_ident, Alias@1}, {keyword, <<"as"/utf8>>} | Rest@1] -> {ok, {view_column, Alias@1, lists:reverse(Rest@1)}}; _ -> case lists:reverse(Reversed) of [{ident, _}, dot, {ident, Col}] -> {ok, {view_column, Col, Col_tokens}}; _ -> case Reversed of [{ident, Name} | _] -> {ok, {view_column, Name, Col_tokens}}; [{quoted_ident, Name@1} | _] -> {ok, {view_column, Name@1, Col_tokens}}; _ -> {error, nil} end end end end ). -file("src/sqlode/schema_parser.gleam", 567). ?DOC(" Detect ALTER TABLE ... ADD [COLUMN] pattern.\n"). -spec is_alter_table_add_column_tokens(list(sqlode@lexer:token())) -> boolean(). is_alter_table_add_column_tokens(Tokens) -> case Tokens of [{keyword, <<"alter"/utf8>>}, {keyword, <<"table"/utf8>>}, _, {keyword, <<"add"/utf8>>}, {keyword, <<"column"/utf8>>} | _] -> true; [{keyword, <<"alter"/utf8>>}, {keyword, <<"table"/utf8>>}, _, {keyword, <<"add"/utf8>>} | Rest] -> case Rest of [{keyword, K} | _] when (((((K =:= <<"constraint"/utf8>>) orelse (K =:= <<"primary"/utf8>>)) orelse (K =:= <<"unique"/utf8>>)) orelse (K =:= <<"foreign"/utf8>>)) orelse (K =:= <<"check"/utf8>>)) orelse (K =:= <<"index"/utf8>>) -> false; _ -> true end; _ -> false end. -file("src/sqlode/schema_parser.gleam", 669). ?DOC( " Skip a leading `IF NOT EXISTS` idempotency modifier on an\n" " ADD COLUMN clause. Without this, the parser would treat `if`\n" " as the column name and fail with a misleading\n" " \"missing type for column if\" error. The column definition\n" " itself is unchanged by the modifier, so dropping the three\n" " keywords is enough.\n" ). -spec strip_if_not_exists(list(sqlode@lexer:token())) -> list(sqlode@lexer:token()). strip_if_not_exists(Tokens) -> case Tokens of [{keyword, <<"if"/utf8>>}, {keyword, <<"not"/utf8>>}, {keyword, <<"exists"/utf8>>} | Rest] -> Rest; _ -> Tokens end. -file("src/sqlode/schema_parser.gleam", 677). -spec extract_ident(sqlode@lexer:token()) -> binary(). extract_ident(Token) -> case Token of {ident, N} -> sqlode@naming:normalize_identifier(N); {quoted_ident, N@1} -> sqlode@naming:normalize_identifier(N@1); _ -> <<""/utf8>> end. -file("src/sqlode/schema_parser.gleam", 640). -spec extract_alter_table_parts(list(sqlode@lexer:token())) -> {binary(), list(sqlode@lexer:token())}. extract_alter_table_parts(Tokens) -> case Tokens of [{keyword, <<"alter"/utf8>>}, {keyword, <<"table"/utf8>>}, Name_tok, {keyword, <<"add"/utf8>>}, {keyword, <<"column"/utf8>>} | Rest] -> {extract_ident(Name_tok), strip_if_not_exists(Rest)}; [{keyword, <<"alter"/utf8>>}, {keyword, <<"table"/utf8>>}, Name_tok@1, {keyword, <<"add"/utf8>>} | Rest@1] -> {extract_ident(Name_tok@1), strip_if_not_exists(Rest@1)}; _ -> {<<""/utf8>>, []} end. -file("src/sqlode/schema_parser.gleam", 812). ?DOC(" Parse DROP TABLE [IF EXISTS] from the token list.\n"). -spec parse_drop_table(list(sqlode@lexer:token())) -> unknown_statement_kind(). parse_drop_table(Tokens) -> case Tokens of [_, _, {keyword, <<"if"/utf8>>}, {keyword, <<"exists"/utf8>>}, Name_tok | _] -> {destructive_d_d_l, {drop_table, extract_ident(Name_tok)}}; [_, _, Name_tok@1 | _] -> {destructive_d_d_l, {drop_table, extract_ident(Name_tok@1)}}; _ -> silently_ignored end. -file("src/sqlode/schema_parser.gleam", 823). ?DOC(" Parse DROP VIEW [IF EXISTS] from the token list.\n"). -spec parse_drop_view(list(sqlode@lexer:token())) -> unknown_statement_kind(). parse_drop_view(Tokens) -> case Tokens of [_, _, {keyword, <<"if"/utf8>>}, {keyword, <<"exists"/utf8>>}, Name_tok | _] -> {destructive_d_d_l, {drop_view, extract_ident(Name_tok)}}; [_, _, Name_tok@1 | _] -> {destructive_d_d_l, {drop_view, extract_ident(Name_tok@1)}}; _ -> silently_ignored end. -file("src/sqlode/schema_parser.gleam", 834). ?DOC(" Parse DROP TYPE [IF EXISTS] from the token list.\n"). -spec parse_drop_type(list(sqlode@lexer:token())) -> unknown_statement_kind(). parse_drop_type(Tokens) -> case Tokens of [_, _, {keyword, <<"if"/utf8>>}, {keyword, <<"exists"/utf8>>}, Name_tok | _] -> {destructive_d_d_l, {drop_type, extract_ident(Name_tok)}}; [_, _, Name_tok@1 | _] -> {destructive_d_d_l, {drop_type, extract_ident(Name_tok@1)}}; _ -> silently_ignored end. -file("src/sqlode/schema_parser.gleam", 863). -spec skip_alter_modifiers(list(sqlode@lexer:token())) -> {list(sqlode@lexer:token()), boolean()}. skip_alter_modifiers(Tokens) -> case Tokens of [{keyword, <<"if"/utf8>>}, {keyword, <<"exists"/utf8>>} | Rest] -> skip_alter_modifiers(Rest); [{keyword, <<"only"/utf8>>} | Rest@1] -> skip_alter_modifiers(Rest@1); _ -> {Tokens, false} end. -file("src/sqlode/schema_parser.gleam", 984). ?DOC(" Extract the column name from ALTER [COLUMN] ... tokens.\n"). -spec extract_alter_column_name(list(sqlode@lexer:token())) -> binary(). extract_alter_column_name(Tokens) -> case Tokens of [_, {keyword, <<"column"/utf8>>}, Name_tok | _] -> extract_ident(Name_tok); [_, Name_tok@1 | _] -> extract_ident(Name_tok@1); _ -> <<""/utf8>> end. -file("src/sqlode/schema_parser.gleam", 1008). -spec take_until_using(list(sqlode@lexer:token()), list(sqlode@lexer:token())) -> list(sqlode@lexer:token()). take_until_using(Tokens, Acc) -> case Tokens of [] -> lists:reverse(Acc); [{keyword, <<"using"/utf8>>} | _] -> lists:reverse(Acc); [Tok | Rest] -> take_until_using(Rest, [Tok | Acc]) end. -file("src/sqlode/schema_parser.gleam", 997). ?DOC( " Extract TYPE tokens from ALTER [COLUMN] [SET DATA] TYPE .\n" " Stops at a trailing `USING ` cast clause so the new type\n" " parses cleanly. Without this, the USING keywords would merge into\n" " the rendered type text and every ALTER TYPE with a USING cast\n" " would silently degrade into a parse failure.\n" ). -spec extract_alter_type_tokens(list(sqlode@lexer:token())) -> list(sqlode@lexer:token()). extract_alter_type_tokens(Tokens) -> case Tokens of [] -> []; [{ident, T} | Rest] when ((T =:= <<"type"/utf8>>) orelse (T =:= <<"TYPE"/utf8>>)) orelse (T =:= <<"Type"/utf8>>) -> take_until_using(Rest, []); [_ | Rest@1] -> extract_alter_type_tokens(Rest@1) end. -file("src/sqlode/schema_parser.gleam", 1209). ?DOC( " Render the leading words of a statement back into a short summary\n" " suitable for the user-facing `UnsupportedMysqlDdl` detail. We only\n" " take the first three non-empty words so a `RENAME TABLE a TO b ...`\n" " reads as `RENAME TABLE a` rather than the entire token stream.\n" ). -spec summarise_unknown_mysql_statement(list(binary())) -> binary(). summarise_unknown_mysql_statement(Words) -> Summary = begin _pipe = Words, _pipe@1 = gleam@list:filter(_pipe, fun(Word) -> Word /= <<""/utf8>> end), _pipe@2 = gleam@list:take(_pipe@1, 3), _pipe@3 = gleam@list:map(_pipe@2, fun string:uppercase/1), gleam@string:join(_pipe@3, <<" "/utf8>>) end, case Summary of <<""/utf8>> -> <<"(empty statement)"/utf8>>; _ -> <<<>/binary, " an issue or pre-process the schema to remove it."/utf8>> end. -file("src/sqlode/schema_parser.gleam", 1225). -spec token_keyword_text(sqlode@lexer:token()) -> binary(). token_keyword_text(Token) -> case Token of {keyword, K} -> K; {ident, I} -> string:lowercase(I); {quoted_ident, I@1} -> string:lowercase(I@1); _ -> <<""/utf8>> end. -file("src/sqlode/schema_parser.gleam", 1238). ?DOC( " Apply `update` to the column named `column_name` inside `table_name`,\n" " leaving all other columns and tables untouched. Used by every ALTER\n" " path that rewrites a single column in place so the per-action code\n" " does not repeat the table/column-walking boilerplate.\n" ). -spec rewrite_column( list(sqlode@model:table()), binary(), binary(), fun((sqlode@model:column()) -> sqlode@model:column()) ) -> list(sqlode@model:table()). rewrite_column(Tables, Table_name, Column_name, Update) -> gleam@list:map( Tables, fun(T) -> case string:lowercase(erlang:element(2, T)) =:= string:lowercase( Table_name ) of true -> {table, erlang:element(2, T), gleam@list:map( erlang:element(3, T), fun(C) -> case string:lowercase(erlang:element(2, C)) =:= string:lowercase( Column_name ) of true -> Update(C); false -> C end end )}; false -> T end end ). -file("src/sqlode/schema_parser.gleam", 1296). -spec is_create_table_tokens(list(sqlode@lexer:token())) -> boolean(). is_create_table_tokens(Tokens) -> case Tokens of [{keyword, <<"create"/utf8>>}, {keyword, <<"table"/utf8>>} | _] -> true; [{keyword, <<"create"/utf8>>}, {keyword, <<"temporary"/utf8>>}, {keyword, <<"table"/utf8>>} | _] -> true; [{keyword, <<"create"/utf8>>}, {keyword, <<"temp"/utf8>>}, {keyword, <<"table"/utf8>>} | _] -> true; [{keyword, <<"create"/utf8>>}, {keyword, <<"unlogged"/utf8>>}, {keyword, <<"table"/utf8>>} | _] -> true; _ -> false end. -file("src/sqlode/schema_parser.gleam", 1317). -spec find_column_in_tables(list(sqlode@model:table()), binary()) -> gleam@option:option(sqlode@model:column()). find_column_in_tables(Tables, Column_name) -> _pipe@1 = gleam@list:find_map( Tables, fun(Table) -> _pipe = gleam@list:find( erlang:element(3, Table), fun(Col) -> string:lowercase(erlang:element(2, Col)) =:= string:lowercase( Column_name ) end ), gleam@result:map_error(_pipe, fun(_) -> nil end) end ), gleam@option:from_result(_pipe@1). -file("src/sqlode/schema_parser.gleam", 439). -spec resolve_column_from_expr_tokens( list(sqlode@lexer:token()), list(sqlode@model:table()) ) -> gleam@option:option(sqlode@model:column()). resolve_column_from_expr_tokens(Expr_tokens, Tables) -> case Expr_tokens of [{ident, Name}] -> find_column_in_tables(Tables, string:lowercase(Name)); [{quoted_ident, Name@1}] -> find_column_in_tables(Tables, string:lowercase(Name@1)); [{ident, _}, dot, {ident, Col}] -> find_column_in_tables(Tables, string:lowercase(Col)); [{quoted_ident, _}, dot, {ident, Col@1}] -> find_column_in_tables(Tables, string:lowercase(Col@1)); _ -> none end. -file("src/sqlode/schema_parser.gleam", 511). -spec extract_aggregate_inner_type( list(sqlode@lexer:token()), list(sqlode@model:table()) ) -> gleam@option:option(sqlode@model:column()). extract_aggregate_inner_type(Tokens, Tables) -> case Tokens of [{ident, Name}, r_paren | _] -> find_column_in_tables(Tables, string:lowercase(Name)); [{ident, Name}, comma | _] -> find_column_in_tables(Tables, string:lowercase(Name)); [{ident, _}, dot, {ident, Col}, r_paren | _] -> find_column_in_tables(Tables, string:lowercase(Col)); [{ident, _}, dot, {ident, Col}, comma | _] -> find_column_in_tables(Tables, string:lowercase(Col)); [{keyword, <<"distinct"/utf8>>}, {ident, Name@1}, r_paren | _] -> find_column_in_tables(Tables, string:lowercase(Name@1)); [{keyword, <<"distinct"/utf8>>}, {ident, Name@1}, comma | _] -> find_column_in_tables(Tables, string:lowercase(Name@1)); _ -> none end. -file("src/sqlode/schema_parser.gleam", 455). -spec infer_view_expression_type( list(sqlode@lexer:token()), list(sqlode@model:table()) ) -> gleam@option:option({sqlode@model:scalar_type(), boolean()}). infer_view_expression_type(Expr_tokens, Tables) -> case Expr_tokens of [{keyword, <<"cast"/utf8>>}, l_paren | Rest] -> infer_cast_type(Rest); [{ident, Fn_name}, l_paren | Rest@1] -> Lowered = string:lowercase(Fn_name), case Lowered of <<"count"/utf8>> -> {some, {int_type, false}}; <<"avg"/utf8>> -> {some, {case extract_aggregate_inner_type(Rest@1, Tables) of {some, Col} -> case erlang:element(3, Col) of int_type -> float_type; float_type -> float_type; Other -> Other end; none -> float_type end, true}}; <<"sum"/utf8>> -> case extract_aggregate_inner_type(Rest@1, Tables) of {some, Col@1} -> {some, {erlang:element(3, Col@1), true}}; none -> {some, {float_type, true}} end; <<"min"/utf8>> -> case extract_aggregate_inner_type(Rest@1, Tables) of {some, Col@2} -> {some, {erlang:element(3, Col@2), true}}; none -> none end; <<"max"/utf8>> -> case extract_aggregate_inner_type(Rest@1, Tables) of {some, Col@2} -> {some, {erlang:element(3, Col@2), true}}; none -> none end; <<"coalesce"/utf8>> -> case extract_aggregate_inner_type(Rest@1, Tables) of {some, Col@3} -> {some, {erlang:element(3, Col@3), false}}; none -> none end; <<"row_number"/utf8>> -> {some, {int_type, false}}; <<"rank"/utf8>> -> {some, {int_type, false}}; <<"dense_rank"/utf8>> -> {some, {int_type, false}}; _ -> none end; [{string_lit, _} | _] -> {some, {string_type, false}}; [{number_lit, N} | _] -> case gleam_stdlib:contains_string(N, <<"."/utf8>>) of true -> {some, {float_type, false}}; false -> {some, {int_type, false}} end; _ -> none end. -file("src/sqlode/schema_parser.gleam", 362). -spec resolve_single_view_column( binary(), list(sqlode@lexer:token()), list(sqlode@model:table()) ) -> {ok, sqlode@model:column()} | {error, schema_warning()}. resolve_single_view_column(Name, Expr_tokens, Tables) -> case find_column_in_tables(Tables, Name) of {some, Col} -> {ok, {column, Name, erlang:element(3, Col), erlang:element(4, Col)}}; none -> case resolve_column_from_expr_tokens(Expr_tokens, Tables) of {some, Col@1} -> {ok, {column, Name, erlang:element(3, Col@1), erlang:element(4, Col@1)}}; none -> case infer_view_expression_type(Expr_tokens, Tables) of {some, {Scalar_type, Nullable}} -> {ok, {column, Name, Scalar_type, Nullable}}; none -> {error, {unresolvable_view_column, Name}} end end end. -file("src/sqlode/schema_parser.gleam", 347). -spec resolve_view_columns( list(sqlode@lexer:token()), list(sqlode@model:table()) ) -> {list(sqlode@model:column()), list(schema_warning())}. resolve_view_columns(Select_tokens, Tables) -> View_cols = extract_view_columns(Select_tokens), gleam@list:fold( View_cols, {[], []}, fun(Acc, View_col) -> {Columns, Warnings} = Acc, Normalized = sqlode@naming:normalize_identifier( erlang:element(2, View_col) ), case resolve_single_view_column( Normalized, erlang:element(3, View_col), Tables ) of {ok, Col} -> {lists:append(Columns, [Col]), Warnings}; {error, Warning} -> {Columns, lists:append(Warnings, [Warning])} end end ). -file("src/sqlode/schema_parser.gleam", 272). -spec parse_create_view_from_tokens( list(sqlode@lexer:token()), list(sqlode@model:table()) ) -> {gleam@option:option(sqlode@model:table()), list(schema_warning())}. parse_create_view_from_tokens(Tokens, Tables) -> case extract_view_name_result(Tokens) of none -> {none, []}; {some, {View_name, After_name}} -> After_as = skip_to_keyword(After_name, <<"as"/utf8>>), case extract_after_select_result(After_as) of none -> {none, []}; {some, After_select} -> {Select_tokens, From_tokens} = split_at_from( After_select, 0, [] ), Source_tables = sqlode@query_analyzer@token_utils:extract_table_names( [{keyword, <<"from"/utf8>>} | From_tokens] ), {Columns, Warnings} = case Select_tokens of [star] -> {gleam@list:flat_map( Source_tables, fun(Table_name) -> case gleam@list:find( Tables, fun(T) -> erlang:element(2, T) =:= Table_name end ) of {ok, Table} -> erlang:element(3, Table); {error, _} -> [] end end ), []}; _ -> resolve_view_columns(Select_tokens, Tables) end, case Columns of [] -> {none, Warnings}; _ -> {{some, {table, View_name, Columns}}, Warnings} end end end. -file("src/sqlode/schema_parser.gleam", 1369). ?DOC(" Split tokens at the first top-level LParen. Returns (header, body_after_lparen).\n"). -spec split_at_lparen(list(sqlode@lexer:token()), list(sqlode@lexer:token())) -> {list(sqlode@lexer:token()), list(sqlode@lexer:token())}. split_at_lparen(Tokens, Header) -> case Tokens of [] -> {lists:reverse(Header), []}; [l_paren | Rest] -> {lists:reverse(Header), Rest}; [Tok | Rest@1] -> split_at_lparen(Rest@1, [Tok | Header]) end. -file("src/sqlode/schema_parser.gleam", 1381). ?DOC(" Find the last Ident or QuotedIdent token in a list and return its name.\n"). -spec find_last_ident(list(sqlode@lexer:token())) -> {ok, binary()} | {error, nil}. find_last_ident(Tokens) -> _pipe = Tokens, _pipe@1 = gleam@list:filter_map(_pipe, fun(Tok) -> case Tok of {ident, N} -> {ok, sqlode@naming:normalize_identifier(N)}; {quoted_ident, N} -> {ok, sqlode@naming:normalize_identifier(N)}; _ -> {error, nil} end end), gleam@list:last(_pipe@1). -file("src/sqlode/schema_parser.gleam", 1401). -spec drop_trailing_rparens(list(sqlode@lexer:token())) -> list(sqlode@lexer:token()). drop_trailing_rparens(Rev_tokens) -> case Rev_tokens of [r_paren | Rest] -> Rest; _ -> Rev_tokens end. -file("src/sqlode/schema_parser.gleam", 1394). ?DOC(" Strip trailing RParen from token list.\n"). -spec strip_trailing_rparen(list(sqlode@lexer:token())) -> list(sqlode@lexer:token()). strip_trailing_rparen(Tokens) -> _pipe = Tokens, _pipe@1 = lists:reverse(_pipe), _pipe@2 = drop_trailing_rparens(_pipe@1), lists:reverse(_pipe@2). -file("src/sqlode/schema_parser.gleam", 1445). -spec split_tokens_by_comma_loop( list(sqlode@lexer:token()), integer(), list(sqlode@lexer:token()), list(list(sqlode@lexer:token())) ) -> list(list(sqlode@lexer:token())). split_tokens_by_comma_loop(Tokens, Depth, Current, Acc) -> case Tokens of [] -> case Current of [] -> lists:reverse(Acc); _ -> lists:reverse([lists:reverse(Current) | Acc]) end; [l_paren | Rest] -> split_tokens_by_comma_loop( Rest, Depth + 1, [l_paren | Current], Acc ); [r_paren | Rest@1] -> split_tokens_by_comma_loop(Rest@1, case Depth > 0 of true -> Depth - 1; false -> 0 end, [r_paren | Current], Acc); [comma | Rest@2] when Depth =:= 0 -> split_tokens_by_comma_loop( Rest@2, Depth, [], [lists:reverse(Current) | Acc] ); [Tok | Rest@3] -> split_tokens_by_comma_loop(Rest@3, Depth, [Tok | Current], Acc) end. -file("src/sqlode/schema_parser.gleam", 1441). ?DOC(" Split a token list by top-level commas (depth-0 only).\n"). -spec split_tokens_by_comma(list(sqlode@lexer:token())) -> list(list(sqlode@lexer:token())). split_tokens_by_comma(Tokens) -> split_tokens_by_comma_loop(Tokens, 0, [], []). -file("src/sqlode/schema_parser.gleam", 1655). -spec synthetic_enum_name(binary(), binary()) -> binary(). synthetic_enum_name(Table_name, Column_name) -> <<<<(string:lowercase(Table_name))/binary, "_"/utf8>>/binary, (string:lowercase(Column_name))/binary>>. -file("src/sqlode/schema_parser.gleam", 1616). ?DOC( " Detect MySQL inline `ENUM(...)` / `SET(...)` on a column's type\n" " tokens. Gated by `engine == MySQL` so PostgreSQL/SQLite schemas —\n" " where `enum` / `set` could conceivably appear as a non-type keyword\n" " elsewhere — keep their existing behavior.\n" "\n" " The name synthesis rule is `_`, which\n" " lets two tables carry columns with overlapping value sets without\n" " colliding. For SET the column resolves to `StringType` (the\n" " documented fallback per Issue #407) but the values are still\n" " preserved in the catalog for future native SET support.\n" ). -spec detect_mysql_inline_enum_set( list(sqlode@lexer:token()), binary(), binary(), sqlode@model:engine() ) -> gleam@option:option(inline_enum_detection()). detect_mysql_inline_enum_set(Type_toks, Table_name, Column_name, Engine) -> case Engine of my_s_q_l -> case Type_toks of [{keyword, <<"enum"/utf8>>}, l_paren | Rest] -> Values = extract_enum_values(Rest, []), Synthetic = synthetic_enum_name(Table_name, Column_name), {some, {inline_enum, {enum_type, Synthetic}, {enum_def, Synthetic, Values, my_sql_enum}}}; [{keyword, <<"set"/utf8>>}, l_paren | Rest@1] -> Values@1 = extract_enum_values(Rest@1, []), Synthetic@1 = synthetic_enum_name(Table_name, Column_name), {some, {inline_enum, {set_type, Synthetic@1}, {enum_def, Synthetic@1, Values@1, my_sql_set}}}; _ -> none end; _ -> none end. -file("src/sqlode/schema_parser.gleam", 1687). -spec render_type_tokens_loop(list(sqlode@lexer:token()), list(binary())) -> list(binary()). render_type_tokens_loop(Tokens, Acc) -> case Tokens of [] -> Acc; [{operator, <<"["/utf8>>}, {operator, <<"]"/utf8>>} | Rest] -> case Acc of [Prev | Prev_rest] -> render_type_tokens_loop( Rest, [<> | Prev_rest] ); [] -> render_type_tokens_loop(Rest, [<<"[]"/utf8>> | Acc]) end; [Tok | Rest@1] -> Token_text = case Tok of {keyword, K} -> K; {ident, N} -> N; {quoted_ident, N@1} -> N@1; {number_lit, N@2} -> N@2; {operator, Op} -> Op; l_paren -> <<"("/utf8>>; r_paren -> <<")"/utf8>>; comma -> <<","/utf8>>; star -> <<"*"/utf8>>; _ -> <<""/utf8>> end, case Token_text of <<""/utf8>> -> render_type_tokens_loop(Rest@1, Acc); _ -> render_type_tokens_loop(Rest@1, [Token_text | Acc]) end end. -file("src/sqlode/schema_parser.gleam", 1681). ?DOC( " Render type tokens back to a type string for parse_sql_type lookup.\n" " Handles array syntax ([] operators) by joining without spaces.\n" ). -spec render_type_tokens(list(sqlode@lexer:token())) -> binary(). render_type_tokens(Tokens) -> _pipe = render_type_tokens_loop(Tokens, []), _pipe@1 = lists:reverse(_pipe), gleam@string:join(_pipe@1, <<" "/utf8>>). -file("src/sqlode/schema_parser.gleam", 1721). -spec tokens_contain_not_null(list(sqlode@lexer:token())) -> boolean(). tokens_contain_not_null(Tokens) -> case Tokens of [] -> false; [_] -> false; [{keyword, <<"not"/utf8>>}, {keyword, <<"null"/utf8>>} | _] -> true; [_ | Rest] -> tokens_contain_not_null(Rest) end. -file("src/sqlode/schema_parser.gleam", 1729). -spec tokens_contain_keyword(list(sqlode@lexer:token()), binary()) -> boolean(). tokens_contain_keyword(Tokens, Keyword) -> gleam@list:any(Tokens, fun(Tok) -> case Tok of {keyword, K} -> K =:= Keyword; _ -> false end end). -file("src/sqlode/schema_parser.gleam", 1738). -spec infer_scalar_type_for_engine(binary(), sqlode@model:engine()) -> {ok, sqlode@model:scalar_type()} | {error, binary()}. infer_scalar_type_for_engine(Type_text, Engine) -> _pipe = sqlode@model:parse_sql_type_for_engine(Type_text, Engine), gleam@result:replace_error( _pipe, <<<<<<<<<<"unrecognized SQL type \""/utf8, Type_text/binary>>/binary, "\". Supported types: int, serial, float, numeric, decimal, bool, text,"/utf8>>/binary, " char, bytea, uuid, json, jsonb, timestamp, datetime, date, time,"/utf8>>/binary, " interval. Hint: add a type override in sqlode.yaml under"/utf8>>/binary, " overrides.db_type"/utf8>> ). -file("src/sqlode/schema_parser.gleam", 1753). -spec is_column_constraint(binary()) -> boolean(). is_column_constraint(Keyword) -> gleam@list:contains( [<<"not"/utf8>>, <<"null"/utf8>>, <<"primary"/utf8>>, <<"unique"/utf8>>, <<"default"/utf8>>, <<"references"/utf8>>, <<"check"/utf8>>, <<"constraint"/utf8>>, <<"generated"/utf8>>, <<"collate"/utf8>>, <<"autoincrement"/utf8>>, <<"auto_increment"/utf8>>, <<"on"/utf8>>, <<"character"/utf8>>, <<"comment"/utf8>>, <<"invisible"/utf8>>, <<"visible"/utf8>>], Keyword ). -file("src/sqlode/schema_parser.gleam", 1659). -spec take_type_tokens_from_lexer( list(sqlode@lexer:token()), list(sqlode@lexer:token()) ) -> list(sqlode@lexer:token()). take_type_tokens_from_lexer(Tokens, Acc) -> case Tokens of [] -> lists:reverse(Acc); [{keyword, K} | Rest] -> case is_column_constraint(K) of true -> lists:reverse(Acc); false -> take_type_tokens_from_lexer(Rest, [{keyword, K} | Acc]) end; [{ident, Name} | Rest@1] -> case is_column_constraint(string:lowercase(Name)) of true -> lists:reverse(Acc); false -> take_type_tokens_from_lexer(Rest@1, [{ident, Name} | Acc]) end; [Tok | Rest@2] -> take_type_tokens_from_lexer(Rest@2, [Tok | Acc]) end. -file("src/sqlode/schema_parser.gleam", 929). ?DOC( " Parse `MODIFY [COLUMN] ` into an\n" " `AlterModifyColumn` action. The type tokens are everything between\n" " the column name and the next column-constraint keyword; the\n" " nullability is derived from a `NOT NULL` presence check on the\n" " remaining tokens (MySQL columns default to nullable).\n" ). -spec classify_mysql_modify(binary(), list(sqlode@lexer:token())) -> unknown_statement_kind(). classify_mysql_modify(Table_name, Tokens) -> After_modify = case Tokens of [_, {keyword, <<"column"/utf8>>} | Rest] -> Rest; [_ | Rest@1] -> Rest@1; _ -> [] end, case After_modify of [Name_tok | Rest@2] -> Column_name = extract_ident(Name_tok), Type_tokens = take_type_tokens_from_lexer(Rest@2, []), Nullable = not tokens_contain_not_null(Rest@2), {destructive_d_d_l, {alter_modify_column, Table_name, Column_name, Type_tokens, Nullable}}; [] -> silently_ignored end. -file("src/sqlode/schema_parser.gleam", 956). ?DOC( " Parse `CHANGE [COLUMN] ` into an\n" " `AlterChangeColumn` action — MySQL's combined rename + retype.\n" ). -spec classify_mysql_change(binary(), list(sqlode@lexer:token())) -> unknown_statement_kind(). classify_mysql_change(Table_name, Tokens) -> After_change = case Tokens of [_, {keyword, <<"column"/utf8>>} | Rest] -> Rest; [_ | Rest@1] -> Rest@1; _ -> [] end, case After_change of [Old_tok, New_tok | Rest@2] -> Old_name = extract_ident(Old_tok), New_name = extract_ident(New_tok), Type_tokens = take_type_tokens_from_lexer(Rest@2, []), Nullable = not tokens_contain_not_null(Rest@2), {destructive_d_d_l, {alter_change_column, Table_name, Old_name, New_name, Type_tokens, Nullable}}; _ -> silently_ignored end. -file("src/sqlode/schema_parser.gleam", 872). -spec classify_alter_action_tokens(binary(), list(sqlode@lexer:token())) -> unknown_statement_kind(). classify_alter_action_tokens(Table_name, Tokens) -> Words = begin _pipe = Tokens, _pipe@1 = gleam@list:take(_pipe, 8), gleam@list:map(_pipe@1, fun token_keyword_text/1) end, case Words of [<<"drop"/utf8>>, <<"constraint"/utf8>> | _] -> silently_ignored; [<<"drop"/utf8>>, <<"column"/utf8>>, <<"if"/utf8>>, <<"exists"/utf8>>, Col_name | _] -> {destructive_d_d_l, {alter_drop_column, Table_name, Col_name}}; [<<"drop"/utf8>>, <<"column"/utf8>>, Col_name@1 | _] -> {destructive_d_d_l, {alter_drop_column, Table_name, Col_name@1}}; [<<"drop"/utf8>>, Col_name@2 | _] -> {destructive_d_d_l, {alter_drop_column, Table_name, Col_name@2}}; [<<"rename"/utf8>>, <<"to"/utf8>>, New_name | _] -> {destructive_d_d_l, {alter_rename_table, Table_name, New_name}}; [<<"rename"/utf8>>, <<"column"/utf8>>, Old_name, <<"to"/utf8>>, New_name@1 | _] -> {destructive_d_d_l, {alter_rename_column, Table_name, Old_name, New_name@1}}; [<<"rename"/utf8>>, Old_name@1, <<"to"/utf8>>, New_name@2 | _] -> {destructive_d_d_l, {alter_rename_column, Table_name, Old_name@1, New_name@2}}; [<<"alter"/utf8>>, <<"column"/utf8>>, _, <<"set"/utf8>>, <<"not"/utf8>>, <<"null"/utf8>> | _] -> {destructive_d_d_l, {alter_column_set_not_null, Table_name, extract_alter_column_name(Tokens)}}; [<<"alter"/utf8>>, <<"column"/utf8>>, _, <<"drop"/utf8>>, <<"not"/utf8>>, <<"null"/utf8>> | _] -> {destructive_d_d_l, {alter_column_drop_not_null, Table_name, extract_alter_column_name(Tokens)}}; [<<"alter"/utf8>>, <<"column"/utf8>>, _, <<"type"/utf8>> | _] -> {destructive_d_d_l, {alter_column_type, Table_name, extract_alter_column_name(Tokens), extract_alter_type_tokens(Tokens)}}; [<<"alter"/utf8>>, <<"column"/utf8>>, _, <<"set"/utf8>>, <<"data"/utf8>>, <<"type"/utf8>> | _] -> {destructive_d_d_l, {alter_column_type, Table_name, extract_alter_column_name(Tokens), extract_alter_type_tokens(Tokens)}}; [<<"modify"/utf8>>, <<"column"/utf8>> | _] -> classify_mysql_modify(Table_name, Tokens); [<<"modify"/utf8>>, _ | _] -> classify_mysql_modify(Table_name, Tokens); [<<"change"/utf8>>, <<"column"/utf8>> | _] -> classify_mysql_change(Table_name, Tokens); [<<"change"/utf8>>, _, _ | _] -> classify_mysql_change(Table_name, Tokens); [<<"add"/utf8>>, <<"constraint"/utf8>> | _] -> silently_ignored; [<<"add"/utf8>>, <<"primary"/utf8>> | _] -> silently_ignored; [<<"add"/utf8>>, <<"unique"/utf8>> | _] -> silently_ignored; [<<"add"/utf8>>, <<"foreign"/utf8>> | _] -> silently_ignored; [<<"add"/utf8>>, <<"check"/utf8>> | _] -> silently_ignored; [<<"add"/utf8>>, <<"index"/utf8>> | _] -> silently_ignored; _ -> silently_ignored end. -file("src/sqlode/schema_parser.gleam", 845). ?DOC(" Classify ALTER TABLE statements from the full token list.\n"). -spec classify_alter_table_from_tokens(list(sqlode@lexer:token())) -> unknown_statement_kind(). classify_alter_table_from_tokens(Tokens) -> After_alter_table = case Tokens of [_, _ | Rest] -> Rest; _ -> [] end, {After_modifiers, _} = skip_alter_modifiers(After_alter_table), case After_modifiers of [Name_tok | Rest@1] -> Table_name = extract_ident(Name_tok), classify_alter_action_tokens(Table_name, Rest@1); [] -> silently_ignored end. -file("src/sqlode/schema_parser.gleam", 773). ?DOC( " Classify a statement that is not a CREATE TABLE / VIEW / ENUM and not an\n" " ALTER TABLE ... ADD COLUMN. The intent is to let informational or\n" " out-of-scope DDL through silently (CREATE INDEX, COMMENT ON, transaction\n" " control) while failing fast on DDL that materially changes the catalog\n" " and would otherwise be missed (DROP TABLE, ALTER TABLE DROP/RENAME/ALTER).\n" "\n" " The lexer reserves only a subset of SQL words as `Keyword` tokens. Words\n" " like `rename`, `savepoint`, and `comment` arrive as `Ident` tokens, so\n" " we normalise the first few tokens of the statement to lowercase strings\n" " before matching, treating Keyword and Ident uniformly.\n" ). -spec classify_unknown_statement( list(sqlode@lexer:token()), sqlode@model:engine() ) -> unknown_statement_kind(). classify_unknown_statement(Tokens, Engine) -> Words = begin _pipe = Tokens, _pipe@1 = gleam@list:take(_pipe, 8), gleam@list:map(_pipe@1, fun token_keyword_text/1) end, case Words of [<<"create"/utf8>>, <<"index"/utf8>> | _] -> silently_ignored; [<<"create"/utf8>>, <<"unique"/utf8>>, <<"index"/utf8>> | _] -> silently_ignored; [<<"drop"/utf8>>, <<"index"/utf8>> | _] -> silently_ignored; [<<"comment"/utf8>>, <<"on"/utf8>> | _] -> silently_ignored; [<<"begin"/utf8>> | _] -> silently_ignored; [<<"commit"/utf8>> | _] -> silently_ignored; [<<"rollback"/utf8>> | _] -> silently_ignored; [<<"savepoint"/utf8>> | _] -> silently_ignored; [<<"release"/utf8>> | _] -> silently_ignored; [<<"set"/utf8>> | _] -> silently_ignored; [<<"drop"/utf8>>, <<"table"/utf8>> | _] -> parse_drop_table(Tokens); [<<"drop"/utf8>>, <<"view"/utf8>> | _] -> parse_drop_view(Tokens); [<<"drop"/utf8>>, <<"type"/utf8>> | _] -> parse_drop_type(Tokens); [<<"alter"/utf8>>, <<"table"/utf8>> | _] -> classify_alter_table_from_tokens(Tokens); _ -> case Engine of my_s_q_l -> {unsupported_mysql, summarise_unknown_mysql_statement(Words)}; postgre_s_q_l -> silently_ignored; s_q_lite -> silently_ignored end end. -file("src/sqlode/schema_parser.gleam", 1782). -spec find_enum(binary(), list(sqlode@model:enum_def())) -> gleam@option:option(binary()). find_enum(Type_text, Enums) -> Lowered = string:lowercase(gleam@string:trim(Type_text)), case gleam@list:find(Enums, fun(E) -> erlang:element(2, E) =:= Lowered end) of {ok, E@1} -> {some, erlang:element(2, E@1)}; {error, _} -> none end. -file("src/sqlode/schema_parser.gleam", 1268). ?DOC( " Resolve the new column type for a MySQL ALTER MODIFY/CHANGE: the\n" " type tokens may contain an inline `ENUM(...)` / `SET(...)`, in\n" " which case we synthesize a fresh `EnumDef` (named after the target\n" " column) and return it for the caller to append to the catalog.\n" " Otherwise we fall back to the engine-aware classifier; an\n" " unrecognised type leaves the existing column type unchanged\n" " (`StringType` is *not* used as a silent fallback).\n" ). -spec resolve_mysql_alter_type( list(sqlode@lexer:token()), binary(), binary(), list(sqlode@model:enum_def()), sqlode@model:engine() ) -> {sqlode@model:scalar_type(), list(sqlode@model:enum_def())}. resolve_mysql_alter_type(Type_tokens, Table_name, Column_name, Enums, Engine) -> case detect_mysql_inline_enum_set( Type_tokens, Table_name, Column_name, Engine ) of {some, {inline_enum, Scalar_type, New_enum}} -> {Scalar_type, lists:append(Enums, [New_enum])}; none -> Type_text = render_type_tokens(Type_tokens), Resolved = case find_enum(Type_text, Enums) of {some, Enum_name} -> {ok, {enum_type, Enum_name}}; none -> sqlode@model:parse_sql_type_for_engine(Type_text, Engine) end, case Resolved of {ok, T} -> {T, Enums}; {error, _} -> {string_type, Enums} end end. -file("src/sqlode/schema_parser.gleam", 1024). ?DOC( " Apply a destructive DDL action to the tables and enums lists. The\n" " engine parameter is threaded through so MySQL `ALTER TABLE ...\n" " MODIFY/CHANGE COLUMN ` can resolve modifier-aware types\n" " (TINYINT(1), DECIMAL, UNSIGNED, ENUM/SET) using the same\n" " classification rules as CREATE TABLE.\n" ). -spec apply_ddl_action( d_d_l_action(), binary(), list(sqlode@model:table()), list(sqlode@model:enum_def()), sqlode@model:engine() ) -> {ok, {list(sqlode@model:table()), list(sqlode@model:enum_def())}} | {error, parse_error()}. apply_ddl_action(Action, Path, Tables, Enums, Engine) -> case Action of {drop_table, Table_name} -> {ok, {gleam@list:filter( Tables, fun(T) -> string:lowercase(erlang:element(2, T)) /= string:lowercase( Table_name ) end ), Enums}}; {drop_view, View_name} -> {ok, {gleam@list:filter( Tables, fun(T@1) -> string:lowercase(erlang:element(2, T@1)) /= string:lowercase( View_name ) end ), Enums}}; {drop_type, Type_name} -> {ok, {Tables, gleam@list:filter( Enums, fun(E) -> string:lowercase(erlang:element(2, E)) /= string:lowercase( Type_name ) end )}}; {alter_drop_column, Table_name@1, Column_name} -> {ok, {gleam@list:map( Tables, fun(T@2) -> case string:lowercase(erlang:element(2, T@2)) =:= string:lowercase( Table_name@1 ) of true -> {table, erlang:element(2, T@2), gleam@list:filter( erlang:element(3, T@2), fun(C) -> string:lowercase( erlang:element(2, C) ) /= string:lowercase(Column_name) end )}; false -> T@2 end end ), Enums}}; {alter_rename_table, Old_name, New_name} -> {ok, {gleam@list:map( Tables, fun(T@3) -> case string:lowercase(erlang:element(2, T@3)) =:= string:lowercase( Old_name ) of true -> {table, string:lowercase(New_name), erlang:element(3, T@3)}; false -> T@3 end end ), Enums}}; {alter_rename_column, Table_name@2, Old_name@1, New_name@1} -> {ok, {gleam@list:map( Tables, fun(T@4) -> case string:lowercase(erlang:element(2, T@4)) =:= string:lowercase( Table_name@2 ) of true -> {table, erlang:element(2, T@4), gleam@list:map( erlang:element(3, T@4), fun(C@1) -> case string:lowercase( erlang:element(2, C@1) ) =:= string:lowercase(Old_name@1) of true -> {column, string:lowercase( New_name@1 ), erlang:element( 3, C@1 ), erlang:element( 4, C@1 )}; false -> C@1 end end )}; false -> T@4 end end ), Enums}}; {alter_column_type, Table_name@3, Column_name@1, Type_tokens} -> Type_text = render_type_tokens(Type_tokens), case sqlode@model:parse_sql_type_for_engine(Type_text, Engine) of {ok, Scalar_type} -> {ok, {rewrite_column( Tables, Table_name@3, Column_name@1, fun(C@2) -> {column, erlang:element(2, C@2), Scalar_type, erlang:element(4, C@2)} end ), Enums}}; {error, _} -> {error, {invalid_column, Path, Table_name@3, <<<<<<<<"ALTER COLUMN \""/utf8, Column_name@1/binary>>/binary, "\" TYPE: unsupported or unrecognized type \""/utf8>>/binary, Type_text/binary>>/binary, "\". Use a supported SQL type; if this statement should be ignored, remove it from the schema file."/utf8>>}} end; {alter_modify_column, Table_name@4, Column_name@2, Type_tokens@1, Nullable} -> {Scalar_type@1, New_enums} = resolve_mysql_alter_type( Type_tokens@1, Table_name@4, Column_name@2, Enums, Engine ), Updated_tables = rewrite_column( Tables, Table_name@4, Column_name@2, fun(C@3) -> {column, erlang:element(2, C@3), Scalar_type@1, Nullable} end ), {ok, {Updated_tables, New_enums}}; {alter_change_column, Table_name@5, Old_name@2, New_name@2, Type_tokens@2, Nullable@1} -> {Scalar_type@2, New_enums@1} = resolve_mysql_alter_type( Type_tokens@2, Table_name@5, New_name@2, Enums, Engine ), Updated_tables@1 = rewrite_column( Tables, Table_name@5, Old_name@2, fun(_) -> {column, string:lowercase(New_name@2), Scalar_type@2, Nullable@1} end ), {ok, {Updated_tables@1, New_enums@1}}; {alter_column_set_not_null, Table_name@6, Column_name@3} -> {ok, {gleam@list:map( Tables, fun(T@5) -> case string:lowercase(erlang:element(2, T@5)) =:= string:lowercase( Table_name@6 ) of true -> {table, erlang:element(2, T@5), gleam@list:map( erlang:element(3, T@5), fun(C@4) -> case string:lowercase( erlang:element(2, C@4) ) =:= string:lowercase( Column_name@3 ) of true -> {column, erlang:element( 2, C@4 ), erlang:element( 3, C@4 ), false}; false -> C@4 end end )}; false -> T@5 end end ), Enums}}; {alter_column_drop_not_null, Table_name@7, Column_name@4} -> {ok, {gleam@list:map( Tables, fun(T@6) -> case string:lowercase(erlang:element(2, T@6)) =:= string:lowercase( Table_name@7 ) of true -> {table, erlang:element(2, T@6), gleam@list:map( erlang:element(3, T@6), fun(C@5) -> case string:lowercase( erlang:element(2, C@5) ) =:= string:lowercase( Column_name@4 ) of true -> {column, erlang:element( 2, C@5 ), erlang:element( 3, C@5 ), true}; false -> C@5 end end )}; false -> T@6 end end ), Enums}} end. -file("src/sqlode/schema_parser.gleam", 1566). ?DOC( " Shared logic for `parse_named_column` and `parse_column_tokens`.\n" " Inspects the column's type tokens, detects MySQL inline `ENUM(...)`\n" " or `SET(...)` when `engine == MySQL`, synthesizes enum definitions,\n" " and falls through to the shared lookup / `parse_sql_type` pipeline\n" " otherwise.\n" ). -spec build_column_from_type_tokens( binary(), binary(), binary(), list(sqlode@lexer:token()), list(sqlode@lexer:token()), list(sqlode@model:enum_def()), sqlode@model:engine() ) -> {ok, gleam@option:option({sqlode@model:column(), list(sqlode@model:enum_def())})} | {error, parse_error()}. build_column_from_type_tokens( Path, Table_name, Name, All_tokens, Type_toks, Enums, Engine ) -> Type_text = render_type_tokens(Type_toks), Nullable = case (tokens_contain_not_null(All_tokens) orelse tokens_contain_keyword( All_tokens, <<"primary"/utf8>> )) orelse gleam_stdlib:contains_string(Type_text, <<"serial"/utf8>>) of true -> false; false -> true end, case detect_mysql_inline_enum_set(Type_toks, Table_name, Name, Engine) of {some, {inline_enum, Scalar_type, New_enum}} -> {ok, {some, {{column, Name, Scalar_type, Nullable}, [New_enum]}}}; none -> gleam@result:'try'(case find_enum(Type_text, Enums) of {some, Enum_name} -> {ok, {enum_type, Enum_name}}; none -> _pipe = infer_scalar_type_for_engine(Type_text, Engine), gleam@result:map_error( _pipe, fun(Detail) -> {invalid_column, Path, Table_name, Detail} end ) end, fun(Scalar_type@1) -> {ok, {some, {{column, Name, Scalar_type@1, Nullable}, []}}} end) end. -file("src/sqlode/schema_parser.gleam", 1481). -spec parse_named_column( binary(), binary(), binary(), list(sqlode@lexer:token()), list(sqlode@lexer:token()), list(sqlode@model:enum_def()), sqlode@model:engine() ) -> {ok, gleam@option:option({sqlode@model:column(), list(sqlode@model:enum_def())})} | {error, parse_error()}. parse_named_column(Path, Table_name, Raw_name, All_tokens, Rest, Enums, Engine) -> Name = sqlode@naming:normalize_identifier(Raw_name), Type_toks = take_type_tokens_from_lexer(Rest, []), case Type_toks of [] -> {error, {invalid_column, Path, Table_name, <<"missing type for column "/utf8, Name/binary>>}}; _ -> build_column_from_type_tokens( Path, Table_name, Name, All_tokens, Type_toks, Enums, Engine ) end. -file("src/sqlode/schema_parser.gleam", 1512). -spec parse_column_tokens( binary(), binary(), list(sqlode@lexer:token()), list(sqlode@model:enum_def()), sqlode@model:engine() ) -> {ok, gleam@option:option({sqlode@model:column(), list(sqlode@model:enum_def())})} | {error, parse_error()}. parse_column_tokens(Path, Table_name, Tokens, Enums, Engine) -> case Tokens of [] -> {ok, none}; [First | Rest] -> case First of {keyword, <<"primary"/utf8>>} -> {ok, none}; {keyword, <<"foreign"/utf8>>} -> {ok, none}; {keyword, <<"unique"/utf8>>} -> {ok, none}; {keyword, <<"constraint"/utf8>>} -> {ok, none}; {keyword, <<"check"/utf8>>} -> {ok, none}; {keyword, N} -> parse_named_column( Path, Table_name, N, Tokens, Rest, Enums, Engine ); {ident, N@1} -> Name = sqlode@naming:normalize_identifier(N@1), Type_toks = take_type_tokens_from_lexer(Rest, []), case Type_toks of [] -> {error, {invalid_column, Path, Table_name, <<"missing type for column "/utf8, Name/binary>>}}; _ -> build_column_from_type_tokens( Path, Table_name, Name, Tokens, Type_toks, Enums, Engine ) end; {quoted_ident, N@1} -> Name = sqlode@naming:normalize_identifier(N@1), Type_toks = take_type_tokens_from_lexer(Rest, []), case Type_toks of [] -> {error, {invalid_column, Path, Table_name, <<"missing type for column "/utf8, Name/binary>>}}; _ -> build_column_from_type_tokens( Path, Table_name, Name, Tokens, Type_toks, Enums, Engine ) end; _ -> {ok, none} end end. -file("src/sqlode/schema_parser.gleam", 604). ?DOC( " Parse ALTER TABLE ADD [COLUMN] and apply to existing tables.\n" "\n" " Returns both the updated table list and any enum definitions synthesized\n" " from inline MySQL `ENUM(...)` / `SET(...)` column types, so the caller can\n" " thread them into the running catalog.\n" ). -spec apply_alter_table_add_column( binary(), list(sqlode@lexer:token()), list(sqlode@model:enum_def()), list(sqlode@model:table()), sqlode@model:engine() ) -> {ok, {list(sqlode@model:table()), list(sqlode@model:enum_def())}} | {error, parse_error()}. apply_alter_table_add_column(Path, Tokens, Enums, Tables, Engine) -> {Table_name, Col_tokens} = extract_alter_table_parts(Tokens), case Table_name of <<""/utf8>> -> {ok, {Tables, []}}; _ -> gleam@result:'try'( parse_column_tokens(Path, Table_name, Col_tokens, Enums, Engine), fun(Maybe_col) -> case Maybe_col of none -> {ok, {Tables, []}}; {some, {Col, New_enums}} -> {ok, {gleam@list:map( Tables, fun(T) -> case erlang:element(2, T) =:= Table_name of true -> {table, erlang:element(2, T), lists:append( erlang:element(3, T), [Col] )}; false -> T end end ), New_enums}} end end ) end. -file("src/sqlode/schema_parser.gleam", 1408). -spec parse_columns_tokens( binary(), binary(), list(sqlode@lexer:token()), list(sqlode@model:enum_def()), sqlode@model:engine() ) -> {ok, {list(sqlode@model:column()), list(sqlode@model:enum_def())}} | {error, parse_error()}. parse_columns_tokens(Path, Table_name, Tokens, Enums, Engine) -> _pipe = split_tokens_by_comma(Tokens), _pipe@1 = gleam@list:try_fold( _pipe, {[], []}, fun(Acc, Col_tokens) -> {Columns, Collected_enums} = Acc, Visible_enums = lists:append(Enums, Collected_enums), gleam@result:'try'( parse_column_tokens( Path, Table_name, Col_tokens, Visible_enums, Engine ), fun(Maybe_column) -> case Maybe_column of {some, {Column, New_enums}} -> {ok, {[Column | Columns], lists:append(Collected_enums, New_enums)}}; none -> {ok, Acc} end end ) end ), gleam@result:map( _pipe@1, fun(Pair) -> {lists:reverse(erlang:element(1, Pair)), erlang:element(2, Pair)} end ). -file("src/sqlode/schema_parser.gleam", 1330). -spec parse_create_table_tokens( binary(), list(sqlode@lexer:token()), list(sqlode@model:enum_def()), sqlode@model:engine() ) -> {ok, gleam@option:option({sqlode@model:table(), list(sqlode@model:enum_def())})} | {error, parse_error()}. parse_create_table_tokens(Path, Tokens, Enums, Engine) -> {Header, Body} = split_at_lparen(Tokens, []), case Body of [] -> {error, {invalid_create_table, Path, <<"missing opening parenthesis in CREATE TABLE statement"/utf8>>}}; _ -> gleam@result:'try'( begin _pipe = find_last_ident(Header), gleam@result:map_error( _pipe, fun(_) -> {invalid_create_table, Path, <<"missing table name"/utf8>>} end ) end, fun(Table_name) -> Body_tokens = strip_trailing_rparen(Body), gleam@result:'try'( parse_columns_tokens( Path, Table_name, Body_tokens, Enums, Engine ), fun(_use0) -> {Columns, New_enums} = _use0, {ok, {some, {{table, Table_name, Columns}, New_enums}}} end ) end ) end. -file("src/sqlode/schema_parser.gleam", 691). -spec parse_statement_tokens( binary(), list(sqlode@lexer:token()), list(sqlode@model:enum_def()), sqlode@model:engine() ) -> {ok, statement_result()} | {error, parse_error()}. parse_statement_tokens(Path, Tokens, Enums, Engine) -> case is_create_table_tokens(Tokens) of true -> gleam@result:'try'( parse_create_table_tokens(Path, Tokens, Enums, Engine), fun(Maybe_table) -> case Maybe_table of {some, {Table, New_enums}} -> {ok, {create_table_result, Table, New_enums}}; none -> {ok, ignored} end end ); false -> case classify_unknown_statement(Tokens, Engine) of {destructive_d_d_l, Action} -> {ok, {d_d_l_applied, Action}}; silently_ignored -> {ok, ignored}; {unsupported_mysql, Detail} -> {error, {unsupported_mysql_ddl, Path, Detail}} end end. -file("src/sqlode/schema_parser.gleam", 78). ?DOC( " Parse a single schema file. Accepts the catalog accumulated from\n" " previously-parsed files so migration-history fixtures that split\n" " CREATE TABLE and later ALTER statements across files can still\n" " apply the ALTERs against the real table list.\n" ). -spec parse_content( binary(), binary(), list(sqlode@model:table()), list(sqlode@model:enum_def()), sqlode@model:engine() ) -> {ok, parsed_schema()} | {error, parse_error()}. parse_content(Path, Content, Existing_tables, Known_enums, Engine) -> Tokens = sqlode@lexer:tokenize(Content, Engine), Statements = split_token_statements(Tokens, [], []), Enums = begin _pipe = Statements, gleam@list:filter_map( _pipe, fun(Stmt_tokens) -> case is_create_enum_tokens(Stmt_tokens) of true -> parse_create_enum_from_tokens(Stmt_tokens); false -> {error, nil} end end ) end, All_enums = lists:append(Known_enums, Enums), Initial_tables = lists:reverse(Existing_tables), gleam@result:'try'( begin _pipe@1 = Statements, _pipe@2 = gleam@list:try_fold( _pipe@1, {Initial_tables, All_enums, []}, fun(Acc, Stmt_tokens@1) -> {Tables, Current_enums, Warnings} = Acc, case is_create_view_tokens(Stmt_tokens@1) of true -> {Maybe_table, View_warnings} = parse_create_view_from_tokens( Stmt_tokens@1, lists:reverse(Tables) ), New_warnings = lists:append(Warnings, View_warnings), {ok, case Maybe_table of {some, Table} -> {[Table | Tables], Current_enums, New_warnings}; none -> {Tables, Current_enums, New_warnings} end}; false -> case is_alter_table_add_column_tokens(Stmt_tokens@1) of true -> gleam@result:'try'( apply_alter_table_add_column( Path, Stmt_tokens@1, Current_enums, Tables, Engine ), fun(_use0) -> {New_tables, Added_enums} = _use0, {ok, {New_tables, lists:append( Current_enums, Added_enums ), Warnings}} end ); false -> gleam@result:'try'( parse_statement_tokens( Path, Stmt_tokens@1, Current_enums, Engine ), fun(Stmt_result) -> case Stmt_result of {create_table_result, Table@1, New_enums} -> {ok, {[Table@1 | Tables], lists:append( Current_enums, New_enums ), Warnings}}; {d_d_l_applied, Action} -> gleam@result:'try'( apply_ddl_action( Action, Path, Tables, Current_enums, Engine ), fun(_use0@1) -> {New_tables@1, New_enums@1} = _use0@1, {ok, {New_tables@1, New_enums@1, Warnings}} end ); ignored -> {ok, {Tables, Current_enums, Warnings}} end end ) end end end ), gleam@result:map( _pipe@2, fun(Triple) -> {lists:reverse(erlang:element(1, Triple)), erlang:element(2, Triple), erlang:element(3, Triple)} end ) end, fun(_use0@2) -> {Tables@1, Final_enums, Warnings@1} = _use0@2, {ok, {parsed_schema, Tables@1, Final_enums, Warnings@1}} end ). -file("src/sqlode/schema_parser.gleam", 43). -spec parse_files_with_engine(list({binary(), binary()}), sqlode@model:engine()) -> {ok, {sqlode@model:catalog(), list(schema_warning())}} | {error, parse_error()}. parse_files_with_engine(Entries, Engine) -> _pipe = Entries, _pipe@1 = gleam@list:try_fold( _pipe, {parsed_schema, [], [], []}, fun(Acc, Entry) -> {Path, Content} = Entry, gleam@result:'try'( parse_content( Path, Content, erlang:element(2, Acc), erlang:element(3, Acc), Engine ), fun(Parsed) -> {ok, {parsed_schema, erlang:element(2, Parsed), erlang:element(3, Parsed), lists:append( erlang:element(4, Acc), erlang:element(4, Parsed) )}} end ) end ), gleam@result:map( _pipe@1, fun(Schema) -> {{catalog, erlang:element(2, Schema), erlang:element(3, Schema)}, erlang:element(4, Schema)} end ). -file("src/sqlode/schema_parser.gleam", 37). -spec parse_files(list({binary(), binary()})) -> {ok, {sqlode@model:catalog(), list(schema_warning())}} | {error, parse_error()}. parse_files(Entries) -> parse_files_with_engine(Entries, postgre_s_q_l). -file("src/sqlode/schema_parser.gleam", 1806). -spec path_prefix(binary()) -> binary(). path_prefix(Path) -> case Path of <<""/utf8>> -> <<""/utf8>>; _ -> <> end. -file("src/sqlode/schema_parser.gleam", 1791). -spec error_to_string(parse_error()) -> binary(). error_to_string(Error) -> case Error of {invalid_create_table, Path, Detail} -> <<<<(path_prefix(Path))/binary, "Invalid CREATE TABLE statement: "/utf8>>/binary, Detail/binary>>; {invalid_column, Path@1, Table, Detail@1} -> <<<<<<<<(path_prefix(Path@1))/binary, "Invalid column definition in table "/utf8>>/binary, Table/binary>>/binary, ": "/utf8>>/binary, Detail@1/binary>>; {unsupported_mysql_ddl, Path@2, Detail@2} -> <<<<(path_prefix(Path@2))/binary, "Unsupported MySQL DDL statement: "/utf8>>/binary, Detail@2/binary>> end. -file("src/sqlode/schema_parser.gleam", 1813). -spec warning_to_string(schema_warning()) -> binary(). warning_to_string(Warning) -> {unresolvable_view_column, Column} = Warning, <<<<"Warning: view column \""/utf8, Column/binary>>/binary, "\" could not be resolved from source tables — skipping column."/utf8>>.