geopolitical 3.0.0 → 3.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.
Files changed (108) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +152 -73
  4. data/app/controllers/geopolitical/application_controller.rb +8 -0
  5. data/app/controllers/geopolitical/cities_controller.rb +57 -0
  6. data/app/controllers/geopolitical/geopolitical_controller.rb +19 -0
  7. data/app/controllers/geopolitical/hoods_controller.rb +56 -0
  8. data/app/controllers/geopolitical/nations_controller.rb +51 -0
  9. data/app/controllers/geopolitical/regions_controller.rb +56 -0
  10. data/app/helpers/geopolitical/application_helper.rb +2 -0
  11. data/app/models/city.rb +150 -48
  12. data/app/models/concerns/geopolitocracy.rb +121 -34
  13. data/app/models/hood.rb +44 -18
  14. data/app/models/nation.rb +97 -17
  15. data/app/models/region.rb +44 -14
  16. data/app/views/geopolitical/cities/_city.html.haml +2 -5
  17. data/app/views/geopolitical/cities/_form.html.haml +25 -28
  18. data/app/views/geopolitical/cities/edit.html.haml +1 -0
  19. data/app/views/geopolitical/cities/index.html.haml +17 -20
  20. data/app/views/geopolitical/cities/new.html.haml +1 -0
  21. data/app/views/geopolitical/cities/show.html.haml +7 -7
  22. data/app/views/geopolitical/geopolitical/index.html.haml +7 -9
  23. data/app/views/geopolitical/hoods/_city.html.haml +2 -5
  24. data/app/views/geopolitical/hoods/_form.html.haml +21 -22
  25. data/app/views/geopolitical/hoods/edit.html.haml +1 -0
  26. data/app/views/geopolitical/hoods/index.html.haml +13 -18
  27. data/app/views/geopolitical/hoods/new.html.haml +1 -0
  28. data/app/views/geopolitical/hoods/show.html.haml +5 -5
  29. data/app/views/geopolitical/nations/_form.html.haml +11 -8
  30. data/app/views/geopolitical/nations/_nation.html.haml +5 -0
  31. data/app/views/geopolitical/nations/edit.html.haml +1 -0
  32. data/app/views/geopolitical/nations/index.html.haml +16 -20
  33. data/app/views/geopolitical/nations/new.html.haml +1 -0
  34. data/app/views/geopolitical/nations/show.html.haml +7 -7
  35. data/app/views/geopolitical/regions/_form.html.haml +10 -8
  36. data/app/views/geopolitical/regions/_region.html.haml +2 -5
  37. data/app/views/geopolitical/regions/edit.html.haml +1 -0
  38. data/app/views/geopolitical/regions/index.html.haml +12 -15
  39. data/app/views/geopolitical/regions/new.html.haml +1 -0
  40. data/app/views/geopolitical/regions/show.html.haml +5 -5
  41. data/app/views/layouts/geopolitical.html.haml +13 -15
  42. data/config/locales/geopolitical.en.yml +76 -0
  43. data/config/locales/geopolitical.pt.yml +77 -0
  44. data/geopolitical.gemspec +14 -12
  45. data/lib/geopolitical/engine.rb +3 -0
  46. data/lib/geopolitical/version.rb +3 -2
  47. data/lib/geopolitical.rb +23 -3
  48. metadata +47 -118
  49. data/.coveralls.yml +0 -1
  50. data/.gitignore +0 -11
  51. data/.rspec +0 -1
  52. data/.travis.yml +0 -26
  53. data/Gemfile +0 -15
  54. data/Guardfile +0 -24
  55. data/Rakefile +0 -25
  56. data/app/serializers/city_serializer.rb +0 -8
  57. data/app/serializers/nation_serializer.rb +0 -4
  58. data/app/serializers/region_serializer.rb +0 -4
  59. data/bin/rails +0 -8
  60. data/lib/tasks/geopolitical_tasks.rake +0 -4
  61. data/spec/dummy/README.rdoc +0 -28
  62. data/spec/dummy/Rakefile +0 -6
  63. data/spec/dummy/app/assets/images/.keep +0 -0
  64. data/spec/dummy/app/assets/javascripts/application.js +0 -13
  65. data/spec/dummy/app/assets/stylesheets/application.css +0 -13
  66. data/spec/dummy/app/controllers/concerns/.keep +0 -0
  67. data/spec/dummy/app/helpers/application_helper.rb +0 -2
  68. data/spec/dummy/app/mailers/.keep +0 -0
  69. data/spec/dummy/app/models/.keep +0 -0
  70. data/spec/dummy/app/models/concerns/.keep +0 -0
  71. data/spec/dummy/app/views/layouts/application.html.erb +0 -14
  72. data/spec/dummy/bin/bundle +0 -3
  73. data/spec/dummy/bin/rails +0 -4
  74. data/spec/dummy/bin/rake +0 -4
  75. data/spec/dummy/config/application.rb +0 -27
  76. data/spec/dummy/config/boot.rb +0 -5
  77. data/spec/dummy/config/environment.rb +0 -5
  78. data/spec/dummy/config/environments/development.rb +0 -29
  79. data/spec/dummy/config/environments/production.rb +0 -80
  80. data/spec/dummy/config/environments/test.rb +0 -36
  81. data/spec/dummy/config/initializers/backtrace_silencers.rb +0 -7
  82. data/spec/dummy/config/initializers/filter_parameter_logging.rb +0 -4
  83. data/spec/dummy/config/initializers/inflections.rb +0 -16
  84. data/spec/dummy/config/initializers/mime_types.rb +0 -5
  85. data/spec/dummy/config/initializers/secret_token.rb +0 -12
  86. data/spec/dummy/config/initializers/session_store.rb +0 -3
  87. data/spec/dummy/config/initializers/wrap_parameters.rb +0 -14
  88. data/spec/dummy/config/locales/en.yml +0 -23
  89. data/spec/dummy/config/mongoid.yml +0 -63
  90. data/spec/dummy/config/routes.rb +0 -56
  91. data/spec/dummy/config.ru +0 -4
  92. data/spec/dummy/lib/assets/.keep +0 -0
  93. data/spec/dummy/log/.keep +0 -0
  94. data/spec/dummy/log/development.log +0 -0
  95. data/spec/dummy/log/test.log +0 -23532
  96. data/spec/dummy/public/404.html +0 -58
  97. data/spec/dummy/public/422.html +0 -58
  98. data/spec/dummy/public/500.html +0 -57
  99. data/spec/dummy/public/favicon.ico +0 -0
  100. data/spec/fabricators/city_fabricator.rb +0 -6
  101. data/spec/fabricators/hood_fabricator.rb +0 -5
  102. data/spec/fabricators/nation_fabricator.rb +0 -4
  103. data/spec/fabricators/region_fabricator.rb +0 -5
  104. data/spec/models/city_spec.rb +0 -158
  105. data/spec/models/hood_spec.rb +0 -47
  106. data/spec/models/nation_spec.rb +0 -53
  107. data/spec/models/region_spec.rb +0 -20
  108. data/spec/spec_helper.rb +0 -57
data/app/models/city.rb CHANGED
@@ -1,86 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Represents a city within a nation and, optionally, a region.
1
4
  #
2
- # Cities
5
+ # Cities have a name, geographical location (`geom`), area, and can be associated
6
+ # with neighborhoods (`Hood`). They also inherit common geopolitical attributes
7
+ # from the `Geopolitocracy` concern (like `name`, `abbr`, `slug`, `pop`, `phone`, `postal`).
3
8
  #
9
+ # The city's `slug` is automatically generated and suffixed with its region's
10
+ # abbreviation to ensure uniqueness across regions.
4
11
  class City
5
12
  include Mongoid::Document
6
- include Mongoid::Geospatial
13
+ include Mongoid::Geospatial # For GIS capabilities like `geom` field and spatial queries
7
14
  include Geopolitocracy
8
15
 
9
- field :area, type: Integer # m2 square area
16
+ # @!attribute [rw] area
17
+ # @return [Integer] The square area of the city in square meters (m2).
18
+ field :area, type: Integer
19
+ # @!attribute [rw] geom
20
+ # @return [Point] The geographical coordinates (longitude, latitude) of the city.
21
+ # Indexed for spatial queries.
10
22
  field :geom, type: Point, spatial: true
11
- # field :capital, type: String
23
+ # @!attribute [rw] rbbr
24
+ # @return [String] Cached abbreviation of the city's region.
25
+ # Used internally, primarily to construct unique slugs.
26
+ # Alias: `region_abbr` (writer only, getter is custom).
27
+ field :rbbr, type: String, as: :region_abbr
12
28
 
29
+ # Enables spatial queries on the `geom` field.
13
30
  spatial_scope :geom
14
31
 
15
- attr_writer :x, :y, :z
16
-
32
+ # @!attribute [rw] region
33
+ # @return [Region, nil] The region this city belongs to. Optional.
17
34
  belongs_to :region, inverse_of: :cities, optional: true
35
+ # @!attribute [rw] nation
36
+ # @return [Nation] The nation this city belongs to. Required.
18
37
  belongs_to :nation, inverse_of: :cities
19
- has_many :hoods
20
-
21
- has_one :nation_governancy, as: :nation_capital, class_name: 'Nation'
22
- has_one :region_governancy, as: :region_capital, class_name: 'Region'
23
-
24
- before_validation :set_defaults, on: [:create]
25
-
26
- validates :name, uniqueness: { scope: :region_id }
27
- validate :region_inside_nation
28
38
 
29
- scope :population, -> { order_by(souls: -1) }
30
-
31
- index nation_id: 1
32
- index souls: -1
33
- index name: 1, nation_id: 1
34
- index({ region_id: 1 }, sparse: true)
39
+ # @!attribute [rw] hoods
40
+ # @return [Mongoid::Relations::Targets::Enumerable<Hood>] Neighborhoods within this city.
41
+ has_many :hoods, dependent: :destroy # Assuming hoods should be destroyed if city is
42
+
43
+ # @!attribute [r] nation_governancy
44
+ # @return [Nation, nil] The nation for which this city is the capital.
45
+ # This association signifies that the city is a national capital.
46
+ has_one :nation_governancy, class_name: 'Nation', inverse_of: :capital, foreign_key: :capital_id
47
+ # @!attribute [r] region_governancy
48
+ # @return [Region, nil] The region for which this city is the capital.
49
+ # This association signifies that the city is a regional capital.
50
+ has_one :region_governancy, class_name: 'Region', inverse_of: :capital, foreign_key: :capital_id
51
+
52
+ before_validation :ensure_derived_fields_and_slug
53
+
54
+ validates :name, uniqueness: { scope: %i[nation_id region_id], message: 'must be unique within its region' }
55
+ validates :nation, presence: true
56
+ validate :region_inside_nation_if_region_present
57
+
58
+ # @!method self.population
59
+ # @return [Mongoid::Criteria] Cities ordered by population in descending order.
60
+ scope :population, -> { order_by(souls: :desc) }
61
+
62
+ index({ slug: 1 }, unique: true)
63
+ index({ name: 1, nation_id: 1 }) # For lookups by name within a nation
64
+ index({ nation_id: 1, region_id: 1, name: 1 }, { unique: true }) # Backs the name uniqueness validation
65
+ index({ nation_id: 1 }) # For finding all cities in a nation
66
+ index({ region_id: 1 }, sparse: true) # For finding cities in a region, sparse if region is optional
67
+ index({ souls: -1 }) # For sorting by population count
68
+ index({ geom: '2dsphere' }) # Explicit 2dsphere index for geospatial queries
69
+
70
+ # Validates that if a city is associated with a region, that region
71
+ # belongs to the same nation as the city.
72
+ def region_inside_nation_if_region_present
73
+ return unless region.present? # Only validate if region is set
74
+ return if nation.nil? # Avoid error if nation is not yet set (covered by presence validation)
75
+ return if region.nation == nation
76
+
77
+ errors.add(
78
+ :region,
79
+ 'must be within the same nation as the city. ' \
80
+ "Region's nation: #{region.nation&.abbr}, City's nation: #{nation.abbr}."
81
+ )
82
+ end
35
83
 
36
- def region_inside_nation
37
- return if !region || region.nation == nation
38
- errors.add(:region, 'not inside Nation')
84
+ # Gets the region's abbreviation (`rbbr`).
85
+ # If `rbbr` is not set and the city has a region, it populates `rbbr`
86
+ # with the region's abbreviation or name. This value is then cached on the city.
87
+ #
88
+ # @return [String, nil] The region's abbreviation or name, or nil if not determinable.
89
+ def region_abbr
90
+ current_rbbr = read_attribute(:rbbr)
91
+ if current_rbbr.blank? && region.present?
92
+ new_rbbr = region.abbr.presence || region.name.presence
93
+ write_attribute(:rbbr, new_rbbr) if new_rbbr.present?
94
+ return new_rbbr
95
+ end
96
+ current_rbbr
39
97
  end
40
98
 
41
- def set_defaults
42
- self.nation ||= region.try(:nation)
43
- return unless City.where(slug: slug).first
44
- self.slug += "-#{region.abbr || region.slug}"
45
- return unless City.where(slug: slug).first
46
- raise "Two cities with the same name in #{region}: '#{slug}'"
99
+ # Callback executed before validation to:
100
+ # 1. Derive the `nation` from the `region` if `nation` is not set and `region` is.
101
+ # 2. Ensure `rbbr` (region abbreviation) is populated if possible, and refreshed if the city moved.
102
+ # 3. Append the (lowercase) region abbreviation to the `slug` (generated by `Geopolitocracy`)
103
+ # to ensure city slugs are unique, e.g., "cityname-regionabbr" — or the nation
104
+ # abbreviation when the city has no region, e.g. "singapore-sg".
105
+ def ensure_derived_fields_and_slug
106
+ # 1. Derive nation from region if possible and not already set
107
+ self.nation = region.nation if region.present? && nation.blank?
108
+
109
+ # 2. A moved city gets a fresh abbr cache and a slug rebuilt from its name,
110
+ # otherwise the old suffix would stick around ("santos-sp-rj").
111
+ if persisted? && (region_id_changed? || nation_id_changed?)
112
+ write_attribute(:rbbr, nil)
113
+ self.slug = name
114
+ end
115
+
116
+ # 3. Suffix the slug so it is unique planet-wide: the region abbr where there
117
+ # is a region ("santos-sp"), the nation abbr otherwise ("singapore-sg").
118
+ suffix = region_abbr.presence || nation&.abbr
119
+ return if suffix.blank? || slug.blank?
120
+
121
+ slug_suffix = "-#{Geopolitocracy.slugify(suffix)}"
122
+ self.slug += slug_suffix unless slug.end_with?(slug_suffix)
47
123
  end
48
124
 
125
+ # Retrieves the phone number for the city.
126
+ # Falls back to the region's phone, then the nation's phone if the city's is not set.
127
+ # Assumes `phone` field is provided by `Geopolitocracy`.
128
+ #
129
+ # @return [String, nil] The phone number.
49
130
  def phone
50
- self[:phone] || region.phone || nation.phone
131
+ self[:phone] || region&.phone || nation&.phone
51
132
  end
52
133
 
134
+ # Collects unique, non-nil phone numbers from all associated hoods.
135
+ # Assumes `phone` field is provided by `Geopolitocracy` on Hood model.
136
+ #
137
+ # @return [Array<String>] An array of unique phone numbers.
53
138
  def phones
54
- hoods.map(&:phone)
139
+ hoods.map(&:phone).compact.uniq
55
140
  end
56
141
 
142
+ # Retrieves the postal code for the city.
143
+ # Falls back to the region's postal code, then the nation's if the city's is not set.
144
+ # Assumes `postal` field is provided by `Geopolitocracy`.
145
+ #
146
+ # @return [String, nil] The postal code.
57
147
  def postal
58
- self[:postal] || region.postal || nation.postal
148
+ self[:postal] || region&.postal || nation&.postal
59
149
  end
60
150
 
151
+ # Collects unique, non-nil postal codes from all associated hoods.
152
+ # Assumes `postal` field is provided by `Geopolitocracy` on Hood model.
153
+ #
154
+ # @return [Array<String>] An array of unique postal codes.
61
155
  def postals
62
- hoods.map(&:postal)
156
+ hoods.map(&:postal).compact.uniq
63
157
  end
64
158
 
65
- def ==(other)
66
- return unless other.is_a?(City)
67
- other && slug == other.slug
68
- end
69
-
70
- def <=>(other)
71
- return unless other.is_a?(City)
72
- slug <=> other.slug
73
- end
159
+ # Returns the city's name, optionally suffixed with its region's abbreviation.
160
+ #
161
+ # @param separator [String] The string to use between the name and region abbreviation.
162
+ # @return [String] The formatted name.
163
+ def with_region(separator = '/')
164
+ # Use the getter for region_abbr to ensure it's loaded if available
165
+ current_region_abbr = region_abbr
166
+ return name unless current_region_abbr.present?
74
167
 
75
- def with_region
76
- return name unless region
77
- "#{name}/#{region.abbr || region.name}"
168
+ "#{name}#{separator}#{current_region_abbr}"
78
169
  end
79
170
 
80
- def with_nation
81
- with_region + '/' + nation.abbr
171
+ # Returns the city's name, suffixed with its region's and nation's abbreviations.
172
+ # e.g., "CityName/RegionAbbr/NationAbbr"
173
+ #
174
+ # @param separator [String] The string to use as a separator.
175
+ # @return [String] The formatted name string.
176
+ def with_nation(separator = '/')
177
+ base = with_region(separator) # Relies on with_region using the (potentially now loaded) region_abbr
178
+ # Ensure nation and nation.abbr are present before appending
179
+ base += "#{separator}#{nation.abbr}" if nation&.abbr.present?
180
+ base
82
181
  end
83
182
 
183
+ # Default string representation of the city.
184
+ #
185
+ # @return [String] The city name, possibly with its region abbreviation.
84
186
  def to_s
85
187
  with_region
86
188
  end
@@ -1,61 +1,148 @@
1
- # Geopolitical Helpers
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Provides common fields, validations, and helper methods for geopolitical entities
5
+ # like Nations, Regions, Cities, and Hoods.
6
+ #
7
+ # NOTE: A Geonames ID is used as ID
8
+ #
9
+ # It includes fields for names, abbreviations, population, codes, and contact details.
10
+ # It also handles automatic slug generation and provides a basic search functionality.
11
+ #
2
12
  module Geopolitocracy
3
13
  extend ActiveSupport::Concern
4
14
 
15
+ # Turns any name into a URL-friendly slug: "São Paulo" => "sao-paulo".
16
+ # Latin script is transliterated to ASCII (accents stripped via NFKD, then
17
+ # æ/ø/ß/đ via I18n); other scripts (日本, Москва, دبي) keep their letters so
18
+ # the slug is never blank: "東京" => "東京", "Санкт-Петербург" => "санкт-петербург".
19
+ # @param text [String]
20
+ # @return [String]
21
+ def self.slugify(text)
22
+ # NFC first: decomposed input (macOS filenames, some form posts) would otherwise
23
+ # split a Latin run on its combining marks — "São" => "sa-o".
24
+ latin = text.to_s.unicode_normalize(:nfc).delete('.').gsub(/\p{Latin}+/) do |word|
25
+ ActiveSupport::Inflector.transliterate(word.unicode_normalize(:nfkd).gsub(/\p{Mn}/, ''))
26
+ end
27
+ latin.gsub(/[^\p{Alnum}]+/, '-').gsub(/^-+|-+$/, '').downcase
28
+ end
29
+
5
30
  included do
6
- # field :gid, type: Integer # geonames id
31
+ # @!group Fields
7
32
 
33
+ # @!attribute [rw] name
34
+ # @return [String] The primary name of the entity. Localized.
35
+ # Automatically titleized on assignment if it doesn't contain mixed-case characters.
8
36
  field :name, type: String, localize: true
9
- field :abbr, type: String
10
- field :nick, type: String
11
-
12
- field :souls, type: Integer # Population
37
+ # @!attribute [rw] alt
38
+ # @return [String, nil] An alternative name or spelling for the entity. Localized.
39
+ field :alt, type: String, localize: true, default: nil
40
+ # @!attribute [rw] abbr
41
+ # @return [String, nil] A common abbreviation for the entity (e.g., "NY" for New York).
42
+ field :abbr, type: String, default: nil
43
+ # @!attribute [rw] nick
44
+ # @return [String, nil] A nickname or colloquial name for the entity.
45
+ field :nick, type: String, default: nil
13
46
 
14
- field :ascii, type: String
15
- field :code, type: String
16
- field :slug, type: String # , default: -> { name }
17
-
18
- field :postal, type: String # , default: -> { name }
19
- field :phone, type: String # , default: -> { name }
47
+ # @!attribute [rw] souls
48
+ # @return [Integer, nil] The population count of the entity. Note: `pop` was taken by mongoid
49
+ field :souls, type: Integer, default: nil
50
+ # @!attribute [rw] ascii
51
+ # @return [String, nil] An ASCII-only representation of the name, useful for systems that don't support Unicode.
52
+ field :ascii, type: String, default: nil
53
+ # @!attribute [rw] code
54
+ # @return [String, nil] A code associated with the entity (e.g., FIPS code, ISO 3166-2 subdivision code).
55
+ # Its uniqueness might be scoped by a parent entity (e.g., nation_id) in the including class.
56
+ field :code, type: String, default: nil
57
+ # @!attribute [rw] slug
58
+ # @return [String] A URL-friendly slug, automatically generated from the name if not provided.
59
+ # Guaranteed to be present and unique across all documents including this concern.
60
+ field :slug, type: String
61
+ # @!attribute [rw] postal
62
+ # @return [String, nil] The primary postal code or prefix for the entity.
63
+ field :postal, type: String, default: nil
64
+ # @!attribute [rw] phone
65
+ # @return [String, nil] The primary phone dialing code for the entity.
66
+ field :phone, type: String, default: nil
20
67
 
68
+ # @!group Aliases
69
+ # @!attribute [rw] population # This alias might still be problematic if Mongoid defines a 'population' method.
70
+ # @see #souls # For now, keeping it as it was a direct alias to 'pop'.
21
71
  alias_method :population, :souls
22
- alias_method :iso_3166_2, :code
72
+ alias_method :population=, :souls=
23
73
 
74
+ # @!group Validations
24
75
  validates :name, presence: true
25
- validates :slug, presence: true, uniqueness: true
26
- validates :code, uniqueness: { scope: :nation_id, allow_nil: true }
76
+ validates :slug, presence: true # Uniqueness will be handled by including models with appropriate scope
77
+ # @!endgroup
78
+
79
+ # @!group Indexes
80
+ # NOTE: no slug or abbr index here — each model declares its own (unique where
81
+ # it can be). Mongoid keys a declaration on the fields alone, so a second
82
+ # `index({ abbr: 1 }, unique: true)` in a model would be silently ignored.
83
+ index({ name: 1 }) # For sorting and lookups by name
84
+ index({ code: 1 }, { sparse: true }) # Sparse as code can be nil
85
+ # @!endgroup
27
86
 
28
- index slug: 1
29
- index name: 1
87
+ before_validation :ensure_slug_is_generated
30
88
 
31
- before_validation :ensure_slug
89
+ # @!group Scopes
90
+ # @!method self.ordered
91
+ # @return [Mongoid::Criteria] Documents ordered by name ascending.
92
+ scope :ordered, -> { order_by(name: :asc) }
93
+ # @!endgroup
32
94
 
33
- scope :ordered, -> { order_by(name: 1) }
95
+ # Ensures that a slug is generated from the name if the slug is currently blank.
96
+ # This method is called before validation.
97
+ # It uses the custom `slug=` setter which handles parameterization.
98
+ def ensure_slug_is_generated
99
+ return unless slug.blank? && name.present?
34
100
 
35
- def ensure_slug
36
- self.slug ||= name
101
+ self.slug = name # Will trigger the custom slug setter
37
102
  end
38
103
 
39
- def name=(txt)
40
- return unless txt
41
- txt = txt.titleize unless txt =~ /[A-Z][a-z]/
42
- super txt
104
+ # Custom writer for the name attribute.
105
+ # It titleizes the input string if it doesn't already contain mixed-case characters.
106
+ # @param new_name [String] The new name.
107
+ def name=(new_name)
108
+ if new_name.present? && new_name !~ /[A-Z][a-z]/
109
+ super(new_name.titleize)
110
+ else
111
+ super
112
+ end
43
113
  end
44
114
 
45
- def slug=(txt)
46
- return unless txt
47
- self[:slug] = ActiveSupport::Inflector
48
- .transliterate(txt).delete('.').gsub(/\W/, '-').downcase
115
+ # Custom writer for the slug attribute. See {Geopolitocracy.slugify}.
116
+ # @param new_slug_source [String] The string to be converted into a slug.
117
+ def slug=(new_slug_source)
118
+ super(new_slug_source.present? ? Geopolitocracy.slugify(new_slug_source) : nil)
49
119
  end
50
120
 
121
+ # Default string representation of the entity.
122
+ # @return [String] The name of the entity, or its slug if the name is not set.
51
123
  def to_s
52
- name || slug
124
+ name.presence || slug.presence || ''
53
125
  end
54
126
 
55
- def self.search(txt, lazy = false)
56
- key = ActiveSupport::Inflector.transliterate(txt).gsub(/\W/, '-')
57
- char = lazy ? nil : '$'
58
- where(slug: /^#{key.downcase}#{char}/)
127
+ # @!group Class Methods
128
+ # Performs a search for entities where the slug starts with the given text (after parameterization).
129
+ #
130
+ # @param query_text [String] The text to search for.
131
+ # @param exact_match [Boolean] If true, searches for an exact match of the parameterized query.
132
+ # If false (default), searches for slugs starting with the parameterized query.
133
+ # @return [Mongoid::Criteria] A criteria object for fetching matching documents.
134
+ def self.search(query_text, exact: false)
135
+ return none if query_text.blank? # Return an empty criteria if query is blank
136
+
137
+ parameterized_query = Geopolitocracy.slugify(query_text)
138
+ return none if parameterized_query.blank?
139
+
140
+ if exact # matches the exact slug
141
+ where(slug: parameterized_query)
142
+ else
143
+ where(slug: /^#{Regexp.escape(parameterized_query)}/)
144
+ end
59
145
  end
146
+ # @!endgroup
60
147
  end
61
148
  end
data/app/models/hood.rb CHANGED
@@ -1,35 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Represents a neighborhood (hood) within a city.
1
4
  #
2
- # Hood/Neighborhood
3
- #
5
+ # Hoods belong to a city and inherit common geopolitical attributes
6
+ # from the `Geopolitocracy` concern (like `name`, `abbr`, `slug`, `pop`, `phone`, `postal`).
7
+ # The slug for a hood is typically a combination of the city's slug and the hood's name.
4
8
  class Hood
5
9
  include Mongoid::Document
6
- include Geopolitocracy
10
+ include Geopolitocracy # Provides common geopolitical fields like name, abbr, slug, etc.
7
11
 
8
- field :rank, type: Integer
12
+ # @!attribute [rw] rank
13
+ # @return [Integer, nil] An optional ranking for the neighborhood, if applicable.
14
+ field :rank, type: Integer
9
15
 
16
+ # @!attribute [rw] city
17
+ # @return [City] The city this neighborhood belongs to. Required.
10
18
  belongs_to :city
11
19
 
12
20
  validates :city, presence: true
13
- validates :name, uniqueness: { scope: :city_id }
21
+ validates :name, uniqueness: { scope: :city_id, message: 'must be unique within its city' }
22
+ # `abbr` (from Geopolitocracy) could also be validated for uniqueness within the city if used.
23
+ # validates :abbr,
24
+ # uniqueness: { scope: :city_id, allow_nil: true, message: "must be unique within its city if provided" }
25
+
26
+ index({ city_id: 1, name: 1 }, { unique: true })
27
+ # index({ city_id: 1, abbr: 1 }, { unique: true, sparse: true }) # If abbr is used and needs to be unique
28
+ index({ slug: 1 }, { unique: true }) # Hood slugs are globally unique due to city_slug prefix
29
+
30
+ before_validation :ensure_slug_for_hood
14
31
 
15
- def ensure_slug
16
- return unless city
17
- self.slug ||= "#{city.slug}-#{name}"
32
+ # Slug is "#{city.slug}-#{name slug}"; prepends the city slug if a bare one was
33
+ # given, and is rebuilt when the hood is renamed or moved to another city.
34
+ def ensure_slug_for_hood
35
+ return if city.blank?
36
+
37
+ self.slug = name if persisted? && (name_changed? || city_id_changed?)
38
+ return if slug.blank?
39
+
40
+ prefix = "#{city.slug}-"
41
+ self.slug = "#{prefix}#{slug}" unless slug.start_with?(prefix)
18
42
  end
19
43
 
44
+ # Retrieves the phone dialing code for the neighborhood.
45
+ # Falls back to the city's phone code if the neighborhood's is not set.
46
+ # Assumes `phone` field is provided by `Geopolitocracy`.
47
+ #
48
+ # @return [String, nil] The phone dialing code.
20
49
  def phone
21
- self[:phone] || city.phone
50
+ self[:phone] || city&.phone
22
51
  end
23
52
 
53
+ # Retrieves the primary postal code for the neighborhood.
54
+ # Falls back to the city's postal code if the neighborhood's is not set.
55
+ # Assumes `postal` field is provided by `Geopolitocracy`.
56
+ #
57
+ # @return [String, nil] The postal code.
24
58
  def postal
25
- self[:postal] || city.postal
26
- end
27
-
28
- def as_json(_opts = {})
29
- {
30
- id: id.to_s,
31
- name: name,
32
- city: city
33
- }
59
+ self[:postal] || city&.postal
34
60
  end
35
61
  end
data/app/models/nation.rb CHANGED
@@ -1,42 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Represents a nation or country.
1
5
  #
2
- # Nation/Country/Flag
6
+ # Each nation has a unique abbreviation (`abbr`) which also serves as its `_id`.
7
+ # It includes geopolitical information such as its Geonames ID, top-level domain,
8
+ # currency, ISO 3166-3 code, and languages.
3
9
  #
10
+ # Nations can have a capital city, multiple regions, and multiple cities.
4
11
  class Nation
5
12
  include Mongoid::Document
6
13
  include Geopolitocracy
7
14
 
8
- field :_id, type: String, default: -> { abbr }
15
+ # The unique identifier for the nation, defaults to its abbreviation.
16
+ # The `abbr` is expected to be provided by the Geopolitocracy concern or set explicitly.
17
+ field :_id, type: String, default: -> { abbr }, overwrite: true
9
18
 
10
- field :gid, type: Integer # geonames id
11
- field :tld, type: String # Top level domain
12
- field :cash, type: String # Currency prefix
13
- field :code3, type: String # Iso 3166_3
14
- field :lang, type: String # Official/main language
15
- field :langs, type: Array # All official languages
19
+ # @!attribute [rw] gid
20
+ # @return [Integer] The Geonames identifier for the nation.
21
+ field :gid, type: Integer
22
+ # @!attribute [rw] tld
23
+ # @return [String] The top-level domain (e.g., ".us", ".de").
24
+ field :tld, type: String
25
+ # @!attribute [rw] cash
26
+ # @return [String] The currency symbol or code (e.g., "$", "EUR").
27
+ field :cash, type: String
28
+ # @!attribute [rw] code3
29
+ # @return [String] The ISO 3166-1 alpha-3 code for the country.
30
+ field :code3, type: String
31
+ # @!attribute [rw] langs
32
+ # @return [Array<String>] A list of all official language codes.
33
+ field :langs, type: Array, default: []
16
34
 
35
+ # Alias for the `cash` field.
36
+ # @return [String] The currency symbol or code.
17
37
  alias currency cash
18
- alias iso_3166_3 code3
38
+ # Alias for the `cash=` writer.
39
+ alias currency= cash=
40
+
41
+ # Alias for the `code3` field, representing the ISO 3166-1 alpha-3 code.
42
+ # @return [String] The ISO 3166-1 alpha-3 code.
43
+ # alias iso_3166_3 code3
44
+ # # Alias for the `code3=` writer.
45
+ # alias iso_3166_3= code3=
19
46
 
20
- validates :abbr, uniqueness: true, presence: true
47
+ validates :abbr, presence: true # Name presence is validated in Geopolitocracy
48
+ validates_uniqueness_of :abbr, case_sensitive: false
49
+ validates :slug, uniqueness: { message: 'must be unique' } # Slug presence is validated in Geopolitocracy
21
50
 
22
- belongs_to :capital, inverse_of: :nation_capital, class_name: 'City',
23
- optional: true
51
+ # @!attribute [rw] capital
52
+ # @return [City] The capital city of this nation.
53
+ belongs_to :capital, inverse_of: :nation_governancy, class_name: 'City', optional: true
24
54
 
55
+ # @!attribute [rw] regions
56
+ # @return [Mongoid::Relations::Targets::Enumerable<Region>] The regions within this nation.
25
57
  has_many :regions, dependent: :destroy
58
+ # @!attribute [rw] cities
59
+ # @return [Mongoid::Relations::Targets::Enumerable<City>] The cities within this nation.
26
60
  has_many :cities, dependent: :destroy
27
61
 
28
- index lang: 1
62
+ index({ name: 1 }) # Index for sorting by name, common operation
63
+ index({ slug: 1 }, { unique: true }) # Slugs must be globally unique for nations
64
+ index({ abbr: 1 }, { unique: true }) # abbr is upcased by its writer, so plain unique is case-insensitive
65
+
66
+ # Nation['br'] => the Nation with abbr BR (the abbr is the _id), nil if unknown.
67
+ # @param abbr [String, Symbol]
68
+ # @return [Nation, nil]
69
+ def self.[](abbr) = where(_id: abbr.to_s.upcase).first
70
+
71
+ # A whimsical method indicating the planet.
72
+ # In a more complex system, this might point to a Planet model.
73
+ # @return [Symbol] Always returns `:earth`.
74
+ def planet
75
+ :earth # In a larger cosmological model, this could be `Planet.find_by(name: 'Earth')`
76
+ end
29
77
 
30
- def abbr=(txt)
31
- self[:abbr] = txt && txt.upcase
78
+ # Helper method to retrieve the primary language of the nation.
79
+ # This returns the first language in the `langs` array, if available.
80
+ #
81
+ # @return [String, nil] The primary language code or nil if no languages are set.
82
+ def lang
83
+ langs&.first
32
84
  end
33
85
 
86
+ # Sets the primary language, keeping any other languages the nation speaks.
87
+ # @param value [String, nil]
88
+ def lang=(value)
89
+ rest = langs.to_a.drop(1)
90
+ self.langs = value.present? ? [value.to_s, *rest] : rest
91
+ end
92
+
93
+ # Sets the abbreviation for the nation.
94
+ # The abbreviation is automatically converted to uppercase.
95
+ #
96
+ # @param new_abbr [String] The new abbreviation.
97
+ # @return [String, nil] The uppercased abbreviation or nil if input was nil.
98
+ def abbr=(new_abbr)
99
+ super(new_abbr&.upcase)
100
+ end
101
+
102
+ # Compares this nation with another object for equality.
103
+ # Nations are considered equal if their abbreviations are the same.
104
+ #
105
+ # @param other [Object] The object to compare with.
106
+ # @return [Boolean] True if the other object is a Nation and has the same abbreviation, false otherwise.
34
107
  def ==(other)
35
- return unless other
36
- abbr == other.abbr
108
+ other.is_a?(Nation) && abbr == other.abbr
37
109
  end
38
110
 
111
+ # Compares this nation with another nation for sorting purposes.
112
+ # Comparison is based on the nation's name.
113
+ #
114
+ # @param other [Nation] The other nation to compare with.
115
+ # @return [-1, 0, 1] -1 if self.name < other.name, 0 if equal, 1 if self.name > other.name.
116
+ # @raise [ArgumentError] if other is not a Nation.
39
117
  def <=>(other)
118
+ return nil unless other.is_a?(Nation) # Or raise ArgumentError?
119
+
40
120
  name <=> other.name
41
121
  end
42
122
  end