233 lines
7.1 KiB
Elixir
233 lines
7.1 KiB
Elixir
defmodule Aprsme.Packets.QueryBuilder do
|
|
@moduledoc """
|
|
Query builder functions for composing Packet queries.
|
|
Provides reusable, composable functions for common query patterns.
|
|
"""
|
|
|
|
import Ecto.Query
|
|
|
|
alias Aprsme.Packet
|
|
|
|
@doc """
|
|
Filters query by time range. Supports start_time, end_time, and hours_back options.
|
|
|
|
## Options
|
|
* `:start_time` - DateTime to filter packets after
|
|
* `:end_time` - DateTime to filter packets before
|
|
* `:hours_back` - Alternative to start_time, filters packets from X hours ago
|
|
"""
|
|
@spec with_time_range(Ecto.Query.t(), map() | keyword()) :: Ecto.Query.t()
|
|
def with_time_range(query, opts) when is_list(opts) do
|
|
with_time_range(query, Map.new(opts))
|
|
end
|
|
|
|
def with_time_range(query, opts) when is_map(opts) do
|
|
query
|
|
|> maybe_filter_start_time(opts[:start_time] || opts["start_time"])
|
|
|> maybe_filter_end_time(opts[:end_time] || opts["end_time"])
|
|
|> maybe_filter_hours_back(opts[:hours_back] || opts["hours_back"])
|
|
end
|
|
|
|
@doc """
|
|
Filters query to only include packets with position data.
|
|
"""
|
|
@spec with_position(Ecto.Query.t()) :: Ecto.Query.t()
|
|
def with_position(query) do
|
|
from p in query, where: p.has_position == true
|
|
end
|
|
|
|
@doc """
|
|
Filters query by exact callsign match (case-insensitive).
|
|
Uses the functional index on upper(sender) for performance.
|
|
"""
|
|
@spec for_callsign(Ecto.Query.t(), String.t()) :: Ecto.Query.t()
|
|
def for_callsign(query, callsign) when is_binary(callsign) do
|
|
normalized = String.upcase(String.trim(callsign))
|
|
from p in query, where: fragment("upper(?)", p.sender) == ^normalized
|
|
end
|
|
|
|
@doc """
|
|
Filters query by base callsign (without SSID).
|
|
"""
|
|
@spec for_base_callsign(Ecto.Query.t(), String.t()) :: Ecto.Query.t()
|
|
def for_base_callsign(query, base_callsign) when is_binary(base_callsign) do
|
|
from p in query, where: p.base_callsign == ^base_callsign
|
|
end
|
|
|
|
@doc """
|
|
Orders query by received_at descending (most recent first).
|
|
"""
|
|
@spec recent_first(Ecto.Query.t()) :: Ecto.Query.t()
|
|
def recent_first(query) do
|
|
from p in query, order_by: [desc: p.received_at]
|
|
end
|
|
|
|
@doc """
|
|
Orders query by received_at ascending (oldest first).
|
|
"""
|
|
@spec chronological(Ecto.Query.t()) :: Ecto.Query.t()
|
|
def chronological(query) do
|
|
from p in query, order_by: [asc: p.received_at]
|
|
end
|
|
|
|
@doc """
|
|
Filters query to only weather packets.
|
|
"""
|
|
@spec weather_only(Ecto.Query.t()) :: Ecto.Query.t()
|
|
def weather_only(query) do
|
|
from p in query,
|
|
where:
|
|
not is_nil(p.temperature) or not is_nil(p.humidity) or not is_nil(p.pressure) or
|
|
not is_nil(p.wind_speed) or not is_nil(p.wind_direction) or not is_nil(p.rain_1h) or
|
|
not is_nil(p.rain_24h) or not is_nil(p.rain_since_midnight) or not is_nil(p.snow) or
|
|
not is_nil(p.luminosity)
|
|
end
|
|
|
|
@doc """
|
|
Applies pagination with limit and optional offset.
|
|
"""
|
|
@spec paginate(Ecto.Query.t(), integer(), integer()) :: Ecto.Query.t()
|
|
def paginate(query, limit, offset \\ 0) when is_integer(limit) and is_integer(offset) do
|
|
from p in query, limit: ^limit, offset: ^offset
|
|
end
|
|
|
|
@doc """
|
|
Adds PostGIS coordinate extraction to select clause.
|
|
"""
|
|
@spec with_coordinates(Ecto.Query.t()) :: Ecto.Query.t()
|
|
def with_coordinates(query) do
|
|
from p in query,
|
|
select: %{p | lat: fragment("ST_Y(?)", p.location), lon: fragment("ST_X(?)", p.location)}
|
|
end
|
|
|
|
@doc """
|
|
Filters by region if provided in options.
|
|
"""
|
|
@spec maybe_filter_region(Ecto.Query.t(), map() | keyword()) :: Ecto.Query.t()
|
|
def maybe_filter_region(query, opts) when is_list(opts) do
|
|
maybe_filter_region(query, Map.new(opts))
|
|
end
|
|
|
|
def maybe_filter_region(query, %{region: region}) when not is_nil(region) do
|
|
from p in query, where: p.region == ^region
|
|
end
|
|
|
|
def maybe_filter_region(query, _), do: query
|
|
|
|
@doc """
|
|
Filters by map bounds using bounding box coordinates.
|
|
|
|
## Example
|
|
|
|
within_bounds(query, [-74.0, 40.0, -73.0, 41.0])
|
|
# [west, south, east, north]
|
|
"""
|
|
@spec within_bounds(Ecto.Query.t(), list(number())) :: Ecto.Query.t()
|
|
def within_bounds(query, [west, south, east, north])
|
|
when is_number(west) and is_number(south) and is_number(east) and is_number(north) do
|
|
# Handle antimeridian crossing (e.g., west=170, east=-170)
|
|
if west > east do
|
|
from p in query,
|
|
where: p.has_position == true,
|
|
where: p.lat >= ^south and p.lat <= ^north,
|
|
where: p.lon >= ^west or p.lon <= ^east
|
|
else
|
|
from p in query,
|
|
where: p.has_position == true,
|
|
where: p.lat >= ^south and p.lat <= ^north,
|
|
where: p.lon >= ^west and p.lon <= ^east
|
|
end
|
|
end
|
|
|
|
def within_bounds(query, _), do: query
|
|
|
|
@doc """
|
|
Common query composition for recent position packets.
|
|
|
|
## Options
|
|
* `:limit` - Number of packets to return (default: 100)
|
|
* `:start_time` - Filter packets after this time
|
|
* `:end_time` - Filter packets before this time
|
|
* `:region` - Filter by region
|
|
"""
|
|
@spec recent_position_packets(map() | keyword()) :: Ecto.Query.t()
|
|
def recent_position_packets(opts \\ %{}) do
|
|
limit = opts[:limit] || opts["limit"] || 100
|
|
|
|
Packet
|
|
|> with_position()
|
|
|> with_time_range(opts)
|
|
|> maybe_filter_region(opts)
|
|
|> recent_first()
|
|
|> paginate(limit)
|
|
|> with_coordinates()
|
|
end
|
|
|
|
@doc """
|
|
Common query composition for callsign packet history.
|
|
|
|
## Options
|
|
* `:limit` - Number of packets to return (default: 100)
|
|
* `:start_time` - Filter packets after this time
|
|
* `:end_time` - Filter packets before this time
|
|
"""
|
|
@spec callsign_history(String.t(), map() | keyword()) :: Ecto.Query.t()
|
|
def callsign_history(callsign, opts \\ %{}) do
|
|
limit = opts[:limit] || opts["limit"] || 100
|
|
|
|
Packet
|
|
|> for_callsign(callsign)
|
|
|> with_time_range(opts)
|
|
|> recent_first()
|
|
|> paginate(limit)
|
|
end
|
|
|
|
@doc """
|
|
Common query composition for weather packets.
|
|
|
|
## Options
|
|
* `:callsign` - Filter by specific callsign
|
|
* `:limit` - Number of packets to return (default: 100)
|
|
* `:start_time` - Filter packets after this time
|
|
* `:end_time` - Filter packets before this time
|
|
"""
|
|
@spec weather_packets(map() | keyword()) :: Ecto.Query.t()
|
|
def weather_packets(opts \\ %{}) do
|
|
limit = opts[:limit] || opts["limit"] || 100
|
|
callsign = opts[:callsign] || opts["callsign"]
|
|
|
|
query =
|
|
Packet
|
|
|> weather_only()
|
|
|> with_time_range(opts)
|
|
|> recent_first()
|
|
|> paginate(limit)
|
|
|
|
if callsign do
|
|
for_callsign(query, callsign)
|
|
else
|
|
query
|
|
end
|
|
end
|
|
|
|
# Private helper functions
|
|
|
|
defp maybe_filter_start_time(query, nil), do: query
|
|
|
|
defp maybe_filter_start_time(query, start_time) do
|
|
from p in query, where: p.received_at >= ^start_time
|
|
end
|
|
|
|
defp maybe_filter_end_time(query, nil), do: query
|
|
|
|
defp maybe_filter_end_time(query, end_time) do
|
|
from p in query, where: p.received_at <= ^end_time
|
|
end
|
|
|
|
defp maybe_filter_hours_back(query, nil), do: query
|
|
|
|
defp maybe_filter_hours_back(query, hours) when is_number(hours) do
|
|
start_time = DateTime.add(DateTime.utc_now(), -hours * 3600, :second)
|
|
from p in query, where: p.received_at >= ^start_time
|
|
end
|
|
end
|