From 21971e985bc347ab14a9cee06c27d1529df33971 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Mon, 3 Jun 2024 21:54:08 -0500 Subject: [PATCH 001/259] #1954 Generate Gazetteer model rails generate scaffold Gazetteer --no-javascript --no-view-specs --no-request-specs --no-fixture --no-stylesheets --no-assets --no-scaffold-stylesheet --- app/controllers/gazetteers_controller.rb | 70 +++++++++++++++++++ app/helpers/gazetteers_helper.rb | 2 + app/models/gazetteer.rb | 2 + app/views/gazetteers/_form.html.erb | 17 +++++ app/views/gazetteers/_gazetteer.html.erb | 2 + app/views/gazetteers/_gazetteer.json.jbuilder | 2 + app/views/gazetteers/edit.html.erb | 10 +++ app/views/gazetteers/index.html.erb | 14 ++++ app/views/gazetteers/index.json.jbuilder | 1 + app/views/gazetteers/new.html.erb | 9 +++ app/views/gazetteers/show.html.erb | 10 +++ app/views/gazetteers/show.json.jbuilder | 1 + config/routes.rb | 1 + .../20240604024742_create_gazetteers.rb | 8 +++ spec/factories/gazetteers.rb | 5 ++ spec/helpers/gazetteers_helper_spec.rb | 15 ++++ spec/models/gazetteer_spec.rb | 5 ++ spec/routing/gazetteers_routing_spec.rb | 38 ++++++++++ 18 files changed, 212 insertions(+) create mode 100644 app/controllers/gazetteers_controller.rb create mode 100644 app/helpers/gazetteers_helper.rb create mode 100644 app/models/gazetteer.rb create mode 100644 app/views/gazetteers/_form.html.erb create mode 100644 app/views/gazetteers/_gazetteer.html.erb create mode 100644 app/views/gazetteers/_gazetteer.json.jbuilder create mode 100644 app/views/gazetteers/edit.html.erb create mode 100644 app/views/gazetteers/index.html.erb create mode 100644 app/views/gazetteers/index.json.jbuilder create mode 100644 app/views/gazetteers/new.html.erb create mode 100644 app/views/gazetteers/show.html.erb create mode 100644 app/views/gazetteers/show.json.jbuilder create mode 100644 db/migrate/20240604024742_create_gazetteers.rb create mode 100644 spec/factories/gazetteers.rb create mode 100644 spec/helpers/gazetteers_helper_spec.rb create mode 100644 spec/models/gazetteer_spec.rb create mode 100644 spec/routing/gazetteers_routing_spec.rb diff --git a/app/controllers/gazetteers_controller.rb b/app/controllers/gazetteers_controller.rb new file mode 100644 index 0000000000..4c6bed286a --- /dev/null +++ b/app/controllers/gazetteers_controller.rb @@ -0,0 +1,70 @@ +class GazetteersController < ApplicationController + before_action :set_gazetteer, only: %i[ show edit update destroy ] + + # GET /gazetteers or /gazetteers.json + def index + @gazetteers = Gazetteer.all + end + + # GET /gazetteers/1 or /gazetteers/1.json + def show + end + + # GET /gazetteers/new + def new + @gazetteer = Gazetteer.new + end + + # GET /gazetteers/1/edit + def edit + end + + # POST /gazetteers or /gazetteers.json + def create + @gazetteer = Gazetteer.new(gazetteer_params) + + respond_to do |format| + if @gazetteer.save + format.html { redirect_to gazetteer_url(@gazetteer), notice: "Gazetteer was successfully created." } + format.json { render :show, status: :created, location: @gazetteer } + else + format.html { render :new, status: :unprocessable_entity } + format.json { render json: @gazetteer.errors, status: :unprocessable_entity } + end + end + end + + # PATCH/PUT /gazetteers/1 or /gazetteers/1.json + def update + respond_to do |format| + if @gazetteer.update(gazetteer_params) + format.html { redirect_to gazetteer_url(@gazetteer), notice: "Gazetteer was successfully updated." } + format.json { render :show, status: :ok, location: @gazetteer } + else + format.html { render :edit, status: :unprocessable_entity } + format.json { render json: @gazetteer.errors, status: :unprocessable_entity } + end + end + end + + # DELETE /gazetteers/1 or /gazetteers/1.json + def destroy + @gazetteer.destroy! + + respond_to do |format| + format.html { redirect_to gazetteers_url, notice: "Gazetteer was successfully destroyed." } + format.json { head :no_content } + end + end + + private + # Use callbacks to share common setup or constraints between actions. + def set_gazetteer + @gazetteer = Gazetteer.find(params[:id]) + end + + # Only allow a list of trusted parameters through. + def gazetteer_params + params.fetch(:gazetteer, {}) + end +end diff --git a/app/helpers/gazetteers_helper.rb b/app/helpers/gazetteers_helper.rb new file mode 100644 index 0000000000..6ee6717493 --- /dev/null +++ b/app/helpers/gazetteers_helper.rb @@ -0,0 +1,2 @@ +module GazetteersHelper +end diff --git a/app/models/gazetteer.rb b/app/models/gazetteer.rb new file mode 100644 index 0000000000..b5aaab7d78 --- /dev/null +++ b/app/models/gazetteer.rb @@ -0,0 +1,2 @@ +class Gazetteer < ApplicationRecord +end diff --git a/app/views/gazetteers/_form.html.erb b/app/views/gazetteers/_form.html.erb new file mode 100644 index 0000000000..5b3cd360e7 --- /dev/null +++ b/app/views/gazetteers/_form.html.erb @@ -0,0 +1,17 @@ +<%= form_with(model: gazetteer) do |form| %> + <% if gazetteer.errors.any? %> +
+

<%= pluralize(gazetteer.errors.count, "error") %> prohibited this gazetteer from being saved:

+ + +
+ <% end %> + +
+ <%= form.submit %> +
+<% end %> diff --git a/app/views/gazetteers/_gazetteer.html.erb b/app/views/gazetteers/_gazetteer.html.erb new file mode 100644 index 0000000000..33b145b4fc --- /dev/null +++ b/app/views/gazetteers/_gazetteer.html.erb @@ -0,0 +1,2 @@ +
+
diff --git a/app/views/gazetteers/_gazetteer.json.jbuilder b/app/views/gazetteers/_gazetteer.json.jbuilder new file mode 100644 index 0000000000..3460aaa44f --- /dev/null +++ b/app/views/gazetteers/_gazetteer.json.jbuilder @@ -0,0 +1,2 @@ +json.extract! gazetteer, :id, :created_at, :updated_at +json.url gazetteer_url(gazetteer, format: :json) diff --git a/app/views/gazetteers/edit.html.erb b/app/views/gazetteers/edit.html.erb new file mode 100644 index 0000000000..1f9ffe6f88 --- /dev/null +++ b/app/views/gazetteers/edit.html.erb @@ -0,0 +1,10 @@ +

Editing gazetteer

+ +<%= render "form", gazetteer: @gazetteer %> + +
+ +
+ <%= link_to "Show this gazetteer", @gazetteer %> | + <%= link_to "Back to gazetteers", gazetteers_path %> +
diff --git a/app/views/gazetteers/index.html.erb b/app/views/gazetteers/index.html.erb new file mode 100644 index 0000000000..397e6836bb --- /dev/null +++ b/app/views/gazetteers/index.html.erb @@ -0,0 +1,14 @@ +

<%= notice %>

+ +

Gazetteers

+ +
+ <% @gazetteers.each do |gazetteer| %> + <%= render gazetteer %> +

+ <%= link_to "Show this gazetteer", gazetteer %> +

+ <% end %> +
+ +<%= link_to "New gazetteer", new_gazetteer_path %> diff --git a/app/views/gazetteers/index.json.jbuilder b/app/views/gazetteers/index.json.jbuilder new file mode 100644 index 0000000000..747b784ad5 --- /dev/null +++ b/app/views/gazetteers/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @gazetteers, partial: "gazetteers/gazetteer", as: :gazetteer diff --git a/app/views/gazetteers/new.html.erb b/app/views/gazetteers/new.html.erb new file mode 100644 index 0000000000..35b691e056 --- /dev/null +++ b/app/views/gazetteers/new.html.erb @@ -0,0 +1,9 @@ +

New gazetteer

+ +<%= render "form", gazetteer: @gazetteer %> + +
+ +
+ <%= link_to "Back to gazetteers", gazetteers_path %> +
diff --git a/app/views/gazetteers/show.html.erb b/app/views/gazetteers/show.html.erb new file mode 100644 index 0000000000..22b1a8762e --- /dev/null +++ b/app/views/gazetteers/show.html.erb @@ -0,0 +1,10 @@ +

<%= notice %>

+ +<%= render @gazetteer %> + +
+ <%= link_to "Edit this gazetteer", edit_gazetteer_path(@gazetteer) %> | + <%= link_to "Back to gazetteers", gazetteers_path %> + + <%= button_to "Destroy this gazetteer", @gazetteer, method: :delete %> +
diff --git a/app/views/gazetteers/show.json.jbuilder b/app/views/gazetteers/show.json.jbuilder new file mode 100644 index 0000000000..06863a6708 --- /dev/null +++ b/app/views/gazetteers/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "gazetteers/gazetteer", gazetteer: @gazetteer diff --git a/config/routes.rb b/config/routes.rb index eb9c2dd843..a79e221036 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,6 +5,7 @@ # Routes are moved to config/routes. TaxonWorks::Application.routes.draw do + resources :gazetteers draw :base draw :data draw :tasks diff --git a/db/migrate/20240604024742_create_gazetteers.rb b/db/migrate/20240604024742_create_gazetteers.rb new file mode 100644 index 0000000000..82ebe066b2 --- /dev/null +++ b/db/migrate/20240604024742_create_gazetteers.rb @@ -0,0 +1,8 @@ +class CreateGazetteers < ActiveRecord::Migration[7.1] + def change + create_table :gazetteers do |t| + + t.timestamps + end + end +end diff --git a/spec/factories/gazetteers.rb b/spec/factories/gazetteers.rb new file mode 100644 index 0000000000..8d937ef975 --- /dev/null +++ b/spec/factories/gazetteers.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :gazetteer do + + end +end diff --git a/spec/helpers/gazetteers_helper_spec.rb b/spec/helpers/gazetteers_helper_spec.rb new file mode 100644 index 0000000000..ba70ddff93 --- /dev/null +++ b/spec/helpers/gazetteers_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the GazetteersHelper. For example: +# +# describe GazetteersHelper do +# describe "string concat" do +# it "concats two strings with spaces" do +# expect(helper.concat_strings("this","that")).to eq("this that") +# end +# end +# end +RSpec.describe GazetteersHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/gazetteer_spec.rb b/spec/models/gazetteer_spec.rb new file mode 100644 index 0000000000..d7545d856e --- /dev/null +++ b/spec/models/gazetteer_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Gazetteer, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/routing/gazetteers_routing_spec.rb b/spec/routing/gazetteers_routing_spec.rb new file mode 100644 index 0000000000..5e0c42bfa6 --- /dev/null +++ b/spec/routing/gazetteers_routing_spec.rb @@ -0,0 +1,38 @@ +require "rails_helper" + +RSpec.describe GazetteersController, type: :routing do + describe "routing" do + it "routes to #index" do + expect(get: "/gazetteers").to route_to("gazetteers#index") + end + + it "routes to #new" do + expect(get: "/gazetteers/new").to route_to("gazetteers#new") + end + + it "routes to #show" do + expect(get: "/gazetteers/1").to route_to("gazetteers#show", id: "1") + end + + it "routes to #edit" do + expect(get: "/gazetteers/1/edit").to route_to("gazetteers#edit", id: "1") + end + + + it "routes to #create" do + expect(post: "/gazetteers").to route_to("gazetteers#create") + end + + it "routes to #update via PUT" do + expect(put: "/gazetteers/1").to route_to("gazetteers#update", id: "1") + end + + it "routes to #update via PATCH" do + expect(patch: "/gazetteers/1").to route_to("gazetteers#update", id: "1") + end + + it "routes to #destroy" do + expect(delete: "/gazetteers/1").to route_to("gazetteers#destroy", id: "1") + end + end +end From 450e2a754eb547b089e6f54bdccab70787c18e61 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Tue, 4 Jun 2024 20:01:04 -0500 Subject: [PATCH 002/259] #1954 Initial buildout of Gazetteer --- app/models/gazetteer.rb | 43 +++++++++++++++++++ app/models/geographic_item.rb | 11 ++--- app/models/lead.rb | 4 ++ app/models/project.rb | 1 + config/interface/hub/data.yml | 6 +++ config/routes.rb | 1 - config/routes/data.rb | 4 ++ .../20240604024742_create_gazetteers.rb | 12 +++++- db/schema.rb | 20 ++++++++- spec/factories/gazetteers.rb | 29 ++++++++++++- spec/models/gazetteer_spec.rb | 2 +- 11 files changed, 122 insertions(+), 11 deletions(-) diff --git a/app/models/gazetteer.rb b/app/models/gazetteer.rb index b5aaab7d78..c7e9a52b82 100644 --- a/app/models/gazetteer.rb +++ b/app/models/gazetteer.rb @@ -1,2 +1,45 @@ +# Gazetteer allows a project to add its own named shapes to participate in +# filtering, georeferencing, etc. +# +# @!attribute geography +# @return [RGeo::Geographic::Geography] +# Can hold any of the RGeo geometry types point, line string, polygon, +# multipoint, multilinestring, multipolygon. +# +# @!attribute name +# @return [String] +# The name of the gazetteer item +# +# @!attribute parent_id +# @return [Integer] +# ??? +# +# @!attribute iso_3166_a2 +# @return [String] +# Two alpha-character identification of country. +# +# @!attribute iso_3166_a3 +# @return [String] +# Three alpha-character identification of country. +# +# @!attribute project_id +# @return [Integer] +# the project ID + class Gazetteer < ApplicationRecord + include Housekeeping + include Shared::Citations + include Shared::Notes + include Shared::DataAttributes + include Shared::AlternateValues + include Shared::IsData + + ALTERNATE_VALUES_FOR = [:name].freeze + + has_closure_tree + + belongs_to :geographic_item, inverse_of: :gazetteers + + validates :name, presence: true, length: {minimum: 1} + end diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 62df9cad0e..9e3c145d13 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -12,7 +12,7 @@ # # @!attribute polygon # @return [RGeo::Geographic::ProjectedPolygonImpl] -# CCW orientation is applied +# CCW orientation is applied # # @!attribute multi_point # @return [RGeo::Geographic::ProjectedMultiPointImpl] @@ -22,7 +22,7 @@ # # @!attribute multi_polygon # @return [RGeo::Geographic::ProjectedMultiPolygonImpl] -# CCW orientation is applied +# CCW orientation is applied # # @!attribute type # @return [String] @@ -99,6 +99,7 @@ class GeographicItem < ApplicationRecord has_many :geographic_area_types, through: :geographic_areas has_many :parent_geographic_areas, through: :geographic_areas, source: :parent + has_many :gazetteers, inverse_of: :geographic_item has_many :georeferences, inverse_of: :geographic_item has_many :georeferences_through_error_geographic_item, class_name: 'Georeference', foreign_key: :error_geographic_item_id, inverse_of: :error_geographic_item @@ -1265,15 +1266,15 @@ def align_winding case type when 'multi_polygon' ApplicationRecord.connection.execute( - "UPDATE geographic_items set multi_polygon = ST_ForcePolygonCCW(multi_polygon::geometry) + "UPDATE geographic_items set multi_polygon = ST_ForcePolygonCCW(multi_polygon::geometry) WHERE id = #{self.id};" ) when 'polygon' ApplicationRecord.connection.execute( - "UPDATE geographic_items set polygon = ST_ForcePolygonCCW(polygon::geometry) + "UPDATE geographic_items set polygon = ST_ForcePolygonCCW(polygon::geometry) WHERE id = #{self.id};" ) - end + end end true end diff --git a/app/models/lead.rb b/app/models/lead.rb index d9edd21c49..a1ddef7eea 100644 --- a/app/models/lead.rb +++ b/app/models/lead.rb @@ -41,6 +41,10 @@ # @return [boolean] # True if the key is viewable without being logged in # +# @!attribute project_id +# @return [Integer] +# the project ID +# class Lead < ApplicationRecord include Housekeeping include Shared::Citations diff --git a/app/models/project.rb b/app/models/project.rb index 29f231038f..dd08c276ee 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -71,6 +71,7 @@ class Project < ApplicationRecord Container PublicContent Content + Gazetteer Georeference Identifier Lead diff --git a/config/interface/hub/data.yml b/config/interface/hub/data.yml index 452a94014b..f9c66cc9a0 100644 --- a/config/interface/hub/data.yml +++ b/config/interface/hub/data.yml @@ -176,6 +176,12 @@ Supporting: - biology - dna - matrix + Gazetteer: + status: :prototype + related_models: + - GeographicArea + - Georeference + - CollectingEvent GeneAttribute: status: :prototype related_models: diff --git a/config/routes.rb b/config/routes.rb index a79e221036..eb9c2dd843 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,7 +5,6 @@ # Routes are moved to config/routes. TaxonWorks::Application.routes.draw do - resources :gazetteers draw :base draw :data draw :tasks diff --git a/config/routes/data.rb b/config/routes/data.rb index e2e5c773a9..c2e348a35a 100644 --- a/config/routes/data.rb +++ b/config/routes/data.rb @@ -320,6 +320,10 @@ concerns [:data_routes] end +resources :gazetteers do + concerns [:data_routes] +end + resources :geographic_areas, only: [:index, :show] do collection do get 'download' diff --git a/db/migrate/20240604024742_create_gazetteers.rb b/db/migrate/20240604024742_create_gazetteers.rb index 82ebe066b2..94b059cf6c 100644 --- a/db/migrate/20240604024742_create_gazetteers.rb +++ b/db/migrate/20240604024742_create_gazetteers.rb @@ -2,7 +2,17 @@ class CreateGazetteers < ActiveRecord::Migration[7.1] def change create_table :gazetteers do |t| - t.timestamps + t.integer :geographic_item_id, null: false, index: true + t.integer :parent_id, index: true + t.string :name, null: false, index: true + t.string :iso_3166_a2, index: true + t.string :iso_3166_a3, index: true + t.references :project, index: true, foreign_key: true + + t.timestamps null: false + + t.integer :created_by_id, null: false, index: true + t.integer :updated_by_id, null: false, index: true end end end diff --git a/db/schema.rb b/db/schema.rb index 749095fd76..96a0dd84f5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_04_18_162420) do +ActiveRecord::Schema[7.1].define(version: 2024_06_04_160009) do # These are extensions that must be enabled in order to support this database enable_extension "btree_gin" enable_extension "fuzzystrmatch" @@ -954,6 +954,21 @@ t.index ["updated_by_id"], name: "index_field_occurrences_on_updated_by_id" end + create_table "gazetteers", force: :cascade do |t| + t.integer "geographic_item_id", null: false + t.string "name", null: false + t.bigint "project_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "created_by_id", null: false + t.integer "updated_by_id", null: false + t.index ["created_by_id"], name: "index_gazetteers_on_created_by_id" + t.index ["geographic_item_id"], name: "index_gazetteers_on_geographic_item_id" + t.index ["name"], name: "index_gazetteers_on_name" + t.index ["project_id"], name: "index_gazetteers_on_project_id" + t.index ["updated_by_id"], name: "index_gazetteers_on_updated_by_id" + end + create_table "gene_attributes", id: :serial, force: :cascade do |t| t.integer "descriptor_id", null: false t.integer "sequence_id", null: false @@ -1042,8 +1057,10 @@ t.integer "updated_by_id", null: false t.string "type", null: false t.decimal "cached_total_area" + t.geography "geography", limit: {:srid=>4326, :type=>"geometry", :has_z=>true, :has_m=>true, :geographic=>true} t.index "st_centroid(\nCASE type\n WHEN 'GeographicItem::MultiPolygon'::text THEN (multi_polygon)::geometry\n WHEN 'GeographicItem::Point'::text THEN (point)::geometry\n WHEN 'GeographicItem::LineString'::text THEN (line_string)::geometry\n WHEN 'GeographicItem::Polygon'::text THEN (polygon)::geometry\n WHEN 'GeographicItem::MultiLineString'::text THEN (multi_line_string)::geometry\n WHEN 'GeographicItem::MultiPoint'::text THEN (multi_point)::geometry\n WHEN 'GeographicItem::GeometryCollection'::text THEN (geometry_collection)::geometry\n ELSE NULL::geometry\nEND)", name: "idx_centroid", using: :gist t.index ["created_by_id"], name: "index_geographic_items_on_created_by_id" + t.index ["geography"], name: "index_geographic_items_on_geography" t.index ["geometry_collection"], name: "geometry_collection_gix", using: :gist t.index ["line_string"], name: "line_string_gix", using: :gist t.index ["multi_line_string"], name: "multi_line_string_gix", using: :gist @@ -2309,6 +2326,7 @@ add_foreign_key "field_occurrences", "ranged_lot_categories" add_foreign_key "field_occurrences", "users", column: "created_by_id" add_foreign_key "field_occurrences", "users", column: "updated_by_id" + add_foreign_key "gazetteers", "projects" add_foreign_key "gene_attributes", "controlled_vocabulary_terms" add_foreign_key "gene_attributes", "projects" add_foreign_key "gene_attributes", "sequences" diff --git a/spec/factories/gazetteers.rb b/spec/factories/gazetteers.rb index 8d937ef975..85d964688f 100644 --- a/spec/factories/gazetteers.rb +++ b/spec/factories/gazetteers.rb @@ -1,5 +1,30 @@ FactoryBot.define do - factory :gazetteer do - + + factory :gazetteer, traits: [:housekeeping] do + + factory :valid_gazetteer do + association :geographic_item, factory: :valid_geographic_item + name { 'gaz foo' } + end + + factory :gazetteer_with_random_point do + association :geographic_item, factory: :random_point_geographic_item + name { 'gaz random point' } + end + + factory :gazetteer_with_line_string do + association :geographic_item, factory: :geographic_item_with_line_string + name { 'gaz random line string' } + end + + factory :gazetteer_with_polygon do + association :geographic_item, factory: :geographic_item_with_polygon + name { 'gaz random polygon' } + end + + factory :gazeteer_with_multi_polygon do + association :geographic_item, factory: :geographic_item_with_multi_polygon + name { 'gaz random multi-polygon' } + end end end diff --git a/spec/models/gazetteer_spec.rb b/spec/models/gazetteer_spec.rb index d7545d856e..f22f93a17e 100644 --- a/spec/models/gazetteer_spec.rb +++ b/spec/models/gazetteer_spec.rb @@ -1,5 +1,5 @@ require 'rails_helper' RSpec.describe Gazetteer, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + end From 7bd719c0a6ecf747f6537a90fa2dce15999c29cf Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Fri, 7 Jun 2024 15:31:03 -0500 Subject: [PATCH 003/259] #1954 Update gazetter index action --- app/controllers/gazetteers_controller.rb | 33 +++++++++++++++++------- app/views/gazetteers/index.html.erb | 14 ---------- 2 files changed, 23 insertions(+), 24 deletions(-) delete mode 100644 app/views/gazetteers/index.html.erb diff --git a/app/controllers/gazetteers_controller.rb b/app/controllers/gazetteers_controller.rb index 4c6bed286a..0e197c2ccb 100644 --- a/app/controllers/gazetteers_controller.rb +++ b/app/controllers/gazetteers_controller.rb @@ -1,9 +1,23 @@ class GazetteersController < ApplicationController + include DataControllerConfiguration::ProjectDataControllerConfiguration before_action :set_gazetteer, only: %i[ show edit update destroy ] - # GET /gazetteers or /gazetteers.json + # GET /gazetteers + # GET /gazetteers.json def index - @gazetteers = Gazetteer.all + respond_to do |format| + format.html do + @recent_objects = Gazetteer.recent_from_project_id(sessions_current_project_id).order(updated_at: :desc).limit(10) + render '/shared/data/all/index' + end + format.json do + @geographic_areas = ::Queries::GeographicArea::Filter.new(params).all + .includes(:geographic_items) + .page(params[:page]) + .per(params[:per]) + # .order('geographic_items.cached_total_area, geographic_area.name') + end + end end # GET /gazetteers/1 or /gazetteers/1.json @@ -58,13 +72,12 @@ def destroy end private - # Use callbacks to share common setup or constraints between actions. - def set_gazetteer - @gazetteer = Gazetteer.find(params[:id]) - end - # Only allow a list of trusted parameters through. - def gazetteer_params - params.fetch(:gazetteer, {}) - end + def set_gazetteer + @gazetteer = Gazetteer.find(params[:id]) + end + + def gazetteer_params + params.require(:gazetteer).permit(:name, :parent_id, :iso_3166_a2, :iso_3166_a3) + end end diff --git a/app/views/gazetteers/index.html.erb b/app/views/gazetteers/index.html.erb deleted file mode 100644 index 397e6836bb..0000000000 --- a/app/views/gazetteers/index.html.erb +++ /dev/null @@ -1,14 +0,0 @@ -

<%= notice %>

- -

Gazetteers

- -
- <% @gazetteers.each do |gazetteer| %> - <%= render gazetteer %> -

- <%= link_to "Show this gazetteer", gazetteer %> -

- <% end %> -
- -<%= link_to "New gazetteer", new_gazetteer_path %> From d1b0ffa380805a84c6b1e890e1a87ff495e837a0 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Fri, 7 Jun 2024 15:33:19 -0500 Subject: [PATCH 004/259] #1954 Add gazetteer list action --- app/controllers/gazetteers_controller.rb | 5 +++++ app/views/gazetteers/list.html.erb | 28 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 app/views/gazetteers/list.html.erb diff --git a/app/controllers/gazetteers_controller.rb b/app/controllers/gazetteers_controller.rb index 0e197c2ccb..b705ff6801 100644 --- a/app/controllers/gazetteers_controller.rb +++ b/app/controllers/gazetteers_controller.rb @@ -33,6 +33,11 @@ def new def edit end + # GET /gazetteers/list + def list + @gazetteers = Gazetteer.with_project_id(sessions_current_project_id).page(params[:page]).per(params[[:per]]) + end + # POST /gazetteers or /gazetteers.json def create @gazetteer = Gazetteer.new(gazetteer_params) diff --git a/app/views/gazetteers/list.html.erb b/app/views/gazetteers/list.html.erb new file mode 100644 index 0000000000..1daad23279 --- /dev/null +++ b/app/views/gazetteers/list.html.erb @@ -0,0 +1,28 @@ +<%= render("/shared/data/all/list/list_header", objects: @gazetteers) -%> + + + + + <%= fancy_th_tag(name: 'Name') -%> + <%= fancy_th_tag(name: 'Parent') -%> + <%= fancy_th_tag(name: 'Iso 3166 a2') -%> + <%= fancy_th_tag(name: 'Iso 3166 a3') -%> + <%= fancy_th_tag(name: 'Updated by', group: 'housekeeping') -%> + <%= fancy_th_tag(name: 'Last updated', group: 'housekeeping') -%> + + + + + <% @gazetteers.each do |gaz| %> + <%= content_tag(:tr, class: :contextMenuCells) do -%> + + + + + + <%= fancy_metadata_cells_tag(gaz) -%> + <% end %> + + <% end %> + +
<%= gaz.name %><%= gazetteer_link(gaz.parent) %><%= gazetteer_type_tag(gaz.type) %><%= gaz.iso_3166_a2 %><%= gaz.iso_3166_a3 %>
From d2cdec326ebfa85af49087839cdb07ba3964cdf9 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Fri, 7 Jun 2024 16:26:41 -0500 Subject: [PATCH 005/259] #1954 Short circuit rails New and Edit actions Currently thinking a task will be best for these. --- app/views/gazetteers/_form.html.erb | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/app/views/gazetteers/_form.html.erb b/app/views/gazetteers/_form.html.erb index 5b3cd360e7..b4b9c3febd 100644 --- a/app/views/gazetteers/_form.html.erb +++ b/app/views/gazetteers/_form.html.erb @@ -1,17 +1,9 @@ -<%= form_with(model: gazetteer) do |form| %> - <% if gazetteer.errors.any? %> -
-

<%= pluralize(gazetteer.errors.count, "error") %> prohibited this gazetteer from being saved:

+<%= content_tag(:span, 'Use the new/edit gazetteer task.', class: [:feedback, 'feedback-warning']) %> +
+
+<%# TODO %> +<%= link_to('New/edit gazetteer task', ) %> +
+
-
    - <% gazetteer.errors.each do |error| %> -
  • <%= error.full_message %>
  • - <% end %> -
-
- <% end %> -
- <%= form.submit %> -
-<% end %> From 0c094ea1d62aeae54054b3d4d3ea24a9c997f5ed Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Fri, 7 Jun 2024 16:44:50 -0500 Subject: [PATCH 006/259] #1954 Start adding gazetteer helpers --- app/helpers/gazetteers_helper.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/helpers/gazetteers_helper.rb b/app/helpers/gazetteers_helper.rb index 6ee6717493..ccb2a240e4 100644 --- a/app/helpers/gazetteers_helper.rb +++ b/app/helpers/gazetteers_helper.rb @@ -1,2 +1,7 @@ module GazetteersHelper + def gazetteer_link(gazetteer, link_text = nil) + return nil if gazetteer.nil? + link_text ||= gazetteer.name + link_to(link_text, gazeteer) + end end From ccb236d148c707d6241f9994d05a41efd4155830 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Fri, 7 Jun 2024 16:45:27 -0500 Subject: [PATCH 007/259] #1954 Update gazetteer attributes, index, show json jbuilders --- app/views/gazetteers/_attributes.html.erb | 19 +++++++++++++++++++ app/views/gazetteers/index.json.jbuilder | 4 +++- app/views/gazetteers/show.json.jbuilder | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 app/views/gazetteers/_attributes.html.erb diff --git a/app/views/gazetteers/_attributes.html.erb b/app/views/gazetteers/_attributes.html.erb new file mode 100644 index 0000000000..b920e8ff53 --- /dev/null +++ b/app/views/gazetteers/_attributes.html.erb @@ -0,0 +1,19 @@ +

+Name: +<%= @gazetteer.name -%> +

+ +

+ Parent: + <%= gazetteer_link(@gazetteer.parent) %> +

+ +

+ Iso 3166 a2: + <%= @gazetteer.iso_3166_a2 %> +

+ +

+ Iso 3166 a3: + <%= @gazetteer.iso_3166_a3 %> +

diff --git a/app/views/gazetteers/index.json.jbuilder b/app/views/gazetteers/index.json.jbuilder index 747b784ad5..672729d1ca 100644 --- a/app/views/gazetteers/index.json.jbuilder +++ b/app/views/gazetteers/index.json.jbuilder @@ -1 +1,3 @@ -json.array! @gazetteers, partial: "gazetteers/gazetteer", as: :gazetteer +json.array!(@gazeteers) do |gazeteer| + json.partial! 'attributes', gazeteer: +end diff --git a/app/views/gazetteers/show.json.jbuilder b/app/views/gazetteers/show.json.jbuilder index 06863a6708..d848dcb910 100644 --- a/app/views/gazetteers/show.json.jbuilder +++ b/app/views/gazetteers/show.json.jbuilder @@ -1 +1 @@ -json.partial! "gazetteers/gazetteer", gazetteer: @gazetteer +json.partial! 'attributes', gazetteer: @gazetteer From 3e3315589d9667259f5cdb0726d920aae8772000 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sat, 8 Jun 2024 08:14:11 -0500 Subject: [PATCH 008/259] #1954 Generate New Gazetteer task --- .../tasks/gazetteers/new_gazetteer_controller.rb | 4 ++++ app/javascript/packs/application.js | 4 ++-- .../vue/tasks/gazetteers/new_gazetteer/App.vue | 6 ++++++ .../vue/tasks/gazetteers/new_gazetteer/main.js | 14 ++++++++++++++ .../tasks/gazetteers/new_gazetteer/index.html.erb | 1 + config/interface/hub/user_tasks.yml | 10 ++++++++++ config/routes/tasks.rb | 6 ++++++ db/schema.rb | 8 ++++++-- 8 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 app/controllers/tasks/gazetteers/new_gazetteer_controller.rb create mode 100644 app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue create mode 100644 app/javascript/vue/tasks/gazetteers/new_gazetteer/main.js create mode 100644 app/views/tasks/gazetteers/new_gazetteer/index.html.erb diff --git a/app/controllers/tasks/gazetteers/new_gazetteer_controller.rb b/app/controllers/tasks/gazetteers/new_gazetteer_controller.rb new file mode 100644 index 0000000000..3b25c272b9 --- /dev/null +++ b/app/controllers/tasks/gazetteers/new_gazetteer_controller.rb @@ -0,0 +1,4 @@ +class Tasks::Gazetteers::NewGazetteerController < ApplicationController + include TaskControllerConfiguration + +end \ No newline at end of file diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 3622da5d1c..f255d32ae7 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -107,5 +107,5 @@ import '../vue/tasks/otus/new_otu/main.js' import '../vue/tasks/leads/hub/main.js' import '../vue/tasks/data_attributes/field_synchronize/main.js' import '../vue/tasks/observation_matrices/import_nexus/main.js' - -import '../vue/tasks/dwc_occurrences/filter/main.js' \ No newline at end of file +import '../vue/tasks/dwc_occurrences/filter/main.js' +import '../vue/tasks/gazetteers/new_gazetteer/main.js' diff --git a/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue b/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue new file mode 100644 index 0000000000..984dea42bf --- /dev/null +++ b/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/app/javascript/vue/tasks/gazetteers/new_gazetteer/main.js b/app/javascript/vue/tasks/gazetteers/new_gazetteer/main.js new file mode 100644 index 0000000000..56b69cdaee --- /dev/null +++ b/app/javascript/vue/tasks/gazetteers/new_gazetteer/main.js @@ -0,0 +1,14 @@ +import { createApp } from 'vue' +import App from './App.vue' + +function initApp (element) { + const app = createApp(App) + + app.mount(element) +} + +document.addEventListener('turbolinks:load', () => { + const el = document.querySelector('#new_gazetteer_task') + + if (el) { initApp(el) } +}) diff --git a/app/views/tasks/gazetteers/new_gazetteer/index.html.erb b/app/views/tasks/gazetteers/new_gazetteer/index.html.erb new file mode 100644 index 0000000000..94da3a7bbb --- /dev/null +++ b/app/views/tasks/gazetteers/new_gazetteer/index.html.erb @@ -0,0 +1 @@ +
diff --git a/config/interface/hub/user_tasks.yml b/config/interface/hub/user_tasks.yml index 7cad25338d..d3595e0767 100644 --- a/config/interface/hub/user_tasks.yml +++ b/config/interface/hub/user_tasks.yml @@ -945,3 +945,13 @@ filter_dwc_occurrences_task: - filter status: prototype description: 'Filter records in the DarwinCore index.' +new_gazetteer_task: + hub: true + name: 'New Gazetteer' + related: + categories: + - collecting_event + - filters + - new + status: prototype + description: 'Create named shapes for use in your project.' diff --git a/config/routes/tasks.rb b/config/routes/tasks.rb index 7f0b89ab1c..7ba4210784 100644 --- a/config/routes/tasks.rb +++ b/config/routes/tasks.rb @@ -1,4 +1,10 @@ scope :tasks do + scope :gazetteers do + scope :new_gazetteer, controller: 'tasks/gazetteers/new_gazetteer' do + get '/', action: :index, as: 'new_gazetteer_task' + end + end + scope :dwc_occurrences do scope :filter, controller: 'tasks/dwc_occurrences/filter' do get '/', action: :index, as: 'filter_dwc_occurrences_task' diff --git a/db/schema.rb b/db/schema.rb index 96a0dd84f5..83727d115c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -956,7 +956,10 @@ create_table "gazetteers", force: :cascade do |t| t.integer "geographic_item_id", null: false + t.integer "parent_id" t.string "name", null: false + t.string "iso_3166_a2" + t.string "iso_3166_a3" t.bigint "project_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -964,7 +967,10 @@ t.integer "updated_by_id", null: false t.index ["created_by_id"], name: "index_gazetteers_on_created_by_id" t.index ["geographic_item_id"], name: "index_gazetteers_on_geographic_item_id" + t.index ["iso_3166_a2"], name: "index_gazetteers_on_iso_3166_a2" + t.index ["iso_3166_a3"], name: "index_gazetteers_on_iso_3166_a3" t.index ["name"], name: "index_gazetteers_on_name" + t.index ["parent_id"], name: "index_gazetteers_on_parent_id" t.index ["project_id"], name: "index_gazetteers_on_project_id" t.index ["updated_by_id"], name: "index_gazetteers_on_updated_by_id" end @@ -1057,10 +1063,8 @@ t.integer "updated_by_id", null: false t.string "type", null: false t.decimal "cached_total_area" - t.geography "geography", limit: {:srid=>4326, :type=>"geometry", :has_z=>true, :has_m=>true, :geographic=>true} t.index "st_centroid(\nCASE type\n WHEN 'GeographicItem::MultiPolygon'::text THEN (multi_polygon)::geometry\n WHEN 'GeographicItem::Point'::text THEN (point)::geometry\n WHEN 'GeographicItem::LineString'::text THEN (line_string)::geometry\n WHEN 'GeographicItem::Polygon'::text THEN (polygon)::geometry\n WHEN 'GeographicItem::MultiLineString'::text THEN (multi_line_string)::geometry\n WHEN 'GeographicItem::MultiPoint'::text THEN (multi_point)::geometry\n WHEN 'GeographicItem::GeometryCollection'::text THEN (geometry_collection)::geometry\n ELSE NULL::geometry\nEND)", name: "idx_centroid", using: :gist t.index ["created_by_id"], name: "index_geographic_items_on_created_by_id" - t.index ["geography"], name: "index_geographic_items_on_geography" t.index ["geometry_collection"], name: "geometry_collection_gix", using: :gist t.index ["line_string"], name: "line_string_gix", using: :gist t.index ["multi_line_string"], name: "multi_line_string_gix", using: :gist From 7b7eb50cf997998b453af9f3e74a4e7864923fd4 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sat, 8 Jun 2024 23:43:04 -0500 Subject: [PATCH 009/259] #1954 Add Gazetteer closure_tree hierarchy table --- ...0240609040400_create_gazetteer_hierarchies.rb | 16 ++++++++++++++++ db/schema.rb | 10 +++++++++- lib/export/project_data.rb | 1 + 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20240609040400_create_gazetteer_hierarchies.rb diff --git a/db/migrate/20240609040400_create_gazetteer_hierarchies.rb b/db/migrate/20240609040400_create_gazetteer_hierarchies.rb new file mode 100644 index 0000000000..48cfc32fd1 --- /dev/null +++ b/db/migrate/20240609040400_create_gazetteer_hierarchies.rb @@ -0,0 +1,16 @@ +class CreateGazetteerHierarchies < ActiveRecord::Migration[7.1] + def change + create_table :gazetteer_hierarchies, id: false do |t| + t.integer :ancestor_id, null: false + t.integer :descendant_id, null: false + t.integer :generations, null: false + end + + add_index :gazetteer_hierarchies, [:ancestor_id, :descendant_id, :generations], + unique: true, + name: "gazetteer_anc_desc_idx" + + add_index :gazetteer_hierarchies, [:descendant_id], + name: "gazetteer_desc_idx" + end +end diff --git a/db/schema.rb b/db/schema.rb index 83727d115c..2f8669d2d2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_06_04_160009) do +ActiveRecord::Schema[7.1].define(version: 2024_06_09_040400) do # These are extensions that must be enabled in order to support this database enable_extension "btree_gin" enable_extension "fuzzystrmatch" @@ -954,6 +954,14 @@ t.index ["updated_by_id"], name: "index_field_occurrences_on_updated_by_id" end + create_table "gazetteer_hierarchies", id: false, force: :cascade do |t| + t.integer "ancestor_id", null: false + t.integer "descendant_id", null: false + t.integer "generations", null: false + t.index ["ancestor_id", "descendant_id", "generations"], name: "gazetteer_anc_desc_idx", unique: true + t.index ["descendant_id"], name: "gazetteer_desc_idx" + end + create_table "gazetteers", force: :cascade do |t| t.integer "geographic_item_id", null: false t.integer "parent_id" diff --git a/lib/export/project_data.rb b/lib/export/project_data.rb index da19ec18ae..84299bac45 100644 --- a/lib/export/project_data.rb +++ b/lib/export/project_data.rb @@ -3,6 +3,7 @@ module ::Export::ProjectData # When adding a new table be sure to check there is nothing different compared to existing ones. HIERARCHIES = [ ['container_item_hierarchies', 'container_items'], + ['gazetteer_hierarchies', 'gazetteers'], ['lead_hierarchies', 'leads'], ['geographic_area_hierarchies', 'geographic_areas'], ['taxon_name_hierarchies', 'taxon_names'] From da7580552e6c8578a7423e63591a8ac972971f87 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sat, 8 Jun 2024 14:06:20 -0500 Subject: [PATCH 010/259] #1954 Add ability to create basic Gazetteer using leaflet --- app/controllers/gazetteers_controller.rb | 7 +- .../vue/routes/endpoints/Gazetteer.js | 14 +++ app/javascript/vue/routes/endpoints/index.js | 1 + .../tasks/gazetteers/new_gazetteer/App.vue | 115 +++++++++++++++++- .../new_gazetteer/components/DisplayList.vue | 80 ++++++++++++ .../components/GeographicItem.vue | 61 ++++++++++ app/models/gazetteer.rb | 2 + app/models/geographic_item.rb | 9 +- 8 files changed, 283 insertions(+), 6 deletions(-) create mode 100644 app/javascript/vue/routes/endpoints/Gazetteer.js create mode 100644 app/javascript/vue/tasks/gazetteers/new_gazetteer/components/DisplayList.vue create mode 100644 app/javascript/vue/tasks/gazetteers/new_gazetteer/components/GeographicItem.vue diff --git a/app/controllers/gazetteers_controller.rb b/app/controllers/gazetteers_controller.rb index b705ff6801..09d9d01827 100644 --- a/app/controllers/gazetteers_controller.rb +++ b/app/controllers/gazetteers_controller.rb @@ -45,7 +45,10 @@ def create respond_to do |format| if @gazetteer.save format.html { redirect_to gazetteer_url(@gazetteer), notice: "Gazetteer was successfully created." } - format.json { render :show, status: :created, location: @gazetteer } + format.json { + render :show, status: :created, location: @gazetteer + flash[:notice] = 'Gazetteer created.' + } else format.html { render :new, status: :unprocessable_entity } format.json { render json: @gazetteer.errors, status: :unprocessable_entity } @@ -83,6 +86,6 @@ def set_gazetteer end def gazetteer_params - params.require(:gazetteer).permit(:name, :parent_id, :iso_3166_a2, :iso_3166_a3) + params.require(:gazetteer).permit(:name, :parent_id, :iso_3166_a2, :iso_3166_a3, geographic_item_attributes: {}) end end diff --git a/app/javascript/vue/routes/endpoints/Gazetteer.js b/app/javascript/vue/routes/endpoints/Gazetteer.js new file mode 100644 index 0000000000..a8bc62c312 --- /dev/null +++ b/app/javascript/vue/routes/endpoints/Gazetteer.js @@ -0,0 +1,14 @@ +import baseCRUD from './base' +import AjaxCall from '@/helpers/ajaxCall.js' + +const controller = 'gazetteers' +const permitParams = { + gazetteer: { + name: String, + geographic_item_attributes: { shape: Object } + } +} + +export const Gazetteer = { + ...baseCRUD(controller, permitParams) +} \ No newline at end of file diff --git a/app/javascript/vue/routes/endpoints/index.js b/app/javascript/vue/routes/endpoints/index.js index d48b43d3b7..a59fe913ab 100644 --- a/app/javascript/vue/routes/endpoints/index.js +++ b/app/javascript/vue/routes/endpoints/index.js @@ -26,6 +26,7 @@ export { Documentation } from './Documentation' export { Download } from './Download' export { DwcOcurrence } from './DwcOcurrence' export { Extract } from './Extract' +export { Gazetteer } from './Gazetteer' export { GeographicArea } from './GeographicArea' export { Georeference } from './Georeference' export { Identifier } from './Identifier' diff --git a/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue b/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue index 984dea42bf..ce3f2caf74 100644 --- a/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue +++ b/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue @@ -1,6 +1,117 @@ \ No newline at end of file +import ConfirmationModal from '@/components/ConfirmationModal.vue' +import GeographicItem from './components/GeographicItem.vue' +import NavBar from '@/components/layout/NavBar.vue' +import RadialAnnotator from '@/components/radials/annotator/annotator.vue' +import RadialNavigator from '@/components/radials/navigation/radial.vue' +import VBtn from '@/components/ui/VBtn/index.vue' +import VPin from '@/components/ui/Button/ButtonPin.vue' +import { Gazetteer } from '@/routes/endpoints' +import { ref } from 'vue' + +const gaz = ref({}) +const name = ref('') + +let leafletShapes = [] + +function saveGaz() { + let shape = leafletShapes.value[0] + shape.properties.data_type = 'geography' + const gazetteer = { + name: name.value, + geographic_item_attributes: { shape: JSON.stringify(shape) }, + } + + Gazetteer.create({ gazetteer }) + .then(() => {}) + .catch(() => {}) +} + +function cloneGaz() {} + +function reset() {} + + + + \ No newline at end of file diff --git a/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/DisplayList.vue b/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/DisplayList.vue new file mode 100644 index 0000000000..a9f0047501 --- /dev/null +++ b/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/DisplayList.vue @@ -0,0 +1,80 @@ + + + \ No newline at end of file diff --git a/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/GeographicItem.vue b/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/GeographicItem.vue new file mode 100644 index 0000000000..5e472af164 --- /dev/null +++ b/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/GeographicItem.vue @@ -0,0 +1,61 @@ + + + + + \ No newline at end of file diff --git a/app/models/gazetteer.rb b/app/models/gazetteer.rb index c7e9a52b82..6322dc42c3 100644 --- a/app/models/gazetteer.rb +++ b/app/models/gazetteer.rb @@ -42,4 +42,6 @@ class Gazetteer < ApplicationRecord validates :name, presence: true, length: {minimum: 1} + accepts_nested_attributes_for :geographic_item + end diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 9e3c145d13..21fc7d58a6 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -852,6 +852,8 @@ def eval_for_type(type_name) retval += '::MultiLineString' when 'MULTIPOINT' retval += '::MultiPoint' + when 'GEOGRAPHY' + retval += '::Geography' else retval = nil end @@ -1140,13 +1142,15 @@ def shape=(value) this_type = nil - if geom.respond_to?(:geometry_type) + if geom.respond_to?(:properties) && geom.properties['data_type'].present? + this_type = geom.properties['data_type'] + elsif geom.respond_to?(:geometry_type) this_type = geom.geometry_type.to_s elsif geom.respond_to?(:geometry) this_type = geom.geometry.geometry_type.to_s else end - +byebug self.type = GeographicItem.eval_for_type(this_type) unless geom.nil? if type.blank? @@ -1162,6 +1166,7 @@ def shape=(value) object = Gis::FACTORY.parse_wkt(s) rescue RGeo::Error::InvalidGeometry errors.add(:self, 'Shape value is an Invalid Geometry') + return end write_attribute(this_type.underscore.to_sym, object) From ab8df22ebad263ae44148da5b936d6108d5ee8eb Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Thu, 13 Jun 2024 22:03:49 -0500 Subject: [PATCH 011/259] #1954 Non-functional updates to GeographicItem.rb Including TODOs for my own reference --- app/models/geographic_item.rb | 134 +++++++++++++++++++--------------- 1 file changed, 77 insertions(+), 57 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 21fc7d58a6..ab80093d0d 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -205,8 +205,9 @@ def lat_long_sql(choice) return nil unless [:latitude, :longitude].include?(choice) f = "'D.DDDDDD'" # TODO: probably a constant somewhere v = (choice == :latitude ? 1 : 2) - "CASE geographic_items.type - WHEN 'GeographicItem::GeometryCollection' THEN split_part(ST_AsLatLonText(ST_Centroid" \ + + 'CASE geographic_items.type ' \ + "WHEN 'GeographicItem::GeometryCollection' THEN split_part(ST_AsLatLonText(ST_Centroid" \ "(geometry_collection::geometry), #{f}), ' ', #{v}) WHEN 'GeographicItem::LineString' THEN split_part(ST_AsLatLonText(ST_Centroid(line_string::geometry), " \ "#{f}), ' ', #{v}) @@ -232,8 +233,8 @@ def within_radius_of_item_sql(geographic_item_id, distance) # @param [Integer] geographic_item_id - # @param [Number] distance in meters to grow/shrink the shape (negative allowed) # @param [Number] distance (in meters) (positive only?!) + # @param [Number] buffer: distance in meters to grow/shrink the shapes checked against (negative allowed) # @return [String] def st_buffer_st_within(geographic_item_id, distance, buffer = 0) "ST_DWithin( @@ -242,7 +243,6 @@ def st_buffer_st_within(geographic_item_id, distance, buffer = 0) )" end - # TODO: 3D is overkill here # @param [String] wkt # @param [Integer] distance (meters) @@ -322,7 +322,7 @@ def is_contained_by_sql(column_name, geographic_item) retval end - # @param [Interger, Array of Integer] geographic_item_ids + # @param [Integer, Array of Integer] geographic_item_ids # @return [String] # a select query that returns a single geometry (column name 'single_geometry' for the collection of ids # provided via ST_Collect) @@ -337,7 +337,7 @@ def st_collect_sql(*geographic_item_ids) AS f", geographic_item_ids]) end - # @param [Interger, Array of Integer] geographic_item_ids + # @param [Integer, Array of Integer] geographic_item_ids # @return [String] # returns one or more geographic items combined as a single geometry in column 'single' def single_geometry_sql(*geographic_item_ids) @@ -345,7 +345,7 @@ def single_geometry_sql(*geographic_item_ids) '(SELECT single.single_geometry FROM (' + a + ' ) AS single)' end - # @param [Interger, Array of Integer] geographic_item_ids + # @param [Integer, Array of Integer] geographic_item_ids # @return [String] # returns a single geometry "column" (paren wrapped) as "single" for multiple geographic item ids, or the # geometry as 'geometry' for a single id @@ -358,8 +358,14 @@ def geometry_sql2(*geographic_item_ids) end end - # @param [Interger, Array of Integer] geographic_item_ids + # @param [Integer, Array of Integer] geographic_item_ids # @return [String] + # TODO why does GEOMETRY_SQL.to_sql fail here? + # TODO why was geometrycollection not included here? st_coveredby suports + # it (as of 3.0) + # TODO if old versions of pgis are allowed then do we need to exclude + # geometrycollection from the geography case? + # TODO what happens here when type /is/ geo collection? def containing_where_sql(*geographic_item_ids) "ST_CoveredBy( #{GeographicItem.geometry_sql2(*geographic_item_ids)}, @@ -370,12 +376,18 @@ def containing_where_sql(*geographic_item_ids) WHEN 'GeographicItem::Polygon' THEN polygon::geometry WHEN 'GeographicItem::MultiLineString' THEN multi_line_string::geometry WHEN 'GeographicItem::MultiPoint' THEN multi_point::geometry + WHEN 'GeographicItem::GeometryCollection' THEN geometry_collection::geometry + WHEN 'GeographicItem::Geography' THEN geography::geometry END)" end - # @param [Interger, Array of Integer] geographic_item_ids + # TODO Does this work? Looks like the first parameter is a + # geometry, the second is a geography + # @param [Integer, Array of Integer] geographic_item_ids # @return [String] def containing_where_sql_geog(*geographic_item_ids) + # TODO Does this work? Looks like the first parameter is a + # geometry, the second is a geography "ST_CoveredBy( #{GeographicItem.geometry_sql2(*geographic_item_ids)}, CASE geographic_items.type @@ -427,6 +439,7 @@ def contained_by_with_antimeridian_check(*ids) # @params [String] well known text # @return [String] the SQL fragment for the specific geometry type, shifted by longitude # Note: this routine is called when it is already known that the A argument crosses anti-meridian + # TODO If wkt coords are in the range 0..360 and GI coords are in the range -180..180 (or vice versa), doesn't this fail? Don't you want all coords in the range 0..360 in this geometry case? Is there any assumption about range of inputs for georefs, e.g.? are they always normalized? See anti-meridian spec? def contained_by_wkt_shifted_sql(wkt) "ST_Contains(ST_ShiftLongitude(ST_GeomFromText('#{wkt}', 4326)), ( CASE geographic_items.type @@ -463,9 +476,9 @@ def contained_by_wkt_sql(wkt) retval end - # @param [Interger, Array of Integer] geographic_item_ids - # @return [String] sql for contained_by via ST_ContainsProperly - # Note: Can not use GEOMETRY_SQL because geometry_collection is not supported in ST_ContainsProperly + # @param [Integer, Array of Integer] geographic_item_ids + # @return [String] sql for contained_by via ST_Contains + # Note: Can not use GEOMETRY_SQL because geometry_collection is not supported in older versions of ST_Contains # Note: !! If the target GeographicItem#id crosses the anti-meridian then you may/will get unexpected results. def contained_by_where_sql(*geographic_item_ids) "ST_Contains( @@ -491,15 +504,14 @@ def containing_where_for_point_sql(rgeo_point) )" end - # @param [Interger] geographic_item_id + # @param [Integer] geographic_item_id # @return [String] SQL for geometries - # example, not used def geometry_for_sql(geographic_item_id) 'SELECT ' + GeographicItem::GEOMETRY_SQL.to_sql + ' AS geometry FROM geographic_items WHERE id = ' \ "#{geographic_item_id} LIMIT 1" end - # @param [Interger, Array of Integer] geographic_item_ids + # @param [Integer, Array of Integer] geographic_item_ids # @return [String] SQL for geometries # example, not used def geometry_for_collection_sql(*geographic_item_ids) @@ -511,17 +523,17 @@ def geometry_for_collection_sql(*geographic_item_ids) # Scopes # - # @param [Interger, Array of Integer] geographic_item_ids + # @param [Integer, Array of Integer] geographic_item_ids # @return [Scope] - # the geographic items containing these collective geographic_item ids, not including self + # the geographic items containing all of the geographic_item ids; return value never includes geographic_item_ids def containing(*geographic_item_ids) where(GeographicItem.containing_where_sql(geographic_item_ids)).not_ids(*geographic_item_ids) end - # @param [Interger, Array of Integer] geographic_item_ids + # @param [Integer, Array of Integer] geographic_item_ids # @return [Scope] - # the geographic items contained by any of these geographic_item ids, not including self - # (works via ST_ContainsProperly) + # the geographic items contained by the union of these geographic_item ids; return value always includes geographic_item_ids + # (works via ST_Contains) def contained_by(*geographic_item_ids) where(GeographicItem.contained_by_where_sql(geographic_item_ids)) end @@ -652,6 +664,9 @@ def are_contained_in_item_by_id(column_name, *geographic_item_ids) # = containin when 'any' part = [] DATA_TYPES.each { |column| + # TODO need to check geography column type here + # TODO how does empty return on g_c not cause a problem? (is there + # currently any way to produce a GeoItem of type g_c? Test entering g_c WKT in georeference) unless column == :geometry_collection part.push(GeographicItem.are_contained_in_item_by_id(column.to_s, geographic_item_ids).to_a) end @@ -661,6 +676,7 @@ def are_contained_in_item_by_id(column_name, *geographic_item_ids) # = containin when 'any_poly', 'any_line' part = [] DATA_TYPES.each { |column| + # TODO Need to handle geography's type here if column.to_s.index(column_name.gsub('any_', '')) part.push(GeographicItem.are_contained_in_item_by_id("#{column}", geographic_item_ids).to_a) end @@ -741,6 +757,7 @@ def is_contained_by(column_name, *geographic_items) part = [] DATA_TYPES.each { |column| unless column == :geometry_collection + # TODO this needs to check geography column's type if column.to_s.index(column_name.gsub('any_', '')) part.push(GeographicItem.is_contained_by(column.to_s, geographic_items).to_a) end @@ -896,7 +913,7 @@ def quick_geographic_name_hierarchy h = ga.geographic_name_classification # not quick enough !! return h if h.present? end - return {} + return {} end # @return [Hash] @@ -911,7 +928,8 @@ def inferred_geographic_name_hierarchy .ordered_by_area .limit(1) .first - return small_area.geographic_name_classification + + small_area.geographic_name_classification else {} end @@ -1130,48 +1148,46 @@ def to_geo_json_feature # # @return [Boolean, RGeo object] def shape=(value) + return if value.blank? - if value.present? - - begin - geom = RGeo::GeoJSON.decode(value, json_parser: :json, geo_factory: Gis::FACTORY) - rescue RGeo::Error::InvalidGeometry => e - errors.add(:base, "invalid geometry: #{e.to_s}") - return - end + begin + geom = RGeo::GeoJSON.decode(value, json_parser: :json, geo_factory: Gis::FACTORY) + rescue RGeo::Error::InvalidGeometry => e + errors.add(:base, "invalid geometry: #{e.to_s}") + return + end - this_type = nil + this_type = nil - if geom.respond_to?(:properties) && geom.properties['data_type'].present? - this_type = geom.properties['data_type'] - elsif geom.respond_to?(:geometry_type) - this_type = geom.geometry_type.to_s - elsif geom.respond_to?(:geometry) - this_type = geom.geometry.geometry_type.to_s - else - end -byebug - self.type = GeographicItem.eval_for_type(this_type) unless geom.nil? + if geom.respond_to?(:properties) && geom.properties['data_type'].present? + this_type = geom.properties['data_type'] + elsif geom.respond_to?(:geometry_type) + this_type = geom.geometry_type.to_s + elsif geom.respond_to?(:geometry) + this_type = geom.geometry.geometry_type.to_s + else + end - if type.blank? - errors.add(:base, 'type is not set from shape') - return - end + self.type = GeographicItem.eval_for_type(this_type) unless geom.nil? - object = nil + if type.blank? + errors.add(:base, 'type is not set from shape') + return + end - s = geom.respond_to?(:geometry) ? geom.geometry.to_s : geom.to_s + object = nil - begin - object = Gis::FACTORY.parse_wkt(s) - rescue RGeo::Error::InvalidGeometry - errors.add(:self, 'Shape value is an Invalid Geometry') - return - end + s = geom.respond_to?(:geometry) ? geom.geometry.to_s : geom.to_s - write_attribute(this_type.underscore.to_sym, object) - geom + begin + object = Gis::FACTORY.parse_wkt(s) + rescue RGeo::Error::InvalidGeometry + errors.add(:self, 'Shape value is an Invalid Geometry') + return end + + write_attribute(this_type.underscore.to_sym, object) + geom end # @return [String] @@ -1235,6 +1251,7 @@ def orientations ApplicationRecord.connection.execute("SELECT ST_IsPolygonCCW(polygon::geometry) as is_ccw \ FROM geographic_items where id = #{id};").collect{|a| a['is_ccw']} else + # TODO need to handle geography's types here [] end end @@ -1252,7 +1269,7 @@ def is_basic_donut? end def st_isvalid - r = ApplicationRecord.connection.execute( "SELECT ST_IsValid( #{GeographicItem::GEOMETRY_SQL.to_sql }) from geographic_items where geographic_items.id = #{id}").first['st_isvalid'] + ApplicationRecord.connection.execute( "SELECT ST_IsValid( #{GeographicItem::GEOMETRY_SQL.to_sql }) from geographic_items where geographic_items.id = #{id}").first['st_isvalid'] end def st_isvalidreason @@ -1268,6 +1285,8 @@ def has_polygons? def align_winding if orientations.flatten.include?(false) + # TODO type digs through geography, but the sql needs to reference + # geography column case type when 'multi_polygon' ApplicationRecord.connection.execute( @@ -1288,7 +1307,7 @@ def align_winding # to a png def self.debug_draw(geographic_item_ids = []) return false if geographic_item_ids.empty? - + # TODO why does this only reference multi_polygon? sql = "SELECT ST_AsPNG( ST_AsRaster( (SELECT ST_Union(multi_polygon::geometry) from geographic_items where id IN (" + geographic_item_ids.join(',') + ")), 1920, 1080 @@ -1483,6 +1502,7 @@ def some_data_is_provided errors.add(object, 'More than one shape type provided') end end + # TODO should this be false? true end end From e66035d3a952704279cf2c95cd06ccc7b51a92e7 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Wed, 12 Jun 2024 19:06:31 -0500 Subject: [PATCH 012/259] #1954 Clarify `select_geography_sql` and st_distance vs st_distance_spheroid Apologies for getting somewhat opinionated here, I hope I'm not too far off... The name and the description of `select_geography_sql` (as well as comparison with `select_geometry_sql`) suggest that the return value should be of type `geography` (it was previously being cast to type geometry). But then I'm confused about `st_distance` vs `st_distance_spheroid`: * in `st_distance_spheroid` we cast geography types to geometry types, but then do distance calculations on a spheroid. * in `st_distance`, prior to the `select_geography_sql` change here, we were calculating planar "distance" between two geometry arguments and then converting it to meters - but the use of `select_geography_sql` in that function suggests that what was intended was to calculate `ST_Distance` between two geography points, which is a spheroidal calculation. So I would expect the same answer in both cases, which is what I'm getting here. As to which is faster or slower, I can't say, but `st_distance` seems more natural to me, though `st_distance_spheroid` is the one that's used in other places besides specs. If we really wanted a faster, less accurate calculation I think it would be `st_distance` between two geometry types (and then converting to meters? I'm unclear there) - should one of these be converted to that? --- app/models/geographic_item.rb | 10 +++++----- spec/models/geographic_item_spec.rb | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index ab80093d0d..06a860ebb2 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -194,7 +194,7 @@ def select_geometry_sql(geographic_item_id) # a SQL select statement that returns the geography for the geographic_item with the specified id def select_geography_sql(geographic_item_id) ActiveRecord::Base.send(:sanitize_sql_for_conditions, [ - "SELECT #{GeographicItem::GEOMETRY_SQL.to_sql} from geographic_items where geographic_items.id = ?", + "SELECT #{GeographicItem::GEOGRAPHY_SQL} from geographic_items where geographic_items.id = ?", geographic_item_id]) end @@ -983,15 +983,15 @@ def centroid end # @param [Integer] geographic_item_id - # @return [Double] distance in meters (slower, more accurate) + # @return [Double] distance in meters def st_distance(geographic_item_id) # geo_object q1 = "ST_Distance((#{GeographicItem.select_geography_sql(id)}), " \ "(#{GeographicItem.select_geography_sql(geographic_item_id)})) as d" _q2 = ActiveRecord::Base.send(:sanitize_sql_array, ['ST_Distance((?),(?)) as d', GeographicItem.select_geography_sql(self.id), GeographicItem.select_geography_sql(geographic_item_id)]) - deg = GeographicItem.where(id:).pluck(Arel.sql(q1)).first - deg * Utilities::Geo::ONE_WEST + + GeographicItem.where(id:).pluck(Arel.sql(q1)).first end # @param [GeographicItem] geographic_item @@ -1016,7 +1016,7 @@ def st_distance_to_geographic_item(geographic_item) alias_method :distance_to, :st_distance # @param [Integer] geographic_item_id - # @return [Double] distance in meters (faster, less accurate) + # @return [Double] distance in meters def st_distance_spheroid(geographic_item_id) q1 = "ST_DistanceSpheroid((#{GeographicItem.select_geometry_sql(id)})," \ "(#{GeographicItem.select_geometry_sql(geographic_item_id)}),'#{Gis::SPHEROID}') as distance" diff --git a/spec/models/geographic_item_spec.rb b/spec/models/geographic_item_spec.rb index d6710fbf74..7d5262d428 100644 --- a/spec/models/geographic_item_spec.rb +++ b/spec/models/geographic_item_spec.rb @@ -1091,7 +1091,7 @@ context 'distance to others' do specify 'slow' do - expect(p1.st_distance(p2.id)).to be_within(0.1).of(497835.8972059313) + expect(p1.st_distance(p2.id)).to be_within(0.1).of(479988.25399881) end specify 'fast' do From c3fcd1c0db24a6e8f50889923d5bf2f043d13e7a Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Wed, 12 Jun 2024 11:27:59 -0500 Subject: [PATCH 013/259] #1954 Mark some GeographicItem functions as deprecated These are function that are either completely unreferenced or only referenced in specs that only test the deprecated function itself (i.e. not used to test some aspect of GeoItem other than the function itself). I haven't done anything with "spec helpers", i.e. functions that are used only in specs, in service of testing aspects of GeometricItem other than the helper function itself: * `ordered_by_shortest_distance_from * `ordered_by_longest_distance_from * `with_is_valid_geometry_column` * `start_point` * `st_distance_spheroid` * `st_npoints` --- app/models/geographic_item.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 06a860ebb2..937d0ecded 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -144,6 +144,7 @@ def st_collect(geographic_item_scope) .where(id: geographic_item_scope.pluck(:id)) end + # DEPRECATED # @return [GeographicItem::ActiveRecord_Relation] # @params [Array] array of geographic area ids def default_by_geographic_area_ids(geographic_area_ids = []) @@ -163,6 +164,7 @@ def crosses_anti_meridian?(wkt) ).first.r end + # DEPRECATED # @param [Integer] ids # @return [Boolean] # whether or not any GeographicItem passed intersects the anti-meridian @@ -224,6 +226,7 @@ def lat_long_sql(choice) END as #{choice}" end + # DEPRECATED # @param [Integer] geographic_item_id # @param [Integer] distance # @return [String] @@ -289,6 +292,7 @@ def geometry_sql(geographic_item_id = nil, source_column_name = nil) "where geom_alias_tbl.id = #{geographic_item_id}" end + # DEPRECATED # rubocop:disable Metrics/MethodLength # @param [String] column_name # @param [GeographicItem] geographic_item @@ -381,6 +385,7 @@ def containing_where_sql(*geographic_item_ids) END)" end + # DEPRECATED # TODO Does this work? Looks like the first parameter is a # geometry, the second is a geography # @param [Integer, Array of Integer] geographic_item_ids @@ -400,6 +405,7 @@ def containing_where_sql_geog(*geographic_item_ids) END)" end + # DEPRECATED # @param [Interger, Array of Integer] ids # @return [Array] # If we detect that some query id has crossed the meridian, then loop through @@ -546,6 +552,7 @@ def containing_point(rgeo_point) where(GeographicItem.containing_where_for_point_sql(rgeo_point)) end + # DEPRECATED # @return [Scope] # adds an area_in_meters field, with meters def with_area @@ -586,11 +593,13 @@ def with_collecting_event_through_georeferences ).distinct end + # DEPRECATED # @return [Scope] include a 'latitude' column def with_latitude select(lat_long_sql(:latitude)) end + # DEPRECATED # @return [Scope] include a 'longitude' column def with_longitude select(lat_long_sql(:longitude)) @@ -625,6 +634,7 @@ def within_radius_of_item(geographic_item_id, distance) where(within_radius_of_item_sql(geographic_item_id, distance)) end + # DEPRECATED # @param [String, GeographicItem] # @return [Scope] # a SQL fragment for ST_DISJOINT, specifies all geographic_items that have data in column_name @@ -699,6 +709,7 @@ def are_contained_in_item_by_id(column_name, *geographic_item_ids) # = containin # rubocop:enable Metrics/MethodLength + # DEPRECATED # @param [String] column_name # @param [String] geometry of WKT # @return [Scope] @@ -982,6 +993,7 @@ def centroid return Gis::FACTORY.parse_wkt(self.st_centroid) end + # DEPRECATED # @param [Integer] geographic_item_id # @return [Double] distance in meters def st_distance(geographic_item_id) # geo_object @@ -1013,6 +1025,7 @@ def st_distance_to_geographic_item(geographic_item) ActiveRecord::Base.connection.select_value("SELECT ST_Distance(#{a}, #{b})") end + # DEPRECATED alias_method :distance_to, :st_distance # @param [Integer] geographic_item_id @@ -1087,12 +1100,14 @@ def intersects?(target_geo_object) self.geo_object.intersects?(target_geo_object) end + # DEPRECATED # @param [geo_object, Double] # @return [Boolean] def near(target_geo_object, distance) self.geo_object.unsafe_buffer(distance).contains?(target_geo_object) end + # DEPRECATED # @param [geo_object, Double] # @return [Boolean] def far(target_geo_object, distance) @@ -1117,6 +1132,7 @@ def to_geo_json "FROM geographic_items WHERE id=#{id};")['a']) end + # DEPRECATED # We don't need to serialize to/from JSON def to_geo_json_string GeographicItem.connection.select_one( @@ -1212,6 +1228,7 @@ def area a end + # DEPRECATED # @return [Float, false] # the value in square meters of the interesecting area of this and another GeographicItem def intersecting_area(geographic_item_id) @@ -1276,6 +1293,7 @@ def st_isvalidreason r = ApplicationRecord.connection.execute( "SELECT ST_IsValidReason( #{GeographicItem::GEOMETRY_SQL.to_sql }) from geographic_items where geographic_items.id = #{id}").first['st_isvalidreason'] end + # DEPRECATED # !! Unused. Doesn't check Geometry collection def has_polygons? ['GeographicItem::MultiPolygon', 'GeographicItem::Polygon'].include?(self.type) From d4694a919f9fbecaa73abe3391a9e2732aaf1ba5 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Wed, 12 Jun 2024 16:55:04 -0500 Subject: [PATCH 014/259] #1954 Duplicate GeographicItem specs to prepare for geography column specs --- spec/models/geographic_item_spec.rb | 1246 +++++++++++++++++++++++++-- 1 file changed, 1181 insertions(+), 65 deletions(-) diff --git a/spec/models/geographic_item_spec.rb b/spec/models/geographic_item_spec.rb index 7d5262d428..911f035ea2 100644 --- a/spec/models/geographic_item_spec.rb +++ b/spec/models/geographic_item_spec.rb @@ -3,77 +3,1192 @@ describe GeographicItem, type: :model, group: [:geo, :shared_geo] do include_context 'stuff for complex geo tests' - let(:geographic_item) { GeographicItem.new } - - let(:geo_json) { - '{ - "type": "Feature", - "geometry": { - "type": "Point", - "coordinates": [10, 10] - }, - "properties": { - "name": "Sample Point", - "description": "This is a sample point feature." - } - }' - } - - let(:geo_json2) { - '{ - "type": "Feature", - "geometry": { - "type": "Point", - "coordinates": [20, 20] - }, - "properties": { - "name": "Sample Point", - "description": "This is a sample point feature." + context 'a column for each shape type' do + let(:geographic_item) { GeographicItem.new } + + let(:geo_json) { + '{ + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [10, 10] + }, + "properties": { + "name": "Sample Point", + "description": "This is a sample point feature." + } + }' + } + + let(:geo_json2) { + '{ + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [20, 20] + }, + "properties": { + "name": "Sample Point", + "description": "This is a sample point feature." + } + }' + } + + specify '#shape=' do + g = GeographicItem.new(shape: geo_json) + expect(g.save).to be_truthy + end + + specify '#shape= 2' do + g = GeographicItem.create!(shape: geo_json) + g.update(shape: geo_json2) + expect(g.reload.geo_object.to_s).to match(/20/) + end + + specify '#shape= bad linear ring' do + bad = '{ + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-80.498221, 25.761437], + [-80.498221, 25.761959], + [-80.498221, 25.761959], + [-80.498221, 25.761437] + ] + ] + }, + "properties": {} + }' + + g = GeographicItem.new(shape: bad) + g.valid? + expect(g.errors[:base]).to be_present + end + + context 'using ce_test_objects' do + let(:geographic_item) { FactoryBot.build(:geographic_item) } + let(:geographic_item_with_point_a) { FactoryBot.build(:geographic_item_with_point_a) } + let(:geographic_item_with_point_b) { FactoryBot.build(:geographic_item_with_point_b) } + let(:geographic_item_with_point_c) { FactoryBot.build(:geographic_item_with_point_c) } + let(:geographic_item_with_line_string) { FactoryBot.build(:geographic_item_with_line_string) } + let(:geographic_item_with_polygon) { FactoryBot.build(:geographic_item_with_polygon) } + let(:geographic_item_with_multi_polygon) { FactoryBot.build(:geographic_item_with_multi_polygon) } + +=begin + context 'database functions' do + + specify 'ST_Geometry_Same' do + skip + #expect(GeographicItem.same(geographic_item_with_line_string.geo_object, + # geographic_item_with_line_string.geo_object)).to be_truthy + #expect(GeographicItem.same(geographic_item_with_line_string.geo_object, + # geographic_item_with_polygon.geo_object)).to be_falsey + end + + specify 'ST_Area' do + skip + #expect(GeographicItem.area(geographic_item_with_polygon.geo_object)).to eq 0.123 + end + + specify 'ST_Azimuth' do + skip + #expect(GeographicItem.azimuth(geographic_item_with_point_a.geo_object, + # geographic_item_with_point_b.geo_object)).to eq 44.5 + #expect(GeographicItem.azimuth(geographic_item_with_point_b.geo_object, + # geographic_item_with_point_a.geo_object)).to eq 44.5 + #expect(GeographicItem.azimuth(geographic_item_with_point_a.geo_object, + # geographic_item_with_point_a.geo_object)).to eq 44.5 + end + + specify 'ST_Centroid' do + skip + #expect(GeographicItem.centroid(geographic_item_with_polygon.polygon)).to eq geographic_item_with_point_c + end + + specify 'ST_Contains' do + skip + #expect(GeographicItem.contains(geographic_item_with_polygon.geo_object, + # geographic_item_with_point_c.geo_object)).to be_truthy + #expect(GeographicItem.contains(geographic_item_with_point_c.geo_object, + # geographic_item_with_polygon.geo_object)).to be_falsey + #expect(GeographicItem.contains(geographic_item_with_polygon.geo_object, + # geographic_item_with_polygon.geo_object)).to be_truthy + end + + specify 'self.find_contains ' do + skip 'building a City of Champaign shape, and a point inside it' + end + + specify 'ST_ContainsProperly ' do + skip + #expect(GeographicItem.contains_properly(geographic_item_with_polygon.geo_object, + # geographic_item_with_point_c.geo_object)).to be_truthy + #expect(GeographicItem.contains_properly(geographic_item_with_point_c.geo_object, + # geographic_item_with_polygon.geo_object)).to be_falsey + end + + specify 'ST_Covers' do + skip + #expect(GeographicItem.covers(geographic_item_with_polygon.geo_object, + # geographic_item_with_point_c.geo_object)).to be_truthy + #expect(GeographicItem.covers(geographic_item_with_point_c.geo_object, + # geographic_item_with_polygon.geo_object)).to be_falsey + end + + specify 'ST_CoveredBy' do + skip + #expect(GeographicItem.covers(geographic_item_with_polygon.geo_object, + # geographic_item_with_point_c.geo_object)).to be_truthy + #expect(GeographicItem.covers(geographic_item_with_point_c.geo_object, + # geographic_item_with_polygon.geo_object)).to be_falsey + end + + specify 'ST_Crosses' do + skip + #expect(GeographicItem.covers(geographic_item_with_polygon.geo_object, + # geographic_item_with_point_c.geo_object)).to be_truthy + #expect(GeographicItem.covers(geographic_item_with_point_c.geo_object, + # geographic_item_with_polygon.geo_object)).to be_falsey + end + + specify 'ST_LineCrossingDirection' do + skip + #expect(GeographicItem.covers(geographic_item_with_polygon.geo_object, + # geographic_item_with_point_c.geo_object)).to be_truthy + #expect(GeographicItem.covers(geographic_item_with_point_c.geo_object, + # geographic_item_with_polygon.geo_object)).to be_falsey + end + + specify 'ST_Disjoint' do + skip + #expect(GeographicItem.covers(geographic_item_with_polygon.geo_object, + # geographic_item_with_point_c.geo_object)).to be_truthy + #expect(GeographicItem.covers(geographic_item_with_point_c.geo_object, + # geographic_item_with_polygon.geo_object)).to be_falsey + end + + specify 'ST_Distance' do + skip + #expect(GeographicItem.covers(geographic_item_with_polygon.geo_object, + # geographic_item_with_point_c.geo_object)).to be_truthy + #expect(GeographicItem.covers(geographic_item_with_point_c.geo_object, + # geographic_item_with_polygon.geo_object)).to be_falsey + end + + end +=end + + + # TODO: remove, redundant with single Factory use + specify 'Two different object types share the same factory.' do + # r is the result of an intersection + # p16 uses the config.default factory + expect(r.factory.projection_factory).to eq(p16_on_a.factory.projection_factory) + end + + context 'STI' do + context 'type is set before validation when column is provided (assumes type is null)' do + GeographicItem::DATA_TYPES.each do |t| + specify "for #{t}" do + geographic_item.send("#{t}=", simple_shapes[t]) + expect(geographic_item.valid?).to be_truthy + expect(geographic_item.type).to eq("GeographicItem::#{t.to_s.camelize}") + end + end + end + + context 'subclasses have a SHAPE_COLUMN set' do + GeographicItem.descendants.each do |d| + specify "for #{d}" do + expect(d::SHAPE_COLUMN).to be_truthy + end + end + end + + specify '#geo_object_type' do + expect(geographic_item).to respond_to(:geo_object_type) + end + + specify '#geo_object_type when item not saved' do + geographic_item.point = simple_shapes[:point] + expect(geographic_item.geo_object_type).to eq(:point) + end + end + + context 'validation' do + before(:each) { + geographic_item.valid? } - }' - } - specify '#shape=' do - g = GeographicItem.new(shape: geo_json) - expect(g.save).to be_truthy - end + specify 'some data must be provided' do + expect(geographic_item.errors[:base]).to be_present + end + + specify 'invalid data for point is invalid' do + geographic_item.point = 'Some string' + expect(geographic_item.valid?).to be_falsey + end + + specify 'a valid point is valid' do + expect(geographic_item_with_point_a.valid?).to be_truthy + end + + specify 'A good point that didn\'t change.' do + expect(geographic_item_with_point_a.point.x).to eq -88.241413 + end + + specify 'a point, when provided, has a legal geography' do + geographic_item.point = RSPEC_GEO_FACTORY.point(180.0, 85.0) + expect(geographic_item.valid?).to be_truthy + end + + specify 'One and only one of point, line_string, etc. is set.' do + geographic_item_with_point_a.polygon = geographic_item_with_polygon.polygon + expect(geographic_item_with_point_a.valid?).to be_falsey + end + end + + context 'geo_object interactions (Geographical attribute of GeographicItem)' do + + context 'Any line_string can be made into polygons.' do + specify 'non-closed line string' do + expect(RSPEC_GEO_FACTORY.polygon(list_k).to_s).to eq('POLYGON ((-33.0 -11.0 0.0, -33.0 -23.0 0.0, -21.0 -23.0 0.0, -21.0 -11.0 0.0, -27.0 -13.0 0.0, -33.0 -11.0 0.0))') + end + + specify 'closed line string' do + expect(RSPEC_GEO_FACTORY.polygon(d.geo_object).to_s).to eq('POLYGON ((-33.0 11.0 0.0, -24.0 4.0 0.0, -26.0 13.0 0.0, -38.0 14.0 0.0, -33.0 11.0 0.0))') + end + end + + specify 'That one object contains another, or not.' do + expect(k.contains?(p1.geo_object)).to be_truthy + end + + specify 'That one object contains another, or not.' do + expect(k.contains?(p17.geo_object)).to be_falsey + end + + specify 'That one object contains another, or not.' do + expect(p17.within?(k.geo_object)).to be_falsey + end + + specify 'That one object contains another, or not.' do + expect(p17.within?(k.geo_object)).to be_falsey + end + + specify 'That one object intersects another, or not.' do # using geographic_item.intersects? + expect(e1.intersects?(e2.geo_object)).to be_truthy + end + + specify 'That one object intersects another, or not.' do # using geographic_item.intersects? + expect(e1.intersects?(e3.geo_object)).to be_falsey + end + + specify 'That one object intersects another, or not.' do # using geographic_item.intersects? + expect(p1.intersects?(k.geo_object)).to be_truthy + end + + specify 'That one object intersects another, or not.' do # using geographic_item.intersects? + expect(p17.intersects?(k.geo_object)).to be_falsey + end + + specify 'Two polygons may have various intersections.' do + expect(shapeE1.intersects?(shapeE2)).to be_truthy + end + + specify 'Two polygons may have various intersections.' do + expect(shapeE1.intersects?(shapeE3)).to be_falsey + end + + specify 'Two polygons may have various intersections.' do + expect(shapeE1.overlaps?(shapeE2)).to be_truthy + end + + specify 'Two polygons may have various intersections.' do + expect(shapeE1.overlaps?(shapeE3)).to be_falsey + end + + specify 'Two polygons may have various intersections.' do + expect(shapeE1.intersection(shapeE2)).to eq(e1_and_e2) + end + + specify 'Two polygons may have various intersections.' do + expect(shapeE1.intersection(shapeE4)).to eq(e1_and_e4) + end + + specify 'Two polygons may have various intersections.' do + expect(shapeE1.union(shapeE2)).to eq(e1_or_e2) + end + + specify 'Two polygons may have various intersections.' do + expect(shapeE1.union(shapeE5)).to eq(e1_or_e5) + end + + specify 'Two polygons may have various adjacencies.' do + expect(shapeE1.touches?(shapeE5)).to be_falsey + end + + specify 'Two polygons may have various adjacencies.' do + expect(shapeE2.touches?(shapeE3)).to be_truthy + end + + specify 'Two polygons may have various adjacencies.' do + expect(shapeE2.touches?(shapeE5)).to be_falsey + end + + specify 'Two polygons may have various adjacencies.' do + expect(shapeE1.disjoint?(shapeE5)).to be_truthy + end + + specify 'Two polygons may have various adjacencies.' do + expect(shapeE2.disjoint?(shapeE5)).to be_truthy + end + + specify 'Two polygons may have various adjacencies.' do + expect(shapeE2.disjoint?(shapeE4)).to be_falsey + end + + specify 'Two different object types have various intersections.' do + # Now that these are the same factory the equivalence is the "same" + expect(r).to eq(p16_on_a) + end + + specify 'Two different object types have various intersections.' do + expect(l.geo_object.intersects?(k.geo_object)).to be_truthy + end + + specify 'Two different object types have various intersections.' do + expect(l.geo_object.intersects?(e.geo_object)).to be_falsey + end + + specify 'Two different object types have various intersections.' do + expect(f.geo_object.geometry_n(0).intersection(f.geo_object.geometry_n(1))).to be_truthy + end + + specify 'Objects can be related by distance' do + expect(p17.geo_object.distance(k.geo_object)).to be < p10.geo_object.distance(k.geo_object) + end + + specify 'Objects can be related by distance' do + expect(k.near(p1.geo_object, 0)).to be_truthy + end + + specify 'Objects can be related by distance' do + expect(k.near(p17.geo_object, 2)).to be_truthy + end + + specify 'Objects can be related by distance' do + expect(k.near(p10.geo_object, 5)).to be_falsey + end + + specify 'Objects can be related by distance' do + expect(k.far(p1.geo_object, 0)).to be_falsey + end + + specify 'Objects can be related by distance' do + expect(k.far(p17.geo_object, 1)).to be_truthy + end + + specify 'Objects can be related by distance' do + expect(k.far(p10.geo_object, 5)).to be_truthy + end + + specify 'Outer Limits' do + expect(all_items.geo_object.convex_hull()).to eq(convex_hull) + end + end + + context 'That GeographicItems provide certain methods.' do + before { + geographic_item.point = room2024 + geographic_item.valid? + } + specify 'self.geo_object returns stored data' do + expect(geographic_item.save!).to be_truthy + end + + specify 'self.geo_object returns stored data' do + geographic_item.save! + _geo_id = geographic_item.id + expect(geographic_item.geo_object).to eq(room2024) + end + + specify 'self.geo_object returns stored data' do + geographic_item.save! + geo_id = geographic_item.id + expect(GeographicItem.find(geo_id).geo_object).to eq geographic_item.geo_object + end + end + + context 'instance methods' do + specify '#geo_object' do + expect(geographic_item).to respond_to(:geo_object) + end + + specify '#contains? - to see if one object is contained by another.' do + expect(geographic_item).to respond_to(:contains?) + end + + specify '#within? - to see if one object is within another.' do + expect(geographic_item).to respond_to(:within?) + end + + specify '#near' do + expect(geographic_item).to respond_to(:near) + end + + specify '#far' do + expect(geographic_item).to respond_to(:far) + end + + specify '#contains? if one object is inside the area defined by the other (watch out for holes)' do + expect(k.contains?(p1.geo_object)).to be_truthy + end + + specify '#contains? if one object is inside the area defined by the other (watch out for holes)' do + expect(e1.contains?(p10.geo_object)).to be_falsey + end + + specify '#st_npoints returns the number of included points for a valid GeoItem' do + expect(p0.st_npoints).to eq(1) + end + + specify '#st_npoints returns the number of included points for a valid GeoItem' do + expect(a.st_npoints).to eq(4) + end + + specify '#st_npoints returns the number of included points for a valid GeoItem' do + expect(b.st_npoints).to eq(13) + end + + specify '#st_npoints returns the number of included points for a valid GeoItem' do + expect(h.st_npoints).to eq(5) + end + + specify '#st_npoints returns the number of included points for a valid GeoItem' do + expect(f.st_npoints).to eq(4) + end + + specify '#st_npoints returns the number of included points for a valid GeoItem' do + expect(g.st_npoints).to eq(12) + end + + specify '#st_npoints returns the number of included points for a valid GeoItem' do + expect(all_items.st_npoints).to eq(157) + end + + specify '#st_npoints returns the number of included points for a valid GeoItem' do + expect(outer_limits.st_npoints).to eq(7) + end + + specify '#valid_geometry? returns \'true\' for a valid GeoObject' do + expect(p0.valid_geometry?).to be_truthy + end + + specify '#valid_geometry? returns \'true\' for a valid GeoObject' do + expect(a.valid_geometry?).to be_truthy + end + + specify '#valid_geometry? returns \'true\' for a valid GeoObject' do + expect(b.valid_geometry?).to be_truthy + end + + specify '#valid_geometry? returns \'true\' for a valid GeoObject' do + expect(h.valid_geometry?).to be_truthy + end + + specify '#valid_geometry? returns \'true\' for a valid GeoObject' do + expect(f.valid_geometry?).to be_truthy + end + + specify '#valid_geometry? returns \'true\' for a valid GeoObject' do + expect(g.valid_geometry?).to be_truthy + end + + specify '#valid_geometry? returns \'true\' for a valid GeoObject' do + expect(all_items.valid_geometry?).to be_truthy + end + + specify '#st_centroid returns a lat/lng of the centroid of the GeoObject' do + expect(new_box_a.st_centroid).to eq('POINT(5 5)') + end + + specify '#center_coords' do + expect(new_box_a.center_coords).to eq(['5.000000', '5.000000']) + end + + context '#shape on new' do + let(:object) { GeographicItem.new } + # '[40.190063612251016, -111.58300638198853]' + specify 'for point' do + object.shape = '{"type":"Feature","geometry":{"type":"Point",' \ + '"coordinates":[-88.0975631475394,40.45993808344767]},' \ + '"properties":{"name":"Paxton City Hall"}}' + expect(object.valid?).to be_truthy + end + + specify 'for polygon' do + object.shape = '{"type":"Feature","geometry":{"type":"Polygon",' \ + '"coordinates":[[[-90.25122106075287,38.619731572825145],[-86.12036168575287,39.77758382625017],' \ + '[-87.62384042143822,41.89478088863241],[-90.25122106075287,38.619731572825145]]]},"properties":{}}' + expect(object.valid?).to be_truthy + end + + specify 'for linestring' do + object.shape = '{"type":"Feature","geometry":{"type":"LineString","coordinates":[' \ + '[-90.25122106075287,38.619731572825145],' \ + '[-86.12036168575287,39.77758382625017],' \ + '[-87.62384042143822,41.89478088863241]]},"properties":{}}' + expect(object.valid?).to be_truthy + end + + specify 'for circle' do + object.shape = '{"type":"Feature","geometry":{"type":"Point",' \ + '"coordinates":[-88.09681320155505,40.461195702960666]},' \ + '"properties":{"radius":1468.749413840412, "name":"Paxton City Hall"}}' + expect(object.valid?).to be_truthy + end + end + + context '#centroid' do + specify 'for point' do + expect(r2024.centroid.to_s).to eq('POINT (-88.241413 40.091655 0.0)') + end + + specify 'for line_string' do + expect(c1.centroid.to_s).to match(/POINT \(16\.461453\d* 19\.276957\d* 0\.0\)/) + end + + specify 'for polygon' do + expect(b.centroid.to_s).to match(/POINT \(-8\.091346\d* 16\.666666\d* 0\.0\)/) + end + + specify 'for multi_point' do + expect(h.centroid.to_s).to match(/POINT \(5\.0 -15\.7(4|399999\d*) 0\.0\)/) # TODO: Review the way this is being check (and the others too actually) + end + + specify 'for multi_line_string' do + expect(c.centroid.to_s).to match(/POINT \(16\.538756\d* 15\.300166\d* 0\.0\)/) + end + + specify 'for multi_polygon' do + expect(g.centroid.to_s).to match(/POINT \(21\.126454\d* -3.055235\d* 0\.0\)/) + end + + specify 'for geometry_collection' do + expect(j.centroid.to_s).to match(/POINT \(21\.126454\d* -3\.055235\d* 0\.0\)/) + end + end + end + + context 'class methods' do + + specify '::geometry_sql' do + test = 'select geom_alias_tbl.polygon::geometry from geographic_items geom_alias_tbl ' \ + 'where geom_alias_tbl.id = 2' + expect(GeographicItem.geometry_sql(2, :polygon)).to eq(test) + end + + specify '::ordered_by_shortest_distance_from to specify ordering of found objects.' do + expect(GeographicItem).to respond_to(:ordered_by_shortest_distance_from) + end + + specify '::ordered_by_longest_distance_from' do + expect(GeographicItem).to respond_to(:ordered_by_longest_distance_from) + end + + specify '::disjoint_from to find all objects which are disjoint from an \'and\' list of objects.' do + expect(GeographicItem).to respond_to(:disjoint_from) + end + + specify '::within_radius_of_item to find all objects which are within a specific ' \ + 'distance of a geographic item.' do + expect(GeographicItem).to respond_to(:within_radius_of_item) + end + + specify '::intersecting method to intersecting an \'or\' list of objects.' do + expect(GeographicItem).to respond_to(:intersecting) + end + + specify '::containing_sql' do + test1 = 'ST_Contains(polygon::geometry, (select geom_alias_tbl.point::geometry from ' \ + "geographic_items geom_alias_tbl where geom_alias_tbl.id = #{p1.id}))" + expect(GeographicItem.containing_sql('polygon', + p1.to_param, p1.geo_object_type)).to eq(test1) + end + + specify '::eval_for_type' do + expect(GeographicItem.eval_for_type('polygon')).to eq('GeographicItem::Polygon') + end + + specify '::eval_for_type' do + expect(GeographicItem.eval_for_type('linestring')).to eq('GeographicItem::LineString') + end + + specify '::eval_for_type' do + expect(GeographicItem.eval_for_type('point')).to eq('GeographicItem::Point') + end + + specify '::eval_for_type' do + expect(GeographicItem.eval_for_type('other_thing')).to eq(nil) + end + + context 'scopes (GeographicItems can be found by searching with) ' do + before { + [ce_a, ce_b, gr_a, gr_b].each + } + + specify '::geo_with_collecting_event' do + expect(GeographicItem.geo_with_collecting_event.to_a).to include(p_a, p_b) # + end + + specify '::geo_with_collecting_event' do + expect(GeographicItem.geo_with_collecting_event.to_a).not_to include(e4) + end + + specify '::err_with_collecting_event' do + expect(GeographicItem.err_with_collecting_event.to_a).to include(new_box_a, err_b) # + end + + specify '::err_with_collecting_event' do + expect(GeographicItem.err_with_collecting_event.to_a).not_to include(g, p17) + end + + specify '::with_collecting_event_through_georeferences' do + expect(GeographicItem.with_collecting_event_through_georeferences.order('id').to_a) + .to contain_exactly(new_box_a, p_a, p_b, err_b) # + end + + specify '::with_collecting_event_through_georeferences' do + expect(GeographicItem.with_collecting_event_through_georeferences.order('id').to_a) + .not_to include(e4) + end + + specify '::include_collecting_event' do + expect(GeographicItem.include_collecting_event.to_a) + .to include(new_box_b, new_box_a, err_b, p_a, p_b, new_box_e) + end + + context '::containing' do + before { [k, l, b, b1, b2, e1].each } + + specify 'find the polygon containing the points' do + expect(GeographicItem.containing(p1.id).to_a).to contain_exactly(k) + end + + specify 'find the polygon containing all three points' do + expect(GeographicItem.containing(p1.id, p2.id, p3.id).to_a).to contain_exactly(k) + end + + specify 'find that a line string can contain a point' do + expect(GeographicItem.containing(p4.id).to_a).to contain_exactly(l) + end + + specify 'point in two polygons, but not their intersection' do + expect(GeographicItem.containing(p18.id).to_a).to contain_exactly(b1, b2) + end + + specify 'point in two polygons, one with a hole in it' do + expect(GeographicItem.containing(p19.id).to_a).to contain_exactly(b1, b) + end + end + + context '::are_contained_in - returns objects which contained in another object.' do + before { [e1, k].each } + + # OR! + specify 'three things inside and one thing outside k' do + expect(GeographicItem.are_contained_in_item('polygon', + [p1, p2, p3, p11]).to_a) + .to contain_exactly(e1, k) + end + + # OR! + specify 'one thing inside one thing, and another thing inside another thing' do + expect(GeographicItem.are_contained_in_item('polygon', + [p1, p11]).to_a) + .to contain_exactly(e1, k) + end + + # + # All these are deprecated for ::containing + # + # + # expect(GeographicItem.are_contained_in('not_a_column_name', @p1).to_a).to eq([]) + # expect(GeographicItem.are_contained_in('point', 'Some devious SQL string').to_a).to eq([]) + + # specify 'one thing inside k' do + # expect(GeographicItem.are_contained_in_item('polygon', @p1).to_a).to eq([@k]) + # end + + # specify 'three things inside k' do + # expect(GeographicItem.are_contained_in_item('polygon', [@p1, @p2, @p3]).to_a).to eq([@k]) + # end + + # specify 'one thing outside k' do + # expect(GeographicItem.are_contained_in_item('polygon', @p4).to_a).to eq([]) + # end + + # specify ' one thing inside two things (overlapping)' do + # expect(GeographicItem.are_contained_in_item('polygon', @p12).to_a.sort).to contain_exactly(@e1, @e2) + # end + + # specify 'two things inside one thing, and (1)' do + # expect(GeographicItem.are_contained_in_item('polygon', @p18).to_a).to contain_exactly(@b1, @b2) + # end + + # specify 'two things inside one thing, and (2)' do + # expect(GeographicItem.are_contained_in_item('polygon', @p19).to_a).to contain_exactly(@b1, @b) + # end + end + + context '::contained_by' do + before { [p1, p2, p3, p11, p12, k, l].each } + + specify 'find the points in a polygon' do + expect(GeographicItem.contained_by(k.id).to_a).to contain_exactly(p1, p2, p3, k) + end + + specify 'find the (overlapping) points in a polygon' do + overlapping_point = FactoryBot.create(:geographic_item_point, point: point12.as_binary) + expect(GeographicItem.contained_by(e1.id).to_a).to contain_exactly(p12, overlapping_point, p11, e1) + end + end + + context '::are_contained_in_item_by_id - returns objects which contained in another object.' do + before { [p0, p1, p2, p3, p12, p13, b1, b2, b, e1, e2, k].each } + + specify 'one thing inside k' do + expect(GeographicItem.are_contained_in_item_by_id('polygon', p1.id).to_a).to eq([k]) + end + + specify 'three things inside k (in array)' do + expect(GeographicItem.are_contained_in_item_by_id('polygon', + [p1.id, p2.id, p3.id]).to_a) + .to eq([k]) + end + + specify 'three things inside k (as separate parameters)' do + expect(GeographicItem.are_contained_in_item_by_id('polygon', p1.id, + p2.id, + p3.id).to_a) + .to eq([k]) + end + + specify 'one thing outside k' do + expect(GeographicItem.are_contained_in_item_by_id('polygon', p4.id).to_a) + .to eq([]) + end + + specify ' one thing inside two things (overlapping)' do + expect(GeographicItem.are_contained_in_item_by_id('polygon', p12.id).to_a.sort) + .to contain_exactly(e1, e2) + end + + specify 'three things inside and one thing outside k' do + expect(GeographicItem.are_contained_in_item_by_id('polygon', + [p1.id, p2.id, + p3.id, p11.id]).to_a) + .to contain_exactly(e1, k) + end + + specify 'one thing inside one thing, and another thing inside another thing' do + expect(GeographicItem.are_contained_in_item_by_id('polygon', + [p1.id, p11.id]).to_a) + .to contain_exactly(e1, k) + end + + specify 'two things inside one thing, and (1)' do + expect(GeographicItem.are_contained_in_item_by_id('polygon', p18.id).to_a) + .to contain_exactly(b1, b2) + end + + specify 'two things inside one thing, and (2)' do + expect(GeographicItem.are_contained_in_item_by_id('polygon', p19.id).to_a) + .to contain_exactly(b1, b) + end + end + + context '::is_contained_by - returns objects which are contained by other objects.' do + before { [b, p0, p1, p2, p3, p11, p12, p13, p18, p19].each } + + specify ' three things inside k' do + expect(GeographicItem.is_contained_by('any', k).not_including(k).to_a) + .to contain_exactly(p1, p2, p3) + end + + specify 'one thing outside k' do + expect(GeographicItem.is_contained_by('any', p4).not_including(p4).to_a).to eq([]) + end + + specify 'three things inside and one thing outside k' do + pieces = GeographicItem.is_contained_by('any', + [e2, k]).not_including([k, e2]).to_a + expect(pieces).to contain_exactly(p0, p1, p2, p3, p12, p13) # , @p12c + + end + + # other objects are returned as well, we just don't care about them: + # we want to find p1 inside K, and p11 inside e1 + specify 'one specific thing inside one thing, and another specific thing inside another thing' do + expect(GeographicItem.is_contained_by('any', + [e1, k]).to_a) + .to include(p1, p11) + end + + specify 'one thing (p19) inside a polygon (b) with interior, and another inside ' \ + 'the interior which is NOT included (p18)' do + expect(GeographicItem.is_contained_by('any', b).not_including(b).to_a).to eq([p19]) + end + + specify 'three things inside two things. Notice that the outer ring of b ' \ + 'is co-incident with b1, and thus "contained".' do + expect(GeographicItem.is_contained_by('any', + [b1, b2]).not_including([b1, b2]).to_a) + .to contain_exactly(p18, p19, b) + end + + # other objects are returned as well, we just don't care about them + # we want to find p19 inside b and b1, but returned only once + specify 'both b and b1 contain p19, which gets returned only once' do + expect(GeographicItem.is_contained_by('any', + [b1, b]).to_a) + .to include(p19) + end + end + + context '::not_including([])' do + before { [p1, p4, p17, r2024, r2022, r2020, p10].each { |object| object } } + + specify 'drop specifc item[s] from any scope (list of objects.)' do + # @p2 would have been in the list, except for the exclude + expect(GeographicItem.not_including([p2]) + .ordered_by_shortest_distance_from('point', p3) + .limit(3).to_a) + .to eq([p1, p4, p17]) + end + + specify 'drop specifc item[s] from any scope (list of objects.)' do + # @p2 would *not* have been in the list anyway + expect(GeographicItem.not_including([p2]) + .ordered_by_longest_distance_from('point', p3) + .limit(3).to_a) + .to eq([r2024, r2022, r2020]) + end + + specify 'drop specifc item[s] from any scope (list of objects.)' do + # @r2022 would have been in the list, except for the exclude + expect(GeographicItem.not_including([r2022]) + .ordered_by_longest_distance_from('point', p3) + .limit(3).to_a) + .to eq([r2024, r2020, p10]) + end + end + + # specify '::not_including_self to drop self from any list of objects' do + # skip 'construction of scenario' + # expect(GeographicItem.ordered_by_shortest_distance_from('point', @p7).limit(5)).to_a).to eq([@p2, @p1, @p4]) + # end + + context '::ordered_by_shortest_distance_from' do + before { [p1, p2, p4, outer_limits, l, f1, e5, e3, e4, h, rooms, f, c, g, e, j].each } + + specify ' orders objects by distance from passed object' do + expect(GeographicItem.ordered_by_shortest_distance_from('point', p3) + .limit(3).to_a) + .to eq([p2, p1, p4]) + end + + specify ' orders objects by distance from passed object' do + expect(GeographicItem.ordered_by_shortest_distance_from('line_string', p3) + .limit(3).to_a) + .to eq([outer_limits, l, f1]) + end + + specify ' orders objects by distance from passed object' do + expect(GeographicItem.ordered_by_shortest_distance_from('polygon', p3) + .limit(3).to_a) + .to eq([e5, e3, e4]) + end + + specify ' orders objects by distance from passed object' do + expect(GeographicItem.ordered_by_shortest_distance_from('multi_point', p3) + .limit(3).to_a) + .to eq([h, rooms]) + end + + specify ' orders objects by distance from passed object' do + expect(GeographicItem.ordered_by_shortest_distance_from('multi_line_string', p3) + .limit(3).to_a) + .to eq([f, c]) + end + + specify ' orders objects by distance from passed object' do + subject = GeographicItem.ordered_by_shortest_distance_from('multi_polygon', p3).limit(3).to_a + expect(subject[0..1]).to contain_exactly(new_box_e, new_box_b) # Both boxes are at same distance from p3 + expect(subject[2..]).to eq([new_box_a]) + end + + specify ' orders objects by distance from passed object' do + expect(GeographicItem.ordered_by_shortest_distance_from('geometry_collection', p3) + .limit(3).to_a) + .to eq([e, j]) + end + end + + context '::ordered_by_longest_distance_from' do + before { + [r2024, r2022, r2020, c3, c1, c2, g1, g2, g3, b2, rooms, h, c, f, g, j, e].each + } + + specify 'orders points by distance from passed point' do + expect(GeographicItem.ordered_by_longest_distance_from('point', p3).limit(3).to_a) + .to eq([r2024, r2022, r2020]) + end + + specify 'orders line_strings by distance from passed point' do + expect(GeographicItem.ordered_by_longest_distance_from('line_string', p3) + .limit(3).to_a) + .to eq([c3, c1, c2]) + end + + specify 'orders polygons by distance from passed point' do + expect(GeographicItem.ordered_by_longest_distance_from('polygon', p3) + .limit(4).to_a) + .to eq([g1, g2, g3, b2]) + end + + specify 'orders multi_points by distance from passed point' do + expect(GeographicItem.ordered_by_longest_distance_from('multi_point', p3) + .limit(3).to_a) + .to eq([rooms, h]) + end + + specify 'orders multi_line_strings by distance from passed point' do + expect(GeographicItem.ordered_by_longest_distance_from('multi_line_string', p3) + .limit(3).to_a) + .to eq([c, f]) + end + + specify 'orders multi_polygons by distance from passed point' do + # existing multi_polygons: [new_box_e, new_box_a, new_box_b, g] + # new_box_e is excluded, because p3 is *exactly* the same distance from new_box_e, *and* new_box_a + # This seems to be the reason these two objects *might* be in either order. Thus, one of the two + # is excluded to prevent it from confusing the order (farthest first) of the appearance of the objects. + expect(GeographicItem.ordered_by_longest_distance_from('multi_polygon', p3) + .not_including(new_box_e) + .limit(3).to_a) # TODO: Limit is being called over an array. Check whether this is a gem/rails bug or we need to update code. + .to eq([g, new_box_a, new_box_b]) + end + + specify 'orders objects by distance from passed object geometry_collection' do + expect(GeographicItem.ordered_by_longest_distance_from('geometry_collection', p3) + .limit(3).to_a) + .to eq([j, e]) + end + end + + context '::disjoint_from' do + before { [p1].each } + + specify "list of objects (uses 'and')." do + expect(GeographicItem.disjoint_from('point', + [e1, e2, e3, e4, e5]) + .order(:id) + .limit(1).to_a) + .to contain_exactly(p_b) + end + end + + context '::within_radius_of_item' do + before { [e2, e3, e4, e5, item_a, item_b, item_c, item_d, k, r2022, r2024, p14].each } + + specify 'returns objects within a specific distance of an object.' do + pieces = GeographicItem.within_radius_of_item(p0.id, 1000000) + .where(type: ['GeographicItem::Polygon']) + expect(pieces).to contain_exactly(err_b, e2, e3, e4, e5, item_a, item_b, item_c, item_d) + end + + specify '::within_radius_of_item("any", ...)' do + expect(GeographicItem.within_radius_of_item(p0.id, 1000000)) + .to include(e2, e3, e4, e5, item_a, item_b, item_c, item_d) + end + + specify "::intersecting list of objects (uses 'or')" do + expect(GeographicItem.intersecting('polygon', [l])).to eq([k]) + end + + specify "::intersecting list of objects (uses 'or')" do + expect(GeographicItem.intersecting('polygon', [f1])) + .to eq([]) # Is this right? + end + + specify '::select_distance_with_geo_object provides an extra column called ' \ + '\'distance\' to the output objects' do + result = GeographicItem.select_distance_with_geo_object('point', r2020) + .limit(3).order('distance') + .where_distance_greater_than_zero('point', r2020).to_a + # get back these three points + expect(result).to eq([r2022, r2024, p14]) + end + + specify '::select_distance_with_geo_object provides an extra column called ' \ + '\'distance\' to the output objects' do + result = GeographicItem.select_distance_with_geo_object('point', r2020) + .limit(3).order('distance') + .where_distance_greater_than_zero('point', r2020).to_a + # 5 meters + expect(result.first.distance).to be_within(0.1).of(5.008268179) + end + + specify '::select_distance_with_geo_object provides an extra column called ' \ + '\'distance\' to the output objects' do + result = GeographicItem.select_distance_with_geo_object('point', r2020) + .limit(3).order('distance') + .where_distance_greater_than_zero('point', r2020).to_a + # 10 meters + expect(result[1].distance).to be_within(0.1).of(10.016536381) + end + + specify '::select_distance_with_geo_object provides an extra column called ' \ + '\'distance\' to the output objects' do + result = GeographicItem.select_distance_with_geo_object('point', r2020) + .limit(3).order('distance') + .where_distance_greater_than_zero('point', r2020).to_a + # 5,862 km (3,642 miles) + expect(result[2].distance).to be_within(0.1).of(5862006.0029975) + end + + specify '::with_is_valid_geometry_column returns \'true\' for a valid GeoItem' do + expect(GeographicItem.with_is_valid_geometry_column(p0)).to be_truthy + end + + specify '::with_is_valid_geometry_column returns \'true\' for a valid GeoItem' do + expect(GeographicItem.with_is_valid_geometry_column(a)).to be_truthy + end + + specify '::with_is_valid_geometry_column returns \'true\' for a valid GeoItem' do + expect(GeographicItem.with_is_valid_geometry_column(b)).to be_truthy + end + + specify '::with_is_valid_geometry_column returns \'true\' for a valid GeoItem' do + expect(GeographicItem.with_is_valid_geometry_column(h)).to be_truthy + end + + specify '::with_is_valid_geometry_column returns \'true\' for a valid GeoItem' do + expect(GeographicItem.with_is_valid_geometry_column(f)).to be_truthy + end + + specify '::with_is_valid_geometry_column returns \'true\' for a valid GeoItem' do + expect(GeographicItem.with_is_valid_geometry_column(g)).to be_truthy + end + + specify '::with_is_valid_geometry_column returns \'true\' for a valid GeoItem' do + expect(GeographicItem.with_is_valid_geometry_column(all_items)).to be_truthy + end + end + + context 'distance to others' do + specify 'slow' do + expect(p1.st_distance(p2.id)).to be_within(0.1).of(479988.25399881) + end - specify '#shape= 2' do - g = GeographicItem.create!(shape: geo_json) - g.update(shape: geo_json2) - expect(g.reload.geo_object.to_s).to match(/20/) + specify 'fast' do + expect(p1.st_distance_spheroid(p2.id)).to be_within(0.1).of(479988.253998808) + end + end + end + + context '::gather_geographic_area_or_shape_data' do + specify 'collection_objetcs' do + + end + specify 'asserted_distribution' do + + end + end + end + end # end using ce_test_objects + + context 'concerns' do + it_behaves_like 'is_data' + end end - specify '#shape= bad linear ring' do - bad = '{ - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [-80.498221, 25.761437], - [-80.498221, 25.761959], - [-80.498221, 25.761959], - [-80.498221, 25.761437] + context 'a single geography column holding any type of shape' do + let(:geographic_item) { GeographicItem.new } + + let(:geo_json) { + '{ + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [10, 10] + }, + "properties": { + "name": "Sample Point", + "description": "This is a sample point feature." + } + }' + } + + let(:geo_json2) { + '{ + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [20, 20] + }, + "properties": { + "name": "Sample Point", + "description": "This is a sample point feature." + } + }' + } + + specify '#shape=' do + g = GeographicItem.new(shape: geo_json) + expect(g.save).to be_truthy + end + + specify '#shape= 2' do + g = GeographicItem.create!(shape: geo_json) + g.update(shape: geo_json2) + expect(g.reload.geo_object.to_s).to match(/20/) + end + + specify '#shape= bad linear ring' do + bad = '{ + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-80.498221, 25.761437], + [-80.498221, 25.761959], + [-80.498221, 25.761959], + [-80.498221, 25.761437] + ] ] - ] - }, - "properties": {} - }' - - g = GeographicItem.new(shape: bad) - g.valid? - expect(g.errors[:base]).to be_present - end + }, + "properties": {} + }' - context 'using ce_test_objects' do - let(:geographic_item) { FactoryBot.build(:geographic_item) } - let(:geographic_item_with_point_a) { FactoryBot.build(:geographic_item_with_point_a) } - let(:geographic_item_with_point_b) { FactoryBot.build(:geographic_item_with_point_b) } - let(:geographic_item_with_point_c) { FactoryBot.build(:geographic_item_with_point_c) } - let(:geographic_item_with_line_string) { FactoryBot.build(:geographic_item_with_line_string) } - let(:geographic_item_with_polygon) { FactoryBot.build(:geographic_item_with_polygon) } - let(:geographic_item_with_multi_polygon) { FactoryBot.build(:geographic_item_with_multi_polygon) } + g = GeographicItem.new(shape: bad) + g.valid? + expect(g.errors[:base]).to be_present + end + + context 'using ce_test_objects' do + let(:geographic_item) { FactoryBot.build(:geographic_item) } + let(:geographic_item_with_point_a) { FactoryBot.build(:geographic_item_with_point_a) } + let(:geographic_item_with_point_b) { FactoryBot.build(:geographic_item_with_point_b) } + let(:geographic_item_with_point_c) { FactoryBot.build(:geographic_item_with_point_c) } + let(:geographic_item_with_line_string) { FactoryBot.build(:geographic_item_with_line_string) } + let(:geographic_item_with_polygon) { FactoryBot.build(:geographic_item_with_polygon) } + let(:geographic_item_with_multi_polygon) { FactoryBot.build(:geographic_item_with_multi_polygon) } =begin context 'database functions' do @@ -1114,5 +2229,6 @@ context 'concerns' do it_behaves_like 'is_data' end + end - end +end From 972975a15666755105a8ecf895fe4af3a576fdd2 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Tue, 4 Jun 2024 11:42:59 -0500 Subject: [PATCH 015/259] #1954 Add `geography` column to geographic_item for use with gazetteer Get geographic_item.spec tests to pass TODO: still (many?) more `type`-related changes needed for the new type. --- app/models/geographic_item.rb | 28 ++++++++++--- app/models/geographic_item/geography.rb | 10 +++++ ...4160009_add_geography_to_geometric_item.rb | 7 ++++ db/schema.rb | 2 + .../geographic_item/geography_factory.rb | 6 +++ spec/models/geographic_item/geography_spec.rb | 42 +++++++++++++++++++ spec/support/shared_contexts/shared_geo.rb | 3 +- 7 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 app/models/geographic_item/geography.rb create mode 100644 db/migrate/20240604160009_add_geography_to_geometric_item.rb create mode 100644 spec/factories/geographic_item/geography_factory.rb create mode 100644 spec/models/geographic_item/geography_spec.rb diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 937d0ecded..d5d6834263 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -1,8 +1,8 @@ # require 'rgeo' # A GeographicItem is one and only one of [point, line_string, polygon, multi_point, multi_line_string, -# multi_polygon, geometry_collection] which describes a position, path, or area on the globe, generally associated -# with a geographic_area (through a geographic_area_geographic_item entry), and sometimes only with a georeference. +# multi_polygon, geometry_collection, geography] which describes a position, path, or area on the globe, generally associated +# with a geographic_area (through a geographic_area_geographic_item entry), a gazetteer, or a georeference. # # @!attribute point # @return [RGeo::Geographic::ProjectedPointImpl] @@ -24,9 +24,17 @@ # @return [RGeo::Geographic::ProjectedMultiPolygonImpl] # CCW orientation is applied # +# @!attribute geometry_collection +# @return [RGeo::Geographic::ProjectedGeometryCollectionImpl] +# +# @!attribute geography +# @return [RGeo::Geographic::Geography] +# Holds a shape of any geographic type. Currently only used by Gazetteer, +# eventually all of the above shapes will be folded into here. +# # @!attribute type # @return [String] -# Rails STI, determines the geography column as well +# Rails STI # # @!attribute cached_total_area # @return [Numeric] @@ -65,7 +73,8 @@ class GeographicItem < ApplicationRecord :multi_point, :multi_line_string, :multi_polygon, - :geometry_collection + :geometry_collection, + :geography ].freeze GEOMETRY_SQL = Arel::Nodes::Case.new(arel_table[:type]) @@ -76,8 +85,11 @@ class GeographicItem < ApplicationRecord .when('GeographicItem::MultiLineString').then(Arel::Nodes::NamedFunction.new('CAST', [arel_table[:multi_line_string].as('geometry')])) .when('GeographicItem::MultiPoint').then(Arel::Nodes::NamedFunction.new('CAST', [arel_table[:multi_point].as('geometry')])) .when('GeographicItem::GeometryCollection').then(Arel::Nodes::NamedFunction.new('CAST', [arel_table[:geometry_collection].as('geometry')])) + .when('GeographicItem::Geography').then(Arel::Nodes::NamedFunction.new('CAST', [arel_table[:geography].as('geometry')])) .freeze + # TODO Note this is pg, not rails: it doesn't know anything about the + # type override accessor for geography. GEOGRAPHY_SQL = "CASE geographic_items.type WHEN 'GeographicItem::MultiPolygon' THEN multi_polygon WHEN 'GeographicItem::Point' THEN point @@ -86,6 +98,7 @@ class GeographicItem < ApplicationRecord WHEN 'GeographicItem::MultiLineString' THEN multi_line_string WHEN 'GeographicItem::MultiPoint' THEN multi_point WHEN 'GeographicItem::GeometryCollection' THEN geometry_collection + WHEN 'GeographicItem::Geography' THEN geography END".freeze # ANTI_MERIDIAN = '0X0102000020E61000000200000000000000008066400000000000405640000000000080664000000000004056C0' @@ -122,6 +135,7 @@ class GeographicItem < ApplicationRecord class << self + # TODO add geography case? def aliased_geographic_sql(name = 'a') "CASE #{name}.type \ WHEN 'GeographicItem::MultiPolygon' THEN #{name}.multi_polygon \ @@ -187,6 +201,7 @@ def crosses_anti_meridian_by_id?(*ids) # @param [Integer, String] # @return [String] # a SQL select statement that returns the *geometry* for the geographic_item with the specified id + # TODO Same query as select_geography_sql but without sanitize def select_geometry_sql(geographic_item_id) "SELECT #{GeographicItem::GEOMETRY_SQL.to_sql} from geographic_items where geographic_items.id = #{geographic_item_id}" end @@ -200,7 +215,7 @@ def select_geography_sql(geographic_item_id) geographic_item_id]) end - # @param [Symbol] choice + # @param [Symbol] choice, either :latitude or :longitude # @return [String] # a fragment returning either latitude or longitude columns def lat_long_sql(choice) @@ -223,6 +238,8 @@ def lat_long_sql(choice) "ST_Centroid(multi_line_string::geometry), #{f} ), ' ', #{v}) WHEN 'GeographicItem::MultiPoint' THEN split_part(ST_AsLatLonText(" \ "ST_Centroid(multi_point::geometry), #{f}), ' ', #{v}) + WHEN 'GeographicItem::Geography' THEN split_part(ST_AsLatLonText(" \ + "ST_Centroid(geography::geometry), #{f}), ' ', #{v}) END as #{choice}" end @@ -486,6 +503,7 @@ def contained_by_wkt_sql(wkt) # @return [String] sql for contained_by via ST_Contains # Note: Can not use GEOMETRY_SQL because geometry_collection is not supported in older versions of ST_Contains # Note: !! If the target GeographicItem#id crosses the anti-meridian then you may/will get unexpected results. + # TODO need to handle geography case here def contained_by_where_sql(*geographic_item_ids) "ST_Contains( #{GeographicItem.geometry_sql2(*geographic_item_ids)}, diff --git a/app/models/geographic_item/geography.rb b/app/models/geographic_item/geography.rb new file mode 100644 index 0000000000..ba0c99e35b --- /dev/null +++ b/app/models/geographic_item/geography.rb @@ -0,0 +1,10 @@ +# Geography definition... +# +class GeographicItem::Geography < GeographicItem + SHAPE_COLUMN = :geography + validates_presence_of :geography + + def type + "GeographicItem::#{geography.geometry_type.type_name}" + end +end diff --git a/db/migrate/20240604160009_add_geography_to_geometric_item.rb b/db/migrate/20240604160009_add_geography_to_geometric_item.rb new file mode 100644 index 0000000000..cfd30a23ce --- /dev/null +++ b/db/migrate/20240604160009_add_geography_to_geometric_item.rb @@ -0,0 +1,7 @@ +class AddGeographyToGeometricItem < ActiveRecord::Migration[7.1] + def change + add_column :geographic_items, :geography, :geometry, geographic: true, has_z: true + # TODO: make sure I want :gist here + add_index :geographic_items, :geography, using: :gist + end +end diff --git a/db/schema.rb b/db/schema.rb index 2f8669d2d2..2027558d11 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1071,8 +1071,10 @@ t.integer "updated_by_id", null: false t.string "type", null: false t.decimal "cached_total_area" + t.geography "geography", limit: {:srid=>4326, :type=>"geometry", :has_z=>true, :geographic=>true} t.index "st_centroid(\nCASE type\n WHEN 'GeographicItem::MultiPolygon'::text THEN (multi_polygon)::geometry\n WHEN 'GeographicItem::Point'::text THEN (point)::geometry\n WHEN 'GeographicItem::LineString'::text THEN (line_string)::geometry\n WHEN 'GeographicItem::Polygon'::text THEN (polygon)::geometry\n WHEN 'GeographicItem::MultiLineString'::text THEN (multi_line_string)::geometry\n WHEN 'GeographicItem::MultiPoint'::text THEN (multi_point)::geometry\n WHEN 'GeographicItem::GeometryCollection'::text THEN (geometry_collection)::geometry\n ELSE NULL::geometry\nEND)", name: "idx_centroid", using: :gist t.index ["created_by_id"], name: "index_geographic_items_on_created_by_id" + t.index ["geography"], name: "index_geographic_items_on_geography", using: :gist t.index ["geometry_collection"], name: "geometry_collection_gix", using: :gist t.index ["line_string"], name: "line_string_gix", using: :gist t.index ["multi_line_string"], name: "multi_line_string_gix", using: :gist diff --git a/spec/factories/geographic_item/geography_factory.rb b/spec/factories/geographic_item/geography_factory.rb new file mode 100644 index 0000000000..cfb66abe92 --- /dev/null +++ b/spec/factories/geographic_item/geography_factory.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :geographic_item_geography, class: 'GeographicItem::Geography' do + + end + +end diff --git a/spec/models/geographic_item/geography_spec.rb b/spec/models/geographic_item/geography_spec.rb new file mode 100644 index 0000000000..1e6d0e38ff --- /dev/null +++ b/spec/models/geographic_item/geography_spec.rb @@ -0,0 +1,42 @@ +require 'rails_helper' +require 'support/shared_contexts/shared_geo' + +describe GeographicItem::Geography, type: :model, group: [:geo, :shared_geo] do + include_context 'stuff for complex geo tests' + context 'can hold any' do + specify 'point' do + point = FactoryBot.build(:geographic_item_geography, geography: room2024.as_binary) + expect(point.geography.geometry_type.type_name).to eq('Point') + end + + specify 'line_string' do + line_string = FactoryBot.build(:geographic_item_geography, geography: shape_a1.as_binary) + expect(line_string.geography.geometry_type.type_name).to eq('LineString') + end + + specify 'polygon' do + polygon = FactoryBot.build(:geographic_item_geography, geography: shape_k.as_binary) + expect(polygon.geography.geometry_type.type_name).to eq('Polygon') + end + + specify 'multi_point' do + multi_point = FactoryBot.build(:geographic_item_geography, geography: rooms20nn.as_binary) + expect(multi_point.geography.geometry_type.type_name).to eq('MultiPoint') + end + + specify 'multi_line_string' do + multi_line_string = FactoryBot.build(:geographic_item_geography, geography: shape_c.as_binary) + expect(multi_line_string.geography.geometry_type.type_name).to eq('MultiLineString') + end + + specify 'multi_polygon' do + multi_polygon = FactoryBot.build(:geographic_item_geography, geography: shape_g.as_binary) + expect(multi_polygon.geography.geometry_type.type_name).to eq('MultiPolygon') + end + + specify 'geometry_collection' do + geometry_collection = FactoryBot.build(:geographic_item_geography, geography: all_shapes.as_binary) + expect(geometry_collection.geography.geometry_type.type_name).to eq('GeometryCollection') + end + end +end diff --git a/spec/support/shared_contexts/shared_geo.rb b/spec/support/shared_contexts/shared_geo.rb index 86f2e49c3b..e6c35ecc03 100644 --- a/spec/support/shared_contexts/shared_geo.rb +++ b/spec/support/shared_contexts/shared_geo.rb @@ -155,7 +155,8 @@ multi_polygon: 'MULTIPOLYGON(((0.0 0.0 0.0, 10.0 0.0 0.0, 10.0 10.0 0.0, 0.0 10.0 0.0, ' \ '0.0 0.0 0.0)),((10.0 10.0 0.0, 20.0 10.0 0.0, 20.0 20.0 0.0, 10.0 20.0 0.0, 10.0 10.0 0.0)))', geometry_collection: 'GEOMETRYCOLLECTION( POLYGON((0.0 0.0 0.0, 10.0 0.0 0.0, 10.0 10.0 0.0, ' \ - '0.0 10.0 0.0, 0.0 0.0 0.0)), POINT(10 10 0)) ' + '0.0 10.0 0.0, 0.0 0.0 0.0)), POINT(10 10 0)) ', + geography:'POLYGON((0.0 0.0 0.0, 10.0 0.0 0.0, 10.0 10.0 0.0, 0.0 10.0 0.0, 0.0 0.0 0.0))' }.freeze } let(:room2024) { RSPEC_GEO_FACTORY.point(-88.241413, 40.091655, 757) } From 364468cf5468e376eb5cc2ec83703853eab5830b Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Fri, 14 Jun 2024 19:26:41 -0500 Subject: [PATCH 016/259] Comment on GeographicItem.containing_where_sql_geog containing_where_sql_geog is currently unused, so this is kind of moot, but: ST_CoveredBy does in fact "work" when you pass mixed geography and geometry parameters (see below for a guess of how). Oddly though, the results for both parameters geographic vs both parameters geometric differs - I would have thought ST_CoveredBy(a::geography, b::geography) iff ST_CoveredBy(a::geometry, b::geometry). select ST_CoveredBy(multi_polygon::geometry, multi_polygon::geometry) from geographic_items where id=421; => t select ST_CoveredBy(multi_polygon, multi_polygon) from geographic_items where id=421; => f select ST_CoveredBy(multi_polygon::geography, multi_polygon::geography) from geographic_items where id=421; => f Mixed parameters: select ST_CoveredBy(multi_polygon::geometry, multi_polygon) from geographic_items where id=421; => f suggesting mixed parameters are cast to geography select count(*) from (select ST_CoveredBy(multi_polygon::geometry, multi_polygon::geometry) from geographic_items) as a where a.st_coveredby = 't'; => 34713 (that's all multi polygons) select count(*) from (select ST_CoveredBy(multi_polygon, multi_polygon) from geographic_items) as a where a.st_coveredby = 't'; => 0 (took about half an hour to complete) --- app/models/geographic_item.rb | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index d5d6834263..42898929da 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -381,12 +381,11 @@ def geometry_sql2(*geographic_item_ids) # @param [Integer, Array of Integer] geographic_item_ids # @return [String] + # Result includes self # TODO why does GEOMETRY_SQL.to_sql fail here? - # TODO why was geometrycollection not included here? st_coveredby suports - # it (as of 3.0) - # TODO if old versions of pgis are allowed then do we need to exclude - # geometrycollection from the geography case? - # TODO what happens here when type /is/ geo collection? + # TODO need to exclude geometry collection from the geography case until + # required postgis >= 3.0 - excluding means geometry collection shapes + # are not considered/never returned def containing_where_sql(*geographic_item_ids) "ST_CoveredBy( #{GeographicItem.geometry_sql2(*geographic_item_ids)}, @@ -397,21 +396,16 @@ def containing_where_sql(*geographic_item_ids) WHEN 'GeographicItem::Polygon' THEN polygon::geometry WHEN 'GeographicItem::MultiLineString' THEN multi_line_string::geometry WHEN 'GeographicItem::MultiPoint' THEN multi_point::geometry - WHEN 'GeographicItem::GeometryCollection' THEN geometry_collection::geometry - WHEN 'GeographicItem::Geography' THEN geography::geometry END)" end # DEPRECATED - # TODO Does this work? Looks like the first parameter is a - # geometry, the second is a geography # @param [Integer, Array of Integer] geographic_item_ids # @return [String] + # Result doesn't contain self. Much slower than containing_where_sql def containing_where_sql_geog(*geographic_item_ids) - # TODO Does this work? Looks like the first parameter is a - # geometry, the second is a geography "ST_CoveredBy( - #{GeographicItem.geometry_sql2(*geographic_item_ids)}, + (#{GeographicItem.geometry_sql2(*geographic_item_ids)})::geography, CASE geographic_items.type WHEN 'GeographicItem::MultiPolygon' THEN multi_polygon::geography WHEN 'GeographicItem::Point' THEN point::geography From f4c264bc2c114b57fedca94668692432974c5beb Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Fri, 14 Jun 2024 20:54:09 -0500 Subject: [PATCH 017/259] #1954 Adjust GeographicItem#orientations for geography type Still not sure what the right way to expose the underlying shape of the geography column (is it point, polygon, etc). Right now there are lots of competing types and geo_types and object_types and multiple versions of those names; hopefully when it's just geography that can be simplified. --- app/models/geographic_item.rb | 48 ++++++++++++++++--------- app/models/geographic_item/geography.rb | 4 --- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 42898929da..4cc9e3db69 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -1077,6 +1077,13 @@ def geo_object_type end end + def type_of_geography + return nil unless geo_object_type == :geography + + # TODO: too many versions of "type" ... what should this one be? + "GeographicItem::#{geography.geometry_type.type_name}" + end + # !!TODO: migrate these to use native column calls # @return [RGeo instance, nil] @@ -1264,23 +1271,21 @@ def radius r = (r * Utilities::Geo::ONE_WEST_MEAN).to_i end - # Convention is to store in PostGIS in CCW # @return Array [Boolean] # false - cw - # true - ccw (preferred), except cee donuts + # true - ccw (preferred), except see donuts def orientations - if multi_polygon.present? + if (column = multi_polygon_column) ApplicationRecord.connection.execute(" \ SELECT ST_IsPolygonCCW(a.geom) as is_ccw FROM ( SELECT b.id, (ST_Dump(p_geom)).geom AS geom - FROM (SELECT id, multi_polygon::geometry AS p_geom FROM geographic_items where id = #{id}) AS b \ + FROM (SELECT id, #{column}::geometry AS p_geom FROM geographic_items where id = #{id}) AS b \ ) AS a;").collect{|a| a['is_ccw']} - elsif polygon.present? - ApplicationRecord.connection.execute("SELECT ST_IsPolygonCCW(polygon::geometry) as is_ccw \ - FROM geographic_items where id = #{id};").collect{|a| a['is_ccw']} + elsif (column = polygon_column) + ApplicationRecord.connection.execute("SELECT ST_IsPolygonCCW(#{column}::geometry) as is_ccw \ + FROM geographic_items where id = #{id};").collect{|a| a['is_ccw']} else - # TODO need to handle geography's types here [] end end @@ -1313,19 +1318,30 @@ def has_polygons? private + def polygon_column + return 'polygon' if polygon.present? + return 'geography' if type_of_geography == 'GeographicItem::Polygon' + + nil + end + + def multi_polygon_column + return 'multi_polygon' if multi_polygon.present? + return 'geography' if type_of_geography == 'GeographicItem::MultiPolygon' + + nil + end + def align_winding if orientations.flatten.include?(false) - # TODO type digs through geography, but the sql needs to reference - # geography column - case type - when 'multi_polygon' + if (column = multi_polygon_column) ApplicationRecord.connection.execute( - "UPDATE geographic_items set multi_polygon = ST_ForcePolygonCCW(multi_polygon::geometry) + "UPDATE geographic_items set #{column} = ST_ForcePolygonCCW(#{column}::geometry) WHERE id = #{self.id};" ) - when 'polygon' - ApplicationRecord.connection.execute( - "UPDATE geographic_items set polygon = ST_ForcePolygonCCW(polygon::geometry) + elsif (column = polygon_column) + ApplicationRecord.connection.execute( + "UPDATE geographic_items set #{column} = ST_ForcePolygonCCW(#{column}::geometry) WHERE id = #{self.id};" ) end diff --git a/app/models/geographic_item/geography.rb b/app/models/geographic_item/geography.rb index ba0c99e35b..6ad24f05b9 100644 --- a/app/models/geographic_item/geography.rb +++ b/app/models/geographic_item/geography.rb @@ -3,8 +3,4 @@ class GeographicItem::Geography < GeographicItem SHAPE_COLUMN = :geography validates_presence_of :geography - - def type - "GeographicItem::#{geography.geometry_type.type_name}" - end end From afd485b0c3bbe2c160e9113d51301109c58dc5c3 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sat, 15 Jun 2024 08:05:50 -0500 Subject: [PATCH 018/259] #1954 Update GeographicItem::geo_object_type for geography column Note that the public `geo_object_type` returns the underlying shape of the :geography column in this case (point, polygon, etc), i.e. callers should be unaware that the underlying type is geography. `geo_object` returns the actual shape column stored in GeographicItem, which now may be a geography shape - that fits with the new geo_object_type since if the shape of the :geography column is a polygon e.g. (i.e. geo_object_type returned :polygon) then the :geography column can be treated as a polygon. This also addresses in part the difficulty I was having absorbing the various geo/type/object references: geo_object, geo_object_type, geo_type, SHAPE_COLUMN, all kind of interdependent on one another. To me at least this seems simpler (though possibly slightly less performant if that's a concern). --- app/models/geographic_item.rb | 52 +++++++------------ app/models/geographic_item/geography.rb | 1 - .../geographic_item/geometry_collection.rb | 1 - app/models/geographic_item/line_string.rb | 1 - .../geographic_item/multi_line_string.rb | 1 - app/models/geographic_item/multi_point.rb | 1 - app/models/geographic_item/multi_polygon.rb | 1 - app/models/geographic_item/point.rb | 1 - app/models/geographic_item/polygon.rb | 1 - spec/models/geographic_item_spec.rb | 16 ------ 10 files changed, 18 insertions(+), 58 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 4cc9e3db69..d952774acd 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -1066,22 +1066,15 @@ def st_npoints GeographicItem.where(id:).pluck(Arel.sql("ST_NPoints(#{GeographicItem::GEOMETRY_SQL.to_sql}) as npoints")).first end - # @return [Symbol] - # the geo type (i.e. column like :point, :multipolygon). References the first-found object, - # according to the list of DATA_TYPES, or nil + # @return [Symbol, nil] + # the specific type of geography: :point, :multipolygon, etc. Returns + # the underlying shape of :geography in the :geography case def geo_object_type - if self.class.name == 'GeographicItem' # a proxy check for new records - geo_type - else - self.class::SHAPE_COLUMN - end - end + column = data_column - def type_of_geography - return nil unless geo_object_type == :geography - - # TODO: too many versions of "type" ... what should this one be? - "GeographicItem::#{geography.geometry_type.type_name}" + return column == :geography ? + geography.geometry_type.type_name.underscore.to_sym : + column end # !!TODO: migrate these to use native column calls @@ -1089,15 +1082,9 @@ def type_of_geography # @return [RGeo instance, nil] # the Rgeo shape (See http://rubydoc.info/github/dazuma/rgeo/RGeo/Feature) def geo_object - begin - if r = geo_object_type # rubocop:disable Lint/AssignmentInCondition - send(r) - else - false - end - rescue RGeo::Error::InvalidGeometry - return nil # TODO: we need to render proper error for this! - end + column = data_column + + return column.nil? ? nil : send(column) end # @param [geo_object] @@ -1319,27 +1306,23 @@ def has_polygons? private def polygon_column - return 'polygon' if polygon.present? - return 'geography' if type_of_geography == 'GeographicItem::Polygon' - - nil + geo_object_type == :polygon ? data_column : nil end def multi_polygon_column - return 'multi_polygon' if multi_polygon.present? - return 'geography' if type_of_geography == 'GeographicItem::MultiPolygon' - - nil + geo_object_type == :multi_polygon ? data_column : nil end def align_winding if orientations.flatten.include?(false) if (column = multi_polygon_column) + column = column.to_s ApplicationRecord.connection.execute( "UPDATE geographic_items set #{column} = ST_ForcePolygonCCW(#{column}::geometry) WHERE id = #{self.id};" ) elsif (column = polygon_column) + column = column.to_s ApplicationRecord.connection.execute( "UPDATE geographic_items set #{column} = ST_ForcePolygonCCW(#{column}::geometry) WHERE id = #{self.id};" @@ -1412,8 +1395,9 @@ def set_cached # @return [Symbol] # returns the attribute (column name) containing data - # nearly all methods should use #geo_object_type, not geo_type - def geo_type + # nearly all methods should use #geo_object_type instead + def data_column + # This works before and after this item has been saved DATA_TYPES.each { |item| return item if send(item) } @@ -1423,7 +1407,7 @@ def geo_type # @return [Boolean, String] false if already set, or type to which it was set def set_type_if_geography_present if type.blank? - column = geo_type + column = data_column self.type = "GeographicItem::#{column.to_s.camelize}" if column end end diff --git a/app/models/geographic_item/geography.rb b/app/models/geographic_item/geography.rb index 6ad24f05b9..63b600a191 100644 --- a/app/models/geographic_item/geography.rb +++ b/app/models/geographic_item/geography.rb @@ -1,6 +1,5 @@ # Geography definition... # class GeographicItem::Geography < GeographicItem - SHAPE_COLUMN = :geography validates_presence_of :geography end diff --git a/app/models/geographic_item/geometry_collection.rb b/app/models/geographic_item/geometry_collection.rb index 235e92505a..3800d80e85 100644 --- a/app/models/geographic_item/geometry_collection.rb +++ b/app/models/geographic_item/geometry_collection.rb @@ -1,7 +1,6 @@ # Geometry collection definition... # class GeographicItem::GeometryCollection < GeographicItem - SHAPE_COLUMN = :geometry_collection validates_presence_of :geometry_collection # @return [RGeo::Point] first point in the collection diff --git a/app/models/geographic_item/line_string.rb b/app/models/geographic_item/line_string.rb index 53d7c38351..eb49bb4111 100644 --- a/app/models/geographic_item/line_string.rb +++ b/app/models/geographic_item/line_string.rb @@ -1,7 +1,6 @@ # Line string definition... # class GeographicItem::LineString < GeographicItem - SHAPE_COLUMN = :line_string validates_presence_of :line_string # @return [Array] arrays of points diff --git a/app/models/geographic_item/multi_line_string.rb b/app/models/geographic_item/multi_line_string.rb index 785cf69b58..429fbca766 100644 --- a/app/models/geographic_item/multi_line_string.rb +++ b/app/models/geographic_item/multi_line_string.rb @@ -1,7 +1,6 @@ # Multi line string definition... # class GeographicItem::MultiLineString < GeographicItem - SHAPE_COLUMN = :multi_line_string validates_presence_of :multi_line_string # @return [Array] arrays of points diff --git a/app/models/geographic_item/multi_point.rb b/app/models/geographic_item/multi_point.rb index 2630fa2630..cdf5187633 100644 --- a/app/models/geographic_item/multi_point.rb +++ b/app/models/geographic_item/multi_point.rb @@ -1,7 +1,6 @@ # Multi point definition... # class GeographicItem::MultiPoint < GeographicItem - SHAPE_COLUMN = :multi_point validates_presence_of :multi_point # @return [Array] arrays of points diff --git a/app/models/geographic_item/multi_polygon.rb b/app/models/geographic_item/multi_polygon.rb index 3fa48efa59..c44dd91227 100644 --- a/app/models/geographic_item/multi_polygon.rb +++ b/app/models/geographic_item/multi_polygon.rb @@ -1,7 +1,6 @@ # Multi polygon definition... # class GeographicItem::MultiPolygon < GeographicItem - SHAPE_COLUMN = :multi_polygon validates_presence_of :multi_polygon # @return [Array] arrays of points diff --git a/app/models/geographic_item/point.rb b/app/models/geographic_item/point.rb index 376d8a4394..da3bd75a2b 100644 --- a/app/models/geographic_item/point.rb +++ b/app/models/geographic_item/point.rb @@ -1,7 +1,6 @@ # A geographic point. # class GeographicItem::Point < GeographicItem - SHAPE_COLUMN = :point validates_presence_of :point validate :check_point_limits diff --git a/app/models/geographic_item/polygon.rb b/app/models/geographic_item/polygon.rb index 371877b0f7..f310dcbe36 100644 --- a/app/models/geographic_item/polygon.rb +++ b/app/models/geographic_item/polygon.rb @@ -1,7 +1,6 @@ # Polygon definition... # class GeographicItem::Polygon < GeographicItem - SHAPE_COLUMN = :polygon validates_presence_of :polygon # @return [Array] arrays of points diff --git a/spec/models/geographic_item_spec.rb b/spec/models/geographic_item_spec.rb index 911f035ea2..8a5871dd75 100644 --- a/spec/models/geographic_item_spec.rb +++ b/spec/models/geographic_item_spec.rb @@ -199,14 +199,6 @@ end end - context 'subclasses have a SHAPE_COLUMN set' do - GeographicItem.descendants.each do |d| - specify "for #{d}" do - expect(d::SHAPE_COLUMN).to be_truthy - end - end - end - specify '#geo_object_type' do expect(geographic_item).to respond_to(:geo_object_type) end @@ -1313,14 +1305,6 @@ end end - context 'subclasses have a SHAPE_COLUMN set' do - GeographicItem.descendants.each do |d| - specify "for #{d}" do - expect(d::SHAPE_COLUMN).to be_truthy - end - end - end - specify '#geo_object_type' do expect(geographic_item).to respond_to(:geo_object_type) end From 82a4d8c239be3c29c668a6dde634e94dd2936424 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sat, 15 Jun 2024 10:51:00 -0500 Subject: [PATCH 019/259] #1954 Minor refactorings in GeographicItem --- app/models/geographic_item.rb | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index d952774acd..17513c21d9 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -88,8 +88,6 @@ class GeographicItem < ApplicationRecord .when('GeographicItem::Geography').then(Arel::Nodes::NamedFunction.new('CAST', [arel_table[:geography].as('geometry')])) .freeze - # TODO Note this is pg, not rails: it doesn't know anything about the - # type override accessor for geography. GEOGRAPHY_SQL = "CASE geographic_items.type WHEN 'GeographicItem::MultiPolygon' THEN multi_polygon WHEN 'GeographicItem::Point' THEN point @@ -135,7 +133,6 @@ class GeographicItem < ApplicationRecord class << self - # TODO add geography case? def aliased_geographic_sql(name = 'a') "CASE #{name}.type \ WHEN 'GeographicItem::MultiPolygon' THEN #{name}.multi_polygon \ @@ -145,6 +142,7 @@ def aliased_geographic_sql(name = 'a') WHEN 'GeographicItem::MultiLineString' THEN #{name}.multi_line_string \ WHEN 'GeographicItem::MultiPoint' THEN #{name}.multi_point \ WHEN 'GeographicItem::GeometryCollection' THEN #{name}.geometry_collection \ + WHEN 'GeographicItem::Geography' THEN #{name}.geography \ END" end @@ -201,7 +199,6 @@ def crosses_anti_meridian_by_id?(*ids) # @param [Integer, String] # @return [String] # a SQL select statement that returns the *geometry* for the geographic_item with the specified id - # TODO Same query as select_geography_sql but without sanitize def select_geometry_sql(geographic_item_id) "SELECT #{GeographicItem::GEOMETRY_SQL.to_sql} from geographic_items where geographic_items.id = #{geographic_item_id}" end @@ -683,15 +680,12 @@ def are_contained_in_item_by_id(column_name, *geographic_item_ids) # = containin geographic_item_ids.flatten! # in case there is a array of arrays, or multiple objects column_name.downcase! case column_name + when 'geometry_collection' + none when 'any' part = [] DATA_TYPES.each { |column| - # TODO need to check geography column type here - # TODO how does empty return on g_c not cause a problem? (is there - # currently any way to produce a GeoItem of type g_c? Test entering g_c WKT in georeference) - unless column == :geometry_collection - part.push(GeographicItem.are_contained_in_item_by_id(column.to_s, geographic_item_ids).to_a) - end + part.push(GeographicItem.are_contained_in_item_by_id(column.to_s, geographic_item_ids).to_a) } # TODO: change 'id in (?)' to some other sql construct GeographicItem.where(id: part.flatten.map(&:id)) @@ -1354,7 +1348,7 @@ def self.debug_draw(geographic_item_ids = []) prefix = if geographic_item_ids.size > 10 'multiple' else - geographic_item_ids.join('_') + '_debug.draw.png' + geographic_item_ids.join('_') end n = prefix + '_debug.draw.png' @@ -1532,9 +1526,8 @@ def some_data_is_provided errors.add(object, 'More than one shape type provided') end end - # TODO should this be false? - true - end + false end +end # Dir[Rails.root.to_s + '/app/models/geographic_item/**/*.rb'].each { |file| require_dependency file } From 512d009fb882b4bf1d6a8f8e2e9b78ac10f9c2c4 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sun, 16 Jun 2024 09:44:45 -0500 Subject: [PATCH 020/259] #1954 More non-functional cleanup and TODOs in GeographicItem --- app/models/geographic_item.rb | 142 +++++++++++++----------- app/models/geographic_item/geography.rb | 2 + 2 files changed, 82 insertions(+), 62 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 17513c21d9..2df33e00ad 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -168,7 +168,7 @@ def default_by_geographic_area_ids(geographic_area_ids = []) # @param [String] wkt # @return [Boolean] - # whether or not the wtk intersects with the anti-meridian + # whether or not the wkt intersects with the anti-meridian # !! StrongParams security considerations def crosses_anti_meridian?(wkt) GeographicItem.find_by_sql( @@ -266,6 +266,9 @@ def st_buffer_st_within(geographic_item_id, distance, buffer = 0) # @return [String] # !! This is intersecting def intersecting_radius_of_wkt_sql(wkt, distance) + # TODO the first param is a geography, the second is a geometry - be + # explicit about what is intended + # I'm also unclear on why the transform from 4326 to 4326? "ST_DWithin((#{GeographicItem::GEOGRAPHY_SQL}), ST_Transform( ST_GeomFromText('#{wkt}', " \ "4326), 4326), #{distance})" end @@ -281,7 +284,8 @@ def within_radius_of_wkt_sql(wkt, distance) # @param [String, Integer, String] # @return [String] # a SQL fragment for ST_Contains() function, returns - # all geographic items which are contained in the item supplied + # all geographic items which contain the item supplied + # TODO issue if target or source column is geometrycollection? def containing_sql(target_column_name = nil, geographic_item_id = nil, source_column_name = nil) return 'false' if geographic_item_id.nil? || source_column_name.nil? || target_column_name.nil? "ST_Contains(#{target_column_name}::geometry, (#{geometry_sql(geographic_item_id, source_column_name)}))" @@ -290,7 +294,9 @@ def containing_sql(target_column_name = nil, geographic_item_id = nil, source_co # @param [String, Integer, String] # @return [String] # a SQL fragment for ST_Contains(), returns - # all geographic_items which contain the supplied geographic_item + # all geographic_items which are contained in the supplied + # geographic_item + # TODO issue if target or source column is geometrycollection? def reverse_containing_sql(target_column_name = nil, geographic_item_id = nil, source_column_name = nil) return 'false' if geographic_item_id.nil? || source_column_name.nil? || target_column_name.nil? "ST_Contains((#{geometry_sql(geographic_item_id, source_column_name)}), #{target_column_name}::geometry)" @@ -298,8 +304,8 @@ def reverse_containing_sql(target_column_name = nil, geographic_item_id = nil, s # @param [Integer, String] # @return [String] - # a SQL fragment that represents the geometry of the geographic item specified (which has data in the - # source_column_name, i.e. geo_object_type) + # a SQL fragment that returns the specified geometry column of the + # specified geographic item def geometry_sql(geographic_item_id = nil, source_column_name = nil) return 'false' if geographic_item_id.nil? || source_column_name.nil? "select geom_alias_tbl.#{source_column_name}::geometry from geographic_items geom_alias_tbl " \ @@ -342,8 +348,10 @@ def is_contained_by_sql(column_name, geographic_item) # @param [Integer, Array of Integer] geographic_item_ids # @return [String] - # a select query that returns a single geometry (column name 'single_geometry' for the collection of ids - # provided via ST_Collect) + # A select query that returns a single geometry (column name + # 'single_geometry') for the collection of ids. + # Provided via ST_Collect + # TODO explode this in single_geometry_sql def st_collect_sql(*geographic_item_ids) geographic_item_ids.flatten! ActiveRecord::Base.send(:sanitize_sql_for_conditions, [ @@ -357,7 +365,8 @@ def st_collect_sql(*geographic_item_ids) # @param [Integer, Array of Integer] geographic_item_ids # @return [String] - # returns one or more geographic items combined as a single geometry in column 'single' + # returns one or more geographic items combined as a single geometry + # in a paren wrapped column 'single_geometry' def single_geometry_sql(*geographic_item_ids) a = GeographicItem.st_collect_sql(geographic_item_ids) '(SELECT single.single_geometry FROM (' + a + ' ) AS single)' @@ -365,8 +374,9 @@ def single_geometry_sql(*geographic_item_ids) # @param [Integer, Array of Integer] geographic_item_ids # @return [String] - # returns a single geometry "column" (paren wrapped) as "single" for multiple geographic item ids, or the - # geometry as 'geometry' for a single id + # returns a single geometry "column" (paren wrapped) as + # "single_geometry" for multiple geographic item ids, or the geometry + # as 'geometry' for a single id def geometry_sql2(*geographic_item_ids) geographic_item_ids.flatten! # *ALWAYS* reduce the pile to a single level of ids if geographic_item_ids.count == 1 @@ -377,12 +387,10 @@ def geometry_sql2(*geographic_item_ids) end # @param [Integer, Array of Integer] geographic_item_ids - # @return [String] - # Result includes self + # @return [String] Those geographic items containing the union of + # geographic_item_ids. # TODO why does GEOMETRY_SQL.to_sql fail here? - # TODO need to exclude geometry collection from the geography case until - # required postgis >= 3.0 - excluding means geometry collection shapes - # are not considered/never returned + # TODO need to handle non-geom_collection geography case here def containing_where_sql(*geographic_item_ids) "ST_CoveredBy( #{GeographicItem.geometry_sql2(*geographic_item_ids)}, @@ -451,8 +459,10 @@ def contained_by_with_antimeridian_check(*ids) end # @params [String] well known text - # @return [String] the SQL fragment for the specific geometry type, shifted by longitude - # Note: this routine is called when it is already known that the A argument crosses anti-meridian + # @return [String] the SQL fragment for the specific geometry type, + # shifted by longitude + # Note: this routine is called when it is already known that the A + # argument crosses anti-meridian # TODO If wkt coords are in the range 0..360 and GI coords are in the range -180..180 (or vice versa), doesn't this fail? Don't you want all coords in the range 0..360 in this geometry case? Is there any assumption about range of inputs for georefs, e.g.? are they always normalized? See anti-meridian spec? def contained_by_wkt_shifted_sql(wkt) "ST_Contains(ST_ShiftLongitude(ST_GeomFromText('#{wkt}', 4326)), ( @@ -470,7 +480,8 @@ def contained_by_wkt_shifted_sql(wkt) # TODO: Remove the hard coded 4326 reference # @params [String] wkt - # @return [String] SQL fragment limiting geographics items to those in this WKT + # @return [String] SQL fragment limiting geographic items to those + # contained by this WKT def contained_by_wkt_sql(wkt) if crosses_anti_meridian?(wkt) retval = contained_by_wkt_shifted_sql(wkt) @@ -540,14 +551,16 @@ def geometry_for_collection_sql(*geographic_item_ids) # @param [Integer, Array of Integer] geographic_item_ids # @return [Scope] - # the geographic items containing all of the geographic_item ids; return value never includes geographic_item_ids + # the geographic items containing all of the geographic_item ids; + # return value never includes geographic_item_ids def containing(*geographic_item_ids) where(GeographicItem.containing_where_sql(geographic_item_ids)).not_ids(*geographic_item_ids) end # @param [Integer, Array of Integer] geographic_item_ids # @return [Scope] - # the geographic items contained by the union of these geographic_item ids; return value always includes geographic_item_ids + # the geographic items contained by the union of these + # geographic_item ids; return value always includes geographic_item_ids # (works via ST_Contains) def contained_by(*geographic_item_ids) where(GeographicItem.contained_by_where_sql(geographic_item_ids)) @@ -723,7 +736,6 @@ def are_contained_in_item_by_id(column_name, *geographic_item_ids) # = containin # which are contained in the WKT def are_contained_in_wkt(column_name, geometry) column_name.downcase! - # column_name = 'point' case column_name when 'any' part = [] @@ -744,14 +756,14 @@ def are_contained_in_wkt(column_name, geometry) # TODO: change 'id in (?)' to some other sql construct GeographicItem.where(id: part.flatten) else - # column = points, geometry = square q = "ST_Contains(ST_GeomFromEWKT('srid=4326;#{geometry}'), #{column_name}::geometry)" where(q) # .not_including(geographic_items) end end # rubocop:disable Metrics/MethodLength - # containing the items the shape of which is contained in the geographic_item[s] supplied. + # Returns a scope of all items whose column_name ontains an element of + # geographic_items. # @param column_name [String] can be any of DATA_TYPES, or 'any' to check against all types, 'any_poly' to check # against 'polygon' or 'multi_polygon', or 'any_line' to check against 'line_string' or 'multi_line_string'. # CANNOT be 'geometry_collection'. @@ -869,21 +881,21 @@ def point_inferred_geographic_name_hierarchy(point) GeographicItem.containing_point(point).order(cached_total_area: :ASC).limit(1).first&.inferred_geographic_name_hierarchy end - # @param [String] type_name ('polygon', 'point', 'line' [, 'circle']) + # @param [String] type_name ('polygon', 'point', 'line', etc) # @return [String] if type def eval_for_type(type_name) retval = 'GeographicItem' case type_name.upcase when 'POLYGON' retval += '::Polygon' - when 'LINESTRING' - retval += '::LineString' - when 'POINT' - retval += '::Point' when 'MULTIPOLYGON' retval += '::MultiPolygon' + when 'LINESTRING' + retval += '::LineString' when 'MULTILINESTRING' retval += '::MultiLineString' + when 'POINT' + retval += '::Point' when 'MULTIPOINT' retval += '::MultiPoint' when 'GEOGRAPHY' @@ -972,8 +984,10 @@ def valid_geometry? end # @return [Array of latitude, longitude] - # the lat, lon of the first point in the GeoItem, see subclass for #st_start_point + # the lat, lon of the first point in the GeoItem, see subclass for + # st_start_point def start_point + # TODO add st_start_point for geography o = st_start_point [o.y, o.x] end @@ -995,6 +1009,7 @@ def center_coords # representing the centroid of this geographic item def centroid # Gis::FACTORY.point(*center_coords.reverse) + # TODO check geography type for point return geo_object if type == 'GeographicItem::Point' return Gis::FACTORY.parse_wkt(self.st_centroid) end @@ -1051,6 +1066,7 @@ def st_distance_spheroid(geographic_item_id) # @return [String] # a WKT POINT representing the centroid of the geographic item def st_centroid + # TODO why to_param here? GeographicItem.where(id: to_param).pluck(Arel.sql("ST_AsEWKT(ST_Centroid(#{GeographicItem::GEOMETRY_SQL.to_sql}))")).first.gsub(/SRID=\d*;/, '') end @@ -1060,27 +1076,8 @@ def st_npoints GeographicItem.where(id:).pluck(Arel.sql("ST_NPoints(#{GeographicItem::GEOMETRY_SQL.to_sql}) as npoints")).first end - # @return [Symbol, nil] - # the specific type of geography: :point, :multipolygon, etc. Returns - # the underlying shape of :geography in the :geography case - def geo_object_type - column = data_column - - return column == :geography ? - geography.geometry_type.type_name.underscore.to_sym : - column - end - # !!TODO: migrate these to use native column calls - # @return [RGeo instance, nil] - # the Rgeo shape (See http://rubydoc.info/github/dazuma/rgeo/RGeo/Feature) - def geo_object - column = data_column - - return column.nil? ? nil : send(column) - end - # @param [geo_object] # @return [Boolean] def contains?(target_geo_object) @@ -1126,6 +1123,7 @@ def rgeo_to_geo_json # requires the geo_object_type and id. # def to_geo_json + # TODO need to handle geography case here JSON.parse( GeographicItem.connection.select_one( "SELECT ST_AsGeoJSON(#{geo_object_type}::geometry) a " \ @@ -1162,7 +1160,7 @@ def to_geo_json_feature # # '{"type":"Point","coordinates":[2.5,4.0]},"properties":{"color":"red"}}' # - # @return [Boolean, RGeo object] + # @return [RGeo object] def shape=(value) return if value.blank? @@ -1187,7 +1185,7 @@ def shape=(value) self.type = GeographicItem.eval_for_type(this_type) unless geom.nil? if type.blank? - errors.add(:base, 'type is not set from shape') + errors.add(:base, "unrecognized geometry type '#{this_type}'; note geometry collections are currently not supported") return end @@ -1297,8 +1295,38 @@ def has_polygons? ['GeographicItem::MultiPolygon', 'GeographicItem::Polygon'].include?(self.type) end + # @return [Symbol, nil] + # the specific type of geography: :point, :multipolygon, etc. Returns + # the underlying shape of :geography in the :geography case + def geo_object_type + column = data_column + + return column == :geography ? + geography.geometry_type.type_name.underscore.to_sym : + column + end + + # @return [RGeo instance, nil] + # the Rgeo shape (See http://rubydoc.info/github/dazuma/rgeo/RGeo/Feature) + def geo_object + column = data_column + + return column.nil? ? nil : send(column) + end + private + # @return [Symbol] + # returns the attribute (column name) containing data + # nearly all methods should use #geo_object_type instead + def data_column + # This works before and after this item has been saved + DATA_TYPES.each { |item| + return item if send(item) + } + nil + end + def polygon_column geo_object_type == :polygon ? data_column : nil end @@ -1330,7 +1358,7 @@ def align_winding # to a png def self.debug_draw(geographic_item_ids = []) return false if geographic_item_ids.empty? - # TODO why does this only reference multi_polygon? + # TODO support other shapes sql = "SELECT ST_AsPNG( ST_AsRaster( (SELECT ST_Union(multi_polygon::geometry) from geographic_items where id IN (" + geographic_item_ids.join(',') + ")), 1920, 1080 @@ -1387,18 +1415,8 @@ def set_cached update_column(:cached_total_area, area) end - # @return [Symbol] - # returns the attribute (column name) containing data - # nearly all methods should use #geo_object_type instead - def data_column - # This works before and after this item has been saved - DATA_TYPES.each { |item| - return item if send(item) - } - nil - end - # @return [Boolean, String] false if already set, or type to which it was set + # TODO rename this - geography is now the name of a column def set_type_if_geography_present if type.blank? column = data_column diff --git a/app/models/geographic_item/geography.rb b/app/models/geographic_item/geography.rb index 63b600a191..a610e8158b 100644 --- a/app/models/geographic_item/geography.rb +++ b/app/models/geographic_item/geography.rb @@ -2,4 +2,6 @@ # class GeographicItem::Geography < GeographicItem validates_presence_of :geography + + # TODO: to_a, st_start_point, others? end From b34075afb562a6b37c9431ef4f13b6b4e687b2e6 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sun, 16 Jun 2024 11:02:49 -0500 Subject: [PATCH 021/259] #1954 Small refactorings and changes in GeographicItem `valid_geometry?` is another method only used in specs --- app/models/geographic_item.rb | 55 +++++++++------------------- spec/models/geographic_item_spec.rb | 56 ----------------------------- 2 files changed, 17 insertions(+), 94 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 2df33e00ad..edc94816af 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -119,7 +119,7 @@ class GeographicItem < ApplicationRecord through: :georeferences_through_error_geographic_item, source: :collecting_event # TODO: THIS IS NOT GOOD - before_validation :set_type_if_geography_present + before_validation :set_type_if_shape_column_present validate :some_data_is_provided validates :type, presence: true # not needed @@ -266,11 +266,7 @@ def st_buffer_st_within(geographic_item_id, distance, buffer = 0) # @return [String] # !! This is intersecting def intersecting_radius_of_wkt_sql(wkt, distance) - # TODO the first param is a geography, the second is a geometry - be - # explicit about what is intended - # I'm also unclear on why the transform from 4326 to 4326? - "ST_DWithin((#{GeographicItem::GEOGRAPHY_SQL}), ST_Transform( ST_GeomFromText('#{wkt}', " \ - "4326), 4326), #{distance})" + "ST_DWithin((#{GeographicItem::GEOGRAPHY_SQL}), ST_GeomFromText('#{wkt}', 4326)::geography, #{distance})" end # @param [String] wkt @@ -348,28 +344,19 @@ def is_contained_by_sql(column_name, geographic_item) # @param [Integer, Array of Integer] geographic_item_ids # @return [String] - # A select query that returns a single geometry (column name - # 'single_geometry') for the collection of ids. - # Provided via ST_Collect - # TODO explode this in single_geometry_sql - def st_collect_sql(*geographic_item_ids) + # returns one or more geographic items combined as a single geometry + # in a paren wrapped column 'single_geometry' + def single_geometry_sql(*geographic_item_ids) geographic_item_ids.flatten! - ActiveRecord::Base.send(:sanitize_sql_for_conditions, [ + q = ActiveRecord::Base.send(:sanitize_sql_for_conditions, [ "SELECT ST_Collect(f.the_geom) AS single_geometry FROM ( SELECT (ST_DUMP(#{GeographicItem::GEOMETRY_SQL.to_sql})).geom as the_geom FROM geographic_items WHERE id in (?)) AS f", geographic_item_ids]) - end - # @param [Integer, Array of Integer] geographic_item_ids - # @return [String] - # returns one or more geographic items combined as a single geometry - # in a paren wrapped column 'single_geometry' - def single_geometry_sql(*geographic_item_ids) - a = GeographicItem.st_collect_sql(geographic_item_ids) - '(SELECT single.single_geometry FROM (' + a + ' ) AS single)' + '(' + q + ')' end # @param [Integer, Array of Integer] geographic_item_ids @@ -772,12 +759,13 @@ def are_contained_in_wkt(column_name, geometry) def is_contained_by(column_name, *geographic_items) column_name.downcase! case column_name + when 'geometry_collection' + none + when 'any' part = [] DATA_TYPES.each { |column| - unless column == :geometry_collection - part.push(GeographicItem.is_contained_by(column.to_s, geographic_items).to_a) - end + part.push(GeographicItem.is_contained_by(column.to_s, geographic_items).to_a) } # @TODO change 'id in (?)' to some other sql construct GeographicItem.where(id: part.flatten.map(&:id)) @@ -850,13 +838,6 @@ def not_including(geographic_items) where.not(id: geographic_items) end - # @return [Scope] - # includes an 'is_valid' attribute (True/False) for the passed geographic_item. Uses St_IsValid. - # @param [RGeo object] geographic_item - def with_is_valid_geometry_column(geographic_item) - where(id: geographic_item.id).select("ST_IsValid(ST_AsBinary(#{geographic_item.geo_object_type})) is_valid") - end - # # Other # @@ -898,6 +879,8 @@ def eval_for_type(type_name) retval += '::Point' when 'MULTIPOINT' retval += '::MultiPoint' + when 'GEOMETRYCOLLECTION' + retval += '::GeometryCollection' when 'GEOGRAPHY' retval += '::Geography' else @@ -980,7 +963,7 @@ def containing_geographic_areas # @return [Boolean] # whether stored shape is ST_IsValid def valid_geometry? - GeographicItem.with_is_valid_geometry_column(self).first['is_valid'] + GeographicItem.where(id:).select("ST_IsValid(ST_AsBinary(#{data_column})) is_valid").first['is_valid'] end # @return [Array of latitude, longitude] @@ -1009,8 +992,7 @@ def center_coords # representing the centroid of this geographic item def centroid # Gis::FACTORY.point(*center_coords.reverse) - # TODO check geography type for point - return geo_object if type == 'GeographicItem::Point' + return geo_object if geo_object_type == :point return Gis::FACTORY.parse_wkt(self.st_centroid) end @@ -1121,12 +1103,10 @@ def rgeo_to_geo_json # in GeoJSON format # Computed via "raw" PostGIS (much faster). This # requires the geo_object_type and id. - # def to_geo_json - # TODO need to handle geography case here JSON.parse( GeographicItem.connection.select_one( - "SELECT ST_AsGeoJSON(#{geo_object_type}::geometry) a " \ + "SELECT ST_AsGeoJSON(#{data_column}::geometry) a " \ "FROM geographic_items WHERE id=#{id};")['a']) end @@ -1416,8 +1396,7 @@ def set_cached end # @return [Boolean, String] false if already set, or type to which it was set - # TODO rename this - geography is now the name of a column - def set_type_if_geography_present + def set_type_if_shape_column_present if type.blank? column = data_column self.type = "GeographicItem::#{column.to_s.camelize}" if column diff --git a/spec/models/geographic_item_spec.rb b/spec/models/geographic_item_spec.rb index 8a5871dd75..3aee076c0b 100644 --- a/spec/models/geographic_item_spec.rb +++ b/spec/models/geographic_item_spec.rb @@ -1052,34 +1052,6 @@ # 5,862 km (3,642 miles) expect(result[2].distance).to be_within(0.1).of(5862006.0029975) end - - specify '::with_is_valid_geometry_column returns \'true\' for a valid GeoItem' do - expect(GeographicItem.with_is_valid_geometry_column(p0)).to be_truthy - end - - specify '::with_is_valid_geometry_column returns \'true\' for a valid GeoItem' do - expect(GeographicItem.with_is_valid_geometry_column(a)).to be_truthy - end - - specify '::with_is_valid_geometry_column returns \'true\' for a valid GeoItem' do - expect(GeographicItem.with_is_valid_geometry_column(b)).to be_truthy - end - - specify '::with_is_valid_geometry_column returns \'true\' for a valid GeoItem' do - expect(GeographicItem.with_is_valid_geometry_column(h)).to be_truthy - end - - specify '::with_is_valid_geometry_column returns \'true\' for a valid GeoItem' do - expect(GeographicItem.with_is_valid_geometry_column(f)).to be_truthy - end - - specify '::with_is_valid_geometry_column returns \'true\' for a valid GeoItem' do - expect(GeographicItem.with_is_valid_geometry_column(g)).to be_truthy - end - - specify '::with_is_valid_geometry_column returns \'true\' for a valid GeoItem' do - expect(GeographicItem.with_is_valid_geometry_column(all_items)).to be_truthy - end end context 'distance to others' do @@ -2158,34 +2130,6 @@ # 5,862 km (3,642 miles) expect(result[2].distance).to be_within(0.1).of(5862006.0029975) end - - specify '::with_is_valid_geometry_column returns \'true\' for a valid GeoItem' do - expect(GeographicItem.with_is_valid_geometry_column(p0)).to be_truthy - end - - specify '::with_is_valid_geometry_column returns \'true\' for a valid GeoItem' do - expect(GeographicItem.with_is_valid_geometry_column(a)).to be_truthy - end - - specify '::with_is_valid_geometry_column returns \'true\' for a valid GeoItem' do - expect(GeographicItem.with_is_valid_geometry_column(b)).to be_truthy - end - - specify '::with_is_valid_geometry_column returns \'true\' for a valid GeoItem' do - expect(GeographicItem.with_is_valid_geometry_column(h)).to be_truthy - end - - specify '::with_is_valid_geometry_column returns \'true\' for a valid GeoItem' do - expect(GeographicItem.with_is_valid_geometry_column(f)).to be_truthy - end - - specify '::with_is_valid_geometry_column returns \'true\' for a valid GeoItem' do - expect(GeographicItem.with_is_valid_geometry_column(g)).to be_truthy - end - - specify '::with_is_valid_geometry_column returns \'true\' for a valid GeoItem' do - expect(GeographicItem.with_is_valid_geometry_column(all_items)).to be_truthy - end end context 'distance to others' do From d7b5c8da0a92178c297b78819e61ea8852d0c7d1 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Mon, 17 Jun 2024 09:49:43 -0500 Subject: [PATCH 022/259] #1954 Begin changing `column` references to `shape` refs in GeographicItem A polygon, e.g., can now be found in either the polygon column or the geography column. GeographyItem currently synonymizes the column name with shape name, but now we need to be more general. This commit is just a test case, there are more changes needed. Once there's only a geography column then `shape_column_sql` will either be adjusted to return NULL in the ELSE case, or those queries where it's used can be turned into `WHERE ST_GeometryType(geography::geometry) = 'ST_shape'` --- app/models/geographic_item.rb | 65 +++++++++++++++++++---------- spec/models/geographic_item_spec.rb | 26 ------------ 2 files changed, 44 insertions(+), 47 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index edc94816af..feaf291c7f 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -280,32 +280,40 @@ def within_radius_of_wkt_sql(wkt, distance) # @param [String, Integer, String] # @return [String] # a SQL fragment for ST_Contains() function, returns - # all geographic items which contain the item supplied + # all geographic items whose target_shape contain the item supplied's + # source_shape # TODO issue if target or source column is geometrycollection? - def containing_sql(target_column_name = nil, geographic_item_id = nil, source_column_name = nil) - return 'false' if geographic_item_id.nil? || source_column_name.nil? || target_column_name.nil? - "ST_Contains(#{target_column_name}::geometry, (#{geometry_sql(geographic_item_id, source_column_name)}))" + def containing_sql(target_shape = nil, geographic_item_id = nil, + source_shape = nil) + return 'false' if geographic_item_id.nil? || source_shape.nil? || target_shape.nil? + + target_shape_sql = GeographicItem.shape_column_sql(target_shape) + "ST_Contains(#{target_shape_sql}::geometry, " \ + "(#{geometry_sql(geographic_item_id, source_shape)}))" end # @param [String, Integer, String] # @return [String] - # a SQL fragment for ST_Contains(), returns - # all geographic_items which are contained in the supplied - # geographic_item + # a SQL fragment for ST_Contains() function, returns + # all geographic items whose target_shape is contained in the item + # supplied's source_shape # TODO issue if target or source column is geometrycollection? - def reverse_containing_sql(target_column_name = nil, geographic_item_id = nil, source_column_name = nil) - return 'false' if geographic_item_id.nil? || source_column_name.nil? || target_column_name.nil? - "ST_Contains((#{geometry_sql(geographic_item_id, source_column_name)}), #{target_column_name}::geometry)" + def reverse_containing_sql(target_shape = nil, geographic_item_id = nil, source_shape = nil) + return 'false' if geographic_item_id.nil? || source_shape.nil? || target_shape.nil? + + target_shape_sql = GeographicItem.shape_column_sql(target_shape) + "ST_Contains((#{geometry_sql(geographic_item_id, source_shape)}), #{target_shape_sql}::geometry)" end # @param [Integer, String] # @return [String] - # a SQL fragment that returns the specified geometry column of the - # specified geographic item - def geometry_sql(geographic_item_id = nil, source_column_name = nil) - return 'false' if geographic_item_id.nil? || source_column_name.nil? - "select geom_alias_tbl.#{source_column_name}::geometry from geographic_items geom_alias_tbl " \ - "where geom_alias_tbl.id = #{geographic_item_id}" + # a SQL fragment that returns the column containing data of type + # shape for the specified geographic item + def geometry_sql(geographic_item_id = nil, shape = nil) + return 'false' if geographic_item_id.nil? || shape.nil? + + "SELECT #{GeographicItem.shape_column_sql(shape)}::geometry FROM " \ + "geographic_items WHERE id = #{geographic_item_id}" end # DEPRECATED @@ -646,12 +654,15 @@ def within_radius_of_item(geographic_item_id, distance) # DEPRECATED # @param [String, GeographicItem] # @return [Scope] - # a SQL fragment for ST_DISJOINT, specifies all geographic_items that have data in column_name - # that are disjoint from the passed geographic_items - def disjoint_from(column_name, *geographic_items) + # a SQL fragment for ST_DISJOINT, specifies geographic_items that + # have data of type shape that are disjoint from all of the passed + # geographic_items + def disjoint_from(shape, *geographic_items) + shape_column = GeographicItem.shape_column_sql(shape) + q = geographic_items.flatten.collect { |geographic_item| - "ST_DISJOINT(#{column_name}::geometry, (#{geometry_sql(geographic_item.to_param, - geographic_item.geo_object_type)}))" + "ST_DISJOINT(#{shape_column}::geometry, " \ + "(#{geographic_item.data_column}))" }.join(' and ') where(q) @@ -1315,6 +1326,18 @@ def multi_polygon_column geo_object_type == :multi_polygon ? data_column : nil end + # @param [String] shape, the type of shape you want + # @return [String] + # A paren-wrapped SQL fragment for selecting the column containing + # shape. Returns the column named :shape if no shape is found. + def self.shape_column_sql(shape) + st_shape = 'ST_' + shape.to_s.camelize + + '(CASE ST_GeometryType(geography::geometry) ' \ + "WHEN '#{st_shape}' THEN geography " \ + "ELSE #{shape} END)" + end + def align_winding if orientations.flatten.include?(false) if (column = multi_polygon_column) diff --git a/spec/models/geographic_item_spec.rb b/spec/models/geographic_item_spec.rb index 3aee076c0b..457cdb2ca8 100644 --- a/spec/models/geographic_item_spec.rb +++ b/spec/models/geographic_item_spec.rb @@ -577,12 +577,6 @@ context 'class methods' do - specify '::geometry_sql' do - test = 'select geom_alias_tbl.polygon::geometry from geographic_items geom_alias_tbl ' \ - 'where geom_alias_tbl.id = 2' - expect(GeographicItem.geometry_sql(2, :polygon)).to eq(test) - end - specify '::ordered_by_shortest_distance_from to specify ordering of found objects.' do expect(GeographicItem).to respond_to(:ordered_by_shortest_distance_from) end @@ -604,13 +598,6 @@ expect(GeographicItem).to respond_to(:intersecting) end - specify '::containing_sql' do - test1 = 'ST_Contains(polygon::geometry, (select geom_alias_tbl.point::geometry from ' \ - "geographic_items geom_alias_tbl where geom_alias_tbl.id = #{p1.id}))" - expect(GeographicItem.containing_sql('polygon', - p1.to_param, p1.geo_object_type)).to eq(test1) - end - specify '::eval_for_type' do expect(GeographicItem.eval_for_type('polygon')).to eq('GeographicItem::Polygon') end @@ -1655,12 +1642,6 @@ context 'class methods' do - specify '::geometry_sql' do - test = 'select geom_alias_tbl.polygon::geometry from geographic_items geom_alias_tbl ' \ - 'where geom_alias_tbl.id = 2' - expect(GeographicItem.geometry_sql(2, :polygon)).to eq(test) - end - specify '::ordered_by_shortest_distance_from to specify ordering of found objects.' do expect(GeographicItem).to respond_to(:ordered_by_shortest_distance_from) end @@ -1682,13 +1663,6 @@ expect(GeographicItem).to respond_to(:intersecting) end - specify '::containing_sql' do - test1 = 'ST_Contains(polygon::geometry, (select geom_alias_tbl.point::geometry from ' \ - "geographic_items geom_alias_tbl where geom_alias_tbl.id = #{p1.id}))" - expect(GeographicItem.containing_sql('polygon', - p1.to_param, p1.geo_object_type)).to eq(test1) - end - specify '::eval_for_type' do expect(GeographicItem.eval_for_type('polygon')).to eq('GeographicItem::Polygon') end From 8874fa0f3a4bf8a80728d34072321743f81ee6ee Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Mon, 17 Jun 2024 11:38:32 -0500 Subject: [PATCH 023/259] #1954 Assume postgis >= 3.0, support GeometryCollection throughout Prior to 3.0 ST_Contains and siblings did not support GeometryCollection. There's still one change to be made, which I'll do when I add geography support at the same time. --- app/models/geographic_item.rb | 34 +++++++++------------------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index feaf291c7f..97eb533162 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -282,7 +282,6 @@ def within_radius_of_wkt_sql(wkt, distance) # a SQL fragment for ST_Contains() function, returns # all geographic items whose target_shape contain the item supplied's # source_shape - # TODO issue if target or source column is geometrycollection? def containing_sql(target_shape = nil, geographic_item_id = nil, source_shape = nil) return 'false' if geographic_item_id.nil? || source_shape.nil? || target_shape.nil? @@ -297,7 +296,6 @@ def containing_sql(target_shape = nil, geographic_item_id = nil, # a SQL fragment for ST_Contains() function, returns # all geographic items whose target_shape is contained in the item # supplied's source_shape - # TODO issue if target or source column is geometrycollection? def reverse_containing_sql(target_shape = nil, geographic_item_id = nil, source_shape = nil) return 'false' if geographic_item_id.nil? || source_shape.nil? || target_shape.nil? @@ -331,16 +329,12 @@ def is_contained_by_sql(column_name, geographic_item) case column_name when 'any' DATA_TYPES.each { |column| - unless column == :geometry_collection - retval.push(template % [geo_type, geo_id, column]) - end + retval.push(template % [geo_type, geo_id, column]) } when 'any_poly', 'any_line' DATA_TYPES.each { |column| - unless column == :geometry_collection - if column.to_s.index(column_name.gsub('any_', '')) - retval.push(template % [geo_type, geo_id, column]) - end + if column.to_s.index(column_name.gsub('any_', '')) + retval.push(template % [geo_type, geo_id, column]) end } else @@ -691,8 +685,6 @@ def are_contained_in_item_by_id(column_name, *geographic_item_ids) # = containin geographic_item_ids.flatten! # in case there is a array of arrays, or multiple objects column_name.downcase! case column_name - when 'geometry_collection' - none when 'any' part = [] DATA_TYPES.each { |column| @@ -714,7 +706,7 @@ def are_contained_in_item_by_id(column_name, *geographic_item_ids) # = containin q = geographic_item_ids.flatten.collect { |geographic_item_id| # discover the item types, and convert type to database type for 'multi_' b = GeographicItem.where(id: geographic_item_id) - .pluck(:type)[0].split(':')[2].downcase.gsub('lti', 'lti_') + .pluck(:type)[0].split(':')[2].underscore # a = GeographicItem.find(geographic_item_id).geo_object_type GeographicItem.containing_sql(column_name, geographic_item_id, b) }.join(' or ') @@ -730,7 +722,7 @@ def are_contained_in_item_by_id(column_name, *geographic_item_ids) # = containin # @param [String] column_name # @param [String] geometry of WKT # @return [Scope] - # a single WKT geometry is compared against column or columns (except geometry_collection) to find geographic_items + # a single WKT geometry is compared against column or columns to find geographic_items # which are contained in the WKT def are_contained_in_wkt(column_name, geometry) column_name.downcase! @@ -738,9 +730,7 @@ def are_contained_in_wkt(column_name, geometry) when 'any' part = [] DATA_TYPES.each { |column| - unless column == :geometry_collection - part.push(GeographicItem.are_contained_in_wkt(column.to_s, geometry).pluck(:id).to_a) - end + part.push(GeographicItem.are_contained_in_wkt(column.to_s, geometry).pluck(:id).to_a) } # TODO: change 'id in (?)' to some other sql construct GeographicItem.where(id: part.flatten) @@ -764,15 +754,11 @@ def are_contained_in_wkt(column_name, geometry) # geographic_items. # @param column_name [String] can be any of DATA_TYPES, or 'any' to check against all types, 'any_poly' to check # against 'polygon' or 'multi_polygon', or 'any_line' to check against 'line_string' or 'multi_line_string'. - # CANNOT be 'geometry_collection'. # @param geographic_items [GeographicItem] Can be a single GeographicItem, or an array of GeographicItem. # @return [Scope] def is_contained_by(column_name, *geographic_items) column_name.downcase! case column_name - when 'geometry_collection' - none - when 'any' part = [] DATA_TYPES.each { |column| @@ -784,11 +770,9 @@ def is_contained_by(column_name, *geographic_items) when 'any_poly', 'any_line' part = [] DATA_TYPES.each { |column| - unless column == :geometry_collection - # TODO this needs to check geography column's type - if column.to_s.index(column_name.gsub('any_', '')) - part.push(GeographicItem.is_contained_by(column.to_s, geographic_items).to_a) - end + # TODO this needs to check geography column's type + if column.to_s.index(column_name.gsub('any_', '')) + part.push(GeographicItem.is_contained_by(column.to_s, geographic_items).to_a) end } # @TODO change 'id in (?)' to some other sql construct From 7066fe565281785c11bcd165010076d3f4032bb2 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Mon, 17 Jun 2024 15:01:23 -0500 Subject: [PATCH 024/259] #1954 Mostly replace DATA_TYPES with SHAPE_TYPES in GeographicItem The distinction being that SHAPE_TYPES includes point, polygon, etc. but NOT `geography` (which is a column name in DATA_TYPES). The point of doing that is that if you want to perform some geometric comparison on a given x = geographic_item, say you want to find all GeographicItems of shape polygon that intersect x, then you need to check all GeographicItems whose polygon column intersects x and all GeographicItems whose geography column contains a polygon that intersects x. The decision here was to push that reality as far back as possible, by pretending we're unaware of the distinction between polygon and geography-with-polygon-shape as long as possible. --- app/models/geographic_item.rb | 204 ++++++++++++++++++---------------- 1 file changed, 106 insertions(+), 98 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 97eb533162..58c2abeb40 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -66,17 +66,18 @@ class GeographicItem < ApplicationRecord # When true cached values are not built attr_accessor :no_cached - DATA_TYPES = [ + SHAPE_TYPES = [ :point, :line_string, :polygon, :multi_point, :multi_line_string, :multi_polygon, - :geometry_collection, - :geography + :geometry_collection ].freeze + DATA_TYPES = (SHAPE_TYPES + [:geography]).freeze + GEOMETRY_SQL = Arel::Nodes::Case.new(arel_table[:type]) .when('GeographicItem::MultiPolygon').then(Arel::Nodes::NamedFunction.new('CAST', [arel_table[:multi_polygon].as('geometry')])) .when('GeographicItem::Point').then(Arel::Nodes::NamedFunction.new('CAST', [arel_table[:point].as('geometry')])) @@ -266,6 +267,7 @@ def st_buffer_st_within(geographic_item_id, distance, buffer = 0) # @return [String] # !! This is intersecting def intersecting_radius_of_wkt_sql(wkt, distance) + # TODO use ST_GeogFromText? "ST_DWithin((#{GeographicItem::GEOGRAPHY_SQL}), ST_GeomFromText('#{wkt}', 4326)::geography, #{distance})" end @@ -274,6 +276,7 @@ def intersecting_radius_of_wkt_sql(wkt, distance) # @return [String] # !! This is fully covering def within_radius_of_wkt_sql(wkt, distance) + # TODO use ST_GeogFromText? "ST_Covers( ST_Buffer(ST_SetSRID( ST_GeomFromText('#{wkt}'), 4326)::geography, #{distance}), (#{GeographicItem::GEOGRAPHY_SQL}))" end @@ -305,7 +308,7 @@ def reverse_containing_sql(target_shape = nil, geographic_item_id = nil, source_ # @param [Integer, String] # @return [String] - # a SQL fragment that returns the column containing data of type + # a SQL fragment that returns the column containing data of the given # shape for the specified geographic item def geometry_sql(geographic_item_id = nil, shape = nil) return 'false' if geographic_item_id.nil? || shape.nil? @@ -316,29 +319,30 @@ def geometry_sql(geographic_item_id = nil, shape = nil) # DEPRECATED # rubocop:disable Metrics/MethodLength - # @param [String] column_name + # @param [String] shape # @param [GeographicItem] geographic_item - # @return [String] of SQL - def is_contained_by_sql(column_name, geographic_item) - geo_id = geographic_item.id - geo_type = geographic_item.geo_object_type - template = '(ST_Contains((select geographic_items.%s::geometry from geographic_items where ' \ - 'geographic_items.id = %d), %s::geometry))' + # @return [String] of SQL for all GeographicItems of the given shape + # contained by geographic_item + def is_contained_by_sql(shape, geographic_item) + template = "(ST_Contains(#{geographic_item.geo_object}, %s::geometry))" retval = [] - column_name.downcase! - case column_name + shape = shape.to_s.downcase + case shape when 'any' - DATA_TYPES.each { |column| - retval.push(template % [geo_type, geo_id, column]) + SHAPE_TYPES.each { |shape| + shape_column = GeographicItem.shape_column_sql(shape) + retval.push(template % shape_column) } when 'any_poly', 'any_line' - DATA_TYPES.each { |column| - if column.to_s.index(column_name.gsub('any_', '')) - retval.push(template % [geo_type, geo_id, column]) + SHAPE_TYPES.each { |shape| + if column.to_s.index(shape.gsub('any_', '')) + shape_column = GeographicItem.shape_column_sql(shape) + retval.push(template % shape_column) end } else - retval = template % [geo_type, geo_id, column_name] + shape_column = GeographicItem.shape_column_sql(shape) + retval = template % shape_column end retval = retval.join(' OR ') if retval.instance_of?(Array) retval @@ -618,19 +622,22 @@ def with_longitude # @param [String, GeographicItems] # @return [Scope] - def intersecting(column_name, *geographic_items) - if column_name.downcase == 'any' + def intersecting(shape, *geographic_items) + shape = shape.to_s.downcase + if shape == 'any' pieces = [] - DATA_TYPES.each { |column| - pieces.push(GeographicItem.intersecting(column.to_s, geographic_items).to_a) + SHAPE_TYPES.each { |shape| + pieces.push(GeographicItem.intersecting(shape, geographic_items).to_a) } # @TODO change 'id in (?)' to some other sql construct - GeographicItem.where(id: pieces.flatten.map(&:id)) else + shape_column = GeographicItem.shape_column_sql(shape) q = geographic_items.flatten.collect { |geographic_item| - "ST_Intersects(#{column_name}, '#{geographic_item.geo_object}' )" # seems like we want this: http://danshultz.github.io/talks/mastering_activerecord_arel/#/15/2 + # seems like we want this: http://danshultz.github.io/talks/mastering_activerecord_arel/#/15/2 + # TODO would geometry intersect be equivalent and faster? + "ST_Intersects(#{shape_column}, '#{geographic_item.geo_object}')" }.join(' or ') where(q) @@ -648,15 +655,18 @@ def within_radius_of_item(geographic_item_id, distance) # DEPRECATED # @param [String, GeographicItem] # @return [Scope] - # a SQL fragment for ST_DISJOINT, specifies geographic_items that - # have data of type shape that are disjoint from all of the passed + # a SQL fragment for ST_DISJOINT, specifies geographic_items of the + # given shape that are disjoint from all of the passed # geographic_items def disjoint_from(shape, *geographic_items) shape_column = GeographicItem.shape_column_sql(shape) q = geographic_items.flatten.collect { |geographic_item| + geographic_item_geometry = geometry_sql(geographic_item.id, + geographic_item.geo_object_type) + "ST_DISJOINT(#{shape_column}::geometry, " \ - "(#{geographic_item.data_column}))" + "(#{geographic_item_geometry}))" }.join(' and ') where(q) @@ -664,16 +674,17 @@ def disjoint_from(shape, *geographic_items) # @return [Scope] # see are_contained_in_item_by_id - # @param [String] column_name + # @param [String] shape # @param [GeographicItem, Array] geographic_items - def are_contained_in_item(column_name, *geographic_items) - are_contained_in_item_by_id(column_name, geographic_items.flatten.map(&:id)) + def are_contained_in_item(shape, *geographic_items) + are_contained_in_item_by_id(shape, geographic_items.flatten.map(&:id)) end # rubocop:disable Metrics/MethodLength - # @param [String] column_name to search + # @param [String] shape to search # @param [GeographicItem] geographic_item_ids or array of geographic_item_ids to be tested. - # @return [Scope] of GeographicItems + # @return [Scope] of GeographicItems that contain at least one of + # geographic_item_ids # # If this scope is given an Array of GeographicItems as a second parameter, # it will return the 'OR' of each of the objects against the table. @@ -681,34 +692,33 @@ def are_contained_in_item(column_name, *geographic_items) # WHERE (ST_Contains(polygon::geometry, GeomFromEWKT('srid=4326;POINT (0.0 0.0 0.0)')) # OR ST_Contains(polygon::geometry, GeomFromEWKT('srid=4326;POINT (-9.8 5.0 0.0)'))) # - def are_contained_in_item_by_id(column_name, *geographic_item_ids) # = containing + def are_contained_in_item_by_id(shape, *geographic_item_ids) # = containing geographic_item_ids.flatten! # in case there is a array of arrays, or multiple objects - column_name.downcase! - case column_name + shape = shape.to_s.downcase + case shape when 'any' part = [] - DATA_TYPES.each { |column| - part.push(GeographicItem.are_contained_in_item_by_id(column.to_s, geographic_item_ids).to_a) + SHAPE_TYPES.each { |shape| + part.push(GeographicItem.are_contained_in_item_by_id(shape, geographic_item_ids).to_a) } # TODO: change 'id in (?)' to some other sql construct GeographicItem.where(id: part.flatten.map(&:id)) when 'any_poly', 'any_line' part = [] - DATA_TYPES.each { |column| - # TODO Need to handle geography's type here - if column.to_s.index(column_name.gsub('any_', '')) - part.push(GeographicItem.are_contained_in_item_by_id("#{column}", geographic_item_ids).to_a) + SHAPE_TYPES.each { |shape| + if shape.to_s.index(shape.gsub('any_', '')) + part.push(GeographicItem.are_contained_in_item_by_id(shape, geographic_item_ids).to_a) end } # TODO: change 'id in (?)' to some other sql construct GeographicItem.where(id: part.flatten.map(&:id)) else q = geographic_item_ids.flatten.collect { |geographic_item_id| - # discover the item types, and convert type to database type for 'multi_' - b = GeographicItem.where(id: geographic_item_id) - .pluck(:type)[0].split(':')[2].underscore - # a = GeographicItem.find(geographic_item_id).geo_object_type - GeographicItem.containing_sql(column_name, geographic_item_id, b) + # TODO too bad this is being fetched seperately + geographic_item_shape = + GeographicItem.where(id: geographic_item_id).first.geo_object_type + GeographicItem.containing_sql(shape, geographic_item_id, + geographic_item_shape) }.join(' or ') q = 'FALSE' if q.blank? # this will prevent the invocation of *ALL* of the GeographicItems, if there are # no GeographicItems in the request (see CollectingEvent.name_hash(types)). @@ -719,60 +729,62 @@ def are_contained_in_item_by_id(column_name, *geographic_item_ids) # = containin # rubocop:enable Metrics/MethodLength # DEPRECATED - # @param [String] column_name + # @param [String] shape # @param [String] geometry of WKT # @return [Scope] - # a single WKT geometry is compared against column or columns to find geographic_items - # which are contained in the WKT - def are_contained_in_wkt(column_name, geometry) - column_name.downcase! - case column_name + # A scope of GeographicItems of the given shape contained in the + # WKT. + def are_contained_in_wkt(shape, geometry) + shape = shape.to_s.downcase + case shape when 'any' part = [] - DATA_TYPES.each { |column| - part.push(GeographicItem.are_contained_in_wkt(column.to_s, geometry).pluck(:id).to_a) + SHAPE_TYPES.each { |shape| + part.push(GeographicItem.are_contained_in_wkt(shape, geometry).pluck(:id).to_a) } # TODO: change 'id in (?)' to some other sql construct GeographicItem.where(id: part.flatten) when 'any_poly', 'any_line' part = [] - DATA_TYPES.each { |column| - if column.to_s.index(column_name.gsub('any_', '')) - part.push(GeographicItem.are_contained_in_wkt("#{column}", geometry).pluck(:id).to_a) + SHAPE_TYPES.each { |shape| + if shape.to_s.index(shape.gsub('any_', '')) + part.push(GeographicItem.are_contained_in_wkt("#{shape}", geometry).pluck(:id).to_a) end } # TODO: change 'id in (?)' to some other sql construct GeographicItem.where(id: part.flatten) else - q = "ST_Contains(ST_GeomFromEWKT('srid=4326;#{geometry}'), #{column_name}::geometry)" + shape_column = GeographicItem.shape_column_sql(shape) + q = "ST_Contains(ST_GeomFromEWKT('srid=4326;#{geometry}'), #{shape_column}::geometry)" where(q) # .not_including(geographic_items) end end # rubocop:disable Metrics/MethodLength - # Returns a scope of all items whose column_name ontains an element of - # geographic_items. - # @param column_name [String] can be any of DATA_TYPES, or 'any' to check against all types, 'any_poly' to check - # against 'polygon' or 'multi_polygon', or 'any_line' to check against 'line_string' or 'multi_line_string'. - # @param geographic_items [GeographicItem] Can be a single GeographicItem, or an array of GeographicItem. - # @return [Scope] - def is_contained_by(column_name, *geographic_items) - column_name.downcase! - case column_name + # @param shape [String] can be any of SHAPE_TYPES, or 'any' to check + # against all types, 'any_poly' to check against 'polygon' or + # 'multi_polygon', or 'any_line' to check against 'line_string' or + #'multi_line_string'. + # @param geographic_items [GeographicItem] Can be a single + # GeographicItem, or an array of GeographicItem. + # @return [Scope] of all GeographicItems of the given shape contained + # in one or more of geographic_items + def is_contained_by(shape, *geographic_items) + shape = shape.to_s.downcase + case shape when 'any' part = [] - DATA_TYPES.each { |column| - part.push(GeographicItem.is_contained_by(column.to_s, geographic_items).to_a) + SHAPE_TYPES.each { |shape| + part.push(GeographicItem.is_contained_by(shape, geographic_items).to_a) } # @TODO change 'id in (?)' to some other sql construct GeographicItem.where(id: part.flatten.map(&:id)) when 'any_poly', 'any_line' part = [] - DATA_TYPES.each { |column| - # TODO this needs to check geography column's type - if column.to_s.index(column_name.gsub('any_', '')) - part.push(GeographicItem.is_contained_by(column.to_s, geographic_items).to_a) + SHAPE_TYPES.each { |shape| + if shape.to_s.index(shape.gsub('any_', '')) + part.push(GeographicItem.is_contained_by(shape, geographic_items).to_a) end } # @TODO change 'id in (?)' to some other sql construct @@ -780,7 +792,7 @@ def is_contained_by(column_name, *geographic_items) else q = geographic_items.flatten.collect { |geographic_item| - GeographicItem.reverse_containing_sql(column_name, geographic_item.to_param, + GeographicItem.reverse_containing_sql(shape, geographic_item.to_param, geographic_item.geo_object_type) }.join(' or ') where(q) # .not_including(geographic_items) @@ -791,39 +803,33 @@ def is_contained_by(column_name, *geographic_items) # @param [String, GeographicItem] # @return [Scope] - def ordered_by_shortest_distance_from(column_name, geographic_item) - if true # check_geo_params(column_name, geographic_item) - select_distance_with_geo_object(column_name, geographic_item) - .where_distance_greater_than_zero(column_name, geographic_item).order('distance') - else - where('false') - end + def ordered_by_shortest_distance_from(shape, geographic_item) + select_distance_with_geo_object(shape, geographic_item) + .where_distance_greater_than_zero(shape, geographic_item) + .order('distance') end # @param [String, GeographicItem] # @return [Scope] - def ordered_by_longest_distance_from(column_name, geographic_item) - if true # check_geo_params(column_name, geographic_item) - q = select_distance_with_geo_object(column_name, geographic_item) - .where_distance_greater_than_zero(column_name, geographic_item) - .order('distance desc') - q - else - where('false') - end + def ordered_by_longest_distance_from(shape, geographic_item) + select_distance_with_geo_object(shape, geographic_item) + .where_distance_greater_than_zero(shape, geographic_item) + .order('distance desc') end - # @param [String] column_name + # @param [String] shape # @param [GeographicItem] geographic_item # @return [String] - def select_distance_with_geo_object(column_name, geographic_item) - select("*, ST_Distance(#{column_name}, GeomFromEWKT('srid=4326;#{geographic_item.geo_object}')) as distance") + def select_distance_with_geo_object(shape, geographic_item) + shape_column = GeographicItem.shape_column_sql(shape) + select("*, ST_Distance(#{shape_column}, GeomFromEWKT('srid=4326;#{geographic_item.geo_object}')) as distance") end # @param [String, GeographicItem] # @return [Scope] - def where_distance_greater_than_zero(column_name, geographic_item) - where("#{column_name} is not null and ST_Distance(#{column_name}, " \ + def where_distance_greater_than_zero(shape, geographic_item) + shape_column = GeographicItem.shape_column_sql(shape) + where("#{shape_column} is not null and ST_Distance(#{shape_column}, " \ "GeomFromEWKT('srid=4326;#{geographic_item.geo_object}')) > 0") end @@ -1312,8 +1318,10 @@ def multi_polygon_column # @param [String] shape, the type of shape you want # @return [String] - # A paren-wrapped SQL fragment for selecting the column containing - # shape. Returns the column named :shape if no shape is found. + # A paren-wrapped SQL fragment for selecting the geography column + # containing shape. Returns the column named :shape if no shape is found. + # !! This should probably never be called except to be put directly in a + # raw ST_* statement as the parameter that matches some shape. def self.shape_column_sql(shape) st_shape = 'ST_' + shape.to_s.camelize From b44320b1a02d92d4fb1bd9ca013472b840d70d10 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Mon, 17 Jun 2024 18:29:42 -0500 Subject: [PATCH 025/259] #1954 Expand and contract some switch statements, more geometry_collection --- app/models/geographic_item.rb | 46 +++++++---------------------------- 1 file changed, 9 insertions(+), 37 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 58c2abeb40..30b2a9bf74 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -236,6 +236,8 @@ def lat_long_sql(choice) "ST_Centroid(multi_line_string::geometry), #{f} ), ' ', #{v}) WHEN 'GeographicItem::MultiPoint' THEN split_part(ST_AsLatLonText(" \ "ST_Centroid(multi_point::geometry), #{f}), ' ', #{v}) + WHEN 'GeographicItem::GeometryCollection' THEN split_part(ST_AsLatLonText(" \ + "ST_Centroid(geometry_collection::geometry), #{f}), ' ', #{v}) WHEN 'GeographicItem::Geography' THEN split_part(ST_AsLatLonText(" \ "ST_Centroid(geography::geometry), #{f}), ' ', #{v}) END as #{choice}" @@ -382,19 +384,10 @@ def geometry_sql2(*geographic_item_ids) # @param [Integer, Array of Integer] geographic_item_ids # @return [String] Those geographic items containing the union of # geographic_item_ids. - # TODO why does GEOMETRY_SQL.to_sql fail here? - # TODO need to handle non-geom_collection geography case here def containing_where_sql(*geographic_item_ids) "ST_CoveredBy( #{GeographicItem.geometry_sql2(*geographic_item_ids)}, - CASE geographic_items.type - WHEN 'GeographicItem::MultiPolygon' THEN multi_polygon::geometry - WHEN 'GeographicItem::Point' THEN point::geometry - WHEN 'GeographicItem::LineString' THEN line_string::geometry - WHEN 'GeographicItem::Polygon' THEN polygon::geometry - WHEN 'GeographicItem::MultiLineString' THEN multi_line_string::geometry - WHEN 'GeographicItem::MultiPoint' THEN multi_point::geometry - END)" + #{GeographicItem::GEOMETRY_SQL.to_sql})" end # DEPRECATED @@ -404,14 +397,7 @@ def containing_where_sql(*geographic_item_ids) def containing_where_sql_geog(*geographic_item_ids) "ST_CoveredBy( (#{GeographicItem.geometry_sql2(*geographic_item_ids)})::geography, - CASE geographic_items.type - WHEN 'GeographicItem::MultiPolygon' THEN multi_polygon::geography - WHEN 'GeographicItem::Point' THEN point::geography - WHEN 'GeographicItem::LineString' THEN line_string::geography - WHEN 'GeographicItem::Polygon' THEN polygon::geography - WHEN 'GeographicItem::MultiLineString' THEN multi_line_string::geography - WHEN 'GeographicItem::MultiPoint' THEN multi_point::geography - END)" + #{GEOGRAPHY_SQL})" end # DEPRECATED @@ -466,6 +452,8 @@ def contained_by_wkt_shifted_sql(wkt) WHEN 'GeographicItem::Polygon' THEN ST_ShiftLongitude(polygon::geometry) WHEN 'GeographicItem::MultiLineString' THEN ST_ShiftLongitude(multi_line_string::geometry) WHEN 'GeographicItem::MultiPoint' THEN ST_ShiftLongitude(multi_point::geometry) + WHEN 'GeographicItem::GeometryCollection' THEN ST_ShiftLongitude(geometry_collection::geometry) + WHEN 'GeographicItem::Geography' THEN ST_ShiftLongitude(geography::geometry) END ) )" @@ -479,17 +467,8 @@ def contained_by_wkt_sql(wkt) if crosses_anti_meridian?(wkt) retval = contained_by_wkt_shifted_sql(wkt) else - retval = "ST_Contains(ST_GeomFromText('#{wkt}', 4326), ( - CASE geographic_items.type - WHEN 'GeographicItem::MultiPolygon' THEN multi_polygon::geometry - WHEN 'GeographicItem::Point' THEN point::geometry - WHEN 'GeographicItem::LineString' THEN line_string::geometry - WHEN 'GeographicItem::Polygon' THEN polygon::geometry - WHEN 'GeographicItem::MultiLineString' THEN multi_line_string::geometry - WHEN 'GeographicItem::MultiPoint' THEN multi_point::geometry - END - ) - )" + retval = "ST_Contains(ST_GeomFromText('#{wkt}', 4326), + #{GEOMETRY_SQL.to_sql})" end retval end @@ -502,14 +481,7 @@ def contained_by_wkt_sql(wkt) def contained_by_where_sql(*geographic_item_ids) "ST_Contains( #{GeographicItem.geometry_sql2(*geographic_item_ids)}, - CASE geographic_items.type - WHEN 'GeographicItem::MultiPolygon' THEN multi_polygon::geometry - WHEN 'GeographicItem::Point' THEN point::geometry - WHEN 'GeographicItem::LineString' THEN line_string::geometry - WHEN 'GeographicItem::Polygon' THEN polygon::geometry - WHEN 'GeographicItem::MultiLineString' THEN multi_line_string::geometry - WHEN 'GeographicItem::MultiPoint' THEN multi_point::geometry - END)" + #{GEOMETRY_SQL.to_sql})" end # @param [RGeo:Point] rgeo_point From 0321144be0f9930be5f06ed708ee82b7fb48f506 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Mon, 17 Jun 2024 21:11:56 -0500 Subject: [PATCH 026/259] #1954 Replace GeographicItem.are_contained_in_item_by_id with are_contained_in_item Is there any reason not to do this? It avoids an extra GeographicItem load, and are_contained_in_item was just a thin wrapper around are_contained_in_item_by_id. I changed all of the are_contained_in_item_by_id specs over to are_contained_in_item - some of the old are_contained_in_item specs were marked as deprecated but those all existed in the form of are_contained_in_item_by_id specs (which again is the same as are_contained_in_item), so I kept them all. --- app/models/geographic_item.rb | 45 ++++--- spec/models/geographic_item_spec.rb | 186 +++++++++------------------- 2 files changed, 80 insertions(+), 151 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 30b2a9bf74..4135b6d1db 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -335,6 +335,7 @@ def is_contained_by_sql(shape, geographic_item) shape_column = GeographicItem.shape_column_sql(shape) retval.push(template % shape_column) } + when 'any_poly', 'any_line' SHAPE_TYPES.each { |shape| if column.to_s.index(shape.gsub('any_', '')) @@ -342,6 +343,7 @@ def is_contained_by_sql(shape, geographic_item) retval.push(template % shape_column) end } + else shape_column = GeographicItem.shape_column_sql(shape) retval = template % shape_column @@ -644,19 +646,12 @@ def disjoint_from(shape, *geographic_items) where(q) end - # @return [Scope] - # see are_contained_in_item_by_id - # @param [String] shape - # @param [GeographicItem, Array] geographic_items - def are_contained_in_item(shape, *geographic_items) - are_contained_in_item_by_id(shape, geographic_items.flatten.map(&:id)) - end - # rubocop:disable Metrics/MethodLength # @param [String] shape to search - # @param [GeographicItem] geographic_item_ids or array of geographic_item_ids to be tested. + # @param [GeographicItem] geographic_items or array of geographic_items + # to be tested. # @return [Scope] of GeographicItems that contain at least one of - # geographic_item_ids + # geographic_items # # If this scope is given an Array of GeographicItems as a second parameter, # it will return the 'OR' of each of the objects against the table. @@ -664,36 +659,38 @@ def are_contained_in_item(shape, *geographic_items) # WHERE (ST_Contains(polygon::geometry, GeomFromEWKT('srid=4326;POINT (0.0 0.0 0.0)')) # OR ST_Contains(polygon::geometry, GeomFromEWKT('srid=4326;POINT (-9.8 5.0 0.0)'))) # - def are_contained_in_item_by_id(shape, *geographic_item_ids) # = containing - geographic_item_ids.flatten! # in case there is a array of arrays, or multiple objects + def are_contained_in_item(shape, *geographic_items) # = containing + geographic_items.flatten! # in case there is a array of arrays, or multiple objects shape = shape.to_s.downcase case shape when 'any' part = [] SHAPE_TYPES.each { |shape| - part.push(GeographicItem.are_contained_in_item_by_id(shape, geographic_item_ids).to_a) + part.push(GeographicItem.are_contained_in_item(shape, geographic_items).to_a) } # TODO: change 'id in (?)' to some other sql construct GeographicItem.where(id: part.flatten.map(&:id)) + when 'any_poly', 'any_line' part = [] SHAPE_TYPES.each { |shape| - if shape.to_s.index(shape.gsub('any_', '')) - part.push(GeographicItem.are_contained_in_item_by_id(shape, geographic_item_ids).to_a) + if column.to_s.index(shape.gsub('any_', '')) + part.push(GeographicItem.are_contained_in_item(shape, geographic_items).to_a) end } # TODO: change 'id in (?)' to some other sql construct GeographicItem.where(id: part.flatten.map(&:id)) + else - q = geographic_item_ids.flatten.collect { |geographic_item_id| - # TODO too bad this is being fetched seperately - geographic_item_shape = - GeographicItem.where(id: geographic_item_id).first.geo_object_type - GeographicItem.containing_sql(shape, geographic_item_id, - geographic_item_shape) + q = geographic_items.flatten.collect { |geographic_item| + GeographicItem.containing_sql(shape, geographic_item.id, + geographic_item.geo_object_type) }.join(' or ') - q = 'FALSE' if q.blank? # this will prevent the invocation of *ALL* of the GeographicItems, if there are - # no GeographicItems in the request (see CollectingEvent.name_hash(types)). + + # This will prevent the invocation of *ALL* of the GeographicItems + # if there are no GeographicItems in the request (see + # CollectingEvent.name_hash(types)). + q = 'FALSE' if q.blank? where(q) # .not_including(geographic_items) end end @@ -716,6 +713,7 @@ def are_contained_in_wkt(shape, geometry) } # TODO: change 'id in (?)' to some other sql construct GeographicItem.where(id: part.flatten) + when 'any_poly', 'any_line' part = [] SHAPE_TYPES.each { |shape| @@ -725,6 +723,7 @@ def are_contained_in_wkt(shape, geometry) } # TODO: change 'id in (?)' to some other sql construct GeographicItem.where(id: part.flatten) + else shape_column = GeographicItem.shape_column_sql(shape) q = "ST_Contains(ST_GeomFromEWKT('srid=4326;#{geometry}'), #{shape_column}::geometry)" diff --git a/spec/models/geographic_item_spec.rb b/spec/models/geographic_item_spec.rb index 457cdb2ca8..de1499fd80 100644 --- a/spec/models/geographic_item_spec.rb +++ b/spec/models/geographic_item_spec.rb @@ -675,7 +675,7 @@ end context '::are_contained_in - returns objects which contained in another object.' do - before { [e1, k].each } + before { [p0, p1, p2, p3, p12, p13, b1, b2, b, e1, e2, k].each } # OR! specify 'three things inside and one thing outside k' do @@ -691,105 +691,70 @@ .to contain_exactly(e1, k) end - # - # All these are deprecated for ::containing - # - # - # expect(GeographicItem.are_contained_in('not_a_column_name', @p1).to_a).to eq([]) - # expect(GeographicItem.are_contained_in('point', 'Some devious SQL string').to_a).to eq([]) - - # specify 'one thing inside k' do - # expect(GeographicItem.are_contained_in_item('polygon', @p1).to_a).to eq([@k]) - # end - - # specify 'three things inside k' do - # expect(GeographicItem.are_contained_in_item('polygon', [@p1, @p2, @p3]).to_a).to eq([@k]) - # end - - # specify 'one thing outside k' do - # expect(GeographicItem.are_contained_in_item('polygon', @p4).to_a).to eq([]) - # end - - # specify ' one thing inside two things (overlapping)' do - # expect(GeographicItem.are_contained_in_item('polygon', @p12).to_a.sort).to contain_exactly(@e1, @e2) - # end - - # specify 'two things inside one thing, and (1)' do - # expect(GeographicItem.are_contained_in_item('polygon', @p18).to_a).to contain_exactly(@b1, @b2) - # end - - # specify 'two things inside one thing, and (2)' do - # expect(GeographicItem.are_contained_in_item('polygon', @p19).to_a).to contain_exactly(@b1, @b) - # end - end - - context '::contained_by' do - before { [p1, p2, p3, p11, p12, k, l].each } - - specify 'find the points in a polygon' do - expect(GeographicItem.contained_by(k.id).to_a).to contain_exactly(p1, p2, p3, k) - end - - specify 'find the (overlapping) points in a polygon' do - overlapping_point = FactoryBot.create(:geographic_item_point, point: point12.as_binary) - expect(GeographicItem.contained_by(e1.id).to_a).to contain_exactly(p12, overlapping_point, p11, e1) - end - end - - context '::are_contained_in_item_by_id - returns objects which contained in another object.' do - before { [p0, p1, p2, p3, p12, p13, b1, b2, b, e1, e2, k].each } - specify 'one thing inside k' do - expect(GeographicItem.are_contained_in_item_by_id('polygon', p1.id).to_a).to eq([k]) + expect(GeographicItem.are_contained_in_item('polygon', p1).to_a).to eq([k]) end specify 'three things inside k (in array)' do - expect(GeographicItem.are_contained_in_item_by_id('polygon', - [p1.id, p2.id, p3.id]).to_a) + expect(GeographicItem.are_contained_in_item('polygon', + [p1, p2, p3]).to_a) .to eq([k]) end specify 'three things inside k (as separate parameters)' do - expect(GeographicItem.are_contained_in_item_by_id('polygon', p1.id, - p2.id, - p3.id).to_a) + expect(GeographicItem.are_contained_in_item('polygon', p1, + p2, + p3).to_a) .to eq([k]) end specify 'one thing outside k' do - expect(GeographicItem.are_contained_in_item_by_id('polygon', p4.id).to_a) + expect(GeographicItem.are_contained_in_item('polygon', p4).to_a) .to eq([]) end specify ' one thing inside two things (overlapping)' do - expect(GeographicItem.are_contained_in_item_by_id('polygon', p12.id).to_a.sort) + expect(GeographicItem.are_contained_in_item('polygon', p12).to_a.sort) .to contain_exactly(e1, e2) end specify 'three things inside and one thing outside k' do - expect(GeographicItem.are_contained_in_item_by_id('polygon', - [p1.id, p2.id, - p3.id, p11.id]).to_a) + expect(GeographicItem.are_contained_in_item('polygon', + [p1, p2, + p3, p11]).to_a) .to contain_exactly(e1, k) end specify 'one thing inside one thing, and another thing inside another thing' do - expect(GeographicItem.are_contained_in_item_by_id('polygon', - [p1.id, p11.id]).to_a) + expect(GeographicItem.are_contained_in_item('polygon', + [p1, p11]).to_a) .to contain_exactly(e1, k) end specify 'two things inside one thing, and (1)' do - expect(GeographicItem.are_contained_in_item_by_id('polygon', p18.id).to_a) + expect(GeographicItem.are_contained_in_item('polygon', p18).to_a) .to contain_exactly(b1, b2) end specify 'two things inside one thing, and (2)' do - expect(GeographicItem.are_contained_in_item_by_id('polygon', p19.id).to_a) + expect(GeographicItem.are_contained_in_item('polygon', p19).to_a) .to contain_exactly(b1, b) end end + context '::contained_by' do + before { [p1, p2, p3, p11, p12, k, l].each } + + specify 'find the points in a polygon' do + expect(GeographicItem.contained_by(k.id).to_a).to contain_exactly(p1, p2, p3, k) + end + + specify 'find the (overlapping) points in a polygon' do + overlapping_point = FactoryBot.create(:geographic_item_point, point: point12.as_binary) + expect(GeographicItem.contained_by(e1.id).to_a).to contain_exactly(p12, overlapping_point, p11, e1) + end + end + context '::is_contained_by - returns objects which are contained by other objects.' do before { [b, p0, p1, p2, p3, p11, p12, p13, p18, p19].each } @@ -1740,7 +1705,7 @@ end context '::are_contained_in - returns objects which contained in another object.' do - before { [e1, k].each } + before { [p0, p1, p2, p3, p12, p13, b1, b2, b, e1, e2, k].each } # OR! specify 'three things inside and one thing outside k' do @@ -1756,105 +1721,70 @@ .to contain_exactly(e1, k) end - # - # All these are deprecated for ::containing - # - # - # expect(GeographicItem.are_contained_in('not_a_column_name', @p1).to_a).to eq([]) - # expect(GeographicItem.are_contained_in('point', 'Some devious SQL string').to_a).to eq([]) - - # specify 'one thing inside k' do - # expect(GeographicItem.are_contained_in_item('polygon', @p1).to_a).to eq([@k]) - # end - - # specify 'three things inside k' do - # expect(GeographicItem.are_contained_in_item('polygon', [@p1, @p2, @p3]).to_a).to eq([@k]) - # end - - # specify 'one thing outside k' do - # expect(GeographicItem.are_contained_in_item('polygon', @p4).to_a).to eq([]) - # end - - # specify ' one thing inside two things (overlapping)' do - # expect(GeographicItem.are_contained_in_item('polygon', @p12).to_a.sort).to contain_exactly(@e1, @e2) - # end - - # specify 'two things inside one thing, and (1)' do - # expect(GeographicItem.are_contained_in_item('polygon', @p18).to_a).to contain_exactly(@b1, @b2) - # end - - # specify 'two things inside one thing, and (2)' do - # expect(GeographicItem.are_contained_in_item('polygon', @p19).to_a).to contain_exactly(@b1, @b) - # end - end - - context '::contained_by' do - before { [p1, p2, p3, p11, p12, k, l].each } - - specify 'find the points in a polygon' do - expect(GeographicItem.contained_by(k.id).to_a).to contain_exactly(p1, p2, p3, k) - end - - specify 'find the (overlapping) points in a polygon' do - overlapping_point = FactoryBot.create(:geographic_item_point, point: point12.as_binary) - expect(GeographicItem.contained_by(e1.id).to_a).to contain_exactly(p12, overlapping_point, p11, e1) - end - end - - context '::are_contained_in_item_by_id - returns objects which contained in another object.' do - before { [p0, p1, p2, p3, p12, p13, b1, b2, b, e1, e2, k].each } - specify 'one thing inside k' do - expect(GeographicItem.are_contained_in_item_by_id('polygon', p1.id).to_a).to eq([k]) + expect(GeographicItem.are_contained_in_item('polygon', p1).to_a).to eq([k]) end specify 'three things inside k (in array)' do - expect(GeographicItem.are_contained_in_item_by_id('polygon', - [p1.id, p2.id, p3.id]).to_a) + expect(GeographicItem.are_contained_in_item('polygon', + [p1, p2, p3]).to_a) .to eq([k]) end specify 'three things inside k (as separate parameters)' do - expect(GeographicItem.are_contained_in_item_by_id('polygon', p1.id, - p2.id, - p3.id).to_a) + expect(GeographicItem.are_contained_in_item('polygon', p1, + p2, + p3).to_a) .to eq([k]) end specify 'one thing outside k' do - expect(GeographicItem.are_contained_in_item_by_id('polygon', p4.id).to_a) + expect(GeographicItem.are_contained_in_item('polygon', p4).to_a) .to eq([]) end specify ' one thing inside two things (overlapping)' do - expect(GeographicItem.are_contained_in_item_by_id('polygon', p12.id).to_a.sort) + expect(GeographicItem.are_contained_in_item('polygon', p12).to_a.sort) .to contain_exactly(e1, e2) end specify 'three things inside and one thing outside k' do - expect(GeographicItem.are_contained_in_item_by_id('polygon', - [p1.id, p2.id, - p3.id, p11.id]).to_a) + expect(GeographicItem.are_contained_in_item('polygon', + [p1, p2, + p3, p11]).to_a) .to contain_exactly(e1, k) end specify 'one thing inside one thing, and another thing inside another thing' do - expect(GeographicItem.are_contained_in_item_by_id('polygon', - [p1.id, p11.id]).to_a) + expect(GeographicItem.are_contained_in_item('polygon', + [p1, p11]).to_a) .to contain_exactly(e1, k) end specify 'two things inside one thing, and (1)' do - expect(GeographicItem.are_contained_in_item_by_id('polygon', p18.id).to_a) + expect(GeographicItem.are_contained_in_item('polygon', p18).to_a) .to contain_exactly(b1, b2) end specify 'two things inside one thing, and (2)' do - expect(GeographicItem.are_contained_in_item_by_id('polygon', p19.id).to_a) + expect(GeographicItem.are_contained_in_item('polygon', p19).to_a) .to contain_exactly(b1, b) end end + context '::contained_by' do + before { [p1, p2, p3, p11, p12, k, l].each } + + specify 'find the points in a polygon' do + expect(GeographicItem.contained_by(k.id).to_a).to contain_exactly(p1, p2, p3, k) + end + + specify 'find the (overlapping) points in a polygon' do + overlapping_point = FactoryBot.create(:geographic_item_point, point: point12.as_binary) + expect(GeographicItem.contained_by(e1.id).to_a).to contain_exactly(p12, overlapping_point, p11, e1) + end + end + context '::is_contained_by - returns objects which are contained by other objects.' do before { [b, p0, p1, p2, p3, p11, p12, p13, p18, p19].each } From ea6377e71d470be24009628012208dc3f6d156cf Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Mon, 17 Jun 2024 22:33:31 -0500 Subject: [PATCH 027/259] #1954 Rewrite GeographicItem#intersecting_area --- app/models/geographic_item.rb | 27 +++++++-------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 4135b6d1db..5ecc7b0d7f 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -134,19 +134,6 @@ class GeographicItem < ApplicationRecord class << self - def aliased_geographic_sql(name = 'a') - "CASE #{name}.type \ - WHEN 'GeographicItem::MultiPolygon' THEN #{name}.multi_polygon \ - WHEN 'GeographicItem::Point' THEN #{name}.point \ - WHEN 'GeographicItem::LineString' THEN #{name}.line_string \ - WHEN 'GeographicItem::Polygon' THEN #{name}.polygon \ - WHEN 'GeographicItem::MultiLineString' THEN #{name}.multi_line_string \ - WHEN 'GeographicItem::MultiPoint' THEN #{name}.multi_point \ - WHEN 'GeographicItem::GeometryCollection' THEN #{name}.geometry_collection \ - WHEN 'GeographicItem::Geography' THEN #{name}.geography \ - END" - end - def st_union(geographic_item_scope) GeographicItem.select("ST_Union(#{GeographicItem::GEOMETRY_SQL.to_sql}) as collection") .where(id: geographic_item_scope.pluck(:id)) @@ -1182,15 +1169,15 @@ def area # @return [Float, false] # the value in square meters of the interesecting area of this and another GeographicItem def intersecting_area(geographic_item_id) - a = GeographicItem.aliased_geographic_sql('a') - b = GeographicItem.aliased_geographic_sql('b') + self_shape = GeographicItem.select_geography_sql(id) + other_shape = GeographicItem.select_geography_sql(geographic_item_id) - c = GeographicItem.connection.execute( - "SELECT ST_Area(ST_Intersection(#{a}, #{b})) as intersecting_area - FROM geographic_items a, geographic_items b - WHERE a.id = #{id} AND b.id = #{geographic_item_id};" + r = GeographicItem.connection.execute( + "SELECT ST_Area(ST_Intersection((#{self_shape}), (#{other_shape}))) " \ + 'AS intersecting_area FROM geographic_items limit 1' ).first - c && c['intersecting_area'].to_f + + r && r['intersecting_area'].to_f end # TODO: This is bad, while internal use of ONE_WEST_MEAN is consistent it is in-accurate given the vast differences of radius vs. lat/long position. From 205b887c0e33d2944e927ea5e5a4dd6da915ce3a Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Tue, 18 Jun 2024 21:40:57 -0500 Subject: [PATCH 028/259] #1954 Deprecate the common subclass methods of GeographicItem As far as I can tell these seem to be currently unused, though I haven't marked them deprecated in the other shapes or among the private methods of GeographicItem. --- app/models/geographic_item.rb | 1 - app/models/geographic_item/geography.rb | 12 +++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 5ecc7b0d7f..3c51d14169 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -929,7 +929,6 @@ def valid_geometry? # the lat, lon of the first point in the GeoItem, see subclass for # st_start_point def start_point - # TODO add st_start_point for geography o = st_start_point [o.y, o.x] end diff --git a/app/models/geographic_item/geography.rb b/app/models/geographic_item/geography.rb index a610e8158b..91759bb7a0 100644 --- a/app/models/geographic_item/geography.rb +++ b/app/models/geographic_item/geography.rb @@ -3,5 +3,15 @@ class GeographicItem::Geography < GeographicItem validates_presence_of :geography - # TODO: to_a, st_start_point, others? + # DEPRECATED + # def st_start_point + # end + + # DEPRECATED + # def rendering_hash + # end + + # DEPRECATED + # def to_hash + # end end From 3a0d89968bf0aa9171e02a02d35f3573307a48e8 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Wed, 19 Jun 2024 10:35:24 -0500 Subject: [PATCH 029/259] #1954 GeographicItem cleanup and formatting --- app/models/geographic_item.rb | 150 +++++++++++++++++++--------------- 1 file changed, 84 insertions(+), 66 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 3c51d14169..de717e5e29 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -139,6 +139,7 @@ def st_union(geographic_item_scope) .where(id: geographic_item_scope.pluck(:id)) end + # DEPRECATED def st_collect(geographic_item_scope) GeographicItem.select("ST_Collect(#{GeographicItem::GEOMETRY_SQL.to_sql}) as collection") .where(id: geographic_item_scope.pluck(:id)) @@ -173,11 +174,9 @@ def crosses_anti_meridian?(wkt) # more of which crosses the anti-meridian. In this case the current TW strategy within the # UI is to abandon the search, and prompt the user to refactor the query. def crosses_anti_meridian_by_id?(*ids) - q1 = ["SELECT ST_Intersects((SELECT single_geometry FROM (#{GeographicItem.single_geometry_sql(*ids)}) as " \ - 'left_intersect), ST_GeogFromText(?)) as r;', ANTI_MERIDIAN] - _q2 = ActiveRecord::Base.send(:sanitize_sql_array, ['SELECT ST_Intersects((SELECT single_geometry FROM (?) as ' \ - 'left_intersect), ST_GeogFromText(?)) as r;', GeographicItem.single_geometry_sql(*ids), ANTI_MERIDIAN]) - GeographicItem.find_by_sql(q1).first.r + q = "SELECT ST_Intersects((SELECT single_geometry FROM (#{GeographicItem.single_geometry_sql(*ids)}) as " \ + 'left_intersect), ST_GeogFromText(?)) as r;', ANTI_MERIDIAN + GeographicItem.find_by_sql([q]).first.r end # @@ -186,14 +185,16 @@ def crosses_anti_meridian_by_id?(*ids) # @param [Integer, String] # @return [String] - # a SQL select statement that returns the *geometry* for the geographic_item with the specified id + # a SQL select statement that returns the *geometry* for the + # geographic_item with the specified id def select_geometry_sql(geographic_item_id) "SELECT #{GeographicItem::GEOMETRY_SQL.to_sql} from geographic_items where geographic_items.id = #{geographic_item_id}" end # @param [Integer, String] # @return [String] - # a SQL select statement that returns the geography for the geographic_item with the specified id + # a SQL select statement that returns the geography for the + # geographic_item with the specified id def select_geography_sql(geographic_item_id) ActiveRecord::Base.send(:sanitize_sql_for_conditions, [ "SELECT #{GeographicItem::GEOGRAPHY_SQL} from geographic_items where geographic_items.id = ?", @@ -235,7 +236,11 @@ def lat_long_sql(choice) # @param [Integer] distance # @return [String] def within_radius_of_item_sql(geographic_item_id, distance) - "ST_DWithin((#{GeographicItem::GEOGRAPHY_SQL}), (#{select_geography_sql(geographic_item_id)}), #{distance})" + 'ST_DWithin(' \ + "(#{GeographicItem::GEOGRAPHY_SQL}), " \ + "(#{select_geography_sql(geographic_item_id)}), " \ + "#{distance}" \ + ')' end @@ -244,10 +249,11 @@ def within_radius_of_item_sql(geographic_item_id, distance) # @param [Number] buffer: distance in meters to grow/shrink the shapes checked against (negative allowed) # @return [String] def st_buffer_st_within(geographic_item_id, distance, buffer = 0) - "ST_DWithin( - ST_Buffer(#{GeographicItem::GEOGRAPHY_SQL}, #{buffer}), - (#{select_geography_sql(geographic_item_id)}), #{distance} - )" + 'ST_DWithin(' \ + "ST_Buffer(#{GeographicItem::GEOGRAPHY_SQL}, #{buffer}), " \ + "(#{select_geography_sql(geographic_item_id)}), " \ + "#{distance}" \ + ')' end # TODO: 3D is overkill here @@ -256,8 +262,11 @@ def st_buffer_st_within(geographic_item_id, distance, buffer = 0) # @return [String] # !! This is intersecting def intersecting_radius_of_wkt_sql(wkt, distance) - # TODO use ST_GeogFromText? - "ST_DWithin((#{GeographicItem::GEOGRAPHY_SQL}), ST_GeomFromText('#{wkt}', 4326)::geography, #{distance})" + 'ST_DWithin(' \ + "(#{GeographicItem::GEOGRAPHY_SQL}), " \ + "ST_GeographyFromText('#{wkt}'), " \ + "#{distance}" \ + ')' end # @param [String] wkt @@ -265,22 +274,26 @@ def intersecting_radius_of_wkt_sql(wkt, distance) # @return [String] # !! This is fully covering def within_radius_of_wkt_sql(wkt, distance) - # TODO use ST_GeogFromText? - "ST_Covers( ST_Buffer(ST_SetSRID( ST_GeomFromText('#{wkt}'), 4326)::geography, #{distance}), (#{GeographicItem::GEOGRAPHY_SQL}))" + 'ST_Covers(' \ + "ST_Buffer(ST_GeographyFromText('#{wkt}'), #{distance}), " \ + "(#{GeographicItem::GEOGRAPHY_SQL})" \ + ')' end # @param [String, Integer, String] # @return [String] # a SQL fragment for ST_Contains() function, returns - # all geographic items whose target_shape contain the item supplied's + # all geographic items whose target_shape contains the item supplied's # source_shape def containing_sql(target_shape = nil, geographic_item_id = nil, source_shape = nil) return 'false' if geographic_item_id.nil? || source_shape.nil? || target_shape.nil? target_shape_sql = GeographicItem.shape_column_sql(target_shape) - "ST_Contains(#{target_shape_sql}::geometry, " \ - "(#{geometry_sql(geographic_item_id, source_shape)}))" + 'ST_Contains(' \ + "#{target_shape_sql}::geometry, " \ + "(#{geometry_sql(geographic_item_id, source_shape)})" \ + ')' end # @param [String, Integer, String] @@ -292,7 +305,10 @@ def reverse_containing_sql(target_shape = nil, geographic_item_id = nil, source_ return 'false' if geographic_item_id.nil? || source_shape.nil? || target_shape.nil? target_shape_sql = GeographicItem.shape_column_sql(target_shape) - "ST_Contains((#{geometry_sql(geographic_item_id, source_shape)}), #{target_shape_sql}::geometry)" + 'ST_Contains(' \ + "(#{geometry_sql(geographic_item_id, source_shape)}), " \ + "#{target_shape_sql}::geometry" \ + ')' end # @param [Integer, String] @@ -303,7 +319,7 @@ def geometry_sql(geographic_item_id = nil, shape = nil) return 'false' if geographic_item_id.nil? || shape.nil? "SELECT #{GeographicItem.shape_column_sql(shape)}::geometry FROM " \ - "geographic_items WHERE id = #{geographic_item_id}" + "geographic_items WHERE id = #{geographic_item_id}" end # DEPRECATED @@ -370,6 +386,7 @@ def geometry_sql2(*geographic_item_ids) end end + # DEPRECATED # @param [Integer, Array of Integer] geographic_item_ids # @return [String] Those geographic items containing the union of # geographic_item_ids. @@ -454,23 +471,23 @@ def contained_by_wkt_shifted_sql(wkt) # contained by this WKT def contained_by_wkt_sql(wkt) if crosses_anti_meridian?(wkt) - retval = contained_by_wkt_shifted_sql(wkt) + contained_by_wkt_shifted_sql(wkt) else - retval = "ST_Contains(ST_GeomFromText('#{wkt}', 4326), - #{GEOMETRY_SQL.to_sql})" + 'ST_Contains(' \ + "ST_GeomFromText('#{wkt}', 4326), " \ + "#{GEOMETRY_SQL.to_sql}" \ + ')' end - retval end # @param [Integer, Array of Integer] geographic_item_ids # @return [String] sql for contained_by via ST_Contains - # Note: Can not use GEOMETRY_SQL because geometry_collection is not supported in older versions of ST_Contains # Note: !! If the target GeographicItem#id crosses the anti-meridian then you may/will get unexpected results. - # TODO need to handle geography case here def contained_by_where_sql(*geographic_item_ids) - "ST_Contains( - #{GeographicItem.geometry_sql2(*geographic_item_ids)}, - #{GEOMETRY_SQL.to_sql})" + 'ST_Contains(' \ + "#{GeographicItem.geometry_sql2(*geographic_item_ids)}, " \ + "#{GEOMETRY_SQL.to_sql}" \ + ')' end # @param [RGeo:Point] rgeo_point @@ -478,10 +495,10 @@ def contained_by_where_sql(*geographic_item_ids) # TODO: Remove the hard coded 4326 reference # TODO: should this be wkt_point instead of rgeo_point? def containing_where_for_point_sql(rgeo_point) - "ST_CoveredBy( - ST_GeomFromText('#{rgeo_point}', 4326), - #{GeographicItem::GEOMETRY_SQL.to_sql} - )" + 'ST_CoveredBy(' \ + "ST_GeomFromText('#{rgeo_point}', 4326), " \ + "#{GeographicItem::GEOMETRY_SQL.to_sql}" \ + ')' end # @param [Integer] geographic_item_id @@ -491,6 +508,7 @@ def geometry_for_sql(geographic_item_id) "#{geographic_item_id} LIMIT 1" end + # DEPRECATED # @param [Integer, Array of Integer] geographic_item_ids # @return [String] SQL for geometries # example, not used @@ -712,9 +730,12 @@ def are_contained_in_wkt(shape, geometry) GeographicItem.where(id: part.flatten) else - shape_column = GeographicItem.shape_column_sql(shape) - q = "ST_Contains(ST_GeomFromEWKT('srid=4326;#{geometry}'), #{shape_column}::geometry)" - where(q) # .not_including(geographic_items) + where( + 'ST_Contains(' \ + "ST_GeomFromEWKT('srid=4326;#{geometry}'), " \ + "#{GeographicItem.shape_column_sql(shape)}::geometry" \ + ')' + ) # .not_including(geographic_items) end end @@ -805,13 +826,12 @@ def not_including(geographic_items) # @param [Integer] geographic_item_id2 # @return [Float] def distance_between(geographic_item_id1, geographic_item_id2) - q1 = "ST_Distance(#{GeographicItem::GEOGRAPHY_SQL}, " \ - "(#{select_geography_sql(geographic_item_id2)})) as distance" - _q2 = ActiveRecord::Base.send( - :sanitize_sql_array, ['ST_Distance(?, (?)) as distance', - GeographicItem::GEOGRAPHY_SQL, - select_geography_sql(geographic_item_id2)]) - GeographicItem.where(id: geographic_item_id1).pluck(Arel.sql(q1)).first + q = 'ST_Distance(' \ + "#{GeographicItem::GEOGRAPHY_SQL}, " \ + "(#{select_geography_sql(geographic_item_id2)}) " \ + ') as distance' + + GeographicItem.where(id: geographic_item_id1).pick(Arel.sql(q)) end # @param [RGeo::Point] point @@ -958,13 +978,12 @@ def centroid # @param [Integer] geographic_item_id # @return [Double] distance in meters def st_distance(geographic_item_id) # geo_object - q1 = "ST_Distance((#{GeographicItem.select_geography_sql(id)}), " \ - "(#{GeographicItem.select_geography_sql(geographic_item_id)})) as d" - _q2 = ActiveRecord::Base.send(:sanitize_sql_array, ['ST_Distance((?),(?)) as d', - GeographicItem.select_geography_sql(self.id), - GeographicItem.select_geography_sql(geographic_item_id)]) + q = 'ST_Distance(' \ + "(#{GeographicItem.select_geography_sql(id)}), " \ + "(#{GeographicItem.select_geography_sql(geographic_item_id)})" \ + ') as d' - GeographicItem.where(id:).pluck(Arel.sql(q1)).first + GeographicItem.where(id:).pick(Arel.sql(q)) end # @param [GeographicItem] geographic_item @@ -992,28 +1011,24 @@ def st_distance_to_geographic_item(geographic_item) # @param [Integer] geographic_item_id # @return [Double] distance in meters def st_distance_spheroid(geographic_item_id) - q1 = "ST_DistanceSpheroid((#{GeographicItem.select_geometry_sql(id)})," \ - "(#{GeographicItem.select_geometry_sql(geographic_item_id)}),'#{Gis::SPHEROID}') as distance" - _q2 = ActiveRecord::Base.send(:sanitize_sql_array, - ['ST_DistanceSpheroid((?),(?),?) as distance', - GeographicItem.select_geometry_sql(id), - GeographicItem.select_geometry_sql(geographic_item_id), - Gis::SPHEROID]) - # TODO: what is _q2? - GeographicItem.where(id:).pluck(Arel.sql(q1)).first + q = 'ST_DistanceSpheroid(' \ + "(#{GeographicItem.select_geometry_sql(id)}), " \ + "(#{GeographicItem.select_geometry_sql(geographic_item_id)}) ," \ + "'#{Gis::SPHEROID}'" \ + ') as distance' + GeographicItem.where(id:).pick(Arel.sql(q)) end # @return [String] # a WKT POINT representing the centroid of the geographic item def st_centroid - # TODO why to_param here? - GeographicItem.where(id: to_param).pluck(Arel.sql("ST_AsEWKT(ST_Centroid(#{GeographicItem::GEOMETRY_SQL.to_sql}))")).first.gsub(/SRID=\d*;/, '') + GeographicItem.where(id:).pick(Arel.sql("ST_AsEWKT(ST_Centroid(#{GeographicItem::GEOMETRY_SQL.to_sql}))")).gsub(/SRID=\d*;/, '') end # @return [Integer] # the number of points in the geometry def st_npoints - GeographicItem.where(id:).pluck(Arel.sql("ST_NPoints(#{GeographicItem::GEOMETRY_SQL.to_sql}) as npoints")).first + GeographicItem.where(id:).pick(Arel.sql("ST_NPoints(#{GeographicItem::GEOMETRY_SQL.to_sql}) as npoints")) end # !!TODO: migrate these to use native column calls @@ -1065,7 +1080,9 @@ def to_geo_json JSON.parse( GeographicItem.connection.select_one( "SELECT ST_AsGeoJSON(#{data_column}::geometry) a " \ - "FROM geographic_items WHERE id=#{id};")['a']) + "FROM geographic_items WHERE id=#{id};" + )['a'] + ) end # DEPRECATED @@ -1073,7 +1090,8 @@ def to_geo_json def to_geo_json_string GeographicItem.connection.select_one( "SELECT ST_AsGeoJSON(#{geo_object_type}::geometry) a " \ - "FROM geographic_items WHERE id=#{id};")['a'] + "FROM geographic_items WHERE id=#{id};" + )['a'] end # @return [Hash] @@ -1123,7 +1141,7 @@ def shape=(value) self.type = GeographicItem.eval_for_type(this_type) unless geom.nil? if type.blank? - errors.add(:base, "unrecognized geometry type '#{this_type}'; note geometry collections are currently not supported") + errors.add(:base, "unrecognized geometry type '#{this_type}'") return end @@ -1148,7 +1166,7 @@ def to_wkt # GeographicItem.select("ST_AsText( #{GeographicItem::GEOMETRY_SQL.to_sql}) wkt").where(id: id).first.wkt # 10k - if a = ApplicationRecord.connection.execute( "SELECT ST_AsText( #{GeographicItem::GEOMETRY_SQL.to_sql} ) wkt from geographic_items where geographic_items.id = #{id}").first + if (a = ApplicationRecord.connection.execute( "SELECT ST_AsText( #{GeographicItem::GEOMETRY_SQL.to_sql} ) wkt from geographic_items where geographic_items.id = #{id}").first) return a['wkt'] else return nil From 27e5cdc998f641ed4e571edfc3ba421fcb60409d Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Wed, 19 Jun 2024 11:44:53 -0500 Subject: [PATCH 030/259] #1954 Move GeographicItem deprecated methods into their own module There are 350 lines worth. Some are used only in specs, mostly testing only themselves - feels like there should be a particular place for those, but I'm not finding it. --- app/models/geographic_item.rb | 369 +--------------------- app/models/geographic_item/deprecated.rb | 380 +++++++++++++++++++++++ 2 files changed, 384 insertions(+), 365 deletions(-) create mode 100644 app/models/geographic_item/deprecated.rb diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index de717e5e29..9dc304d8bc 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -52,6 +52,10 @@ class GeographicItem < ApplicationRecord include Shared::IsData include Shared::SharedAcrossProjects + # Methods that are deprecated or used only in specs + # TODO move spec-only methods somewhere else? + include GeographicItem::Deprecated + # @return [Hash, nil] # An internal variable for use in super calls, holds a Hash in GeoJSON format (temporarily) attr_accessor :geometry @@ -139,22 +143,6 @@ def st_union(geographic_item_scope) .where(id: geographic_item_scope.pluck(:id)) end - # DEPRECATED - def st_collect(geographic_item_scope) - GeographicItem.select("ST_Collect(#{GeographicItem::GEOMETRY_SQL.to_sql}) as collection") - .where(id: geographic_item_scope.pluck(:id)) - end - - # DEPRECATED - # @return [GeographicItem::ActiveRecord_Relation] - # @params [Array] array of geographic area ids - def default_by_geographic_area_ids(geographic_area_ids = []) - GeographicItem. - joins(:geographic_areas_geographic_items). - merge(::GeographicAreasGeographicItem.default_geographic_item_data). - where(geographic_areas_geographic_items: {geographic_area_id: geographic_area_ids}) - end - # @param [String] wkt # @return [Boolean] # whether or not the wkt intersects with the anti-meridian @@ -165,20 +153,6 @@ def crosses_anti_meridian?(wkt) ).first.r end - # DEPRECATED - # @param [Integer] ids - # @return [Boolean] - # whether or not any GeographicItem passed intersects the anti-meridian - # !! StrongParams security considerations - # This is our first line of defense against queries that define multiple shapes, one or - # more of which crosses the anti-meridian. In this case the current TW strategy within the - # UI is to abandon the search, and prompt the user to refactor the query. - def crosses_anti_meridian_by_id?(*ids) - q = "SELECT ST_Intersects((SELECT single_geometry FROM (#{GeographicItem.single_geometry_sql(*ids)}) as " \ - 'left_intersect), ST_GeogFromText(?)) as r;', ANTI_MERIDIAN - GeographicItem.find_by_sql([q]).first.r - end - # # SQL fragments # @@ -231,7 +205,6 @@ def lat_long_sql(choice) END as #{choice}" end - # DEPRECATED # @param [Integer] geographic_item_id # @param [Integer] distance # @return [String] @@ -322,39 +295,6 @@ def geometry_sql(geographic_item_id = nil, shape = nil) "geographic_items WHERE id = #{geographic_item_id}" end - # DEPRECATED - # rubocop:disable Metrics/MethodLength - # @param [String] shape - # @param [GeographicItem] geographic_item - # @return [String] of SQL for all GeographicItems of the given shape - # contained by geographic_item - def is_contained_by_sql(shape, geographic_item) - template = "(ST_Contains(#{geographic_item.geo_object}, %s::geometry))" - retval = [] - shape = shape.to_s.downcase - case shape - when 'any' - SHAPE_TYPES.each { |shape| - shape_column = GeographicItem.shape_column_sql(shape) - retval.push(template % shape_column) - } - - when 'any_poly', 'any_line' - SHAPE_TYPES.each { |shape| - if column.to_s.index(shape.gsub('any_', '')) - shape_column = GeographicItem.shape_column_sql(shape) - retval.push(template % shape_column) - end - } - - else - shape_column = GeographicItem.shape_column_sql(shape) - retval = template % shape_column - end - retval = retval.join(' OR ') if retval.instance_of?(Array) - retval - end - # @param [Integer, Array of Integer] geographic_item_ids # @return [String] # returns one or more geographic items combined as a single geometry @@ -386,7 +326,6 @@ def geometry_sql2(*geographic_item_ids) end end - # DEPRECATED # @param [Integer, Array of Integer] geographic_item_ids # @return [String] Those geographic items containing the union of # geographic_item_ids. @@ -396,53 +335,6 @@ def containing_where_sql(*geographic_item_ids) #{GeographicItem::GEOMETRY_SQL.to_sql})" end - # DEPRECATED - # @param [Integer, Array of Integer] geographic_item_ids - # @return [String] - # Result doesn't contain self. Much slower than containing_where_sql - def containing_where_sql_geog(*geographic_item_ids) - "ST_CoveredBy( - (#{GeographicItem.geometry_sql2(*geographic_item_ids)})::geography, - #{GEOGRAPHY_SQL})" - end - - # DEPRECATED - # @param [Interger, Array of Integer] ids - # @return [Array] - # If we detect that some query id has crossed the meridian, then loop through - # and "manually" build up a list of results. - # Should only be used if GeographicItem.crosses_anti_meridian_by_id? is true. - # Note that this does not return a Scope, so you can't chain it like contained_by? - # TODO: test this - def contained_by_with_antimeridian_check(*ids) - ids.flatten! # make sure there is only one level of splat (*) - results = [] - - crossing_ids = [] - - ids.each do |id| - # push each which crosses - crossing_ids.push(id) if GeographicItem.crosses_anti_meridian_by_id?(id) - end - - non_crossing_ids = ids - crossing_ids - results.push GeographicItem.contained_by(non_crossing_ids).to_a if non_crossing_ids.any? - - crossing_ids.each do |id| - # [61666, 61661, 61659, 61654, 61639] - q1 = ActiveRecord::Base.send(:sanitize_sql_array, ['SELECT ST_AsText((SELECT polygon FROM geographic_items ' \ - 'WHERE id = ?))', id]) - r = GeographicItem.where( - # GeographicItem.contained_by_wkt_shifted_sql(GeographicItem.find(id).geo_object.to_s) - GeographicItem.contained_by_wkt_shifted_sql( - ApplicationRecord.connection.execute(q1).first['st_astext']) - ).to_a - results.push(r) - end - - results.flatten.uniq - end - # @params [String] well known text # @return [String] the SQL fragment for the specific geometry type, # shifted by longitude @@ -508,15 +400,6 @@ def geometry_for_sql(geographic_item_id) "#{geographic_item_id} LIMIT 1" end - # DEPRECATED - # @param [Integer, Array of Integer] geographic_item_ids - # @return [String] SQL for geometries - # example, not used - def geometry_for_collection_sql(*geographic_item_ids) - 'SELECT ' + GeographicItem::GEOMETRY_SQL.to_sql + ' AS geometry FROM geographic_items WHERE id IN ' \ - "( #{geographic_item_ids.join(',')} )" - end - # # Scopes # @@ -529,15 +412,6 @@ def containing(*geographic_item_ids) where(GeographicItem.containing_where_sql(geographic_item_ids)).not_ids(*geographic_item_ids) end - # @param [Integer, Array of Integer] geographic_item_ids - # @return [Scope] - # the geographic items contained by the union of these - # geographic_item ids; return value always includes geographic_item_ids - # (works via ST_Contains) - def contained_by(*geographic_item_ids) - where(GeographicItem.contained_by_where_sql(geographic_item_ids)) - end - # @param [RGeo::Point] rgeo_point # @return [Scope] # the geographic items containing this point @@ -546,13 +420,6 @@ def containing_point(rgeo_point) where(GeographicItem.containing_where_for_point_sql(rgeo_point)) end - # DEPRECATED - # @return [Scope] - # adds an area_in_meters field, with meters - def with_area - select("ST_Area(#{GeographicItem::GEOGRAPHY_SQL}, false) as area_in_meters") - end - # return [Scope] # A scope that limits the result to those GeographicItems that have a collecting event # through either the geographic_item or the error_geographic_item @@ -587,18 +454,6 @@ def with_collecting_event_through_georeferences ).distinct end - # DEPRECATED - # @return [Scope] include a 'latitude' column - def with_latitude - select(lat_long_sql(:latitude)) - end - - # DEPRECATED - # @return [Scope] include a 'longitude' column - def with_longitude - select(lat_long_sql(:longitude)) - end - # @param [String, GeographicItems] # @return [Scope] def intersecting(shape, *geographic_items) @@ -631,26 +486,6 @@ def within_radius_of_item(geographic_item_id, distance) where(within_radius_of_item_sql(geographic_item_id, distance)) end - # DEPRECATED - # @param [String, GeographicItem] - # @return [Scope] - # a SQL fragment for ST_DISJOINT, specifies geographic_items of the - # given shape that are disjoint from all of the passed - # geographic_items - def disjoint_from(shape, *geographic_items) - shape_column = GeographicItem.shape_column_sql(shape) - - q = geographic_items.flatten.collect { |geographic_item| - geographic_item_geometry = geometry_sql(geographic_item.id, - geographic_item.geo_object_type) - - "ST_DISJOINT(#{shape_column}::geometry, " \ - "(#{geographic_item_geometry}))" - }.join(' and ') - - where(q) - end - # rubocop:disable Metrics/MethodLength # @param [String] shape to search # @param [GeographicItem] geographic_items or array of geographic_items @@ -702,43 +537,6 @@ def are_contained_in_item(shape, *geographic_items) # = containing # rubocop:enable Metrics/MethodLength - # DEPRECATED - # @param [String] shape - # @param [String] geometry of WKT - # @return [Scope] - # A scope of GeographicItems of the given shape contained in the - # WKT. - def are_contained_in_wkt(shape, geometry) - shape = shape.to_s.downcase - case shape - when 'any' - part = [] - SHAPE_TYPES.each { |shape| - part.push(GeographicItem.are_contained_in_wkt(shape, geometry).pluck(:id).to_a) - } - # TODO: change 'id in (?)' to some other sql construct - GeographicItem.where(id: part.flatten) - - when 'any_poly', 'any_line' - part = [] - SHAPE_TYPES.each { |shape| - if shape.to_s.index(shape.gsub('any_', '')) - part.push(GeographicItem.are_contained_in_wkt("#{shape}", geometry).pluck(:id).to_a) - end - } - # TODO: change 'id in (?)' to some other sql construct - GeographicItem.where(id: part.flatten) - - else - where( - 'ST_Contains(' \ - "ST_GeomFromEWKT('srid=4326;#{geometry}'), " \ - "#{GeographicItem.shape_column_sql(shape)}::geometry" \ - ')' - ) # .not_including(geographic_items) - end - end - # rubocop:disable Metrics/MethodLength # @param shape [String] can be any of SHAPE_TYPES, or 'any' to check # against all types, 'any_poly' to check against 'polygon' or @@ -974,18 +772,6 @@ def centroid return Gis::FACTORY.parse_wkt(self.st_centroid) end - # DEPRECATED - # @param [Integer] geographic_item_id - # @return [Double] distance in meters - def st_distance(geographic_item_id) # geo_object - q = 'ST_Distance(' \ - "(#{GeographicItem.select_geography_sql(id)}), " \ - "(#{GeographicItem.select_geography_sql(geographic_item_id)})" \ - ') as d' - - GeographicItem.where(id:).pick(Arel.sql(q)) - end - # @param [GeographicItem] geographic_item # @return [Double] distance in meters # Like st_distance but works with changed and non persisted objects @@ -1005,9 +791,6 @@ def st_distance_to_geographic_item(geographic_item) ActiveRecord::Base.connection.select_value("SELECT ST_Distance(#{a}, #{b})") end - # DEPRECATED - alias_method :distance_to, :st_distance - # @param [Integer] geographic_item_id # @return [Double] distance in meters def st_distance_spheroid(geographic_item_id) @@ -1052,19 +835,7 @@ def intersects?(target_geo_object) self.geo_object.intersects?(target_geo_object) end - # DEPRECATED - # @param [geo_object, Double] - # @return [Boolean] - def near(target_geo_object, distance) - self.geo_object.unsafe_buffer(distance).contains?(target_geo_object) - end - # DEPRECATED - # @param [geo_object, Double] - # @return [Boolean] - def far(target_geo_object, distance) - !near(target_geo_object, distance) - end # @return [GeoJSON hash] # via Rgeo apparently necessary for GeometryCollection @@ -1085,15 +856,6 @@ def to_geo_json ) end - # DEPRECATED - # We don't need to serialize to/from JSON - def to_geo_json_string - GeographicItem.connection.select_one( - "SELECT ST_AsGeoJSON(#{geo_object_type}::geometry) a " \ - "FROM geographic_items WHERE id=#{id};" - )['a'] - end - # @return [Hash] # the shape as a GeoJSON Feature with some item metadata def to_geo_json_feature @@ -1182,21 +944,6 @@ def area a end - # DEPRECATED - # @return [Float, false] - # the value in square meters of the interesecting area of this and another GeographicItem - def intersecting_area(geographic_item_id) - self_shape = GeographicItem.select_geography_sql(id) - other_shape = GeographicItem.select_geography_sql(geographic_item_id) - - r = GeographicItem.connection.execute( - "SELECT ST_Area(ST_Intersection((#{self_shape}), (#{other_shape}))) " \ - 'AS intersecting_area FROM geographic_items limit 1' - ).first - - r && r['intersecting_area'].to_f - end - # TODO: This is bad, while internal use of ONE_WEST_MEAN is consistent it is in-accurate given the vast differences of radius vs. lat/long position. # When we strike the error-polygon from radius we should remove this # @@ -1245,12 +992,6 @@ def st_isvalidreason r = ApplicationRecord.connection.execute( "SELECT ST_IsValidReason( #{GeographicItem::GEOMETRY_SQL.to_sql }) from geographic_items where geographic_items.id = #{id}").first['st_isvalidreason'] end - # DEPRECATED - # !! Unused. Doesn't check Geometry collection - def has_polygons? - ['GeographicItem::MultiPolygon', 'GeographicItem::Polygon'].include?(self.type) - end - # @return [Symbol, nil] # the specific type of geography: :point, :multipolygon, etc. Returns # the underlying shape of :geography in the :geography case @@ -1393,108 +1134,6 @@ def set_type_if_shape_column_present end end - # @param [RGeo::Point] point - # @return [Array] of a point - # Longitude |, Latitude - - def point_to_a(point) - data = [] - data.push(point.x, point.y) - data - end - - # @param [RGeo::Point] point - # @return [Hash] of a point - def point_to_hash(point) - {points: [point_to_a(point)]} - end - - # @param [RGeo::MultiPoint] multi_point - # @return [Array] of points - def multi_point_to_a(multi_point) - data = [] - multi_point.each { |point| - data.push([point.x, point.y]) - } - data - end - - # @return [Hash] of points - def multi_point_to_hash(_multi_point) - # when we encounter a multi_point type, we only stick the points into the array, NOT it's identity as a group - {points: multi_point_to_a(multi_point)} - end - - # @param [Reo::LineString] line_string - # @return [Array] of points in the line - def line_string_to_a(line_string) - data = [] - line_string.points.each { |point| - data.push([point.x, point.y]) - } - data - end - - # @param [Reo::LineString] line_string - # @return [Hash] of points in the line - def line_string_to_hash(line_string) - {lines: [line_string_to_a(line_string)]} - end - - # @param [RGeo::Polygon] polygon - # @return [Array] of points in the polygon (exterior_ring ONLY) - def polygon_to_a(polygon) - # TODO: handle other parts of the polygon; i.e., the interior_rings (if they exist) - data = [] - polygon.exterior_ring.points.each { |point| - data.push([point.x, point.y]) - } - data - end - - # @param [RGeo::Polygon] polygon - # @return [Hash] of points in the polygon (exterior_ring ONLY) - def polygon_to_hash(polygon) - {polygons: [polygon_to_a(polygon)]} - end - - # @return [Array] of line_strings as arrays of points - # @param [RGeo::MultiLineString] multi_line_string - def multi_line_string_to_a(multi_line_string) - data = [] - multi_line_string.each { |line_string| - line_data = [] - line_string.points.each { |point| - line_data.push([point.x, point.y]) - } - data.push(line_data) - } - data - end - - # @return [Hash] of line_strings as hashes of points - def multi_line_string_to_hash(_multi_line_string) - {lines: to_a} - end - - # @param [RGeo::MultiPolygon] multi_polygon - # @return [Array] of arrays of points in the polygons (exterior_ring ONLY) - def multi_polygon_to_a(multi_polygon) - data = [] - multi_polygon.each { |polygon| - polygon_data = [] - polygon.exterior_ring.points.each { |point| - polygon_data.push([point.x, point.y]) - } - data.push(polygon_data) - } - data - end - - # @return [Hash] of hashes of points in the polygons (exterior_ring ONLY) - def multi_polygon_to_hash(_multi_polygon) - {polygons: to_a} - end - # @return [Boolean] iff there is one and only one shape column set def some_data_is_provided data = [] diff --git a/app/models/geographic_item/deprecated.rb b/app/models/geographic_item/deprecated.rb new file mode 100644 index 0000000000..95b2365139 --- /dev/null +++ b/app/models/geographic_item/deprecated.rb @@ -0,0 +1,380 @@ +module GeographicItem::Deprecated + extend ActiveSupport::Concern + + # + # GeographicItem methods that are currently unused or used only in specs + # + + class_methods do + # DEPRECATED + def st_collect(geographic_item_scope) + GeographicItem.select("ST_Collect(#{GeographicItem::GEOMETRY_SQL.to_sql}) as collection") + .where(id: geographic_item_scope.pluck(:id)) + end + + # DEPRECATED, used only in specs + # @param [Integer, Array of Integer] geographic_item_ids + # @return [Scope] + # the geographic items contained by the union of these + # geographic_item ids; return value always includes geographic_item_ids + # (works via ST_Contains) + def contained_by(*geographic_item_ids) + where(GeographicItem.contained_by_where_sql(geographic_item_ids)) + end + + + # DEPRECATED + # @return [GeographicItem::ActiveRecord_Relation] + # @params [Array] array of geographic area ids + def default_by_geographic_area_ids(geographic_area_ids = []) + GeographicItem. + joins(:geographic_areas_geographic_items). + merge(::GeographicAreasGeographicItem.default_geographic_item_data). + where(geographic_areas_geographic_items: {geographic_area_id: geographic_area_ids}) + end + + # DEPRECATED + # @param [Integer] ids + # @return [Boolean] + # whether or not any GeographicItem passed intersects the anti-meridian + # !! StrongParams security considerations + # This is our first line of defense against queries that define multiple shapes, one or + # more of which crosses the anti-meridian. In this case the current TW strategy within the + # UI is to abandon the search, and prompt the user to refactor the query. + def crosses_anti_meridian_by_id?(*ids) + q = "SELECT ST_Intersects((SELECT single_geometry FROM (#{GeographicItem.single_geometry_sql(*ids)}) as " \ + 'left_intersect), ST_GeogFromText(?)) as r;', ANTI_MERIDIAN + GeographicItem.find_by_sql([q]).first.r + end + + # DEPRECATED + # rubocop:disable Metrics/MethodLength + # @param [String] shape + # @param [GeographicItem] geographic_item + # @return [String] of SQL for all GeographicItems of the given shape + # contained by geographic_item + def is_contained_by_sql(shape, geographic_item) + template = "(ST_Contains(#{geographic_item.geo_object}, %s::geometry))" + retval = [] + shape = shape.to_s.downcase + case shape + when 'any' + SHAPE_TYPES.each { |shape| + shape_column = GeographicItem.shape_column_sql(shape) + retval.push(template % shape_column) + } + + when 'any_poly', 'any_line' + SHAPE_TYPES.each { |shape| + if column.to_s.index(shape.gsub('any_', '')) + shape_column = GeographicItem.shape_column_sql(shape) + retval.push(template % shape_column) + end + } + + else + shape_column = GeographicItem.shape_column_sql(shape) + retval = template % shape_column + end + retval = retval.join(' OR ') if retval.instance_of?(Array) + retval + end + # rubocop:enable Metrics/MethodLength + + # DEPRECATED + # @param [Integer, Array of Integer] geographic_item_ids + # @return [String] + # Result doesn't contain self. Much slower than containing_where_sql + def containing_where_sql_geog(*geographic_item_ids) + "ST_CoveredBy( + (#{GeographicItem.geometry_sql2(*geographic_item_ids)})::geography, + #{GEOGRAPHY_SQL})" + end + + # DEPRECATED + # @param [Interger, Array of Integer] ids + # @return [Array] + # If we detect that some query id has crossed the meridian, then loop through + # and "manually" build up a list of results. + # Should only be used if GeographicItem.crosses_anti_meridian_by_id? is true. + # Note that this does not return a Scope, so you can't chain it like contained_by? + # TODO: test this + def contained_by_with_antimeridian_check(*ids) + ids.flatten! # make sure there is only one level of splat (*) + results = [] + + crossing_ids = [] + + ids.each do |id| + # push each which crosses + crossing_ids.push(id) if GeographicItem.crosses_anti_meridian_by_id?(id) + end + + non_crossing_ids = ids - crossing_ids + results.push GeographicItem.contained_by(non_crossing_ids).to_a if non_crossing_ids.any? + + crossing_ids.each do |id| + # [61666, 61661, 61659, 61654, 61639] + q1 = ActiveRecord::Base.send(:sanitize_sql_array, ['SELECT ST_AsText((SELECT polygon FROM geographic_items ' \ + 'WHERE id = ?))', id]) + r = GeographicItem.where( + # GeographicItem.contained_by_wkt_shifted_sql(GeographicItem.find(id).geo_object.to_s) + GeographicItem.contained_by_wkt_shifted_sql( + ApplicationRecord.connection.execute(q1).first['st_astext']) + ).to_a + results.push(r) + end + + results.flatten.uniq + end + + # DEPRECATED + # @param [Integer, Array of Integer] geographic_item_ids + # @return [String] SQL for geometries + # example, not used + def geometry_for_collection_sql(*geographic_item_ids) + 'SELECT ' + GeographicItem::GEOMETRY_SQL.to_sql + ' AS geometry FROM geographic_items WHERE id IN ' \ + "( #{geographic_item_ids.join(',')} )" + end + + # DEPRECATED + # @return [Scope] + # adds an area_in_meters field, with meters + def with_area + select("ST_Area(#{GeographicItem::GEOGRAPHY_SQL}, false) as area_in_meters") + end + + # DEPRECATED + # @return [Scope] include a 'latitude' column + def with_latitude + select(lat_long_sql(:latitude)) + end + + # DEPRECATED + # @return [Scope] include a 'longitude' column + def with_longitude + select(lat_long_sql(:longitude)) + end + + # DEPRECATED, used only in specs to test itself + # @param [String, GeographicItem] + # @return [Scope] + # a SQL fragment for ST_DISJOINT, specifies geographic_items of the + # given shape that are disjoint from all of the passed + # geographic_items + def disjoint_from(shape, *geographic_items) + shape_column = GeographicItem.shape_column_sql(shape) + + q = geographic_items.flatten.collect { |geographic_item| + geographic_item_geometry = geometry_sql(geographic_item.id, + geographic_item.geo_object_type) + + "ST_DISJOINT(#{shape_column}::geometry, " \ + "(#{geographic_item_geometry}))" + }.join(' and ') + + where(q) + end + + # DEPRECATED + # @param [String] shape + # @param [String] geometry of WKT + # @return [Scope] + # A scope of GeographicItems of the given shape contained in the + # WKT. + def are_contained_in_wkt(shape, geometry) + shape = shape.to_s.downcase + case shape + when 'any' + part = [] + SHAPE_TYPES.each { |shape| + part.push(GeographicItem.are_contained_in_wkt(shape, geometry).pluck(:id).to_a) + } + # TODO: change 'id in (?)' to some other sql construct + GeographicItem.where(id: part.flatten) + + when 'any_poly', 'any_line' + part = [] + SHAPE_TYPES.each { |shape| + if shape.to_s.index(shape.gsub('any_', '')) + part.push(GeographicItem.are_contained_in_wkt("#{shape}", geometry).pluck(:id).to_a) + end + } + # TODO: change 'id in (?)' to some other sql construct + GeographicItem.where(id: part.flatten) + + else + where( + 'ST_Contains(' \ + "ST_GeomFromEWKT('srid=4326;#{geometry}'), " \ + "#{GeographicItem.shape_column_sql(shape)}::geometry" \ + ')' + ) # .not_including(geographic_items) + end + end + + end # class_methods + + # Used only in specs + # @param [Integer] geographic_item_id + # @return [Double] distance in meters + def st_distance(geographic_item_id) # geo_object + q = 'ST_Distance(' \ + "(#{GeographicItem.select_geography_sql(id)}), " \ + "(#{GeographicItem.select_geography_sql(geographic_item_id)})" \ + ') as d' + + GeographicItem.where(id:).pick(Arel.sql(q)) + end + + # DEPRECATED + alias_method :distance_to, :st_distance + + # DEPRECATED, used only in specs to test itself + # @param [geo_object, Double] + # @return [Boolean] + def near(target_geo_object, distance) + self.geo_object.unsafe_buffer(distance).contains?(target_geo_object) + end + + # DEPRECATED, used only in specs to test itself + # @param [geo_object, Double] + # @return [Boolean] + def far(target_geo_object, distance) + !near(target_geo_object, distance) + end + + # DEPRECATED + # We don't need to serialize to/from JSON + def to_geo_json_string + GeographicItem.connection.select_one( + "SELECT ST_AsGeoJSON(#{geo_object_type}::geometry) a " \ + "FROM geographic_items WHERE id=#{id};" + )['a'] + end + + # DEPRECATED + # @return [Float, false] + # the value in square meters of the interesecting area of this and another GeographicItem + def intersecting_area(geographic_item_id) + self_shape = GeographicItem.select_geography_sql(id) + other_shape = GeographicItem.select_geography_sql(geographic_item_id) + + r = GeographicItem.connection.execute( + "SELECT ST_Area(ST_Intersection((#{self_shape}), (#{other_shape}))) " \ + 'AS intersecting_area FROM geographic_items limit 1' + ).first + + r && r['intersecting_area'].to_f + end + + # DEPRECATED + # !! Unused. Doesn't check Geometry collection or geography column + def has_polygons? + ['GeographicItem::MultiPolygon', 'GeographicItem::Polygon'].include?(self.type) + end + + private + + # @param [RGeo::Point] point + # @return [Array] of a point + # Longitude |, Latitude - + def point_to_a(point) + data = [] + data.push(point.x, point.y) + data + end + + # @param [RGeo::Point] point + # @return [Hash] of a point + def point_to_hash(point) + {points: [point_to_a(point)]} + end + + # @param [RGeo::MultiPoint] multi_point + # @return [Array] of points + def multi_point_to_a(multi_point) + data = [] + multi_point.each { |point| + data.push([point.x, point.y]) + } + data + end + + # @return [Hash] of points + def multi_point_to_hash(_multi_point) + # when we encounter a multi_point type, we only stick the points into the array, NOT it's identity as a group + {points: multi_point_to_a(multi_point)} + end + + # @param [Reo::LineString] line_string + # @return [Array] of points in the line + def line_string_to_a(line_string) + data = [] + line_string.points.each { |point| + data.push([point.x, point.y]) + } + data + end + + # @param [Reo::LineString] line_string + # @return [Hash] of points in the line + def line_string_to_hash(line_string) + {lines: [line_string_to_a(line_string)]} + end + + # @param [RGeo::Polygon] polygon + # @return [Array] of points in the polygon (exterior_ring ONLY) + def polygon_to_a(polygon) + # TODO: handle other parts of the polygon; i.e., the interior_rings (if they exist) + data = [] + polygon.exterior_ring.points.each { |point| + data.push([point.x, point.y]) + } + data + end + + # @param [RGeo::Polygon] polygon + # @return [Hash] of points in the polygon (exterior_ring ONLY) + def polygon_to_hash(polygon) + {polygons: [polygon_to_a(polygon)]} + end + + # @return [Array] of line_strings as arrays of points + # @param [RGeo::MultiLineString] multi_line_string + def multi_line_string_to_a(multi_line_string) + data = [] + multi_line_string.each { |line_string| + line_data = [] + line_string.points.each { |point| + line_data.push([point.x, point.y]) + } + data.push(line_data) + } + data + end + + # @return [Hash] of line_strings as hashes of points + def multi_line_string_to_hash(_multi_line_string) + {lines: to_a} + end + + # @param [RGeo::MultiPolygon] multi_polygon + # @return [Array] of arrays of points in the polygons (exterior_ring ONLY) + def multi_polygon_to_a(multi_polygon) + data = [] + multi_polygon.each { |polygon| + polygon_data = [] + polygon.exterior_ring.points.each { |point| + polygon_data.push([point.x, point.y]) + } + data.push(polygon_data) + } + data + end + + # @return [Hash] of hashes of points in the polygons (exterior_ring ONLY) + def multi_polygon_to_hash(_multi_polygon) + {polygons: to_a} + end +end \ No newline at end of file From 976fb1953badb81b519390b31c39e5f3e4838c79 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Thu, 20 Jun 2024 08:23:53 -0500 Subject: [PATCH 031/259] #1954 Rewrite GeographicItem query in CollectingEvent to not use GEOMETRY_SQL Don't want to be using GEOMETRY_SQL outside of GeographicItem. I may well be missing something, but in regards to the comment about not wanting to load the entire GeographicItem, my thinking is that the size of the shape dwarfs the rest of a geoitem in general, and the rewrite here fetches that shape as wkb instead of as geojson (which i think would be larger?). Also I copied the snippet here from the same function in Georeference :) --- app/models/collecting_event.rb | 11 ++++------- app/models/geographic_item.rb | 12 ++++++++---- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/app/models/collecting_event.rb b/app/models/collecting_event.rb index b90e8d27c0..c291a184fa 100644 --- a/app/models/collecting_event.rb +++ b/app/models/collecting_event.rb @@ -900,7 +900,6 @@ def county_or_equivalent_name # @return [GeoJSON::Feature] # the first geographic item of the first georeference on this collecting event def to_geo_json_feature - # !! avoid loading the whole geographic item, just grab the bits we need: # self.georeferences(true) # do this to to_simple_json_feature.merge({ 'properties' => { @@ -921,14 +920,12 @@ def to_simple_json_feature } if geographic_items.any? - geo_item_id = geographic_items.select(:id).first.id - query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json" - base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json) + base['geometry'] = RGeo::GeoJSON.encode(geographic_items.first.geo_object) end + base end - # @param [Float] delta_z, will be used to fill in the z coordinate of the point # @return [RGeo::Geographic::ProjectedPointImpl, nil] # for the *verbatim* latitude/longitude only @@ -1013,7 +1010,7 @@ def clone(annotations: false, incremented_identifier_id: nil) # not_georeference_attributes = %w{created_at updated_at project_id updated_by_id created_by_id collecting_event_id id position} georeferences.each do |g| i = g.dup - + g.georeferencer_roles.each do |r| i.georeferencer_roles.build(person: r.person, position: r.position) end @@ -1027,7 +1024,7 @@ def clone(annotations: false, incremented_identifier_id: nil) add_incremented_identifier(to_object: a, incremented_identifier_id:) end - if !annotations.blank? # TODO: boolean param this + if !annotations.blank? # TODO: boolean param this clone_annotations(to_object: a, except: [:identifiers]) end diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 9dc304d8bc..8b26d0ac20 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -636,7 +636,7 @@ def distance_between(geographic_item_id1, geographic_item_id2) # @return [Hash] # as per #inferred_geographic_name_hierarchy but for Rgeo point def point_inferred_geographic_name_hierarchy(point) - GeographicItem.containing_point(point).order(cached_total_area: :ASC).limit(1).first&.inferred_geographic_name_hierarchy + GeographicItem.containing_point(point).order(cached_total_area: :ASC).first&.inferred_geographic_name_hierarchy end # @param [String] type_name ('polygon', 'point', 'line', etc) @@ -715,7 +715,6 @@ def inferred_geographic_name_hierarchy .joins(:geographic_areas_geographic_items) .merge(GeographicAreasGeographicItem.ordered_by_data_origin) .ordered_by_area - .limit(1) .first small_area.geographic_name_classification @@ -724,6 +723,13 @@ def inferred_geographic_name_hierarchy end end + # @param [RGeo::Point] point + # @return [Hash] + # as per #inferred_geographic_name_hierarchy but for Rgeo point + def point_inferred_geographic_name_hierarchy(point) + GeographicItem.containing_point(point).order(cached_total_area: :ASC).first&.inferred_geographic_name_hierarchy + end + def geographic_name_hierarchy a = quick_geographic_name_hierarchy # quick; almost never the case, UI not setup to do this return a if a.present? @@ -835,8 +841,6 @@ def intersects?(target_geo_object) self.geo_object.intersects?(target_geo_object) end - - # @return [GeoJSON hash] # via Rgeo apparently necessary for GeometryCollection def rgeo_to_geo_json From 77440942b726d6e1651398d46843556417e0025b Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Thu, 20 Jun 2024 17:20:46 -0500 Subject: [PATCH 032/259] #1954 Refactor GeographicItem ST_Covers/ST_CoveredBy methods I think keeping the naming (st_covers) for the new methods would have been more confusing since there's now only one parameter; hopefully the new names `within(shape_sql)` and `covering(shape_sql)` are easily readable/meaning-guessable. The lack of `covering_union_of_sql` is due to the 'covering' case requiring that the input shape(s) are not included in the result (only an issue when there's only one shape/all shapes are the same). In the 'within' case input shapes are all included in the result. I'm not sure why the difference (since ST_Covers(A, B) iff ST_Coveredby(B, A)). --- app/models/collecting_event.rb | 11 +- app/models/collection_object.rb | 2 +- app/models/geographic_item.rb | 126 ++++++++++---------- app/models/geographic_item/deprecated.rb | 8 +- db/schema.rb | 20 ---- lib/queries/asserted_distribution/filter.rb | 3 +- lib/queries/collecting_event/filter.rb | 3 +- spec/models/geographic_item_spec.rb | 28 ++--- spec/spec_helper.rb | 1 + 9 files changed, 96 insertions(+), 106 deletions(-) diff --git a/app/models/collecting_event.rb b/app/models/collecting_event.rb index c291a184fa..588deaabe3 100644 --- a/app/models/collecting_event.rb +++ b/app/models/collecting_event.rb @@ -362,9 +362,10 @@ def select_optimized(user_id, project_id) # @param [GeographicItem] geographic_item # @return [Scope] - # TODO: use joins(:geographic_items).where(containing scope), simplied to def contained_within(geographic_item) - CollectingEvent.joins(:geographic_items).where(GeographicItem.contained_by_where_sql(geographic_item.id)) + CollectingEvent.joins(:geographic_items).where( + GeographicItem.within_union_of_sql(geographic_item.id) + ) end # @param [CollectingEvent Scope] collecting_events @@ -790,6 +791,7 @@ def get_geographic_name_classification r = geographic_area.geographic_name_classification when :verbatim_map_center # elsif map_center # slowest + # TODO test this r = GeographicItem.point_inferred_geographic_name_hierarchy(verbatim_map_center) end r ||= {} @@ -821,7 +823,10 @@ def containing_geographic_items # Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set # !! and there was no tests broken # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq - gi_list = GeographicItem.containing(*geographic_items.pluck(:id)).pluck(:id).uniq + gi_list = GeographicItem + .covering_union_of(*geographic_items.pluck(:id)) + .pluck(:id) + .uniq else # use geographic_area only if there are no GIs or EGIs diff --git a/app/models/collection_object.rb b/app/models/collection_object.rb index 8d64b5caca..61757e017d 100644 --- a/app/models/collection_object.rb +++ b/app/models/collection_object.rb @@ -342,7 +342,7 @@ def self.in_geographic_item(geographic_item, limit, steps = false) retval = CollectionObject.where(id: step_4.sort) else retval = CollectionObject.joins(:geographic_items) - .where(GeographicItem.contained_by_where_sql(geographic_item.id)) + .where(GeographicItem.within_union_of_sql(geographic_item.id)) .limit(limit) .includes(:data_attributes, :collecting_event) end diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 8b26d0ac20..1b27af8056 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -143,6 +143,43 @@ def st_union(geographic_item_scope) .where(id: geographic_item_scope.pluck(:id)) end + # True for those shapes that are contained within the shape_sql shape. + def within_sql(shape_sql) + 'ST_Covers(' \ + "#{shape_sql}, " \ + "#{GeographicItem::GEOMETRY_SQL.to_sql}" \ + ')' + end + + # Note: !! If the target GeographicItem#id crosses the anti-meridian then + # you may/will get unexpected results. + def within_union_of_sql(*geographic_item_ids) + within_sql("#{GeographicItem.geometry_sql2(*geographic_item_ids)}") + end + + def within_union_of(*geographic_item_ids) + where(within_union_of_sql(*geographic_item_ids)) + end + + # True for those shapes that cover the shape_sql shape. + def covering_sql(shape_sql) + 'ST_Covers(' \ + "#{GeographicItem::GEOMETRY_SQL.to_sql}, " \ + "#{shape_sql}" \ + ')' + end + + # @return [Scope] of items covering the union of geographic_item_ids; + # does not include any of geographic_item_ids + def covering_union_of(*geographic_item_ids) + where( + self.covering_sql( + GeographicItem.geometry_sql2(*geographic_item_ids) + ) + ) + .not_ids(*geographic_item_ids) + end + # @param [String] wkt # @return [Boolean] # whether or not the wkt intersects with the anti-meridian @@ -244,13 +281,11 @@ def intersecting_radius_of_wkt_sql(wkt, distance) # @param [String] wkt # @param [Integer] distance (meters) - # @return [String] - # !! This is fully covering - def within_radius_of_wkt_sql(wkt, distance) - 'ST_Covers(' \ - "ST_Buffer(ST_GeographyFromText('#{wkt}'), #{distance}), " \ - "(#{GeographicItem::GEOGRAPHY_SQL})" \ - ')' + # @return [String] Those items contained in the distance-buffer of wkt + def within_radius_of_wkt(wkt, distance) + where(self.within_sql( + "ST_Buffer(ST_GeographyFromText('#{wkt}'), #{distance})" + )) end # @param [String, Integer, String] @@ -258,8 +293,8 @@ def within_radius_of_wkt_sql(wkt, distance) # a SQL fragment for ST_Contains() function, returns # all geographic items whose target_shape contains the item supplied's # source_shape - def containing_sql(target_shape = nil, geographic_item_id = nil, - source_shape = nil) + def containing_shape_sql(target_shape = nil, geographic_item_id = nil, + source_shape = nil) return 'false' if geographic_item_id.nil? || source_shape.nil? || target_shape.nil? target_shape_sql = GeographicItem.shape_column_sql(target_shape) @@ -326,21 +361,13 @@ def geometry_sql2(*geographic_item_ids) end end - # @param [Integer, Array of Integer] geographic_item_ids - # @return [String] Those geographic items containing the union of - # geographic_item_ids. - def containing_where_sql(*geographic_item_ids) - "ST_CoveredBy( - #{GeographicItem.geometry_sql2(*geographic_item_ids)}, - #{GeographicItem::GEOMETRY_SQL.to_sql})" - end - # @params [String] well known text # @return [String] the SQL fragment for the specific geometry type, # shifted by longitude # Note: this routine is called when it is already known that the A # argument crosses anti-meridian # TODO If wkt coords are in the range 0..360 and GI coords are in the range -180..180 (or vice versa), doesn't this fail? Don't you want all coords in the range 0..360 in this geometry case? Is there any assumption about range of inputs for georefs, e.g.? are they always normalized? See anti-meridian spec? + # TODO rename within? def contained_by_wkt_shifted_sql(wkt) "ST_Contains(ST_ShiftLongitude(ST_GeomFromText('#{wkt}', 4326)), ( CASE geographic_items.type @@ -365,6 +392,7 @@ def contained_by_wkt_sql(wkt) if crosses_anti_meridian?(wkt) contained_by_wkt_shifted_sql(wkt) else + # TODO should probably be ST_Covers? Then use covering_sql 'ST_Contains(' \ "ST_GeomFromText('#{wkt}', 4326), " \ "#{GEOMETRY_SQL.to_sql}" \ @@ -372,27 +400,6 @@ def contained_by_wkt_sql(wkt) end end - # @param [Integer, Array of Integer] geographic_item_ids - # @return [String] sql for contained_by via ST_Contains - # Note: !! If the target GeographicItem#id crosses the anti-meridian then you may/will get unexpected results. - def contained_by_where_sql(*geographic_item_ids) - 'ST_Contains(' \ - "#{GeographicItem.geometry_sql2(*geographic_item_ids)}, " \ - "#{GEOMETRY_SQL.to_sql}" \ - ')' - end - - # @param [RGeo:Point] rgeo_point - # @return [String] sql for containing via ST_CoveredBy - # TODO: Remove the hard coded 4326 reference - # TODO: should this be wkt_point instead of rgeo_point? - def containing_where_for_point_sql(rgeo_point) - 'ST_CoveredBy(' \ - "ST_GeomFromText('#{rgeo_point}', 4326), " \ - "#{GeographicItem::GEOMETRY_SQL.to_sql}" \ - ')' - end - # @param [Integer] geographic_item_id # @return [String] SQL for geometries def geometry_for_sql(geographic_item_id) @@ -404,20 +411,14 @@ def geometry_for_sql(geographic_item_id) # Scopes # - # @param [Integer, Array of Integer] geographic_item_ids - # @return [Scope] - # the geographic items containing all of the geographic_item ids; - # return value never includes geographic_item_ids - def containing(*geographic_item_ids) - where(GeographicItem.containing_where_sql(geographic_item_ids)).not_ids(*geographic_item_ids) - end - # @param [RGeo::Point] rgeo_point # @return [Scope] # the geographic items containing this point # TODO: should be containing_wkt ? def containing_point(rgeo_point) - where(GeographicItem.containing_where_for_point_sql(rgeo_point)) + where( + self.covering_sql("ST_GeomFromText('#{rgeo_point}', 4326)") + ) end # return [Scope] @@ -499,6 +500,7 @@ def within_radius_of_item(geographic_item_id, distance) # WHERE (ST_Contains(polygon::geometry, GeomFromEWKT('srid=4326;POINT (0.0 0.0 0.0)')) # OR ST_Contains(polygon::geometry, GeomFromEWKT('srid=4326;POINT (-9.8 5.0 0.0)'))) # + # TODO rename in st_ style (I think it's backwards now?) def are_contained_in_item(shape, *geographic_items) # = containing geographic_items.flatten! # in case there is a array of arrays, or multiple objects shape = shape.to_s.downcase @@ -514,7 +516,8 @@ def are_contained_in_item(shape, *geographic_items) # = containing when 'any_poly', 'any_line' part = [] SHAPE_TYPES.each { |shape| - if column.to_s.index(shape.gsub('any_', '')) + shape = shape.to_s.downcase + if shape.index(shape.gsub('any_', '')) part.push(GeographicItem.are_contained_in_item(shape, geographic_items).to_a) end } @@ -523,8 +526,11 @@ def are_contained_in_item(shape, *geographic_items) # = containing else q = geographic_items.flatten.collect { |geographic_item| - GeographicItem.containing_sql(shape, geographic_item.id, - geographic_item.geo_object_type) + GeographicItem.containing_shape_sql( + shape, + geographic_item.id, + geographic_item.geo_object_type + ) }.join(' or ') # This will prevent the invocation of *ALL* of the GeographicItems @@ -599,14 +605,14 @@ def ordered_by_longest_distance_from(shape, geographic_item) # @return [String] def select_distance_with_geo_object(shape, geographic_item) shape_column = GeographicItem.shape_column_sql(shape) - select("*, ST_Distance(#{shape_column}, GeomFromEWKT('srid=4326;#{geographic_item.geo_object}')) as distance") + select("*, ST_Distance(#{shape_column}, GeomFromEWKT('srid=4326;#{geographic_item.geo_object}')) AS distance") end # @param [String, GeographicItem] # @return [Scope] def where_distance_greater_than_zero(shape, geographic_item) shape_column = GeographicItem.shape_column_sql(shape) - where("#{shape_column} is not null and ST_Distance(#{shape_column}, " \ + where("#{shape_column} IS NOT NULL AND ST_Distance(#{shape_column}, " \ "GeomFromEWKT('srid=4326;#{geographic_item.geo_object}')) > 0") end @@ -636,7 +642,10 @@ def distance_between(geographic_item_id1, geographic_item_id2) # @return [Hash] # as per #inferred_geographic_name_hierarchy but for Rgeo point def point_inferred_geographic_name_hierarchy(point) - GeographicItem.containing_point(point).order(cached_total_area: :ASC).first&.inferred_geographic_name_hierarchy + self + .containing_point(point) + .order(cached_total_area: :ASC) + .first&.inferred_geographic_name_hierarchy end # @param [String] type_name ('polygon', 'point', 'line', etc) @@ -723,13 +732,6 @@ def inferred_geographic_name_hierarchy end end - # @param [RGeo::Point] point - # @return [Hash] - # as per #inferred_geographic_name_hierarchy but for Rgeo point - def point_inferred_geographic_name_hierarchy(point) - GeographicItem.containing_point(point).order(cached_total_area: :ASC).first&.inferred_geographic_name_hierarchy - end - def geographic_name_hierarchy a = quick_geographic_name_hierarchy # quick; almost never the case, UI not setup to do this return a if a.present? @@ -740,7 +742,7 @@ def geographic_name_hierarchy # the Geographic Areas that contain (gis) this geographic item def containing_geographic_areas GeographicArea.joins(:geographic_items).includes(:geographic_area_type) - .joins("JOIN (#{GeographicItem.containing(id).to_sql}) j on geographic_items.id = j.id") + .joins("JOIN (#{GeographicItem.covering_union_of(id).to_sql}) j ON geographic_items.id = j.id") end # @return [Boolean] diff --git a/app/models/geographic_item/deprecated.rb b/app/models/geographic_item/deprecated.rb index 95b2365139..518e0a21db 100644 --- a/app/models/geographic_item/deprecated.rb +++ b/app/models/geographic_item/deprecated.rb @@ -19,10 +19,9 @@ def st_collect(geographic_item_scope) # geographic_item ids; return value always includes geographic_item_ids # (works via ST_Contains) def contained_by(*geographic_item_ids) - where(GeographicItem.contained_by_where_sql(geographic_item_ids)) + where(GeographicItem.within_union_of_sql(geographic_item_ids)) end - # DEPRECATED # @return [GeographicItem::ActiveRecord_Relation] # @params [Array] array of geographic area ids @@ -91,7 +90,7 @@ def containing_where_sql_geog(*geographic_item_ids) #{GEOGRAPHY_SQL})" end - # DEPRECATED + # DEPRECATED, used only in specs # @param [Interger, Array of Integer] ids # @return [Array] # If we detect that some query id has crossed the meridian, then loop through @@ -99,6 +98,7 @@ def containing_where_sql_geog(*geographic_item_ids) # Should only be used if GeographicItem.crosses_anti_meridian_by_id? is true. # Note that this does not return a Scope, so you can't chain it like contained_by? # TODO: test this + # TODO rename within? def contained_by_with_antimeridian_check(*ids) ids.flatten! # make sure there is only one level of splat (*) results = [] @@ -111,7 +111,7 @@ def contained_by_with_antimeridian_check(*ids) end non_crossing_ids = ids - crossing_ids - results.push GeographicItem.contained_by(non_crossing_ids).to_a if non_crossing_ids.any? + results.push self.where(self.within_union_of_sql(non_crossing_ids)).to_a if non_crossing_ids.any? crossing_ids.each do |id| # [61666, 61661, 61659, 61654, 61639] diff --git a/db/schema.rb b/db/schema.rb index 2027558d11..abf1cc3e79 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1116,26 +1116,6 @@ t.index ["updated_by_id"], name: "index_georeferences_on_updated_by_id" end - create_table "greece", primary_key: "gid", id: :serial, force: :cascade do |t| - t.string "gid_3", limit: 12 - t.string "gid_0", limit: 10 - t.string "country", limit: 10 - t.string "gid_1", limit: 10 - t.string "name_1", limit: 31 - t.string "nl_name_1", limit: 58 - t.string "gid_2", limit: 10 - t.string "name_2", limit: 25 - t.string "nl_name_2", limit: 55 - t.string "name_3", limit: 27 - t.string "varname_3", limit: 27 - t.string "nl_name_3", limit: 56 - t.string "type_3", limit: 10 - t.string "engtype_3", limit: 12 - t.string "cc_3", limit: 10 - t.string "hasc_3", limit: 10 - t.geography "geomz", limit: {:srid=>4326, :type=>"multi_polygon", :has_z=>true, :geographic=>true} - end - create_table "identifiers", id: :serial, force: :cascade do |t| t.string "identifier", null: false t.string "type", null: false diff --git a/lib/queries/asserted_distribution/filter.rb b/lib/queries/asserted_distribution/filter.rb index 4696003f32..30d918ae98 100644 --- a/lib/queries/asserted_distribution/filter.rb +++ b/lib/queries/asserted_distribution/filter.rb @@ -170,7 +170,8 @@ def spatial_query if geometry = RGeo::GeoJSON.decode(geo_json) case geometry.geometry_type.to_s when 'Point' - ::GeographicItem.joins(:geographic_areas).where( ::GeographicItem.within_radius_of_wkt_sql(geometry.to_s, radius ) ) + # TODO test this + ::GeographicItem.joins(:geographic_areas).within_radius_of_wkt_sql(geometry.to_s, radius ) when 'Polygon', 'MultiPolygon' ::GeographicItem.joins(:geographic_areas).where(::GeographicItem.contained_by_wkt_sql(geometry.to_s)) else diff --git a/lib/queries/collecting_event/filter.rb b/lib/queries/collecting_event/filter.rb index 40aa291130..7927bb5f38 100644 --- a/lib/queries/collecting_event/filter.rb +++ b/lib/queries/collecting_event/filter.rb @@ -319,9 +319,10 @@ def geo_json_facet def spatial_query(geometry_type, wkt) case geometry_type when 'Point' + # TODO test this ::CollectingEvent .joins(:geographic_items) - .where(::GeographicItem.within_radius_of_wkt_sql(wkt, radius )) + .within_radius_of_wkt(wkt, radius) when 'Polygon', 'MultiPolygon' ::CollectingEvent .joins(:geographic_items) diff --git a/spec/models/geographic_item_spec.rb b/spec/models/geographic_item_spec.rb index de1499fd80..f1c3d2b6ea 100644 --- a/spec/models/geographic_item_spec.rb +++ b/spec/models/geographic_item_spec.rb @@ -654,23 +654,23 @@ before { [k, l, b, b1, b2, e1].each } specify 'find the polygon containing the points' do - expect(GeographicItem.containing(p1.id).to_a).to contain_exactly(k) + expect(GeographicItem.covering_union_of(p1.id).to_a).to contain_exactly(k) end specify 'find the polygon containing all three points' do - expect(GeographicItem.containing(p1.id, p2.id, p3.id).to_a).to contain_exactly(k) + expect(GeographicItem.covering_union_of(p1.id, p2.id, p3.id).to_a).to contain_exactly(k) end specify 'find that a line string can contain a point' do - expect(GeographicItem.containing(p4.id).to_a).to contain_exactly(l) + expect(GeographicItem.covering_union_of(p4.id).to_a).to contain_exactly(l) end specify 'point in two polygons, but not their intersection' do - expect(GeographicItem.containing(p18.id).to_a).to contain_exactly(b1, b2) + expect(GeographicItem.covering_union_of(p18.id).to_a).to contain_exactly(b1, b2) end specify 'point in two polygons, one with a hole in it' do - expect(GeographicItem.containing(p19.id).to_a).to contain_exactly(b1, b) + expect(GeographicItem.covering_union_of(p19.id).to_a).to contain_exactly(b1, b) end end @@ -746,12 +746,12 @@ before { [p1, p2, p3, p11, p12, k, l].each } specify 'find the points in a polygon' do - expect(GeographicItem.contained_by(k.id).to_a).to contain_exactly(p1, p2, p3, k) + expect(GeographicItem.within_union_of(k.id).to_a).to contain_exactly(p1, p2, p3, k) end specify 'find the (overlapping) points in a polygon' do overlapping_point = FactoryBot.create(:geographic_item_point, point: point12.as_binary) - expect(GeographicItem.contained_by(e1.id).to_a).to contain_exactly(p12, overlapping_point, p11, e1) + expect(GeographicItem.within_union_of(e1.id).to_a).to contain_exactly(p12, overlapping_point, p11, e1) end end @@ -1684,23 +1684,23 @@ before { [k, l, b, b1, b2, e1].each } specify 'find the polygon containing the points' do - expect(GeographicItem.containing(p1.id).to_a).to contain_exactly(k) + expect(GeographicItem.covering_union_of(p1.id).to_a).to contain_exactly(k) end specify 'find the polygon containing all three points' do - expect(GeographicItem.containing(p1.id, p2.id, p3.id).to_a).to contain_exactly(k) + expect(GeographicItem.covering_union_of(p1.id, p2.id, p3.id).to_a).to contain_exactly(k) end specify 'find that a line string can contain a point' do - expect(GeographicItem.containing(p4.id).to_a).to contain_exactly(l) + expect(GeographicItem.covering_union_of(p4.id).to_a).to contain_exactly(l) end specify 'point in two polygons, but not their intersection' do - expect(GeographicItem.containing(p18.id).to_a).to contain_exactly(b1, b2) + expect(GeographicItem.covering_union_of(p18.id).to_a).to contain_exactly(b1, b2) end specify 'point in two polygons, one with a hole in it' do - expect(GeographicItem.containing(p19.id).to_a).to contain_exactly(b1, b) + expect(GeographicItem.covering_union_of(p19.id).to_a).to contain_exactly(b1, b) end end @@ -1776,12 +1776,12 @@ before { [p1, p2, p3, p11, p12, k, l].each } specify 'find the points in a polygon' do - expect(GeographicItem.contained_by(k.id).to_a).to contain_exactly(p1, p2, p3, k) + expect(GeographicItem.within_union_of(k.id).to_a).to contain_exactly(p1, p2, p3, k) end specify 'find the (overlapping) points in a polygon' do overlapping_point = FactoryBot.create(:geographic_item_point, point: point12.as_binary) - expect(GeographicItem.contained_by(e1.id).to_a).to contain_exactly(p12, overlapping_point, p11, e1) + expect(GeographicItem.within_union_of(e1.id).to_a).to contain_exactly(p12, overlapping_point, p11, e1) end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b87390825b..fe2c4db301 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,3 +1,4 @@ +#ActiveRecord::Base.logger = Logger.new(STDOUT) # This file was generated by the `rails generate rspec:install` command. Conventionally, all # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. # The generated `.rspec` file contains `--require spec_helper` which will cause this From f852c51baff4c20ca9d97af41b8959fcbbb9e666 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Thu, 20 Jun 2024 10:43:47 -0500 Subject: [PATCH 033/259] #1954 Add GeographicItem.st_buffer_for_item --- app/models/cached_map_item.rb | 1 + app/models/geographic_item.rb | 29 ++++++++++++++++-------- app/models/geographic_item/deprecated.rb | 2 +- app/models/georeference.rb | 19 ++++++++-------- app/models/georeference/geo_locate.rb | 4 ++-- 5 files changed, 33 insertions(+), 22 deletions(-) diff --git a/app/models/cached_map_item.rb b/app/models/cached_map_item.rb index 127b4ff710..8b0e38fd62 100644 --- a/app/models/cached_map_item.rb +++ b/app/models/cached_map_item.rb @@ -168,6 +168,7 @@ def self.translate_by_spatial_overlap(geographic_item_id, data_origin, buffer) # aware of this assumption # This is a fast first pass, pure intersection + # TODO move this to geo item a = GeographicItem .joins(:geographic_areas_geographic_items) .where(geographic_areas_geographic_items: { data_origin: }) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 1b27af8056..983366b08c 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -180,6 +180,26 @@ def covering_union_of(*geographic_item_ids) .not_ids(*geographic_item_ids) end + # @param [Integer] geographic_item_id + # @param [Integer] buffer can be positive or negative, in meters for the + # default geographic case + # @param [Boolean] geometric: whether the buffer should be created using + # geographic (default) or geometric coordinates + # @return [RGeo::Polygon or RGeo::MultiPolygon] + # The buffer of size `buffer` around geographic_item_id + def st_buffer_for_item(geographic_item_id, buffer, geometric: false) + geometric = geometric ? '::geometry' : '' + + GeographicItem.select( + 'ST_Buffer(' \ + "#{GeographicItem::GEOGRAPHY_SQL}#{geometric}, " \ + "#{buffer}" \ + ') AS buffer' + ) + .where(id: geographic_item_id) + .first.buffer + end + # @param [String] wkt # @return [Boolean] # whether or not the wkt intersects with the anti-meridian @@ -253,7 +273,6 @@ def within_radius_of_item_sql(geographic_item_id, distance) ')' end - # @param [Integer] geographic_item_id # @param [Number] distance (in meters) (positive only?!) # @param [Number] buffer: distance in meters to grow/shrink the shapes checked against (negative allowed) @@ -479,14 +498,6 @@ def intersecting(shape, *geographic_items) end end - # @param [GeographicItem#id] geographic_item_id - # @param [Float] distance in meters ?!?! - # @return [ActiveRecord::Relation] - # !! should be distance, not radius?! - def within_radius_of_item(geographic_item_id, distance) - where(within_radius_of_item_sql(geographic_item_id, distance)) - end - # rubocop:disable Metrics/MethodLength # @param [String] shape to search # @param [GeographicItem] geographic_items or array of geographic_items diff --git a/app/models/geographic_item/deprecated.rb b/app/models/geographic_item/deprecated.rb index 518e0a21db..0054900cc3 100644 --- a/app/models/geographic_item/deprecated.rb +++ b/app/models/geographic_item/deprecated.rb @@ -6,7 +6,7 @@ module GeographicItem::Deprecated # class_methods do - # DEPRECATED + # DEPRECATED def st_collect(geographic_item_scope) GeographicItem.select("ST_Collect(#{GeographicItem::GEOMETRY_SQL.to_sql}) as collection") .where(id: geographic_item_scope.pluck(:id)) diff --git a/app/models/georeference.rb b/app/models/georeference.rb index e8b0b62a36..24a1dcfb25 100644 --- a/app/models/georeference.rb +++ b/app/models/georeference.rb @@ -151,16 +151,11 @@ def self.filter_by(params) # @return [Scope] georeferences # all georeferences within some distance of a geographic_item, by id def self.within_radius_of_item(geographic_item_id, distance) - return where(id: -1) if geographic_item_id.nil? || distance.nil? - # sanitize_sql_array(["name=:name and group_id=:group_id", name: "foo'bar", group_id: 4]) - # => "name='foo''bar' and group_id=4" - q1 = "ST_Distance(#{GeographicItem::GEOGRAPHY_SQL}, " \ - "(#{GeographicItem.select_geography_sql(geographic_item_id)})) < #{distance}" - # q2 = ActiveRecord::Base.send(:sanitize_sql_array, ['ST_Distance(?, (?)) < ?', - # GeographicItem::GEOGRAPHY_SQL, - # GeographicItem.select_geography_sql(geographic_item_id), - # distance]) - Georeference.joins(:geographic_item).where(q1) + return none if geographic_item_id.nil? || distance.nil? + + Georeference.joins(:geographic_item).where( + GeographicItem.within_radius_of_item_sql(geographic_item_id, distance) + ) end # @param [String] locality string @@ -307,10 +302,14 @@ def error_box retval end + # DEPRECATED # @return [Rgeo::polygon, nil] # a polygon representing the buffer def error_radius_buffer_polygon return nil if error_radius.nil? || geographic_item.nil? + + # This should be moved to GeographicItem, but can we assume geographic_item has + # been saved yet? sql_str = ActivRecord::Base.send( :sanitize_sql_array, ['SELECT ST_Buffer(?, ?)', diff --git a/app/models/georeference/geo_locate.rb b/app/models/georeference/geo_locate.rb index 59bec42518..14f6f7388f 100644 --- a/app/models/georeference/geo_locate.rb +++ b/app/models/georeference/geo_locate.rb @@ -41,11 +41,11 @@ def iframe_response=(response_string) if uncertainty_points.nil? # make a circle from the geographic_item if response_radius.present? - # q1 = "SELECT ST_BUFFER('#{self.geographic_item.geo_object}', #{error_radius.to_f / Utilities::Geo::ONE_WEST_MEAN});" - self.error_radius = response_radius # Why are we turning error radius into a polygon!? + # TODO this should be moved to GeographicItem, but geographic_item + # hasn't been saved yet q2 = ActiveRecord::Base.send(:sanitize_sql_array, ['SELECT ST_Buffer(?, ?);', self.geographic_item.geo_object.to_s, ((response_radius) / Utilities::Geo::ONE_WEST_MEAN)]) From 18cd583cd1fa8c80affe7e72296a7e706ef2b8db Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Fri, 21 Jun 2024 16:04:22 -0500 Subject: [PATCH 034/259] #1954 Add and use GeographicItem.st_dwithin_sql --- app/models/geographic_item.rb | 47 ++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 983366b08c..8cc7f8367a 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -200,6 +200,17 @@ def st_buffer_for_item(geographic_item_id, buffer, geometric: false) .first.buffer end + # True for those shapes that are within `distance` of (i.e. intersect the + # `distance`-buffer of) the shape_sql shape. This is a geography dwithin, + # distance is in meters. + def st_dwithin_sql(shape_sql, distance) + 'ST_DWithin(' \ + "(#{GeographicItem::GEOGRAPHY_SQL}), " \ + "(#{shape_sql}), " \ + "#{distance}" \ + ')' + end + # @param [String] wkt # @return [Boolean] # whether or not the wkt intersects with the anti-meridian @@ -263,14 +274,18 @@ def lat_long_sql(choice) end # @param [Integer] geographic_item_id - # @param [Integer] distance - # @return [String] + # @param [Integer] distance in meters + # @return [Scope] of shapes within distance of (i.e. whose + # distance-buffer intersects) geographic_item_id def within_radius_of_item_sql(geographic_item_id, distance) - 'ST_DWithin(' \ - "(#{GeographicItem::GEOGRAPHY_SQL}), " \ - "(#{select_geography_sql(geographic_item_id)}), " \ - "#{distance}" \ - ')' + self.st_dwithin_sql( + "#{select_geography_sql(geographic_item_id)}", + distance + ) + end + + def within_radius_of_item(geographic_item_id, distance) + where(within_radius_of_item_sql(geographic_item_id, distance)) end # @param [Integer] geographic_item_id @@ -278,6 +293,9 @@ def within_radius_of_item_sql(geographic_item_id, distance) # @param [Number] buffer: distance in meters to grow/shrink the shapes checked against (negative allowed) # @return [String] def st_buffer_st_within(geographic_item_id, distance, buffer = 0) + # You can't always switch the buffer to the second argument, even when + # distance is 0, without further assumptions (think of buffer being + # large negative compared to geographic_item_id but not another shape)) 'ST_DWithin(' \ "ST_Buffer(#{GeographicItem::GEOGRAPHY_SQL}, #{buffer}), " \ "(#{select_geography_sql(geographic_item_id)}), " \ @@ -288,14 +306,13 @@ def st_buffer_st_within(geographic_item_id, distance, buffer = 0) # TODO: 3D is overkill here # @param [String] wkt # @param [Integer] distance (meters) - # @return [String] - # !! This is intersecting + # @return [String] Shapes within distance of (i.e. whose + # distance-buffer intersects) wkt def intersecting_radius_of_wkt_sql(wkt, distance) - 'ST_DWithin(' \ - "(#{GeographicItem::GEOGRAPHY_SQL}), " \ - "ST_GeographyFromText('#{wkt}'), " \ - "#{distance}" \ - ')' + self.st_dwithin_sql( + "ST_GeographyFromText('#{wkt}')", + distance + ) end # @param [String] wkt @@ -527,7 +544,7 @@ def are_contained_in_item(shape, *geographic_items) # = containing when 'any_poly', 'any_line' part = [] SHAPE_TYPES.each { |shape| - shape = shape.to_s.downcase + shape = shape.to_s if shape.index(shape.gsub('any_', '')) part.push(GeographicItem.are_contained_in_item(shape, geographic_items).to_a) end From d91a3a47ae27bac1ad0db51d97f70fc86a378bd0 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Fri, 21 Jun 2024 20:27:26 -0500 Subject: [PATCH 035/259] #1954 Change GeographicItem.geometry_sql to items_as_one_geometry_sql --- app/models/geographic_item.rb | 26 ++++++++++++------- app/models/geographic_item/deprecated.rb | 2 +- .../geographic_item/anti_meridian_spec.rb | 4 +-- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 8cc7f8367a..76aec5aceb 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -139,7 +139,7 @@ class GeographicItem < ApplicationRecord class << self def st_union(geographic_item_scope) - GeographicItem.select("ST_Union(#{GeographicItem::GEOMETRY_SQL.to_sql}) as collection") + self.select("ST_Union(#{GeographicItem::GEOMETRY_SQL.to_sql}) as collection") .where(id: geographic_item_scope.pluck(:id)) end @@ -154,7 +154,9 @@ def within_sql(shape_sql) # Note: !! If the target GeographicItem#id crosses the anti-meridian then # you may/will get unexpected results. def within_union_of_sql(*geographic_item_ids) - within_sql("#{GeographicItem.geometry_sql2(*geographic_item_ids)}") + within_sql("#{ + self.items_as_one_geometry_sql(*geographic_item_ids) + }") end def within_union_of(*geographic_item_ids) @@ -174,7 +176,7 @@ def covering_sql(shape_sql) def covering_union_of(*geographic_item_ids) where( self.covering_sql( - GeographicItem.geometry_sql2(*geographic_item_ids) + self.items_as_one_geometry_sql(*geographic_item_ids) ) ) .not_ids(*geographic_item_ids) @@ -334,6 +336,7 @@ def containing_shape_sql(target_shape = nil, geographic_item_id = nil, return 'false' if geographic_item_id.nil? || source_shape.nil? || target_shape.nil? target_shape_sql = GeographicItem.shape_column_sql(target_shape) + 'ST_Contains(' \ "#{target_shape_sql}::geometry, " \ "(#{geometry_sql(geographic_item_id, source_shape)})" \ @@ -349,6 +352,7 @@ def reverse_containing_sql(target_shape = nil, geographic_item_id = nil, source_ return 'false' if geographic_item_id.nil? || source_shape.nil? || target_shape.nil? target_shape_sql = GeographicItem.shape_column_sql(target_shape) + 'ST_Contains(' \ "(#{geometry_sql(geographic_item_id, source_shape)}), " \ "#{target_shape_sql}::geometry" \ @@ -374,11 +378,15 @@ def single_geometry_sql(*geographic_item_ids) geographic_item_ids.flatten! q = ActiveRecord::Base.send(:sanitize_sql_for_conditions, [ "SELECT ST_Collect(f.the_geom) AS single_geometry - FROM ( - SELECT (ST_DUMP(#{GeographicItem::GEOMETRY_SQL.to_sql})).geom as the_geom - FROM geographic_items - WHERE id in (?)) - AS f", geographic_item_ids]) + FROM ( + SELECT ( + ST_DUMP(#{GeographicItem::GEOMETRY_SQL.to_sql}) + ).geom AS the_geom + FROM geographic_items + WHERE id in (?) + ) AS f", + geographic_item_ids + ]) '(' + q + ')' end @@ -388,7 +396,7 @@ def single_geometry_sql(*geographic_item_ids) # returns a single geometry "column" (paren wrapped) as # "single_geometry" for multiple geographic item ids, or the geometry # as 'geometry' for a single id - def geometry_sql2(*geographic_item_ids) + def items_as_one_geometry_sql(*geographic_item_ids) geographic_item_ids.flatten! # *ALWAYS* reduce the pile to a single level of ids if geographic_item_ids.count == 1 "(#{GeographicItem.geometry_for_sql(geographic_item_ids.first)})" diff --git a/app/models/geographic_item/deprecated.rb b/app/models/geographic_item/deprecated.rb index 0054900cc3..5bc5596994 100644 --- a/app/models/geographic_item/deprecated.rb +++ b/app/models/geographic_item/deprecated.rb @@ -86,7 +86,7 @@ def is_contained_by_sql(shape, geographic_item) # Result doesn't contain self. Much slower than containing_where_sql def containing_where_sql_geog(*geographic_item_ids) "ST_CoveredBy( - (#{GeographicItem.geometry_sql2(*geographic_item_ids)})::geography, + (#{GeographicItem.items_as_one_geometry(*geographic_item_ids)})::geography, #{GEOGRAPHY_SQL})" end diff --git a/spec/models/geographic_item/anti_meridian_spec.rb b/spec/models/geographic_item/anti_meridian_spec.rb index 8f39ed909b..b55145e315 100644 --- a/spec/models/geographic_item/anti_meridian_spec.rb +++ b/spec/models/geographic_item/anti_meridian_spec.rb @@ -336,14 +336,14 @@ before { build_structure } specify 'results from single non-meridian crossing polygon is found' do - # invokes geometry_sql2 + # invokes items_as_one_geometry # using contained_by_with_antimeridian_check is not harmful for non-crossing objects expect(GeographicItem.contained_by_with_antimeridian_check(western_box.id).map(&:id)) .to contain_exactly(point_in_western_box.id, western_box.id) end specify 'results from multiple non-meridian crossing polygons are found' do - # invokes geometry_sql2 + # invokes items_as_one_geometry # using contained_by_with_antimeridian_check is not harmful for non-crossing objects expect(GeographicItem.contained_by_with_antimeridian_check(eastern_box.id, western_box.id).map(&:id)) .to contain_exactly(point_in_eastern_box.id, From c46bf0fd808170facdedabad1461b6f262f56c49 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Fri, 21 Jun 2024 21:23:38 -0500 Subject: [PATCH 036/259] #1954 Deprecate more GeographicItem methods D'oh, I factored out st_distance_item_to_shape and then realized where I was using it was only used in specs. My feeling has been that it's better to select the geography column of a fixed geo_item from the database (once, as a subquery) rather that sending its geo_object, which could be "very" large, over the network and then having the database read it into memory. Thoughts? --- app/models/geographic_item.rb | 108 ++++++----------------- app/models/geographic_item/deprecated.rb | 93 +++++++++++++++++++ 2 files changed, 120 insertions(+), 81 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 76aec5aceb..b08c33d61f 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -620,38 +620,6 @@ def is_contained_by(shape, *geographic_items) # rubocop:enable Metrics/MethodLength - # @param [String, GeographicItem] - # @return [Scope] - def ordered_by_shortest_distance_from(shape, geographic_item) - select_distance_with_geo_object(shape, geographic_item) - .where_distance_greater_than_zero(shape, geographic_item) - .order('distance') - end - - # @param [String, GeographicItem] - # @return [Scope] - def ordered_by_longest_distance_from(shape, geographic_item) - select_distance_with_geo_object(shape, geographic_item) - .where_distance_greater_than_zero(shape, geographic_item) - .order('distance desc') - end - - # @param [String] shape - # @param [GeographicItem] geographic_item - # @return [String] - def select_distance_with_geo_object(shape, geographic_item) - shape_column = GeographicItem.shape_column_sql(shape) - select("*, ST_Distance(#{shape_column}, GeomFromEWKT('srid=4326;#{geographic_item.geo_object}')) AS distance") - end - - # @param [String, GeographicItem] - # @return [Scope] - def where_distance_greater_than_zero(shape, geographic_item) - shape_column = GeographicItem.shape_column_sql(shape) - where("#{shape_column} IS NOT NULL AND ST_Distance(#{shape_column}, " \ - "GeomFromEWKT('srid=4326;#{geographic_item.geo_object}')) > 0") - end - # @param [GeographicItem] # @return [Scope] def not_including(geographic_items) @@ -668,7 +636,7 @@ def not_including(geographic_items) def distance_between(geographic_item_id1, geographic_item_id2) q = 'ST_Distance(' \ "#{GeographicItem::GEOGRAPHY_SQL}, " \ - "(#{select_geography_sql(geographic_item_id2)}) " \ + "(#{self.select_geography_sql(geographic_item_id2)}) " \ ') as distance' GeographicItem.where(id: geographic_item_id1).pick(Arel.sql(q)) @@ -710,27 +678,6 @@ def eval_for_type(type_name) end retval end - - # example, not used - # @param [Integer] geographic_item_id - # @return [RGeo::Geographic object] - def geometry_for(geographic_item_id) - GeographicItem.select(GeographicItem::GEOMETRY_SQL.to_sql + ' AS geometry').find(geographic_item_id)['geometry'] - end - - # example, not used - # @param [Integer, Array] geographic_item_ids - # @return [Scope] - def st_multi(*geographic_item_ids) - GeographicItem.find_by_sql( - "SELECT ST_Multi(ST_Collect(g.the_geom)) AS singlegeom - FROM ( - SELECT (ST_DUMP(#{GeographicItem::GEOMETRY_SQL.to_sql})).geom AS the_geom - FROM geographic_items - WHERE id IN (?)) - AS g;", geographic_item_ids.flatten - ) - end end # class << self # @return [Hash] @@ -777,22 +724,25 @@ def geographic_name_hierarchy # @return [Scope] # the Geographic Areas that contain (gis) this geographic item def containing_geographic_areas - GeographicArea.joins(:geographic_items).includes(:geographic_area_type) - .joins("JOIN (#{GeographicItem.covering_union_of(id).to_sql}) j ON geographic_items.id = j.id") + GeographicArea + .joins(:geographic_items) + .includes(:geographic_area_type) + .joins( + "JOIN (#{GeographicItem.covering_union_of(id).to_sql}) AS j ON " \ + 'geographic_items.id = j.id' + ) end # @return [Boolean] # whether stored shape is ST_IsValid def valid_geometry? - GeographicItem.where(id:).select("ST_IsValid(ST_AsBinary(#{data_column})) is_valid").first['is_valid'] - end - - # @return [Array of latitude, longitude] - # the lat, lon of the first point in the GeoItem, see subclass for - # st_start_point - def start_point - o = st_start_point - [o.y, o.x] + GeographicItem + .where(id:) + .select( + 'ST_IsValid(' \ + "ST_AsBinary(#{data_column})" \ + ') AS is_valid' + ).first['is_valid'] end # @return [Array] @@ -813,7 +763,7 @@ def center_coords def centroid # Gis::FACTORY.point(*center_coords.reverse) return geo_object if geo_object_type == :point - return Gis::FACTORY.parse_wkt(self.st_centroid) + return Gis::FACTORY.parse_wkt(st_centroid) end # @param [GeographicItem] geographic_item @@ -852,12 +802,6 @@ def st_centroid GeographicItem.where(id:).pick(Arel.sql("ST_AsEWKT(ST_Centroid(#{GeographicItem::GEOMETRY_SQL.to_sql}))")).gsub(/SRID=\d*;/, '') end - # @return [Integer] - # the number of points in the geometry - def st_npoints - GeographicItem.where(id:).pick(Arel.sql("ST_NPoints(#{GeographicItem::GEOMETRY_SQL.to_sql}) as npoints")) - end - # !!TODO: migrate these to use native column calls # @param [geo_object] @@ -927,7 +871,7 @@ def shape=(value) begin geom = RGeo::GeoJSON.decode(value, json_parser: :json, geo_factory: Gis::FACTORY) rescue RGeo::Error::InvalidGeometry => e - errors.add(:base, "invalid geometry: #{e.to_s}") + errors.add(:base, "invalid geometry: #{e}") return end @@ -1007,8 +951,10 @@ def orientations FROM (SELECT id, #{column}::geometry AS p_geom FROM geographic_items where id = #{id}) AS b \ ) AS a;").collect{|a| a['is_ccw']} elsif (column = polygon_column) - ApplicationRecord.connection.execute("SELECT ST_IsPolygonCCW(#{column}::geometry) as is_ccw \ - FROM geographic_items where id = #{id};").collect{|a| a['is_ccw']} + ApplicationRecord.connection.execute( + "SELECT ST_IsPolygonCCW(#{column}::geometry) as is_ccw \ + FROM geographic_items where id = #{id};" + ).collect { |a| a['is_ccw'] } else [] end @@ -1031,7 +977,7 @@ def st_isvalid end def st_isvalidreason - r = ApplicationRecord.connection.execute( "SELECT ST_IsValidReason( #{GeographicItem::GEOMETRY_SQL.to_sql }) from geographic_items where geographic_items.id = #{id}").first['st_isvalidreason'] + ApplicationRecord.connection.execute( "SELECT ST_IsValidReason( #{GeographicItem::GEOMETRY_SQL.to_sql }) from geographic_items where geographic_items.id = #{id}").first['st_isvalidreason'] end # @return [Symbol, nil] @@ -1056,8 +1002,8 @@ def geo_object private # @return [Symbol] - # returns the attribute (column name) containing data - # nearly all methods should use #geo_object_type instead + # Returns the attribute (column name) containing data. + # Nearly all methods should use #geo_object_type instead. def data_column # This works before and after this item has been saved DATA_TYPES.each { |item| @@ -1076,8 +1022,9 @@ def multi_polygon_column # @param [String] shape, the type of shape you want # @return [String] - # A paren-wrapped SQL fragment for selecting the geography column - # containing shape. Returns the column named :shape if no shape is found. + # A paren-wrapped SQL fragment for selecting the column containing + # the given shape (e.g. a polygon). + # Returns the column named :shape if no shape is found. # !! This should probably never be called except to be put directly in a # raw ST_* statement as the parameter that matches some shape. def self.shape_column_sql(shape) @@ -1168,7 +1115,6 @@ def set_cached update_column(:cached_total_area, area) end - # @return [Boolean, String] false if already set, or type to which it was set def set_type_if_shape_column_present if type.blank? column = data_column diff --git a/app/models/geographic_item/deprecated.rb b/app/models/geographic_item/deprecated.rb index 5bc5596994..0b636c9f2b 100644 --- a/app/models/geographic_item/deprecated.rb +++ b/app/models/geographic_item/deprecated.rb @@ -6,6 +6,58 @@ module GeographicItem::Deprecated # class_methods do + + # DEPRECATED, used only in specs + def st_distance_item_to_shape(geographic_item_id, shape) + shape_column = GeographicItem.shape_column_sql(shape) + + 'ST_Distance(' \ + "#{shape_column}, " \ + "(#{self.select_geography_sql(geographic_item_id)})" \ + ')' + end + + # DEPRECATED, used only in specs + # @param [String, GeographicItem] + # @return [Scope] + def ordered_by_shortest_distance_from(shape, geographic_item) + select_distance_with_geo_object(shape, geographic_item) + .where_distance_greater_than_zero(shape, geographic_item) + .order('distance') + end + + # DEPRECATED, used only in specs + # @param [String, GeographicItem] + # @return [Scope] + def ordered_by_longest_distance_from(shape, geographic_item) + select_distance_with_geo_object(shape, geographic_item) + .where_distance_greater_than_zero(shape, geographic_item) + .order('distance desc') + end + + # DEPRECATED, used only in specs + # @param [String] shape + # @param [GeographicItem] geographic_item + # @return [String] + def select_distance_with_geo_object(shape, geographic_item) + select( + '*, ' \ + "#{self.st_distance_item_to_shape(geographic_item.id, shape)} " \ + ' AS distance' + ) + end + + # DEPRECATED, used only in specs + # @param [String, GeographicItem] + # @return [Scope] + def where_distance_greater_than_zero(shape, geographic_item) + shape_column = GeographicItem.shape_column_sql(shape) + where( + "#{shape_column} IS NOT NULL AND " \ + "#{self.st_distance_item_to_shape(geographic_item.id, shape)} > 0" + ) + end + # DEPRECATED def st_collect(geographic_item_scope) GeographicItem.select("ST_Collect(#{GeographicItem::GEOMETRY_SQL.to_sql}) as collection") @@ -137,6 +189,15 @@ def geometry_for_collection_sql(*geographic_item_ids) "( #{geographic_item_ids.join(',')} )" end + # DEPRECATED + # example, not used + # @param [Integer] geographic_item_id + # @return [RGeo::Geographic object] + def geometry_for(geographic_item_id) + select(GeographicItem::GEOMETRY_SQL.to_sql + ' AS geometry') + .find(geographic_item_id)['geometry'] + end + # DEPRECATED # @return [Scope] # adds an area_in_meters field, with meters @@ -213,6 +274,21 @@ def are_contained_in_wkt(shape, geometry) end end + # example, not used + # @param [Integer, Array] geographic_item_ids + # @return [Scope] + def st_multi(*geographic_item_ids) + # TODO why is ST_Multi here? + GeographicItem.find_by_sql( + "SELECT ST_Multi(ST_Collect(g.the_geom)) AS singlegeom + FROM ( + SELECT (ST_DUMP(#{GeographicItem::GEOMETRY_SQL.to_sql})).geom AS the_geom + FROM geographic_items + WHERE id IN (?)) + AS g;", geographic_item_ids.flatten + ) + end + end # class_methods # Used only in specs @@ -274,6 +350,13 @@ def has_polygons? ['GeographicItem::MultiPolygon', 'GeographicItem::Polygon'].include?(self.type) end + # DEPRECATED, used only in specs + # @return [Integer] + # the number of points in the geometry + def st_npoints + GeographicItem.where(id:).pick(Arel.sql("ST_NPoints(#{GeographicItem::GEOMETRY_SQL.to_sql}) as npoints")) + end + private # @param [RGeo::Point] point @@ -377,4 +460,14 @@ def multi_polygon_to_a(multi_polygon) def multi_polygon_to_hash(_multi_polygon) {polygons: to_a} end + + # DEPRECATED - subclass st_start_point methods are also unused + # @return [Array of latitude, longitude] + # the lat, lon of the first point in the GeoItem, see subclass for + # st_start_point + def start_point + o = st_start_point + [o.y, o.x] + end + # end private end \ No newline at end of file From 108803278cdd93e752775a328c0ec57cc2a53361 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sat, 22 Jun 2024 09:20:06 -0500 Subject: [PATCH 037/259] #1954 Move another outside use of GeographicItem internals to GeographicItem Note the external use was incorrect for the new geography column. --- app/models/cached_map_item.rb | 3 +-- app/models/collecting_event.rb | 2 +- app/models/geographic_item.rb | 15 +++++++++------ spec/models/geographic_item_spec.rb | 8 ++++---- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/app/models/cached_map_item.rb b/app/models/cached_map_item.rb index 8b0e38fd62..c1a9f077e9 100644 --- a/app/models/cached_map_item.rb +++ b/app/models/cached_map_item.rb @@ -168,11 +168,10 @@ def self.translate_by_spatial_overlap(geographic_item_id, data_origin, buffer) # aware of this assumption # This is a fast first pass, pure intersection - # TODO move this to geo item a = GeographicItem .joins(:geographic_areas_geographic_items) .where(geographic_areas_geographic_items: { data_origin: }) - .where( "ST_Intersects( multi_polygon, ( select #{ GeographicItem::GEOGRAPHY_SQL } from geographic_items where geographic_items.id = #{geographic_item_id}) )" ) + .merge(GeographicItem.intersecting(:multi_polygon, geographic_item_id)) .pluck(:id) return a if buffer.nil? diff --git a/app/models/collecting_event.rb b/app/models/collecting_event.rb index 588deaabe3..dca9b9b14f 100644 --- a/app/models/collecting_event.rb +++ b/app/models/collecting_event.rb @@ -660,7 +660,7 @@ def collecting_events_within_radius_of(distance) # @return [Scope] # Find all (other) CEs which have GIs or EGIs (through georeferences) which intersect self def collecting_events_intersecting_with - pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first).distinct + pieces = GeographicItem.with_collecting_event_through_georeferences.intersecting('any', self.geographic_items.first.id).distinct gr = [] # all collecting events for a geographic_item pieces.each { |o| diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index b08c33d61f..8d4551538b 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -499,24 +499,27 @@ def with_collecting_event_through_georeferences ).distinct end - # @param [String, GeographicItems] # @return [Scope] - def intersecting(shape, *geographic_items) + def intersecting(shape, *geographic_item_ids) shape = shape.to_s.downcase if shape == 'any' pieces = [] SHAPE_TYPES.each { |shape| - pieces.push(GeographicItem.intersecting(shape, geographic_items).to_a) + pieces.push( + GeographicItem.intersecting(shape, geographic_item_ids).to_a + ) } # @TODO change 'id in (?)' to some other sql construct GeographicItem.where(id: pieces.flatten.map(&:id)) else shape_column = GeographicItem.shape_column_sql(shape) - q = geographic_items.flatten.collect { |geographic_item| + q = geographic_item_ids.flatten.collect { |geographic_item_id| # seems like we want this: http://danshultz.github.io/talks/mastering_activerecord_arel/#/15/2 - # TODO would geometry intersect be equivalent and faster? - "ST_Intersects(#{shape_column}, '#{geographic_item.geo_object}')" + 'ST_Intersects(' \ + "#{shape_column}::geometry, " \ + "(#{self.select_geometry_sql(geographic_item_id)})" \ + ')' }.join(' or ') where(q) diff --git a/spec/models/geographic_item_spec.rb b/spec/models/geographic_item_spec.rb index f1c3d2b6ea..2b9179bc79 100644 --- a/spec/models/geographic_item_spec.rb +++ b/spec/models/geographic_item_spec.rb @@ -961,11 +961,11 @@ end specify "::intersecting list of objects (uses 'or')" do - expect(GeographicItem.intersecting('polygon', [l])).to eq([k]) + expect(GeographicItem.intersecting('polygon', [l.id])).to eq([k]) end specify "::intersecting list of objects (uses 'or')" do - expect(GeographicItem.intersecting('polygon', [f1])) + expect(GeographicItem.intersecting('polygon', [f1.id])) .to eq([]) # Is this right? end @@ -1991,11 +1991,11 @@ end specify "::intersecting list of objects (uses 'or')" do - expect(GeographicItem.intersecting('polygon', [l])).to eq([k]) + expect(GeographicItem.intersecting('polygon', [l.id])).to eq([k]) end specify "::intersecting list of objects (uses 'or')" do - expect(GeographicItem.intersecting('polygon', [f1])) + expect(GeographicItem.intersecting('polygon', [f1.id])) .to eq([]) # Is this right? end From abb4722f7d917843fa42b1eeeeba356f34f1319d Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sat, 22 Jun 2024 09:41:40 -0500 Subject: [PATCH 038/259] #1954 Move a couple more GEOMETRY_SQL uses into GeographicItem There are two left that I'm not going to move --- app/helpers/collecting_events_helper.rb | 4 ++-- app/models/geographic_item.rb | 4 ++++ lib/gis/geo_json.rb | 5 ++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/helpers/collecting_events_helper.rb b/app/helpers/collecting_events_helper.rb index 0de6918a17..51ba7fe137 100644 --- a/app/helpers/collecting_events_helper.rb +++ b/app/helpers/collecting_events_helper.rb @@ -249,8 +249,8 @@ def collecting_event_to_simple_json_feature(collecting_event) if collecting_event.geographic_items.any? geo_item_id = collecting_event.geographic_items.select(:id).first.id - query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json" - base['geometry'] = JSON.parse(GeographicItem.select(query).find(geo_item_id).geo_json) + base['geometry'] = + JSON.parse(GeographicItem.st_asgeojson.find(geo_item_id).geo_json) end base end diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 8d4551538b..d1bde23b36 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -213,6 +213,10 @@ def st_dwithin_sql(shape_sql, distance) ')' end + def st_asgeojson + select("ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}) geo_json") + end + # @param [String] wkt # @return [Boolean] # whether or not the wkt intersects with the anti-meridian diff --git a/lib/gis/geo_json.rb b/lib/gis/geo_json.rb index b16fd7d0ee..6f0657fb93 100644 --- a/lib/gis/geo_json.rb +++ b/lib/gis/geo_json.rb @@ -70,12 +70,11 @@ def self.feature(object) def self.quick_geo_json_string(geographic_item_id) return nil if geographic_item_id.nil? - query = "ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}::geometry) geo_json_str" a = GeographicItem.where(id: geographic_item_id) - .select(query) + .st_asgeojson .limit(1) ::GeographicItem.connection.select_all(a.to_sql) - .first['geo_json_str'] + .first['geo_json'] end # @return [GeoJSON] content for geometry From 767c3cfbc2c62953e076850d31a0f5e845ae0832 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sat, 22 Jun 2024 10:26:16 -0500 Subject: [PATCH 039/259] #1954 !!My be breaking!! - Clarify more containing/covering/coveredby usage in GeographicItem Use covering instead of contains since "Generally this function [ST_Covers] should be used instead of ST_Contains, since it has a simpler definition which does not have the quirk that "geometries do not contain their boundary". In other words, vertics/points in the boundary of a polygon are not contained in the polygon (but are covered by the polygon), points don't contain themselves (but do cover themselves). Previously there was a mix of ST_Covers and ST_Contains in GeographicItem.rb, so I'm not sure if the behavior of ST_Contains was specifically desired? If so, why and in what cases? Which one should I use in new code here? THIS COULD BE A SUBTLE BREAKING CHANGE. One of the specs added one additional intersecting point because it was on the edge of one of the spec's context polygons - which wouldn't have counted before the change! The point was introduced indirectly in a parent context because it's the geoitem of a georeference included there - that's separate from the variables introduced and used in the spec's immediate context, so that seems a little sketchy to me - nonetheless it does end up testing the edge case! If ST_Cover is intended in general then there are other places where it could be changed. This also changes a couple method names that I think were backwards relative to the ST_ naming convention, namely ST_X(A, B) means A X B, not B X A. --- .../gis/match_georeference_controller.rb | 4 +- app/models/collecting_event.rb | 4 +- app/models/collection_object.rb | 2 +- app/models/geographic_area.rb | 6 +- app/models/geographic_item.rb | 116 +++++++++--------- app/models/geographic_item/deprecated.rb | 6 +- lib/queries/asserted_distribution/filter.rb | 4 +- lib/queries/collecting_event/filter.rb | 2 +- .../geographic_item/anti_meridian_spec.rb | 2 +- spec/models/geographic_item_spec.rb | 82 +++++++------ 10 files changed, 116 insertions(+), 112 deletions(-) diff --git a/app/controllers/tasks/gis/match_georeference_controller.rb b/app/controllers/tasks/gis/match_georeference_controller.rb index f5fee75445..364894e66c 100644 --- a/app/controllers/tasks/gis/match_georeference_controller.rb +++ b/app/controllers/tasks/gis/match_georeference_controller.rb @@ -70,7 +70,7 @@ def drawn_collecting_events when 'polygon' @collecting_events = CollectingEvent.with_project_id(sessions_current_project_id) .joins(:geographic_items) - .where(GeographicItem.contained_by_wkt_sql(geometry)) + .where(GeographicItem.convered_by_wkt_sql(geometry)) else end end @@ -143,7 +143,7 @@ def drawn_georeferences when 'polygon' @georeferences = Georeference.with_project_id(sessions_current_project_id) .joins(:geographic_item) - .where(GeographicItem.contained_by_wkt_sql(geometry)) + .where(GeographicItem.covered_by_wkt_sql(geometry)) else end if @georeferences.blank? diff --git a/app/models/collecting_event.rb b/app/models/collecting_event.rb index dca9b9b14f..d611746c3f 100644 --- a/app/models/collecting_event.rb +++ b/app/models/collecting_event.rb @@ -822,7 +822,7 @@ def containing_geographic_items # # Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set # !! and there was no tests broken - # GeographicItem.are_contained_in_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq + # GeographicItem.st_covers_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq gi_list = GeographicItem .covering_union_of(*geographic_items.pluck(:id)) .pluck(:id) @@ -833,7 +833,7 @@ def containing_geographic_items unless self.geographic_area.nil? # unless self.geographic_area.geographic_items.empty? # we need to use the geographic_area directly - gi_list = GeographicItem.are_contained_in_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq + gi_list = GeographicItem.st_covers_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq # end end end diff --git a/app/models/collection_object.rb b/app/models/collection_object.rb index 61757e017d..454d41063c 100644 --- a/app/models/collection_object.rb +++ b/app/models/collection_object.rb @@ -332,7 +332,7 @@ def self.in_geographic_item(geographic_item, limit, steps = false) if steps gi = GeographicItem.find(geographic_item_id) # find the geographic_items inside gi - step_1 = GeographicItem.is_contained_by('any', gi) # .pluck(:id) + step_1 = GeographicItem.st_coveredby_item('any', gi) # .pluck(:id) # find the georeferences from the geographic_items step_2 = step_1.map(&:georeferences).uniq.flatten # find the collecting events connected to the georeferences diff --git a/app/models/geographic_area.rb b/app/models/geographic_area.rb index 07b81483fb..bca0c4979c 100644 --- a/app/models/geographic_area.rb +++ b/app/models/geographic_area.rb @@ -185,7 +185,7 @@ def self.parent scope :ordered_by_area, -> (direction = :ASC) { joins(:geographic_items).order("geographic_items.cached_total_area #{direction || 'ASC'}") } - # Based strictly on the original data recording a level ID, + # Based strictly on the original data recording a level ID, # this is *inferrence* and it will fail with some data. def self.inferred_as_country where('geographic_areas.level0_id = geographic_areas.id') @@ -244,7 +244,7 @@ def self.countries def self.is_contained_by(geographic_area) pieces = nil if geographic_area.geographic_items.any? - pieces = GeographicItem.is_contained_by('any_poly', geographic_area.geo_object) + pieces = GeographicItem.st_coveredby_item('any_poly', geographic_area.geo_object) others = [] pieces.each { |other| others.push(other.geographic_areas.to_a) @@ -259,7 +259,7 @@ def self.is_contained_by(geographic_area) def self.are_contained_in(geographic_area) pieces = nil if geographic_area.geographic_items.any? - pieces = GeographicItem.are_contained_in_item('any_poly', geographic_area.geo_object) + pieces = GeographicItem.st_covers_item('any_poly', geographic_area.geo_object) others = [] pieces.each { |other| others.push(other.geographic_areas.to_a) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index d1bde23b36..0d28e1cd9e 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -143,7 +143,7 @@ def st_union(geographic_item_scope) .where(id: geographic_item_scope.pluck(:id)) end - # True for those shapes that are contained within the shape_sql shape. + # True for those shapes that are within (subsets of) the shape_sql shape. def within_sql(shape_sql) 'ST_Covers(' \ "#{shape_sql}, " \ @@ -323,7 +323,7 @@ def intersecting_radius_of_wkt_sql(wkt, distance) # @param [String] wkt # @param [Integer] distance (meters) - # @return [String] Those items contained in the distance-buffer of wkt + # @return [String] Those items within the distance-buffer of wkt def within_radius_of_wkt(wkt, distance) where(self.within_sql( "ST_Buffer(ST_GeographyFromText('#{wkt}'), #{distance})" @@ -332,16 +332,16 @@ def within_radius_of_wkt(wkt, distance) # @param [String, Integer, String] # @return [String] - # a SQL fragment for ST_Contains() function, returns - # all geographic items whose target_shape contains the item supplied's + # a SQL fragment for ST_Covers() function, returns + # all geographic items whose target_shape covers the item supplied's # source_shape - def containing_shape_sql(target_shape = nil, geographic_item_id = nil, + def st_covers_sql(target_shape = nil, geographic_item_id = nil, source_shape = nil) return 'false' if geographic_item_id.nil? || source_shape.nil? || target_shape.nil? target_shape_sql = GeographicItem.shape_column_sql(target_shape) - 'ST_Contains(' \ + 'ST_Covers(' \ "#{target_shape_sql}::geometry, " \ "(#{geometry_sql(geographic_item_id, source_shape)})" \ ')' @@ -349,17 +349,17 @@ def containing_shape_sql(target_shape = nil, geographic_item_id = nil, # @param [String, Integer, String] # @return [String] - # a SQL fragment for ST_Contains() function, returns - # all geographic items whose target_shape is contained in the item + # a SQL fragment for ST_Covers() function, returns + # all geographic items whose target_shape is covered by the item # supplied's source_shape - def reverse_containing_sql(target_shape = nil, geographic_item_id = nil, source_shape = nil) + def st_coveredby_sql(target_shape = nil, geographic_item_id = nil, source_shape = nil) return 'false' if geographic_item_id.nil? || source_shape.nil? || target_shape.nil? target_shape_sql = GeographicItem.shape_column_sql(target_shape) - 'ST_Contains(' \ - "(#{geometry_sql(geographic_item_id, source_shape)}), " \ - "#{target_shape_sql}::geometry" \ + 'ST_CoveredBy(' \ + "#{target_shape_sql}::geometry, " \ + "(#{geometry_sql(geographic_item_id, source_shape)})" \ ')' end @@ -415,36 +415,31 @@ def items_as_one_geometry_sql(*geographic_item_ids) # Note: this routine is called when it is already known that the A # argument crosses anti-meridian # TODO If wkt coords are in the range 0..360 and GI coords are in the range -180..180 (or vice versa), doesn't this fail? Don't you want all coords in the range 0..360 in this geometry case? Is there any assumption about range of inputs for georefs, e.g.? are they always normalized? See anti-meridian spec? - # TODO rename within? - def contained_by_wkt_shifted_sql(wkt) - "ST_Contains(ST_ShiftLongitude(ST_GeomFromText('#{wkt}', 4326)), ( - CASE geographic_items.type - WHEN 'GeographicItem::MultiPolygon' THEN ST_ShiftLongitude(multi_polygon::geometry) - WHEN 'GeographicItem::Point' THEN ST_ShiftLongitude(point::geometry) - WHEN 'GeographicItem::LineString' THEN ST_ShiftLongitude(line_string::geometry) - WHEN 'GeographicItem::Polygon' THEN ST_ShiftLongitude(polygon::geometry) - WHEN 'GeographicItem::MultiLineString' THEN ST_ShiftLongitude(multi_line_string::geometry) - WHEN 'GeographicItem::MultiPoint' THEN ST_ShiftLongitude(multi_point::geometry) - WHEN 'GeographicItem::GeometryCollection' THEN ST_ShiftLongitude(geometry_collection::geometry) - WHEN 'GeographicItem::Geography' THEN ST_ShiftLongitude(geography::geometry) - END - ) - )" + def covered_by_wkt_shifted_sql(wkt) + "ST_CoveredBy( + (CASE geographic_items.type + WHEN 'GeographicItem::MultiPolygon' THEN ST_ShiftLongitude(multi_polygon::geometry) + WHEN 'GeographicItem::Point' THEN ST_ShiftLongitude(point::geometry) + WHEN 'GeographicItem::LineString' THEN ST_ShiftLongitude(line_string::geometry) + WHEN 'GeographicItem::Polygon' THEN ST_ShiftLongitude(polygon::geometry) + WHEN 'GeographicItem::MultiLineString' THEN ST_ShiftLongitude(multi_line_string::geometry) + WHEN 'GeographicItem::MultiPoint' THEN ST_ShiftLongitude(multi_point::geometry) + WHEN 'GeographicItem::GeometryCollection' THEN ST_ShiftLongitude(geometry_collection::geometry) + WHEN 'GeographicItem::Geography' THEN ST_ShiftLongitude(geography::geometry) + END), + ST_ShiftLongitude(ST_GeomFromText('#{wkt}', 4326)), + )" end # TODO: Remove the hard coded 4326 reference # @params [String] wkt # @return [String] SQL fragment limiting geographic items to those - # contained by this WKT - def contained_by_wkt_sql(wkt) + # covered by this WKT + def covered_by_wkt_sql(wkt) if crosses_anti_meridian?(wkt) - contained_by_wkt_shifted_sql(wkt) + covered_by_wkt_shifted_sql(wkt) else - # TODO should probably be ST_Covers? Then use covering_sql - 'ST_Contains(' \ - "ST_GeomFromText('#{wkt}', 4326), " \ - "#{GEOMETRY_SQL.to_sql}" \ - ')' + self.within_sql("ST_GeomFromText('#{wkt}', 4326)") end end @@ -461,9 +456,9 @@ def geometry_for_sql(geographic_item_id) # @param [RGeo::Point] rgeo_point # @return [Scope] - # the geographic items containing this point - # TODO: should be containing_wkt ? - def containing_point(rgeo_point) + # the geographic items covering this point + # TODO: should be covering_wkt ? + def covering_point(rgeo_point) where( self.covering_sql("ST_GeomFromText('#{rgeo_point}', 4326)") ) @@ -534,24 +529,23 @@ def intersecting(shape, *geographic_item_ids) # @param [String] shape to search # @param [GeographicItem] geographic_items or array of geographic_items # to be tested. - # @return [Scope] of GeographicItems that contain at least one of + # @return [Scope] of GeographicItems that cover at least one of # geographic_items # # If this scope is given an Array of GeographicItems as a second parameter, # it will return the 'OR' of each of the objects against the table. # SELECT COUNT(*) FROM "geographic_items" - # WHERE (ST_Contains(polygon::geometry, GeomFromEWKT('srid=4326;POINT (0.0 0.0 0.0)')) - # OR ST_Contains(polygon::geometry, GeomFromEWKT('srid=4326;POINT (-9.8 5.0 0.0)'))) + # WHERE (ST_Covers(polygon::geometry, GeomFromEWKT('srid=4326;POINT (0.0 0.0 0.0)')) + # OR ST_Covers(polygon::geometry, GeomFromEWKT('srid=4326;POINT (-9.8 5.0 0.0)'))) # - # TODO rename in st_ style (I think it's backwards now?) - def are_contained_in_item(shape, *geographic_items) # = containing + def st_covers_item(shape, *geographic_items) geographic_items.flatten! # in case there is a array of arrays, or multiple objects shape = shape.to_s.downcase case shape when 'any' part = [] SHAPE_TYPES.each { |shape| - part.push(GeographicItem.are_contained_in_item(shape, geographic_items).to_a) + part.push(GeographicItem.st_covers_item(shape, geographic_items).to_a) } # TODO: change 'id in (?)' to some other sql construct GeographicItem.where(id: part.flatten.map(&:id)) @@ -561,7 +555,7 @@ def are_contained_in_item(shape, *geographic_items) # = containing SHAPE_TYPES.each { |shape| shape = shape.to_s if shape.index(shape.gsub('any_', '')) - part.push(GeographicItem.are_contained_in_item(shape, geographic_items).to_a) + part.push(GeographicItem.st_covers_item(shape, geographic_items).to_a) end } # TODO: change 'id in (?)' to some other sql construct @@ -569,7 +563,7 @@ def are_contained_in_item(shape, *geographic_items) # = containing else q = geographic_items.flatten.collect { |geographic_item| - GeographicItem.containing_shape_sql( + GeographicItem.st_covers_sql( shape, geographic_item.id, geographic_item.geo_object_type @@ -593,15 +587,15 @@ def are_contained_in_item(shape, *geographic_items) # = containing #'multi_line_string'. # @param geographic_items [GeographicItem] Can be a single # GeographicItem, or an array of GeographicItem. - # @return [Scope] of all GeographicItems of the given shape contained - # in one or more of geographic_items - def is_contained_by(shape, *geographic_items) + # @return [Scope] of all GeographicItems of the given shape covered by + # one or more of geographic_items + def st_coveredby_item(shape, *geographic_items) shape = shape.to_s.downcase case shape when 'any' part = [] SHAPE_TYPES.each { |shape| - part.push(GeographicItem.is_contained_by(shape, geographic_items).to_a) + part.push(GeographicItem.st_coveredby_item(shape, geographic_items).to_a) } # @TODO change 'id in (?)' to some other sql construct GeographicItem.where(id: part.flatten.map(&:id)) @@ -609,8 +603,9 @@ def is_contained_by(shape, *geographic_items) when 'any_poly', 'any_line' part = [] SHAPE_TYPES.each { |shape| - if shape.to_s.index(shape.gsub('any_', '')) - part.push(GeographicItem.is_contained_by(shape, geographic_items).to_a) + shape = shape.to_s + if shape.index(shape.gsub('any_', '')) + part.push(GeographicItem.st_coveredby_item(shape, geographic_items).to_a) end } # @TODO change 'id in (?)' to some other sql construct @@ -618,8 +613,11 @@ def is_contained_by(shape, *geographic_items) else q = geographic_items.flatten.collect { |geographic_item| - GeographicItem.reverse_containing_sql(shape, geographic_item.to_param, - geographic_item.geo_object_type) + GeographicItem.st_coveredby_sql( + shape, + geographic_item.to_param, + geographic_item.geo_object_type + ) }.join(' or ') where(q) # .not_including(geographic_items) end @@ -654,7 +652,7 @@ def distance_between(geographic_item_id1, geographic_item_id2) # as per #inferred_geographic_name_hierarchy but for Rgeo point def point_inferred_geographic_name_hierarchy(point) self - .containing_point(point) + .covering_point(point) .order(cached_total_area: :ASC) .first&.inferred_geographic_name_hierarchy end @@ -706,11 +704,11 @@ def quick_geographic_name_hierarchy # @return [Hash] # a geographic_name_classification (see GeographicArea) inferred by - # finding the smallest area containing this GeographicItem, in the most accurate gazetteer + # finding the smallest area covering this GeographicItem, in the most accurate gazetteer # and using it to return country/state/county. See also the logic in # filling in missing levels in GeographicArea. def inferred_geographic_name_hierarchy - if small_area = containing_geographic_areas + if small_area = covering_geographic_areas .joins(:geographic_areas_geographic_items) .merge(GeographicAreasGeographicItem.ordered_by_data_origin) .ordered_by_area @@ -729,8 +727,8 @@ def geographic_name_hierarchy end # @return [Scope] - # the Geographic Areas that contain (gis) this geographic item - def containing_geographic_areas + # the Geographic Areas that cover (gis) this geographic item + def covering_geographic_areas GeographicArea .joins(:geographic_items) .includes(:geographic_area_type) diff --git a/app/models/geographic_item/deprecated.rb b/app/models/geographic_item/deprecated.rb index 0b636c9f2b..5cc0b6128a 100644 --- a/app/models/geographic_item/deprecated.rb +++ b/app/models/geographic_item/deprecated.rb @@ -104,7 +104,7 @@ def crosses_anti_meridian_by_id?(*ids) # @param [GeographicItem] geographic_item # @return [String] of SQL for all GeographicItems of the given shape # contained by geographic_item - def is_contained_by_sql(shape, geographic_item) + def is_covered_by_sql(shape, geographic_item) template = "(ST_Contains(#{geographic_item.geo_object}, %s::geometry))" retval = [] shape = shape.to_s.downcase @@ -170,8 +170,8 @@ def contained_by_with_antimeridian_check(*ids) q1 = ActiveRecord::Base.send(:sanitize_sql_array, ['SELECT ST_AsText((SELECT polygon FROM geographic_items ' \ 'WHERE id = ?))', id]) r = GeographicItem.where( - # GeographicItem.contained_by_wkt_shifted_sql(GeographicItem.find(id).geo_object.to_s) - GeographicItem.contained_by_wkt_shifted_sql( + # GeographicItem.covered_by_wkt_shifted_sql(GeographicItem.find(id).geo_object.to_s) + GeographicItem.covered_by_wkt_shifted_sql( ApplicationRecord.connection.execute(q1).first['st_astext']) ).to_a results.push(r) diff --git a/lib/queries/asserted_distribution/filter.rb b/lib/queries/asserted_distribution/filter.rb index 30d918ae98..c6a3547e2b 100644 --- a/lib/queries/asserted_distribution/filter.rb +++ b/lib/queries/asserted_distribution/filter.rb @@ -130,7 +130,7 @@ def wkt_facet end def from_wkt(wkt_shape) - i = ::GeographicItem.joins(:geographic_areas).where(::GeographicItem.contained_by_wkt_sql(wkt_shape)) + i = ::GeographicItem.joins(:geographic_areas).where(::GeographicItem.covered_by_wkt_sql(wkt_shape)) j = ::GeographicArea.joins(:geographic_items).where(geographic_items: i) k = ::GeographicArea.descendants_of(j) # Add children that might not be caught because they don't have a shapes @@ -173,7 +173,7 @@ def spatial_query # TODO test this ::GeographicItem.joins(:geographic_areas).within_radius_of_wkt_sql(geometry.to_s, radius ) when 'Polygon', 'MultiPolygon' - ::GeographicItem.joins(:geographic_areas).where(::GeographicItem.contained_by_wkt_sql(geometry.to_s)) + ::GeographicItem.joins(:geographic_areas).where(::GeographicItem.covered_by_wkt_sql(geometry.to_s)) else nil end diff --git a/lib/queries/collecting_event/filter.rb b/lib/queries/collecting_event/filter.rb index 7927bb5f38..ad23a1a92d 100644 --- a/lib/queries/collecting_event/filter.rb +++ b/lib/queries/collecting_event/filter.rb @@ -326,7 +326,7 @@ def spatial_query(geometry_type, wkt) when 'Polygon', 'MultiPolygon' ::CollectingEvent .joins(:geographic_items) - .where(::GeographicItem.contained_by_wkt_sql(wkt)) + .where(::GeographicItem.covered_by_wkt_sql(wkt)) else nil end diff --git a/spec/models/geographic_item/anti_meridian_spec.rb b/spec/models/geographic_item/anti_meridian_spec.rb index b55145e315..e51f99fba5 100644 --- a/spec/models/geographic_item/anti_meridian_spec.rb +++ b/spec/models/geographic_item/anti_meridian_spec.rb @@ -373,7 +373,7 @@ specify 'shifting an already shifted polygon has no effect' do shifted_wkt = eastern_box.geo_object.to_s expect(shifted_wkt =~ /-/).to be_falsey - expect(GeographicItem.where(GeographicItem.contained_by_wkt_sql(shifted_wkt)).map(&:id)) + expect(GeographicItem.where(GeographicItem.covered_by_wkt_sql(shifted_wkt)).map(&:id)) .to contain_exactly(point_in_eastern_box.id, eastern_box.id) end end diff --git a/spec/models/geographic_item_spec.rb b/spec/models/geographic_item_spec.rb index 2b9179bc79..4d6e39c299 100644 --- a/spec/models/geographic_item_spec.rb +++ b/spec/models/geographic_item_spec.rb @@ -679,65 +679,65 @@ # OR! specify 'three things inside and one thing outside k' do - expect(GeographicItem.are_contained_in_item('polygon', + expect(GeographicItem.st_covers_item('polygon', [p1, p2, p3, p11]).to_a) .to contain_exactly(e1, k) end # OR! specify 'one thing inside one thing, and another thing inside another thing' do - expect(GeographicItem.are_contained_in_item('polygon', + expect(GeographicItem.st_covers_item('polygon', [p1, p11]).to_a) .to contain_exactly(e1, k) end specify 'one thing inside k' do - expect(GeographicItem.are_contained_in_item('polygon', p1).to_a).to eq([k]) + expect(GeographicItem.st_covers_item('polygon', p1).to_a).to eq([k]) end specify 'three things inside k (in array)' do - expect(GeographicItem.are_contained_in_item('polygon', + expect(GeographicItem.st_covers_item('polygon', [p1, p2, p3]).to_a) .to eq([k]) end specify 'three things inside k (as separate parameters)' do - expect(GeographicItem.are_contained_in_item('polygon', p1, + expect(GeographicItem.st_covers_item('polygon', p1, p2, p3).to_a) .to eq([k]) end specify 'one thing outside k' do - expect(GeographicItem.are_contained_in_item('polygon', p4).to_a) + expect(GeographicItem.st_covers_item('polygon', p4).to_a) .to eq([]) end specify ' one thing inside two things (overlapping)' do - expect(GeographicItem.are_contained_in_item('polygon', p12).to_a.sort) + expect(GeographicItem.st_covers_item('polygon', p12).to_a.sort) .to contain_exactly(e1, e2) end specify 'three things inside and one thing outside k' do - expect(GeographicItem.are_contained_in_item('polygon', + expect(GeographicItem.st_covers_item('polygon', [p1, p2, p3, p11]).to_a) .to contain_exactly(e1, k) end specify 'one thing inside one thing, and another thing inside another thing' do - expect(GeographicItem.are_contained_in_item('polygon', + expect(GeographicItem.st_covers_item('polygon', [p1, p11]).to_a) .to contain_exactly(e1, k) end specify 'two things inside one thing, and (1)' do - expect(GeographicItem.are_contained_in_item('polygon', p18).to_a) + expect(GeographicItem.st_covers_item('polygon', p18).to_a) .to contain_exactly(b1, b2) end specify 'two things inside one thing, and (2)' do - expect(GeographicItem.are_contained_in_item('polygon', p19).to_a) + expect(GeographicItem.st_covers_item('polygon', p19).to_a) .to contain_exactly(b1, b) end end @@ -759,37 +759,40 @@ before { [b, p0, p1, p2, p3, p11, p12, p13, p18, p19].each } specify ' three things inside k' do - expect(GeographicItem.is_contained_by('any', k).not_including(k).to_a) + expect(GeographicItem.st_coveredby_item('any', k).not_including(k).to_a) .to contain_exactly(p1, p2, p3) end specify 'one thing outside k' do - expect(GeographicItem.is_contained_by('any', p4).not_including(p4).to_a).to eq([]) + expect(GeographicItem.st_coveredby_item('any', p4).not_including(p4).to_a).to eq([]) end specify 'three things inside and one thing outside k' do - pieces = GeographicItem.is_contained_by('any', + pieces = GeographicItem.st_coveredby_item('any', [e2, k]).not_including([k, e2]).to_a - expect(pieces).to contain_exactly(p0, p1, p2, p3, p12, p13) # , @p12c + # p_a just happens to be in context because it happens to be the + # GeographicItem of the Georeference g_a defined in an outer + # context + expect(pieces).to contain_exactly(p0, p1, p2, p3, p12, p13, p_a) # , @p12c end # other objects are returned as well, we just don't care about them: # we want to find p1 inside K, and p11 inside e1 specify 'one specific thing inside one thing, and another specific thing inside another thing' do - expect(GeographicItem.is_contained_by('any', + expect(GeographicItem.st_coveredby_item('any', [e1, k]).to_a) .to include(p1, p11) end specify 'one thing (p19) inside a polygon (b) with interior, and another inside ' \ 'the interior which is NOT included (p18)' do - expect(GeographicItem.is_contained_by('any', b).not_including(b).to_a).to eq([p19]) + expect(GeographicItem.st_coveredby_item('any', b).not_including(b).to_a).to eq([p19]) end specify 'three things inside two things. Notice that the outer ring of b ' \ 'is co-incident with b1, and thus "contained".' do - expect(GeographicItem.is_contained_by('any', + expect(GeographicItem.st_coveredby_item('any', [b1, b2]).not_including([b1, b2]).to_a) .to contain_exactly(p18, p19, b) end @@ -797,7 +800,7 @@ # other objects are returned as well, we just don't care about them # we want to find p19 inside b and b1, but returned only once specify 'both b and b1 contain p19, which gets returned only once' do - expect(GeographicItem.is_contained_by('any', + expect(GeographicItem.st_coveredby_item('any', [b1, b]).to_a) .to include(p19) end @@ -1709,65 +1712,65 @@ # OR! specify 'three things inside and one thing outside k' do - expect(GeographicItem.are_contained_in_item('polygon', + expect(GeographicItem.st_covers_item('polygon', [p1, p2, p3, p11]).to_a) .to contain_exactly(e1, k) end # OR! specify 'one thing inside one thing, and another thing inside another thing' do - expect(GeographicItem.are_contained_in_item('polygon', + expect(GeographicItem.st_covers_item('polygon', [p1, p11]).to_a) .to contain_exactly(e1, k) end specify 'one thing inside k' do - expect(GeographicItem.are_contained_in_item('polygon', p1).to_a).to eq([k]) + expect(GeographicItem.st_covers_item('polygon', p1).to_a).to eq([k]) end specify 'three things inside k (in array)' do - expect(GeographicItem.are_contained_in_item('polygon', + expect(GeographicItem.st_covers_item('polygon', [p1, p2, p3]).to_a) .to eq([k]) end specify 'three things inside k (as separate parameters)' do - expect(GeographicItem.are_contained_in_item('polygon', p1, + expect(GeographicItem.st_covers_item('polygon', p1, p2, p3).to_a) .to eq([k]) end specify 'one thing outside k' do - expect(GeographicItem.are_contained_in_item('polygon', p4).to_a) + expect(GeographicItem.st_covers_item('polygon', p4).to_a) .to eq([]) end specify ' one thing inside two things (overlapping)' do - expect(GeographicItem.are_contained_in_item('polygon', p12).to_a.sort) + expect(GeographicItem.st_covers_item('polygon', p12).to_a.sort) .to contain_exactly(e1, e2) end specify 'three things inside and one thing outside k' do - expect(GeographicItem.are_contained_in_item('polygon', + expect(GeographicItem.st_covers_item('polygon', [p1, p2, p3, p11]).to_a) .to contain_exactly(e1, k) end specify 'one thing inside one thing, and another thing inside another thing' do - expect(GeographicItem.are_contained_in_item('polygon', + expect(GeographicItem.st_covers_item('polygon', [p1, p11]).to_a) .to contain_exactly(e1, k) end specify 'two things inside one thing, and (1)' do - expect(GeographicItem.are_contained_in_item('polygon', p18).to_a) + expect(GeographicItem.st_covers_item('polygon', p18).to_a) .to contain_exactly(b1, b2) end specify 'two things inside one thing, and (2)' do - expect(GeographicItem.are_contained_in_item('polygon', p19).to_a) + expect(GeographicItem.st_covers_item('polygon', p19).to_a) .to contain_exactly(b1, b) end end @@ -1789,37 +1792,40 @@ before { [b, p0, p1, p2, p3, p11, p12, p13, p18, p19].each } specify ' three things inside k' do - expect(GeographicItem.is_contained_by('any', k).not_including(k).to_a) + expect(GeographicItem.st_coveredby_item('any', k).not_including(k).to_a) .to contain_exactly(p1, p2, p3) end specify 'one thing outside k' do - expect(GeographicItem.is_contained_by('any', p4).not_including(p4).to_a).to eq([]) + expect(GeographicItem.st_coveredby_item('any', p4).not_including(p4).to_a).to eq([]) end specify 'three things inside and one thing outside k' do - pieces = GeographicItem.is_contained_by('any', + pieces = GeographicItem.st_coveredby_item('any', [e2, k]).not_including([k, e2]).to_a - expect(pieces).to contain_exactly(p0, p1, p2, p3, p12, p13) # , @p12c + # p_a just happens to be in context because it happens to be the + # GeographicItem of the Georeference g_a defined in an outer + # context + expect(pieces).to contain_exactly(p0, p1, p2, p3, p12, p13, p_a) # , @p12c end # other objects are returned as well, we just don't care about them: # we want to find p1 inside K, and p11 inside e1 specify 'one specific thing inside one thing, and another specific thing inside another thing' do - expect(GeographicItem.is_contained_by('any', + expect(GeographicItem.st_coveredby_item('any', [e1, k]).to_a) .to include(p1, p11) end specify 'one thing (p19) inside a polygon (b) with interior, and another inside ' \ 'the interior which is NOT included (p18)' do - expect(GeographicItem.is_contained_by('any', b).not_including(b).to_a).to eq([p19]) + expect(GeographicItem.st_coveredby_item('any', b).not_including(b).to_a).to eq([p19]) end specify 'three things inside two things. Notice that the outer ring of b ' \ 'is co-incident with b1, and thus "contained".' do - expect(GeographicItem.is_contained_by('any', + expect(GeographicItem.st_coveredby_item('any', [b1, b2]).not_including([b1, b2]).to_a) .to contain_exactly(p18, p19, b) end @@ -1827,7 +1833,7 @@ # other objects are returned as well, we just don't care about them # we want to find p19 inside b and b1, but returned only once specify 'both b and b1 contain p19, which gets returned only once' do - expect(GeographicItem.is_contained_by('any', + expect(GeographicItem.st_coveredby_item('any', [b1, b]).to_a) .to include(p19) end From d1d94f13f980f8c0804bef784376bc04863e78e6 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sat, 22 Jun 2024 21:15:59 -0500 Subject: [PATCH 040/259] Make Leaflet map controls hideable and showable --- .../vue/components/georeferences/map.vue | 48 +++++++++++++------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/app/javascript/vue/components/georeferences/map.vue b/app/javascript/vue/components/georeferences/map.vue index 38d57256f3..db024469ac 100644 --- a/app/javascript/vue/components/georeferences/map.vue +++ b/app/javascript/vue/components/georeferences/map.vue @@ -35,6 +35,21 @@ let geographicArea const TILE_MAP_STORAGE_KEY = 'tw::map::tile' +const DRAW_CONTROLS_PROPS = [ + 'drawCircle', + 'drawCircleMarker', + 'drawMarker', + 'drawPolyline', + 'drawPolygon', + 'drawRectangle', + 'drawText', + 'editMode', + 'dragMode', + 'cutPolygon', + 'removalMode', + 'rotateMode' +] + const props = defineProps({ zoomAnimate: { type: Boolean, @@ -198,6 +213,13 @@ watch( } ) +watch( + () => props.drawControls, + (newVal) => { + mapObject.pm.addControls(getControls(newVal)) + } +) + onMounted(() => { mapObject = L.map(leafletMap.value, { center: props.center, @@ -245,6 +267,15 @@ onUnmounted(() => { observeMap?.disconnect() }) +function getControls(show) { + let controls = { position: 'topleft' } + DRAW_CONTROLS_PROPS.forEach((prop) => { + controls[prop] = show ? props[prop] : false + }) + + return controls +} + const addDrawControllers = () => { getDefaultTile().addTo(mapObject) if (props.tilesSelection) { @@ -254,21 +285,7 @@ const addDrawControllers = () => { } if (props.drawControls) { - mapObject.pm.addControls({ - position: 'topleft', - drawCircle: props.drawCircle, - drawCircleMarker: props.drawCircleMarker, - drawMarker: props.drawMarker, - drawPolyline: props.drawPolyline, - drawPolygon: props.drawPolygon, - drawRectangle: props.drawRectangle, - drawText: props.drawText, - editMode: props.editMode, - dragMode: props.dragMode, - cutPolygon: props.cutPolygon, - removalMode: props.removalMode, - rotateMode: props.rotateMode - }) + mapObject.pm.addControls(getControls(true)) } if (!props.actions) { @@ -277,6 +294,7 @@ const addDrawControllers = () => { }) } } + const handleEvents = () => { mapObject.on('baselayerchange', (e) => { localStorage.setItem(TILE_MAP_STORAGE_KEY, e.name) From b6eeecc142df328ad4c4c3e0879055af5df18b25 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sat, 22 Jun 2024 21:19:33 -0500 Subject: [PATCH 041/259] #1954 Make Leaflet drawing non-editable after initial draw Also disallow drawing a circle; at a glance it looks like Leaflet returns a point and a radius, which we could convert into a polygon, but then there are issues with that - maybe revist later. --- .../gazetteers/new_gazetteer/components/GeographicItem.vue | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/GeographicItem.vue b/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/GeographicItem.vue index 5e472af164..3a04410673 100644 --- a/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/GeographicItem.vue +++ b/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/GeographicItem.vue @@ -8,10 +8,12 @@ fit-bounds zoom="1" resize - :draw-controls="true" + :draw-controls="drawControls" + :draw-circle="false" :draw-polyline="false" :cut-polygon="false" :removal-mode="false" + :edit-mode="false" tooltips actions @geoJsonLayersEdited="(shape) => addToShapes(shape)" @@ -34,6 +36,7 @@ import { ref } from 'vue' const emit = defineEmits(['shapesUpdated']) const shapes = ref([]) +const drawControls = ref(true) function addToShapes(shape) { if (!shape.uuid) { @@ -42,10 +45,12 @@ function addToShapes(shape) { addToArray(shapes.value, shape, { property: 'uuid' }) emit('shapesUpdated', shapes) + drawControls.value = false } function removeFromShapes(shape) { removeFromArray(shapes.value, shape, { property: 'uuid' }) + drawControls.value = true } From 768ff7502118ed4fcbe19f879466300bfbd29495 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sat, 22 Jun 2024 22:08:40 -0500 Subject: [PATCH 042/259] #1954 Make name required before shape can be added --- .../vue/tasks/gazetteers/new_gazetteer/App.vue | 16 ++++++++++++++-- .../new_gazetteer/components/GeographicItem.vue | 15 ++++++++++++--- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue b/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue index ce3f2caf74..bfdc9831ca 100644 --- a/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue +++ b/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue @@ -64,7 +64,7 @@
- +
- +
+ Only one shape can be added and once added it can't be edited, instead you can + delete and re-add. +
+ From 0b453cf479198cc48552b6ff9602796ddaf4bf1f Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sun, 23 Jun 2024 16:23:08 -0500 Subject: [PATCH 044/259] #1954 Accept multiple shapes in Gazetteer controller --- app/controllers/gazetteers_controller.rb | 103 +++++++++++++++--- .../vue/routes/endpoints/Gazetteer.js | 5 +- .../tasks/gazetteers/new_gazetteer/App.vue | 18 ++- .../components/GeographicItem.vue | 2 +- 4 files changed, 106 insertions(+), 22 deletions(-) diff --git a/app/controllers/gazetteers_controller.rb b/app/controllers/gazetteers_controller.rb index 09d9d01827..34e6ac763e 100644 --- a/app/controllers/gazetteers_controller.rb +++ b/app/controllers/gazetteers_controller.rb @@ -35,24 +35,29 @@ def edit # GET /gazetteers/list def list - @gazetteers = Gazetteer.with_project_id(sessions_current_project_id).page(params[:page]).per(params[[:per]]) + @gazetteers = Gazetteer + .with_project_id(sessions_current_project_id) + .page(params[:page]).per(params[[:per]]) end - # POST /gazetteers or /gazetteers.json + # POST /gazetteers.json def create @gazetteer = Gazetteer.new(gazetteer_params) - respond_to do |format| - if @gazetteer.save - format.html { redirect_to gazetteer_url(@gazetteer), notice: "Gazetteer was successfully created." } - format.json { - render :show, status: :created, location: @gazetteer - flash[:notice] = 'Gazetteer created.' - } - else - format.html { render :new, status: :unprocessable_entity } - format.json { render json: @gazetteer.errors, status: :unprocessable_entity } - end + shape = combine_geojson_shapes_to_rgeo(shape_params['shapes']['geojson']) + if shape.nil? + render json: @gazetteer.errors, status: :unprocessable_entity + return + end + + @gazetteer.geographic_item = GeographicItem.new(geography: shape) + + if @gazetteer.save + render :show, status: :created, location: @gazetteer + # TODO make this notice work + flash[:notice] = 'Gazetteer created.' + else + render json: @gazetteer.errors, status: :unprocessable_entity end end @@ -86,6 +91,76 @@ def set_gazetteer end def gazetteer_params - params.require(:gazetteer).permit(:name, :parent_id, :iso_3166_a2, :iso_3166_a3, geographic_item_attributes: {}) + params.require(:gazetteer).permit(:name, :parent_id, :iso_3166_a2, :iso_3166_a3) + end + + def shape_params + params.require(:gazetteer).permit(shapes: { geojson: []}) + end + + # Adds to errors and returns nil on error + def geojson_to_rgeo(shape) + begin + RGeo::GeoJSON.decode(shape, json_parser: :json, geo_factory: Gis::FACTORY) + rescue RGeo::Error::InvalidGeometry => e + @gazetteer.errors.add(:base, "invalid geometry: #{e}") + return nil + end + end + + def combine_geojson_shapes_to_rgeo(shapes) + if shapes.empty? + @gazetteer.errors.add(:base, 'No shapes provided') + return nil + end + + if shapes.count == 1 + return geojson_to_rgeo(shapes[0]).geometry + end + + # Attempt to combine multiple shapes into a single geometry + multi = nil + type = nil + rgeo_shapes = shapes.map { |shape| geojson_to_rgeo(shape) } + if rgeo_shapes.include?(nil) + return nil + end + + types = rgeo_shapes.map { |shape| + # TODO does this always work? (see cases in shape=) + shape.geometry.geometry_type.type_name + }.uniq + + if types.count == 1 + type = types[0] + case type + when 'Point' + points = rgeo_shapes.map { |shape| shape.geometry } + multi = Gis::FACTORY.multi_point(points) + when 'LineString' + line_strings = rgeo_shapes.map { |shape| shape.geometry } + multi = Gis::FACTORY.multi_line_string(line_strings) + when 'Polygon' + polygons = rgeo_shapes.map { |shape| shape.geometry } + multi = Gis::FACTORY.multi_polygon(polygons) + when 'GeometryCollection' + geom_collections = rgeo_shapes.map { |shape| shape.geometry } + multi = Gis::FACTORY.collection(geom_collections) + end + else # multiple geometries of different types + type = 'GeometryCollection' + # This could itself include GeometryCollection(s) + various = rgeo_shapes.map { |shape| shape.geometry } + multi = Gis::FACTORY.collection(various) + end + + if multi.nil? + @gazetteer.errors.add(:base, + "Error in combining multiple #{type}s into a multi-#{type}" + ) + return nil + end + + multi end end diff --git a/app/javascript/vue/routes/endpoints/Gazetteer.js b/app/javascript/vue/routes/endpoints/Gazetteer.js index a8bc62c312..8128d9f3c5 100644 --- a/app/javascript/vue/routes/endpoints/Gazetteer.js +++ b/app/javascript/vue/routes/endpoints/Gazetteer.js @@ -5,7 +5,10 @@ const controller = 'gazetteers' const permitParams = { gazetteer: { name: String, - geographic_item_attributes: { shape: Object } + shapes: { + geojson: [] + } + //geographic_item_attributes: { shape: Object } } } diff --git a/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue b/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue index a707bebc00..80edae6323 100644 --- a/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue +++ b/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue @@ -92,23 +92,29 @@ import VPin from '@/components/ui/Button/ButtonPin.vue' import { Gazetteer } from '@/routes/endpoints' import { computed, ref } from 'vue' -let leafletShapes = [] - +let leafletShapes = ref([]) const gaz = ref({}) const name = ref('') const saveLabel = ref('Save') const shapeSaved = ref(false) const saveDisabled = computed(() => { - return !(name.value) || leafletShapes.length == 0 + return !(name.value) || leafletShapes.value.length == 0 }) function saveGaz() { - let shape = leafletShapes.value[0] - shape.properties.data_type = 'geography' + const geojson = leafletShapes.value.map((shape) => { + delete shape['uuid'] + return JSON.stringify(shape) + }) + + const shapes = { + geojson + } + const gazetteer = { name: name.value, - geographic_item_attributes: { shape: JSON.stringify(shape) }, + shapes } Gazetteer.create({ gazetteer }) diff --git a/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/GeographicItem.vue b/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/GeographicItem.vue index cc6101f2e6..9c3230e9e4 100644 --- a/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/GeographicItem.vue +++ b/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/GeographicItem.vue @@ -51,7 +51,7 @@ function addToShapes(shape) { } addToArray(shapes.value, shape, { property: 'uuid' }) - emit('shapesUpdated', shapes) + emit('shapesUpdated', shapes.value) } function removeFromShapes(shape) { From b753cd543bcaa394fe61b8850b35e88797e84699 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sun, 23 Jun 2024 20:23:30 -0500 Subject: [PATCH 045/259] #1954 Finish more of create Gazetteer, add _attributes.json.jbuilder --- .../tasks/gazetteers/new_gazetteer/App.vue | 79 ++++++++++++------- .../components/GeographicItem.vue | 1 - app/models/gazetteer.rb | 21 +++++ .../gazetteers/_attributes.json.jbuilder | 10 +++ app/views/gazetteers/index.json.jbuilder | 4 +- 5 files changed, 85 insertions(+), 30 deletions(-) create mode 100644 app/views/gazetteers/_attributes.json.jbuilder diff --git a/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue b/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue index 80edae6323..c882872ba7 100644 --- a/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue +++ b/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue @@ -3,27 +3,21 @@
- - New gazetteer + {{ headerLabel }}
- - + +
    @@ -37,14 +31,14 @@ Clone @@ -73,11 +67,22 @@
- You can create multiple shapes which will be saved as a single collection. Once saved you can no longer edit the shape(s), instead you can delete and recreate. +
    +
  • + Multiple shapes will be combined into a single collection. +
  • +
  • + Once saved you can no longer edit the shape(s), instead you can delete and recreate. +
  • +
  • + Overlapping shapes are discouraged and may give unexpected results. +
  • +
+
@@ -93,39 +98,59 @@ import { Gazetteer } from '@/routes/endpoints' import { computed, ref } from 'vue' let leafletShapes = ref([]) -const gaz = ref({}) +const gz = ref({}) const name = ref('') const saveLabel = ref('Save') -const shapeSaved = ref(false) const saveDisabled = computed(() => { return !(name.value) || leafletShapes.value.length == 0 }) -function saveGaz() { +const headerLabel = computed(() => { + return gz.value.id ? gz.value.name : 'New Gazetteer' +}) + +function saveGz() { + if (gz.value.id) { + updateGz() + } else { + saveNewGz() + } +} + +function saveNewGz() { const geojson = leafletShapes.value.map((shape) => { delete shape['uuid'] return JSON.stringify(shape) }) - const shapes = { - geojson - } - const gazetteer = { name: name.value, - shapes + shapes: { geojson } } Gazetteer.create({ gazetteer }) - .then(() => { - shapeSaved.value = true + .then(({ body }) => { + gz.value = body saveLabel.value = 'Update name' }) .catch(() => {}) } -function cloneGaz() {} +function updateGz() { + // TODO finish + const gazetteer = { + name: name.value + } + + Gazetteer.update({ gazetteer }) + .then(() => { + + }) + .catch(() => {}) +} + +function cloneGz() {} function reset() {} diff --git a/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/GeographicItem.vue b/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/GeographicItem.vue index 9c3230e9e4..44d525d287 100644 --- a/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/GeographicItem.vue +++ b/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/GeographicItem.vue @@ -10,7 +10,6 @@ resize :draw-controls="!editingDisabled" :draw-circle="false" - :draw-polyline="false" :cut-polygon="false" :removal-mode="false" :edit-mode="false" diff --git a/app/models/gazetteer.rb b/app/models/gazetteer.rb index 6322dc42c3..c5d0372904 100644 --- a/app/models/gazetteer.rb +++ b/app/models/gazetteer.rb @@ -44,4 +44,25 @@ class Gazetteer < ApplicationRecord accepts_nested_attributes_for :geographic_item + # @return [Hash] of the pieces of a GeoJSON 'Feature' + def to_geo_json_feature + to_simple_json_feature.merge( + properties: { + gazetteer: { + id:, + tag: name + } + } + ) + end + + def to_simple_json_feature + { + type: 'Feature', + properties: {}, + geometry: geographic_item.to_geo_json + } + end + + end diff --git a/app/views/gazetteers/_attributes.json.jbuilder b/app/views/gazetteers/_attributes.json.jbuilder new file mode 100644 index 0000000000..45659e3842 --- /dev/null +++ b/app/views/gazetteers/_attributes.json.jbuilder @@ -0,0 +1,10 @@ +json.extract! gazetteer, :id, :name, :parent_id, :project_id, + :iso_3166_a2, :iso_3166_a3, + :created_by_id, :updated_by_id, :created_at, :updated_at + + # TODO how does embed work? + #if embed_response_with('shape') + json.shape gazetteer.to_geo_json_feature + #end + + json.partial!('/shared/data/all/metadata', object: gazetteer) diff --git a/app/views/gazetteers/index.json.jbuilder b/app/views/gazetteers/index.json.jbuilder index 672729d1ca..a92f6d8e9f 100644 --- a/app/views/gazetteers/index.json.jbuilder +++ b/app/views/gazetteers/index.json.jbuilder @@ -1,3 +1,3 @@ -json.array!(@gazeteers) do |gazeteer| - json.partial! 'attributes', gazeteer: +json.array!(@gazetteers) do |gazetteer| + json.partial! 'attributes', gazetteer: end From 404b1f815060ef23f2452504517bf22a0cdc509a Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sun, 23 Jun 2024 22:01:43 -0500 Subject: [PATCH 046/259] #1954 Support name update --- app/controllers/gazetteers_controller.rb | 7 +++++-- .../vue/tasks/gazetteers/new_gazetteer/App.vue | 9 +++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/controllers/gazetteers_controller.rb b/app/controllers/gazetteers_controller.rb index 34e6ac763e..e50d77fd22 100644 --- a/app/controllers/gazetteers_controller.rb +++ b/app/controllers/gazetteers_controller.rb @@ -61,12 +61,15 @@ def create end end - # PATCH/PUT /gazetteers/1 or /gazetteers/1.json + # PATCH/PUT /gazetteers/1 + # PATCH/PUT /gazetteers/1.json def update + byebug respond_to do |format| if @gazetteer.update(gazetteer_params) format.html { redirect_to gazetteer_url(@gazetteer), notice: "Gazetteer was successfully updated." } - format.json { render :show, status: :ok, location: @gazetteer } + # TODO Add updated message + format.json { render :show, status: :ok } else format.html { render :edit, status: :unprocessable_entity } format.json { render json: @gazetteer.errors, status: :unprocessable_entity } diff --git a/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue b/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue index c882872ba7..b52c93cf36 100644 --- a/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue +++ b/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue @@ -1,4 +1,5 @@ @@ -70,8 +47,4 @@ function removeFromShapes(shape) { max-width: 80vw; margin: 0px auto 2em auto; } - -.geolist { - margin-bottom: 2em; -} \ No newline at end of file From dd3b0e23562a30e799f4fa2fe0dc10d8bae0edce Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Tue, 25 Jun 2024 14:21:37 -0500 Subject: [PATCH 050/259] Generalize the WKT.vue component somewhat I think it's used in three places now, maybe it's time to move it to the general components folder somewhere? --- .../components/parsed/georeferences/wkt.vue | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/app/javascript/vue/tasks/collecting_events/new_collecting_event/components/parsed/georeferences/wkt.vue b/app/javascript/vue/tasks/collecting_events/new_collecting_event/components/parsed/georeferences/wkt.vue index d813fd8ecc..21be3510d1 100644 --- a/app/javascript/vue/tasks/collecting_events/new_collecting_event/components/parsed/georeferences/wkt.vue +++ b/app/javascript/vue/tasks/collecting_events/new_collecting_event/components/parsed/georeferences/wkt.vue @@ -39,10 +39,26 @@ \ No newline at end of file From 2cab3ae870bcfbb10242c26017fc64f1b7fc9ffa Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Wed, 26 Jun 2024 06:53:01 -0500 Subject: [PATCH 052/259] #1954 Get index, list, show working --- app/controllers/gazetteers_controller.rb | 1 + app/helpers/gazetteers_helper.rb | 25 ++++++++++++++++++- app/views/gazetteers/_form.html.erb | 3 +-- app/views/gazetteers/list.html.erb | 1 - app/views/gazetteers/show.html.erb | 18 +++++++------ app/views/geographic_items/show.html.erb | 4 +++ .../geographic_items/debug/index.html.erb | 7 +++++- spec/factories/gazetteers.rb | 2 +- 8 files changed, 48 insertions(+), 13 deletions(-) diff --git a/app/controllers/gazetteers_controller.rb b/app/controllers/gazetteers_controller.rb index f3587db68c..c0043d58e5 100644 --- a/app/controllers/gazetteers_controller.rb +++ b/app/controllers/gazetteers_controller.rb @@ -50,6 +50,7 @@ def create return end + # TODO does this bypass save and set_cached_area? If not, how does that happen? @gazetteer.geographic_item = GeographicItem.new(geography: shape) if @gazetteer.save diff --git a/app/helpers/gazetteers_helper.rb b/app/helpers/gazetteers_helper.rb index ccb2a240e4..2c7e1fe7ed 100644 --- a/app/helpers/gazetteers_helper.rb +++ b/app/helpers/gazetteers_helper.rb @@ -1,7 +1,30 @@ module GazetteersHelper + def gazetteer_tag(gazetteer) + return nil if gazetteer.nil? + gazetteer.name + end + + def label_for_gazetteer(gazetteer) + return nil if gazetteer.nil? + gazetteer.name + end + def gazetteer_link(gazetteer, link_text = nil) return nil if gazetteer.nil? link_text ||= gazetteer.name - link_to(link_text, gazeteer) + link_to(link_text, gazetteer) + end + + def geographic_item_link(geographic_item, link_text = nil) + return nil if geographic_item.nil? + link_text ||= geographic_item.to_param + link_to(link_text, geographic_item_path(geographic_item), data: {turbolinks: false}) + end + + def gazetteer_link_list(gazetteers) + content_tag(:ul) do + gazetteers.collect { |a| content_tag(:li, gazetteer_link(a)) }.join.html_safe + end end + end diff --git a/app/views/gazetteers/_form.html.erb b/app/views/gazetteers/_form.html.erb index b4b9c3febd..c4e2deb2c0 100644 --- a/app/views/gazetteers/_form.html.erb +++ b/app/views/gazetteers/_form.html.erb @@ -1,8 +1,7 @@ <%= content_tag(:span, 'Use the new/edit gazetteer task.', class: [:feedback, 'feedback-warning']) %>

-<%# TODO %> -<%= link_to('New/edit gazetteer task', ) %> +<%= link_to('New gazetteer task', new_gazetteer_task_path(id: @gazetteer&.id )) %>

diff --git a/app/views/gazetteers/list.html.erb b/app/views/gazetteers/list.html.erb index 1daad23279..22f328939c 100644 --- a/app/views/gazetteers/list.html.erb +++ b/app/views/gazetteers/list.html.erb @@ -17,7 +17,6 @@ <%= content_tag(:tr, class: :contextMenuCells) do -%> <%= gaz.name %> <%= gazetteer_link(gaz.parent) %> - <%= gazetteer_type_tag(gaz.type) %> <%= gaz.iso_3166_a2 %> <%= gaz.iso_3166_a3 %> <%= fancy_metadata_cells_tag(gaz) -%> diff --git a/app/views/gazetteers/show.html.erb b/app/views/gazetteers/show.html.erb index 22b1a8762e..28a9692769 100644 --- a/app/views/gazetteers/show.html.erb +++ b/app/views/gazetteers/show.html.erb @@ -1,10 +1,14 @@ -

<%= notice %>

+<%= content_for :associated do -%> +

Shape:

+ <%= simple_map([@gazetteer]) -%> -<%= render @gazetteer %> +

+ GeographicItems (shape IDs): + <%= geographic_item_link(@gazetteer.geographic_item) -%> +

-
- <%= link_to "Edit this gazetteer", edit_gazetteer_path(@gazetteer) %> | - <%= link_to "Back to gazetteers", gazetteers_path %> + <%= link_to('Debug default geographic item', debug_geographic_item_task_path(geographic_item_id: @gazetteer.geographic_item_id)) %> - <%= button_to "Destroy this gazetteer", @gazetteer, method: :delete %> -
+<% end %> + +<%= render(partial: 'shared/data/project/show', locals: {object: @gazetteer}) -%> diff --git a/app/views/geographic_items/show.html.erb b/app/views/geographic_items/show.html.erb index d3ef4d7703..7123800256 100644 --- a/app/views/geographic_items/show.html.erb +++ b/app/views/geographic_items/show.html.erb @@ -3,6 +3,10 @@

Names from geographic areas: <%= geographic_area_link_list(@geographic_item.geographic_areas) -%>

Parents through geographic areas: <%= geographic_area_link_list(@geographic_item.parent_geographic_areas) -%>

+

Gazetteers

+

Names from gazetteers: <%= gazetteer_link_list(@geographic_item.gazetteers) -%>

+ <%# TODO gazetteer parents%> +

Geographic Items

Parent items through geographic areas: <%= geographic_item_parent_nav_links(@geographic_item) -%>

diff --git a/app/views/tasks/geographic_items/debug/index.html.erb b/app/views/tasks/geographic_items/debug/index.html.erb index 68e0674a32..0631866121 100644 --- a/app/views/tasks/geographic_items/debug/index.html.erb +++ b/app/views/tasks/geographic_items/debug/index.html.erb @@ -27,7 +27,8 @@

Project specific

<%= table_from_hash_tag({ 'Georeferences' => collecting_events_count_using_this_geographic_item(@geographic_item.id), - 'AssertedDistributions' => @geographic_item.asserted_distributions.where(project_id: sessions_current_project_id).count + 'AssertedDistributions' => @geographic_item.asserted_distributions.where(project_id: sessions_current_project_id).count, + 'Gazetteers' => @geographic_item.gazetteers.where(project_id: sessions_current_project_id).count }) %> @@ -82,6 +83,10 @@

Names from geographic areas: <%= geographic_area_link_list(@geographic_item.geographic_areas) -%>

Parents through geographic areas: <%= geographic_area_link_list(@geographic_item.parent_geographic_areas) -%>

+

Gazetteer

+

Names from gazetteers: <%= gazetteer_link_list(@geographic_item.gazetteers) -%>

+ <%# TODO Gazetteer parents %> +

Parents

Through geographic areas: <%= geographic_item_parent_nav_links(@geographic_item) -%>

diff --git a/spec/factories/gazetteers.rb b/spec/factories/gazetteers.rb index 85d964688f..f4424eb4b5 100644 --- a/spec/factories/gazetteers.rb +++ b/spec/factories/gazetteers.rb @@ -22,7 +22,7 @@ name { 'gaz random polygon' } end - factory :gazeteer_with_multi_polygon do + factory :gazetteer_with_multi_polygon do association :geographic_item, factory: :geographic_item_with_multi_polygon name { 'gaz random multi-polygon' } end From 7aa9482b615d8a27d332955c46609bb2cbd451e1 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Wed, 26 Jun 2024 08:47:23 -0500 Subject: [PATCH 053/259] #1954 Add more st_*_sql methods to GeographicItem There's a little more I can do here. --- app/models/geographic_item.rb | 91 ++++++++++++++++++++++++++++------- 1 file changed, 74 insertions(+), 17 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 0d28e1cd9e..10e55e3d0c 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -202,6 +202,44 @@ def st_buffer_for_item(geographic_item_id, buffer, geometric: false) .first.buffer end + def st_distance_sql(shape_sql) + 'ST_Distance(' \ + "#{GeographicItem::GEOGRAPHY_SQL}, " \ + "(#{shape_sql})" \ + ')' + end + + def st_area_sql(shape_sql) + 'ST_Area(' \ + "(#{shape_sql})" \ + ')' + end + + def st_isvalid_sql(shape_sql) + 'ST_IsValid(' \ + "(#{shape_sql})" \ + ')' + end + + + def st_isvalidreason_sql(shape_sql) + 'ST_IsValidReason(' \ + "(#{shape_sql})" \ + ')' + end + + def st_astext_sql(shape_sql) + 'ST_AsText(' \ + "(#{shape_sql})" \ + ')' + end + + def st_minimumboundingradius_sql(shape_sql) + 'ST_MinimumBoundingRadius(' \ + "(#{shape_sql})" \ + ')' + end + # True for those shapes that are within `distance` of (i.e. intersect the # `distance`-buffer of) the shape_sql shape. This is a geography dwithin, # distance is in meters. @@ -414,7 +452,6 @@ def items_as_one_geometry_sql(*geographic_item_ids) # shifted by longitude # Note: this routine is called when it is already known that the A # argument crosses anti-meridian - # TODO If wkt coords are in the range 0..360 and GI coords are in the range -180..180 (or vice versa), doesn't this fail? Don't you want all coords in the range 0..360 in this geometry case? Is there any assumption about range of inputs for georefs, e.g.? are they always normalized? See anti-meridian spec? def covered_by_wkt_shifted_sql(wkt) "ST_CoveredBy( (CASE geographic_items.type @@ -639,10 +676,9 @@ def not_including(geographic_items) # @param [Integer] geographic_item_id2 # @return [Float] def distance_between(geographic_item_id1, geographic_item_id2) - q = 'ST_Distance(' \ - "#{GeographicItem::GEOGRAPHY_SQL}, " \ - "(#{self.select_geography_sql(geographic_item_id2)}) " \ - ') as distance' + q = self.st_distance_sql( + self.select_geography_sql(geographic_item_id2) + ) GeographicItem.where(id: geographic_item_id1).pick(Arel.sql(q)) end @@ -744,10 +780,8 @@ def valid_geometry? GeographicItem .where(id:) .select( - 'ST_IsValid(' \ - "ST_AsBinary(#{data_column})" \ - ') AS is_valid' - ).first['is_valid'] + self.class.st_isvalid_sql("ST_AsBinary(#{data_column})") + ).first['st_isvalid'] end # @return [Array] @@ -913,14 +947,16 @@ def shape=(value) geom end - # @return [String] + # @return [String] wkt def to_wkt # 10k # # GeographicItem.select("ST_AsText( #{GeographicItem::GEOMETRY_SQL.to_sql}) wkt").where(id: id).first.wkt # 10k - if (a = ApplicationRecord.connection.execute( "SELECT ST_AsText( #{GeographicItem::GEOMETRY_SQL.to_sql} ) wkt from geographic_items where geographic_items.id = #{id}").first) - return a['wkt'] + if (a = select_self( + self.class.st_astext_sql(GeographicItem::GEOMETRY_SQL.to_sql) + )) + return a['st_astext'] else return nil end @@ -930,7 +966,11 @@ def to_wkt # TODO: share with world # Geographic item 96862 (Cajamar in Brazil) is the only(?) record to fail using `false` (quicker) method of everything we tested def area - a = GeographicItem.where(id:).select("ST_Area(#{GeographicItem::GEOGRAPHY_SQL}, true) as area_in_meters").first['area_in_meters'] + # use select_self + a = GeographicItem.where(id:).select( + self.class.st_area_sql(GeographicItem::GEOGRAPHY_SQL) + ).first['st_area'] + a = nil if a.nan? a end @@ -940,8 +980,13 @@ def area # # Use case is returning the radius from a circle we calculated via buffer for error-polygon creation. def radius - r = ApplicationRecord.connection.execute( "SELECT ST_MinimumBoundingRadius( ST_Transform( #{GeographicItem::GEOMETRY_SQL.to_sql}, 4326 ) ) AS radius from geographic_items where geographic_items.id = #{id}").first['radius'].split(',').last.chop.to_f - r = (r * Utilities::Geo::ONE_WEST_MEAN).to_i + r = select_self( + self.class.st_minimumboundingradius_sql( + GeographicItem::GEOMETRY_SQL.to_sql + ) + )['st_minimumboundingradius'].split(',').last.chop.to_f + + (r * Utilities::Geo::ONE_WEST_MEAN).to_i end # Convention is to store in PostGIS in CCW @@ -978,11 +1023,19 @@ def is_basic_donut? end def st_isvalid - ApplicationRecord.connection.execute( "SELECT ST_IsValid( #{GeographicItem::GEOMETRY_SQL.to_sql }) from geographic_items where geographic_items.id = #{id}").first['st_isvalid'] + select_self( + self.class.st_isvalid_sql( + GeographicItem::GEOMETRY_SQL.to_sql + ) + )['st_isvalid'] end def st_isvalidreason - ApplicationRecord.connection.execute( "SELECT ST_IsValidReason( #{GeographicItem::GEOMETRY_SQL.to_sql }) from geographic_items where geographic_items.id = #{id}").first['st_isvalidreason'] + select_self( + self.class.st_isvalidreason_sql( + GeographicItem::GEOMETRY_SQL.to_sql + ) + )['st_isvalidreason'] end # @return [Symbol, nil] @@ -1040,6 +1093,10 @@ def self.shape_column_sql(shape) "ELSE #{shape} END)" end + def select_self(shape_sql) + ApplicationRecord.connection.execute( "SELECT #{shape_sql} FROM geographic_items WHERE geographic_items.id = #{id}").first + end + def align_winding if orientations.flatten.include?(false) if (column = multi_polygon_column) From eafa060ee5544eebf30e874eb556cfc5fbacd6a1 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sat, 29 Jun 2024 16:49:09 -0500 Subject: [PATCH 054/259] #1954 Move some Gazetteer methods from controller to model Addresses review comments, though I'm not sure in this case if the model would better be GeographicItem than Gazetteer. --- app/controllers/gazetteers_controller.rb | 117 +++-------------------- app/models/gazetteer.rb | 94 ++++++++++++++++++ app/models/geographic_item.rb | 2 +- 3 files changed, 108 insertions(+), 105 deletions(-) diff --git a/app/controllers/gazetteers_controller.rb b/app/controllers/gazetteers_controller.rb index c0043d58e5..56302ca34b 100644 --- a/app/controllers/gazetteers_controller.rb +++ b/app/controllers/gazetteers_controller.rb @@ -11,6 +11,7 @@ def index render '/shared/data/all/index' end format.json do + # TODO gzs, not gas @geographic_areas = ::Queries::GeographicArea::Filter.new(params).all .includes(:geographic_items) .page(params[:page]) @@ -44,8 +45,18 @@ def list def create @gazetteer = Gazetteer.new(gazetteer_params) - shape = combine_shapes_to_rgeo(shape_params['shapes']) - if shape.nil? + begin + shape = Gazetteer.combine_shapes_to_rgeo(shape_params['shapes']) + # TODO make sure these errors work + rescue RGeo::Error::RGeoError => e + @gazetteer.errors.add(:base, "Invalid WKT: #{e}") + rescue RGeo::Error::InvalidGeometry => e + @gazetteer.errors.add(:base, "Invalid geometry: #{e}") + rescue TaxonWorks::Error => e + @gazetteer.errors.add(:base, e) + end + + if @gazetteer.errors.include?(:base) || shape.nil? render json: @gazetteer.errors, status: :unprocessable_entity return end @@ -101,107 +112,5 @@ def shape_params params.require(:gazetteer).permit(shapes: { geojson: [], wkt: []}) end - # @return [Array] of RGeo::Geographic::Projected*Impl - def convert_geojson_to_rgeo(shapes) - return [] if shapes.blank? - - rgeo_shapes = shapes.map { |shape| - begin - RGeo::GeoJSON.decode( - shape, json_parser: :json, geo_factory: Gis::FACTORY - ) - rescue RGeo::Error::InvalidGeometry => e - @gazetteer.errors.add(:base, "invalid geometry: #{e}") - nil - end - } - if rgeo_shapes.include?(nil) - return nil - end - - rgeo_shapes.map { |shape| shape.geometry } - end - - # @return [Array] of RGeo::Geographic::Projected*Impl - def convert_wkt_to_rgeo(wkt_shapes) - return [] if wkt_shapes.blank? - - rgeo_shapes = wkt_shapes.map { |shape| - begin - ::Gis::FACTORY.parse_wkt(shape) - rescue RGeo::Error::RGeoError => e - @gazetteer.errors.add(:base, "invalid WKT: #{e}") - nil - end - } - if rgeo_shapes.include?(nil) - return nil - end - - rgeo_shapes - end - - # Assumes @gazetteer is set - # @param [Hash] - # @return A single rgeo shape containing all of the input shapes, or nil on - # error with @gazetteer.errors set - def combine_shapes_to_rgeo(shapes) - if shapes['geojson'].blank? && shapes['wkt'].blank? - @gazetteer.errors.add(:base, 'No shapes provided') - return nil - end - geojson_rgeo = convert_geojson_to_rgeo(shapes['geojson']) - wkt_rgeo = convert_wkt_to_rgeo(shapes['wkt']) - if geojson_rgeo.nil? || wkt_rgeo.nil? - return nil - end - - shapes = geojson_rgeo + wkt_rgeo - - combine_rgeo_shapes(shapes) - end - - # @param [Array] rgeo_shapes of RGeo::Geographic::Projected*Impl - # @return [RGeo::Geographic::Projected*Impl] A single shape combining all of the - # input shapes - def combine_rgeo_shapes(rgeo_shapes) - if rgeo_shapes.count == 1 - return rgeo_shapes[0] - end - - multi = nil - type = nil - - types = rgeo_shapes.map { |shape| - shape.geometry_type.type_name - }.uniq - - if types.count == 1 - type = types[0] - case type - when 'Point' - multi = Gis::FACTORY.multi_point(rgeo_shapes) - when 'LineString' - multi = Gis::FACTORY.multi_line_string(rgeo_shapes) - when 'Polygon' - multi = Gis::FACTORY.multi_polygon(rgeo_shapes) - when 'GeometryCollection' - multi = Gis::FACTORY.collection(rgeo_shapes) - end - else # multiple geometries of different types - type = 'GeometryCollection' - # This could itself include GeometryCollection(s) - multi = Gis::FACTORY.collection(rgeo_shapes) - end - - if multi.nil? - @gazetteer.errors.add(:base, - "Error in combining multiple #{type}s into a multi-#{type}" - ) - return nil - end - - multi - end end diff --git a/app/models/gazetteer.rb b/app/models/gazetteer.rb index c5d0372904..e5f429a8bb 100644 --- a/app/models/gazetteer.rb +++ b/app/models/gazetteer.rb @@ -64,5 +64,99 @@ def to_simple_json_feature } end + # Assumes @gazetteer is set + # @param [Hash] TODO describe shape of hash + # @return A single rgeo shape containing all of the input shapes + # Raises on error + def self.combine_shapes_to_rgeo(shapes) + if shapes['geojson'].blank? && shapes['wkt'].blank? + raise TaxonWorks::Error, 'No shapes provided' + end + + geojson_rgeo = convert_geojson_to_rgeo(shapes['geojson']) + wkt_rgeo = convert_wkt_to_rgeo(shapes['wkt']) + if geojson_rgeo.nil? || wkt_rgeo.nil? + return nil + end + + shapes = geojson_rgeo + wkt_rgeo + + combine_rgeo_shapes(shapes) + end + + # @return [Array] of RGeo::Geographic::Projected*Impl + # Raises RGeo::Error::InvalidGeometry on error + def self.convert_geojson_to_rgeo(shapes) + return [] if shapes.blank? + + rgeo_shapes = shapes.map { |shape| + # Raises RGeo::Error::InvalidGeometry on error + RGeo::GeoJSON.decode( + shape, json_parser: :json, geo_factory: Gis::FACTORY + ) + } + + # TODO can i do the &geometry thing here? + rgeo_shapes.map { |shape| shape.geometry } + end + + # @return [Array] of RGeo::Geographic::Projected*Impl + # Raises RGeo::Error::RGeoError on error + def self.convert_wkt_to_rgeo(wkt_shapes) + return [] if wkt_shapes.blank? + + wkt_shapes.map { |shape| + begin + ::Gis::FACTORY.parse_wkt(shape) + rescue RGeo::Error::RGeoError => e + raise e.exception("Invalid WKT: #{e.message}") + end + } + end + + # @param [Array] rgeo_shapes of RGeo::Geographic::Projected*Impl + # @return [RGeo::Geographic::Projected*Impl] A single shape combining all of the + # input shapes + # Raises TaxonWorks::Error on error + def self.combine_rgeo_shapes(rgeo_shapes) + if rgeo_shapes.count == 1 + return rgeo_shapes[0] + end + + multi = nil + type = nil + + types = rgeo_shapes.map { |shape| + shape.geometry_type.type_name + }.uniq + + if types.count == 1 + type = types[0] + case type + when 'Point' + multi = Gis::FACTORY.multi_point(rgeo_shapes) + when 'LineString' + multi = Gis::FACTORY.multi_line_string(rgeo_shapes) + when 'Polygon' + multi = Gis::FACTORY.multi_polygon(rgeo_shapes) + when 'GeometryCollection' + multi = Gis::FACTORY.collection(rgeo_shapes) + end + else # multiple geometries of different types + type = 'Multi-types' + # This could itself include GeometryCollection(s) + multi = Gis::FACTORY.collection(rgeo_shapes) + end + + if multi.nil? + message = type == 'Multi-types' ? + 'Error in combining mutiple types into a single GeometryCollection' : + "Error in combining multiple #{type}s into a multi-#{type}" + raise Taxonworks::Error, message + end + + multi + end + end diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 10e55e3d0c..b517f39566 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -339,7 +339,7 @@ def within_radius_of_item(geographic_item_id, distance) def st_buffer_st_within(geographic_item_id, distance, buffer = 0) # You can't always switch the buffer to the second argument, even when # distance is 0, without further assumptions (think of buffer being - # large negative compared to geographic_item_id but not another shape)) + # large negative compared to geographic_item_id, but not another shape)) 'ST_DWithin(' \ "ST_Buffer(#{GeographicItem::GEOGRAPHY_SQL}, #{buffer}), " \ "(#{select_geography_sql(geographic_item_id)}), " \ From 8263c1a7a1876b581294b0f4cb534c21d122e2a3 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sat, 29 Jun 2024 18:47:26 -0500 Subject: [PATCH 055/259] #1954 Rework GeographicItem.to_geo_json Also addresses review comment regarding helper --- app/helpers/collecting_events_helper.rb | 3 +-- app/models/geographic_item.rb | 19 ++++++++----------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/app/helpers/collecting_events_helper.rb b/app/helpers/collecting_events_helper.rb index 51ba7fe137..c21c7e0890 100644 --- a/app/helpers/collecting_events_helper.rb +++ b/app/helpers/collecting_events_helper.rb @@ -249,8 +249,7 @@ def collecting_event_to_simple_json_feature(collecting_event) if collecting_event.geographic_items.any? geo_item_id = collecting_event.geographic_items.select(:id).first.id - base['geometry'] = - JSON.parse(GeographicItem.st_asgeojson.find(geo_item_id).geo_json) + base['geometry'] = GeographicItem.find(geo_item_id).to_geo_json end base end diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index b517f39566..4da3e8e9b0 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -251,8 +251,8 @@ def st_dwithin_sql(shape_sql, distance) ')' end - def st_asgeojson - select("ST_AsGeoJSON(#{GeographicItem::GEOMETRY_SQL.to_sql}) geo_json") + def st_asgeojson_sql(shape_sql) + "ST_AsGeoJSON(#{shape_sql})" end # @param [String] wkt @@ -868,16 +868,12 @@ def rgeo_to_geo_json RGeo::GeoJSON.encode(geo_object).to_json end - # @return [Hash] - # in GeoJSON format - # Computed via "raw" PostGIS (much faster). This - # requires the geo_object_type and id. + # @return [Hash] in GeoJSON format def to_geo_json JSON.parse( - GeographicItem.connection.select_one( - "SELECT ST_AsGeoJSON(#{data_column}::geometry) a " \ - "FROM geographic_items WHERE id=#{id};" - )['a'] + select_self( + self.class.st_asgeojson_sql(data_column) + )['st_asgeojson'] ) end @@ -966,7 +962,7 @@ def to_wkt # TODO: share with world # Geographic item 96862 (Cajamar in Brazil) is the only(?) record to fail using `false` (quicker) method of everything we tested def area - # use select_self + # TODO use select_self a = GeographicItem.where(id:).select( self.class.st_area_sql(GeographicItem::GEOGRAPHY_SQL) ).first['st_area'] @@ -1093,6 +1089,7 @@ def self.shape_column_sql(shape) "ELSE #{shape} END)" end + # TODO select_from_self? def select_self(shape_sql) ApplicationRecord.connection.execute( "SELECT #{shape_sql} FROM geographic_items WHERE geographic_items.id = #{id}").first end From 4b7c2eddd9aae2551f0caeea394f7be6f874edc4 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sat, 29 Jun 2024 19:12:20 -0500 Subject: [PATCH 056/259] #1954 Drop `_item` from GeographicItem st_covers_item and st_coveredby_item Addresses review comment, the reasoning being that now you don't need to remeber that this st_* function ends in _item, it's now the same as the postgis name. --- app/models/collecting_event.rb | 4 +- app/models/collection_object.rb | 2 +- app/models/geographic_area.rb | 4 +- app/models/geographic_item.rb | 25 +++++----- spec/models/geographic_item_spec.rb | 72 ++++++++++++++--------------- 5 files changed, 55 insertions(+), 52 deletions(-) diff --git a/app/models/collecting_event.rb b/app/models/collecting_event.rb index d611746c3f..37dc740b7f 100644 --- a/app/models/collecting_event.rb +++ b/app/models/collecting_event.rb @@ -822,7 +822,7 @@ def containing_geographic_items # # Struck EGI, EGI must contain GI, therefor anything that contains EGI contains GI, threfor containing GI will always be the bigger set # !! and there was no tests broken - # GeographicItem.st_covers_item('any_poly', self.geographic_items.to_a).pluck(:id).uniq + # GeographicItem.st_covers('any_poly', self.geographic_items.to_a).pluck(:id).uniq gi_list = GeographicItem .covering_union_of(*geographic_items.pluck(:id)) .pluck(:id) @@ -833,7 +833,7 @@ def containing_geographic_items unless self.geographic_area.nil? # unless self.geographic_area.geographic_items.empty? # we need to use the geographic_area directly - gi_list = GeographicItem.st_covers_item('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq + gi_list = GeographicItem.st_covers('any_poly', self.geographic_area.geographic_items).pluck(:id).uniq # end end end diff --git a/app/models/collection_object.rb b/app/models/collection_object.rb index 454d41063c..b5c88b2116 100644 --- a/app/models/collection_object.rb +++ b/app/models/collection_object.rb @@ -332,7 +332,7 @@ def self.in_geographic_item(geographic_item, limit, steps = false) if steps gi = GeographicItem.find(geographic_item_id) # find the geographic_items inside gi - step_1 = GeographicItem.st_coveredby_item('any', gi) # .pluck(:id) + step_1 = GeographicItem.st_coveredby('any', gi) # .pluck(:id) # find the georeferences from the geographic_items step_2 = step_1.map(&:georeferences).uniq.flatten # find the collecting events connected to the georeferences diff --git a/app/models/geographic_area.rb b/app/models/geographic_area.rb index bca0c4979c..fb99342260 100644 --- a/app/models/geographic_area.rb +++ b/app/models/geographic_area.rb @@ -244,7 +244,7 @@ def self.countries def self.is_contained_by(geographic_area) pieces = nil if geographic_area.geographic_items.any? - pieces = GeographicItem.st_coveredby_item('any_poly', geographic_area.geo_object) + pieces = GeographicItem.st_coveredby('any_poly', geographic_area.geo_object) others = [] pieces.each { |other| others.push(other.geographic_areas.to_a) @@ -259,7 +259,7 @@ def self.is_contained_by(geographic_area) def self.are_contained_in(geographic_area) pieces = nil if geographic_area.geographic_items.any? - pieces = GeographicItem.st_covers_item('any_poly', geographic_area.geo_object) + pieces = GeographicItem.st_covers('any_poly', geographic_area.geo_object) others = [] pieces.each { |other| others.push(other.geographic_areas.to_a) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 4da3e8e9b0..7e49576cfd 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -563,10 +563,13 @@ def intersecting(shape, *geographic_item_ids) end # rubocop:disable Metrics/MethodLength - # @param [String] shape to search + # @param [String] shape can be any of SHAPE_TYPES, or 'any' to check + # against all types, 'any_poly' to check against 'polygon' or + # 'multi_polygon', or 'any_line' to check against 'line_string' or + # 'multi_line_string'. # @param [GeographicItem] geographic_items or array of geographic_items # to be tested. - # @return [Scope] of GeographicItems that cover at least one of + # @return [Scope] of GeographicItems whose `shape` covers at least one of # geographic_items # # If this scope is given an Array of GeographicItems as a second parameter, @@ -575,14 +578,14 @@ def intersecting(shape, *geographic_item_ids) # WHERE (ST_Covers(polygon::geometry, GeomFromEWKT('srid=4326;POINT (0.0 0.0 0.0)')) # OR ST_Covers(polygon::geometry, GeomFromEWKT('srid=4326;POINT (-9.8 5.0 0.0)'))) # - def st_covers_item(shape, *geographic_items) + def st_covers(shape, *geographic_items) geographic_items.flatten! # in case there is a array of arrays, or multiple objects shape = shape.to_s.downcase case shape when 'any' part = [] SHAPE_TYPES.each { |shape| - part.push(GeographicItem.st_covers_item(shape, geographic_items).to_a) + part.push(GeographicItem.st_covers(shape, geographic_items).to_a) } # TODO: change 'id in (?)' to some other sql construct GeographicItem.where(id: part.flatten.map(&:id)) @@ -592,7 +595,7 @@ def st_covers_item(shape, *geographic_items) SHAPE_TYPES.each { |shape| shape = shape.to_s if shape.index(shape.gsub('any_', '')) - part.push(GeographicItem.st_covers_item(shape, geographic_items).to_a) + part.push(GeographicItem.st_covers(shape, geographic_items).to_a) end } # TODO: change 'id in (?)' to some other sql construct @@ -621,18 +624,18 @@ def st_covers_item(shape, *geographic_items) # @param shape [String] can be any of SHAPE_TYPES, or 'any' to check # against all types, 'any_poly' to check against 'polygon' or # 'multi_polygon', or 'any_line' to check against 'line_string' or - #'multi_line_string'. + # 'multi_line_string'. # @param geographic_items [GeographicItem] Can be a single # GeographicItem, or an array of GeographicItem. - # @return [Scope] of all GeographicItems of the given shape covered by + # @return [Scope] of all GeographicItems of the given `shape ` covered by # one or more of geographic_items - def st_coveredby_item(shape, *geographic_items) + def st_coveredby(shape, *geographic_items) shape = shape.to_s.downcase case shape when 'any' part = [] SHAPE_TYPES.each { |shape| - part.push(GeographicItem.st_coveredby_item(shape, geographic_items).to_a) + part.push(GeographicItem.st_coveredby(shape, geographic_items).to_a) } # @TODO change 'id in (?)' to some other sql construct GeographicItem.where(id: part.flatten.map(&:id)) @@ -642,7 +645,7 @@ def st_coveredby_item(shape, *geographic_items) SHAPE_TYPES.each { |shape| shape = shape.to_s if shape.index(shape.gsub('any_', '')) - part.push(GeographicItem.st_coveredby_item(shape, geographic_items).to_a) + part.push(GeographicItem.st_coveredby(shape, geographic_items).to_a) end } # @TODO change 'id in (?)' to some other sql construct @@ -652,7 +655,7 @@ def st_coveredby_item(shape, *geographic_items) q = geographic_items.flatten.collect { |geographic_item| GeographicItem.st_coveredby_sql( shape, - geographic_item.to_param, + geographic_item.id, geographic_item.geo_object_type ) }.join(' or ') diff --git a/spec/models/geographic_item_spec.rb b/spec/models/geographic_item_spec.rb index 4d6e39c299..da426d39d1 100644 --- a/spec/models/geographic_item_spec.rb +++ b/spec/models/geographic_item_spec.rb @@ -679,65 +679,65 @@ # OR! specify 'three things inside and one thing outside k' do - expect(GeographicItem.st_covers_item('polygon', + expect(GeographicItem.st_covers('polygon', [p1, p2, p3, p11]).to_a) .to contain_exactly(e1, k) end # OR! specify 'one thing inside one thing, and another thing inside another thing' do - expect(GeographicItem.st_covers_item('polygon', + expect(GeographicItem.st_covers('polygon', [p1, p11]).to_a) .to contain_exactly(e1, k) end specify 'one thing inside k' do - expect(GeographicItem.st_covers_item('polygon', p1).to_a).to eq([k]) + expect(GeographicItem.st_covers('polygon', p1).to_a).to eq([k]) end specify 'three things inside k (in array)' do - expect(GeographicItem.st_covers_item('polygon', + expect(GeographicItem.st_covers('polygon', [p1, p2, p3]).to_a) .to eq([k]) end specify 'three things inside k (as separate parameters)' do - expect(GeographicItem.st_covers_item('polygon', p1, + expect(GeographicItem.st_covers('polygon', p1, p2, p3).to_a) .to eq([k]) end specify 'one thing outside k' do - expect(GeographicItem.st_covers_item('polygon', p4).to_a) + expect(GeographicItem.st_covers('polygon', p4).to_a) .to eq([]) end specify ' one thing inside two things (overlapping)' do - expect(GeographicItem.st_covers_item('polygon', p12).to_a.sort) + expect(GeographicItem.st_covers('polygon', p12).to_a.sort) .to contain_exactly(e1, e2) end specify 'three things inside and one thing outside k' do - expect(GeographicItem.st_covers_item('polygon', + expect(GeographicItem.st_covers('polygon', [p1, p2, p3, p11]).to_a) .to contain_exactly(e1, k) end specify 'one thing inside one thing, and another thing inside another thing' do - expect(GeographicItem.st_covers_item('polygon', + expect(GeographicItem.st_covers('polygon', [p1, p11]).to_a) .to contain_exactly(e1, k) end specify 'two things inside one thing, and (1)' do - expect(GeographicItem.st_covers_item('polygon', p18).to_a) + expect(GeographicItem.st_covers('polygon', p18).to_a) .to contain_exactly(b1, b2) end specify 'two things inside one thing, and (2)' do - expect(GeographicItem.st_covers_item('polygon', p19).to_a) + expect(GeographicItem.st_covers('polygon', p19).to_a) .to contain_exactly(b1, b) end end @@ -759,16 +759,16 @@ before { [b, p0, p1, p2, p3, p11, p12, p13, p18, p19].each } specify ' three things inside k' do - expect(GeographicItem.st_coveredby_item('any', k).not_including(k).to_a) + expect(GeographicItem.st_coveredby('any', k).not_including(k).to_a) .to contain_exactly(p1, p2, p3) end specify 'one thing outside k' do - expect(GeographicItem.st_coveredby_item('any', p4).not_including(p4).to_a).to eq([]) + expect(GeographicItem.st_coveredby('any', p4).not_including(p4).to_a).to eq([]) end specify 'three things inside and one thing outside k' do - pieces = GeographicItem.st_coveredby_item('any', + pieces = GeographicItem.st_coveredby('any', [e2, k]).not_including([k, e2]).to_a # p_a just happens to be in context because it happens to be the # GeographicItem of the Georeference g_a defined in an outer @@ -780,19 +780,19 @@ # other objects are returned as well, we just don't care about them: # we want to find p1 inside K, and p11 inside e1 specify 'one specific thing inside one thing, and another specific thing inside another thing' do - expect(GeographicItem.st_coveredby_item('any', + expect(GeographicItem.st_coveredby('any', [e1, k]).to_a) .to include(p1, p11) end specify 'one thing (p19) inside a polygon (b) with interior, and another inside ' \ 'the interior which is NOT included (p18)' do - expect(GeographicItem.st_coveredby_item('any', b).not_including(b).to_a).to eq([p19]) + expect(GeographicItem.st_coveredby('any', b).not_including(b).to_a).to eq([p19]) end specify 'three things inside two things. Notice that the outer ring of b ' \ 'is co-incident with b1, and thus "contained".' do - expect(GeographicItem.st_coveredby_item('any', + expect(GeographicItem.st_coveredby('any', [b1, b2]).not_including([b1, b2]).to_a) .to contain_exactly(p18, p19, b) end @@ -800,7 +800,7 @@ # other objects are returned as well, we just don't care about them # we want to find p19 inside b and b1, but returned only once specify 'both b and b1 contain p19, which gets returned only once' do - expect(GeographicItem.st_coveredby_item('any', + expect(GeographicItem.st_coveredby('any', [b1, b]).to_a) .to include(p19) end @@ -1712,65 +1712,65 @@ # OR! specify 'three things inside and one thing outside k' do - expect(GeographicItem.st_covers_item('polygon', + expect(GeographicItem.st_covers('polygon', [p1, p2, p3, p11]).to_a) .to contain_exactly(e1, k) end # OR! specify 'one thing inside one thing, and another thing inside another thing' do - expect(GeographicItem.st_covers_item('polygon', + expect(GeographicItem.st_covers('polygon', [p1, p11]).to_a) .to contain_exactly(e1, k) end specify 'one thing inside k' do - expect(GeographicItem.st_covers_item('polygon', p1).to_a).to eq([k]) + expect(GeographicItem.st_covers('polygon', p1).to_a).to eq([k]) end specify 'three things inside k (in array)' do - expect(GeographicItem.st_covers_item('polygon', + expect(GeographicItem.st_covers('polygon', [p1, p2, p3]).to_a) .to eq([k]) end specify 'three things inside k (as separate parameters)' do - expect(GeographicItem.st_covers_item('polygon', p1, + expect(GeographicItem.st_covers('polygon', p1, p2, p3).to_a) .to eq([k]) end specify 'one thing outside k' do - expect(GeographicItem.st_covers_item('polygon', p4).to_a) + expect(GeographicItem.st_covers('polygon', p4).to_a) .to eq([]) end specify ' one thing inside two things (overlapping)' do - expect(GeographicItem.st_covers_item('polygon', p12).to_a.sort) + expect(GeographicItem.st_covers('polygon', p12).to_a.sort) .to contain_exactly(e1, e2) end specify 'three things inside and one thing outside k' do - expect(GeographicItem.st_covers_item('polygon', + expect(GeographicItem.st_covers('polygon', [p1, p2, p3, p11]).to_a) .to contain_exactly(e1, k) end specify 'one thing inside one thing, and another thing inside another thing' do - expect(GeographicItem.st_covers_item('polygon', + expect(GeographicItem.st_covers('polygon', [p1, p11]).to_a) .to contain_exactly(e1, k) end specify 'two things inside one thing, and (1)' do - expect(GeographicItem.st_covers_item('polygon', p18).to_a) + expect(GeographicItem.st_covers('polygon', p18).to_a) .to contain_exactly(b1, b2) end specify 'two things inside one thing, and (2)' do - expect(GeographicItem.st_covers_item('polygon', p19).to_a) + expect(GeographicItem.st_covers('polygon', p19).to_a) .to contain_exactly(b1, b) end end @@ -1792,16 +1792,16 @@ before { [b, p0, p1, p2, p3, p11, p12, p13, p18, p19].each } specify ' three things inside k' do - expect(GeographicItem.st_coveredby_item('any', k).not_including(k).to_a) + expect(GeographicItem.st_coveredby('any', k).not_including(k).to_a) .to contain_exactly(p1, p2, p3) end specify 'one thing outside k' do - expect(GeographicItem.st_coveredby_item('any', p4).not_including(p4).to_a).to eq([]) + expect(GeographicItem.st_coveredby('any', p4).not_including(p4).to_a).to eq([]) end specify 'three things inside and one thing outside k' do - pieces = GeographicItem.st_coveredby_item('any', + pieces = GeographicItem.st_coveredby('any', [e2, k]).not_including([k, e2]).to_a # p_a just happens to be in context because it happens to be the # GeographicItem of the Georeference g_a defined in an outer @@ -1813,19 +1813,19 @@ # other objects are returned as well, we just don't care about them: # we want to find p1 inside K, and p11 inside e1 specify 'one specific thing inside one thing, and another specific thing inside another thing' do - expect(GeographicItem.st_coveredby_item('any', + expect(GeographicItem.st_coveredby('any', [e1, k]).to_a) .to include(p1, p11) end specify 'one thing (p19) inside a polygon (b) with interior, and another inside ' \ 'the interior which is NOT included (p18)' do - expect(GeographicItem.st_coveredby_item('any', b).not_including(b).to_a).to eq([p19]) + expect(GeographicItem.st_coveredby('any', b).not_including(b).to_a).to eq([p19]) end specify 'three things inside two things. Notice that the outer ring of b ' \ 'is co-incident with b1, and thus "contained".' do - expect(GeographicItem.st_coveredby_item('any', + expect(GeographicItem.st_coveredby('any', [b1, b2]).not_including([b1, b2]).to_a) .to contain_exactly(p18, p19, b) end @@ -1833,7 +1833,7 @@ # other objects are returned as well, we just don't care about them # we want to find p19 inside b and b1, but returned only once specify 'both b and b1 contain p19, which gets returned only once' do - expect(GeographicItem.st_coveredby_item('any', + expect(GeographicItem.st_coveredby('any', [b1, b]).to_a) .to include(p19) end From 2fa3f17f537f41fb6ecec50f0c68dda14bcace0e Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sun, 30 Jun 2024 07:42:25 -0500 Subject: [PATCH 057/259] #1954 Fix geojson spec windings (because of fixed GeographicItem windings) I never noticed when I refactored GeographicItem#align_winding, which gets called on after_save, that the original align_winding wasn't doing anything. The original: https://github.com/SpeciesFileGroup/taxonworks/blob/0e4775eecb26c7ab55aa22c90c3e753ea3c150bd/app/models/geographic_item.rb#L1263-L1279 def align_winding if orientations.flatten.include?(false) case type when 'multi_polygon' ApplicationRecord.connection.execute( "UPDATE geographic_items set multi_polygon = ST_ForcePolygonCCW(multi_polygon::geometry) WHERE id = #{self.id};" ) when 'polygon' ApplicationRecord.connection.execute( "UPDATE geographic_items set polygon = ST_ForcePolygonCCW(polygon::geometry) WHERE id = #{self.id};" ) end end true end I think the type cases there should have been 'GeographicItem::MultiPolygon' and 'GeographicItem::Polygon'. --- app/models/geographic_item.rb | 8 ++++---- spec/lib/gis/geo_json_spec.rb | 28 ++++++++++++++-------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 7e49576cfd..bda360f9a1 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -915,6 +915,7 @@ def shape=(value) this_type = nil + # TODO is this first case still used? if geom.respond_to?(:properties) && geom.properties['data_type'].present? this_type = geom.properties['data_type'] elsif geom.respond_to?(:geometry_type) @@ -994,10 +995,10 @@ def radius # true - ccw (preferred), except see donuts def orientations if (column = multi_polygon_column) - ApplicationRecord.connection.execute(" \ - SELECT ST_IsPolygonCCW(a.geom) as is_ccw + ApplicationRecord.connection.execute( + "SELECT ST_IsPolygonCCW(a.geom) as is_ccw FROM ( SELECT b.id, (ST_Dump(p_geom)).geom AS geom - FROM (SELECT id, #{column}::geometry AS p_geom FROM geographic_items where id = #{id}) AS b \ + FROM (SELECT id, #{column}::geometry AS p_geom FROM geographic_items where id = #{id}) AS b ) AS a;").collect{|a| a['is_ccw']} elsif (column = polygon_column) ApplicationRecord.connection.execute( @@ -1113,7 +1114,6 @@ def align_winding ) end end - true end # Crude debuging helper, write the shapes diff --git a/spec/lib/gis/geo_json_spec.rb b/spec/lib/gis/geo_json_spec.rb index 84d29b938a..34caa98839 100644 --- a/spec/lib/gis/geo_json_spec.rb +++ b/spec/lib/gis/geo_json_spec.rb @@ -117,8 +117,8 @@ expect(json).to eq({ 'type' => 'FeatureCollection', 'features' => [{ 'type' => 'Feature', 'geometry' => { 'type' => 'Polygon', - 'coordinates' => [[[1, -1, 0], [9, -1, 0], - [9, -9, 0], [1, -9, 0], + 'coordinates' => [[[1, -1, 0], [1, -9, 0], + [9, -9, 0], [9, -1, 0], [1, -1, 0]], [[2.5, -2.5, 0], [7.5, -2.5, 0], [7.5, -7.5, 0], [2.5, -7.5, 0], @@ -156,10 +156,10 @@ expect(json).to eq({ 'type' => 'FeatureCollection', 'features' => [{ 'type' => 'Feature', 'geometry' => { 'type' => 'MultiPolygon', - 'coordinates' => [[[[1, -1, 0], [9, -1, 0], - [9, -9, 0], [1, -9, 0], [1, -1, 0]]], - [[[2.5, -2.5, 0], [7.5, -2.5, 0], - [7.5, -7.5, 0], [2.5, -7.5, 0], + 'coordinates' => [[[[1, -1, 0], [1, -9, 0], + [9, -9, 0], [9, -1, 0], [1, -1, 0]]], + [[[2.5, -2.5, 0], [2.5, -7.5, 0], + [7.5, -7.5, 0], [7.5, -2.5, 0], [2.5, -2.5, 0]]]] }, 'properties' => { 'geographic_item' => { 'id' => multipolygon_b.id } }, 'id' => feature_index.to_i }] }) @@ -193,8 +193,8 @@ expect(json).to eq({ 'type' => 'FeatureCollection', 'features' => [{ 'type' => 'Feature', 'geometry' => { 'type' => 'MultiPolygon', - 'coordinates' => [[[[0, 0, 0], [0, 10, 0], - [10, 10, 0], [10, 0, 0], + 'coordinates' => [[[[0, 0, 0], [10, 0, 0], + [10, 10, 0], [0, 10, 0], [0, 0, 0]]]] }, 'properties' => { 'geographic_area' => { 'id' => object.id, 'tag' => 'A' } }, @@ -247,15 +247,15 @@ expect(json).to eq({ 'type' => 'FeatureCollection', 'features' => [{ 'type' => 'Feature', 'geometry' => { 'type' => 'MultiPolygon', - 'coordinates' => [[[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], - [10.0, -10.0, 0.0], [0.0, -10.0, 0.0], + 'coordinates' => [[[[0.0, 0.0, 0.0], [0.0, -10.0, 0.0], + [10.0, -10.0, 0.0], [10.0, 0.0, 0.0], [0.0, 0.0, 0.0]]]] }, 'properties' => { 'asserted_distribution' => { 'id' => objects[0].id } }, 'id' => (feature_index.to_i + 0) }, { 'type' => 'Feature', 'geometry' => { 'type' => 'MultiPolygon', - 'coordinates' => [[[[0.0, 10.0, 0.0], [10.0, 10.0, 0.0], - [10.0, -10.0, 0.0], [0.0, -10.0, 0.0], + 'coordinates' => [[[[0.0, 10.0, 0.0], [0.0, -10.0, 0.0], + [10.0, -10.0, 0.0], [10.0, 10.0, 0.0], [0.0, 10.0, 0.0]]]] }, 'properties' => { 'asserted_distribution' => { 'id' => objects[1].id } }, 'id' => (feature_index.to_i + 1) }] }) @@ -280,8 +280,8 @@ 'properties' => { 'geographic_area' => { 'id' => area_b.id, 'tag' => area_b.name } }, 'geometry' => { 'type' => 'MultiPolygon', - 'coordinates' => [[[[0, 0, 0], [10, 0, 0], - [10, -10, 0], [0, -10, 0], + 'coordinates' => [[[[0, 0, 0], [0, -10, 0], + [10, -10, 0], [10, 0, 0], [0, 0, 0]]]] }, 'id' => (feature_index.to_i + 1) }, { 'type' => 'Feature', From de42b5d86af307b7c31b37155ac03e2cab14dd10 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sun, 30 Jun 2024 08:26:37 -0500 Subject: [PATCH 058/259] #1954 Change GeographicItem.within_radius_of_wkt to within_radius_of_wkt_sql --- app/models/geographic_item.rb | 6 +++--- lib/queries/collecting_event/filter.rb | 3 +-- spec/lib/queries/collecting_event/filter_spec.rb | 8 ++++---- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index bda360f9a1..f813b616c8 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -362,10 +362,10 @@ def intersecting_radius_of_wkt_sql(wkt, distance) # @param [String] wkt # @param [Integer] distance (meters) # @return [String] Those items within the distance-buffer of wkt - def within_radius_of_wkt(wkt, distance) - where(self.within_sql( + def within_radius_of_wkt_sql(wkt, distance) + self.within_sql( "ST_Buffer(ST_GeographyFromText('#{wkt}'), #{distance})" - )) + ) end # @param [String, Integer, String] diff --git a/lib/queries/collecting_event/filter.rb b/lib/queries/collecting_event/filter.rb index ad23a1a92d..0c4a371f53 100644 --- a/lib/queries/collecting_event/filter.rb +++ b/lib/queries/collecting_event/filter.rb @@ -319,10 +319,9 @@ def geo_json_facet def spatial_query(geometry_type, wkt) case geometry_type when 'Point' - # TODO test this ::CollectingEvent .joins(:geographic_items) - .within_radius_of_wkt(wkt, radius) + .where(::GeographicItem.within_radius_of_wkt_sql(wkt, radius)) when 'Polygon', 'MultiPolygon' ::CollectingEvent .joins(:geographic_items) diff --git a/spec/lib/queries/collecting_event/filter_spec.rb b/spec/lib/queries/collecting_event/filter_spec.rb index e1c0d68c2f..2b2e2ae04b 100644 --- a/spec/lib/queries/collecting_event/filter_spec.rb +++ b/spec/lib/queries/collecting_event/filter_spec.rb @@ -24,7 +24,7 @@ # let(:p1) { FactoryBot.create(:valid_person, last_name: 'Smith') } specify '#recent' do - query.recent = true + query.recent = true expect(query.all.map(&:id)).to contain_exactly(ce2.id, ce1.id) end @@ -46,7 +46,7 @@ specify '#collection_objects' do CollectionObject.create!(collecting_event: ce1, total: 1) - query.collection_objects = false + query.collection_objects = false expect(query.all.map(&:id)).to contain_exactly(ce2.id) end @@ -98,7 +98,7 @@ specify 'between date range 1, ActionController::Parameters' do h = {start_date: '1999-1-1', end_date: '2001-1-1'} p = ActionController::Parameters.new( h ) - q = Queries::CollectingEvent::Filter.new(p) + q = Queries::CollectingEvent::Filter.new(p) expect(q.all.map(&:id)).to contain_exactly(ce2.id) end @@ -161,7 +161,7 @@ end specify '#wkt (POLYGON)' do - query.wkt = wkt_point + query.wkt = wkt_polygon expect(query.all.map(&:id)).to contain_exactly(ce1.id) end From 599b3d6ea7d02ace783d092e4eb52634ee228574 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sun, 30 Jun 2024 21:27:47 -0500 Subject: [PATCH 059/259] #1954 Use Gazetteer.build_geographic_item in create (This commit was more involved until I caught my embarassingly frequent mistake of selecting from GeographicItem expecting to get the most recent one, not one of many that has a cached_total_area of 0 (still not sure why those exist).) --- app/controllers/gazetteers_controller.rb | 15 +++++++++------ .../new_gazetteer/components/NavBar.vue | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/controllers/gazetteers_controller.rb b/app/controllers/gazetteers_controller.rb index 56302ca34b..f5d4c6146f 100644 --- a/app/controllers/gazetteers_controller.rb +++ b/app/controllers/gazetteers_controller.rb @@ -46,7 +46,7 @@ def create @gazetteer = Gazetteer.new(gazetteer_params) begin - shape = Gazetteer.combine_shapes_to_rgeo(shape_params['shapes']) + rgeo_shape = Gazetteer.combine_shapes_to_rgeo(shape_params['shapes']) # TODO make sure these errors work rescue RGeo::Error::RGeoError => e @gazetteer.errors.add(:base, "Invalid WKT: #{e}") @@ -56,13 +56,15 @@ def create @gazetteer.errors.add(:base, e) end - if @gazetteer.errors.include?(:base) || shape.nil? + if @gazetteer.errors.include?(:base) || rgeo_shape.nil? render json: @gazetteer.errors, status: :unprocessable_entity return end - # TODO does this bypass save and set_cached_area? If not, how does that happen? - @gazetteer.geographic_item = GeographicItem.new(geography: shape) + @gazetteer.build_geographic_item( + type: 'GeographicItem::Geography', + geography: rgeo_shape + ) if @gazetteer.save render :show, status: :created, location: @gazetteer @@ -105,8 +107,9 @@ def set_gazetteer end def gazetteer_params - params.require(:gazetteer).permit(:name, :parent_id, :iso_3166_a2, :iso_3166_a3) - end + params.require(:gazetteer).permit(:name, :parent_id, + :iso_3166_a2, :iso_3166_a3) + end def shape_params params.require(:gazetteer).permit(shapes: { geojson: [], wkt: []}) diff --git a/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/NavBar.vue b/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/NavBar.vue index 0b8feaf8eb..1410e85891 100644 --- a/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/NavBar.vue +++ b/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/NavBar.vue @@ -79,7 +79,7 @@ const props = defineProps({ } }) -const emit = defineEmits(['cloneGz, saveGz, resetGz']) +const emit = defineEmits(['cloneGz', 'saveGz', 'resetGz']) const headerLabel = computed(() => { return props.gz.id ? props.gz.name : 'New Gazetteer' From da30f456528aa0b112fe296f42917eb088c030cc Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Mon, 1 Jul 2024 11:03:58 -0500 Subject: [PATCH 060/259] #1954 Add more st_*_sql function and cleanup in GeographicItem As with #to_wkt, the `select_self` version of #area is faster (though I benchmarked from rails console, which spews to the console as it runs - does that make any difference in timing?) # # TODO use select_self a = GeographicItem.where(id:).select( self.class.st_area_sql(GeographicItem::GEOGRAPHY_SQL) ).first['st_area'] # a = select_self( self.class.st_area_sql(GeographicItem::GEOGRAPHY_SQL) )['st_area'] --- app/models/cached_map_item.rb | 4 +- app/models/geographic_item.rb | 304 +++++++++++------------ app/models/geographic_item/deprecated.rb | 11 + spec/models/geographic_item_spec.rb | 12 +- 4 files changed, 166 insertions(+), 165 deletions(-) diff --git a/app/models/cached_map_item.rb b/app/models/cached_map_item.rb index c1a9f077e9..77503cba66 100644 --- a/app/models/cached_map_item.rb +++ b/app/models/cached_map_item.rb @@ -179,7 +179,9 @@ def self.translate_by_spatial_overlap(geographic_item_id, data_origin, buffer) # Refine the pass by smoothing using buffer/st_within return GeographicItem .where(id: a) - .where( GeographicItem.st_buffer_st_within(geographic_item_id, 0.0, buffer) ) + .where( + GeographicItem.st_buffer_st_within_sql(geographic_item_id, 0.0, buffer) + ) .pluck(:id) end diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index f813b616c8..04163cc62b 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -143,65 +143,55 @@ def st_union(geographic_item_scope) .where(id: geographic_item_scope.pluck(:id)) end - # True for those shapes that are within (subsets of) the shape_sql shape. - def within_sql(shape_sql) + def st_covers_sql(shape1_sql, shape2_sql) 'ST_Covers(' \ - "#{shape_sql}, " \ - "#{GeographicItem::GEOMETRY_SQL.to_sql}" \ + "(#{shape1_sql})::geometry, " \ + "(#{shape2_sql})::geometry" \ ')' end + # True for those shapes that are within (subsets of) the shape_sql shape. + def within_sql(shape_sql) + st_covers_sql( + shape_sql, + GeographicItem::GEOMETRY_SQL.to_sql + ) + end + # Note: !! If the target GeographicItem#id crosses the anti-meridian then # you may/will get unexpected results. def within_union_of_sql(*geographic_item_ids) - within_sql("#{ + within_sql( self.items_as_one_geometry_sql(*geographic_item_ids) - }") + ) end - def within_union_of(*geographic_item_ids) - where(within_union_of_sql(*geographic_item_ids)) + def st_coveredby_sql(shape1_sql, shape2_sql) + 'ST_CoveredBy(' \ + "(#{shape1_sql})::geometry, " \ + "(#{shape2_sql})::geometry" \ + ')' end # True for those shapes that cover the shape_sql shape. def covering_sql(shape_sql) - 'ST_Covers(' \ - "#{GeographicItem::GEOMETRY_SQL.to_sql}, " \ - "#{shape_sql}" \ - ')' + st_coveredby_sql( + shape_sql, + GeographicItem::GEOMETRY_SQL.to_sql + ) end # @return [Scope] of items covering the union of geographic_item_ids; # does not include any of geographic_item_ids def covering_union_of(*geographic_item_ids) where( - self.covering_sql( - self.items_as_one_geometry_sql(*geographic_item_ids) + covering_sql( + items_as_one_geometry_sql(*geographic_item_ids) ) ) .not_ids(*geographic_item_ids) end - # @param [Integer] geographic_item_id - # @param [Integer] buffer can be positive or negative, in meters for the - # default geographic case - # @param [Boolean] geometric: whether the buffer should be created using - # geographic (default) or geometric coordinates - # @return [RGeo::Polygon or RGeo::MultiPolygon] - # The buffer of size `buffer` around geographic_item_id - def st_buffer_for_item(geographic_item_id, buffer, geometric: false) - geometric = geometric ? '::geometry' : '' - - GeographicItem.select( - 'ST_Buffer(' \ - "#{GeographicItem::GEOGRAPHY_SQL}#{geometric}, " \ - "#{buffer}" \ - ') AS buffer' - ) - .where(id: geographic_item_id) - .first.buffer - end - def st_distance_sql(shape_sql) 'ST_Distance(' \ "#{GeographicItem::GEOGRAPHY_SQL}, " \ @@ -221,7 +211,6 @@ def st_isvalid_sql(shape_sql) ')' end - def st_isvalidreason_sql(shape_sql) 'ST_IsValidReason(' \ "(#{shape_sql})" \ @@ -240,6 +229,53 @@ def st_minimumboundingradius_sql(shape_sql) ')' end + def st_asgeojson_sql(shape_sql) + 'ST_AsGeoJSON(' \ + "(#{shape_sql})" \ + ')' + end + + def st_geographyfromtext_sql(wkt_sql) + 'ST_GeographyFromText(' \ + "'#{wkt_sql}'" \ + ')' + end + + def st_geomfromtext_sql(wkt_sql) + 'ST_GeomFromText(' \ + "'#{wkt_sql}', " \ + '4326' \ + ')' + end + + def st_centroid_sql(shape_sql) + 'ST_Centroid(' \ + "(#{shape_sql})" \ + ')' + end + + def st_buffer_sql(shape_sql, distance) + 'ST_Buffer(' \ + "(#{shape_sql})::geography," \ + "#{distance}" \ + ')' + end + + def st_intersects_sql(shape1_sql, shape2_sql) + 'ST_Intersects(' \ + "(#{shape1_sql})::geometry, " \ + "(#{shape2_sql})::geometry" \ + ')' + end + + def st_distancespheroid_sql(shape1_sql, shape2_sql) + 'ST_DistanceSpheroid(' \ + "(#{shape1_sql})::geometry, " \ + "(#{shape2_sql})::geometry, " \ + "'#{Gis::SPHEROID}'" \ + ')' + end + # True for those shapes that are within `distance` of (i.e. intersect the # `distance`-buffer of) the shape_sql shape. This is a geography dwithin, # distance is in meters. @@ -251,10 +287,6 @@ def st_dwithin_sql(shape_sql, distance) ')' end - def st_asgeojson_sql(shape_sql) - "ST_AsGeoJSON(#{shape_sql})" - end - # @param [String] wkt # @return [Boolean] # whether or not the wkt intersects with the anti-meridian @@ -274,7 +306,7 @@ def crosses_anti_meridian?(wkt) # a SQL select statement that returns the *geometry* for the # geographic_item with the specified id def select_geometry_sql(geographic_item_id) - "SELECT #{GeographicItem::GEOMETRY_SQL.to_sql} from geographic_items where geographic_items.id = #{geographic_item_id}" + "SELECT #{GeographicItem::GEOMETRY_SQL.to_sql} FROM geographic_items WHERE geographic_items.id = #{geographic_item_id}" end # @param [Integer, String] @@ -283,7 +315,7 @@ def select_geometry_sql(geographic_item_id) # geographic_item with the specified id def select_geography_sql(geographic_item_id) ActiveRecord::Base.send(:sanitize_sql_for_conditions, [ - "SELECT #{GeographicItem::GEOGRAPHY_SQL} from geographic_items where geographic_items.id = ?", + "SELECT #{GeographicItem::GEOGRAPHY_SQL} FROM geographic_items WHERE geographic_items.id = ?", geographic_item_id]) end @@ -295,26 +327,25 @@ def lat_long_sql(choice) f = "'D.DDDDDD'" # TODO: probably a constant somewhere v = (choice == :latitude ? 1 : 2) - 'CASE geographic_items.type ' \ - "WHEN 'GeographicItem::GeometryCollection' THEN split_part(ST_AsLatLonText(ST_Centroid" \ - "(geometry_collection::geometry), #{f}), ' ', #{v}) - WHEN 'GeographicItem::LineString' THEN split_part(ST_AsLatLonText(ST_Centroid(line_string::geometry), " \ - "#{f}), ' ', #{v}) - WHEN 'GeographicItem::MultiPolygon' THEN split_part(ST_AsLatLonText(" \ - "ST_Centroid(multi_polygon::geometry), #{f}), ' ', #{v}) - WHEN 'GeographicItem::Point' THEN split_part(ST_AsLatLonText(" \ - "ST_Centroid(point::geometry), #{f}), ' ', #{v}) - WHEN 'GeographicItem::Polygon' THEN split_part(ST_AsLatLonText(" \ - "ST_Centroid(polygon::geometry), #{f}), ' ', #{v}) - WHEN 'GeographicItem::MultiLineString' THEN split_part(ST_AsLatLonText(" \ - "ST_Centroid(multi_line_string::geometry), #{f} ), ' ', #{v}) - WHEN 'GeographicItem::MultiPoint' THEN split_part(ST_AsLatLonText(" \ - "ST_Centroid(multi_point::geometry), #{f}), ' ', #{v}) - WHEN 'GeographicItem::GeometryCollection' THEN split_part(ST_AsLatLonText(" \ - "ST_Centroid(geometry_collection::geometry), #{f}), ' ', #{v}) - WHEN 'GeographicItem::Geography' THEN split_part(ST_AsLatLonText(" \ - "ST_Centroid(geography::geometry), #{f}), ' ', #{v}) - END as #{choice}" + 'CASE geographic_items.type ' \ + "WHEN 'GeographicItem::GeometryCollection' THEN split_part(ST_AsLatLonText(ST_Centroid" \ + "(geometry_collection::geometry), #{f}), ' ', #{v}) + WHEN 'GeographicItem::LineString' THEN split_part(ST_AsLatLonText(ST_Centroid(line_string::geometry), " \ + "#{f}), ' ', #{v}) + WHEN 'GeographicItem::MultiPolygon' THEN split_part(ST_AsLatLonText(" \ + "ST_Centroid(multi_polygon::geometry), #{f}), ' ', #{v}) + WHEN 'GeographicItem::Point' THEN split_part(ST_AsLatLonText(" \ + "ST_Centroid(point::geometry), #{f}), ' ', #{v}) + WHEN 'GeographicItem::Polygon' THEN split_part(ST_AsLatLonText(" \ + "ST_Centroid(polygon::geometry), #{f}), ' ', #{v}) + WHEN 'GeographicItem::MultiLineString' THEN split_part(ST_AsLatLonText(" \ + "ST_Centroid(multi_line_string::geometry), #{f} ), ' ', #{v}) + WHEN 'GeographicItem::MultiPoint' THEN split_part(ST_AsLatLonText(" \ + "ST_Centroid(multi_point::geometry), #{f}), ' ', #{v}) + WHEN 'GeographicItem::GeometryCollection' THEN split_part(ST_AsLatLonText(" \ + "ST_Centroid(geometry_collection::geometry), #{f}), ' ', #{v}) + WHEN 'GeographicItem::Geography' THEN split_part(ST_AsLatLonText(" \ + "ST_Centroid(geography::geometry), #{f}), ' ', #{v}) END as #{choice}" end # @param [Integer] geographic_item_id @@ -322,8 +353,8 @@ def lat_long_sql(choice) # @return [Scope] of shapes within distance of (i.e. whose # distance-buffer intersects) geographic_item_id def within_radius_of_item_sql(geographic_item_id, distance) - self.st_dwithin_sql( - "#{select_geography_sql(geographic_item_id)}", + st_dwithin_sql( + select_geography_sql(geographic_item_id), distance ) end @@ -336,7 +367,7 @@ def within_radius_of_item(geographic_item_id, distance) # @param [Number] distance (in meters) (positive only?!) # @param [Number] buffer: distance in meters to grow/shrink the shapes checked against (negative allowed) # @return [String] - def st_buffer_st_within(geographic_item_id, distance, buffer = 0) + def st_buffer_st_within_sql(geographic_item_id, distance, buffer = 0) # You can't always switch the buffer to the second argument, even when # distance is 0, without further assumptions (think of buffer being # large negative compared to geographic_item_id, but not another shape)) @@ -353,8 +384,8 @@ def st_buffer_st_within(geographic_item_id, distance, buffer = 0) # @return [String] Shapes within distance of (i.e. whose # distance-buffer intersects) wkt def intersecting_radius_of_wkt_sql(wkt, distance) - self.st_dwithin_sql( - "ST_GeographyFromText('#{wkt}')", + st_dwithin_sql( + st_geographyfromtext_sql(wkt), distance ) end @@ -363,44 +394,14 @@ def intersecting_radius_of_wkt_sql(wkt, distance) # @param [Integer] distance (meters) # @return [String] Those items within the distance-buffer of wkt def within_radius_of_wkt_sql(wkt, distance) - self.within_sql( - "ST_Buffer(ST_GeographyFromText('#{wkt}'), #{distance})" + within_sql( + st_buffer_sql( + st_geographyfromtext_sql(wkt), + distance + ) ) end - # @param [String, Integer, String] - # @return [String] - # a SQL fragment for ST_Covers() function, returns - # all geographic items whose target_shape covers the item supplied's - # source_shape - def st_covers_sql(target_shape = nil, geographic_item_id = nil, - source_shape = nil) - return 'false' if geographic_item_id.nil? || source_shape.nil? || target_shape.nil? - - target_shape_sql = GeographicItem.shape_column_sql(target_shape) - - 'ST_Covers(' \ - "#{target_shape_sql}::geometry, " \ - "(#{geometry_sql(geographic_item_id, source_shape)})" \ - ')' - end - - # @param [String, Integer, String] - # @return [String] - # a SQL fragment for ST_Covers() function, returns - # all geographic items whose target_shape is covered by the item - # supplied's source_shape - def st_coveredby_sql(target_shape = nil, geographic_item_id = nil, source_shape = nil) - return 'false' if geographic_item_id.nil? || source_shape.nil? || target_shape.nil? - - target_shape_sql = GeographicItem.shape_column_sql(target_shape) - - 'ST_CoveredBy(' \ - "#{target_shape_sql}::geometry, " \ - "(#{geometry_sql(geographic_item_id, source_shape)})" \ - ')' - end - # @param [Integer, String] # @return [String] # a SQL fragment that returns the column containing data of the given @@ -408,7 +409,7 @@ def st_coveredby_sql(target_shape = nil, geographic_item_id = nil, source_shape def geometry_sql(geographic_item_id = nil, shape = nil) return 'false' if geographic_item_id.nil? || shape.nil? - "SELECT #{GeographicItem.shape_column_sql(shape)}::geometry FROM " \ + "SELECT #{shape_column_sql(shape)}::geometry FROM " \ "geographic_items WHERE id = #{geographic_item_id}" end @@ -441,9 +442,9 @@ def single_geometry_sql(*geographic_item_ids) def items_as_one_geometry_sql(*geographic_item_ids) geographic_item_ids.flatten! # *ALWAYS* reduce the pile to a single level of ids if geographic_item_ids.count == 1 - "(#{GeographicItem.geometry_for_sql(geographic_item_ids.first)})" + "(#{geometry_for_sql(geographic_item_ids.first)})" else - GeographicItem.single_geometry_sql(geographic_item_ids) + single_geometry_sql(geographic_item_ids) end end @@ -468,7 +469,6 @@ def covered_by_wkt_shifted_sql(wkt) )" end - # TODO: Remove the hard coded 4326 reference # @params [String] wkt # @return [String] SQL fragment limiting geographic items to those # covered by this WKT @@ -476,31 +476,23 @@ def covered_by_wkt_sql(wkt) if crosses_anti_meridian?(wkt) covered_by_wkt_shifted_sql(wkt) else - self.within_sql("ST_GeomFromText('#{wkt}', 4326)") + within_sql( + st_geomfromtext_sql(wkt) + ) end end # @param [Integer] geographic_item_id # @return [String] SQL for geometries def geometry_for_sql(geographic_item_id) - 'SELECT ' + GeographicItem::GEOMETRY_SQL.to_sql + ' AS geometry FROM geographic_items WHERE id = ' \ - "#{geographic_item_id} LIMIT 1" + "SELECT #{GeographicItem::GEOMETRY_SQL.to_sql} AS geometry FROM " \ + "geographic_items WHERE id = #{geographic_item_id} LIMIT 1" end # # Scopes # - # @param [RGeo::Point] rgeo_point - # @return [Scope] - # the geographic items covering this point - # TODO: should be covering_wkt ? - def covering_point(rgeo_point) - where( - self.covering_sql("ST_GeomFromText('#{rgeo_point}', 4326)") - ) - end - # return [Scope] # A scope that limits the result to those GeographicItems that have a collecting event # through either the geographic_item or the error_geographic_item @@ -549,13 +541,12 @@ def intersecting(shape, *geographic_item_ids) # @TODO change 'id in (?)' to some other sql construct GeographicItem.where(id: pieces.flatten.map(&:id)) else - shape_column = GeographicItem.shape_column_sql(shape) q = geographic_item_ids.flatten.collect { |geographic_item_id| # seems like we want this: http://danshultz.github.io/talks/mastering_activerecord_arel/#/15/2 - 'ST_Intersects(' \ - "#{shape_column}::geometry, " \ - "(#{self.select_geometry_sql(geographic_item_id)})" \ - ')' + st_intersects_sql( + shape_column_sql(shape), + select_geometry_sql(geographic_item_id) + ) }.join(' or ') where(q) @@ -603,10 +594,9 @@ def st_covers(shape, *geographic_items) else q = geographic_items.flatten.collect { |geographic_item| - GeographicItem.st_covers_sql( - shape, - geographic_item.id, - geographic_item.geo_object_type + st_covers_sql( + shape_column_sql(shape), + geometry_sql(geographic_item.id, geographic_item.geo_object_type) ) }.join(' or ') @@ -653,12 +643,13 @@ def st_coveredby(shape, *geographic_items) else q = geographic_items.flatten.collect { |geographic_item| - GeographicItem.st_coveredby_sql( - shape, - geographic_item.id, - geographic_item.geo_object_type + st_coveredby_sql( + shape_column_sql(shape), + geometry_sql(geographic_item.id, geographic_item.geo_object_type) ) }.join(' or ') + + q = 'FALSE' if q.blank? where(q) # .not_including(geographic_items) end end @@ -679,8 +670,8 @@ def not_including(geographic_items) # @param [Integer] geographic_item_id2 # @return [Float] def distance_between(geographic_item_id1, geographic_item_id2) - q = self.st_distance_sql( - self.select_geography_sql(geographic_item_id2) + q = st_distance_sql( + select_geography_sql(geographic_item_id2) ) GeographicItem.where(id: geographic_item_id1).pick(Arel.sql(q)) @@ -691,7 +682,7 @@ def distance_between(geographic_item_id1, geographic_item_id2) # as per #inferred_geographic_name_hierarchy but for Rgeo point def point_inferred_geographic_name_hierarchy(point) self - .covering_point(point) + .where(covering_sql(st_geomfromtext_sql(point.to_s))) .order(cached_total_area: :ASC) .first&.inferred_geographic_name_hierarchy end @@ -783,7 +774,7 @@ def valid_geometry? GeographicItem .where(id:) .select( - self.class.st_isvalid_sql("ST_AsBinary(#{data_column})") + self.class.st_isvalid_sql("#{data_column}::geometry") ).first['st_isvalid'] end @@ -805,7 +796,8 @@ def center_coords def centroid # Gis::FACTORY.point(*center_coords.reverse) return geo_object if geo_object_type == :point - return Gis::FACTORY.parse_wkt(st_centroid) + + Gis::FACTORY.parse_wkt(st_centroid) end # @param [GeographicItem] geographic_item @@ -813,35 +805,30 @@ def centroid # Like st_distance but works with changed and non persisted objects def st_distance_to_geographic_item(geographic_item) unless !persisted? || changed? - a = "(#{GeographicItem.select_geography_sql(id)})" + a = "(#{self.class.select_geography_sql(id)})" else - a = "ST_GeographyFromText('#{geo_object}')" + a = self.class.st_geographyfromtext_sql(geo_object.to_s) end unless !geographic_item.persisted? || geographic_item.changed? - b = "(#{GeographicItem.select_geography_sql(geographic_item.id)})" + b = "(#{self.class.select_geography_sql(geographic_item.id)})" else - b = "ST_GeographyFromText('#{geographic_item.geo_object}')" + b = self.class.st_geographyfromtext_sql(geographic_item.geo_object.to_s) end ActiveRecord::Base.connection.select_value("SELECT ST_Distance(#{a}, #{b})") end - # @param [Integer] geographic_item_id - # @return [Double] distance in meters - def st_distance_spheroid(geographic_item_id) - q = 'ST_DistanceSpheroid(' \ - "(#{GeographicItem.select_geometry_sql(id)}), " \ - "(#{GeographicItem.select_geometry_sql(geographic_item_id)}) ," \ - "'#{Gis::SPHEROID}'" \ - ') as distance' - GeographicItem.where(id:).pick(Arel.sql(q)) - end - # @return [String] # a WKT POINT representing the centroid of the geographic item def st_centroid - GeographicItem.where(id:).pick(Arel.sql("ST_AsEWKT(ST_Centroid(#{GeographicItem::GEOMETRY_SQL.to_sql}))")).gsub(/SRID=\d*;/, '') + GeographicItem + .where(id:) + .pick(Arel.sql( + self.class.st_astext_sql( + self.class.st_centroid_sql(GeographicItem::GEOMETRY_SQL.to_sql) + ) + )) end # !!TODO: migrate these to use native column calls @@ -962,14 +949,13 @@ def to_wkt end end - # @return [Float] in meters, calculated + # @return [Float] in meters, calculated # TODO: share with world # Geographic item 96862 (Cajamar in Brazil) is the only(?) record to fail using `false` (quicker) method of everything we tested def area - # TODO use select_self - a = GeographicItem.where(id:).select( + a = select_self( self.class.st_area_sql(GeographicItem::GEOGRAPHY_SQL) - ).first['st_area'] + )['st_area'] a = nil if a.nan? a @@ -1083,8 +1069,6 @@ def multi_polygon_column # A paren-wrapped SQL fragment for selecting the column containing # the given shape (e.g. a polygon). # Returns the column named :shape if no shape is found. - # !! This should probably never be called except to be put directly in a - # raw ST_* statement as the parameter that matches some shape. def self.shape_column_sql(shape) st_shape = 'ST_' + shape.to_s.camelize @@ -1103,13 +1087,13 @@ def align_winding if (column = multi_polygon_column) column = column.to_s ApplicationRecord.connection.execute( - "UPDATE geographic_items set #{column} = ST_ForcePolygonCCW(#{column}::geometry) + "UPDATE geographic_items SET #{column} = ST_ForcePolygonCCW(#{column}::geometry) WHERE id = #{self.id};" ) elsif (column = polygon_column) column = column.to_s ApplicationRecord.connection.execute( - "UPDATE geographic_items set #{column} = ST_ForcePolygonCCW(#{column}::geometry) + "UPDATE geographic_items SET #{column} = ST_ForcePolygonCCW(#{column}::geometry) WHERE id = #{self.id};" ) end diff --git a/app/models/geographic_item/deprecated.rb b/app/models/geographic_item/deprecated.rb index 5cc0b6128a..802d5d55a4 100644 --- a/app/models/geographic_item/deprecated.rb +++ b/app/models/geographic_item/deprecated.rb @@ -357,6 +357,17 @@ def st_npoints GeographicItem.where(id:).pick(Arel.sql("ST_NPoints(#{GeographicItem::GEOMETRY_SQL.to_sql}) as npoints")) end + # DEPRECATED + # @param [Integer] geographic_item_id + # @return [Double] distance in meters + def st_distance_spheroid(geographic_item_id) + q = self.class.st_distancespheroid_sql( + self.class.select_geometry_sql(id), + self.class.select_geometry_sql(geographic_item_id) + ) + GeographicItem.where(id:).pick(Arel.sql(q)) + end + private # @param [RGeo::Point] point diff --git a/spec/models/geographic_item_spec.rb b/spec/models/geographic_item_spec.rb index da426d39d1..f6265618d4 100644 --- a/spec/models/geographic_item_spec.rb +++ b/spec/models/geographic_item_spec.rb @@ -746,12 +746,16 @@ before { [p1, p2, p3, p11, p12, k, l].each } specify 'find the points in a polygon' do - expect(GeographicItem.within_union_of(k.id).to_a).to contain_exactly(p1, p2, p3, k) + expect( + GeographicItem.where( + GeographicItem.within_union_of_sql(k.id) + ).to_a + ).to contain_exactly(p1, p2, p3, k) end specify 'find the (overlapping) points in a polygon' do overlapping_point = FactoryBot.create(:geographic_item_point, point: point12.as_binary) - expect(GeographicItem.within_union_of(e1.id).to_a).to contain_exactly(p12, overlapping_point, p11, e1) + expect(GeographicItem.where(GeographicItem.within_union_of_sql(e1.id)).to_a).to contain_exactly(p12, overlapping_point, p11, e1) end end @@ -1779,12 +1783,12 @@ before { [p1, p2, p3, p11, p12, k, l].each } specify 'find the points in a polygon' do - expect(GeographicItem.within_union_of(k.id).to_a).to contain_exactly(p1, p2, p3, k) + expect(GeographicItem.where(GeographicItem.within_union_of_sql(k.id))).to_a.to contain_exactly(p1, p2, p3, k) end specify 'find the (overlapping) points in a polygon' do overlapping_point = FactoryBot.create(:geographic_item_point, point: point12.as_binary) - expect(GeographicItem.within_union_of(e1.id).to_a).to contain_exactly(p12, overlapping_point, p11, e1) + expect(GeographicItem.where(GeographicItem.within_union_of_sql(e1.id))).to_a.to contain_exactly(p12, overlapping_point, p11, e1) end end From d5d33ee9dc840366451306481b96c209ea4af058 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Mon, 1 Jul 2024 20:10:47 -0500 Subject: [PATCH 061/259] #1954 Remove duplicated GoegraphicItem specs Why oh why oh why didn't I do this a long time ago (because I thought I was going to add new specs as I introduced geography, lol), --- spec/models/geographic_item_spec.rb | 1031 --------------------------- 1 file changed, 1031 deletions(-) diff --git a/spec/models/geographic_item_spec.rb b/spec/models/geographic_item_spec.rb index f6265618d4..fd4135cec1 100644 --- a/spec/models/geographic_item_spec.rb +++ b/spec/models/geographic_item_spec.rb @@ -1040,1037 +1040,6 @@ end end - context 'a single geography column holding any type of shape' do - let(:geographic_item) { GeographicItem.new } - - let(:geo_json) { - '{ - "type": "Feature", - "geometry": { - "type": "Point", - "coordinates": [10, 10] - }, - "properties": { - "name": "Sample Point", - "description": "This is a sample point feature." - } - }' - } - - let(:geo_json2) { - '{ - "type": "Feature", - "geometry": { - "type": "Point", - "coordinates": [20, 20] - }, - "properties": { - "name": "Sample Point", - "description": "This is a sample point feature." - } - }' - } - - specify '#shape=' do - g = GeographicItem.new(shape: geo_json) - expect(g.save).to be_truthy - end - - specify '#shape= 2' do - g = GeographicItem.create!(shape: geo_json) - g.update(shape: geo_json2) - expect(g.reload.geo_object.to_s).to match(/20/) - end - - specify '#shape= bad linear ring' do - bad = '{ - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [-80.498221, 25.761437], - [-80.498221, 25.761959], - [-80.498221, 25.761959], - [-80.498221, 25.761437] - ] - ] - }, - "properties": {} - }' - - g = GeographicItem.new(shape: bad) - g.valid? - expect(g.errors[:base]).to be_present - end - - context 'using ce_test_objects' do - let(:geographic_item) { FactoryBot.build(:geographic_item) } - let(:geographic_item_with_point_a) { FactoryBot.build(:geographic_item_with_point_a) } - let(:geographic_item_with_point_b) { FactoryBot.build(:geographic_item_with_point_b) } - let(:geographic_item_with_point_c) { FactoryBot.build(:geographic_item_with_point_c) } - let(:geographic_item_with_line_string) { FactoryBot.build(:geographic_item_with_line_string) } - let(:geographic_item_with_polygon) { FactoryBot.build(:geographic_item_with_polygon) } - let(:geographic_item_with_multi_polygon) { FactoryBot.build(:geographic_item_with_multi_polygon) } - -=begin - context 'database functions' do - - specify 'ST_Geometry_Same' do - skip - #expect(GeographicItem.same(geographic_item_with_line_string.geo_object, - # geographic_item_with_line_string.geo_object)).to be_truthy - #expect(GeographicItem.same(geographic_item_with_line_string.geo_object, - # geographic_item_with_polygon.geo_object)).to be_falsey - end - - specify 'ST_Area' do - skip - #expect(GeographicItem.area(geographic_item_with_polygon.geo_object)).to eq 0.123 - end - - specify 'ST_Azimuth' do - skip - #expect(GeographicItem.azimuth(geographic_item_with_point_a.geo_object, - # geographic_item_with_point_b.geo_object)).to eq 44.5 - #expect(GeographicItem.azimuth(geographic_item_with_point_b.geo_object, - # geographic_item_with_point_a.geo_object)).to eq 44.5 - #expect(GeographicItem.azimuth(geographic_item_with_point_a.geo_object, - # geographic_item_with_point_a.geo_object)).to eq 44.5 - end - - specify 'ST_Centroid' do - skip - #expect(GeographicItem.centroid(geographic_item_with_polygon.polygon)).to eq geographic_item_with_point_c - end - - specify 'ST_Contains' do - skip - #expect(GeographicItem.contains(geographic_item_with_polygon.geo_object, - # geographic_item_with_point_c.geo_object)).to be_truthy - #expect(GeographicItem.contains(geographic_item_with_point_c.geo_object, - # geographic_item_with_polygon.geo_object)).to be_falsey - #expect(GeographicItem.contains(geographic_item_with_polygon.geo_object, - # geographic_item_with_polygon.geo_object)).to be_truthy - end - - specify 'self.find_contains ' do - skip 'building a City of Champaign shape, and a point inside it' - end - - specify 'ST_ContainsProperly ' do - skip - #expect(GeographicItem.contains_properly(geographic_item_with_polygon.geo_object, - # geographic_item_with_point_c.geo_object)).to be_truthy - #expect(GeographicItem.contains_properly(geographic_item_with_point_c.geo_object, - # geographic_item_with_polygon.geo_object)).to be_falsey - end - - specify 'ST_Covers' do - skip - #expect(GeographicItem.covers(geographic_item_with_polygon.geo_object, - # geographic_item_with_point_c.geo_object)).to be_truthy - #expect(GeographicItem.covers(geographic_item_with_point_c.geo_object, - # geographic_item_with_polygon.geo_object)).to be_falsey - end - - specify 'ST_CoveredBy' do - skip - #expect(GeographicItem.covers(geographic_item_with_polygon.geo_object, - # geographic_item_with_point_c.geo_object)).to be_truthy - #expect(GeographicItem.covers(geographic_item_with_point_c.geo_object, - # geographic_item_with_polygon.geo_object)).to be_falsey - end - - specify 'ST_Crosses' do - skip - #expect(GeographicItem.covers(geographic_item_with_polygon.geo_object, - # geographic_item_with_point_c.geo_object)).to be_truthy - #expect(GeographicItem.covers(geographic_item_with_point_c.geo_object, - # geographic_item_with_polygon.geo_object)).to be_falsey - end - - specify 'ST_LineCrossingDirection' do - skip - #expect(GeographicItem.covers(geographic_item_with_polygon.geo_object, - # geographic_item_with_point_c.geo_object)).to be_truthy - #expect(GeographicItem.covers(geographic_item_with_point_c.geo_object, - # geographic_item_with_polygon.geo_object)).to be_falsey - end - - specify 'ST_Disjoint' do - skip - #expect(GeographicItem.covers(geographic_item_with_polygon.geo_object, - # geographic_item_with_point_c.geo_object)).to be_truthy - #expect(GeographicItem.covers(geographic_item_with_point_c.geo_object, - # geographic_item_with_polygon.geo_object)).to be_falsey - end - - specify 'ST_Distance' do - skip - #expect(GeographicItem.covers(geographic_item_with_polygon.geo_object, - # geographic_item_with_point_c.geo_object)).to be_truthy - #expect(GeographicItem.covers(geographic_item_with_point_c.geo_object, - # geographic_item_with_polygon.geo_object)).to be_falsey - end - - end -=end - - - # TODO: remove, redundant with single Factory use - specify 'Two different object types share the same factory.' do - # r is the result of an intersection - # p16 uses the config.default factory - expect(r.factory.projection_factory).to eq(p16_on_a.factory.projection_factory) - end - - context 'STI' do - context 'type is set before validation when column is provided (assumes type is null)' do - GeographicItem::DATA_TYPES.each do |t| - specify "for #{t}" do - geographic_item.send("#{t}=", simple_shapes[t]) - expect(geographic_item.valid?).to be_truthy - expect(geographic_item.type).to eq("GeographicItem::#{t.to_s.camelize}") - end - end - end - - specify '#geo_object_type' do - expect(geographic_item).to respond_to(:geo_object_type) - end - - specify '#geo_object_type when item not saved' do - geographic_item.point = simple_shapes[:point] - expect(geographic_item.geo_object_type).to eq(:point) - end - end - - context 'validation' do - before(:each) { - geographic_item.valid? - } - - specify 'some data must be provided' do - expect(geographic_item.errors[:base]).to be_present - end - - specify 'invalid data for point is invalid' do - geographic_item.point = 'Some string' - expect(geographic_item.valid?).to be_falsey - end - - specify 'a valid point is valid' do - expect(geographic_item_with_point_a.valid?).to be_truthy - end - - specify 'A good point that didn\'t change.' do - expect(geographic_item_with_point_a.point.x).to eq -88.241413 - end - - specify 'a point, when provided, has a legal geography' do - geographic_item.point = RSPEC_GEO_FACTORY.point(180.0, 85.0) - expect(geographic_item.valid?).to be_truthy - end - - specify 'One and only one of point, line_string, etc. is set.' do - geographic_item_with_point_a.polygon = geographic_item_with_polygon.polygon - expect(geographic_item_with_point_a.valid?).to be_falsey - end - end - - context 'geo_object interactions (Geographical attribute of GeographicItem)' do - - context 'Any line_string can be made into polygons.' do - specify 'non-closed line string' do - expect(RSPEC_GEO_FACTORY.polygon(list_k).to_s).to eq('POLYGON ((-33.0 -11.0 0.0, -33.0 -23.0 0.0, -21.0 -23.0 0.0, -21.0 -11.0 0.0, -27.0 -13.0 0.0, -33.0 -11.0 0.0))') - end - - specify 'closed line string' do - expect(RSPEC_GEO_FACTORY.polygon(d.geo_object).to_s).to eq('POLYGON ((-33.0 11.0 0.0, -24.0 4.0 0.0, -26.0 13.0 0.0, -38.0 14.0 0.0, -33.0 11.0 0.0))') - end - end - - specify 'That one object contains another, or not.' do - expect(k.contains?(p1.geo_object)).to be_truthy - end - - specify 'That one object contains another, or not.' do - expect(k.contains?(p17.geo_object)).to be_falsey - end - - specify 'That one object contains another, or not.' do - expect(p17.within?(k.geo_object)).to be_falsey - end - - specify 'That one object contains another, or not.' do - expect(p17.within?(k.geo_object)).to be_falsey - end - - specify 'That one object intersects another, or not.' do # using geographic_item.intersects? - expect(e1.intersects?(e2.geo_object)).to be_truthy - end - - specify 'That one object intersects another, or not.' do # using geographic_item.intersects? - expect(e1.intersects?(e3.geo_object)).to be_falsey - end - - specify 'That one object intersects another, or not.' do # using geographic_item.intersects? - expect(p1.intersects?(k.geo_object)).to be_truthy - end - - specify 'That one object intersects another, or not.' do # using geographic_item.intersects? - expect(p17.intersects?(k.geo_object)).to be_falsey - end - - specify 'Two polygons may have various intersections.' do - expect(shapeE1.intersects?(shapeE2)).to be_truthy - end - - specify 'Two polygons may have various intersections.' do - expect(shapeE1.intersects?(shapeE3)).to be_falsey - end - - specify 'Two polygons may have various intersections.' do - expect(shapeE1.overlaps?(shapeE2)).to be_truthy - end - - specify 'Two polygons may have various intersections.' do - expect(shapeE1.overlaps?(shapeE3)).to be_falsey - end - - specify 'Two polygons may have various intersections.' do - expect(shapeE1.intersection(shapeE2)).to eq(e1_and_e2) - end - - specify 'Two polygons may have various intersections.' do - expect(shapeE1.intersection(shapeE4)).to eq(e1_and_e4) - end - - specify 'Two polygons may have various intersections.' do - expect(shapeE1.union(shapeE2)).to eq(e1_or_e2) - end - - specify 'Two polygons may have various intersections.' do - expect(shapeE1.union(shapeE5)).to eq(e1_or_e5) - end - - specify 'Two polygons may have various adjacencies.' do - expect(shapeE1.touches?(shapeE5)).to be_falsey - end - - specify 'Two polygons may have various adjacencies.' do - expect(shapeE2.touches?(shapeE3)).to be_truthy - end - - specify 'Two polygons may have various adjacencies.' do - expect(shapeE2.touches?(shapeE5)).to be_falsey - end - - specify 'Two polygons may have various adjacencies.' do - expect(shapeE1.disjoint?(shapeE5)).to be_truthy - end - - specify 'Two polygons may have various adjacencies.' do - expect(shapeE2.disjoint?(shapeE5)).to be_truthy - end - - specify 'Two polygons may have various adjacencies.' do - expect(shapeE2.disjoint?(shapeE4)).to be_falsey - end - - specify 'Two different object types have various intersections.' do - # Now that these are the same factory the equivalence is the "same" - expect(r).to eq(p16_on_a) - end - - specify 'Two different object types have various intersections.' do - expect(l.geo_object.intersects?(k.geo_object)).to be_truthy - end - - specify 'Two different object types have various intersections.' do - expect(l.geo_object.intersects?(e.geo_object)).to be_falsey - end - - specify 'Two different object types have various intersections.' do - expect(f.geo_object.geometry_n(0).intersection(f.geo_object.geometry_n(1))).to be_truthy - end - - specify 'Objects can be related by distance' do - expect(p17.geo_object.distance(k.geo_object)).to be < p10.geo_object.distance(k.geo_object) - end - - specify 'Objects can be related by distance' do - expect(k.near(p1.geo_object, 0)).to be_truthy - end - - specify 'Objects can be related by distance' do - expect(k.near(p17.geo_object, 2)).to be_truthy - end - - specify 'Objects can be related by distance' do - expect(k.near(p10.geo_object, 5)).to be_falsey - end - - specify 'Objects can be related by distance' do - expect(k.far(p1.geo_object, 0)).to be_falsey - end - - specify 'Objects can be related by distance' do - expect(k.far(p17.geo_object, 1)).to be_truthy - end - - specify 'Objects can be related by distance' do - expect(k.far(p10.geo_object, 5)).to be_truthy - end - - specify 'Outer Limits' do - expect(all_items.geo_object.convex_hull()).to eq(convex_hull) - end - end - - context 'That GeographicItems provide certain methods.' do - before { - geographic_item.point = room2024 - geographic_item.valid? - } - specify 'self.geo_object returns stored data' do - expect(geographic_item.save!).to be_truthy - end - - specify 'self.geo_object returns stored data' do - geographic_item.save! - _geo_id = geographic_item.id - expect(geographic_item.geo_object).to eq(room2024) - end - - specify 'self.geo_object returns stored data' do - geographic_item.save! - geo_id = geographic_item.id - expect(GeographicItem.find(geo_id).geo_object).to eq geographic_item.geo_object - end - end - - context 'instance methods' do - specify '#geo_object' do - expect(geographic_item).to respond_to(:geo_object) - end - - specify '#contains? - to see if one object is contained by another.' do - expect(geographic_item).to respond_to(:contains?) - end - - specify '#within? - to see if one object is within another.' do - expect(geographic_item).to respond_to(:within?) - end - - specify '#near' do - expect(geographic_item).to respond_to(:near) - end - - specify '#far' do - expect(geographic_item).to respond_to(:far) - end - - specify '#contains? if one object is inside the area defined by the other (watch out for holes)' do - expect(k.contains?(p1.geo_object)).to be_truthy - end - - specify '#contains? if one object is inside the area defined by the other (watch out for holes)' do - expect(e1.contains?(p10.geo_object)).to be_falsey - end - - specify '#st_npoints returns the number of included points for a valid GeoItem' do - expect(p0.st_npoints).to eq(1) - end - - specify '#st_npoints returns the number of included points for a valid GeoItem' do - expect(a.st_npoints).to eq(4) - end - - specify '#st_npoints returns the number of included points for a valid GeoItem' do - expect(b.st_npoints).to eq(13) - end - - specify '#st_npoints returns the number of included points for a valid GeoItem' do - expect(h.st_npoints).to eq(5) - end - - specify '#st_npoints returns the number of included points for a valid GeoItem' do - expect(f.st_npoints).to eq(4) - end - - specify '#st_npoints returns the number of included points for a valid GeoItem' do - expect(g.st_npoints).to eq(12) - end - - specify '#st_npoints returns the number of included points for a valid GeoItem' do - expect(all_items.st_npoints).to eq(157) - end - - specify '#st_npoints returns the number of included points for a valid GeoItem' do - expect(outer_limits.st_npoints).to eq(7) - end - - specify '#valid_geometry? returns \'true\' for a valid GeoObject' do - expect(p0.valid_geometry?).to be_truthy - end - - specify '#valid_geometry? returns \'true\' for a valid GeoObject' do - expect(a.valid_geometry?).to be_truthy - end - - specify '#valid_geometry? returns \'true\' for a valid GeoObject' do - expect(b.valid_geometry?).to be_truthy - end - - specify '#valid_geometry? returns \'true\' for a valid GeoObject' do - expect(h.valid_geometry?).to be_truthy - end - - specify '#valid_geometry? returns \'true\' for a valid GeoObject' do - expect(f.valid_geometry?).to be_truthy - end - - specify '#valid_geometry? returns \'true\' for a valid GeoObject' do - expect(g.valid_geometry?).to be_truthy - end - - specify '#valid_geometry? returns \'true\' for a valid GeoObject' do - expect(all_items.valid_geometry?).to be_truthy - end - - specify '#st_centroid returns a lat/lng of the centroid of the GeoObject' do - expect(new_box_a.st_centroid).to eq('POINT(5 5)') - end - - specify '#center_coords' do - expect(new_box_a.center_coords).to eq(['5.000000', '5.000000']) - end - - context '#shape on new' do - let(:object) { GeographicItem.new } - # '[40.190063612251016, -111.58300638198853]' - specify 'for point' do - object.shape = '{"type":"Feature","geometry":{"type":"Point",' \ - '"coordinates":[-88.0975631475394,40.45993808344767]},' \ - '"properties":{"name":"Paxton City Hall"}}' - expect(object.valid?).to be_truthy - end - - specify 'for polygon' do - object.shape = '{"type":"Feature","geometry":{"type":"Polygon",' \ - '"coordinates":[[[-90.25122106075287,38.619731572825145],[-86.12036168575287,39.77758382625017],' \ - '[-87.62384042143822,41.89478088863241],[-90.25122106075287,38.619731572825145]]]},"properties":{}}' - expect(object.valid?).to be_truthy - end - - specify 'for linestring' do - object.shape = '{"type":"Feature","geometry":{"type":"LineString","coordinates":[' \ - '[-90.25122106075287,38.619731572825145],' \ - '[-86.12036168575287,39.77758382625017],' \ - '[-87.62384042143822,41.89478088863241]]},"properties":{}}' - expect(object.valid?).to be_truthy - end - - specify 'for circle' do - object.shape = '{"type":"Feature","geometry":{"type":"Point",' \ - '"coordinates":[-88.09681320155505,40.461195702960666]},' \ - '"properties":{"radius":1468.749413840412, "name":"Paxton City Hall"}}' - expect(object.valid?).to be_truthy - end - end - - context '#centroid' do - specify 'for point' do - expect(r2024.centroid.to_s).to eq('POINT (-88.241413 40.091655 0.0)') - end - - specify 'for line_string' do - expect(c1.centroid.to_s).to match(/POINT \(16\.461453\d* 19\.276957\d* 0\.0\)/) - end - - specify 'for polygon' do - expect(b.centroid.to_s).to match(/POINT \(-8\.091346\d* 16\.666666\d* 0\.0\)/) - end - - specify 'for multi_point' do - expect(h.centroid.to_s).to match(/POINT \(5\.0 -15\.7(4|399999\d*) 0\.0\)/) # TODO: Review the way this is being check (and the others too actually) - end - - specify 'for multi_line_string' do - expect(c.centroid.to_s).to match(/POINT \(16\.538756\d* 15\.300166\d* 0\.0\)/) - end - - specify 'for multi_polygon' do - expect(g.centroid.to_s).to match(/POINT \(21\.126454\d* -3.055235\d* 0\.0\)/) - end - - specify 'for geometry_collection' do - expect(j.centroid.to_s).to match(/POINT \(21\.126454\d* -3\.055235\d* 0\.0\)/) - end - end - end - - context 'class methods' do - - specify '::ordered_by_shortest_distance_from to specify ordering of found objects.' do - expect(GeographicItem).to respond_to(:ordered_by_shortest_distance_from) - end - - specify '::ordered_by_longest_distance_from' do - expect(GeographicItem).to respond_to(:ordered_by_longest_distance_from) - end - - specify '::disjoint_from to find all objects which are disjoint from an \'and\' list of objects.' do - expect(GeographicItem).to respond_to(:disjoint_from) - end - - specify '::within_radius_of_item to find all objects which are within a specific ' \ - 'distance of a geographic item.' do - expect(GeographicItem).to respond_to(:within_radius_of_item) - end - - specify '::intersecting method to intersecting an \'or\' list of objects.' do - expect(GeographicItem).to respond_to(:intersecting) - end - - specify '::eval_for_type' do - expect(GeographicItem.eval_for_type('polygon')).to eq('GeographicItem::Polygon') - end - - specify '::eval_for_type' do - expect(GeographicItem.eval_for_type('linestring')).to eq('GeographicItem::LineString') - end - - specify '::eval_for_type' do - expect(GeographicItem.eval_for_type('point')).to eq('GeographicItem::Point') - end - - specify '::eval_for_type' do - expect(GeographicItem.eval_for_type('other_thing')).to eq(nil) - end - - context 'scopes (GeographicItems can be found by searching with) ' do - before { - [ce_a, ce_b, gr_a, gr_b].each - } - - specify '::geo_with_collecting_event' do - expect(GeographicItem.geo_with_collecting_event.to_a).to include(p_a, p_b) # - end - - specify '::geo_with_collecting_event' do - expect(GeographicItem.geo_with_collecting_event.to_a).not_to include(e4) - end - - specify '::err_with_collecting_event' do - expect(GeographicItem.err_with_collecting_event.to_a).to include(new_box_a, err_b) # - end - - specify '::err_with_collecting_event' do - expect(GeographicItem.err_with_collecting_event.to_a).not_to include(g, p17) - end - - specify '::with_collecting_event_through_georeferences' do - expect(GeographicItem.with_collecting_event_through_georeferences.order('id').to_a) - .to contain_exactly(new_box_a, p_a, p_b, err_b) # - end - - specify '::with_collecting_event_through_georeferences' do - expect(GeographicItem.with_collecting_event_through_georeferences.order('id').to_a) - .not_to include(e4) - end - - specify '::include_collecting_event' do - expect(GeographicItem.include_collecting_event.to_a) - .to include(new_box_b, new_box_a, err_b, p_a, p_b, new_box_e) - end - - context '::containing' do - before { [k, l, b, b1, b2, e1].each } - - specify 'find the polygon containing the points' do - expect(GeographicItem.covering_union_of(p1.id).to_a).to contain_exactly(k) - end - - specify 'find the polygon containing all three points' do - expect(GeographicItem.covering_union_of(p1.id, p2.id, p3.id).to_a).to contain_exactly(k) - end - - specify 'find that a line string can contain a point' do - expect(GeographicItem.covering_union_of(p4.id).to_a).to contain_exactly(l) - end - - specify 'point in two polygons, but not their intersection' do - expect(GeographicItem.covering_union_of(p18.id).to_a).to contain_exactly(b1, b2) - end - - specify 'point in two polygons, one with a hole in it' do - expect(GeographicItem.covering_union_of(p19.id).to_a).to contain_exactly(b1, b) - end - end - - context '::are_contained_in - returns objects which contained in another object.' do - before { [p0, p1, p2, p3, p12, p13, b1, b2, b, e1, e2, k].each } - - # OR! - specify 'three things inside and one thing outside k' do - expect(GeographicItem.st_covers('polygon', - [p1, p2, p3, p11]).to_a) - .to contain_exactly(e1, k) - end - - # OR! - specify 'one thing inside one thing, and another thing inside another thing' do - expect(GeographicItem.st_covers('polygon', - [p1, p11]).to_a) - .to contain_exactly(e1, k) - end - - specify 'one thing inside k' do - expect(GeographicItem.st_covers('polygon', p1).to_a).to eq([k]) - end - - specify 'three things inside k (in array)' do - expect(GeographicItem.st_covers('polygon', - [p1, p2, p3]).to_a) - .to eq([k]) - end - - specify 'three things inside k (as separate parameters)' do - expect(GeographicItem.st_covers('polygon', p1, - p2, - p3).to_a) - .to eq([k]) - end - - specify 'one thing outside k' do - expect(GeographicItem.st_covers('polygon', p4).to_a) - .to eq([]) - end - - specify ' one thing inside two things (overlapping)' do - expect(GeographicItem.st_covers('polygon', p12).to_a.sort) - .to contain_exactly(e1, e2) - end - - specify 'three things inside and one thing outside k' do - expect(GeographicItem.st_covers('polygon', - [p1, p2, - p3, p11]).to_a) - .to contain_exactly(e1, k) - end - - specify 'one thing inside one thing, and another thing inside another thing' do - expect(GeographicItem.st_covers('polygon', - [p1, p11]).to_a) - .to contain_exactly(e1, k) - end - - specify 'two things inside one thing, and (1)' do - expect(GeographicItem.st_covers('polygon', p18).to_a) - .to contain_exactly(b1, b2) - end - - specify 'two things inside one thing, and (2)' do - expect(GeographicItem.st_covers('polygon', p19).to_a) - .to contain_exactly(b1, b) - end - end - - context '::contained_by' do - before { [p1, p2, p3, p11, p12, k, l].each } - - specify 'find the points in a polygon' do - expect(GeographicItem.where(GeographicItem.within_union_of_sql(k.id))).to_a.to contain_exactly(p1, p2, p3, k) - end - - specify 'find the (overlapping) points in a polygon' do - overlapping_point = FactoryBot.create(:geographic_item_point, point: point12.as_binary) - expect(GeographicItem.where(GeographicItem.within_union_of_sql(e1.id))).to_a.to contain_exactly(p12, overlapping_point, p11, e1) - end - end - - context '::is_contained_by - returns objects which are contained by other objects.' do - before { [b, p0, p1, p2, p3, p11, p12, p13, p18, p19].each } - - specify ' three things inside k' do - expect(GeographicItem.st_coveredby('any', k).not_including(k).to_a) - .to contain_exactly(p1, p2, p3) - end - - specify 'one thing outside k' do - expect(GeographicItem.st_coveredby('any', p4).not_including(p4).to_a).to eq([]) - end - - specify 'three things inside and one thing outside k' do - pieces = GeographicItem.st_coveredby('any', - [e2, k]).not_including([k, e2]).to_a - # p_a just happens to be in context because it happens to be the - # GeographicItem of the Georeference g_a defined in an outer - # context - expect(pieces).to contain_exactly(p0, p1, p2, p3, p12, p13, p_a) # , @p12c - - end - - # other objects are returned as well, we just don't care about them: - # we want to find p1 inside K, and p11 inside e1 - specify 'one specific thing inside one thing, and another specific thing inside another thing' do - expect(GeographicItem.st_coveredby('any', - [e1, k]).to_a) - .to include(p1, p11) - end - - specify 'one thing (p19) inside a polygon (b) with interior, and another inside ' \ - 'the interior which is NOT included (p18)' do - expect(GeographicItem.st_coveredby('any', b).not_including(b).to_a).to eq([p19]) - end - - specify 'three things inside two things. Notice that the outer ring of b ' \ - 'is co-incident with b1, and thus "contained".' do - expect(GeographicItem.st_coveredby('any', - [b1, b2]).not_including([b1, b2]).to_a) - .to contain_exactly(p18, p19, b) - end - - # other objects are returned as well, we just don't care about them - # we want to find p19 inside b and b1, but returned only once - specify 'both b and b1 contain p19, which gets returned only once' do - expect(GeographicItem.st_coveredby('any', - [b1, b]).to_a) - .to include(p19) - end - end - - context '::not_including([])' do - before { [p1, p4, p17, r2024, r2022, r2020, p10].each { |object| object } } - - specify 'drop specifc item[s] from any scope (list of objects.)' do - # @p2 would have been in the list, except for the exclude - expect(GeographicItem.not_including([p2]) - .ordered_by_shortest_distance_from('point', p3) - .limit(3).to_a) - .to eq([p1, p4, p17]) - end - - specify 'drop specifc item[s] from any scope (list of objects.)' do - # @p2 would *not* have been in the list anyway - expect(GeographicItem.not_including([p2]) - .ordered_by_longest_distance_from('point', p3) - .limit(3).to_a) - .to eq([r2024, r2022, r2020]) - end - - specify 'drop specifc item[s] from any scope (list of objects.)' do - # @r2022 would have been in the list, except for the exclude - expect(GeographicItem.not_including([r2022]) - .ordered_by_longest_distance_from('point', p3) - .limit(3).to_a) - .to eq([r2024, r2020, p10]) - end - end - - # specify '::not_including_self to drop self from any list of objects' do - # skip 'construction of scenario' - # expect(GeographicItem.ordered_by_shortest_distance_from('point', @p7).limit(5)).to_a).to eq([@p2, @p1, @p4]) - # end - - context '::ordered_by_shortest_distance_from' do - before { [p1, p2, p4, outer_limits, l, f1, e5, e3, e4, h, rooms, f, c, g, e, j].each } - - specify ' orders objects by distance from passed object' do - expect(GeographicItem.ordered_by_shortest_distance_from('point', p3) - .limit(3).to_a) - .to eq([p2, p1, p4]) - end - - specify ' orders objects by distance from passed object' do - expect(GeographicItem.ordered_by_shortest_distance_from('line_string', p3) - .limit(3).to_a) - .to eq([outer_limits, l, f1]) - end - - specify ' orders objects by distance from passed object' do - expect(GeographicItem.ordered_by_shortest_distance_from('polygon', p3) - .limit(3).to_a) - .to eq([e5, e3, e4]) - end - - specify ' orders objects by distance from passed object' do - expect(GeographicItem.ordered_by_shortest_distance_from('multi_point', p3) - .limit(3).to_a) - .to eq([h, rooms]) - end - - specify ' orders objects by distance from passed object' do - expect(GeographicItem.ordered_by_shortest_distance_from('multi_line_string', p3) - .limit(3).to_a) - .to eq([f, c]) - end - - specify ' orders objects by distance from passed object' do - subject = GeographicItem.ordered_by_shortest_distance_from('multi_polygon', p3).limit(3).to_a - expect(subject[0..1]).to contain_exactly(new_box_e, new_box_b) # Both boxes are at same distance from p3 - expect(subject[2..]).to eq([new_box_a]) - end - - specify ' orders objects by distance from passed object' do - expect(GeographicItem.ordered_by_shortest_distance_from('geometry_collection', p3) - .limit(3).to_a) - .to eq([e, j]) - end - end - - context '::ordered_by_longest_distance_from' do - before { - [r2024, r2022, r2020, c3, c1, c2, g1, g2, g3, b2, rooms, h, c, f, g, j, e].each - } - - specify 'orders points by distance from passed point' do - expect(GeographicItem.ordered_by_longest_distance_from('point', p3).limit(3).to_a) - .to eq([r2024, r2022, r2020]) - end - - specify 'orders line_strings by distance from passed point' do - expect(GeographicItem.ordered_by_longest_distance_from('line_string', p3) - .limit(3).to_a) - .to eq([c3, c1, c2]) - end - - specify 'orders polygons by distance from passed point' do - expect(GeographicItem.ordered_by_longest_distance_from('polygon', p3) - .limit(4).to_a) - .to eq([g1, g2, g3, b2]) - end - specify 'orders multi_points by distance from passed point' do - expect(GeographicItem.ordered_by_longest_distance_from('multi_point', p3) - .limit(3).to_a) - .to eq([rooms, h]) - end - - specify 'orders multi_line_strings by distance from passed point' do - expect(GeographicItem.ordered_by_longest_distance_from('multi_line_string', p3) - .limit(3).to_a) - .to eq([c, f]) - end - - specify 'orders multi_polygons by distance from passed point' do - # existing multi_polygons: [new_box_e, new_box_a, new_box_b, g] - # new_box_e is excluded, because p3 is *exactly* the same distance from new_box_e, *and* new_box_a - # This seems to be the reason these two objects *might* be in either order. Thus, one of the two - # is excluded to prevent it from confusing the order (farthest first) of the appearance of the objects. - expect(GeographicItem.ordered_by_longest_distance_from('multi_polygon', p3) - .not_including(new_box_e) - .limit(3).to_a) # TODO: Limit is being called over an array. Check whether this is a gem/rails bug or we need to update code. - .to eq([g, new_box_a, new_box_b]) - end - - specify 'orders objects by distance from passed object geometry_collection' do - expect(GeographicItem.ordered_by_longest_distance_from('geometry_collection', p3) - .limit(3).to_a) - .to eq([j, e]) - end - end - - context '::disjoint_from' do - before { [p1].each } - - specify "list of objects (uses 'and')." do - expect(GeographicItem.disjoint_from('point', - [e1, e2, e3, e4, e5]) - .order(:id) - .limit(1).to_a) - .to contain_exactly(p_b) - end - end - - context '::within_radius_of_item' do - before { [e2, e3, e4, e5, item_a, item_b, item_c, item_d, k, r2022, r2024, p14].each } - - specify 'returns objects within a specific distance of an object.' do - pieces = GeographicItem.within_radius_of_item(p0.id, 1000000) - .where(type: ['GeographicItem::Polygon']) - expect(pieces).to contain_exactly(err_b, e2, e3, e4, e5, item_a, item_b, item_c, item_d) - end - - specify '::within_radius_of_item("any", ...)' do - expect(GeographicItem.within_radius_of_item(p0.id, 1000000)) - .to include(e2, e3, e4, e5, item_a, item_b, item_c, item_d) - end - - specify "::intersecting list of objects (uses 'or')" do - expect(GeographicItem.intersecting('polygon', [l.id])).to eq([k]) - end - - specify "::intersecting list of objects (uses 'or')" do - expect(GeographicItem.intersecting('polygon', [f1.id])) - .to eq([]) # Is this right? - end - - specify '::select_distance_with_geo_object provides an extra column called ' \ - '\'distance\' to the output objects' do - result = GeographicItem.select_distance_with_geo_object('point', r2020) - .limit(3).order('distance') - .where_distance_greater_than_zero('point', r2020).to_a - # get back these three points - expect(result).to eq([r2022, r2024, p14]) - end - - specify '::select_distance_with_geo_object provides an extra column called ' \ - '\'distance\' to the output objects' do - result = GeographicItem.select_distance_with_geo_object('point', r2020) - .limit(3).order('distance') - .where_distance_greater_than_zero('point', r2020).to_a - # 5 meters - expect(result.first.distance).to be_within(0.1).of(5.008268179) - end - - specify '::select_distance_with_geo_object provides an extra column called ' \ - '\'distance\' to the output objects' do - result = GeographicItem.select_distance_with_geo_object('point', r2020) - .limit(3).order('distance') - .where_distance_greater_than_zero('point', r2020).to_a - # 10 meters - expect(result[1].distance).to be_within(0.1).of(10.016536381) - end - - specify '::select_distance_with_geo_object provides an extra column called ' \ - '\'distance\' to the output objects' do - result = GeographicItem.select_distance_with_geo_object('point', r2020) - .limit(3).order('distance') - .where_distance_greater_than_zero('point', r2020).to_a - # 5,862 km (3,642 miles) - expect(result[2].distance).to be_within(0.1).of(5862006.0029975) - end - end - - context 'distance to others' do - specify 'slow' do - expect(p1.st_distance(p2.id)).to be_within(0.1).of(479988.25399881) - end - - specify 'fast' do - expect(p1.st_distance_spheroid(p2.id)).to be_within(0.1).of(479988.253998808) - end - end - end - - context '::gather_geographic_area_or_shape_data' do - specify 'collection_objetcs' do - - end - specify 'asserted_distribution' do - - end - end - end - end # end using ce_test_objects - - context 'concerns' do - it_behaves_like 'is_data' - end - end end From 8176923cafc41a7394257742223859408a26a671 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Mon, 1 Jul 2024 21:10:05 -0500 Subject: [PATCH 062/259] #1954 De-deprecate GeographicItem.crosses_anti_meridian_by_id? Per mjy, "a useful concept to maintain". --- app/models/geographic_item.rb | 14 ++++++++++++++ app/models/geographic_item/deprecated.rb | 14 -------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 04163cc62b..4b5fa05bd9 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -297,6 +297,20 @@ def crosses_anti_meridian?(wkt) ).first.r end + # Unused, kept for reference + # @param [Integer] ids + # @return [Boolean] + # whether or not any GeographicItem passed intersects the anti-meridian + # !! StrongParams security considerations + # This is our first line of defense against queries that define multiple shapes, one or + # more of which crosses the anti-meridian. In this case the current TW strategy within the + # UI is to abandon the search, and prompt the user to refactor the query. + def crosses_anti_meridian_by_id?(*ids) + q = "SELECT ST_Intersects((SELECT single_geometry FROM (#{GeographicItem.single_geometry_sql(*ids)}) as " \ + 'left_intersect), ST_GeogFromText(?)) as r;', ANTI_MERIDIAN + GeographicItem.find_by_sql(q).first.r + end + # # SQL fragments # diff --git a/app/models/geographic_item/deprecated.rb b/app/models/geographic_item/deprecated.rb index 802d5d55a4..8852a69905 100644 --- a/app/models/geographic_item/deprecated.rb +++ b/app/models/geographic_item/deprecated.rb @@ -84,20 +84,6 @@ def default_by_geographic_area_ids(geographic_area_ids = []) where(geographic_areas_geographic_items: {geographic_area_id: geographic_area_ids}) end - # DEPRECATED - # @param [Integer] ids - # @return [Boolean] - # whether or not any GeographicItem passed intersects the anti-meridian - # !! StrongParams security considerations - # This is our first line of defense against queries that define multiple shapes, one or - # more of which crosses the anti-meridian. In this case the current TW strategy within the - # UI is to abandon the search, and prompt the user to refactor the query. - def crosses_anti_meridian_by_id?(*ids) - q = "SELECT ST_Intersects((SELECT single_geometry FROM (#{GeographicItem.single_geometry_sql(*ids)}) as " \ - 'left_intersect), ST_GeogFromText(?)) as r;', ANTI_MERIDIAN - GeographicItem.find_by_sql([q]).first.r - end - # DEPRECATED # rubocop:disable Metrics/MethodLength # @param [String] shape From 39feb83ec7741cd0322482c4cc8dbd7531a73fd0 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Mon, 1 Jul 2024 21:02:35 -0500 Subject: [PATCH 063/259] #1954 Remove all deprecated GeographicItem methods and specs These are all GeographicItem methods that are one of: * unreferenced * referenced only in specs testing themself * referenced only in methods that are referenced only in specs testing themself Also includes GeographicItem subclass methods that weren't in the Deprecated module but nonetheless were unused (save one use of Point#to_a). --- app/models/collecting_event.rb | 7 - app/models/geographic_item.rb | 17 +- app/models/geographic_item/deprecated.rb | 470 ------------------ app/models/geographic_item/geography.rb | 11 - .../geographic_item/geometry_collection.rb | 68 --- app/models/geographic_item/line_string.rb | 15 - .../geographic_item/multi_line_string.rb | 15 - app/models/geographic_item/multi_point.rb | 15 - app/models/geographic_item/multi_polygon.rb | 15 - app/models/geographic_item/point.rb | 15 - app/models/geographic_item/polygon.rb | 18 - app/models/georeference.rb | 7 +- .../geographic_item/anti_meridian_spec.rb | 37 +- .../geometry_collection_spec.rb | 66 --- .../geographic_item/line_string_spec.rb | 13 - .../geographic_item/multi_line_string_spec.rb | 15 - .../geographic_item/multi_point_spec.rb | 15 - .../geographic_item/multi_polygon_spec.rb | 15 - spec/models/geographic_item/point_spec.rb | 12 - spec/models/geographic_item/polygon_spec.rb | 14 - spec/models/geographic_item_spec.rb | 275 +--------- 21 files changed, 6 insertions(+), 1129 deletions(-) delete mode 100644 app/models/geographic_item/deprecated.rb diff --git a/app/models/collecting_event.rb b/app/models/collecting_event.rb index 37dc740b7f..9d57149163 100644 --- a/app/models/collecting_event.rb +++ b/app/models/collecting_event.rb @@ -638,13 +638,6 @@ def geo_json_data end end - # @param [GeographicItem] - # @return [String] - # see how far away we are from another gi - def distance_to(geographic_item_id) - GeographicItem.distance_between(preferred_georeference.geographic_item_id, geographic_item_id) - end - # @param [Double] distance in meters # @return [Scope] def collecting_events_within_radius_of(distance) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 4b5fa05bd9..4702e1e242 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -52,10 +52,6 @@ class GeographicItem < ApplicationRecord include Shared::IsData include Shared::SharedAcrossProjects - # Methods that are deprecated or used only in specs - # TODO move spec-only methods somewhere else? - include GeographicItem::Deprecated - # @return [Hash, nil] # An internal variable for use in super calls, holds a Hash in GeoJSON format (temporarily) attr_accessor :geometry @@ -680,17 +676,6 @@ def not_including(geographic_items) # Other # - # @param [Integer] geographic_item_id1 - # @param [Integer] geographic_item_id2 - # @return [Float] - def distance_between(geographic_item_id1, geographic_item_id2) - q = st_distance_sql( - select_geography_sql(geographic_item_id2) - ) - - GeographicItem.where(id: geographic_item_id1).pick(Arel.sql(q)) - end - # @param [RGeo::Point] point # @return [Hash] # as per #inferred_geographic_name_hierarchy but for Rgeo point @@ -816,7 +801,7 @@ def centroid # @param [GeographicItem] geographic_item # @return [Double] distance in meters - # Like st_distance but works with changed and non persisted objects + # Works with changed and non persisted objects def st_distance_to_geographic_item(geographic_item) unless !persisted? || changed? a = "(#{self.class.select_geography_sql(id)})" diff --git a/app/models/geographic_item/deprecated.rb b/app/models/geographic_item/deprecated.rb deleted file mode 100644 index 8852a69905..0000000000 --- a/app/models/geographic_item/deprecated.rb +++ /dev/null @@ -1,470 +0,0 @@ -module GeographicItem::Deprecated - extend ActiveSupport::Concern - - # - # GeographicItem methods that are currently unused or used only in specs - # - - class_methods do - - # DEPRECATED, used only in specs - def st_distance_item_to_shape(geographic_item_id, shape) - shape_column = GeographicItem.shape_column_sql(shape) - - 'ST_Distance(' \ - "#{shape_column}, " \ - "(#{self.select_geography_sql(geographic_item_id)})" \ - ')' - end - - # DEPRECATED, used only in specs - # @param [String, GeographicItem] - # @return [Scope] - def ordered_by_shortest_distance_from(shape, geographic_item) - select_distance_with_geo_object(shape, geographic_item) - .where_distance_greater_than_zero(shape, geographic_item) - .order('distance') - end - - # DEPRECATED, used only in specs - # @param [String, GeographicItem] - # @return [Scope] - def ordered_by_longest_distance_from(shape, geographic_item) - select_distance_with_geo_object(shape, geographic_item) - .where_distance_greater_than_zero(shape, geographic_item) - .order('distance desc') - end - - # DEPRECATED, used only in specs - # @param [String] shape - # @param [GeographicItem] geographic_item - # @return [String] - def select_distance_with_geo_object(shape, geographic_item) - select( - '*, ' \ - "#{self.st_distance_item_to_shape(geographic_item.id, shape)} " \ - ' AS distance' - ) - end - - # DEPRECATED, used only in specs - # @param [String, GeographicItem] - # @return [Scope] - def where_distance_greater_than_zero(shape, geographic_item) - shape_column = GeographicItem.shape_column_sql(shape) - where( - "#{shape_column} IS NOT NULL AND " \ - "#{self.st_distance_item_to_shape(geographic_item.id, shape)} > 0" - ) - end - - # DEPRECATED - def st_collect(geographic_item_scope) - GeographicItem.select("ST_Collect(#{GeographicItem::GEOMETRY_SQL.to_sql}) as collection") - .where(id: geographic_item_scope.pluck(:id)) - end - - # DEPRECATED, used only in specs - # @param [Integer, Array of Integer] geographic_item_ids - # @return [Scope] - # the geographic items contained by the union of these - # geographic_item ids; return value always includes geographic_item_ids - # (works via ST_Contains) - def contained_by(*geographic_item_ids) - where(GeographicItem.within_union_of_sql(geographic_item_ids)) - end - - # DEPRECATED - # @return [GeographicItem::ActiveRecord_Relation] - # @params [Array] array of geographic area ids - def default_by_geographic_area_ids(geographic_area_ids = []) - GeographicItem. - joins(:geographic_areas_geographic_items). - merge(::GeographicAreasGeographicItem.default_geographic_item_data). - where(geographic_areas_geographic_items: {geographic_area_id: geographic_area_ids}) - end - - # DEPRECATED - # rubocop:disable Metrics/MethodLength - # @param [String] shape - # @param [GeographicItem] geographic_item - # @return [String] of SQL for all GeographicItems of the given shape - # contained by geographic_item - def is_covered_by_sql(shape, geographic_item) - template = "(ST_Contains(#{geographic_item.geo_object}, %s::geometry))" - retval = [] - shape = shape.to_s.downcase - case shape - when 'any' - SHAPE_TYPES.each { |shape| - shape_column = GeographicItem.shape_column_sql(shape) - retval.push(template % shape_column) - } - - when 'any_poly', 'any_line' - SHAPE_TYPES.each { |shape| - if column.to_s.index(shape.gsub('any_', '')) - shape_column = GeographicItem.shape_column_sql(shape) - retval.push(template % shape_column) - end - } - - else - shape_column = GeographicItem.shape_column_sql(shape) - retval = template % shape_column - end - retval = retval.join(' OR ') if retval.instance_of?(Array) - retval - end - # rubocop:enable Metrics/MethodLength - - # DEPRECATED - # @param [Integer, Array of Integer] geographic_item_ids - # @return [String] - # Result doesn't contain self. Much slower than containing_where_sql - def containing_where_sql_geog(*geographic_item_ids) - "ST_CoveredBy( - (#{GeographicItem.items_as_one_geometry(*geographic_item_ids)})::geography, - #{GEOGRAPHY_SQL})" - end - - # DEPRECATED, used only in specs - # @param [Interger, Array of Integer] ids - # @return [Array] - # If we detect that some query id has crossed the meridian, then loop through - # and "manually" build up a list of results. - # Should only be used if GeographicItem.crosses_anti_meridian_by_id? is true. - # Note that this does not return a Scope, so you can't chain it like contained_by? - # TODO: test this - # TODO rename within? - def contained_by_with_antimeridian_check(*ids) - ids.flatten! # make sure there is only one level of splat (*) - results = [] - - crossing_ids = [] - - ids.each do |id| - # push each which crosses - crossing_ids.push(id) if GeographicItem.crosses_anti_meridian_by_id?(id) - end - - non_crossing_ids = ids - crossing_ids - results.push self.where(self.within_union_of_sql(non_crossing_ids)).to_a if non_crossing_ids.any? - - crossing_ids.each do |id| - # [61666, 61661, 61659, 61654, 61639] - q1 = ActiveRecord::Base.send(:sanitize_sql_array, ['SELECT ST_AsText((SELECT polygon FROM geographic_items ' \ - 'WHERE id = ?))', id]) - r = GeographicItem.where( - # GeographicItem.covered_by_wkt_shifted_sql(GeographicItem.find(id).geo_object.to_s) - GeographicItem.covered_by_wkt_shifted_sql( - ApplicationRecord.connection.execute(q1).first['st_astext']) - ).to_a - results.push(r) - end - - results.flatten.uniq - end - - # DEPRECATED - # @param [Integer, Array of Integer] geographic_item_ids - # @return [String] SQL for geometries - # example, not used - def geometry_for_collection_sql(*geographic_item_ids) - 'SELECT ' + GeographicItem::GEOMETRY_SQL.to_sql + ' AS geometry FROM geographic_items WHERE id IN ' \ - "( #{geographic_item_ids.join(',')} )" - end - - # DEPRECATED - # example, not used - # @param [Integer] geographic_item_id - # @return [RGeo::Geographic object] - def geometry_for(geographic_item_id) - select(GeographicItem::GEOMETRY_SQL.to_sql + ' AS geometry') - .find(geographic_item_id)['geometry'] - end - - # DEPRECATED - # @return [Scope] - # adds an area_in_meters field, with meters - def with_area - select("ST_Area(#{GeographicItem::GEOGRAPHY_SQL}, false) as area_in_meters") - end - - # DEPRECATED - # @return [Scope] include a 'latitude' column - def with_latitude - select(lat_long_sql(:latitude)) - end - - # DEPRECATED - # @return [Scope] include a 'longitude' column - def with_longitude - select(lat_long_sql(:longitude)) - end - - # DEPRECATED, used only in specs to test itself - # @param [String, GeographicItem] - # @return [Scope] - # a SQL fragment for ST_DISJOINT, specifies geographic_items of the - # given shape that are disjoint from all of the passed - # geographic_items - def disjoint_from(shape, *geographic_items) - shape_column = GeographicItem.shape_column_sql(shape) - - q = geographic_items.flatten.collect { |geographic_item| - geographic_item_geometry = geometry_sql(geographic_item.id, - geographic_item.geo_object_type) - - "ST_DISJOINT(#{shape_column}::geometry, " \ - "(#{geographic_item_geometry}))" - }.join(' and ') - - where(q) - end - - # DEPRECATED - # @param [String] shape - # @param [String] geometry of WKT - # @return [Scope] - # A scope of GeographicItems of the given shape contained in the - # WKT. - def are_contained_in_wkt(shape, geometry) - shape = shape.to_s.downcase - case shape - when 'any' - part = [] - SHAPE_TYPES.each { |shape| - part.push(GeographicItem.are_contained_in_wkt(shape, geometry).pluck(:id).to_a) - } - # TODO: change 'id in (?)' to some other sql construct - GeographicItem.where(id: part.flatten) - - when 'any_poly', 'any_line' - part = [] - SHAPE_TYPES.each { |shape| - if shape.to_s.index(shape.gsub('any_', '')) - part.push(GeographicItem.are_contained_in_wkt("#{shape}", geometry).pluck(:id).to_a) - end - } - # TODO: change 'id in (?)' to some other sql construct - GeographicItem.where(id: part.flatten) - - else - where( - 'ST_Contains(' \ - "ST_GeomFromEWKT('srid=4326;#{geometry}'), " \ - "#{GeographicItem.shape_column_sql(shape)}::geometry" \ - ')' - ) # .not_including(geographic_items) - end - end - - # example, not used - # @param [Integer, Array] geographic_item_ids - # @return [Scope] - def st_multi(*geographic_item_ids) - # TODO why is ST_Multi here? - GeographicItem.find_by_sql( - "SELECT ST_Multi(ST_Collect(g.the_geom)) AS singlegeom - FROM ( - SELECT (ST_DUMP(#{GeographicItem::GEOMETRY_SQL.to_sql})).geom AS the_geom - FROM geographic_items - WHERE id IN (?)) - AS g;", geographic_item_ids.flatten - ) - end - - end # class_methods - - # Used only in specs - # @param [Integer] geographic_item_id - # @return [Double] distance in meters - def st_distance(geographic_item_id) # geo_object - q = 'ST_Distance(' \ - "(#{GeographicItem.select_geography_sql(id)}), " \ - "(#{GeographicItem.select_geography_sql(geographic_item_id)})" \ - ') as d' - - GeographicItem.where(id:).pick(Arel.sql(q)) - end - - # DEPRECATED - alias_method :distance_to, :st_distance - - # DEPRECATED, used only in specs to test itself - # @param [geo_object, Double] - # @return [Boolean] - def near(target_geo_object, distance) - self.geo_object.unsafe_buffer(distance).contains?(target_geo_object) - end - - # DEPRECATED, used only in specs to test itself - # @param [geo_object, Double] - # @return [Boolean] - def far(target_geo_object, distance) - !near(target_geo_object, distance) - end - - # DEPRECATED - # We don't need to serialize to/from JSON - def to_geo_json_string - GeographicItem.connection.select_one( - "SELECT ST_AsGeoJSON(#{geo_object_type}::geometry) a " \ - "FROM geographic_items WHERE id=#{id};" - )['a'] - end - - # DEPRECATED - # @return [Float, false] - # the value in square meters of the interesecting area of this and another GeographicItem - def intersecting_area(geographic_item_id) - self_shape = GeographicItem.select_geography_sql(id) - other_shape = GeographicItem.select_geography_sql(geographic_item_id) - - r = GeographicItem.connection.execute( - "SELECT ST_Area(ST_Intersection((#{self_shape}), (#{other_shape}))) " \ - 'AS intersecting_area FROM geographic_items limit 1' - ).first - - r && r['intersecting_area'].to_f - end - - # DEPRECATED - # !! Unused. Doesn't check Geometry collection or geography column - def has_polygons? - ['GeographicItem::MultiPolygon', 'GeographicItem::Polygon'].include?(self.type) - end - - # DEPRECATED, used only in specs - # @return [Integer] - # the number of points in the geometry - def st_npoints - GeographicItem.where(id:).pick(Arel.sql("ST_NPoints(#{GeographicItem::GEOMETRY_SQL.to_sql}) as npoints")) - end - - # DEPRECATED - # @param [Integer] geographic_item_id - # @return [Double] distance in meters - def st_distance_spheroid(geographic_item_id) - q = self.class.st_distancespheroid_sql( - self.class.select_geometry_sql(id), - self.class.select_geometry_sql(geographic_item_id) - ) - GeographicItem.where(id:).pick(Arel.sql(q)) - end - - private - - # @param [RGeo::Point] point - # @return [Array] of a point - # Longitude |, Latitude - - def point_to_a(point) - data = [] - data.push(point.x, point.y) - data - end - - # @param [RGeo::Point] point - # @return [Hash] of a point - def point_to_hash(point) - {points: [point_to_a(point)]} - end - - # @param [RGeo::MultiPoint] multi_point - # @return [Array] of points - def multi_point_to_a(multi_point) - data = [] - multi_point.each { |point| - data.push([point.x, point.y]) - } - data - end - - # @return [Hash] of points - def multi_point_to_hash(_multi_point) - # when we encounter a multi_point type, we only stick the points into the array, NOT it's identity as a group - {points: multi_point_to_a(multi_point)} - end - - # @param [Reo::LineString] line_string - # @return [Array] of points in the line - def line_string_to_a(line_string) - data = [] - line_string.points.each { |point| - data.push([point.x, point.y]) - } - data - end - - # @param [Reo::LineString] line_string - # @return [Hash] of points in the line - def line_string_to_hash(line_string) - {lines: [line_string_to_a(line_string)]} - end - - # @param [RGeo::Polygon] polygon - # @return [Array] of points in the polygon (exterior_ring ONLY) - def polygon_to_a(polygon) - # TODO: handle other parts of the polygon; i.e., the interior_rings (if they exist) - data = [] - polygon.exterior_ring.points.each { |point| - data.push([point.x, point.y]) - } - data - end - - # @param [RGeo::Polygon] polygon - # @return [Hash] of points in the polygon (exterior_ring ONLY) - def polygon_to_hash(polygon) - {polygons: [polygon_to_a(polygon)]} - end - - # @return [Array] of line_strings as arrays of points - # @param [RGeo::MultiLineString] multi_line_string - def multi_line_string_to_a(multi_line_string) - data = [] - multi_line_string.each { |line_string| - line_data = [] - line_string.points.each { |point| - line_data.push([point.x, point.y]) - } - data.push(line_data) - } - data - end - - # @return [Hash] of line_strings as hashes of points - def multi_line_string_to_hash(_multi_line_string) - {lines: to_a} - end - - # @param [RGeo::MultiPolygon] multi_polygon - # @return [Array] of arrays of points in the polygons (exterior_ring ONLY) - def multi_polygon_to_a(multi_polygon) - data = [] - multi_polygon.each { |polygon| - polygon_data = [] - polygon.exterior_ring.points.each { |point| - polygon_data.push([point.x, point.y]) - } - data.push(polygon_data) - } - data - end - - # @return [Hash] of hashes of points in the polygons (exterior_ring ONLY) - def multi_polygon_to_hash(_multi_polygon) - {polygons: to_a} - end - - # DEPRECATED - subclass st_start_point methods are also unused - # @return [Array of latitude, longitude] - # the lat, lon of the first point in the GeoItem, see subclass for - # st_start_point - def start_point - o = st_start_point - [o.y, o.x] - end - # end private -end \ No newline at end of file diff --git a/app/models/geographic_item/geography.rb b/app/models/geographic_item/geography.rb index 91759bb7a0..0612d1aaaf 100644 --- a/app/models/geographic_item/geography.rb +++ b/app/models/geographic_item/geography.rb @@ -3,15 +3,4 @@ class GeographicItem::Geography < GeographicItem validates_presence_of :geography - # DEPRECATED - # def st_start_point - # end - - # DEPRECATED - # def rendering_hash - # end - - # DEPRECATED - # def to_hash - # end end diff --git a/app/models/geographic_item/geometry_collection.rb b/app/models/geographic_item/geometry_collection.rb index 3800d80e85..77169c0961 100644 --- a/app/models/geographic_item/geometry_collection.rb +++ b/app/models/geographic_item/geometry_collection.rb @@ -3,74 +3,6 @@ class GeographicItem::GeometryCollection < GeographicItem validates_presence_of :geometry_collection - # @return [RGeo::Point] first point in the collection - def st_start_point - rgeo_to_geo_json =~ /(-?\d+\.?\d*),(-?\d+\.?\d*)/ - Gis::FACTORY.point($1.to_f, $2.to_f, 0.0) - end - - # @return [Hash] - def rendering_hash - to_hash(self.geometry_collection) - end - - # @todo Seems to be deprecated for rgeo_to_geo_json?! - # @param [GeometryCollection] - # @return [Hash] a simple representation of the collection in points, lines, and polygons. - def to_hash(geometry_collection) - data = { - points: [], - lines: [], - polygons: [] - } - geometry_collection.each { |it| - case it.geometry_type.type_name - when 'Point' - # POINT (-88.241421 40.091565 757.0) - point = point_to_hash(it)[:points] - # @todo would it really be better to use object_to_hash here? Structure-wise, perhaps, but it really is faster to do it here directly, I think... - data[:points].push(point_to_a(it)) - when /^Line[S]*/ #when 'Line' or 'LineString' - # LINESTRING (-32.0 21.0 0.0, -25.0 21.0 0.0, -25.0 16.0 0.0, -21.0 20.0 0.0) - data[:lines].push(line_string_to_a(it)) - when 'Polygon' - # POLYGON ((-14.0 23.0 0.0, -14.0 11.0 0.0, -2.0 11.0 0.0, -2.0 23.0 0.0, -8.0 21.0 0.0, -14.0 23.0 0.0), (-11.0 18.0 0.0, -8.0 17.0 0.0, -6.0 20.0 0.0, -4.0 16.0 0.0, -7.0 13.0 0.0, -11.0 14.0 0.0, -11.0 18.0 0.0)) - # note: only the exterior_ring is processed - data[:polygons].push(polygon_to_a(it)) - # in the cases of the multi-objects, break each down to its constituent parts (i.e., remove its identity as a multi-whatever), and record those parts - when 'MultiPoint' - # MULTIPOINT ((3.0 -14.0 0.0), (6.0 -12.9 0.0), (5.0 -16.0 0.0), (4.0 -17.9 0.0), (7.0 -17.9 0.0)) - multi_point_to_a(it).each { |point| - data[:points].push(point) - } - when 'MultiLineString' - # MULTILINESTRING ((23.0 21.0 0.0, 16.0 21.0 0.0, 16.0 16.0 0.0, 11.0 20.0 0.0), (4.0 12.6 0.0, 16.0 12.6 0.0, 16.0 7.6 0.0), (21.0 12.6 0.0, 26.0 12.6 0.0, 22.0 17.6 0.0)) - multi_line_string_to_a(it).each { |line_string| - data[:lines].push(line_string) - } - when 'MultiPolygon' - # MULTIPOLYGON (((28.0 2.3 0.0, 23.0 -1.7 0.0, 26.0 -4.8 0.0, 28.0 2.3 0.0)) - it.each { |polygon| - polygon_data = [] - polygon.exterior_ring.points.each { |point| - polygon_data.push([point.x, point.y]) } - data[:polygons].push(polygon_data) - } - when 'GeometryCollection' - collection_hash = to_hash(it) - collection_hash.each_key { |key| - collection_hash[key].each { |item| - data[key].push(item) } - } - else - # leave everything as it is... - end - } - # remove any keys with empty arrays - data.delete_if { |key, value| value == [] } - data - end - # @return [GeoJSON Feature] # the shape as a Feature/Feature Collection def to_geo_json_feature diff --git a/app/models/geographic_item/line_string.rb b/app/models/geographic_item/line_string.rb index eb49bb4111..4420e13e09 100644 --- a/app/models/geographic_item/line_string.rb +++ b/app/models/geographic_item/line_string.rb @@ -3,19 +3,4 @@ class GeographicItem::LineString < GeographicItem validates_presence_of :line_string - # @return [Array] arrays of points - def to_a - line_string_to_a(self.line_string) - end - - # @return [RGeo::Point] first point in the line_string - def st_start_point - geo_object.point_n(0) - end - - # @return [Hash] - def rendering_hash - line_string_to_hash(self.line_string) - end - end diff --git a/app/models/geographic_item/multi_line_string.rb b/app/models/geographic_item/multi_line_string.rb index 429fbca766..cbb30f33fe 100644 --- a/app/models/geographic_item/multi_line_string.rb +++ b/app/models/geographic_item/multi_line_string.rb @@ -3,19 +3,4 @@ class GeographicItem::MultiLineString < GeographicItem validates_presence_of :multi_line_string - # @return [Array] arrays of points - def to_a - multi_line_string_to_a(self.multi_line_string) - end - - # @return [RGeo::Point] first point in the first line_string - def st_start_point - geo_object[0].point_n(0) - end - - # @return [Hash] - def rendering_hash - multi_line_string_to_hash(self.multi_line_string) - end - end diff --git a/app/models/geographic_item/multi_point.rb b/app/models/geographic_item/multi_point.rb index cdf5187633..61a6350966 100644 --- a/app/models/geographic_item/multi_point.rb +++ b/app/models/geographic_item/multi_point.rb @@ -3,19 +3,4 @@ class GeographicItem::MultiPoint < GeographicItem validates_presence_of :multi_point - # @return [Array] arrays of points - def to_a - multi_point_to_a(self.multi_point) - end - - # @return [RGeo::Point] first point - def st_start_point - geo_object[0] - end - - # @return [Hash] - def rendering_hash - multi_point_to_hash(self.multi_point) - end - end diff --git a/app/models/geographic_item/multi_polygon.rb b/app/models/geographic_item/multi_polygon.rb index c44dd91227..a0511b592a 100644 --- a/app/models/geographic_item/multi_polygon.rb +++ b/app/models/geographic_item/multi_polygon.rb @@ -3,19 +3,4 @@ class GeographicItem::MultiPolygon < GeographicItem validates_presence_of :multi_polygon - # @return [Array] arrays of points - def to_a - multi_polygon_to_a(self.multi_polygon) - end - - # @return [RGeo::Point] first point in first polygon - def st_start_point - geo_object[0].exterior_ring.point_n(0) - end - - # @return [Hash] - def rendering_hash - multi_polygon_to_hash(self.multi_polygon) - end - end diff --git a/app/models/geographic_item/point.rb b/app/models/geographic_item/point.rb index da3bd75a2b..ce46ac2a9e 100644 --- a/app/models/geographic_item/point.rb +++ b/app/models/geographic_item/point.rb @@ -4,21 +4,6 @@ class GeographicItem::Point < GeographicItem validates_presence_of :point validate :check_point_limits - # @return [Array] a point - def to_a - point_to_a(self.point) - end - - # @return [RGeo::Point] the first POINT of self - def st_start_point - self.geo_object - end - - # @return [Hash] - def rendering_hash - point_to_hash(self.point) - end - protected # @return [Boolean] diff --git a/app/models/geographic_item/polygon.rb b/app/models/geographic_item/polygon.rb index f310dcbe36..6cc3a38b7b 100644 --- a/app/models/geographic_item/polygon.rb +++ b/app/models/geographic_item/polygon.rb @@ -3,22 +3,4 @@ class GeographicItem::Polygon < GeographicItem validates_presence_of :polygon - # @return [Array] arrays of points - def to_a - polygon_to_a(self.polygon) - end - - # @return [RGeo::Point] first point in the polygon - def st_start_point - geo_object.exterior_ring.point_n(0) - end - - # @return [Hash] - def rendering_hash - polygon_to_hash(self.polygon) - end - - def keystone_error_box - geo_object - end end diff --git a/app/models/georeference.rb b/app/models/georeference.rb index 24a1dcfb25..d59f026504 100644 --- a/app/models/georeference.rb +++ b/app/models/georeference.rb @@ -260,10 +260,9 @@ def dwc_georeference_attributes(h = {}) georeferenceProtocol: protocols.collect{|p| p.name}.join('|') ) - if geographic_item.type == 'GeographicItem::Point' - b = geographic_item.to_a - h[:decimalLongitude] = b.first - h[:decimalLatitude] = b.second + if geographic_item.geo_object_type == :point + h[:decimalLongitude] = geographic_item.geo_object.x + h[:decimalLatitude] = geographic_item.geo_object.y h[:coordinateUncertaintyInMeters] = error_radius end diff --git a/spec/models/geographic_item/anti_meridian_spec.rb b/spec/models/geographic_item/anti_meridian_spec.rb index e51f99fba5..82d954fc9d 100644 --- a/spec/models/geographic_item/anti_meridian_spec.rb +++ b/spec/models/geographic_item/anti_meridian_spec.rb @@ -332,44 +332,9 @@ end end - context '.contained_by_with_antimeridian_check(*ids)' do + context 'shifted wkt' do before { build_structure } - specify 'results from single non-meridian crossing polygon is found' do - # invokes items_as_one_geometry - # using contained_by_with_antimeridian_check is not harmful for non-crossing objects - expect(GeographicItem.contained_by_with_antimeridian_check(western_box.id).map(&:id)) - .to contain_exactly(point_in_western_box.id, western_box.id) - end - - specify 'results from multiple non-meridian crossing polygons are found' do - # invokes items_as_one_geometry - # using contained_by_with_antimeridian_check is not harmful for non-crossing objects - expect(GeographicItem.contained_by_with_antimeridian_check(eastern_box.id, western_box.id).map(&:id)) - .to contain_exactly(point_in_eastern_box.id, - point_in_western_box.id, - eastern_box.id, - western_box.id) - end - - specify 'results from single meridian crossing polygon are found' do - # why is crossing_box not finding l_r_line or r_l_line - # why does crossing_box find point_in_eastern_box - expect(GeographicItem.contained_by_with_antimeridian_check(crossing_box.id).map(&:id)) - .to contain_exactly(l_r_line.id, r_l_line.id, crossing_box.id) - end - - specify 'results from merdian crossing and non-meridian crossing polygons are found' do - # why is crossing_box not finding l_r_line or r_l_line - expect(GeographicItem.contained_by_with_antimeridian_check(eastern_box.id, western_box.id, crossing_box.id) - .map(&:id)).to contain_exactly(point_in_eastern_box.id, - point_in_western_box.id, - l_r_line.id, r_l_line.id, - eastern_box.id, - western_box.id, - crossing_box.id) - end - specify 'shifting an already shifted polygon has no effect' do shifted_wkt = eastern_box.geo_object.to_s expect(shifted_wkt =~ /-/).to be_falsey diff --git a/spec/models/geographic_item/geometry_collection_spec.rb b/spec/models/geographic_item/geometry_collection_spec.rb index 4ce99757c9..06b6ca4e54 100644 --- a/spec/models/geographic_item/geometry_collection_spec.rb +++ b/spec/models/geographic_item/geometry_collection_spec.rb @@ -24,71 +24,5 @@ end end - context 'that a geometry_collection knows how to emits its own hash' do - specify 'points' do - expect(all_items.rendering_hash[:points]).to eq [[-88.241421, 40.091565], - [-88.241417, 40.09161], - [-88.241413, 40.091655], - [0.0, 0.0], [-29.0, -16.0], [-25.0, -18.0], - [-28.0, -21.0], [-19.0, -18.0], [3.0, -14.0], - [6.0, -12.9], [5.0, -16.0], [4.0, -17.9], - [7.0, -17.9], [32.2, 22.0], [-17.0, 7.0], - [-9.8, 5.0], [-10.7, 0.0], [-30.0, 21.0], - [-25.0, 18.3], [-23.0, 18.0], [-19.6, -13.0], - [-7.6, 14.2], [-4.6, 11.9], [-8.0, -4.0], - [-4.0, -8.0], [-10.0, -6.0], [3.0, -14.0], - [6.0, -12.9], [5.0, -16.0], [4.0, -17.9], - [7.0, -17.9], [3.0, -14.0], [6.0, -12.9], - [5.0, -16.0], [4.0, -17.9], [7.0, -17.9]] - end - - specify 'lines' do - expect(all_items.rendering_hash[:lines].to_s).to eq('[[[-32.0, 21.0], [-25.0, 21.0], [-25.0, 16.0], [-21.0, 20.0]], [[23.0, 21.0], [16.0, 21.0], [16.0, 16.0], [11.0, 20.0]], [[4.0, 12.6], [16.0, 12.6], [16.0, 7.6]], [[21.0, 12.6], [26.0, 12.6], [22.0, 17.6]], [[-33.0, 11.0], [-24.0, 4.0], [-26.0, 13.0], [-38.0, 14.0], [-33.0, 11.0]], [[-20.0, -1.0], [-26.0, -6.0]], [[-21.0, -4.0], [-31.0, -4.0]], [[27.0, -14.0], [18.0, -21.0], [20.0, -12.0], [25.0, -23.0]], [[27.0, -14.0], [18.0, -21.0], [20.0, -12.0], [25.0, -23.0]], [[-16.0, -15.5], [-22.0, -20.5]]]') - end - - specify 'polygons' do - expect(all_items.rendering_hash[:polygons]).to eq([[[-14.0, 23.0], [-14.0, 11.0], [-2.0, 11.0], - [-2.0, 23.0], [-8.0, 21.0], [-14.0, 23.0]], - [[-19.0, 9.0], [-9.0, 9.0], [-9.0, 2.0], - [-19.0, 2.0], [-19.0, 9.0]], - [[5.0, -1.0], [-14.0, -1.0], [-14.0, 6.0], - [5.0, 6.0], [5.0, -1.0]], - [[-11.0, -1.0], [-11.0, -5.0], [-7.0, -5.0], - [-7.0, -1.0], [-11.0, -1.0]], - [[-3.0, -9.0], [-3.0, -1.0], [-7.0, -1.0], - [-7.0, -9.0], [-3.0, -9.0]], - [[-7.0, -9.0], [-7.0, -5.0], [-11.0, -5.0], - [-11.0, -9.0], [-7.0, -9.0]], - [[28.0, 2.3], [23.0, -1.7], [26.0, -4.8], [28.0, 2.3]], - [[22.0, -6.8], [22.0, -9.8], [16.0, -6.8], [22.0, -6.8]], - [[16.0, 2.3], [14.0, -2.8], [18.0, -2.8], [16.0, 2.3]], - [[28.0, 2.3], [23.0, -1.7], [26.0, -4.8], [28.0, 2.3]], - [[22.0, -6.8], [22.0, -9.8], [16.0, -6.8], [22.0, -6.8]], - [[16.0, 2.3], [14.0, -2.8], [18.0, -2.8], [16.0, 2.3]], - [[-33.0, -11.0], [-33.0, -23.0], [-21.0, -23.0], - [-21.0, -11.0], [-27.0, -13.0], [-33.0, -11.0]], - [[-1.0, 1.0], [1.0, 1.0], [1.0, -1.0], [-1.0, -1.0], - [-1.0, 1.0]], - [[-2.0, 2.0], [2.0, 2.0], [2.0, -2.0], [-2.0, -2.0], - [-2.0, 2.0]], - [[-3.0, 3.0], [3.0, 3.0], [3.0, -3.0], [-3.0, -3.0], - [-3.0, 3.0]], - [[-4.0, 4.0], [4.0, 4.0], [4.0, -4.0], [-4.0, -4.0], - [-4.0, 4.0]]]) - end - - specify 'all objects' do - expect(all_items.rendering_hash.to_s).to eq('{:points=>[[-88.241421, 40.091565], [-88.241417, 40.09161], [-88.241413, 40.091655], [0.0, 0.0], [-29.0, -16.0], [-25.0, -18.0], [-28.0, -21.0], [-19.0, -18.0], [3.0, -14.0], [6.0, -12.9], [5.0, -16.0], [4.0, -17.9], [7.0, -17.9], [32.2, 22.0], [-17.0, 7.0], [-9.8, 5.0], [-10.7, 0.0], [-30.0, 21.0], [-25.0, 18.3], [-23.0, 18.0], [-19.6, -13.0], [-7.6, 14.2], [-4.6, 11.9], [-8.0, -4.0], [-4.0, -8.0], [-10.0, -6.0], [3.0, -14.0], [6.0, -12.9], [5.0, -16.0], [4.0, -17.9], [7.0, -17.9], [3.0, -14.0], [6.0, -12.9], [5.0, -16.0], [4.0, -17.9], [7.0, -17.9]], :lines=>[[[-32.0, 21.0], [-25.0, 21.0], [-25.0, 16.0], [-21.0, 20.0]], [[23.0, 21.0], [16.0, 21.0], [16.0, 16.0], [11.0, 20.0]], [[4.0, 12.6], [16.0, 12.6], [16.0, 7.6]], [[21.0, 12.6], [26.0, 12.6], [22.0, 17.6]], [[-33.0, 11.0], [-24.0, 4.0], [-26.0, 13.0], [-38.0, 14.0], [-33.0, 11.0]], [[-20.0, -1.0], [-26.0, -6.0]], [[-21.0, -4.0], [-31.0, -4.0]], [[27.0, -14.0], [18.0, -21.0], [20.0, -12.0], [25.0, -23.0]], [[27.0, -14.0], [18.0, -21.0], [20.0, -12.0], [25.0, -23.0]], [[-16.0, -15.5], [-22.0, -20.5]]], :polygons=>[[[-14.0, 23.0], [-14.0, 11.0], [-2.0, 11.0], [-2.0, 23.0], [-8.0, 21.0], [-14.0, 23.0]], [[-19.0, 9.0], [-9.0, 9.0], [-9.0, 2.0], [-19.0, 2.0], [-19.0, 9.0]], [[5.0, -1.0], [-14.0, -1.0], [-14.0, 6.0], [5.0, 6.0], [5.0, -1.0]], [[-11.0, -1.0], [-11.0, -5.0], [-7.0, -5.0], [-7.0, -1.0], [-11.0, -1.0]], [[-3.0, -9.0], [-3.0, -1.0], [-7.0, -1.0], [-7.0, -9.0], [-3.0, -9.0]], [[-7.0, -9.0], [-7.0, -5.0], [-11.0, -5.0], [-11.0, -9.0], [-7.0, -9.0]], [[28.0, 2.3], [23.0, -1.7], [26.0, -4.8], [28.0, 2.3]], [[22.0, -6.8], [22.0, -9.8], [16.0, -6.8], [22.0, -6.8]], [[16.0, 2.3], [14.0, -2.8], [18.0, -2.8], [16.0, 2.3]], [[28.0, 2.3], [23.0, -1.7], [26.0, -4.8], [28.0, 2.3]], [[22.0, -6.8], [22.0, -9.8], [16.0, -6.8], [22.0, -6.8]], [[16.0, 2.3], [14.0, -2.8], [18.0, -2.8], [16.0, 2.3]], [[-33.0, -11.0], [-33.0, -23.0], [-21.0, -23.0], [-21.0, -11.0], [-27.0, -13.0], [-33.0, -11.0]], [[-1.0, 1.0], [1.0, 1.0], [1.0, -1.0], [-1.0, -1.0], [-1.0, 1.0]], [[-2.0, 2.0], [2.0, 2.0], [2.0, -2.0], [-2.0, -2.0], [-2.0, 2.0]], [[-3.0, 3.0], [3.0, 3.0], [3.0, -3.0], [-3.0, -3.0], [-3.0, 3.0]], [[-4.0, 4.0], [4.0, 4.0], [4.0, -4.0], [-4.0, -4.0], [-4.0, 4.0]]]}') - end - end - - specify 'returns a lat/lng of the first point of the GeoObject' do - expect(all_items.start_point).to eq([40.091565, -88.241421]) - end - - specify '#st_start_point returns the first POINT of the GeoObject' do - expect(all_items.st_start_point.to_s).to eq('POINT (-88.241421 40.091565 0.0)') - end - end end diff --git a/spec/models/geographic_item/line_string_spec.rb b/spec/models/geographic_item/line_string_spec.rb index e99f7a781a..77027f20b4 100644 --- a/spec/models/geographic_item/line_string_spec.rb +++ b/spec/models/geographic_item/line_string_spec.rb @@ -10,18 +10,5 @@ expect(a.valid?).to be_truthy expect(a.geo_object.to_s).to eq('LINESTRING (-32.0 21.0 0.0, -25.0 21.0 0.0, -25.0 16.0 0.0, -21.0 20.0 0.0)') end - - specify 'that a line_string knows how to emits its own hash' do - expect(a.rendering_hash).to eq(lines: [[[-32.0, 21.0], [-25.0, 21.0], [-25.0, 16.0], [-21.0, 20.0]]]) - end - - specify 'returns a lat/lng of the first point of the GeoObject' do - expect(a.start_point).to eq([21.0, -32.0]) - end - - specify '#st_start_point returns the first POINT of the GeoObject' do - expect(a.st_start_point.to_s).to eq('POINT (-32.0 21.0 0.0)') - end - end end diff --git a/spec/models/geographic_item/multi_line_string_spec.rb b/spec/models/geographic_item/multi_line_string_spec.rb index e23d0d2ec0..b3d02c2004 100644 --- a/spec/models/geographic_item/multi_line_string_spec.rb +++ b/spec/models/geographic_item/multi_line_string_spec.rb @@ -12,20 +12,5 @@ '11.0 20.0 0.0), (4.0 12.6 0.0, 16.0 12.6 0.0, 16.0 7.6 0.0), ' \ '(21.0 12.6 0.0, 26.0 12.6 0.0, 22.0 17.6 0.0))') end - - specify 'for a multi_line_string' do - expect(c.rendering_hash).to eq(lines: [[[23.0, 21.0], [16.0, 21.0], [16.0, 16.0], [11.0, 20.0]], - [[4.0, 12.6], [16.0, 12.6], [16.0, 7.6]], - [[21.0, 12.6], [26.0, 12.6], [22.0, 17.6]]]) - end - - specify 'returns a lat/lng of the first point of the GeoObject' do - expect(c.start_point).to eq([21.0, 23.0]) - end - - specify '#st_start_point returns the first POINT of the GeoObject' do - expect(c.st_start_point.to_s).to eq('POINT (23.0 21.0 0.0)') - end - end end diff --git a/spec/models/geographic_item/multi_point_spec.rb b/spec/models/geographic_item/multi_point_spec.rb index ab7ce3b319..4e2b9cb7d6 100644 --- a/spec/models/geographic_item/multi_point_spec.rb +++ b/spec/models/geographic_item/multi_point_spec.rb @@ -11,20 +11,5 @@ expect(rooms.geo_object.to_s).to eq('MULTIPOINT ((-88.241421 40.091565 0.0), ' \ '(-88.241417 40.09161 0.0), (-88.241413 40.091655 0.0))') end - - specify 'for a multi_point' do - expect(rooms.rendering_hash).to eq(points: [[-88.241421, 40.091565], - [-88.241417, 40.09161], - [-88.241413, 40.091655]]) - end - - specify 'returns a lat/lng of the first point of the GeoObject' do - expect(rooms.start_point).to eq([40.091565, -88.241421]) - end - - specify '#st_start_point returns the first POINT of the GeoObject' do - expect(rooms.st_start_point.to_s).to eq('POINT (-88.241421 40.091565 0.0)') - end - end end diff --git a/spec/models/geographic_item/multi_polygon_spec.rb b/spec/models/geographic_item/multi_polygon_spec.rb index 133c3ce655..ee197fe7d2 100644 --- a/spec/models/geographic_item/multi_polygon_spec.rb +++ b/spec/models/geographic_item/multi_polygon_spec.rb @@ -13,20 +13,5 @@ '22.0 -9.8 0.0, 16.0 -6.8 0.0, 22.0 -6.8 0.0)), ' \ '((16.0 2.3 0.0, 14.0 -2.8 0.0, 18.0 -2.8 0.0, 16.0 2.3 0.0)))') end - - specify 'for a multi_polygon' do - expect(g.rendering_hash).to eq(polygons: [[[28.0, 2.3], [23.0, -1.7], [26.0, -4.8], [28.0, 2.3]], - [[22.0, -6.8], [22.0, -9.8], [16.0, -6.8], [22.0, -6.8]], - [[16.0, 2.3], [14.0, -2.8], [18.0, -2.8], [16.0, 2.3]]]) - end - - specify 'returns a lat/lng of the first point of the GeoObject' do - expect(g.start_point).to eq([2.3, 28.0]) - end - - specify '#st_start_point returns the first POINT of the GeoObject' do - expect(g.st_start_point.to_s).to eq('POINT (28.0 2.3 0.0)') - end - end end diff --git a/spec/models/geographic_item/point_spec.rb b/spec/models/geographic_item/point_spec.rb index 843d241170..5a5fab82da 100644 --- a/spec/models/geographic_item/point_spec.rb +++ b/spec/models/geographic_item/point_spec.rb @@ -11,17 +11,5 @@ expect(r2024.valid?).to be_truthy expect(r2024.geo_object.to_s).to eq('POINT (-88.241413 40.091655 0.0)') end - - specify 'knows how to emits its own hash' do - expect(r2024.rendering_hash).to eq(points: [[-88.241413, 40.091655]]) - end - - specify 'start_point returns a lat/lng of the first point of the GeoObject' do - expect(r2024.start_point).to eq([40.091655, -88.241413]) - end - - specify '#st_start_point returns the first POINT of the GeoObject' do - expect(r2024.st_start_point.to_s).to eq('POINT (-88.241413 40.091655 0.0)') - end end end diff --git a/spec/models/geographic_item/polygon_spec.rb b/spec/models/geographic_item/polygon_spec.rb index 60cdaf1082..8c20683cdd 100644 --- a/spec/models/geographic_item/polygon_spec.rb +++ b/spec/models/geographic_item/polygon_spec.rb @@ -11,19 +11,5 @@ expect(k.geo_object.to_s).to eq('POLYGON ((-33.0 -11.0 0.0, -33.0 -23.0 0.0, -21.0 -23.0 0.0, ' \ '-21.0 -11.0 0.0, -27.0 -13.0 0.0, -33.0 -11.0 0.0))') end - - specify 'that a polygon knows how to emits its own hash' do - expect(k.rendering_hash).to eq(polygons: [[[-33.0, -11.0], [-33.0, -23.0], [-21.0, -23.0], - [-21.0, -11.0], [-27.0, -13.0], [-33.0, -11.0]]]) - end - - specify 'returns a lat/lng of the first point of the GeoObject' do - expect(k.start_point).to eq([-11.0, -33.0]) - end - - specify '#st_start_point returns the first POINT of the GeoObject' do - expect(k.st_start_point.to_s).to eq('POINT (-33.0 -11.0 0.0)') - end - end end diff --git a/spec/models/geographic_item_spec.rb b/spec/models/geographic_item_spec.rb index fd4135cec1..19dc9183e9 100644 --- a/spec/models/geographic_item_spec.rb +++ b/spec/models/geographic_item_spec.rb @@ -169,14 +169,6 @@ # geographic_item_with_polygon.geo_object)).to be_falsey end - specify 'ST_Distance' do - skip - #expect(GeographicItem.covers(geographic_item_with_polygon.geo_object, - # geographic_item_with_point_c.geo_object)).to be_truthy - #expect(GeographicItem.covers(geographic_item_with_point_c.geo_object, - # geographic_item_with_polygon.geo_object)).to be_falsey - end - end =end @@ -363,30 +355,6 @@ expect(p17.geo_object.distance(k.geo_object)).to be < p10.geo_object.distance(k.geo_object) end - specify 'Objects can be related by distance' do - expect(k.near(p1.geo_object, 0)).to be_truthy - end - - specify 'Objects can be related by distance' do - expect(k.near(p17.geo_object, 2)).to be_truthy - end - - specify 'Objects can be related by distance' do - expect(k.near(p10.geo_object, 5)).to be_falsey - end - - specify 'Objects can be related by distance' do - expect(k.far(p1.geo_object, 0)).to be_falsey - end - - specify 'Objects can be related by distance' do - expect(k.far(p17.geo_object, 1)).to be_truthy - end - - specify 'Objects can be related by distance' do - expect(k.far(p10.geo_object, 5)).to be_truthy - end - specify 'Outer Limits' do expect(all_items.geo_object.convex_hull()).to eq(convex_hull) end @@ -427,14 +395,6 @@ expect(geographic_item).to respond_to(:within?) end - specify '#near' do - expect(geographic_item).to respond_to(:near) - end - - specify '#far' do - expect(geographic_item).to respond_to(:far) - end - specify '#contains? if one object is inside the area defined by the other (watch out for holes)' do expect(k.contains?(p1.geo_object)).to be_truthy end @@ -443,38 +403,6 @@ expect(e1.contains?(p10.geo_object)).to be_falsey end - specify '#st_npoints returns the number of included points for a valid GeoItem' do - expect(p0.st_npoints).to eq(1) - end - - specify '#st_npoints returns the number of included points for a valid GeoItem' do - expect(a.st_npoints).to eq(4) - end - - specify '#st_npoints returns the number of included points for a valid GeoItem' do - expect(b.st_npoints).to eq(13) - end - - specify '#st_npoints returns the number of included points for a valid GeoItem' do - expect(h.st_npoints).to eq(5) - end - - specify '#st_npoints returns the number of included points for a valid GeoItem' do - expect(f.st_npoints).to eq(4) - end - - specify '#st_npoints returns the number of included points for a valid GeoItem' do - expect(g.st_npoints).to eq(12) - end - - specify '#st_npoints returns the number of included points for a valid GeoItem' do - expect(all_items.st_npoints).to eq(157) - end - - specify '#st_npoints returns the number of included points for a valid GeoItem' do - expect(outer_limits.st_npoints).to eq(7) - end - specify '#valid_geometry? returns \'true\' for a valid GeoObject' do expect(p0.valid_geometry?).to be_truthy end @@ -577,18 +505,6 @@ context 'class methods' do - specify '::ordered_by_shortest_distance_from to specify ordering of found objects.' do - expect(GeographicItem).to respond_to(:ordered_by_shortest_distance_from) - end - - specify '::ordered_by_longest_distance_from' do - expect(GeographicItem).to respond_to(:ordered_by_longest_distance_from) - end - - specify '::disjoint_from to find all objects which are disjoint from an \'and\' list of objects.' do - expect(GeographicItem).to respond_to(:disjoint_from) - end - specify '::within_radius_of_item to find all objects which are within a specific ' \ 'distance of a geographic item.' do expect(GeographicItem).to respond_to(:within_radius_of_item) @@ -742,7 +658,7 @@ end end - context '::contained_by' do + context 'contained by' do before { [p1, p2, p3, p11, p12, k, l].each } specify 'find the points in a polygon' do @@ -810,149 +726,6 @@ end end - context '::not_including([])' do - before { [p1, p4, p17, r2024, r2022, r2020, p10].each { |object| object } } - - specify 'drop specifc item[s] from any scope (list of objects.)' do - # @p2 would have been in the list, except for the exclude - expect(GeographicItem.not_including([p2]) - .ordered_by_shortest_distance_from('point', p3) - .limit(3).to_a) - .to eq([p1, p4, p17]) - end - - specify 'drop specifc item[s] from any scope (list of objects.)' do - # @p2 would *not* have been in the list anyway - expect(GeographicItem.not_including([p2]) - .ordered_by_longest_distance_from('point', p3) - .limit(3).to_a) - .to eq([r2024, r2022, r2020]) - end - - specify 'drop specifc item[s] from any scope (list of objects.)' do - # @r2022 would have been in the list, except for the exclude - expect(GeographicItem.not_including([r2022]) - .ordered_by_longest_distance_from('point', p3) - .limit(3).to_a) - .to eq([r2024, r2020, p10]) - end - end - - # specify '::not_including_self to drop self from any list of objects' do - # skip 'construction of scenario' - # expect(GeographicItem.ordered_by_shortest_distance_from('point', @p7).limit(5)).to_a).to eq([@p2, @p1, @p4]) - # end - - context '::ordered_by_shortest_distance_from' do - before { [p1, p2, p4, outer_limits, l, f1, e5, e3, e4, h, rooms, f, c, g, e, j].each } - - specify ' orders objects by distance from passed object' do - expect(GeographicItem.ordered_by_shortest_distance_from('point', p3) - .limit(3).to_a) - .to eq([p2, p1, p4]) - end - - specify ' orders objects by distance from passed object' do - expect(GeographicItem.ordered_by_shortest_distance_from('line_string', p3) - .limit(3).to_a) - .to eq([outer_limits, l, f1]) - end - - specify ' orders objects by distance from passed object' do - expect(GeographicItem.ordered_by_shortest_distance_from('polygon', p3) - .limit(3).to_a) - .to eq([e5, e3, e4]) - end - - specify ' orders objects by distance from passed object' do - expect(GeographicItem.ordered_by_shortest_distance_from('multi_point', p3) - .limit(3).to_a) - .to eq([h, rooms]) - end - - specify ' orders objects by distance from passed object' do - expect(GeographicItem.ordered_by_shortest_distance_from('multi_line_string', p3) - .limit(3).to_a) - .to eq([f, c]) - end - - specify ' orders objects by distance from passed object' do - subject = GeographicItem.ordered_by_shortest_distance_from('multi_polygon', p3).limit(3).to_a - expect(subject[0..1]).to contain_exactly(new_box_e, new_box_b) # Both boxes are at same distance from p3 - expect(subject[2..]).to eq([new_box_a]) - end - - specify ' orders objects by distance from passed object' do - expect(GeographicItem.ordered_by_shortest_distance_from('geometry_collection', p3) - .limit(3).to_a) - .to eq([e, j]) - end - end - - context '::ordered_by_longest_distance_from' do - before { - [r2024, r2022, r2020, c3, c1, c2, g1, g2, g3, b2, rooms, h, c, f, g, j, e].each - } - - specify 'orders points by distance from passed point' do - expect(GeographicItem.ordered_by_longest_distance_from('point', p3).limit(3).to_a) - .to eq([r2024, r2022, r2020]) - end - - specify 'orders line_strings by distance from passed point' do - expect(GeographicItem.ordered_by_longest_distance_from('line_string', p3) - .limit(3).to_a) - .to eq([c3, c1, c2]) - end - - specify 'orders polygons by distance from passed point' do - expect(GeographicItem.ordered_by_longest_distance_from('polygon', p3) - .limit(4).to_a) - .to eq([g1, g2, g3, b2]) - end - - specify 'orders multi_points by distance from passed point' do - expect(GeographicItem.ordered_by_longest_distance_from('multi_point', p3) - .limit(3).to_a) - .to eq([rooms, h]) - end - - specify 'orders multi_line_strings by distance from passed point' do - expect(GeographicItem.ordered_by_longest_distance_from('multi_line_string', p3) - .limit(3).to_a) - .to eq([c, f]) - end - - specify 'orders multi_polygons by distance from passed point' do - # existing multi_polygons: [new_box_e, new_box_a, new_box_b, g] - # new_box_e is excluded, because p3 is *exactly* the same distance from new_box_e, *and* new_box_a - # This seems to be the reason these two objects *might* be in either order. Thus, one of the two - # is excluded to prevent it from confusing the order (farthest first) of the appearance of the objects. - expect(GeographicItem.ordered_by_longest_distance_from('multi_polygon', p3) - .not_including(new_box_e) - .limit(3).to_a) # TODO: Limit is being called over an array. Check whether this is a gem/rails bug or we need to update code. - .to eq([g, new_box_a, new_box_b]) - end - - specify 'orders objects by distance from passed object geometry_collection' do - expect(GeographicItem.ordered_by_longest_distance_from('geometry_collection', p3) - .limit(3).to_a) - .to eq([j, e]) - end - end - - context '::disjoint_from' do - before { [p1].each } - - specify "list of objects (uses 'and')." do - expect(GeographicItem.disjoint_from('point', - [e1, e2, e3, e4, e5]) - .order(:id) - .limit(1).to_a) - .to contain_exactly(p_b) - end - end - context '::within_radius_of_item' do before { [e2, e3, e4, e5, item_a, item_b, item_c, item_d, k, r2022, r2024, p14].each } @@ -975,52 +748,6 @@ expect(GeographicItem.intersecting('polygon', [f1.id])) .to eq([]) # Is this right? end - - specify '::select_distance_with_geo_object provides an extra column called ' \ - '\'distance\' to the output objects' do - result = GeographicItem.select_distance_with_geo_object('point', r2020) - .limit(3).order('distance') - .where_distance_greater_than_zero('point', r2020).to_a - # get back these three points - expect(result).to eq([r2022, r2024, p14]) - end - - specify '::select_distance_with_geo_object provides an extra column called ' \ - '\'distance\' to the output objects' do - result = GeographicItem.select_distance_with_geo_object('point', r2020) - .limit(3).order('distance') - .where_distance_greater_than_zero('point', r2020).to_a - # 5 meters - expect(result.first.distance).to be_within(0.1).of(5.008268179) - end - - specify '::select_distance_with_geo_object provides an extra column called ' \ - '\'distance\' to the output objects' do - result = GeographicItem.select_distance_with_geo_object('point', r2020) - .limit(3).order('distance') - .where_distance_greater_than_zero('point', r2020).to_a - # 10 meters - expect(result[1].distance).to be_within(0.1).of(10.016536381) - end - - specify '::select_distance_with_geo_object provides an extra column called ' \ - '\'distance\' to the output objects' do - result = GeographicItem.select_distance_with_geo_object('point', r2020) - .limit(3).order('distance') - .where_distance_greater_than_zero('point', r2020).to_a - # 5,862 km (3,642 miles) - expect(result[2].distance).to be_within(0.1).of(5862006.0029975) - end - end - - context 'distance to others' do - specify 'slow' do - expect(p1.st_distance(p2.id)).to be_within(0.1).of(479988.25399881) - end - - specify 'fast' do - expect(p1.st_distance_spheroid(p2.id)).to be_within(0.1).of(479988.253998808) - end end end From 7357d4d9a17c5a3039e6c3b92b95ef2dd732c17f Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Wed, 3 Jul 2024 07:13:02 -0500 Subject: [PATCH 064/259] #1954 Fix naming in GeographicItem * Don't use `within` to mean st_covers or st_covered_by - within is the converse of st_contains(!). Instead I've switched to subset_of and superset_of - more general terms but they have the virtue of being obvious opposites, as opposed to 'within' and 'contained_by'. Also change all ST_* names to their underscore versions. --- app/models/collecting_event.rb | 4 +- app/models/collection_object.rb | 4 +- app/models/geographic_area.rb | 2 +- app/models/geographic_item.rb | 114 +++++++++++++--------------- spec/models/geographic_item_spec.rb | 30 ++++---- 5 files changed, 74 insertions(+), 80 deletions(-) diff --git a/app/models/collecting_event.rb b/app/models/collecting_event.rb index 9d57149163..854a9ae642 100644 --- a/app/models/collecting_event.rb +++ b/app/models/collecting_event.rb @@ -364,7 +364,7 @@ def select_optimized(user_id, project_id) # @return [Scope] def contained_within(geographic_item) CollectingEvent.joins(:geographic_items).where( - GeographicItem.within_union_of_sql(geographic_item.id) + GeographicItem.subset_of_union_of_sql(geographic_item.id) ) end @@ -817,7 +817,7 @@ def containing_geographic_items # !! and there was no tests broken # GeographicItem.st_covers('any_poly', self.geographic_items.to_a).pluck(:id).uniq gi_list = GeographicItem - .covering_union_of(*geographic_items.pluck(:id)) + .superset_of_union_of(*geographic_items.pluck(:id)) .pluck(:id) .uniq diff --git a/app/models/collection_object.rb b/app/models/collection_object.rb index b5c88b2116..b015372642 100644 --- a/app/models/collection_object.rb +++ b/app/models/collection_object.rb @@ -332,7 +332,7 @@ def self.in_geographic_item(geographic_item, limit, steps = false) if steps gi = GeographicItem.find(geographic_item_id) # find the geographic_items inside gi - step_1 = GeographicItem.st_coveredby('any', gi) # .pluck(:id) + step_1 = GeographicItem.st_covered_by('any', gi) # .pluck(:id) # find the georeferences from the geographic_items step_2 = step_1.map(&:georeferences).uniq.flatten # find the collecting events connected to the georeferences @@ -342,7 +342,7 @@ def self.in_geographic_item(geographic_item, limit, steps = false) retval = CollectionObject.where(id: step_4.sort) else retval = CollectionObject.joins(:geographic_items) - .where(GeographicItem.within_union_of_sql(geographic_item.id)) + .where(GeographicItem.subset_of_union_of_sql(geographic_item.id)) .limit(limit) .includes(:data_attributes, :collecting_event) end diff --git a/app/models/geographic_area.rb b/app/models/geographic_area.rb index fb99342260..c68d7430f0 100644 --- a/app/models/geographic_area.rb +++ b/app/models/geographic_area.rb @@ -244,7 +244,7 @@ def self.countries def self.is_contained_by(geographic_area) pieces = nil if geographic_area.geographic_items.any? - pieces = GeographicItem.st_coveredby('any_poly', geographic_area.geo_object) + pieces = GeographicItem.st_covered_by('any_poly', geographic_area.geo_object) others = [] pieces.each { |other| others.push(other.geographic_areas.to_a) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 4702e1e242..8110b12cc3 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -135,7 +135,7 @@ class GeographicItem < ApplicationRecord class << self def st_union(geographic_item_scope) - self.select("ST_Union(#{GeographicItem::GEOMETRY_SQL.to_sql}) as collection") + select("ST_Union(#{GeographicItem::GEOMETRY_SQL.to_sql}) as collection") .where(id: geographic_item_scope.pluck(:id)) end @@ -146,46 +146,47 @@ def st_covers_sql(shape1_sql, shape2_sql) ')' end - # True for those shapes that are within (subsets of) the shape_sql shape. - def within_sql(shape_sql) + # True for those shapes that cover the shape_sql shape. + def superset_of_sql(shape_sql) st_covers_sql( - shape_sql, - GeographicItem::GEOMETRY_SQL.to_sql + GeographicItem::GEOMETRY_SQL.to_sql, + shape_sql ) end - # Note: !! If the target GeographicItem#id crosses the anti-meridian then - # you may/will get unexpected results. - def within_union_of_sql(*geographic_item_ids) - within_sql( - self.items_as_one_geometry_sql(*geographic_item_ids) + # @return [Scope] of items covering the union of geographic_item_ids; + # does not include any of geographic_item_ids + def superset_of_union_of(*geographic_item_ids) + where( + superset_of_sql( + items_as_one_geometry_sql(*geographic_item_ids) + ) ) + .not_ids(*geographic_item_ids) end - def st_coveredby_sql(shape1_sql, shape2_sql) + def st_covered_by_sql(shape1_sql, shape2_sql) 'ST_CoveredBy(' \ "(#{shape1_sql})::geometry, " \ "(#{shape2_sql})::geometry" \ ')' end - # True for those shapes that cover the shape_sql shape. - def covering_sql(shape_sql) - st_coveredby_sql( - shape_sql, - GeographicItem::GEOMETRY_SQL.to_sql + # True for those shapes that are subsets of the shape_sql shape. + def subset_of_sql(shape_sql) + st_covered_by_sql( + GeographicItem::GEOMETRY_SQL.to_sql, + shape_sql ) end - # @return [Scope] of items covering the union of geographic_item_ids; - # does not include any of geographic_item_ids - def covering_union_of(*geographic_item_ids) - where( - covering_sql( - items_as_one_geometry_sql(*geographic_item_ids) - ) + # Note: !! If the target GeographicItem#id crosses the anti-meridian then + # you may/will get unexpected results. + # TODO why don't we exclude self here? + def subset_of_union_of_sql(*geographic_item_ids) + subset_of_sql( + self.items_as_one_geometry_sql(*geographic_item_ids) ) - .not_ids(*geographic_item_ids) end def st_distance_sql(shape_sql) @@ -201,43 +202,43 @@ def st_area_sql(shape_sql) ')' end - def st_isvalid_sql(shape_sql) + def st_is_valid_sql(shape_sql) 'ST_IsValid(' \ "(#{shape_sql})" \ ')' end - def st_isvalidreason_sql(shape_sql) + def st_is_valid_reason_sql(shape_sql) 'ST_IsValidReason(' \ "(#{shape_sql})" \ ')' end - def st_astext_sql(shape_sql) + def st_as_text_sql(shape_sql) 'ST_AsText(' \ "(#{shape_sql})" \ ')' end - def st_minimumboundingradius_sql(shape_sql) + def st_minimum_bounding_radius_sql(shape_sql) 'ST_MinimumBoundingRadius(' \ "(#{shape_sql})" \ ')' end - def st_asgeojson_sql(shape_sql) + def st_as_geo_json_sql(shape_sql) 'ST_AsGeoJSON(' \ "(#{shape_sql})" \ ')' end - def st_geographyfromtext_sql(wkt_sql) + def st_geography_from_text_sql(wkt_sql) 'ST_GeographyFromText(' \ "'#{wkt_sql}'" \ ')' end - def st_geomfromtext_sql(wkt_sql) + def st_geom_from_text_sql(wkt_sql) 'ST_GeomFromText(' \ "'#{wkt_sql}', " \ '4326' \ @@ -264,14 +265,6 @@ def st_intersects_sql(shape1_sql, shape2_sql) ')' end - def st_distancespheroid_sql(shape1_sql, shape2_sql) - 'ST_DistanceSpheroid(' \ - "(#{shape1_sql})::geometry, " \ - "(#{shape2_sql})::geometry, " \ - "'#{Gis::SPHEROID}'" \ - ')' - end - # True for those shapes that are within `distance` of (i.e. intersect the # `distance`-buffer of) the shape_sql shape. This is a geography dwithin, # distance is in meters. @@ -395,7 +388,7 @@ def st_buffer_st_within_sql(geographic_item_id, distance, buffer = 0) # distance-buffer intersects) wkt def intersecting_radius_of_wkt_sql(wkt, distance) st_dwithin_sql( - st_geographyfromtext_sql(wkt), + st_geography_from_text_sql(wkt), distance ) end @@ -404,9 +397,9 @@ def intersecting_radius_of_wkt_sql(wkt, distance) # @param [Integer] distance (meters) # @return [String] Those items within the distance-buffer of wkt def within_radius_of_wkt_sql(wkt, distance) - within_sql( + subset_of_sql( st_buffer_sql( - st_geographyfromtext_sql(wkt), + st_geography_from_text_sql(wkt), distance ) ) @@ -464,7 +457,7 @@ def items_as_one_geometry_sql(*geographic_item_ids) # Note: this routine is called when it is already known that the A # argument crosses anti-meridian def covered_by_wkt_shifted_sql(wkt) - "ST_CoveredBy( + "st_covered_by( (CASE geographic_items.type WHEN 'GeographicItem::MultiPolygon' THEN ST_ShiftLongitude(multi_polygon::geometry) WHEN 'GeographicItem::Point' THEN ST_ShiftLongitude(point::geometry) @@ -486,8 +479,8 @@ def covered_by_wkt_sql(wkt) if crosses_anti_meridian?(wkt) covered_by_wkt_shifted_sql(wkt) else - within_sql( - st_geomfromtext_sql(wkt) + subset_of_sql( + st_geom_from_text_sql(wkt) ) end end @@ -629,13 +622,14 @@ def st_covers(shape, *geographic_items) # GeographicItem, or an array of GeographicItem. # @return [Scope] of all GeographicItems of the given `shape ` covered by # one or more of geographic_items - def st_coveredby(shape, *geographic_items) + # !! Returns geographic_item when geographic_item is of type `shape` + def st_covered_by(shape, *geographic_items) shape = shape.to_s.downcase case shape when 'any' part = [] SHAPE_TYPES.each { |shape| - part.push(GeographicItem.st_coveredby(shape, geographic_items).to_a) + part.push(GeographicItem.st_covered_by(shape, geographic_items).to_a) } # @TODO change 'id in (?)' to some other sql construct GeographicItem.where(id: part.flatten.map(&:id)) @@ -645,7 +639,7 @@ def st_coveredby(shape, *geographic_items) SHAPE_TYPES.each { |shape| shape = shape.to_s if shape.index(shape.gsub('any_', '')) - part.push(GeographicItem.st_coveredby(shape, geographic_items).to_a) + part.push(GeographicItem.st_covered_by(shape, geographic_items).to_a) end } # @TODO change 'id in (?)' to some other sql construct @@ -653,7 +647,7 @@ def st_coveredby(shape, *geographic_items) else q = geographic_items.flatten.collect { |geographic_item| - st_coveredby_sql( + st_covered_by_sql( shape_column_sql(shape), geometry_sql(geographic_item.id, geographic_item.geo_object_type) ) @@ -681,7 +675,7 @@ def not_including(geographic_items) # as per #inferred_geographic_name_hierarchy but for Rgeo point def point_inferred_geographic_name_hierarchy(point) self - .where(covering_sql(st_geomfromtext_sql(point.to_s))) + .where(superset_of_sql(st_geom_from_text_sql(point.to_s))) .order(cached_total_area: :ASC) .first&.inferred_geographic_name_hierarchy end @@ -762,7 +756,7 @@ def covering_geographic_areas .joins(:geographic_items) .includes(:geographic_area_type) .joins( - "JOIN (#{GeographicItem.covering_union_of(id).to_sql}) AS j ON " \ + "JOIN (#{GeographicItem.superset_of_union_of(id).to_sql}) AS j ON " \ 'geographic_items.id = j.id' ) end @@ -773,7 +767,7 @@ def valid_geometry? GeographicItem .where(id:) .select( - self.class.st_isvalid_sql("#{data_column}::geometry") + self.class.st_is_valid_sql("#{data_column}::geometry") ).first['st_isvalid'] end @@ -806,13 +800,13 @@ def st_distance_to_geographic_item(geographic_item) unless !persisted? || changed? a = "(#{self.class.select_geography_sql(id)})" else - a = self.class.st_geographyfromtext_sql(geo_object.to_s) + a = self.class.st_geography_from_text_sql(geo_object.to_s) end unless !geographic_item.persisted? || geographic_item.changed? b = "(#{self.class.select_geography_sql(geographic_item.id)})" else - b = self.class.st_geographyfromtext_sql(geographic_item.geo_object.to_s) + b = self.class.st_geography_from_text_sql(geographic_item.geo_object.to_s) end ActiveRecord::Base.connection.select_value("SELECT ST_Distance(#{a}, #{b})") @@ -824,7 +818,7 @@ def st_centroid GeographicItem .where(id:) .pick(Arel.sql( - self.class.st_astext_sql( + self.class.st_as_text_sql( self.class.st_centroid_sql(GeographicItem::GEOMETRY_SQL.to_sql) ) )) @@ -861,7 +855,7 @@ def rgeo_to_geo_json def to_geo_json JSON.parse( select_self( - self.class.st_asgeojson_sql(data_column) + self.class.st_as_geo_json_sql(data_column) )['st_asgeojson'] ) end @@ -940,7 +934,7 @@ def to_wkt # 10k if (a = select_self( - self.class.st_astext_sql(GeographicItem::GEOMETRY_SQL.to_sql) + self.class.st_as_text_sql(GeographicItem::GEOMETRY_SQL.to_sql) )) return a['st_astext'] else @@ -966,7 +960,7 @@ def area # Use case is returning the radius from a circle we calculated via buffer for error-polygon creation. def radius r = select_self( - self.class.st_minimumboundingradius_sql( + self.class.st_minimum_bounding_radius_sql( GeographicItem::GEOMETRY_SQL.to_sql ) )['st_minimumboundingradius'].split(',').last.chop.to_f @@ -1009,7 +1003,7 @@ def is_basic_donut? def st_isvalid select_self( - self.class.st_isvalid_sql( + self.class.st_is_valid_sql( GeographicItem::GEOMETRY_SQL.to_sql ) )['st_isvalid'] @@ -1017,7 +1011,7 @@ def st_isvalid def st_isvalidreason select_self( - self.class.st_isvalidreason_sql( + self.class.st_is_valid_reason_sql( GeographicItem::GEOMETRY_SQL.to_sql ) )['st_isvalidreason'] diff --git a/spec/models/geographic_item_spec.rb b/spec/models/geographic_item_spec.rb index 19dc9183e9..9bb1339c5d 100644 --- a/spec/models/geographic_item_spec.rb +++ b/spec/models/geographic_item_spec.rb @@ -137,7 +137,7 @@ # geographic_item_with_polygon.geo_object)).to be_falsey end - specify 'ST_CoveredBy' do + specify 'st_covered_by' do skip #expect(GeographicItem.covers(geographic_item_with_polygon.geo_object, # geographic_item_with_point_c.geo_object)).to be_truthy @@ -570,23 +570,23 @@ before { [k, l, b, b1, b2, e1].each } specify 'find the polygon containing the points' do - expect(GeographicItem.covering_union_of(p1.id).to_a).to contain_exactly(k) + expect(GeographicItem.superset_of_union_of(p1.id).to_a).to contain_exactly(k) end specify 'find the polygon containing all three points' do - expect(GeographicItem.covering_union_of(p1.id, p2.id, p3.id).to_a).to contain_exactly(k) + expect(GeographicItem.superset_of_union_of(p1.id, p2.id, p3.id).to_a).to contain_exactly(k) end specify 'find that a line string can contain a point' do - expect(GeographicItem.covering_union_of(p4.id).to_a).to contain_exactly(l) + expect(GeographicItem.superset_of_union_of(p4.id).to_a).to contain_exactly(l) end specify 'point in two polygons, but not their intersection' do - expect(GeographicItem.covering_union_of(p18.id).to_a).to contain_exactly(b1, b2) + expect(GeographicItem.superset_of_union_of(p18.id).to_a).to contain_exactly(b1, b2) end specify 'point in two polygons, one with a hole in it' do - expect(GeographicItem.covering_union_of(p19.id).to_a).to contain_exactly(b1, b) + expect(GeographicItem.superset_of_union_of(p19.id).to_a).to contain_exactly(b1, b) end end @@ -664,14 +664,14 @@ specify 'find the points in a polygon' do expect( GeographicItem.where( - GeographicItem.within_union_of_sql(k.id) + GeographicItem.subset_of_union_of_sql(k.id) ).to_a ).to contain_exactly(p1, p2, p3, k) end specify 'find the (overlapping) points in a polygon' do overlapping_point = FactoryBot.create(:geographic_item_point, point: point12.as_binary) - expect(GeographicItem.where(GeographicItem.within_union_of_sql(e1.id)).to_a).to contain_exactly(p12, overlapping_point, p11, e1) + expect(GeographicItem.where(GeographicItem.subset_of_union_of_sql(e1.id)).to_a).to contain_exactly(p12, overlapping_point, p11, e1) end end @@ -679,16 +679,16 @@ before { [b, p0, p1, p2, p3, p11, p12, p13, p18, p19].each } specify ' three things inside k' do - expect(GeographicItem.st_coveredby('any', k).not_including(k).to_a) + expect(GeographicItem.st_covered_by('any', k).not_including(k).to_a) .to contain_exactly(p1, p2, p3) end specify 'one thing outside k' do - expect(GeographicItem.st_coveredby('any', p4).not_including(p4).to_a).to eq([]) + expect(GeographicItem.st_covered_by('any', p4).not_including(p4).to_a).to eq([]) end specify 'three things inside and one thing outside k' do - pieces = GeographicItem.st_coveredby('any', + pieces = GeographicItem.st_covered_by('any', [e2, k]).not_including([k, e2]).to_a # p_a just happens to be in context because it happens to be the # GeographicItem of the Georeference g_a defined in an outer @@ -700,19 +700,19 @@ # other objects are returned as well, we just don't care about them: # we want to find p1 inside K, and p11 inside e1 specify 'one specific thing inside one thing, and another specific thing inside another thing' do - expect(GeographicItem.st_coveredby('any', + expect(GeographicItem.st_covered_by('any', [e1, k]).to_a) .to include(p1, p11) end specify 'one thing (p19) inside a polygon (b) with interior, and another inside ' \ 'the interior which is NOT included (p18)' do - expect(GeographicItem.st_coveredby('any', b).not_including(b).to_a).to eq([p19]) + expect(GeographicItem.st_covered_by('any', b).not_including(b).to_a).to eq([p19]) end specify 'three things inside two things. Notice that the outer ring of b ' \ 'is co-incident with b1, and thus "contained".' do - expect(GeographicItem.st_coveredby('any', + expect(GeographicItem.st_covered_by('any', [b1, b2]).not_including([b1, b2]).to_a) .to contain_exactly(p18, p19, b) end @@ -720,7 +720,7 @@ # other objects are returned as well, we just don't care about them # we want to find p19 inside b and b1, but returned only once specify 'both b and b1 contain p19, which gets returned only once' do - expect(GeographicItem.st_coveredby('any', + expect(GeographicItem.st_covered_by('any', [b1, b]).to_a) .to include(p19) end From 34032d7f6e03b30928f3b7890506b1715eae30fb Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Tue, 2 Jul 2024 14:17:38 -0500 Subject: [PATCH 065/259] #1954 Copy simple geographic_item_spec specs for geography Copy existing specs from geographic_item_spec.rb that only require a simple non-specific shape to complete the test. * Some of the originals used a non-specific shape where they didn't need to, those were adjusted to use a simple shape * Some specs got moved around * Some went away because they won't be used once we have just a single geography column used for all shapes * I removed what seemed to me like rgeo tests that tested rgeo methods on rgeo objects independent of any involvement with GeographicItem. Those can be added back later if desired. * (A few specs requiring specific relations between shapes were commented out for now just so I wouldn't lose track of them) --- app/models/geographic_item.rb | 1 + spec/models/geographic_item/geography_spec.rb | 210 ++++++++++++++++-- .../shared_geo_for_geography.rb | 53 +++++ 3 files changed, 249 insertions(+), 15 deletions(-) create mode 100644 spec/support/shared_contexts/shared_geo_for_geography.rb diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 8110b12cc3..d6407c4b01 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -814,6 +814,7 @@ def st_distance_to_geographic_item(geographic_item) # @return [String] # a WKT POINT representing the centroid of the geographic item + # *as a geometry object* def st_centroid GeographicItem .where(id:) diff --git a/spec/models/geographic_item/geography_spec.rb b/spec/models/geographic_item/geography_spec.rb index 1e6d0e38ff..79d2b420ca 100644 --- a/spec/models/geographic_item/geography_spec.rb +++ b/spec/models/geographic_item/geography_spec.rb @@ -2,41 +2,221 @@ require 'support/shared_contexts/shared_geo' describe GeographicItem::Geography, type: :model, group: [:geo, :shared_geo] do - include_context 'stuff for complex geo tests' + include_context 'stuff for geography tests' + + let(:geographic_item) { GeographicItem.new } + context 'can hold any' do specify 'point' do - point = FactoryBot.build(:geographic_item_geography, geography: room2024.as_binary) - expect(point.geography.geometry_type.type_name).to eq('Point') + expect(simple_point.geography.geometry_type.type_name).to eq('Point') end specify 'line_string' do - line_string = FactoryBot.build(:geographic_item_geography, geography: shape_a1.as_binary) - expect(line_string.geography.geometry_type.type_name).to eq('LineString') + expect(simple_line_string.geography.geometry_type.type_name).to eq('LineString') end specify 'polygon' do - polygon = FactoryBot.build(:geographic_item_geography, geography: shape_k.as_binary) - expect(polygon.geography.geometry_type.type_name).to eq('Polygon') + expect(simple_polygon.geography.geometry_type.type_name).to eq('Polygon') end specify 'multi_point' do - multi_point = FactoryBot.build(:geographic_item_geography, geography: rooms20nn.as_binary) - expect(multi_point.geography.geometry_type.type_name).to eq('MultiPoint') + expect(simple_multi_point.geography.geometry_type.type_name).to eq('MultiPoint') end specify 'multi_line_string' do - multi_line_string = FactoryBot.build(:geographic_item_geography, geography: shape_c.as_binary) - expect(multi_line_string.geography.geometry_type.type_name).to eq('MultiLineString') + expect(simple_multi_line_string.geography.geometry_type.type_name).to eq('MultiLineString') end specify 'multi_polygon' do - multi_polygon = FactoryBot.build(:geographic_item_geography, geography: shape_g.as_binary) - expect(multi_polygon.geography.geometry_type.type_name).to eq('MultiPolygon') + expect(simple_multi_polygon.geography.geometry_type.type_name).to eq('MultiPolygon') end specify 'geometry_collection' do - geometry_collection = FactoryBot.build(:geographic_item_geography, geography: all_shapes.as_binary) - expect(geometry_collection.geography.geometry_type.type_name).to eq('GeometryCollection') + expect(simple_geometry_collection.geography.geometry_type.type_name).to eq('GeometryCollection') + end + end + + context 'construction via #shape=' do + + let(:geo_json) { + '{ + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [10, 10] + }, + "properties": { + "name": "Sample Point", + "description": "This is a sample point feature." + } + }' + } + + let(:geo_json2) { + '{ + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [20, 20] + }, + "properties": { + "name": "Sample Point", + "description": "This is a sample point feature." + } + }' + } + + specify '#shape=' do + g = GeographicItem.new(shape: geo_json) + expect(g.save).to be_truthy + end + + specify '#shape= 2' do + g = GeographicItem.create!(shape: geo_json) + g.update!(shape: geo_json2) + expect(g.reload.geo_object.to_s).to match(/20/) + end + + specify '#shape= bad linear ring' do + bad = '{ + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-80.498221, 25.761437], + [-80.498221, 25.761959], + [-80.498221, 25.761959], + [-80.498221, 25.761437] + ] + ] + }, + "properties": {} + }' + + g = GeographicItem.new(shape: bad) + g.valid? + expect(g.errors[:base]).to be_present + end + + specify 'for polygon' do + geographic_item.shape = '{"type":"Feature","geometry":{"type":"Polygon",' \ + '"coordinates":[[[-90.25122106075287,38.619731572825145],[-86.12036168575287,39.77758382625017],' \ + '[-87.62384042143822,41.89478088863241],[-90.25122106075287,38.619731572825145]]]},"properties":{}}' + expect(geographic_item.valid?).to be_truthy + end + + specify 'for linestring' do + geographic_item.shape = + '{"type":"Feature","geometry":{"type":"LineString","coordinates":[' \ + '[-90.25122106075287,38.619731572825145],' \ + '[-86.12036168575287,39.77758382625017],' \ + '[-87.62384042143822,41.89478088863241]]},"properties":{}}' + expect(geographic_item.valid?).to be_truthy + end + + specify 'for "circle"' do + geographic_item.shape = '{"type":"Feature","geometry":{"type":"Point",' \ + '"coordinates":[-88.09681320155505,40.461195702960666]},' \ + '"properties":{"radius":1468.749413840412, "name":"Paxton City Hall"}}' + expect(geographic_item.valid?).to be_truthy + end + end + + context '#geo_object_type gives underlying shape' do + specify '#geo_object_type' do + expect(geographic_item).to respond_to(:geo_object_type) + end + + specify '#geo_object_type when item not saved' do + geographic_item.point = simple_shapes[:point] + expect(geographic_item.geo_object_type).to eq(:point) + end + end + + context 'validation' do + specify 'some data must be provided' do + geographic_item.valid? + expect(geographic_item.errors[:base]).to be_present + end + + specify 'invalid data for point is invalid' do + geographic_item.point = 'Some string' + expect(geographic_item.valid?).to be_falsey + end + + specify 'a valid point is valid' do + expect(simple_point.valid?).to be_truthy + end + + specify "a good point didn't change on creation" do + expect(simple_point.geography.x).to eq 10 + end + + specify 'a point, when provided, has a legal geography' do + geographic_item.geography = simple_rgeo_point + expect(geographic_item.valid?).to be_truthy + end + + specify 'geography can change shape' do + simple_point.geography = simple_polygon.geography + expect(simple_point.valid?).to be_truthy + expect(simple_point.geo_object_type).to eq(:polygon) + end + end + + context '#geo_object' do + before { + geographic_item.geography = simple_rgeo_point + } + + specify '#geo_object returns stored data' do + geographic_item.save! + expect(geographic_item.geo_object).to eq(simple_rgeo_point) + end + + specify '#geo_object returns stored db data' do + geographic_item.save! + geo_id = geographic_item.id + expect(GeographicItem.find(geo_id).geo_object).to eq geographic_item.geo_object + end + end + + context 'instance methods' do + specify '#geo_object' do + expect(geographic_item).to respond_to(:geo_object) + end + + specify '#contains? - to see if one object is contained by another.' do + expect(geographic_item).to respond_to(:contains?) + end + + specify '#within? - to see if one object is within another.' do + expect(geographic_item).to respond_to(:within?) + end + + specify '#contains? if one object is inside the area defined by the other (watch out for holes)' do + #expect(k.contains?(p1.geo_object)).to be_truthy + end + + specify '#contains? if one object is inside the area defined by the other (watch out for holes)' do + #expect(e1.contains?(p10.geo_object)).to be_falsey + end + + specify '#st_centroid returns a lat/lng of the centroid of the GeoObject' do + simple_polygon.save! + expect(simple_polygon.st_centroid).to eq('POINT(5 5)') + end + end + + context 'class methods' do + + specify '::within_radius_of_item' do + expect(GeographicItem).to respond_to(:within_radius_of_item) + end + + specify '::intersecting method' do + expect(GeographicItem).to respond_to(:intersecting) end end end diff --git a/spec/support/shared_contexts/shared_geo_for_geography.rb b/spec/support/shared_contexts/shared_geo_for_geography.rb new file mode 100644 index 0000000000..85c78a850c --- /dev/null +++ b/spec/support/shared_contexts/shared_geo_for_geography.rb @@ -0,0 +1,53 @@ +require 'support/vendor/rspec_geo_helpers' + +RSPEC_GEO_FACTORY = Gis::FACTORY + +shared_context 'stuff for geography tests' do + + let(:simple_shapes) { { + point: 'POINT(10 -10 0)', + line_string: 'LINESTRING(0.0 0.0 0.0, 10.0 0.0 0.0)', + polygon:'POLYGON((0.0 0.0 0.0, 10.0 0.0 0.0, 10.0 10.0 0.0, 0.0 10.0 0.0, 0.0 0.0 0.0))', + multi_point: 'MULTIPOINT((10.0 10.0 0.0), (20.0 20.0 0.0))', + multi_line_string: 'MULTILINESTRING((0.0 0.0 0.0, 10.0 0.0 0.0), (20.0 0.0 0.0, 30.0 0.0 0.0))', + multi_polygon: 'MULTIPOLYGON(((0.0 0.0 0.0, 10.0 0.0 0.0, 10.0 10.0 0.0, 0.0 10.0 0.0, ' \ + '0.0 0.0 0.0)),((10.0 10.0 0.0, 20.0 10.0 0.0, 20.0 20.0 0.0, 10.0 20.0 0.0, 10.0 10.0 0.0)))', + geometry_collection: 'GEOMETRYCOLLECTION( POLYGON((0.0 0.0 0.0, 10.0 0.0 0.0, 10.0 10.0 0.0, ' \ + '0.0 10.0 0.0, 0.0 0.0 0.0)), POINT(10 10 0)) ', + geography:'POLYGON((0.0 0.0 0.0, 10.0 0.0 0.0, 10.0 10.0 0.0, 0.0 10.0 0.0, 0.0 0.0 0.0))' + }.freeze } + + let(:simple_point) { + FactoryBot.create(:geographic_item_geography, geography: simple_shapes[:point]) + } + + let(:simple_line_string) { + FactoryBot.create(:geographic_item_geography, geography: simple_shapes[:line_string]) + } + + let(:simple_polygon) { + FactoryBot.create(:geographic_item_geography, geography: simple_shapes[:polygon]) + } + + let(:simple_multi_point) { + FactoryBot.create(:geographic_item_geography, geography: simple_shapes[:multi_point]) + } + + let(:simple_multi_line_string) { + FactoryBot.create(:geographic_item_geography, geography: simple_shapes[:multi_line_string]) + } + + let(:simple_multi_polygon) { + FactoryBot.create( + :geographic_item_geography, geography: simple_shapes[:multi_polygon] + ) + } + + let(:simple_geometry_collection) { + FactoryBot.create( + :geographic_item_geography, geography: simple_shapes[:geometry_collection] + ) + } + + let(:simple_rgeo_point) { RSPEC_GEO_FACTORY.point(10, -10, 0) } +end \ No newline at end of file From e1cf3c6b6a6b58b311063ce2bdf38dc48d525e9b Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Tue, 2 Jul 2024 21:50:05 -0500 Subject: [PATCH 066/259] #1954 Fix bugs in GeographicItem st_covers and st_coveredby --- app/models/geographic_item.rb | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index d6407c4b01..385973e9f7 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -563,8 +563,10 @@ def intersecting(shape, *geographic_item_ids) # 'multi_line_string'. # @param [GeographicItem] geographic_items or array of geographic_items # to be tested. - # @return [Scope] of GeographicItems whose `shape` covers at least one of - # geographic_items + # @return [Scope] of GeographicItems whose `shape` contains at least one + # of geographic_items. + # !! Returns geographic_item when geographic_item is of + # type `shape` # # If this scope is given an Array of GeographicItems as a second parameter, # it will return the 'OR' of each of the objects against the table. @@ -578,18 +580,18 @@ def st_covers(shape, *geographic_items) case shape when 'any' part = [] - SHAPE_TYPES.each { |shape| - part.push(GeographicItem.st_covers(shape, geographic_items).to_a) + SHAPE_TYPES.each { |s| + part.push(GeographicItem.st_covers(s, geographic_items).to_a) } # TODO: change 'id in (?)' to some other sql construct GeographicItem.where(id: part.flatten.map(&:id)) when 'any_poly', 'any_line' part = [] - SHAPE_TYPES.each { |shape| - shape = shape.to_s - if shape.index(shape.gsub('any_', '')) - part.push(GeographicItem.st_covers(shape, geographic_items).to_a) + SHAPE_TYPES.each { |s| + s = s.to_s + if s.index(shape.gsub('any_', '')) + part.push(GeographicItem.st_covers(s, geographic_items).to_a) end } # TODO: change 'id in (?)' to some other sql construct @@ -620,7 +622,7 @@ def st_covers(shape, *geographic_items) # 'multi_line_string'. # @param geographic_items [GeographicItem] Can be a single # GeographicItem, or an array of GeographicItem. - # @return [Scope] of all GeographicItems of the given `shape ` covered by + # @return [Scope] of all GeographicItems of the given `shape` covered by # one or more of geographic_items # !! Returns geographic_item when geographic_item is of type `shape` def st_covered_by(shape, *geographic_items) @@ -628,18 +630,18 @@ def st_covered_by(shape, *geographic_items) case shape when 'any' part = [] - SHAPE_TYPES.each { |shape| - part.push(GeographicItem.st_covered_by(shape, geographic_items).to_a) + SHAPE_TYPES.each { |s| + part.push(GeographicItem.st_covered_by(s, geographic_items).to_a) } # @TODO change 'id in (?)' to some other sql construct GeographicItem.where(id: part.flatten.map(&:id)) when 'any_poly', 'any_line' part = [] - SHAPE_TYPES.each { |shape| - shape = shape.to_s - if shape.index(shape.gsub('any_', '')) - part.push(GeographicItem.st_covered_by(shape, geographic_items).to_a) + SHAPE_TYPES.each { |s| + s = s.to_s + if s.index(shape.gsub('any_', '')) + part.push(GeographicItem.st_covered_by(s, geographic_items).to_a) end } # @TODO change 'id in (?)' to some other sql construct From 1576853b6f0e6d36d9737449d98f205b6cd9a42f Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Wed, 3 Jul 2024 09:18:27 -0500 Subject: [PATCH 067/259] #1954 Enable GeographicItem#shape= to assign to geography column Nobody assigns to the geography column via shape= yet. --- app/models/geographic_item.rb | 10 +++++---- spec/models/geographic_item/geography_spec.rb | 22 ++++++++++++++----- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 385973e9f7..97d004afa5 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -876,7 +876,7 @@ def to_geo_json_feature } end - # @param value [String] like: + # @param value [String] geojson like: # '{"type":"Feature","geometry":{"type":"Point","coordinates":[2.5,4.0]},"properties":{"color":"red"}}' # # '{"type":"Feature","geometry":{"type":"Polygon","coordinates":"[[[-125.29394388198853, 48.584480409793], @@ -885,7 +885,11 @@ def to_geo_json_feature # # '{"type":"Point","coordinates":[2.5,4.0]},"properties":{"color":"red"}}' # - # @return [RGeo object] + # TODO: WHY! boolean not nil, or object + # !! To assign to the geography column you must include + # "data_type":"geography" in the properties hash of `value`` (once all + # shapes are assigned to the geography column this will of course no longer + # be necessary). def shape=(value) return if value.blank? @@ -898,14 +902,12 @@ def shape=(value) this_type = nil - # TODO is this first case still used? if geom.respond_to?(:properties) && geom.properties['data_type'].present? this_type = geom.properties['data_type'] elsif geom.respond_to?(:geometry_type) this_type = geom.geometry_type.to_s elsif geom.respond_to?(:geometry) this_type = geom.geometry.geometry_type.to_s - else end self.type = GeographicItem.eval_for_type(this_type) unless geom.nil? diff --git a/spec/models/geographic_item/geography_spec.rb b/spec/models/geographic_item/geography_spec.rb index 79d2b420ca..b473283c4d 100644 --- a/spec/models/geographic_item/geography_spec.rb +++ b/spec/models/geographic_item/geography_spec.rb @@ -36,8 +36,9 @@ end end + # Note these all use geography as the shape column via + # "data_type":"geography" in the properties hash context 'construction via #shape=' do - let(:geo_json) { '{ "type": "Feature", @@ -46,6 +47,7 @@ "coordinates": [10, 10] }, "properties": { + "data_type":"geography", "name": "Sample Point", "description": "This is a sample point feature." } @@ -60,11 +62,19 @@ "coordinates": [20, 20] }, "properties": { + "data_type":"geography", "name": "Sample Point", "description": "This is a sample point feature." } }' } + specify 'geojson with properties: data_type: geography assigns to ' \ + 'geography column' do + geographic_item.shape = '{"type":"Feature","geometry":{"type":"Point",' \ + '"coordinates":[-88.09681320155505,40.461195702960666]},' \ + '"properties":{"data_type":"geography", "name":"Paxton City Hall"}}' + expect(geographic_item.geography).to be_truthy + end specify '#shape=' do g = GeographicItem.new(shape: geo_json) @@ -91,7 +101,7 @@ ] ] }, - "properties": {} + "properties": {"data_type":"geography"} }' g = GeographicItem.new(shape: bad) @@ -102,7 +112,7 @@ specify 'for polygon' do geographic_item.shape = '{"type":"Feature","geometry":{"type":"Polygon",' \ '"coordinates":[[[-90.25122106075287,38.619731572825145],[-86.12036168575287,39.77758382625017],' \ - '[-87.62384042143822,41.89478088863241],[-90.25122106075287,38.619731572825145]]]},"properties":{}}' + '[-87.62384042143822,41.89478088863241],[-90.25122106075287,38.619731572825145]]]},"properties":{"data_type":"geography"}}' expect(geographic_item.valid?).to be_truthy end @@ -111,14 +121,16 @@ '{"type":"Feature","geometry":{"type":"LineString","coordinates":[' \ '[-90.25122106075287,38.619731572825145],' \ '[-86.12036168575287,39.77758382625017],' \ - '[-87.62384042143822,41.89478088863241]]},"properties":{}}' + '[-87.62384042143822,41.89478088863241]]},' \ + '"properties":{"data_type":"geography"}}' expect(geographic_item.valid?).to be_truthy end specify 'for "circle"' do geographic_item.shape = '{"type":"Feature","geometry":{"type":"Point",' \ '"coordinates":[-88.09681320155505,40.461195702960666]},' \ - '"properties":{"radius":1468.749413840412, "name":"Paxton City Hall"}}' + '"properties":{"data_type":"geography","radius":1468.749413840412,' \ + '"name":"Paxton City Hall"}}' expect(geographic_item.valid?).to be_truthy end end From 9390c93bb1fe8f95f97160699fbe630f0ba3b278 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Tue, 2 Jul 2024 22:35:29 -0500 Subject: [PATCH 068/259] #1954 Port existing GeographicItem specs to geography specs * Basically port, but also adjustments/deletions/additions as I went * Fewer, more basic shapes * Emphasis more on doing enough to make sure methods do the expected thing with basic inputs, as opposed to testing multiple configurations of different/complicated shapes, which from this point of view belongs more to rgeo and/or postgis specific tests. --- app/models/geographic_item.rb | 4 +- spec/models/geographic_item/geography_spec.rb | 320 +++++++++++++++++- .../shared_geo_for_geography.rb | 158 +++++++++ 3 files changed, 468 insertions(+), 14 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 97d004afa5..92002f7401 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -352,7 +352,7 @@ def lat_long_sql(choice) end # @param [Integer] geographic_item_id - # @param [Integer] distance in meters + # @param [Integer] distance in meters TODO not so sure about that - geo items are in degrees, postgis says units are meters # @return [Scope] of shapes within distance of (i.e. whose # distance-buffer intersects) geographic_item_id def within_radius_of_item_sql(geographic_item_id, distance) @@ -537,7 +537,7 @@ def intersecting(shape, *geographic_item_ids) pieces = [] SHAPE_TYPES.each { |shape| pieces.push( - GeographicItem.intersecting(shape, geographic_item_ids).to_a + self.intersecting(shape, geographic_item_ids).to_a ) } diff --git a/spec/models/geographic_item/geography_spec.rb b/spec/models/geographic_item/geography_spec.rb index b473283c4d..2de73f04e1 100644 --- a/spec/models/geographic_item/geography_spec.rb +++ b/spec/models/geographic_item/geography_spec.rb @@ -2,37 +2,43 @@ require 'support/shared_contexts/shared_geo' describe GeographicItem::Geography, type: :model, group: [:geo, :shared_geo] do - include_context 'stuff for geography tests' + include_context 'stuff for geography tests' # spec/support/shared_contexts/shared_geo_for_geography.rb + + # the pattern `before { [s1, s2, ...].each }` is to instantiate variables + # that have been `let` (not `let!`) by referencing them using [...].each. + + # TODO add some geometry_collection specs let(:geographic_item) { GeographicItem.new } context 'can hold any' do specify 'point' do - expect(simple_point.geography.geometry_type.type_name).to eq('Point') + expect(simple_point.geo_object_type).to eq(:point) end specify 'line_string' do - expect(simple_line_string.geography.geometry_type.type_name).to eq('LineString') + expect(simple_line_string.geo_object_type).to eq(:line_string) end specify 'polygon' do - expect(simple_polygon.geography.geometry_type.type_name).to eq('Polygon') + expect(simple_polygon.geo_object_type).to eq(:polygon) end specify 'multi_point' do - expect(simple_multi_point.geography.geometry_type.type_name).to eq('MultiPoint') + expect(simple_multi_point.geo_object_type).to eq(:multi_point) end specify 'multi_line_string' do - expect(simple_multi_line_string.geography.geometry_type.type_name).to eq('MultiLineString') + expect(simple_multi_line_string.geo_object_type).to eq(:multi_line_string) end specify 'multi_polygon' do - expect(simple_multi_polygon.geography.geometry_type.type_name).to eq('MultiPolygon') + expect(simple_multi_polygon.geo_object_type).to eq(:multi_polygon) end specify 'geometry_collection' do - expect(simple_geometry_collection.geography.geometry_type.type_name).to eq('GeometryCollection') + expect(simple_geometry_collection.geo_object_type) + .to eq(:geometry_collection) end end @@ -207,12 +213,12 @@ expect(geographic_item).to respond_to(:within?) end - specify '#contains? if one object is inside the area defined by the other (watch out for holes)' do - #expect(k.contains?(p1.geo_object)).to be_truthy + specify '#contains? if one object is inside the area defined by the other' do + expect(donut.contains?(donut_interior_point.geo_object)).to be_truthy end - specify '#contains? if one object is inside the area defined by the other (watch out for holes)' do - #expect(e1.contains?(p10.geo_object)).to be_falsey + specify '#contains? if one object is outside the area defined by the other' do + expect(donut.contains?(distant_point.geo_object)).to be_falsey end specify '#st_centroid returns a lat/lng of the centroid of the GeoObject' do @@ -230,5 +236,295 @@ specify '::intersecting method' do expect(GeographicItem).to respond_to(:intersecting) end + + context '::superset_of_union_of - return objects containing the union of the + given objects' do + before(:each) { + [donut, donut_hole_point, donut_interior_point, + donut_left_interior_edge_point].each + } + + specify 'find the polygon containing the point' do + expect(GeographicItem.superset_of_union_of( + donut_interior_point.id + ).to_a).to contain_exactly(donut) + end + + specify 'find the polygon containing two points' do + expect(GeographicItem.superset_of_union_of( + donut_interior_point.id, donut_left_interior_edge_point.id + ).to_a).to contain_exactly(donut) + end + + specify 'a polygon covers its edge' do + expect(GeographicItem.superset_of_union_of( + donut_bottom_and_left_interior_edges.id + ).to_a).to contain_exactly(donut) + end + + specify "donut doesn't contain point in donut hole" do + expect( + GeographicItem.superset_of_union_of( + donut_hole_point.id + ).to_a).to eq([]) + end + + specify 'find that shapes contain their vertices' do + vertex = FactoryBot.create(:geographic_item_geography, + geography: donut_left_interior_edge.geo_object.start_point) + + expect(GeographicItem.superset_of_union_of( + vertex.id + ).to_a).to contain_exactly(donut_left_interior_edge, donut) + end + end + + context '::within_union_of' do + before { [donut_bottom_and_left_interior_edges, + donut_interior_point, donut_hole_point, + donut_left_interior_edge].each + } + + specify 'a shape is within_union_of itself' do + expect( + GeographicItem.where( + GeographicItem.subset_of_union_of_sql(donut.id) + ).to_a + ).to include(donut) + end + + specify 'finds the shapes covered by a polygon' do + expect( + GeographicItem.where( + GeographicItem.subset_of_union_of_sql(donut.id) + ).to_a + ).to contain_exactly(donut, donut_bottom_and_left_interior_edges, + donut_interior_point, donut_left_interior_edge) + end + + specify 'returns duplicates' do + duplicate_point = FactoryBot.create(:geographic_item_geography, + geography: box_centroid.geo_object) + + expect( + GeographicItem.where( + GeographicItem.subset_of_union_of_sql(box.id) + ).to_a + ).to contain_exactly(box_centroid, duplicate_point, box) + end + end + + context '::st_covers - returns objects of a given shape which contain one + or more given objects' do + before { [donut, donut_left_interior_edge, + donut_bottom_and_left_interior_edges, + donut_rectangle_multi_polygon, + box, box_rectangle_union + ].each } + + specify 'includes self when self is of the right shape' do + expect(GeographicItem.st_covers('multi_line_string', + [donut_bottom_and_left_interior_edges]).to_a) + .to include(donut_bottom_and_left_interior_edges) + end + + specify 'a shape that covers two input shapes is only returned once' do + expect(GeographicItem.st_covers('polygon', + [box_centroid, box_horizontal_bisect_line]).to_a) + # box and box_rectangle_union contain both inputs + .to contain_exactly( + box, rectangle_intersecting_box, box_rectangle_union + ) + end + + specify 'includes shapes that cover part of their boundary' do + expect(GeographicItem.st_covers('any', + [donut_left_interior_edge]).to_a) + .to contain_exactly(donut_left_interior_edge, + donut_bottom_and_left_interior_edges, donut, + donut_rectangle_multi_polygon + ) + end + + specify 'point covered by nothing is only covered by itself' do + expect(GeographicItem.st_covers('any', + distant_point).to_a) + .to contain_exactly(distant_point) + end + + # OR! + specify 'disjoint polygons each containing an input' do + expect(GeographicItem.st_covers('polygon', + [donut_left_interior_edge, box_centroid]).to_a) + .to contain_exactly( + donut, box, rectangle_intersecting_box, box_rectangle_union + ) + end + + specify 'works with any_line' do + expect(GeographicItem.st_covers('any_line', + donut_left_interior_edge_point, distant_point).to_a) + .to contain_exactly( + donut_left_interior_edge, + donut_bottom_and_left_interior_edges + ) + end + + specify 'works with any_poly' do + expect(GeographicItem.st_covers('any_poly', + box_centroid).to_a) + .to contain_exactly( + box, rectangle_intersecting_box, box_rectangle_union, + donut_rectangle_multi_polygon + ) + end + + specify 'works with any' do + expect(GeographicItem.st_covers('any', + donut_left_interior_edge_point).to_a) + .to contain_exactly(donut_left_interior_edge_point, + donut_left_interior_edge, + donut_bottom_and_left_interior_edges, + donut, + donut_rectangle_multi_polygon + ) + end + end + + context '::st_covered_by - returns objects which are contained by given + objects.' do + before { [donut, donut_interior_point, donut_left_interior_edge_point, + donut_left_interior_edge_point, donut_left_interior_edge, + donut_bottom_and_left_interior_edges, + box, box_centroid, box_horizontal_bisect_line, + rectangle_intersecting_box, box_rectangle_intersection_point, + donut_rectangle_multi_polygon].each } + + specify 'object of the right shape is st_covered_by itself' do + expect(GeographicItem.st_covered_by('multi_line_string', + [donut_bottom_and_left_interior_edges]).to_a) + .to include(donut_bottom_and_left_interior_edges) + end + + specify 'includes shapes which are a boundary component of an input' do + expect(GeographicItem.st_covered_by('line_string', + donut).to_a) + .to contain_exactly(donut_left_interior_edge) + end + + specify 'a point only covers itself' do + expect(GeographicItem.st_covered_by('any', + donut_left_interior_edge_point).to_a) + .to eq([donut_left_interior_edge_point]) + end + + specify 'shapes contained by two shapes are only returned once' do + expect(GeographicItem.st_covered_by('point', + box, rectangle_intersecting_box).to_a) + .to eq([box_centroid, box_rectangle_intersection_point]) + end + + specify 'points in separate polygons' do + expect(GeographicItem.st_covered_by('point', + donut, box).to_a) + .to contain_exactly(donut_interior_point, + donut_left_interior_edge_point, box_centroid, box_rectangle_intersection_point) + end + + specify 'works with any_line' do + expect(GeographicItem.st_covered_by('any_line', + donut).to_a) + .to contain_exactly( + donut_left_interior_edge, donut_bottom_and_left_interior_edges + ) + end + + specify 'works with any_poly' do + expect(GeographicItem.st_covered_by('any_poly', + donut_and_rectangle_geometry_collection).to_a) + .to contain_exactly( + donut, rectangle_intersecting_box, donut_rectangle_multi_polygon + ) + end + + specify 'DOES NOT WORK with arbitrary geometry collection' do + pending 'ST_Covers fails when input GeometryCollection has a line intersecting a polygon\'s interior' + # The same test as the previous only the collection in the first + # argument also contains a line intersecting the interior of rectangle + expect(GeographicItem.st_covered_by('any_poly', + fail_multi_dimen_geometry_collection).to_a) + .to contain_exactly( + donut, rectangle_intersecting_box, donut_rectangle_multi_polygon + ) + end + + specify 'works with any' do + expect(GeographicItem.st_covered_by('any', + box_rectangle_union).to_a) + .to contain_exactly(box_rectangle_union, + box, rectangle_intersecting_box, box_horizontal_bisect_line, + box_centroid, box_rectangle_intersection_point + ) + end + end + + context '::within_radius_of_item' do + before { [box, box_horizontal_bisect_line, box_centroid].each } + + specify 'returns objects within a specific distance of an object' do + # box is 20 from donut at the "equator", box_centroid is 30 from donut + r = 25 * Utilities::Geo::ONE_WEST + expect( + GeographicItem.within_radius_of_item(donut.id, r) + ).to contain_exactly(donut, + box, box_horizontal_bisect_line + ) + end + + # Intended? + specify 'shape is within_radius_of itself' do + expect( + GeographicItem.within_radius_of_item(box_centroid.id, 100) + ).to include(box_centroid) + end + end + + context '::intersecting' do + before { [ + donut, donut_left_interior_edge, + box, box_centroid, box_horizontal_bisect_line, + rectangle_intersecting_box, box_rectangle_union + ].each } + + # Intended? + specify 'a geometry of the right shape intersects itself' do + expect(GeographicItem.intersecting('any', distant_point.id).to_a) + .to eq([distant_point]) + end + + specify 'works with a specific shape' do + expect(GeographicItem.intersecting('polygon', + box_rectangle_intersection_point.id).to_a) + .to contain_exactly( + box, rectangle_intersecting_box, box_rectangle_union + ) + end + + specify 'works with multiple input shapes' do + expect(GeographicItem.intersecting('line_string', + [donut_left_interior_edge_point.id, box_centroid.id]).to_a) + .to contain_exactly( + donut_left_interior_edge, box_horizontal_bisect_line + ) + end + + specify 'works with any' do + expect(GeographicItem.intersecting('any', + box_horizontal_bisect_line.id).to_a) + .to contain_exactly(box_horizontal_bisect_line, + box, box_centroid, rectangle_intersecting_box, box_rectangle_union + ) + end + end end end diff --git a/spec/support/shared_contexts/shared_geo_for_geography.rb b/spec/support/shared_contexts/shared_geo_for_geography.rb index 85c78a850c..40798714c8 100644 --- a/spec/support/shared_contexts/shared_geo_for_geography.rb +++ b/spec/support/shared_contexts/shared_geo_for_geography.rb @@ -4,6 +4,7 @@ shared_context 'stuff for geography tests' do + ###### Simple shapes - no intended relation to each other let(:simple_shapes) { { point: 'POINT(10 -10 0)', line_string: 'LINESTRING(0.0 0.0 0.0, 10.0 0.0 0.0)', @@ -50,4 +51,161 @@ } let(:simple_rgeo_point) { RSPEC_GEO_FACTORY.point(10, -10, 0) } + + ###### Specific shapes testing relations between shapes + + ### Point intended to be outside of any of the shapes defined below + let(:distant_point) { + FactoryBot.create(:geographic_item_geography, geography: 'POINT(1000 1000 0)') + } + + ### A donut polygon and sub-shapes + let(:donut) do + # Definitions below assume both interior and exterior are squares starting + # at the lower left corner; exterior ccw, interior cw + d = 'POLYGON((0 0 0, 20 0 0, 20 20 0, 0 20 0, 0 0 0), + (5 5 0, 15 5 0, 15 15 0, 5 15 0, 5 5 0))' + + FactoryBot.create(:geographic_item_geography, geography: d) + end + + let(:donut_hole_point) { + FactoryBot.create(:geographic_item_geography, geography: donut.centroid) + } + + let (:donut_interior_point) do + # Both corners are lower left + exterior_ring_corner = donut.geo_object.exterior_ring.start_point + interior_ring_corner = donut.geo_object.interior_rings.first.start_point + interior_x = (interior_ring_corner.x - exterior_ring_corner.x) / 2 + interior_y = (interior_ring_corner.y - exterior_ring_corner.y) / 2 + + FactoryBot.create(:geographic_item_point, + point: "POINT(#{interior_x} #{interior_y} 0)") + end + + let (:donut_left_interior_edge_point) { + # lower left corner of interior ring + interior_llc = donut.geo_object.interior_rings.first.start_point + # upper left corner of interior ring + interior_ulc = donut.geo_object.interior_rings.first.points.fourth + x = interior_llc.x + y = interior_llc.y + (interior_ulc.y - interior_llc.y) / 2 + + FactoryBot.create(:geographic_item_geography, + geography: "POINT(#{x} #{y} 0)") + } + + let (:donut_left_interior_edge) { + start_point = donut.geo_object.interior_rings.first.points.first + end_point = donut.geo_object.interior_rings.first.points.fourth + + FactoryBot.create(:geographic_item_geography, + geography: RSPEC_GEO_FACTORY.line_string([start_point, end_point])) + } + + # A multi_line_string + let (:donut_bottom_and_left_interior_edges) { + # lower left corner + llc = donut.geo_object.interior_rings.first.points.first + # lower right corner + lrc = donut.geo_object.interior_rings.first.points.second + #upper left corner + ulc = donut.geo_object.interior_rings.first.points.fourth + lower_edge = RSPEC_GEO_FACTORY.line_string([llc, lrc]) + left_edge = RSPEC_GEO_FACTORY.line_string([ulc, llc]) + + FactoryBot.create(:geographic_item_geography, + geography: RSPEC_GEO_FACTORY.multi_line_string([left_edge, lower_edge])) + } + + ### A box polygon and sub-shapes + let(:box) do + b = 'POLYGON((40 0 0, 60 0 0, 60 20 0, 40 20 0, 40 0 0))' + + FactoryBot.create(:geographic_item_geography, geography: b) + end + + let(:box_centroid) { + FactoryBot.create(:geographic_item_geography, geography: box.centroid) + } + + let(:box_horizontal_bisect_line) { + points = box.geo_object.exterior_ring.points + left_x = points.first.x + right_x = points.second.x + y = points.first.y + (points.fourth.y - points.first.y) / 2 + line = "LINESTRING (#{left_x} #{y} 0, #{right_x} #{y} 0)" + FactoryBot.create(:geographic_item_geography, geography: line) + } + + ### A rectangle polygon intersecting the previous box; both start at y=0, + # the rectangle is taller than box_centroid but shorter than box; + # box_centroid is in the left side of rectangle + let(:rectangle_intersecting_box) do + b = 'POLYGON((50 0 0, 70 0 0, 70 15 0, 50 15 0, 50 0 0))' + + FactoryBot.create(:geographic_item_geography, geography: b) + end + + ### A point in the interior of the intersection of box and rectangle + let(:box_rectangle_intersection_point) { + FactoryBot.create(:geographic_item_geography, geography: 'POINT (55 5 0)') + } + + ### The union of the box and rectangle polygons as a single polygon + let(:box_rectangle_union) { + FactoryBot.create(:geographic_item_geography, + geography: box.geo_object.union(rectangle_intersecting_box.geo_object) + )} + + ###### Multi-shapes + ### A multi_point + let(:donut_box_multi_point) do + donut_point = donut_interior_point.geo_object + box_point = box_centroid + m_p = RSPEC_GEO_FACTORY.multi_point([donut_point, box_point]) + + FactoryBot.create(:geographic_item_geography, geography: m_p) + end + + ### A mult_line_string + # :donut_bottom_and_left_interior_edges is a multi_line_string + + ### A multi_polygon + let(:donut_rectangle_multi_polygon) do + m_poly = RSPEC_GEO_FACTORY.multi_polygon( + [donut.geo_object, rectangle_intersecting_box.geo_object] + ) + + FactoryBot.create(:geographic_item_geography, geography: m_poly) + end + + ### A geometry_collection + let(:donut_and_rectangle_geometry_collection) do + g_c = RSPEC_GEO_FACTORY.collection( + [ + donut.geo_object, + rectangle_intersecting_box.geo_object + ] + ) + + FactoryBot.create(:geographic_item_geography, geography: g_c) + end + + # Same as :donut_and_rectangle_geometry_collection but including a line + # intersecting the interior of rectangle (adding a point in the interior of + # rectangle seems fine) - st_cover fails with this collection as its first + # argument. + let(:fail_multi_dimen_geometry_collection) do + g_c = RSPEC_GEO_FACTORY.collection( + [ + donut.geo_object, + rectangle_intersecting_box.geo_object, + box_horizontal_bisect_line.geo_object + ] + ) + + FactoryBot.create(:geographic_item_geography, geography: g_c) + end end \ No newline at end of file From 759759c79e0759d959108517c47211475680624b Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Fri, 5 Jul 2024 21:58:04 -0500 Subject: [PATCH 069/259] #1954 Add some additional GeographicItem geography tests --- app/models/geographic_item.rb | 68 ++-- app/models/georeference.rb | 4 +- app/models/georeference/verbatim_data.rb | 6 +- spec/models/geographic_item/geography_spec.rb | 317 ++++++++++++++---- .../shared_geo_for_geography.rb | 84 +++-- 5 files changed, 350 insertions(+), 129 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 92002f7401..2f309fae01 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -1,8 +1,10 @@ # require 'rgeo' -# A GeographicItem is one and only one of [point, line_string, polygon, multi_point, multi_line_string, -# multi_polygon, geometry_collection, geography] which describes a position, path, or area on the globe, generally associated -# with a geographic_area (through a geographic_area_geographic_item entry), a gazetteer, or a georeference. +# A GeographicItem is one and only one of [point, line_string, polygon, +# multi_point, multi_line_string, multi_polygon, geometry_collection, +# geography] which describes a position, path, or area on the globe, +# generally associated with a geographic_area (through a +# geographic_area_geographic_item entry), a gazetteer, or a georeference. # # @!attribute point # @return [RGeo::Geographic::ProjectedPointImpl] @@ -369,7 +371,8 @@ def within_radius_of_item(geographic_item_id, distance) # @param [Integer] geographic_item_id # @param [Number] distance (in meters) (positive only?!) # @param [Number] buffer: distance in meters to grow/shrink the shapes checked against (negative allowed) - # @return [String] + # @return [String] Shapes whose `buffer` is within `distance` of + # geographic_item def st_buffer_st_within_sql(geographic_item_id, distance, buffer = 0) # You can't always switch the buffer to the second argument, even when # distance is 0, without further assumptions (think of buffer being @@ -773,28 +776,6 @@ def valid_geometry? ).first['st_isvalid'] end - # @return [Array] - # the lat, long, as STRINGs for the centroid of this geographic item - # Meh- this: https://postgis.net/docs/en/ST_MinimumBoundingRadius.html - def center_coords - r = GeographicItem.find_by_sql( - "Select split_part(ST_AsLatLonText(ST_Centroid(#{GeographicItem::GEOMETRY_SQL.to_sql}), " \ - "'D.DDDDDD'), ' ', 1) latitude, split_part(ST_AsLatLonText(ST_Centroid" \ - "(#{GeographicItem::GEOMETRY_SQL.to_sql}), 'D.DDDDDD'), ' ', 2) " \ - "longitude from geographic_items where id = #{id};")[0] - - [r.latitude, r.longitude] - end - - # @return [RGeo::Geographic::ProjectedPointImpl] - # representing the centroid of this geographic item - def centroid - # Gis::FACTORY.point(*center_coords.reverse) - return geo_object if geo_object_type == :point - - Gis::FACTORY.parse_wkt(st_centroid) - end - # @param [GeographicItem] geographic_item # @return [Double] distance in meters # Works with changed and non persisted objects @@ -815,8 +796,7 @@ def st_distance_to_geographic_item(geographic_item) end # @return [String] - # a WKT POINT representing the centroid of the geographic item - # *as a geometry object* + # a WKT POINT representing the geometry centroid of the geographic item def st_centroid GeographicItem .where(id:) @@ -827,6 +807,29 @@ def st_centroid )) end + # @return [RGeo::Geographic::ProjectedPointImpl] + # representing the geometric centroid of this geographic item + def centroid + # Gis::FACTORY.point(*center_coords.reverse) + return geo_object if geo_object_type == :point + + Gis::FACTORY.parse_wkt(st_centroid) + end + + # @return [Array] + # the lat, long, as STRINGs for the geometric centroid of this geographic + # item + # Meh- this: https://postgis.net/docs/en/ST_MinimumBoundingRadius.html + def center_coords + r = GeographicItem.find_by_sql( + "Select split_part(ST_AsLatLonText(ST_Centroid(#{GeographicItem::GEOMETRY_SQL.to_sql}), " \ + "'D.DDDDDD'), ' ', 1) latitude, split_part(ST_AsLatLonText(ST_Centroid" \ + "(#{GeographicItem::GEOMETRY_SQL.to_sql}), 'D.DDDDDD'), ' ', 2) " \ + "longitude from geographic_items where id = #{id};")[0] + + [r.latitude, r.longitude] + end + # !!TODO: migrate these to use native column calls # @param [geo_object] @@ -1082,13 +1085,8 @@ def select_self(shape_sql) def align_winding if orientations.flatten.include?(false) - if (column = multi_polygon_column) - column = column.to_s - ApplicationRecord.connection.execute( - "UPDATE geographic_items SET #{column} = ST_ForcePolygonCCW(#{column}::geometry) - WHERE id = #{self.id};" - ) - elsif (column = polygon_column) + column = polygon_column || multi_polygon_column + if (column) column = column.to_s ApplicationRecord.connection.execute( "UPDATE geographic_items SET #{column} = ST_ForcePolygonCCW(#{column}::geometry) diff --git a/app/models/georeference.rb b/app/models/georeference.rb index d59f026504..e641837fdc 100644 --- a/app/models/georeference.rb +++ b/app/models/georeference.rb @@ -307,8 +307,8 @@ def error_box def error_radius_buffer_polygon return nil if error_radius.nil? || geographic_item.nil? - # This should be moved to GeographicItem, but can we assume geographic_item has - # been saved yet? + # TODO This should be moved to GeographicItem, but can we assume geographic_item + # has been saved yet? sql_str = ActivRecord::Base.send( :sanitize_sql_array, ['SELECT ST_Buffer(?, ?)', diff --git a/app/models/georeference/verbatim_data.rb b/app/models/georeference/verbatim_data.rb index 868c34905f..c6319221be 100644 --- a/app/models/georeference/verbatim_data.rb +++ b/app/models/georeference/verbatim_data.rb @@ -71,13 +71,13 @@ def dwc_georeference_attributes georeferenceRemarks: "Derived from a instance of TaxonWorks' Georeference::VerbatimData.", geodeticDatum: nil # TODO: check ) - h[:georeferenceProtocol] = 'A geospatial point translated from verbatim values recorded on human-readable media (e.g. paper specimen label, field notebook).' if h[:georeferenceProtocol].blank? + h[:georeferenceProtocol] = 'A geospatial point translated from verbatim values recorded on human-readable media (e.g. paper specimen label, field notebook).' if h[:georeferenceProtocol].blank? h end # @return [Boolean] - # true if geographic_item.geo_object is completely contained in collecting_event.geographic_area - # .default_geographic_item + # true if geographic_item.geo_object is within `distance` of + # collecting_event.geographic_area def check_obj_within_distance_from_area(distance) # case 6 retval = true diff --git a/spec/models/geographic_item/geography_spec.rb b/spec/models/geographic_item/geography_spec.rb index 2de73f04e1..b73d838a2f 100644 --- a/spec/models/geographic_item/geography_spec.rb +++ b/spec/models/geographic_item/geography_spec.rb @@ -6,8 +6,13 @@ # the pattern `before { [s1, s2, ...].each }` is to instantiate variables # that have been `let` (not `let!`) by referencing them using [...].each. + # Shapes that were FactoryBot.created in `let`s will be saved to the + # database at that time, so you can specify your shapes universe for a given + # context by listing the shapes you want to exist in that universe. # TODO add some geometry_collection specs + # TODO add and comment out any ce, co, gr, ad specs that currently only need + # to be tested against non-geography columns let(:geographic_item) { GeographicItem.new } @@ -42,6 +47,56 @@ end end + context 'initialization hooks' do + context 'winding' do + let(:cw_polygon) do + # exterior cw, interior ccw (both backwards) + p = 'POLYGON ((0 0, 0 10, 10 10, 10 0, 0 0), ' \ + '(3 3, 6 3, 6 6, 3 6, 3 3))' + + FactoryBot.create(:geographic_item_geography, geography: p) + end + + let(:ccw_cw_m_p) do + # First ccw, second cw + m_p = 'MULTIPOLYGON (((0 0, 10 0, 10 10, 0 10, 0 0)),' \ + '((20 0, 20 10, 30 10, 30 0, 20 0)))' + + FactoryBot.create(:geographic_item_geography, geography: m_p) + end + + specify 'polygon winding is ccw after save' do + expect(cw_polygon.geo_object.exterior_ring.ccw?).to eq(false) + expect( + cw_polygon.geo_object.interior_rings.map(&:ccw?).uniq + ).to eq([true]) + + cw_polygon.save! + cw_polygon.reload + expect(cw_polygon.geo_object.exterior_ring.ccw?).to eq(true) + expect( + cw_polygon.geo_object.interior_rings.map(&:ccw?).uniq + ).to eq([false]) + end + + specify 'multi_polygon winding is ccw after save' do + expect(ccw_cw_m_p.geo_object[0].exterior_ring.ccw?).to eq(true) + expect(ccw_cw_m_p.geo_object[1].exterior_ring.ccw?). to eq(false) + + ccw_cw_m_p.save! + ccw_cw_m_p.reload + expect(ccw_cw_m_p.geo_object[0].exterior_ring.ccw?).to eq(true) + expect(ccw_cw_m_p.geo_object[1].exterior_ring.ccw?). to eq(true) + end + end + + context 'area' do + specify 'area of a saved polygon is > 0' do + expect(box.area).to be > 0 + end + end + end + # Note these all use geography as the shape column via # "data_type":"geography" in the properties hash context 'construction via #shape=' do @@ -172,7 +227,7 @@ end specify 'a point, when provided, has a legal geography' do - geographic_item.geography = simple_rgeo_point + geographic_item.geography = simple_point.geo_object expect(geographic_item.valid?).to be_truthy end @@ -183,90 +238,129 @@ end end - context '#geo_object' do - before { - geographic_item.geography = simple_rgeo_point - } + context 'instance methods' do - specify '#geo_object returns stored data' do - geographic_item.save! - expect(geographic_item.geo_object).to eq(simple_rgeo_point) - end + context 'rgeo through geo_object' do + context '#geo_object' do + before { + geographic_item.geography = simple_point.geo_object + } - specify '#geo_object returns stored db data' do - geographic_item.save! - geo_id = geographic_item.id - expect(GeographicItem.find(geo_id).geo_object).to eq geographic_item.geo_object - end - end + specify '#geo_object' do + expect(geographic_item).to respond_to(:geo_object) + end - context 'instance methods' do - specify '#geo_object' do - expect(geographic_item).to respond_to(:geo_object) - end + specify '#geo_object returns stored data' do + geographic_item.save! + expect(geographic_item.geo_object).to eq(simple_point.geo_object) + end - specify '#contains? - to see if one object is contained by another.' do - expect(geographic_item).to respond_to(:contains?) - end + specify '#geo_object returns stored db data' do + geographic_item.save! + geo_id = geographic_item.id + expect(GeographicItem.find(geo_id).geo_object) + .to eq geographic_item.geo_object + end + end - specify '#within? - to see if one object is within another.' do - expect(geographic_item).to respond_to(:within?) - end + specify '#contains? - to see if one object is contained by another.' do + expect(geographic_item).to respond_to(:contains?) + end - specify '#contains? if one object is inside the area defined by the other' do - expect(donut.contains?(donut_interior_point.geo_object)).to be_truthy - end + specify '#within? - to see if one object is within another.' do + expect(geographic_item).to respond_to(:within?) + end - specify '#contains? if one object is outside the area defined by the other' do - expect(donut.contains?(distant_point.geo_object)).to be_falsey - end + specify '#contains? if one object is inside the area defined by the other' do + expect(donut.contains?(donut_interior_point.geo_object)).to be_truthy + end - specify '#st_centroid returns a lat/lng of the centroid of the GeoObject' do - simple_polygon.save! - expect(simple_polygon.st_centroid).to eq('POINT(5 5)') + specify '#contains? if one object is outside the area defined by the other' do + expect(donut.contains?(distant_point.geo_object)).to be_falsey + end end - end - context 'class methods' do + context 'centroids' do + context '#st_centroid' do + specify '#st_centroid returns wkt of the centroid' do + expect(simple_polygon.st_centroid).to eq('POINT(5 5)') + end + end + + context '#centroid' do + specify '#centroid returns an rgeo centroid' do + expect(box.centroid).to eq(box_centroid.geo_object) + end + end - specify '::within_radius_of_item' do - expect(GeographicItem).to respond_to(:within_radius_of_item) + context '#center_coords' do + specify 'works for a polygon' do + lat, long = box.center_coords + expect(lat.to_s).to eq(lat) + expect(long.to_s).to eq(long) + expect(lat.to_f).to be_within(0.01).of(box_centroid.geo_object.y) + expect(long.to_f).to be_within(0.01).of(box_centroid.geo_object.x) + end end + end - specify '::intersecting method' do - expect(GeographicItem).to respond_to(:intersecting) + context '#st_distance_to_geographic_item' do + specify 'works for distance from polygon to point on equator' do + # p is on the equator, we compute distance from p to donut + p = box.geo_object.exterior_ring.points.first + p_x = p.x + p = FactoryBot.create(:geographic_item_geography, geography: p) + long_difference = p_x - donut.geo_object.exterior_ring.points.second.x + expected_distance = Utilities::Geo::ONE_WEST * long_difference + expect(donut.st_distance_to_geographic_item(p)) + .to be_within(0.01).of(expected_distance) + end end + end - context '::superset_of_union_of - return objects containing the union of the - given objects' do - before(:each) { - [donut, donut_hole_point, donut_interior_point, - donut_left_interior_edge_point].each + context 'class methods' do + + context '::superset_of_union_of - return objects containing the union of the given objects' do + before { + [donut, donut_centroid, donut_interior_point, + donut_left_interior_edge_point, donut_left_interior_edge, + donut_bottom_and_left_interior_edges].each } + specify "doesn't return self" do + expect(GeographicItem.superset_of_union_of( + donut.id + ).to_a).to eq([]) + end + specify 'find the polygon containing the point' do expect(GeographicItem.superset_of_union_of( donut_interior_point.id ).to_a).to contain_exactly(donut) end - specify 'find the polygon containing two points' do + specify 'tests against the *union* of its inputs' do expect(GeographicItem.superset_of_union_of( - donut_interior_point.id, donut_left_interior_edge_point.id + donut_interior_point.id, distant_point .id + ).to_a).to eq([]) + end + + specify 'polygon containing two of its points is returned once' do + expect(GeographicItem.superset_of_union_of( + [donut_interior_point.id, donut_left_interior_edge_point.id] ).to_a).to contain_exactly(donut) end - specify 'a polygon covers its edge' do + specify 'polygon containing two of its points is returned once' do expect(GeographicItem.superset_of_union_of( - donut_bottom_and_left_interior_edges.id + [donut_interior_point.id, donut_left_interior_edge_point.id] ).to_a).to contain_exactly(donut) end - specify "donut doesn't contain point in donut hole" do - expect( - GeographicItem.superset_of_union_of( - donut_hole_point.id - ).to_a).to eq([]) + specify 'a polygon covers its edge' do + expect(GeographicItem.superset_of_union_of( + donut_bottom_and_left_interior_edges.id + ).to_a).to contain_exactly(donut) end specify 'find that shapes contain their vertices' do @@ -275,13 +369,15 @@ expect(GeographicItem.superset_of_union_of( vertex.id - ).to_a).to contain_exactly(donut_left_interior_edge, donut) + ).to_a).to contain_exactly(donut, + donut_left_interior_edge, donut_bottom_and_left_interior_edges + ) end end context '::within_union_of' do before { [donut_bottom_and_left_interior_edges, - donut_interior_point, donut_hole_point, + donut_interior_point, donut_centroid, donut_left_interior_edge].each } @@ -441,7 +537,7 @@ specify 'works with any_poly' do expect(GeographicItem.st_covered_by('any_poly', - donut_and_rectangle_geometry_collection).to_a) + donut_rectangle_multi_polygon).to_a) .to contain_exactly( donut, rectangle_intersecting_box, donut_rectangle_multi_polygon ) @@ -449,10 +545,22 @@ specify 'DOES NOT WORK with arbitrary geometry collection' do pending 'ST_Covers fails when input GeometryCollection has a line intersecting a polygon\'s interior' - # The same test as the previous only the collection in the first - # argument also contains a line intersecting the interior of rectangle + # The same test as the previous only the geometry collection in the + # first argument also contains a line intersecting the interior of + # rectangle - if you remove the line from the collection the test + # passes: + # pass_g_c = FactoryBot.create(:geographic_item_geography, geography: + # RSPEC_GEO_FACTORY.collection( + # [ + # donut.geo_object, + # rectangle_intersecting_box.geo_object, + # ] + # ) + # If the line only intersects the boundary of rectangle, or the line + # is interior to rectangle, or if it's instead an interior point of + # rectangle, the test also passes. expect(GeographicItem.st_covered_by('any_poly', - fail_multi_dimen_geometry_collection).to_a) + donut_box_bisector_rectangle_geometry_collection).to_a) .to contain_exactly( donut, rectangle_intersecting_box, donut_rectangle_multi_polygon ) @@ -526,5 +634,94 @@ ) end end + + context '::lat_long_sql' do + specify 'returns latitude of a point' do + expect(GeographicItem + .where(id: distant_point.id) + .select(GeographicItem.lat_long_sql(:latitude)) + .first['latitude'].to_f + ).to be_within(0.01).of(distant_point.geo_object.y) + end + + specify 'returns longitude of a point' do + expect(GeographicItem + .where(id: distant_point.id) + .select(GeographicItem.lat_long_sql(:longitude)) + .first['longitude'].to_f + ).to be_within(0.01).of(distant_point.geo_object.x) + end + end + + context '::within_radius_of_wkt_sql' do + specify 'works for a wkt point' do + wkt = donut_centroid.to_wkt + # donut centroid is (10, 10), r is a little more than the radius of + # donut + r = 10 * 1.5 * Utilities::Geo::ONE_WEST + expect(GeographicItem.where( + GeographicItem.within_radius_of_wkt_sql(wkt, r)) + ).to contain_exactly(donut_centroid, + donut, donut_bottom_and_left_interior_edges, + donut_left_interior_edge, donut_left_interior_edge_point + ) + end + end + + context '::covered_by_wkt_sql' do + specify 'works for a wkt multipolygon' do + wkt = donut_rectangle_multi_polygon.to_wkt + expect(GeographicItem.where( + GeographicItem.covered_by_wkt_sql(wkt)) + # Should contain all donut shapes and all rectangle shapes + ).to contain_exactly(donut_rectangle_multi_polygon, + donut, donut_bottom_and_left_interior_edges, + donut_left_interior_edge, donut_left_interior_edge_point, + rectangle_intersecting_box + ) + end + end + + context '::st_buffer_st_within_sql' do + before { [donut, box, rectangle_intersecting_box].each } + + specify 'buffer = 0, d = 0 is intersection' do + expect( + GeographicItem.where( + GeographicItem.st_buffer_st_within_sql(box.id, 0, 0) + ) + ).to contain_exactly(box, rectangle_intersecting_box) + end + + specify 'expanding target shapes works' do + # box is 20 units from donut + buffer = 25 * Utilities::Geo::ONE_WEST + expect( + GeographicItem.where( + GeographicItem.st_buffer_st_within_sql(donut.id, 0, buffer) + ).to_a + ).to contain_exactly(donut, box) + end + + specify 'shrinking target shapes works' do + # control case + buffer = 0 + expect( + GeographicItem.where( + GeographicItem.st_buffer_st_within_sql( + donut_left_interior_edge_point.id, 0, buffer) + ).to_a + ).to eq([donut]) + + buffer = -(1 * Utilities::Geo::ONE_WEST) + expect( + GeographicItem.where( + GeographicItem.st_buffer_st_within_sql( + donut_left_interior_edge_point.id, 0, buffer) + ).to_a + ).to eq([]) + end + end end + end diff --git a/spec/support/shared_contexts/shared_geo_for_geography.rb b/spec/support/shared_contexts/shared_geo_for_geography.rb index 40798714c8..61f96c229b 100644 --- a/spec/support/shared_contexts/shared_geo_for_geography.rb +++ b/spec/support/shared_contexts/shared_geo_for_geography.rb @@ -19,23 +19,33 @@ }.freeze } let(:simple_point) { - FactoryBot.create(:geographic_item_geography, geography: simple_shapes[:point]) + FactoryBot.create( + :geographic_item_geography, geography: simple_shapes[:point] + ) } let(:simple_line_string) { - FactoryBot.create(:geographic_item_geography, geography: simple_shapes[:line_string]) + FactoryBot.create( + :geographic_item_geography, geography: simple_shapes[:line_string] + ) } let(:simple_polygon) { - FactoryBot.create(:geographic_item_geography, geography: simple_shapes[:polygon]) + FactoryBot.create( + :geographic_item_geography, geography: simple_shapes[:polygon] + ) } let(:simple_multi_point) { - FactoryBot.create(:geographic_item_geography, geography: simple_shapes[:multi_point]) + FactoryBot.create( + :geographic_item_geography, geography: simple_shapes[:multi_point] + ) } let(:simple_multi_line_string) { - FactoryBot.create(:geographic_item_geography, geography: simple_shapes[:multi_line_string]) + FactoryBot.create( + :geographic_item_geography, geography: simple_shapes[:multi_line_string] + ) } let(:simple_multi_polygon) { @@ -50,13 +60,42 @@ ) } - let(:simple_rgeo_point) { RSPEC_GEO_FACTORY.point(10, -10, 0) } - - ###### Specific shapes testing relations between shapes + ###### Specific shapes for testing relations between shapes + # + # donut box distant_point + # 20 @@@@@@@@@ @@@@@@@@@ # + # @ @ @ @ + # 15 @ &@@@@ @ @ %%%%%%%%% + # @ & @ @ @ % @ % + # 10 @ # # @ @ &&&&#&&&& % + # @ & @ @ @ % @ % rectangle_intersecting_box + # 5 @ &@@@@ @ @ % # @ % + # @# @ @ % @ % + # 0 @@@@@@@@@ @@@@%%%%%%%%% + # + # 0 20 40 50 60 70 + # # = point, & = line + # + # Donut shapes: donut, donut_left_interior_edge, + # donut_bottom_and_left_interior_edges (a multi_line), + # donut_centroid, donut_left_interior_edge_point, donut_interior_point + # + # Box shapes: box, box_horizontal_bisect_line, box_centroid + # + # Rectangle shapes: rectangle_intersecting_box, + # box_rectangle_intersection_point + # + # box_rectangle_union is what it says as a single polygon + # + # MultiPoint: donut_box_multi_point (donut_interior_point, box_centroid) + # MultiLine: donut_bottom_and_left_interior_edges + # MultiPolygon: donut_rectangle_multi_polygon + # GeometryCollection: donut_box_bisector_rectangle_geometry_collection + # (donut, box_horizontal_bisect_line, rectangle_intersecting_box) ### Point intended to be outside of any of the shapes defined below let(:distant_point) { - FactoryBot.create(:geographic_item_geography, geography: 'POINT(1000 1000 0)') + FactoryBot.create(:geographic_item_geography, geography: 'POINT(85 175 0)') } ### A donut polygon and sub-shapes @@ -69,7 +108,8 @@ FactoryBot.create(:geographic_item_geography, geography: d) end - let(:donut_hole_point) { + # geometric centroid + let(:donut_centroid) { FactoryBot.create(:geographic_item_geography, geography: donut.centroid) } @@ -126,6 +166,7 @@ FactoryBot.create(:geographic_item_geography, geography: b) end + # geometric centroid let(:box_centroid) { FactoryBot.create(:geographic_item_geography, geography: box.centroid) } @@ -140,8 +181,8 @@ } ### A rectangle polygon intersecting the previous box; both start at y=0, - # the rectangle is taller than box_centroid but shorter than box; - # box_centroid is in the left side of rectangle + # the rectangle is taller than box_centroid but shorter than box (is that + # important?); box_centroid is in the left side of rectangle let(:rectangle_intersecting_box) do b = 'POLYGON((50 0 0, 70 0 0, 70 15 0, 50 15 0, 50 0 0))' @@ -163,7 +204,7 @@ ### A multi_point let(:donut_box_multi_point) do donut_point = donut_interior_point.geo_object - box_point = box_centroid + box_point = box_centroid.geo_object m_p = RSPEC_GEO_FACTORY.multi_point([donut_point, box_point]) FactoryBot.create(:geographic_item_geography, geography: m_p) @@ -182,22 +223,7 @@ end ### A geometry_collection - let(:donut_and_rectangle_geometry_collection) do - g_c = RSPEC_GEO_FACTORY.collection( - [ - donut.geo_object, - rectangle_intersecting_box.geo_object - ] - ) - - FactoryBot.create(:geographic_item_geography, geography: g_c) - end - - # Same as :donut_and_rectangle_geometry_collection but including a line - # intersecting the interior of rectangle (adding a point in the interior of - # rectangle seems fine) - st_cover fails with this collection as its first - # argument. - let(:fail_multi_dimen_geometry_collection) do + let(:donut_box_bisector_rectangle_geometry_collection) do g_c = RSPEC_GEO_FACTORY.collection( [ donut.geo_object, From 68c88266e64700105cf14495855ebce38450c0d8 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Sat, 6 Jul 2024 23:54:42 -0500 Subject: [PATCH 070/259] #1954 Remove/replace GeographicItem#rgeo_to_geo_json I may just be confused here, but to me it looks like rgeo_to_geo_json was returning a json string, not a hash as the @return said. I think we want a hash for the geometry key of the geojson feature (as a hash) in to_geo_json_feature? In that case why not just use to_geo_json for a geometry collection for the value of the geometry key? (Maybe it wasn't supported when this code was originally written?) --- app/models/geographic_item.rb | 13 +------------ app/models/geographic_item/geometry_collection.rb | 7 ------- 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 2f309fae01..2ee779d667 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -54,10 +54,6 @@ class GeographicItem < ApplicationRecord include Shared::IsData include Shared::SharedAcrossProjects - # @return [Hash, nil] - # An internal variable for use in super calls, holds a Hash in GeoJSON format (temporarily) - attr_accessor :geometry - # @return [Boolean, RGeo object] # @params value [Hash in GeoJSON format] ?! # TODO: WHY! boolean not nil, or object @@ -851,12 +847,6 @@ def intersects?(target_geo_object) self.geo_object.intersects?(target_geo_object) end - # @return [GeoJSON hash] - # via Rgeo apparently necessary for GeometryCollection - def rgeo_to_geo_json - RGeo::GeoJSON.encode(geo_object).to_json - end - # @return [Hash] in GeoJSON format def to_geo_json JSON.parse( @@ -869,9 +859,8 @@ def to_geo_json # @return [Hash] # the shape as a GeoJSON Feature with some item metadata def to_geo_json_feature - @geometry ||= to_geo_json {'type' => 'Feature', - 'geometry' => geometry, + 'geometry' => to_geo_json, 'properties' => { 'geographic_item' => { 'id' => id} diff --git a/app/models/geographic_item/geometry_collection.rb b/app/models/geographic_item/geometry_collection.rb index 77169c0961..0ce30fdf94 100644 --- a/app/models/geographic_item/geometry_collection.rb +++ b/app/models/geographic_item/geometry_collection.rb @@ -3,11 +3,4 @@ class GeographicItem::GeometryCollection < GeographicItem validates_presence_of :geometry_collection - # @return [GeoJSON Feature] - # the shape as a Feature/Feature Collection - def to_geo_json_feature - self.geometry = rgeo_to_geo_json - super - end - end From 841cc5f315ff2cf4be473dd023a1e163b6a96f8b Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Mon, 8 Jul 2024 10:14:19 -0500 Subject: [PATCH 071/259] #1954 Union multiple gazetteer shapes instead of collecting them I think unary_union would be preferred here, but for some reason it doesn't appear to be supported for geographic geometries, even though things like intersection and union are :( https://github.com/rgeo/rgeo/blob/main/lib/rgeo/geographic/projected_feature_methods.rb (we're at geos 3.10 and only 3.3 is needed for unary_union, so that doesn't seem to be the issue). --- app/models/gazetteer.rb | 45 ++++++++--------------------------------- 1 file changed, 8 insertions(+), 37 deletions(-) diff --git a/app/models/gazetteer.rb b/app/models/gazetteer.rb index e5f429a8bb..ffef387a4c 100644 --- a/app/models/gazetteer.rb +++ b/app/models/gazetteer.rb @@ -75,9 +75,6 @@ def self.combine_shapes_to_rgeo(shapes) geojson_rgeo = convert_geojson_to_rgeo(shapes['geojson']) wkt_rgeo = convert_wkt_to_rgeo(shapes['wkt']) - if geojson_rgeo.nil? || wkt_rgeo.nil? - return nil - end shapes = geojson_rgeo + wkt_rgeo @@ -96,8 +93,7 @@ def self.convert_geojson_to_rgeo(shapes) ) } - # TODO can i do the &geometry thing here? - rgeo_shapes.map { |shape| shape.geometry } + rgeo_shapes.map(&:geometry) end # @return [Array] of RGeo::Geographic::Projected*Impl @@ -123,40 +119,15 @@ def self.combine_rgeo_shapes(rgeo_shapes) return rgeo_shapes[0] end - multi = nil - type = nil - - types = rgeo_shapes.map { |shape| - shape.geometry_type.type_name - }.uniq - - if types.count == 1 - type = types[0] - case type - when 'Point' - multi = Gis::FACTORY.multi_point(rgeo_shapes) - when 'LineString' - multi = Gis::FACTORY.multi_line_string(rgeo_shapes) - when 'Polygon' - multi = Gis::FACTORY.multi_polygon(rgeo_shapes) - when 'GeometryCollection' - multi = Gis::FACTORY.collection(rgeo_shapes) - end - else # multiple geometries of different types - type = 'Multi-types' - # This could itself include GeometryCollection(s) - multi = Gis::FACTORY.collection(rgeo_shapes) - end + # unary_union, which would be preferable here, is apparently unavailable + # for geographic geometries + u = rgeo_shapes[0] + rgeo_shapes[1..].each { |s| u = u.union(s) } - if multi.nil? - message = type == 'Multi-types' ? - 'Error in combining mutiple types into a single GeometryCollection' : - "Error in combining multiple #{type}s into a multi-#{type}" - raise Taxonworks::Error, message + if u.nil? + raise TaxonWorks::Error, 'Computing the union of the shapes failed' end - multi + u end - - end From ae9d1abc89a282d77d39a6cc01767c6d995c4ae8 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Mon, 8 Jul 2024 20:58:52 -0500 Subject: [PATCH 072/259] #1954 Make GeographicItem has_one gazetteer instead of has_many It's (currently) not possible to create two gazetteers that have the same GeographicItem. If clone ga to gz becomes an option a new gi will be created, same for cloning gz (which I'm not sure would make sense since gz's already have alternate_values_for :name). Am I missing anything? --- app/controllers/gazetteers_controller.rb | 9 +++++++-- app/helpers/gazetteers_helper.rb | 6 ------ app/models/gazetteer.rb | 2 +- app/models/geographic_item.rb | 3 ++- app/views/geographic_items/show.html.erb | 4 ++-- app/views/tasks/geographic_items/debug/index.html.erb | 4 ++-- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/app/controllers/gazetteers_controller.rb b/app/controllers/gazetteers_controller.rb index f5d4c6146f..e7c1ff0d89 100644 --- a/app/controllers/gazetteers_controller.rb +++ b/app/controllers/gazetteers_controller.rb @@ -90,12 +90,17 @@ def update end end - # DELETE /gazetteers/1 or /gazetteers/1.json + # DELETE /gazetteers/1 + # DELETE /gazetteers/1.json def destroy + # TODO Handle children/parents (if used) @gazetteer.destroy! respond_to do |format| - format.html { redirect_to gazetteers_url, notice: "Gazetteer was successfully destroyed." } + format.html { + redirect_to gazetteers_url, + notice: 'Gazetteer was successfully destroyed.' + } format.json { head :no_content } end end diff --git a/app/helpers/gazetteers_helper.rb b/app/helpers/gazetteers_helper.rb index 2c7e1fe7ed..5ff97dd7c9 100644 --- a/app/helpers/gazetteers_helper.rb +++ b/app/helpers/gazetteers_helper.rb @@ -15,12 +15,6 @@ def gazetteer_link(gazetteer, link_text = nil) link_to(link_text, gazetteer) end - def geographic_item_link(geographic_item, link_text = nil) - return nil if geographic_item.nil? - link_text ||= geographic_item.to_param - link_to(link_text, geographic_item_path(geographic_item), data: {turbolinks: false}) - end - def gazetteer_link_list(gazetteers) content_tag(:ul) do gazetteers.collect { |a| content_tag(:li, gazetteer_link(a)) }.join.html_safe diff --git a/app/models/gazetteer.rb b/app/models/gazetteer.rb index ffef387a4c..421114bdee 100644 --- a/app/models/gazetteer.rb +++ b/app/models/gazetteer.rb @@ -38,7 +38,7 @@ class Gazetteer < ApplicationRecord has_closure_tree - belongs_to :geographic_item, inverse_of: :gazetteers + belongs_to :geographic_item, inverse_of: :gazetteer, dependent: :destroy validates :name, presence: true, length: {minimum: 1} diff --git a/app/models/geographic_item.rb b/app/models/geographic_item.rb index 2ee779d667..e7fd7a602f 100644 --- a/app/models/geographic_item.rb +++ b/app/models/geographic_item.rb @@ -109,7 +109,6 @@ class GeographicItem < ApplicationRecord has_many :geographic_area_types, through: :geographic_areas has_many :parent_geographic_areas, through: :geographic_areas, source: :parent - has_many :gazetteers, inverse_of: :geographic_item has_many :georeferences, inverse_of: :geographic_item has_many :georeferences_through_error_geographic_item, class_name: 'Georeference', foreign_key: :error_geographic_item_id, inverse_of: :error_geographic_item @@ -117,6 +116,8 @@ class GeographicItem < ApplicationRecord has_many :collecting_events_through_georeference_error_geographic_item, through: :georeferences_through_error_geographic_item, source: :collecting_event + has_one :gazetteer, inverse_of: :geographic_item + # TODO: THIS IS NOT GOOD before_validation :set_type_if_shape_column_present diff --git a/app/views/geographic_items/show.html.erb b/app/views/geographic_items/show.html.erb index 7123800256..af526f1827 100644 --- a/app/views/geographic_items/show.html.erb +++ b/app/views/geographic_items/show.html.erb @@ -3,8 +3,8 @@

Names from geographic areas: <%= geographic_area_link_list(@geographic_item.geographic_areas) -%>

Parents through geographic areas: <%= geographic_area_link_list(@geographic_item.parent_geographic_areas) -%>

-

Gazetteers

-

Names from gazetteers: <%= gazetteer_link_list(@geographic_item.gazetteers) -%>

+

Gazetteer

+

Name from gazetteer: <%= gazetteer_link(@geographic_item.gazetteer) -%>

<%# TODO gazetteer parents%>

Geographic Items

diff --git a/app/views/tasks/geographic_items/debug/index.html.erb b/app/views/tasks/geographic_items/debug/index.html.erb index 0631866121..dda9ee58b1 100644 --- a/app/views/tasks/geographic_items/debug/index.html.erb +++ b/app/views/tasks/geographic_items/debug/index.html.erb @@ -28,7 +28,7 @@ <%= table_from_hash_tag({ 'Georeferences' => collecting_events_count_using_this_geographic_item(@geographic_item.id), 'AssertedDistributions' => @geographic_item.asserted_distributions.where(project_id: sessions_current_project_id).count, - 'Gazetteers' => @geographic_item.gazetteers.where(project_id: sessions_current_project_id).count + 'Gazetteer' => @geographic_item.gazetteer&.project_id == sessions_current_project_id ? 1 : 0 }) %> @@ -84,7 +84,7 @@

Parents through geographic areas: <%= geographic_area_link_list(@geographic_item.parent_geographic_areas) -%>

Gazetteer

-

Names from gazetteers: <%= gazetteer_link_list(@geographic_item.gazetteers) -%>

+

Name from gazetteer: <%= gazetteer_link(@geographic_item.gazetteer) -%>

<%# TODO Gazetteer parents %>

Parents

From 0d1ab785d961e3895526ddf4007f1c5e7033f37f Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Mon, 8 Jul 2024 22:26:13 -0500 Subject: [PATCH 073/259] #1954 Make all new links for gazetteer link to the task This puts two New slices in the nav radial, since that radial requires a task in object_radials.yml; is there a reason for that? --- app/controllers/gazetteers_controller.rb | 4 +++- config/interface/object_radials.yml | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/controllers/gazetteers_controller.rb b/app/controllers/gazetteers_controller.rb index e7c1ff0d89..c15812330d 100644 --- a/app/controllers/gazetteers_controller.rb +++ b/app/controllers/gazetteers_controller.rb @@ -27,7 +27,9 @@ def show # GET /gazetteers/new def new - @gazetteer = Gazetteer.new + respond_to do |format| + format.html { redirect_to new_gazetteer_task_path } + end end # GET /gazetteers/1/edit diff --git a/config/interface/object_radials.yml b/config/interface/object_radials.yml index b5268f6a47..3f265862f4 100644 --- a/config/interface/object_radials.yml +++ b/config/interface/object_radials.yml @@ -98,6 +98,10 @@ FieldOccurrence: new: new_field_occurrence_task, config: recent: true +Gazetteer: + tasks: + - new_gazetteer_task + new: new_gazetteer_task GeographicArea: tasks: - filter_asserted_distributions_task From 6245ee3fedfcf51551d69901f1716652b40b5eff Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Tue, 9 Jul 2024 07:51:48 -0500 Subject: [PATCH 074/259] #1954 Enable editing of Gazetteers (non-shape data only) --- app/controllers/gazetteers_controller.rb | 3 ++ .../vue/routes/endpoints/Gazetteer.js | 1 - .../tasks/gazetteers/new_gazetteer/App.vue | 35 ++++++++++++++----- config/interface/object_radials.yml | 3 +- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/app/controllers/gazetteers_controller.rb b/app/controllers/gazetteers_controller.rb index c15812330d..24b29ef1ea 100644 --- a/app/controllers/gazetteers_controller.rb +++ b/app/controllers/gazetteers_controller.rb @@ -34,6 +34,9 @@ def new # GET /gazetteers/1/edit def edit + respond_to do |format| + format.html { redirect_to new_gazetteer_task_path gazetteer_id: @gazetteer.id } + end end # GET /gazetteers/list diff --git a/app/javascript/vue/routes/endpoints/Gazetteer.js b/app/javascript/vue/routes/endpoints/Gazetteer.js index 91f933e0b9..3b65537bc6 100644 --- a/app/javascript/vue/routes/endpoints/Gazetteer.js +++ b/app/javascript/vue/routes/endpoints/Gazetteer.js @@ -9,7 +9,6 @@ const permitParams = { geojson: [], wkt: [] } - //geographic_item_attributes: { shape: Object } } } diff --git a/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue b/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue index 5352ec739d..5c24333aa9 100644 --- a/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue +++ b/app/javascript/vue/tasks/gazetteers/new_gazetteer/App.vue @@ -13,7 +13,7 @@ @@ -85,6 +85,7 @@ import { Gazetteer } from '@/routes/endpoints' import { computed, ref } from 'vue' import { randomUUID } from '@/helpers' import { addToArray, removeFromArray } from '@/helpers/arrays' +import { URLParamsToJSON } from '@/helpers/url/parse' import { //GZ_POINT, GZ_WKT, @@ -93,17 +94,37 @@ import { const shapes = ref([]) const gz = ref({}) -const name = ref('') const geoItemComponent = ref(null) +const loading = ref(false) const leafletShapes = computed(() => { return shapes.value.map((item) => item.shape) }) const saveDisabled = computed(() => { - return !(name.value) || shapes.value.length == 0 + return !(gz.value.name) || shapes.value.length == 0 }) +const { gazetteer_id } = URLParamsToJSON(location.href) + +if (gazetteer_id) { + loadGz(gazetteer_id) +} + +function loadGz(gzId) { + Gazetteer.find(gzId) + .then(({ body }) => { + gz.value = body + shapes.value = [ + { + shape: body.shape, + type: GZ_LEAFLET + } + ] + }) + .catch(() => {}) +} + function saveGz() { if (gz.value.id) { updateGz() @@ -122,7 +143,7 @@ function saveNewGz() { .map((item) => item.shape) const gazetteer = { - name: name.value, + name: gz.value.name, shapes: { geojson, wkt @@ -132,15 +153,14 @@ function saveNewGz() { Gazetteer.create({ gazetteer }) .then(({ body }) => { gz.value = body - // TODO can we update the map to display the combined shape? Maybe fetch - // a geojson version? + // TODO probably want to fetch the edit version with the combined shape }) .catch(() => {}) } function updateGz() { const gazetteer = { - name: name.value + name: gz.value.name } Gazetteer.update(gz.value.id, { gazetteer }) @@ -155,7 +175,6 @@ function cloneGz() {} function reset() { shapes.value = [] gz.value = {} - name.value = '' } function addToShapes(shape, type) { diff --git a/config/interface/object_radials.yml b/config/interface/object_radials.yml index 3f265862f4..372b4f8d18 100644 --- a/config/interface/object_radials.yml +++ b/config/interface/object_radials.yml @@ -3,7 +3,7 @@ # The values here are returned as JSON via /metadata/radial/:klass # # Valid attributes (* required) -# tasks: - A named task (see user_tasks.yml) !! Limit this to 3 or 4 +# tasks*: - A named task (see user_tasks.yml) !! Limit this to 3 or 4 # recent: - defaults to true if not provided, if false no recent list is shown # edit: over-ride data->edit # new: over-ride data->new @@ -102,6 +102,7 @@ Gazetteer: tasks: - new_gazetteer_task new: new_gazetteer_task + edit: new_gazetteer_task GeographicArea: tasks: - filter_asserted_distributions_task From b56ea752f221d80c1c96d796f3e922f5202fa6f8 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Tue, 9 Jul 2024 19:56:14 -0500 Subject: [PATCH 075/259] #1954 Fix display of GeometryCollections in Gazetteer displaylist Could potentially be seeing a lot more GeometryCollections with Gazetteer, but this is also an issue with the "same" Georeference display list where you can enter GeometryCollections as WKT. --- .../new_gazetteer/components/DisplayList.vue | 52 ++++++++++++++++--- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/DisplayList.vue b/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/DisplayList.vue index eb6dc53c92..485d082bb8 100644 --- a/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/DisplayList.vue +++ b/app/javascript/vue/tasks/gazetteers/new_gazetteer/components/DisplayList.vue @@ -15,7 +15,7 @@ > {{ shapeType(item) }} @@ -91,17 +91,55 @@ function shapeType(item) { function getCoordinates(item) { switch(item.type) { + // TODO display this as copyable WKT case GZ_LEAFLET: - const coordinates = item.shape.geometry.coordinates - const flattened = coordinates.flat(1) - if (typeof flattened[0] === 'number') { - return convertToLatLongOrder(coordinates) - } else { - return flattened.map((arr) => convertToLatLongOrder(arr)) + switch(shapeType(item)) { + case 'GeometryCollection': + return coordinatesForGeometryCollection( + item.shape.geometry.geometries + ) + + default: // not GeometryCollection + const coordinates = item.shape.geometry.coordinates + const flattened = coordinates.flat(1) + if (typeof flattened[0] === 'number') { + return convertToLatLongOrder(coordinates) + } else { + return flattened.map((arr) => convertToLatLongOrder(arr)) + } } break case GZ_WKT: return item.shape } } + +function coordinatesForGeometryCollection(geometries) { + let collectionStrings = [] + geometries.forEach((geometry) => { + let shape_hash + if (geometry.type == 'GeometryCollection') { + // TODO test this (a geom_collection inside a geom_collection) + shape_hash = { + geometries: geometry.geometries + } + } + else { + shape_hash = { + geometry + } + } + + const new_shape = { + type: GZ_LEAFLET, + shape: shape_hash + } + + // TODO how to display this type without double quotes? + collectionStrings.push(geometry.type) + collectionStrings.push(getCoordinates(new_shape)) + }) + + return collectionStrings +} \ No newline at end of file From 07057335248416f343f93f31c52656f107ac6b16 Mon Sep 17 00:00:00 2001 From: Tom Klein Date: Tue, 9 Jul 2024 20:16:55 -0500 Subject: [PATCH 076/259] Allow WKT modal button to be disabled --- .../components/parsed/georeferences/wkt.vue | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/javascript/vue/tasks/collecting_events/new_collecting_event/components/parsed/georeferences/wkt.vue b/app/javascript/vue/tasks/collecting_events/new_collecting_event/components/parsed/georeferences/wkt.vue index 2025768d06..a1628da2ff 100644 --- a/app/javascript/vue/tasks/collecting_events/new_collecting_event/components/parsed/georeferences/wkt.vue +++ b/app/javascript/vue/tasks/collecting_events/new_collecting_event/components/parsed/georeferences/wkt.vue @@ -1,6 +1,7 @@ + + diff --git a/app/javascript/vue/tasks/collecting_events/filter/components/Filter.vue b/app/javascript/vue/tasks/collecting_events/filter/components/Filter.vue index 59d97d7d0d..edc0a53c74 100644 --- a/app/javascript/vue/tasks/collecting_events/filter/components/Filter.vue +++ b/app/javascript/vue/tasks/collecting_events/filter/components/Filter.vue @@ -1,5 +1,6 @@