diff --git a/tooling/pipeline/includes/biergarten_pipeline.h b/tooling/pipeline/includes/biergarten_pipeline.h index f9061fd9..a04f894e 100644 --- a/tooling/pipeline/includes/biergarten_pipeline.h +++ b/tooling/pipeline/includes/biergarten_pipeline.h @@ -58,6 +58,10 @@ #include "services/enrichment/mock_enrichment.h" #include "services/enrichment/wikipedia_service.h" +// --- services: postal_code --- +#include "services/postal_code/mock_postal_code_service.h" +#include "services/postal_code/postal_code_service.h" + // --- services: logging --- #include "services/logging/log_dispatcher.h" #include "services/logging/log_entry.h" diff --git a/tooling/pipeline/includes/biergarten_pipeline_orchestrator.h b/tooling/pipeline/includes/biergarten_pipeline_orchestrator.h index e731e6e4..89dbc648 100644 --- a/tooling/pipeline/includes/biergarten_pipeline_orchestrator.h +++ b/tooling/pipeline/includes/biergarten_pipeline_orchestrator.h @@ -16,6 +16,7 @@ #include "services/database/export_service.h" #include "services/enrichment/enrichment_service.h" #include "services/logging/logger.h" +#include "services/postal_code/postal_code_service.h" /** * @brief Main orchestrator for the Biergarten data generation pipeline. @@ -34,6 +35,8 @@ class BiergartenPipelineOrchestrator { * @param exporter Database backend for persisting generated records. * @param curated_data_service Loads curated location, persona, and name * data used to seed generation. + * @param postal_code_service Resolves a postal code for a city from its + * curated postal-code data. * @param application_options CLI configuration and paths. */ BiergartenPipelineOrchestrator( @@ -42,6 +45,7 @@ class BiergartenPipelineOrchestrator { std::unique_ptr generator, std::unique_ptr exporter, std::unique_ptr curated_data_service, + std::unique_ptr postal_code_service, const ApplicationOptions& application_options); /** @@ -78,6 +82,7 @@ class BiergartenPipelineOrchestrator { */ std::unique_ptr exporter_; std::unique_ptr curated_data_service_; + std::unique_ptr postal_code_service_; ApplicationOptions application_options_; @@ -87,7 +92,7 @@ class BiergartenPipelineOrchestrator { * @return Vector of locations randomly sampled per * PipelineOptions::location_count. */ - std::vector QueryLocations(); + std::vector QueryLocations(); /** * @brief Generate breweries for enriched cities. diff --git a/tooling/pipeline/includes/data_generation/llama_generator.h b/tooling/pipeline/includes/data_generation/llama_generator.h index 0502c4a8..c0754ddd 100644 --- a/tooling/pipeline/includes/data_generation/llama_generator.h +++ b/tooling/pipeline/includes/data_generation/llama_generator.h @@ -51,7 +51,7 @@ class LlamaGenerator final : public DataGenerator { /** * @brief Generates brewery data for a specific location. * - * @param location Location object. + * @param location City object. * @param region_context Additional regional context. * @return Generated brewery result. */ diff --git a/tooling/pipeline/includes/data_generation/mock_generator.h b/tooling/pipeline/includes/data_generation/mock_generator.h index e2690ebd..7d090372 100644 --- a/tooling/pipeline/includes/data_generation/mock_generator.h +++ b/tooling/pipeline/includes/data_generation/mock_generator.h @@ -41,7 +41,7 @@ class MockGenerator final : public DataGenerator { * @param location City and country names. * @return Deterministic hash value. */ - static size_t DeterministicHash(const Location& location); + static size_t DeterministicHash(const City& location); /** * @brief Combines city, persona, and name into a stable hash value. @@ -51,7 +51,7 @@ class MockGenerator final : public DataGenerator { * @param name Sampled first/last name. * @return Deterministic hash value. */ - static size_t DeterministicHash(const Location& location, + static size_t DeterministicHash(const City& location, const UserPersona& persona, const Name& name); // Hash stride constants for deterministic distribution across fixed-size diff --git a/tooling/pipeline/includes/data_model/generated_models.h b/tooling/pipeline/includes/data_model/generated_models.h index 84a5fcf6..fed8341a 100644 --- a/tooling/pipeline/includes/data_model/generated_models.h +++ b/tooling/pipeline/includes/data_model/generated_models.h @@ -87,15 +87,33 @@ struct UserResult { * @brief Enriched city data with Wikipedia context. */ struct EnrichedCity { - Location location; + City location; std::string region_context{}; }; +/** + * @brief A physical address: the city plus a concrete postal code selected + * from that city's curated postal-code examples. + */ +struct BreweryAddress { + City city; + std::string postal_code{}; +}; + +/** + * @brief A physical address for a generated user: the city plus a concrete + * postal code selected from that city's curated postal-code examples. + */ +struct UserAddress { + City city; + std::string postal_code{}; +}; + /** * @brief Helper struct to store generated brewery data. */ struct BreweryRecord { - Location location; + BreweryAddress address; BreweryResult brewery; }; @@ -107,7 +125,7 @@ struct BreweryRecord { * consumer can register real accounts from the pipeline's SQLite export. */ struct UserRecord { - Location location; + UserAddress address; UserResult user; std::string email{}; std::string date_of_birth{}; diff --git a/tooling/pipeline/includes/data_model/models.h b/tooling/pipeline/includes/data_model/models.h index 4c46a96d..89e23e7d 100644 --- a/tooling/pipeline/includes/data_model/models.h +++ b/tooling/pipeline/includes/data_model/models.h @@ -22,16 +22,39 @@ class ILogger; namespace prog_opts = boost::program_options; // ============================================================================ -// Location Models +// City Models // ============================================================================ /** - * @brief Canonical location record for city-level generation. + * @brief Curated postal-code metadata for a city, mirroring the + * `postal_code` object in the locations dataset. */ -struct Location { +struct PostalCodeSpec { + /** + * @brief Permissive country-wide postal-code format regex, used as the + * validation fallback for a selected example. + */ + std::string country_format_regex{}; + + /** + * @brief City-specific postal-code regexes (more precise than the country + * format). + */ + std::vector city_regexes{}; + + /** + * @brief Concrete example postal codes for this city. The postal code + * service selects from these rather than synthesizing codes. + */ + std::vector examples{}; +}; + +/** + * @brief Canonical city record for city-level generation. + */ +struct City { std::string city{}; std::string state_province{}; - std::string postal_code{}; /** * @brief ISO 3166-2 subdivision code. @@ -51,14 +74,10 @@ struct Location { std::vector local_languages{}; /** - * @brief Latitude in decimal degrees. + * @brief Postal-code metadata (format regex, city regexes, and concrete + * examples) sourced directly from the curated locations dataset. */ - double latitude{}; - - /** - * @brief Longitude in decimal degrees. - */ - double longitude{}; + PostalCodeSpec postal_code{}; }; // ============================================================================ diff --git a/tooling/pipeline/includes/services/curated_data/curated_data_service.h b/tooling/pipeline/includes/services/curated_data/curated_data_service.h index d6505ce3..aaadbbd9 100644 --- a/tooling/pipeline/includes/services/curated_data/curated_data_service.h +++ b/tooling/pipeline/includes/services/curated_data/curated_data_service.h @@ -15,7 +15,7 @@ using ForenameList = std::vector; using SurnameList = std::vector; -using LocationsList = std::vector; +using LocationsList = std::vector; using PersonasList = std::vector; using ForenamesByCountryMap = std::unordered_map; using SurnamesByCountryMap = std::unordered_map; diff --git a/tooling/pipeline/includes/services/database/sqlite_export_service.h b/tooling/pipeline/includes/services/database/sqlite_export_service.h index e5c49361..258e0213 100644 --- a/tooling/pipeline/includes/services/database/sqlite_export_service.h +++ b/tooling/pipeline/includes/services/database/sqlite_export_service.h @@ -45,27 +45,29 @@ class SqliteExportService final : public IExportService { void RollbackAndCloseNoThrow() noexcept; [[nodiscard]] std::filesystem::path BuildDatabasePath() const; - [[nodiscard]] static std::string BuildLocationKey(const Location& location); + [[nodiscard]] static std::string BuildCityKey(const City& city); /** - * @brief Returns the row id for @p location, inserting it first if it has + * @brief Returns the row id for @p city, inserting it first if it has * not already been seen during this run. * - * Shared by both ProcessRecord() overloads so breweries and users - * referencing the same location resolve to the same row. + * Shared by both ProcessRecord() overloads so brewery and user addresses + * referencing the same city resolve to the same row. */ - [[nodiscard]] sqlite3_int64 ResolveLocationId(const Location& location); + [[nodiscard]] sqlite3_int64 ResolveCityId(const City& city); std::unique_ptr date_time_provider_; std::filesystem::path output_path_; std::string run_timestamp_utc_; std::filesystem::path database_path_; SqliteDatabaseHandle db_handle_; - SqliteStatementHandle insert_location_stmt_; + SqliteStatementHandle insert_city_stmt_; SqliteStatementHandle insert_brewery_stmt_; + SqliteStatementHandle insert_brewery_address_stmt_; SqliteStatementHandle insert_user_stmt_; + SqliteStatementHandle insert_user_address_stmt_; bool transaction_open_ = false; - std::unordered_map location_cache_; + std::unordered_map city_cache_; }; #endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_DATABASE_SQLITE_EXPORT_SERVICE_H_ diff --git a/tooling/pipeline/includes/services/database/sqlite_statement_helpers.h b/tooling/pipeline/includes/services/database/sqlite_statement_helpers.h index b2143c4c..1cdc2365 100644 --- a/tooling/pipeline/includes/services/database/sqlite_statement_helpers.h +++ b/tooling/pipeline/includes/services/database/sqlite_statement_helpers.h @@ -17,9 +17,9 @@ namespace sqlite_export_service_internal { -inline constexpr std::string_view kCreateLocationsTableSql = R"sql( +inline constexpr std::string_view kCreateCitiesTableSql = R"sql( -CREATE TABLE IF NOT EXISTS locations ( +CREATE TABLE IF NOT EXISTS cities ( id INTEGER PRIMARY KEY AUTOINCREMENT, city TEXT NOT NULL, state_province TEXT NOT NULL, @@ -27,9 +27,9 @@ CREATE TABLE IF NOT EXISTS locations ( country TEXT NOT NULL, iso3166_1 TEXT NOT NULL, local_languages_json TEXT NOT NULL, - latitude REAL NOT NULL, - longitude REAL NOT NULL, - UNIQUE(city, state_province, iso3166_2, country, latitude, longitude) + postal_code_country_format_regex TEXT NOT NULL, + postal_code_city_regex_json TEXT NOT NULL, + UNIQUE(city, state_province, iso3166_2, country) ); )sql"; @@ -38,15 +38,29 @@ inline constexpr std::string_view kCreateBreweriesTableSql = R"sql( CREATE TABLE IF NOT EXISTS breweries ( id INTEGER PRIMARY KEY AUTOINCREMENT, - location_id INTEGER NOT NULL, name_en TEXT NOT NULL, description_en TEXT NOT NULL, name_local TEXT NOT NULL, - description_local TEXT NOT NULL, - FOREIGN KEY(location_id) REFERENCES locations(id) ON DELETE CASCADE + description_local TEXT NOT NULL ); -CREATE INDEX IF NOT EXISTS idx_breweries_location_id ON breweries(location_id); +)sql"; + +inline constexpr std::string_view kCreateBreweryAddressesTableSql = R"sql( + +CREATE TABLE IF NOT EXISTS brewery_addresses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + brewery_id INTEGER NOT NULL, + city_id INTEGER NOT NULL, + postal_code TEXT NOT NULL, + FOREIGN KEY(brewery_id) REFERENCES breweries(id) ON DELETE CASCADE, + FOREIGN KEY(city_id) REFERENCES cities(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_brewery_addresses_brewery_id + ON brewery_addresses(brewery_id); +CREATE INDEX IF NOT EXISTS idx_brewery_addresses_city_id + ON brewery_addresses(city_id); )sql"; @@ -54,7 +68,6 @@ inline constexpr std::string_view kCreateUsersTableSql = R"sql( CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, - location_id INTEGER NOT NULL, first_name TEXT NOT NULL, last_name TEXT NOT NULL, gender TEXT NOT NULL, @@ -62,40 +75,61 @@ CREATE TABLE IF NOT EXISTS users ( bio TEXT NOT NULL, activity_weight REAL NOT NULL, email TEXT NOT NULL UNIQUE, - date_of_birth TEXT NOT NULL, - FOREIGN KEY(location_id) REFERENCES locations(id) ON DELETE CASCADE + date_of_birth TEXT NOT NULL ); -CREATE INDEX IF NOT EXISTS idx_users_location_id ON users(location_id); - )sql"; -inline constexpr std::string_view kInsertLocationSql = R"sql( -INSERT INTO locations ( +inline constexpr std::string_view kCreateUserAddressesTableSql = R"sql( + +CREATE TABLE IF NOT EXISTS user_addresses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + city_id INTEGER NOT NULL, + postal_code TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(city_id) REFERENCES cities(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_user_addresses_user_id + ON user_addresses(user_id); +CREATE INDEX IF NOT EXISTS idx_user_addresses_city_id + ON user_addresses(city_id); + +)sql"; + +inline constexpr std::string_view kInsertCitySql = R"sql( +INSERT INTO cities ( city, state_province, iso3166_2, country, iso3166_1, local_languages_json, - latitude, - longitude + postal_code_country_format_regex, + postal_code_city_regex_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?); )sql"; inline constexpr std::string_view kInsertBrewerySql = R"sql( INSERT INTO breweries ( - location_id, name_en, description_en, name_local, description_local -) VALUES (?, ?, ?, ?, ?); +) VALUES (?, ?, ?, ?); +)sql"; + +inline constexpr std::string_view kInsertBreweryAddressSql = R"sql( +INSERT INTO brewery_addresses ( + brewery_id, + city_id, + postal_code +) VALUES (?, ?, ?); )sql"; inline constexpr std::string_view kInsertUserSql = R"sql( INSERT INTO users ( - location_id, first_name, last_name, gender, @@ -104,33 +138,45 @@ INSERT INTO users ( activity_weight, email, date_of_birth -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); +) VALUES (?, ?, ?, ?, ?, ?, ?, ?); +)sql"; + +inline constexpr std::string_view kInsertUserAddressSql = R"sql( +INSERT INTO user_addresses ( + user_id, + city_id, + postal_code +) VALUES (?, ?, ?); )sql"; // sqlite3_bind_*() parameter indices are 1-based, matching the "?" // placeholder order in the SQL above. -enum LocationBindIndex { - kLocationCityBindIndex = 1, - kLocationStateProvinceBindIndex, - kLocationIso31662BindIndex, - kLocationCountryBindIndex, - kLocationIso31661BindIndex, - kLocationLanguagesBindIndex, - kLocationLatitudeBindIndex, - kLocationLongitudeBindIndex, +enum CityBindIndex { + kCityCityBindIndex = 1, + kCityStateProvinceBindIndex, + kCityIso31662BindIndex, + kCityCountryBindIndex, + kCityIso31661BindIndex, + kCityLanguagesBindIndex, + kCityPostalCodeCountryFormatRegexBindIndex, + kCityPostalCodeCityRegexJsonBindIndex, }; enum BreweryBindIndex { - kBreweryLocationIdBindIndex = 1, - kBreweryEnglishNameBindIndex, + kBreweryEnglishNameBindIndex = 1, kBreweryEnglishDescriptionBindIndex, kBreweryLocalNameBindIndex, kBreweryLocalDescriptionBindIndex, }; +enum BreweryAddressBindIndex { + kBreweryAddressBreweryIdBindIndex = 1, + kBreweryAddressCityIdBindIndex, + kBreweryAddressPostalCodeBindIndex, +}; + enum UserBindIndex { - kUserLocationIdBindIndex = 1, - kUserFirstNameBindIndex, + kUserFirstNameBindIndex = 1, kUserLastNameBindIndex, kUserGenderBindIndex, kUserUsernameBindIndex, @@ -140,6 +186,12 @@ enum UserBindIndex { kUserDateOfBirthBindIndex, }; +enum UserAddressBindIndex { + kUserAddressUserIdBindIndex = 1, + kUserAddressCityIdBindIndex, + kUserAddressPostalCodeBindIndex, +}; + SqliteStatementHandle PrepareStatement(const SqliteDatabaseHandle& db_handle, std::string_view sql, const char* action); diff --git a/tooling/pipeline/includes/services/enrichment/enrichment_service.h b/tooling/pipeline/includes/services/enrichment/enrichment_service.h index 9c791372..34aa88b0 100644 --- a/tooling/pipeline/includes/services/enrichment/enrichment_service.h +++ b/tooling/pipeline/includes/services/enrichment/enrichment_service.h @@ -20,10 +20,10 @@ class IEnrichmentService { /** * @brief Resolves contextual enrichment for a location. * - * @param loc Location to enrich. + * @param loc City to enrich. * @return Context text, or an empty string if unavailable. */ - virtual std::string GetLocationContext(const Location& loc) = 0; + virtual std::string GetLocationContext(const City& loc) = 0; }; #endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_ENRICHMENT_ENRICHMENT_SERVICE_H_ diff --git a/tooling/pipeline/includes/services/enrichment/mock_enrichment.h b/tooling/pipeline/includes/services/enrichment/mock_enrichment.h index 9a45b3f3..88bc39aa 100644 --- a/tooling/pipeline/includes/services/enrichment/mock_enrichment.h +++ b/tooling/pipeline/includes/services/enrichment/mock_enrichment.h @@ -15,7 +15,7 @@ */ class MockEnrichmentService final : public IEnrichmentService { public: - std::string GetLocationContext(const Location& /*loc*/) override { + std::string GetLocationContext(const City& /*loc*/) override { return {}; } }; diff --git a/tooling/pipeline/includes/services/enrichment/wikipedia_service.h b/tooling/pipeline/includes/services/enrichment/wikipedia_service.h index 1063aea8..e33b94a9 100644 --- a/tooling/pipeline/includes/services/enrichment/wikipedia_service.h +++ b/tooling/pipeline/includes/services/enrichment/wikipedia_service.h @@ -29,7 +29,7 @@ class WikipediaEnrichmentService final : public IEnrichmentService { /** * @brief Returns the Wikipedia-derived context for a location. */ - [[nodiscard]] std::string GetLocationContext(const Location& loc) override; + [[nodiscard]] std::string GetLocationContext(const City& loc) override; private: std::string FetchExtract(std::string_view query); diff --git a/tooling/pipeline/includes/services/logging/log_entry.h b/tooling/pipeline/includes/services/logging/log_entry.h index b6d6fba2..11131da3 100644 --- a/tooling/pipeline/includes/services/logging/log_entry.h +++ b/tooling/pipeline/includes/services/logging/log_entry.h @@ -51,7 +51,7 @@ enum class PipelinePhase { */ Startup, /** - * @brief Location/context enrichment (e.g. Wikipedia lookups). + * @brief City/context enrichment (e.g. Wikipedia lookups). */ Enrichment, /** diff --git a/tooling/pipeline/includes/services/postal_code/mock_postal_code_service.h b/tooling/pipeline/includes/services/postal_code/mock_postal_code_service.h new file mode 100644 index 00000000..87b05c4e --- /dev/null +++ b/tooling/pipeline/includes/services/postal_code/mock_postal_code_service.h @@ -0,0 +1,79 @@ +#ifndef BIERGARTEN_PIPELINE_INCLUDES_SERVICES_POSTAL_CODE_MOCK_POSTAL_CODE_SERVICE_H_ +#define BIERGARTEN_PIPELINE_INCLUDES_SERVICES_POSTAL_CODE_MOCK_POSTAL_CODE_SERVICE_H_ + +/** + * @file services/postal_code/mock_postal_code_service.h + * @brief Data-driven postal code service that selects the first curated + * example for a city and validates it against the city's postal-code regex. + */ + +#include +#include +#include +#include + +#include "services/postal_code/postal_code_service.h" + +/** + * @brief Postal code service backed entirely by the curated locations + * dataset: it returns the city's first example postal code after checking it + * against the curated postal-code regex. + * + * A precise city regex is preferred when present; otherwise the permissive + * country format regex is used as the fallback. A city with no examples, or an + * example that fails validation, throws so the caller can skip the city. + */ +class MockPostalCodeService final : public IPostalCodeService { + public: + std::string GeneratePostalCode(const City& city) override { + const PostalCodeSpec& spec = city.postal_code; + + if (spec.examples.empty()) { + throw std::runtime_error(std::format( + "No postal-code examples available for city '{}'", city.city)); + } + + const std::string& candidate = spec.examples.front(); + + if (!MatchesAnyPattern(candidate, spec)) { + throw std::runtime_error( + std::format("Postal code '{}' for city '{}' does not match the " + "curated postal-code regex", + candidate, city.city)); + } + + return candidate; + } + + private: + /** + * @brief Returns true if @p candidate matches any city regex, or the + * country format regex when no city regex matches. When the spec carries no + * usable regex at all, validation is skipped (returns true). + */ + static bool MatchesAnyPattern(const std::string& candidate, + const PostalCodeSpec& spec) { + bool has_pattern = false; + + for (const std::string& pattern : spec.city_regexes) { + if (pattern.empty()) { + continue; + } + has_pattern = true; + if (std::regex_match(candidate, std::regex(pattern))) { + return true; + } + } + + if (!spec.country_format_regex.empty()) { + has_pattern = true; + if (std::regex_match(candidate, std::regex(spec.country_format_regex))) { + return true; + } + } + + return !has_pattern; + } +}; + +#endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_POSTAL_CODE_MOCK_POSTAL_CODE_SERVICE_H_ diff --git a/tooling/pipeline/includes/services/postal_code/postal_code_service.h b/tooling/pipeline/includes/services/postal_code/postal_code_service.h new file mode 100644 index 00000000..016cf369 --- /dev/null +++ b/tooling/pipeline/includes/services/postal_code/postal_code_service.h @@ -0,0 +1,35 @@ +#ifndef BIERGARTEN_PIPELINE_INCLUDES_SERVICES_POSTAL_CODE_POSTAL_CODE_SERVICE_H_ +#define BIERGARTEN_PIPELINE_INCLUDES_SERVICES_POSTAL_CODE_POSTAL_CODE_SERVICE_H_ + +/** + * @file services/postal_code/postal_code_service.h + * @brief Abstraction for resolving a postal code for a city. + */ + +#include + +#include "data_model/models.h" + +/** + * @brief Interface for services that resolve a postal code for a city. + */ +class IPostalCodeService { + public: + IPostalCodeService() = default; + virtual ~IPostalCodeService() = default; + + IPostalCodeService(const IPostalCodeService&) = delete; + IPostalCodeService& operator=(const IPostalCodeService&) = delete; + IPostalCodeService(IPostalCodeService&&) = delete; + IPostalCodeService& operator=(IPostalCodeService&&) = delete; + + /** + * @brief Resolves a postal code for @p city. + * + * @param city City whose curated postal-code examples supply the result. + * @return A postal code string, or an empty string if none is available. + */ + virtual std::string GeneratePostalCode(const City& city) = 0; +}; + +#endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_POSTAL_CODE_POSTAL_CODE_SERVICE_H_ diff --git a/tooling/pipeline/locations.json b/tooling/pipeline/locations.json index 2b216734..e0d37e51 100644 --- a/tooling/pipeline/locations.json +++ b/tooling/pipeline/locations.json @@ -7,7 +7,17 @@ "iso3166_1": "ZA", "latitude": -33.9249, "longitude": 18.4241, - "local_languages": ["af", "en", "xh"] + "local_languages": ["af", "en", "xh"], + "postal_code": { + "country_format_regex": "^\\d{4}$", + "city_regex": [ + "^(?:7(?:10[03]|4(?:[26][05]|0[04-7]|3[05-79]|4[0-4679]|5[0156]|8[09]|9[0139])|5(?:[56][01]|0[013-7]|25|3[0235-9]|40|8[12])|7(?:[23]5|0[058]|4[05]|5[0-256]|6[02-4679]|7[09]|8[02-9]|9[08])|8(?:[26]4|0[0-2568]|48|72|8[08])|9(?:[12]5|[59]0|3[57]|4[156]|6[56]|7[4-9]))|8(?:0(?:[246]0|0[0-3578]|1[0278]|5[01])|160))$" + ], + "examples": ["7100", "7103", "7400"], + "source": "derived from GeoNames postal data (radius, r=25km)", + "codes_matched": 135, + "notes": "Cape Town metro spans roughly 7000-8099; 7xxx also covers parts of the wider Western Cape. Enumerated from 135 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Johannesburg", @@ -17,7 +27,17 @@ "iso3166_1": "ZA", "latitude": -26.2041, "longitude": 28.0473, - "local_languages": ["en", "zu", "st", "af"] + "local_languages": ["en", "zu", "st", "af"], + "postal_code": { + "country_format_regex": "^\\d{4}$", + "city_regex": [ + "^(?:1(?:7(?:0[08]|1[1256])|8(?:0[13-9]|1[14-689]|37|41|5[23]|6[35]))|2(?:0(?:[156]\\d|0[013-9]|2[0-468]|3[0-8]|4[0-47-9]|7[02-46-8]|8[12]|9[0-5])|1(?:[01]\\d|2[0-489]|3[0-24-79]|4[0-57-9]|5[1-9]|6[0-579]|7[01]|8[89]|9[0-8])))$" + ], + "examples": ["1700", "1708", "1711"], + "source": "derived from GeoNames postal data (radius, r=18km)", + "codes_matched": 178, + "notes": "Johannesburg street codes cluster in 2000-2199; PO Box codes differ. Enumerated from 178 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Durban", @@ -27,7 +47,17 @@ "iso3166_1": "ZA", "latitude": -29.8587, "longitude": 31.0218, - "local_languages": ["zu", "en"] + "local_languages": ["zu", "en"], + "postal_code": { + "country_format_regex": "^\\d{4}$", + "city_regex": [ + "^(?:36(?:0[02-57-9]|1[0-7]|2[014-69]|3[0145]|5[0-25])|4(?:0(?:0[014-8]|1[13-9]|2[1-9]|3[124-9]|5[0-36-9]|6\\d|7[0-24589]|8[023589]|9[0-48])|1(?:0[149]|1[0135]|3[378]|4[48]|53)|3(?:[02]1|10|5[09]|6[0-24])))$" + ], + "examples": ["3600", "3602", "3603"], + "source": "derived from GeoNames postal data (radius, r=20km)", + "codes_matched": 121, + "notes": "eThekwini/Durban street codes cluster in 4000-4199. Enumerated from 121 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Franschhoek", @@ -37,7 +67,15 @@ "iso3166_1": "ZA", "latitude": -33.9146, "longitude": 19.1198, - "local_languages": ["af", "en"] + "local_languages": ["af", "en"], + "postal_code": { + "country_format_regex": "^\\d{4}$", + "city_regex": ["^769[01]$"], + "examples": ["7690", "7691"], + "source": "derived from GeoNames postal data (name, r=15km)", + "codes_matched": 2, + "notes": "Single town code. Enumerated from 2 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Nairobi", @@ -47,7 +85,17 @@ "iso3166_1": "KE", "latitude": -1.2921, "longitude": 36.8219, - "local_languages": ["sw", "en"] + "local_languages": ["sw", "en"], + "postal_code": { + "country_format_regex": "^\\d{5}$", + "city_regex": [ + "^00(?:[348]00|10[0-2]|2(?:1[03-69]|3[013569])|5(?:0\\d|1[0-35-9]|2[0-3])|6(?:0[013-79]|1[0-589]|2[0-35-7])|90[02356])$" + ], + "examples": ["00100", "00101", "00102"], + "source": "derived from GeoNames postal data (radius, r=20km)", + "codes_matched": 69, + "notes": "All Nairobi County codes begin 00; GPO is 00100. Range 00100-00999. Enumerated from 69 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Buenos Aires", @@ -57,7 +105,15 @@ "iso3166_1": "AR", "latitude": -34.6037, "longitude": -58.3816, - "local_languages": ["es-AR"] + "local_languages": ["es-AR"], + "postal_code": { + "country_format_regex": "^(?:[A-HJ-NP-TV-Z]\\d{4}[A-Z]{3}|\\d{4})$", + "city_regex": ["^C1[0-4]\\d{2}[A-Z]{3}$", "^1[0-4]\\d{2}$"], + "examples": ["C1425DKE", "1425"], + "source": "researched (no usable open dataset)", + "codes_matched": null, + "notes": "CABA uses CPA prefix 'C'. Legacy 4-digit form 1000-1499 still widely accepted." + } }, { "city": "Bariloche", @@ -67,7 +123,15 @@ "iso3166_1": "AR", "latitude": -41.1335, "longitude": -71.3103, - "local_languages": ["es-AR"] + "local_languages": ["es-AR"], + "postal_code": { + "country_format_regex": "^(?:[A-HJ-NP-TV-Z]\\d{4}[A-Z]{3}|\\d{4})$", + "city_regex": ["^840[09]$"], + "examples": ["8400", "8409"], + "source": "derived from GeoNames postal data (sub, r=25km)", + "codes_matched": 2, + "notes": "San Carlos de Bariloche legacy code 8400; CPA prefix 'R' (Rio Negro). Enumerated from 2 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Bogotá", @@ -77,7 +141,17 @@ "iso3166_1": "CO", "latitude": 4.711, "longitude": -74.0721, - "local_languages": ["es-CO"] + "local_languages": ["es-CO"], + "postal_code": { + "country_format_regex": "^\\d{6}$", + "city_regex": [ + "^11(?:0(?:[29][1-3]1|[36][12]1|[47][1-4]1|1[1-5]1|8[1-8]1)|1(?:[457]11|[23][12]1|[89][1-4]1|0[1-7]1|1(?:[1-4]1|[5-7][16])|6[1-3]1))$" + ], + "examples": ["110111", "110121", "110131"], + "source": "derived from GeoNames postal data (radius, r=20km)", + "codes_matched": 66, + "notes": "Bogota D.C. = DANE department code 11. Enumerated from 66 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Medellín", @@ -87,7 +161,15 @@ "iso3166_1": "CO", "latitude": 6.2442, "longitude": -75.5812, - "local_languages": ["es-CO"] + "local_languages": ["es-CO"], + "postal_code": { + "country_format_regex": "^\\d{6}$", + "city_regex": ["^0500(?:0[1-7]|1[0-8]|2\\d|3[0-7]|4[0-478])$"], + "examples": ["050001", "050002", "050003"], + "source": "derived from GeoNames postal data (name, r=25km)", + "codes_matched": 41, + "notes": "Antioquia = 05; Medellin zone = 050xxx. Wider metro uses 051xxx-055xxx. Enumerated from 41 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "São Paulo", @@ -97,7 +179,15 @@ "iso3166_1": "BR", "latitude": -23.5505, "longitude": -46.6333, - "local_languages": ["pt-BR"] + "local_languages": ["pt-BR"], + "postal_code": { + "country_format_regex": "^\\d{5}-?\\d{3}$", + "city_regex": ["^0[1-5]\\d{3}-?\\d{3}$", "^08[0-4]\\d{2}-?\\d{3}$"], + "examples": ["01310-100"], + "source": "researched (no usable open dataset)", + "codes_matched": null, + "notes": "Sao Paulo city CEP: 01000-000 to 05999-999, plus 08000-000 to 08499-999." + } }, { "city": "Curitiba", @@ -107,7 +197,15 @@ "iso3166_1": "BR", "latitude": -25.4284, "longitude": -49.2733, - "local_languages": ["pt-BR"] + "local_languages": ["pt-BR"], + "postal_code": { + "country_format_regex": "^\\d{5}-?\\d{3}$", + "city_regex": ["^8[0-2]\\d{3}-?\\d{3}$"], + "examples": ["80010-010"], + "source": "researched (no usable open dataset)", + "codes_matched": null, + "notes": "Curitiba CEP: 80000-000 to 82999-999." + } }, { "city": "Rio de Janeiro", @@ -117,7 +215,15 @@ "iso3166_1": "BR", "latitude": -22.9068, "longitude": -43.1729, - "local_languages": ["pt-BR"] + "local_languages": ["pt-BR"], + "postal_code": { + "country_format_regex": "^\\d{5}-?\\d{3}$", + "city_regex": ["^(?:2[0-2]\\d{3}|23[0-7]\\d{2})-?\\d{3}$"], + "examples": ["20040-020"], + "source": "researched (no usable open dataset)", + "codes_matched": null, + "notes": "Rio city CEP: 20000-000 to 23799-999." + } }, { "city": "Santiago", @@ -127,7 +233,17 @@ "iso3166_1": "CL", "latitude": -33.4489, "longitude": -70.6693, - "local_languages": ["es-CL"] + "local_languages": ["es-CL"], + "postal_code": { + "country_format_regex": "^\\d{7}$", + "city_regex": [ + "^(?:7(?:5[05]|63|75|8[15]|9[17])|8(?:[26]4|[48][26]|[59][048]|01|3[28]|7[08])|9(?:0[28]|1[26]|2[05]))\\d{4}$" + ], + "examples": ["7500000", "7550000", "7630000"], + "source": "derived from GeoNames postal data (radius, r=18km)", + "codes_matched": 31, + "notes": "Santiago Province spans approx. 7500000-9250000; 7xx = eastern communes, 8xx = central. Dataset is commune-level (nnn0000); 3-digit commune prefix + block. Enumerated from 31 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Valdivia", @@ -137,7 +253,15 @@ "iso3166_1": "CL", "latitude": -39.8142, "longitude": -73.2459, - "local_languages": ["es-CL"] + "local_languages": ["es-CL"], + "postal_code": { + "country_format_regex": "^\\d{7}$", + "city_regex": ["^509\\d{4}$"], + "examples": ["5090000"], + "source": "derived from GeoNames postal data (sub, r=20km)", + "codes_matched": 1, + "notes": "Los Rios region range approx. 5090000-5230000; Valdivia commune base code 5090000. Dataset is commune-level (nnn0000); 3-digit commune prefix + block. Enumerated from 1 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Lima", @@ -147,7 +271,17 @@ "iso3166_1": "PE", "latitude": -12.0464, "longitude": -77.0428, - "local_languages": ["es-PE"] + "local_languages": ["es-PE"], + "postal_code": { + "country_format_regex": "^\\d{5}$", + "city_regex": [ + "^15(?:0(?:0[1346-9]|1[1289]|2[1-46]|3[346-9]|4[6-9]|5[46-8]|6[3467]|7[2-469]|8[1-46-8]|9[346])|1(?:0[1-36-9]|1[23])|3(?:0[12467]|1[124]|2[6-8]|3[1-3])|4(?:[039][148]|[14][269]|[28][37]|53|79)|8(?:0[13469]|1[126-8]|2[489]|3[1467]|42))$" + ], + "examples": ["15001", "15003", "15004"], + "source": "derived from GeoNames postal data (radius, r=20km)", + "codes_matched": 111, + "notes": "Lima Region = prefix 15; metropolitan Lima districts sit in 15001-15099. Callao is 07xxx. Enumerated from 111 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Tokyo", @@ -157,7 +291,17 @@ "iso3166_1": "JP", "latitude": 35.6762, "longitude": 139.6503, - "local_languages": ["ja"] + "local_languages": ["ja"], + "postal_code": { + "country_format_regex": "^\\d{3}-?\\d{4}$", + "city_regex": [ + "^(?:1(?:[068]\\d|[14][0-6]|2[013-5]|3[0-24-7]|5[0-8]|7[013-9]|95)|20[1-46])-?\\d{4}$" + ], + "examples": ["100-0000", "100-0001", "100-0002"], + "source": "derived from GeoNames postal data (radius, r=20km)", + "codes_matched": 7876, + "notes": "Tokyo Metropolis: 100-xxxx to 208-xxxx. The 23 wards are 100-179. Dataset holds full 7-digit codes; compressed on the 3-digit area prefix. Enumerated from 7876 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Osaka", @@ -167,7 +311,15 @@ "iso3166_1": "JP", "latitude": 34.6937, "longitude": 135.5023, - "local_languages": ["ja"] + "local_languages": ["ja"], + "postal_code": { + "country_format_regex": "^\\d{3}-?\\d{4}$", + "city_regex": ["^5(?:[35]\\d|4[0-7])-?\\d{4}$"], + "examples": ["530-0000", "530-0001", "530-0002"], + "source": "derived from GeoNames postal data (radius, r=12km)", + "codes_matched": 1440, + "notes": "Osaka City: 530-xxxx to 559-xxxx. Wider Osaka Prefecture is 5xx. Dataset holds full 7-digit codes; compressed on the 3-digit area prefix. Enumerated from 1440 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Kyoto", @@ -177,7 +329,15 @@ "iso3166_1": "JP", "latitude": 35.0116, "longitude": 135.7681, - "local_languages": ["ja"] + "local_languages": ["ja"], + "postal_code": { + "country_format_regex": "^\\d{3}-?\\d{4}$", + "city_regex": ["^6(?:0[0-7]|1[0-256])-?\\d{4}$"], + "examples": ["600-0000", "600-8001", "600-8002"], + "source": "derived from GeoNames postal data (radius, r=12km)", + "codes_matched": 4614, + "notes": "Kyoto City: 600-xxxx to 616-xxxx. Dataset holds full 7-digit codes; compressed on the 3-digit area prefix. Enumerated from 4614 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Sapporo", @@ -187,7 +347,15 @@ "iso3166_1": "JP", "latitude": 43.0618, "longitude": 141.3545, - "local_languages": ["ja"] + "local_languages": ["ja"], + "postal_code": { + "country_format_regex": "^\\d{3}-?\\d{4}$", + "city_regex": ["^0(?:0[1-7]|6[0-5])-?\\d{4}$"], + "examples": ["001-0000", "001-0010", "001-0011"], + "source": "derived from GeoNames postal data (radius, r=20km)", + "codes_matched": 1373, + "notes": "Sapporo: 001-xxxx to 007-xxxx (north/west) and 060-xxxx to 065-xxxx (central/east). Dataset holds full 7-digit codes; compressed on the 3-digit area prefix. Enumerated from 1373 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Seoul", @@ -197,7 +365,17 @@ "iso3166_1": "KR", "latitude": 37.5665, "longitude": 126.978, - "local_languages": ["ko"] + "local_languages": ["ko"], + "postal_code": { + "country_format_regex": "^\\d{5}$", + "city_regex": [ + "^0(?:1[0-46-9]|2[0-24-8]|3[013-79]|4\\d|5[0-8]|6[0-35-9]|7[02-9]|8[0-35-8])\\d{2}$" + ], + "examples": ["01000", "01001", "01002"], + "source": "derived from GeoNames postal data (radius, r=18km)", + "codes_matched": 5628, + "notes": "Post-2015 5-digit system: Seoul = 01000-09999. Compressed on the 3-digit prefix. Enumerated from 5628 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Busan", @@ -207,7 +385,15 @@ "iso3166_1": "KR", "latitude": 35.1796, "longitude": 129.0756, - "local_languages": ["ko"] + "local_languages": ["ko"], + "postal_code": { + "country_format_regex": "^\\d{5}$", + "city_regex": ["^4(?:6[0235-79]|7[0-35-9]|8[0-57-9]|9[0-5])\\d{2}$"], + "examples": ["46002", "46018", "46027"], + "source": "derived from GeoNames postal data (radius, r=18km)", + "codes_matched": 2214, + "notes": "Busan = 46000-49999. Compressed on the 3-digit prefix. Enumerated from 2214 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Ho Chi Minh City", @@ -217,7 +403,15 @@ "iso3166_1": "VN", "latitude": 10.8231, "longitude": 106.6297, - "local_languages": ["vi"] + "local_languages": ["vi"], + "postal_code": { + "country_format_regex": "^\\d{5,6}$", + "city_regex": ["^7[0-4]\\d{3}$", "^7[0-4]\\d{4}$"], + "examples": ["70000", "700000"], + "source": "researched (no usable open dataset)", + "codes_matched": null, + "notes": "Current 5-digit system: HCMC = 70000-74999. Legacy/6-digit form (700000) still appears in some systems." + } }, { "city": "Hanoi", @@ -227,7 +421,15 @@ "iso3166_1": "VN", "latitude": 21.0285, "longitude": 105.8542, - "local_languages": ["vi"] + "local_languages": ["vi"], + "postal_code": { + "country_format_regex": "^\\d{5,6}$", + "city_regex": ["^1[0-4]\\d{3}$", "^1[0-4]\\d{4}$"], + "examples": ["10000", "100000"], + "source": "researched (no usable open dataset)", + "codes_matched": null, + "notes": "Hanoi = 10000-14999 (5-digit). Legacy 6-digit form 100000 still in circulation." + } }, { "city": "Da Nang", @@ -237,7 +439,15 @@ "iso3166_1": "VN", "latitude": 16.0544, "longitude": 108.2022, - "local_languages": ["vi"] + "local_languages": ["vi"], + "postal_code": { + "country_format_regex": "^\\d{5,6}$", + "city_regex": ["^5[0-5]\\d{3}$", "^5[0-5]\\d{4}$"], + "examples": ["50000", "550000"], + "source": "researched (no usable open dataset)", + "codes_matched": null, + "notes": "Sources conflict: Da Nang is cited as both 50000 and 55000. Pattern accepts 50000-55999 and the 6-digit legacy form." + } }, { "city": "Bangkok", @@ -247,7 +457,15 @@ "iso3166_1": "TH", "latitude": 13.7563, "longitude": 100.5018, - "local_languages": ["th"] + "local_languages": ["th"], + "postal_code": { + "country_format_regex": "^\\d{5}$", + "city_regex": ["^10(?:[4-8]00|1[0-7]0|2[024-69]0|3[0-3]0)$"], + "examples": ["10100", "10110", "10120"], + "source": "derived from GeoNames postal data (radius, r=20km)", + "codes_matched": 23, + "notes": "Bangkok = province code 10; codes run 10100-10900. Enumerated from 23 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Taipei", @@ -257,7 +475,15 @@ "iso3166_1": "TW", "latitude": 25.033, "longitude": 121.5654, - "local_languages": ["zh-TW"] + "local_languages": ["zh-TW"], + "postal_code": { + "country_format_regex": "^\\d{3}(?:\\d{2}|\\d{3})?$", + "city_regex": ["^1(?:0[0-9]|1[0-6])(?:\\d{2}|\\d{3})?$"], + "examples": ["100", "10058", "100013"], + "source": "researched (no usable open dataset)", + "codes_matched": null, + "notes": "Taipei City districts = 100-116. 3-digit is valid alone; 3+2 is legacy, 3+3 is current." + } }, { "city": "Beijing", @@ -267,7 +493,15 @@ "iso3166_1": "CN", "latitude": 39.9042, "longitude": 116.4074, - "local_languages": ["zh-CN"] + "local_languages": ["zh-CN"], + "postal_code": { + "country_format_regex": "^\\d{6}$", + "city_regex": ["^10[0-2]\\d{3}$"], + "examples": ["100000", "101100", "101200"], + "source": "derived from GeoNames postal data (radius, r=40km)", + "codes_matched": 11, + "notes": "Beijing municipality = 100000-102629. Dataset is city-level; compressed on the 3-digit prefix. Enumerated from 11 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Shanghai", @@ -277,7 +511,15 @@ "iso3166_1": "CN", "latitude": 31.2304, "longitude": 121.4737, - "local_languages": ["zh-CN"] + "local_languages": ["zh-CN"], + "postal_code": { + "country_format_regex": "^\\d{6}$", + "city_regex": ["^20[0-2]\\d{3}$"], + "examples": ["200000", "201100", "201300"], + "source": "derived from GeoNames postal data (radius, r=40km)", + "codes_matched": 10, + "notes": "Shanghai municipality = 200000-202699. Dataset is city-level; compressed on the 3-digit prefix. Enumerated from 10 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Bengaluru", @@ -287,7 +529,15 @@ "iso3166_1": "IN", "latitude": 12.9716, "longitude": 77.5946, - "local_languages": ["kn", "en"] + "local_languages": ["kn", "en"], + "postal_code": { + "country_format_regex": "^[1-9]\\d{5}$", + "city_regex": ["^560\\d{3}$"], + "examples": ["560001", "560002", "560003"], + "source": "derived from GeoNames postal data (field)", + "codes_matched": 106, + "notes": "Bengaluru urban PIN codes = 560xxx. Bengaluru Rural uses 561xxx/562xxx. Compressed on the 3-digit pin prefix. Enumerated from 106 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Singapore", @@ -297,7 +547,15 @@ "iso3166_1": "SG", "latitude": 1.3521, "longitude": 103.8198, - "local_languages": ["en", "zh", "ms", "ta"] + "local_languages": ["en", "zh", "ms", "ta"], + "postal_code": { + "country_format_regex": "^\\d{6}$", + "city_regex": ["^(?:[1-6]\\d|0[1-9]|7[0-35-9]|8[0-28]|91)\\d{4}$"], + "examples": ["018906", "018907", "018910"], + "source": "derived from GeoNames postal data (radius, r=30km)", + "codes_matched": 121135, + "notes": "Singapore is a single postal district; sectors 01-82 cover the whole island. Compressed on the 2-digit sector prefix. Enumerated from 121135 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Melbourne", @@ -307,7 +565,17 @@ "iso3166_1": "AU", "latitude": -37.8136, "longitude": 144.9631, - "local_languages": ["en-AU"] + "local_languages": ["en-AU"], + "postal_code": { + "country_format_regex": "^\\d{4}$", + "city_regex": [ + "^(?:3(?:0(?:[45]\\d|0[0-68]|1[0-35689]|2[0-35-9]|3[1-46-9]|6[0-25-8]|7[0-689]|8[1-9]|9[013-5])|1(?:[0248][1-9]|[39][0-4]|1[14]|5[0-2]|6[1-35-9]|7[0-24])|20[24-7]|428|752)|8(?:0(?:0[1-9]|1[0-2]|45|51|6[69]|7[01])|1(?:0[27]|11|20)|205|383|438|511))$" + ], + "examples": ["3000", "3001", "3002"], + "source": "derived from GeoNames postal data (radius, r=25km)", + "codes_matched": 179, + "notes": "Greater Melbourne approx. 3000-3207. Victoria as a whole is 3000-3999. Enumerated from 179 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Sydney", @@ -317,7 +585,17 @@ "iso3166_1": "AU", "latitude": -33.8688, "longitude": 151.2093, - "local_languages": ["en-AU"] + "local_languages": ["en-AU"], + "postal_code": { + "country_format_regex": "^\\d{4}$", + "city_regex": [ + "^(?:1(?:0(?:0[1-9]|10|2[0-35-9]|3\\d|4[0-6])|1(?:[2-9]\\d|0[015-9]|1[02-9])|2(?:0[1-3578]|3\\d|40|9[1-9])|3(?:[046]0|14|35|5[05])|4(?:[35][05]|[67][056]|0[15]|19|2[02-9]|4[015]|8[0145]|9[0359])|5(?:[18]5|6[05]|70|9[057])|6(?:[46]0|[78][05]|02|3[059]|5[58])|7(?:[09]0|[15][05]|[78]1|4[01]|65)|8(?:0[05]|1[19]|25|3[015]|60|7[45]|8[58]|91))|2(?:0(?:[2-46]\\d|[79][0-79]|0[0-246-9]|1[0-35-9]|5[0257-9]|8[4-9])|1(?:[1-39]\\d|0[0-49]|4[0-6]|5[0-24]|6[0-35]|7[23])|2(?:0[03-9]|1[0-46-9]|2\\d|3[014])))$" + ], + "examples": ["1001", "1002", "1003"], + "source": "derived from GeoNames postal data (radius, r=25km)", + "codes_matched": 424, + "notes": "Sydney metro approx. 2000-2249; 1000-1999 are Sydney PO Box / large-volume codes. Enumerated from 424 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Brisbane", @@ -327,7 +605,17 @@ "iso3166_1": "AU", "latitude": -27.4705, "longitude": 153.026, - "local_languages": ["en-AU"] + "local_languages": ["en-AU"], + "postal_code": { + "country_format_regex": "^\\d{4}$", + "city_regex": [ + "^(?:4(?:0(?:0\\d|1[0-478]|3[0-24-7]|5[13-59]|6[015-9]|7[02-8])|1(?:[05][1-9]|1\\d|2[0-37]|3[12]|6[0139]|7[0-489]))|90(?:0[0-2579]|1[035]))$" + ], + "examples": ["4000", "4001", "4002"], + "source": "derived from GeoNames postal data (radius, r=25km)", + "codes_matched": 99, + "notes": "Greater Brisbane approx. 4000-4207. Enumerated from 99 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Adelaide", @@ -337,7 +625,17 @@ "iso3166_1": "AU", "latitude": -34.9285, "longitude": 138.6007, - "local_languages": ["en-AU"] + "local_languages": ["en-AU"], + "postal_code": { + "country_format_regex": "^\\d{4}$", + "city_regex": [ + "^5(?:0(?:[14]\\d|[68][1-9]|0[015-9]|2[0-5]|3[1-57-9]|5[0-2]|7[0-6]|9[0-8])|1(?:0[6-9]|2[67]|3[1-46-9]|4[0-24]|5[0-24-689]|6[12])|8(?:0[01]|10|39)|9(?:42|50))$" + ], + "examples": ["5000", "5001", "5005"], + "source": "derived from GeoNames postal data (radius, r=20km)", + "codes_matched": 112, + "notes": "Adelaide metro approx. 5000-5199. Enumerated from 112 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Perth", @@ -347,7 +645,17 @@ "iso3166_1": "AU", "latitude": -31.9505, "longitude": 115.8605, - "local_languages": ["en-AU"] + "local_languages": ["en-AU"], + "postal_code": { + "country_format_regex": "^\\d{4}$", + "city_regex": [ + "^6(?:0(?:[56]\\d|0[013-9]|1[0-24-9]|2[0-79]|7[0167]|90)|1(?:[05]\\d|10|4[7-9]|6[02-46])|8(?:[79]2|0[039]|17|2[07]|3[0-27-9]|4\\d|50|65)|9(?:[18]\\d|[35][1-9]|0[0-79]|2[0-69]|4[0-7]|6[013-579]|7[09]|9[017]))$" + ], + "examples": ["6000", "6001", "6003"], + "source": "derived from GeoNames postal data (radius, r=25km)", + "codes_matched": 182, + "notes": "Perth metro approx. 6000-6199. Enumerated from 182 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Hobart", @@ -357,7 +665,15 @@ "iso3166_1": "AU", "latitude": -42.8821, "longitude": 147.3272, - "local_languages": ["en-AU"] + "local_languages": ["en-AU"], + "postal_code": { + "country_format_regex": "^\\d{4}$", + "city_regex": ["^7(?:0(?:0[0-24-9]|1[0-25-9]|2[13]|5[0-35])|170)$"], + "examples": ["7000", "7001", "7002"], + "source": "derived from GeoNames postal data (radius, r=15km)", + "codes_matched": 25, + "notes": "Hobart approx. 7000-7099. Enumerated from 25 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Wellington", @@ -367,7 +683,15 @@ "iso3166_1": "NZ", "latitude": -41.2865, "longitude": 174.7762, - "local_languages": ["en", "mi"] + "local_languages": ["en", "mi"], + "postal_code": { + "country_format_regex": "^\\d{4}$", + "city_regex": ["^60(?:1[12]|2[1-3]|3[57])$"], + "examples": ["6011", "6012", "6021"], + "source": "derived from GeoNames postal data (radius, r=12km)", + "codes_matched": 7, + "notes": "Wellington City = 6011-6037. Wider region extends to 5010-5999. Enumerated from 7 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Auckland", @@ -377,7 +701,17 @@ "iso3166_1": "NZ", "latitude": -36.8485, "longitude": 174.7633, - "local_languages": ["en", "mi"] + "local_languages": ["en", "mi"], + "postal_code": { + "country_format_regex": "^\\d{4}$", + "city_regex": [ + "^(?:0(?:6(?:0[024]|1[0248]|2[024679]|3[02]|4[0-5]|5[0-7]|6[0-3])|7(?:4\\d|5[0-8]|82|9[2-4])|8(?:1[04]|20|41|9[12])|9(?:30|43))|1(?:0(?:[47][12]|[56][0-2]|1[01]|2[1-6])|1(?:4[0-57-9]|5[01])|24[56]|3(?:4[024-9]|5[0-2])|44[0356]|54[1-6]|64[0-3]|74[0-6]|84[02])|2(?:0(?:1[02-468]|2[2-5])|1(?:0[2-5]|4[0-79]|5[013-9]|60)|24[0-3]|5(?:7[16]|80)))$" + ], + "examples": ["0600", "0602", "0604"], + "source": "derived from GeoNames postal data (radius, r=25km)", + "codes_matched": 169, + "notes": "Auckland is non-contiguous: 0600-0999 (north/west), 1010-1081 (central), 2010-2699 (south). Enumerated from 169 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Christchurch", @@ -387,7 +721,15 @@ "iso3166_1": "NZ", "latitude": -43.532, "longitude": 172.6306, - "local_languages": ["en", "mi"] + "local_languages": ["en", "mi"], + "postal_code": { + "country_format_regex": "^\\d{4}$", + "city_regex": ["^80(?:[46][12]|[58][1-3]|1[134]|2[2-5])$"], + "examples": ["8011", "8013", "8014"], + "source": "derived from GeoNames postal data (radius, r=15km)", + "codes_matched": 17, + "notes": "Christchurch City = 8011-8062. Canterbury region extends 7600-8999. Enumerated from 17 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Nelson", @@ -397,7 +739,15 @@ "iso3166_1": "NZ", "latitude": -41.2706, "longitude": 173.284, - "local_languages": ["en", "mi"] + "local_languages": ["en", "mi"], + "postal_code": { + "country_format_regex": "^\\d{4}$", + "city_regex": ["^70(?:1[01]|4[0-79])$"], + "examples": ["7010", "7011", "7040"], + "source": "derived from GeoNames postal data (radius, r=10km)", + "codes_matched": 11, + "notes": "Nelson City = 7010-7011 and 7040-7047. Enumerated from 11 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Munich", @@ -407,7 +757,17 @@ "iso3166_1": "DE", "latitude": 48.1351, "longitude": 11.582, - "local_languages": ["de"] + "local_languages": ["de"], + "postal_code": { + "country_format_regex": "^\\d{5}$", + "city_regex": [ + "^8(?:0(?:33[135-79]|469|53[89]|6(?:3[46-9]|8[679])|79[6-9]|80[1-579]|9(?:3[3579]|9[23579]))|1(?:[89]2[579]|24[1357-9]|3(?:69|7[13579])|47[5-79]|5(?:39|4[13579])|6(?:6[79]|7[13579])|73[579]))$" + ], + "examples": ["80331", "80333", "80335"], + "source": "derived from GeoNames postal data (name, r=20km)", + "codes_matched": 75, + "notes": "Munich = 80331-81929. Enumerated from 75 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Berlin", @@ -417,7 +777,17 @@ "iso3166_1": "DE", "latitude": 52.52, "longitude": 13.405, - "local_languages": ["de"] + "local_languages": ["de"], + "postal_code": { + "country_format_regex": "^\\d{5}$", + "city_regex": [ + "^1(?:0(?:[68]2[3579]|1(?:1[579]|7[89])|3(?:1[57-9]|6[579])|4[03][579]|5(?:5[13579]|8[579])|7(?:[07][79]|[18][13579]))|1011|2(?:0(?:4[3579]|5[13579]|99)|1(?:[06][13579]|5[79])|2(?:[47][79]|0[3579])|3(?:0[579]|4[79]|5[13579])|4(?:3[579]|59|8[79])|5(?:2[4679]|5[579]|8[79])|6(?:[17]9|2[1379]|8[13579]))|3(?:0(?:5[13579]|8[689])|1(?:2[579]|5[689]|8[79])|3(?:4[79]|5[13579])|4(?:[36][579]|0[3579])|5(?:[89][13579]|0[3579])|62[79])|4(?:0(?:5[023579]|89)|1(?:[02]9|[69][3579]|31)))$" + ], + "examples": ["10115", "10117", "10119"], + "source": "derived from GeoNames postal data (name, r=30km)", + "codes_matched": 182, + "notes": "Berlin = 10115-14199. Enumerated from 182 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Cologne", @@ -427,7 +797,17 @@ "iso3166_1": "DE", "latitude": 50.9375, "longitude": 6.9603, - "local_languages": ["de"] + "local_languages": ["de"], + "postal_code": { + "country_format_regex": "^\\d{5}$", + "city_regex": [ + "^5(?:0(?:6(?:6[78]|7[0246-9])|7(?:3[3579]|6[579])|8(?:2[3579]|5[89])|9(?:3[13579]|6[89]|9[679]))|1(?:06[13579]|1[04][3579]))$" + ], + "examples": ["50667", "50668", "50670"], + "source": "derived from GeoNames postal data (name, r=20km)", + "codes_matched": 45, + "notes": "Cologne = 50667-51149. Enumerated from 45 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Bamberg", @@ -437,7 +817,15 @@ "iso3166_1": "DE", "latitude": 49.8916, "longitude": 10.8916, - "local_languages": ["de"] + "local_languages": ["de"], + "postal_code": { + "country_format_regex": "^\\d{5}$", + "city_regex": ["^960(?:4[79]|5[02])$"], + "examples": ["96047", "96049", "96050"], + "source": "derived from GeoNames postal data (name, r=15km)", + "codes_matched": 4, + "notes": "Bamberg = 96047-96052. Enumerated from 4 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Brussels", @@ -447,7 +835,15 @@ "iso3166_1": "BE", "latitude": 50.8503, "longitude": 4.3517, - "local_languages": ["fr", "nl"] + "local_languages": ["fr", "nl"], + "postal_code": { + "country_format_regex": "^\\d{4}$", + "city_regex": ["^1(?:0(?:[02-79]0|8[0-3])|1[2-9]0|2[01]0)$"], + "examples": ["1000", "1020", "1030"], + "source": "derived from GeoNames postal data (radius, r=8km)", + "codes_matched": 22, + "notes": "Brussels-Capital Region = 1000-1210. Enumerated from 22 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Antwerp", @@ -457,7 +853,15 @@ "iso3166_1": "BE", "latitude": 51.2194, "longitude": 4.4025, - "local_languages": ["nl"] + "local_languages": ["nl"], + "postal_code": { + "country_format_regex": "^\\d{4}$", + "city_regex": ["^20(?:[02-6]0|18)$"], + "examples": ["2000", "2018", "2020"], + "source": "derived from GeoNames postal data (name, r=20km)", + "codes_matched": 7, + "notes": "City of Antwerp = 2000-2660; 2xxx also covers other Antwerp Province towns. Enumerated from 7 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Bruges", @@ -467,7 +871,15 @@ "iso3166_1": "BE", "latitude": 51.2093, "longitude": 3.2247, - "local_languages": ["nl"] + "local_languages": ["nl"], + "postal_code": { + "country_format_regex": "^\\d{4}$", + "city_regex": ["^8(?:[02]00|3[148]0)$"], + "examples": ["8000", "8200", "8310"], + "source": "derived from GeoNames postal data (radius, r=12km)", + "codes_matched": 5, + "notes": "Bruges uses discrete codes: 8000 (centre), 8200, 8310, 8340, 8380 (submunicipalities). Enumerated from 5 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "London", @@ -477,7 +889,17 @@ "iso3166_1": "GB", "latitude": 51.5074, "longitude": -0.1278, - "local_languages": ["en-GB"] + "local_languages": ["en-GB"], + "postal_code": { + "country_format_regex": "^[A-Z]{1,2}\\d[A-Z\\d]? ?\\d[A-Z]{2}$", + "city_regex": [ + "^(?:E(?:1W|C(?:[14][AMNRVY]|2[AMNPRVY]|3[AMNPV]))|SW1[EHPV-Y]|W(?:1[B-DF-HJKS-UW]|C(?:1[ABEHNRVX]|2[ABEHNR]))) ?\\d[A-Z]{2}$" + ], + "examples": ["E1W 1AA", "EC1A 1AA", "EC1M 1AA"], + "source": "derived from GeoNames postal data (name, r=25km)", + "codes_matched": 58, + "notes": "First pattern = inner London postcode areas; second = outer Greater London areas. Dataset is outward-code level; inward code appended. Enumerated from 58 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Bristol", @@ -487,7 +909,15 @@ "iso3166_1": "GB", "latitude": 51.4545, "longitude": -2.5879, - "local_languages": ["en-GB"] + "local_languages": ["en-GB"], + "postal_code": { + "country_format_regex": "^[A-Z]{1,2}\\d[A-Z\\d]? ?\\d[A-Z]{2}$", + "city_regex": ["^BS(?:[1256]|3[12459]?|4[01]) ?\\d[A-Z]{2}$"], + "examples": ["BS1 1AA", "BS2 1AA", "BS3 1AA"], + "source": "derived from GeoNames postal data (name, r=20km)", + "codes_matched": 12, + "notes": "BS postcode area (also covers parts of North Somerset / South Gloucestershire). Dataset is outward-code level; inward code appended. Enumerated from 12 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Edinburgh", @@ -497,7 +927,15 @@ "iso3166_1": "GB", "latitude": 55.9533, "longitude": -3.1883, - "local_languages": ["en-GB", "gd"] + "local_languages": ["en-GB", "gd"], + "postal_code": { + "country_format_regex": "^[A-Z]{1,2}\\d[A-Z\\d]? ?\\d[A-Z]{2}$", + "city_regex": ["^EH(?:[2-9]|1[12]?) ?\\d[A-Z]{2}$"], + "examples": ["EH1 1AA", "EH11 1AA", "EH12 1AA"], + "source": "derived from GeoNames postal data (name, r=20km)", + "codes_matched": 11, + "notes": "EH postcode area. Dataset is outward-code level; inward code appended. Enumerated from 11 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Glasgow", @@ -507,7 +945,15 @@ "iso3166_1": "GB", "latitude": 55.8642, "longitude": -4.2518, - "local_languages": ["en-GB", "gd"] + "local_languages": ["en-GB", "gd"], + "postal_code": { + "country_format_regex": "^[A-Z]{1,2}\\d[A-Z\\d]? ?\\d[A-Z]{2}$", + "city_regex": ["^G(?:[12][23]?|3[14]?|4[0-24]?|52?) ?\\d[A-Z]{2}$"], + "examples": ["G1 1AA", "G12 1AA", "G13 1AA"], + "source": "derived from GeoNames postal data (name, r=20km)", + "codes_matched": 16, + "notes": "G postcode area. Dataset is outward-code level; inward code appended. Enumerated from 16 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Prague", @@ -517,7 +963,17 @@ "iso3166_1": "CZ", "latitude": 50.0755, "longitude": 14.4378, - "local_languages": ["cs"] + "local_languages": ["cs"], + "postal_code": { + "country_format_regex": "^\\d{3} ?\\d{2}$", + "city_regex": [ + "^1(?:0[0-246-9]\\ 00|1[089]\\ 00|2[08]\\ 00|30\\ 00|4[0-37-9]\\ 00|5(?:[02489]\\ 00|5\\ (?:[23]1|00))|6[0-59]\\ 00|7[01]\\ 00|8[0-246]\\ 00|9(?:[3689]\\ 00|0\\ (?:00|1[1257])))$" + ], + "examples": ["100 00", "101 00", "102 00"], + "source": "derived from GeoNames postal data (radius, r=15km)", + "codes_matched": 52, + "notes": "Prague = 100 00 - 199 00. Enumerated from 52 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Pilsen", @@ -527,7 +983,15 @@ "iso3166_1": "CZ", "latitude": 49.7384, "longitude": 13.3736, - "local_languages": ["cs"] + "local_languages": ["cs"], + "postal_code": { + "country_format_regex": "^\\d{3} ?\\d{2}$", + "city_regex": ["^3(?:0[17]\\ 00|1[02]\\ 00)$"], + "examples": ["301 00", "307 00", "310 00"], + "source": "derived from GeoNames postal data (radius, r=10km)", + "codes_matched": 4, + "notes": "Plzen = 301 00 - 312 00. Enumerated from 4 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Amsterdam", @@ -537,7 +1001,17 @@ "iso3166_1": "NL", "latitude": 52.3676, "longitude": 4.9041, - "local_languages": ["nl"] + "local_languages": ["nl"], + "postal_code": { + "country_format_regex": "^\\d{4} ?[A-Z]{2}$", + "city_regex": [ + "^1(?:0(?:[157][1-9]|[29][1-8]|[34][1-7]|6\\d|8[1-367])|10[1-9]) ?[A-Z]{2}$" + ], + "examples": ["1011 AB", "1012 AB", "1013 AB"], + "source": "derived from GeoNames postal data (name, r=15km)", + "codes_matched": 81, + "notes": "Amsterdam = 1000-1109 numeric part. Dataset is 4-digit numeric; letter pair appended. Enumerated from 81 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Copenhagen", @@ -547,7 +1021,17 @@ "iso3166_1": "DK", "latitude": 55.6761, "longitude": 12.5683, - "local_languages": ["da"] + "local_languages": ["da"], + "postal_code": { + "country_format_regex": "^\\d{4}$", + "city_regex": [ + "^(?:1(?:8(?:[017]\\d|2[02-9]|35|5[0-7]|6[0-8])|9(?:[16][0-7]|0[0-689]|2[0-8]|5\\d|7[0-4]))|2000)$" + ], + "examples": ["1800", "1801", "1802"], + "source": "derived from GeoNames postal data (sub, r=12km)", + "codes_matched": 107, + "notes": "Copenhagen K = 1000-1499; Frederiksberg and surrounding districts = 1800-2500. Enumerated from 107 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Warsaw", @@ -557,7 +1041,17 @@ "iso3166_1": "PL", "latitude": 52.2297, "longitude": 21.0122, - "local_languages": ["pl"] + "local_languages": ["pl"], + "postal_code": { + "country_format_regex": "^\\d{2}-\\d{3}$", + "city_regex": [ + "^0(?:0\\-(?:[013-7]\\d|2[0-8]|8[0-79]|91)|1\\-(?:[13-57-9]\\d|[02][0-6]|6[0-57-9])|2\\-\\d\\d|3\\-(?:[0-79]\\d|8[0-46-9])|4\\-(?:[0-46-8]\\d|5[0-7]|9[05689]))\\d$" + ], + "examples": ["00-002", "00-003", "00-004"], + "source": "derived from GeoNames postal data (radius, r=15km)", + "codes_matched": 3661, + "notes": "Warsaw = 00-001 to 04-999. Compressed one digit short of full length. Enumerated from 3661 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Krakow", @@ -567,7 +1061,17 @@ "iso3166_1": "PL", "latitude": 50.0647, "longitude": 19.945, - "local_languages": ["pl"] + "local_languages": ["pl"], + "postal_code": { + "country_format_regex": "^\\d{2}-\\d{3}$", + "city_regex": [ + "^3(?:0\\-(?:[036]\\d|[27][0-589]|1[0-59]|4[0-49]|5[0-8]|8[0-69]|9[0469])|1\\-(?:[45][0-8]|[89]\\d|0[0-7]|1[0-6]|2[0-46-8]|3[0-79]|6[0-579]|7[0-2569]))\\d$" + ], + "examples": ["30-001", "30-002", "30-003"], + "source": "derived from GeoNames postal data (name, r=20km)", + "codes_matched": 1106, + "notes": "Krakow = 30-001 to 31-999. Compressed one digit short of full length. Enumerated from 1106 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Rome", @@ -577,7 +1081,15 @@ "iso3166_1": "IT", "latitude": 41.9028, "longitude": 12.4964, - "local_languages": ["it"] + "local_languages": ["it"], + "postal_code": { + "country_format_regex": "^\\d{5}$", + "city_regex": ["^001(?:[3-9][1-9]|1[89]|2[0-8])$"], + "examples": ["00118", "00119", "00120"], + "source": "derived from GeoNames postal data (name, r=20km)", + "codes_matched": 74, + "notes": "Rome = 00118-00199 (00120 = Vatican City, a separate state). Enumerated from 74 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Milan", @@ -587,7 +1099,15 @@ "iso3166_1": "IT", "latitude": 45.4642, "longitude": 9.19, - "local_languages": ["it"] + "local_languages": ["it"], + "postal_code": { + "country_format_regex": "^\\d{5}$", + "city_regex": ["^201(?:[2-5][1-9]|6[12])$"], + "examples": ["20121", "20122", "20123"], + "source": "derived from GeoNames postal data (name, r=15km)", + "codes_matched": 38, + "notes": "Milan city = 20121-20162. Enumerated from 38 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Barcelona", @@ -597,7 +1117,15 @@ "iso3166_1": "ES", "latitude": 41.3851, "longitude": 2.1734, - "local_languages": ["ca", "es"] + "local_languages": ["ca", "es"], + "postal_code": { + "country_format_regex": "^\\d{5}$", + "city_regex": ["^080(?:[1-3]\\d|0[1-9]|4[0-2]|7[015]|80)$"], + "examples": ["08001", "08002", "08003"], + "source": "derived from GeoNames postal data (name, r=15km)", + "codes_matched": 46, + "notes": "Barcelona city = 08001-08042. Province is 08xxx. Enumerated from 46 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Madrid", @@ -607,7 +1135,15 @@ "iso3166_1": "ES", "latitude": 40.4168, "longitude": -3.7038, - "local_languages": ["es"] + "local_languages": ["es"], + "postal_code": { + "country_format_regex": "^\\d{5}$", + "city_regex": ["^280(?:[1-4]\\d|0[1-9]|5[0-5]|7[01]|8[0235-7])$"], + "examples": ["28001", "28002", "28003"], + "source": "derived from GeoNames postal data (name, r=20km)", + "codes_matched": 63, + "notes": "Madrid city = 28001-28080. Community of Madrid is 28xxx. Enumerated from 63 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Paris", @@ -617,7 +1153,15 @@ "iso3166_1": "FR", "latitude": 48.8566, "longitude": 2.3522, - "local_languages": ["fr"] + "local_languages": ["fr"], + "postal_code": { + "country_format_regex": "^\\d{5}$", + "city_regex": ["^750(?:[01]\\d|20)$"], + "examples": ["75000", "75001", "75002"], + "source": "derived from GeoNames postal data (name, r=15km)", + "codes_matched": 21, + "notes": "Paris = 75001-75020, plus 75116 (a second code for the 16th arrondissement). Enumerated from 21 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Lyon", @@ -627,7 +1171,15 @@ "iso3166_1": "FR", "latitude": 45.764, "longitude": 4.8357, - "local_languages": ["fr"] + "local_languages": ["fr"], + "postal_code": { + "country_format_regex": "^\\d{5}$", + "city_regex": ["^6900\\d$"], + "examples": ["69000", "69001", "69002"], + "source": "derived from GeoNames postal data (name, r=15km)", + "codes_matched": 10, + "notes": "Lyon = 69001-69009 (nine arrondissements). Enumerated from 10 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Stockholm", @@ -637,7 +1189,17 @@ "iso3166_1": "SE", "latitude": 59.3293, "longitude": 18.0686, - "local_languages": ["sv"] + "local_languages": ["sv"], + "postal_code": { + "country_format_regex": "^\\d{3} ?\\d{2}$", + "city_regex": [ + "^1(?:0(?:[569]\\ \\d|[02]\\ [0-7]|1\\ [0-35]|3\\ [0-35-9]|4\\ [02-6]|7\\ [0-26-9])|1(?:[0-2478]\\ \\d|[35]\\ [02-9]|6\\ [0-46-9])|2(?:[17]\\ 2|0\\ [0-36-9]|5\\ 8|6\\ [0-278])|3(?:1\\ [12589]|5\\ 7)|4(?:1\\ [89]|5\\ 8)|6(?:[49]\\ [89]|1\\ 8|2\\ [18]|3\\ 9)|7(?:0\\ 8|1\\ [17]|2\\ [89]|3\\ [13-68])|82\\ 8|95\\ 8)\\d$" + ], + "examples": ["100 04", "100 05", "100 12"], + "source": "derived from GeoNames postal data (name, r=20km)", + "codes_matched": 1139, + "notes": "Stockholm County = 1xx xx. Compressed one digit short of full length. Enumerated from 1139 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Gothenburg", @@ -647,7 +1209,17 @@ "iso3166_1": "SE", "latitude": 57.7089, "longitude": 11.9746, - "local_languages": ["sv"] + "local_languages": ["sv"], + "postal_code": { + "country_format_regex": "^\\d{3} ?\\d{2}$", + "city_regex": [ + "^4(?:0(?:[89]\\ \\d|0\\ [1-79]|1\\ [12589]|2\\ [2-7]|3\\ [0-4]|4\\ [0238]|5\\ [0-57-9]|7\\ 0)|1(?:[26]\\ [024-9]|1\\ [0-489]|3\\ [0-2489]|4\\ [025-8]|5\\ \\d|7\\ [0-8]|8\\ [0-47]))\\d$" + ], + "examples": ["400 10", "400 12", "400 13"], + "source": "derived from GeoNames postal data (name, r=20km)", + "codes_matched": 755, + "notes": "Gothenburg = 400 00 - 425 99. Compressed one digit short of full length. Enumerated from 755 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Oslo", @@ -657,7 +1229,17 @@ "iso3166_1": "NO", "latitude": 59.9139, "longitude": 10.7522, - "local_languages": ["no"] + "local_languages": ["no"], + "postal_code": { + "country_format_regex": "^\\d{4}$", + "city_regex": [ + "^(?:0(?:[56][0-25-9]|0[0-5]|1\\d|2[013-8]|3[0-8]|4[0-24-9]|7[015-9]|8[04-9]|9[015-8])|1(?:[01][015-8]|2[015-9]))\\d$" + ], + "examples": ["0001", "0010", "0015"], + "source": "derived from GeoNames postal data (name, r=20km)", + "codes_matched": 631, + "notes": "Oslo = 0001-1295; the 0xxx block is the city proper. Compressed one digit short of full length. Enumerated from 631 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Dublin", @@ -667,7 +1249,15 @@ "iso3166_1": "IE", "latitude": 53.3498, "longitude": -6.2603, - "local_languages": ["en", "ga"] + "local_languages": ["en", "ga"], + "postal_code": { + "country_format_regex": "^(?:D6W|[AC-FHKNPRTV-Y]\\d{2}) ?[0-9AC-FHKNPRTV-Y]{4}$", + "city_regex": ["^D(?:0[1-9]|1[0-8]|2[024]|6W) ?[0-9AC-FHKNPRTV-Y]{4}$"], + "examples": ["D01 XY35", "D02 XY35", "D03 XY35"], + "source": "derived from GeoNames postal data (sub, r=25km)", + "codes_matched": 22, + "notes": "Dublin routing keys: D01-D18, D20, D22, D24 and the exception D6W. Note D19, D21, D23 are not issued. Dataset is routing-key level; unique identifier appended. Enumerated from 22 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Vienna", @@ -677,7 +1267,15 @@ "iso3166_1": "AT", "latitude": 48.2082, "longitude": 16.3738, - "local_languages": ["de-AT"] + "local_languages": ["de-AT"], + "postal_code": { + "country_format_regex": "^\\d{4}$", + "city_regex": ["^1(?:0[1-9]0|1\\d0|2[0-3]0)$"], + "examples": ["1010", "1020", "1030"], + "source": "derived from GeoNames postal data (field)", + "codes_matched": 23, + "notes": "Vienna = 1010-1230, always ending in 0 (district number x10). Enumerated from 23 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Zurich", @@ -687,7 +1285,17 @@ "iso3166_1": "CH", "latitude": 47.3769, "longitude": 8.5417, - "local_languages": ["de-CH"] + "local_languages": ["de-CH"], + "postal_code": { + "country_format_regex": "^\\d{4}$", + "city_regex": [ + "^80(?:0[0-68]|1[027]|2[247]|3[1246-8]|4[0-24-9]|5[0-3578]|6[034]|7[05]|8[178]|9[0-389])$" + ], + "examples": ["8000", "8001", "8002"], + "source": "derived from GeoNames postal data (name, r=12km)", + "codes_matched": 50, + "notes": "City of Zurich = 8001-8064. Enumerated from 50 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Tallinn", @@ -697,7 +1305,17 @@ "iso3166_1": "EE", "latitude": 59.437, "longitude": 24.7536, - "local_languages": ["et"] + "local_languages": ["et"], + "postal_code": { + "country_format_regex": "^\\d{5}$", + "city_regex": [ + "^(?:1(?:0(?:001|1(?:[2-4]\\d|1[1-9]|5[0-689])|3(?:1[1-9]|2[013])|41[1-6]|6(?:1[1-9]|2[01])|9(?:1[1-9]|2[0-2]))|1(?:[23]1[1-8]|101|41[1-7]|6(?:1[1-9]|2[0-5])|71[1-3]|91[1-4])|2(?:01[1-5]|11[1-3]|6(?:1[1-9]|20)|91[1-9])|3(?:4(?:1[1-9]|2[0-6])|5(?:1[1-9]|2[0-4])|6(?:1[1-9]|2\\d)|81[1267]|91[1-9])|5(?:0(?:[16]5|0[79]|2[69]|30|4[17-9]|5[59]|7[01]|9[4589])|1(?:6[15]|70|8[58]|9[039]))|9086)|7(?:4(?:0(?:0[18]|1[014-69]|20)|20[168])|5(?:3(?:0[1346-8]|1[02]|2[1-357]|31)|4(?:03|12)|511)|69(?:0[12]|1[26]|2[2-4])))$" + ], + "examples": ["10001", "10111", "10112"], + "source": "derived from GeoNames postal data (radius, r=12km)", + "codes_matched": 289, + "notes": "Tallinn = 10xxx-13xxx. Enumerated from 289 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Denver", @@ -707,7 +1325,17 @@ "iso3166_1": "US", "latitude": 39.7392, "longitude": -104.9903, - "local_languages": ["en-US"] + "local_languages": ["en-US"], + "postal_code": { + "country_format_regex": "^\\d{5}(?:-\\d{4})?$", + "city_regex": [ + "^802(?:[23]\\d|0[1-9]|1[0-24-9]|4[346-9]|5[0-2679]|6[0-6]|7[134]|81|9[01349])$" + ], + "examples": ["80201", "80202", "80203"], + "source": "derived from GeoNames postal data (name, r=30km)", + "codes_matched": 66, + "notes": "Denver = 80202-80249 plus 80290-80299. Enumerated from 66 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Portland", @@ -717,7 +1345,17 @@ "iso3166_1": "US", "latitude": 45.5152, "longitude": -122.6784, - "local_languages": ["en-US"] + "local_languages": ["en-US"], + "postal_code": { + "country_format_regex": "^\\d{5}(?:-\\d{4})?$", + "city_regex": [ + "^972(?:[59][0-468]|0[1-9]|1\\d|2[0-57-9]|3[0-3689]|4[02]|6[6-9]|8[0-36])$" + ], + "examples": ["97201", "97202", "97203"], + "source": "derived from GeoNames postal data (name, r=30km)", + "codes_matched": 60, + "notes": "Portland, Oregon = 972xx. Enumerated from 60 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "San Diego", @@ -727,7 +1365,17 @@ "iso3166_1": "US", "latitude": 32.7157, "longitude": -117.1611, - "local_languages": ["en-US", "es-US"] + "local_languages": ["en-US", "es-US"], + "postal_code": { + "country_format_regex": "^\\d{5}(?:-\\d{4})?$", + "city_regex": [ + "^921(?:0[1-9]|1[0-79]|2[0-469]|3[0-24-9]|4[02579]|5[02-589]|6[0135-9]|7[0-24-79]|8[267]|9[0-35-9])$" + ], + "examples": ["92101", "92102", "92103"], + "source": "derived from GeoNames postal data (name, r=30km)", + "codes_matched": 74, + "notes": "San Diego = 921xx. Enumerated from 74 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Asheville", @@ -737,7 +1385,15 @@ "iso3166_1": "US", "latitude": 35.5951, "longitude": -82.5515, - "local_languages": ["en-US"] + "local_languages": ["en-US"], + "postal_code": { + "country_format_regex": "^\\d{5}(?:-\\d{4})?$", + "city_regex": ["^288(?:0[1-6]|1[03-6])$"], + "examples": ["28801", "28802", "28803"], + "source": "derived from GeoNames postal data (name, r=25km)", + "codes_matched": 11, + "notes": "Asheville = 28801-28816. Enumerated from 11 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Grand Rapids", @@ -747,7 +1403,17 @@ "iso3166_1": "US", "latitude": 42.9634, "longitude": -85.6681, - "local_languages": ["en-US"] + "local_languages": ["en-US"], + "postal_code": { + "country_format_regex": "^\\d{5}(?:-\\d{4})?$", + "city_regex": [ + "^495(?:0[1-8]|1[024-68]|2[358]|3[04]|4[468]|55|60|88|99)$" + ], + "examples": ["49501", "49502", "49503"], + "source": "derived from GeoNames postal data (name, r=25km)", + "codes_matched": 26, + "notes": "Grand Rapids = 495xx. Enumerated from 26 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Chicago", @@ -757,7 +1423,17 @@ "iso3166_1": "US", "latitude": 41.8781, "longitude": -87.6298, - "local_languages": ["en-US", "es-US"] + "local_languages": ["en-US", "es-US"], + "postal_code": { + "country_format_regex": "^\\d{5}(?:-\\d{4})?$", + "city_regex": [ + "^60(?:6(?:0[1-9]|1\\d|2[0-689]|3[0-46-9]|4[0-79]|5[1-79]|6[014689]|7[03-578]|8[0-24-9]|9[013-79])|701)$" + ], + "examples": ["60601", "60602", "60603"], + "source": "derived from GeoNames postal data (name, r=30km)", + "codes_matched": 84, + "notes": "Chicago = 606xx-608xx. Enumerated from 84 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Seattle", @@ -767,7 +1443,17 @@ "iso3166_1": "US", "latitude": 47.6062, "longitude": -122.3321, - "local_languages": ["en-US"] + "local_languages": ["en-US"], + "postal_code": { + "country_format_regex": "^\\d{5}(?:-\\d{4})?$", + "city_regex": [ + "^981(?:[01][1-9]|2[124-79]|3[134689]|4[14-68]|5[458]|6[014-68]|7[04578]|8[158]|9[014589])$" + ], + "examples": ["98101", "98102", "98103"], + "source": "derived from GeoNames postal data (name, r=30km)", + "codes_matched": 59, + "notes": "Seattle = 981xx. Enumerated from 59 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Austin", @@ -777,7 +1463,17 @@ "iso3166_1": "US", "latitude": 30.2672, "longitude": -97.7431, - "local_languages": ["en-US", "es-US"] + "local_languages": ["en-US", "es-US"], + "postal_code": { + "country_format_regex": "^\\d{5}(?:-\\d{4})?$", + "city_regex": [ + "^7(?:33(?:01|44)|87(?:[1-356]\\d|0[1-589]|4[124-9]|7[2-489]|83|99))$" + ], + "examples": ["73301", "73344", "78701"], + "source": "derived from GeoNames postal data (name, r=30km)", + "codes_matched": 74, + "notes": "Austin = 787xx. Enumerated from 74 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Boston", @@ -787,7 +1483,17 @@ "iso3166_1": "US", "latitude": 42.3601, "longitude": -71.0589, - "local_languages": ["en-US"] + "local_languages": ["en-US"], + "postal_code": { + "country_format_regex": "^\\d{5}(?:-\\d{4})?$", + "city_regex": [ + "^02(?:1(?:[36]3|0[89]|1[0-8]|2[378]|9[69])|2(?:0[13-6]|1[0-257]|22|41|66|8[34]|9[378]))$" + ], + "examples": ["02108", "02109", "02110"], + "source": "derived from GeoNames postal data (name, r=25km)", + "codes_matched": 36, + "notes": "Boston = 021xx and 022xx. Enumerated from 36 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Philadelphia", @@ -797,7 +1503,17 @@ "iso3166_1": "US", "latitude": 39.9526, "longitude": -75.1652, - "local_languages": ["en-US"] + "local_languages": ["en-US"], + "postal_code": { + "country_format_regex": "^\\d{5}(?:-\\d{4})?$", + "city_regex": [ + "^19(?:0(?:19|9[239])|1(?:[2-4]\\d|0[1-9]|1[0-689]|5[0-5]|6[0-2]|7[0-35-9]|8[1-578]|9[0-7])|2(?:44|55))$" + ], + "examples": ["19019", "19092", "19093"], + "source": "derived from GeoNames postal data (name, r=30km)", + "codes_matched": 87, + "notes": "Philadelphia = 191xx. Enumerated from 87 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Brooklyn", @@ -807,7 +1523,15 @@ "iso3166_1": "US", "latitude": 40.6782, "longitude": -73.9442, - "local_languages": ["en-US", "es-US"] + "local_languages": ["en-US", "es-US"], + "postal_code": { + "country_format_regex": "^\\d{5}(?:-\\d{4})?$", + "city_regex": ["^112(?:[13]\\d|0[1-9]|2[0-689]|4[1-3579]|5[126])$"], + "examples": ["11201", "11202", "11203"], + "source": "derived from GeoNames postal data (name, r=25km)", + "codes_matched": 47, + "notes": "Brooklyn = 112xx. Enumerated from 47 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Milwaukee", @@ -817,7 +1541,17 @@ "iso3166_1": "US", "latitude": 43.0389, "longitude": -87.9065, - "local_languages": ["en-US"] + "local_languages": ["en-US"], + "postal_code": { + "country_format_regex": "^\\d{5}(?:-\\d{4})?$", + "city_regex": [ + "^532(?:0[1-9]|1\\d|2[0-8]|3[347]|59|6[378]|7[48]|88|9[035])$" + ], + "examples": ["53201", "53202", "53203"], + "source": "derived from GeoNames postal data (name, r=25km)", + "codes_matched": 41, + "notes": "Milwaukee = 532xx. Enumerated from 41 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Richmond", @@ -827,7 +1561,17 @@ "iso3166_1": "US", "latitude": 37.5407, "longitude": -77.436, - "local_languages": ["en-US"] + "local_languages": ["en-US"], + "postal_code": { + "country_format_regex": "^\\d{5}(?:-\\d{4})?$", + "city_regex": [ + "^23(?:173|2(?:1[89]|2[0-7]|3[024-7]|4[19]|50|6[019]|7[4689]|8[24-69]|9[0-3578]))$" + ], + "examples": ["23173", "23218", "23219"], + "source": "derived from GeoNames postal data (name, r=25km)", + "codes_matched": 39, + "notes": "Richmond = 232xx. Enumerated from 39 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Cincinnati", @@ -837,7 +1581,17 @@ "iso3166_1": "US", "latitude": 39.1031, "longitude": -84.512, - "local_languages": ["en-US"] + "local_languages": ["en-US"], + "postal_code": { + "country_format_regex": "^\\d{5}(?:-\\d{4})?$", + "city_regex": [ + "^45(?:2(?:[01][1-9]|[34]\\d|2[0-79]|5[0-58]|6[2-47-9]|7[013-57]|80|9[689])|999)$" + ], + "examples": ["45201", "45202", "45203"], + "source": "derived from GeoNames postal data (name, r=25km)", + "codes_matched": 71, + "notes": "Cincinnati = 452xx. Enumerated from 71 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "St. Louis", @@ -847,7 +1601,17 @@ "iso3166_1": "US", "latitude": 38.627, "longitude": -90.1994, - "local_languages": ["en-US"] + "local_languages": ["en-US"], + "postal_code": { + "country_format_regex": "^\\d{5}(?:-\\d{4})?$", + "city_regex": [ + "^631(?:[1-3]\\d|0[1-9]|4[013-7]|5[015-8]|6[034679]|7[17-9]|8[028]|9[579])$" + ], + "examples": ["63101", "63102", "63103"], + "source": "derived from GeoNames postal data (name, r=25km)", + "codes_matched": 68, + "notes": "St. Louis = 631xx. Enumerated from 68 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Tampa", @@ -857,7 +1621,17 @@ "iso3166_1": "US", "latitude": 27.9506, "longitude": -82.4572, - "local_languages": ["en-US", "es-US"] + "local_languages": ["en-US", "es-US"], + "postal_code": { + "country_format_regex": "^\\d{5}(?:-\\d{4})?$", + "city_regex": [ + "^336(?:0[1-9]|1\\d|2[0-69]|3[013-57]|4[67]|5[05]|6[0-4]|7[2-579]|8[0-24-9]|94)$" + ], + "examples": ["33601", "33602", "33603"], + "source": "derived from GeoNames postal data (name, r=30km)", + "codes_matched": 58, + "notes": "Tampa = 336xx. Enumerated from 58 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Minneapolis", @@ -867,7 +1641,17 @@ "iso3166_1": "US", "latitude": 44.9778, "longitude": -93.265, - "local_languages": ["en-US"] + "local_languages": ["en-US"], + "postal_code": { + "country_format_regex": "^\\d{5}(?:-\\d{4})?$", + "city_regex": [ + "^554(?:[1-4]\\d|0[1-9]|5[04589]|6[07]|7[02-489]|8[03-8])$" + ], + "examples": ["55401", "55402", "55403"], + "source": "derived from GeoNames postal data (name, r=25km)", + "codes_matched": 69, + "notes": "Minneapolis = 554xx. Enumerated from 69 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Burlington", @@ -877,7 +1661,15 @@ "iso3166_1": "US", "latitude": 44.4759, "longitude": -73.2121, - "local_languages": ["en-US"] + "local_languages": ["en-US"], + "postal_code": { + "country_format_regex": "^\\d{5}(?:-\\d{4})?$", + "city_regex": ["^0540[12568]$"], + "examples": ["05401", "05402", "05405"], + "source": "derived from GeoNames postal data (name, r=20km)", + "codes_matched": 5, + "notes": "Burlington = 054xx. Enumerated from 5 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Portland", @@ -887,7 +1679,15 @@ "iso3166_1": "US", "latitude": 43.6591, "longitude": -70.2568, - "local_languages": ["en-US"] + "local_languages": ["en-US"], + "postal_code": { + "country_format_regex": "^\\d{5}(?:-\\d{4})?$", + "city_regex": ["^041(?:0[1-49]|12|2[2-4])$"], + "examples": ["04101", "04102", "04103"], + "source": "derived from GeoNames postal data (name, r=25km)", + "codes_matched": 9, + "notes": "Portland, Maine = 041xx. Distinct from Portland, Oregon (972xx). Enumerated from 9 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Atlanta", @@ -897,7 +1697,17 @@ "iso3166_1": "US", "latitude": 33.749, "longitude": -84.388, - "local_languages": ["en-US"] + "local_languages": ["en-US"], + "postal_code": { + "country_format_regex": "^\\d{5}(?:-\\d{4})?$", + "city_regex": [ + "^3(?:03(?:0[1-9]|1\\d|2[0-24-9]|3[1246-9]|4[0-689]|5[03-578]|6[013489]|7[014578]|8[0458]|9[2468])|11(?:0[67]|26|3[169]|5[06]|9[2356]))$" + ], + "examples": ["30301", "30302", "30303"], + "source": "derived from GeoNames postal data (name, r=30km)", + "codes_matched": 82, + "notes": "Atlanta city = 303xx; the wider metro extends across 303xx-309xx. Enumerated from 82 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Toronto", @@ -907,7 +1717,17 @@ "iso3166_1": "CA", "latitude": 43.651, "longitude": -79.347, - "local_languages": ["en-CA"] + "local_languages": ["en-CA"], + "postal_code": { + "country_format_regex": "^[ABCEGHJ-NPRSTVXY]\\d[ABCEGHJ-NPRSTV-Z] ?\\d[ABCEGHJ-NPRSTV-Z]\\d$", + "city_regex": [ + "^M(?:1[BCEGHJ-NPR-TV-X]|2[HJ-NPR]|3[A-CHJ-N]|4[A-CEGHJ-NPR-TV-Y]|5[A-CEGHJ-NPR-TV-X]|6[A-CEGHJ-NPRS]|7[AY]|8[V-Z]|9[A-CL-NPRVW]) ?\\d[ABCEGHJ-NPRSTV-Z]\\d$" + ], + "examples": ["M1B 1A1", "M1C 1A1", "M1E 1A1"], + "source": "derived from GeoNames postal data (radius, r=30km)", + "codes_matched": 102, + "notes": "Toronto = the entire 'M' forward sortation area. Dataset is fsa-level; local delivery unit appended. Enumerated from 102 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Vancouver", @@ -917,7 +1737,17 @@ "iso3166_1": "CA", "latitude": 49.2827, "longitude": -123.1207, - "local_languages": ["en-CA"] + "local_languages": ["en-CA"], + "postal_code": { + "country_format_regex": "^[ABCEGHJ-NPRSTVXY]\\d[ABCEGHJ-NPRSTV-Z] ?\\d[ABCEGHJ-NPRSTV-Z]\\d$", + "city_regex": [ + "^V(?:5[K-NPR-TV-Z]|6[A-CEGHJ-NPRSZ]|7[GHJ-NPR-TV-Y]) ?\\d[ABCEGHJ-NPRSTV-Z]\\d$" + ], + "examples": ["V5K 1A1", "V5L 1A1", "V5M 1A1"], + "source": "derived from GeoNames postal data (sub, r=20km)", + "codes_matched": 43, + "notes": "Vancouver proper = V5/V6; V7 covers North/West Vancouver and Richmond. Dataset is fsa-level; local delivery unit appended. Enumerated from 43 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Montreal", @@ -927,7 +1757,17 @@ "iso3166_1": "CA", "latitude": 45.5017, "longitude": -73.5673, - "local_languages": ["fr-CA", "en-CA"] + "local_languages": ["fr-CA", "en-CA"], + "postal_code": { + "country_format_regex": "^[ABCEGHJ-NPRSTVXY]\\d[ABCEGHJ-NPRSTV-Z] ?\\d[ABCEGHJ-NPRSTV-Z]\\d$", + "city_regex": [ + "^H(?:[1-3][A-CEGHJ-NPR-TV-Z]|0M|4[A-CEGHJ-NPR-TV-XZ]|5[AB]|7[A-CEGHJ-NPR-TV-Y]|8[NPR-TYZ]|9[A-CGHJPRSW]) ?\\d[ABCEGHJ-NPRSTV-Z]\\d$" + ], + "examples": ["H0M 1A1", "H1A 1A1", "H1B 1A1"], + "source": "derived from GeoNames postal data (radius, r=25km)", + "codes_matched": 118, + "notes": "Montreal = the entire 'H' forward sortation area. Dataset is fsa-level; local delivery unit appended. Enumerated from 118 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Calgary", @@ -937,7 +1777,17 @@ "iso3166_1": "CA", "latitude": 51.0447, "longitude": -114.0719, - "local_languages": ["en-CA"] + "local_languages": ["en-CA"], + "postal_code": { + "country_format_regex": "^[ABCEGHJ-NPRSTVXY]\\d[ABCEGHJ-NPRSTV-Z] ?\\d[ABCEGHJ-NPRSTV-Z]\\d$", + "city_regex": [ + "^T(?:1Y|2[A-CEGHJ-NPR-TV-Z]|3[A-CEGHJ-NPRS]) ?\\d[ABCEGHJ-NPRSTV-Z]\\d$" + ], + "examples": ["T1Y 1A1", "T2A 1A1", "T2B 1A1"], + "source": "derived from GeoNames postal data (sub, r=30km)", + "codes_matched": 35, + "notes": "Calgary uses T1Y, T2A-T2Z and T3A-T3R; the T1/T3 blocks also include nearby communities. Dataset is fsa-level; local delivery unit appended. Enumerated from 35 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Halifax", @@ -947,7 +1797,15 @@ "iso3166_1": "CA", "latitude": 44.6488, "longitude": -63.5752, - "local_languages": ["en-CA"] + "local_languages": ["en-CA"], + "postal_code": { + "country_format_regex": "^[ABCEGHJ-NPRSTVXY]\\d[ABCEGHJ-NPRSTV-Z] ?\\d[ABCEGHJ-NPRSTV-Z]\\d$", + "city_regex": ["^B3[HJ-NPRS] ?\\d[ABCEGHJ-NPRSTV-Z]\\d$"], + "examples": ["B3H 1A1", "B3J 1A1", "B3K 1A1"], + "source": "derived from GeoNames postal data (sub, r=25km)", + "codes_matched": 9, + "notes": "Halifax peninsula/urban = B3x. The wider municipality also uses B2x and B4x. Dataset is fsa-level; local delivery unit appended. Enumerated from 9 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Mexico City", @@ -957,7 +1815,17 @@ "iso3166_1": "MX", "latitude": 19.4326, "longitude": -99.1332, - "local_languages": ["es-MX"] + "local_languages": ["es-MX"], + "postal_code": { + "country_format_regex": "^\\d{5}$", + "city_regex": [ + "^(?:0(?:1(?:[0247]\\d|1[0-8]|3[1-478]|5[0-689]|6[0-5]|8[0-24-7]|9[049])|2(?:[13][0-6]|[49]\\d|0[0-24-9]|2[03-6]|5[0-4]|6[03-8]|7[0-35-9]|8[013-7])|3(?:[3479][0-4]|0[0-2]|1[09]|2[02-4]|5\\d|6[0-6]|8[0-24])|4(?:0[0-4]|1[0-2]|2[013-8]|3\\d|4[0-24-9]|5[013]|6[0-6]|7[0-3]|8[013-579]|9[0-8])|5(?:0[0236]|1[0-3]|2[0-46-8]|3[1-467]|5[03]|6[02])|6(?:0\\d|1[047]|2[024578]|3[05]|4[0357]|5[089]|6[09]|7[024689]|8[024-9]|9[029])|7(?:[289]\\d|[3-5][0-8]|0[0-24-9]|1[013-9]|6[02-8]|7[02-9])|8(?:[028][0-4]|1[06-8]|3[0-2]|4[02]|5[0-268]|6[0-25]|7[0-367]|9[0-3])|9(?:[278]\\d|[14][0-8]|0[0-46-9]|3[0-356]|5[0-35-7]|6[02-46-9]|9[0-467]))|1(?:0(?:[04]0|1[013]|2[09]|3[0235-8]|5[08]|6[0-5]|7[0-4]|8[1-4]|9[0-2])|1(?:[17]0|[24]\\d|[38][0-7]|0[04]|5[0-689]|6[0145]|9[1-35])|2(?:0[078]|20)|3(?:0[0367]|1[128]|2[013578]|3[016]|4[13]|55|6[04])|4(?:[03]\\d|1[1-6]|2[0-7]|4[0-68]|6[0-5])|5(?:[48][0-7]|0[0-2]|1[02]|2\\d|3[0-57-9]|5[0-4]|6[0-8]|7[0-5]|9[04-9])|6(?:0[1-57]|10|3[0-4]|4[14]|62|7[1-58]|8[168])))\\d$" + ], + "examples": ["01000", "01010", "01020"], + "source": "derived from GeoNames postal data (radius, r=20km)", + "codes_matched": 1207, + "notes": "Mexico City = 01000-16999. Compressed one digit short of full length. Enumerated from 1207 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Tijuana", @@ -967,7 +1835,17 @@ "iso3166_1": "MX", "latitude": 32.5149, "longitude": -117.0382, - "local_languages": ["es-MX"] + "local_languages": ["es-MX"], + "postal_code": { + "country_format_regex": "^\\d{5}$", + "city_regex": [ + "^22(?:0(?:[23][045]|00|4[46])|1(?:15|2[03-7]|6[03-5]|70|94)|2(?:06|1[04-7]|50)|4(?:0[056]|24|4[046]|57)|5(?:0[04-6]|1[05-7]|2[3-6]|3[04-7]|5[057]|6[03-7])|6(?:0[04-6]|1[046]|2[4-6]|4[03-5]|5[04]|6[03-7]))$" + ], + "examples": ["22000", "22020", "22024"], + "source": "derived from GeoNames postal data (radius, r=20km)", + "codes_matched": 85, + "notes": "Tijuana = 22000-22699. Enumerated from 85 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Monterrey", @@ -977,7 +1855,17 @@ "iso3166_1": "MX", "latitude": 25.6866, "longitude": -100.3161, - "local_languages": ["es-MX"] + "local_languages": ["es-MX"], + "postal_code": { + "country_format_regex": "^\\d{5}$", + "city_regex": [ + "^64(?:0(?:[235-7]0|0[04679]|1[089]|4[09])|1(?:[239]0|0[024-9]|1[06-9]|4[058]|5[07-9]|6[05-8]|7[08]|8[06])|2(?:[7-9]0|0[04-689]|1[0589]|2[05-9]|3[03-8]|4[04-9]|5[089]|6[04-69])|3(?:[0-257-9]0|3[09]|4[04-9]|6[05-9])|4(?:[0135-79]0|4[02]|8[089])|5(?:[06][08]|[58]0|1[07]|3[06]|4[09]|7[01])|6(?:[06]0|[45][09]|1[089]|2[03])|7(?:[037]0|15|2[03]|5[034]|6[0459]|8[038]|9[08])|8(?:[0-28]0|3[0346]|4[04-69]|5[089]|6[05]|78|9[037-9])|9(?:[1356]0|[04][09]|2[056]|7[089]|8[034679]|9[067]))$" + ], + "examples": ["64000", "64004", "64006"], + "source": "derived from GeoNames postal data (radius, r=18km)", + "codes_matched": 212, + "notes": "Monterrey city = 64000-64999; the metro area extends into 65xxx-67xxx. Enumerated from 212 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Guadalajara", @@ -987,7 +1875,17 @@ "iso3166_1": "MX", "latitude": 20.6597, "longitude": -103.3496, - "local_languages": ["es-MX"] + "local_languages": ["es-MX"], + "postal_code": { + "country_format_regex": "^\\d{5}$", + "city_regex": [ + "^44(?:009|1(?:[01346-9]0|5[08])|2(?:[0378]0|1[04-9]|2[07]|4[0679]|5[07-9]|6[05-8]|9[089])|3(?:[3-8]0|0[067]|2[09]|9[0589])|4(?:[02457]0|1[09]|3[08]|6[079]|9[03])|5(?:[1268]0|[045][09]|3[058])|6(?:[029]0|[18][09]|3[0589]|4[78]|5[06-9]|6[0578]|7[089])|7(?:[078]0|1[069]|2[03-79]|3[079]|4[08]|5[057]|6[05689]|9[09])|8(?:[046]0|1[0578]|2[0457-9]|7[09]|9[05-9])|9(?:[039]0|[14][089]|2[09]|5[069]|6[0679]|7[078]|8[04-79]))$" + ], + "examples": ["44009", "44100", "44110"], + "source": "derived from GeoNames postal data (radius, r=15km)", + "codes_matched": 183, + "notes": "Guadalajara city = 44100-44999; Zapopan/Tlaquepaque use 45xxx. Enumerated from 183 observed codes; the country_format_regex is the permissive fallback." + } }, { "city": "Ensenada", @@ -997,6 +1895,16 @@ "iso3166_1": "MX", "latitude": 31.8667, "longitude": -116.5964, - "local_languages": ["es-MX"] + "local_languages": ["es-MX"], + "postal_code": { + "country_format_regex": "^\\d{5}$", + "city_regex": [ + "^228(?:[03]0|1[02-589]|2[023]|4[027]|5[0259]|6[03]|7[0239]|8[07-9]|9[578])$" + ], + "examples": ["22800", "22810", "22812"], + "source": "derived from GeoNames postal data (radius, r=15km)", + "codes_matched": 32, + "notes": "Ensenada = 22800-22899. Enumerated from 32 observed codes; the country_format_regex is the permissive fallback." + } } ] diff --git a/tooling/pipeline/src/biergarten_pipeline_orchestrator/biergarten_pipeline_orchestrator.cc b/tooling/pipeline/src/biergarten_pipeline_orchestrator/biergarten_pipeline_orchestrator.cc index eae518aa..59b09ed0 100644 --- a/tooling/pipeline/src/biergarten_pipeline_orchestrator/biergarten_pipeline_orchestrator.cc +++ b/tooling/pipeline/src/biergarten_pipeline_orchestrator/biergarten_pipeline_orchestrator.cc @@ -13,10 +13,12 @@ BiergartenPipelineOrchestrator::BiergartenPipelineOrchestrator( std::unique_ptr generator, std::unique_ptr exporter, std::unique_ptr curated_data_service, + std::unique_ptr postal_code_service, const ApplicationOptions& application_options) : logger_(std::move(logger)), context_service_(std::move(context_service)), generator_(std::move(generator)), exporter_(std::move(exporter)), curated_data_service_(std::move(curated_data_service)), + postal_code_service_(std::move(postal_code_service)), application_options_(application_options) {} \ No newline at end of file diff --git a/tooling/pipeline/src/biergarten_pipeline_orchestrator/generate_breweries.cc b/tooling/pipeline/src/biergarten_pipeline_orchestrator/generate_breweries.cc index 3002a0c1..69bade26 100644 --- a/tooling/pipeline/src/biergarten_pipeline_orchestrator/generate_breweries.cc +++ b/tooling/pipeline/src/biergarten_pipeline_orchestrator/generate_breweries.cc @@ -51,13 +51,13 @@ void BiergartenPipelineOrchestrator::GenerateBreweries( exporter_->ProcessRecord(record); } catch (const std::exception& export_exception) { ++export_failed_count; - logger_->Log( - {.level = LogLevel::Warn, - .phase = PipelinePhase::BreweryAndBeerGeneration, - .message = std::format("[Pipeline] Generated brewery for '{}' ({}) " - "but SQLite export failed: {}", - record.location.city, record.location.country, - export_exception.what())}); + logger_->Log({.level = LogLevel::Warn, + .phase = PipelinePhase::BreweryAndBeerGeneration, + .message = std::format( + "[Pipeline] Generated brewery for '{}' ({}) " + "but SQLite export failed: {}", + record.address.city.city, record.address.city.country, + export_exception.what())}); } }; diff --git a/tooling/pipeline/src/biergarten_pipeline_orchestrator/generate_users.cc b/tooling/pipeline/src/biergarten_pipeline_orchestrator/generate_users.cc index bbfe52d1..a16e85a2 100644 --- a/tooling/pipeline/src/biergarten_pipeline_orchestrator/generate_users.cc +++ b/tooling/pipeline/src/biergarten_pipeline_orchestrator/generate_users.cc @@ -135,9 +135,12 @@ void BiergartenPipelineOrchestrator::GenerateUsers( try { const UserResult user = generator_->GenerateUser(city, persona, sampled_name); + const std::string postal_code = + postal_code_service_->GeneratePostalCode(city.location); return UserRecord{ - .location = city.location, + .address = + UserAddress{.city = city.location, .postal_code = postal_code}, .user = user, .email = BuildEmail(sampled_name, used_email_local_parts), .date_of_birth = GenerateDateOfBirth(rng), @@ -161,13 +164,13 @@ void BiergartenPipelineOrchestrator::GenerateUsers( } catch (const std::exception& export_exception) { ++export_failed_count; - logger_->Log( - {.level = LogLevel::Warn, - .phase = PipelinePhase::UserGeneration, - .message = std::format("[Pipeline] Generated user for '{}' ({}) but " - "SQLite export failed: {}", - record.location.city, record.location.country, - export_exception.what())}); + logger_->Log({.level = LogLevel::Warn, + .phase = PipelinePhase::UserGeneration, + .message = std::format( + "[Pipeline] Generated user for '{}' ({}) but " + "SQLite export failed: {}", + record.address.city.city, record.address.city.country, + export_exception.what())}); } }; diff --git a/tooling/pipeline/src/biergarten_pipeline_orchestrator/log_results.cc b/tooling/pipeline/src/biergarten_pipeline_orchestrator/log_results.cc index 9587ced3..0fa7af00 100644 --- a/tooling/pipeline/src/biergarten_pipeline_orchestrator/log_results.cc +++ b/tooling/pipeline/src/biergarten_pipeline_orchestrator/log_results.cc @@ -14,20 +14,20 @@ void BiergartenPipelineOrchestrator::LogResults() const { boost::json::array brewery_output; - for (const auto& [location, brewery] : generated_breweries_) { + for (const auto& [address, brewery] : generated_breweries_) { + const City& city = address.city; brewery_output.push_back(boost::json::object{ {"name_en", brewery.name_en}, {"description_en", brewery.description_en}, {"name_local", brewery.name_local}, {"description_local", brewery.description_local}, - {"location", boost::json::object{ - {"city", location.city}, - {"country", location.country}, - {"state_province", location.state_province}, - {"iso3166_2", location.iso3166_2}, - {"latitude", location.latitude}, - {"longitude", location.longitude}, - }}}); + {"address", boost::json::object{ + {"city", city.city}, + {"country", city.country}, + {"state_province", city.state_province}, + {"iso3166_2", city.iso3166_2}, + {"postal_code", address.postal_code}, + }}}); } std::ostringstream brewery_oss; diff --git a/tooling/pipeline/src/biergarten_pipeline_orchestrator/query_cities_with_countries.cc b/tooling/pipeline/src/biergarten_pipeline_orchestrator/query_cities_with_countries.cc index edea0840..76999f43 100644 --- a/tooling/pipeline/src/biergarten_pipeline_orchestrator/query_cities_with_countries.cc +++ b/tooling/pipeline/src/biergarten_pipeline_orchestrator/query_cities_with_countries.cc @@ -14,13 +14,13 @@ #include "services/curated_data/curated_json_data_service.h" #include "services/logging/logger.h" -std::vector +std::vector BiergartenPipelineOrchestrator::QueryLocations() { logger_->Log({.level = LogLevel::Info, .phase = PipelinePhase::Startup, .message = "=== GEOGRAPHIC DATA OVERVIEW ==="}); - const std::vector& all_locations = + const std::vector& all_locations = curated_data_service_->LoadLocations(); const size_t sample_count = std::min( @@ -31,7 +31,7 @@ BiergartenPipelineOrchestrator::QueryLocations() { static_cast>( sample_count); - std::vector sampled_locations; + std::vector sampled_locations; sampled_locations.reserve(sample_count); std::random_device random_generator; diff --git a/tooling/pipeline/src/biergarten_pipeline_orchestrator/run.cc b/tooling/pipeline/src/biergarten_pipeline_orchestrator/run.cc index 19e42f0a..8e3bb25b 100644 --- a/tooling/pipeline/src/biergarten_pipeline_orchestrator/run.cc +++ b/tooling/pipeline/src/biergarten_pipeline_orchestrator/run.cc @@ -14,7 +14,7 @@ bool BiergartenPipelineOrchestrator::Run() { try { exporter_->Initialize(); - std::vector cities = QueryLocations(); + std::vector cities = QueryLocations(); std::vector enriched; enriched.reserve(cities.size()); diff --git a/tooling/pipeline/src/data_generation/llama/generate_brewery.cc b/tooling/pipeline/src/data_generation/llama/generate_brewery.cc index f2c855d7..f9362bd2 100644 --- a/tooling/pipeline/src/data_generation/llama/generate_brewery.cc +++ b/tooling/pipeline/src/data_generation/llama/generate_brewery.cc @@ -68,7 +68,7 @@ BreweryResult LlamaGenerator::GenerateBrewery( * Store location context for retry prompts (without repeating full context) */ const std::string retry_location = - std::format("Location: {}{}\nLocal language codes: {}", location.city, + std::format("City: {}{}\nLocal language codes: {}", location.city, country_suffix, local_language_codes); /** diff --git a/tooling/pipeline/src/data_generation/mock/deterministic_hash.cc b/tooling/pipeline/src/data_generation/mock/deterministic_hash.cc index 06d46b7e..26adc6aa 100644 --- a/tooling/pipeline/src/data_generation/mock/deterministic_hash.cc +++ b/tooling/pipeline/src/data_generation/mock/deterministic_hash.cc @@ -8,14 +8,14 @@ #include "data_generation/mock_generator.h" -size_t MockGenerator::DeterministicHash(const Location& location) { +size_t MockGenerator::DeterministicHash(const City& location) { size_t seed = 0; boost::hash_combine(seed, location.city); boost::hash_combine(seed, location.country); return seed; } -size_t MockGenerator::DeterministicHash(const Location& location, +size_t MockGenerator::DeterministicHash(const City& location, const UserPersona& persona, const Name& name) { size_t seed = DeterministicHash(location); diff --git a/tooling/pipeline/src/main.cc b/tooling/pipeline/src/main.cc index 3086ff61..66d9004e 100644 --- a/tooling/pipeline/src/main.cc +++ b/tooling/pipeline/src/main.cc @@ -86,6 +86,7 @@ int main(const int argc, char** argv) { di::bind().to(options), di::bind().to(model_path), di::bind().to(), + di::bind().to(), di::bind().to( [options, &log_producer]() -> std::unique_ptr { if (options.generator.use_mocked) { diff --git a/tooling/pipeline/src/services/curated_data/curated_json_data_service.cc b/tooling/pipeline/src/services/curated_data/curated_json_data_service.cc index d9a14c37..cf8b11f3 100644 --- a/tooling/pipeline/src/services/curated_data/curated_json_data_service.cc +++ b/tooling/pipeline/src/services/curated_data/curated_json_data_service.cc @@ -1,6 +1,6 @@ /** * @file json_handling/json_loader.cc - * @brief Parses curated location JSON input into strongly typed Location + * @brief Parses curated location JSON input into strongly typed City * records with strict field validation and descriptive error reporting. */ @@ -30,16 +30,6 @@ static std::string ReadRequiredString(const boost::json::object& object, return std::string(text); } -static double ReadRequiredNumber(const boost::json::object& object, - const char* key) { - const boost::json::value* value = object.if_contains(key); - if (value == nullptr || !value->is_number()) { - throw std::runtime_error( - std::format("Missing or invalid numeric field: {}", key)); - } - return value->to_number(); -} - static std::vector ReadRequiredStringArray( const boost::json::object& object, const char* key) { const boost::json::value* value = object.if_contains(key); @@ -61,6 +51,21 @@ static std::vector ReadRequiredStringArray( return items; } +static PostalCodeSpec ReadPostalCodeSpec(const boost::json::object& object) { + const boost::json::value* value = object.if_contains("postal_code"); + if (value == nullptr || !value->is_object()) { + throw std::runtime_error("Missing or invalid object field: postal_code"); + } + + const auto& postal_code = value->as_object(); + return PostalCodeSpec{ + .country_format_regex = + ReadRequiredString(postal_code, "country_format_regex"), + .city_regexes = ReadRequiredStringArray(postal_code, "city_regex"), + .examples = ReadRequiredStringArray(postal_code, "examples"), + }; +} + namespace { boost::json::value ParseJsonFile(const std::filesystem::path& filepath, @@ -133,15 +138,14 @@ const LocationsList& CuratedJsonDataService::LoadLocations() { } const auto& object = item.as_object(); - locations.push_back(Location{ + locations.push_back(City{ .city = ReadRequiredString(object, "city"), .state_province = ReadRequiredString(object, "state_province"), .iso3166_2 = ReadRequiredString(object, "iso3166_2"), .country = ReadRequiredString(object, "country"), .iso3166_1 = ReadRequiredString(object, "iso3166_1"), .local_languages = ReadRequiredStringArray(object, "local_languages"), - .latitude = ReadRequiredNumber(object, "latitude"), - .longitude = ReadRequiredNumber(object, "longitude"), + .postal_code = ReadPostalCodeSpec(object), }); } cache_.locations = std::move(locations); diff --git a/tooling/pipeline/src/services/curated_data/mock_curated_data_service.cc b/tooling/pipeline/src/services/curated_data/mock_curated_data_service.cc index dda41840..3332fed7 100644 --- a/tooling/pipeline/src/services/curated_data/mock_curated_data_service.cc +++ b/tooling/pipeline/src/services/curated_data/mock_curated_data_service.cc @@ -7,38 +7,46 @@ MockCuratedDataService::MockCuratedDataService() : locations_{ - Location{.city = "Portland", - .state_province = "Oregon", - .iso3166_2 = "US-OR", - .country = "United States", - .iso3166_1 = "US", - .local_languages = {"en"}, - .latitude = 45.5152, - .longitude = -122.6784}, - Location{.city = "Munich", - .state_province = "Bavaria", - .iso3166_2 = "DE-BY", - .country = "Germany", - .iso3166_1 = "DE", - .local_languages = {"de"}, - .latitude = 48.1351, - .longitude = 11.5820}, - Location{.city = "Lyon", - .state_province = "Auvergne-Rhone-Alpes", - .iso3166_2 = "FR-ARA", - .country = "France", - .iso3166_1 = "FR", - .local_languages = {"fr"}, - .latitude = 45.7640, - .longitude = 4.8357}, - Location{.city = "Brussels", - .state_province = "Brussels-Capital", - .iso3166_2 = "BE-BRU", - .country = "Belgium", - .iso3166_1 = "BE", - .local_languages = {"nl", "fr"}, - .latitude = 50.8503, - .longitude = 4.3517}, + City{.city = "Portland", + .state_province = "Oregon", + .iso3166_2 = "US-OR", + .country = "United States", + .iso3166_1 = "US", + .local_languages = {"en"}, + .postal_code = + PostalCodeSpec{.country_format_regex = R"(^\d{5}$)", + .city_regexes = {R"(^972\d{2}$)"}, + .examples = {"97201", "97202", "97209"}}}, + City{.city = "Munich", + .state_province = "Bavaria", + .iso3166_2 = "DE-BY", + .country = "Germany", + .iso3166_1 = "DE", + .local_languages = {"de"}, + .postal_code = + PostalCodeSpec{.country_format_regex = R"(^\d{5}$)", + .city_regexes = {R"(^8\d{4}$)"}, + .examples = {"80331", "80333", "81667"}}}, + City{.city = "Lyon", + .state_province = "Auvergne-Rhone-Alpes", + .iso3166_2 = "FR-ARA", + .country = "France", + .iso3166_1 = "FR", + .local_languages = {"fr"}, + .postal_code = + PostalCodeSpec{.country_format_regex = R"(^\d{5}$)", + .city_regexes = {R"(^690\d{2}$)"}, + .examples = {"69001", "69002", "69007"}}}, + City{.city = "Brussels", + .state_province = "Brussels-Capital", + .iso3166_2 = "BE-BRU", + .country = "Belgium", + .iso3166_1 = "BE", + .local_languages = {"nl", "fr"}, + .postal_code = + PostalCodeSpec{.country_format_regex = R"(^\d{4}$)", + .city_regexes = {R"(^1\d{3}$)"}, + .examples = {"1000", "1050", "1180"}}}, }, personas_{ UserPersona{.name = "Hophead Explorer", diff --git a/tooling/pipeline/src/services/enrichment/wikipedia/get_summary.cc b/tooling/pipeline/src/services/enrichment/wikipedia/get_summary.cc index 342f84a5..07cfde7d 100644 --- a/tooling/pipeline/src/services/enrichment/wikipedia/get_summary.cc +++ b/tooling/pipeline/src/services/enrichment/wikipedia/get_summary.cc @@ -11,7 +11,7 @@ #include "services/enrichment/wikipedia_service.h" std::string WikipediaEnrichmentService::GetLocationContext( - const Location& loc) { + const City& loc) { using namespace std::literals::chrono_literals; if (!this->client_) { if (logger_) { diff --git a/tooling/pipeline/src/services/sqlite/finalize.cc b/tooling/pipeline/src/services/sqlite/finalize.cc index c4e4ff64..b8e64574 100644 --- a/tooling/pipeline/src/services/sqlite/finalize.cc +++ b/tooling/pipeline/src/services/sqlite/finalize.cc @@ -14,9 +14,11 @@ void SqliteExportService::Finalize() { } try { + insert_user_address_stmt_.reset(); insert_user_stmt_.reset(); + insert_brewery_address_stmt_.reset(); insert_brewery_stmt_.reset(); - insert_location_stmt_.reset(); + insert_city_stmt_.reset(); if (transaction_open_) { sqlite_export_service_internal::ExecSql( db_handle_, "COMMIT;", "Failed to commit SQLite transaction"); @@ -24,7 +26,7 @@ void SqliteExportService::Finalize() { } db_handle_.reset(); - location_cache_.clear(); + city_cache_.clear(); } catch (...) { RollbackAndCloseNoThrow(); throw; diff --git a/tooling/pipeline/src/services/sqlite/helpers/sqlite_statement_helpers.cc b/tooling/pipeline/src/services/sqlite/helpers/sqlite_statement_helpers.cc index 8b2f75f3..2420ea81 100644 --- a/tooling/pipeline/src/services/sqlite/helpers/sqlite_statement_helpers.cc +++ b/tooling/pipeline/src/services/sqlite/helpers/sqlite_statement_helpers.cc @@ -88,7 +88,8 @@ sqlite3_int64 LastInsertRowId(const SqliteDatabaseHandle& db_handle) { } std::string SerializeVector(const std::vector& str_vec) { - boost::json::array array(str_vec.size()); + boost::json::array array; + array.reserve(str_vec.size()); for (const auto& s : str_vec) { array.emplace_back(s); } diff --git a/tooling/pipeline/src/services/sqlite/initialize.cc b/tooling/pipeline/src/services/sqlite/initialize.cc index 4c7def22..4fffc4e9 100644 --- a/tooling/pipeline/src/services/sqlite/initialize.cc +++ b/tooling/pipeline/src/services/sqlite/initialize.cc @@ -28,26 +28,40 @@ std::filesystem::path SqliteExportService::BuildDatabasePath() const { void SqliteExportService::InitializeSchema() const { sqlite_export_service_internal::ExecSql( - db_handle_, sqlite_export_service_internal::kCreateLocationsTableSql, - "Failed to create SQLite locations table"); + db_handle_, sqlite_export_service_internal::kCreateCitiesTableSql, + "Failed to create SQLite cities table"); sqlite_export_service_internal::ExecSql( db_handle_, sqlite_export_service_internal::kCreateBreweriesTableSql, "Failed to create SQLite breweries table"); + sqlite_export_service_internal::ExecSql( + db_handle_, + sqlite_export_service_internal::kCreateBreweryAddressesTableSql, + "Failed to create SQLite brewery_addresses table"); sqlite_export_service_internal::ExecSql( db_handle_, sqlite_export_service_internal::kCreateUsersTableSql, "Failed to create SQLite users table"); + sqlite_export_service_internal::ExecSql( + db_handle_, sqlite_export_service_internal::kCreateUserAddressesTableSql, + "Failed to create SQLite user_addresses table"); } void SqliteExportService::PrepareStatements() { - insert_location_stmt_ = sqlite_export_service_internal::PrepareStatement( - db_handle_, sqlite_export_service_internal::kInsertLocationSql, - "Failed to prepare SQLite location insert statement"); + insert_city_stmt_ = sqlite_export_service_internal::PrepareStatement( + db_handle_, sqlite_export_service_internal::kInsertCitySql, + "Failed to prepare SQLite city insert statement"); insert_brewery_stmt_ = sqlite_export_service_internal::PrepareStatement( db_handle_, sqlite_export_service_internal::kInsertBrewerySql, "Failed to prepare SQLite brewery insert statement"); + insert_brewery_address_stmt_ = + sqlite_export_service_internal::PrepareStatement( + db_handle_, sqlite_export_service_internal::kInsertBreweryAddressSql, + "Failed to prepare SQLite brewery address insert statement"); insert_user_stmt_ = sqlite_export_service_internal::PrepareStatement( db_handle_, sqlite_export_service_internal::kInsertUserSql, "Failed to prepare SQLite user insert statement"); + insert_user_address_stmt_ = sqlite_export_service_internal::PrepareStatement( + db_handle_, sqlite_export_service_internal::kInsertUserAddressSql, + "Failed to prepare SQLite user address insert statement"); } void SqliteExportService::RollbackAndCloseNoThrow() noexcept { @@ -60,11 +74,13 @@ void SqliteExportService::RollbackAndCloseNoThrow() noexcept { transaction_open_ = false; } + insert_user_address_stmt_.reset(); insert_user_stmt_.reset(); + insert_brewery_address_stmt_.reset(); insert_brewery_stmt_.reset(); - insert_location_stmt_.reset(); + insert_city_stmt_.reset(); db_handle_.reset(); - location_cache_.clear(); + city_cache_.clear(); } void SqliteExportService::Initialize() { diff --git a/tooling/pipeline/src/services/sqlite/process_record.cc b/tooling/pipeline/src/services/sqlite/process_record.cc index 9acc0fc3..a4cf9f1a 100644 --- a/tooling/pipeline/src/services/sqlite/process_record.cc +++ b/tooling/pipeline/src/services/sqlite/process_record.cc @@ -1,10 +1,9 @@ /** * @file services/sqlite/process_record.cc * @brief SqliteExportService::ProcessRecord() implementation - * and the shared location-resolution helper. + * and the shared city-resolution helper. */ -#include #include #include #include @@ -12,94 +11,91 @@ #include "services/database/sqlite_export_service.h" #include "services/database/sqlite_export_service_helpers.h" -constexpr int kLocationPrecision = 17; - -std::string SqliteExportService::BuildLocationKey(const Location& location) { +std::string SqliteExportService::BuildCityKey(const City& city) { std::ostringstream key_stream; - key_stream << location.city << '\n' - << location.state_province << '\n' - << location.iso3166_2 << '\n' - << location.country << '\n' - << location.iso3166_1 << '\n' - << std::setprecision(kLocationPrecision) << location.latitude - << '\n' - << std::setprecision(kLocationPrecision) << location.longitude - << '\n' + key_stream << city.city << '\n' + << city.state_province << '\n' + << city.iso3166_2 << '\n' + << city.country << '\n' + << city.iso3166_1 << '\n' << sqlite_export_service_internal::SerializeVector( - location.local_languages); + city.local_languages); return key_stream.str(); } -sqlite3_int64 SqliteExportService::ResolveLocationId(const Location& location) { - const std::string location_key = BuildLocationKey(location); - const auto cached_location = location_cache_.find(location_key); - if (cached_location != location_cache_.end()) { - return cached_location->second; +sqlite3_int64 SqliteExportService::ResolveCityId(const City& city) { + const std::string city_key = BuildCityKey(city); + const auto cached_city = city_cache_.find(city_key); + if (cached_city != city_cache_.end()) { + return cached_city->second; } const std::string local_languages_json = - sqlite_export_service_internal::SerializeVector(location.local_languages); + sqlite_export_service_internal::SerializeVector(city.local_languages); + const std::string city_regex_json = + sqlite_export_service_internal::SerializeVector( + city.postal_code.city_regexes); sqlite_export_service_internal::Bind( - insert_location_stmt_, + insert_city_stmt_, sqlite_export_service_internal::BoundParam{ - .index = sqlite_export_service_internal::kLocationCityBindIndex, - .value = location.city, - .action = "Failed to bind SQLite location city"}); + .index = sqlite_export_service_internal::kCityCityBindIndex, + .value = city.city, + .action = "Failed to bind SQLite city name"}); sqlite_export_service_internal::Bind( - insert_location_stmt_, + insert_city_stmt_, sqlite_export_service_internal::BoundParam{ - .index = - sqlite_export_service_internal::kLocationStateProvinceBindIndex, - .value = location.state_province, - .action = "Failed to bind SQLite location state/province"}); + .index = sqlite_export_service_internal::kCityStateProvinceBindIndex, + .value = city.state_province, + .action = "Failed to bind SQLite city state/province"}); sqlite_export_service_internal::Bind( - insert_location_stmt_, + insert_city_stmt_, sqlite_export_service_internal::BoundParam{ - .index = sqlite_export_service_internal::kLocationIso31662BindIndex, - .value = location.iso3166_2, - .action = "Failed to bind SQLite location ISO 3166-2 code"}); + .index = sqlite_export_service_internal::kCityIso31662BindIndex, + .value = city.iso3166_2, + .action = "Failed to bind SQLite city ISO 3166-2 code"}); sqlite_export_service_internal::Bind( - insert_location_stmt_, + insert_city_stmt_, sqlite_export_service_internal::BoundParam{ - .index = sqlite_export_service_internal::kLocationCountryBindIndex, - .value = location.country, - .action = "Failed to bind SQLite location country"}); + .index = sqlite_export_service_internal::kCityCountryBindIndex, + .value = city.country, + .action = "Failed to bind SQLite city country"}); sqlite_export_service_internal::Bind( - insert_location_stmt_, + insert_city_stmt_, sqlite_export_service_internal::BoundParam{ - .index = sqlite_export_service_internal::kLocationIso31661BindIndex, - .value = location.iso3166_1, - .action = "Failed to bind SQLite location ISO 3166-1 code"}); + .index = sqlite_export_service_internal::kCityIso31661BindIndex, + .value = city.iso3166_1, + .action = "Failed to bind SQLite city ISO 3166-1 code"}); sqlite_export_service_internal::Bind( - insert_location_stmt_, + insert_city_stmt_, sqlite_export_service_internal::BoundParam{ - .index = sqlite_export_service_internal::kLocationLanguagesBindIndex, + .index = sqlite_export_service_internal::kCityLanguagesBindIndex, .value = local_languages_json, - .action = "Failed to bind SQLite location languages"}); + .action = "Failed to bind SQLite city languages"}); sqlite_export_service_internal::Bind( - insert_location_stmt_, - sqlite_export_service_internal::BoundParam{ - .index = sqlite_export_service_internal::kLocationLatitudeBindIndex, - .value = location.latitude, - .action = "Failed to bind SQLite location latitude"}); + insert_city_stmt_, + sqlite_export_service_internal::BoundParam{ + .index = sqlite_export_service_internal:: + kCityPostalCodeCountryFormatRegexBindIndex, + .value = city.postal_code.country_format_regex, + .action = "Failed to bind SQLite city postal-code format regex"}); sqlite_export_service_internal::Bind( - insert_location_stmt_, - sqlite_export_service_internal::BoundParam{ - .index = sqlite_export_service_internal::kLocationLongitudeBindIndex, - .value = location.longitude, - .action = "Failed to bind SQLite location longitude"}); + insert_city_stmt_, + sqlite_export_service_internal::BoundParam{ + .index = sqlite_export_service_internal:: + kCityPostalCodeCityRegexJsonBindIndex, + .value = city_regex_json, + .action = "Failed to bind SQLite city postal-code city regexes"}); sqlite_export_service_internal::StepStatement( - db_handle_, insert_location_stmt_, - "Failed to insert SQLite location row"); + db_handle_, insert_city_stmt_, "Failed to insert SQLite city row"); - const sqlite3_int64 location_id = + const sqlite3_int64 city_id = sqlite_export_service_internal::LastInsertRowId(db_handle_); - location_cache_.emplace(location_key, location_id); - sqlite_export_service_internal::ResetStatement(insert_location_stmt_); + city_cache_.emplace(city_key, city_id); + sqlite_export_service_internal::ResetStatement(insert_city_stmt_); - return location_id; + return city_id; } uint64_t SqliteExportService::ProcessRecord(const BreweryRecord& brewery) { @@ -107,14 +103,7 @@ uint64_t SqliteExportService::ProcessRecord(const BreweryRecord& brewery) { throw std::runtime_error("SQLite export service is not initialized"); } - const sqlite3_int64 location_id = ResolveLocationId(brewery.location); - - sqlite_export_service_internal::Bind( - insert_brewery_stmt_, - sqlite_export_service_internal::BoundParam{ - .index = sqlite_export_service_internal::kBreweryLocationIdBindIndex, - .value = location_id, - .action = "Failed to bind SQLite brewery location id"}); + const sqlite3_int64 city_id = ResolveCityId(brewery.address.city); sqlite_export_service_internal::Bind( insert_brewery_stmt_, @@ -149,7 +138,38 @@ uint64_t SqliteExportService::ProcessRecord(const BreweryRecord& brewery) { sqlite_export_service_internal::StepStatement( db_handle_, insert_brewery_stmt_, "Failed to insert SQLite brewery row"); + const sqlite3_int64 brewery_id = + sqlite_export_service_internal::LastInsertRowId(db_handle_); + sqlite_export_service_internal::ResetStatement(insert_brewery_stmt_); - return sqlite_export_service_internal::LastInsertRowId(db_handle_); + sqlite_export_service_internal::Bind( + insert_brewery_address_stmt_, + sqlite_export_service_internal::BoundParam{ + .index = + sqlite_export_service_internal::kBreweryAddressBreweryIdBindIndex, + .value = brewery_id, + .action = "Failed to bind SQLite brewery address brewery id"}); + sqlite_export_service_internal::Bind( + insert_brewery_address_stmt_, + sqlite_export_service_internal::BoundParam{ + .index = + sqlite_export_service_internal::kBreweryAddressCityIdBindIndex, + .value = city_id, + .action = "Failed to bind SQLite brewery address city id"}); + sqlite_export_service_internal::Bind( + insert_brewery_address_stmt_, + sqlite_export_service_internal::BoundParam{ + .index = sqlite_export_service_internal:: + kBreweryAddressPostalCodeBindIndex, + .value = brewery.address.postal_code, + .action = "Failed to bind SQLite brewery address postal code"}); + + sqlite_export_service_internal::StepStatement( + db_handle_, insert_brewery_address_stmt_, + "Failed to insert SQLite brewery address row"); + + sqlite_export_service_internal::ResetStatement(insert_brewery_address_stmt_); + + return static_cast(brewery_id); } diff --git a/tooling/pipeline/src/services/sqlite/process_user_record.cc b/tooling/pipeline/src/services/sqlite/process_user_record.cc index 34bac070..3b3f369d 100644 --- a/tooling/pipeline/src/services/sqlite/process_user_record.cc +++ b/tooling/pipeline/src/services/sqlite/process_user_record.cc @@ -13,14 +13,8 @@ uint64_t SqliteExportService::ProcessRecord(const UserRecord& user) { throw std::runtime_error("SQLite export service is not initialized"); } - const sqlite3_int64 location_id = ResolveLocationId(user.location); + const sqlite3_int64 city_id = ResolveCityId(user.address.city); - sqlite_export_service_internal::Bind( - insert_user_stmt_, - sqlite_export_service_internal::BoundParam{ - .index = sqlite_export_service_internal::kUserLocationIdBindIndex, - .value = location_id, - .action = "Failed to bind SQLite user location id"}); sqlite_export_service_internal::Bind( insert_user_stmt_, sqlite_export_service_internal::BoundParam{ @@ -73,7 +67,36 @@ uint64_t SqliteExportService::ProcessRecord(const UserRecord& user) { sqlite_export_service_internal::StepStatement( db_handle_, insert_user_stmt_, "Failed to insert SQLite user row"); + const sqlite3_int64 user_id = + sqlite_export_service_internal::LastInsertRowId(db_handle_); + sqlite_export_service_internal::ResetStatement(insert_user_stmt_); - return sqlite_export_service_internal::LastInsertRowId(db_handle_); + sqlite_export_service_internal::Bind( + insert_user_address_stmt_, + sqlite_export_service_internal::BoundParam{ + .index = sqlite_export_service_internal::kUserAddressUserIdBindIndex, + .value = user_id, + .action = "Failed to bind SQLite user address user id"}); + sqlite_export_service_internal::Bind( + insert_user_address_stmt_, + sqlite_export_service_internal::BoundParam{ + .index = sqlite_export_service_internal::kUserAddressCityIdBindIndex, + .value = city_id, + .action = "Failed to bind SQLite user address city id"}); + sqlite_export_service_internal::Bind( + insert_user_address_stmt_, + sqlite_export_service_internal::BoundParam{ + .index = + sqlite_export_service_internal::kUserAddressPostalCodeBindIndex, + .value = user.address.postal_code, + .action = "Failed to bind SQLite user address postal code"}); + + sqlite_export_service_internal::StepStatement( + db_handle_, insert_user_address_stmt_, + "Failed to insert SQLite user address row"); + + sqlite_export_service_internal::ResetStatement(insert_user_address_stmt_); + + return static_cast(user_id); }