locatable 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b00bd3e85ad8ba1435a8368b0ba2c3ca71d15d1e2e288e4a9d36068b51ef6e91
4
+ data.tar.gz: 45bb56e4b676f1cc7411e6a42d2788a205ae092271924201320f60649b2497fd
5
+ SHA512:
6
+ metadata.gz: b3318c653b4e4f07e842e9352c5a01264d0bf38ed27f4542d719ddf58d573c4ab6e1a5cae4e5367abe12209095ba3af83381c76c36527e08cc8d79b08c4390e8
7
+ data.tar.gz: dbf11ce695414d9daed6cf5791dff0e7a2f86b2c9f787bdd79f63009781734af049dded028a9563bbca76c4b4fe8338b0324f78675003d4dc5f2f9cb3ec12af9
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-06-20
4
+
5
+ - Initial release
data/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # Locatable
2
+
3
+ Simple, fast, Geocoder-compatible PostGIS-backed location scopes for Active Record models.
4
+
5
+ ## Requirements
6
+
7
+ - Ruby 3.2+
8
+ - Active Record
9
+ - PostgreSQL with PostGIS
10
+
11
+ ## Installation
12
+
13
+ Add the gem to your Gemfile:
14
+
15
+ ```ruby
16
+ gem "locatable"
17
+ ```
18
+
19
+ Then run:
20
+
21
+ ```sh
22
+ bundle install
23
+ ```
24
+
25
+ ## Setup
26
+
27
+ Add latitude and longitude columns, then call `make_locatable` in the migration.
28
+
29
+ ```ruby
30
+ class CreatePlaces < ActiveRecord::Migration[8.1]
31
+ def change
32
+ create_table :places do |t|
33
+ t.float :latitude
34
+ t.float :longitude
35
+ t.timestamps
36
+ end
37
+
38
+ make_locatable :places, latitude: :latitude, longitude: :longitude
39
+ end
40
+ end
41
+ ```
42
+
43
+ `make_locatable` adds generated PostGIS geography and geometry columns, plus GiST indexes.
44
+
45
+ In the model, call `locatable`:
46
+
47
+ ```ruby
48
+ class Place < ApplicationRecord
49
+ locatable
50
+ end
51
+ ```
52
+
53
+ ## Usage
54
+
55
+ Coordinates are passed as `[latitude, longitude]`.
56
+
57
+ ```ruby
58
+ origin = [40.7128, -74.0060]
59
+
60
+ Place.within_bounding_box([[40.70, -74.02], [40.73, -73.99]]) # Geocoder-compatible
61
+ Place.near(origin, 5) # Geocoder-compatible
62
+ Place.within_radius(origin, 5)
63
+ Place.order_by_closest_to(origin)
64
+ Place.select_distance_to(origin)
65
+ ```
66
+
67
+ These 2 calls are equivalent:
68
+
69
+ ```ruby
70
+ Place.near(origin, 2)
71
+ Place.within_radius(origin, 2).order_by_closest_to(origin)
72
+ ```
73
+
74
+ Distances use miles by default. Supported units are `:km`, `:mi`, and `:nm`.
75
+
76
+ Set the units directly on a scope call:
77
+
78
+ ```ruby
79
+ Place.near(origin, 10, units: :km)
80
+ Place.within_radius(origin, 5, units: :nm)
81
+ ```
82
+
83
+ Or set the default once in an initializer:
84
+
85
+ ```ruby
86
+ # config/initializers/locatable.rb
87
+ Locatable.default_units = :km
88
+ ```
89
+
90
+ ## Development
91
+
92
+ Install dependencies:
93
+
94
+ ```sh
95
+ bin/setup
96
+ ```
97
+
98
+ Run tests with a PostGIS database URL:
99
+
100
+ ```sh
101
+ LOCATABLE_DATABASE_URL=postgis://user:password@localhost/locatable_test bundle exec rake test
102
+ ```
103
+
104
+ Run formatting:
105
+
106
+ ```sh
107
+ bundle exec rake standard
108
+ ```
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "standard/rake"
9
+
10
+ task default: %i[test standard]
@@ -0,0 +1,33 @@
1
+ module Locatable::Helpers
2
+ module_function
3
+
4
+ def extract_lat_lng(origin)
5
+ lat, lng = case origin
6
+ when Array
7
+ [origin[0], origin[1]]
8
+ when Hash
9
+ [origin[:lat] || origin["lat"], origin[:lng] || origin["lng"]]
10
+ else
11
+ [origin.try(:lat), origin.try(:lng)]
12
+ end
13
+
14
+ [parse_float(lat), parse_float(lng)]
15
+ end
16
+
17
+ def parse_float(value)
18
+ Float(value)
19
+ rescue ArgumentError, TypeError
20
+ nil
21
+ end
22
+
23
+ def meters_per_units(units)
24
+ Locatable::METERS_PER_UNIT.fetch(Locatable.normalize_units(units))
25
+ end
26
+
27
+ def convert_to_meters(distance, units)
28
+ distance = parse_float(distance)
29
+ return nil if distance.nil?
30
+
31
+ distance * meters_per_units(units)
32
+ end
33
+ end
@@ -0,0 +1,27 @@
1
+ module Locatable::MigrationHelpers
2
+ def make_locatable(table, latitude:, longitude:)
3
+ reversible do |dir|
4
+ dir.up do
5
+ execute <<~SQL
6
+ ALTER TABLE #{table}
7
+ ADD COLUMN location_geography geography(Point, 4326)
8
+ GENERATED ALWAYS AS (
9
+ ST_Point(#{longitude}, #{latitude}, 4326)::geography
10
+ ) STORED,
11
+ ADD COLUMN location_geometry geometry(Point, 4326)
12
+ GENERATED ALWAYS AS (
13
+ ST_Point(#{longitude}, #{latitude}, 4326)
14
+ ) STORED;
15
+ SQL
16
+ end
17
+
18
+ dir.down do
19
+ remove_column table, :location_geometry
20
+ remove_column table, :location_geography
21
+ end
22
+ end
23
+
24
+ add_index table, :location_geography, using: :gist
25
+ add_index table, :location_geometry, using: :gist
26
+ end
27
+ end
@@ -0,0 +1,10 @@
1
+ require "locatable/scopes"
2
+ module Locatable::Model
3
+ extend ActiveSupport::Concern
4
+
5
+ class_methods do
6
+ def locatable
7
+ include Locatable::Scopes
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,19 @@
1
+ require "rails/railtie"
2
+ require "locatable/migration_helpers"
3
+ require "locatable/model"
4
+
5
+ module Locatable
6
+ class Railtie < Rails::Railtie
7
+ initializer "locatable.migration_helpers" do
8
+ ActiveSupport.on_load(:active_record) do
9
+ ActiveRecord::Migration.include Locatable::MigrationHelpers
10
+ end
11
+ end
12
+
13
+ initializer "locatable.active_record" do
14
+ ActiveSupport.on_load(:active_record) do
15
+ include Locatable::Model
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,80 @@
1
+ require "active_support/concern"
2
+ require "active_record"
3
+ require "locatable/helpers"
4
+
5
+ module Locatable::Scopes
6
+ extend ActiveSupport::Concern
7
+
8
+ SRID = 4326
9
+
10
+ included do
11
+ scope :within_bounding_box, ->(sw_ne_corners) do
12
+ next none if sw_ne_corners.nil?
13
+
14
+ sw_corner = sw_ne_corners.flatten[0..1]
15
+ ne_corner = sw_ne_corners.flatten[2..3]
16
+
17
+ sw_lat, sw_lng = Locatable::Helpers.extract_lat_lng(sw_corner)
18
+ ne_lat, ne_lng = Locatable::Helpers.extract_lat_lng(ne_corner)
19
+
20
+ next none if [sw_lat, sw_lng, ne_lat, ne_lng].any?(&:nil?)
21
+
22
+ location_column = :location_geometry
23
+
24
+ within_bbox_sql = if ne_lng > sw_lng
25
+ <<~SQL.squish
26
+ #{location_column} && ST_MakeEnvelope(#{sw_lng}, #{sw_lat}, #{ne_lng}, #{ne_lat}, #{SRID})
27
+ SQL
28
+ else
29
+ <<~SQL.squish
30
+ #{location_column} && ST_MakeEnvelope(#{sw_lng}, #{sw_lat}, 180, #{ne_lat}, #{SRID}) OR
31
+ #{location_column} && ST_MakeEnvelope(-180, #{sw_lat}, #{ne_lng}, #{ne_lat}, #{SRID})
32
+ SQL
33
+ end
34
+
35
+ where(Arel.sql(within_bbox_sql))
36
+ end
37
+
38
+ scope :order_by_closest_to, ->(origin) do
39
+ lat, lng = Locatable::Helpers.extract_lat_lng(origin)
40
+
41
+ next all if lat.nil? || lng.nil?
42
+
43
+ order(Arel.sql("location_geography <-> ST_Point(#{lng}, #{lat}, #{SRID})::geography"))
44
+ end
45
+
46
+ scope :select_distance_to, ->(origin, units: nil) do
47
+ units ||= Locatable.default_units
48
+ lat, lng = Locatable::Helpers.extract_lat_lng(origin)
49
+ meters_per_units = Locatable::Helpers.meters_per_units(units)
50
+
51
+ distance_sql = if lat.nil? || lng.nil?
52
+ "NULL"
53
+ else
54
+ "ST_Distance(location_geography, ST_Point(#{lng}, #{lat}, #{SRID})::geography) / #{meters_per_units}"
55
+ end
56
+
57
+ select("#{distance_sql} AS distance")
58
+ end
59
+
60
+ scope :within_radius, ->(origin, radius, units: nil) do
61
+ units ||= Locatable.default_units
62
+ lat, lng = Locatable::Helpers.extract_lat_lng(origin)
63
+ radius = Locatable::Helpers.convert_to_meters(radius, units)
64
+
65
+ next none if lat.nil? || lng.nil?
66
+ next all if radius.nil?
67
+
68
+ where(Arel.sql("ST_DWithin(location_geography, ST_Point(#{lng}, #{lat}, #{SRID})::geography, #{radius})"))
69
+ end
70
+
71
+ scope :near, ->(origin, radius = 20, units: nil, order_by_closest: true, select_distance: false) do
72
+ units ||= Locatable.default_units
73
+ scope = all
74
+ scope = scope.within_radius(origin, radius, units: units) if radius.present?
75
+ scope = scope.order_by_closest_to(origin) if order_by_closest
76
+ scope = scope.select_distance_to(origin, units: units) if select_distance
77
+ scope
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Locatable
4
+ VERSION = "0.1.1"
5
+ end
data/lib/locatable.rb ADDED
@@ -0,0 +1,35 @@
1
+ require_relative "locatable/version"
2
+ require_relative "locatable/migration_helpers"
3
+ require_relative "locatable/scopes"
4
+
5
+ require "activerecord-postgis-adapter"
6
+
7
+ require "locatable/railtie" if defined?(Rails::Railtie)
8
+
9
+ module Locatable
10
+ VALID_UNITS = %i[km mi nm].freeze
11
+ METERS_PER_UNIT = {
12
+ km: 1_000.0,
13
+ mi: 1_609.344,
14
+ nm: 1_852.0
15
+ }.freeze
16
+
17
+ class << self
18
+ def default_units
19
+ @default_units ||= :mi
20
+ end
21
+
22
+ def default_units=(units)
23
+ @default_units = normalize_units(units)
24
+ end
25
+
26
+ def normalize_units(units)
27
+ units = units.to_sym
28
+ return units if VALID_UNITS.include?(units)
29
+
30
+ raise ArgumentError, "units must be one of: #{VALID_UNITS.join(", ")}"
31
+ rescue NoMethodError
32
+ raise ArgumentError, "units must be one of: #{VALID_UNITS.join(", ")}"
33
+ end
34
+ end
35
+ end
data/sig/locatable.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Locatable
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: locatable
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - simon
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord-postgis-adapter
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: pg
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: railties
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ description: Location scopes for Active Record models backed by PostGIS.
55
+ email:
56
+ - simonrmurcia@gmail.com
57
+ executables: []
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - CHANGELOG.md
62
+ - README.md
63
+ - Rakefile
64
+ - lib/locatable.rb
65
+ - lib/locatable/helpers.rb
66
+ - lib/locatable/migration_helpers.rb
67
+ - lib/locatable/model.rb
68
+ - lib/locatable/railtie.rb
69
+ - lib/locatable/scopes.rb
70
+ - lib/locatable/version.rb
71
+ - sig/locatable.rbs
72
+ homepage: https://github.com/simon/locatable
73
+ licenses:
74
+ - MIT
75
+ metadata:
76
+ allowed_push_host: https://rubygems.org
77
+ homepage_uri: https://github.com/simon/locatable
78
+ source_code_uri: https://github.com/simon/locatable
79
+ changelog_uri: https://github.com/simon/locatable/blob/main/CHANGELOG.md
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: 3.2.0
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 4.0.6
95
+ specification_version: 4
96
+ summary: Location scopes for Active Record models.
97
+ test_files: []