|
| 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