-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore: initial projection query DSL implementation
- Loading branch information
Adriano Santos
committed
Dec 11, 2024
1 parent
fa49665
commit 75e7fa9
Showing
3 changed files
with
227 additions
and
0 deletions.
There are no files selected for viewing
78 changes: 78 additions & 0 deletions
78
spawn_statestores/statestores/lib/statestores/projection/query/parser.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
defmodule Statestores.Projection.Query.Parser do | ||
@moduledoc """ | ||
Query Parser module | ||
""" | ||
|
||
def parse(dsl) do | ||
[select_part, rest] = String.split(dsl, " where ", parts: 2) | ||
|
||
select_clause = parse_select(select_part) | ||
|
||
{where_part, order_by_part} = | ||
case String.split(rest || "", " order by ", parts: 2) do | ||
[where] -> {where, nil} | ||
[where, order] -> {where, order} | ||
_ -> {nil, nil} | ||
end | ||
|
||
conditions = | ||
if where_part do | ||
where_part | ||
|> String.split(" and ") | ||
|> Enum.map(&parse_condition/1) | ||
else | ||
[] | ||
end | ||
|
||
order_by = | ||
if order_by_part do | ||
order_by_part | ||
|> String.split(", ") | ||
|> Enum.map(&parse_order/1) | ||
else | ||
[] | ||
end | ||
|
||
{select_clause, conditions, order_by} | ||
end | ||
|
||
defp parse_select(select_part) do | ||
select_part | ||
|> String.replace("select ", "") | ||
|> String.split(", ") | ||
|> Enum.map(&parse_column_or_function/1) | ||
end | ||
|
||
defp parse_column_or_function(column) do | ||
case String.split(column, "(") do | ||
[func, args] -> | ||
{String.to_atom(func), String.trim_trailing(args, ")")} | ||
_ -> | ||
{:column, column} | ||
end | ||
end | ||
|
||
defp parse_condition(condition) do | ||
cond do | ||
String.contains?(condition, " = ") -> | ||
[key, value] = String.split(condition, " = ") | ||
{key, parse_value(value)} | ||
|
||
true -> | ||
raise ArgumentError, "Invalid condition format: #{condition}" | ||
end | ||
end | ||
|
||
defp parse_value(value) do | ||
if String.starts_with?(value, ":") do | ||
{:bind, String.trim_leading(value, ":")} | ||
else | ||
{:literal, value} | ||
end | ||
end | ||
|
||
defp parse_order(order) do | ||
[field, direction] = String.split(order, " ") | ||
{field, direction} | ||
end | ||
end |
59 changes: 59 additions & 0 deletions
59
spawn_statestores/statestores/lib/statestores/projection/query/query_builder.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
defmodule Statestores.Projection.Query.QueryBuilder do | ||
@moduledoc """ | ||
QueryBuilder module | ||
""" | ||
def build_query(select_clause, conditions, order_by, binds) do | ||
select = | ||
select_clause | ||
|> Enum.map(&format_select_part/1) | ||
|> Enum.join(", ") | ||
|
||
where_clauses = | ||
conditions | ||
|> Enum.map(fn {key, value} -> format_condition(key, value) end) | ||
|> Enum.join(" AND ") | ||
|
||
order_by_clause = | ||
order_by | ||
|> Enum.map(fn {field, direction} -> "tags->>'#{field}' #{direction}" end) | ||
|> Enum.join(", ") | ||
|
||
# Gerar query básica | ||
query = """ | ||
SELECT #{select} | ||
FROM projections | ||
WHERE #{where_clauses} | ||
""" | ||
|
||
query = if order_by_clause != "", do: "#{query} ORDER BY #{order_by_clause}", else: query | ||
|
||
# Substituir parâmetros nos binds | ||
{query, params} = | ||
Enum.reduce(conditions, {query, []}, fn {_key, value}, {q, params} -> | ||
case value do | ||
{:bind, bind_name} -> | ||
{q, params ++ [{bind_name, Map.get(binds, bind_name)}]} | ||
|
||
_ -> {q, params} | ||
end | ||
end) | ||
|
||
{query, params} | ||
end | ||
|
||
defp format_select_part({:column, field}), do: "tags->>'#{field}' AS #{field}" | ||
defp format_select_part({:count, _}), do: "COUNT(*)" | ||
defp format_select_part({:sum, field}), do: "SUM(tags->>'#{field}'::numeric)" | ||
defp format_select_part({:avg, field}), do: "AVG(tags->>'#{field}'::numeric)" | ||
defp format_select_part({:max, field}), do: "MAX(tags->>'#{field}'::timestamp)" | ||
defp format_select_part({:min, field}), do: "MIN(tags->>'#{field}'::timestamp)" | ||
|
||
defp format_condition(key, {:bind, bind_name}), do: "(tags->>'#{key}') = :#{bind_name}" | ||
defp format_condition(key, {:literal, value}) do | ||
if String.match?(value, ~r/^true|false$/) do | ||
"(tags->>'#{key}')::boolean = #{value}" | ||
else | ||
"tags->>'#{key}' = '#{value}'" | ||
end | ||
end | ||
end |
90 changes: 90 additions & 0 deletions
90
spawn_statestores/statestores/lib/statestores/projection/query_executor.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
defmodule Statestores.Projection.QueryExecutor do | ||
@moduledoc """ | ||
Query Executor module for executing queries written in a custom DSL. | ||
## Usage Examples | ||
### Example 1: Basic Query with Literal Values | ||
```elixir | ||
dsl = "select name, count(*) where name = 'John' and active = true order by created_at desc" | ||
Statestores.Projection.QueryExecutor.execute(MyApp.Repo, dsl) | ||
``` | ||
**Generated SQL Query:** | ||
```sql | ||
SELECT tags->>'name' AS name, COUNT(*) | ||
FROM projections | ||
WHERE (tags->>'name') = 'John' AND (tags->>'active')::boolean = true | ||
ORDER BY tags->>'created_at' DESC; | ||
``` | ||
**Parameters Substituted:** | ||
No parameters, as all values are literals. | ||
--- | ||
### Example 2: Query with Binds | ||
```elixir | ||
dsl = "select name, count(*) where name = :name and active = :active order by created_at desc" | ||
binds = %{"name" => "Jane", "active" => true} | ||
Statestores.Projection.QueryExecutor.execute(MyApp.Repo, dsl, binds) | ||
``` | ||
**Generated SQL Query:** | ||
```sql | ||
SELECT tags->>'name' AS name, COUNT(*) | ||
FROM projections | ||
WHERE (tags->>'name') = $1 AND (tags->>'active')::boolean = $2 | ||
ORDER BY tags->>'created_at' DESC; | ||
``` | ||
**Parameters Substituted:** | ||
```plaintext | ||
$1 = "Jane" | ||
$2 = true | ||
``` | ||
--- | ||
### Example 3: Query with Aggregations | ||
```elixir | ||
dsl = "select avg(age), max(score) where active = true" | ||
Statestores.Projection.QueryExecutor.execute(MyApp.Repo, dsl) | ||
``` | ||
**Generated SQL Query:** | ||
```sql | ||
SELECT AVG(tags->>'age'::numeric), MAX(tags->>'score'::numeric) | ||
FROM projections | ||
WHERE (tags->>'active')::boolean = true; | ||
``` | ||
**Parameters Substituted:** | ||
No parameters, as all values are literals. | ||
""" | ||
|
||
import Ecto.Query | ||
|
||
alias Statestores.Projection.Query.QueryBuilder | ||
alias Statestores.Projection.Query.Parser, as: DSLParser | ||
|
||
def execute(repo, dsl, binds \\ %{}) do | ||
{select_clause, conditions, order_by} = DSLParser.parse(dsl) | ||
{query, params} = QueryBuilder.build_query(select_clause, conditions, order_by, binds) | ||
|
||
Ecto.Adapters.SQL.query!(repo, query, Enum.map(params, &elem(&1, 1))) | ||
end | ||
end |