Skip to content

Commit

Permalink
Merge branch '20_recognize_filter' into 'main'
Browse files Browse the repository at this point in the history
20_recognize_filter

Closes #20

See merge request qgis/hvbg-filterplugin!17
  • Loading branch information
pgipper committed Nov 16, 2022
2 parents 177204f + cefe9ba commit fe71e0e
Show file tree
Hide file tree
Showing 5 changed files with 113 additions and 47 deletions.
48 changes: 27 additions & 21 deletions controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from .maptool import PolygonTool
from .filters import FilterDefinition, Predicate
from .helpers import getPostgisLayers, removeFilterFromLayer, addFilterToLayer, refreshLayerTree, hasLayerException
from .helpers import getSupportedLayers, removeFilterFromLayer, addFilterToLayer, refreshLayerTree, hasLayerException
from .settings import FILTER_COMMENT_START, FILTER_COMMENT_STOP


Expand All @@ -21,40 +21,46 @@ def __init__(self, parent: Optional[QObject] = None) -> None:
super().__init__(parent=parent)
self.currentFilter = None
self.rubberBands = []
self.connectSignals()

def removeFilter(self) -> None:
self.currentFilter = None
self.refreshFilter()
def connectSignals(self):
QgsProject.instance().layersAdded.connect(self.onLayersAdded)

def updateConnectionProjectLayersAdded(self):
self.disconnectProjectLayersAdded()
if self.hasValidFilter():
QgsProject.instance().layersAdded.connect(self.onLayersAdded)
def disconnectSignals(self):
QgsProject.instance().layersAdded.disconnect(self.onLayersAdded)

def disconnectProjectLayersAdded(self):
try:
QgsProject.instance().layersAdded.disconnect(self.onLayersAdded)
except TypeError:
pass
def removeFilter(self):
self.currentFilter = None
self.refreshFilter()

def onLayersAdded(self, layers: Iterable[QgsMapLayer]):
if not self.currentFilter.isValid:
return
for layer in getPostgisLayers(layers):
filterCondition = self.currentFilter.filterString(layer)
filterString = f'{FILTER_COMMENT_START}{filterCondition}{FILTER_COMMENT_STOP}'
layer.setSubsetString(filterString)
if self.hasValidFilter():
# Apply the filter to added layers or loaded project
for layer in getSupportedLayers(layers):
filterCondition = self.currentFilter.filterString(layer)
filterString = f'{FILTER_COMMENT_START}{filterCondition}{FILTER_COMMENT_STOP}'
layer.setSubsetString(filterString)
else:
# Look for saved filters to use with the plugin (possible when project was loaded)
for layer in getSupportedLayers(layers):
if FILTER_COMMENT_START in layer.subsetString():
self.setFilterFromLayer(layer)
return

def setFilterFromLayer(self, layer):
filterDefinition = FilterDefinition.fromFilterString(layer.subsetString())
self.currentFilter = filterDefinition
self.refreshFilter()

def updateLayerFilters(self):
for layer in getPostgisLayers(QgsProject.instance().mapLayers().values()):
for layer in getSupportedLayers(QgsProject.instance().mapLayers().values()):
if self.hasValidFilter() and not hasLayerException(layer):
addFilterToLayer(layer, self.currentFilter)
else:
removeFilterFromLayer(layer)
refreshLayerTree()

def updateProjectLayers(self):
self.updateConnectionProjectLayersAdded()
self.updateLayerFilters()

def refreshFilter(self):
Expand Down
50 changes: 44 additions & 6 deletions filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
from qgis.core import QgsVectorLayer, QgsGeometry, QgsCoordinateReferenceSystem
from qgis.utils import iface

from .helpers import tr, saveSettingsValue, readSettingsValue, allSettingsValues, removeSettingsValue, getLayerGeomName
from .settings import FILTER_COMMENT_START, FILTER_COMMENT_STOP
from .helpers import tr, saveSettingsValue, readSettingsValue, allSettingsValues, removeSettingsValue, getLayerGeomName, matchFormatString


FILTERSTRING_TEMPLATE = "{spatial_predicate}({geom_name}, ST_TRANSFORM(ST_GeomFromText('{wkt}', {srid}), {layer_srid}))"


class Predicate(IntEnum):
Expand All @@ -33,6 +37,10 @@ def __lt__(self, other):
def geometry(self) -> QgsGeometry:
return QgsGeometry.fromWkt(self.wkt)

@property
def boxGeometry(self) -> QgsGeometry:
return QgsGeometry.fromRect(self.geometry.boundingBox())

def filterString(self, layer: QgsVectorLayer) -> str:
"""Returns a layer filter string corresponding to the filter definition.
Expand All @@ -42,27 +50,45 @@ def filterString(self, layer: QgsVectorLayer) -> str:
Returns:
str: A layer filter string
"""
template = "{spatial_predicate}({geom_name}, ST_TRANSFORM(ST_GeomFromText('{wkt}', {srid}), {layer_srid}))"

# ST_DISJOINT does not use spatial indexes, but we can use its opposite "NOT ST_INTERSECTS" which does
spatial_predicate = f"ST_{Predicate(self.predicate).name}"
if self.predicate == Predicate.DISJOINT:
spatial_predicate = "NOT ST_INTERSECTS"

wkt = self.wkt
if self.bbox:
rect = QgsGeometry.fromWkt(self.wkt).boundingBox()
wkt = QgsGeometry.fromRect(rect).asWkt()
wkt = self.boxGeometry.asWkt()

geom_name = getLayerGeomName(layer)
return template.format(

return FILTERSTRING_TEMPLATE.format(
spatial_predicate=spatial_predicate,
geom_name=geom_name,
wkt=wkt,
srid=self.crs.postgisSrid(),
layer_srid=layer.crs().postgisSrid()
)

@staticmethod
def fromFilterString(subsetString: str) -> 'FilterDefinition':
start_index = subsetString.find(FILTER_COMMENT_START) + len(FILTER_COMMENT_START)
stop_index = subsetString.find(FILTER_COMMENT_STOP)
filterString = subsetString[start_index: stop_index]
filterString = filterString.replace(' AND ', '')
params = matchFormatString(FILTERSTRING_TEMPLATE, filterString)
predicateName = params['spatial_predicate'][len('ST_'):]
if filterString.startswith('NOT ST_INTERSECTS'):
predicateName = 'DISJOINT'
predicate = Predicate[predicateName]
filterDefinition = FilterDefinition(
name=tr('Unknown filter'),
wkt=params['wkt'],
crs=QgsCoordinateReferenceSystem(int(params['srid'])),
predicate=predicate.value,
bbox=False
)
return updateFilterNameFromStorage(filterDefinition)

@property
def storageDict(self) -> dict:
"""Returns a text serialisation of the FilterDefinition.
Expand Down Expand Up @@ -134,6 +160,18 @@ def deleteFilterDefinition(filterDef: FilterDefinition) -> None:
removeSettingsValue(filterDef.name)


def updateFilterNameFromStorage(filterDef: FilterDefinition) -> FilterDefinition:
for storageFilter in loadAllFilterDefinitions():
if filterDef.crs == storageFilter.crs and filterDef.wkt == storageFilter.wkt:
storageFilter.predicate = filterDef.predicate
return storageFilter
if filterDef.crs == storageFilter.crs and filterDef.wkt == storageFilter.boxGeometry.asWkt():
storageFilter.predicate = filterDef.predicate
storageFilter.bbox = True
return storageFilter
return filterDef


def askApply() -> bool:
txt = tr('Current settings will be lost. Apply anyway?')
return QMessageBox.question(iface.mainWindow(), tr('Continue?'), txt,
Expand Down
52 changes: 40 additions & 12 deletions helpers.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import re
from typing import Any, List, Iterable

from PyQt5.QtCore import QCoreApplication
from qgis.core import QgsExpressionContextUtils, QgsSettings, QgsMapLayer, QgsMapLayerType, QgsVectorLayer

from .settings import GROUP, FILTER_COMMENT_START, FILTER_COMMENT_STOP, LAYER_EXCEPTION_VARIABLE
from .settings import SUPPORTED_PROVIDERS, GROUP, FILTER_COMMENT_START, FILTER_COMMENT_STOP, LAYER_EXCEPTION_VARIABLE


def tr(message):
Expand Down Expand Up @@ -44,11 +45,11 @@ def refreshLayerTree() -> None:
pass


def getPostgisLayers(layers: Iterable[QgsMapLayer]):
def getSupportedLayers(layers: Iterable[QgsMapLayer]):
for layer in layers:
if layer.type() != QgsMapLayerType.VectorLayer:
continue
if layer.providerType() != 'postgres':
if layer.providerType() not in SUPPORTED_PROVIDERS:
continue
yield layer

Expand Down Expand Up @@ -85,12 +86,39 @@ def setLayerException(layer: QgsVectorLayer, exception: bool) -> None:
QgsExpressionContextUtils.setLayerVariable(layer, LAYER_EXCEPTION_VARIABLE, exception)


def getTestFilterDefinition():
from .filters import Predicate, FilterDefinition
name = 'museumsinsel'
srsid = 3452 # 4326
predicate = Predicate.INTERSECTS.value
wkt = 'Polygon ((13.38780495720708963 52.50770539474106613, 13.41583642354597039 52.50770539474106613, ' \
'13.41583642354597039 52.52548910505585411, 13.38780495720708963 52.52548910505585411, ' \
'13.38780495720708963 52.50770539474106613))'
return FilterDefinition(name, wkt, srsid, predicate)
def matchFormatString(format_str: str, s: str) -> dict:
"""Match s against the given format string, return dict of matches.
We assume all of the arguments in format string are named keyword arguments (i.e. no {} or
{:0.2f}). We also assume that all chars are allowed in each keyword argument, so separators
need to be present which aren't present in the keyword arguments (i.e. '{one}{two}' won't work
reliably as a format string but '{one}-{two}' will if the hyphen isn't used in {one} or {two}).
We raise if the format string does not match s.
Example:
fs = '{test}-{flight}-{go}'
s = fs.format('first', 'second', 'third')
match_format_string(fs, s) -> {'test': 'first', 'flight': 'second', 'go': 'third'}
source: https://stackoverflow.com/questions/10663093/use-python-format-string-in-reverse-for-parsing
"""

# First split on any keyword arguments, note that the names of keyword arguments will be in the
# 1st, 3rd, ... positions in this list
tokens = re.split(r'\{(.*?)\}', format_str)
keywords = tokens[1::2]

# Now replace keyword arguments with named groups matching them. We also escape between keyword
# arguments so we support meta-characters there. Re-join tokens to form our regexp pattern
tokens[1::2] = map(u'(?P<{}>.*)'.format, keywords)
tokens[0::2] = map(re.escape, tokens[0::2])
pattern = ''.join(tokens)

# Use our pattern to match the given string, raise if it doesn't match
matches = re.match(pattern, s)
if not matches:
raise Exception("Format string did not match")

# Return a dict with all of our keywords and their values
return {x: matches.group(x) for x in keywords}
1 change: 1 addition & 0 deletions map_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,5 @@ def initGui(self):
def unload(self):
self.toolbar.hideFilterGeom()
self.toolbar.controller.removeFilter()
self.toolbar.controller.disconnectSignals()
self.toolbar.deleteLater()
9 changes: 1 addition & 8 deletions widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,15 +380,8 @@ def onFilterChanged(self, filterDef: Optional[FilterDefinition]):
def changeDisplayedName(self, filterDef: FilterDefinition):
if filterDef and filterDef.isValid:
self.labelFilterName.setText(filterDef.name)
# self.setItalicName(not filterDef.isSaved)
else:
self.labelFilterName.setText(self.tr("No filter geometry set"))
# self.setItalicName(True)

def setItalicName(self, italic: bool):
font = self.labelFilterName.font()
font.setItalic(italic)
self.labelFilterName.setFont(font)

def startFilterFromExtentDialog(self):
dlg = ExtentDialog(self.controller, parent=self)
Expand Down Expand Up @@ -428,7 +421,7 @@ def drawFilterGeom(self):
filterRubberBand = QgsRubberBand(iface.mapCanvas(), QgsWkbTypes.PolygonGeometry)
filterGeom = self.controller.currentFilter.geometry
if self.controller.currentFilter.bbox:
filterGeom = QgsGeometry.fromRect(filterGeom.boundingBox())
filterGeom = self.controller.currentFilter.boxGeometry
filterCrs = self.controller.currentFilter.crs
projectCrs = QgsCoordinateReferenceSystem(QgsProject.instance().crs())
filterProj = QgsCoordinateTransform(filterCrs, projectCrs, QgsProject.instance())
Expand Down

0 comments on commit fe71e0e

Please sign in to comment.