Skip to content

Commit 2efe980

Browse files
authored
Merge pull request #892 from MerginMaps/prototype/layer-filtering
Layer filtering
2 parents d2633fc + 2d7f28c commit 2efe980

9 files changed

Lines changed: 1089 additions & 6 deletions

File tree

.github/workflows/run-test.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
strategy:
3434
fail-fast: false
3535
matrix:
36-
qgis-version: ["3.22", "3.28", "3.34", "3.40", "3.44"]
36+
qgis-version: [ "3.22", "3.28", "3.34", "3.40", "3.44" ]
3737

3838
steps:
3939
- name: Checkout plugin code
@@ -84,6 +84,6 @@ jobs:
8484
run: cp -r client/mergin plugin/${{ env.PLUGIN_NAME }}
8585

8686
- name: Run tests
87-
run: conda run -n qgis_env pytest ${{ github.workspace }}/plugin/tests --cov=Mergin --cov-report=term-missing:skip-covered -rP -vv -s
87+
run: conda run -n qgis_env pytest ${{ github.workspace }}/plugin/tests --cov=Mergin --cov-report=term-missing:skip-covered --no-cov-on-fail -rP -vv -s
8888
env:
8989
PYTHONPATH: ${{ github.workspace }}/plugin

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@ repos:
44
rev: 25.1.0
55
hooks:
66
- id: black
7-
language_version: python3.10
7+
args: [--line-length=120]

Mergin/field_filtering.py

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
# GPLv3 license
2+
# Copyright Lutra Consulting Limited
3+
4+
import json
5+
from enum import Enum
6+
from typing import Optional, Union, List
7+
8+
from qgis.core import QgsVectorLayer, QgsFields, QgsProject, QgsMapLayer, Qgis
9+
from qgis.PyQt.QtCore import Qt, QAbstractListModel, QModelIndex, pyqtSignal, QMetaType
10+
from qgis.PyQt.QtWidgets import QListView
11+
from qgis.PyQt.QtGui import QMouseEvent
12+
13+
14+
SQL_PLACEHOLDER_VALUE = "@@value@@"
15+
SQL_PLACEHOLDER_VALUE_FROM = "@@value_from@@"
16+
SQL_PLACEHOLDER_VALUE_TO = "@@value_to@@"
17+
18+
19+
class FieldFilterType(str, Enum):
20+
TEXT = "Text"
21+
NUMBER = "Number"
22+
DATE = "Date"
23+
CHECKBOX = "Checkbox"
24+
SINGLE_SELECT = "Single select"
25+
MULTI_SELECT = "Multi select"
26+
27+
28+
def excluded_layers_list() -> List[QgsMapLayer]:
29+
excluded_layers: List[QgsMapLayer] = []
30+
31+
project_layers = QgsProject.instance().mapLayers()
32+
33+
layer: QgsMapLayer
34+
for _, layer in project_layers.items():
35+
36+
is_vector = False
37+
38+
if Qgis.versionInt() >= 33000:
39+
is_vector = layer.type() == Qgis.LayerType.Vector
40+
else:
41+
is_vector = layer.type() == QgsMapLayer.VectorLayer
42+
43+
if not is_vector:
44+
excluded_layers.append(layer)
45+
continue
46+
47+
dp = layer.dataProvider()
48+
49+
if dp.name() != "ogr":
50+
excluded_layers.append(layer)
51+
continue
52+
53+
# storage type in OGR should return driver name of the datasource
54+
if not hasattr(dp, "storageType") or dp.storageType() != "GPKG":
55+
excluded_layers.append(layer)
56+
continue
57+
58+
return excluded_layers
59+
60+
61+
def field_filters_to_json(filters: List["FieldFilter"]) -> str:
62+
"""Serialize a list of FieldFilter objects to a JSON string."""
63+
return json.dumps([f.to_dict() for f in filters])
64+
65+
66+
def field_filters_from_json(data: str) -> List["FieldFilter"]:
67+
"""Deserialize a JSON string into a list of FieldFilter objects."""
68+
return [FieldFilter.from_dict(item) for item in json.loads(data)]
69+
70+
71+
class FieldFilter:
72+
73+
def __init__(
74+
self,
75+
layer: Optional[QgsVectorLayer],
76+
field_name: str,
77+
filter_type: FieldFilterType,
78+
filter_name: str,
79+
):
80+
if layer is not None and not isinstance(layer, QgsVectorLayer):
81+
raise ValueError("layer must be a QgsVectorLayer")
82+
83+
if layer is not None and field_name not in layer.fields().names():
84+
raise ValueError(f"Field '{field_name}' does not exist in layer '{layer.name()}'")
85+
86+
self.provider = ""
87+
self.layer_id = ""
88+
89+
if layer is not None:
90+
provider = layer.dataProvider()
91+
self.provider = provider.name() if provider else ""
92+
self.layer_id = layer.id()
93+
94+
self.field_name = field_name
95+
self.filter_type = filter_type
96+
self.filter_name = filter_name
97+
self.sql_expression = ""
98+
99+
if layer is not None:
100+
self._generate_sql_expression()
101+
102+
@classmethod
103+
def from_dict(cls, data: dict) -> "FieldFilter":
104+
"""Create a FieldFilter instance from a dictionary"""
105+
f = object.__new__(cls)
106+
f.layer_id = data["layer_id"]
107+
f.provider = data.get("provider", "")
108+
f.field_name = data["field_name"]
109+
f.filter_type = FieldFilterType(data["filter_type"])
110+
f.filter_name = data["filter_name"]
111+
f.sql_expression = data.get("sql_expression", "")
112+
if not f.sql_expression:
113+
f._generate_sql_expression()
114+
return f
115+
116+
def to_dict(self) -> dict:
117+
"""Convert the object to a dictionary"""
118+
return {
119+
"layer_id": self.layer_id,
120+
"provider": self.provider,
121+
"field_name": self.field_name,
122+
"filter_type": self.filter_type.value,
123+
"filter_name": self.filter_name,
124+
"sql_expression": self.sql_expression,
125+
}
126+
127+
def __eq__(self, value: object) -> bool:
128+
if not isinstance(value, FieldFilter):
129+
return NotImplemented
130+
return (
131+
self.layer_id == value.layer_id
132+
and self.provider == value.provider
133+
and self.field_name == value.field_name
134+
and self.filter_type == value.filter_type
135+
and self.filter_name == value.filter_name
136+
)
137+
138+
def _generate_sql_expression(self) -> None:
139+
"""Generate a SQL WHERE clause template with named value placeholders.
140+
141+
Every placeholder is replaced entirely by the substituting code, which must
142+
supply a complete, properly-quoted SQL literal for the target provider.
143+
144+
Placeholders:
145+
SQL_PLACEHOLDER_VALUE
146+
— single value (TEXT, CHECKBOX, SINGLE_SELECT)
147+
e.g. '%hello%' for LIKE, 'text', 42, true
148+
SQL_PLACEHOLDER_VALUE_FROM
149+
— lower bound of a range (NUMBER, DATE)
150+
e.g. 10, '2024-01-01'
151+
SQL_PLACEHOLDER_VALUE_TO
152+
— upper bound of a range (NUMBER, DATE)
153+
"""
154+
field = f'"{self.field_name}"'
155+
156+
if self.filter_type == FieldFilterType.TEXT:
157+
cast = self._cast_field(field)
158+
expr = f"{cast} LIKE '%{SQL_PLACEHOLDER_VALUE}%'"
159+
160+
elif self.filter_type == FieldFilterType.NUMBER:
161+
cast = self._cast_field(field)
162+
expr = f"{cast} >= {SQL_PLACEHOLDER_VALUE_FROM} AND {cast} <= {SQL_PLACEHOLDER_VALUE_TO}"
163+
164+
elif self.filter_type == FieldFilterType.DATE:
165+
cast = self._cast_field(field)
166+
expr = f"{cast} >= '{SQL_PLACEHOLDER_VALUE_FROM}' AND {cast} <= '{SQL_PLACEHOLDER_VALUE_TO}'"
167+
168+
elif self.filter_type == FieldFilterType.CHECKBOX:
169+
expr = f"{field} = {SQL_PLACEHOLDER_VALUE}"
170+
171+
elif self.filter_type in (FieldFilterType.SINGLE_SELECT, FieldFilterType.MULTI_SELECT):
172+
expr = f"{field} IS {SQL_PLACEHOLDER_VALUE}"
173+
174+
else:
175+
expr = ""
176+
177+
self.sql_expression = expr
178+
179+
def apply_values(
180+
self,
181+
value=None,
182+
value_from=None,
183+
value_to=None,
184+
) -> str:
185+
"""Replace placeholders in sql_expression with properly quoted SQL literals. Raises ValueError if sql_expression is empty."""
186+
if not self.sql_expression:
187+
self._generate_sql_expression()
188+
189+
expr = self.sql_expression
190+
191+
uses_value = SQL_PLACEHOLDER_VALUE in expr
192+
uses_value_from = SQL_PLACEHOLDER_VALUE_FROM in expr
193+
uses_value_to = SQL_PLACEHOLDER_VALUE_TO in expr
194+
195+
if uses_value and value is None:
196+
raise ValueError("sql_expression requires 'value' but it was not provided")
197+
if uses_value_from and value_from is None:
198+
raise ValueError("sql_expression requires 'value_from' but it was not provided")
199+
if uses_value_to and value_to is None:
200+
raise ValueError("sql_expression requires 'value_to' but it was not provided")
201+
202+
if value is not None and not uses_value:
203+
raise ValueError(f"'value' was provided but sql_expression has no {SQL_PLACEHOLDER_VALUE} placeholder")
204+
if value_from is not None and not uses_value_from:
205+
raise ValueError(
206+
f"'value_from' was provided but sql_expression has no {SQL_PLACEHOLDER_VALUE_FROM} placeholder"
207+
)
208+
if value_to is not None and not uses_value_to:
209+
raise ValueError(
210+
f"'value_to' was provided but sql_expression has no {SQL_PLACEHOLDER_VALUE_TO} placeholder"
211+
)
212+
213+
if value is not None:
214+
if self.filter_type == FieldFilterType.TEXT:
215+
escaped = str(value).replace("'", "''")
216+
literal = f"'%{escaped}%'"
217+
expr = expr.replace(SQL_PLACEHOLDER_VALUE, literal)
218+
219+
elif self.filter_type == FieldFilterType.CHECKBOX:
220+
literal = "1" if value else "0"
221+
expr = expr.replace(SQL_PLACEHOLDER_VALUE, literal)
222+
223+
elif self.filter_type == FieldFilterType.SINGLE_SELECT:
224+
escaped = str(value).replace("'", "''")
225+
expr = expr.replace(SQL_PLACEHOLDER_VALUE, f"'{escaped}'")
226+
227+
if value_from is not None:
228+
if self.filter_type == FieldFilterType.DATE:
229+
expr = expr.replace(SQL_PLACEHOLDER_VALUE_FROM, f"'{value_from}'")
230+
else:
231+
expr = expr.replace(SQL_PLACEHOLDER_VALUE_FROM, str(value_from))
232+
233+
if value_to is not None:
234+
if self.filter_type == FieldFilterType.DATE:
235+
expr = expr.replace(SQL_PLACEHOLDER_VALUE_TO, f"'{value_to}'")
236+
else:
237+
expr = expr.replace(SQL_PLACEHOLDER_VALUE_TO, str(value_to))
238+
239+
return expr
240+
241+
def _cast_field(self, field: str) -> str:
242+
if self.filter_type == FieldFilterType.TEXT:
243+
cast_type = "CHARACTER"
244+
elif self.filter_type == FieldFilterType.NUMBER:
245+
cast_type = "FLOAT"
246+
elif self.filter_type == FieldFilterType.DATE:
247+
cast_type = "CHARACTER"
248+
else:
249+
return field
250+
251+
return f"CAST({field} AS {cast_type})"
252+
253+
254+
class FieldFilterModel(QAbstractListModel):
255+
"""Model to manage a list of FieldFilter objects, providing methods to add, remove, and reorder filters."""
256+
257+
def __init__(self, parent=None):
258+
super().__init__(parent)
259+
self._filters: list[FieldFilter] = []
260+
261+
def rowCount(self, parent=QModelIndex()) -> int:
262+
return len(self._filters)
263+
264+
def data(self, index: QModelIndex, role=Qt.ItemDataRole.UserRole) -> Union[str, FieldFilter, None]:
265+
if not index.isValid() or index.row() >= len(self._filters):
266+
return None
267+
f = self._filters[index.row()]
268+
if role == Qt.ItemDataRole.DisplayRole:
269+
return f.filter_name
270+
elif role == Qt.ItemDataRole.UserRole:
271+
return f
272+
return None
273+
274+
def add_filter(self, field_filter: FieldFilter):
275+
"""Add filter to the model, notifying views of the change."""
276+
self.beginInsertRows(QModelIndex(), len(self._filters), len(self._filters))
277+
self._filters.append(field_filter)
278+
self.endInsertRows()
279+
280+
def remove_filter(self, row: int):
281+
"""Remove filter at the specified row, notifying views of the change."""
282+
if 0 <= row < len(self._filters):
283+
self.beginRemoveRows(QModelIndex(), row, row)
284+
self._filters.pop(row)
285+
self.endRemoveRows()
286+
287+
def replace_filter(self, row: int, field_filter: FieldFilter) -> None:
288+
"""Replace filter at the specified row, notifying views of the change."""
289+
if 0 <= row < len(self._filters):
290+
self._filters[row] = field_filter
291+
index = self.index(row)
292+
self.dataChanged.emit(index, index)
293+
294+
def move_filter(self, row: int, offset: int) -> None:
295+
"""Move filter at the specified row by the given offset, notifying views of the change."""
296+
target = row + offset
297+
if 0 <= row < len(self._filters) and 0 <= target < len(self._filters):
298+
self._filters[row], self._filters[target] = self._filters[target], self._filters[row]
299+
top, bottom = min(row, target), max(row, target)
300+
self.dataChanged.emit(self.index(top), self.index(bottom))
301+
302+
def filter_names(self) -> List[str]:
303+
"""Get list of filter names for all filters in the model."""
304+
return [f.filter_name for f in self._filters]
305+
306+
def to_json(self) -> str:
307+
"""Serialize the list of filters in the model to a JSON string."""
308+
return field_filters_to_json(self._filters)
309+
310+
def load_from_json(self, data: str) -> None:
311+
"""Load filters from a JSON string, replacing existing filters and notifying views of the change."""
312+
self.beginResetModel()
313+
self._filters = field_filters_from_json(data)
314+
self.endResetModel()
315+
316+
317+
class DeselectableListView(QListView):
318+
"""QListView that clears selection when clicking outside items or on the already-selected item."""
319+
320+
selectionCleared = pyqtSignal(QModelIndex, QModelIndex)
321+
322+
def mousePressEvent(self, event: Optional[QMouseEvent]) -> None:
323+
if event:
324+
index = self.indexAt(event.pos())
325+
if not index.isValid():
326+
self.blockSignals(True)
327+
self.clearSelection()
328+
self.setCurrentIndex(QModelIndex())
329+
self.blockSignals(False)
330+
self.selectionCleared.emit(QModelIndex(), QModelIndex())
331+
return
332+
333+
super().mousePressEvent(event)
334+
335+
336+
def get_fields_for_checkbox(layer: QgsVectorLayer) -> QgsFields:
337+
"""Get fields of type boolean or with checkbox editor widget from the given layer."""
338+
fields = QgsFields()
339+
if layer and layer.isValid() and isinstance(layer, QgsVectorLayer):
340+
for field in layer.fields():
341+
if field.type() == QMetaType.Type.Bool or field.editorWidgetSetup().type() == "CheckBox":
342+
fields.append(field)
343+
return fields

0 commit comments

Comments
 (0)