diff --git a/.gitignore b/.gitignore index 3b65926..28462be 100644 --- a/.gitignore +++ b/.gitignore @@ -97,13 +97,13 @@ !/test/**/*.test.exs # versioned migrations -!/priv/ -/priv/* -!/priv/repo/ -/priv/repo/* -!/priv/repo/migrations -/priv/repo/migrations/* -!/priv/repo/migrations/*.exs +# !/priv/ +# /priv/* +# !/priv/repo/ +# /priv/repo/* +# !/priv/repo/migrations +# /priv/repo/migrations/* +# !/priv/repo/migrations/*.exs # deps !/.deps/ diff --git a/src/zenflows/vf/economic_resource/query.ex b/src/zenflows/vf/economic_resource/query.ex index 19ef236..d1d779c 100644 --- a/src/zenflows/vf/economic_resource/query.ex +++ b/src/zenflows/vf/economic_resource/query.ex @@ -23,13 +23,19 @@ import Ecto.Query alias Ecto.{Changeset, Queryable} alias Zenflows.DB.{ID, Page, Schema, Validate} -alias Zenflows.VF.{EconomicEvent, EconomicResource} +alias Zenflows.VF.{EconomicEvent, EconomicResource, SpatialThing} @spec all(Page.t()) :: {:ok, Queryable.t()} | {:error, Changeset.t()} def all(%{filter: nil}), do: {:ok, EconomicResource} def all(%{filter: params}) do with {:ok, filters} <- all_validate(params) do - {:ok, Enum.reduce(filters, EconomicResource, &all_f(&2, &1))} + {geo_filters, other_filters} = Enum.split_with(filters, fn + {k, _} -> k in [:near_lat, :near_long, :near_distance_km] + _ -> false + end) + q = Enum.reduce(other_filters, EconomicResource, &all_f(&2, &1)) + q = apply_geo_filter(q, Map.new(geo_filters)) + {:ok, q} end end @@ -75,6 +81,19 @@ defp all_f(q, {:repo, v}), defp all_f(q, {:or_repo, v}), do: or_where(q, [x], x.repo == ^v) +@spec apply_geo_filter(Queryable.t(), map()) :: Queryable.t() +defp apply_geo_filter(q, %{near_lat: lat, near_long: long, near_distance_km: dist}) do + from x in q, + join: loc in SpatialThing, + on: loc.id == x.current_location_id, + where: not is_nil(loc.lat) and not is_nil(loc.long), + where: fragment( + "(6371 * acos(LEAST(1.0, cos(radians(?)) * cos(radians(?)) * cos(radians(?) - radians(?)) + sin(radians(?)) * sin(radians(?))))) <= ?", + ^lat, loc.lat, loc.long, ^long, ^lat, loc.lat, ^dist + ) +end +defp apply_geo_filter(q, _), do: q + @spec all_validate(Schema.params()) :: {:ok, Changeset.data()} | {:error, Changeset.t()} defp all_validate(params) do @@ -99,6 +118,9 @@ defp all_validate(params) do or_note: :string, repo: :string, or_repo: :string, + near_lat: :decimal, + near_long: :decimal, + near_distance_km: :decimal, }} |> Changeset.cast(params, ~w[ id or_id @@ -109,6 +131,7 @@ defp all_validate(params) do gt_onhand_quantity_has_numerical_value or_gt_onhand_quantity_has_numerical_value name or_name note or_note repo or_repo + near_lat near_long near_distance_km ]a) |> Validate.class(:id) |> Validate.class(:or_id) @@ -146,9 +169,42 @@ defp all_validate(params) do |> Validate.escape_like(:or_name) |> Validate.escape_like(:note) |> Validate.escape_like(:or_note) + |> validate_geo_params() |> Changeset.apply_action(nil) end +@spec validate_geo_params(Changeset.t()) :: Changeset.t() +defp validate_geo_params(cset) do + near_lat = Changeset.get_change(cset, :near_lat) + near_long = Changeset.get_change(cset, :near_long) + near_dist = Changeset.get_change(cset, :near_distance_km) + + geo_fields = [near_lat, near_long, near_dist] + provided = Enum.count(geo_fields, & &1 != nil) + + cond do + provided == 0 -> + cset + provided != 3 -> + cset + |> maybe_geo_error(:near_lat, near_lat, "near_lat, near_long, and near_distance_km must all be provided together") + |> maybe_geo_error(:near_long, near_long, "near_lat, near_long, and near_distance_km must all be provided together") + |> maybe_geo_error(:near_distance_km, near_dist, "near_lat, near_long, and near_distance_km must all be provided together") + true -> + cset + |> Changeset.validate_number(:near_lat, + greater_than_or_equal_to: -90, less_than_or_equal_to: 90) + |> Changeset.validate_number(:near_long, + greater_than_or_equal_to: -180, less_than_or_equal_to: 180) + |> Changeset.validate_number(:near_distance_km, + greater_than: 0) + end +end + +defp maybe_geo_error(cset, field, nil, msg), + do: Changeset.add_error(cset, field, msg) +defp maybe_geo_error(cset, _field, _val, _msg), do: cset + @spec previous(Schema.id()) :: Queryable.t() def previous(id) do from e in EconomicEvent, diff --git a/src/zenflows/vf/economic_resource/type.ex b/src/zenflows/vf/economic_resource/type.ex index 1d80e07..12dc607 100644 --- a/src/zenflows/vf/economic_resource/type.ex +++ b/src/zenflows/vf/economic_resource/type.ex @@ -275,6 +275,13 @@ input_object :economic_resource_filter_params do field :or_note, :string field :repo, :string field :or_repo, :string + + @desc "Latitude of the center point for geo search." + field :near_lat, :decimal + @desc "Longitude of the center point for geo search." + field :near_long, :decimal + @desc "Search radius in kilometers for geo search." + field :near_distance_km, :decimal end input_object :economic_resource_classifications_filter_params do diff --git a/test/vf/economic_resource/geo_search.test.exs b/test/vf/economic_resource/geo_search.test.exs new file mode 100644 index 0000000..b951fda --- /dev/null +++ b/test/vf/economic_resource/geo_search.test.exs @@ -0,0 +1,163 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Zenflows is software that implements the Valueflows vocabulary. +# Zenflows is designed, written, and maintained by srfsh +# Copyright (C) 2021-2023 Dyne.org foundation . +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +defmodule ZenflowsTest.VF.EconomicResource.GeoSearch do +use ZenflowsTest.Help.EctoCase, async: true + +alias Zenflows.DB.Page +alias Zenflows.VF.EconomicResource.Domain + +# Helper to create a SpatialThing at given coordinates +defp insert_spatial_thing!(lat, long) do + Factory.insert!(:spatial_thing, %{ + lat: Decimal.from_float(lat), + long: Decimal.from_float(long), + }) +end + +# Helper to create an EconomicResource at a given location +defp insert_resource_at!(location) do + agent = Factory.insert!(:agent) + %{resource_inventoried_as_id: res_id} = + Zenflows.VF.EconomicEvent.Domain.create!(%{ + action_id: "raise", + provider_id: agent.id, + receiver_id: agent.id, + resource_classified_as: Factory.str_list("some uri"), + resource_conforms_to_id: Factory.insert!(:resource_specification).id, + resource_quantity: %{ + has_numerical_value: Factory.decimald(), + has_unit_id: Factory.insert!(:unit).id, + }, + has_point_in_time: Factory.now(), + to_location_id: location.id, + }, %{name: Factory.str("some name")}) + Domain.one!(res_id) +end + +describe "geo search filter" do + test "returns resources within the given radius" do + # Rome, Italy (41.9028, 12.4964) + rome = insert_spatial_thing!(41.9028, 12.4964) + # Milan, Italy (45.4642, 9.1900) - ~480km from Rome + milan = insert_spatial_thing!(45.4642, 9.1900) + # Paris, France (48.8566, 2.3522) - ~1100km from Rome + paris = insert_spatial_thing!(48.8566, 2.3522) + + res_rome = insert_resource_at!(rome) + res_milan = insert_resource_at!(milan) + _res_paris = insert_resource_at!(paris) + + # Search 500km around Rome: should find Rome and Milan but not Paris + page = Page.new(%{filter: %{ + near_lat: "41.9028", + near_long: "12.4964", + near_distance_km: "500", + }}) + {:ok, results} = Domain.all(page) + result_ids = MapSet.new(results, & &1.id) + + assert MapSet.member?(result_ids, res_rome.id) + assert MapSet.member?(result_ids, res_milan.id) + refute MapSet.member?(result_ids, _res_paris.id) + end + + test "returns only nearby resources with small radius" do + # Rome + rome = insert_spatial_thing!(41.9028, 12.4964) + # Milan (~480km away) + milan = insert_spatial_thing!(45.4642, 9.1900) + + res_rome = insert_resource_at!(rome) + _res_milan = insert_resource_at!(milan) + + # Search 100km around Rome: should find only Rome + page = Page.new(%{filter: %{ + near_lat: "41.9028", + near_long: "12.4964", + near_distance_km: "100", + }}) + {:ok, results} = Domain.all(page) + result_ids = MapSet.new(results, & &1.id) + + assert MapSet.member?(result_ids, res_rome.id) + refute MapSet.member?(result_ids, _res_milan.id) + end + + test "excludes resources without a location" do + rome = insert_spatial_thing!(41.9028, 12.4964) + res_rome = insert_resource_at!(rome) + + # Create a resource without location (standard factory) + res_no_loc = Factory.insert!(:economic_resource) + + page = Page.new(%{filter: %{ + near_lat: "41.9028", + near_long: "12.4964", + near_distance_km: "50000", + }}) + {:ok, results} = Domain.all(page) + result_ids = MapSet.new(results, & &1.id) + + assert MapSet.member?(result_ids, res_rome.id) + refute MapSet.member?(result_ids, res_no_loc.id) + end + + test "validates all three geo params must be provided together" do + page = Page.new(%{filter: %{ + near_lat: "41.9028", + near_long: "12.4964", + }}) + assert {:error, %Ecto.Changeset{}} = Domain.all(page) + end + + test "validates lat range" do + page = Page.new(%{filter: %{ + near_lat: "91.0", + near_long: "12.0", + near_distance_km: "100", + }}) + assert {:error, %Ecto.Changeset{}} = Domain.all(page) + end + + test "validates distance must be positive" do + page = Page.new(%{filter: %{ + near_lat: "41.0", + near_long: "12.0", + near_distance_km: "0", + }}) + assert {:error, %Ecto.Changeset{}} = Domain.all(page) + end + + test "combines geo filter with other filters" do + rome = insert_spatial_thing!(41.9028, 12.4964) + res_rome = insert_resource_at!(rome) + + page = Page.new(%{filter: %{ + near_lat: "41.9028", + near_long: "12.4964", + near_distance_km: "100", + name: res_rome.name, + }}) + {:ok, results} = Domain.all(page) + result_ids = MapSet.new(results, & &1.id) + + assert MapSet.member?(result_ids, res_rome.id) + end +end +end