Current section

Files

Jump to
ash_scylla lib ash_scylla data_layer query_builder.ex
Raw

lib/ash_scylla/data_layer/query_builder.ex

# Copyright [2024] AshScylla Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule AshScylla.DataLayer.QueryBuilder do
@moduledoc """
Query building functions for AshScylla data layer.
Provides optimized query building with filter-to-CQL conversion,
prepared statement support, and token-based pagination.
## Secondary Index Support
When filtering on non-primary key columns, this module checks if a
secondary index exists and generates appropriate CQL. ScyllaDB/Cassandra
can use secondary indexes for equality checks (=) but not for range queries.
"""
alias AshScylla.DataLayer
alias AshScylla.DataLayer.Dsl
@doc """
Builds an optimized CQL query from the data layer query struct.
If filters are on columns with secondary indexes, the query will
use those indexes automatically.
"""
@spec build_optimized_query(DataLayer.t()) :: {String.t(), list()}
def build_optimized_query(%DataLayer{
table: table,
filters: filters,
sorts: sorts,
limit: limit,
offset: offset,
select: select
}) do
# Build SELECT clause
select_clause =
case select do
nil -> "*"
[] -> "*"
columns -> Enum.map_join(columns, ", ", &"#{&1}")
end
base_query = "SELECT #{select_clause} FROM #{table}"
# Build WHERE clause from filters
{where_clause, params} = build_where_clause(filters)
query =
if String.length(where_clause) > 0 do
"#{base_query} WHERE #{where_clause}"
else
base_query
end
# Build ORDER BY clause
{order_clause, order_params} = build_order_by(sorts)
query =
if String.length(order_clause) > 0 do
"#{query} ORDER BY #{order_clause}"
else
query
end
params = params ++ order_params
# Add LIMIT
{query, params} =
if limit do
{"#{query} LIMIT ?", params ++ [limit]}
else
{query, params}
end
# Add OFFSET (note: OFFSET in CQL requires special handling)
if offset do
# ScyllaDB/Cassandra doesn't support OFFSET natively
# This would need to be handled differently (pagination with tokens)
{query, params}
else
{query, params}
end
end
@doc """
Builds WHERE clause from Ash filters.
"""
@spec build_where_clause(list()) :: {String.t(), list()}
def build_where_clause(filters) do
case filters do
[] ->
{"", []}
_ ->
{clause, params} =
filters
|> Enum.map(&filter_to_cql/1)
|> Enum.reduce({"", []}, fn {c, p}, {acc_c, acc_p} ->
joined =
if acc_c == "" do
c
else
"#{acc_c} AND #{c}"
end
{joined, acc_p ++ p}
end)
{clause, params}
end
end
@doc """
Builds ORDER BY clause from sort items.
"""
@spec build_order_by(list()) :: {String.t(), list()}
def build_order_by(sorts) do
case sorts do
[] ->
{"", []}
_ ->
clauses =
Enum.map(sorts, fn sort_item ->
field = Map.get(sort_item, :field)
direction = Map.get(sort_item, :direction, :asc)
"#{field} #{direction}"
end)
{Enum.join(clauses, ", "), []}
end
end
@doc """
Checks if a filter can use secondary indexes.
Returns `{:ok, indexed_columns}` if the filter can use indexes,
or `{:error, reason}` if it cannot.
"""
@spec can_use_secondary_index?(term(), list()) :: {:ok, list()} | {:error, term()}
def can_use_secondary_index?(resource, filters) do
indexed_columns =
resource
|> Dsl.secondary_indexes()
|> Enum.flat_map(fn idx -> idx.columns end)
|> MapSet.new()
filter_columns = get_filter_columns(filters)
# Check if all filter columns have secondary indexes
# Note: Secondary indexes work best with equality checks
case filter_columns do
[] ->
{:error, :no_filters}
cols ->
all_indexed = cols |> Enum.all?(fn col -> MapSet.member?(indexed_columns, col) end)
if all_indexed do
{:ok, cols}
else
non_indexed = cols |> Enum.reject(fn col -> MapSet.member?(indexed_columns, col) end)
{:error, {:missing_indexes, non_indexed}}
end
end
end
defp get_filter_columns(filters) do
filters
|> Enum.flat_map(&extract_filter_columns/1)
|> Enum.uniq()
end
defp extract_filter_columns(%{left: %{name: name}}) when not is_nil(name), do: [name]
defp extract_filter_columns(%{expression: expr}), do: get_filter_columns([expr])
defp extract_filter_columns(%{left: left, right: right}), do: get_filter_columns([left, right])
defp extract_filter_columns(_), do: []
@doc """
Converts Ash filter expressions to CQL.
Note: For secondary indexes to be used, filters should be equality checks
on indexed columns. Range queries on secondary indexes are not recommended.
"""
@spec filter_to_cql(map()) :: {String.t(), list()}
def filter_to_cql(%{expression: expression}) do
filter_to_cql(expression)
end
def filter_to_cql(%{op: op, left: left, right: right}) do
{left_cql, left_params} = filter_to_cql(left)
{right_cql, right_params} = filter_to_cql(right)
case op do
:and ->
{"(#{left_cql}) AND (#{right_cql})", left_params ++ right_params}
:or ->
{"(#{left_cql}) OR (#{right_cql})", left_params ++ right_params}
_ ->
{"", []}
end
end
@operator_mapping %{
:eq => "=",
:not_eq => "!=",
:gt => ">",
:gte => ">=",
:lt => "<",
:lte => "<=",
:contains => "LIKE",
:starts_with => "LIKE",
:ends_with => "LIKE"
}
@operator_values %{
:contains => "?",
:starts_with => "%?",
:ends_with => "?%"
}
def filter_to_cql(%{operator: op, left: left, right: right}) do
{left_cql, left_params} = filter_to_cql(left)
case {op, right} do
{:in, %{value: values}} when is_list(values) ->
placeholders = Enum.map_join(1..length(values), ", ", fn _ -> "?" end)
{"#{left_cql} IN (#{placeholders})", left_params ++ values}
_ ->
{_right_cql, right_params} = filter_to_cql(right)
cql_op = Map.get(@operator_mapping, op, "=")
cql_val = Map.get(@operator_values, op, "?")
{"#{left_cql} #{cql_op} #{cql_val}", left_params ++ right_params}
end
end
def filter_to_cql(%{value: value}) do
{"?", [value]}
end
def filter_to_cql(%{name: name}) do
{"#{name}", []}
end
def filter_to_cql(unknown) do
raise ArgumentError, "Unknown filter expression: #{inspect(unknown)}"
end
end