From 6ddc471b8f1eca5de77d5908a4ecc0f93c2f902e Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Wed, 18 Mar 2026 10:59:36 +0100 Subject: [PATCH 01/25] add ui elements --- Mergin/ui/ui_project_config.ui | 184 +++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) diff --git a/Mergin/ui/ui_project_config.ui b/Mergin/ui/ui_project_config.ui index e29310cc..3be11a6b 100644 --- a/Mergin/ui/ui_project_config.ui +++ b/Mergin/ui/ui_project_config.ui @@ -500,6 +500,180 @@ + + + + Filtering + + + + + + <html><head/><body><p>Define which fields mobile app users can filter by. When enabled, the filters configured here appear in the mobile app. Not all filter types are compatible with every field. <a href="https://merginmaps.com/docs/field/filtering/"><span style=" text-decoration: underline; color:#1d99f3;">Learn more here</span></a> about filtering and how to use it in the mobile app.</p></body></html> + + + true + + + true + + + + + + + Enable filtering + + + + + + + + + false + + + + + + + + + + + Add filter + + + + :/images/themes/default/mActionAdd.svg:/images/themes/default/mActionAdd.svg + + + QToolButton::MenuButtonPopup + + + Qt::ToolButtonTextBesideIcon + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + :/images/themes/default/mActionRemove.svg:/images/themes/default/mActionRemove.svg + + + + + + + + + + + :/images/themes/default/mActionArrowUp.svg:/images/themes/default/mActionArrowUp.svg + + + + + + + + + + + :/images/themes/default/mActionArrowDown.svg:/images/themes/default/mActionArrowDown.svg + + + + + + + + + + + + false + + + + + + + + + Type + + + + + + + + + + Layer + + + + + + + Field + + + + + + + QFrame::HLine + + + QFrame::Sunken + + + + + + + Title + + + + + + + + + + + + + + + + + + + + @@ -525,6 +699,16 @@ QToolButton
qgscolorbutton.h
+ + QgsFieldComboBox + QComboBox +
qgsfieldcombobox.h
+
+ + QgsMapLayerComboBox + QComboBox +
qgsmaplayercombobox.h
+
QgsExpressionLineEdit QWidget From 3fd7fdb48d5c5dad61259a983b922eeb6bb6c22e Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Wed, 18 Mar 2026 11:23:21 +0100 Subject: [PATCH 02/25] field filtering --- Mergin/field_filtering.py | 145 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 Mergin/field_filtering.py diff --git a/Mergin/field_filtering.py b/Mergin/field_filtering.py new file mode 100644 index 00000000..2b27b446 --- /dev/null +++ b/Mergin/field_filtering.py @@ -0,0 +1,145 @@ +import json +from enum import Enum +from typing import Optional, Union, List + +from qgis.core import QgsMapLayer, QgsProviderRegistry +from qgis.PyQt.QtCore import Qt, QAbstractListModel, QModelIndex, pyqtSignal +from qgis.PyQt.QtWidgets import QListView +from qgis.PyQt.QtGui import QMouseEvent + + +class FieldFilterType(str, Enum): + SINGLE_SELECT = "Single select" + MULTI_SELECT = "Multi select" + CHECKBOX = "Checkbox" + DATE = "Date" + NUMBER = "Number" + TEXT = "Text" + + +def excluded_filtering_providers() -> List[str]: + """Get list of providers to exclude from layer selection in field filter settings.""" + excluded_providers = QgsProviderRegistry.instance().providerList() + excluded_providers.remove("ogr") + excluded_providers.remove("postgres") + return excluded_providers + + +def field_filters_to_json(filters: List["FieldFilter"]) -> str: + """Serialize a list of FieldFilter objects to a JSON string.""" + return json.dumps([f.to_json() for f in filters]) + + +def field_filters_from_json(data: str) -> List["FieldFilter"]: + """Deserialize a JSON string into a list of FieldFilter objects.""" + return [FieldFilter.from_json(item) for item in json.loads(data)] + + +class FieldFilter: + + def __init__(self, layer: QgsMapLayer, field_name: str, filter_type: FieldFilterType, filter_name: str): + provider = layer.dataProvider() + self.layer_id = layer.id() + self.provider = provider.name() if provider else "" + self.field_name = field_name + self.filter_type = filter_type + self.filter_name = filter_name + self.sql_expression = "" + + @classmethod + def from_json(cls, data: dict) -> "FieldFilter": + """Create a FieldFilter instance from a JSON dictionary""" + f = object.__new__(cls) + f.layer_id = data["layer_id"] + f.provider = data.get("provider", "") + f.field_name = data["field_name"] + f.filter_type = FieldFilterType(data["filter_type"]) + f.filter_name = data["filter_name"] + f.sql_expression = data.get("sql_expression", "") + return f + + def to_json(self) -> dict: + """Convert the object to a JSON-serializable dictionary""" + return { + "layer_id": self.layer_id, + "provider": self.provider, + "field_name": self.field_name, + "filter_type": self.filter_type.value, + "filter_name": self.filter_name, + "sql_expression": self.sql_expression, + } + + +class FieldFilterModel(QAbstractListModel): + """Model to manage a list of FieldFilter objects, providing methods to add, remove, and reorder filters.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._filters: list[FieldFilter] = [] + + def rowCount(self, parent=QModelIndex()) -> int: + return len(self._filters) + + def data(self, index: QModelIndex, role=Qt.ItemDataRole.UserRole) -> Union[str, FieldFilter, None]: + if not index.isValid() or index.row() >= len(self._filters): + return None + f = self._filters[index.row()] + if role == Qt.ItemDataRole.DisplayRole: + return f.filter_name + elif role == Qt.ItemDataRole.UserRole: + return f + return None + + def add_filter(self, field_filter: FieldFilter): + """Add filter to the model, notifying views of the change.""" + self.beginInsertRows(QModelIndex(), len(self._filters), len(self._filters)) + self._filters.append(field_filter) + self.endInsertRows() + + def remove_filter(self, row: int): + """Remove filter at the specified row, notifying views of the change.""" + if 0 <= row < len(self._filters): + self.beginRemoveRows(QModelIndex(), row, row) + self._filters.pop(row) + self.endRemoveRows() + + def move_filter(self, row: int, offset: int) -> None: + """Move filter at the specified row by the given offset, notifying views of the change.""" + target = row + offset + if 0 <= row < len(self._filters) and 0 <= target < len(self._filters): + self._filters[row], self._filters[target] = self._filters[target], self._filters[row] + top, bottom = min(row, target), max(row, target) + self.dataChanged.emit(self.index(top), self.index(bottom)) + + def filter_names(self) -> List[str]: + """Get list of filter names for all filters in the model.""" + return [f.filter_name for f in self._filters] + + def to_json(self) -> str: + """Serialize the list of filters in the model to a JSON string.""" + return field_filters_to_json(self._filters) + + def load_from_json(self, data: str) -> None: + """Load filters from a JSON string, replacing existing filters and notifying views of the change.""" + self.beginResetModel() + self._filters = field_filters_from_json(data) + self.endResetModel() + + +class DeselectableListView(QListView): + """QListView that clears selection when clicking outside items or on the already-selected item.""" + + selectionCleared = pyqtSignal(QModelIndex, QModelIndex) + + def mousePressEvent(self, event: Optional[QMouseEvent]) -> None: + if event: + index = self.indexAt(event.pos()) + if not index.isValid() or index == self.currentIndex(): + self.blockSignals(True) + self.clearSelection() + self.setCurrentIndex(QModelIndex()) + self.blockSignals(False) + self.selectionCleared.emit(QModelIndex(), QModelIndex()) + return + + super().mousePressEvent(event) From 68af8241a5d311692e80be2d4be1bb30719a6f5a Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Wed, 18 Mar 2026 11:24:54 +0100 Subject: [PATCH 03/25] add filtering to project settings --- Mergin/project_settings_widget.py | 164 +++++++++++++++++++++++++++++- 1 file changed, 161 insertions(+), 3 deletions(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index 67027ffa..88435501 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -4,10 +4,11 @@ import json import os import typing + from qgis.PyQt import uic from qgis.PyQt.QtGui import QIcon, QColor -from qgis.PyQt.QtCore import Qt -from qgis.PyQt.QtWidgets import QFileDialog, QMessageBox +from qgis.PyQt.QtCore import Qt, QModelIndex +from qgis.PyQt.QtWidgets import QFileDialog, QMessageBox, QGroupBox, QComboBox, QListView from qgis.core import ( QgsProject, QgsExpressionContext, @@ -16,8 +17,15 @@ QgsFeatureRequest, QgsExpression, QgsMapLayer, + QgsFieldProxyModel, +) +from qgis.gui import ( + QgsOptionsWidgetFactory, + QgsOptionsPageWidget, + QgsColorButton, + QgsMapLayerComboBox, + QgsFieldComboBox, ) -from qgis.gui import QgsOptionsWidgetFactory, QgsOptionsPageWidget, QgsColorButton from .attachment_fields_model import AttachmentFieldsModel from .utils import ( mm_symbol_path, @@ -33,6 +41,13 @@ escape_html_minimal, sanitize_path, ) +from .field_filtering import ( + FieldFilterType, + FieldFilter, + FieldFilterModel, + DeselectableListView, + excluded_filtering_providers, +) ui_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ui", "ui_project_config.ui") ProjectConfigUiWidget, _ = uic.loadUiType(ui_file) @@ -53,6 +68,13 @@ def createWidget(self, parent): class ProjectConfigWidget(ProjectConfigUiWidget, QgsOptionsPageWidget): + + cmb_filter_type: QComboBox + cmb_filter_layer: QgsMapLayerComboBox + cmb_filter_field: QgsFieldComboBox + groupBox_filters_list: QGroupBox + groupBox_filter_detail: QGroupBox + def __init__(self, parent=None): QgsOptionsPageWidget.__init__(self, parent) self.setupUi(self) @@ -119,6 +141,39 @@ def __init__(self, parent=None): idx = self.cmb_sort_method.findData(mode) if ok else 1 self.cmb_sort_method.setCurrentIndex(idx) + self.filters_model = FieldFilterModel() + self.btn_add_filter.clicked.connect(self.on_add_filter_clicked) + self.btn_remove_filter.clicked.connect(self.on_remove_filter_clicked) + self.btn_move_filter_up.clicked.connect(self.on_move_filter_up_clicked) + self.btn_move_filter_down.clicked.connect(self.on_move_filter_down_clicked) + + self.lst_filters = DeselectableListView(self) + self.groupBox_filters_list.layout().insertWidget(0, self.lst_filters) + self.lst_filters.setModel(self.filters_model) + self.lst_filters.selectionCleared.connect(self.on_filter_selection_changed) + self.lst_filters.selectionModel().selectionChanged.connect(self._update_filter_buttons) + self.lst_filters.selectionModel().currentChanged.connect(self.on_filter_selection_changed) + + enabled, _ = QgsProject.instance().readBoolEntry("Mergin", "Filtering/Enabled", False) + self.chk_filtering_enabled.setChecked(enabled) + self.groupBox_filters_list.setEnabled(enabled) + self.groupBox_filter_detail.setEnabled(enabled) + self.chk_filtering_enabled.stateChanged.connect(self.on_filtering_state_changed) + + filters_json, _ = QgsProject.instance().readEntry("Mergin", "Filtering/Filters", "[]") + self.filters_model.load_from_json(filters_json) + + self.cmb_filter_layer.setAllowEmptyLayer(True) + self.cmb_filter_layer.setExcludedProviders(excluded_filtering_providers()) + self.cmb_filter_layer.layerChanged.connect(self.on_filter_layer_fields_changed) + + for f in FieldFilterType: + self.cmb_filter_type.addItem(f.value, f) + self.cmb_filter_type.currentIndexChanged.connect(self.on_filter_layer_fields_changed) + + self._update_filter_buttons() + self.on_filter_layer_fields_changed() + self.local_project_dir = mergin_project_local_path() if self.local_project_dir: @@ -351,6 +406,9 @@ def apply(self): self.setup_tracking() self.setup_map_sketches() + QgsProject.instance().writeEntry("Mergin", "Filtering/Enabled", self.chk_filtering_enabled.isChecked()) + QgsProject.instance().writeEntry("Mergin", "Filtering/Filters", self.filters_model.to_json()) + def colors_change_state(self) -> None: """ Enable/disable color buttons based on the state of the map sketches checkbox. @@ -359,3 +417,103 @@ def colors_change_state(self) -> None: item = self.mColorsHorizontalLayout.itemAt(i).widget() if isinstance(item, QgsColorButton): item.setEnabled(self.chk_map_sketches_enabled.isChecked()) + + def on_filtering_state_changed(self, state: Qt.CheckState) -> None: + """ + Enable/disable filtering options based on the state of the filtering checkbox. + """ + if state == Qt.CheckState.Checked: + self.groupBox_filters_list.setEnabled(True) + self.groupBox_filter_detail.setEnabled(True) + else: + self.groupBox_filters_list.setEnabled(False) + self.groupBox_filter_detail.setEnabled(False) + + def on_add_filter_clicked(self) -> None: + layer = self.cmb_filter_layer.currentLayer() + field_name = self.cmb_filter_field.currentField() + filter_type = self.cmb_filter_type.currentData() + filter_name = self.edit_filter_title.text().strip() + + if not layer or not layer.isValid(): + return + if not field_name: + return + if not filter_name: + return + + self.filters_model.add_filter( + FieldFilter( + layer=layer, + field_name=field_name, + filter_type=filter_type, + filter_name=filter_name, + ) + ) + + def on_filter_selection_changed(self, current: QModelIndex, previous: QModelIndex) -> None: + field_filter: typing.Optional[FieldFilter] = self.filters_model.data(current, Qt.ItemDataRole.UserRole) + if field_filter is None: + self.cmb_filter_layer.setLayer(None) + self.cmb_filter_field.setLayer(None) + self.cmb_filter_type.setCurrentIndex(0) + self.edit_filter_title.clear() + return + + layer = QgsProject.instance().mapLayer(field_filter.layer_id) + + self.cmb_filter_layer.blockSignals(True) + self.cmb_filter_layer.setLayer(layer) + self.cmb_filter_layer.blockSignals(False) + + self.cmb_filter_field.blockSignals(True) + self.cmb_filter_field.setLayer(layer) + self.cmb_filter_field.setField(field_filter.field_name) + self.cmb_filter_field.blockSignals(False) + + idx = self.cmb_filter_type.findData(field_filter.filter_type) + self.cmb_filter_type.blockSignals(True) + self.cmb_filter_type.setCurrentIndex(idx) + self.cmb_filter_type.blockSignals(False) + + self.edit_filter_title.setText(field_filter.filter_name) + + def _update_filter_buttons(self) -> None: + has_selection = self.lst_filters.selectionModel().hasSelection() + self.btn_remove_filter.setEnabled(has_selection) + self.btn_move_filter_up.setEnabled(has_selection) + self.btn_move_filter_down.setEnabled(has_selection) + + def on_remove_filter_clicked(self) -> None: + row = self.lst_filters.currentIndex().row() + self.filters_model.remove_filter(row) + + def on_move_filter_up_clicked(self) -> None: + row = self.lst_filters.currentIndex().row() + self.filters_model.move_filter(row, -1) + self.lst_filters.setCurrentIndex(self.filters_model.index(row - 1)) + + def on_move_filter_down_clicked(self) -> None: + row = self.lst_filters.currentIndex().row() + self.filters_model.move_filter(row, 1) + self.lst_filters.setCurrentIndex(self.filters_model.index(row + 1)) + + def on_filter_layer_fields_changed(self) -> None: + """ + Update the fields in the filter field combo box based on the selected layer. + """ + layer = self.cmb_filter_layer.currentLayer() + self.cmb_filter_field.setLayer(layer) + match self.cmb_filter_type.currentData(): + case FieldFilterType.SINGLE_SELECT: + self.cmb_filter_field.setFilters(QgsFieldProxyModel.Filter.AllTypes) + case FieldFilterType.MULTI_SELECT: + self.cmb_filter_field.setFilters(QgsFieldProxyModel.Filter.AllTypes) + case FieldFilterType.CHECKBOX: + self.cmb_filter_field.setFilters(QgsFieldProxyModel.Filter.Boolean) + case FieldFilterType.DATE: + self.cmb_filter_field.setFilters(QgsFieldProxyModel.Filter.Date) + case FieldFilterType.NUMBER: + self.cmb_filter_field.setFilters(QgsFieldProxyModel.Filter.Numeric | QgsFieldProxyModel.Filter.String) + case FieldFilterType.TEXT: + self.cmb_filter_field.setFilters(QgsFieldProxyModel.Filter.String | QgsFieldProxyModel.Filter.Numeric) From 1f113863a64f348e00cb6a437fc129bd0b1a123e Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Wed, 18 Mar 2026 12:05:18 +0100 Subject: [PATCH 04/25] switch from match-case to if-esleif --- Mergin/project_settings_widget.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index 88435501..1fb2a3ae 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -504,16 +504,14 @@ def on_filter_layer_fields_changed(self) -> None: """ layer = self.cmb_filter_layer.currentLayer() self.cmb_filter_field.setLayer(layer) - match self.cmb_filter_type.currentData(): - case FieldFilterType.SINGLE_SELECT: - self.cmb_filter_field.setFilters(QgsFieldProxyModel.Filter.AllTypes) - case FieldFilterType.MULTI_SELECT: - self.cmb_filter_field.setFilters(QgsFieldProxyModel.Filter.AllTypes) - case FieldFilterType.CHECKBOX: - self.cmb_filter_field.setFilters(QgsFieldProxyModel.Filter.Boolean) - case FieldFilterType.DATE: - self.cmb_filter_field.setFilters(QgsFieldProxyModel.Filter.Date) - case FieldFilterType.NUMBER: - self.cmb_filter_field.setFilters(QgsFieldProxyModel.Filter.Numeric | QgsFieldProxyModel.Filter.String) - case FieldFilterType.TEXT: - self.cmb_filter_field.setFilters(QgsFieldProxyModel.Filter.String | QgsFieldProxyModel.Filter.Numeric) + filter_type = self.cmb_filter_type.currentData() + if filter_type in (FieldFilterType.SINGLE_SELECT, FieldFilterType.MULTI_SELECT): + self.cmb_filter_field.setFilters(QgsFieldProxyModel.Filter.AllTypes) + elif filter_type == FieldFilterType.CHECKBOX: + self.cmb_filter_field.setFilters(QgsFieldProxyModel.Filter.Boolean) + elif filter_type == FieldFilterType.DATE: + self.cmb_filter_field.setFilters(QgsFieldProxyModel.Filter.Date) + elif filter_type in (FieldFilterType.NUMBER, FieldFilterType.TEXT): + self.cmb_filter_field.setFilters( + QgsFieldProxyModel.Filter(QgsFieldProxyModel.Filter.Numeric | QgsFieldProxyModel.Filter.String) + ) From bc132d39002f021404eac604554734f29e2d14c1 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Wed, 18 Mar 2026 12:09:27 +0100 Subject: [PATCH 05/25] remove unused --- Mergin/project_settings_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index 1fb2a3ae..a7dba176 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -8,7 +8,7 @@ from qgis.PyQt import uic from qgis.PyQt.QtGui import QIcon, QColor from qgis.PyQt.QtCore import Qt, QModelIndex -from qgis.PyQt.QtWidgets import QFileDialog, QMessageBox, QGroupBox, QComboBox, QListView +from qgis.PyQt.QtWidgets import QFileDialog, QMessageBox, QGroupBox, QComboBox from qgis.core import ( QgsProject, QgsExpressionContext, From c6526ea40764921495d9debf693c6cdaf961fd3f Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Wed, 18 Mar 2026 17:52:15 +0100 Subject: [PATCH 06/25] rename for clarity --- Mergin/field_filtering.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Mergin/field_filtering.py b/Mergin/field_filtering.py index 2b27b446..92106566 100644 --- a/Mergin/field_filtering.py +++ b/Mergin/field_filtering.py @@ -27,12 +27,12 @@ def excluded_filtering_providers() -> List[str]: def field_filters_to_json(filters: List["FieldFilter"]) -> str: """Serialize a list of FieldFilter objects to a JSON string.""" - return json.dumps([f.to_json() for f in filters]) + return json.dumps([f.to_dict() for f in filters]) def field_filters_from_json(data: str) -> List["FieldFilter"]: """Deserialize a JSON string into a list of FieldFilter objects.""" - return [FieldFilter.from_json(item) for item in json.loads(data)] + return [FieldFilter.from_dict(item) for item in json.loads(data)] class FieldFilter: @@ -47,8 +47,8 @@ def __init__(self, layer: QgsMapLayer, field_name: str, filter_type: FieldFilter self.sql_expression = "" @classmethod - def from_json(cls, data: dict) -> "FieldFilter": - """Create a FieldFilter instance from a JSON dictionary""" + def from_dict(cls, data: dict) -> "FieldFilter": + """Create a FieldFilter instance from a dictionary""" f = object.__new__(cls) f.layer_id = data["layer_id"] f.provider = data.get("provider", "") @@ -58,8 +58,8 @@ def from_json(cls, data: dict) -> "FieldFilter": f.sql_expression = data.get("sql_expression", "") return f - def to_json(self) -> dict: - """Convert the object to a JSON-serializable dictionary""" + def to_dict(self) -> dict: + """Convert the object to a dictionary""" return { "layer_id": self.layer_id, "provider": self.provider, From 1c9b29239936b558bd2c14bd49fd411e02ce9826 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Wed, 18 Mar 2026 18:20:22 +0100 Subject: [PATCH 07/25] do not require specify python version for running precomit --- .pre-commit-config.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 64802134..c4cfa777 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,4 +4,3 @@ repos: rev: 25.1.0 hooks: - id: black - language_version: python3.10 \ No newline at end of file From b946f59bb72d744ff701804b40b041295e9b7a62 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Wed, 18 Mar 2026 18:23:33 +0100 Subject: [PATCH 08/25] make precommit the same settings as github workflow --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c4cfa777..42d2e621 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,3 +4,4 @@ repos: rev: 25.1.0 hooks: - id: black + args: [--line-length=120] From 487aa07823ea1324b734beb98ced795ba87b9a12 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Wed, 18 Mar 2026 18:24:06 +0100 Subject: [PATCH 09/25] do some validation checks before creating --- Mergin/field_filtering.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Mergin/field_filtering.py b/Mergin/field_filtering.py index 92106566..6a691fc9 100644 --- a/Mergin/field_filtering.py +++ b/Mergin/field_filtering.py @@ -2,7 +2,7 @@ from enum import Enum from typing import Optional, Union, List -from qgis.core import QgsMapLayer, QgsProviderRegistry +from qgis.core import QgsProviderRegistry, QgsVectorLayer from qgis.PyQt.QtCore import Qt, QAbstractListModel, QModelIndex, pyqtSignal from qgis.PyQt.QtWidgets import QListView from qgis.PyQt.QtGui import QMouseEvent @@ -37,10 +37,22 @@ def field_filters_from_json(data: str) -> List["FieldFilter"]: class FieldFilter: - def __init__(self, layer: QgsMapLayer, field_name: str, filter_type: FieldFilterType, filter_name: str): + def __init__( + self, + layer: QgsVectorLayer, + field_name: str, + filter_type: FieldFilterType, + filter_name: str, + ): + if not isinstance(layer, QgsVectorLayer): + raise ValueError("layer must be a QgsVectorLayer") + + if field_name not in layer.fields().names(): + raise ValueError(f"Field '{field_name}' does not exist in layer '{layer.name()}'") + provider = layer.dataProvider() - self.layer_id = layer.id() self.provider = provider.name() if provider else "" + self.layer_id = layer.id() self.field_name = field_name self.filter_type = filter_type self.filter_name = filter_name From 57540dd339c5f3ebf3cbd5eda9af0fc1ee4f84d9 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Tue, 24 Mar 2026 12:46:36 +0100 Subject: [PATCH 10/25] replace filter --- Mergin/field_filtering.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Mergin/field_filtering.py b/Mergin/field_filtering.py index 6a691fc9..0922b234 100644 --- a/Mergin/field_filtering.py +++ b/Mergin/field_filtering.py @@ -115,6 +115,13 @@ def remove_filter(self, row: int): self._filters.pop(row) self.endRemoveRows() + def replace_filter(self, row: int, field_filter: FieldFilter) -> None: + """Replace filter at the specified row, notifying views of the change.""" + if 0 <= row < len(self._filters): + self._filters[row] = field_filter + index = self.index(row) + self.dataChanged.emit(index, index) + def move_filter(self, row: int, offset: int) -> None: """Move filter at the specified row by the given offset, notifying views of the change.""" target = row + offset From b72297b89a5d8fe4e5b2fce4e3e40bdb24119e01 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Tue, 24 Mar 2026 12:51:11 +0100 Subject: [PATCH 11/25] allow constrution of incomplete fieldfilter --- Mergin/field_filtering.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/Mergin/field_filtering.py b/Mergin/field_filtering.py index 0922b234..0578256d 100644 --- a/Mergin/field_filtering.py +++ b/Mergin/field_filtering.py @@ -39,25 +39,33 @@ class FieldFilter: def __init__( self, - layer: QgsVectorLayer, + layer: Optional[QgsVectorLayer], field_name: str, filter_type: FieldFilterType, filter_name: str, ): - if not isinstance(layer, QgsVectorLayer): + if layer is not None and not isinstance(layer, QgsVectorLayer): raise ValueError("layer must be a QgsVectorLayer") - if field_name not in layer.fields().names(): + if layer is not None and field_name not in layer.fields().names(): raise ValueError(f"Field '{field_name}' does not exist in layer '{layer.name()}'") - provider = layer.dataProvider() - self.provider = provider.name() if provider else "" - self.layer_id = layer.id() + self.provider = "" + self.layer_id = "" + + if layer is not None: + provider = layer.dataProvider() + self.provider = provider.name() if provider else "" + self.layer_id = layer.id() + self.field_name = field_name self.filter_type = filter_type self.filter_name = filter_name self.sql_expression = "" + if layer is not None: + self._generate_sql_expression() + @classmethod def from_dict(cls, data: dict) -> "FieldFilter": """Create a FieldFilter instance from a dictionary""" From 1386fbb5fe8c15d815ddb490c4879f56f4a0e1c0 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Tue, 24 Mar 2026 13:14:07 +0100 Subject: [PATCH 12/25] property and equal --- Mergin/field_filtering.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Mergin/field_filtering.py b/Mergin/field_filtering.py index 0578256d..6f36a9a1 100644 --- a/Mergin/field_filtering.py +++ b/Mergin/field_filtering.py @@ -89,6 +89,21 @@ def to_dict(self) -> dict: "sql_expression": self.sql_expression, } + @property + def is_postgres(self) -> bool: + return self.provider == "postgres" + + def __eq__(self, value: object) -> bool: + if not isinstance(value, FieldFilter): + return NotImplemented + return ( + self.layer_id == value.layer_id + and self.provider == value.provider + and self.field_name == value.field_name + and self.filter_type == value.filter_type + and self.filter_name == value.filter_name + ) + class FieldFilterModel(QAbstractListModel): """Model to manage a list of FieldFilter objects, providing methods to add, remove, and reorder filters.""" From 4cc56123fc5cbeb24b996d1ddb4d7f1f0e26919f Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Tue, 24 Mar 2026 13:14:42 +0100 Subject: [PATCH 13/25] sql generation --- Mergin/field_filtering.py | 148 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/Mergin/field_filtering.py b/Mergin/field_filtering.py index 6f36a9a1..ad63b4ec 100644 --- a/Mergin/field_filtering.py +++ b/Mergin/field_filtering.py @@ -8,6 +8,12 @@ from qgis.PyQt.QtGui import QMouseEvent +SQL_PLACEHOLDER_VALUE = "%%value%%" +SQL_PLACEHOLDER_VALUES = "%%values%%" +SQL_PLACEHOLDER_VALUE_FROM = "%%value_from%%" +SQL_PLACEHOLDER_VALUE_TO = "%%value_to%%" + + class FieldFilterType(str, Enum): SINGLE_SELECT = "Single select" MULTI_SELECT = "Multi select" @@ -104,6 +110,148 @@ def __eq__(self, value: object) -> bool: and self.filter_name == value.filter_name ) + def _generate_sql_expression(self) -> None: + """Generate a SQL WHERE clause template with named value placeholders. + + Every placeholder is replaced entirely by the substituting code, which must + supply a complete, properly-quoted SQL literal for the target provider. + + Placeholders: + SQL_PLACEHOLDER_VALUE + — single value (TEXT, CHECKBOX, SINGLE_SELECT) + e.g. '%hello%' for LIKE, 'text', 42, true + SQL_PLACEHOLDER_VALUE_FROM + — lower bound of a range (NUMBER, DATE) + e.g. 10, '2024-01-01' + SQL_PLACEHOLDER_VALUE_TO + — upper bound of a range (NUMBER, DATE) + SQL_PLACEHOLDER_VALUES + — comma-separated literals for MULTI_SELECT + e.g. 'a', 'b', 'c' or 1, 2, 3 + """ + field = f'"{self.field_name}"' + + if self.filter_type == FieldFilterType.TEXT: + op = "ILIKE" if self.is_postgres else "LIKE" + cast = self._cast_field(field) + expr = f"{cast} {op} {SQL_PLACEHOLDER_VALUE}" + + elif self.filter_type == FieldFilterType.NUMBER: + cast = self._cast_field(field) + expr = f"{cast} >= {SQL_PLACEHOLDER_VALUE_FROM} AND {cast} <= {SQL_PLACEHOLDER_VALUE_TO}" + + elif self.filter_type == FieldFilterType.DATE: + cast = self._cast_field(field) + expr = f"{cast} >= {SQL_PLACEHOLDER_VALUE_FROM} AND {cast} <= {SQL_PLACEHOLDER_VALUE_TO}" + + elif self.filter_type == FieldFilterType.CHECKBOX: + expr = f"{field} = {SQL_PLACEHOLDER_VALUE}" + + elif self.filter_type == FieldFilterType.SINGLE_SELECT: + expr = f"{field} = {SQL_PLACEHOLDER_VALUE}" + + elif self.filter_type == FieldFilterType.MULTI_SELECT: + expr = f"{field} IN ({SQL_PLACEHOLDER_VALUES})" + + else: + expr = "" + + self.sql_expression = expr + + def apply_values( + self, + value=None, + values=None, + value_from=None, + value_to=None, + ) -> str: + """Replace placeholders in sql_expression with properly quoted SQL literals. Raises ValueError if sql_expression is empty.""" + if not self.sql_expression: + self._generate_sql_expression() + + expr = self.sql_expression + + uses_value = SQL_PLACEHOLDER_VALUE in expr + uses_values = SQL_PLACEHOLDER_VALUES in expr + uses_value_from = SQL_PLACEHOLDER_VALUE_FROM in expr + uses_value_to = SQL_PLACEHOLDER_VALUE_TO in expr + + if uses_value and value is None: + raise ValueError("sql_expression requires 'value' but it was not provided") + if uses_values and values is None: + raise ValueError("sql_expression requires 'values' but it was not provided") + if uses_value_from and value_from is None: + raise ValueError("sql_expression requires 'value_from' but it was not provided") + if uses_value_to and value_to is None: + raise ValueError("sql_expression requires 'value_to' but it was not provided") + + if value is not None and not uses_value: + raise ValueError(f"'value' was provided but sql_expression has no {SQL_PLACEHOLDER_VALUE} placeholder") + if values is not None and not uses_values: + raise ValueError(f"'values' was provided but sql_expression has no {SQL_PLACEHOLDER_VALUES} placeholder") + if value_from is not None and not uses_value_from: + raise ValueError( + f"'value_from' was provided but sql_expression has no {SQL_PLACEHOLDER_VALUE_FROM} placeholder" + ) + if value_to is not None and not uses_value_to: + raise ValueError( + f"'value_to' was provided but sql_expression has no {SQL_PLACEHOLDER_VALUE_TO} placeholder" + ) + + if value is not None: + if self.filter_type == FieldFilterType.TEXT: + escaped = str(value).replace("'", "''") + literal = f"'%{escaped}%'" + expr = expr.replace(SQL_PLACEHOLDER_VALUE, literal) + + elif self.filter_type == FieldFilterType.CHECKBOX: + if self.is_postgres: + literal = "TRUE" if value else "FALSE" + else: + literal = "1" if value else "0" + expr = expr.replace(SQL_PLACEHOLDER_VALUE, literal) + + elif self.filter_type == FieldFilterType.SINGLE_SELECT: + escaped = str(value).replace("'", "''") + expr = expr.replace(SQL_PLACEHOLDER_VALUE, f"'{escaped}'") + + if values is not None: + items = [f"'{str(v).replace(chr(39), chr(39) * 2)}'" for v in values] + expr = expr.replace(SQL_PLACEHOLDER_VALUES, ", ".join(items)) + + if value_from is not None: + if self.filter_type == FieldFilterType.DATE: + expr = expr.replace(SQL_PLACEHOLDER_VALUE_FROM, f"'{value_from}'") + else: + expr = expr.replace(SQL_PLACEHOLDER_VALUE_FROM, str(value_from)) + + if value_to is not None: + if self.filter_type == FieldFilterType.DATE: + expr = expr.replace(SQL_PLACEHOLDER_VALUE_TO, f"'{value_to}'") + else: + expr = expr.replace(SQL_PLACEHOLDER_VALUE_TO, str(value_to)) + + return expr + + def _cast_field(self, field: str) -> str: + """Wrap field in a CAST expression matching the filter type and provider. + + Cast types: + TEXT — CHARACTER (OGR) / text (PostgreSQL) + NUMBER — FLOAT (OGR) / numeric (PostgreSQL) + DATE — DATE (OGR) / timestamp (PostgreSQL) + """ + if self.filter_type == FieldFilterType.TEXT: + cast_type = "text" if self.is_postgres else "CHARACTER" + elif self.filter_type == FieldFilterType.NUMBER: + cast_type = "numeric" if self.is_postgres else "FLOAT" + elif self.filter_type == FieldFilterType.DATE: + cast_type = "timestamp" if self.is_postgres else "DATE" + else: + return field + + return f"CAST({field} AS {cast_type})" + class FieldFilterModel(QAbstractListModel): """Model to manage a list of FieldFilter objects, providing methods to add, remove, and reorder filters.""" From e600ac2c04070a08489e30a6efb91d450eeddaae Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Tue, 24 Mar 2026 13:17:24 +0100 Subject: [PATCH 14/25] emptying selection of filter and enabling and disabling edits --- Mergin/project_settings_widget.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index a7dba176..80b925c2 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -150,7 +150,7 @@ def __init__(self, parent=None): self.lst_filters = DeselectableListView(self) self.groupBox_filters_list.layout().insertWidget(0, self.lst_filters) self.lst_filters.setModel(self.filters_model) - self.lst_filters.selectionCleared.connect(self.on_filter_selection_changed) + self.lst_filters.selectionCleared.connect(self.on_filter_selection_removed) self.lst_filters.selectionModel().selectionChanged.connect(self._update_filter_buttons) self.lst_filters.selectionModel().currentChanged.connect(self.on_filter_selection_changed) @@ -451,15 +451,24 @@ def on_add_filter_clicked(self) -> None: ) ) + def _clear_filter_values(self) -> None: + self.cmb_filter_layer.setLayer(None) + self.cmb_filter_field.setLayer(None) + self.cmb_filter_type.setCurrentIndex(0) + self.edit_filter_title.clear() + + def on_filter_selection_removed(self, selected: QModelIndex, previous: QModelIndex) -> None: + self.groupBox_filter_detail.setEnabled(False) + self._clear_filter_values() + def on_filter_selection_changed(self, current: QModelIndex, previous: QModelIndex) -> None: field_filter: typing.Optional[FieldFilter] = self.filters_model.data(current, Qt.ItemDataRole.UserRole) if field_filter is None: - self.cmb_filter_layer.setLayer(None) - self.cmb_filter_field.setLayer(None) - self.cmb_filter_type.setCurrentIndex(0) - self.edit_filter_title.clear() + self._clear_filter_values() return + self.groupBox_filter_detail.setEnabled(True) + layer = QgsProject.instance().mapLayer(field_filter.layer_id) self.cmb_filter_layer.blockSignals(True) @@ -476,7 +485,10 @@ def on_filter_selection_changed(self, current: QModelIndex, previous: QModelInde self.cmb_filter_type.setCurrentIndex(idx) self.cmb_filter_type.blockSignals(False) + # block signals to avoid triggering modification of the field filter + self.edit_filter_title.blockSignals(True) self.edit_filter_title.setText(field_filter.filter_name) + self.edit_filter_title.blockSignals(False) def _update_filter_buttons(self) -> None: has_selection = self.lst_filters.selectionModel().hasSelection() From 45472234bea20912ee7a291f85360a5d35f0639d Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Tue, 24 Mar 2026 13:18:12 +0100 Subject: [PATCH 15/25] clear and disable filter editing on creation --- Mergin/project_settings_widget.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index 80b925c2..d8b53278 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -174,6 +174,10 @@ def __init__(self, parent=None): self._update_filter_buttons() self.on_filter_layer_fields_changed() + # clear filter values and disable filter details until we load actual filter from list view + self._clear_filter_values() + self.groupBox_filter_detail.setEnabled(False) + self.local_project_dir = mergin_project_local_path() if self.local_project_dir: From 43f0e8112f0e97f0092d0b77ffc960cac1988202 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Tue, 24 Mar 2026 13:51:50 +0100 Subject: [PATCH 16/25] change ordering --- Mergin/field_filtering.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Mergin/field_filtering.py b/Mergin/field_filtering.py index ad63b4ec..c428e0d0 100644 --- a/Mergin/field_filtering.py +++ b/Mergin/field_filtering.py @@ -15,12 +15,12 @@ class FieldFilterType(str, Enum): + TEXT = "Text" + NUMBER = "Number" + DATE = "Date" + CHECKBOX = "Checkbox" SINGLE_SELECT = "Single select" MULTI_SELECT = "Multi select" - CHECKBOX = "Checkbox" - DATE = "Date" - NUMBER = "Number" - TEXT = "Text" def excluded_filtering_providers() -> List[str]: From 8c602a9bbd7da816b686bfe13bf8daacfbaefd65 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Tue, 24 Mar 2026 13:52:41 +0100 Subject: [PATCH 17/25] add unnamed filter --- Mergin/project_settings_widget.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index d8b53278..05aa98b7 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -4,11 +4,12 @@ import json import os import typing +from functools import partial from qgis.PyQt import uic from qgis.PyQt.QtGui import QIcon, QColor from qgis.PyQt.QtCore import Qt, QModelIndex -from qgis.PyQt.QtWidgets import QFileDialog, QMessageBox, QGroupBox, QComboBox +from qgis.PyQt.QtWidgets import QAction, QFileDialog, QMenu, QMessageBox, QGroupBox, QComboBox from qgis.core import ( QgsProject, QgsExpressionContext, @@ -147,6 +148,13 @@ def __init__(self, parent=None): self.btn_move_filter_up.clicked.connect(self.on_move_filter_up_clicked) self.btn_move_filter_down.clicked.connect(self.on_move_filter_down_clicked) + add_filter_menu = QMenu(self) + for filter_type in FieldFilterType: + action = QAction(filter_type.value, self) + action.triggered.connect(partial(self.add_unnamed_filter, filter_type)) + add_filter_menu.addAction(action) + self.btn_add_filter.setMenu(add_filter_menu) + self.lst_filters = DeselectableListView(self) self.groupBox_filters_list.layout().insertWidget(0, self.lst_filters) self.lst_filters.setModel(self.filters_model) @@ -531,3 +539,10 @@ def on_filter_layer_fields_changed(self) -> None: self.cmb_filter_field.setFilters( QgsFieldProxyModel.Filter(QgsFieldProxyModel.Filter.Numeric | QgsFieldProxyModel.Filter.String) ) + + def add_unnamed_filter(self, field_filter_type: FieldFilterType) -> None: + """Create a default field filter with specific type and then select it in the list view to allow user to edit it right away.""" + self.filters_model.add_filter( + FieldFilter(layer=None, field_name="", filter_type=field_filter_type, filter_name="Unnamed Filter") + ) + self.lst_filters.setCurrentIndex(self.filters_model.index(self.filters_model.rowCount() - 1)) From 24669800b7d11df23c63ea8e5a3db6deacdd0344 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Tue, 24 Mar 2026 14:09:50 +0100 Subject: [PATCH 18/25] fix --- Mergin/ui/ui_project_config.ui | 260 ++++++++++++++++----------------- 1 file changed, 130 insertions(+), 130 deletions(-) diff --git a/Mergin/ui/ui_project_config.ui b/Mergin/ui/ui_project_config.ui index 3be11a6b..8432a6d7 100644 --- a/Mergin/ui/ui_project_config.ui +++ b/Mergin/ui/ui_project_config.ui @@ -527,84 +527,84 @@
- + false - - - - - - - - - - Add filter - - - - :/images/themes/default/mActionAdd.svg:/images/themes/default/mActionAdd.svg - - - QToolButton::MenuButtonPopup - - - Qt::ToolButtonTextBesideIcon - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - :/images/themes/default/mActionRemove.svg:/images/themes/default/mActionRemove.svg - - - - - - - - - - - :/images/themes/default/mActionArrowUp.svg:/images/themes/default/mActionArrowUp.svg - - - - - - - - - - - :/images/themes/default/mActionArrowDown.svg:/images/themes/default/mActionArrowDown.svg - - - - - - + + + + + + + + + + Add filter + + + + :/images/themes/default/mActionAdd.svg:/images/themes/default/mActionAdd.svg + + + QToolButton::InstantPopup + + + Qt::ToolButtonTextBesideIcon + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + :/images/themes/default/mActionRemove.svg:/images/themes/default/mActionRemove.svg + + + + + + + + + + + :/images/themes/default/mActionArrowUp.svg:/images/themes/default/mActionArrowUp.svg + + + + + + + + + + + :/images/themes/default/mActionArrowDown.svg:/images/themes/default/mActionArrowDown.svg + + + + + + @@ -612,64 +612,64 @@ false - - - - - - - - Type - - - - - - - - - - Layer - - - - - - - Field - - - - - - - QFrame::HLine - - - QFrame::Sunken - - - - - - - Title - - - - - - - - - - - - - - - + + + + + + + + Type + + + + + + + + + + Layer + + + + + + + Field + + + + + + + QFrame::HLine + + + QFrame::Sunken + + + + + + + Title + + + + + + + + + + + + + + + From 02c04291fcb60effc6cd82a9a96f4a42ea65a7bf Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Tue, 24 Mar 2026 14:13:48 +0100 Subject: [PATCH 19/25] allow FieldFilter updating --- Mergin/project_settings_widget.py | 35 ++++ Mergin/test/test_field_filtering.py | 268 ++++++++++++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 Mergin/test/test_field_filtering.py diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index 05aa98b7..1fd613f0 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -18,6 +18,7 @@ QgsFeatureRequest, QgsExpression, QgsMapLayer, + QgsVectorLayer, QgsFieldProxyModel, ) from qgis.gui import ( @@ -179,6 +180,12 @@ def __init__(self, parent=None): self.cmb_filter_type.addItem(f.value, f) self.cmb_filter_type.currentIndexChanged.connect(self.on_filter_layer_fields_changed) + # update existing FileFilter on edits + self.cmb_filter_layer.layerChanged.connect(self.on_filter_detail_changed) + self.cmb_filter_type.currentIndexChanged.connect(self.on_filter_detail_changed) + self.cmb_filter_field.fieldChanged.connect(self.on_filter_detail_changed) + self.edit_filter_title.textChanged.connect(self.on_filter_detail_changed) + self._update_filter_buttons() self.on_filter_layer_fields_changed() @@ -522,6 +529,34 @@ def on_move_filter_down_clicked(self) -> None: self.filters_model.move_filter(row, 1) self.lst_filters.setCurrentIndex(self.filters_model.index(row + 1)) + def on_filter_detail_changed(self) -> None: + """Recreate and replace the selected filter when any detail widget changes.""" + current = self.lst_filters.currentIndex() + if not current.isValid(): + return + + layer = self.cmb_filter_layer.currentLayer() + field_name = self.cmb_filter_field.currentField() + filter_type = self.cmb_filter_type.currentData() + filter_name = self.edit_filter_title.text().strip() + + if not isinstance(layer, QgsVectorLayer) or not layer.isValid(): + return + if not field_name: + return + if not filter_name: + return + + self.filters_model.replace_filter( + current.row(), + FieldFilter( + layer=layer, + field_name=field_name, + filter_type=filter_type, + filter_name=filter_name, + ), + ) + def on_filter_layer_fields_changed(self) -> None: """ Update the fields in the filter field combo box based on the selected layer. diff --git a/Mergin/test/test_field_filtering.py b/Mergin/test/test_field_filtering.py new file mode 100644 index 00000000..ffa9d8c6 --- /dev/null +++ b/Mergin/test/test_field_filtering.py @@ -0,0 +1,268 @@ +# -*- coding: utf-8 -*- + +# GPLv3 license +# Copyright Lutra Consulting Limited + + +import json +import os + +import nose2 + +from qgis.core import QgsVectorLayer +from qgis.testing import start_app, unittest + +from Mergin.field_filtering import ( + FieldFilter, + FieldFilterModel, + FieldFilterType, + SQL_PLACEHOLDER_VALUE, + SQL_PLACEHOLDER_VALUES, + SQL_PLACEHOLDER_VALUE_FROM, + SQL_PLACEHOLDER_VALUE_TO, + field_filters_from_json, + field_filters_to_json, +) + +test_data_path = os.path.join(os.path.dirname(__file__), "data") + + +class _LayerTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + start_app() + + def setUp(self): + layer_path = os.path.join(test_data_path, "data_field_filter.gpkg") + self.layer = QgsVectorLayer(layer_path, "field filter layer", "ogr") + self.assertTrue(self.layer.isValid()) + + +class TestFieldFilter(_LayerTestCase): + + def test_init_sets_attributes(self): + """Test that the constructor sets all attributes correctly.""" + f = FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "Text Filter") + + self.assertEqual(f.layer_id, self.layer.id()) + self.assertEqual(f.provider, "ogr") + self.assertEqual(f.field_name, "attr_string") + self.assertEqual(f.filter_type, FieldFilterType.TEXT) + self.assertEqual(f.filter_name, "Text Filter") + + def test_init_raises_for_nonexistent_field(self): + """Test that the constructor raises a ValueError if the specified field does not exist.""" + with self.assertRaises(ValueError): + FieldFilter(self.layer, "nonexistent_field", FieldFilterType.TEXT, "Text Filter") + + def test_to_dict(self): + """Test that to_dict produces the expected dictionary representation with proper values.""" + f = FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "My Filter") + data = f.to_dict() + + self.assertEqual( + set(data.keys()), + {"layer_id", "provider", "field_name", "filter_type", "filter_name", "sql_expression"}, + ) + + self.assertEqual(data["field_name"], "attr_string") + self.assertEqual(data["filter_type"], "Text") + self.assertEqual(data["filter_name"], "My Filter") + self.assertEqual(data["provider"], "ogr") + self.assertEqual(data["layer_id"], self.layer.id()) + + f = FieldFilter(self.layer, "attr_double", FieldFilterType.NUMBER, "My Filter 2") + data = f.to_dict() + + self.assertEqual(data["field_name"], "attr_double") + self.assertEqual(data["filter_type"], "Number") + self.assertEqual(data["filter_name"], "My Filter 2") + + def test_from_dict_roundtrip(self): + """Test that from_dict can restore an instance from the dictionary produced by to_dict.""" + original = FieldFilter(self.layer, "attr_bool", FieldFilterType.CHECKBOX, "Bool") + restored = FieldFilter.from_dict(original.to_dict()) + + self.assertEqual(restored, original) + + +class TestFieldFilterSqlExpression(_LayerTestCase): + + # ------------------------------------------------------------------------- + # ogr provider + # ------------------------------------------------------------------------- + + def test_text_ogr(self): + f = FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "Text") + self.assertEqual(f.sql_expression, f'CAST("attr_string" AS CHARACTER) LIKE {SQL_PLACEHOLDER_VALUE}') + + def test_number_ogr(self): + f = FieldFilter(self.layer, "attr_double", FieldFilterType.NUMBER, "Number") + self.assertEqual( + f.sql_expression, + f'CAST("attr_double" AS FLOAT) >= {SQL_PLACEHOLDER_VALUE_FROM} ' + f'AND CAST("attr_double" AS FLOAT) <= {SQL_PLACEHOLDER_VALUE_TO}', + ) + + def test_date_ogr(self): + f = FieldFilter(self.layer, "attr_date", FieldFilterType.DATE, "Date") + self.assertEqual( + f.sql_expression, + f'CAST("attr_date" AS DATE) >= {SQL_PLACEHOLDER_VALUE_FROM} ' + f'AND CAST("attr_date" AS DATE) <= {SQL_PLACEHOLDER_VALUE_TO}', + ) + + def test_checkbox_ogr(self): + f = FieldFilter(self.layer, "attr_bool", FieldFilterType.CHECKBOX, "Bool") + self.assertEqual(f.sql_expression, f'"attr_bool" = {SQL_PLACEHOLDER_VALUE}') + + def test_single_select_ogr(self): + f = FieldFilter(self.layer, "attr_string", FieldFilterType.SINGLE_SELECT, "Single") + self.assertEqual(f.sql_expression, f'"attr_string" = {SQL_PLACEHOLDER_VALUE}') + + def test_multi_select_ogr(self): + f = FieldFilter(self.layer, "attr_string", FieldFilterType.MULTI_SELECT, "Multi") + self.assertEqual(f.sql_expression, f'"attr_string" IN ({SQL_PLACEHOLDER_VALUES})') + + # ------------------------------------------------------------------------- + # postgres provider + # ------------------------------------------------------------------------- + + def test_text_postgres(self): + data = FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "Text").to_dict() + data["provider"] = "postgres" + self.assertEqual( + FieldFilter.from_dict(data).sql_expression, + f'CAST("attr_string" AS text) ILIKE {SQL_PLACEHOLDER_VALUE}', + ) + + def test_number_postgres(self): + data = FieldFilter(self.layer, "attr_double", FieldFilterType.NUMBER, "Number").to_dict() + data["provider"] = "postgres" + self.assertEqual( + FieldFilter.from_dict(data).sql_expression, + f'CAST("attr_double" AS numeric) >= {SQL_PLACEHOLDER_VALUE_FROM} ' + f'AND CAST("attr_double" AS numeric) <= {SQL_PLACEHOLDER_VALUE_TO}', + ) + + def test_date_postgres(self): + data = FieldFilter(self.layer, "attr_date", FieldFilterType.DATE, "Date").to_dict() + data["provider"] = "postgres" + self.assertEqual( + FieldFilter.from_dict(data).sql_expression, + f'CAST("attr_date" AS timestamp) >= {SQL_PLACEHOLDER_VALUE_FROM} ' + f'AND CAST("attr_date" AS timestamp) <= {SQL_PLACEHOLDER_VALUE_TO}', + ) + + +class TestFieldFilterHelpers(_LayerTestCase): + + def test_to_json_produces_valid_json(self): + filters = [ + FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "Text"), + FieldFilter(self.layer, "attr_double", FieldFilterType.NUMBER, "Number"), + ] + parsed = json.loads(field_filters_to_json(filters)) + + self.assertEqual(len(parsed), 2) + self.assertEqual(parsed[0]["filter_name"], "Text") + self.assertEqual(parsed[1]["filter_name"], "Number") + + def test_from_json_roundtrip(self): + filters = [ + FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "Text"), + FieldFilter(self.layer, "attr_bool", FieldFilterType.CHECKBOX, "Bool"), + ] + restored = field_filters_from_json(field_filters_to_json(filters)) + + self.assertEqual(len(restored), 2) + self.assertEqual(restored[0].field_name, "attr_string") + self.assertEqual(restored[1].filter_type, FieldFilterType.CHECKBOX) + + def test_from_json_empty(self): + self.assertEqual(field_filters_from_json("[]"), []) + + +class TestFieldFilterModel(_LayerTestCase): + + def test_add_filter(self): + model = FieldFilterModel() + model.add_filter(FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "My Filter")) + + self.assertEqual(model.rowCount(), 1) + + def test_remove_filter(self): + model = FieldFilterModel() + model.add_filter(FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "A")) + model.add_filter(FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "B")) + + model.remove_filter(0) + + self.assertEqual(model.rowCount(), 1) + self.assertEqual(model.filter_names(), ["B"]) + + def test_remove_filter_out_of_range(self): + model = FieldFilterModel() + model.add_filter(FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "My Filter")) + + model.remove_filter(5) # no-op, should not raise + + self.assertEqual(model.rowCount(), 1) + + def test_move_filter_down(self): + model = FieldFilterModel() + model.add_filter(FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "A")) + model.add_filter(FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "B")) + model.add_filter(FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "C")) + + model.move_filter(0, 1) + + self.assertEqual(model.filter_names(), ["B", "A", "C"]) + + def test_move_filter_up(self): + model = FieldFilterModel() + model.add_filter(FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "A")) + model.add_filter(FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "B")) + + model.move_filter(1, -1) + + self.assertEqual(model.filter_names(), ["B", "A"]) + + def test_move_filter_out_of_range_is_noop(self): + model = FieldFilterModel() + model.add_filter(FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "A")) + model.add_filter(FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "B")) + + model.move_filter(1, 1) # target index 2 is out of range + + self.assertEqual(model.filter_names(), ["A", "B"]) + + def test_filter_names(self): + model = FieldFilterModel() + for name in ("First", "Second", "Third"): + model.add_filter(FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, name)) + + self.assertEqual(model.filter_names(), ["First", "Second", "Third"]) + + def test_json_roundtrip(self): + model = FieldFilterModel() + model.add_filter(FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "Text")) + model.add_filter(FieldFilter(self.layer, "attr_double", FieldFilterType.NUMBER, "Number")) + + model2 = FieldFilterModel() + model2.load_from_json(model.to_json()) + + self.assertEqual(model2.filter_names(), ["Text", "Number"]) + + def test_load_from_json_replaces_existing(self): + model = FieldFilterModel() + model.add_filter(FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "Old")) + + new_filters = [FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "New")] + model.load_from_json(field_filters_to_json(new_filters)) + + self.assertEqual(model.filter_names(), ["New"]) + + +if __name__ == "__main__": + nose2.main() From 7b66ea3d978097c4fce6ec91f544458834947bd0 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Tue, 24 Mar 2026 14:14:01 +0100 Subject: [PATCH 20/25] fix typo --- Mergin/project_settings_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index 1fd613f0..eb366032 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -180,7 +180,7 @@ def __init__(self, parent=None): self.cmb_filter_type.addItem(f.value, f) self.cmb_filter_type.currentIndexChanged.connect(self.on_filter_layer_fields_changed) - # update existing FileFilter on edits + # update existing FieldFilter on edits self.cmb_filter_layer.layerChanged.connect(self.on_filter_detail_changed) self.cmb_filter_type.currentIndexChanged.connect(self.on_filter_detail_changed) self.cmb_filter_field.fieldChanged.connect(self.on_filter_detail_changed) From 58ef5e3757b73dde1b732eca4ea5cf5ba9059b83 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Tue, 24 Mar 2026 14:14:07 +0100 Subject: [PATCH 21/25] add data --- Mergin/test/data/data_field_filter.gpkg | Bin 0 -> 106496 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 Mergin/test/data/data_field_filter.gpkg diff --git a/Mergin/test/data/data_field_filter.gpkg b/Mergin/test/data/data_field_filter.gpkg new file mode 100644 index 0000000000000000000000000000000000000000..b48c39aba2acb6b334deb101d2dc197facddd6e7 GIT binary patch literal 106496 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 MLp9jO;1${V|1Dx`mH+?% literal 0 HcmV?d00001 From 6434c59bcb8a8013742adb587fda892cbe73c7ff Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Tue, 24 Mar 2026 14:17:22 +0100 Subject: [PATCH 22/25] update tests --- Mergin/test/test_field_filtering.py | 48 +++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/Mergin/test/test_field_filtering.py b/Mergin/test/test_field_filtering.py index ffa9d8c6..e3951e1c 100644 --- a/Mergin/test/test_field_filtering.py +++ b/Mergin/test/test_field_filtering.py @@ -88,6 +88,18 @@ def test_from_dict_roundtrip(self): class TestFieldFilterSqlExpression(_LayerTestCase): + def _postgres_filter(self, field_name: str, filter_type: FieldFilterType) -> FieldFilter: + """Create a FieldFilter with a postgres provider, bypassing layer validation.""" + f = object.__new__(FieldFilter) + f.layer_id = self.layer.id() + f.provider = "postgres" + f.field_name = field_name + f.filter_type = filter_type + f.filter_name = "test" + f.sql_expression = "" + f._generate_sql_expression() + return f + # ------------------------------------------------------------------------- # ogr provider # ------------------------------------------------------------------------- @@ -129,27 +141,21 @@ def test_multi_select_ogr(self): # ------------------------------------------------------------------------- def test_text_postgres(self): - data = FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "Text").to_dict() - data["provider"] = "postgres" - self.assertEqual( - FieldFilter.from_dict(data).sql_expression, - f'CAST("attr_string" AS text) ILIKE {SQL_PLACEHOLDER_VALUE}', - ) + f = self._postgres_filter("attr_string", FieldFilterType.TEXT) + self.assertEqual(f.sql_expression, f'CAST("attr_string" AS text) ILIKE {SQL_PLACEHOLDER_VALUE}') def test_number_postgres(self): - data = FieldFilter(self.layer, "attr_double", FieldFilterType.NUMBER, "Number").to_dict() - data["provider"] = "postgres" + f = self._postgres_filter("attr_double", FieldFilterType.NUMBER) self.assertEqual( - FieldFilter.from_dict(data).sql_expression, + f.sql_expression, f'CAST("attr_double" AS numeric) >= {SQL_PLACEHOLDER_VALUE_FROM} ' f'AND CAST("attr_double" AS numeric) <= {SQL_PLACEHOLDER_VALUE_TO}', ) def test_date_postgres(self): - data = FieldFilter(self.layer, "attr_date", FieldFilterType.DATE, "Date").to_dict() - data["provider"] = "postgres" + f = self._postgres_filter("attr_date", FieldFilterType.DATE) self.assertEqual( - FieldFilter.from_dict(data).sql_expression, + f.sql_expression, f'CAST("attr_date" AS timestamp) >= {SQL_PLACEHOLDER_VALUE_FROM} ' f'AND CAST("attr_date" AS timestamp) <= {SQL_PLACEHOLDER_VALUE_TO}', ) @@ -254,6 +260,24 @@ def test_json_roundtrip(self): self.assertEqual(model2.filter_names(), ["Text", "Number"]) + def test_replace_filter(self): + model = FieldFilterModel() + model.add_filter(FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "Original")) + model.add_filter(FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "Other")) + + model.replace_filter(0, FieldFilter(self.layer, "attr_double", FieldFilterType.NUMBER, "Replaced")) + + self.assertEqual(model.rowCount(), 2) + self.assertEqual(model.filter_names(), ["Replaced", "Other"]) + + def test_replace_filter_out_of_range(self): + model = FieldFilterModel() + model.add_filter(FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "A")) + + model.replace_filter(5, FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "B")) # no-op + + self.assertEqual(model.filter_names(), ["A"]) + def test_load_from_json_replaces_existing(self): model = FieldFilterModel() model.add_filter(FieldFilter(self.layer, "attr_string", FieldFilterType.TEXT, "Old")) From 66e929ee4a234cee31478843a9cd850afb7c82c7 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Fri, 10 Apr 2026 11:51:55 +0200 Subject: [PATCH 23/25] do not allow deselection by second click on item --- Mergin/field_filtering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mergin/field_filtering.py b/Mergin/field_filtering.py index c428e0d0..d67de271 100644 --- a/Mergin/field_filtering.py +++ b/Mergin/field_filtering.py @@ -324,7 +324,7 @@ class DeselectableListView(QListView): def mousePressEvent(self, event: Optional[QMouseEvent]) -> None: if event: index = self.indexAt(event.pos()) - if not index.isValid() or index == self.currentIndex(): + if not index.isValid(): self.blockSignals(True) self.clearSelection() self.setCurrentIndex(QModelIndex()) From 4e23fe5b0d5918c6a45c51aad81ff33d25b0c77f Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Fri, 10 Apr 2026 11:55:03 +0200 Subject: [PATCH 24/25] properly enable disable filtering gui --- Mergin/project_settings_widget.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index eb366032..cfa5bdc1 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -166,7 +166,6 @@ def __init__(self, parent=None): enabled, _ = QgsProject.instance().readBoolEntry("Mergin", "Filtering/Enabled", False) self.chk_filtering_enabled.setChecked(enabled) self.groupBox_filters_list.setEnabled(enabled) - self.groupBox_filter_detail.setEnabled(enabled) self.chk_filtering_enabled.stateChanged.connect(self.on_filtering_state_changed) filters_json, _ = QgsProject.instance().readEntry("Mergin", "Filtering/Filters", "[]") @@ -444,6 +443,13 @@ def on_filtering_state_changed(self, state: Qt.CheckState) -> None: if state == Qt.CheckState.Checked: self.groupBox_filters_list.setEnabled(True) self.groupBox_filter_detail.setEnabled(True) + fields_enabled = False + if self.lst_filters.selectedIndexes(): + fields_enabled = self.lst_filters.selectedIndexes()[0].isValid() + self.cmb_filter_type.setEnabled(fields_enabled) + self.cmb_filter_layer.setEnabled(fields_enabled) + self.cmb_filter_field.setEnabled(fields_enabled) + self.edit_filter_title.setEnabled(fields_enabled) else: self.groupBox_filters_list.setEnabled(False) self.groupBox_filter_detail.setEnabled(False) @@ -486,6 +492,11 @@ def on_filter_selection_changed(self, current: QModelIndex, previous: QModelInde self._clear_filter_values() return + self.cmb_filter_type.setEnabled(True) + self.cmb_filter_layer.setEnabled(True) + self.cmb_filter_field.setEnabled(True) + self.edit_filter_title.setEnabled(True) + self.groupBox_filter_detail.setEnabled(True) layer = QgsProject.instance().mapLayer(field_filter.layer_id) From d0caebf350ae1c725fb50746e35bdca884b6146e Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Fri, 10 Apr 2026 12:31:40 +0200 Subject: [PATCH 25/25] when loading set filter type first, then layer and field (including field filters) --- Mergin/project_settings_widget.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index cfa5bdc1..63a4c21f 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -501,20 +501,20 @@ def on_filter_selection_changed(self, current: QModelIndex, previous: QModelInde layer = QgsProject.instance().mapLayer(field_filter.layer_id) + idx = self.cmb_filter_type.findData(field_filter.filter_type) + self.cmb_filter_type.blockSignals(True) + self.cmb_filter_type.setCurrentIndex(idx) + self.cmb_filter_type.blockSignals(False) + self.cmb_filter_layer.blockSignals(True) self.cmb_filter_layer.setLayer(layer) self.cmb_filter_layer.blockSignals(False) self.cmb_filter_field.blockSignals(True) - self.cmb_filter_field.setLayer(layer) + self.on_filter_layer_fields_changed() # update available fields based on the selected layer and filter type before setting the field to avoid issues with invalid field selection self.cmb_filter_field.setField(field_filter.field_name) self.cmb_filter_field.blockSignals(False) - idx = self.cmb_filter_type.findData(field_filter.filter_type) - self.cmb_filter_type.blockSignals(True) - self.cmb_filter_type.setCurrentIndex(idx) - self.cmb_filter_type.blockSignals(False) - # block signals to avoid triggering modification of the field filter self.edit_filter_title.blockSignals(True) self.edit_filter_title.setText(field_filter.filter_name)