Packages

Start with everything. Keep what performs. Pre-aggregate what matters. | Inline Cubes with Ecto.Schema

Current section

Files

Jump to
power_of_3 lib power_of_three query_builder.ex
Raw

lib/power_of_three/query_builder.ex

defmodule PowerOfThree.QueryBuilder do
@moduledoc """
Builds Cube SQL queries from MeasureRef and DimensionRef structs.
## Examples
# Build a simple query
query = QueryBuilder.build(
cube: "customer",
columns: [
%DimensionRef{name: :email, ...},
%MeasureRef{name: :count, ...}
]
)
# => "SELECT customer.email, MEASURE(customer.count) FROM customer GROUP BY 1"
# Build with filters and ordering
query = QueryBuilder.build(
cube: "customer",
columns: [dimension_ref, measure_ref],
where: "brand_code = 'NIKE'",
order_by: [{1, :asc}],
limit: 10
)
"""
alias PowerOfThree.{MeasureRef, DimensionRef}
@type column_ref :: MeasureRef.t() | DimensionRef.t()
@type order_direction :: :asc | :desc
@type order_spec :: {pos_integer(), order_direction()} | pos_integer()
@type build_opts :: [
cube: String.t() | atom(),
columns: [column_ref()],
where: String.t() | nil,
order_by: [order_spec()] | nil,
limit: pos_integer() | nil,
offset: non_neg_integer() | nil
]
@doc """
Builds a Cube SQL query from column references and options.
## Options
* `:cube` - Required. The cube name (string or atom)
* `:columns` - Required. List of MeasureRef and/or DimensionRef structs
* `:where` - Optional. SQL WHERE clause (without "WHERE" keyword)
* `:order_by` - Optional. List of {column_index, :asc | :desc} or just column_index
* `:limit` - Optional. Maximum number of rows to return
* `:offset` - Optional. Number of rows to skip
## Examples
QueryBuilder.build(
cube: "customer",
columns: [
%DimensionRef{name: :brand, module: Customer, type: :string, sql: "brand_code"},
%MeasureRef{name: :count, module: Customer, type: :count}
]
)
# => "SELECT customer.brand, MEASURE(customer.count) FROM customer GROUP BY 1"
QueryBuilder.build(
cube: :customer,
columns: [dimension, measure],
where: "brand_code = 'NIKE'",
order_by: [{2, :desc}],
limit: 10,
offset: 5
)
"""
@spec build(build_opts()) :: String.t()
def build(opts) do
cube = Keyword.fetch!(opts, :cube) |> to_string()
columns = Keyword.fetch!(opts, :columns)
where = Keyword.get(opts, :where)
order_by = Keyword.get(opts, :order_by)
limit = Keyword.get(opts, :limit)
offset = Keyword.get(opts, :offset)
validate_columns!(columns)
select_clause = build_select_clause(cube, columns)
from_clause = "FROM #{cube}"
group_by_clause = build_group_by_clause(columns)
where_clause = if where, do: "WHERE #{where}", else: nil
order_by_clause = if order_by, do: build_order_by_clause(order_by), else: nil
limit_clause = if limit, do: "LIMIT #{limit}", else: nil
offset_clause = if offset, do: "OFFSET #{offset}", else: nil
[
select_clause,
from_clause,
group_by_clause,
where_clause,
order_by_clause,
limit_clause,
offset_clause
]
|> Enum.reject(&is_nil/1)
|> Enum.join("\n")
end
@doc """
Validates that all columns are either MeasureRef or DimensionRef structs.
Raises ArgumentError if validation fails.
"""
@spec validate_columns!([column_ref()]) :: :ok
def validate_columns!([]), do: raise(ArgumentError, "columns cannot be empty")
def validate_columns!(columns) when is_list(columns) do
Enum.each(columns, fn col ->
unless match?(%MeasureRef{}, col) or match?(%DimensionRef{}, col) do
raise ArgumentError,
"Expected MeasureRef or DimensionRef, got: #{inspect(col)}"
end
end)
:ok
end
def validate_columns!(_), do: raise(ArgumentError, "columns must be a list")
@doc """
Builds the SELECT clause with dimension and measure references.
## Examples
iex> build_select_clause("customer", [dimension, measure])
"SELECT customer.email, MEASURE(customer.count)"
"""
@spec build_select_clause(String.t(), [column_ref()]) :: String.t()
def build_select_clause(cube, columns) do
select_items =
Enum.map(columns, fn
%DimensionRef{name: name} ->
"#{cube}.#{name}"
%MeasureRef{name: name} ->
"MEASURE(#{cube}.#{name})"
end)
"SELECT " <> Enum.join(select_items, ", ")
end
@doc """
Builds the GROUP BY clause with column indices.
Only includes dimensions (measures are aggregated).
## Examples
iex> build_group_by_clause([dimension1, measure1, dimension2])
"GROUP BY 1, 3"
"""
@spec build_group_by_clause([column_ref()]) :: String.t() | nil
def build_group_by_clause(columns) do
dimension_indices =
columns
|> Enum.with_index(1)
|> Enum.filter(fn {col, _idx} -> match?(%DimensionRef{}, col) end)
|> Enum.map(fn {_col, idx} -> idx end)
case dimension_indices do
[] -> nil
indices -> "GROUP BY " <> Enum.join(indices, ", ")
end
end
@doc """
Builds the ORDER BY clause from order specifications.
## Examples
iex> build_order_by_clause([{1, :asc}, {2, :desc}])
"ORDER BY 1 ASC, 2 DESC"
iex> build_order_by_clause([1, 2])
"ORDER BY 1, 2"
"""
@spec build_order_by_clause([order_spec()]) :: String.t()
def build_order_by_clause(order_specs) do
order_items =
Enum.map(order_specs, fn
{index, :asc} -> "#{index} ASC"
{index, :desc} -> "#{index} DESC"
index when is_integer(index) -> "#{index}"
end)
"ORDER BY " <> Enum.join(order_items, ", ")
end
@doc """
Extracts the cube name from a list of column references.
All columns must belong to the same cube (same module).
## Examples
iex> extract_cube_name([
...> %DimensionRef{module: Customer, ...},
...> %MeasureRef{module: Customer, ...}
...> ])
"customer"
"""
@spec extract_cube_name([column_ref()]) :: String.t()
def extract_cube_name([]), do: raise(ArgumentError, "columns cannot be empty")
def extract_cube_name(columns) do
[first | rest] = columns
first_module = get_module(first)
first_cube = extract_module_cube_name(first_module)
# Validate all columns are from the same cube
Enum.each(rest, fn col ->
col_module = get_module(col)
col_cube = extract_module_cube_name(col_module)
if col_cube != first_cube do
raise ArgumentError,
"All columns must be from the same cube. Found #{first_cube} and #{col_cube}"
end
end)
first_cube
end
defp get_module(%MeasureRef{module: module}), do: module
defp get_module(%DimensionRef{module: module}), do: module
defp extract_module_cube_name(module) do
module.__schema__(:source)
end
end