diff --git a/__init__.py b/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/osm2pgsql-query-builder/__init__.py b/osm2pgsql-query-builder/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/osm2pgsql-query-builder/osm2pgsql_query_builder/__init__.py b/osm2pgsql-query-builder/osm2pgsql_query_builder/__init__.py index 7f636b29..f68f3186 100644 --- a/osm2pgsql-query-builder/osm2pgsql_query_builder/__init__.py +++ b/osm2pgsql-query-builder/osm2pgsql_query_builder/__init__.py @@ -1,3 +1,11 @@ +from .postpass_module import ( + POSTPASS_TABLE_MAP, + BboxFilter, + PostPassQueryParams, + build_postpass_bbox_filter, + build_postpass_query, + wrap_postpass_geojson, +) from .builder import ( build_column_select, build_geom_filter, @@ -53,4 +61,10 @@ "SnapshotQueryParams", "TagsFilter", "to_camel", -] + "POSTPASS_TABLE_MAP", + "BboxFilter", + "PostPassQueryParams", + "build_postpass_bbox_filter", + "build_postpass_query", + "wrap_postpass_geojson", +] \ No newline at end of file diff --git a/osm2pgsql-query-builder/osm2pgsql_query_builder/postpass_module.py b/osm2pgsql-query-builder/osm2pgsql_query_builder/postpass_module.py new file mode 100644 index 00000000..beb74878 --- /dev/null +++ b/osm2pgsql-query-builder/osm2pgsql_query_builder/postpass_module.py @@ -0,0 +1,439 @@ +# Copyright (C) 2026 Humanitarian OpenStreetmap Team + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""PostPass query builder. + +Generates SQL for the Geofabrik PostPass API +(https://postpass.geofabrik.de/api/0.2/interpreter). + +PostPass and osm2pgsql use the same tag column format (``tags ->> 'key'``) +so all filter helpers are reused from this package. The only +PostPass-specific pieces are: + +* Different table names: ``postpass_point``, ``postpass_line``, + ``postpass_polygon`` +* Bbox geometry expressed with the ``&&`` operator (fast index scan) + rather than ``ST_intersects`` against a full polygon +* A ``json_build_object`` GeoJSON wrapper suitable for the PostPass + HTTP response format + +Example usage:: + + from osm2pgsql_query_builder.postpass import ( + PostPassQueryParams, + build_postpass_query, + wrap_postpass_geojson, + ) + + params = PostPassQueryParams( + bbox={"minx": 36.7, "miny": -1.3, "maxx": 36.9, "maxy": -1.2}, + filters={ + "tags": {"all_geometry": {"join_or": {"amenity": ["school", "hospital"]}}} + }, + geometry_type=["point"], + ) + + sql = wrap_postpass_geojson(build_postpass_query(params)) +""" + +from __future__ import annotations + +import logging +from typing import List, Optional, Union + +from geojson_pydantic import Feature, FeatureCollection, MultiPolygon, Polygon +from pydantic import Field, field_validator, model_validator + +from .builder import build_column_select, build_tag_filter, parse_filters +from .models import BaseModel, Filters + +log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# PostPass table mapping +# --------------------------------------------------------------------------- + +#: Maps geometry-type name → PostPass table name. +POSTPASS_TABLE_MAP: dict[str, str] = { + "point": "postpass_point", + "line": "postpass_line", + "polygon": "postpass_polygon", +} + +#: Default geometry types queried when none is specified. +ALL_POSTPASS_GEOMETRY_TYPES: list[str] = ["point", "line", "polygon"] + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + + +class BboxFilter(BaseModel): + """Axis-aligned bounding box in WGS-84 (EPSG:4326). + + All four fields are required; coordinates must be in decimal degrees. + + Example:: + + BboxFilter(minx=36.7, miny=-1.3, maxx=36.9, maxy=-1.2) + + Or from a dict:: + + BboxFilter(**{"minx": 36.7, "miny": -1.3, "maxx": 36.9, "maxy": -1.2}) + """ + + minx: float = Field(..., description="Western longitude boundary") + miny: float = Field(..., description="Southern latitude boundary") + maxx: float = Field(..., description="Eastern longitude boundary") + maxy: float = Field(..., description="Northern latitude boundary") + + @model_validator(mode="after") + def validate_bounds(self) -> "BboxFilter": + if self.minx >= self.maxx: + raise ValueError( + f"minx ({self.minx}) must be less than maxx ({self.maxx})" + ) + if self.miny >= self.maxy: + raise ValueError( + f"miny ({self.miny}) must be less than maxy ({self.maxy})" + ) + return self + + +class PostPassQueryParams(BaseModel): + """Parameters for PostPass SQL query generation. + + Accepts **either** a full GeoJSON geometry (bbox is derived from its + envelope) **or** an explicit :class:`BboxFilter`. At least one must + be supplied. + + ``filters`` uses the same structure as :class:`~.models.SnapshotQueryParams` + so existing filter configs can be reused without modification. + + ``geometry_type`` controls which PostPass tables are queried. Defaults + to all three (``["point", "line", "polygon"]``). + + Example — explicit bbox:: + + PostPassQueryParams( + bbox={"minx": 36.7, "miny": -1.3, "maxx": 36.9, "maxy": -1.2}, + filters={ + "tags": { + "all_geometry": {"join_or": {"amenity": ["school", "hospital"]}} + } + }, + geometry_type=["point"], + ) + + Example — derive bbox from a GeoJSON polygon:: + + PostPassQueryParams( + geometry={ + "type": "Polygon", + "coordinates": [[[36.7, -1.3], [36.9, -1.3], + [36.9, -1.2], [36.7, -1.2], [36.7, -1.3]]], + }, + filters={"tags": {"all_geometry": {"join_or": {"building": []}}}}, + ) + """ + + geometry: Optional[ + Union[Polygon, MultiPolygon, Feature, FeatureCollection] + ] = Field( + default=None, + description=( + "GeoJSON geometry (Polygon/MultiPolygon/Feature/FeatureCollection). " + "The bbox is automatically extracted from the envelope. " + "Either this or bbox must be provided." + ), + ) + bbox: Optional[BboxFilter] = Field( + default=None, + description=( + "Explicit bounding box in WGS-84. " + "Either this or geometry must be provided." + ), + ) + filters: Optional[Filters] = Field( + default=None, + description=( + "Tag and attribute filters. " + "Uses the same structure as SnapshotQueryParams." + ), + json_schema_extra={ + "example": { + "tags": {"all_geometry": {"join_or": {"amenity": ["school"]}}}, + "attributes": {"all_geometry": ["name"]}, + } + }, + ) + geometry_type: Optional[List[str]] = Field( + default=None, + description=( + "Which PostPass tables to query: 'point', 'line', 'polygon'. " + "Defaults to all three." + ), + json_schema_extra={"example": ["point", "polygon"]}, + ) + + # ------------------------------------------------------------------ + # Validators + # ------------------------------------------------------------------ + + @field_validator("geometry") + @classmethod + def _extract_polygon( + cls, + value: Optional[Union[Polygon, MultiPolygon, Feature, FeatureCollection]], + ) -> Optional[Union[Polygon, MultiPolygon]]: + """Unwrap Feature / FeatureCollection, enforce Polygon/MultiPolygon.""" + if value is None: + return None + if value.type == "Feature": + if value.geometry.type not in ("Polygon", "MultiPolygon"): + raise ValueError( + f"Feature geometry must be Polygon or MultiPolygon, " + f"got {value.geometry.type}" + ) + return value.geometry + if value.type == "FeatureCollection": + if len(value.features) != 1: + raise ValueError( + "FeatureCollection must contain exactly one feature" + ) + geom = value.features[0].geometry + if geom.type not in ("Polygon", "MultiPolygon"): + raise ValueError( + f"Feature geometry must be Polygon or MultiPolygon, " + f"got {geom.type}" + ) + return geom + return value + + @field_validator("geometry_type") + @classmethod + def _validate_geometry_types(cls, value: Optional[List[str]]) -> Optional[List[str]]: + if value is None: + return value + invalid = [g for g in value if g not in POSTPASS_TABLE_MAP] + if invalid: + raise ValueError( + f"Invalid geometry_type values: {invalid}. " + f"Must be one of: {list(POSTPASS_TABLE_MAP)}" + ) + return list(set(value)) # deduplicate + + @model_validator(mode="after") + def _require_geometry_or_bbox(self) -> "PostPassQueryParams": + if self.geometry is None and self.bbox is None: + raise ValueError("Either 'geometry' or 'bbox' must be provided") + return self + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def get_bbox(self) -> BboxFilter: + """Return the effective bbox. + + If ``bbox`` was supplied explicitly, return it directly. + Otherwise compute the envelope of the ``geometry``. + """ + if self.bbox is not None: + return self.bbox + + # Derive bbox from geometry envelope + def _flatten(coords): + if not coords: + return [] + if isinstance(coords[0], (int, float)): + return [coords] + return [pt for ring in coords for pt in _flatten(ring)] + + raw = self.geometry.model_dump() + coords = raw.get("coordinates", []) + points = _flatten(coords) + if not points: + raise ValueError("Cannot extract bbox: geometry has no coordinates") + + xs = [p[0] for p in points] + ys = [p[1] for p in points] + return BboxFilter( + minx=min(xs), miny=min(ys), maxx=max(xs), maxy=max(ys) + ) + + def get_tables(self) -> list[str]: + """Return the PostPass table names to query.""" + types = self.geometry_type or ALL_POSTPASS_GEOMETRY_TYPES + return [POSTPASS_TABLE_MAP[t] for t in types] + + +# --------------------------------------------------------------------------- +# Query builders +# --------------------------------------------------------------------------- + + +def build_postpass_bbox_filter(bbox: BboxFilter) -> str: + """Build a PostGIS bbox filter using the ``&&`` operator. + + The ``&&`` operator uses the spatial index for a fast bounding-box + pre-filter, equivalent to ``ST_intersects(geom, ST_MakeEnvelope(...))``. + + Args: + bbox: A :class:`BboxFilter` instance. + + Returns: + A SQL fragment suitable for a WHERE clause, e.g.:: + + geom && ST_SetSRID(ST_MakeBox2D(ST_MakePoint(36.7, -1.3), + ST_MakePoint(36.9, -1.2)), 4326) + """ + return ( + f"geom && ST_SetSRID(" + f"ST_MakeBox2D(" + f"ST_MakePoint({bbox.minx}, {bbox.miny}), " + f"ST_MakePoint({bbox.maxx}, {bbox.maxy})" + f"), 4326)" + ) + + +def build_postpass_query( + params: PostPassQueryParams, + attributes: Optional[List[str]] = None, +) -> str: + """Build SQL SELECT statements for PostPass tables. + + Generates one sub-query per geometry type (or per table in + ``params.geometry_type``) and joins them with ``UNION ALL``. + + Args: + params: A :class:`PostPassQueryParams` instance. + attributes: Optional list of OSM tag keys to project as named + columns (e.g. ``["name", "amenity"]``). When omitted, + only ``osm_id``, ``tags``, and ``geom`` are returned. + + Returns: + A SQL string that can be sent directly to the PostPass API. + + Example output:: + + SELECT osm_id, tags, geom + FROM postpass_point + WHERE (tags ->> 'amenity' IN ('school', 'hospital')) + AND geom && ST_SetSRID(ST_MakeBox2D(...), 4326) + UNION ALL + SELECT osm_id, tags, geom + FROM postpass_line + WHERE ... + """ + bbox = params.get_bbox() + tables = params.get_tables() + + # --- SELECT clause --- + if attributes: + from .builder import sanitize_column_name + col_parts = ["osm_id"] + for attr in attributes: + col_parts.append( + f"tags ->> '{attr.strip()}' AS {sanitize_column_name(attr.strip())}" + ) + col_parts.append("geom") + select_sql = ", ".join(col_parts) + else: + select_sql = "osm_id, tags, geom" + + # --- WHERE clauses --- + bbox_clause = build_postpass_bbox_filter(bbox) + + # Parse tag filters (reuses the shared parse_filters / build_tag_filter) + tag_where: Optional[str] = None + if params.filters: + filters_dict = params.filters.model_dump() + ( + _tags, + _attributes, + _point_attr, + _line_attr, + _poly_attr, + _master_attr, + point_tag_filter, + line_tag_filter, + poly_tag_filter, + master_tag_filter, + ) = parse_filters(filters_dict) + + # For PostPass we apply master_tag_filter (all_geometry) to every table, + # and geometry-specific filters where they match. + # Build a simple combined filter for all tables (PostPass has no + # per-geometry per-table differentiation in its schema). + all_filters = [f for f in [ + master_tag_filter, + point_tag_filter, + line_tag_filter, + poly_tag_filter, + ] if f] + if all_filters: + built = [build_tag_filter(f) for f in all_filters if f] + built = [b for b in built if b] + tag_where = " AND ".join(f"({b})" for b in built) if built else None + + # --- Assemble per-table sub-queries --- + sub_queries: list[str] = [] + for table in tables: + where_parts = [] + if tag_where: + where_parts.append(tag_where) + where_parts.append(bbox_clause) + + where_sql = " AND ".join(where_parts) + sub_queries.append( + f"SELECT {select_sql} FROM {table} WHERE {where_sql}" + ) + + return "\nUNION ALL\n".join(sub_queries) + + +def wrap_postpass_geojson(base_sql: str) -> str: + """Wrap a SELECT query in a GeoJSON FeatureCollection builder. + + The inner query must return at least ``osm_id``, ``geom``, and ``tags``. + The ``COALESCE`` ensures an empty ``[]`` is returned rather than ``null`` + when no features match. + + Args: + base_sql: Raw SQL from :func:`build_postpass_query`. + + Returns: + SQL that produces a single-row result containing the full + GeoJSON FeatureCollection as a JSON object. + """ + inner = base_sql.rstrip(";") + return ( + "SELECT json_build_object(\n" + " 'type', 'FeatureCollection',\n" + " 'features', COALESCE(json_agg(\n" + " json_build_object(\n" + " 'type', 'Feature',\n" + " 'id', sub.osm_id,\n" + " 'geometry', ST_AsGeoJSON(sub.geom)::json,\n" + " 'properties', sub.tags\n" + " )\n" + " ), '[]'::json)\n" + ")\n" + f"FROM (\n{inner}\n) AS sub" + ) + + diff --git a/osm2pgsql-query-builder/tests/test_postpass.py b/osm2pgsql-query-builder/tests/test_postpass.py new file mode 100644 index 00000000..a783a7c2 --- /dev/null +++ b/osm2pgsql-query-builder/tests/test_postpass.py @@ -0,0 +1,349 @@ +# Copyright (C) 2026 Humanitarian OpenStreetmap Team + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Tests for the PostPass query builder (osm2pgsql_query_builder.postpass).""" + +import pytest +from pydantic import ValidationError + +from osm2pgsql_query_builder.postpass_module import ( + BboxFilter, + PostPassQueryParams, + build_postpass_bbox_filter, + build_postpass_query, + wrap_postpass_geojson, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +NAIROBI_BBOX = {"minx": 36.7, "miny": -1.3, "maxx": 36.9, "maxy": -1.2} + +NAIROBI_POLYGON = { + "type": "Polygon", + "coordinates": [ + [ + [36.7, -1.3], + [36.9, -1.3], + [36.9, -1.2], + [36.7, -1.2], + [36.7, -1.3], + ] + ], +} + + +# --------------------------------------------------------------------------- +# BboxFilter tests +# --------------------------------------------------------------------------- + + +def test_bbox_filter_valid(): + bbox = BboxFilter(**NAIROBI_BBOX) + assert bbox.minx == 36.7 + assert bbox.miny == -1.3 + assert bbox.maxx == 36.9 + assert bbox.maxy == -1.2 + + +def test_bbox_filter_inverted_x(): + with pytest.raises(ValidationError, match="minx"): + BboxFilter(minx=36.9, miny=-1.3, maxx=36.7, maxy=-1.2) + + +def test_bbox_filter_inverted_y(): + with pytest.raises(ValidationError, match="miny"): + BboxFilter(minx=36.7, miny=-1.2, maxx=36.9, maxy=-1.3) + + +# --------------------------------------------------------------------------- +# PostPassQueryParams tests +# --------------------------------------------------------------------------- + + +def test_params_with_explicit_bbox(): + params = PostPassQueryParams(bbox=NAIROBI_BBOX) + assert params.bbox.minx == 36.7 + assert params.geometry is None + + +def test_params_with_polygon_geometry(): + params = PostPassQueryParams(geometry=NAIROBI_POLYGON) + assert params.geometry is not None + assert params.bbox is None + bbox = params.get_bbox() + assert round(bbox.minx, 1) == 36.7 + assert round(bbox.maxy, 1) == -1.2 + + +def test_params_with_feature_geometry(): + feature = { + "type": "Feature", + "properties": {}, + "geometry": NAIROBI_POLYGON, + } + params = PostPassQueryParams(geometry=feature) + # GeometryValidatorMixin should unwrap to the inner geometry + assert params.geometry.type == "Polygon" + + +def test_params_requires_geometry_or_bbox(): + with pytest.raises(ValidationError, match="Either 'geometry' or 'bbox'"): + PostPassQueryParams() + + +def test_params_invalid_geometry_type(): + with pytest.raises(ValidationError, match="Invalid geometry_type"): + PostPassQueryParams(bbox=NAIROBI_BBOX, geometry_type=["raster"]) + + +def test_params_geometry_type_deduplication(): + params = PostPassQueryParams( + bbox=NAIROBI_BBOX, + geometry_type=["point", "point", "line"], + ) + assert len(params.geometry_type) == 2 + assert set(params.geometry_type) == {"point", "line"} + + +def test_get_bbox_prefers_explicit(): + params = PostPassQueryParams( + bbox=NAIROBI_BBOX, + geometry=NAIROBI_POLYGON, + ) + bbox = params.get_bbox() + assert bbox.minx == 36.7 # uses explicit bbox, not derived + + +def test_get_tables_defaults_all(): + params = PostPassQueryParams(bbox=NAIROBI_BBOX) + tables = params.get_tables() + assert set(tables) == {"postpass_point", "postpass_line", "postpass_polygon"} + + +def test_get_tables_filtered(): + params = PostPassQueryParams(bbox=NAIROBI_BBOX, geometry_type=["point"]) + assert params.get_tables() == ["postpass_point"] + + +# --------------------------------------------------------------------------- +# build_postpass_bbox_filter +# --------------------------------------------------------------------------- + + +def test_build_bbox_filter_output(): + bbox = BboxFilter(**NAIROBI_BBOX) + clause = build_postpass_bbox_filter(bbox) + assert "ST_MakeBox2D" in clause + assert "ST_SetSRID" in clause + assert "4326" in clause + assert "36.7" in clause + assert "-1.3" in clause + + +# --------------------------------------------------------------------------- +# build_postpass_query — geometry type routing +# --------------------------------------------------------------------------- + + +def test_query_points_only(): + params = PostPassQueryParams( + bbox=NAIROBI_BBOX, + geometry_type=["point"], + ) + sql = build_postpass_query(params) + assert "postpass_point" in sql + assert "postpass_line" not in sql + assert "postpass_polygon" not in sql + + +def test_query_all_tables_by_default(): + params = PostPassQueryParams(bbox=NAIROBI_BBOX) + sql = build_postpass_query(params) + assert "postpass_point" in sql + assert "postpass_line" in sql + assert "postpass_polygon" in sql + assert "UNION ALL" in sql + + +def test_query_two_tables(): + params = PostPassQueryParams( + bbox=NAIROBI_BBOX, + geometry_type=["point", "line"], + ) + sql = build_postpass_query(params) + assert "postpass_point" in sql + assert "postpass_line" in sql + assert "postpass_polygon" not in sql + assert sql.count("UNION ALL") == 1 + + +# --------------------------------------------------------------------------- +# build_postpass_query — tag filters +# --------------------------------------------------------------------------- + + +def test_query_with_all_geometry_filter(): + params = PostPassQueryParams( + bbox=NAIROBI_BBOX, + geometry_type=["point"], + filters={ + "tags": { + "all_geometry": {"join_or": {"amenity": ["school", "hospital"]}} + } + }, + ) + sql = build_postpass_query(params) + assert "amenity" in sql + assert "school" in sql + assert "hospital" in sql + + +def test_query_single_value_tag(): + params = PostPassQueryParams( + bbox=NAIROBI_BBOX, + geometry_type=["line"], + filters={ + "tags": {"all_geometry": {"join_or": {"highway": ["primary"]}}} + }, + ) + sql = build_postpass_query(params) + assert "highway" in sql + assert "primary" in sql + assert "postpass_line" in sql + + +def test_query_join_and_filter(): + params = PostPassQueryParams( + bbox=NAIROBI_BBOX, + geometry_type=["polygon"], + filters={ + "tags": { + "all_geometry": { + "join_and": { + "building": ["yes"], + "amenity": ["school"], + } + } + } + }, + ) + sql = build_postpass_query(params) + assert "building" in sql + assert "amenity" in sql + assert "AND" in sql + + +def test_query_no_filters_just_bbox(): + params = PostPassQueryParams( + bbox=NAIROBI_BBOX, + geometry_type=["point"], + ) + sql = build_postpass_query(params) + assert "ST_MakeBox2D" in sql + assert "WHERE" in sql + + +# --------------------------------------------------------------------------- +# build_postpass_query — attribute projection +# --------------------------------------------------------------------------- + + +def test_query_with_attributes(): + params = PostPassQueryParams( + bbox=NAIROBI_BBOX, + geometry_type=["point"], + ) + sql = build_postpass_query(params, attributes=["name", "amenity"]) + assert "tags ->> 'name' AS name" in sql + assert "tags ->> 'amenity' AS amenity" in sql + + +def test_query_without_attributes_returns_tags_column(): + params = PostPassQueryParams(bbox=NAIROBI_BBOX, geometry_type=["point"]) + sql = build_postpass_query(params) + assert "osm_id, tags, geom" in sql + + +def test_query_attribute_with_colon_sanitized(): + params = PostPassQueryParams(bbox=NAIROBI_BBOX, geometry_type=["point"]) + sql = build_postpass_query(params, attributes=["addr:street"]) + # colon should be replaced with underscore in column alias + assert "addr_street" in sql + + +# --------------------------------------------------------------------------- +# wrap_postpass_geojson +# --------------------------------------------------------------------------- + + +def test_wrap_geojson_structure(): + inner = "SELECT osm_id, tags, geom FROM postpass_point WHERE geom && bbox" + wrapped = wrap_postpass_geojson(inner) + assert "json_build_object" in wrapped + assert "'FeatureCollection'" in wrapped + assert "ST_AsGeoJSON" in wrapped + assert "COALESCE" in wrapped + assert "[]" in wrapped # empty fallback + + +def test_wrap_geojson_strips_trailing_semicolon(): + inner = "SELECT osm_id, tags, geom FROM postpass_point;" + wrapped = wrap_postpass_geojson(inner) + # The inner query should not contain the semicolon + assert "postpass_point;" not in wrapped + + +def test_full_pipeline_bbox(): + """End-to-end: bbox params → query → wrapped GeoJSON SQL.""" + params = PostPassQueryParams( + bbox=NAIROBI_BBOX, + geometry_type=["point"], + filters={ + "tags": { + "all_geometry": {"join_or": {"amenity": ["school", "hospital"]}} + } + }, + ) + sql = wrap_postpass_geojson(build_postpass_query(params)) + assert "FeatureCollection" in sql + assert "postpass_point" in sql + assert "amenity" in sql + + +def test_full_pipeline_from_polygon(): + """End-to-end: polygon geometry → bbox derived → query built.""" + params = PostPassQueryParams( + geometry=NAIROBI_POLYGON, + geometry_type=["point"], + ) + sql = build_postpass_query(params) + assert "postpass_point" in sql + assert "ST_MakeBox2D" in sql + + +def test_top_level_import(): + """PostPass symbols are importable from the package root.""" + from osm2pgsql_query_builder import ( + BboxFilter, + PostPassQueryParams, + build_postpass_query, + wrap_postpass_geojson, + ) + assert BboxFilter is not None + assert PostPassQueryParams is not None + assert callable(build_postpass_query) + assert callable(wrap_postpass_geojson) \ No newline at end of file