Skip to content

Commit

Permalink
chore: initial projection query DSL implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
Adriano Santos committed Dec 11, 2024
1 parent fa49665 commit 75e7fa9
Show file tree
Hide file tree
Showing 3 changed files with 227 additions and 0 deletions.
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
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
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

0 comments on commit 75e7fa9

Please sign in to comment.