Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/run_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,38 @@ jobs:
DB: postgres://pydal_test:pydal_test@localhost:5432/pydal_test
run: |
python -m unittest tests

postgis-geo:
runs-on: ubuntu-24.04

services:
postgres:
image: postgis/postgis:16-3.5
env:
POSTGRES_USER: pydal_test
POSTGRES_PASSWORD: pydal_test
POSTGRES_DB: pydal_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5

steps:
- uses: actions/checkout@v2
- name: Set up Python 3.12
uses: actions/setup-python@v2
with:
python-version: "3.12"
- name: Install Everything
run: |
python -m pip install --upgrade pip
python -m pip install -e .[test]
- name: Test PostgreSQL Geo compiler
env:
PYDAL_TEST_POSTGIS: "1"
DB: postgres://pydal_test:pydal_test@localhost:5432/pydal_test
run: |
python -m unittest tests.postgres_geo
40 changes: 37 additions & 3 deletions pydal/ast_translate.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

# Op names that translate as straight BinOp(name, left, right) with no
# opts and no structural transformation.
# Specialized literal type hints are handled before the generic dispatch.
_PLAIN_BINOPS = frozenset(
{
"lt",
Expand Down Expand Up @@ -224,6 +225,21 @@ def _expr_to_ast(expr) -> ast.Node:
return ast.FuncCall("count", (to_ast(f),), opts=(("distinct", True),))
return ast.FuncCall("count", (to_ast(f),))

# ---------- GIS scalar arguments ----------
if name in ("st_simplify", "st_simplifypreservetopology"):
return ast.BinOp(
name,
to_ast(f),
to_ast(s, type_hint="double"),
)
if name == "st_transform":
target_type = "integer" if isinstance(s, int) else "string"
return ast.BinOp(
name,
to_ast(f),
to_ast(s, type_hint=target_type),
)

# ---------- plain BinOps ----------
if name in _PLAIN_BINOPS:
return ast.BinOp(name, to_ast(f), to_ast(s, type_hint=_field_type(f)))
Expand Down Expand Up @@ -282,14 +298,32 @@ def _slice_arg(v):

if name == "st_asgeojson":
# second is a dict {"precision": ..., "options": ...}
opts = tuple(sorted(s.items())) if isinstance(s, dict) else ()
return ast.FuncCall("st_asgeojson", (to_ast(f),), opts=opts)
if not isinstance(s, dict) or set(s) != {"precision", "options"}:
raise TypeError(
"st_asgeojson expects {'precision': ..., 'options': ...}"
)
precision = s["precision"]
options = s["options"]
opts = tuple(sorted(s.items()))
return ast.FuncCall(
"st_asgeojson",
(
to_ast(f),
to_ast(precision, type_hint="integer"),
to_ast(options, type_hint="integer"),
),
opts=opts,
)

if name == "st_dwithin":
other, distance = s
return ast.FuncCall(
"st_dwithin",
(to_ast(f), to_ast(other), to_ast(distance, type_hint="double")),
(
to_ast(f),
to_ast(other, type_hint=_field_type(f)),
to_ast(distance, type_hint="double"),
),
)

# ---------- fallback: opaque function call ----------
Expand Down
59 changes: 59 additions & 0 deletions pydal/compilers/postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,65 @@ def _render_like_left(self, l: ast.Node, lowered_left: bool) -> str:
rendered = "%s::text" % rendered
return ("LOWER(%s)" % rendered) if lowered_left else rendered

# GIS operations deliberately live here rather than in the legacy
# Postgres dialect. Their operands have independent types: the second
# geometry operand inherits geometry/geography, while tolerances,
# distances, precision/options, and SRID/Proj4 arguments stay scalar.
def _geo_binary(self, name, l, r):
return "%s(%s,%s)" % (name, self.visit(l), self.visit(r))

def op_st_contains(self, l, r, _):
return self._geo_binary("ST_Contains", l, r)

def op_st_equals(self, l, r, _):
return self._geo_binary("ST_Equals", l, r)

def op_st_intersects(self, l, r, _):
return self._geo_binary("ST_Intersects", l, r)

def op_st_overlaps(self, l, r, _):
return self._geo_binary("ST_Overlaps", l, r)

def op_st_touches(self, l, r, _):
return self._geo_binary("ST_Touches", l, r)

def op_st_within(self, l, r, _):
return self._geo_binary("ST_Within", l, r)

def op_st_distance(self, l, r, _):
return self._geo_binary("ST_Distance", l, r)

def op_st_simplify(self, l, r, _):
return self._geo_binary("ST_Simplify", l, r)

def op_st_simplifypreservetopology(self, l, r, _):
return self._geo_binary("ST_SimplifyPreserveTopology", l, r)

def op_st_transform(self, l, r, _):
return self._geo_binary("ST_Transform", l, r)

def un_st_astext(self, x, _):
return "ST_AsText(%s)" % self.visit(x)

def un_st_aswkb(self, x, _):
# Preserve pydal's historical semantics: st_aswkb() is a pass-through
# expression rather than an implicit ST_AsBinary() call.
return self.visit(x)

def un_st_x(self, x, _):
return "ST_X(%s)" % self.visit(x)

def un_st_y(self, x, _):
return "ST_Y(%s)" % self.visit(x)

def fn_st_asgeojson(self, args, _):
if len(args) != 3:
raise ValueError("st_asgeojson expects geometry, precision, and options")
return "ST_AsGeoJSON(%s,%s,%s)" % tuple(self.visit(a) for a in args)

def fn_st_dwithin(self, args, _):
return "ST_DWithin(%s,%s,%s)" % tuple(self.visit(a) for a in args)


@compilers.register_for(PostgresPsyco)
class PostgresPsycoCompiler(PostgresCompiler):
Expand Down
1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from .tier2_units import *
from .tier4_units import *
from .tier5_units import *
from .postgres_geo import *
from .base import *
from .caching import TestCache
from .contribs import *
Expand Down
Loading
Loading