has_geo_lookup 0.1.0
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 +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +389 -0
- data/Rakefile +8 -0
- data/lib/generators/has_geo_lookup/install_generator.rb +66 -0
- data/lib/generators/has_geo_lookup/templates/INSTALL.md +60 -0
- data/lib/generators/has_geo_lookup/templates/create_feature_codes.rb.erb +17 -0
- data/lib/generators/has_geo_lookup/templates/create_geoboundaries.rb.erb +24 -0
- data/lib/generators/has_geo_lookup/templates/create_geoboundaries_metros.rb.erb +10 -0
- data/lib/generators/has_geo_lookup/templates/create_geonames.rb.erb +40 -0
- data/lib/generators/has_geo_lookup/templates/create_geonames_metros.rb.erb +10 -0
- data/lib/generators/has_geo_lookup/templates/create_metros.rb.erb +17 -0
- data/lib/has_geo_lookup/concern.rb +813 -0
- data/lib/has_geo_lookup/index_checker.rb +360 -0
- data/lib/has_geo_lookup/models/feature_code.rb +194 -0
- data/lib/has_geo_lookup/models/geoboundary.rb +220 -0
- data/lib/has_geo_lookup/models/geoname.rb +152 -0
- data/lib/has_geo_lookup/models/metro.rb +247 -0
- data/lib/has_geo_lookup/railtie.rb +11 -0
- data/lib/has_geo_lookup/version.rb +5 -0
- data/lib/has_geo_lookup.rb +28 -0
- data/lib/tasks/has_geo_lookup.rake +111 -0
- data/sig/has_geo_lookup.rbs +4 -0
- metadata +183 -0
@@ -0,0 +1,360 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HasGeoLookup
|
4
|
+
# Utility class for checking and recommending database indexes for optimal performance
|
5
|
+
#
|
6
|
+
# This class analyzes models that include HasGeoLookup and provides recommendations
|
7
|
+
# for database indexes to optimize geographic queries. It can detect missing indexes
|
8
|
+
# and optionally create them automatically.
|
9
|
+
#
|
10
|
+
# @example Check indexes for all models
|
11
|
+
# HasGeoLookup::IndexChecker.analyze_all_models
|
12
|
+
#
|
13
|
+
# @example Check indexes for a specific model
|
14
|
+
# HasGeoLookup::IndexChecker.check_model(Listing)
|
15
|
+
#
|
16
|
+
# @example Create missing indexes
|
17
|
+
# HasGeoLookup::IndexChecker.create_missing_indexes(Listing)
|
18
|
+
class IndexChecker
|
19
|
+
class << self
|
20
|
+
|
21
|
+
# Required columns for HasGeoLookup functionality
|
22
|
+
REQUIRED_COLUMNS = [
|
23
|
+
{ name: :latitude, type: :decimal, precision: 10, scale: 6 },
|
24
|
+
{ name: :longitude, type: :decimal, precision: 10, scale: 6 }
|
25
|
+
].freeze
|
26
|
+
|
27
|
+
# Recommended indexes for models using HasGeoLookup functionality
|
28
|
+
RECOMMENDED_INDEXES = {
|
29
|
+
coordinate_indexes: [
|
30
|
+
{ columns: [:latitude, :longitude], name: 'coordinates' },
|
31
|
+
{ columns: [:latitude], name: 'latitude' },
|
32
|
+
{ columns: [:longitude], name: 'longitude' }
|
33
|
+
],
|
34
|
+
geo_attribute_indexes: [
|
35
|
+
{ columns: [:country], name: 'country' },
|
36
|
+
{ columns: [:state_or_province], name: 'state_or_province' },
|
37
|
+
{ columns: [:city], name: 'city' },
|
38
|
+
{ columns: [:postal_code], name: 'postal_code' }
|
39
|
+
]
|
40
|
+
}.freeze
|
41
|
+
|
42
|
+
# Analyze all models that include HasGeoLookup
|
43
|
+
#
|
44
|
+
# Scans the application for models that include HasGeoLookup and analyzes
|
45
|
+
# their index coverage for geographic operations.
|
46
|
+
#
|
47
|
+
# @return [Hash] Summary of analysis results by model
|
48
|
+
#
|
49
|
+
# @example
|
50
|
+
# results = HasGeoLookup::IndexChecker.analyze_all_models
|
51
|
+
# # => {
|
52
|
+
# # "Listing" => {
|
53
|
+
# # missing_indexes: 2,
|
54
|
+
# # recommendations: ["add_index :listings, [:latitude, :longitude]"],
|
55
|
+
# # table_name: "listings"
|
56
|
+
# # }
|
57
|
+
# # }
|
58
|
+
def analyze_all_models
|
59
|
+
results = {}
|
60
|
+
|
61
|
+
# Find all models that include HasGeoLookup
|
62
|
+
models_with_geo_lookup.each do |model|
|
63
|
+
results[model.name] = check_model(model)
|
64
|
+
end
|
65
|
+
|
66
|
+
results
|
67
|
+
end
|
68
|
+
|
69
|
+
# Check index coverage for a specific model
|
70
|
+
#
|
71
|
+
# Analyzes the database indexes for a model and compares them against
|
72
|
+
# the recommended indexes for HasGeoLookup functionality.
|
73
|
+
#
|
74
|
+
# @param model [Class] ActiveRecord model class
|
75
|
+
# @return [Hash] Analysis results with missing indexes and recommendations
|
76
|
+
#
|
77
|
+
# @example
|
78
|
+
# analysis = HasGeoLookup::IndexChecker.check_model(Listing)
|
79
|
+
# puts analysis[:missing_indexes].length
|
80
|
+
# puts analysis[:recommendations]
|
81
|
+
def check_model(model)
|
82
|
+
return { error: "Model does not include HasGeoLookup" } unless model.include?(HasGeoLookup)
|
83
|
+
|
84
|
+
table_name = model.table_name
|
85
|
+
existing_indexes = get_existing_indexes(table_name)
|
86
|
+
missing_columns = check_missing_columns(model)
|
87
|
+
missing_indexes = []
|
88
|
+
recommendations = []
|
89
|
+
|
90
|
+
# Check coordinate indexes (always recommended, but only if columns exist or will be created)
|
91
|
+
RECOMMENDED_INDEXES[:coordinate_indexes].each do |index_def|
|
92
|
+
# Check if all required columns exist or will be added
|
93
|
+
columns_available = index_def[:columns].all? do |col|
|
94
|
+
model.column_names.include?(col.to_s) || missing_columns.any? { |mc| mc[:name] == col }
|
95
|
+
end
|
96
|
+
|
97
|
+
next unless columns_available
|
98
|
+
|
99
|
+
unless has_index?(existing_indexes, index_def[:columns])
|
100
|
+
missing_indexes << index_def
|
101
|
+
recommendations << generate_index_command(table_name, index_def)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
# Check geo attribute indexes (only for columns that exist)
|
106
|
+
RECOMMENDED_INDEXES[:geo_attribute_indexes].each do |index_def|
|
107
|
+
columns = index_def[:columns].select { |col| model.column_names.include?(col.to_s) }
|
108
|
+
next if columns.empty?
|
109
|
+
|
110
|
+
unless has_index?(existing_indexes, columns)
|
111
|
+
index_def_with_existing_cols = index_def.merge(columns: columns)
|
112
|
+
missing_indexes << index_def_with_existing_cols
|
113
|
+
recommendations << generate_index_command(table_name, index_def_with_existing_cols)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
{
|
118
|
+
table_name: table_name,
|
119
|
+
missing_columns: missing_columns.length,
|
120
|
+
missing_column_details: missing_columns,
|
121
|
+
missing_indexes: missing_indexes.length,
|
122
|
+
missing_index_details: missing_indexes,
|
123
|
+
recommendations: recommendations,
|
124
|
+
existing_indexes: existing_indexes.map { |idx| idx.columns.sort }
|
125
|
+
}
|
126
|
+
end
|
127
|
+
|
128
|
+
# Check for missing required columns in a model
|
129
|
+
#
|
130
|
+
# @param model [Class] ActiveRecord model class
|
131
|
+
# @return [Array<Hash>] Array of missing column definitions
|
132
|
+
def check_missing_columns(model)
|
133
|
+
REQUIRED_COLUMNS.reject do |col_def|
|
134
|
+
model.column_names.include?(col_def[:name].to_s)
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
|
139
|
+
# Generate a Rails migration file for creating missing columns and indexes
|
140
|
+
#
|
141
|
+
# Creates a timestamped migration file containing all missing columns and indexes for optimal
|
142
|
+
# HasGeoLookup performance. This is the recommended approach for production use
|
143
|
+
# as it maintains proper migration history and version control.
|
144
|
+
#
|
145
|
+
# @param model [Class] ActiveRecord model class, or nil for all models
|
146
|
+
# @return [String] Path to the generated migration file, or nil if nothing needed
|
147
|
+
#
|
148
|
+
# @example Generate migration for a specific model
|
149
|
+
# HasGeoLookup::IndexChecker.generate_index_migration(Listing)
|
150
|
+
#
|
151
|
+
# @example Generate migration for all models with missing columns/indexes
|
152
|
+
# HasGeoLookup::IndexChecker.generate_index_migration
|
153
|
+
def generate_index_migration(model = nil)
|
154
|
+
if model
|
155
|
+
models_to_check = { model.name => check_model(model) }
|
156
|
+
else
|
157
|
+
models_to_check = analyze_all_models
|
158
|
+
end
|
159
|
+
|
160
|
+
models_with_missing = models_to_check.select do |_, analysis|
|
161
|
+
(analysis[:missing_columns] || 0) > 0 || (analysis[:missing_indexes] || 0) > 0
|
162
|
+
end
|
163
|
+
|
164
|
+
if models_with_missing.empty?
|
165
|
+
puts "✓ All models have optimal columns and indexes for HasGeoLookup"
|
166
|
+
return nil
|
167
|
+
end
|
168
|
+
|
169
|
+
# Generate migration content
|
170
|
+
migration_name = "add_has_geo_lookup_setup"
|
171
|
+
migration_name += "_for_#{model.name.underscore}" if model
|
172
|
+
|
173
|
+
migration_content = generate_migration_content(models_with_missing)
|
174
|
+
migration_path = create_migration_file(migration_name, migration_content)
|
175
|
+
|
176
|
+
puts "✓ Generated migration: #{migration_path}"
|
177
|
+
puts "Run 'rails db:migrate' to apply the columns and indexes"
|
178
|
+
|
179
|
+
migration_path
|
180
|
+
end
|
181
|
+
|
182
|
+
# Generate a performance report for HasGeoLookup usage
|
183
|
+
#
|
184
|
+
# Creates a comprehensive report showing index coverage across all models
|
185
|
+
# that use HasGeoLookup functionality.
|
186
|
+
#
|
187
|
+
# @return [String] Formatted report suitable for console output
|
188
|
+
def performance_report
|
189
|
+
results = analyze_all_models
|
190
|
+
|
191
|
+
if results.empty?
|
192
|
+
return "No models found that include HasGeoLookup"
|
193
|
+
end
|
194
|
+
|
195
|
+
report = []
|
196
|
+
report << "=" * 80
|
197
|
+
report << "HasGeoLookup Performance Analysis"
|
198
|
+
report << "=" * 80
|
199
|
+
|
200
|
+
total_missing = 0
|
201
|
+
|
202
|
+
results.each do |model_name, analysis|
|
203
|
+
report << "\n#{model_name} (table: #{analysis[:table_name]})"
|
204
|
+
report << "-" * 40
|
205
|
+
|
206
|
+
missing_columns = analysis[:missing_columns] || 0
|
207
|
+
missing_indexes = analysis[:missing_indexes] || 0
|
208
|
+
|
209
|
+
if missing_columns.zero? && missing_indexes.zero?
|
210
|
+
report << "✓ All recommended columns and indexes present"
|
211
|
+
else
|
212
|
+
if missing_columns > 0
|
213
|
+
total_missing += missing_columns
|
214
|
+
report << "⚠ #{missing_columns} missing column#{'s' if missing_columns > 1}: #{analysis[:missing_column_details].map { |c| c[:name] }.join(', ')}"
|
215
|
+
end
|
216
|
+
|
217
|
+
if missing_indexes > 0
|
218
|
+
total_missing += missing_indexes
|
219
|
+
report << "⚠ #{missing_indexes} missing index#{'es' if missing_indexes > 1}"
|
220
|
+
report << "\nRecommendations:"
|
221
|
+
analysis[:recommendations].each do |rec|
|
222
|
+
report << " #{rec}"
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
report << "\nExisting indexes: #{analysis[:existing_indexes].join(', ')}" if analysis[:existing_indexes].any?
|
228
|
+
end
|
229
|
+
|
230
|
+
report << "\n" + "=" * 80
|
231
|
+
report << "Summary: #{total_missing} missing columns/indexes across #{results.length} model#{'s' if results.length != 1}"
|
232
|
+
|
233
|
+
if total_missing > 0
|
234
|
+
report << "\nTo create missing columns and indexes, run:"
|
235
|
+
report << " rake has_geo_lookup:create_setup"
|
236
|
+
end
|
237
|
+
|
238
|
+
report << "=" * 80
|
239
|
+
|
240
|
+
report.join("\n")
|
241
|
+
end
|
242
|
+
|
243
|
+
private
|
244
|
+
|
245
|
+
# Find all models that include HasGeoLookup
|
246
|
+
def models_with_geo_lookup
|
247
|
+
models = []
|
248
|
+
|
249
|
+
# Load all models by checking every .rb file in app/models
|
250
|
+
Dir.glob(Rails.root.join("app/models/**/*.rb")).each do |file|
|
251
|
+
model_name = File.basename(file, ".rb").camelize
|
252
|
+
begin
|
253
|
+
model = model_name.constantize
|
254
|
+
if model.respond_to?(:include?) && model.include?(HasGeoLookup)
|
255
|
+
models << model
|
256
|
+
end
|
257
|
+
rescue => e
|
258
|
+
# Skip models that can't be loaded
|
259
|
+
Rails.logger.debug "Skipping model #{model_name}: #{e.message}"
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
models
|
264
|
+
end
|
265
|
+
|
266
|
+
# Get existing indexes for a table
|
267
|
+
def get_existing_indexes(table_name)
|
268
|
+
ActiveRecord::Base.connection.indexes(table_name)
|
269
|
+
end
|
270
|
+
|
271
|
+
# Check if an index exists for the given columns
|
272
|
+
def has_index?(existing_indexes, columns)
|
273
|
+
column_names = columns.map(&:to_s).sort
|
274
|
+
existing_indexes.any? { |index| index.columns.sort == column_names }
|
275
|
+
end
|
276
|
+
|
277
|
+
# Generate Rails migration command for creating an index
|
278
|
+
def generate_index_command(table_name, index_def)
|
279
|
+
columns = index_def[:columns]
|
280
|
+
if columns.length == 1
|
281
|
+
"add_index :#{table_name}, :#{columns.first}"
|
282
|
+
else
|
283
|
+
"add_index :#{table_name}, #{columns.inspect}"
|
284
|
+
end
|
285
|
+
end
|
286
|
+
|
287
|
+
# Generate the content for a Rails migration file
|
288
|
+
def generate_migration_content(models_with_missing)
|
289
|
+
migration_class_name = "AddHasGeoLookupSetup"
|
290
|
+
|
291
|
+
up_commands = []
|
292
|
+
down_commands = []
|
293
|
+
|
294
|
+
models_with_missing.each do |model_name, analysis|
|
295
|
+
table_name = analysis[:table_name]
|
296
|
+
|
297
|
+
# Add missing columns first
|
298
|
+
if analysis[:missing_column_details]&.any?
|
299
|
+
up_commands << " # Add missing columns for #{model_name}"
|
300
|
+
analysis[:missing_column_details].each do |col_def|
|
301
|
+
if col_def[:precision] && col_def[:scale]
|
302
|
+
up_commands << " add_column :#{table_name}, :#{col_def[:name]}, :#{col_def[:type]}, precision: #{col_def[:precision]}, scale: #{col_def[:scale]}"
|
303
|
+
else
|
304
|
+
up_commands << " add_column :#{table_name}, :#{col_def[:name]}, :#{col_def[:type]}"
|
305
|
+
end
|
306
|
+
|
307
|
+
down_commands.unshift(" remove_column :#{table_name}, :#{col_def[:name]}")
|
308
|
+
end
|
309
|
+
up_commands << ""
|
310
|
+
end
|
311
|
+
|
312
|
+
# Add missing indexes
|
313
|
+
if analysis[:missing_index_details]&.any?
|
314
|
+
up_commands << " # Add indexes for #{model_name}"
|
315
|
+
analysis[:missing_index_details].each do |index_def|
|
316
|
+
index_name = "index_#{table_name}_on_#{index_def[:name]}"
|
317
|
+
columns = index_def[:columns]
|
318
|
+
|
319
|
+
if columns.length == 1
|
320
|
+
up_commands << " add_index :#{table_name}, :#{columns.first}, name: '#{index_name}'"
|
321
|
+
else
|
322
|
+
up_commands << " add_index :#{table_name}, #{columns.inspect}, name: '#{index_name}'"
|
323
|
+
end
|
324
|
+
|
325
|
+
down_commands.unshift(" remove_index :#{table_name}, name: '#{index_name}'" +
|
326
|
+
(analysis[:missing_column_details]&.any? ? " if index_exists?(:#{table_name}, name: '#{index_name}')" : ""))
|
327
|
+
end
|
328
|
+
end
|
329
|
+
end
|
330
|
+
|
331
|
+
<<~MIGRATION
|
332
|
+
# frozen_string_literal: true
|
333
|
+
|
334
|
+
# Migration generated by HasGeoLookup::IndexChecker
|
335
|
+
# This migration adds required columns and recommended indexes for optimal geographic query performance
|
336
|
+
class #{migration_class_name} < ActiveRecord::Migration[#{ActiveRecord::Migration.current_version}]
|
337
|
+
def up
|
338
|
+
#{up_commands.join("\n")}
|
339
|
+
end
|
340
|
+
|
341
|
+
def down
|
342
|
+
#{down_commands.join("\n")}
|
343
|
+
end
|
344
|
+
end
|
345
|
+
MIGRATION
|
346
|
+
end
|
347
|
+
|
348
|
+
# Create a timestamped migration file
|
349
|
+
def create_migration_file(migration_name, content)
|
350
|
+
timestamp = Time.current.strftime("%Y%m%d%H%M%S")
|
351
|
+
filename = "#{timestamp}_#{migration_name}.rb"
|
352
|
+
migration_path = Rails.root.join("db", "migrate", filename)
|
353
|
+
|
354
|
+
File.write(migration_path, content)
|
355
|
+
migration_path.to_s
|
356
|
+
end
|
357
|
+
|
358
|
+
end # class << self
|
359
|
+
end
|
360
|
+
end
|
@@ -0,0 +1,194 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Feature classification codes for geographic places from Geonames.org
|
4
|
+
#
|
5
|
+
# This model stores the feature classification system used by Geonames.org to categorize
|
6
|
+
# different types of geographic features. Each feature code belongs to a feature class
|
7
|
+
# and provides detailed categorization for places, administrative divisions, hydrographic
|
8
|
+
# features, terrain features, etc.
|
9
|
+
#
|
10
|
+
# Feature classes include:
|
11
|
+
# - A: Administrative divisions (countries, states, counties)
|
12
|
+
# - P: Populated places (cities, towns, villages)
|
13
|
+
# - H: Hydrographic features (rivers, lakes, seas)
|
14
|
+
# - T: Terrain features (mountains, hills, valleys)
|
15
|
+
# - R: Roads and railways
|
16
|
+
# - S: Spots and buildings (schools, churches, stations)
|
17
|
+
# - U: Undersea features
|
18
|
+
# - V: Vegetation features (forests, parks)
|
19
|
+
# - L: Localities and areas
|
20
|
+
#
|
21
|
+
# @attr [String] feature_class Single letter feature class (A, P, H, T, R, S, U, V, L)
|
22
|
+
# @attr [String] feature_code Specific feature code (e.g., PPL, ADM1, RIV, MT)
|
23
|
+
# @attr [String] name Human-readable name of the feature type
|
24
|
+
# @attr [String] description Detailed description of what this feature represents
|
25
|
+
#
|
26
|
+
# @example Find administrative division codes
|
27
|
+
# FeatureCode.where(feature_class: "A")
|
28
|
+
#
|
29
|
+
# @example Find populated place codes
|
30
|
+
# FeatureCode.where(feature_class: "P")
|
31
|
+
#
|
32
|
+
# @see https://www.geonames.org/export/codes.html Geonames feature codes documentation
|
33
|
+
class FeatureCode < ActiveRecord::Base
|
34
|
+
# Simple model - no associations for now
|
35
|
+
|
36
|
+
# Validations
|
37
|
+
validates :feature_class, presence: true, length: { is: 1 }
|
38
|
+
validates :feature_code, presence: true, length: { maximum: 10 }
|
39
|
+
validates :name, presence: true
|
40
|
+
|
41
|
+
# Ensure uniqueness of feature_class + feature_code combination
|
42
|
+
validates :feature_code, uniqueness: { scope: :feature_class }
|
43
|
+
|
44
|
+
# Scopes for different feature classes
|
45
|
+
scope :administrative, -> { where(feature_class: "A") }
|
46
|
+
scope :populated_places, -> { where(feature_class: "P") }
|
47
|
+
scope :hydrographic, -> { where(feature_class: "H") }
|
48
|
+
scope :terrain, -> { where(feature_class: "T") }
|
49
|
+
scope :roads_railways, -> { where(feature_class: "R") }
|
50
|
+
scope :spots_buildings, -> { where(feature_class: "S") }
|
51
|
+
scope :undersea, -> { where(feature_class: "U") }
|
52
|
+
scope :vegetation, -> { where(feature_class: "V") }
|
53
|
+
scope :localities, -> { where(feature_class: "L") }
|
54
|
+
|
55
|
+
# Search scopes
|
56
|
+
scope :by_keyword, ->(keyword) {
|
57
|
+
where("LOWER(name) LIKE :keyword OR LOWER(description) LIKE :keyword",
|
58
|
+
keyword: "%#{keyword.to_s.downcase}%")
|
59
|
+
}
|
60
|
+
|
61
|
+
scope :administrative_levels, -> {
|
62
|
+
where(feature_class: "A").where("feature_code LIKE 'ADM%'").order(:feature_code)
|
63
|
+
}
|
64
|
+
|
65
|
+
# Class methods for common feature code lookups
|
66
|
+
|
67
|
+
# Find feature codes matching a search term
|
68
|
+
#
|
69
|
+
# Searches both the name and description fields for the given keyword,
|
70
|
+
# useful for finding appropriate feature codes when you know the type
|
71
|
+
# of place but not the exact code.
|
72
|
+
#
|
73
|
+
# @param keyword [String] Search term to match against name and description
|
74
|
+
# @return [ActiveRecord::Relation] Matching feature codes
|
75
|
+
#
|
76
|
+
# @example
|
77
|
+
# FeatureCode.search("county")
|
78
|
+
# # => Returns feature codes related to counties
|
79
|
+
#
|
80
|
+
# @example
|
81
|
+
# FeatureCode.search("populated")
|
82
|
+
# # => Returns feature codes for populated places
|
83
|
+
def self.search(keyword)
|
84
|
+
by_keyword(keyword)
|
85
|
+
end
|
86
|
+
|
87
|
+
# Get all administrative division levels
|
88
|
+
#
|
89
|
+
# Returns all ADM (administrative) feature codes ordered by level,
|
90
|
+
# useful for understanding the administrative hierarchy.
|
91
|
+
#
|
92
|
+
# @return [Array<FeatureCode>] Administrative division feature codes
|
93
|
+
#
|
94
|
+
# @example
|
95
|
+
# FeatureCode.admin_levels
|
96
|
+
# # => [ADM0 (countries), ADM1 (states), ADM2 (counties), etc.]
|
97
|
+
def self.admin_levels
|
98
|
+
administrative_levels.to_a
|
99
|
+
end
|
100
|
+
|
101
|
+
# Find the most appropriate feature code for a keyword
|
102
|
+
#
|
103
|
+
# Returns the first feature code that matches the keyword, prioritizing
|
104
|
+
# exact name matches over description matches.
|
105
|
+
#
|
106
|
+
# @param keyword [String] Search term
|
107
|
+
# @return [FeatureCode, nil] Best matching feature code or nil
|
108
|
+
#
|
109
|
+
# @example
|
110
|
+
# FeatureCode.find_by_keyword("county")
|
111
|
+
# # => #<FeatureCode feature_class: "A", feature_code: "ADM2", name: "second-order administrative division">
|
112
|
+
def self.find_by_keyword(keyword)
|
113
|
+
return nil if keyword.blank?
|
114
|
+
|
115
|
+
# First try exact name match
|
116
|
+
exact_match = where("LOWER(name) = ?", keyword.to_s.downcase).first
|
117
|
+
return exact_match if exact_match
|
118
|
+
|
119
|
+
# Then try partial matches
|
120
|
+
by_keyword(keyword).first
|
121
|
+
end
|
122
|
+
|
123
|
+
# Instance methods
|
124
|
+
|
125
|
+
# Returns the full feature identifier
|
126
|
+
#
|
127
|
+
# Combines feature class and feature code into a single identifier
|
128
|
+
# string for display or logging purposes.
|
129
|
+
#
|
130
|
+
# @return [String] Combined feature class and code
|
131
|
+
#
|
132
|
+
# @example
|
133
|
+
# feature_code.full_code
|
134
|
+
# # => "P.PPL" (for populated place)
|
135
|
+
def full_code
|
136
|
+
"#{feature_class}.#{feature_code}"
|
137
|
+
end
|
138
|
+
|
139
|
+
# Returns a human-readable description
|
140
|
+
#
|
141
|
+
# Combines the name and description with the feature code for
|
142
|
+
# comprehensive identification.
|
143
|
+
#
|
144
|
+
# @return [String] Formatted description
|
145
|
+
#
|
146
|
+
# @example
|
147
|
+
# feature_code.display_name
|
148
|
+
# # => "ADM2 - second-order administrative division (county, district)"
|
149
|
+
def display_name
|
150
|
+
"#{feature_code} - #{name}"
|
151
|
+
end
|
152
|
+
|
153
|
+
# Check if this represents an administrative division
|
154
|
+
# @return [Boolean] true if feature_class is "A"
|
155
|
+
def administrative?
|
156
|
+
feature_class == "A"
|
157
|
+
end
|
158
|
+
|
159
|
+
# Check if this represents a populated place
|
160
|
+
# @return [Boolean] true if feature_class is "P"
|
161
|
+
def populated_place?
|
162
|
+
feature_class == "P"
|
163
|
+
end
|
164
|
+
|
165
|
+
# Check if this represents a hydrographic feature
|
166
|
+
# @return [Boolean] true if feature_class is "H"
|
167
|
+
def hydrographic?
|
168
|
+
feature_class == "H"
|
169
|
+
end
|
170
|
+
|
171
|
+
# Check if this represents a terrain feature
|
172
|
+
# @return [Boolean] true if feature_class is "T"
|
173
|
+
def terrain?
|
174
|
+
feature_class == "T"
|
175
|
+
end
|
176
|
+
|
177
|
+
# Get the administrative level for administrative features
|
178
|
+
# @return [Integer, nil] Administrative level (0-4) or nil for non-administrative features
|
179
|
+
def admin_level
|
180
|
+
return nil unless administrative? && feature_code.start_with?("ADM")
|
181
|
+
|
182
|
+
level_match = feature_code.match(/ADM(\d)/)
|
183
|
+
level_match ? level_match[1].to_i : nil
|
184
|
+
end
|
185
|
+
|
186
|
+
# Ensure feature class and code are stored in uppercase
|
187
|
+
def feature_class=(value)
|
188
|
+
super(value&.upcase)
|
189
|
+
end
|
190
|
+
|
191
|
+
def feature_code=(value)
|
192
|
+
super(value&.upcase)
|
193
|
+
end
|
194
|
+
end
|