Compare commits

...

132 Commits

Author SHA1 Message Date
Sarah Hoffmann
986d303c95 Merge pull request #3980 from lonvia/security-smells
Improve SQL query assembly
2026-02-10 15:26:34 +01:00
Sarah Hoffmann
7a3ea55f3d ignore tables with odd names in SQLPreprocessor 2026-02-10 11:40:52 +01:00
Sarah Hoffmann
d10d70944d avoid f-strings in SQL creation in tests 2026-02-10 11:39:19 +01:00
Sarah Hoffmann
73590baf15 use psycopg.sql for SQL building in tokenizer 2026-02-10 11:39:19 +01:00
Sarah Hoffmann
e17d0cb5cf only allow alphanumeric and dash in DATABASE_WEBUSER
This variable is used a lot in raw SQL. Avoid injection issues.
2026-02-10 11:39:17 +01:00
Sarah Hoffmann
7a62c7d812 sanity check class names before inserting into classtype tables
The subsequent INSERT is done on an unqouted table name, making in
theory an SQL injection through an OSM value possible. In practise
this cannot happen because we check for the existance of the table.
During the creation of the classtype tables there is a sanity
check in place to disallow any table names that consist of anything
other than alphanumeric characters.
2026-02-10 11:38:26 +01:00
Sarah Hoffmann
615804b1b3 Merge pull request #3978 from jayaddison/issue-2714-prep/index-boundaries-method-signature-nitpick
Refactor: add default params to Indexer.index_boundaries
2026-02-10 09:45:29 +01:00
Sarah Hoffmann
79bbdfd55c Merge pull request #3975 from kad-link/fix/utf8-encoding-clean
Fix: Enforce explicit UTF-8 encoding in file I/O
2026-02-10 09:32:06 +01:00
James Addison
509f59b193 Refactor: add default params to index_boundaries 2026-02-09 21:36:30 +00:00
Sri CHaRan
f84b279540 fix: add utf-8 encoding in read-write files 2026-02-10 00:38:40 +05:30
Sarah Hoffmann
cd2f6e458b Merge pull request #3970 from lonvia/improve-dev-docs
Some minor improvement to developer docs
2026-02-05 21:57:54 +01:00
Sarah Hoffmann
fc49a77e70 Merge pull request #3960 from jayaddison/tests/has-pending-monkeypatch-robustness
Tests: parameter-agnostic 'Indexer.has_pending' monkeypatching
2026-02-05 21:05:57 +01:00
Sarah Hoffmann
28baa34bdc point to developer docs from CONTRIBUTING.md 2026-02-05 20:51:41 +01:00
Sarah Hoffmann
151a5b64a8 docs: fix list of packages for development install 2026-02-05 20:45:18 +01:00
James Addison
3db7c6d804 Tests: parameter-agnostic has_pending monkeypatching
Instead of relying on runtime parameter compatibility between
the patched `has_pending` method and `list.pop`, use a proxy
lambda function that accepts arbitrary keyword params.
2026-02-05 15:09:09 +00:00
Sarah Hoffmann
b2f868d2fc Merge pull request #3966 from remo-lab/fix/sql-injection-truncate
Fix SQL injection in truncate_data_tables
2026-02-05 14:44:55 +01:00
remo-lab
ae7301921a Fix SQL injection in truncate_data_tables
Signed-off-by: remo-lab <remopanda7@gmail.com>
2026-02-05 17:04:10 +05:30
Sarah Hoffmann
8188689765 Merge pull request #3962 from lonvia/docs-deploy
Docs: switch deployment to use gunicorn's asgi/uwsgi support
2026-02-03 11:45:57 +01:00
Sarah Hoffmann
135453e463 docs: switch deployment to use gunicorn's asgi/uwsgi support 2026-02-03 09:08:06 +01:00
Sarah Hoffmann
cc9c8963f3 Merge pull request #3949 from Itz-Agasta/try
Feat: Add admin function for granting access to read-only user
2026-02-02 09:53:24 +01:00
Sarah Hoffmann
c882718355 Merge pull request #3959 from Aditya30ag/fix/readme-nominatim-api-module-path
Fix README: update Nominatim API server module path
2026-02-02 09:12:24 +01:00
Aditya30ag
3f02a4e33b Fix README: update Nominatim API server module path 2026-02-02 11:43:03 +05:30
Sarah Hoffmann
1cf5464d3a Merge pull request #3955 from AmmarYasser455/fix/typos
docs: fix multiple typos in documentation and source code
2026-02-01 10:05:34 +01:00
Sarah Hoffmann
dcbfa2a3d0 Merge pull request #3952 from jayaddison/pr-3687-followup/boundary-admin-level-for-linkage
Tests: resolve an issue in the place-linkage name expansion test case
2026-02-01 10:05:16 +01:00
James Addison
5cdc6724de Tests: set boundary admin level to enable linking 2026-01-31 22:00:23 +00:00
Itz-Agasta
45972811e3 Preserve import error tables during freeze
- Remove 'import_polygon_%' from UPDATE_TABLES to keep import_polygon_error
and import_polygon_delete tables in frozen databases.

- These tables contain permanent import error tracking data and should not
be deleted during freeze. The ro-access grant system expects them to exist
in all database states.
2026-01-31 22:50:18 +05:30
Itz-Agasta
e021f558bf Restore grants for dynamic tables in tokenizer, migration, and tiger import 2026-01-30 20:43:57 +05:30
AmmarYasser455
fcc5ce3f92 docs: fix multiple typos in documentation and source code 2026-01-30 12:13:23 +02:00
Sarah Hoffmann
9a979b7429 Merge pull request #3951 from Itz-Agasta/cli
Feat: Adds layer filtering option to search cli command
2026-01-29 09:58:06 +01:00
Itz-Agasta
6ad87db1eb Updates layer selection to allow optional default
- Modifies layer argument handling to permit no default layers appropriate.
- Update the help text for the layer parameter in the reverse command
2026-01-29 11:33:21 +05:30
Sarah Hoffmann
f4820bed0e Merge pull request #3950 from jayaddison/fixup/sql-debug-output-escaping
Fixup: add single-quote escaping within debug message
2026-01-28 20:30:11 +01:00
Itz-Agasta
bf6eb01d68 Adds layer filtering option to search command
Introduces a cli argument to restrict search results
to specified data layers, enabling more targeted queries.
2026-01-28 12:16:43 +05:30
James Addison
f07676a376 Fixup: add single-quote escaping within debug message 2026-01-28 01:27:53 +00:00
Itz-Agasta
5e2ce10fe0 Adds mock grants SQL file for import test 2026-01-27 17:55:51 +05:30
Itz-Agasta
58cae70596 Adds option to grant web user read-only DB access
Introduces a command-line flag to grant read-only access to the web user for all tables, improving ease of permissions management during refresh operations.
2026-01-27 17:54:10 +05:30
Itz-Agasta
bf0ee6685b Grants read-only access after import
Adds execution of grant statements to provide read-only privileges
for the web user following table creation or via a dedicated function.
Facilitates easier post-import permission management.
2026-01-27 17:53:25 +05:30
Itz-Agasta
ff1f1b06d9 Moves db grant statements to dedicated script
Centralizes all read-only access grants into a single SQL script, ensuring permissions are managed in one place.
2026-01-27 17:49:51 +05:30
Sarah Hoffmann
67ecf5f6a0 Merge pull request #3943 from Itz-Agasta/test_fix
Tests: Replace eval() with ast.literal_eval() for safer parsing
2026-01-25 10:10:15 +01:00
Itz-Agasta
e77a4c2f35 Switch to ast.literal_eval for dict parsing
Due to  some test data in the BDD feature files includes Python raw strings and escape sequences that standard json.loads() cannot parse switching to safer Python literal evaluation
for converting string representations of dictionaries.
2026-01-24 15:32:47 +05:30
Itz-Agasta
9fa980bca2 Replaces eval with json.loads for safer dict parsing
Switches from eval to json.loads when parsing string representations
of dictionaries to  prevent arbitrary code
execution.
2026-01-24 15:32:47 +05:30
Sarah Hoffmann
fe773c12b2 Merge pull request #3946 from lonvia/enable-entrances-for-reverse
Enable entrance lookup for reverse and lookup
2026-01-23 22:10:43 +01:00
Sarah Hoffmann
cc96912580 Merge pull request #3906 from AyushDharDubey/fix/issue_2463-Use-search_name-table-for-TIGER-data-imports-on-'dropped'-databases
Use `search_name` as fallback for TIGER imports when update tables are dropped
2026-01-23 20:52:40 +01:00
Sarah Hoffmann
77a3ecd72d Merge pull request #3945 from lonvia/fix-starlette-tests
Update Starlette tests to using their TestClient
2026-01-23 20:45:15 +01:00
Sarah Hoffmann
6a6a064ef7 enable entrances for reverse and lookup 2026-01-23 17:38:47 +01:00
Sarah Hoffmann
35b42ad9ce update Starlette tests to using their TestClient 2026-01-23 16:28:13 +01:00
Sri Charan Chittineni
c4dc2c862e fix mypy typing for Starlette state object (#3944) 2026-01-22 13:21:34 +01:00
Sarah Hoffmann
7e44256f4a Merge pull request #3939 from lonvia/more-table-constraints
Add NOT NULL and UNIQUE contraints on tables
2026-01-14 15:04:45 +01:00
Ayush Dhar Dubey
eefd0efa59 update test frozen db: new tiger import mechanism 2026-01-09 17:47:07 +05:30
Ayush Dhar Dubey
2698382552 permit import of tiger after freeze 2026-01-09 17:35:01 +05:30
Ayush Dhar Dubey
954771a42d Add fallback search mechanism for dropped databases lookup 2026-01-09 17:35:01 +05:30
Sarah Hoffmann
e47601754a do not attempt to delete old data for newly created placex entries 2026-01-07 17:08:28 +01:00
Sarah Hoffmann
2cdf2db184 add NOT NULL and UNIQUE constraints where possible 2026-01-07 15:46:05 +01:00
Sarah Hoffmann
5200e11f33 ignore countries without geometry or country code for location_area 2026-01-07 11:43:32 +01:00
Sarah Hoffmann
ba1fc5a5b8 do not insert entries with empty name into search name 2026-01-07 11:27:55 +01:00
Sarah Hoffmann
d35a71c123 ensure correct indexed_status transitions 2026-01-07 11:12:35 +01:00
Sarah Hoffmann
e31862b7b5 make sure that importance is always set to a non-null value
Secondary importance might return invalid values in some cases.
2026-01-07 10:29:45 +01:00
Sarah Hoffmann
9ac5e0256d make sure array_merge() never returns null 2026-01-07 10:22:03 +01:00
Sarah Hoffmann
a4a2176ded immediately terminate indexing when a task catches an exception 2026-01-07 09:58:40 +01:00
Sarah Hoffmann
f30fcdcd9d BDD: make sure randomly generated names always contain a letter 2026-01-07 09:58:40 +01:00
otbutz
77b8e76be6 Add PR template (#3934) 2026-01-05 17:42:35 +01:00
Sarah Hoffmann
20a333dd9b Merge pull request #3930 from lonvia/remove-new-query-log-table
Remove unused new_query_log table
2026-01-02 09:58:05 +01:00
Sarah Hoffmann
084e1b8177 remove unused new_query_log table 2026-01-01 20:30:37 +01:00
Sarah Hoffmann
2e2ce2c979 fix version counts 2026-01-01 14:42:12 +01:00
Sarah Hoffmann
99643aa0e9 ignore postcode areas in countries without postcodes properly 2026-01-01 11:21:40 +01:00
Sarah Hoffmann
c05b8f241c make sure we use exactly the same table structure as osm2pgsql 2025-12-31 00:21:27 +01:00
Sarah Hoffmann
da94d7eea3 need an analyse after the migration 2025-12-30 19:49:07 +01:00
Sarah Hoffmann
f9864b7ec7 grant access right to www user for new postcode table 2025-12-30 17:48:33 +01:00
Sarah Hoffmann
df4abfd5cc Merge pull request #3926 from lonvia/rework-postcode-handling
Reorganise postcode handling
2025-12-30 15:54:33 +01:00
Sarah Hoffmann
42d139a5d0 analyze postcode table during import 2025-12-30 15:21:20 +01:00
Sarah Hoffmann
f2110e12d6 simplify postcode area for lookups 2025-12-30 15:21:20 +01:00
Sarah Hoffmann
3bcd1aa721 adapt BDD tests for new postcode table structure 2025-12-30 15:21:20 +01:00
Sarah Hoffmann
354aa07cad adapt unit tests to new postcode algorithms 2025-12-30 15:21:18 +01:00
Sarah Hoffmann
deb6654cfd add migration for new postcode table 2025-12-30 15:20:46 +01:00
Sarah Hoffmann
6a67cfcddf adapt search frontend to new postcode table 2025-12-30 15:20:46 +01:00
Sarah Hoffmann
f9cf320794 set custom postcode extents for some countries 2025-12-30 15:20:46 +01:00
Sarah Hoffmann
d1cb578535 rework postcode computation
Now adds areas to location_postcodes, ignores postcode points
inside areas and supports customizable extents.
2025-12-30 15:20:46 +01:00
Sarah Hoffmann
a97b5d97cb add support for custom per-country postcode extents 2025-12-30 15:20:46 +01:00
Sarah Hoffmann
9ec607b556 change confusing value in debug output for missing importance 2025-12-30 15:20:46 +01:00
Sarah Hoffmann
89821d01e0 reorganise layout of location_postcode table
Also renames the table as this will make it easier to migrate.
2025-12-30 15:20:46 +01:00
Sarah Hoffmann
7ef3f99fa4 drop new place sub-tables on freezing 2025-12-30 15:20:46 +01:00
Sarah Hoffmann
0aa9eee3e7 remove special casing for postcodes in trigger code 2025-12-30 15:20:46 +01:00
Sarah Hoffmann
340fe64e8b put postcodes in extra table on import 2025-12-30 15:20:46 +01:00
Sarah Hoffmann
0b11dd0eba Merge pull request #3925 from Aditya30ag/fix-typo-place-addressline-test
Fix typo in place_addressline table name in tests
2025-12-30 14:36:59 +01:00
Aditya30ag
3b182afa72 Fix typo in place_addressline table name in tests 2025-12-30 17:40:08 +05:30
Sarah Hoffmann
ae77a9512a Merge pull request #3919 from 28Deepakpandey/Fix-docs-locale-typo
Fix: Locale → Locales references in docs
2025-12-28 15:55:49 +01:00
28Deepakpandey
f7ba1fc9e1 Fix: corrected Locale → Locales references and ensured proper casing in docs 2025-12-26 02:46:26 +05:30
Sarah Hoffmann
26e62fda19 Merge pull request #3913 from AyushDharDubey/fix/issue_3909
Reuse Configuration instance in Locales
2025-12-22 16:38:27 +01:00
Ayush Dhar Dubey
4fd616254a update Locales constructor:
expect output names as argument and avoid redundant configuration initialization
2025-12-20 19:15:33 +05:30
Ayush Dhar Dubey
049164086a fix: ensure Locales is not initialized when provided in options 2025-12-20 19:12:15 +05:30
Sarah Hoffmann
5e965d5216 Merge pull request #3910 from lonvia/update-ps-names
Update default names for Palestinian Territories
2025-12-15 21:16:56 +01:00
Sarah Hoffmann
2097401b12 update default names for Palestinian Territories 2025-12-15 19:25:15 +01:00
Sarah Hoffmann
58e56ec53d Merge pull request #3901 from AyushDharDubey/fix/issue_3829-use-mwparserfromhell-to-parse-sp-wiki-page
Replace regex with `mwparserfromhell` based MW WikiCode Parsing for Special Phrases
2025-12-08 11:51:50 +01:00
Ayush Dhar Dubey
fe170c9286 add mwparserfromhell to apt prerequisites for CI build 2025-12-08 15:51:35 +05:30
Ayush Dhar Dubey
0c5af2e3e4 update new dependency instructions: mwparserfromhell 2025-12-08 15:01:35 +05:30
Sarah Hoffmann
681daeea29 Merge pull request #3902 from lonvia/fix-only-closest-housenumber
Reverse: only return housenumbers near street
2025-12-08 09:48:29 +01:00
Ayush Dhar Dubey
49454048c4 use mwparserfromhell to parse SP wiki page reliably 2025-12-08 11:01:14 +05:30
Ayush Dhar Dubey
4919240377 test for cell-per-line format 2025-12-08 11:01:14 +05:30
Ayush Dhar Dubey
56cb183c4e update sp test content
add latest <generator>MediaWiki 1.43.5</generator>
add test case for one-row-per-line
2025-12-08 10:59:10 +05:30
Sarah Hoffmann
35060164ab reverse: only return housenumbers near street 2025-12-07 11:00:23 +01:00
Sarah Hoffmann
4cfc1792fb Merge pull request #3899 from lonvia/improve-reverse-performance
Streamline reverse lookup slightly
2025-12-07 09:39:10 +01:00
Sarah Hoffmann
3bb5d00848 avoid extra query for finding closest housenumber in reverse 2025-12-05 17:09:13 +01:00
Sarah Hoffmann
b366b9df6f reverse: avoid interpolation lookup when result is already perfect 2025-12-05 17:08:46 +01:00
Sarah Hoffmann
6b12501c7a Merge pull request #3898 from lonvia/fix-country-restriction
Fix comparision between country tokens and country restriction
2025-12-04 20:03:14 +01:00
Sarah Hoffmann
ffd5c32f17 fix comparision between countr tokens and country restriction 2025-12-04 18:29:25 +01:00
Sarah Hoffmann
6c8869439f Merge pull request #3897 from lonvia/test-psycopg-33
Allow psycopg 3.3 back
2025-12-04 17:10:55 +01:00
Sarah Hoffmann
8188946394 ignore typing isssue 2025-12-03 10:22:11 +01:00
Sarah Hoffmann
19134cc15c exclude psycopg 3.3.0 which breaks named cursors 2025-12-03 10:22:04 +01:00
Sarah Hoffmann
d0b9aac400 Merge pull request #3895 from lonvia/flaky-test
Fix flaky test around postcode word match penalties
2025-12-02 12:46:43 +01:00
Sarah Hoffmann
48d13c593b fix flaky test around postcode word match penalties 2025-12-02 11:15:37 +01:00
Sarah Hoffmann
96d04e3a2e Merge pull request #3894 from lonvia/country-names-with-word-lookup
Add normalized form of country names to coutry tokens in word table
2025-12-01 14:54:24 +01:00
Sarah Hoffmann
23db1ab981 avoid most recent psycopg 3.3 release 2025-12-01 14:23:36 +01:00
Sarah Hoffmann
cd1b1736a9 add migration for changed country token format 2025-12-01 13:10:18 +01:00
Sarah Hoffmann
9447c90b09 adapt tests to new country token format 2025-12-01 13:10:18 +01:00
Sarah Hoffmann
81c6cb72e6 add normalised country name to word table
Country tokens now follow the usual convetion of having the
normalized version in the word column and the extra info about the
country code in the info column.
2025-12-01 13:10:18 +01:00
Sarah Hoffmann
f2a122c5c0 Merge pull request #3893 from lonvia/nature-reserve
Prefer leisure=nature_reserve as main tag over boundary=protected_area
2025-12-01 11:36:17 +01:00
Sarah Hoffmann
57ef0e1f98 prefer leisure=nature_reserve as main tag 2025-12-01 09:47:55 +01:00
Sarah Hoffmann
922667b650 Merge pull request #3892 from daishu0000/master
Add success message to setup.log: related to #3891
2025-11-30 14:13:51 +01:00
Sarah Hoffmann
fba803167c fix imprecise import 2025-11-30 11:50:55 +01:00
daishu0000
782df52ea0 Add success message to db log 2025-11-30 01:53:40 +08:00
Sarah Hoffmann
c36da68a48 Merge pull request #3890 from mtmail/remove-nat-name
Skip nat_name in default import
2025-11-28 14:13:30 +01:00
marc tobias
716de13bc9 Skip nat_name in default import 2025-11-28 11:35:35 +01:00
Sarah Hoffmann
1df56d7548 Merge pull request #3889 from lonvia/improve-linkage-code
Small improvements to place linking code
2025-11-26 22:11:11 +01:00
Sarah Hoffmann
9cfef7a31a prefer wikidata over name match when linking 2025-11-26 17:44:47 +01:00
Sarah Hoffmann
139678f367 fix linkage removal when nothing has changed 2025-11-26 17:03:19 +01:00
Sarah Hoffmann
e578c60ff4 Merge pull request #3874 from vytas7/falcon-4.2-typing
Adapt type annotations to Falcon App type changes
2025-11-16 16:12:35 +01:00
Vytautas Liuolia
7b4a3c8500 Add from __future__ import annotations to delay evaluation 2025-11-16 14:41:25 +01:00
Vytautas Liuolia
7751f9a6b6 Adapt type annotations to Falcon App type changes
See also: https://falcon.readthedocs.io/en/latest/api/typing.html#generic-app-types
2025-11-10 20:09:17 +01:00
Sarah Hoffmann
303ac42b47 Merge pull request #3862 from mtmail/skip-all-zero-postcodes
Postcode sanetizer now skips values which are only zeros
2025-10-31 10:36:05 +01:00
Sarah Hoffmann
6a2d2daad5 Merge pull request #3863 from lonvia/improve-bdd-test-names
Add custom pytest collector for BDD feature files
2025-10-31 10:19:56 +01:00
Sarah Hoffmann
a51c771107 disable improved BDD test naming for pytest < 8
Needs the improved test collector introduced in pytest 8.0.
2025-10-30 20:50:00 +01:00
Sarah Hoffmann
55547723bf add custom pytest collector for BDD feature files 2025-10-30 17:56:23 +01:00
marc tobias
362088775f postcode sanetizer skips postcodes which are only zeros 2025-10-30 13:45:29 +01:00
136 changed files with 2409 additions and 1478 deletions

12
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,12 @@
## Summary
<!-- Describe the purpose of your pull request and, if present, link to existing issues. -->
## AI usage
<!-- Please list where and to what extent AI was used. -->
## Contributor guidelines (mandatory)
<!-- We only accept pull requests that follow our guidelines. A deliberate violation may result in a ban. -->
- [ ] I have adhered to the [coding style](https://github.com/osm-search/Nominatim/blob/master/CONTRIBUTING.md#coding-style)
- [ ] I have [tested](https://github.com/osm-search/Nominatim/blob/master/CONTRIBUTING.md#testing) the proposed changes
- [ ] I have [disclosed](https://github.com/osm-search/Nominatim/blob/master/CONTRIBUTING.md#using-ai-assisted-code-generators) above any use of AI to generate code, documentation, or the pull request description

View File

@@ -22,7 +22,7 @@ runs:
- name: Install prerequisites from apt
run: |
sudo apt-get install -y -qq python3-icu python3-datrie python3-jinja2 python3-psutil python3-dotenv python3-yaml python3-sqlalchemy python3-psycopg python3-asyncpg
sudo apt-get install -y -qq python3-icu python3-datrie python3-jinja2 python3-psutil python3-dotenv python3-yaml python3-sqlalchemy python3-psycopg python3-asyncpg python3-mwparserfromhell
shell: bash
if: inputs.dependencies == 'apt'

View File

@@ -4,7 +4,7 @@
Bugs can be reported at https://github.com/openstreetmap/Nominatim/issues.
Please always open a separate issue for each problem. In particular, do
not add your bugs to closed issues. They may looks similar to you but
not add your bugs to closed issues. They may look similar to you but
often are completely different from the maintainer's point of view.
## Workflow for Pull Requests
@@ -21,7 +21,7 @@ that you are responsible for your pull requests. You should be prepared
to get change requests because as the maintainers we have to make sure
that your contribution fits well with the rest of the code. Please make
sure that you have time to react to these comments and amend the code or
engage in a conversion. Do not expect that others will pick up your code,
engage in a conversation. Do not expect that others will pick up your code,
it will almost never happen.
Please open a separate pull request for each issue you want to address.
@@ -38,10 +38,19 @@ description or in documentation need to
1. clearly mark the AI-generated sections as such, for example, by
mentioning all use of AI in the PR description, and
2. include proof that you have run the generated code on an actual
installation of Nominatim. Adding and excuting tests will not be
installation of Nominatim. Adding and executing tests will not be
sufficient. You need to show that the code actually solves the problem
the PR claims to solve.
## Getting Started with Development
Please see the development section of the Nominatim documentation for
* [an architecture overview](https://nominatim.org/release-docs/develop/develop/overview/)
and backgrounds on some of the algorithms
* [how to set up a development environment](https://nominatim.org/release-docs/develop/develop/Development-Environment/)
* and background on [how tests are organised](https://nominatim.org/release-docs/develop/develop/Testing/)
## Coding style

View File

@@ -10,14 +10,14 @@ Nominatim. Please refer to the documentation of
[Nginx](https://nginx.org/en/docs/) for background information on how
to configure it.
!!! Note
Throughout this page, we assume your Nominatim project directory is
located in `/srv/nominatim-project`. If you have put it somewhere else,
you need to adjust the commands and configuration accordingly.
### Installing the required packages
!!! warning
ASGI support in gunicorn requires at least version 25.0. If you need
to work with an older version of gunicorn, please refer to
[older Nominatim deployment documentation](https://nominatim.org/release-docs/5.2/admin/Deployment-Python/)
to learn how to run gunicorn with uvicorn.
The Nominatim frontend is best run from its own virtual environment. If
you have already created one for the database backend during the
[installation](Installation.md#building-nominatim), you can use that. Otherwise
@@ -37,23 +37,27 @@ cd Nominatim
```
The recommended way to deploy a Python ASGI application is to run
the ASGI runner [uvicorn](https://www.uvicorn.org/)
together with [gunicorn](https://gunicorn.org/) HTTP server. We use
the [gunicorn](https://gunicorn.org/) HTTP server. We use
Falcon here as the web framework.
Add the necessary packages to your virtual environment:
``` sh
/srv/nominatim-venv/bin/pip install falcon uvicorn gunicorn
/srv/nominatim-venv/bin/pip install falcon gunicorn
```
### Setting up Nominatim as a systemd job
!!! Note
These instructions assume your Nominatim project directory is
located in `/srv/nominatim-project`. If you have put it somewhere else,
you need to adjust the commands and configuration accordingly.
Next you need to set up the service that runs the Nominatim frontend. This is
easiest done with a systemd job.
First you need to tell systemd to create a socket file to be used by
hunicorn. Create the following file `/etc/systemd/system/nominatim.socket`:
gunicorn. Create the following file `/etc/systemd/system/nominatim.socket`:
``` systemd
[Unit]
@@ -81,10 +85,8 @@ Type=simple
User=www-data
Group=www-data
WorkingDirectory=/srv/nominatim-project
ExecStart=/srv/nominatim-venv/bin/gunicorn -b unix:/run/nominatim.sock -w 4 -k uvicorn.workers.UvicornWorker "nominatim_api.server.falcon.server:run_wsgi()"
ExecStart=/srv/nominatim-venv/bin/gunicorn -b unix:/run/nominatim.sock -w 4 --worker-class asgi --protocol uwsgi --worker-connections 1000 "nominatim_api.server.falcon.server:run_wsgi()"
ExecReload=/bin/kill -s HUP $MAINPID
StandardOutput=append:/var/log/gunicorn-nominatim.log
StandardError=inherit
PrivateTmp=true
TimeoutStopSec=5
KillMode=mixed
@@ -96,7 +98,10 @@ WantedBy=multi-user.target
This sets up gunicorn with 4 workers (`-w 4` in ExecStart). Each worker runs
its own Python process using
[`NOMINATIM_API_POOL_SIZE`](../customize/Settings.md#nominatim_api_pool_size)
connections to the database to serve requests in parallel.
connections to the database to serve requests in parallel. The parameter
`--worker-connections` restricts how many requests gunicorn will queue for
each worker. This can help distribute work better when the server is under
high load.
Make the new services known to systemd and start it:
@@ -108,13 +113,15 @@ sudo systemctl enable nominatim.service
sudo systemctl start nominatim.service
```
This sets the service up, so that Nominatim is automatically started
This sets the service up so that Nominatim is automatically started
on reboot.
### Configuring nginx
To make the service available to the world, you need to proxy it through
nginx. Add the following definition to the default configuration:
nginx. We use the binary uwsgi protocol to speed up communication
between nginx and gunicorn. Add the following definition to the default
configuration:
``` nginx
upstream nominatim_service {
@@ -129,11 +136,8 @@ server {
index /search;
location / {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
proxy_pass http://nominatim_service;
uwsgi_pass nominatim_service;
include uwsgi_params;
}
}
```

View File

@@ -37,6 +37,7 @@ Furthermore the following Python libraries are required:
* [Jinja2](https://palletsprojects.com/p/jinja/)
* [PyICU](https://pypi.org/project/PyICU/)
* [PyYaml](https://pyyaml.org/) (5.1+)
* [mwparserfromhell](https://github.com/earwig/mwparserfromhell/)
These will be installed automatically when using pip installation.

View File

@@ -113,6 +113,7 @@ The following classifications are recognized:
| named | Consider as main tag, when the object has a primary name (see [names](#name-tags) below) |
| named_with_key | Consider as main tag, when the object has a primary name with a domain prefix. For example, if the main tag is `bridge=yes`, then it will only be added as an extra entry, if there is a tag `bridge:name[:XXX]` for the same object. If this property is set, all names that are not domain-specific are ignored. |
| fallback | Consider as main tag only when no other main tag was found. Fallback always implies `named`, i.e. fallbacks are only tried for objects with primary names. |
| postcode_area | Tag indicates a postcode area. Copy area into the table of postcodes but only when the object is a relation and has a postcode tagged. |
| delete | Completely ignore the tag in any further processing |
| extra | Move the tag to extratags and then ignore it for further processing |
| `<function>`| Advanced handling, see [below](#advanced-main-tag-handling) |

View File

@@ -229,7 +229,7 @@ _None._
| Option | Description |
|-----------------|-------------|
| locales | [Locale](../library/Result-Handling.md#locale) object for the requested language(s) |
| locales | [Locales](../library/Result-Handling.md#locale) object for the requested language(s) |
| group_hierarchy | Setting of [group_hierarchy](../api/Details.md#output-details) parameter |
| icon_base_url | (optional) URL pointing to icons as set in [NOMINATIM_MAPICON_URL](Settings.md#nominatim_mapicon_url) |

View File

@@ -79,7 +79,7 @@ the placex table. Only three columns are special:
Address interpolations are always ways in OSM, which is why there is no column
`osm_type`.
The **location_postcode** table holds computed centroids of all postcodes that
The **location_postcodes** table holds computed centroids of all postcodes that
can be found in the OSM data. The meaning of the columns is again the same
as that of the placex table.

View File

@@ -56,7 +56,7 @@ The easiest way, to handle these Python dependencies is to run your
development from within a virtual environment.
```sh
sudo apt install libsqlite3-mod-spatialite osm2pgsql \
sudo apt install build-essential libsqlite3-mod-spatialite osm2pgsql \
postgresql-postgis postgresql-postgis-scripts \
pkg-config libicu-dev virtualenv
```
@@ -68,12 +68,12 @@ virtualenv ~/nominatim-dev-venv
~/nominatim-dev-venv/bin/pip install\
psutil 'psycopg[binary]' PyICU SQLAlchemy \
python-dotenv jinja2 pyYAML \
mkdocs 'mkdocstrings[python]' mkdocs-gen-files \
mkdocs 'mkdocstrings[python]' mkdocs-gen-files mkdocs-material \
pytest pytest-asyncio pytest-bdd flake8 \
types-jinja2 types-markupsafe types-psutil types-psycopg2 \
types-pygments types-pyyaml types-requests types-ujson \
types-urllib3 typing-extensions unicorn falcon starlette \
uvicorn mypy osmium aiosqlite
types-urllib3 typing-extensions gunicorn falcon starlette \
uvicorn mypy osmium aiosqlite mwparserfromhell
```
Now enter the virtual environment whenever you want to develop:

View File

@@ -52,6 +52,15 @@ To run the functional tests, do
pytest test/bdd
You can run a single feature file using expression matching:
pytest test/bdd -k osm2pgsql/import/entrances.feature
This even works for running single tests by adding the line number of the
scenario header like that:
pytest test/bdd -k 'osm2pgsql/import/entrances.feature and L4'
The BDD tests create databases for the tests. You can set name of the databases
through configuration variables in your `pytest.ini`:

View File

@@ -74,15 +74,16 @@ map place_addressline {
isaddress => BOOLEAN
}
map location_postcode {
map location_postcodes {
place_id => BIGINT
osm_id => BIGINT
postcode => TEXT
parent_place_id => BIGINT
rank_search => SMALLINT
rank_address => SMALLINT
indexed_status => SMALLINT
indexed_date => TIMESTAMP
geometry => GEOMETRY
centroid -> GEOMETRY
}
placex::place_id <-- search_name::place_id
@@ -94,6 +95,6 @@ search_name::nameaddress_vector --> word::word_id
place_addressline -[hidden]> location_property_osmline
search_name -[hidden]> place_addressline
location_property_osmline -[hidden]-> location_postcode
location_property_osmline -[hidden]-> location_postcodes
@enduml

View File

@@ -248,19 +248,19 @@ of the result. To do that, you first need to decide in which language the
results should be presented. As with the names in the result itself, the
places in `address_rows` contain all possible name translation for each row.
The library has a helper class `Locale` which helps extracting a name of a
The library has a helper class `Locales` which helps extracting a name of a
place in the preferred language. It takes a single parameter with a list
of language codes in the order of preference. So
``` python
locale = napi.Locale(['fr', 'en'])
locale = napi.Locales(['fr', 'en'])
```
creates a helper class that returns the name preferably in French. If that is
not possible, it tries English and eventually falls back to the default `name`
or `ref`.
The `Locale` object can be applied to a name dictionary to return the best-matching
The `Locales` object can be applied to a name dictionary to return the best-matching
name out of it:
``` python
@@ -273,7 +273,7 @@ component based on its `local_name` field. This is then utilized by the overall
which has a helper function to apply the function to all its address_row members and saves
the result in the `locale_name` field.
However, in order to set this `local_name` field in a preferred language, you must use the `Locale`
However, in order to set this `local_name` field in a preferred language, you must use the `Locales`
object which contains the function `localize_results`, which explicitly sets each `local_name field`.
``` python

View File

@@ -13,7 +13,8 @@ for infile in VAGRANT_PATH.glob('Install-on-*.sh'):
outfile = f"admin/{infile.stem}.md"
title = infile.stem.replace('-', ' ')
with mkdocs_gen_files.open(outfile, "w") as outfd, infile.open() as infd:
with mkdocs_gen_files.open(outfile, "w", encoding='utf-8') as outfd, \
infile.open(encoding='utf-8') as infd:
print("#", title, file=outfd)
has_empty = False
for line in infd:

View File

@@ -65,7 +65,19 @@ local table_definitions = {
{ column = 'geometry', type = 'geometry', projection = 'WGS84', not_null = true }
},
indexes = {}
}
},
place_postcode = {
ids = { type = 'any', id_column = 'osm_id', type_column = 'osm_type' },
columns = {
{ column = 'postcode', type = 'text', not_null = true },
{ column = 'country_code', type = 'text' },
{ column = 'centroid', type = 'point', projection = 'WGS84', not_null = true },
{ column = 'geometry', type = 'geometry', projection = 'WGS84' }
},
indexes = {
{ column = 'postcode', method = 'btree' }
}
}
}
local insert_row = {}
@@ -113,6 +125,7 @@ local PlaceTransform = {}
-- Special transform meanings which are interpreted elsewhere
PlaceTransform.fallback = 'fallback'
PlaceTransform.postcode_area = 'postcode_area'
PlaceTransform.delete = 'delete'
PlaceTransform.extra = 'extra'
@@ -419,11 +432,25 @@ function Place:write_place(k, v, mfunc)
return 0
end
function Place:write_row(k, v)
function Place:geometry_is_valid()
if self.geometry == nil then
self.geometry = self.geom_func(self.object)
if self.geometry == nil or self.geometry:is_null() then
self.geometry = false
return false
end
return true
end
if self.geometry == nil or self.geometry:is_null() then
return self.geometry ~= false
end
function Place:write_row(k, v)
if not self:geometry_is_valid() then
return 0
end
@@ -675,9 +702,6 @@ function module.process_tags(o)
if o.address.country ~= nil and #o.address.country ~= 2 then
o.address['country'] = nil
end
if POSTCODE_FALLBACK and fallback == nil and o.address.postcode ~= nil then
fallback = {'place', 'postcode', PlaceTransform.always}
end
if o.address.interpolation ~= nil then
o:write_place('place', 'houses', PlaceTransform.always)
@@ -685,20 +709,41 @@ function module.process_tags(o)
end
-- collect main keys
local postcode_collect = false
for k, v in pairs(o.intags) do
local ktable = MAIN_KEYS[k]
if ktable then
local ktype = ktable[v] or ktable[1]
if type(ktype) == 'function' then
o:write_place(k, v, ktype)
elseif ktype == 'postcode_area' then
postcode_collect = true
if o.object.type == 'relation'
and o.address.postcode ~= nil
and o:geometry_is_valid() then
insert_row.place_postcode{
postcode = o.address.postcode,
centroid = o.geometry:centroid(),
geometry = o.geometry
}
end
elseif ktype == 'fallback' and o.has_name then
fallback = {k, v, PlaceTransform.named}
end
end
end
if fallback ~= nil and o.num_entries == 0 then
o:write_place(fallback[1], fallback[2], fallback[3])
if o.num_entries == 0 then
if fallback ~= nil then
o:write_place(fallback[1], fallback[2], fallback[3])
elseif POSTCODE_FALLBACK and not postcode_collect
and o.address.postcode ~= nil
and o:geometry_is_valid() then
insert_row.place_postcode{
postcode = o.address.postcode,
centroid = o.geometry:centroid()
}
end
end
end

View File

@@ -117,7 +117,8 @@ module.MAIN_TAGS.all_boundaries = {
boundary = {'named',
place = 'delete',
land_area = 'delete',
postal_code = 'always'},
protected_area = 'fallback',
postal_code = 'postcode_area'},
landuse = 'fallback',
place = 'always'
}
@@ -198,7 +199,7 @@ module.MAIN_TAGS_POIS = function (group)
no = group},
landuse = {cemetery = 'always'},
leisure = {'always',
nature_reserve = 'fallback',
nature_reserve = 'named',
swimming_pool = 'named',
garden = 'named',
common = 'named',
@@ -321,7 +322,6 @@ module.NAME_TAGS = {}
module.NAME_TAGS.core = {main = {'name', 'name:*',
'int_name', 'int_name:*',
'nat_name', 'nat_name:*',
'reg_name', 'reg_name:*',
'loc_name', 'loc_name:*',
'old_name', 'old_name:*',

View File

@@ -2,7 +2,7 @@
--
-- This file is part of Nominatim. (https://nominatim.org)
--
-- Copyright (C) 2022 by the Nominatim developer community.
-- Copyright (C) 2025 by the Nominatim developer community.
-- For a full list of authors see the git log.
{% include('functions/utils.sql') %}
@@ -18,7 +18,7 @@
{% include 'functions/placex_triggers.sql' %}
{% endif %}
{% if 'location_postcode' in db.tables %}
{% if 'location_postcodes' in db.tables %}
{% include 'functions/postcode_triggers.sql' %}
{% endif %}

View File

@@ -2,7 +2,7 @@
--
-- This file is part of Nominatim. (https://nominatim.org)
--
-- Copyright (C) 2022 by the Nominatim developer community.
-- Copyright (C) 2026 by the Nominatim developer community.
-- For a full list of authors see the git log.
-- Functions for interpreting wkipedia/wikidata tags and computing importance.
@@ -166,7 +166,7 @@ BEGIN
END LOOP;
-- Nothing? Then try with the wikidata tag.
IF result.importance is null AND extratags ? 'wikidata' THEN
IF extratags ? 'wikidata' THEN
FOR match IN
{% if 'wikimedia_importance' in db.tables %}
SELECT * FROM wikimedia_importance
@@ -185,18 +185,18 @@ BEGIN
END IF;
-- Still nothing? Fall back to a default.
IF result.importance is null THEN
result.importance := 0.40001 - (rank_search::float / 75);
END IF;
result.importance := 0.40001 - (rank_search::float / 75);
{% if 'secondary_importance' in db.tables %}
FOR match IN
SELECT ST_Value(rast, centroid) as importance
FROM secondary_importance
WHERE ST_Intersects(ST_ConvexHull(rast), centroid) LIMIT 1
FROM secondary_importance
WHERE ST_Intersects(ST_ConvexHull(rast), centroid) LIMIT 1
LOOP
-- Secondary importance as tie breaker with 0.0001 weight.
result.importance := result.importance + match.importance::float / 655350000;
IF match.importance is not NULL THEN
-- Secondary importance as tie breaker with 0.0001 weight.
result.importance := result.importance + match.importance::float / 655350000;
END IF;
END LOOP;
{% endif %}

View File

@@ -2,7 +2,7 @@
--
-- This file is part of Nominatim. (https://nominatim.org)
--
-- Copyright (C) 2022 by the Nominatim developer community.
-- Copyright (C) 2026 by the Nominatim developer community.
-- For a full list of authors see the git log.
DROP TYPE IF EXISTS nearfeaturecentr CASCADE;
@@ -123,10 +123,12 @@ BEGIN
RETURN TRUE;
END IF;
IF in_rank_search <= 4 and not in_estimate THEN
INSERT INTO location_area_country (place_id, country_code, geometry)
(SELECT in_place_id, in_country_code, geom
FROM split_geometry(in_geometry) as geom);
IF in_rank_search <= 4 THEN
IF not in_estimate and in_country_code is not NULL THEN
INSERT INTO location_area_country (place_id, country_code, geometry)
(SELECT in_place_id, in_country_code, geom
FROM split_geometry(in_geometry) as geom);
END IF;
RETURN TRUE;
END IF;
@@ -212,7 +214,6 @@ DECLARE
BEGIN
{% for partition in db.partitions %}
IF in_partition = {{ partition }} THEN
DELETE FROM search_name_{{ partition }} values WHERE place_id = in_place_id;
IF in_rank_address > 0 THEN
INSERT INTO search_name_{{ partition }} (place_id, address_rank, name_vector, centroid)
values (in_place_id, in_rank_address, in_name_vector, in_geometry);
@@ -251,7 +252,6 @@ BEGIN
{% for partition in db.partitions %}
IF in_partition = {{ partition }} THEN
DELETE FROM location_road_{{ partition }} where place_id = in_place_id;
INSERT INTO location_road_{{ partition }} (partition, place_id, country_code, geometry)
values (in_partition, in_place_id, in_country_code, in_geometry);
RETURN TRUE;

View File

@@ -2,7 +2,7 @@
--
-- This file is part of Nominatim. (https://nominatim.org)
--
-- Copyright (C) 2022 by the Nominatim developer community.
-- Copyright (C) 2026 by the Nominatim developer community.
-- For a full list of authors see the git log.
CREATE OR REPLACE FUNCTION place_insert()
@@ -66,7 +66,8 @@ BEGIN
-- They get their parent from the interpolation.
UPDATE placex p SET indexed_status = 2
FROM planet_osm_ways w
WHERE w.id = NEW.osm_id and p.osm_type = 'N' and p.osm_id = any(w.nodes);
WHERE w.id = NEW.osm_id and p.osm_type = 'N' and p.osm_id = any(w.nodes)
and indexed_status = 0;
-- If there is already an entry in place, just update that, if necessary.
IF existing.osm_type is not null THEN
@@ -89,35 +90,6 @@ BEGIN
RETURN NEW;
END IF;
-- ---- Postcode points.
IF NEW.class = 'place' AND NEW.type = 'postcode' THEN
-- Pure postcodes are never queried from placex so we don't add them.
-- location_postcodes is filled from the place table directly.
-- Remove any old placex entry.
DELETE FROM placex WHERE osm_type = NEW.osm_type and osm_id = NEW.osm_id;
IF existing.osm_type IS NOT NULL THEN
IF coalesce(existing.address, ''::hstore) != coalesce(NEW.address, ''::hstore)
OR existing.geometry::text != NEW.geometry::text
THEN
UPDATE place
SET name = NEW.name,
address = NEW.address,
extratags = NEW.extratags,
admin_level = NEW.admin_level,
geometry = NEW.geometry
WHERE osm_type = NEW.osm_type and osm_id = NEW.osm_id
and class = NEW.class and type = NEW.type;
END IF;
RETURN NULL;
END IF;
RETURN NEW;
END IF;
-- ---- All other place types.
-- When an area is changed from large to small: log and discard change
@@ -269,17 +241,6 @@ BEGIN
WHERE osm_type = NEW.osm_type and osm_id = NEW.osm_id
and class = NEW.class and type = NEW.type;
-- Postcode areas are only kept, when there is an actual postcode assigned.
IF NEW.class = 'boundary' AND NEW.type = 'postal_code' THEN
IF NEW.address is NULL OR NOT NEW.address ? 'postcode' THEN
-- postcode was deleted, no longer retain in placex
DELETE FROM placex where place_id = existingplacex.place_id;
RETURN NULL;
END IF;
NEW.name := hstore('ref', NEW.address->'postcode');
END IF;
-- Boundaries must be areas.
IF NEW.class in ('boundary')
AND ST_GeometryType(NEW.geometry) not in ('ST_Polygon','ST_MultiPolygon')

View File

@@ -2,7 +2,7 @@
--
-- This file is part of Nominatim. (https://nominatim.org)
--
-- Copyright (C) 2025 by the Nominatim developer community.
-- Copyright (C) 2026 by the Nominatim developer community.
-- For a full list of authors see the git log.
-- Trigger functions for the placex table.
@@ -304,7 +304,6 @@ DECLARE
BEGIN
IF bnd.rank_search >= 26 or bnd.rank_address = 0
or ST_GeometryType(bnd.geometry) NOT IN ('ST_Polygon','ST_MultiPolygon')
or bnd.type IN ('postcode', 'postal_code')
THEN
RETURN NULL;
END IF;
@@ -341,26 +340,6 @@ BEGIN
END IF;
END IF;
-- If extratags has a place tag, look for linked nodes by their place type.
-- Area and node still have to have the same name.
IF bnd.extratags ? 'place' and bnd.extratags->'place' != 'postcode'
and bnd_name is not null
THEN
FOR linked_placex IN
SELECT * FROM placex
WHERE (position(lower(name->'name') in bnd_name) > 0
OR position(bnd_name in lower(name->'name')) > 0)
AND placex.class = 'place' AND placex.type = bnd.extratags->'place'
AND placex.osm_type = 'N'
AND (placex.linked_place_id is null or placex.linked_place_id = bnd.place_id)
AND placex.rank_search < 26 -- needed to select the right index
AND ST_Covers(bnd.geometry, placex.geometry)
LOOP
{% if debug %}RAISE WARNING 'Found type-matching place node %', linked_placex.osm_id;{% endif %}
RETURN linked_placex;
END LOOP;
END IF;
IF bnd.extratags ? 'wikidata' THEN
FOR linked_placex IN
SELECT * FROM placex
@@ -377,6 +356,25 @@ BEGIN
END LOOP;
END IF;
-- If extratags has a place tag, look for linked nodes by their place type.
-- Area and node still have to have the same name.
IF bnd.extratags ? 'place' and bnd_name is not null
THEN
FOR linked_placex IN
SELECT * FROM placex
WHERE (position(lower(name->'name') in bnd_name) > 0
OR position(bnd_name in lower(name->'name')) > 0)
AND placex.class = 'place' AND placex.type = bnd.extratags->'place'
AND placex.osm_type = 'N'
AND (placex.linked_place_id is null or placex.linked_place_id = bnd.place_id)
AND placex.rank_search < 26 -- needed to select the right index
AND ST_Covers(bnd.geometry, placex.geometry)
LOOP
{% if debug %}RAISE WARNING 'Found type-matching place node %', linked_placex.osm_id;{% endif %}
RETURN linked_placex;
END LOOP;
END IF;
-- Name searches can be done for ways as well as relations
IF bnd_name is not null THEN
{% if debug %}RAISE WARNING 'Looking for nodes with matching names';{% endif %}
@@ -393,7 +391,6 @@ BEGIN
AND placex.class = 'place'
AND (placex.linked_place_id is null or placex.linked_place_id = bnd.place_id)
AND placex.rank_search < 26 -- needed to select the right index
AND placex.type != 'postcode'
AND ST_Covers(bnd.geometry, placex.geometry)
LOOP
{% if debug %}RAISE WARNING 'Found matching place node %', linked_placex.osm_id;{% endif %}
@@ -468,7 +465,7 @@ BEGIN
END IF;
END LOOP;
name_vector := token_get_name_search_tokens(token_info);
name_vector := COALESCE(token_get_name_search_tokens(token_info), '{}'::INTEGER[]);
-- Check if the parent covers all address terms.
-- If not, create a search name entry with the house number as the name.
@@ -675,7 +672,7 @@ CREATE OR REPLACE FUNCTION placex_insert()
AS $$
DECLARE
postcode TEXT;
result BOOLEAN;
result INT;
is_area BOOLEAN;
country_code VARCHAR(2);
diameter FLOAT;
@@ -697,17 +694,7 @@ BEGIN
ELSE
is_area := ST_GeometryType(NEW.geometry) IN ('ST_Polygon','ST_MultiPolygon');
IF NEW.class in ('place','boundary')
AND NEW.type in ('postcode','postal_code')
THEN
IF NEW.address IS NULL OR NOT NEW.address ? 'postcode' THEN
-- most likely just a part of a multipolygon postcode boundary, throw it away
RETURN NULL;
END IF;
NEW.name := hstore('ref', NEW.address->'postcode');
ELSEIF NEW.class = 'highway' AND is_area AND NEW.name is null
IF NEW.class = 'highway' AND is_area AND NEW.name is null
AND NEW.extratags ? 'area' AND NEW.extratags->'area' = 'yes'
THEN
RETURN NULL;
@@ -790,11 +777,12 @@ BEGIN
-- add to tables for special search
-- Note: won't work on initial import because the classtype tables
-- do not yet exist. It won't hurt either.
classtable := 'place_classtype_' || NEW.class || '_' || NEW.type;
SELECT count(*)>0 FROM pg_tables WHERE tablename = classtable and schemaname = current_schema() INTO result;
IF result THEN
SELECT count(*) INTO result
FROM pg_tables
WHERE classtable NOT SIMILAR TO '%\W%'
AND tablename = classtable and schemaname = current_schema();
IF result > 0 THEN
EXECUTE 'INSERT INTO ' || classtable::regclass || ' (place_id, centroid) VALUES ($1,$2)'
USING NEW.place_id, NEW.centroid;
END IF;
@@ -853,13 +841,15 @@ BEGIN
NEW.indexed_date = now();
{% if 'search_name' in db.tables %}
DELETE from search_name WHERE place_id = NEW.place_id;
{% endif %}
result := deleteSearchName(NEW.partition, NEW.place_id);
DELETE FROM place_addressline WHERE place_id = NEW.place_id;
result := deleteRoad(NEW.partition, NEW.place_id);
result := deleteLocationArea(NEW.partition, NEW.place_id, NEW.rank_search);
IF OLD.indexed_status > 1 THEN
{% if 'search_name' in db.tables %}
DELETE from search_name WHERE place_id = NEW.place_id;
{% endif %}
result := deleteSearchName(NEW.partition, NEW.place_id);
DELETE FROM place_addressline WHERE place_id = NEW.place_id;
result := deleteRoad(NEW.partition, NEW.place_id);
result := deleteLocationArea(NEW.partition, NEW.place_id, NEW.rank_search);
END IF;
NEW.extratags := NEW.extratags - 'linked_place'::TEXT;
IF NEW.extratags = ''::hstore THEN
@@ -872,19 +862,12 @@ BEGIN
NEW.linked_place_id := OLD.linked_place_id;
-- Remove linkage, if we have computed a different new linkee.
UPDATE placex SET linked_place_id = null, indexed_status = 2
WHERE linked_place_id = NEW.place_id
and (linked_place is null or linked_place_id != linked_place);
-- update not necessary for osmline, cause linked_place_id does not exist
-- Postcodes are just here to compute the centroids. They are not searchable
-- unless they are a boundary=postal_code.
-- There was an error in the style so that boundary=postal_code used to be
-- imported as place=postcode. That's why relations are allowed to pass here.
-- This can go away in a couple of versions.
IF NEW.class = 'place' and NEW.type = 'postcode' and NEW.osm_type != 'R' THEN
NEW.token_info := null;
RETURN NEW;
IF OLD.indexed_status > 1 THEN
UPDATE placex
SET linked_place_id = null,
indexed_status = CASE WHEN indexed_status = 0 THEN 2 ELSE indexed_status END
WHERE linked_place_id = NEW.place_id
and (linked_place is null or place_id != linked_place);
END IF;
-- Compute a preliminary centroid.
@@ -1055,7 +1038,9 @@ BEGIN
LOOP
UPDATE placex SET linked_place_id = NEW.place_id WHERE place_id = linked_node_id;
{% if 'search_name' in db.tables %}
DELETE FROM search_name WHERE place_id = linked_node_id;
IF OLD.indexed_status > 1 THEN
DELETE FROM search_name WHERE place_id = linked_node_id;
END IF;
{% endif %}
END LOOP;
END IF;
@@ -1204,11 +1189,6 @@ BEGIN
-- reset the address rank if necessary.
UPDATE placex set linked_place_id = NEW.place_id, indexed_status = 2
WHERE place_id = location.place_id;
-- ensure that those places are not found anymore
{% if 'search_name' in db.tables %}
DELETE FROM search_name WHERE place_id = location.place_id;
{% endif %}
PERFORM deleteLocationArea(NEW.partition, location.place_id, NEW.rank_search);
SELECT wikipedia, importance
FROM compute_importance(location.extratags, NEW.country_code,
@@ -1219,7 +1199,7 @@ BEGIN
IF linked_importance is not null AND
(NEW.importance is null or NEW.importance < linked_importance)
THEN
NEW.importance = linked_importance;
NEW.importance := linked_importance;
END IF;
ELSE
-- No linked place? As a last resort check if the boundary is tagged with
@@ -1261,7 +1241,7 @@ BEGIN
LIMIT 1
LOOP
IF location.osm_id = NEW.osm_id THEN
{% if debug %}RAISE WARNING 'Updating names for country '%' with: %', NEW.country_code, NEW.name;{% endif %}
{% if debug %}RAISE WARNING 'Updating names for country ''%'' with: %', NEW.country_code, NEW.name;{% endif %}
UPDATE country_name SET derived_name = NEW.name WHERE country_code = NEW.country_code;
END IF;
END LOOP;
@@ -1286,8 +1266,6 @@ BEGIN
END IF;
ELSEIF NEW.rank_address > 25 THEN
max_rank := 25;
ELSEIF NEW.class in ('place','boundary') and NEW.type in ('postcode','postal_code') THEN
max_rank := NEW.rank_search;
ELSE
max_rank := NEW.rank_address;
END IF;
@@ -1302,10 +1280,10 @@ BEGIN
NEW.postcode := coalesce(token_get_postcode(NEW.token_info), NEW.postcode);
-- if we have a name add this to the name search table
IF NEW.name IS NOT NULL THEN
name_vector := token_get_name_search_tokens(NEW.token_info);
IF array_length(name_vector, 1) is not NULL THEN
-- Initialise the name vector using our name
NEW.name := add_default_place_name(NEW.country_code, NEW.name);
name_vector := token_get_name_search_tokens(NEW.token_info);
IF NEW.rank_search <= 25 and NEW.rank_address > 0 THEN
result := add_location(NEW.place_id, NEW.country_code, NEW.partition,
@@ -1360,15 +1338,16 @@ CREATE OR REPLACE FUNCTION placex_delete()
AS $$
DECLARE
b BOOLEAN;
result INT;
classtable TEXT;
BEGIN
-- RAISE WARNING 'placex_delete % %',OLD.osm_type,OLD.osm_id;
IF OLD.linked_place_id is null THEN
update placex set linked_place_id = null, indexed_status = 2 where linked_place_id = OLD.place_id and indexed_status = 0;
{% if debug %}RAISE WARNING 'placex_delete:01 % %',OLD.osm_type,OLD.osm_id;{% endif %}
update placex set linked_place_id = null where linked_place_id = OLD.place_id;
{% if debug %}RAISE WARNING 'placex_delete:02 % %',OLD.osm_type,OLD.osm_id;{% endif %}
UPDATE placex
SET linked_place_id = NULL,
indexed_status = CASE WHEN indexed_status = 0 THEN 2 ELSE indexed_status END
WHERE linked_place_id = OLD.place_id;
ELSE
update placex set indexed_status = 2 where place_id = OLD.linked_place_id and indexed_status = 0;
END IF;
@@ -1392,6 +1371,7 @@ BEGIN
-- reparenting also for OSM Interpolation Lines (and for Tiger?)
update location_property_osmline set indexed_status = 2 where indexed_status = 0 and parent_place_id = OLD.place_id;
UPDATE location_postcodes SET indexed_status = 2 WHERE parent_place_id = OLD.place_id;
END IF;
{% if debug %}RAISE WARNING 'placex_delete:08 % %',OLD.osm_type,OLD.osm_id;{% endif %}
@@ -1417,15 +1397,16 @@ BEGIN
-- remove from tables for special search
classtable := 'place_classtype_' || OLD.class || '_' || OLD.type;
SELECT count(*)>0 FROM pg_tables WHERE tablename = classtable and schemaname = current_schema() INTO b;
IF b THEN
SELECT count(*) INTO result
FROM pg_tables
WHERE classtable NOT SIMILAR TO '%\W%'
AND tablename = classtable and schemaname = current_schema();
IF result > 0 THEN
EXECUTE 'DELETE FROM ' || classtable::regclass || ' WHERE place_id = $1' USING OLD.place_id;
END IF;
{% if debug %}RAISE WARNING 'placex_delete:12 % %',OLD.osm_type,OLD.osm_id;{% endif %}
UPDATE location_postcode SET indexed_status = 2 WHERE parent_place_id = OLD.place_id;
RETURN OLD;
END;

View File

@@ -2,10 +2,10 @@
--
-- This file is part of Nominatim. (https://nominatim.org)
--
-- Copyright (C) 2022 by the Nominatim developer community.
-- Copyright (C) 2025 by the Nominatim developer community.
-- For a full list of authors see the git log.
-- Trigger functions for location_postcode table.
-- Trigger functions for location_postcodes table.
-- Trigger for updates of location_postcode
@@ -13,7 +13,7 @@
-- Computes the parent object the postcode most likely refers to.
-- This will be the place that determines the address displayed when
-- searching for this postcode.
CREATE OR REPLACE FUNCTION postcode_update()
CREATE OR REPLACE FUNCTION postcodes_update()
RETURNS TRIGGER
AS $$
DECLARE
@@ -28,13 +28,10 @@ BEGIN
partition := get_partition(NEW.country_code);
SELECT * FROM get_postcode_rank(NEW.country_code, NEW.postcode)
INTO NEW.rank_search, NEW.rank_address;
NEW.parent_place_id = 0;
FOR location IN
SELECT place_id
FROM getNearFeatures(partition, NEW.geometry, NEW.geometry, NEW.rank_search)
FROM getNearFeatures(partition, NEW.centroid, NEW.centroid, NEW.rank_search)
WHERE NOT isguess ORDER BY rank_address DESC, distance asc LIMIT 1
LOOP
NEW.parent_place_id = location.place_id;
@@ -45,3 +42,89 @@ END;
$$
LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION postcodes_delete()
RETURNS TRIGGER
AS $$
BEGIN
{% if not disable_diff_updates %}
UPDATE placex p SET indexed_status = 2
WHERE p.postcode = OLD.postcode AND ST_Intersects(OLD.geometry, p.geometry)
AND indexed_status = 0;
{% endif %}
RETURN OLD;
END;
$$
LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION postcodes_insert()
RETURNS TRIGGER
AS $$
DECLARE
existing RECORD;
BEGIN
IF NEW.osm_id is not NULL THEN
-- postcode area, remove existing from same OSM object
SELECT * INTO existing FROM location_postcodes p
WHERE p.osm_id = NEW.osm_id;
IF existing.place_id is not NULL THEN
IF existing.postcode != NEW.postcode or existing.country_code != NEW.country_code THEN
DELETE FROM location_postcodes p WHERE p.osm_id = NEW.osm_id;
existing := NULL;
END IF;
END IF;
END IF;
IF existing is NULL THEN
SELECT * INTO existing FROM location_postcodes p
WHERE p.country_code = NEW.country_code AND p.postcode = NEW.postcode;
IF existing.postcode is NULL THEN
{% if not disable_diff_updates %}
UPDATE placex p SET indexed_status = 2
WHERE ST_Intersects(NEW.geometry, p.geometry)
AND indexed_status = 0
AND p.rank_address >= 22 AND not p.address ? 'postcode';
{% endif %}
-- new entry, just insert
NEW.indexed_status := 1;
NEW.place_id := nextval('seq_place');
RETURN NEW;
END IF;
END IF;
-- update: only when there are changes
IF coalesce(NEW.osm_id, -1) != coalesce(existing.osm_id, -1)
OR (NEW.osm_id is not null AND NEW.geometry::text != existing.geometry::text)
OR (NEW.osm_id is null
AND (abs(ST_X(existing.centroid) - ST_X(NEW.centroid)) > 0.0000001
OR abs(ST_Y(existing.centroid) - ST_Y(NEW.centroid)) > 0.0000001))
THEN
{% if not disable_diff_updates %}
UPDATE placex p SET indexed_status = 2
WHERE ST_Intersects(ST_Difference(NEW.geometry, existing.geometry), p.geometry)
AND indexed_status = 0
AND p.rank_address >= 22 AND not p.address ? 'postcode';
UPDATE placex p SET indexed_status = 2
WHERE ST_Intersects(ST_Difference(existing.geometry, NEW.geometry), p.geometry)
AND indexed_status = 0
AND p.postcode = OLD.postcode;
{% endif %}
UPDATE location_postcodes p
SET osm_id = NEW.osm_id,
indexed_status = 2,
centroid = NEW.centroid,
geometry = NEW.geometry
WHERE p.country_code = NEW.country_code AND p.postcode = NEW.postcode;
END IF;
RETURN NULL;
END;
$$
LANGUAGE plpgsql;

View File

@@ -2,7 +2,7 @@
--
-- This file is part of Nominatim. (https://nominatim.org)
--
-- Copyright (C) 2022 by the Nominatim developer community.
-- Copyright (C) 2025 by the Nominatim developer community.
-- For a full list of authors see the git log.
-- Functions related to search and address ranks
@@ -114,66 +114,6 @@ $$
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE;
-- Guess a ranking for postcodes from country and postcode format.
CREATE OR REPLACE FUNCTION get_postcode_rank(country_code VARCHAR(2), postcode TEXT,
OUT rank_search SMALLINT,
OUT rank_address SMALLINT)
AS $$
DECLARE
part TEXT;
BEGIN
rank_search := 30;
rank_address := 30;
postcode := upper(postcode);
IF country_code = 'gb' THEN
IF postcode ~ '^([A-Z][A-Z]?[0-9][0-9A-Z]? [0-9][A-Z][A-Z])$' THEN
rank_search := 25;
rank_address := 5;
ELSEIF postcode ~ '^([A-Z][A-Z]?[0-9][0-9A-Z]? [0-9])$' THEN
rank_search := 23;
rank_address := 5;
ELSEIF postcode ~ '^([A-Z][A-Z]?[0-9][0-9A-Z])$' THEN
rank_search := 21;
rank_address := 5;
END IF;
ELSEIF country_code = 'sg' THEN
IF postcode ~ '^([0-9]{6})$' THEN
rank_search := 25;
rank_address := 11;
END IF;
ELSEIF country_code = 'de' THEN
IF postcode ~ '^([0-9]{5})$' THEN
rank_search := 21;
rank_address := 11;
END IF;
ELSE
-- Guess at the postcode format and coverage (!)
IF postcode ~ '^[A-Z0-9]{1,5}$' THEN -- Probably too short to be very local
rank_search := 21;
rank_address := 11;
ELSE
-- Does it look splitable into and area and local code?
part := substring(postcode from '^([- :A-Z0-9]+)([- :][A-Z0-9]+)$');
IF part IS NOT NULL THEN
rank_search := 25;
rank_address := 11;
ELSEIF postcode ~ '^[- :A-Z0-9]{6,}$' THEN
rank_search := 21;
rank_address := 11;
END IF;
END IF;
END IF;
END;
$$
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE;
-- Get standard search and address rank for an object.
--
-- \param country Two-letter country code where the object is in.
@@ -198,12 +138,7 @@ AS $$
DECLARE
classtype TEXT;
BEGIN
IF place_class in ('place','boundary')
and place_type in ('postcode','postal_code')
THEN
SELECT * INTO search_rank, address_rank
FROM get_postcode_rank(country, postcode);
ELSEIF extended_type = 'N' AND place_class = 'highway' THEN
IF extended_type = 'N' AND place_class = 'highway' THEN
search_rank = 30;
address_rank = 30;
ELSEIF place_class = 'landuse' AND extended_type != 'A' THEN

View File

@@ -2,7 +2,7 @@
--
-- This file is part of Nominatim. (https://nominatim.org)
--
-- Copyright (C) 2025 by the Nominatim developer community.
-- Copyright (C) 2026 by the Nominatim developer community.
-- For a full list of authors see the git log.
-- Assorted helper functions for the triggers.
@@ -46,13 +46,13 @@ DECLARE
r INTEGER[];
BEGIN
IF array_upper(a, 1) IS NULL THEN
RETURN b;
RETURN COALESCE(b, '{}'::INTEGER[]);
END IF;
IF array_upper(b, 1) IS NULL THEN
RETURN a;
RETURN COALESCE(a, '{}'::INTEGER[]);
END IF;
r := a;
FOR i IN 1..array_upper(b, 1) LOOP
FOR i IN 1..array_upper(b, 1) LOOP
IF NOT (ARRAY[b[i]] <@ r) THEN
r := r || b[i];
END IF;
@@ -139,37 +139,46 @@ $$
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE;
-- Find the nearest artificial postcode for the given geometry.
-- TODO For areas there should not be more than two inside the geometry.
-- Find the best-matching postcode for the given geometry
CREATE OR REPLACE FUNCTION get_nearest_postcode(country VARCHAR(2), geom GEOMETRY)
RETURNS TEXT
AS $$
DECLARE
outcode TEXT;
cnt INTEGER;
location RECORD;
BEGIN
-- If the geometry is an area then only one postcode must be within
-- that area, otherwise consider the area as not having a postcode.
IF ST_GeometryType(geom) in ('ST_Polygon','ST_MultiPolygon') THEN
SELECT min(postcode), count(*) FROM
(SELECT postcode FROM location_postcode
WHERE ST_Contains(geom, location_postcode.geometry) LIMIT 2) sub
INTO outcode, cnt;
SELECT min(postcode), count(*) FROM
(SELECT postcode FROM location_postcodes
WHERE geom && location_postcodes.geometry -- want to use the index
AND ST_Contains(geom, location_postcodes.centroid)
AND country_code = country
LIMIT 2) sub
INTO outcode, cnt;
IF cnt = 1 THEN
RETURN outcode;
ELSE
RETURN null;
END IF;
RETURN null;
END IF;
SELECT postcode FROM location_postcode
WHERE ST_DWithin(geom, location_postcode.geometry, 0.05)
AND location_postcode.country_code = country
ORDER BY ST_Distance(geom, location_postcode.geometry) LIMIT 1
INTO outcode;
-- Otherwise: be fully within the coverage area of a postcode
FOR location IN
SELECT postcode
FROM location_postcodes p
WHERE ST_Covers(p.geometry, geom)
AND p.country_code = country
ORDER BY osm_id is null, ST_Distance(p.centroid, geom)
LIMIT 1
LOOP
RETURN location.postcode;
END LOOP;
RETURN outcode;
RETURN NULL;
END;
$$
LANGUAGE plpgsql STABLE PARALLEL SAFE;
@@ -314,6 +323,17 @@ END;
$$
LANGUAGE plpgsql;
-- Return the bounding box of the geometry buffered by the given number
-- of meters.
CREATE OR REPLACE FUNCTION expand_by_meters(geom GEOMETRY, meters FLOAT)
RETURNS GEOMETRY
AS $$
SELECT ST_Envelope(ST_Buffer(geom::geography, meters, 1)::geometry)
$$
LANGUAGE sql IMMUTABLE PARALLEL SAFE;
-- Create a bounding box with an extent computed from the radius (in meters)
-- which in turn is derived from the given search rank.
CREATE OR REPLACE FUNCTION place_node_fuzzy_area(geom GEOMETRY, rank_search INTEGER)
@@ -332,9 +352,7 @@ BEGIN
radius := 1000;
END IF;
RETURN ST_Envelope(ST_Collect(
ST_Project(geom::geography, radius, 0.785398)::geometry,
ST_Project(geom::geography, radius, 3.9269908)::geometry));
RETURN expand_by_meters(geom, radius);
END;
$$
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE;
@@ -350,8 +368,6 @@ CREATE OR REPLACE FUNCTION add_location(place_id BIGINT, country_code varchar(2)
DECLARE
postcode TEXT;
BEGIN
PERFORM deleteLocationArea(partition, place_id, rank_search);
-- add postcode only if it contains a single entry, i.e. ignore postcode lists
postcode := NULL;
IF in_postcode is not null AND in_postcode not similar to '%(,|;)%' THEN

47
lib-sql/grants.sql Normal file
View File

@@ -0,0 +1,47 @@
-- SPDX-License-Identifier: GPL-2.0-only
--
-- This file is part of Nominatim. (https://nominatim.org)
--
-- Copyright (C) 2026 by the Nominatim developer community.
-- For a full list of authors see the git log.
--
-- Grant read-only access to the web user for all Nominatim tables.
-- Core tables
GRANT SELECT ON import_status TO "{{config.DATABASE_WEBUSER}}";
GRANT SELECT ON country_name TO "{{config.DATABASE_WEBUSER}}";
GRANT SELECT ON nominatim_properties TO "{{config.DATABASE_WEBUSER}}";
-- Location tables
GRANT SELECT ON location_property_tiger TO "{{config.DATABASE_WEBUSER}}";
GRANT SELECT ON location_property_osmline TO "{{config.DATABASE_WEBUSER}}";
GRANT SELECT ON location_postcodes TO "{{config.DATABASE_WEBUSER}}";
-- Search tables
{% if not db.reverse_only %}
GRANT SELECT ON search_name TO "{{config.DATABASE_WEBUSER}}";
{% endif %}
-- Main place tables
GRANT SELECT ON placex TO "{{config.DATABASE_WEBUSER}}";
GRANT SELECT ON place_addressline TO "{{config.DATABASE_WEBUSER}}";
GRANT SELECT ON placex_entrance TO "{{config.DATABASE_WEBUSER}}";
-- Error/delete tracking tables
GRANT SELECT ON import_polygon_error TO "{{config.DATABASE_WEBUSER}}";
GRANT SELECT ON import_polygon_delete TO "{{config.DATABASE_WEBUSER}}";
-- Country grid
GRANT SELECT ON country_osm_grid TO "{{config.DATABASE_WEBUSER}}";
-- Tokenizer tables (word table)
{% if 'word' in db.tables %}
GRANT SELECT ON word TO "{{config.DATABASE_WEBUSER}}";
{% endif %}
-- Special phrase tables
{% for table in db.tables %}
{% if table.startswith('place_classtype_') %}
GRANT SELECT ON {{ table }} TO "{{config.DATABASE_WEBUSER}}";
{% endif %}
{% endfor %}

View File

@@ -2,7 +2,7 @@
--
-- This file is part of Nominatim. (https://nominatim.org)
--
-- Copyright (C) 2022 by the Nominatim developer community.
-- Copyright (C) 2025 by the Nominatim developer community.
-- For a full list of authors see the git log.
-- Indices used only during search and update.
@@ -21,30 +21,25 @@ CREATE INDEX IF NOT EXISTS idx_placex_parent_place_id
ON placex USING BTREE (parent_place_id) {{db.tablespace.search_index}}
WHERE parent_place_id IS NOT NULL;
---
-- Used to find postcode areas after a search in location_postcode.
CREATE INDEX IF NOT EXISTS idx_placex_postcode_areas
ON placex USING BTREE (country_code, postcode) {{db.tablespace.search_index}}
WHERE osm_type = 'R' AND class = 'boundary' AND type = 'postal_code';
---
CREATE INDEX IF NOT EXISTS idx_placex_geometry ON placex
USING GIST (geometry) {{db.tablespace.search_index}};
---
-- Index is needed during import but can be dropped as soon as a full
-- geometry index is in place. The partial index is almost as big as the full
-- index.
---
DROP INDEX IF EXISTS idx_placex_geometry_lower_rank_ways;
---
CREATE INDEX IF NOT EXISTS idx_placex_geometry_reverse_lookupPolygon
ON placex USING gist (geometry) {{db.tablespace.search_index}}
WHERE St_GeometryType(geometry) in ('ST_Polygon', 'ST_MultiPolygon')
AND rank_address between 4 and 25 AND type != 'postcode'
AND rank_address between 4 and 25
AND name is not null AND indexed_status = 0 AND linked_place_id is null;
---
-- used in reverse large area lookup
CREATE INDEX IF NOT EXISTS idx_placex_geometry_reverse_lookupPlaceNode
ON placex USING gist (ST_Buffer(geometry, reverse_place_diameter(rank_search)))
{{db.tablespace.search_index}}
WHERE rank_address between 4 and 25 AND type != 'postcode'
WHERE rank_address between 4 and 25
AND name is not null AND linked_place_id is null AND osm_type = 'N';
---
CREATE INDEX IF NOT EXISTS idx_osmline_parent_place_id
@@ -53,9 +48,6 @@ CREATE INDEX IF NOT EXISTS idx_osmline_parent_place_id
---
CREATE INDEX IF NOT EXISTS idx_osmline_parent_osm_id
ON location_property_osmline USING BTREE (osm_id) {{db.tablespace.search_index}};
---
CREATE INDEX IF NOT EXISTS idx_postcode_postcode
ON location_postcode USING BTREE (postcode) {{db.tablespace.search_index}};
{% if drop %}
---
@@ -82,8 +74,8 @@ CREATE INDEX IF NOT EXISTS idx_postcode_postcode
deferred BOOLEAN
);
---
CREATE INDEX IF NOT EXISTS idx_location_postcode_parent_place_id
ON location_postcode USING BTREE (parent_place_id) {{db.tablespace.address_index}};
CREATE INDEX IF NOT EXISTS idx_location_postcodes_parent_place_id
ON location_postcodes USING BTREE (parent_place_id) {{db.tablespace.address_index}};
{% endif %}
-- Indices only needed for search.

View File

@@ -2,36 +2,48 @@
--
-- This file is part of Nominatim. (https://nominatim.org)
--
-- Copyright (C) 2022 by the Nominatim developer community.
-- Copyright (C) 2026 by the Nominatim developer community.
-- For a full list of authors see the git log.
drop table IF EXISTS search_name_blank CASCADE;
CREATE TABLE search_name_blank (
place_id BIGINT,
address_rank smallint,
name_vector integer[],
centroid GEOMETRY(Geometry, 4326)
place_id BIGINT NOT NULL,
address_rank smallint NOT NULL,
name_vector integer[] NOT NULL,
centroid GEOMETRY(Geometry, 4326) NOT NULL
);
{% for partition in db.partitions %}
CREATE TABLE location_area_large_{{ partition }} () INHERITS (location_area_large) {{db.tablespace.address_data}};
CREATE INDEX idx_location_area_large_{{ partition }}_place_id ON location_area_large_{{ partition }} USING BTREE (place_id) {{db.tablespace.address_index}};
CREATE INDEX idx_location_area_large_{{ partition }}_geometry ON location_area_large_{{ partition }} USING GIST (geometry) {{db.tablespace.address_index}};
CREATE INDEX idx_location_area_large_{{ partition }}_place_id
ON location_area_large_{{ partition }}
USING BTREE (place_id) {{db.tablespace.address_index}};
CREATE INDEX idx_location_area_large_{{ partition }}_geometry
ON location_area_large_{{ partition }}
USING GIST (geometry) {{db.tablespace.address_index}};
CREATE TABLE search_name_{{ partition }} () INHERITS (search_name_blank) {{db.tablespace.address_data}};
CREATE INDEX idx_search_name_{{ partition }}_place_id ON search_name_{{ partition }} USING BTREE (place_id) {{db.tablespace.address_index}};
CREATE INDEX idx_search_name_{{ partition }}_centroid_street ON search_name_{{ partition }} USING GIST (centroid) {{db.tablespace.address_index}} where address_rank between 26 and 27;
CREATE INDEX idx_search_name_{{ partition }}_centroid_place ON search_name_{{ partition }} USING GIST (centroid) {{db.tablespace.address_index}} where address_rank between 2 and 25;
CREATE UNIQUE INDEX idx_search_name_{{ partition }}_place_id
ON search_name_{{ partition }}
USING BTREE (place_id) {{db.tablespace.address_index}};
CREATE INDEX idx_search_name_{{ partition }}_centroid_street
ON search_name_{{ partition }} USING GIST (centroid) {{db.tablespace.address_index}}
WHERE address_rank between 26 and 27;
CREATE INDEX idx_search_name_{{ partition }}_centroid_place
ON search_name_{{ partition }} USING GIST (centroid) {{db.tablespace.address_index}}
WHERE address_rank between 2 and 25;
DROP TABLE IF EXISTS location_road_{{ partition }};
CREATE TABLE location_road_{{ partition }} (
place_id BIGINT,
partition SMALLINT,
place_id BIGINT NOT NULL,
partition SMALLINT NOT NULL,
country_code VARCHAR(2),
geometry GEOMETRY(Geometry, 4326)
geometry GEOMETRY(Geometry, 4326) NOT NULL
) {{db.tablespace.address_data}};
CREATE INDEX idx_location_road_{{ partition }}_geometry ON location_road_{{ partition }} USING GIST (geometry) {{db.tablespace.address_index}};
CREATE INDEX idx_location_road_{{ partition }}_place_id ON location_road_{{ partition }} USING BTREE (place_id) {{db.tablespace.address_index}};
CREATE INDEX idx_location_road_{{ partition }}_geometry
ON location_road_{{ partition }}
USING GIST (geometry) {{db.tablespace.address_index}};
CREATE UNIQUE INDEX idx_location_road_{{ partition }}_place_id
ON location_road_{{ partition }}
USING BTREE (place_id) {{db.tablespace.address_index}};
{% endfor %}

View File

@@ -2,7 +2,7 @@
--
-- This file is part of Nominatim. (https://nominatim.org)
--
-- Copyright (C) 2022 by the Nominatim developer community.
-- Copyright (C) 2025 by the Nominatim developer community.
-- For a full list of authors see the git log.
-- insert creates the location tables, creates location indexes if indexed == true
@@ -25,5 +25,9 @@ CREATE TRIGGER place_before_delete BEFORE DELETE ON place
CREATE TRIGGER place_before_insert BEFORE INSERT ON place
FOR EACH ROW EXECUTE PROCEDURE place_insert();
CREATE TRIGGER location_postcode_before_update BEFORE UPDATE ON location_postcode
FOR EACH ROW EXECUTE PROCEDURE postcode_update();
CREATE TRIGGER location_postcode_before_update BEFORE UPDATE ON location_postcodes
FOR EACH ROW EXECUTE PROCEDURE postcodes_update();
CREATE TRIGGER location_postcodes_before_delete BEFORE DELETE ON location_postcodes
FOR EACH ROW EXECUTE PROCEDURE postcodes_delete();
CREATE TRIGGER location_postcodes_before_insert BEFORE INSERT ON location_postcodes
FOR EACH ROW EXECUTE PROCEDURE postcodes_insert();

View File

@@ -2,7 +2,7 @@
--
-- This file is part of Nominatim. (https://nominatim.org)
--
-- Copyright (C) 2022 by the Nominatim developer community.
-- Copyright (C) 2026 by the Nominatim developer community.
-- For a full list of authors see the git log.
drop table if exists import_status;
@@ -11,7 +11,6 @@ CREATE TABLE import_status (
sequence_id integer,
indexed boolean
);
GRANT SELECT ON import_status TO "{{config.DATABASE_WEBUSER}}" ;
drop table if exists import_osmosis_log;
CREATE TABLE import_osmosis_log (
@@ -23,82 +22,60 @@ CREATE TABLE import_osmosis_log (
event text
);
CREATE TABLE new_query_log (
type text,
starttime timestamp,
ipaddress text,
useragent text,
language text,
query text,
searchterm text,
endtime timestamp,
results integer,
format text,
secret text
);
CREATE INDEX idx_new_query_log_starttime ON new_query_log USING BTREE (starttime);
GRANT INSERT ON new_query_log TO "{{config.DATABASE_WEBUSER}}" ;
GRANT UPDATE ON new_query_log TO "{{config.DATABASE_WEBUSER}}" ;
GRANT SELECT ON new_query_log TO "{{config.DATABASE_WEBUSER}}" ;
GRANT SELECT ON TABLE country_name TO "{{config.DATABASE_WEBUSER}}";
DROP TABLE IF EXISTS nominatim_properties;
CREATE TABLE nominatim_properties (
property TEXT NOT NULL,
value TEXT
);
GRANT SELECT ON TABLE nominatim_properties TO "{{config.DATABASE_WEBUSER}}";
drop table IF EXISTS location_area CASCADE;
CREATE TABLE location_area (
place_id BIGINT,
keywords INTEGER[],
partition SMALLINT,
place_id BIGINT NOT NULL,
keywords INTEGER[] NOT NULL,
partition SMALLINT NOT NULL,
rank_search SMALLINT NOT NULL,
rank_address SMALLINT NOT NULL,
country_code VARCHAR(2),
isguess BOOL,
isguess BOOL NOT NULL,
postcode TEXT,
centroid GEOMETRY(Point, 4326),
geometry GEOMETRY(Geometry, 4326)
centroid GEOMETRY(Point, 4326) NOT NULL,
geometry GEOMETRY(Geometry, 4326) NOT NULL
);
CREATE TABLE location_area_large () INHERITS (location_area);
DROP TABLE IF EXISTS location_area_country;
CREATE TABLE location_area_country (
place_id BIGINT,
country_code varchar(2),
geometry GEOMETRY(Geometry, 4326)
place_id BIGINT NOT NULL,
country_code varchar(2) NOT NULL,
geometry GEOMETRY(Geometry, 4326) NOT NULL
) {{db.tablespace.address_data}};
CREATE INDEX idx_location_area_country_geometry ON location_area_country USING GIST (geometry) {{db.tablespace.address_index}};
CREATE TABLE location_property_tiger (
place_id BIGINT,
place_id BIGINT NOT NULL,
parent_place_id BIGINT,
startnumber INTEGER,
endnumber INTEGER,
step SMALLINT,
partition SMALLINT,
linegeo GEOMETRY,
startnumber INTEGER NOT NULL,
endnumber INTEGER NOT NULL,
step SMALLINT NOT NULL,
partition SMALLINT NOT NULL,
linegeo GEOMETRY NOT NULL,
postcode TEXT);
GRANT SELECT ON location_property_tiger TO "{{config.DATABASE_WEBUSER}}";
drop table if exists location_property_osmline;
CREATE TABLE location_property_osmline (
place_id BIGINT NOT NULL,
osm_id BIGINT,
osm_id BIGINT NOT NULL,
parent_place_id BIGINT,
geometry_sector INTEGER,
geometry_sector INTEGER NOT NULL,
indexed_date TIMESTAMP,
startnumber INTEGER,
endnumber INTEGER,
step SMALLINT,
partition SMALLINT,
indexed_status SMALLINT,
linegeo GEOMETRY,
partition SMALLINT NOT NULL,
indexed_status SMALLINT NOT NULL,
linegeo GEOMETRY NOT NULL,
address HSTORE,
token_info JSONB, -- custom column for tokenizer use only
postcode TEXT,
@@ -108,32 +85,31 @@ CREATE UNIQUE INDEX idx_osmline_place_id ON location_property_osmline USING BTRE
CREATE INDEX idx_osmline_geometry_sector ON location_property_osmline USING BTREE (geometry_sector) {{db.tablespace.address_index}};
CREATE INDEX idx_osmline_linegeo ON location_property_osmline USING GIST (linegeo) {{db.tablespace.search_index}}
WHERE startnumber is not null;
GRANT SELECT ON location_property_osmline TO "{{config.DATABASE_WEBUSER}}";
drop table IF EXISTS search_name;
{% if not db.reverse_only %}
CREATE TABLE search_name (
place_id BIGINT,
importance FLOAT,
search_rank SMALLINT,
address_rank SMALLINT,
name_vector integer[],
nameaddress_vector integer[],
place_id BIGINT NOT NULL,
importance FLOAT NOT NULL,
search_rank SMALLINT NOT NULL,
address_rank SMALLINT NOT NULL,
name_vector integer[] NOT NULL,
nameaddress_vector integer[] NOT NULL,
country_code varchar(2),
centroid GEOMETRY(Geometry, 4326)
centroid GEOMETRY(Geometry, 4326) NOT NULL
) {{db.tablespace.search_data}};
CREATE INDEX idx_search_name_place_id ON search_name USING BTREE (place_id) {{db.tablespace.search_index}};
GRANT SELECT ON search_name to "{{config.DATABASE_WEBUSER}}" ;
CREATE UNIQUE INDEX idx_search_name_place_id
ON search_name USING BTREE (place_id) {{db.tablespace.search_index}};
{% endif %}
drop table IF EXISTS place_addressline;
CREATE TABLE place_addressline (
place_id BIGINT,
address_place_id BIGINT,
distance FLOAT,
cached_rank_address SMALLINT,
fromarea boolean,
isaddress boolean
place_id BIGINT NOT NULL,
address_place_id BIGINT NOT NULL,
distance FLOAT NOT NULL,
cached_rank_address SMALLINT NOT NULL,
fromarea boolean NOT NULL,
isaddress boolean NOT NULL
) {{db.tablespace.search_data}};
CREATE INDEX idx_place_addressline_place_id on place_addressline USING BTREE (place_id) {{db.tablespace.search_index}};
@@ -146,18 +122,18 @@ CREATE TABLE placex (
linked_place_id BIGINT,
importance FLOAT,
indexed_date TIMESTAMP,
geometry_sector INTEGER,
rank_address SMALLINT,
rank_search SMALLINT,
partition SMALLINT,
indexed_status SMALLINT,
geometry_sector INTEGER NOT NULL,
rank_address SMALLINT NOT NULL,
rank_search SMALLINT NOT NULL,
partition SMALLINT NOT NULL,
indexed_status SMALLINT NOT NULL,
LIKE place INCLUDING CONSTRAINTS,
wikipedia TEXT, -- calculated wikipedia article name (language:title)
token_info JSONB, -- custom column for tokenizer use only
country_code varchar(2),
housenumber TEXT,
postcode TEXT,
centroid GEOMETRY(Geometry, 4326)
centroid GEOMETRY(Geometry, 4326) NOT NULL
) {{db.tablespace.search_data}};
CREATE UNIQUE INDEX idx_place_id ON placex USING BTREE (place_id) {{db.tablespace.search_index}};
@@ -192,8 +168,7 @@ CREATE INDEX idx_placex_geometry_buildings ON placex
-- - linking of place nodes with same type to boundaries
CREATE INDEX idx_placex_geometry_placenode ON placex
USING SPGIST (geometry) {{db.tablespace.address_index}}
WHERE osm_type = 'N' and rank_search < 26
and class = 'place' and type != 'postcode';
WHERE osm_type = 'N' and rank_search < 26 and class = 'place';
-- Usage: - is node part of a way?
-- - find parent of interpolation spatially
@@ -221,28 +196,30 @@ CREATE INDEX idx_placex_rank_boundaries_sector ON placex
DROP SEQUENCE IF EXISTS seq_place;
CREATE SEQUENCE seq_place start 1;
GRANT SELECT on placex to "{{config.DATABASE_WEBUSER}}" ;
GRANT SELECT on place_addressline to "{{config.DATABASE_WEBUSER}}" ;
GRANT SELECT ON planet_osm_ways to "{{config.DATABASE_WEBUSER}}" ;
GRANT SELECT ON planet_osm_rels to "{{config.DATABASE_WEBUSER}}" ;
GRANT SELECT on location_area to "{{config.DATABASE_WEBUSER}}" ;
-- Table for synthetic postcodes.
DROP TABLE IF EXISTS location_postcode;
CREATE TABLE location_postcode (
place_id BIGINT,
DROP TABLE IF EXISTS location_postcodes;
CREATE TABLE location_postcodes (
place_id BIGINT NOT NULL,
parent_place_id BIGINT,
rank_search SMALLINT,
rank_address SMALLINT,
indexed_status SMALLINT,
osm_id BIGINT,
rank_search SMALLINT NOT NULL,
indexed_status SMALLINT NOT NULL,
indexed_date TIMESTAMP,
country_code varchar(2),
postcode TEXT,
geometry GEOMETRY(Geometry, 4326)
country_code varchar(2) NOT NULL,
postcode TEXT NOT NULL,
centroid GEOMETRY(Geometry, 4326) NOT NULL,
geometry GEOMETRY(Geometry, 4326) NOT NULL
);
CREATE UNIQUE INDEX idx_postcode_id ON location_postcode USING BTREE (place_id) {{db.tablespace.search_index}};
CREATE INDEX idx_postcode_geometry ON location_postcode USING GIST (geometry) {{db.tablespace.address_index}};
GRANT SELECT ON location_postcode TO "{{config.DATABASE_WEBUSER}}" ;
CREATE UNIQUE INDEX idx_location_postcodes_id ON location_postcodes
USING BTREE (place_id) {{db.tablespace.search_index}};
CREATE INDEX idx_location_postcodes_geometry ON location_postcodes
USING GIST (geometry) {{db.tablespace.search_index}};
CREATE INDEX IF NOT EXISTS idx_location_postcodes_postcode
ON location_postcodes USING BTREE (postcode, country_code)
{{db.tablespace.search_index}};
CREATE INDEX IF NOT EXISTS idx_location_postcodes_osmid
ON location_postcodes USING BTREE (osm_id) {{db.tablespace.search_index}};
-- Table to store location of entrance nodes
DROP TABLE IF EXISTS placex_entrance;
@@ -255,7 +232,6 @@ CREATE TABLE placex_entrance (
);
CREATE UNIQUE INDEX idx_placex_entrance_place_id_osm_id ON placex_entrance
USING BTREE (place_id, osm_id) {{db.tablespace.search_index}};
GRANT SELECT ON placex_entrance TO "{{config.DATABASE_WEBUSER}}" ;
-- Create an index on the place table for lookups to populate the entrance
-- table
@@ -277,7 +253,6 @@ CREATE TABLE import_polygon_error (
newgeometry GEOMETRY(Geometry, 4326)
);
CREATE INDEX idx_import_polygon_error_osmid ON import_polygon_error USING BTREE (osm_type, osm_id);
GRANT SELECT ON import_polygon_error TO "{{config.DATABASE_WEBUSER}}";
DROP TABLE IF EXISTS import_polygon_delete;
CREATE TABLE import_polygon_delete (
@@ -287,7 +262,6 @@ CREATE TABLE import_polygon_delete (
type TEXT NOT NULL
);
CREATE INDEX idx_import_polygon_delete_osmid ON import_polygon_delete USING BTREE (osm_type, osm_id);
GRANT SELECT ON import_polygon_delete TO "{{config.DATABASE_WEBUSER}}";
DROP SEQUENCE IF EXISTS file;
CREATE SEQUENCE file start 1;
@@ -318,5 +292,3 @@ CREATE INDEX planet_osm_rels_relation_members_idx ON planet_osm_rels USING gin(p
CREATE INDEX IF NOT EXISTS idx_place_interpolations
ON place USING gist(geometry) {{db.tablespace.address_index}}
WHERE osm_type = 'W' and address ? 'interpolation';
GRANT SELECT ON table country_osm_grid to "{{config.DATABASE_WEBUSER}}";

View File

@@ -15,6 +15,99 @@ CREATE TABLE location_property_tiger_import (
step SMALLINT,
postcode TEXT);
-- Lookup functions for tiger import when update
-- tables are dropped (see gh-issue #2463)
CREATE OR REPLACE FUNCTION getNearestNamedRoadPlaceIdSlow(in_centroid GEOMETRY,
in_token_info JSONB)
RETURNS BIGINT
AS $$
DECLARE
out_place_id BIGINT;
BEGIN
SELECT place_id INTO out_place_id
FROM search_name
WHERE
-- finds rows where name_vector shares elements with search tokens.
token_matches_street(in_token_info, name_vector)
-- limits search area
AND centroid && ST_Expand(in_centroid, 0.015)
AND address_rank BETWEEN 26 AND 27
ORDER BY ST_Distance(centroid, in_centroid) ASC
LIMIT 1;
RETURN out_place_id;
END
$$
LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION getNearestParallelRoadFeatureSlow(line GEOMETRY)
RETURNS BIGINT
AS $$
DECLARE
r RECORD;
search_diameter FLOAT;
p1 GEOMETRY;
p2 GEOMETRY;
p3 GEOMETRY;
BEGIN
IF ST_GeometryType(line) not in ('ST_LineString') THEN
RETURN NULL;
END IF;
p1 := ST_LineInterpolatePoint(line,0);
p2 := ST_LineInterpolatePoint(line,0.5);
p3 := ST_LineInterpolatePoint(line,1);
search_diameter := 0.0005;
WHILE search_diameter < 0.01 LOOP
FOR r IN
SELECT place_id FROM placex
WHERE ST_DWithin(line, geometry, search_diameter)
AND rank_address BETWEEN 26 AND 27
ORDER BY (ST_distance(geometry, p1)+
ST_distance(geometry, p2)+
ST_distance(geometry, p3)) ASC limit 1
LOOP
RETURN r.place_id;
END LOOP;
search_diameter := search_diameter * 2;
END LOOP;
RETURN NULL;
END
$$
LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION getNearestRoadPlaceIdSlow(point GEOMETRY)
RETURNS BIGINT
AS $$
DECLARE
r RECORD;
search_diameter FLOAT;
BEGIN
search_diameter := 0.00005;
WHILE search_diameter < 0.1 LOOP
FOR r IN
SELECT place_id FROM placex
WHERE ST_DWithin(geometry, point, search_diameter)
AND rank_address BETWEEN 26 AND 27
ORDER BY ST_Distance(geometry, point) ASC limit 1
LOOP
RETURN r.place_id;
END LOOP;
search_diameter := search_diameter * 2;
END LOOP;
RETURN NULL;
END
$$
LANGUAGE plpgsql;
-- Tiger import function
CREATE OR REPLACE FUNCTION tiger_line_import(linegeo GEOMETRY, in_startnumber INTEGER,
in_endnumber INTEGER, interpolationtype TEXT,
token_info JSONB, in_postcode TEXT) RETURNS INTEGER
@@ -71,28 +164,51 @@ BEGIN
place_centroid := ST_Centroid(linegeo);
out_partition := get_partition('us');
out_parent_place_id := getNearestNamedRoadPlaceId(out_partition, place_centroid,
-- HYBRID LOOKUP LOGIC (see gh-issue #2463)
-- if partition tables exist, use them for fast spatial lookups
{% if 'location_road_0' in db.tables %}
out_parent_place_id := getNearestNamedRoadPlaceId(out_partition, place_centroid,
token_info);
IF out_parent_place_id IS NULL THEN
SELECT getNearestParallelRoadFeature(out_partition, linegeo)
INTO out_parent_place_id;
IF out_parent_place_id IS NULL THEN
SELECT getNearestParallelRoadFeature(out_partition, linegeo)
INTO out_parent_place_id;
END IF;
IF out_parent_place_id IS NULL THEN
SELECT getNearestRoadPlaceId(out_partition, place_centroid)
INTO out_parent_place_id;
END IF;
-- When updatable information has been dropped:
-- Partition tables no longer exist, but search_name still persists.
{% elif 'search_name' in db.tables %}
-- Fallback: Look up in 'search_name' table
-- though spatial lookups here can be slower.
out_parent_place_id := getNearestNamedRoadPlaceIdSlow(place_centroid, token_info);
IF out_parent_place_id IS NULL THEN
out_parent_place_id := getNearestParallelRoadFeatureSlow(linegeo);
END IF;
IF out_parent_place_id IS NULL THEN
out_parent_place_id := getNearestRoadPlaceIdSlow(place_centroid);
END IF;
{% endif %}
-- If parent was found, insert street(line) into import table
IF out_parent_place_id IS NOT NULL THEN
INSERT INTO location_property_tiger_import (linegeo, place_id, partition,
parent_place_id, startnumber, endnumber,
step, postcode)
VALUES (linegeo, nextval('seq_place'), out_partition,
out_parent_place_id, startnumber, endnumber,
stepsize, in_postcode);
RETURN 1;
END IF;
RETURN 0;
IF out_parent_place_id IS NULL THEN
SELECT getNearestRoadPlaceId(out_partition, place_centroid)
INTO out_parent_place_id;
END IF;
--insert street(line) into import table
insert into location_property_tiger_import (linegeo, place_id, partition,
parent_place_id, startnumber, endnumber,
step, postcode)
values (linegeo, nextval('seq_place'), out_partition,
out_parent_place_id, startnumber, endnumber,
stepsize, in_postcode);
RETURN 1;
END;
$$
LANGUAGE plpgsql;

View File

@@ -23,7 +23,7 @@ an ASGI-capable server like uvicorn. To install them from pypi run:
You need to have a Nominatim database imported with the 'nominatim-db'
package. Go to the project directory, then run uvicorn as:
uvicorn --factory nominatim.server.falcon.server:run_wsgi
uvicorn --factory nominatim_api.server.falcon.server:run_wsgi
## Documentation

View File

@@ -15,12 +15,13 @@ classifiers = [
"Operating System :: OS Independent",
]
dependencies = [
"psycopg",
"psycopg != 3.3.0",
"python-dotenv",
"jinja2",
"pyYAML>=5.1",
"psutil",
"PyICU"
"PyICU",
"mwparserfromhell"
]
dynamic = ["version"]

View File

@@ -1,3 +1,4 @@
name:
default: Palestinian Territory
name:
default: الأراضي الفلسطينية
en: Palestinian Territories
"no": Det palestinske området

View File

@@ -305,6 +305,7 @@ ch:
names: !include country-names/ch.yaml
postcode:
pattern: "dddd"
extent: 3000
# Côte d'Ivoire (Côte dIvoire)
@@ -472,6 +473,7 @@ ee:
names: !include country-names/ee.yaml
postcode:
pattern: "ddddd"
extent: 3000
# Egypt (مصر)
@@ -585,6 +587,7 @@ gb:
postcode:
pattern: "(l?ld[A-Z0-9]?) ?(dll)"
output: \1 \2
extent: 700
# Grenada (Grenada)
@@ -612,6 +615,7 @@ gg:
postcode:
pattern: "(GYdd?) ?(dll)"
output: \1 \2
extent: 1000
# Ghana (Ghana)
@@ -766,6 +770,7 @@ ie:
postcode:
pattern: "(ldd) ?([0123456789ACDEFHKNPRTVWXY]{4})"
output: \1 \2
extent: 50
# Israel (ישראל)
@@ -785,6 +790,7 @@ im:
postcode:
pattern: "(IMdd?) ?(dll)"
output: \1 \2
extent: 700
# India (India)
@@ -879,6 +885,7 @@ jp:
postcode:
pattern: "(ddd)-?(dddd)"
output: \1-\2
extent: 3000
# Kenya (Kenya)
@@ -1013,6 +1020,7 @@ li:
names: !include country-names/li.yaml
postcode:
pattern: "dddd"
extent: 4000
# Sri Lanka (ශ්‍රී ලංකාව இலங்கை)
@@ -1058,6 +1066,7 @@ lu:
names: !include country-names/lu.yaml
postcode:
pattern: "dddd"
extent: 1000
# Latvia (Latvija)
@@ -1290,6 +1299,7 @@ nl:
postcode:
pattern: "(dddd) ?(ll)"
output: \1 \2
extent: 800
# Norway (Norge)
@@ -1425,6 +1435,7 @@ pt:
names: !include country-names/pt.yaml
postcode:
pattern: "dddd(?:-ddd)?"
extent: 1000
# Palau (Belau)
@@ -1460,6 +1471,7 @@ ro:
names: !include country-names/ro.yaml
postcode:
pattern: "dddddd"
extent: 2500
# Serbia (Србија)

View File

@@ -8,7 +8,6 @@
Helper functions for localizing names of results.
"""
from typing import Mapping, List, Optional
from .config import Configuration
from .results import AddressLines, BaseResultT
import re
@@ -18,15 +17,15 @@ class Locales:
""" Helper class for localization of names.
It takes a list of language prefixes in their order of preferred
usage.
usage and comma separated name keys (Configuration.OUTPUT_NAMES).
"""
def __init__(self, langs: Optional[List[str]] = None):
self.config = Configuration(None)
def __init__(self, langs: Optional[List[str]] = None,
names: str = 'name:XX,name') -> None:
self.languages = langs or []
self.name_tags: List[str] = []
parts = self.config.OUTPUT_NAMES.split(',')
parts = names.split(',') if names else []
for part in parts:
part = part.strip()
@@ -68,7 +67,7 @@ class Locales:
return next(iter(names.values()))
@staticmethod
def from_accept_languages(langstr: str) -> 'Locales':
def from_accept_languages(langstr: str, names: str = 'name:XX,name') -> 'Locales':
""" Create a localization object from a language list in the
format of HTTP accept-languages header.
@@ -96,7 +95,7 @@ class Locales:
if len(parts) > 1 and all(c[0] != parts[0] for c in candidates):
languages.append(parts[0])
return Locales(languages)
return Locales(languages, names)
def localize(self, lines: AddressLines) -> None:
""" Sets the local name of address parts according to the chosen

View File

@@ -288,7 +288,7 @@ class TextLogger(BaseLogger):
self._write(f"rank={res.rank_address}, ")
self._write(f"osm={''.join(map(str, res.osm_object or []))}, ")
self._write(f'cc={res.country_code}, ')
self._write(f'importance={res.importance or -1:.5f})\n')
self._write(f'importance={res.importance or float("NaN"):.5f})\n')
total += 1
self._write(f'TOTAL: {total}\n\n')

View File

@@ -2,7 +2,7 @@
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2024 by the Nominatim developer community.
# Copyright (C) 2025 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Implementation of place lookup by ID (doing many places at once).
@@ -291,12 +291,30 @@ async def find_in_postcode(conn: SearchConnection, collector: Collector) -> bool
.table_valued(sa.column('value', type_=sa.JSON))
t = conn.t.postcode
sql = sa.select(pid_tab.c.value['i'].as_integer().label('_idx'),
t.c.place_id, t.c.parent_place_id,
t.c.rank_search, t.c.rank_address,
t.c.osm_id, t.c.place_id, t.c.parent_place_id,
t.c.rank_search,
t.c.indexed_date, t.c.postcode, t.c.country_code,
t.c.geometry.label('centroid'))\
t.c.centroid)\
.where(t.c.place_id == pid_tab.c.value['id'].as_string().cast(sa.BigInteger))
if await collector.add_rows_from_sql(conn, sql, t.c.geometry,
nres.create_from_postcode_row):
return True
osm_ids = [{'i': i, 'oi': p.osm_id}
for i, p in collector.enumerate_free_osm_ids() if p.osm_type == 'R']
if osm_ids:
pid_tab = sa.func.JsonArrayEach(sa.type_coerce(osm_ids, sa.JSON))\
.table_valued(sa.column('value', type_=sa.JSON))
t = conn.t.postcode
sql = sa.select(pid_tab.c.value['i'].as_integer().label('_idx'),
t.c.osm_id, t.c.place_id, t.c.parent_place_id,
t.c.rank_search,
t.c.indexed_date, t.c.postcode, t.c.country_code,
t.c.centroid)\
.where(t.c.osm_id == pid_tab.c.value['oi'].as_string().cast(sa.BigInteger))
return await collector.add_rows_from_sql(conn, sql, t.c.geometry,
nres.create_from_postcode_row)

View File

@@ -10,7 +10,7 @@ Helper classes and functions for formatting results into API responses.
from typing import Type, TypeVar, Dict, List, Callable, Any, Mapping, Optional, cast
from collections import defaultdict
from pathlib import Path
import importlib
import importlib.util
from .server.content_types import CONTENT_JSON
@@ -43,7 +43,7 @@ class FormatDispatcher:
return decorator
def error_format_func(self, func: ErrorFormatFunc) -> ErrorFormatFunc:
""" Decorator for a function that formats error messges.
""" Decorator for a function that formats error messages.
There is only one error formatter per dispatcher. Using
the decorator repeatedly will overwrite previous functions.
"""
@@ -79,7 +79,7 @@ class FormatDispatcher:
def set_content_type(self, fmt: str, content_type: str) -> None:
""" Set the content type for the given format. This is the string
that will be returned in the Content-Type header of the HTML
response, when the given format is choosen.
response, when the given format is chosen.
"""
self.content_types[fmt] = content_type

View File

@@ -2,7 +2,7 @@
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2024 by the Nominatim developer community.
# Copyright (C) 2025 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Dataclasses for search results and helper functions to fill them.
@@ -407,11 +407,13 @@ def create_from_postcode_row(row: SaRow, class_type: Type[BaseResultT]) -> BaseR
"""
return class_type(source_table=SourceTable.POSTCODE,
place_id=row.place_id,
osm_object=None if row.osm_id is None else ('R', row.osm_id),
parent_place_id=row.parent_place_id,
category=('place', 'postcode'),
category=(('place', 'postcode') if row.osm_id is None
else ('boundary', 'postal_code')),
names={'ref': row.postcode},
rank_search=row.rank_search,
rank_address=row.rank_address,
rank_address=5,
country_code=row.country_code,
centroid=Point.from_wkb(row.centroid),
geometry=_filter_geometries(row))
@@ -494,17 +496,15 @@ def _get_address_lookup_id(result: BaseResultT) -> int:
async def _finalize_entry(conn: SearchConnection, result: BaseResultT) -> None:
assert result.address_rows is not None
if result.category[0] not in ('boundary', 'place')\
or result.category[1] not in ('postal_code', 'postcode'):
postcode = result.postcode
if not postcode and result.address:
postcode = result.address.get('postcode')
if postcode and ',' not in postcode and ';' not in postcode:
result.address_rows.append(AddressLine(
category=('place', 'postcode'),
names={'ref': postcode},
fromarea=False, isaddress=True, rank_address=5,
distance=0.0))
postcode = result.postcode or (result.address and result.address.get('postcode'))
if postcode and ',' not in postcode and ';' not in postcode:
result.address_rows.append(AddressLine(
category=('place', 'postcode'),
names={'ref': postcode},
fromarea=False, isaddress=True, rank_address=5,
distance=0.0))
if result.country_code:
async def _get_country_names() -> Optional[Dict[str, str]]:
t = conn.t.country_name
@@ -627,13 +627,6 @@ async def complete_address_details(conn: SearchConnection, results: List[BaseRes
if current_result.country_code is None and row.country_code:
current_result.country_code = row.country_code
if row.type in ('postcode', 'postal_code') and location_isaddress:
if not row.fromarea or \
(current_result.address and 'postcode' in current_result.address):
location_isaddress = False
else:
current_result.postcode = None
assert current_result.address_rows is not None
current_result.address_rows.append(_result_row_to_address_row(row, location_isaddress))
current_rank_address = row.rank_address

View File

@@ -157,16 +157,19 @@ class ReverseGeocoder:
include.extend(('natural', 'water', 'waterway'))
return table.c.class_.in_(tuple(include))
async def _find_closest_street_or_poi(self, distance: float) -> Optional[SaRow]:
""" Look up the closest rank 26+ place in the database, which
is closer than the given distance.
async def _find_closest_street_or_pois(self, distance: float,
fuzziness: float) -> list[SaRow]:
""" Look up the closest rank 26+ place in the database.
The function finds the object that is closest to the reverse
search point as well as all objects within 'fuzziness' distance
to that best result.
"""
t = self.conn.t.placex
# PostgreSQL must not get the distance as a parameter because
# there is a danger it won't be able to properly estimate index use
# when used with prepared statements
diststr = sa.text(f"{distance}")
diststr = sa.text(f"{distance + fuzziness}")
sql: SaLambdaSelect = sa.lambda_stmt(
lambda: _select_from_placex(t)
@@ -174,9 +177,7 @@ class ReverseGeocoder:
.where(t.c.indexed_status == 0)
.where(t.c.linked_place_id == None)
.where(sa.or_(sa.not_(t.c.geometry.is_area()),
t.c.centroid.ST_Distance(WKT_PARAM) < diststr))
.order_by('distance')
.limit(2))
t.c.centroid.ST_Distance(WKT_PARAM) < diststr)))
if self.has_geometries():
sql = self._add_geometry_columns(sql, t.c.geometry)
@@ -198,24 +199,44 @@ class ReverseGeocoder:
self._filter_by_layer(t)))
if not restrict:
return None
return []
sql = sql.where(sa.or_(*restrict))
inner = sql.where(sa.or_(*restrict)) \
.add_columns(t.c.geometry.label('_geometry')) \
.subquery()
# If the closest object is inside an area, then check if there is a
# POI node nearby and return that.
prev_row = None
for row in await self.conn.execute(sql, self.bind_params):
if prev_row is None:
if row.rank_search <= 27 or row.osm_type == 'N' or row.distance > 0:
return row
prev_row = row
else:
if row.rank_search > 27 and row.osm_type == 'N'\
and row.distance < 0.0001:
return row
# Use a window function to get the closest results to the best result.
windowed = sa.select(inner,
sa.func.first_value(inner.c.distance)
.over(order_by=inner.c.distance)
.label('_min_distance'),
sa.func.first_value(
sa.case((inner.c.rank_search <= 27,
inner.c._geometry.ST_ClosestPoint(WKT_PARAM)),
else_=None))
.over(order_by=inner.c.distance)
.label('_closest_point'),
sa.func.first_value(sa.case((sa.or_(inner.c.rank_search <= 27,
inner.c.osm_type == 'N'), None),
else_=inner.c._geometry))
.over(order_by=inner.c.distance)
.label('_best_geometry')) \
.subquery()
return prev_row
outer = sa.select(*(c for c in windowed.c if not c.key.startswith('_')),
sa.case((sa.or_(windowed.c._closest_point == None,
windowed.c.housenumber == None), None),
else_=windowed.c.centroid.ST_Distance(windowed.c._closest_point))
.label('distance_from_best'),
sa.case((sa.or_(windowed.c._best_geometry == None,
windowed.c.rank_search <= 27,
windowed.c.osm_type != 'N'), False),
else_=windowed.c.centroid.ST_CoveredBy(windowed.c._best_geometry))
.label('best_inside')) \
.where(windowed.c.distance < windowed.c._min_distance + fuzziness) \
.order_by(windowed.c.distance)
return list(await self.conn.execute(outer, self.bind_params))
async def _find_housenumber_for_street(self, parent_place_id: int) -> Optional[SaRow]:
t = self.conn.t.placex
@@ -301,55 +322,69 @@ class ReverseGeocoder:
""" Find a street or POI/address for the given WKT point.
"""
log().section('Reverse lookup on street/address level')
distance = 0.006
parent_place_id = None
row = await self._find_closest_street_or_poi(distance)
row_func: RowFunc = nres.create_from_placex_row
log().var_dump('Result (street/building)', row)
distance = 0.006
# If the closest result was a street, but an address was requested,
# check for a housenumber nearby which is part of the street.
if row is not None:
if self.max_rank > 27 \
and self.layer_enabled(DataLayer.ADDRESS) \
and row.rank_address <= 27:
distance = 0.001
parent_place_id = row.place_id
log().comment('Find housenumber for street')
addr_row = await self._find_housenumber_for_street(parent_place_id)
log().var_dump('Result (street housenumber)', addr_row)
if addr_row is not None:
row = addr_row
row_func = nres.create_from_placex_row
distance = addr_row.distance
elif row.country_code == 'us' and parent_place_id is not None:
log().comment('Find TIGER housenumber for street')
addr_row = await self._find_tiger_number_for_street(parent_place_id)
log().var_dump('Result (street Tiger housenumber)', addr_row)
if addr_row is not None:
row_func = cast(RowFunc,
functools.partial(nres.create_from_tiger_row,
osm_type=row.osm_type,
osm_id=row.osm_id))
row = addr_row
else:
result = None
hnr_distance = None
parent_street = None
for row in await self._find_closest_street_or_pois(distance, 0.001):
if result is None:
log().var_dump('Closest result', row)
result = row
if self.max_rank > 27 \
and self.layer_enabled(DataLayer.ADDRESS) \
and result.rank_address <= 27:
parent_street = result.place_id
distance = 0.001
else:
distance = row.distance
# If the closest result was a street but an address was requested,
# see if we can refine the result with a housenumber closeby.
elif parent_street is not None \
and row.distance_from_best is not None \
and row.distance_from_best < 0.001 \
and (hnr_distance is None or hnr_distance > row.distance_from_best) \
and row.parent_place_id == parent_street:
log().var_dump('Housenumber to closest result', row)
result = row
hnr_distance = row.distance_from_best
distance = row.distance
# If the closest object is inside an area, then check if there is
# a POI nearby and return that with preference.
elif result.osm_type != 'N' and result.rank_search > 27 \
and result.distance == 0 \
and row.best_inside:
log().var_dump('POI near closest result area', row)
result = row
break # it can't get better than that, everything else is farther away
# For the US also check the TIGER data, when no housenumber/POI was found.
if result is not None and parent_street is not None and hnr_distance is None \
and result.country_code == 'us':
log().comment('Find TIGER housenumber for street')
addr_row = await self._find_tiger_number_for_street(parent_street)
log().var_dump('Result (street Tiger housenumber)', addr_row)
if addr_row is not None:
row_func = cast(RowFunc,
functools.partial(nres.create_from_tiger_row,
osm_type=row.osm_type,
osm_id=row.osm_id))
result = addr_row
# Check for an interpolation that is either closer than our result
# or belongs to a close street found.
if self.max_rank > 27 and self.layer_enabled(DataLayer.ADDRESS):
# No point in doing this when the result is already inside a building,
# i.e. when the distance is already 0.
if self.max_rank > 27 and self.layer_enabled(DataLayer.ADDRESS) and distance > 0:
log().comment('Find interpolation for street')
addr_row = await self._find_interpolation_for_street(parent_place_id,
distance)
addr_row = await self._find_interpolation_for_street(parent_street, distance)
log().var_dump('Result (street interpolation)', addr_row)
if addr_row is not None:
row = addr_row
row_func = nres.create_from_osmline_row
return addr_row, nres.create_from_osmline_row
return row, row_func
return result, row_func
async def _lookup_area_address(self) -> Optional[SaRow]:
""" Lookup large addressable areas for the given WKT point.

View File

@@ -374,7 +374,7 @@ class SearchBuilder:
tokens = self.get_country_tokens(assignment.country)
if not tokens:
return None
sdata.set_strings('countries', tokens)
sdata.set_countries(tokens)
sdata.penalty += self.query.get_in_word_penalty(assignment.country)
elif self.details.countries:
sdata.countries = dbf.WeightedStrings(self.details.countries,
@@ -413,7 +413,7 @@ class SearchBuilder:
"""
tokens = self.query.get_tokens(trange, qmod.TOKEN_COUNTRY)
if self.details.countries:
tokens = [t for t in tokens if t.lookup_word in self.details.countries]
tokens = [t for t in tokens if t.get_country() in self.details.countries]
return tokens

View File

@@ -22,7 +22,7 @@ class CountedTokenIDs:
""" A list of token IDs with their respective counts, sorted
from least frequent to most frequent.
If a token count is one, then statistics are likely to be unavaible
If a token count is one, then statistics are likely to be unavailable
and a relatively high count is assumed instead.
"""
@@ -244,6 +244,21 @@ class SearchData:
setattr(self, field, wstrs)
def set_countries(self, tokens: List[Token]) -> None:
""" Set the WeightedStrings properties for countries. Multiple
entries for the same country are deduplicated and the minimum
penalty is used. Adapts the global penalty, so that the
minimum penalty is 0.
"""
if tokens:
min_penalty = min(t.penalty for t in tokens)
self.penalty += min_penalty
countries: dict[str, float] = {}
for t in tokens:
cc = t.get_country()
countries[cc] = min(t.penalty - min_penalty, countries.get(cc, 10000))
self.countries = WeightedStrings(list(countries.keys()), list(countries.values()))
def set_qualifiers(self, tokens: List[Token]) -> None:
""" Set the qulaifier field from the given tokens.
"""

View File

@@ -175,7 +175,8 @@ class AddressSearch(base.AbstractSearch):
sql = sql.where(sa.select(tpc.c.postcode)
.where(tpc.c.postcode.in_(self.postcodes.values))
.where(tpc.c.country_code == t.c.country_code)
.where(t.c.centroid.within_distance(tpc.c.geometry, 0.4))
.where(t.c.centroid.intersects(tpc.c.geometry,
use_index=False))
.exists())
if details.viewbox is not None:
@@ -225,7 +226,7 @@ class AddressSearch(base.AbstractSearch):
tpc = conn.t.postcode
pcs = self.postcodes.values
pc_near = sa.select(sa.func.min(tpc.c.geometry.ST_Distance(t.c.centroid)
pc_near = sa.select(sa.func.min(tpc.c.centroid.ST_Distance(t.c.centroid)
* (tpc.c.rank_search - 19)))\
.where(tpc.c.postcode.in_(pcs))\
.where(tpc.c.country_code == t.c.country_code)\

View File

@@ -79,7 +79,8 @@ class PlaceSearch(base.AbstractSearch):
tpc = conn.t.postcode
sql = sql.where(sa.select(tpc.c.postcode)
.where(tpc.c.postcode.in_(self.postcodes.values))
.where(t.c.centroid.within_distance(tpc.c.geometry, 0.4))
.where(t.c.centroid.intersects(tpc.c.geometry,
use_index=False))
.exists())
if details.viewbox is not None:
@@ -157,7 +158,7 @@ class PlaceSearch(base.AbstractSearch):
tpc = conn.t.postcode
pcs = self.postcodes.values
pc_near = sa.select(sa.func.min(tpc.c.geometry.ST_Distance(t.c.centroid)))\
pc_near = sa.select(sa.func.min(tpc.c.centroid.ST_Distance(t.c.centroid)))\
.where(tpc.c.postcode.in_(pcs))\
.scalar_subquery()
penalty += sa.case((t.c.postcode.in_(pcs), 0.0),

View File

@@ -14,7 +14,7 @@ from . import base
from ...typing import SaBind, SaExpression
from ...sql.sqlalchemy_types import Geometry, IntArray
from ...connection import SearchConnection
from ...types import SearchDetails, Bbox
from ...types import SearchDetails
from ... import results as nres
from ..db_search_fields import SearchData
@@ -42,10 +42,9 @@ class PostcodeSearch(base.AbstractSearch):
t = conn.t.postcode
pcs = self.postcodes.values
sql = sa.select(t.c.place_id, t.c.parent_place_id,
t.c.rank_search, t.c.rank_address,
t.c.postcode, t.c.country_code,
t.c.geometry.label('centroid'))\
sql = sa.select(t.c.place_id, t.c.parent_place_id, t.c.osm_id,
t.c.rank_search, t.c.postcode, t.c.country_code,
t.c.centroid)\
.where(t.c.postcode.in_(pcs))
if details.geometry_output:
@@ -59,7 +58,7 @@ class PostcodeSearch(base.AbstractSearch):
else_=1.0)
if details.near is not None:
sql = sql.order_by(t.c.geometry.ST_Distance(NEAR_PARAM))
sql = sql.order_by(t.c.centroid.ST_Distance(NEAR_PARAM))
sql = base.filter_by_area(sql, t, details)
@@ -100,29 +99,9 @@ class PostcodeSearch(base.AbstractSearch):
results = nres.SearchResults()
for row in await conn.execute(sql, bind_params):
p = conn.t.placex
placex_sql = base.select_placex(p)\
.add_columns(p.c.importance)\
.where(sa.text("""class = 'boundary'
AND type = 'postal_code'
AND osm_type = 'R'"""))\
.where(p.c.country_code == row.country_code)\
.where(p.c.postcode == row.postcode)\
.limit(1)
result = nres.create_from_postcode_row(row, nres.SearchResult)
if details.geometry_output:
placex_sql = base.add_geometry_columns(placex_sql, p.c.geometry, details)
for prow in await conn.execute(placex_sql, bind_params):
result = nres.create_from_placex_row(prow, nres.SearchResult)
if result is not None:
result.bbox = Bbox.from_wkb(prow.bbox)
break
else:
result = nres.create_from_postcode_row(row, nres.SearchResult)
if result.place_id not in details.excluded:
result.accuracy = row.accuracy
results.append(result)
result.accuracy = row.accuracy
results.append(result)
return results

View File

@@ -59,12 +59,16 @@ class ICUToken(qmod.Token):
assert self.info
return self.info.get('class', ''), self.info.get('type', '')
def rematch(self, norm: str) -> None:
def get_country(self) -> str:
assert self.info
return cast(str, self.info.get('cc', ''))
def match_penalty(self, norm: str) -> float:
""" Check how well the token matches the given normalized string
and add a penalty, if necessary.
"""
if not self.lookup_word:
return
return 0.0
seq = difflib.SequenceMatcher(a=self.lookup_word, b=norm)
distance = 0
@@ -75,7 +79,7 @@ class ICUToken(qmod.Token):
distance += max((ato-afrom), (bto-bfrom))
elif tag != 'equal':
distance += abs((ato-afrom) - (bto-bfrom))
self.penalty += (distance/len(self.lookup_word))
return (distance/len(self.lookup_word))
@staticmethod
def from_db_row(row: SaRow) -> 'ICUToken':
@@ -330,9 +334,10 @@ class ICUQueryAnalyzer(AbstractQueryAnalyzer):
norm = ''.join(f"{n.term_normalized}{'' if n.btype == qmod.BREAK_TOKEN else ' '}"
for n in query.nodes[start + 1:end + 1]).strip()
for ttype, tokens in tlist.items():
if ttype != qmod.TOKEN_COUNTRY:
for token in tokens:
cast(ICUToken, token).rematch(norm)
for token in tokens:
itok = cast(ICUToken, token)
itok.penalty += itok.match_penalty(norm) * \
(1 if ttype in (qmod.TOKEN_WORD, qmod.TOKEN_PARTIAL) else 2)
def compute_break_penalties(self, query: qmod.QueryStruct) -> None:
""" Set the break penalties for the nodes in the query.

View File

@@ -17,7 +17,7 @@ import dataclasses
# The x value for the regression computation will be the position of the
# token in the query. Thus we know the x values will be [0, query length).
# As the denominator only depends on the x values, we can pre-compute here
# the denominatior to use for a given query length.
# the denominator to use for a given query length.
# Note that query length of two or less is special cased and will not use
# the values from this array. Thus it is not a problem that they are 0.
LINFAC = [i * (sum(si * si for si in range(i)) - (i - 1) * i * (i - 1) / 4)
@@ -127,6 +127,12 @@ class Token(ABC):
category objects.
"""
@abstractmethod
def get_country(self) -> str:
""" Return the country code this token is associated with
(currently for country tokens only).
"""
@dataclasses.dataclass
class TokenRange:
@@ -225,7 +231,7 @@ class QueryNode:
return max(0, -self.penalty)
def name_address_ratio(self) -> float:
""" Return the propability that the partial token belonging to
""" Return the probability that the partial token belonging to
this node forms part of a name (as opposed of part of the address).
"""
if self.partial is None:
@@ -269,7 +275,7 @@ class QueryStruct:
directed acyclic graph.
A query also has a direction penalty 'dir_penalty'. This describes
the likelyhood if the query should be read from left-to-right or
the likelihood if the query should be read from left-to-right or
vice versa. A negative 'dir_penalty' should be read as a penalty on
right-to-left reading, while a positive value represents a penalty
for left-to-right reading. The default value is 0, which is equivalent

View File

@@ -7,6 +7,8 @@
"""
Server implementation using the falcon webserver framework.
"""
from __future__ import annotations
from typing import Optional, Mapping, Any, List, cast
from pathlib import Path
import asyncio
@@ -161,7 +163,7 @@ class APIMiddleware:
def __init__(self, project_dir: Path, environ: Optional[Mapping[str, str]]) -> None:
self.api = NominatimAPIAsync(project_dir, environ)
self.app: Optional[App] = None
self.app: Optional[App[Request, Response]] = None
@property
def config(self) -> Configuration:
@@ -169,7 +171,7 @@ class APIMiddleware:
"""
return self.api.config
def set_app(self, app: App) -> None:
def set_app(self, app: App[Request, Response]) -> None:
""" Set the Falcon application this middleware is connected to.
"""
self.app = app
@@ -193,7 +195,7 @@ class APIMiddleware:
def get_application(project_dir: Path,
environ: Optional[Mapping[str, str]] = None) -> App:
environ: Optional[Mapping[str, str]] = None) -> App[Request, Response]:
""" Create a Nominatim Falcon ASGI application.
"""
apimw = APIMiddleware(project_dir, environ)
@@ -215,7 +217,7 @@ def get_application(project_dir: Path,
return app
def run_wsgi() -> App:
def run_wsgi() -> App[Request, Response]:
""" Entry point for uvicorn.
Make sure uvicorn is run from the project directory.

View File

@@ -50,7 +50,7 @@ class ParamWrapper(ASGIAdaptor):
headers={'content-type': self.content_type})
def create_response(self, status: int, output: str, num_results: int) -> Response:
self.request.state.num_results = num_results
setattr(self.request.state, 'num_results', num_results)
return Response(output, status_code=status, media_type=self.content_type)
def base_uri(self) -> str:
@@ -95,7 +95,7 @@ class FileLoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request,
call_next: RequestResponseEndpoint) -> Response:
qs = QueryStatistics()
request.state.query_stats = qs
setattr(request.state, 'query_stats', qs)
response = await call_next(request)
if response.status_code != 200 or 'start' not in qs:

View File

@@ -2,7 +2,7 @@
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2024 by the Nominatim developer community.
# Copyright (C) 2025 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Custom functions and expressions for SQLAlchemy.
@@ -32,7 +32,6 @@ def _default_intersects(element: PlacexGeometryReverseLookuppolygon,
compiler: 'sa.Compiled', **kw: Any) -> str:
return ("(ST_GeometryType(placex.geometry) in ('ST_Polygon', 'ST_MultiPolygon')"
" AND placex.rank_address between 4 and 25"
" AND placex.type != 'postcode'"
" AND placex.name is not null"
" AND placex.indexed_status = 0"
" AND placex.linked_place_id is null)")
@@ -43,7 +42,6 @@ def _sqlite_intersects(element: PlacexGeometryReverseLookuppolygon,
compiler: 'sa.Compiled', **kw: Any) -> str:
return ("(ST_GeometryType(placex.geometry) in ('POLYGON', 'MULTIPOLYGON')"
" AND placex.rank_address between 4 and 25"
" AND placex.type != 'postcode'"
" AND placex.name is not null"
" AND placex.indexed_status = 0"
" AND placex.linked_place_id is null)")
@@ -64,7 +62,6 @@ def default_reverse_place_diameter(element: IntersectsReverseDistance,
compiler: 'sa.Compiled', **kw: Any) -> str:
table = element.tablename
return f"({table}.rank_address between 4 and 25"\
f" AND {table}.type != 'postcode'"\
f" AND {table}.name is not null"\
f" AND {table}.linked_place_id is null"\
f" AND {table}.osm_type = 'N'" + \
@@ -79,7 +76,6 @@ def sqlite_reverse_place_diameter(element: IntersectsReverseDistance,
table = element.tablename
return (f"({table}.rank_address between 4 and 25"
f" AND {table}.type != 'postcode'"
f" AND {table}.name is not null"
f" AND {table}.linked_place_id is null"
f" AND {table}.osm_type = 'N'"

View File

@@ -2,7 +2,7 @@
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2024 by the Nominatim developer community.
# Copyright (C) 2025 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
SQLAlchemy definitions for all tables used by the frontend.
@@ -67,15 +67,16 @@ class SearchTables:
sa.Column('isaddress', sa.Boolean))
self.postcode = sa.Table(
'location_postcode', meta,
'location_postcodes', meta,
sa.Column('place_id', sa.BigInteger),
sa.Column('parent_place_id', sa.BigInteger),
sa.Column('osm_id', sa.BigInteger),
sa.Column('rank_search', sa.SmallInteger),
sa.Column('rank_address', sa.SmallInteger),
sa.Column('indexed_status', sa.SmallInteger),
sa.Column('indexed_date', sa.DateTime),
sa.Column('country_code', sa.String(2)),
sa.Column('postcode', sa.Text),
sa.Column('centroid', Geometry),
sa.Column('geometry', Geometry))
self.osmline = sa.Table(

View File

@@ -2,7 +2,7 @@
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2024 by the Nominatim developer community.
# Copyright (C) 2025 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Custom types for SQLAlchemy.
@@ -178,6 +178,8 @@ class Geometry(types.UserDefinedType): # type: ignore[type-arg]
def bind_processor(self, dialect: 'sa.Dialect') -> Callable[[Any], str]:
def process(value: Any) -> str:
if value is None:
return 'null'
if isinstance(value, str):
return value

View File

@@ -2,7 +2,7 @@
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2024 by the Nominatim developer community.
# Copyright (C) 2025 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Hard-coded information about tag categories.
@@ -20,7 +20,9 @@ def get_label_tag(category: Tuple[str, str], extratags: Optional[Mapping[str, st
rank: int, country: Optional[str]) -> str:
""" Create a label tag for the given place that can be used as an XML name.
"""
if rank < 26 and extratags and 'place' in extratags:
if category in (('place', 'postcode'), ('boundary', 'postal_code')):
label = 'postcode'
elif rank < 26 and extratags and 'place' in extratags:
label = extratags['place']
elif rank < 26 and extratags and 'linked_place' in extratags:
label = extratags['linked_place']
@@ -28,8 +30,6 @@ def get_label_tag(category: Tuple[str, str], extratags: Optional[Mapping[str, st
label = ADMIN_LABELS.get((country or '', rank // 2))\
or ADMIN_LABELS.get(('', rank // 2))\
or 'Administrative'
elif category[1] == 'postal_code':
label = 'postcode'
elif rank < 26:
label = category[1] if category[1] != 'yes' else category[0]
elif rank < 28:

View File

@@ -132,7 +132,7 @@ def _add_parent_rows_grouped(writer: JsonWriter, rows: AddressLines,
@dispatch.format_func(DetailedResult, 'json')
def _format_details_json(result: DetailedResult, options: Mapping[str, Any]) -> str:
locales = options.get('locales', Locales())
locales = options.get('locales') or Locales()
geom = result.geometry.get('geojson')
centroid = result.centroid.to_geojson()

View File

@@ -2,7 +2,7 @@
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2024 by the Nominatim developer community.
# Copyright (C) 2025 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Helper function for parsing parameters and and outputting data
@@ -12,7 +12,7 @@ from typing import Tuple, Optional, Any, Dict, Iterable
from itertools import chain
import re
from ..results import SearchResult, SearchResults, SourceTable
from ..results import SearchResults, SourceTable
from ..types import SearchDetails, GeometryFormat
@@ -106,10 +106,6 @@ def deduplicate_results(results: SearchResults, max_results: int) -> SearchResul
classification_done = set()
deduped = SearchResults()
for result in results:
if result.source_table == SourceTable.POSTCODE:
assert result.names and 'ref' in result.names
if any(_is_postcode_relation_for(r, result.names['ref']) for r in results):
continue
if result.source_table == SourceTable.PLACEX:
classification = (result.osm_object[0] if result.osm_object else None,
result.category,
@@ -128,15 +124,6 @@ def deduplicate_results(results: SearchResults, max_results: int) -> SearchResul
return deduped
def _is_postcode_relation_for(result: SearchResult, postcode: str) -> bool:
return result.source_table == SourceTable.PLACEX \
and result.osm_object is not None \
and result.osm_object[0] == 'R' \
and result.category == ('boundary', 'postal_code') \
and result.names is not None \
and result.names.get('ref') == postcode
def _deg(axis: str) -> str:
return f"(?P<{axis}_deg>\\d+\\.\\d+)°?"

View File

@@ -2,7 +2,7 @@
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2025 by the Nominatim developer community.
# Copyright (C) 2026 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Generic part of the server implementation of the v1 API.
@@ -174,7 +174,8 @@ async def details_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
if result is None:
params.raise_error('No place with that OSM ID found.', status=404)
locales = Locales.from_accept_languages(get_accepted_languages(params))
locales = Locales.from_accept_languages(get_accepted_languages(params),
params.config().OUTPUT_NAMES)
locales.localize_results([result])
output = params.formatting().format_result(
@@ -199,6 +200,7 @@ async def reverse_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
details['max_rank'] = helpers.zoom_to_rank(params.get_int('zoom', 18))
details['layers'] = get_layers(params)
details['query_stats'] = params.query_stats()
details['entrances'] = params.get_bool('entrances', False)
result = await api.reverse(coord, **details)
@@ -215,8 +217,8 @@ async def reverse_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
query = ''
if result:
Locales.from_accept_languages(get_accepted_languages(params)).localize_results(
[result])
Locales.from_accept_languages(get_accepted_languages(params),
params.config().OUTPUT_NAMES).localize_results([result])
fmt_options = {'query': query,
'extratags': params.get_bool('extratags', False),
@@ -237,6 +239,7 @@ async def lookup_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
debug = setup_debugging(params)
details = parse_geometry_details(params, fmt)
details['query_stats'] = params.query_stats()
details['entrances'] = params.get_bool('entrances', False)
places = []
for oid in (params.get('osm_ids') or '').split(','):
@@ -255,7 +258,8 @@ async def lookup_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
if debug:
return build_response(params, loglib.get_and_disable(), num_results=len(results))
Locales.from_accept_languages(get_accepted_languages(params)).localize_results(results)
Locales.from_accept_languages(get_accepted_languages(params),
params.config().OUTPUT_NAMES).localize_results(results)
fmt_options = {'extratags': params.get_bool('extratags', False),
'namedetails': params.get_bool('namedetails', False),
@@ -348,7 +352,8 @@ async def search_endpoint(api: NominatimAPIAsync, params: ASGIAdaptor) -> Any:
except UsageError as err:
params.raise_error(str(err))
Locales.from_accept_languages(get_accepted_languages(params)).localize_results(results)
Locales.from_accept_languages(get_accepted_languages(params),
params.config().OUTPUT_NAMES).localize_results(results)
if details['dedupe'] and len(results) > 1:
results = helpers.deduplicate_results(results, max_results)

View File

@@ -65,14 +65,14 @@ class UpdateAddData:
def run(self, args: NominatimArgs) -> int:
from ..tools import add_osm_data
if args.tiger_data:
return asyncio.run(self._add_tiger_data(args))
with connect(args.config.get_libpq_dsn()) as conn:
if is_frozen(conn):
print('Database is marked frozen. New data can\'t be added.')
return 1
if args.tiger_data:
return asyncio.run(self._add_tiger_data(args))
osm2pgsql_params = args.osm2pgsql_options(default_cache=1000, default_threads=1)
if args.file or args.diff:
return add_osm_data.add_data_from_file(args.config.get_libpq_dsn(),

View File

@@ -19,6 +19,7 @@ import nominatim_api as napi
from nominatim_api.v1.helpers import zoom_to_rank, deduplicate_results
from nominatim_api.server.content_types import CONTENT_JSON
import nominatim_api.logging as loglib
from ..config import Configuration
from ..errors import UsageError
from .args import NominatimArgs
@@ -91,18 +92,19 @@ def _get_geometry_output(args: NominatimArgs) -> napi.GeometryFormat:
raise UsageError(f"Unknown polygon output format '{args.polygon_output}'.") from exp
def _get_locales(args: NominatimArgs, default: Optional[str]) -> napi.Locales:
def _get_locales(args: NominatimArgs, config: Configuration) -> napi.Locales:
""" Get the locales from the language parameter.
"""
if args.lang:
return napi.Locales.from_accept_languages(args.lang)
if default:
return napi.Locales.from_accept_languages(default)
language = args.lang or config.DEFAULT_LANGUAGE
output_names = config.OUTPUT_NAMES
if language:
return napi.Locales.from_accept_languages(language, output_names)
return napi.Locales()
def _get_layers(args: NominatimArgs, default: napi.DataLayer) -> Optional[napi.DataLayer]:
def _get_layers(args: NominatimArgs, default: Optional[napi.DataLayer]) -> Optional[napi.DataLayer]:
""" Get the list of selected layers as a DataLayer enum.
"""
if not args.layers:
@@ -134,7 +136,7 @@ def _print_output(formatter: napi.FormatDispatcher, result: Any,
json.dump(json.loads(output), sys.stdout, indent=4, ensure_ascii=False)
except json.decoder.JSONDecodeError as err:
# Catch the error here, so that data can be debugged,
# when people are developping custom result formatters.
# when people are developing custom result formatters.
LOG.fatal("Parsing json failed: %s\nUnformatted output:\n%s", err, output)
else:
sys.stdout.write(output)
@@ -171,6 +173,10 @@ class APISearch:
help='Preferred area to find search results')
group.add_argument('--bounded', action='store_true',
help='Strictly restrict results to viewbox area')
group.add_argument('--layer', metavar='LAYER',
choices=[n.name.lower() for n in napi.DataLayer if n.name],
action='append', required=False, dest='layers',
help='Restrict results to one or more layers (may be repeated)')
group.add_argument('--no-dedupe', action='store_false', dest='dedupe',
help='Do not remove duplicates from the result list')
_add_list_format(parser)
@@ -187,6 +193,8 @@ class APISearch:
raise UsageError(f"Unsupported format '{args.format}'. "
'Use --list-formats to see supported formats.')
layers = _get_layers(args, None)
try:
with napi.NominatimAPI(args.project_dir) as api:
params: Dict[str, Any] = {'max_results': args.limit + min(args.limit, 10),
@@ -197,6 +205,7 @@ class APISearch:
'excluded': args.exclude_place_ids,
'viewbox': args.viewbox,
'bounded_viewbox': args.bounded,
'layers': layers,
'entrances': args.entrances,
}
@@ -214,7 +223,7 @@ class APISearch:
except napi.UsageError as ex:
raise UsageError(ex) from ex
locales = _get_locales(args, api.config.DEFAULT_LANGUAGE)
locales = _get_locales(args, api.config)
locales.localize_results(results)
if args.dedupe and len(results) > 1:
@@ -253,7 +262,7 @@ class APIReverse:
group.add_argument('--layer', metavar='LAYER',
choices=[n.name.lower() for n in napi.DataLayer if n.name],
action='append', required=False, dest='layers',
help='OSM id to lookup in format <NRW><id> (may be repeated)')
help='Restrict results to one or more layers (may be repeated)')
_add_api_output_arguments(parser)
_add_list_format(parser)
@@ -287,7 +296,7 @@ class APIReverse:
raise UsageError(ex) from ex
if result is not None:
locales = _get_locales(args, api.config.DEFAULT_LANGUAGE)
locales = _get_locales(args, api.config)
locales.localize_results([result])
if args.format == 'debug':
@@ -352,7 +361,7 @@ class APILookup:
except napi.UsageError as ex:
raise UsageError(ex) from ex
locales = _get_locales(args, api.config.DEFAULT_LANGUAGE)
locales = _get_locales(args, api.config)
locales.localize_results(results)
if args.format == 'debug':
@@ -452,7 +461,7 @@ class APIDetails:
raise UsageError(ex) from ex
if result is not None:
locales = _get_locales(args, api.config.DEFAULT_LANGUAGE)
locales = _get_locales(args, api.config)
locales.localize_results([result])
if args.format == 'debug':

View File

@@ -119,6 +119,7 @@ class NominatimArgs:
enable_debug_statements: bool
data_object: Sequence[Tuple[str, int]]
data_area: Sequence[Tuple[str, int]]
ro_access: bool
# Arguments to 'replication'
init: bool

View File

@@ -154,7 +154,7 @@ async def dump_results(conn: napi.SearchConnection,
await add_result_details(conn, results,
LookupDetails(address_details=True))
locale = napi.Locales([lang] if lang else None)
locale = napi.Locales([lang] if lang else None, conn.config.OUTPUT_NAMES)
locale.localize_results(results)
for result in results:

View File

@@ -65,6 +65,8 @@ class UpdateRefresh:
help='Update secondary importance raster data')
group.add_argument('--importance', action='store_true',
help='Recompute place importances (expensive!)')
group.add_argument('--ro-access', action='store_true',
help='Grant read-only access to web user for all tables')
group.add_argument('--website', action='store_true',
help='DEPRECATED. This function has no function anymore'
' and will be removed in a future version.')
@@ -159,6 +161,11 @@ class UpdateRefresh:
LOG.error('WARNING: Website setup is no longer required. '
'This function will be removed in future version of Nominatim.')
if args.ro_access:
from ..tools import admin
LOG.warning('Grant read-only access to web user')
admin.grant_ro_access(args.config.get_libpq_dsn(), args.config)
if args.data_object or args.data_area:
with connect(args.config.get_libpq_dsn()) as conn:
for obj in args.data_object or []:

View File

@@ -23,6 +23,7 @@ from ..tokenizer.base import AbstractTokenizer
from ..version import NOMINATIM_VERSION
from .args import NominatimArgs
import time
LOG = logging.getLogger()
@@ -86,6 +87,8 @@ class SetupAll:
from ..tools import database_import, postcodes, freeze
from ..indexer.indexer import Indexer
start_time = time.time()
num_threads = args.threads or psutil.cpu_count() or 1
country_info.setup_country_config(args.config)
@@ -138,6 +141,10 @@ class SetupAll:
LOG.warning('Recompute word counts')
tokenizer.update_statistics(args.config, threads=num_threads)
end_time = time.time()
elapsed = end_time - start_time
LOG.warning(f'Import completed successfully in {elapsed:.2f} seconds.')
self._finalize_database(args.config.get_libpq_dsn(), args.offline)
return 0

View File

@@ -2,7 +2,7 @@
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2025 by the Nominatim developer community.
# Copyright (C) 2026 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Nominatim configuration accessor.
@@ -12,6 +12,7 @@ import importlib.util
import logging
import os
import sys
import re
from pathlib import Path
import json
import yaml
@@ -80,6 +81,10 @@ class Configuration:
self.lib_dir = _LibDirs()
self._private_plugins: Dict[str, object] = {}
if re.fullmatch(r'[\w-]+', self.DATABASE_WEBUSER) is None:
raise UsageError("Misconfigured DATABASE_WEBUSER. "
"Only alphnumberic characters, - and _ are allowed.")
def set_libdirs(self, **kwargs: StrPath) -> None:
""" Set paths to library functions and data.
"""
@@ -197,7 +202,7 @@ class Configuration:
if dsn.startswith('pgsql:'):
return dict((p.split('=', 1) for p in dsn[6:].split(';')))
return conninfo_to_dict(dsn)
return conninfo_to_dict(dsn) # type: ignore
def get_import_style_file(self) -> Path:
""" Return the import style file as a path object. Translates the

View File

@@ -2,7 +2,7 @@
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2024 by the Nominatim developer community.
# Copyright (C) 2025 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Functions for formatting postcodes according to their country-specific
@@ -29,6 +29,9 @@ class CountryPostcodeMatcher:
self.norm_pattern = re.compile(f'\\s*(?:{country_code.upper()}[ -]?)?({pc_pattern})\\s*')
self.pattern = re.compile(pc_pattern)
# We want to exclude 0000, 00-000, 000 00 etc
self.zero_pattern = re.compile(r'^[0\- ]+$')
self.output = config.get('output', r'\g<0>')
def match(self, postcode: str) -> Optional[Match[str]]:
@@ -40,7 +43,10 @@ class CountryPostcodeMatcher:
normalized = self.norm_pattern.fullmatch(postcode.upper())
if normalized:
return self.pattern.fullmatch(normalized.group(1))
match = self.pattern.fullmatch(normalized.group(1))
if match and self.zero_pattern.match(match.string):
return None
return match
return None
@@ -61,12 +67,15 @@ class PostcodeFormatter:
self.country_without_postcode: Set[Optional[str]] = {None}
self.country_matcher = {}
self.default_matcher = CountryPostcodeMatcher('', {'pattern': '.*'})
self.postcode_extent: dict[Optional[str], int] = {}
for ccode, prop in country_info.iterate('postcode'):
if prop is False:
self.country_without_postcode.add(ccode)
elif isinstance(prop, dict):
self.country_matcher[ccode] = CountryPostcodeMatcher(ccode, prop)
if 'extent' in prop:
self.postcode_extent[ccode] = int(prop['extent'])
else:
raise UsageError(f"Invalid entry 'postcode' for country '{ccode}'")
@@ -107,3 +116,9 @@ class PostcodeFormatter:
`match()`
"""
return self.country_matcher.get(country_code, self.default_matcher).normalize(match)
def get_postcode_extent(self, country_code: Optional[str]) -> int:
""" Return the extent (in m) to use for the given country. If no
specific extent is set, then the default of 5km will be returned.
"""
return self.postcode_extent.get(country_code, 5000)

View File

@@ -2,7 +2,7 @@
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2024 by the Nominatim developer community.
# Copyright (C) 2026 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
A connection pool that executes incoming queries in parallel.
@@ -27,20 +27,28 @@ class QueryPool:
The results of the queries is discarded.
"""
def __init__(self, dsn: str, pool_size: int = 1, **conn_args: Any) -> None:
self.is_cancelled = False
self.wait_time = 0.0
self.query_queue: 'asyncio.Queue[QueueItem]' = asyncio.Queue(maxsize=2 * pool_size)
self.pool = [asyncio.create_task(self._worker_loop(dsn, **conn_args))
self.pool = [asyncio.create_task(self._worker_loop_cancellable(dsn, **conn_args))
for _ in range(pool_size)]
async def put_query(self, query: psycopg.abc.Query, params: Any) -> None:
""" Schedule a query for execution.
"""
if self.is_cancelled:
await self.finish()
return
tstart = time.time()
await self.query_queue.put((query, params))
self.wait_time += time.time() - tstart
await asyncio.sleep(0)
if self.is_cancelled:
await self.finish()
async def finish(self) -> None:
""" Wait for all queries to finish and close the pool.
"""
@@ -56,6 +64,25 @@ class QueryPool:
if excp is not None:
raise excp
def clear_queue(self) -> None:
""" Drop all items silently that might still be queued.
"""
try:
while True:
self.query_queue.get_nowait()
except asyncio.QueueEmpty:
pass # expected
async def _worker_loop_cancellable(self, dsn: str, **conn_args: Any) -> None:
try:
await self._worker_loop(dsn, **conn_args)
except Exception as e:
# Make sure the exception is forwarded to the main function
self.is_cancelled = True
# clear the queue here to ensure that any put() that may be blocked returns
self.clear_queue()
raise e
async def _worker_loop(self, dsn: str, **conn_args: Any) -> None:
conn_args['autocommit'] = True
aconn = await psycopg.AsyncConnection.connect(dsn, **conn_args)

View File

@@ -2,12 +2,13 @@
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2024 by the Nominatim developer community.
# Copyright (C) 2026 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Preprocessing of SQL files.
"""
from typing import Set, Dict, Any, cast
import re
import jinja2
@@ -34,7 +35,9 @@ def _get_tables(conn: Connection) -> Set[str]:
with conn.cursor() as cur:
cur.execute("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")
return set((row[0] for row in list(cur)))
# paranoia check: make sure we don't get table names that cause
# an SQL injection later
return {row[0] for row in list(cur) if re.fullmatch(r'\w+', row[0])}
def _get_middle_db_format(conn: Connection, tables: Set[str]) -> str:

View File

@@ -2,7 +2,7 @@
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2024 by the Nominatim developer community.
# Copyright (C) 2025 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Main work horse for indexing (computing addresses) the database.
@@ -59,7 +59,7 @@ class Indexer:
if await self.index_by_rank(0, 4) > 0:
_analyze()
if await self.index_boundaries(0, 30) > 100:
if await self.index_boundaries() > 100:
_analyze()
if await self.index_by_rank(5, 25) > 100:
@@ -74,7 +74,7 @@ class Indexer:
if not self.has_pending():
break
async def index_boundaries(self, minrank: int, maxrank: int) -> int:
async def index_boundaries(self, minrank: int = 0, maxrank: int = 30) -> int:
""" Index only administrative boundaries within the given rank range.
"""
total = 0
@@ -154,7 +154,7 @@ class Indexer:
return total
async def index_postcodes(self) -> int:
"""Index the entries of the location_postcode table.
"""Index the entries of the location_postcodes table.
"""
LOG.warning("Starting indexing postcodes using %s threads", self.num_threads)
@@ -177,7 +177,7 @@ class Indexer:
`total_tuples` may contain the total number of rows to process.
When not supplied, the value will be computed using the
approriate runner function.
appropriate runner function.
"""
LOG.warning("Starting %s (using batch size %s)", runner.name(), batch)

View File

@@ -2,7 +2,7 @@
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2024 by the Nominatim developer community.
# Copyright (C) 2025 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Mix-ins that provide the actual commands for the indexer for various indexing
@@ -143,22 +143,22 @@ class InterpolationRunner:
class PostcodeRunner(Runner):
""" Provides the SQL commands for indexing the location_postcode table.
""" Provides the SQL commands for indexing the location_postcodes table.
"""
def name(self) -> str:
return "postcodes (location_postcode)"
return "postcodes (location_postcodes)"
def sql_count_objects(self) -> Query:
return 'SELECT count(*) FROM location_postcode WHERE indexed_status > 0'
return 'SELECT count(*) FROM location_postcodes WHERE indexed_status > 0'
def sql_get_objects(self) -> Query:
return """SELECT place_id FROM location_postcode
return """SELECT place_id FROM location_postcodes
WHERE indexed_status > 0
ORDER BY country_code, postcode"""
def index_places_query(self, batch_size: int) -> Query:
return pysql.SQL("""UPDATE location_postcode SET indexed_status = 0
return pysql.SQL("""UPDATE location_postcodes SET indexed_status = 0
WHERE place_id IN ({})""")\
.format(pysql.SQL(',').join((pysql.Placeholder() for _ in range(batch_size))))

View File

@@ -71,7 +71,7 @@ class AbstractAnalyzer(ABC):
@abstractmethod
def update_postcodes_from_db(self) -> None:
""" Update the tokenizer's postcode tokens from the current content
of the `location_postcode` table.
of the `location_postcodes` table.
"""
@abstractmethod

View File

@@ -2,7 +2,7 @@
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2025 by the Nominatim developer community.
# Copyright (C) 2026 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Tokenizer implementing normalisation as used before Nominatim 4 but using
@@ -294,13 +294,12 @@ class ICUTokenizer(AbstractTokenizer):
with connect(self.dsn) as conn:
drop_tables(conn, 'word')
with conn.cursor() as cur:
cur.execute(f"ALTER TABLE {old} RENAME TO word")
for idx in ('word_token', 'word_id'):
cur.execute(f"""ALTER INDEX idx_{old}_{idx}
RENAME TO idx_word_{idx}""")
for name, _ in WORD_TYPES:
cur.execute(f"""ALTER INDEX idx_{old}_{name}
RENAME TO idx_word_{name}""")
cur.execute(pysql.SQL("ALTER TABLE {} RENAME TO word")
.format(pysql.Identifier(old)))
for idx in ['word_token', 'word_id'] + [n[0] for n in WORD_TYPES]:
cur.execute(pysql.SQL("ALTER INDEX {} RENAME TO {}")
.format(pysql.Identifier(f"idx_{old}_{idx}"),
pysql.Identifier(f"idx_word_{idx}")))
conn.commit()
@@ -475,20 +474,23 @@ class ICUNameAnalyzer(AbstractAnalyzer):
assert self.conn is not None
word_tokens = set()
for name in names:
norm_name = self._search_normalized(name.name)
if norm_name:
word_tokens.add(norm_name)
norm_name = self._normalized(name.name)
token_name = self._search_normalized(name.name)
if norm_name and token_name:
word_tokens.add((token_name, norm_name))
with self.conn.cursor() as cur:
# Get existing names
cur.execute("""SELECT word_token, coalesce(info ? 'internal', false) as is_internal
cur.execute("""SELECT word_token,
word as lookup,
coalesce(info ? 'internal', false) as is_internal
FROM word
WHERE type = 'C' and word = %s""",
WHERE type = 'C' and info->>'cc' = %s""",
(country_code, ))
# internal/external names
existing_tokens: Dict[bool, Set[str]] = {True: set(), False: set()}
existing_tokens: Dict[bool, Set[Tuple[str, str]]] = {True: set(), False: set()}
for word in cur:
existing_tokens[word[1]].add(word[0])
existing_tokens[word[2]].add((word[0], word[1]))
# Delete names that no longer exist.
gone_tokens = existing_tokens[internal] - word_tokens
@@ -496,10 +498,10 @@ class ICUNameAnalyzer(AbstractAnalyzer):
gone_tokens.update(existing_tokens[False] & word_tokens)
if gone_tokens:
cur.execute("""DELETE FROM word
USING unnest(%s::text[]) as token
WHERE type = 'C' and word = %s
and word_token = token""",
(list(gone_tokens), country_code))
USING jsonb_array_elements(%s) as data
WHERE type = 'C' and info->>'cc' = %s
and word_token = data->>0 and word = data->>1""",
(Jsonb(list(gone_tokens)), country_code))
# Only add those names that are not yet in the list.
new_tokens = word_tokens - existing_tokens[True]
@@ -508,15 +510,17 @@ class ICUNameAnalyzer(AbstractAnalyzer):
if new_tokens:
if internal:
sql = """INSERT INTO word (word_token, type, word, info)
(SELECT token, 'C', %s, '{"internal": "yes"}'
FROM unnest(%s::text[]) as token)
(SELECT data->>0, 'C', data->>1,
jsonb_build_object('internal', 'yes', 'cc', %s::text)
FROM jsonb_array_elements(%s) as data)
"""
else:
sql = """INSERT INTO word (word_token, type, word)
(SELECT token, 'C', %s
FROM unnest(%s::text[]) as token)
sql = """INSERT INTO word (word_token, type, word, info)
(SELECT data->>0, 'C', data->>1,
jsonb_build_object('cc', %s::text)
FROM jsonb_array_elements(%s) as data)
"""
cur.execute(sql, (country_code, list(new_tokens)))
cur.execute(sql, (country_code, Jsonb(list(new_tokens))))
def process_place(self, place: PlaceInfo) -> Mapping[str, Any]:
""" Determine tokenizer information about the given place.

View File

@@ -16,6 +16,7 @@ from psycopg.types.json import Json
from ..typing import DictCursorResult
from ..config import Configuration
from ..db.connection import connect, Cursor, register_hstore
from ..db.sql_preprocessor import SQLPreprocessor
from ..errors import UsageError
from ..tokenizer import factory as tokenizer_factory
from ..data.place_info import PlaceInfo
@@ -105,3 +106,12 @@ def clean_deleted_relations(config: Configuration, age: str) -> None:
except psycopg.DataError as exc:
raise UsageError('Invalid PostgreSQL time interval format') from exc
conn.commit()
def grant_ro_access(dsn: str, config: Configuration) -> None:
""" Grant read-only access to the web user for all Nominatim tables.
This can be used to grant access to a different user after import.
"""
with connect(dsn) as conn:
sql = SQLPreprocessor(conn, config)
sql.run_sql_file(conn, 'grants.sql')

View File

@@ -113,8 +113,8 @@ def _get_indexes(conn: Connection) -> List[str]:
'idx_placex_geometry_placenode',
'idx_osmline_parent_place_id',
'idx_osmline_parent_osm_id',
'idx_postcode_id',
'idx_postcode_postcode'
'idx_location_postcodes_id',
'idx_location_postcodes_postcode'
]
# These won't exist if --reverse-only import was used

View File

@@ -2,7 +2,7 @@
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2024 by the Nominatim developer community.
# Copyright (C) 2025 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Functions for setting up and importing a new Nominatim database.
@@ -157,6 +157,8 @@ def create_tables(conn: Connection, config: Configuration, reverse_only: bool =
sql.run_sql_file(conn, 'tables.sql')
sql.run_sql_file(conn, 'grants.sql')
def create_table_triggers(conn: Connection, config: Configuration) -> None:
""" Create the triggers for the tables. The trigger functions must already
@@ -183,7 +185,7 @@ def truncate_data_tables(conn: Connection) -> None:
cur.execute('TRUNCATE location_area_country')
cur.execute('TRUNCATE location_property_tiger')
cur.execute('TRUNCATE location_property_osmline')
cur.execute('TRUNCATE location_postcode')
cur.execute('TRUNCATE location_postcodes')
if table_exists(conn, 'search_name'):
cur.execute('TRUNCATE search_name')
cur.execute('DROP SEQUENCE IF EXISTS seq_place')
@@ -193,7 +195,7 @@ def truncate_data_tables(conn: Connection) -> None:
WHERE tablename LIKE 'location_road_%'""")
for table in [r[0] for r in list(cur)]:
cur.execute('TRUNCATE ' + table)
cur.execute(pysql.SQL('TRUNCATE {}').format(pysql.Identifier(table)))
conn.commit()
@@ -225,7 +227,7 @@ async def load_data(dsn: str, threads: int) -> None:
total=pysql.Literal(placex_threads),
mod=pysql.Literal(imod)), None)
# Interpolations need to be copied seperately
# Interpolations need to be copied separately
await pool.put_query("""
INSERT INTO location_property_osmline (osm_id, address, linegeo)
SELECT osm_id, address, geometry FROM place

View File

@@ -2,7 +2,7 @@
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2024 by the Nominatim developer community.
# Copyright (C) 2025 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Functions for removing unnecessary data from the database.
@@ -18,10 +18,11 @@ UPDATE_TABLES = [
'address_levels',
'gb_postcode',
'import_osmosis_log',
'import_polygon_%',
'location_area%',
'location_road%',
'place',
'place_entrance',
'place_postcode',
'planet_osm_%',
'search_name_%',
'us_postcode',

View File

@@ -2,7 +2,7 @@
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2024 by the Nominatim developer community.
# Copyright (C) 2025 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Functions for database migration to newer software versions.
@@ -18,6 +18,8 @@ from ..db.connection import connect, Connection, \
from ..db.sql_preprocessor import SQLPreprocessor
from ..version import NominatimVersion, NOMINATIM_VERSION, parse_version
from ..tokenizer import factory as tokenizer_factory
from ..data.country_info import create_country_names, setup_country_config
from .freeze import is_frozen
from . import refresh
LOG = logging.getLogger()
@@ -27,7 +29,7 @@ _MIGRATION_FUNCTIONS: List[Tuple[NominatimVersion, Callable[..., None]]] = []
def migrate(config: Configuration, paths: Any) -> int:
""" Check for the current database version and execute migrations,
if necesssary.
if necessary.
"""
with connect(config.get_libpq_dsn()) as conn:
register_hstore(conn)
@@ -141,7 +143,7 @@ def create_placex_entrance_table(conn: Connection, config: Configuration, **_: A
@_migration(5, 1, 99, 1)
def create_place_entrance_table(conn: Connection, config: Configuration, **_: Any) -> None:
""" Add the place_entrance table to store incomming entrance nodes
""" Add the place_entrance table to store incoming entrance nodes
"""
if not table_exists(conn, 'place_entrance'):
with conn.cursor() as cur:
@@ -156,3 +158,195 @@ def create_place_entrance_table(conn: Connection, config: Configuration, **_: An
CREATE UNIQUE INDEX place_entrance_osm_id_idx ON place_entrance
USING BTREE (osm_id);
""")
@_migration(5, 2, 99, 0)
def convert_country_tokens(conn: Connection, config: Configuration, **_: Any) -> None:
""" Convert country word tokens
Country tokens now save the country in the info field instead of the
word. This migration removes all country tokens from the word table
and reimports the default country name. This means that custom names
are lost. If you need them back, invalidate the OSM objects containing
the names by setting indexed_status to 2 and then reindex the database.
"""
tokenizer = tokenizer_factory.get_tokenizer_for_db(config)
# There is only one tokenizer at the time of migration, so we make
# some assumptions here about the structure of the database. This will
# fail if somebody has written a custom tokenizer.
with conn.cursor() as cur:
cur.execute("DELETE FROM word WHERE type = 'C'")
conn.commit()
setup_country_config(config)
create_country_names(conn, tokenizer, config.get_str_list('LANGUAGES'))
@_migration(5, 2, 99, 1)
def create_place_postcode_table(conn: Connection, config: Configuration, **_: Any) -> None:
""" Restructure postcode tables
"""
sqlp = SQLPreprocessor(conn, config)
mutable = not is_frozen(conn)
has_place_table = table_exists(conn, 'place_postcode')
has_postcode_table = table_exists(conn, 'location_postcodes')
if mutable and not has_place_table:
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE place_postcode (
osm_type CHAR(1) NOT NULL,
osm_id BIGINT NOT NULL,
postcode TEXT NOT NULL,
country_code TEXT,
centroid GEOMETRY(Point, 4326) NOT NULL,
geometry GEOMETRY(Geometry, 4326)
)
""")
# Move postcode points into the new table
cur.execute("ALTER TABLE place DISABLE TRIGGER ALL")
cur.execute(
"""
WITH deleted AS (
DELETE FROM place
WHERE (class = 'place' AND type = 'postcode')
OR (osm_type = 'R'
AND class = 'boundary' AND type = 'postal_code')
RETURNING osm_type, osm_id, address->'postcode' as postcode,
ST_Centroid(geometry) as centroid,
(CASE WHEN class = 'place' THEN NULL ELSE geometry END) as geometry)
INSERT INTO place_postcode (osm_type, osm_id, postcode, centroid, geometry)
(SELECT * FROM deleted
WHERE deleted.postcode is not NULL AND deleted.centroid is not NULL)
""")
cur.execute(
"""
CREATE INDEX place_postcode_osm_id_idx ON place_postcode
USING BTREE (osm_type, osm_id)
""")
cur.execute(
"""
CREATE INDEX place_postcode_postcode_idx ON place_postcode
USING BTREE (postcode)
""")
cur.execute("ALTER TABLE place ENABLE TRIGGER ALL")
if not has_postcode_table:
sqlp.run_sql_file(conn, 'functions/postcode_triggers.sql')
with conn.cursor() as cur:
# create a new location_postcode table which will replace the
# old one atomically in the end
cur.execute(
"""
CREATE TABLE location_postcodes (
place_id BIGINT,
osm_id BIGINT,
rank_search SMALLINT,
parent_place_id BIGINT,
indexed_status SMALLINT,
indexed_date TIMESTAMP,
country_code VARCHAR(2),
postcode TEXT,
centroid Geometry(Point, 4326),
geometry Geometry(Geometry, 4326) NOT NULL
)
""")
sqlp.run_string(conn,
'GRANT SELECT ON location_postcodes TO "{{config.DATABASE_WEBUSER}}"')
# remove postcodes from the various auxiliary tables
cur.execute(
"""
DELETE FROM place_addressline
WHERE address_place_id = ANY(
SELECT place_id FROM placex
WHERE osm_type = 'R'
AND class = 'boundary' AND type = 'postal_code')
""")
if mutable:
cur.execute(
"""
SELECT deleteLocationArea(partition, place_id, rank_search),
deleteSearchName(partition, place_id)
FROM placex
WHERE osm_type = 'R' AND class = 'boundary' AND type = 'postal_code'
""")
if table_exists(conn, 'search_name'):
cur.execute(
"""
DELETE FROM search_name
WHERE place_id = ANY(
SELECT place_id FROM placex
WHERE osm_type = 'R'
AND class = 'boundary' AND type = 'postal_code')
""")
# move postcode areas from placex to location_postcodes
# avoiding automatic invalidation
cur.execute("ALTER TABLE placex DISABLE TRIGGER ALL")
cur.execute(
"""
WITH deleted AS (
DELETE FROM placex
WHERE osm_type = 'R'
AND class = 'boundary' AND type = 'postal_code'
RETURNING place_id, osm_id, rank_search, parent_place_id,
indexed_status, indexed_date,
country_code, postcode, centroid, geometry)
INSERT INTO location_postcodes (SELECT * from deleted)
""")
cur.execute("ALTER TABLE placex ENABLE TRIGGER ALL")
# remove any old postcode centroid that would overlap with areas
cur.execute(
"""
DELETE FROM location_postcode o USING location_postcodes n
WHERE o.country_code = n.country_code
AND o.postcode = n.postcode
""")
# copy over old postcodes
cur.execute(
"""
INSERT INTO location_postcodes
(SELECT place_id, NULL, rank_search, parent_place_id,
indexed_status, indexed_date, country_code,
postcode, geometry,
ST_Expand(geometry, 0.05)
FROM location_postcode)
""")
# add indexes and triggers
cur.execute("""CREATE INDEX idx_location_postcodes_geometry
ON location_postcodes USING GIST(geometry)""")
cur.execute("""CREATE INDEX idx_location_postcodes_id
ON location_postcodes USING BTREE(place_id)""")
cur.execute("""CREATE INDEX idx_location_postcodes_osmid
ON location_postcodes USING BTREE(osm_id)""")
cur.execute("""CREATE INDEX idx_location_postcodes_postcode
ON location_postcodes USING BTREE(postcode, country_code)""")
cur.execute("""CREATE INDEX idx_location_postcodes_parent_place_id
ON location_postcodes USING BTREE(parent_place_id)""")
cur.execute("""CREATE TRIGGER location_postcodes_before_update
BEFORE UPDATE ON location_postcodes
FOR EACH ROW EXECUTE PROCEDURE postcodes_update()""")
cur.execute("""CREATE TRIGGER location_postcodes_before_delete
BEFORE DELETE ON location_postcodes
FOR EACH ROW EXECUTE PROCEDURE postcodes_delete()""")
cur.execute("""CREATE TRIGGER location_postcodes_before_insert
BEFORE INSERT ON location_postcodes
FOR EACH ROW EXECUTE PROCEDURE postcodes_insert()""")
sqlp.run_string(
conn,
"""
CREATE INDEX IF NOT EXISTS idx_placex_geometry_reverse_lookupPolygon_nopostcode
ON placex USING gist (geometry) {{db.tablespace.search_index}}
WHERE St_GeometryType(geometry) in ('ST_Polygon', 'ST_MultiPolygon')
AND rank_address between 4 and 25
AND name is not null AND linked_place_id is null;
CREATE INDEX IF NOT EXISTS idx_placex_geometry_reverse_lookupPlaceNode_nopostcode
ON placex USING gist (ST_Buffer(geometry, reverse_place_diameter(rank_search)))
{{db.tablespace.search_index}}
WHERE rank_address between 4 and 25
AND name is not null AND linked_place_id is null AND osm_type = 'N';
CREATE INDEX idx_placex_geometry_placenode_nopostcode ON placex
USING SPGIST (geometry) {{db.tablespace.address_index}}
WHERE osm_type = 'N' and rank_search < 26 and class = 'place';
ANALYSE;
""")

View File

@@ -8,7 +8,7 @@
Functions for importing, updating and otherwise maintaining the table
of artificial postcode centroids.
"""
from typing import Optional, Tuple, Dict, List, TextIO
from typing import Optional, Tuple, Dict, TextIO
from collections import defaultdict
from pathlib import Path
import csv
@@ -38,13 +38,26 @@ def _to_float(numstr: str, max_value: float) -> float:
return num
def _extent_to_rank(extent: int) -> int:
""" Guess a suitable search rank from the extent of a postcode.
"""
if extent <= 100:
return 25
if extent <= 3000:
return 23
return 21
class _PostcodeCollector:
""" Collector for postcodes of a single country.
"""
def __init__(self, country: str, matcher: Optional[CountryPostcodeMatcher]):
def __init__(self, country: str, matcher: Optional[CountryPostcodeMatcher],
default_extent: int, exclude: set[str] = set()):
self.country = country
self.matcher = matcher
self.extent = default_extent
self.exclude = exclude
self.collected: Dict[str, PointsCentroid] = defaultdict(PointsCentroid)
self.normalization_cache: Optional[Tuple[str, Optional[str]]] = None
@@ -61,7 +74,7 @@ class _PostcodeCollector:
normalized = self.matcher.normalize(match) if match else None
self.normalization_cache = (postcode, normalized)
if normalized:
if normalized and normalized not in self.exclude:
self.collected[normalized] += (x, y)
def commit(self, conn: Connection, analyzer: AbstractAnalyzer,
@@ -73,61 +86,38 @@ class _PostcodeCollector:
"""
if project_dir is not None:
self._update_from_external(analyzer, project_dir)
to_add, to_delete, to_update = self._compute_changes(conn)
LOG.info("Processing country '%s' (%s added, %s deleted, %s updated).",
self.country, len(to_add), len(to_delete), len(to_update))
with conn.cursor() as cur:
cur.execute("""SELECT postcode FROM location_postcodes
WHERE country_code = %s AND osm_id is null""",
(self.country, ))
to_delete = [row[0] for row in cur if row[0] not in self.collected]
to_add = [dict(zip(('pc', 'x', 'y'), (k, *v.centroid())))
for k, v in self.collected.items()]
self.collected = defaultdict(PointsCentroid)
LOG.info("Processing country '%s' (%s added, %s deleted).",
self.country, len(to_add), len(to_delete))
with conn.cursor() as cur:
if to_add:
cur.executemany(pysql.SQL(
"""INSERT INTO location_postcode
(place_id, indexed_status, country_code,
postcode, geometry)
VALUES (nextval('seq_place'), 1, {}, %s,
ST_SetSRID(ST_MakePoint(%s, %s), 4326))
""").format(pysql.Literal(self.country)),
"""INSERT INTO location_postcodes
(country_code, rank_search, postcode, centroid, geometry)
VALUES ({}, {}, %(pc)s,
ST_SetSRID(ST_MakePoint(%(x)s, %(y)s), 4326),
expand_by_meters(ST_SetSRID(ST_MakePoint(%(x)s, %(y)s), 4326), {}))
""").format(pysql.Literal(self.country),
pysql.Literal(_extent_to_rank(self.extent)),
pysql.Literal(self.extent)),
to_add)
if to_delete:
cur.execute("""DELETE FROM location_postcode
cur.execute("""DELETE FROM location_postcodes
WHERE country_code = %s and postcode = any(%s)
AND osm_id is null
""", (self.country, to_delete))
if to_update:
cur.executemany(
pysql.SQL("""UPDATE location_postcode
SET indexed_status = 2,
geometry = ST_SetSRID(ST_Point(%s, %s), 4326)
WHERE country_code = {} and postcode = %s
""").format(pysql.Literal(self.country)),
to_update)
def _compute_changes(
self, conn: Connection
) -> Tuple[List[Tuple[str, float, float]], List[str], List[Tuple[float, float, str]]]:
""" Compute which postcodes from the collected postcodes have to be
added or modified and which from the location_postcode table
have to be deleted.
"""
to_update = []
to_delete = []
with conn.cursor() as cur:
cur.execute("""SELECT postcode, ST_X(geometry), ST_Y(geometry)
FROM location_postcode
WHERE country_code = %s""",
(self.country, ))
for postcode, x, y in cur:
pcobj = self.collected.pop(postcode, None)
if pcobj:
newx, newy = pcobj.centroid()
if abs(x - newx) > 0.0000001 or abs(y - newy) > 0.0000001:
to_update.append((newx, newy, postcode))
else:
to_delete.append(postcode)
to_add = [(k, *v.centroid()) for k, v in self.collected.items()]
self.collected = defaultdict(PointsCentroid)
return to_add, to_delete, to_update
cur.execute("ANALYSE location_postcodes")
def _update_from_external(self, analyzer: AbstractAnalyzer, project_dir: Path) -> None:
""" Look for an external postcode file for the active country in
@@ -152,7 +142,7 @@ class _PostcodeCollector:
_to_float(row['lat'], 90))
self.collected[postcode] += centroid
except ValueError:
LOG.warning("Bad coordinates %s, %s in %s country postcode file.",
LOG.warning("Bad coordinates %s, %s in '%s' country postcode file.",
row['lat'], row['lon'], self.country)
finally:
@@ -169,69 +159,165 @@ class _PostcodeCollector:
if fname.is_file():
LOG.info("Using external postcode file '%s'.", fname)
return gzip.open(fname, 'rt')
return gzip.open(fname, 'rt', encoding='utf-8')
return None
def update_postcodes(dsn: str, project_dir: Optional[Path], tokenizer: AbstractTokenizer) -> None:
""" Update the table of artificial postcodes.
Computes artificial postcode centroids from the placex table,
potentially enhances it with external data and then updates the
postcodes in the table 'location_postcode'.
""" Update the table of postcodes from the input tables
placex and place_postcode.
"""
matcher = PostcodeFormatter()
with tokenizer.name_analyzer() as analyzer:
with connect(dsn) as conn:
# First get the list of countries that currently have postcodes.
# (Doing this before starting to insert, so it is fast on import.)
with conn.cursor() as cur:
cur.execute("SELECT DISTINCT country_code FROM location_postcode")
todo_countries = set((row[0] for row in cur))
# Recompute the list of valid postcodes from placex.
with conn.cursor(name="placex_postcodes") as cur:
cur.execute("""
SELECT cc, pc, ST_X(centroid), ST_Y(centroid)
FROM (SELECT
COALESCE(plx.country_code,
get_country_code(ST_Centroid(pl.geometry))) as cc,
pl.address->'postcode' as pc,
COALESCE(plx.centroid, ST_Centroid(pl.geometry)) as centroid
FROM place AS pl LEFT OUTER JOIN placex AS plx
ON pl.osm_id = plx.osm_id AND pl.osm_type = plx.osm_type
WHERE pl.address ? 'postcode' AND pl.geometry IS NOT null) xx
WHERE pc IS NOT null AND cc IS NOT null
ORDER BY cc, pc""")
collector = None
for country, postcode, x, y in cur:
if collector is None or country != collector.country:
if collector is not None:
collector.commit(conn, analyzer, project_dir)
collector = _PostcodeCollector(country, matcher.get_matcher(country))
todo_countries.discard(country)
collector.add(postcode, x, y)
if collector is not None:
collector.commit(conn, analyzer, project_dir)
# Now handle any countries that are only in the postcode table.
for country in todo_countries:
fmt = matcher.get_matcher(country)
_PostcodeCollector(country, fmt).commit(conn, analyzer, project_dir)
# Backfill country_code column where required
conn.execute("""UPDATE place_postcode
SET country_code = get_country_code(centroid)
WHERE country_code is null
""")
# Now update first postcode areas
_update_postcode_areas(conn, analyzer, matcher)
# Then fill with estimated postcode centroids from other info
_update_guessed_postcode(conn, analyzer, matcher, project_dir)
conn.commit()
analyzer.update_postcodes_from_db()
def can_compute(dsn: str) -> bool:
def _insert_postcode_areas(conn: Connection, country_code: str,
extent: int, pcs: list[dict[str, str]]) -> None:
if pcs:
with conn.cursor() as cur:
cur.executemany(
pysql.SQL(
""" INSERT INTO location_postcodes
(osm_id, country_code, rank_search, postcode, centroid, geometry)
SELECT osm_id, country_code, {}, %(out)s, centroid, geometry
FROM place_postcode
WHERE osm_type = 'R'
and country_code = {} and postcode = %(in)s
and geometry is not null
""").format(pysql.Literal(_extent_to_rank(extent)),
pysql.Literal(country_code)),
pcs)
def _update_postcode_areas(conn: Connection, analyzer: AbstractAnalyzer,
matcher: PostcodeFormatter) -> None:
""" Update the postcode areas made from postcode boundaries.
"""
Check that the place table exists so that
postcodes can be computed.
# first delete all areas that have gone
conn.execute(""" DELETE FROM location_postcodes pc
WHERE pc.osm_id is not null
AND NOT EXISTS(
SELECT * FROM place_postcode pp
WHERE pp.osm_type = 'R' and pp.osm_id = pc.osm_id
and geometry is not null)
""")
# now insert all in country batches, triggers will ensure proper updates
with conn.cursor() as cur:
cur.execute(""" SELECT country_code, postcode FROM place_postcode
WHERE geometry is not null and osm_type = 'R'
ORDER BY country_code
""")
country_code = None
fmt = None
pcs = []
for cc, postcode in cur:
if country_code is None:
country_code = cc
fmt = matcher.get_matcher(country_code)
elif country_code != cc:
_insert_postcode_areas(conn, country_code,
matcher.get_postcode_extent(country_code), pcs)
country_code = cc
fmt = matcher.get_matcher(country_code)
pcs = []
if fmt is not None:
if (m := fmt.match(postcode)):
pcs.append({'out': fmt.normalize(m), 'in': postcode})
if country_code is not None and pcs:
_insert_postcode_areas(conn, country_code,
matcher.get_postcode_extent(country_code), pcs)
def _update_guessed_postcode(conn: Connection, analyzer: AbstractAnalyzer,
matcher: PostcodeFormatter, project_dir: Optional[Path]) -> None:
""" Computes artificial postcode centroids from the placex table,
potentially enhances it with external data and then updates the
postcodes in the table 'location_postcodes'.
"""
# First get the list of countries that currently have postcodes.
# (Doing this before starting to insert, so it is fast on import.)
with conn.cursor() as cur:
cur.execute("""SELECT DISTINCT country_code FROM location_postcodes
WHERE osm_id is null""")
todo_countries = {row[0] for row in cur}
# Next, get the list of postcodes that are already covered by areas.
area_pcs = defaultdict(set)
with conn.cursor() as cur:
cur.execute("""SELECT country_code, postcode
FROM location_postcodes WHERE osm_id is not null
ORDER BY country_code""")
for cc, pc in cur:
area_pcs[cc].add(pc)
# Create a temporary table which contains coverage of the postcode areas.
with conn.cursor() as cur:
cur.execute("DROP TABLE IF EXISTS _global_postcode_area")
cur.execute("""CREATE TABLE _global_postcode_area AS
(SELECT ST_SubDivide(ST_SimplifyPreserveTopology(
ST_Union(geometry), 0.00001), 128) as geometry
FROM place_postcode WHERE geometry is not null)
""")
cur.execute("CREATE INDEX ON _global_postcode_area USING gist(geometry)")
# Recompute the list of valid postcodes from placex.
with conn.cursor(name="placex_postcodes") as cur:
cur.execute("""
SELECT country_code, postcode, ST_X(centroid), ST_Y(centroid)
FROM (
(SELECT country_code, address->'postcode' as postcode, centroid
FROM placex WHERE address ? 'postcode')
UNION
(SELECT country_code, postcode, centroid
FROM place_postcode WHERE geometry is null)
) x
WHERE not postcode like '%,%' and not postcode like '%;%'
AND NOT EXISTS(SELECT * FROM _global_postcode_area g
WHERE ST_Intersects(x.centroid, g.geometry))
ORDER BY country_code""")
collector = None
for country, postcode, x, y in cur:
if collector is None or country != collector.country:
if collector is not None:
collector.commit(conn, analyzer, project_dir)
collector = _PostcodeCollector(country, matcher.get_matcher(country),
matcher.get_postcode_extent(country),
exclude=area_pcs[country])
todo_countries.discard(country)
collector.add(postcode, x, y)
if collector is not None:
collector.commit(conn, analyzer, project_dir)
# Now handle any countries that are only in the postcode table.
for country in todo_countries:
fmt = matcher.get_matcher(country)
ext = matcher.get_postcode_extent(country)
_PostcodeCollector(country, fmt, ext,
exclude=area_pcs[country]).commit(conn, analyzer, project_dir)
conn.execute("DROP TABLE IF EXISTS _global_postcode_area")
def can_compute(dsn: str) -> bool:
""" Check that the necessary tables exist so that postcodes can be computed.
"""
with connect(dsn) as conn:
return table_exists(conn, 'place')
return table_exists(conn, 'place_postcode')

View File

@@ -141,7 +141,9 @@ def import_importance_csv(dsn: str, data_file: Path) -> int:
copy_cmd = """COPY wikimedia_importance(language, title, importance, wikidata)
FROM STDIN"""
with gzip.open(str(data_file), 'rt') as fd, cur.copy(copy_cmd) as copy:
with gzip.open(
str(data_file), 'rt', encoding='utf-8') as fd, \
cur.copy(copy_cmd) as copy:
for row in csv.DictReader(fd, delimiter='\t', quotechar='|'):
wd_id = int(row['wikidata_id'][1:])
copy.write_row((row['language'],

View File

@@ -11,6 +11,8 @@ from typing import Iterable
import re
import logging
import mwparserfromhell
from ...config import Configuration
from ...utils.url_utils import get_url
from .special_phrase import SpecialPhrase
@@ -36,10 +38,6 @@ class SPWikiLoader:
"""
def __init__(self, config: Configuration) -> None:
self.config = config
# Compile the regex here to increase performances.
self.occurence_pattern = re.compile(
r'\| *([^\|]+) *\|\| *([^\|]+) *\|\| *([^\|]+) *\|\| *([^\|]+) *\|\| *([\-YN])'
)
# Hack around a bug where building=yes was imported with quotes into the wiki
self.type_fix_pattern = re.compile(r'\"|&quot;')
@@ -58,11 +56,21 @@ class SPWikiLoader:
LOG.warning('Importing phrases for lang: %s...', lang)
loaded_xml = _get_wiki_content(lang)
# One match will be of format [label, class, type, operator, plural]
matches = self.occurence_pattern.findall(loaded_xml)
wikicode = mwparserfromhell.parse(loaded_xml)
for match in matches:
yield SpecialPhrase(match[0],
match[1],
self.type_fix_pattern.sub('', match[2]),
match[3])
for table in wikicode.filter_tags(matches=lambda t: t.tag == 'table'):
for row in table.contents.filter_tags(matches=lambda t: t.tag == 'tr'):
cells = list(row.contents.filter_tags(matches=lambda t: t.tag == 'td'))
if len(cells) < 5:
continue
label = cells[0].contents.strip_code().strip()
cls = cells[1].contents.strip_code().strip()
typ = cells[2].contents.strip_code().strip()
operator = cells[3].contents.strip_code().strip()
yield SpecialPhrase(label,
cls,
self.type_fix_pattern.sub('', typ),
operator)

View File

@@ -17,13 +17,12 @@ import tarfile
from psycopg.types.json import Json
from ..config import Configuration
from ..db.connection import connect
from ..db.connection import connect, table_exists
from ..db.sql_preprocessor import SQLPreprocessor
from ..errors import UsageError
from ..db.query_pool import QueryPool
from ..data.place_info import PlaceInfo
from ..tokenizer.base import AbstractTokenizer
from . import freeze
LOG = logging.getLogger()
@@ -90,16 +89,19 @@ async def add_tiger_data(data_dir: str, config: Configuration, threads: int,
"""
dsn = config.get_libpq_dsn()
with connect(dsn) as conn:
if freeze.is_frozen(conn):
raise UsageError("Tiger cannot be imported when database frozen (Github issue #3048)")
with TigerInput(data_dir) as tar:
if not tar:
return 1
with connect(dsn) as conn:
sql = SQLPreprocessor(conn, config)
if not table_exists(conn, 'search_name'):
raise UsageError(
"Cannot perform tiger import: required tables are missing. "
"See https://github.com/osm-search/Nominatim/issues/2463 for details."
)
sql.run_sql_file(conn, 'tiger_import_start.sql')
# Reading files and then for each file line handling

View File

@@ -55,7 +55,7 @@ def parse_version(version: str) -> NominatimVersion:
return NominatimVersion(*[int(x) for x in parts[:2] + parts[2].split('-')])
NOMINATIM_VERSION = parse_version('5.2.0-0')
NOMINATIM_VERSION = parse_version('5.2.99-2')
POSTGRESQL_REQUIRED_VERSION = (12, 0)
POSTGIS_REQUIRED_VERSION = (3, 0)

View File

@@ -9,6 +9,7 @@ Fixtures for BDD test steps
"""
import sys
import json
import re
from pathlib import Path
import psycopg
@@ -20,7 +21,8 @@ sys.path.insert(0, str(SRC_DIR / 'src'))
import pytest
from pytest_bdd.parsers import re as step_parse
from pytest_bdd import given, when, then
from pytest_bdd import given, when, then, scenario
from pytest_bdd.feature import get_features
pytest.register_assert_rewrite('utils')
@@ -373,3 +375,57 @@ def check_place_missing_lines(db_conn, table, osm_type, osm_id, osm_class):
with db_conn.cursor() as cur:
assert cur.execute(sql, params).fetchone()[0] == 0
if pytest.version_tuple >= (8, 0, 0):
def pytest_pycollect_makemodule(module_path, parent):
return BddTestCollector.from_parent(parent, path=module_path)
class BddTestCollector(pytest.Module):
def __init__(self, **kwargs):
super().__init__(**kwargs)
def collect(self):
for item in super().collect():
yield item
if hasattr(self.obj, 'PYTEST_BDD_SCENARIOS'):
for path in self.obj.PYTEST_BDD_SCENARIOS:
for feature in get_features([str(Path(self.path.parent, path).resolve())]):
yield FeatureFile.from_parent(self,
name=str(Path(path, feature.rel_filename)),
path=Path(feature.filename),
feature=feature)
# borrowed from pytest-bdd: src/pytest_bdd/scenario.py
def make_python_name(string: str) -> str:
"""Make python attribute name out of a given string."""
string = re.sub(r"\W", "", string.replace(" ", "_"))
return re.sub(r"^\d+_*", "", string).lower()
class FeatureFile(pytest.File):
class obj:
pass
def __init__(self, feature, **kwargs):
self.feature = feature
super().__init__(**kwargs)
def collect(self):
for sname, sobject in self.feature.scenarios.items():
class_name = f"L{sobject.line_number}"
test_name = "test_" + make_python_name(sname)
@scenario(self.feature.filename, sname)
def _test():
pass
tclass = type(class_name, (),
{test_name: staticmethod(_test)})
setattr(self.obj, class_name, tclass)
yield pytest.Class.from_parent(self, name=class_name, obj=tclass)

View File

@@ -42,6 +42,22 @@ Feature: Tests for finding places by osm_type and osm_id
| jsonv2 | json |
| geojson | geojson |
Scenario Outline: Lookup with entrances
When sending v1/lookup with format <format>
| osm_ids | entrances |
| W429210603 | 1 |
Then a HTTP 200 is returned
And the result is valid <outformat>
And result 0 contains in field entrances+0
| osm_id | type | lat | lon |
| 6580031131 | yes | 47.2489382 | 9.5284033 |
Examples:
| format | outformat |
| json | json |
| jsonv2 | json |
| geojson | geojson |
Scenario: Linked places return information from the linkee
When sending v1/lookup with format geocodejson
| osm_ids |

View File

@@ -167,3 +167,18 @@ Feature: v1/reverse Parameter Tests
| json | json |
| jsonv2 | json |
| xml | xml |
Scenario Outline: Reverse with entrances
When sending v1/reverse with format <format>
| lat | lon | entrances | zoom |
| 47.24942041089678 | 9.52854573737568 | 1 | 18 |
Then a HTTP 200 is returned
And the result is valid <outformat>
And the result contains array field entrances where element 0 contains
| osm_id | type | lat | lon |
| 6580031131 | yes | 47.2489382 | 9.5284033 |
Examples:
| format | outformat |
| json | json |
| jsonv2 | json |

View File

@@ -268,33 +268,6 @@ Feature: Address computation
| W93 | R34 |
| W93 | R4 |
Scenario: postcode boundaries do appear in the address of a way
Given the grid with origin DE
| 1 | | | | | 8 | | 6 | | 2 |
| |10 |11 | | | | | | | |
| |13 |12 | | | | | | | |
| 20| | | 21| | | | | | |
| | | | | | | | | | |
| | | | | | 9 | | | | |
| 4 | | | | | | | 7 | | 3 |
And the named places
| osm | class | type | admin | addr+postcode | geometry |
| R1 | boundary | administrative | 6 | 10000 | (1,2,3,4,1) |
| R34 | boundary | administrative | 8 | 11000 | (1,6,7,4,1) |
And the places
| osm | class | type | addr+postcode | geometry |
| R4 | boundary | postal_code | 11200 | (1,8,9,4,1) |
And the named places
| osm | class | type | geometry |
| W93 | highway | residential | 20,21 |
And the places
| osm | class | type | addr+postcode | geometry |
| W22 | place | postcode | 11234 | (10,11,12,13,10) |
When importing
Then place_addressline contains
| object | address |
| W93 | R4 |
Scenario: squares do not appear in the address of a street
Given the grid
| | 1 | | 2 | |

View File

@@ -16,21 +16,6 @@ Feature: Linking of places
| R13 | - |
| N256 | - |
Scenario: Postcode areas cannot be linked
Given the grid with origin US
| 1 | | 2 |
| | 9 | |
| 4 | | 3 |
And the named places
| osm | class | type | addr+postcode | extra+wikidata | geometry |
| R13 | boundary | postal_code | 12345 | Q87493 | (1,2,3,4,1) |
| N25 | place | suburb | 12345 | Q87493 | 9 |
When importing
Then placex contains
| object | linked_place_id |
| R13 | - |
| N25 | - |
Scenario: Waterways are linked when in waterway relations
Given the grid
| 1 | | | | 3 | 4 | | | | 6 |
@@ -321,11 +306,17 @@ Feature: Linking of places
Given the places
| osm | class | type | name+name | geometry |
| N9 | place | city | Popayán | 9 |
| R1 | boundary | administrative | Perímetro Urbano Popayán | (1,2,3,4,1) |
Given the places
| osm | class | type | name+name | geometry | admin |
| R1 | boundary | administrative | Perímetro Urbano Popayán | (1,2,3,4,1) | 8 |
And the relations
| id | members |
| 1 | N9:label |
When importing
Then placex contains
| object | linked_place_id |
| N9:place | R1 |
| R1:boundary | - |
Then placex contains
| object | name+_place_name | name+_place_name:es |
| R1 | Popayán | Popayán |

View File

@@ -46,23 +46,6 @@ Feature: Import into placex
| object | admin_level |
| N1 | 3 |
Scenario: postcode node without postcode is dropped
Given the places
| osm | class | type | name+ref |
| N1 | place | postcode | 12334 |
When importing
Then placex has no entry for N1
Scenario: postcode boundary without postcode is dropped
Given the 0.01 grid
| 1 | 2 |
| 3 | |
Given the places
| osm | class | type | name+ref | geometry |
| R1 | boundary | postal_code | 554476 | (1,2,3,1) |
When importing
Then placex has no entry for R1
Scenario: search and address ranks for boundaries are correctly assigned
Given the named places
| osm | class | type |

View File

@@ -121,8 +121,8 @@ Feature: Import of postcodes
| | 1 | 2 | | |
| | 4 | 3 | | |
And the named places
| osm | class | type | geometry |
| W93 | highway | pedestriant | (10,11,12,13,10) |
| osm | class | type | geometry |
| W93 | highway | pedestrian | (10,11,12,13,10) |
And the named places
| osm | class | type | addr+postcode | geometry |
| W22 | building | yes | 45023 | (1,2,3,4,1) |
@@ -134,14 +134,13 @@ Feature: Import of postcodes
Scenario: Roads get postcodes from nearby unnamed buildings without other info
Given the grid with origin US
| 10 | | | | 11 |
| | 1 | 2 | | |
| | 4 | 3 | | |
| | 1 | | | |
And the named places
| osm | class | type | geometry |
| W93 | highway | residential | 10,11 |
And the places
| osm | class | type | addr+postcode | geometry |
| W22 | place | postcode | 45023 | (1,2,3,4,1) |
And the postcodes
| osm | postcode | centroid |
| W22 | 45023 | 1 |
When importing
Then placex contains
| object | postcode |
@@ -172,26 +171,12 @@ Feature: Import of postcodes
Scenario: Postcodes are added to the postcode
Given the places
| osm | class | type | addr+postcode | addr+housenumber | geometry |
| N34 | place | house | 01982 | 111 |country:de |
| N34 | place | house | 01982 | 111 | country:de |
When importing
Then location_postcode contains exactly
| country_code | postcode | geometry!wkt |
Then location_postcodes contains exactly
| country_code | postcode | centroid!wkt |
| de | 01982 | country:de |
@skip
Scenario: search and address ranks for GB post codes correctly assigned
Given the places
| osm | class | type | postcode | geometry |
| N1 | place | postcode | E45 2CD | country:gb |
| N2 | place | postcode | E45 2 | country:gb |
| N3 | place | postcode | Y45 | country:gb |
When importing
Then location_postcode contains exactly
| postcode | country_code | rank_search | rank_address |
| E45 2CD | gb | 25 | 5 |
| E45 2 | gb | 23 | 5 |
| Y45 | gb | 21 | 5 |
Scenario: Postcodes outside all countries are not added to the postcode table
Given the places
| osm | class | type | addr+postcode | addr+housenumber | addr+place | geometry |
@@ -200,7 +185,7 @@ Feature: Import of postcodes
| osm | class | type | name | geometry |
| N1 | place | hamlet | Null Island | 0 0 |
When importing
Then location_postcode contains exactly
Then location_postcodes contains exactly
| place_id |
When geocoding "111, 01982 Null Island"
Then the result set contains

View File

@@ -154,19 +154,6 @@ Feature: Import and search of names
| object |
| R2 |
Scenario: Postcode boundaries without ref
Given the grid with origin FR
| | 2 | |
| 1 | | 3 |
Given the places
| osm | class | type | postcode | geometry |
| R1 | boundary | postal_code | 123-45 | (1,2,3,1) |
When importing
When geocoding "123-45"
Then result 0 contains
| object |
| R1 |
Scenario Outline: Housenumbers with special characters are found
Given the grid
| 1 | | | | 2 |

View File

@@ -78,8 +78,8 @@ Feature: Querying fo postcode variants
| N34 | place | house | EH4 7EA | 111 | country:gb |
| N35 | place | house | E4 7EA | 111 | country:gb |
When importing
Then location_postcode contains exactly
| country_code | postcode | geometry!wkt |
Then location_postcodes contains exactly
| country_code | postcode | centroid!wkt |
| gb | EH4 7EA | country:gb |
| gb | E4 7EA | country:gb |
When geocoding "EH4 7EA"
@@ -90,20 +90,3 @@ Feature: Querying fo postcode variants
Then result 0 contains
| type | display_name |
| postcode | E4 7EA, United Kingdom |
Scenario: Postcode areas are preferred over postcode points
Given the grid with origin DE
| 1 | 2 |
| 4 | 3 |
Given the places
| osm | class | type | postcode | geometry |
| R23 | boundary | postal_code | 12345 | (1,2,3,4,1) |
When importing
Then location_postcode contains exactly
| country_code | postcode |
| de | 12345 |
When geocoding "12345, de"
Then result 0 contains
| object |
| R23 |

View File

@@ -9,13 +9,32 @@ Feature: Reverse searches
And the places
| osm | class | type | geometry |
| W1 | aeroway | terminal | (1,2,3,4,1) |
| N1 | amenity | restaurant | 9 |
| N9 | amenity | restaurant | 9 |
When importing
And reverse geocoding 1.0001,1.0001
Then the result contains
| object |
| N1 |
| N9 |
When reverse geocoding 1.0003,1.0001
Then the result contains
| object |
| W1 |
Scenario: Find closest housenumber for street matches
Given the 0.0001 grid with origin 1,1
| | 1 | | |
| | | 2 | |
| 10 | | | 11 |
And the places
| osm | class | type | name | geometry |
| W1 | highway | service | Goose Drive | 10,11 |
| N2 | tourism | art_work | Beauty | 2 |
And the places
| osm | class | type | housenr | geometry |
| N1 | place | house | 23 | 1 |
When importing
When reverse geocoding 1.0002,1.0002
Then the result contains
| object |
| N1 |

View File

@@ -1,20 +0,0 @@
Feature: Update of names in place objects
Test all naming related issues in updates
Scenario: Delete postcode from postcode boundaries without ref
Given the grid with origin DE
| 1 | 2 |
| 4 | 3 |
Given the places
| osm | class | type | postcode | geometry |
| R1 | boundary | postal_code | 123-45 | (1,2,3,4,1) |
When importing
And geocoding "123-45"
Then result 0 contains
| object |
| R1 |
When updating places
| osm | class | type | geometry |
| R1 | boundary | postal_code | (1,2,3,4,1) |
Then placex has no entry for R1

View File

@@ -2,20 +2,22 @@ Feature: Update of postcode
Tests for updating of data related to postcodes
Scenario: Updating postcode in postcode boundaries without ref
Given the grid
| 1 | 2 |
| 4 | 3 |
Given the places
| osm | class | type | postcode | geometry |
| R1 | boundary | postal_code | 12345 | (1,2,3,4,1) |
Given the grid with origin FR
| 1 | | 2 |
| | 9 | |
| 4 | | 3 |
Given the postcodes
| osm | postcode | centroid | geometry |
| R1 | 12345 | 9 | (1,2,3,4,1) |
When importing
And geocoding "12345"
Then result 0 contains
| object |
| R1 |
When updating places
| osm | class | type | postcode | geometry |
| R1 | boundary | postal_code | 54321 | (1,2,3,4,1) |
Given the postcodes
| osm | postcode | centroid | geometry |
| R1 | 54321 | 9 | (1,2,3,4,1) |
When refreshing postcodes
And geocoding "12345"
Then exactly 0 results are returned
When geocoding "54321"
@@ -28,17 +30,21 @@ Feature: Update of postcode
| osm | class | type | addr+postcode | addr+housenumber | geometry |
| N34 | place | house | 01982 | 111 | country:de |
When importing
Then location_postcode contains exactly
| country_code | postcode | geometry!wkt |
Then location_postcodes contains exactly
| country_code | postcode | centroid!wkt |
| de | 01982 | country:de |
Given the postcodes
| osm | postcode | centroid |
| N66 | 99201 | country:fr |
When updating places
| osm | class | type | addr+postcode | addr+housenumber | geometry |
| N35 | place | house | 4567 | 5 | country:ch |
And updating postcodes
Then location_postcode contains exactly
| country_code | postcode | geometry!wkt |
And refreshing postcodes
Then location_postcodes contains exactly
| country_code | postcode | centroid!wkt |
| de | 01982 | country:de |
| ch | 4567 | country:ch |
| fr | 99201 | country:fr |
Scenario: When the last postcode is deleted, it is deleted from postcode
Given the places
@@ -47,9 +53,9 @@ Feature: Update of postcode
| N35 | place | house | 4567 | 5 | country:ch |
When importing
And marking for delete N34
And updating postcodes
Then location_postcode contains exactly
| country_code | postcode | geometry!wkt |
And refreshing postcodes
Then location_postcodes contains exactly
| country_code | postcode | centroid!wkt |
| ch | 4567 | country:ch |
Scenario: A postcode is not deleted from postcode when it exist in another country
@@ -59,64 +65,24 @@ Feature: Update of postcode
| N35 | place | house | 01982 | 5 | country:fr |
When importing
And marking for delete N34
And updating postcodes
Then location_postcode contains exactly
| country_code | postcode | geometry!wkt|
And refreshing postcodes
Then location_postcodes contains exactly
| country_code | postcode | centroid!wkt|
| fr | 01982 | country:fr |
Scenario: Updating a postcode is reflected in postcode table
Given the places
| osm | class | type | addr+postcode | geometry |
| osm | class | type | addr+postcode | geometry |
| N34 | place | postcode | 01982 | country:de |
When importing
And updating places
| osm | class | type | addr+postcode | geometry |
| N34 | place | postcode | 20453 | country:de |
And updating postcodes
Then location_postcode contains exactly
| country_code | postcode | geometry!wkt |
And refreshing postcodes
Then location_postcodes contains exactly
| country_code | postcode | centroid!wkt |
| de | 20453 | country:de |
Scenario: When changing from a postcode type, the entry appears in placex
When importing
And updating places
| osm | class | type | addr+postcode | geometry |
| N34 | place | postcode | 01982 | country:de |
Then placex has no entry for N34
When updating places
| osm | class | type | addr+postcode | housenr | geometry |
| N34 | place | house | 20453 | 1 | country:de |
Then placex contains
| object | addr+housenumber | geometry!wkt |
| N34 | 1 | country:de |
And place contains exactly
| osm_type | osm_id | class | type |
| N | 34 | place | house |
When updating postcodes
Then location_postcode contains exactly
| country_code | postcode | geometry!wkt |
| de | 20453 | country:de |
Scenario: When changing to a postcode type, the entry disappears from placex
When importing
And updating places
| osm | class | type | addr+postcode | housenr | geometry |
| N34 | place | house | 20453 | 1 | country:de |
Then placex contains
| object | addr+housenumber | geometry!wkt |
| N34 | 1 | country:de|
When updating places
| osm | class | type | addr+postcode | geometry |
| N34 | place | postcode | 01982 | country:de |
Then placex has no entry for N34
And place contains exactly
| osm_type | osm_id | class | type |
| N | 34 | place | postcode |
When updating postcodes
Then location_postcode contains exactly
| country_code | postcode | geometry!wkt |
| de | 01982 | country:de |
Scenario: When a parent is deleted, the postcode gets a new parent
Given the grid with origin DE
| 1 | | 3 | 4 |
@@ -126,14 +92,59 @@ Feature: Update of postcode
| osm | class | type | name | admin | geometry |
| R1 | boundary | administrative | Big | 6 | (1,4,6,2,1) |
| R2 | boundary | administrative | Small | 6 | (1,3,5,2,1) |
Given the places
| osm | class | type | addr+postcode | geometry |
| N9 | place | postcode | 12345 | 9 |
Given the postcodes
| osm | postcode | centroid |
| N9 | 12345 | 9 |
When importing
Then location_postcode contains exactly
| postcode | geometry!wkt | parent_place_id |
Then location_postcodes contains exactly
| postcode | centroid!wkt | parent_place_id |
| 12345 | 9 | R2 |
When marking for delete R2
Then location_postcode contains exactly
| country_code | postcode | geometry!wkt | parent_place_id |
Then location_postcodes contains exactly
| country_code | postcode | centroid!wkt | parent_place_id |
| de | 12345 | 9 | R1 |
Scenario: When a postcode area appears, postcode points are shadowed
Given the grid with origin DE
| 1 | | 3 | |
| | 9 | | 8 |
| 2 | | 5 | |
Given the postcodes
| osm | postcode | centroid |
| N92 | 44321 | 9 |
| N4 | 00245 | 8 |
When importing
Then location_postcodes contains exactly
| country_code | postcode | osm_id | centroid!wkt |
| de | 44321 | - | 9 |
| de | 00245 | - | 8 |
Given the postcodes
| osm | postcode | centroid | geometry |
| R45 | 00245 | 9 | (1,3,5,2,1) |
When refreshing postcodes
Then location_postcodes contains exactly
| country_code | postcode | osm_id | centroid!wkt |
| de | 00245 | 45 | 9 |
Scenario: When a postcode area disappears, postcode points are unshadowed
Given the grid with origin DE
| 1 | | 3 | |
| | 9 | | 8 |
| 2 | | 5 | |
Given the postcodes
| osm | postcode | centroid | geometry |
| R45 | 00245 | 9 | (1,3,5,2,1) |
Given the postcodes
| osm | postcode | centroid |
| N92 | 44321 | 9 |
| N4 | 00245 | 8 |
When importing
Then location_postcodes contains exactly
| country_code | postcode | osm_id | centroid!wkt |
| de | 00245 | 45 | 9 |
When marking for delete R45
And refreshing postcodes
Then location_postcodes contains exactly
| country_code | postcode | osm_id | centroid!wkt |
| de | 44321 | - | 9 |
| de | 00245 | - | 8 |

View File

@@ -92,12 +92,16 @@ Feature: Tag evaluation
n6001 Tshop=bank,addr:postcode=12345
n6002 Tshop=bank,tiger:zip_left=34343
n6003 Tshop=bank,is_in:postcode=9009
n6004 Taddr:postcode=54322
"""
Then place contains exactly
| object | class | address!dict |
| N6001 | shop | 'postcode': '12345' |
| N6002 | shop | 'postcode': '34343' |
| N6003 | shop | - |
And place_postcode contains exactly
| object | postcode | geometry |
| N6004 | 54322 | - |
Scenario: Postcode areas
@@ -107,11 +111,15 @@ Feature: Tag evaluation
n2 x12.36853 y51.42362
n3 x12.63666 y51.42362
n4 x12.63666 y51.50618
w1 Tboundary=postal_code,ref=3456 Nn1,n2,n3,n4,n1
w1 Nn1,n2,n3,n4,n1
w2 Tboundary=postal_code,postal_code=443 Nn1,n2,n3,n4,n1
r1 Ttype=boundary,boundary=postal_code,postal_code=3456 Mw1@
"""
Then place contains exactly
| object | class | type | name!dict |
| W1 | boundary | postal_code | 'ref': '3456' |
| object |
And place_postcode contains exactly
| object | postcode | geometry!wkt |
| R1 | 3456 | (12.36853 51.50618, 12.36853 51.42362, 12.63666 51.42362, 12.63666 51.50618, 12.36853 51.50618) |
Scenario: Main with extra
When loading osm data
@@ -192,7 +200,9 @@ Feature: Tag evaluation
| N12001 | tourism | hotel |
| N12003 | building | shed |
| N12004 | building | yes |
| N12005 | place | postcode |
And place_postcode contains exactly
| object | postcode | geometry |
| N12005 | 12345 | - |
Scenario: Address interpolations

View File

@@ -2,7 +2,6 @@ Feature: Update of postcode only objects
Tests that changes to objects containing only a postcode are
propagated correctly.
Scenario: Adding a postcode-only node
When loading osm data
"""
@@ -15,11 +14,10 @@ Feature: Update of postcode only objects
"""
n34 Tpostcode=4456
"""
Then place contains exactly
| object | class | type |
| N34 | place | postcode |
When indexing
Then placex contains exactly
Then place_postcode contains exactly
| object | postcode |
| N34 | 4456 |
And place contains exactly
| object |
@@ -28,9 +26,11 @@ Feature: Update of postcode only objects
"""
n34 Tpostcode=4456
"""
Then place contains exactly
| object | class | type |
| N34 | place | postcode |
Then place_postcode contains exactly
| object | postcode |
| N34 | 4456 |
And place contains exactly
| object |
When updating osm data
"""
@@ -38,8 +38,7 @@ Feature: Update of postcode only objects
"""
Then place contains exactly
| object |
When indexing
Then placex contains exactly
And place_postcode contains exactly
| object |
@@ -57,8 +56,10 @@ Feature: Update of postcode only objects
n34 Tpostcode=4456
"""
Then place contains exactly
| object | class | type |
| N34 | place | postcode |
| object |
And place_postcode contains exactly
| object | postcode |
| N34 | 4456 |
When indexing
Then placex contains exactly
| object |
@@ -74,9 +75,9 @@ Feature: Update of postcode only objects
"""
n34 Tpostcode=4456
"""
Then place contains exactly
| object | class | type |
| N34 | place | postcode |
Then place_postcode contains exactly
| object | postcode |
| N34 | 4456 |
When updating osm data
"""
@@ -85,6 +86,8 @@ Feature: Update of postcode only objects
Then place contains exactly
| object | class | type |
| N34 | <class> | <type> |
And place_postcode contains exactly
| object |
When indexing
Then placex contains exactly
| object | class | type |
@@ -96,7 +99,7 @@ Feature: Update of postcode only objects
| place | hamlet |
Scenario: Converting na interpolation into a postcode-only node
Scenario: Converting an interpolation into a postcode-only node
Given the grid
| 1 | 2 |
When loading osm data
@@ -119,14 +122,12 @@ Feature: Update of postcode only objects
| object | class | type |
| N1 | place | house |
| N2 | place | house |
| W34 | place | postcode |
Then place_postcode contains exactly
| object | postcode |
| W34 | 4456 |
When indexing
Then location_property_osmline contains exactly
| osm_id |
And placex contains exactly
| object | class | type |
| N1 | place | house |
| N2 | place | house |
Scenario: Converting a postcode-only node into an interpolation
@@ -144,7 +145,9 @@ Feature: Update of postcode only objects
| N1 | place | house |
| N2 | place | house |
| W33 | highway | residential |
| W34 | place | postcode |
And place_postcode contains exactly
| object | postcode |
| W34 | 4456 |
When updating osm data
"""
@@ -156,6 +159,8 @@ Feature: Update of postcode only objects
| N2 | place | house |
| W33 | highway | residential |
| W34 | place | houses |
And place_postcode contains exactly
| object |
When indexing
Then location_property_osmline contains exactly
| osm_id | startnumber | endnumber |

View File

@@ -15,7 +15,7 @@ import xml.etree.ElementTree as ET
import pytest
from pytest_bdd.parsers import re as step_parse
from pytest_bdd import scenarios, when, given, then
from pytest_bdd import when, given, then
from nominatim_db import cli
from nominatim_db.config import Configuration
@@ -150,4 +150,8 @@ def parse_api_json_response(api_response, fmt, num):
return result
scenarios('features/api')
if pytest.version_tuple >= (8, 0, 0):
PYTEST_BDD_SCENARIOS = ['features/api']
else:
from pytest_bdd import scenarios
scenarios('features/api')

View File

@@ -11,15 +11,17 @@ These tests check the Nominatim import chain after the osm2pgsql import.
"""
import asyncio
import re
from collections import defaultdict
import psycopg
import pytest
from pytest_bdd import scenarios, when, then, given
from pytest_bdd import when, then, given
from pytest_bdd.parsers import re as step_parse
from utils.place_inserter import PlaceColumn
from utils.checks import check_table_content
from utils.geometry_alias import ALIASES
from nominatim_db.config import Configuration
from nominatim_db import cli
@@ -97,6 +99,41 @@ def import_place_entrances(db_conn, datatable, node_grid):
data.columns.get('extratags')))
@given(step_parse('the postcodes'), target_fixture=None)
def import_place_postcode(db_conn, datatable, node_grid):
""" Insert todo rows into the place_postcode table. If a row for the
requested object already exists it is overwritten.
"""
with db_conn.cursor() as cur:
for row in datatable[1:]:
data = defaultdict(lambda: None)
data.update((k, v) for k, v in zip(datatable[0], row))
if data['centroid'].startswith('country:'):
ccode = data['centroid'][8:].upper()
data['centroid'] = 'srid=4326;POINT({} {})'.format(*ALIASES[ccode])
else:
data['centroid'] = f"srid=4326;{node_grid.geometry_to_wkt(data['centroid'])}"
data['osm_type'] = data['osm'][0]
data['osm_id'] = data['osm'][1:]
if 'geometry' in data:
geom = f"'srid=4326;{node_grid.geometry_to_wkt(data['geometry'])}'::geometry"
else:
geom = 'null'
cur.execute(""" DELETE FROM place_postcode
WHERE osm_type = %(osm_type)s and osm_id = %(osm_id)s""",
data)
cur.execute(f"""INSERT INTO place_postcode
(osm_type, osm_id, country_code, postcode, centroid, geometry)
VALUES (%(osm_type)s, %(osm_id)s,
%(country)s, %(postcode)s,
%(centroid)s, {geom})""", data)
db_conn.commit()
@given('the ways', target_fixture=None)
def import_ways(db_conn, datatable):
""" Import raw ways into the osm2pgsql way middle table.
@@ -168,7 +205,7 @@ def do_update(db_conn, update_config, node_grid, datatable):
@when('updating entrances', target_fixture=None)
def update_place_entrances(db_conn, datatable, node_grid):
""" Insert todo rows into the place_entrance table.
""" Update rows in the place_entrance table.
"""
with db_conn.cursor() as cur:
for row in datatable[1:]:
@@ -181,9 +218,10 @@ def update_place_entrances(db_conn, datatable, node_grid):
VALUES (%s, %s, %s, {})""".format(data.get_wkt()),
(data.columns['osm_id'], data.columns['type'],
data.columns.get('extratags')))
db_conn.commit()
@when('updating postcodes')
@when('refreshing postcodes')
def do_postcode_update(update_config):
""" Recompute the postcode centroids.
"""
@@ -203,6 +241,8 @@ def do_delete_place(db_conn, update_config, node_grid, otype, oid):
if otype == 'N':
cur.execute('DELETE FROM place_entrance WHERE osm_id = %s',
(oid, ))
cur.execute('DELETE FROM place_postcode WHERE osm_type = %s and osm_id = %s',
(otype, oid))
db_conn.commit()
cli.nominatim(['index', '-q'], update_config.environ)
@@ -276,4 +316,8 @@ def then_check_interpolation_table_negative(db_conn, oid):
assert cur.fetchone()[0] == 0
scenarios('features/db')
if pytest.version_tuple >= (8, 0, 0):
PYTEST_BDD_SCENARIOS = ['features/db']
else:
from pytest_bdd import scenarios
scenarios('features/db')

View File

@@ -11,7 +11,7 @@ import asyncio
import random
import pytest
from pytest_bdd import scenarios, when, then, given
from pytest_bdd import when, then, given
from pytest_bdd.parsers import re as step_parse
from nominatim_db import cli
@@ -43,7 +43,7 @@ def opl_writer(tmp_path, node_grid):
def _write(data):
fname = tmp_path / f"test_osm_{nr[0]}.opl"
nr[0] += 1
with fname.open('wt') as fd:
with fname.open('wt', encoding='utf-8') as fd:
for line in data.split('\n'):
if line.startswith('n') and ' x' not in line:
coord = node_grid.get(line[1:].split(' ')[0]) \
@@ -59,7 +59,7 @@ def opl_writer(tmp_path, node_grid):
@given('the lua style file', target_fixture='osm2pgsql_options')
def set_lua_style_file(osm2pgsql_options, docstring, tmp_path):
style = tmp_path / 'custom.lua'
style.write_text(docstring)
style.write_text(docstring, encoding='utf-8')
osm2pgsql_options['osm2pgsql_style'] = str(style)
return osm2pgsql_options
@@ -106,4 +106,8 @@ def check_place_content(db_conn, datatable, node_grid, table, exact):
check_table_content(db_conn, table, datatable, grid=node_grid, exact=bool(exact))
scenarios('features/osm2pgsql')
if pytest.version_tuple >= (8, 0, 0):
PYTEST_BDD_SCENARIOS = ['features/osm2pgsql']
else:
from pytest_bdd import scenarios
scenarios('features/osm2pgsql')

View File

@@ -2,7 +2,7 @@
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2025 by the Nominatim developer community.
# Copyright (C) 2026 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Various helper classes for running Nominatim commands.
@@ -54,15 +54,14 @@ class APIRunner:
def create_engine_starlette(self, environ):
import nominatim_api.server.starlette.server
from asgi_lifespan import LifespanManager
import httpx
from starlette.testclient import TestClient
async def _request(endpoint, params, http_headers):
app = nominatim_api.server.starlette.server.get_application(None, environ)
async with LifespanManager(app):
async with httpx.AsyncClient(app=app, base_url="http://nominatim.test") as client:
response = await client.get("/" + endpoint, params=params,
headers=http_headers)
client = TestClient(app, base_url="http://nominatim.test")
response = client.get("/" + endpoint, params=params, headers=http_headers)
return APIResponse(endpoint, response.status_code,
response.text, response.headers)

View File

@@ -7,6 +7,7 @@
"""
Helper functions to compare expected values.
"""
import ast
import collections.abc
import json
import re
@@ -58,7 +59,8 @@ COMPARISON_FUNCS = {
None: lambda val, exp: str(val) == exp,
'i': lambda val, exp: str(val).lower() == exp.lower(),
'fm': lambda val, exp: re.fullmatch(exp, val) is not None,
'dict': lambda val, exp: val is None if exp == '-' else (val == eval('{' + exp + '}')),
'dict': lambda val, exp: (val is None if exp == '-'
else (val == ast.literal_eval('{' + exp + '}'))),
'in_box': within_box
}

View File

@@ -8,6 +8,7 @@
A grid describing node placement in an area.
Useful for visually describing geometries.
"""
import re
class Grid:
@@ -44,3 +45,28 @@ class Grid:
def parse_line(self, value):
return [self.parse_point(p) for p in value.split(',')]
def geometry_to_wkt(self, value):
""" Parses the given value into a geometry and returns the WKT.
The value can either be a WKT already or a geometry shortcut
with coordinates or grid points.
"""
if re.fullmatch(r'([A-Z]+)\((.*)\)', value) is not None:
return value # already a WKT
# points
if ',' not in value:
x, y = self.parse_point(value)
return f"POINT({x} {y})"
# linestring
if '(' not in value:
coords = ','.join(' '.join(f"{p:.7f}" for p in pt)
for pt in self.parse_line(value))
return f"LINESTRING({coords})"
# simple polygons
coords = ','.join(' '.join(f"{p:.7f}" for p in pt)
for pt in self.parse_line(value[1:-1]))
return f"POLYGON(({coords}))"

View File

@@ -2,15 +2,17 @@
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2025 by the Nominatim developer community.
# Copyright (C) 2026 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Helper classes for filling the place table.
"""
import ast
import random
import string
from .geometry_alias import ALIASES
from .grid import Grid
class PlaceColumn:
@@ -19,7 +21,7 @@ class PlaceColumn:
"""
def __init__(self, grid=None):
self.columns = {'admin_level': 15}
self.grid = grid
self.grid = grid or Grid()
self.geometry = None
def add_row(self, headings, row, force_name):
@@ -34,7 +36,8 @@ class PlaceColumn:
self._add_hstore(
'name',
'name',
''.join(random.choices(string.printable, k=random.randrange(30))),
''.join(random.choices(string.ascii_uppercase)
+ random.choices(string.printable, k=random.randrange(30))),
)
return self
@@ -49,7 +52,7 @@ class PlaceColumn:
elif key.startswith('addr+'):
self._add_hstore('address', key[5:], value)
elif key in ('name', 'address', 'extratags'):
self.columns[key] = eval('{' + value + '}')
self.columns[key] = ast.literal_eval('{' + value + '}')
else:
assert key in ('class', 'type'), "Unknown column '{}'.".format(key)
self.columns[key] = None if value == '' else value
@@ -91,26 +94,9 @@ class PlaceColumn:
if value.startswith('country:'):
ccode = value[8:].upper()
self.geometry = "ST_SetSRID(ST_Point({}, {}), 4326)".format(*ALIASES[ccode])
elif ',' not in value:
if self.grid:
pt = self.grid.parse_point(value)
else:
pt = value.split(' ')
self.geometry = f"ST_SetSRID(ST_Point({pt[0]}, {pt[1]}), 4326)"
elif '(' not in value:
if self.grid:
coords = ','.join(' '.join(f"{p:.7f}" for p in pt)
for pt in self.grid.parse_line(value))
else:
coords = value
self.geometry = f"'srid=4326;LINESTRING({coords})'::geometry"
else:
if self.grid:
coords = ','.join(' '.join(f"{p:.7f}" for p in pt)
for pt in self.grid.parse_line(value[1:-1]))
else:
coords = value[1:-1]
self.geometry = f"'srid=4326;POLYGON(({coords}))'::geometry"
wkt = self.grid.geometry_to_wkt(value)
self.geometry = f"'srid=4326;{wkt}'::geometry"
def _add_hstore(self, column, key, value):
if column in self.columns:
@@ -120,7 +106,7 @@ class PlaceColumn:
def get_wkt(self):
if self.columns['osm_type'] == 'N' and self.geometry is None:
pt = self.grid.get(str(self.columns['osm_id'])) if self.grid else None
pt = self.grid.get(str(self.columns['osm_id']))
if pt is None:
pt = (random.uniform(-180, 180), random.uniform(-90, 90))

Some files were not shown because too many files have changed in this diff Show More