Skip to content

Commit 0d20062

Browse files
committed
[FEATURE][processing] Add non-joinable output to Join by Location alg
Allows unjoinable features to be saved to a separate optional layer
1 parent 773371a commit 0d20062

File tree

4 files changed

+139
-13
lines changed

4 files changed

+139
-13
lines changed

python/plugins/processing/algs/qgis/SpatialJoin.py

+48-13
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
QgsProcessingParameterEnum,
4242
QgsProcessingParameterField,
4343
QgsProcessingParameterFeatureSink,
44-
QgsProcessingParameterString)
44+
QgsProcessingParameterString,
45+
QgsProcessingOutputNumber)
4546

4647
from processing.algs.qgis.QgisAlgorithm import QgisAlgorithm
4748
from processing.tools import vector
@@ -58,6 +59,8 @@ class SpatialJoin(QgisAlgorithm):
5859
DISCARD_NONMATCHING = "DISCARD_NONMATCHING"
5960
PREFIX = "PREFIX"
6061
OUTPUT = "OUTPUT"
62+
NON_MATCHING = "NON_MATCHING"
63+
JOINED_COUNT = "JOINED_COUNT"
6164

6265
def group(self):
6366
return self.tr('Vector general')
@@ -120,7 +123,19 @@ def initAlgorithm(self, config=None):
120123
self.addParameter(QgsProcessingParameterString(self.PREFIX,
121124
self.tr('Joined field prefix'), optional=True))
122125
self.addParameter(QgsProcessingParameterFeatureSink(self.OUTPUT,
123-
self.tr('Joined layer')))
126+
self.tr('Joined layer'),
127+
QgsProcessing.TypeVectorAnyGeometry,
128+
defaultValue=None, optional=True, createByDefault=True))
129+
130+
non_matching = QgsProcessingParameterFeatureSink(self.NON_MATCHING,
131+
self.tr('Unjoinable features from first layer'),
132+
QgsProcessing.TypeVectorAnyGeometry,
133+
defaultValue=None, optional=True, createByDefault=False)
134+
# TODO GUI doesn't support advanced outputs yet
135+
# non_matching.setFlags(non_matching.flags() | QgsProcessingParameterDefinition.FlagAdvanced )
136+
self.addParameter(non_matching)
137+
138+
self.addOutput(QgsProcessingOutputNumber(self.JOINED_COUNT, self.tr("Number of joined features from input table")))
124139

125140
def name(self):
126141
return 'joinattributesbylocation'
@@ -170,9 +185,14 @@ def processAlgorithm(self, parameters, context, feedback):
170185

171186
(sink, dest_id) = self.parameterAsSink(parameters, self.OUTPUT, context,
172187
out_fields, source.wkbType(), source.sourceCrs())
173-
if sink is None:
188+
if self.OUTPUT in parameters and parameters[self.OUTPUT] is not None and sink is None:
174189
raise QgsProcessingException(self.invalidSinkError(parameters, self.OUTPUT))
175190

191+
(non_matching_sink, non_matching_dest_id) = self.parameterAsSink(parameters, self.NON_MATCHING, context,
192+
source.fields(), source.wkbType(), source.sourceCrs())
193+
if self.NON_MATCHING in parameters and parameters[self.NON_MATCHING] is not None and non_matching_sink is None:
194+
raise QgsProcessingException(self.invalidSinkError(parameters, self.NON_MATCHING))
195+
176196
# do the join
177197

178198
# build a list of 'reversed' predicates, because in this function
@@ -182,7 +202,7 @@ def processAlgorithm(self, parameters, context, feedback):
182202
self.parameterAsEnums(parameters, self.PREDICATE, context)]
183203

184204
remaining = set()
185-
if not discard_nomatch:
205+
if not discard_nomatch or non_matching_sink is not None:
186206
remaining = set(source.allFeatureIds())
187207

188208
added_set = set()
@@ -191,6 +211,9 @@ def processAlgorithm(self, parameters, context, feedback):
191211
features = join_source.getFeatures(request)
192212
total = 100.0 / join_source.featureCount() if join_source.featureCount() else 0
193213

214+
joined_count = 0
215+
unjoined_count = 0
216+
194217
for current, f in enumerate(features):
195218
if feedback.isCanceled():
196219
break
@@ -221,21 +244,33 @@ def processAlgorithm(self, parameters, context, feedback):
221244
if getattr(engine, predicate)(test_feat.geometry().constGet()):
222245
added_set.add(test_feat.id())
223246

224-
# join attributes and add
225-
attributes = test_feat.attributes()
226-
attributes.extend(join_attributes)
227-
output_feature = test_feat
228-
output_feature.setAttributes(attributes)
229-
sink.addFeature(output_feature, QgsFeatureSink.FastInsert)
247+
if sink is not None:
248+
# join attributes and add
249+
attributes = test_feat.attributes()
250+
attributes.extend(join_attributes)
251+
output_feature = test_feat
252+
output_feature.setAttributes(attributes)
253+
sink.addFeature(output_feature, QgsFeatureSink.FastInsert)
230254
break
231255

232256
feedback.setProgress(int(current * total))
233257

234-
if not discard_nomatch:
258+
if not discard_nomatch or non_matching_sink is not None:
235259
remaining = remaining.difference(added_set)
236260
for f in source.getFeatures(QgsFeatureRequest().setFilterFids(list(remaining))):
237261
if feedback.isCanceled():
238262
break
239-
sink.addFeature(f, QgsFeatureSink.FastInsert)
263+
if sink is not None:
264+
sink.addFeature(f, QgsFeatureSink.FastInsert)
265+
if non_matching_sink is not None:
266+
non_matching_sink.addFeature(f, QgsFeatureSink.FastInsert)
267+
268+
result = {}
269+
if sink is not None:
270+
result[self.OUTPUT] = dest_id
271+
if non_matching_sink is not None:
272+
result[self.NON_MATCHING] = non_matching_dest_id
273+
274+
result[self.JOINED_COUNT] = len(added_set)
240275

241-
return {self.OUTPUT: dest_id}
276+
return result
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?xml version="1.0" encoding="utf-8" ?>
2+
<ogr:FeatureCollection
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="http://ogr.maptools.org/ join_by_location_unjoinable.xsd"
5+
xmlns:ogr="http://ogr.maptools.org/"
6+
xmlns:gml="http://www.opengis.net/gml">
7+
<gml:boundedBy>
8+
<gml:Box>
9+
<gml:coord><gml:X>0</gml:X><gml:Y>-5</gml:Y></gml:coord>
10+
<gml:coord><gml:X>8</gml:X><gml:Y>2</gml:Y></gml:coord>
11+
</gml:Box>
12+
</gml:boundedBy>
13+
14+
<gml:featureMember>
15+
<ogr:join_by_location_unjoinable fid="points.3">
16+
<ogr:geometryProperty><gml:Point srsName="EPSG:4326"><gml:coordinates>5,2</gml:coordinates></gml:Point></ogr:geometryProperty>
17+
<ogr:id>4</ogr:id>
18+
<ogr:id2>2</ogr:id2>
19+
</ogr:join_by_location_unjoinable>
20+
</gml:featureMember>
21+
<gml:featureMember>
22+
<ogr:join_by_location_unjoinable fid="points.5">
23+
<ogr:geometryProperty><gml:Point srsName="EPSG:4326"><gml:coordinates>0,-5</gml:coordinates></gml:Point></ogr:geometryProperty>
24+
<ogr:id>6</ogr:id>
25+
<ogr:id2>0</ogr:id2>
26+
</ogr:join_by_location_unjoinable>
27+
</gml:featureMember>
28+
<gml:featureMember>
29+
<ogr:join_by_location_unjoinable fid="points.6">
30+
<ogr:geometryProperty><gml:Point srsName="EPSG:4326"><gml:coordinates>8,-1</gml:coordinates></gml:Point></ogr:geometryProperty>
31+
<ogr:id>7</ogr:id>
32+
<ogr:id2>0</ogr:id2>
33+
</ogr:join_by_location_unjoinable>
34+
</gml:featureMember>
35+
</ogr:FeatureCollection>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<xs:schema targetNamespace="http://ogr.maptools.org/" xmlns:ogr="http://ogr.maptools.org/" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:gml="http://www.opengis.net/gml" elementFormDefault="qualified" version="1.0">
3+
<xs:import namespace="http://www.opengis.net/gml" schemaLocation="http://schemas.opengis.net/gml/2.1.2/feature.xsd"/>
4+
<xs:element name="FeatureCollection" type="ogr:FeatureCollectionType" substitutionGroup="gml:_FeatureCollection"/>
5+
<xs:complexType name="FeatureCollectionType">
6+
<xs:complexContent>
7+
<xs:extension base="gml:AbstractFeatureCollectionType">
8+
<xs:attribute name="lockId" type="xs:string" use="optional"/>
9+
<xs:attribute name="scope" type="xs:string" use="optional"/>
10+
</xs:extension>
11+
</xs:complexContent>
12+
</xs:complexType>
13+
<xs:element name="join_by_location_unjoinable" type="ogr:join_by_location_unjoinable_Type" substitutionGroup="gml:_Feature"/>
14+
<xs:complexType name="join_by_location_unjoinable_Type">
15+
<xs:complexContent>
16+
<xs:extension base="gml:AbstractFeatureType">
17+
<xs:sequence>
18+
<xs:element name="geometryProperty" type="gml:PointPropertyType" nillable="true" minOccurs="0" maxOccurs="1"/>
19+
<xs:element name="id" nillable="true" minOccurs="0" maxOccurs="1">
20+
<xs:simpleType>
21+
<xs:restriction base="xs:long">
22+
<xs:totalDigits value="10"/>
23+
</xs:restriction>
24+
</xs:simpleType>
25+
</xs:element>
26+
<xs:element name="id2" nillable="true" minOccurs="0" maxOccurs="1">
27+
<xs:simpleType>
28+
<xs:restriction base="xs:long">
29+
<xs:totalDigits value="10"/>
30+
</xs:restriction>
31+
</xs:simpleType>
32+
</xs:element>
33+
</xs:sequence>
34+
</xs:extension>
35+
</xs:complexContent>
36+
</xs:complexType>
37+
</xs:schema>

python/plugins/processing/tests/testdata/qgis_algorithm_tests.yaml

+19
Original file line numberDiff line numberDiff line change
@@ -4738,6 +4738,25 @@ tests:
47384738
fid: skip
47394739
fid_2: skip
47404740

4741+
- algorithm: qgis:joinattributesbylocation
4742+
name: Join by location, unjoinable
4743+
params:
4744+
DISCARD_NONMATCHING: false
4745+
INPUT:
4746+
name: custom/points.shp
4747+
type: vector
4748+
JOIN:
4749+
name: polys.gml
4750+
type: vector
4751+
METHOD: 0
4752+
PREDICATE:
4753+
- 0
4754+
PREFIX: ''
4755+
results:
4756+
NON_MATCHING:
4757+
name: expected/join_by_location_unjoinable.gml
4758+
type: vector
4759+
47414760
- algorithm: qgis:joinbylocationsummary
47424761
name: Join by location (summary), intersects
47434762
params:

0 commit comments

Comments
 (0)