pilipinas 0.1.2 → 1.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.
data/README.md CHANGED
@@ -1,53 +1,288 @@
1
1
  # Pilipinas
2
2
 
3
- It's a collection of Regions, Provinces, Cities/Municipalities & Barangays within Philippines.
3
+ [![CI](https://github.com/denmarkmeralpis/pilipinas/actions/workflows/ci.yml/badge.svg)](https://github.com/denmarkmeralpis/pilipinas/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/pilipinas.svg)](https://badge.fury.io/rb/pilipinas)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
6
 
5
- It's a file based PH directory. If you want to get data from db, use `gem pinas`.
7
+ A complete, read-only directory of Philippine geographic divisions:
8
+
9
+ ```
10
+ Region → Province → City / Municipality → Barangay
11
+ (17) (86) (1,648) (42,027)
12
+ ```
13
+
14
+ ## Features
15
+
16
+ - **Zero runtime dependencies** — pure Ruby + stdlib Psych (ships with Ruby).
17
+ - **Lazy-loaded & cached** — YAML is parsed once per class per process and held in a thread-safe, process-lifetime cache. Repeated calls are free.
18
+ - **O(1) look-ups** — separate hash indices keyed by code and by name; no linear scans.
19
+ - **Immutable value objects** — every entity instance is frozen. Safe to share across threads and fibers without copying.
20
+ - **Optional ActiveRecord integration** — a migration generator and Rake task seed the four `pilipinas_*` tables from the bundled YAML data.
21
+ - **Read-only by default** — persisted AR model instances raise `ActiveRecord::ReadOnlyRecord` on accidental writes; opt out per-class via `enforce_readonly = false`.
22
+ - **Test helper included** — `require 'pilipinas/testing/rspec'` disables the read-only guard for the entire RSpec suite with one line.
23
+ - **Ruby ≥ 3.4** required; developed against Ruby 4.0.
24
+
25
+ ---
6
26
 
7
27
  ## Installation
8
28
 
9
29
  Add this line to your application's Gemfile:
10
30
 
11
31
  ```ruby
12
- gem 'pilipinas'
32
+ gem "pilipinas"
13
33
  ```
14
34
 
15
- And then execute:
35
+ Then run:
36
+
37
+ ```sh
38
+ bundle install
39
+ ```
16
40
 
17
- $ bundle
41
+ Or install directly:
18
42
 
19
- Or install it yourself as:
43
+ ```sh
44
+ gem install pilipinas
45
+ ```
20
46
 
21
- $ gem install pilipinas
47
+ ---
22
48
 
23
49
  ## Usage
24
50
 
51
+ ### Require
52
+
25
53
  ```ruby
26
- # All Regions
27
- Pilipinas::Region.all
54
+ require "pilipinas"
55
+ ```
28
56
 
29
- # All Provinces
30
- Pilipinas::Province.all
57
+ Rails applications load the gem automatically via Bundler.
31
58
 
32
- # All Cities/Municipalities
33
- Pilipinas::City.all
59
+ ---
60
+
61
+ ### Regions
62
+
63
+ ```ruby
64
+ # All 17 regions (returns a frozen Array)
65
+ Pilipinas::Region.all # => [#<Region code="1" name="NCR…">, …]
66
+ Pilipinas::Region.count # => 17
67
+ Pilipinas::Region.first # => #<Region …>
68
+ Pilipinas::Region.last # => #<Region …>
34
69
 
35
- # All Barangays
36
- Pilipinas::Barangay.all
70
+ # Find by code (O(1), case-insensitive)
71
+ Pilipinas::Region.find_by(code: "17744")
72
+ Pilipinas::Region.find_by_code("17744")
37
73
 
38
- # Finding record thru find_by_(code/name) method
39
- region = Pilipinas::Region.find_by_name("REGION V (Bicol Region)")
74
+ # Find by name (O(1), case-insensitive)
75
+ Pilipinas::Region.find_by(name: "REGION V (Bicol Region)")
76
+ Pilipinas::Region.find_by_name("region v (bicol region)") # same result
40
77
 
41
- # Get provinces by region
42
- region.provinces
78
+ # Traverse down the hierarchy
79
+ region = Pilipinas::Region.find_by(name: "REGION V (Bicol Region)")
80
+ region.provinces # => [#<Province …>, …]
43
81
  ```
44
- ## Acknowledgement
45
82
 
46
- The data used in this gem is from `gem pinas`. Kudos!
83
+ ---
84
+
85
+ ### Provinces
86
+
87
+ ```ruby
88
+ Pilipinas::Province.all # => Array of 86 Province objects
89
+ Pilipinas::Province.count # => 86
90
+
91
+ province = Pilipinas::Province.find_by(name: "CAMARINES SUR")
92
+ province.cities # => Array of City objects
93
+ ```
94
+
95
+ ---
96
+
97
+ ### Cities / Municipalities
98
+
99
+ ```ruby
100
+ Pilipinas::City.all # => Array of 1,648 City objects
101
+ Pilipinas::City.count # => 1648
102
+
103
+ city = Pilipinas::City.find_by(name: "NAGA CITY")
104
+ city.barangays # => Array of Barangay objects
105
+ ```
106
+
107
+ ---
108
+
109
+ ### Barangays
110
+
111
+ ```ruby
112
+ Pilipinas::Barangay.all # => Array of 42,027 Barangay objects
113
+ Pilipinas::Barangay.count # => 42027
114
+
115
+ Pilipinas::Barangay.find_by(code: "21687")
116
+ Pilipinas::Barangay.find_by(name: "Casay")
117
+ ```
118
+
119
+ ---
120
+
121
+ ### Entity interface
122
+
123
+ Every entity object exposes:
124
+
125
+ | Method | Returns | Description |
126
+ |------------|----------|-------------------------------------|
127
+ | `#code` | `String` | Unique geographic code |
128
+ | `#name` | `String` | Human-readable name |
129
+ | `#to_s` | `String` | `"Region(code: 1, name: NCR…)"` |
130
+ | `#inspect` | `String` | `#<Region code="1" name="NCR…">` |
131
+ | `#==` | `Boolean`| Equality by class + code |
132
+ | `#frozen?` | `true` | Always true — instances are frozen |
133
+
134
+ ---
135
+
136
+ ### Supported find_by attributes
137
+
138
+ Both `find_by(attr: value)` and `find_by_attr(value)` accept:
139
+
140
+ | Attribute | Notes |
141
+ |-----------|------------------------|
142
+ | `:code` | Geographic code string |
143
+ | `:name` | Human-readable name |
144
+
145
+ Any other attribute raises `Pilipinas::UnknownAttribute`.
146
+
147
+ ---
148
+
149
+ ### Error classes
150
+
151
+ | Class | Superclass | Raised when |
152
+ |-------------------------------|--------------------|--------------------------------------|
153
+ | `Pilipinas::Error` | `StandardError` | Base class for all Pilipinas errors |
154
+ | `Pilipinas::UnknownAttribute` | `Pilipinas::Error` | Unsupported attribute in `find_by_*` |
155
+
156
+ ---
157
+
158
+ ## Rails / ActiveRecord integration (optional)
159
+
160
+ The gem ships with an optional database back-end for applications that prefer SQL queries over in-memory look-ups.
161
+
162
+ ### 1. Generate the migration
163
+
164
+ ```sh
165
+ rails generate pilipinas:migration
166
+ rails db:migrate
167
+ ```
168
+
169
+ This creates four tables: `pilipinas_regions`, `pilipinas_provinces`, `pilipinas_cities`, `pilipinas_barangays`.
170
+
171
+ ### 2. Seed the tables
172
+
173
+ ```sh
174
+ rake pilipinas:load
175
+ ```
47
176
 
48
- ## TODO
177
+ ### 3. Use the AR models
49
178
 
50
- * Add a form helper
179
+ ```ruby
180
+ region = Pilipinas::Db::Region.find_by(code: "17744")
181
+ region.provinces.count # ActiveRecord query
182
+
183
+ province = Pilipinas::Db::Province.find_by(name: "CAMARINES SUR")
184
+ province.cities # has_many association
185
+ ```
186
+
187
+ > **Note:** The AR models are auto-loaded and require `activerecord` to be available.
188
+
189
+ ### Read-only behaviour
190
+
191
+ All four `Pilipinas::Db::*` models are **read-only by default**. Any attempt to call `update!`, `save`, or `destroy` on a persisted record raises `ActiveRecord::ReadOnlyRecord`. This is intentional — the pilipinas tables are static reference data that should never be mutated after seeding.
192
+
193
+ New (unsaved) records are always writable, so `create!` works normally in the Loader and in test factories.
194
+
195
+ #### Opting a subclass out of read-only enforcement
196
+
197
+ If your application inherits from a Pilipinas DB model and legitimately needs write access, set `enforce_readonly` to `false` on the subclass:
198
+
199
+ ```ruby
200
+ class Locations::Barangay < Pilipinas::Db::Barangay
201
+ self.enforce_readonly = false
202
+ end
203
+ ```
204
+
205
+ This does not affect the parent class or any other model.
206
+
207
+ ---
208
+
209
+ ## Testing
210
+
211
+ The gem ships a ready-made RSpec helper that turns off the read-only guard for the entire test suite — no stubbing required.
212
+
213
+ ```ruby
214
+ # spec/rails_helper.rb (or spec/support/pilipinas.rb)
215
+ require 'pilipinas/testing/rspec'
216
+ ```
217
+
218
+ This sets `enforce_readonly = false` on all four models (`Region`, `Province`, `City`, `Barangay`) inside a `before(:suite)` hook, so FactoryBot factories, fixtures, and any spec that writes to pilipinas tables work without extra setup.
219
+
220
+ If you only need writable records in a specific context:
221
+
222
+ ```ruby
223
+ around do |example|
224
+ Locations::Barangay.enforce_readonly = false
225
+ example.run
226
+ ensure
227
+ Locations::Barangay.enforce_readonly = true
228
+ end
229
+ ```
230
+
231
+ ---
232
+
233
+ ## Advanced
234
+
235
+ ### Cache management
236
+
237
+ The in-memory cache is global to the process. In long-running processes the data never reloads (intentional — YAML files are static). To force a reload (e.g. in tests):
238
+
239
+ ```ruby
240
+ Pilipinas::Cache.clear
241
+ ```
242
+
243
+ ### Thread-safety
244
+
245
+ All class-level state is initialised through `Pilipinas::Cache`, which uses a `Mutex` with double-checked locking. Entity instances are frozen. The gem is safe to use in multi-threaded applications (Puma, Sidekiq, etc.) without additional synchronisation.
246
+
247
+ ---
248
+
249
+ ## Development
250
+
251
+ ```sh
252
+ git clone https://github.com/denmarkmeralpis/pilipinas
253
+ cd pilipinas
254
+ bin/setup # install dependencies
255
+ bundle exec rake # run the full test suite
256
+ bin/console # start an IRB session with the gem loaded
257
+ ```
258
+
259
+ ### Running tests
260
+
261
+ ```sh
262
+ bundle exec rspec # all specs
263
+ bundle exec rspec spec/pilipinas/ # unit specs only
264
+ bundle exec rubocop # lint
265
+ ```
266
+
267
+ ---
268
+
269
+ ## Contributing
270
+
271
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/denmarkmeralpis/pilipinas).
272
+
273
+ Please read [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) before contributing.
274
+
275
+ ---
276
+
277
+ ## License
278
+
279
+ The gem is available as open source under the terms of the [MIT License](LICENSE).
280
+
281
+ ---
282
+
283
+ ## Acknowledgements
284
+
285
+ The data used in this gem is from `gem pinas`. Kudos!
51
286
 
52
287
  ## Development
53
288
 
data/Rakefile CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'bundler/gem_tasks'
2
4
  require 'rspec/core/rake_task'
3
5
 
data/bin/console CHANGED
@@ -1,14 +1,17 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
- require 'bundler/setup'
4
- require 'pilipinas'
4
+ require "bundler/setup"
5
+ require "pilipinas"
5
6
 
6
- # You can add fixtures and/or initialization code here to make experimenting
7
- # with your gem easier. You can also use a different console, if you like.
7
+ # Convenience aliases for interactive exploration:
8
+ #
9
+ # Region.all
10
+ # Province.find_by(name: "CAMARINES SUR")
11
+ # City.find_by_code("18817").barangays
8
12
 
9
- # (If you use this, don't forget to add pry to your Gemfile!)
10
- # require "pry"
11
- # Pry.start
13
+ include Pilipinas # rubocop:disable Style/MixinUsage
12
14
 
13
- require 'irb'
15
+ require "irb"
14
16
  IRB.start(__FILE__)
17
+
@@ -1,31 +1,36 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'rails/generators/base'
2
4
  require 'rails/generators/active_record'
3
5
 
4
6
  module Pilipinas
7
+ # Rails generator that creates the four pilipinas_* database tables.
8
+ #
9
+ # @example
10
+ # rails generate pilipinas:migration
11
+ #
5
12
  class MigrationGenerator < Rails::Generators::Base
6
13
  include Rails::Generators::Migration
7
14
 
8
- source_root File.expand_path('../', __dir__)
15
+ source_root File.expand_path('..', __dir__)
9
16
 
10
17
  def generate_migration
11
- generate_block_migration
18
+ migration_template 'templates/migration.rb', 'db/migrate/create_pilipinas_locations.rb'
12
19
  end
13
20
 
21
+ # Returns a timestamp-based migration number required by the Rails
22
+ # migration DSL.
23
+ #
24
+ # @param _dir [String] unused (required by the interface)
25
+ # @return [String]
14
26
  def self.next_migration_number(_dir)
15
27
  Time.now.utc.strftime('%Y%m%d%H%M%S')
16
28
  end
17
29
 
18
30
  private
19
31
 
20
- def generate_block_migration
21
- migration_template 'templates/migration.rb', 'db/migrate/create_locations.rb'
22
- end
23
-
32
+ # @return [String, nil] migration version bracket, e.g. "[8.0]"
24
33
  def migration_version
25
- formatted_version if ActiveRecord::VERSION::MAJOR.to_i >= 5
26
- end
27
-
28
- def formatted_version
29
34
  "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
30
35
  end
31
36
  end
@@ -1,14 +1,14 @@
1
- class CreateLocations < ActiveRecord::Migration<%= migration_version %>
1
+ class CreatePilipinasLocations < ActiveRecord::Migration<%= migration_version %>
2
2
  def change
3
3
  create_table :pilipinas_regions do |t|
4
4
  t.bigint :location_id
5
5
  t.integer :lft
6
6
  t.integer :rgt
7
- t.string :code
8
- t.string :name
7
+ t.string :code, null: false
8
+ t.string :name, null: false
9
9
  t.string :longitude
10
10
  t.string :latitude
11
- t.timestamps default: Time.now
11
+ t.timestamps null: false
12
12
  end
13
13
 
14
14
  create_table :pilipinas_provinces do |t|
@@ -16,11 +16,11 @@ class CreateLocations < ActiveRecord::Migration<%= migration_version %>
16
16
  t.bigint :parent_id
17
17
  t.integer :lft
18
18
  t.integer :rgt
19
- t.string :code
20
- t.string :name
19
+ t.string :code, null: false
20
+ t.string :name, null: false
21
21
  t.string :longitude
22
22
  t.string :latitude
23
- t.timestamps default: Time.now
23
+ t.timestamps null: false
24
24
  end
25
25
 
26
26
  create_table :pilipinas_cities do |t|
@@ -28,15 +28,15 @@ class CreateLocations < ActiveRecord::Migration<%= migration_version %>
28
28
  t.bigint :parent_id
29
29
  t.integer :lft
30
30
  t.integer :rgt
31
- t.string :code
32
- t.string :name
33
- t.boolean :city, default: false
31
+ t.string :code, null: false
32
+ t.string :name, null: false
33
+ t.boolean :city, default: false, null: false
34
34
  t.string :income_class
35
35
  t.string :urban_rural
36
36
  t.string :district
37
37
  t.string :longitude
38
38
  t.string :latitude
39
- t.timestamps default: Time.now
39
+ t.timestamps null: false
40
40
  end
41
41
 
42
42
  create_table :pilipinas_barangays do |t|
@@ -44,29 +44,37 @@ class CreateLocations < ActiveRecord::Migration<%= migration_version %>
44
44
  t.bigint :parent_id
45
45
  t.integer :lft
46
46
  t.integer :rgt
47
- t.string :code
48
- t.string :name
47
+ t.string :code, null: false
48
+ t.string :name, null: false
49
49
  t.string :urban_rural
50
- t.timestamps default: Time.now
50
+ t.timestamps null: false
51
51
  end
52
52
 
53
- add_index :pilipinas_regions, :location_id, unique: true
54
- add_index :pilipinas_regions, :code
55
- add_index :pilipinas_regions, :rgt
53
+ add_index :pilipinas_regions, :location_id, unique: true
54
+ add_index :pilipinas_regions, :code, unique: true
55
+ # Composite (lft, rgt) supports nested-set descendant range queries:
56
+ # WHERE lft >= X AND rgt <= Y — a single rgt index cannot satisfy this.
57
+ add_index :pilipinas_regions, %i[lft rgt], name: 'idx_pilipinas_regions_lft_rgt'
58
+ # Functional index lets LOWER(name) = LOWER(?) use the index (PostgreSQL).
59
+ add_index :pilipinas_regions, 'LOWER(name)', name: 'idx_pilipinas_regions_lower_name'
56
60
 
57
61
  add_index :pilipinas_provinces, :location_id, unique: true
58
- add_index :pilipinas_provinces, :code
62
+ add_index :pilipinas_provinces, :code, unique: true
59
63
  add_index :pilipinas_provinces, :parent_id
60
- add_index :pilipinas_provinces, :rgt
64
+ add_index :pilipinas_provinces, %i[lft rgt], name: 'idx_pilipinas_provinces_lft_rgt'
65
+ add_index :pilipinas_provinces, 'LOWER(name)', name: 'idx_pilipinas_provinces_lower_name'
61
66
 
62
- add_index :pilipinas_cities, :location_id, unique: true
63
- add_index :pilipinas_cities, :code
64
- add_index :pilipinas_cities, :parent_id
65
- add_index :pilipinas_cities, :rgt
67
+ add_index :pilipinas_cities, :location_id, unique: true
68
+ add_index :pilipinas_cities, :code, unique: true
69
+ add_index :pilipinas_cities, :parent_id
70
+ add_index :pilipinas_cities, %i[lft rgt], name: 'idx_pilipinas_cities_lft_rgt'
71
+ add_index :pilipinas_cities, 'LOWER(name)', name: 'idx_pilipinas_cities_lower_name'
66
72
 
67
73
  add_index :pilipinas_barangays, :location_id, unique: true
68
- add_index :pilipinas_barangays, :code
74
+ add_index :pilipinas_barangays, :code, unique: true
69
75
  add_index :pilipinas_barangays, :parent_id
70
- add_index :pilipinas_barangays, :rgt
76
+ add_index :pilipinas_barangays, %i[lft rgt], name: 'idx_pilipinas_barangays_lft_rgt'
77
+ add_index :pilipinas_barangays, 'LOWER(name)', name: 'idx_pilipinas_barangays_lower_name'
71
78
  end
72
79
  end
80
+
@@ -1,8 +1,22 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Pilipinas
4
+ # Represents a barangay (the smallest administrative division) of the
5
+ # Philippines.
6
+ #
7
+ # Barangays belong to a {City} or municipality.
8
+ #
9
+ # @example
10
+ # Pilipinas::Barangay.count # => 42_027
11
+ # Pilipinas::Barangay.find_by(name: "Casay")
12
+ #
2
13
  class Barangay < Base
3
14
  class << self
4
- def load_data
5
- load_file(File.join(File.dirname(__FILE__), '..', 'data', 'barangays.yml'))
15
+ private
16
+
17
+ # @return [String] absolute path to the barangays YAML file
18
+ def data_file
19
+ File.join(Pilipinas::DATA_DIR, 'barangays.yml')
6
20
  end
7
21
  end
8
22
  end