aprs.me/lib/aprsme/packets/query_builder.ex
Graham McIntire 48bc27e43c
fix: Resolve majority of dialyzer type errors
- 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>
2025-07-30 13:30:21 -05:00

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