- Fix leader election guard test for :undefined pid - Fix cpu_sup.util() pattern matching to handle numeric return - Fix encoding_utils to always return explicit nil when needed - Remove unused finite_float? function - Fix signal handler to match :ok return from :os.set_signal/2 - Remove redundant catch-all pattern in database metrics - Fix Gridsquare pattern matching in info_live template - Remove unnecessary nil check for calculate_course result - Fix get_packet_received_at to not check for nil (always returns DateTime) - Remove redundant catch-all pattern in weather format_weather_value - Fix query builder to use from(p in Packet) instead of bare Packet atom Reduced dialyzer errors from 49 to 6. Remaining warnings are mostly false positives from template compilation and overloaded function specs. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
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
|
|
|
|
from(p in 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
|
|
|
|
from(p in 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 =
|
|
from(p in 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
|