Skip to main content

lib/quack_db/ecto.ex

if Code.ensure_loaded?(Ecto.Query) do
  defmodule QuackDB.Ecto do
    @moduledoc """
    Convenience imports for Ecto-based QuackDB query modules.

    Use this in modules that build DuckDB analytical, spatial, series, or
    full-text search Ecto queries and want the standard Ecto query DSL together with
    QuackDB's Ecto helper macros:

        defmodule MyApp.Analytics do
          use QuackDB.Ecto

          def category_scores do
            from event in "events",
              group_by: event.category,
              select: %{
                category: event.category,
                median_score: median(event.score),
                fts_score: search_score("fts_main_events", event.id, ^"duckdb")
              }
          end
        end

    The macro imports:

    - `Ecto.Query`.
    - `QuackDB.Ecto.Analytics`.
    - `QuackDB.Ecto.Spatial`.
    - `QuackDB.Ecto.FTS`.
    - `QuackDB.Ecto.Regex`.
    - `QuackDB.Ecto.Text`.
    - `QuackDB.Ecto.List`.
    - `QuackDB.Ecto.Map`.
    - `QuackDB.Ecto.Struct`.
    - `QuackDB.Ecto.Series`.
    - `QuackDB.Ecto.Star`.
    - `QuackDB.Ecto.WindowFrames`.

    `QuackDB.Ecto.Conditionals.case_when/1` is also imported for multi-branch
    DuckDB `CASE WHEN` expressions using Elixir clause syntax.

    Imports can be disabled individually. When spatial and text helpers are both
    enabled, shared `contains/2` dispatches obvious text calls to DuckDB
    `contains` and spatial helper calls to `ST_Contains`. Ambiguous calls raise;
    use `contains_text/2` or `st_contains/2` when intent is not obvious.

        use QuackDB.Ecto, spatial: false
        use QuackDB.Ecto, full_text_search: false
        use QuackDB.Ecto, analytics: false
        use QuackDB.Ecto, regex: false
        use QuackDB.Ecto, text: false
        use QuackDB.Ecto, list: false
        use QuackDB.Ecto, map: false
        use QuackDB.Ecto, struct: false
        use QuackDB.Ecto, series: false
        use QuackDB.Ecto, star: false
        use QuackDB.Ecto, window_frames: false
        use QuackDB.Ecto, query: false
    """

    @doc """
    Returns the sequence name QuackDB migrations use for a serial table column.

        QuackDB.Ecto.column_sequence_name("fragments", :id)
        #=> "fragments_id_seq"

        QuackDB.Ecto.column_sequence_name({"main", "fragments"}, :id)
        #=> "main_fragments_id_seq"

    Schema modules and `{source, schema}` tuples use Ecto field-source metadata:

        QuackDB.Ecto.column_sequence_name(FragmentRecord, :id)
        QuackDB.Ecto.column_sequence_name({"tenant_fragments", FragmentRecord}, :id)
    """
    @spec column_sequence_name(
            module()
            | atom()
            | String.t()
            | {atom() | String.t(), atom() | String.t()}
            | {atom() | String.t(), module()},
            atom() | String.t()
          ) :: String.t()
    def column_sequence_name({source, schema}, field) when is_atom(schema) do
      if ecto_schema?(schema) do
        source = to_string(source)
        field = schema_field_source(schema, field)

        case schema_prefix(schema) do
          nil -> sequence_name([source, field])
          prefix -> sequence_name([prefix, source, field])
        end
      else
        sequence_name([source, schema, field])
      end
    end

    def column_sequence_name(schema, field) when is_atom(schema) do
      if ecto_schema?(schema) do
        source = schema.__schema__(:source)
        field = schema_field_source(schema, field)

        case schema_prefix(schema) do
          nil -> sequence_name([source, field])
          prefix -> sequence_name([prefix, source, field])
        end
      else
        sequence_name([schema, field])
      end
    end

    def column_sequence_name({prefix, table}, field) do
      sequence_name([prefix, table, field])
    end

    def column_sequence_name(table, field) do
      sequence_name([table, field])
    end

    defp sequence_name(parts),
      do: parts |> Enum.concat(["seq"]) |> Enum.map_join("_", &to_string/1)

    defp ecto_schema?(schema) do
      Code.ensure_loaded?(schema) and function_exported?(schema, :__schema__, 1)
    end

    defp schema_field_source(schema, field) when is_atom(field) do
      schema.__schema__(:field_source, field)
    rescue
      FunctionClauseError -> field
    end

    defp schema_field_source(_schema, field), do: field

    defp schema_prefix(schema) do
      schema.__schema__(:prefix)
    rescue
      FunctionClauseError -> nil
    end

    @doc false
    defmacro __using__(options) do
      enabled? = fn key -> Keyword.get(options, key, true) end
      shared_predicates? = enabled?.(:predicates) and enabled?.(:spatial) and enabled?.(:text)

      imports =
        [
          maybe_import(enabled?.(:query), Ecto.Query),
          maybe_import(enabled?.(:analytics), QuackDB.Ecto.Analytics),
          spatial_import(enabled?.(:spatial), shared_predicates?),
          maybe_import(enabled?.(:full_text_search), QuackDB.Ecto.FTS),
          maybe_import(enabled?.(:series), QuackDB.Ecto.Series),
          maybe_import(enabled?.(:star), QuackDB.Ecto.Star),
          maybe_import(enabled?.(:list), QuackDB.Ecto.List, except: [contains: 2]),
          maybe_import(enabled?.(:map), QuackDB.Ecto.Map,
            except: [contains: 2, extract: 2, values: 1, concat: 2]
          ),
          maybe_import(enabled?.(:struct), QuackDB.Ecto.Struct,
            except: [contains: 2, extract: 2, values: 1, position: 2, concat: 2]
          ),
          maybe_import(enabled?.(:window_frames), QuackDB.Ecto.WindowFrames),
          maybe_import(enabled?.(:regex), QuackDB.Ecto.Regex),
          text_import(enabled?.(:text), shared_predicates?),
          maybe_import(shared_predicates?, QuackDB.Ecto.Predicates),
          maybe_import(enabled?.(:conditionals), QuackDB.Ecto.Conditionals)
        ]
        |> Enum.reject(&is_nil/1)

      quote do
        (unquote_splicing(imports))
      end
    end

    defp spatial_import(false, _shared_predicates?), do: nil

    defp spatial_import(true, true),
      do: import_quoted(QuackDB.Ecto.Spatial, except: [contains: 2])

    defp spatial_import(true, false), do: import_quoted(QuackDB.Ecto.Spatial)

    defp text_import(false, _shared_predicates?), do: nil
    defp text_import(true, true), do: import_quoted(QuackDB.Ecto.Text, except: [contains: 2])
    defp text_import(true, false), do: import_quoted(QuackDB.Ecto.Text)

    defp maybe_import(enabled?, module, options \\ [])
    defp maybe_import(false, _module, _options), do: nil
    defp maybe_import(true, module, options), do: import_quoted(module, options)

    defp import_quoted(module, options \\ [])
    defp import_quoted(module, []), do: quote(do: import(unquote(module)))

    defp import_quoted(module, options),
      do: quote(do: import(unquote(module), unquote(options)))
  end
end