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
qgscolorbutton.h
+ + QgsFieldComboBox + QComboBox +
qgsfieldcombobox.h
+
+ + QgsMapLayerComboBox + QComboBox +
qgsmaplayercombobox.h
+
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()