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.
@@ -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