Current section
Files
Jump to
Current section
Files
lib/power_of_three/cube_query_translator.ex
defmodule PowerOfThree.CubeQueryTranslator do
@moduledoc """
Translates PowerOfThree query options to Cube Query JSON format.
Converts from the QueryBuilder-style options (SQL-oriented) to the
Cube REST API JSON query format.
## Translation Examples
# Input (QueryBuilder options):
[
cube: "customer",
columns: [
%DimensionRef{name: :brand, module: Customer},
%MeasureRef{name: :count, module: Customer}
],
where: "brand_code = 'NIKE'",
order_by: [{2, :desc}],
limit: 10,
offset: 5
]
# Output (Cube Query JSON):
%{
"dimensions" => ["of_customers.brand"],
"measures" => ["of_customers.count"],
"filters" => [
%{"member" => "of_customers.brand_code", "operator" => "equals", "values" => ["NIKE"]}
],
"order" => [["of_customers.count", "desc"]],
"limit" => 10,
"offset" => 5
}
## Limitations
Phase 1 supports simple WHERE clauses with basic operators:
- `=` (equals)
- `!=` (notEquals)
- `>`, `>=`, `<`, `<=` (comparison operators)
- `IN (...)` (set membership)
Complex WHERE clauses with multiple conditions or subqueries are not
supported and will return an error. For complex queries, use ADBC instead.
"""
alias PowerOfThree.{DimensionRef, MeasureRef, QueryError}
@doc """
Translates QueryBuilder options to Cube Query JSON format.
## Parameters
- `opts` - Keyword list with query options
## Required Options
- `:columns` - List of DimensionRef and MeasureRef structs
## Optional Options
- `:where` - SQL WHERE clause (simple expressions only)
- `:order_by` - List of `{column_index, direction}` tuples
- `:limit` - Maximum number of rows
- `:offset` - Number of rows to skip
## Returns
- `{:ok, cube_query}` - Map in Cube Query JSON format
- `{:error, %QueryError{}}` - Translation error
## Examples
iex> opts = [
...> columns: [
...> %DimensionRef{name: :brand, module: Customer},
...> %MeasureRef{name: :count, module: Customer}
...> ],
...> where: "brand_code = 'NIKE'",
...> limit: 10
...> ]
iex> PowerOfThree.CubeQueryTranslator.to_cube_query(opts)
{:ok, %{
"dimensions" => ["of_customers.brand"],
"measures" => ["of_customers.count"],
"filters" => [...],
"limit" => 10
}}
"""
def to_cube_query(opts) do
with {:ok, columns} <- get_required_option(opts, :columns),
{:ok, {dimensions, measures}} <- extract_dimensions_and_measures(columns),
{:ok, filters} <- parse_where_clause(Keyword.get(opts, :where), columns),
{:ok, order} <- translate_order_by(Keyword.get(opts, :order_by), columns) do
cube_query =
%{
"dimensions" => dimensions,
"measures" => measures
}
|> maybe_add_filters(filters)
|> maybe_add_order(order)
|> maybe_add_limit(Keyword.get(opts, :limit))
|> maybe_add_offset(Keyword.get(opts, :offset))
{:ok, cube_query}
end
end
# Gets a required option or returns error
defp get_required_option(opts, key) do
case Keyword.fetch(opts, key) do
{:ok, value} -> {:ok, value}
:error -> {:error, QueryError.translation_error("Missing required option: #{key}")}
end
end
# Extracts dimensions and measures from columns list
defp extract_dimensions_and_measures(columns) do
dimensions =
columns
|> Enum.filter(&match?(%DimensionRef{}, &1))
|> Enum.map(&dimension_to_cube_name/1)
measures =
columns
|> Enum.filter(&match?(%MeasureRef{}, &1))
|> Enum.map(&measure_to_cube_name/1)
{:ok, {dimensions, measures}}
rescue
error ->
{:error, QueryError.translation_error("Failed to extract columns: #{inspect(error)}")}
end
@doc """
Converts a DimensionRef to Cube dimension name format.
## Examples
iex> dim = %DimensionRef{name: :brand, module: Customer}
iex> PowerOfThree.CubeQueryTranslator.dimension_to_cube_name(dim)
"of_customers.brand"
"""
def dimension_to_cube_name(%DimensionRef{name: name, module: module}) do
cube_name = extract_cube_name(module)
"#{cube_name}.#{name}"
end
@doc """
Converts a MeasureRef to Cube measure name format.
## Examples
iex> measure = %MeasureRef{name: :count, module: Customer}
iex> PowerOfThree.CubeQueryTranslator.measure_to_cube_name(measure)
"of_customers.count"
"""
def measure_to_cube_name(%MeasureRef{name: name, module: module}) do
cube_name = extract_cube_name(module)
"#{cube_name}.#{name}"
end
# Extracts cube name from module schema
# E.g., Customer module with source "customer" → "of_customers"
defp extract_cube_name(module) do
module.__info__(:attributes)[:cube_config]
|> List.first()
|> Map.get(:name)
|> to_string()
end
# Parses SQL WHERE clause to Cube filters
defp parse_where_clause(nil, _columns), do: {:ok, []}
defp parse_where_clause("", _columns), do: {:ok, []}
defp parse_where_clause(where_sql, columns) when is_binary(where_sql) do
# Simple WHERE clause parser for common patterns
# Supports: field = 'value', field != 'value', field > value, field IN (...)
where_sql = String.trim(where_sql)
cond do
# Pattern: field = 'value' or field = value
Regex.match?(~r/^(\w+)\s*=\s*'([^']+)'$/, where_sql) ->
parse_equals_filter(where_sql, columns)
Regex.match?(~r/^(\w+)\s*=\s*(\d+)$/, where_sql) ->
parse_equals_filter(where_sql, columns)
# Pattern: field != 'value'
Regex.match?(~r/^(\w+)\s*!=\s*'([^']+)'$/, where_sql) ->
parse_not_equals_filter(where_sql, columns)
# Pattern: field > value, field >= value, etc.
Regex.match?(~r/^(\w+)\s*(>|>=|<|<=)\s*(\d+)$/, where_sql) ->
parse_comparison_filter(where_sql, columns)
# Pattern: field IN ('a', 'b', 'c')
Regex.match?(~r/^(\w+)\s+IN\s*\(/i, where_sql) ->
parse_in_filter(where_sql, columns)
true ->
{:error,
QueryError.translation_error(
"Complex WHERE clause not supported in HTTP mode. " <>
"Use ADBC or structured filters. WHERE: #{where_sql}"
)}
end
end
# Parses "field = 'value'" pattern
defp parse_equals_filter(where_sql, columns) do
case Regex.run(~r/^(\w+)\s*=\s*'([^']+)'$/, where_sql) do
[_, field, value] ->
member = field_to_cube_member(field, columns)
{:ok, [%{"member" => member, "operator" => "equals", "values" => [value]}]}
nil ->
# Try numeric value
case Regex.run(~r/^(\w+)\s*=\s*(\d+)$/, where_sql) do
[_, field, value] ->
member = field_to_cube_member(field, columns)
{:ok, [%{"member" => member, "operator" => "equals", "values" => [value]}]}
nil ->
{:error, QueryError.translation_error("Failed to parse WHERE clause: #{where_sql}")}
end
end
end
# Parses "field != 'value'" pattern
defp parse_not_equals_filter(where_sql, _columns) do
case Regex.run(~r/^(\w+)\s*!=\s*'([^']+)'$/, where_sql) do
[_, field, value] ->
{:ok, [%{"member" => field, "operator" => "notEquals", "values" => [value]}]}
nil ->
{:error, QueryError.translation_error("Failed to parse WHERE clause: #{where_sql}")}
end
end
# Parses "field > value" patterns
defp parse_comparison_filter(where_sql, _columns) do
case Regex.run(~r/^(\w+)\s*(>|>=|<|<=)\s*(\d+)$/, where_sql) do
[_, field, operator, value] ->
cube_operator =
case operator do
">" -> "gt"
">=" -> "gte"
"<" -> "lt"
"<=" -> "lte"
end
{:ok, [%{"member" => field, "operator" => cube_operator, "values" => [value]}]}
nil ->
{:error, QueryError.translation_error("Failed to parse WHERE clause: #{where_sql}")}
end
end
# Parses "field IN ('a', 'b', 'c')" pattern
defp parse_in_filter(where_sql, _columns) do
case Regex.run(~r/^(\w+)\s+IN\s*\(([^)]+)\)/i, where_sql) do
[_, field, values_str] ->
values =
values_str
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.map(&String.trim(&1, "'\""))
{:ok, [%{"member" => field, "operator" => "set", "values" => values}]}
nil ->
{:error, QueryError.translation_error("Failed to parse WHERE clause: #{where_sql}")}
end
end
# Converts a field name to Cube member format
# Tries to find matching dimension/measure in columns list by SQL field name
defp field_to_cube_member(field, columns) do
# First, try to find a dimension/measure that uses this SQL field
found =
Enum.find(columns, fn
%DimensionRef{sql: ^field} -> true
%DimensionRef{meta: %{ecto_field: ecto_field}} -> to_string(ecto_field) == field
%MeasureRef{sql: sql} when is_binary(sql) -> sql == field
%MeasureRef{meta: %{ecto_field: ecto_field}} -> to_string(ecto_field) == field
_ -> false
end)
case found do
%DimensionRef{} = dim ->
dimension_to_cube_name(dim)
%MeasureRef{} = measure ->
measure_to_cube_name(measure)
nil ->
# If not found, try to construct cube member from first column's cube name
case List.first(columns) do
%DimensionRef{module: module} ->
cube_name = extract_cube_name(module)
"#{cube_name}.#{field}"
%MeasureRef{module: module} ->
cube_name = extract_cube_name(module)
"#{cube_name}.#{field}"
_ ->
field
end
end
end
# Translates ORDER BY from column indices to field names
defp translate_order_by(nil, _columns), do: {:ok, []}
defp translate_order_by([], _columns), do: {:ok, []}
defp translate_order_by(order_specs, columns) when is_list(order_specs) do
order =
Enum.map(order_specs, fn
{index, direction} when is_integer(index) ->
column = Enum.at(columns, index - 1)
field_name = column_to_cube_name(column)
[field_name, to_string(direction)]
index when is_integer(index) ->
column = Enum.at(columns, index - 1)
field_name = column_to_cube_name(column)
[field_name, "asc"]
end)
{:ok, order}
rescue
error ->
{:error, QueryError.translation_error("Failed to translate ORDER BY: #{inspect(error)}")}
end
# Converts a column ref to Cube name
defp column_to_cube_name(%DimensionRef{} = dim), do: dimension_to_cube_name(dim)
defp column_to_cube_name(%MeasureRef{} = measure), do: measure_to_cube_name(measure)
# Helper functions to conditionally add query parts
defp maybe_add_filters(query, []), do: query
defp maybe_add_filters(query, filters), do: Map.put(query, "filters", filters)
defp maybe_add_order(query, []), do: query
defp maybe_add_order(query, order), do: Map.put(query, "order", order)
defp maybe_add_limit(query, nil), do: query
defp maybe_add_limit(query, limit), do: Map.put(query, "limit", limit)
defp maybe_add_offset(query, nil), do: query
defp maybe_add_offset(query, offset), do: Map.put(query, "offset", offset)
end