-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, unknown_statement_kind/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_statement, 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 unknown_statement_kind() :: {unsupported, binary()} | silently_ignored. -file("src/sqlode/schema_parser.gleam", 136). ?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", 157). ?DOC(" Check if a token list represents a CREATE TYPE ... AS ENUM statement.\n"). -spec is_create_enum_tokens(list(sqlode@lexer:token())) -> boolean(). is_create_enum_tokens(Tokens) -> case Tokens of [{keyword, <<"create"/utf8>>}, {keyword, <<"type"/utf8>>}, _, {keyword, <<"as"/utf8>>}, {keyword, <<"enum"/utf8>>} | _] -> true; _ -> false end. -file("src/sqlode/schema_parser.gleam", 197). ?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", 172). ?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>>}, {keyword, <<"type"/utf8>>}, Name_token, {keyword, <<"as"/utf8>>}, {keyword, <<"enum"/utf8>>} | Rest] -> 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}}; _ -> {error, nil} end. -file("src/sqlode/schema_parser.gleam", 209). -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", 264). -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", 285). -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", 333). -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", 344). -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", 479). -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", 494). -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", 360). -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", 518). ?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", 607). -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", 584). -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), Rest}; [{keyword, <<"alter"/utf8>>}, {keyword, <<"table"/utf8>>}, Name_tok@1, {keyword, <<"add"/utf8>>} | Rest@1] -> {extract_ident(Name_tok@1), Rest@1}; _ -> {<<""/utf8>>, []} end. -file("src/sqlode/schema_parser.gleam", 682). -spec classify_alter_table_action(list(binary())) -> unknown_statement_kind(). classify_alter_table_action(Words) -> case Words of [<<"drop"/utf8>>, <<"constraint"/utf8>> | _] -> silently_ignored; [<<"drop"/utf8>>, <<"column"/utf8>> | _] -> {unsupported, <<"ALTER TABLE DROP COLUMN"/utf8>>}; [<<"drop"/utf8>> | _] -> {unsupported, <<"ALTER TABLE DROP"/utf8>>}; [<<"rename"/utf8>>, <<"to"/utf8>> | _] -> {unsupported, <<"ALTER TABLE RENAME TO"/utf8>>}; [<<"rename"/utf8>> | _] -> {unsupported, <<"ALTER TABLE RENAME COLUMN"/utf8>>}; [<<"alter"/utf8>> | _] -> {unsupported, <<"ALTER TABLE ALTER COLUMN"/utf8>>}; [<<"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", 670). -spec classify_alter_table(list(binary())) -> unknown_statement_kind(). classify_alter_table(Words) -> After_modifiers = case Words of [<<"if"/utf8>>, <<"exists"/utf8>> | R] -> R; [<<"only"/utf8>> | R@1] -> R@1; _ -> Words end, case After_modifiers of [_ | Rest] -> classify_alter_table_action(Rest); [] -> silently_ignored end. -file("src/sqlode/schema_parser.gleam", 700). -spec token_keyword_text(sqlode@lexer:token()) -> binary(). token_keyword_text(Token) -> case Token of {keyword, K} -> K; {ident, I} -> string:lowercase(I); _ -> <<""/utf8>> end. -file("src/sqlode/schema_parser.gleam", 646). ?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())) -> unknown_statement_kind(). classify_unknown_statement(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 [<<"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>> | _] -> {unsupported, <<"DROP TABLE"/utf8>>}; [<<"drop"/utf8>>, <<"view"/utf8>> | _] -> {unsupported, <<"DROP VIEW"/utf8>>}; [<<"drop"/utf8>>, <<"type"/utf8>> | _] -> {unsupported, <<"DROP TYPE"/utf8>>}; [<<"alter"/utf8>>, <<"table"/utf8>> | Rest] -> classify_alter_table(Rest); _ -> silently_ignored end. -file("src/sqlode/schema_parser.gleam", 708). -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", 729). -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", 390). -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", 462). -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", 406). -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", 313). -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", 298). -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", 223). -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", 779). ?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", 791). ?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", 811). -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", 804). ?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", 845). -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", 841). ?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", 959). -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] -> S = 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 S of <<""/utf8>> -> render_type_tokens_loop(Rest@1, Acc); _ -> render_type_tokens_loop(Rest@1, [S | Acc]) end end. -file("src/sqlode/schema_parser.gleam", 953). ?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", 993). -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", 1001). -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", 1010). -spec infer_scalar_type(binary()) -> {ok, sqlode@model:scalar_type()} | {error, binary()}. infer_scalar_type(Type_text) -> _pipe = sqlode@model:parse_sql_type(Type_text), gleam@result:replace_error( _pipe, <<<<<<<<"unrecognized SQL type \""/utf8, Type_text/binary>>/binary, "\". Supported types: int, serial, float, numeric, bool, text, char, bytea,"/utf8>>/binary, " uuid, json, jsonb, timestamp, datetime, date, time, interval."/utf8>>/binary, " Hint: add a type override in sqlode.yaml under overrides.db_type"/utf8>> ). -file("src/sqlode/schema_parser.gleam", 1021). -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>>], Keyword ). -file("src/sqlode/schema_parser.gleam", 936). -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; [Tok | Rest@1] -> take_type_tokens_from_lexer(Rest@1, [Tok | Acc]) end. -file("src/sqlode/schema_parser.gleam", 1040). -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", 881). -spec parse_column_tokens( binary(), binary(), list(sqlode@lexer:token()), list(sqlode@model:enum_def()) ) -> {ok, gleam@option:option(sqlode@model:column())} | {error, parse_error()}. parse_column_tokens(Path, Table_name, Tokens, Enums) -> 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}; {ident, N} -> Name = sqlode@naming:normalize_identifier(N), 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>>}}; _ -> Type_text = render_type_tokens(Type_toks), Nullable = case (tokens_contain_not_null(Tokens) orelse tokens_contain_keyword( Tokens, <<"primary"/utf8>> )) orelse gleam_stdlib:contains_string( Type_text, <<"serial"/utf8>> ) of true -> false; false -> true end, gleam@result:'try'( case find_enum(Type_text, Enums) of {some, Enum_name} -> {ok, {enum_type, Enum_name}}; none -> _pipe = infer_scalar_type(Type_text), gleam@result:map_error( _pipe, fun(Detail) -> {invalid_column, Path, Table_name, Detail} end ) end, fun(Scalar_type) -> {ok, {some, {column, Name, Scalar_type, Nullable}}} end ) end; {quoted_ident, N} -> Name = sqlode@naming:normalize_identifier(N), 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>>}}; _ -> Type_text = render_type_tokens(Type_toks), Nullable = case (tokens_contain_not_null(Tokens) orelse tokens_contain_keyword( Tokens, <<"primary"/utf8>> )) orelse gleam_stdlib:contains_string( Type_text, <<"serial"/utf8>> ) of true -> false; false -> true end, gleam@result:'try'( case find_enum(Type_text, Enums) of {some, Enum_name} -> {ok, {enum_type, Enum_name}}; none -> _pipe = infer_scalar_type(Type_text), gleam@result:map_error( _pipe, fun(Detail) -> {invalid_column, Path, Table_name, Detail} end ) end, fun(Scalar_type) -> {ok, {some, {column, Name, Scalar_type, Nullable}}} end ) end; _ -> {ok, none} end end. -file("src/sqlode/schema_parser.gleam", 551). ?DOC(" Parse ALTER TABLE ADD [COLUMN] and apply to existing tables.\n"). -spec apply_alter_table_add_column( binary(), list(sqlode@lexer:token()), list(sqlode@model:enum_def()), list(sqlode@model:table()) ) -> {ok, list(sqlode@model:table())} | {error, parse_error()}. apply_alter_table_add_column(Path, Tokens, Enums, Tables) -> {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), fun(Maybe_col) -> case Maybe_col of none -> {ok, Tables}; {some, Col} -> {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 )} end end ) end. -file("src/sqlode/schema_parser.gleam", 818). -spec parse_columns_tokens( binary(), binary(), list(sqlode@lexer:token()), list(sqlode@model:enum_def()) ) -> {ok, list(sqlode@model:column())} | {error, parse_error()}. parse_columns_tokens(Path, Table_name, Tokens, Enums) -> _pipe = split_tokens_by_comma(Tokens), _pipe@1 = gleam@list:try_fold( _pipe, [], fun(Columns, Col_tokens) -> gleam@result:'try'( parse_column_tokens(Path, Table_name, Col_tokens, Enums), fun(Maybe_column) -> {ok, case Maybe_column of {some, Column} -> [Column | Columns]; none -> Columns end} end ) end ), gleam@result:map(_pipe@1, fun lists:reverse/1). -file("src/sqlode/schema_parser.gleam", 742). -spec parse_create_table_tokens( binary(), list(sqlode@lexer:token()), list(sqlode@model:enum_def()) ) -> {ok, gleam@option:option(sqlode@model:table())} | {error, parse_error()}. parse_create_table_tokens(Path, Tokens, Enums) -> {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 ), fun(Columns) -> {ok, {some, {table, Table_name, Columns}}} end ) end ) end. -file("src/sqlode/schema_parser.gleam", 615). -spec parse_statement_tokens( binary(), list(sqlode@lexer:token()), list(sqlode@model:enum_def()) ) -> {ok, gleam@option:option(sqlode@model:table())} | {error, parse_error()}. parse_statement_tokens(Path, Tokens, Enums) -> case is_create_table_tokens(Tokens) of true -> parse_create_table_tokens(Path, Tokens, Enums); false -> case classify_unknown_statement(Tokens) of {unsupported, Statement_type} -> {error, {unsupported_statement, Path, Statement_type}}; silently_ignored -> {ok, none} end end. -file("src/sqlode/schema_parser.gleam", 68). -spec parse_content( binary(), binary(), list(sqlode@model:enum_def()), sqlode@model:engine() ) -> {ok, parsed_schema()} | {error, parse_error()}. parse_content(Path, Content, 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), gleam@result:'try'( begin _pipe@1 = Statements, _pipe@2 = gleam@list:try_fold( _pipe@1, {[], []}, fun(Acc, Stmt_tokens@1) -> {Tables, 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], New_warnings}; none -> {Tables, 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, All_enums, Tables ), fun(New_tables) -> {ok, {New_tables, Warnings}} end ); false -> gleam@result:'try'( parse_statement_tokens( Path, Stmt_tokens@1, All_enums ), fun(Maybe_table@1) -> {ok, case Maybe_table@1 of {some, Table@1} -> {[Table@1 | Tables], Warnings}; none -> {Tables, Warnings} end} end ) end end end ), gleam@result:map( _pipe@2, fun(Pair) -> {lists:reverse(erlang:element(1, Pair)), erlang:element(2, Pair)} end ) end, fun(_use0) -> {Tables@1, Warnings@1} = _use0, {ok, {parsed_schema, Tables@1, 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(3, Acc), Engine), fun(Parsed) -> {ok, {parsed_schema, lists:append( erlang:element(2, Acc), erlang:element(2, Parsed) ), lists:append( erlang:element(3, Acc), 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", 1070). -spec path_prefix(binary()) -> binary(). path_prefix(Path) -> case Path of <<""/utf8>> -> <<""/utf8>>; _ -> <> end. -file("src/sqlode/schema_parser.gleam", 1049). -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_statement, Path@2, Statement_type} -> <<<<<<<<<<<<(path_prefix(Path@2))/binary, "Unsupported schema DDL: "/utf8>>/binary, Statement_type/binary>>/binary, ". sqlode only applies CREATE TABLE, CREATE VIEW, CREATE TYPE ... AS ENUM,"/utf8>>/binary, " and ALTER TABLE ... ADD COLUMN to the catalog;"/utf8>>/binary, " keep other schema-changing DDL out of the schema input"/utf8>>/binary, " or consolidate migrations into a single CREATE TABLE snapshot."/utf8>> end. -file("src/sqlode/schema_parser.gleam", 1077). -spec warning_to_string(schema_warning()) -> binary(). warning_to_string(Warning) -> case Warning of {unresolvable_view_column, Column} -> <<<<"Warning: view column \""/utf8, Column/binary>>/binary, "\" could not be resolved from source tables — skipping column."/utf8>> end.