usa 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5cf7ef01b60f4c0bc0cc59c9e565b999ee8e705c4435e548f61e5b5b187fc545
4
+ data.tar.gz: 4e7153ac68f27f05cfe8865ea4b227d1857792be082d65808794379048906d50
5
+ SHA512:
6
+ metadata.gz: 69b120cbcbc243dd1d840426c6a38f760c0f77b6a5e11dff1e571330ecae47a80c76397b79b6827a6dcdaa87821060750d7c26591a75611367cbf63f8b99cb10
7
+ data.tar.gz: b5ce850cfe280fe816e740952441583e6936b96599e772c1c14b571e8ad733ffe2c971aa131dc955d3b9000fb4fda43b30a5d80b85c64cfd8896040fe5430bf0
data/CHANGELOG.md ADDED
@@ -0,0 +1,38 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ For more information about changelogs, check [Keep a Changelog](http://keepachangelog.com) and
6
+ [Vandamme](http://tech-angels.github.io/vandamme).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## 0.1.0 - 2026-09-14
11
+
12
+ * [Feature] `USA::State`, `USA::County` and `USA::ZIP`, on `usa_states`, `usa_counties` and
13
+ `usa_zips`: the three tables three apps were each keeping, each with a CSV and a backfill of
14
+ its own. A state has a code, a FIPS and a name; a county a FIPS, a name and a state; a ZIP a
15
+ code, the city it is addressed as, a time zone named as Rails names one, and one county
16
+ * [Feature] `USA::City` on `usa_cities`, and `USA::CityCounty` on `usa_city_counties`: a city
17
+ belongs to one state and lies in one or more counties, so `city.counties` and `county.cities`
18
+ both answer. The place FIPS is unique within a state rather than nationally, which is what the
19
+ index says
20
+ * [Feature] `google_place_id` on all four, filled for every county and blank elsewhere until a
21
+ release fills it -- which is a reason `db:usa:seed` exists
22
+ * [Feature] `bin/rails g usa:install`, which copies the six migrations into the host under
23
+ timestamps of its own, and writes nothing else: there is nothing here to configure
24
+ * [Feature] `USA.seed`, and a `seed` on each model: every write is an upsert keyed on the code
25
+ or the FIPS, so a second run inserts what is missing, leaves the id of every row a host
26
+ already had, and does not touch the `updated_at` of a row that did not change
27
+ * [Feature] `bin/rails db:usa:seed`, which is how a database made from `db/schema.rb` gets its
28
+ rows -- a dump carries none -- and how one catches up with a release that added some
29
+ * [Feature] `usa_states.counties_count` and `usa_counties.zips_count`, counted from the rows
30
+ after a seed, since an upsert runs no callback, and only where the count moved
31
+ * [Feature] The acronyms `USA`, `ZIP` and `FIPS`, registered before the engine loads, so
32
+ `USA::ZIP` is the class, `usa_zips` the table and 'ZIP' the heading
33
+ * [Feature] A load hook per model -- `ActiveSupport.on_load(:usa_zip) { ... }` -- and
34
+ `:usa_record`, where a host says how these tables connect
35
+
36
+ The data is the current Census vintage: the counties include Connecticut's nine planning
37
+ regions and Alaska's current census areas, and the places come from the 2025 Gazetteer rather
38
+ than the 2010 list one of the three apps was still carrying.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 HouseAccount
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # USA
2
+
3
+ Every state, county, city and ZIP code of the United States, as five tables a Rails app joins
4
+ to. One copy, held here, rather than a CSV and a backfill in each app that needs an address to
5
+ mean something.
6
+
7
+ ## How to install
8
+
9
+ To install on your system, run
10
+
11
+ gem install usa
12
+
13
+ To use inside a bundled Ruby project, add this line to the `Gemfile`:
14
+
15
+ gem 'usa', '~> 0.1.0'
16
+
17
+ Below 1.0 the pin stops at the next minor rather than the next major, because that is where a
18
+ breaking change may still land. It becomes `~> 1.0` once the API is settled on purpose.
19
+
20
+ ## Getting the tables
21
+
22
+ ```sh
23
+ bin/rails g usa:install # six migrations, copied into your app and yours to keep
24
+ bin/rails db:migrate # the five tables, and every row in them
25
+ ```
26
+
27
+ The last one takes about a minute: it writes 51 states, 3,144 counties, some 32,000 cities and
28
+ some 41,000 ZIPs.
29
+
30
+ **A database made from `db/schema.rb` has the tables and none of the rows.** A dump carries no
31
+ data, so `db:schema:load`, `db:test:prepare` and a fresh clone all leave the five tables empty.
32
+ Run `bin/rails db:usa:seed`, or call `USA.seed` from your own `db/seeds.rb`. The same command is
33
+ how a database catches up with a release that added rows: every write is an upsert keyed on the
34
+ code or the FIPS, so it inserts what is missing, updates what this gem owns, and leaves the id
35
+ of every row you already had exactly where it was.
36
+
37
+ ## What you get
38
+
39
+ ```ruby
40
+ zip = USA::ZIP.find_by code: '90210' # => #<USA::ZIP>
41
+ zip.city # => 'Beverly Hills'
42
+ zip.time_zone # => 'Pacific Time (US & Canada)'
43
+ zip.county # => #<USA::County>
44
+ zip.county.fips # => '06037'
45
+ zip.county.state.code # => 'CA'
46
+
47
+ county = zip.county
48
+ county.zips_count # => 508
49
+ county.cities # => [#<USA::City>, ...]
50
+
51
+ state = county.state
52
+ state.counties_count # => 58
53
+ state.counties # => [#<USA::County>, ...]
54
+ state.cities # => [#<USA::City>, ...]
55
+ ```
56
+
57
+ A state has a `code`, a `fips` and a `name`. A county has a `fips`, a `name` and a state. A city
58
+ has a `fips` -- a Census place code, unique within its state rather than nationally -- a `name`,
59
+ a state, and the one or more counties it lies in, since a city may cross a county line. A ZIP has
60
+ a `code`, the `city` it is addressed as, a `time_zone` named as Rails names one, and one county.
61
+ Every one of the four also has a `google_place_id`, filled for counties and blank elsewhere until
62
+ a release fills it.
63
+
64
+ Two counter caches are kept by the seed rather than by a callback: `usa_states.counties_count`
65
+ and `usa_counties.zips_count`.
66
+
67
+ ## Making them yours
68
+
69
+ Each model runs a load hook, so an app adds to it without reopening a file it does not own:
70
+
71
+ ```ruby
72
+ # config/initializers/usa.rb
73
+ ActiveSupport.on_load(:usa_zip) do
74
+ has_many :bookings, dependent: :destroy
75
+
76
+ scope :served, -> { where.not markets_count: 0 }
77
+ end
78
+ ```
79
+
80
+ `:usa_state`, `:usa_county`, `:usa_city` and `:usa_zip` are the four, and `:usa_record` is where
81
+ an app says how all of them connect -- a reading role, say, which these models otherwise know
82
+ nothing about:
83
+
84
+ ```ruby
85
+ ActiveSupport.on_load(:usa_record) { connects_to database: { writing: :primary, reading: :reader } }
86
+ ```
87
+
88
+ Columns of your own go on these tables in a migration of your own. They survive every seed: this
89
+ gem writes only the columns it ships.
90
+
91
+ ## Adopting it in an app that already has these tables
92
+
93
+ The gem registers `USA`, `ZIP` and `FIPS` as acronyms, so a class of yours called `Zip` stops
94
+ being found the day you install it -- rename it, or reach `USA::ZIP` instead. Point your foreign
95
+ keys at `usa_zips.id` rather than at a `zips` of your own, and where your rows were loaded with
96
+ explicit ids, run `setval` on the sequence before the first seed, or the first insert collides.
97
+
98
+ The gem does not support an app that sets `ActiveRecord::Base.table_name_prefix`: its migrations
99
+ name `usa_states` while the models would look for the prefix and yours together.
100
+
101
+ ## Development
102
+
103
+ `bin/setup` gets a clone working, `bin/console` opens a prompt with the library loaded, and
104
+ `bundle exec rake` runs the suite, the linter and the two size limits. The dummy app under
105
+ `test/dummy` is SQLite on purpose: it is a fixture rather than an app, and running the seed's
106
+ upserts and the counter statements on a second adapter, with no server to start, is worth more
107
+ than resembling the apps that install this.
108
+
109
+ `bin/geocode` is the maintainer's, not the gem's: it fills the blank place ids in one CSV from
110
+ the Google Geocoding API and is not packaged.
111
+
112
+ ## Reference
113
+
114
+ The API reference is built from what RubyGems holds, at
115
+ [rubydoc.info/gems/usa](https://rubydoc.info/gems/usa). The source is at
116
+ [github.com/claudiob/usa](https://github.com/claudiob/usa).
117
+
118
+ ## License
119
+
120
+ MIT, see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,22 @@
1
+ # What every table this gem ships knows how to do: write its own rows, twice over if asked.
2
+ module USA::Seeded extend ActiveSupport::Concern
3
+ # Rows per statement. An upsert inlines its values rather than binding them, so this bounds
4
+ # how long one statement runs and how many rows are held at once, not a placeholder count.
5
+ SLICE = 1000
6
+
7
+ class_methods do
8
+ # Writes the rows this release holds that are missing, and the columns of the rest.
9
+ # @return [void]
10
+ def seed
11
+ seeds.each_slice(SLICE) do |slice|
12
+ upsert_all slice, unique_by: natural_key, record_timestamps: true
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def csv(name)
19
+ CSV.foreach USA::Engine.root.join("db/seeds/#{name}.csv"), headers: true
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,32 @@
1
+ # A city, town or census designated place: what a street address is addressed to.
2
+ class USA::City < USA::Record
3
+ include USA::Seeded
4
+
5
+ belongs_to :state
6
+
7
+ has_many :city_counties, dependent: :delete_all
8
+ has_many :counties, through: :city_counties
9
+
10
+ validates :fips, presence: true, length: { is: 5 },
11
+ uniqueness: { scope: :state_id }
12
+ validates :name, presence: true
13
+
14
+ # @return [String] the default representation (used in views).
15
+ def to_s = "#{name} (#{state.code})"
16
+
17
+ class << self
18
+ private
19
+
20
+ def natural_key = %i[state_id fips]
21
+
22
+ def seeds
23
+ states = USA::State.pluck(:code, :id).to_h
24
+ csv('cities').lazy.map do |row|
25
+ { fips: row['fips'], name: row['name'], state_id: states.fetch(row['state']),
26
+ google_place_id: row['google_place_id'], }
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ ActiveSupport.run_load_hooks :usa_city, USA::City
@@ -0,0 +1,23 @@
1
+ # Where a city lies: one row per county it reaches into, since a city may span several.
2
+ class USA::CityCounty < USA::Record
3
+ include USA::Seeded
4
+
5
+ belongs_to :city
6
+ belongs_to :county
7
+
8
+ class << self
9
+ private
10
+
11
+ def natural_key = %i[city_id county_id]
12
+
13
+ def seeds
14
+ cities = USA::City.joins(:state).pluck('usa_states.code', :fips, 'usa_cities.id').
15
+ to_h { |code, fips, id| [ [ code, fips ], id ] }
16
+ counties = USA::County.pluck(:fips, :id).to_h
17
+ csv('cities').lazy.flat_map do |row|
18
+ city_id = cities.fetch [ row['state'], row['fips'] ]
19
+ row['counties'].split.map { |fips| { city_id:, county_id: counties.fetch(fips) } }
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,42 @@
1
+ # A county, parish, borough or census area: the division every ZIP is counted under.
2
+ class USA::County < USA::Record
3
+ include USA::Seeded
4
+
5
+ belongs_to :state, counter_cache: true
6
+
7
+ has_many :city_counties, dependent: :delete_all
8
+ has_many :cities, through: :city_counties
9
+ has_many :zips, dependent: :destroy
10
+
11
+ validates :fips, presence: true, length: { is: 5 }, uniqueness: true
12
+ validates :name, presence: true
13
+
14
+ # How many ZIPs a county holds, asked of the rows: an upsert runs no callback to keep it.
15
+ ZIPS = 'SELECT COUNT(*) FROM usa_zips WHERE usa_zips.county_id = usa_counties.id'
16
+
17
+ # Counts the ZIPs of every county the count is wrong for, and touches none of the others.
18
+ # @return [void]
19
+ def self.recount_zips
20
+ where("zips_count <> (#{ZIPS})").
21
+ update_all [ "zips_count = (#{ZIPS}), updated_at = ?", Time.current ]
22
+ end
23
+
24
+ # @return [String] the default representation (used in views).
25
+ def to_s = "#{name} (#{state.code})"
26
+
27
+ class << self
28
+ private
29
+
30
+ def natural_key = :fips
31
+
32
+ def seeds
33
+ states = USA::State.pluck(:code, :id).to_h
34
+ csv('counties').lazy.map do |row|
35
+ { fips: row['fips'], name: row['name'], state_id: states.fetch(row['state']),
36
+ google_place_id: row['google_place_id'], }
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ ActiveSupport.run_load_hooks :usa_county, USA::County
@@ -0,0 +1,6 @@
1
+ # What every record this gem holds descends from, so a host says in one line how they connect.
2
+ class USA::Record < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+
6
+ ActiveSupport.run_load_hooks :usa_record, USA::Record
@@ -0,0 +1,39 @@
1
+ # A state of the United States, or the District of Columbia.
2
+ class USA::State < USA::Record
3
+ include USA::Seeded
4
+
5
+ has_many :counties, dependent: :destroy
6
+ has_many :cities, dependent: :destroy
7
+
8
+ validates :code, presence: true, length: { is: 2 }, uniqueness: true
9
+ validates :fips, presence: true, length: { is: 2 }, uniqueness: true
10
+ validates :name, presence: true, uniqueness: true
11
+
12
+ # How many counties a state holds, asked of the rows: an upsert runs no callback to keep it.
13
+ COUNTIES = 'SELECT COUNT(*) FROM usa_counties WHERE usa_counties.state_id = usa_states.id'
14
+
15
+ # Counts the counties of every state the count is wrong for, and touches none of the others.
16
+ # @return [void]
17
+ def self.recount_counties
18
+ where("counties_count <> (#{COUNTIES})").
19
+ update_all [ "counties_count = (#{COUNTIES}), updated_at = ?", Time.current ]
20
+ end
21
+
22
+ # @return [String] the default representation (used in views).
23
+ def to_s = name
24
+
25
+ class << self
26
+ private
27
+
28
+ def natural_key = :code
29
+
30
+ def seeds
31
+ csv('states').lazy.map do |row|
32
+ { code: row['code'], fips: row['fips'], name: row['name'],
33
+ google_place_id: row['google_place_id'], }
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ ActiveSupport.run_load_hooks :usa_state, USA::State
@@ -0,0 +1,29 @@
1
+ # A ZIP code, and the city, time zone and county the Postal Service delivers it in.
2
+ class USA::ZIP < USA::Record
3
+ include USA::Seeded
4
+
5
+ belongs_to :county, counter_cache: true
6
+
7
+ validates :code, presence: true, length: { is: 5 }, uniqueness: true
8
+ validates :city, presence: true
9
+ validates :time_zone, inclusion: { in: ActiveSupport::TimeZone.us_zones.map(&:name) }
10
+
11
+ # @return [String] the default representation (used in views).
12
+ def to_s = code
13
+
14
+ class << self
15
+ private
16
+
17
+ def natural_key = :code
18
+
19
+ def seeds
20
+ counties = USA::County.pluck(:fips, :id).to_h
21
+ csv('zips').lazy.map do |row|
22
+ { code: row['code'], city: row['city'], time_zone: row['time_zone'],
23
+ county_id: counties.fetch(row['county']), google_place_id: row['google_place_id'], }
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ ActiveSupport.run_load_hooks :usa_zip, USA::ZIP
@@ -0,0 +1,12 @@
1
+ class CreateUSAStates < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :usa_states do |t|
4
+ t.string :code, limit: 2, null: false, index: { unique: true }
5
+ t.string :fips, limit: 2, null: false, index: { unique: true }
6
+ t.string :name, null: false, index: { unique: true }
7
+ t.integer :counties_count, default: 0, null: false
8
+ t.string :google_place_id
9
+ t.timestamps
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ class CreateUSACounties < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :usa_counties do |t|
4
+ t.string :fips, limit: 5, null: false, index: { unique: true }
5
+ t.string :name, null: false, index: true
6
+ t.references :state, null: false, foreign_key: { to_table: :usa_states }
7
+ t.integer :zips_count, default: 0, null: false
8
+ t.string :google_place_id
9
+ t.timestamps
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ class CreateUSAZips < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :usa_zips do |t|
4
+ t.string :code, limit: 5, null: false, index: { unique: true }
5
+ t.string :city, null: false, index: true
6
+ t.string :time_zone, null: false
7
+ t.references :county, null: false, foreign_key: { to_table: :usa_counties }
8
+ t.string :google_place_id
9
+ t.timestamps
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ class CreateUSACities < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :usa_cities do |t|
4
+ t.string :fips, limit: 5, null: false
5
+ t.string :name, null: false, index: true
6
+ t.references :state, null: false, index: false, foreign_key: { to_table: :usa_states }
7
+ t.string :google_place_id
8
+ t.timestamps
9
+ t.index %i[state_id fips], unique: true
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,9 @@
1
+ class CreateUSACityCounties < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :usa_city_counties, id: false do |t|
4
+ t.references :city, null: false, index: false, foreign_key: { to_table: :usa_cities }
5
+ t.references :county, null: false, foreign_key: { to_table: :usa_counties }
6
+ t.index %i[city_id county_id], unique: true
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ class SeedUSA < ActiveRecord::Migration[8.1]
2
+ def change
3
+ up_only { USA.seed }
4
+ end
5
+ end