Skip to content
This repository was archived by the owner on Sep 26, 2025. It is now read-only.

Commit 364b303

Browse files
authored
Merge branch 'goat-community:main' into main
2 parents 430dab2 + 9200646 commit 364b303

File tree

3 files changed

+227
-29
lines changed

3 files changed

+227
-29
lines changed

src/crud/crud_layer_project.py

Lines changed: 52 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
build_where_clause,
3030
search_value,
3131
)
32+
from src.expression_converter import QgsExpressionToSqlConverter
3233

3334
# Local application imports
3435
from .base import CRUDBase
@@ -395,8 +396,9 @@ async def get_statistic_aggregation(
395396
project_id: UUID,
396397
layer_project_id: int,
397398
column_name: str | None,
398-
operation: ColumnStatisticsOperation,
399-
group_by_column_name: str,
399+
operation: ColumnStatisticsOperation | None,
400+
group_by_column_name: str | None,
401+
expression: str | None,
400402
size: int,
401403
query: str,
402404
order: str,
@@ -408,26 +410,48 @@ async def get_statistic_aggregation(
408410
async_session=async_session, project_id=project_id, id=layer_project_id
409411
)
410412

411-
# Check if mapped statistics field is float, integer or biginteger
412-
mapped_statistics_field = (
413-
await self.check_column_statistics(
414-
layer_project=layer_project,
415-
column_name=column_name,
416-
operation=operation,
417-
)
418-
)["mapped_statistics_field"]
413+
mapped_statistics_field = None
414+
415+
# For expression-based operations, validate columns and attribute mapping
416+
if expression:
417+
converter = QgsExpressionToSqlConverter(expression)
418+
column_names = converter.extract_field_names()
419+
for column in column_names:
420+
try:
421+
# Check if column is in attribute mapping
422+
column_mapped = search_value(layer_project.attribute_mapping, column)
423+
except ValueError:
424+
raise HTTPException(
425+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
426+
detail=f"Column {column} not found in layer attribute mapping",
427+
)
419428

420-
# TODO: Consider performing a data type check on the group-by column
421-
mapped_group_by_field = search_value(
422-
layer_project.attribute_mapping,
423-
group_by_column_name,
424-
)
429+
# Replace original column name with mapped column name
430+
converter.replace_field_name(column, column_mapped)
425431

426-
# Build statistics portion of select clause
427-
statistics_column_query = self.get_statistics_sql(
428-
field=mapped_statistics_field,
429-
operation=operation,
430-
)
432+
# Convert expression to SQL
433+
statistics_column_query, mapped_group_by_field = converter.translate()
434+
else:
435+
# Check if mapped statistics field is float, integer or biginteger
436+
mapped_statistics_field = (
437+
await self.check_column_statistics(
438+
layer_project=layer_project,
439+
column_name=column_name,
440+
operation=operation,
441+
)
442+
)["mapped_statistics_field"]
443+
444+
# TODO: Consider performing a data type check on the group-by column
445+
mapped_group_by_field = search_value(
446+
layer_project.attribute_mapping,
447+
group_by_column_name,
448+
)
449+
450+
# Build statistics portion of select clause
451+
statistics_column_query = self.get_statistics_sql(
452+
field=mapped_statistics_field,
453+
operation=operation,
454+
)
431455

432456
# Build where clause combining layer project and CQL query
433457
where_query = build_where_clause(
@@ -452,14 +476,16 @@ async def get_statistic_aggregation(
452476
total_count = (await async_session.execute(text(sql_count_query))).scalar_one()
453477

454478
# Build final statistics queries
479+
group_by_clause = f"GROUP BY {mapped_group_by_field}" if mapped_group_by_field else ""
455480
order_mapped = {"descendent": "DESC", "ascendent": "ASC"}[order]
456481
sql_data_query = f"""
457482
SELECT *
458483
FROM (
459-
SELECT {mapped_group_by_field} AS grouped_value, {statistics_column_query} AS operation_value
484+
SELECT {statistics_column_query} operation_value
485+
{f',{mapped_group_by_field}' if mapped_group_by_field else ''}
460486
FROM {layer_project.table_name}
461487
{where_query}
462-
GROUP BY {mapped_group_by_field}
488+
{group_by_clause}
463489
) subquery
464490
ORDER BY operation_value {order_mapped};
465491
"""
@@ -468,7 +494,10 @@ async def get_statistic_aggregation(
468494
# Create a response object
469495
response = {
470496
"items": [
471-
{"grouped_value": res[0], "operation_value": res[1]}
497+
{
498+
"operation_value": res[0],
499+
"grouped_value": res[1] if mapped_group_by_field else None,
500+
}
472501
for res in result[:size]
473502
],
474503
"total_items": len(result),

src/endpoints/v2/project.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -536,18 +536,23 @@ async def get_statistic_aggregation(
536536
description="The column name to get the unique values from",
537537
example="name",
538538
),
539-
operation: ColumnStatisticsOperation = Query(
540-
...,
539+
operation: ColumnStatisticsOperation | None = Query(
540+
None,
541541
description="The operation to perform",
542542
example="sum",
543543
),
544-
group_by_column_name: str = Query(
545-
...,
544+
group_by_column_name: str | None = Query(
545+
None,
546546
description="The name of the column to group by",
547547
example="name",
548548
),
549+
expression: str | None = Query(
550+
None,
551+
description="The QGIS expression to use for the statistic operation",
552+
example="sum(\"population\")",
553+
),
549554
size: int = Query(
550-
None, description="The number of grouped values to return", example=5
555+
100, description="The number of grouped values to return", example=5
551556
),
552557
query: str = Query(
553558
None,
@@ -562,12 +567,26 @@ async def get_statistic_aggregation(
562567
):
563568
"""Get aggregated statistics for a numeric column based on the supplied group-by column and CQL-filter."""
564569

570+
# Ensure an operation or expression is specified
571+
if operation is None and expression is None:
572+
raise HTTPException(
573+
status_code=status.HTTP_400_BAD_REQUEST,
574+
detail="An operation or expression must be specified.",
575+
)
576+
565577
# Ensure a column name is specified for all operations except count
566-
if operation != ColumnStatisticsOperation.count and column_name is None:
578+
if operation and operation != ColumnStatisticsOperation.count and column_name is None:
567579
raise HTTPException(
568580
status_code=status.HTTP_400_BAD_REQUEST,
569581
detail="A column name must be specified for all operations except count.",
570582
)
583+
584+
# If an operation is specified, a group-by column name must also be specified
585+
if operation and group_by_column_name is None:
586+
raise HTTPException(
587+
status_code=status.HTTP_400_BAD_REQUEST,
588+
detail="A group-by column name must be specified.",
589+
)
571590

572591
# Ensure the size is not excessively large
573592
if size > 100:
@@ -584,6 +603,7 @@ async def get_statistic_aggregation(
584603
column_name=column_name,
585604
operation=operation,
586605
group_by_column_name=group_by_column_name,
606+
expression=expression,
587607
size=size,
588608
query=query,
589609
order=order,

src/expression_converter.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import re
2+
from qgis.core import QgsExpression
3+
4+
"""Minimal converter: QGIS expression → Postgres/PostGIS SQL."""
5+
6+
_NUMERIC_FUNCS = {"abs","sqrt","pow","exp","ln","log10","round","ceil","floor","pi","sin","cos","tan","asin","acos","atan","degrees","radians","rand"}
7+
_STRING_FUNCS = {"length","char_length","upper","lower","trim","ltrim","rtrim","substr","substring","left","right","replace","regexp_replace","regexp_substr","strpos","concat"}
8+
_DATETIME_FUNCS= {"now","age","extract","date_part","make_date","make_time","make_timestamp","to_date","to_timestamp","to_char"}
9+
_CAST_FUNCS = {"to_int","to_real","to_string"}
10+
_GENERIC_FUNCS = {"coalesce","nullif"}
11+
_AGG_FUNCS = {"sum","avg","min","max","count"}
12+
13+
_METRIC_UNARY = {"area":"ST_Area","length":"ST_Length","perimeter":"ST_Perimeter"}
14+
_METRIC_BUFFER = "buffer"
15+
_GEOM_UNARY = {"centroid":"ST_Centroid","convex_hull":"ST_ConvexHull","envelope":"ST_Envelope","make_valid":"ST_MakeValid","is_empty":"ST_IsEmpty","is_valid":"ST_IsValid","x":"ST_X","y":"ST_Y","xmin":"ST_XMin","xmax":"ST_XMax","ymin":"ST_YMin","ymax":"ST_YMax"}
16+
17+
_ALL_FUNCS = (_NUMERIC_FUNCS|_STRING_FUNCS|_DATETIME_FUNCS|_CAST_FUNCS|_GENERIC_FUNCS|_AGG_FUNCS|set(_METRIC_UNARY)|{_METRIC_BUFFER}|set(_GEOM_UNARY))
18+
_BANNED_MULTI_GEOM = {"distance","intersects","touches","within","overlaps","crosses"}
19+
_OP_MAP = {"!=":"<>"}
20+
21+
_SPECIAL = {
22+
"$geometry":"geom",
23+
"$area":"ST_Area(geom::geography)",
24+
"$length":"ST_Length(geom::geography)",
25+
"$perimeter":"ST_Perimeter(geom::geography)",
26+
"$x":"ST_X(geom)","$y":"ST_Y(geom)",
27+
"$xmin":"ST_XMin(geom)","$xmax":"ST_XMax(geom)",
28+
"$ymin":"ST_YMin(geom)","$ymax":"ST_YMax(geom)"
29+
}
30+
31+
_FUNC_RE = re.compile(r"\b([a-zA-Z_][a-zA-Z0-9_]*)\s*\(", re.IGNORECASE)
32+
33+
class QgsExpressionToSqlConverter:
34+
def __init__(self, expr):
35+
expr = QgsExpression(expr)
36+
if not expr.isValid():
37+
if expr.hasParserError():
38+
raise ValueError(f"Invalid expression: {expr.parserErrorString()}")
39+
raise ValueError("Invalid expression")
40+
self.raw = expr.expression()
41+
42+
def extract_field_names(self):
43+
"""Return set of column names (assumed always double‑quoted)."""
44+
45+
return set(re.findall(r'"([^"]+)"', self.raw))
46+
47+
def replace_field_name(self, old, new):
48+
"""Replace field name in expression with attribute-mapped name."""
49+
50+
pattern = re.compile(rf'"{re.escape(old)}"')
51+
self.raw = pattern.sub(f'"{new}"', self.raw)
52+
53+
54+
# ------------------------------------------------------------------
55+
def translate(self):
56+
"""Convert QGIS expression to a PostgreSQL compliant SELECT and GROUP BY clause."""
57+
58+
sql = self.raw
59+
self._validate(sql)
60+
sql = self._subst(sql, _SPECIAL)
61+
sql = self._rewrite_casts(sql)
62+
sql = self._rewrite_buffer(sql)
63+
sql = self._rewrite_metric(sql)
64+
sql = self._rename_unary_geom(sql)
65+
sql = re.sub(r"\brand\s*\(", "random(", sql, flags=re.IGNORECASE)
66+
for q, pg in _OP_MAP.items():
67+
sql = sql.replace(q, pg)
68+
sql, grp = self._rewrite_aggs(sql)
69+
return sql, ", ".join(sorted(grp))
70+
71+
# ------------------------------------------------------------------
72+
def _validate(self, text):
73+
for fn in _FUNC_RE.findall(text):
74+
l = fn.lower()
75+
if l in _BANNED_MULTI_GEOM:
76+
raise ValueError(f"Function '{fn}' needs multiple geometries – not supported.")
77+
if l not in _ALL_FUNCS:
78+
raise ValueError(f"Function '{fn}' is not supported.")
79+
80+
@staticmethod
81+
def _subst(txt, mapping):
82+
for k, v in mapping.items():
83+
txt = re.sub(re.escape(k), v, txt, flags=re.IGNORECASE)
84+
return txt
85+
86+
@staticmethod
87+
def _rewrite_casts(txt):
88+
txt = re.sub(r"\bto_int\s*\(([^)]+)\)", r"CAST(\1 AS integer)", txt, flags=re.IGNORECASE)
89+
txt = re.sub(r"\bto_real\s*\(([^)]+)\)", r"CAST(\1 AS double precision)", txt, flags=re.IGNORECASE)
90+
txt = re.sub(r"\bto_string\s*\(([^)]+)\)", r"CAST(\1 AS text)", txt, flags=re.IGNORECASE)
91+
return txt
92+
93+
@staticmethod
94+
def _rewrite_buffer(txt):
95+
pat = re.compile(r"\bbuffer\s*\(\s*([^,]+?),\s*([^)]+?)\)", re.IGNORECASE)
96+
return pat.sub(lambda m: f"ST_Buffer({m.group(1)}::geography, {m.group(2)})", txt)
97+
98+
def _geom_like(self, arg: str):
99+
a = arg.lower()
100+
hints = ["geom", "::geography", "st_", "buffer("] + list(_GEOM_UNARY) + list(_METRIC_UNARY) + [_METRIC_BUFFER]
101+
return any(h in a for h in hints)
102+
103+
def _rewrite_metric(self, txt):
104+
nest = r"(?:[^()]+|\([^()]*\))+"
105+
for q, pg in _METRIC_UNARY.items():
106+
pat = re.compile(rf"\b{q}\s*\(\s*({nest})\s*\)", re.IGNORECASE)
107+
def rp(m):
108+
arg = m.group(1).strip()
109+
if self._geom_like(arg):
110+
return f"{pg}({arg}::geography)"
111+
if q == "length":
112+
return f"char_length({arg})"
113+
return m.group(0)
114+
txt = pat.sub(rp, txt)
115+
return txt
116+
117+
def _rename_unary_geom(self, txt):
118+
nest = r"(?:[^()]+|\([^()]*\))+"
119+
for q, pg in _GEOM_UNARY.items():
120+
pat = re.compile(rf"\b{q}\s*\(\s*({nest})\s*\)", re.IGNORECASE)
121+
txt = pat.sub(lambda m: f"{pg}({m.group(1)})" if self._geom_like(m.group(1)) else m.group(0), txt)
122+
return txt
123+
124+
def _rewrite_aggs(self, sql):
125+
grp = set()
126+
def rp(m):
127+
func, params = m.group(1), m.group(2)
128+
parts = [p.strip() for p in re.split(r",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)", params) if p.strip()]
129+
exprs = []
130+
for p in parts:
131+
if p.lower().startswith("group_by"):
132+
val = re.split(r":=|=", p, 1)[-1]
133+
for c in [v.strip().strip('"') for v in val.split(',') if v.strip()]:
134+
grp.add(f'"{c}"')
135+
else:
136+
exprs.append(p)
137+
rebuilt = ", ".join(exprs) if exprs else params
138+
return f"{func}({rebuilt})"
139+
pattern = re.compile(rf"\b({'|'.join(_AGG_FUNCS)})\s*\((.*?)\)", re.IGNORECASE | re.DOTALL)
140+
return pattern.sub(rp, sql), grp
141+
142+
def build_sql(expr, alias=None):
143+
sql, grp = QgsExpressionToSqlConverter(expr).translate()
144+
if alias:
145+
sql += f" AS {alias}"
146+
out = f"SELECT {sql}"
147+
if grp:
148+
out += "\nGROUP BY " + ", ".join(sorted(grp))
149+
return out

0 commit comments

Comments
 (0)