Current section
Files
Jump to
Current section
Files
src/barrel_bql.erl
%% @doc BQL, barrel's PartiQL dialect: the public compile surface.
%%
%% Pipeline: lex -> parse -> canonicalize -> validate -> lower. The
%% resulting plan carries a barrel_query spec for the document scan, an
%% optional table-function source (executed by the barrel module, never
%% here), an optional unnest, and per-row post stages.
%%
%% ```
%% {ok, Plan} = barrel_bql:compile(
%% <<"SELECT title FROM db WHERE type = 'post' LIMIT 10">>),
%% {ok, Plan2} = barrel_bql:compile(
%% <<"SELECT * FROM db WHERE org = $org">>,
%% #{params => #{<<"org">> => <<"acme">>}}).
%% '''
-module(barrel_bql).
-export([
parse/1,
compile/1,
compile/2,
format_error/1
]).
-export_type([plan/0, bql_error/0]).
-type plan() :: barrel_bql_lower:plan().
-type loc() :: barrel_bql_ast:loc().
-type bql_error() ::
{parse_error, loc(), binary()}
| {invalid_query, loc() | undefined, tuple()}.
%% @doc Parse BQL source to its AST. Locations are {Line, Column}.
-spec parse(binary() | string()) ->
{ok, barrel_bql_ast:ast()} | {error, bql_error()}.
parse(Source) when is_binary(Source) ->
parse(unicode:characters_to_list(Source));
parse(Source) when is_list(Source) ->
case barrel_bql_lexer:string(Source) of
{ok, Tokens, _EndLoc} ->
case barrel_bql_parser:parse(Tokens) of
{ok, Ast} ->
{ok, Ast};
{error, {Loc, Module, Message}} ->
{error, {parse_error, Loc,
iolist_to_binary(Module:format_error(Message))}}
end;
{error, {Loc, Module, Reason}, _EndLoc} ->
{error, {parse_error, Loc,
iolist_to_binary(Module:format_error(Reason))}}
end.
%% @doc Compile BQL source (or a parsed AST) to an executable plan.
-spec compile(binary() | string() | barrel_bql_ast:ast()) ->
{ok, plan()} | {error, bql_error()}.
compile(Input) ->
compile(Input, #{}).
-spec compile(binary() | string() | barrel_bql_ast:ast(),
#{params => #{binary() => term()}}) ->
{ok, plan()} | {error, bql_error()}.
compile(Ast, Opts) when is_map(Ast) ->
Params = maps:get(params, Opts, #{}),
case barrel_bql_lower:canonicalize(Ast, Params) of
{ok, Canon} ->
case barrel_bql_lower:validate(Canon) of
ok -> barrel_bql_lower:lower(Canon);
{error, _} = Error -> Error
end;
{error, _} = Error ->
Error
end;
compile(Source, Opts) ->
case parse(Source) of
{ok, Ast} -> compile(Ast, Opts);
{error, _} = Error -> Error
end.
%% @doc One-line human rendering of a compile error, for logs and the
%% REST 400 body.
-spec format_error(bql_error()) -> binary().
format_error({parse_error, Loc, Message}) ->
iolist_to_binary([loc_prefix(Loc), "syntax error: ", Message]);
format_error({invalid_query, Loc, Reason}) ->
iolist_to_binary([loc_prefix(Loc), reason_text(Reason)]).
loc_prefix({Line, Column}) ->
io_lib:format("line ~b column ~b: ", [Line, Column]);
loc_prefix(undefined) ->
"".
reason_text({unbound_param, Name}) ->
["parameter $", Name, " is not bound"];
reason_text({invalid_param, Name}) ->
["parameter $", Name, " must be a scalar value"];
reason_text({alias_required, table_fn}) ->
"a table function source needs an alias (AS v)";
reason_text({alias_required, indexed_projection}) ->
"a projection ending in an array index needs AS";
reason_text({duplicate_alias, Alias}) ->
["alias '", Alias, "' is used twice"];
reason_text({duplicate_output_name, Name}) ->
["duplicate output column '", Name, "'; add AS"];
reason_text({reserved_field, Field}) ->
["field '", Field, "' is reserved and not queryable"];
reason_text({use_is_null, _}) ->
"comparisons with NULL never match; use IS NULL or IS MISSING";
reason_text({constant_predicate, _}) ->
"predicates must reference a document field";
reason_text({unknown_table_function, Name}) ->
["unknown table function ", Name,
" (vector_top_k, bm25_top_k, hybrid_top_k)"];
reason_text({invalid_table_fn_arg, Fn, query}) ->
["", atom_to_binary(Fn, utf8),
" needs a string query as its first argument"];
reason_text({invalid_table_fn_arg, Fn, {duplicate, Name}}) ->
["duplicate option ", Name, " for ", atom_to_binary(Fn, utf8)];
reason_text({invalid_table_fn_arg, Fn, What}) when is_binary(What) ->
["unknown option ", What, " for ", atom_to_binary(Fn, utf8)];
reason_text({invalid_table_fn_arg, Fn, What}) ->
io_lib:format("invalid ~s option for ~s", [What, Fn]);
reason_text({invalid_unnest_path, _}) ->
"UNNEST needs a document array path";
reason_text({invalid_order_key, _}) ->
"cannot ORDER BY the source itself";
reason_text({unsupported_with_subscribe, What}) ->
io_lib:format("~s is not supported with SUBSCRIBE", [What]);
reason_text({unsupported, wildcard_path, use_unnest}) ->
"wildcard [*] paths are not supported; use UNNEST";
reason_text({unsupported, id_condition}) ->
"the document id only supports =, range comparisons and "
"LIKE 'prefix%'";
reason_text({unsupported, id_in_disjunction}) ->
"document id conditions cannot appear inside OR or NOT";
reason_text({unsupported, multi_key_order_by}) ->
"ORDER BY supports a single key";
reason_text({unsupported, score_in_where}) ->
"_score and _distance can be selected and ordered by, "
"not filtered on";
reason_text({unsupported, What}) ->
io_lib:format("unsupported: ~s", [What]);
reason_text(Reason) ->
io_lib:format("~p", [Reason]).