diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 64802134..42d2e621 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -4,4 +4,4 @@ repos:
rev: 25.1.0
hooks:
- id: black
- language_version: python3.10
\ No newline at end of file
+ args: [--line-length=120]
diff --git a/Mergin/field_filtering.py b/Mergin/field_filtering.py
new file mode 100644
index 00000000..d67de271
--- /dev/null
+++ b/Mergin/field_filtering.py
@@ -0,0 +1,335 @@
+import json
+from enum import Enum
+from typing import Optional, Union, List
+
+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
+
+
+SQL_PLACEHOLDER_VALUE = "%%value%%"
+SQL_PLACEHOLDER_VALUES = "%%values%%"
+SQL_PLACEHOLDER_VALUE_FROM = "%%value_from%%"
+SQL_PLACEHOLDER_VALUE_TO = "%%value_to%%"
+
+
+class FieldFilterType(str, Enum):
+ TEXT = "Text"
+ NUMBER = "Number"
+ DATE = "Date"
+ CHECKBOX = "Checkbox"
+ SINGLE_SELECT = "Single select"
+ MULTI_SELECT = "Multi select"
+
+
+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_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_dict(item) for item in json.loads(data)]
+
+
+class FieldFilter:
+
+ def __init__(
+ self,
+ layer: Optional[QgsVectorLayer],
+ field_name: str,
+ filter_type: FieldFilterType,
+ filter_name: str,
+ ):
+ if layer is not None and not isinstance(layer, QgsVectorLayer):
+ raise ValueError("layer must be a QgsVectorLayer")
+
+ 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()}'")
+
+ 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"""
+ 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_dict(self) -> dict:
+ """Convert the object to a 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,
+ }
+
+ @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
+ )
+
+ 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."""
+
+ 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 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
+ 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():
+ self.blockSignals(True)
+ self.clearSelection()
+ self.setCurrentIndex(QModelIndex())
+ self.blockSignals(False)
+ self.selectionCleared.emit(QModelIndex(), QModelIndex())
+ return
+
+ super().mousePressEvent(event)
diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py
index 67027ffa..63a4c21f 100644
--- a/Mergin/project_settings_widget.py
+++ b/Mergin/project_settings_widget.py
@@ -4,10 +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
-from qgis.PyQt.QtWidgets import QFileDialog, QMessageBox
+from qgis.PyQt.QtCore import Qt, QModelIndex
+from qgis.PyQt.QtWidgets import QAction, QFileDialog, QMenu, QMessageBox, QGroupBox, QComboBox
from qgis.core import (
QgsProject,
QgsExpressionContext,
@@ -16,8 +18,16 @@
QgsFeatureRequest,
QgsExpression,
QgsMapLayer,
+ QgsVectorLayer,
+ 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 +43,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 +70,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 +143,55 @@ 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)
+
+ 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)
+ 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)
+
+ enabled, _ = QgsProject.instance().readBoolEntry("Mergin", "Filtering/Enabled", False)
+ self.chk_filtering_enabled.setChecked(enabled)
+ self.groupBox_filters_list.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)
+
+ # 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)
+ self.edit_filter_title.textChanged.connect(self.on_filter_detail_changed)
+
+ 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:
@@ -351,6 +424,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 +435,160 @@ 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)
+ 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)
+
+ 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 _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._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)
+
+ 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.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)
+
+ # 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()
+ 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_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.
+ """
+ layer = self.cmb_filter_layer.currentLayer()
+ self.cmb_filter_field.setLayer(layer)
+ 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)
+ )
+
+ 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))
diff --git a/Mergin/ui/ui_project_config.ui b/Mergin/ui/ui_project_config.ui
index e29310cc..8432a6d7 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::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
+
+
+
+
+
+
+
+
+ -
+
+
+ false
+
+
+
+
+
+
-
+
+
+ Type
+
+
+
+ -
+
+
+ -
+
+
+ Layer
+
+
+
+ -
+
+
+ Field
+
+
+
+ -
+
+
+ QFrame::HLine
+
+
+ QFrame::Sunken
+
+
+
+ -
+
+
+ Title
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+
+
+
+
+
+
+
-
@@ -525,6 +699,16 @@
QToolButton
+
+ QgsFieldComboBox
+ QComboBox
+
+
+
+ QgsMapLayerComboBox
+ QComboBox
+
+
QgsExpressionLineEdit
QWidget
diff --git a/tests/data/data_field_filter.gpkg b/tests/data/data_field_filter.gpkg
new file mode 100644
index 00000000..b48c39ab
Binary files /dev/null and b/tests/data/data_field_filter.gpkg differ
diff --git a/tests/test_field_filtering.py b/tests/test_field_filtering.py
new file mode 100644
index 00000000..e3951e1c
--- /dev/null
+++ b/tests/test_field_filtering.py
@@ -0,0 +1,292 @@
+# -*- 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):
+
+ 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
+ # -------------------------------------------------------------------------
+
+ 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):
+ 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):
+ f = self._postgres_filter("attr_double", FieldFilterType.NUMBER)
+ self.assertEqual(
+ 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):
+ f = self._postgres_filter("attr_date", FieldFilterType.DATE)
+ self.assertEqual(
+ f.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_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"))
+
+ 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()