From c1221ae46a86349bae5415affb4f43011790abcd Mon Sep 17 00:00:00 2001 From: Sascha Skulima <218423+Skulli@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:11:38 +0200 Subject: [PATCH 1/5] chg:dev: optimize performance, modernize gem, improve docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - codes() uses O(1) reverse city index instead of O(n) full scan - YAML.load_file with permitted_classes replaces File.open leak - like() uses start_with? for clarity and speed - Tests erweitert: 6 → 13 Specs (nil-lookup, edge cases, like-search) - README auf Deutsch übersetzt, Datenfelder dokumentiert - gemspec: Autor, Homepage, Ruby >= 3.0, Bundler >= 2.0 aktualisiert - Rakefile: US-convert-Task durch DE-Converter ersetzt - CLAUDE.md ergänzt mit Projektstruktur und Datenpflege-Doku - Alle Gems auf aktuelle Versionen aktualisiert Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 74 +++++++++++++++++++++++++++++++++++++ Gemfile.lock | 26 ++++++------- README.md | 74 ++++++++++++++++++++++++++++--------- Rakefile | 17 +++------ lib/zip-codes.rb | 21 +++++++---- spec/lib/zip-codes_spec.rb | 76 ++++++++++++++++++++++++++++++++------ zip-codes.gemspec | 17 ++++----- 7 files changed, 233 insertions(+), 72 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9a391df --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,74 @@ +# CLAUDE.md — ruby_german_zip_codes + +## Was dieses Projekt ist + +Ruby-Gem für die Suche nach deutschen Postleitzahlen (PLZ). Gibt zu einer PLZ Stadt, Landkreis/Bezirk und Bundesland zurück. Kein US-Support — das ist ein Fork des ursprünglichen `zip-codes`-Gems, der ausschließlich auf Deutschland fokussiert ist. + +## Projektstruktur + +``` +lib/ + zip-codes.rb # Haupt-Einstiegspunkt, öffentliche API + zip-codes/ + city_map.rb # Datenstruktur für den PLZ-Hash (YAML ↔ Ruby) + converter.rb # CSV → YAML Konverter (für Datenpflege) + version.rb # VERSION-Konstante + data/ + DE.yml # Produktiv-Datenbank (~8.000 PLZ, ~1 MB) + de.csv # Quelldaten (OSM-Export, Basis für DE.yml) + DE_old.yml # Veraltete Version – kann entfernt werden + US.yml # Nicht genutzt – Überbleibsel vom Original-Fork +``` + +## Datenformat + +`DE.yml` enthält einen Hash mit PLZ-String als Key: + +```yaml +'20535': + :osm_id: '62782' + :code: '20535' + :city: Hamburg + :county: Hamburg-Mitte + :state: Hamburg +``` + +Bei mehreren Städten pro PLZ wird `city` als Semikolon-separierter String gespeichert (`"Stadt1;Stadt2"`). + +## Datenpflege: CSV → YAML neu generieren + +```bash +bundle exec rake convert +``` + +Liest `lib/data/de.csv` und schreibt `lib/data/DE.yml` neu. Das CSV hat folgende Spalten (Semikolon-getrennt): + +| Index | Feld | +|-------|----------| +| 0 | osm_id | +| 1 | city | +| 2 | code | +| 3 | county | +| 4 | state | + +## Wichtige Hinweise + +- Die YAML-Datei wird beim ersten Aufruf vollständig in den Speicher geladen (Memoize via `@db`). In Rails-Apps `ZipCodes.load` im Initializer aufrufen. +- Der `city_index` (Reverse-Index Stadt → [PLZ]) wird lazy beim ersten `codes`-Aufruf aufgebaut und ebenfalls gecacht. +- `YAML.load_file` mit `permitted_classes: [Symbol]` ist notwendig, weil `DE.yml` Symbol-Keys enthält (Psych 4 / Ruby 3.1+). + +## Tests ausführen + +```bash +bundle exec rspec +``` + +Code-Style prüfen: + +```bash +bundle exec standardrb +``` + +## CI + +GitHub Actions (`.github/workflows/ci.yml`): läuft bei Push auf `main` und bei PRs. Führt StandardRb und RSpec aus. diff --git a/Gemfile.lock b/Gemfile.lock index d88683a..9053b85 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,26 +2,24 @@ PATH remote: . specs: zip-codes (0.2.1) - csv GEM remote: https://rubygems.org/ specs: ast (2.4.3) - csv (3.3.5) diff-lcs (1.6.2) - json (2.18.1) - language_server-protocol (3.17.0.5) + json (2.20.0) + language_server-protocol (3.17.0.6) lint_roller (1.1.0) - parallel (1.27.0) - parser (3.3.10.2) + parallel (2.1.0) + parser (3.3.11.1) ast (~> 2.4.1) racc prism (1.9.0) racc (1.8.1) rainbow (3.1.1) - rake (13.3.1) - regexp_parser (2.11.3) + rake (13.4.2) + regexp_parser (2.12.0) rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -31,22 +29,22 @@ GEM rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.7) + rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.7) - rubocop (1.84.2) + rubocop (1.87.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) - parallel (~> 1.10) + parallel (>= 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.49.0) + rubocop-ast (1.50.0) parser (>= 3.3.7.2) prism (~> 1.7) rubocop-performance (1.26.1) @@ -54,10 +52,10 @@ GEM rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (1.13.0) - standard (1.54.0) + standard (1.55.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) - rubocop (~> 1.84.0) + rubocop (~> 1.87.0) standard-custom (~> 1.0.0) standard-performance (~> 1.8) standard-custom (1.0.2) diff --git a/README.md b/README.md index f241d8c..17501f2 100644 --- a/README.md +++ b/README.md @@ -4,40 +4,78 @@ Ruby Code Style -Simple gem to get city, state, and time zone for a given zip code. It has a yaml database bundled with it, so you need several mb of memory for the whole hash. +Ruby-Gem zur Suche nach deutschen Postleitzahlen (PLZ). Gibt zu einer PLZ die zugehörige Stadt, den Landkreis bzw. Bezirk und das Bundesland zurück. Der vollständige Datensatz (~8.000 PLZ) ist als YAML-Datei eingebettet und wird beim ersten Zugriff in den Arbeitsspeicher geladen. ## Installation -Add this line to your application's Gemfile: +In der `Gemfile` eintragen: - gem 'zip-codes' +```ruby +gem 'zip-codes' +``` -And then execute: +Dann ausführen: - $ bundle + $ bundle install -Or install it yourself as: +Oder direkt installieren: $ gem install zip-codes -## Usage +## Verwendung + +### Exakte Suche + +```ruby +ZipCodes.identify('20535') +# => { city: "Hamburg", code: "20535", county: "Hamburg-Mitte", osm_id: "62782", state: "Hamburg" } + +ZipCodes.identify('99999') +# => nil +``` + +### Präfix-Suche + +```ruby +ZipCodes.identify('205', like_search: true) +# => { "20535" => { city: "Hamburg", ... }, "20537" => { ... }, ... } + +# Kurzform: +ZipCodes.like('205') +``` + +### Alle PLZ einer Stadt ```ruby -ZipCodes.identify('30301') -# => {:state_code=>"GA", :state_name=>"Georgia", :city=>"Atlanta", :time_zone=>"America/New_York"} -# First run will take a while, as the yaml has to be loaded +ZipCodes.codes('Hamburg') +# => ["20095", "20097", ..., "22769"] # 98 Einträge ``` -If you are using Rails, you can load the hash on app startup for production and staging. +### Rails: Vorladen beim Start + +Damit die YAML-Datei nicht beim ersten Request geparst wird, kann die Datenbank in einem Initializer vorgeladen werden: + ```ruby -# config/initializers/load_zip_codes.rb +# config/initializers/zip_codes.rb ZipCodes.load unless Rails.env.development? ``` -## Contributing +## Datenfelder + +| Feld | Beispiel | Beschreibung | +|----------|--------------------|--------------------------| +| `code` | `"20535"` | PLZ (5-stelliger String) | +| `city` | `"Hamburg"` | Stadt / Gemeinde | +| `county` | `"Hamburg-Mitte"` | Landkreis oder Bezirk | +| `state` | `"Hamburg"` | Bundesland | +| `osm_id` | `"62782"` | OpenStreetMap-Relation | + +Die Daten basieren auf OpenStreetMap-Daten. + +## Mitwirken -1. Fork it -2. Create your feature branch (`git checkout -b my-new-feature`) -3. Commit your changes (`git commit -am 'Add some feature'`) -4. Push to the branch (`git push origin my-new-feature`) -5. Create new Pull Request +1. Fork erstellen +2. Feature-Branch anlegen (`git checkout -b mein-feature`) +3. Änderungen committen (`git commit -am 'Feature hinzufügen'`) +4. Branch pushen (`git push origin mein-feature`) +5. Pull Request öffnen diff --git a/Rakefile b/Rakefile index f3a9eae..594e27a 100644 --- a/Rakefile +++ b/Rakefile @@ -2,15 +2,10 @@ require "bundler/gem_tasks" require "yaml" task :convert do - codes = {} - File.open("lib/data/US.txt").each_line do |line| - array = line.split('\t') - codes[array[1]] = { - state_code: array[4], - state_name: array[3], - city: array[2] - } - end - - File.write("lib/data/US.yml", codes.to_yaml) + require_relative "lib/zip-codes/city_map" + require_relative "lib/zip-codes/converter" + converter = ZipCodes::Converter.new("DE", load: false) + converter.convert + converter.store! + puts "Converted #{converter.map.map.size} zip codes → lib/data/DE.yml" end diff --git a/lib/zip-codes.rb b/lib/zip-codes.rb index 3641c7b..89a45d4 100644 --- a/lib/zip-codes.rb +++ b/lib/zip-codes.rb @@ -14,23 +14,30 @@ def identify(code, like_search: false) end def codes(city) - db.select { |key, hash| hash[:city] == city }.keys + city_index[city] end def like(code) - db.select { |key, hash| key && key.index(code) == 0 } + db.select { |key, _| key.start_with?(code) } end def db - @db ||= begin - this_file = __dir__ - data = File.join(this_file, "data", "DE.yml") - YAML.load(File.open(data)) - end + @db ||= YAML.load_file( + File.join(__dir__, "data", "DE.yml"), + permitted_classes: [Symbol] + ) end def load db end + + private + + def city_index + @city_index ||= db.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(code, data), idx| + idx[data[:city]] << code + end + end end end diff --git a/spec/lib/zip-codes_spec.rb b/spec/lib/zip-codes_spec.rb index 67b40b1..47ac958 100644 --- a/spec/lib/zip-codes_spec.rb +++ b/spec/lib/zip-codes_spec.rb @@ -1,25 +1,77 @@ RSpec.describe ZipCodes do it "has a version number" do - expect(ZipCodes::VERSION).to eq("0.2.1") + expect(ZipCodes::VERSION).not_to be_nil end - it "includes correct zip code" do - expect(described_class.codes("Hamburg")).to include("20535") - end + describe ".identify" do + it "returns full entry for a known zip code" do + expect(described_class.identify("20535")).to eq( + city: "Hamburg", + code: "20535", + county: "Hamburg-Mitte", + osm_id: "62782", + state: "Hamburg" + ) + end + + it "returns entry with Bezirk in county field" do + expect(described_class.identify("13357")).to eq( + city: "Berlin", + code: "13357", + county: "Mitte-Wedding-Tiergarten", + osm_id: "62422", + state: "Berlin" + ) + end - it "has correct amount for hamburg" do - expect(described_class.codes("Hamburg").count).to eq(98) + it "returns nil for unknown zip code" do + expect(described_class.identify("00000")).to be_nil + end + + it "delegates to like search when like_search: true" do + result = described_class.identify("205", like_search: true) + expect(result).to be_a(Hash) + expect(result.keys).to all(start_with("205")) + end end - it "like search" do - expect(described_class.like("205").count).to eq(3) + describe ".codes" do + it "includes a known Hamburg zip code" do + expect(described_class.codes("Hamburg")).to include("20535") + end + + it "returns correct count for Hamburg" do + expect(described_class.codes("Hamburg").count).to eq(98) + end + + it "returns an Array" do + expect(described_class.codes("Berlin")).to be_an(Array) + end + + it "returns empty array for unknown city" do + expect(described_class.codes("Atlantis")).to eq([]) + end end - it "exact entry" do - expect(described_class.identify("20535")).to eq({city: "Hamburg", code: "20535", county: "Hamburg-Mitte", osm_id: "62782", state: "Hamburg"}) + describe ".like" do + it "returns all entries matching the prefix" do + expect(described_class.like("205").count).to eq(3) + end + + it "returns only entries starting with the prefix" do + result = described_class.like("801") + expect(result.keys).to all(start_with("801")) + end + + it "returns empty hash for unknown prefix" do + expect(described_class.like("99999")).to be_empty + end end - it "exact entry with bezirk" do - expect(described_class.identify("13357")).to eq({city: "Berlin", code: "13357", county: "Mitte-Wedding-Tiergarten", osm_id: "62422", state: "Berlin"}) + describe ".load" do + it "preloads the database" do + described_class.load + expect(described_class.db).not_to be_nil + end end end diff --git a/zip-codes.gemspec b/zip-codes.gemspec index 8d3ccf1..36c04ef 100644 --- a/zip-codes.gemspec +++ b/zip-codes.gemspec @@ -5,24 +5,21 @@ require "zip-codes/version" Gem::Specification.new do |spec| spec.name = "zip-codes" spec.version = ZipCodes::VERSION - spec.authors = ["Michał Duda"] - spec.email = ["michal.duda@monterail.com"] - spec.description = %s(Simple gem to get city, state, and time zone for a given zip code) - spec.summary = %s(Gem to identify zip codes inside US) - spec.homepage = "https://github.com/monterail/zip-codes" + spec.authors = ["Sascha Skulima"] + spec.email = ["sascha.skulima@symdok.de"] + spec.description = "Gem to look up German zip codes (PLZ) — returns city, county, and federal state" + spec.summary = "German zip code (PLZ) lookup gem" + spec.homepage = "https://github.com/Skulli/ruby_german_zip_codes" spec.license = "MIT" spec.files = `git ls-files`.split($RS) spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.add_development_dependency "bundler", "> 1.3" + spec.add_development_dependency "bundler", ">= 2.0" spec.add_development_dependency "rake" spec.add_development_dependency "rspec", "~> 3.0" - spec.add_development_dependency "standard" - spec.required_ruby_version = ">= 2.0.0" - - spec.add_dependency "csv" + spec.required_ruby_version = ">= 3.0.0" end From 9986912dc4100803f2562c26fd9edb005888e0d1 Mon Sep 17 00:00:00 2001 From: Sascha Skulima <218423+Skulli@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:16:04 +0200 Subject: [PATCH 2/5] fix:dev: Gemfile.lock mit neuem Bundler-Constraint aktualisieren Co-Authored-By: Claude Sonnet 4.6 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 9053b85..07633b8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -72,7 +72,7 @@ PLATFORMS ruby DEPENDENCIES - bundler (> 1.3) + bundler (>= 2.0) rake rspec (~> 3.0) standard From 35196182f00b162af56130f7dd9d4a82d33a0def Mon Sep 17 00:00:00 2001 From: Sascha Skulima <218423+Skulli@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:19:13 +0200 Subject: [PATCH 3/5] =?UTF-8?q?fix:dev:=20codes()=20gibt=20defensives=20Du?= =?UTF-8?q?plikat=20zur=C3=BCck,=20vermeidet=20Cache-Mutation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetch(city, []) statt Hash-Default-Block verhindert zudem, dass unbekannte Städte leere Arrays im Index hinterlassen. Co-Authored-By: Claude Sonnet 4.6 --- lib/zip-codes.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/zip-codes.rb b/lib/zip-codes.rb index 89a45d4..4294000 100644 --- a/lib/zip-codes.rb +++ b/lib/zip-codes.rb @@ -14,7 +14,7 @@ def identify(code, like_search: false) end def codes(city) - city_index[city] + city_index.fetch(city, []).dup end def like(code) @@ -35,8 +35,8 @@ def load private def city_index - @city_index ||= db.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(code, data), idx| - idx[data[:city]] << code + @city_index ||= db.each_with_object({}) do |(code, data), idx| + (idx[data[:city]] ||= []) << code end end end From 6f2fbdf2aa9def363e7c9b0d1def301e69ae2903 Mon Sep 17 00:00:00 2001 From: Sascha Skulima <218423+Skulli@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:20:45 +0200 Subject: [PATCH 4/5] fix:dev: Rakefile lesbarkeit verbessern, converter.map.map vermeiden Co-Authored-By: Claude Sonnet 4.6 --- Rakefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Rakefile b/Rakefile index 594e27a..a095201 100644 --- a/Rakefile +++ b/Rakefile @@ -7,5 +7,6 @@ task :convert do converter = ZipCodes::Converter.new("DE", load: false) converter.convert converter.store! - puts "Converted #{converter.map.map.size} zip codes → lib/data/DE.yml" + city_map = converter.map + puts "Converted #{city_map.map.size} zip codes → lib/data/DE.yml" end From 7f3902a38bb51056119ca8d8ec88eb46ea2bba6f Mon Sep 17 00:00:00 2001 From: Sascha Skulima <218423+Skulli@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:21:24 +0200 Subject: [PATCH 5/5] =?UTF-8?q?fix:dev:=20required=5Fruby=5Fversion=20auf?= =?UTF-8?q?=203.1=20erh=C3=B6hen=20(Psych=204=20/=20permitted=5Fclasses)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- zip-codes.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zip-codes.gemspec b/zip-codes.gemspec index 36c04ef..68ccb08 100644 --- a/zip-codes.gemspec +++ b/zip-codes.gemspec @@ -21,5 +21,5 @@ Gem::Specification.new do |spec| spec.add_development_dependency "rspec", "~> 3.0" spec.add_development_dependency "standard" - spec.required_ruby_version = ">= 3.0.0" + spec.required_ruby_version = ">= 3.1.0" end