Current section

Files

Jump to
sqlode src sqlode@query_parser.erl
Raw

src/sqlode@query_parser.erl

-module(sqlode@query_parser).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/sqlode/query_parser.gleam").
-export([error_to_string/1, parse_file/4]).
-export_type([parse_error/0, pending_query/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_annotation, binary(), integer(), binary()} |
{missing_sql, binary(), integer(), binary()}.
-type pending_query() :: {pending_query,
binary(),
binary(),
sqlode@runtime:query_command(),
integer(),
list(binary())}.
-file("src/sqlode/query_parser.gleam", 277).
-spec extract_line_annotation(binary()) -> gleam@option:option(binary()).
extract_line_annotation(Line) ->
case gleam_stdlib:string_starts_with(Line, <<"-- name:"/utf8>>) of
false ->
none;
true ->
_pipe = Line,
_pipe@1 = gleam@string:replace(
_pipe,
<<"-- name:"/utf8>>,
<<""/utf8>>
),
_pipe@2 = gleam@string:trim(_pipe@1),
{some, _pipe@2}
end.
-file("src/sqlode/query_parser.gleam", 288).
-spec extract_block_annotation(binary()) -> gleam@option:option(binary()).
extract_block_annotation(Line) ->
case gleam_stdlib:string_starts_with(Line, <<"/*"/utf8>>) andalso gleam_stdlib:string_ends_with(
Line,
<<"*/"/utf8>>
) of
false ->
none;
true ->
Inner = begin
_pipe = Line,
_pipe@1 = gleam@string:drop_start(_pipe, 2),
gleam@string:drop_end(_pipe@1, 2)
end,
case gleam_stdlib:contains_string(Inner, <<"*/"/utf8>>) of
true ->
none;
false ->
Inner_trimmed = gleam@string:trim(Inner),
case gleam_stdlib:string_starts_with(
Inner_trimmed,
<<"name:"/utf8>>
) of
false ->
none;
true ->
_pipe@2 = Inner_trimmed,
_pipe@3 = gleam@string:replace(
_pipe@2,
<<"name:"/utf8>>,
<<""/utf8>>
),
_pipe@4 = gleam@string:trim(_pipe@3),
{some, _pipe@4}
end
end
end.
-file("src/sqlode/query_parser.gleam", 270).
?DOC(
" Extracts the annotation payload (the text after `name:` and before any\n"
" terminator) from a trimmed line. Supports both `-- name: ...` and\n"
" `/* name: ... */` forms. Returns None for non-annotation lines so callers\n"
" can treat them as SQL body.\n"
).
-spec extract_annotation_payload(binary()) -> gleam@option:option(binary()).
extract_annotation_payload(Line) ->
case extract_line_annotation(Line) of
{some, Payload} ->
{some, Payload};
none ->
extract_block_annotation(Line)
end.
-file("src/sqlode/query_parser.gleam", 220).
-spec parse_annotation(
sqlode@naming:naming_context(),
binary(),
binary(),
integer()
) -> {ok, gleam@option:option(pending_query())} | {error, parse_error()}.
parse_annotation(Naming_ctx, Line, Path, Line_number) ->
case extract_annotation_payload(Line) of
none ->
{ok, none};
{some, Rest} ->
Parts = begin
_pipe = gleam@string:split(Rest, <<" "/utf8>>),
_pipe@1 = gleam@list:map(_pipe, fun gleam@string:trim/1),
gleam@list:filter(_pipe@1, fun(Part) -> Part /= <<""/utf8>> end)
end,
case Parts of
[Name, Command_text] ->
gleam@result:'try'(
begin
_pipe@2 = sqlode@model:parse_query_command(
Command_text
),
gleam@result:map_error(
_pipe@2,
fun(Detail) ->
{invalid_annotation,
Path,
Line_number,
Detail}
end
)
end,
fun(Command) ->
{ok,
{some,
{pending_query,
Name,
sqlode@naming:to_snake_case(
Naming_ctx,
Name
),
Command,
Line_number,
[]}}}
end
);
_ ->
{error,
{invalid_annotation,
Path,
Line_number,
<<"expected '-- name: <Name> <command>' or '/* name: <Name> <command> */' where command is one of: :one, :many, :exec, :execresult, :execrows, :execlastid, :batchone, :batchmany, :batchexec, :copyfrom"/utf8>>}}
end
end.
-file("src/sqlode/query_parser.gleam", 315).
-spec is_skip_annotation(binary()) -> boolean().
is_skip_annotation(Line) ->
Line =:= <<"-- sqlode:skip"/utf8>>.
-file("src/sqlode/query_parser.gleam", 374).
-spec is_marker_placeholder(binary()) -> boolean().
is_marker_placeholder(P) ->
gleam_stdlib:string_starts_with(P, <<"__sqlode_param_"/utf8>>) orelse gleam_stdlib:string_starts_with(
P,
<<"__sqlode_slice_"/utf8>>
).
-file("src/sqlode/query_parser.gleam", 379).
-spec extract_marker_index(binary()) -> {ok, integer()} | {error, nil}.
extract_marker_index(P) ->
Body = case gleam_stdlib:string_starts_with(P, <<"__sqlode_param_"/utf8>>) of
true ->
gleam@string:drop_start(P, 15);
false ->
case gleam_stdlib:string_starts_with(P, <<"__sqlode_slice_"/utf8>>) of
true ->
gleam@string:drop_start(P, 15);
false ->
P
end
end,
Without_suffix = case gleam_stdlib:string_ends_with(Body, <<"__"/utf8>>) of
true ->
gleam@string:drop_end(Body, 2);
false ->
Body
end,
gleam_stdlib:parse_int(Without_suffix).
-file("src/sqlode/query_parser.gleam", 326).
?DOC(
" Count parameters from an already-expanded token list.\n"
"\n"
" After macro/`@name` expansion every parameter is represented by a\n"
" `__sqlode_param_<idx>__` or `__sqlode_slice_<idx>__` marker, so the\n"
" parameter count is the highest index that appears. Raw engine\n"
" placeholders (`$1`, `?1`, bare `?`) written directly by the user are\n"
" also counted for back-compat with handwritten SQL.\n"
).
-spec count_parameters_from_tokens(
sqlode@model:engine(),
list(sqlode@lexer:token())
) -> integer().
count_parameters_from_tokens(Engine, Tokens) ->
Placeholders = begin
_pipe = Tokens,
gleam@list:filter_map(_pipe, fun(Token) -> case Token of
{placeholder, P} ->
{ok, P};
_ ->
{error, nil}
end end)
end,
{Markers, Raw} = gleam@list:partition(
Placeholders,
fun(P@1) -> is_marker_placeholder(P@1) end
),
Marker_count = begin
_pipe@1 = Markers,
_pipe@2 = gleam@list:filter_map(_pipe@1, fun extract_marker_index/1),
_pipe@3 = gleam@list:unique(_pipe@2),
erlang:length(_pipe@3)
end,
Raw_count = case Engine of
postgre_s_q_l ->
_pipe@4 = Raw,
_pipe@6 = gleam@list:filter_map(
_pipe@4,
fun(P@2) ->
_pipe@5 = gleam@string:replace(
P@2,
<<"$"/utf8>>,
<<""/utf8>>
),
gleam_stdlib:parse_int(_pipe@5)
end
),
gleam@list:fold(_pipe@6, 0, fun(Max_idx, V) -> case V > Max_idx of
true ->
V;
false ->
Max_idx
end end);
my_s_q_l ->
erlang:length(Raw);
s_q_lite ->
{Anon, Named} = gleam@list:partition(
Raw,
fun(P@3) -> P@3 =:= <<"?"/utf8>> end
),
erlang:length(Anon) + erlang:length(gleam@list:unique(Named))
end,
Marker_count + Raw_count.
-file("src/sqlode/query_parser.gleam", 395).
-spec error_to_string(parse_error()) -> binary().
error_to_string(Error) ->
case Error of
{invalid_annotation, Path, Line, Detail} ->
<<<<<<<<Path/binary, ":"/utf8>>/binary,
(erlang:integer_to_binary(Line))/binary>>/binary,
": invalid query annotation: "/utf8>>/binary,
Detail/binary>>;
{missing_sql, Path@1, Line@1, Name} ->
<<<<<<<<<<Path@1/binary, ":"/utf8>>/binary,
(erlang:integer_to_binary(Line@1))/binary>>/binary,
": query "/utf8>>/binary,
Name/binary>>/binary,
" is missing SQL body"/utf8>>
end.
-file("src/sqlode/query_parser.gleam", 548).
?DOC(" Collect tokens inside macro parens until the matching RParen.\n").
-spec collect_macro_arg_tokens(
list(sqlode@lexer:token()),
integer(),
list(sqlode@lexer:token())
) -> {list(sqlode@lexer:token()), list(sqlode@lexer:token())}.
collect_macro_arg_tokens(Tokens, Depth, Acc) ->
case Depth =< 0 of
true ->
{lists:reverse(Acc), Tokens};
false ->
case Tokens of
[] ->
{lists:reverse(Acc), []};
[l_paren | Rest] ->
collect_macro_arg_tokens(Rest, Depth + 1, [l_paren | Acc]);
[r_paren | Rest@1] ->
case Depth =:= 1 of
true ->
{lists:reverse(Acc), Rest@1};
false ->
collect_macro_arg_tokens(
Rest@1,
Depth - 1,
[r_paren | Acc]
)
end;
[Token | Rest@2] ->
collect_macro_arg_tokens(Rest@2, Depth, [Token | Acc])
end
end.
-file("src/sqlode/query_parser.gleam", 573).
?DOC(
" Extract the argument name from macro arg tokens.\n"
" Handles: Ident(name), StringLit(name), QuotedIdent(name).\n"
).
-spec extract_arg_name(list(sqlode@lexer:token())) -> binary().
extract_arg_name(Tokens) ->
case Tokens of
[{ident, Name}] ->
Name;
[{string_lit, Name@1}] ->
Name@1;
[{quoted_ident, Name@2}] ->
Name@2;
_ ->
_pipe = Tokens,
_pipe@1 = gleam@list:filter_map(_pipe, fun(T) -> case T of
{ident, N} ->
{ok, N};
{string_lit, N@1} ->
{ok, N@1};
{quoted_ident, N@2} ->
{ok, N@2};
_ ->
{error, nil}
end end),
gleam@string:join(_pipe@1, <<""/utf8>>)
end.
-file("src/sqlode/query_parser.gleam", 593).
-spec engine_placeholder(sqlode@model:engine(), integer()) -> binary().
engine_placeholder(_, Index) ->
sqlode@runtime:param_marker(Index).
-file("src/sqlode/query_parser.gleam", 433).
-spec expand_at_loop(
sqlode@model:engine(),
list(sqlode@lexer:token()),
integer(),
gleam@dict:dict(binary(), integer()),
list(sqlode@lexer:token()),
list(sqlode@model:macro())
) -> {list(sqlode@lexer:token()), list(sqlode@model:macro()), integer()}.
expand_at_loop(Engine, Tokens, Idx, Seen, Token_acc, Macro_acc) ->
case Tokens of
[] ->
{lists:reverse(Token_acc), lists:reverse(Macro_acc), Idx};
[{placeholder, P} | Rest] ->
case gleam_stdlib:string_starts_with(P, <<"@"/utf8>>) of
true ->
Name = gleam@string:drop_start(P, 1),
case {Engine, gleam_stdlib:map_get(Seen, Name)} of
{s_q_lite, {ok, Existing_idx}} ->
Placeholder = engine_placeholder(
Engine,
Existing_idx
),
expand_at_loop(
Engine,
Rest,
Idx,
Seen,
[{placeholder, Placeholder} | Token_acc],
Macro_acc
);
{_, _} ->
Placeholder@1 = engine_placeholder(Engine, Idx),
New_seen = case Engine of
s_q_lite ->
gleam@dict:insert(Seen, Name, Idx);
_ ->
Seen
end,
expand_at_loop(
Engine,
Rest,
Idx + 1,
New_seen,
[{placeholder, Placeholder@1} | Token_acc],
[{macro_arg, Idx, Name} | Macro_acc]
)
end;
false ->
expand_at_loop(
Engine,
Rest,
Idx,
Seen,
[{placeholder, P} | Token_acc],
Macro_acc
)
end;
[Token | Rest@1] ->
expand_at_loop(
Engine,
Rest@1,
Idx,
Seen,
[Token | Token_acc],
Macro_acc
)
end.
-file("src/sqlode/query_parser.gleam", 422).
?DOC(
" Expand @name shorthands in the token list.\n"
" For non-MySQL engines, Placeholder(\"@name\") tokens are replaced\n"
" with engine-appropriate placeholders ($N, ?N).\n"
" The lexer only emits Placeholder(\"@...\") when @ is followed by\n"
" alpha/underscore, so @1 is never matched (it becomes Operator+NumberLit).\n"
).
-spec expand_at_name_tokens(
sqlode@model:engine(),
list(sqlode@lexer:token()),
integer()
) -> {list(sqlode@lexer:token()), list(sqlode@model:macro()), integer()}.
expand_at_name_tokens(Engine, Tokens, Start_idx) ->
case Engine of
my_s_q_l ->
{Tokens, [], Start_idx};
_ ->
expand_at_loop(Engine, Tokens, Start_idx, maps:new(), [], [])
end.
-file("src/sqlode/query_parser.gleam", 602).
-spec engine_slice_placeholder(sqlode@model:engine(), integer()) -> binary().
engine_slice_placeholder(_, Index) ->
sqlode@runtime:slice_marker(Index).
-file("src/sqlode/query_parser.gleam", 506).
-spec expand_macro_loop(
sqlode@model:engine(),
list(sqlode@lexer:token()),
integer(),
list(sqlode@lexer:token()),
list(sqlode@model:macro())
) -> {list(sqlode@lexer:token()), list(sqlode@model:macro())}.
expand_macro_loop(Engine, Tokens, Idx, Token_acc, Macro_acc) ->
case Tokens of
[] ->
{lists:reverse(Token_acc), lists:reverse(Macro_acc)};
[{ident, Mod}, dot, {ident, Kind}, l_paren | Rest] when (((Mod =:= <<"sqlode"/utf8>>) orelse (Mod =:= <<"Sqlode"/utf8>>)) orelse (Mod =:= <<"SQLODE"/utf8>>)) andalso (((Kind =:= <<"arg"/utf8>>) orelse (Kind =:= <<"narg"/utf8>>)) orelse (Kind =:= <<"slice"/utf8>>)) ->
{Arg_tokens, Remaining} = collect_macro_arg_tokens(Rest, 1, []),
Name = extract_arg_name(Arg_tokens),
Placeholder = case Kind of
<<"slice"/utf8>> ->
engine_slice_placeholder(Engine, Idx);
_ ->
engine_placeholder(Engine, Idx)
end,
Macro_entry = case Kind of
<<"narg"/utf8>> ->
{macro_narg, Idx, Name};
<<"slice"/utf8>> ->
{macro_slice, Idx, Name};
_ ->
{macro_arg, Idx, Name}
end,
expand_macro_loop(
Engine,
Remaining,
Idx + 1,
[{placeholder, Placeholder} | Token_acc],
[Macro_entry | Macro_acc]
);
[Token | Rest@1] ->
expand_macro_loop(
Engine,
Rest@1,
Idx,
[Token | Token_acc],
Macro_acc
)
end.
-file("src/sqlode/query_parser.gleam", 498).
?DOC(
" Expand sqlode.arg(name), sqlode.narg(name), sqlode.slice(name) macros\n"
" by walking the token list and replacing the token span with a Placeholder.\n"
" Pattern: Ident(\"sqlode\") Dot Ident(\"arg\"|\"narg\"|\"slice\") LParen ...arg... RParen\n"
).
-spec expand_macro_tokens(
sqlode@model:engine(),
list(sqlode@lexer:token()),
integer()
) -> {list(sqlode@lexer:token()), list(sqlode@model:macro())}.
expand_macro_tokens(Engine, Tokens, Start_idx) ->
expand_macro_loop(Engine, Tokens, Start_idx, [], []).
-file("src/sqlode/query_parser.gleam", 156).
-spec finalize_pending(
sqlode@naming:naming_context(),
gleam@option:option(pending_query()),
binary(),
sqlode@model:engine(),
list(sqlode@model:parsed_query())
) -> {ok, list(sqlode@model:parsed_query())} | {error, parse_error()}.
finalize_pending(_, Pending, Path, Engine, Parsed_rev) ->
case Pending of
none ->
{ok, Parsed_rev};
{some,
{pending_query, Name, Function_name, Command, Start_line, Body_rev}} ->
Sql = begin
_pipe = Body_rev,
_pipe@1 = lists:reverse(_pipe),
_pipe@2 = gleam@string:join(_pipe@1, <<"\n"/utf8>>),
gleam@string:trim(_pipe@2)
end,
case Sql =:= <<""/utf8>> of
true ->
{error, {missing_sql, Path, Start_line, Name}};
false ->
Tokens = sqlode@lexer:tokenize(Sql, Engine),
{Tokens@1, At_macros, Next_idx} = expand_at_name_tokens(
Engine,
Tokens,
1
),
{Tokens@2, Expanded_macros} = expand_macro_tokens(
Engine,
Tokens@1,
Next_idx
),
Macros = lists:append(At_macros, Expanded_macros),
Expanded_sql = sqlode@lexer:tokens_to_string(
Tokens@2,
{token_render_options, false, true, {some, Engine}}
),
Param_count = count_parameters_from_tokens(Engine, Tokens@2),
{ok,
[{parsed_query,
Name,
Function_name,
Command,
Expanded_sql,
Path,
Param_count,
Macros} |
Parsed_rev]}
end
end.
-file("src/sqlode/query_parser.gleam", 46).
-spec parse_lines(
sqlode@naming:naming_context(),
list(binary()),
binary(),
sqlode@model:engine(),
integer(),
gleam@option:option(pending_query()),
list(sqlode@model:parsed_query()),
boolean()
) -> {ok, list(sqlode@model:parsed_query())} | {error, parse_error()}.
parse_lines(
Naming_ctx,
Lines,
Path,
Engine,
Line_number,
Pending,
Parsed_rev,
Skip_next
) ->
case Lines of
[] ->
finalize_pending(Naming_ctx, Pending, Path, Engine, Parsed_rev);
[Line | Rest] ->
Trimmed = gleam@string:trim(Line),
case is_skip_annotation(Trimmed) of
true ->
gleam@result:'try'(
finalize_pending(
Naming_ctx,
Pending,
Path,
Engine,
Parsed_rev
),
fun(Parsed_rev@1) ->
parse_lines(
Naming_ctx,
Rest,
Path,
Engine,
Line_number + 1,
none,
Parsed_rev@1,
true
)
end
);
false ->
case parse_annotation(
Naming_ctx,
Trimmed,
Path,
Line_number
) of
{ok, {some, Next_pending}} ->
gleam@result:'try'(
finalize_pending(
Naming_ctx,
Pending,
Path,
Engine,
Parsed_rev
),
fun(Parsed_rev@2) -> case Skip_next of
true ->
parse_lines(
Naming_ctx,
Rest,
Path,
Engine,
Line_number + 1,
none,
Parsed_rev@2,
false
);
false ->
parse_lines(
Naming_ctx,
Rest,
Path,
Engine,
Line_number + 1,
{some, Next_pending},
Parsed_rev@2,
false
)
end end
);
{ok, none} ->
Pending@1 = case Pending of
{some,
{pending_query,
Name,
Function_name,
Command,
Start_line,
Body_rev}} ->
{some,
{pending_query,
Name,
Function_name,
Command,
Start_line,
[Line | Body_rev]}};
none ->
none
end,
parse_lines(
Naming_ctx,
Rest,
Path,
Engine,
Line_number + 1,
Pending@1,
Parsed_rev,
Skip_next
);
{error, Error} ->
{error, Error}
end
end
end.
-file("src/sqlode/query_parser.gleam", 27).
-spec parse_file(
binary(),
sqlode@model:engine(),
sqlode@naming:naming_context(),
binary()
) -> {ok, list(sqlode@model:parsed_query())} | {error, parse_error()}.
parse_file(Path, Engine, Naming_ctx, Content) ->
_pipe = parse_lines(
Naming_ctx,
gleam@string:split(Content, <<"\n"/utf8>>),
Path,
Engine,
1,
none,
[],
false
),
gleam@result:map(_pipe, fun lists:reverse/1).