has_geo_lookup 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d873a9710e8df280355b3d488e2329a5d1cf6e9ab1c6f768a4e1b814e567b4c0
4
- data.tar.gz: 3269979fbb084f3dca2edaa0ea6f045c924e9d79ce7236869e4049c9a550dbe6
3
+ metadata.gz: c346daadf2a1be6115c2c5a5568f3298a90fa7320b8418ffbb1404c48f1e3be4
4
+ data.tar.gz: 39fbae916da78a3aa2646e5ef0770f67fb65d15d37b276e9963f045354974882
5
5
  SHA512:
6
- metadata.gz: f2a2ae74c77015a2196d91a1c8e0c0dd82253ef316620b803484e7956f040c806c5898a6452f396ca41663cd4e91d9aca26e18775e09067ad5d0f5fce3684643
7
- data.tar.gz: a5e3462c0ebc72b7ba367ec271d12b732f5be9d19ca30a1a8f62c0e9246ea2d9d1f1a2e5f47202b1fcab6b572a33c46fb296bf0b176ff604a35d9b0c89bf9ff8
6
+ metadata.gz: b116a1d609b20c28140e37cf62389b520d4caef383a6abf34e9eacd7b60415929ac3ea9ad464f0ea15abf9fb563d59375dbc47eabdb860b4a19e08944777b2a0
7
+ data.tar.gz: df4d371bd9667eb8605a32dafbd23271e00b86bfb16a4e38f2d328a294c8e97789c2cec9a87802d4badf2ebfa85b952003d020df9923b8ee5f4175570108403d
data/README.md CHANGED
@@ -269,25 +269,25 @@ This gem integrates data from:
269
269
 
270
270
  ## Performance Considerations
271
271
 
272
- ### Database Setup and Optimization
272
+ ### Index Analysis and Optimization
273
273
 
274
- The gem includes built-in tools to analyze and set up the complete database requirements for HasGeoLookup functionality:
274
+ The gem includes built-in tools to analyze and optimize database indexes for HasGeoLookup functionality:
275
275
 
276
276
  ```bash
277
- # Check HasGeoLookup setup for all models (columns and indexes)
278
- rake has_geo_lookup:check_setup
277
+ # Check index coverage for all models using HasGeoLookup
278
+ rake has_geo_lookup:check_indexes
279
279
 
280
- # Preview what setup changes would be made (dry run)
281
- rake has_geo_lookup:preview_setup
280
+ # Preview what indexes would be created (dry run)
281
+ rake has_geo_lookup:preview_indexes
282
282
 
283
- # Generate Rails migration for complete HasGeoLookup setup
284
- rake has_geo_lookup:create_setup
283
+ # Generate Rails migration for missing columns and indexes
284
+ rake has_geo_lookup:create_indexes
285
285
 
286
286
  # Analyze a specific model in detail
287
287
  rake has_geo_lookup:analyze_model[Listing]
288
288
  ```
289
289
 
290
- **Programmatic Setup Management:**
290
+ **Programmatic Index Management:**
291
291
 
292
292
  ```ruby
293
293
  # Get performance analysis for all models
@@ -307,9 +307,9 @@ migration_path = HasGeoLookup::IndexChecker.generate_index_migration(Listing)
307
307
  puts HasGeoLookup::IndexChecker.performance_report
308
308
  ```
309
309
 
310
- **Complete Database Setup:**
310
+ **Setup and Index Creation:**
311
311
 
312
- The `create_setup` task generates proper Rails migration files that can be committed to version control and run as part of your deployment process. The migration includes:
312
+ The `create_indexes` task generates proper Rails migration files that can be committed to version control and run as part of your deployment process. The migration includes:
313
313
 
314
314
  - **Required columns**: Adds `latitude` and `longitude` decimal columns if missing
315
315
  - **Recommended indexes**: Creates optimized database indexes for geographic queries
@@ -0,0 +1,414 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open-uri"
4
+ require "json"
5
+ require "rgeo"
6
+ require "fileutils"
7
+ require "digest/sha1"
8
+ require "iso_3166"
9
+
10
+ module HasGeoLookup
11
+ class BoundaryImporter
12
+ class << self
13
+ # Import all available ADM levels (1-4) for a country
14
+ #
15
+ # @param iso2_code [String] 2-letter ISO country code (e.g., "US", "FR")
16
+ # @param options [Hash] Import options
17
+ # @option options [String] :cache_dir Directory for caching downloaded files
18
+ # @option options [Boolean] :verbose Enable detailed progress output (default: true)
19
+ #
20
+ # @return [Hash] Import results with :success, :total_processed, :errors keys
21
+ def import_country(iso2_code, options = {})
22
+ iso2 = iso2_code&.upcase
23
+ raise ArgumentError, "Please provide a 2-letter country code" unless iso2
24
+
25
+ cache_dir = options[:cache_dir] || default_cache_dir
26
+ verbose = options.fetch(:verbose, true)
27
+
28
+ # Convert ISO2 to ISO3 using iso_3166 gem with fallbacks
29
+ iso3, country_name = resolve_country_codes(iso2, verbose)
30
+
31
+ errors = []
32
+ total_processed = 0
33
+ factory = create_geometry_factory
34
+
35
+ (1..5).each do |level|
36
+ adm = "ADM#{level}"
37
+ puts "📍 Importing #{adm} boundaries..." if verbose
38
+
39
+ result = import_adm_level(iso3, adm, factory, cache_dir, errors, verbose)
40
+
41
+ unless result[:success]
42
+ puts "⏭️ Skipping remaining ADM levels (#{adm} and higher not available)" if verbose
43
+ break
44
+ end
45
+
46
+ total_processed += result[:count]
47
+ end
48
+
49
+ {
50
+ success: true,
51
+ total_processed: total_processed,
52
+ errors: errors,
53
+ country: { iso2: iso2, iso3: iso3, name: country_name }
54
+ }
55
+ end
56
+
57
+ # Import boundaries for a specific ADM level
58
+ #
59
+ # @param iso2_code [String] 2-letter ISO country code
60
+ # @param adm_level [String] Administrative level ("ADM1", "ADM2", "ADM3", "ADM4", "ADM5")
61
+ # @param options [Hash] Import options (same as import_country)
62
+ #
63
+ # @return [Hash] Import results
64
+ def import_level(iso2_code, adm_level, options = {})
65
+ iso2 = iso2_code&.upcase
66
+ raise ArgumentError, "Please provide a 2-letter country code" unless iso2
67
+ raise ArgumentError, "Please provide ADM level (ADM1-ADM5)" unless adm_level&.match?(/\AADM[1-5]\z/)
68
+
69
+ cache_dir = options[:cache_dir] || default_cache_dir
70
+ verbose = options.fetch(:verbose, true)
71
+
72
+ iso3, country_name = resolve_country_codes(iso2, verbose)
73
+
74
+ errors = []
75
+ factory = create_geometry_factory
76
+
77
+ result = import_adm_level(iso3, adm_level, factory, cache_dir, errors, verbose)
78
+
79
+ {
80
+ success: result[:success],
81
+ total_processed: result[:count],
82
+ errors: errors,
83
+ country: { iso2: iso2, iso3: iso3, name: country_name }
84
+ }
85
+ end
86
+
87
+ private
88
+
89
+ def default_cache_dir
90
+ if defined?(Rails)
91
+ Rails.root.join("db", "boundaries")
92
+ else
93
+ File.join(Dir.tmpdir, "has_geo_lookup_boundaries")
94
+ end
95
+ end
96
+
97
+ def create_geometry_factory
98
+ RGeo::Cartesian.factory(
99
+ srid: 4326,
100
+ uses_lenient_assertions: true,
101
+ has_z_coordinate: false,
102
+ wkt_parser: { support_ewkt: true }
103
+ )
104
+ end
105
+
106
+ def resolve_country_codes(iso2, verbose = true)
107
+ country = Iso3166.for_code(iso2)
108
+
109
+ if country
110
+ iso3 = country.code3
111
+ country_name = country.name.downcase.split(' ').map(&:capitalize).join(' ')
112
+ puts "🌍 Converting #{iso2} → #{iso3} (#{country_name})" if verbose
113
+ [iso3, country_name]
114
+ else
115
+ # Fallback for codes not recognized by iso_3166 gem
116
+ puts "⚠️ Country code '#{iso2}' not found in iso_3166 gem, trying direct lookup..." if verbose
117
+
118
+ # Common manual mappings for missing territories
119
+ manual_mappings = {
120
+ 'BL' => { iso3: 'BLM', name: 'Saint Barthélemy' },
121
+ 'MF' => { iso3: 'MAF', name: 'Saint Martin' },
122
+ 'SX' => { iso3: 'SXM', name: 'Sint Maarten' }
123
+ }
124
+
125
+ if manual_mappings[iso2]
126
+ mapping = manual_mappings[iso2]
127
+ puts "🌍 Using manual mapping: #{iso2} → #{mapping[:iso3]} (#{mapping[:name]})" if verbose
128
+ [mapping[:iso3], mapping[:name]]
129
+ else
130
+ # Last resort: use ISO2 as ISO3 and try the API call
131
+ puts "🌍 Using direct code: #{iso2} → #{iso2} (attempting API lookup)" if verbose
132
+ [iso2, iso2]
133
+ end
134
+ end
135
+ end
136
+
137
+ def import_adm_level(iso3, adm_level, factory, cache_dir, errors = [], verbose = true)
138
+ api_url = "https://www.geoboundaries.org/api/current/gbOpen/#{iso3}/#{adm_level}/"
139
+ puts "🌍 Fetching metadata from #{api_url}..." if verbose
140
+
141
+ # Get metadata (not cached since it's small and may change)
142
+ begin
143
+ metadata_response = URI.open(api_url).read
144
+ metadata = JSON.parse(metadata_response)
145
+ rescue OpenURI::HTTPError => e
146
+ if e.message.include?("404")
147
+ puts "⚠️ #{adm_level} boundaries not available for #{iso3} (404 Not Found)" if verbose
148
+ return { success: false, count: 0 }
149
+ else
150
+ puts "❌ HTTP error fetching metadata: #{e.message}" if verbose
151
+ return { success: false, count: 0 }
152
+ end
153
+ rescue JSON::ParserError => e
154
+ puts "❌ Invalid JSON in API response: #{e.message}" if verbose
155
+ return { success: false, count: 0 }
156
+ rescue => e
157
+ puts "❌ Unexpected error fetching metadata: #{e.class}: #{e.message}" if verbose
158
+ return { success: false, count: 0 }
159
+ end
160
+
161
+ # Use simplified geometry if available, fall back to full resolution
162
+ geojson_url = metadata["simplifiedGeometryGeoJSON"] || metadata["gjDownloadURL"]
163
+ unless geojson_url
164
+ puts "⚠️ No download URL found in API response for #{adm_level}" if verbose
165
+ return { success: false, count: 0 }
166
+ end
167
+
168
+ simplified = metadata["simplifiedGeometryGeoJSON"] ? "(simplified)" : "(full resolution)"
169
+ puts "📐 Using #{simplified} geometry" if verbose
170
+
171
+ # Check if GeoJSON is already cached
172
+ cached_file = local_geojson_path(iso3, adm_level, geojson_url, cache_dir)
173
+ if File.exist?(cached_file)
174
+ puts "📂 Using cached GeoJSON: #{cached_file}" if verbose
175
+ begin
176
+ data = JSON.parse(File.read(cached_file, encoding: "UTF-8"))
177
+ rescue JSON::ParserError => e
178
+ puts "❌ Invalid JSON in cached file, re-downloading: #{e.message}" if verbose
179
+ File.delete(cached_file)
180
+ data = download_and_cache_geojson(geojson_url, cached_file, verbose)
181
+ return { success: false, count: 0 } unless data
182
+ rescue Encoding::UndefinedConversionError => e
183
+ puts "❌ Encoding error in cached file, re-downloading: #{e.message}" if verbose
184
+ File.delete(cached_file)
185
+ data = download_and_cache_geojson(geojson_url, cached_file, verbose)
186
+ return { success: false, count: 0 } unless data
187
+ end
188
+ else
189
+ data = download_and_cache_geojson(geojson_url, cached_file, verbose)
190
+ return { success: false, count: 0 } unless data
191
+ end
192
+
193
+ unless data["features"] && data["features"].any?
194
+ puts "⚠️ No boundary features found in GeoJSON for #{adm_level}" if verbose
195
+ return { success: false, count: 0 }
196
+ end
197
+
198
+ count = process_features(data["features"], adm_level, geojson_url, factory, errors, verbose)
199
+
200
+ puts "✅ Processed #{count} boundaries for #{adm_level}." if verbose
201
+ { success: true, count: count }
202
+ end
203
+
204
+ def process_features(features, adm_level, geojson_url, factory, errors, verbose)
205
+ count = 0
206
+
207
+ features.each do |feature|
208
+ props = feature["properties"]
209
+ coords = feature["geometry"]["coordinates"]
210
+ next unless coords && props
211
+
212
+ name = props["shapeName"]
213
+ shape_id = props["shapeID"]
214
+ shape_iso = props["shapeISO"]
215
+ shape_group = props["shapeGroup"]
216
+
217
+ puts "🔍 Importing #{name.inspect}..." if verbose
218
+
219
+ # Process geometry
220
+ multi = create_multipolygon_from_feature(feature, factory, name, errors, verbose)
221
+ next unless multi
222
+
223
+ wkt = multi.as_text
224
+
225
+ # Insert into database using the gem's Geoboundary model
226
+ Geoboundary.connection.execute(<<~SQL)
227
+ INSERT INTO geoboundaries (name, level, shape_id, shape_iso, shape_group, source_url, boundary, created_at, updated_at)
228
+ VALUES (
229
+ #{ActiveRecord::Base.connection.quote(name)},
230
+ #{ActiveRecord::Base.connection.quote(adm_level)},
231
+ #{ActiveRecord::Base.connection.quote(shape_id)},
232
+ #{ActiveRecord::Base.connection.quote(shape_iso)},
233
+ #{ActiveRecord::Base.connection.quote(shape_group)},
234
+ #{ActiveRecord::Base.connection.quote(geojson_url)},
235
+ ST_GeomFromText('#{wkt}', 4326),
236
+ NOW(), NOW()
237
+ )
238
+ ON DUPLICATE KEY UPDATE
239
+ name = VALUES(name),
240
+ level = VALUES(level),
241
+ shape_iso = VALUES(shape_iso),
242
+ shape_group = VALUES(shape_group),
243
+ source_url = VALUES(source_url),
244
+ boundary = VALUES(boundary),
245
+ updated_at = NOW()
246
+ SQL
247
+
248
+ count += 1
249
+ rescue => e
250
+ # Enhanced error info with debug context
251
+ error_info = {
252
+ name: name || "Unknown",
253
+ adm_level: adm_level,
254
+ message: "#{e.class}: #{e.message}",
255
+ details: build_error_details(e, wkt, feature&.dig("geometry", "type"), coords&.class, coords&.size),
256
+ backtrace: e.is_a?(NoMethodError) ? e.backtrace.first(3) : nil
257
+ }
258
+ errors << error_info
259
+ puts "❌ Failed to process #{name.inspect}: #{e.class} (error details saved for summary)" if verbose
260
+ end
261
+
262
+ count
263
+ end
264
+
265
+ def create_multipolygon_from_feature(feature, factory, name, errors, verbose)
266
+ coords = feature["geometry"]["coordinates"]
267
+ geom_type = feature["geometry"]["type"]
268
+
269
+ # Collect debug geometry structure silently
270
+ coords_structure = "#{coords.class} -> #{coords.first&.class} -> #{coords.first&.first&.class}"
271
+
272
+ polygon_groups = geom_type == "MultiPolygon" ? coords : [coords]
273
+ debug_info = []
274
+
275
+ reversed_polygons = polygon_groups.map.with_index do |poly_coords, poly_index|
276
+ # Collect debug info silently for problematic geometries
277
+ unless poly_coords.is_a?(Array) && poly_coords.any?
278
+ debug_info << "poly_coords[#{poly_index}] is #{poly_coords.class}: #{poly_coords.inspect[0..200]}..."
279
+ next nil
280
+ end
281
+
282
+ raw_ring = poly_coords.first
283
+ unless raw_ring.is_a?(Array) && raw_ring.any?
284
+ debug_info << "raw_ring is #{raw_ring.class}: #{raw_ring.inspect[0..200]}..."
285
+ debug_info << "poly_coords structure: #{poly_coords.map(&:class)}"
286
+ next nil
287
+ end
288
+
289
+ outer =
290
+ if raw_ring.size == 2 && raw_ring.all? { |pt| pt.is_a?(Array) && pt.size == 2 }
291
+ puts "⚠️ Constructing rectangular fallback for #{name.inspect} (2 points)" if verbose
292
+ (lon1, lat1), (lon2, lat2) = raw_ring
293
+
294
+ rectangle = [
295
+ [lat1, lon1],
296
+ [lat2, lon1],
297
+ [lat2, lon2],
298
+ [lat1, lon2],
299
+ [lat1, lon1]
300
+ ].map { |lat, lon| factory.point(lat, lon) }
301
+
302
+ factory.linear_ring(rectangle)
303
+ else
304
+ ring_points = raw_ring.map { |lon, lat| factory.point(lat, lon) }
305
+ factory.linear_ring(ring_points)
306
+ end
307
+
308
+ # Process holes with better validation
309
+ holes = []
310
+ if poly_coords.size > 1
311
+ debug_info << "Processing #{poly_coords.size - 1} holes"
312
+ holes = poly_coords[1..].filter_map.with_index do |ring, hole_index|
313
+ unless ring.is_a?(Array) && ring.any?
314
+ debug_info << "hole[#{hole_index}] is #{ring.class}: #{ring.inspect[0..100]}..."
315
+ next nil
316
+ end
317
+
318
+ begin
319
+ debug_info << "hole[#{hole_index}] has #{ring.size} points, first point: #{ring.first.inspect}"
320
+ hole_points = ring.map { |lon, lat| factory.point(lat, lon) }
321
+ factory.linear_ring(hole_points)
322
+ rescue => e
323
+ debug_info << "hole[#{hole_index}] failed: #{e.class}: #{e.message}"
324
+ debug_info << "hole[#{hole_index}] structure: #{ring.map(&:class).uniq}"
325
+ debug_info << "hole[#{hole_index}] sample: #{ring.first(3).inspect}"
326
+ nil
327
+ end
328
+ end
329
+ debug_info << "Successfully processed #{holes.size} holes"
330
+ end
331
+
332
+ begin
333
+ # RGeo factory.polygon expects: outer_ring, hole1, hole2, hole3...
334
+ debug_info << "Creating polygon with outer: #{outer.class}, holes: #{holes.size} (#{holes.map(&:class)})"
335
+ factory.polygon(outer, *holes)
336
+ rescue => e
337
+ debug_info << "polygon creation failed: #{e.class}: #{e.message}"
338
+ debug_info << "outer ring valid: #{outer.respond_to?(:exterior_ring)}"
339
+ debug_info << "holes valid: #{holes.all? { |h| h.respond_to?(:exterior_ring) }}"
340
+ # Try without holes as fallback
341
+ begin
342
+ debug_info << "Retrying without holes..."
343
+ factory.polygon(outer)
344
+ rescue => e2
345
+ debug_info << "outer ring also failed: #{e2.class}: #{e2.message}"
346
+ nil
347
+ end
348
+ end
349
+ end.compact
350
+
351
+ # Skip if no valid polygons were created
352
+ if reversed_polygons.empty?
353
+ debug_info << "No valid polygons found, skipping"
354
+ return nil
355
+ end
356
+
357
+ factory.multi_polygon(reversed_polygons)
358
+ end
359
+
360
+ def local_geojson_path(iso3, adm_level, geojson_url, cache_dir)
361
+ # Create a safe filename from the URL and parameters
362
+ url_hash = Digest::SHA1.hexdigest(geojson_url)[0..8]
363
+ filename = "#{iso3}-#{adm_level}-#{url_hash}.geojson"
364
+ File.join(cache_dir, filename)
365
+ end
366
+
367
+ def build_error_details(error, wkt, geom_type, coords_class, coords_size)
368
+ details = []
369
+ details << "WKT length: #{wkt&.length || 'n/a'}"
370
+ details << "Geometry type: #{geom_type || 'n/a'}"
371
+ details << "Coordinates: #{coords_class}"
372
+ details << "Coordinates size: #{coords_size || 'n/a'}"
373
+
374
+ if error.is_a?(NoMethodError)
375
+ details << "Method called on: #{error.receiver&.class || 'unknown'}"
376
+ end
377
+
378
+ details.join(", ")
379
+ end
380
+
381
+ def download_and_cache_geojson(geojson_url, cached_file, verbose = true)
382
+ puts "⬇️ Downloading GeoJSON from #{geojson_url}..." if verbose
383
+
384
+ begin
385
+ geojson_response = URI.open(geojson_url, "rb") do |file|
386
+ file.read.force_encoding("UTF-8")
387
+ end
388
+ data = JSON.parse(geojson_response)
389
+
390
+ # Ensure directory exists
391
+ FileUtils.mkdir_p(File.dirname(cached_file))
392
+
393
+ # Cache the file
394
+ File.write(cached_file, geojson_response, encoding: "UTF-8")
395
+ puts "💾 Cached GeoJSON to #{cached_file}" if verbose
396
+
397
+ return data
398
+ rescue OpenURI::HTTPError => e
399
+ puts "❌ HTTP error downloading GeoJSON: #{e.message}" if verbose
400
+ return nil
401
+ rescue JSON::ParserError => e
402
+ puts "❌ Invalid JSON in GeoJSON file: #{e.message}" if verbose
403
+ return nil
404
+ rescue Encoding::UndefinedConversionError => e
405
+ puts "❌ Encoding error in GeoJSON file: #{e.message}" if verbose
406
+ return nil
407
+ rescue => e
408
+ puts "❌ Unexpected error downloading GeoJSON: #{e.class}: #{e.message}" if verbose
409
+ return nil
410
+ end
411
+ end
412
+ end
413
+ end
414
+ end
@@ -154,7 +154,7 @@ module HasGeoLookup
154
154
  end
155
155
 
156
156
  # Looks up the closest township-level area using feature_code = ADM3.
157
- # Falls back to ADM4 if no ADM3 match is found.
157
+ # Falls back to ADM4, then ADM5 if no ADM3 match is found.
158
158
  #
159
159
  # Options:
160
160
  # :radius_km — limit search radius (default: 25)
@@ -162,7 +162,7 @@ module HasGeoLookup
162
162
  #
163
163
  # Returns a Result struct with distance and matched geoname.
164
164
  def closest_township(radius_km: 25, country_code: nil)
165
- %w[ADM3 ADM4].each do |code|
165
+ %w[ADM3 ADM4 ADM5].each do |code|
166
166
  result = nearest_geonames(
167
167
  feature_class: "A",
168
168
  feature_code: code,
@@ -254,10 +254,10 @@ module HasGeoLookup
254
254
  end
255
255
 
256
256
  # GeoBoundary equivalent of closest_township
257
- # Returns ADM3 or ADM4 boundary that contains this point, with fallback
257
+ # Returns ADM3, ADM4 or ADM5 boundary that contains this point, with fallback
258
258
  def township_boundary
259
- # Try ADM3 first, then ADM4
260
- %w[ADM3 ADM4].each do |level|
259
+ # Try ADM3 first, then ADM4, then ADM5
260
+ %w[ADM5 ADM4 ADM3].each do |level|
261
261
  boundary = containing_boundary(level)
262
262
  return GeoboundaryResult.new(boundary, 0.0, level, boundary.name) if boundary
263
263
  end
@@ -286,7 +286,7 @@ module HasGeoLookup
286
286
  return geoname_result unless geoname_result
287
287
 
288
288
  # Add boundary context to help validate the subdivision
289
- boundaries = containing_boundaries(levels: %w[ADM2 ADM3 ADM4])
289
+ boundaries = containing_boundaries(levels: %w[ADM2 ADM3 ADM4 ADM5])
290
290
  geoname_result.record.define_singleton_method(:containing_boundaries) { boundaries }
291
291
 
292
292
  geoname_result
@@ -770,8 +770,9 @@ module HasGeoLookup
770
770
  # Check if boundary data exists for a country
771
771
  #
772
772
  # @param iso2 [String] 2-letter country code
773
+ # @param level [String, nil] Optional specific ADM level to check (e.g., "ADM2")
773
774
  # @return [Boolean] true if boundary data exists
774
- def has_boundary_data?(iso2)
775
+ def has_boundary_data?(iso2, level = nil)
775
776
  # Special cases for territories that don't have separate boundary data
776
777
  territories_without_boundaries = %w[PR VI GU AS MP TC] # US territories + others
777
778
  return true if territories_without_boundaries.include?(iso2)
@@ -782,9 +783,54 @@ module HasGeoLookup
782
783
 
783
784
  iso3 = country.code3
784
785
 
785
- # Check if we have any boundaries for this country's ISO3 code
786
- # Boundaries are stored with country info in shape_group or can be inferred from shape_iso
787
- Geoboundary.where("shape_iso LIKE ? OR shape_group LIKE ?", "%#{iso3}%", "%#{iso3}%").exists?
786
+ # Build query for boundaries with optional level filter
787
+ query = Geoboundary.where("shape_iso LIKE ? OR shape_group LIKE ?", "%#{iso3}%", "%#{iso3}%")
788
+ query = query.where(level: level) if level
789
+
790
+ query.exists?
791
+ end
792
+
793
+ # Get detailed boundary coverage by ADM level for a country
794
+ #
795
+ # @param iso2 [String] 2-letter country code
796
+ # @return [Hash] Hash with ADM levels as keys and counts as values
797
+ # @example
798
+ # boundary_coverage_by_level('US')
799
+ # # => { 'ADM1' => 51, 'ADM2' => 3142, 'ADM3' => 0, 'ADM4' => 0, 'ADM5' => 0 }
800
+ def boundary_coverage_by_level(iso2)
801
+ # Special cases for territories
802
+ territories_without_boundaries = %w[PR VI GU AS MP TC]
803
+ if territories_without_boundaries.include?(iso2)
804
+ return %w[ADM1 ADM2 ADM3 ADM4 ADM5].index_with { |_| 1 } # Assume coverage
805
+ end
806
+
807
+ # Convert ISO2 to ISO3
808
+ country = Iso3166.for_code(iso2)
809
+ return {} unless country
810
+
811
+ iso3 = country.code3
812
+
813
+ # Count boundaries by level for this country
814
+ boundaries_by_level = Geoboundary.where("shape_iso LIKE ? OR shape_group LIKE ?", "%#{iso3}%", "%#{iso3}%")
815
+ .group(:level)
816
+ .count
817
+
818
+ # Ensure all ADM levels are represented (with 0 counts for missing)
819
+ %w[ADM1 ADM2 ADM3 ADM4 ADM5].index_with do |level|
820
+ boundaries_by_level[level] || 0
821
+ end
822
+ end
823
+
824
+ # Get list of missing ADM levels for a country
825
+ #
826
+ # @param iso2 [String] 2-letter country code
827
+ # @return [Array<String>] Array of missing ADM level strings
828
+ # @example
829
+ # missing_adm_levels('US')
830
+ # # => ['ADM3', 'ADM4', 'ADM5']
831
+ def missing_adm_levels(iso2)
832
+ coverage = boundary_coverage_by_level(iso2)
833
+ coverage.select { |_level, count| count == 0 }.keys
788
834
  end
789
835
 
790
836
  # Check if geonames data exists for a country
@@ -803,15 +849,28 @@ module HasGeoLookup
803
849
  # Get comprehensive coverage status for a country
804
850
  #
805
851
  # @param iso2 [String] 2-letter country code
806
- # @return [Hash] Coverage status with :boundaries, :geonames, :complete keys
852
+ # @return [Hash] Coverage status with detailed boundary information
853
+ # @example
854
+ # coverage_status('US')
855
+ # # => {
856
+ # # boundaries: true,
857
+ # # geonames: true,
858
+ # # complete: false,
859
+ # # boundary_coverage: { 'ADM1' => 51, 'ADM2' => 3142, 'ADM3' => 0, 'ADM4' => 0, 'ADM5' => 0 },
860
+ # # missing_adm_levels: ['ADM3', 'ADM4', 'ADM5']
861
+ # # }
807
862
  def coverage_status(iso2)
808
863
  boundaries = has_boundary_data?(iso2)
809
864
  geonames = has_geonames_data?(iso2)
865
+ boundary_coverage = boundary_coverage_by_level(iso2)
866
+ missing_levels = missing_adm_levels(iso2)
810
867
 
811
868
  {
812
869
  boundaries: boundaries,
813
870
  geonames: geonames,
814
- complete: boundaries && geonames
871
+ complete: boundaries && geonames && missing_levels.empty?,
872
+ boundary_coverage: boundary_coverage,
873
+ missing_adm_levels: missing_levels
815
874
  }
816
875
  end
817
876
 
@@ -13,12 +13,13 @@
13
13
  # - ADM2: County/district boundaries (e.g., Los Angeles County)
14
14
  # - ADM3: Municipality boundaries (e.g., city limits)
15
15
  # - ADM4: Neighborhood/ward boundaries
16
+ # - ADM5: Sub-neighborhood boundaries (city blocks, micro-districts)
16
17
  #
17
18
  # The boundary geometries are stored using PostGIS and can be used for precise
18
19
  # point-in-polygon queries to determine administrative containment.
19
20
  #
20
21
  # @attr [String] name Official boundary name
21
- # @attr [String] level Administrative level (ADM0, ADM1, ADM2, ADM3, ADM4)
22
+ # @attr [String] level Administrative level (ADM0, ADM1, ADM2, ADM3, ADM4, ADM5)
22
23
  # @attr [String] shape_iso ISO3 country code for this boundary
23
24
  # @attr [String] shape_group Grouping identifier for related boundaries
24
25
  # @attr [RGeo::Geos::CAPIGeometryMethods] boundary PostGIS geometry (polygon/multipolygon)
@@ -36,7 +37,7 @@ class Geoboundary < ActiveRecord::Base
36
37
 
37
38
  # Validations
38
39
  validates :name, presence: true
39
- validates :level, presence: true, inclusion: { in: %w[ADM0 ADM1 ADM2 ADM3 ADM4] }
40
+ validates :level, presence: true, inclusion: { in: %w[ADM0 ADM1 ADM2 ADM3 ADM4 ADM5] }
40
41
  validates :boundary, presence: true
41
42
 
42
43
  # Scopes for different administrative levels
@@ -45,6 +46,7 @@ class Geoboundary < ActiveRecord::Base
45
46
  scope :counties_districts, -> { where(level: "ADM2") }
46
47
  scope :municipalities, -> { where(level: "ADM3") }
47
48
  scope :neighborhoods, -> { where(level: "ADM4") }
49
+ scope :sub_neighborhoods, -> { where(level: "ADM5") }
48
50
 
49
51
  scope :by_country, ->(country_code) {
50
52
  iso3 = country_code_to_iso3(country_code)
@@ -146,6 +148,7 @@ class Geoboundary < ActiveRecord::Base
146
148
  when "ADM2" then "County/District"
147
149
  when "ADM3" then "Municipality"
148
150
  when "ADM4" then "Neighborhood/Ward"
151
+ when "ADM5" then "Sub-Neighborhood/Block"
149
152
  else level
150
153
  end
151
154
 
@@ -185,6 +188,12 @@ class Geoboundary < ActiveRecord::Base
185
188
  level == "ADM4"
186
189
  end
187
190
 
191
+ # Check if this is a sub-neighborhood-level boundary
192
+ # @return [Boolean] true if level is "ADM5"
193
+ def sub_neighborhood?
194
+ level == "ADM5"
195
+ end
196
+
188
197
  private
189
198
 
190
199
  # Convert 2-letter country code to 3-letter ISO3 code
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HasGeoLookup
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.1"
5
5
  end
@@ -15,6 +15,7 @@ require_relative "has_geo_lookup/models/metro"
15
15
 
16
16
  # Require utilities
17
17
  require_relative "has_geo_lookup/index_checker"
18
+ require_relative "has_geo_lookup/boundary_importer"
18
19
 
19
20
  # Require generators and railtie if Rails is available
20
21
  if defined?(Rails)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: has_geo_lookup
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Edlund
@@ -145,6 +145,7 @@ files:
145
145
  - lib/generators/has_geo_lookup/templates/create_geonames_metros.rb.erb
146
146
  - lib/generators/has_geo_lookup/templates/create_metros.rb.erb
147
147
  - lib/has_geo_lookup.rb
148
+ - lib/has_geo_lookup/boundary_importer.rb
148
149
  - lib/has_geo_lookup/concern.rb
149
150
  - lib/has_geo_lookup/index_checker.rb
150
151
  - lib/has_geo_lookup/models/feature_code.rb