Skip to content

Commit 8588edf

Browse files
CMR-10886: Granule counts are different for certain shapefiles when comparing include_granule_counts with the collections endpoint vs the granules endpoint (#2348)
* CMR-10886: adds tests for granule count of multipoly * CMR-10886: refactors multipolygon es cond handling * CMR-10886: adds unit tests for extract-spatial-conditions and helper * CMR-10886: handles duplicate poly edge case in spatial extraction function * CMR-10886: addresses some PR comments
1 parent 159f56a commit 8588edf

File tree

4 files changed

+286
-2
lines changed

4 files changed

+286
-2
lines changed

search-app/src/cmr/search/services/query_execution/granule_counts_results_feature.clj

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,39 @@
6363
(validate-path-to-condition query condition-path)
6464
true))))
6565

66+
(defn- is-spatial-or-group?
67+
"Returns true if the condition is an OR ConditionGroup containing only SpatialConditions.
68+
This identifies groups created by MultiPolygon/multi-feature shapefiles that should preserve
69+
their OR semantics."
70+
[condition]
71+
(and (instance? ConditionGroup condition)
72+
(= :or (:operation condition))
73+
(seq (:conditions condition))
74+
(every? #(instance? SpatialCondition %) (:conditions condition))))
75+
6676
(defn extract-spatial-conditions
67-
"Returns a list of the spatial conditions in the query."
77+
"Returns a list of the spatial conditions in the query. If the conditions are wrapped
78+
in an OR group (e.g., from a MultiPolygon shapefile), preserves that structure by
79+
returning the entire group. Individual SpatialConditions not in such groups are also extracted."
6880
[query]
69-
(extract-conditions query SpatialCondition))
81+
;; Step 1: Extract OR groups containing only SpatialConditions
82+
(let [spatial-or-groups (condition-extractor/extract-conditions
83+
query
84+
(fn [condition-path condition]
85+
(when (is-spatial-or-group? condition)
86+
(validate-path-to-condition query condition-path)
87+
true)))
88+
89+
;; Step 2: Extract individual SpatialConditions whose parent is NOT a spatial OR group
90+
ungrouped-spatial-conds (condition-extractor/extract-conditions
91+
query
92+
(fn [condition-path condition]
93+
(when (and (instance? SpatialCondition condition)
94+
(not (is-spatial-or-group? (last condition-path))))
95+
(validate-path-to-condition query condition-path)
96+
true)))]
97+
98+
(concat spatial-or-groups ungrouped-spatial-conds)))
7099

71100
(defn- sanitize-temporal-condition
72101
"Returns the temporal condition with collection related info cleared out."
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
(ns cmr.search.test.unit.services.query-execution.granule-counts-results-feature-test
2+
(:require
3+
[clojure.test :refer :all]
4+
[cmr.common.services.search.query-model :as q]
5+
[cmr.search.models.query :as qm]
6+
[cmr.search.services.query-execution.granule-counts-results-feature :as gcrf]
7+
[cmr.spatial.point :as p]
8+
[cmr.spatial.polygon :as poly]
9+
[cmr.spatial.geodetic-ring :as gr]
10+
[cmr.elastic-utils.search.es-group-query-conditions :as gc]))
11+
12+
(defn- make-spatial-condition
13+
"Creates a SpatialCondition with a simple polygon"
14+
[lon lat]
15+
(let [points [(p/point lon lat)
16+
(p/point (+ lon 10) lat)
17+
(p/point (+ lon 10) (+ lat 10))
18+
(p/point lon (+ lat 10))
19+
(p/point lon lat)]
20+
ring (gr/ring points)
21+
polygon (poly/polygon :geodetic [ring])]
22+
(qm/->SpatialCondition polygon)))
23+
24+
(deftest is-spatial-or-group-test
25+
(testing "Returns true for OR group of SpatialConditions"
26+
(let [cond1 (make-spatial-condition 10 0)
27+
cond2 (make-spatial-condition 44 -25)
28+
or-group (gc/or-conds [cond1 cond2])]
29+
(is (true? (#'gcrf/is-spatial-or-group? or-group)))))
30+
31+
(testing "Returns false for AND group of SpatialConditions"
32+
(let [cond1 (make-spatial-condition 10 0)
33+
cond2 (make-spatial-condition 44 -25)
34+
and-group (gc/and-conds [cond1 cond2])]
35+
(is (false? (#'gcrf/is-spatial-or-group? and-group)))))
36+
37+
(testing "Returns false for OR group with non-SpatialConditions"
38+
(let [cond1 (make-spatial-condition 10 0)
39+
cond2 (q/string-condition :entry-title "test")
40+
or-group (gc/or-conds [cond1 cond2])]
41+
(is (false? (#'gcrf/is-spatial-or-group? or-group)))))
42+
43+
(testing "Returns false for single SpatialCondition"
44+
(let [cond1 (make-spatial-condition 10 0)]
45+
(is (false? (#'gcrf/is-spatial-or-group? cond1))))))
46+
47+
(deftest extract-spatial-conditions-preserves-or-groups-test
48+
(testing "Extracts OR group of SpatialConditions preserving structure"
49+
(let [cond1 (make-spatial-condition 10 0)
50+
cond2 (make-spatial-condition 44 -25)
51+
or-group (gc/or-conds [cond1 cond2])
52+
query (q/query {:concept-type :collection
53+
:condition or-group})
54+
result (gcrf/extract-spatial-conditions query)]
55+
(is (= [or-group] result))))
56+
57+
(testing "Extracts OR group nested with non-spatial conditions"
58+
(let [cond1 (make-spatial-condition 10 0)
59+
cond2 (make-spatial-condition 44 -25)
60+
or-group (gc/or-conds [cond1 cond2])
61+
title-cond (q/string-condition :entry-title "test")
62+
provider-cond (q/string-condition :provider "PROV1")
63+
combined (gc/and-conds [title-cond or-group provider-cond])
64+
query (q/query {:concept-type :collection
65+
:condition combined})
66+
result (gcrf/extract-spatial-conditions query)]
67+
(is (= [or-group] result)))))
68+
69+
(deftest extract-spatial-conditions-single-condition-test
70+
(testing "Extracts single SpatialCondition"
71+
(let [cond1 (make-spatial-condition 10 0)
72+
query (q/query {:concept-type :collection
73+
:condition cond1})
74+
result (gcrf/extract-spatial-conditions query)]
75+
(is (= [cond1] result))))
76+
77+
(testing "Extracts single SpatialCondition nested with non-spatial conditions"
78+
(let [spatial-cond (make-spatial-condition 10 0)
79+
title-cond (q/string-condition :entry-title "test")
80+
provider-cond (q/string-condition :provider "PROV1")
81+
combined (gc/and-conds [title-cond spatial-cond provider-cond])
82+
query (q/query {:concept-type :collection
83+
:condition combined})
84+
result (gcrf/extract-spatial-conditions query)]
85+
(is (= [spatial-cond] result)))))
86+
87+
(deftest extract-spatial-conditions-mixed-test
88+
(testing "Extracts both OR groups and ungrouped SpatialConditions"
89+
(let [cond1 (make-spatial-condition 10 0)
90+
cond2 (make-spatial-condition 44 -25)
91+
cond3 (make-spatial-condition 100 50)
92+
or-group (gc/or-conds [cond1 cond2])
93+
combined (gc/and-conds [or-group cond3])
94+
query (q/query {:concept-type :collection
95+
:condition combined})
96+
result (gcrf/extract-spatial-conditions query)]
97+
(is (= [or-group cond3] result))))
98+
99+
(testing "Extracts OR groups and ungrouped conditions with non-spatial conditions mixed in"
100+
(let [cond1 (make-spatial-condition 10 0)
101+
cond2 (make-spatial-condition 44 -25)
102+
cond3 (make-spatial-condition 100 50)
103+
or-group (gc/or-conds [cond1 cond2])
104+
title-cond (q/string-condition :entry-title "test")
105+
provider-cond (q/string-condition :provider "PROV1")
106+
combined (gc/and-conds [title-cond or-group provider-cond cond3])
107+
query (q/query {:concept-type :collection
108+
:condition combined})
109+
result (gcrf/extract-spatial-conditions query)]
110+
(is (= [or-group cond3] result)))))
111+
112+
(deftest extract-spatial-conditions-empty-test
113+
(testing "Returns empty list when no spatial conditions"
114+
(let [cond1 (q/string-condition :entry-title "test")
115+
query (q/query {:concept-type :collection
116+
:condition cond1})
117+
result (gcrf/extract-spatial-conditions query)]
118+
(is (= [] result))))
119+
120+
(testing "Returns empty list when only non-spatial conditions"
121+
(let [title-cond (q/string-condition :entry-title "test")
122+
provider-cond (q/string-condition :provider "PROV1")
123+
combined (gc/and-conds [title-cond provider-cond])
124+
query (q/query {:concept-type :collection
125+
:condition combined})
126+
result (gcrf/extract-spatial-conditions query)]
127+
(is (= [] result)))))
128+
129+
(deftest extract-spatial-conditions-with-duplicate-polygon-test
130+
(testing "Extracts both OR group and standalone duplicate SpatialCondition"
131+
(let [cond1 (make-spatial-condition 10 0)
132+
cond2 (make-spatial-condition 44 -25)
133+
cond3 (make-spatial-condition 10 0)
134+
or-group (gc/or-conds [cond1 cond2])
135+
combined (gc/and-conds [or-group cond3])
136+
query (q/query {:concept-type :collection
137+
:condition combined})
138+
result (gcrf/extract-spatial-conditions query)]
139+
(is (= [or-group cond3] result)))))
140+
141+
(deftest extract-spatial-conditions-validates-parent-path-test
142+
(testing "Rejects SpatialCondition under NegatedCondition"
143+
(let [spatial-cond (make-spatial-condition 10 0)
144+
negated (q/negated-condition spatial-cond)
145+
query (q/query {:concept-type :collection
146+
:condition negated})]
147+
(is (thrown? Exception
148+
(gcrf/extract-spatial-conditions query)))))
149+
150+
(testing "Rejects SpatialCondition nested under NegatedCondition in AND group"
151+
(let [spatial-cond (make-spatial-condition 10 0)
152+
title-cond (q/string-condition :entry-title "test")
153+
negated (q/negated-condition spatial-cond)
154+
combined (gc/and-conds [title-cond negated])
155+
query (q/query {:concept-type :collection
156+
:condition combined})]
157+
(is (thrown? Exception
158+
(gcrf/extract-spatial-conditions query)))))
159+
160+
(testing "Accepts valid SpatialCondition in AND group"
161+
(let [spatial-cond (make-spatial-condition 10 0)
162+
title-cond (q/string-condition :entry-title "test")
163+
combined (gc/and-conds [title-cond spatial-cond])
164+
query (q/query {:concept-type :collection
165+
:condition combined})]
166+
(is (seq (gcrf/extract-spatial-conditions query)))))
167+
168+
(testing "Accepts valid SpatialCondition at top level"
169+
(let [spatial-cond (make-spatial-condition 10 0)
170+
query (q/query {:concept-type :collection
171+
:condition spatial-cond})]
172+
(is (seq (gcrf/extract-spatial-conditions query)))))
173+
174+
(testing "Preserves MultiPolygon OR groups with validation"
175+
(let [spatial-cond-1 (make-spatial-condition 10 0)
176+
spatial-cond-2 (make-spatial-condition 20 10)
177+
or-group (gc/or-conds [spatial-cond-1 spatial-cond-2])
178+
query (q/query {:concept-type :collection
179+
:condition or-group})]
180+
(is (= [or-group] (gcrf/extract-spatial-conditions query))))))
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"type": "FeatureCollection",
3+
"features": [
4+
{
5+
"type": "Feature",
6+
"properties": {},
7+
"geometry": {
8+
"type": "MultiPolygon",
9+
"coordinates": [
10+
[
11+
[
12+
[10.0, 0.0],
13+
[30.0, 0.0],
14+
[30.0, 15.0],
15+
[10.0, 15.0],
16+
[10.0, 0.0]
17+
]
18+
],
19+
[
20+
[
21+
[44.0, -25.0],
22+
[50.0, -25.0],
23+
[50.0, -15.0],
24+
[44.0, -15.0],
25+
[44.0, -25.0]
26+
]
27+
]
28+
]
29+
}
30+
}
31+
]
32+
}

system-int-test/test/cmr/system_int_test/search/granule/granule_counts_search_test.clj

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
(ns cmr.system-int-test.search.granule.granule-counts-search-test
22
"This tests the granule counts search feature which allows retrieving counts of granules per collection."
33
(:require
4+
[clojure.java.io :as io]
45
[clojure.test :refer [are deftest is testing use-fixtures]]
6+
[cmr.common.mime-types :as mt]
57
[cmr.common.util :as util :refer [are3]]
8+
[cmr.common-app.test.side-api :as side]
9+
[cmr.search.services.parameters.converters.shapefile :as shapefile]
610
[cmr.spatial.codec :as codec]
711
[cmr.spatial.mbr :as m]
812
[cmr.spatial.point :as p]
@@ -749,3 +753,42 @@
749753

750754
"granule count in json format"
751755
:atom (search/find-concepts-json :collection {:include-granule-counts false})))))
756+
757+
(deftest multipolygon-shapefile-granule-counts-test
758+
(side/eval-form `(shapefile/set-enable-shapefile-parameter-flag! true))
759+
760+
(let [coll (make-coll 1 m/whole-world nil)]
761+
;; Granule in first polygon of MultiPolygon
762+
(make-gran coll (p/point 20.0 5.0) nil)
763+
;; Granule in second polygon of MultiPolygon
764+
(make-gran coll (p/point 47.0 -20.0) nil)
765+
;; Granule outside MultiPolygon
766+
(make-gran coll (p/point -100.0 40.0) nil)
767+
768+
(index/wait-until-indexed)
769+
(index/full-refresh-collection-granule-aggregate-cache)
770+
(ingest/reindex-all-collections)
771+
(index/wait-until-indexed)
772+
773+
(testing "MultiPolygon shapefile granule counts match direct granule search"
774+
(let [granule-results (search/find-refs-with-multi-part-form-post
775+
:granule
776+
[{:name "shapefile"
777+
:content (io/file (io/resource "shapefiles/multipolygon_test.geojson"))
778+
:mime-type mt/geojson}
779+
{:name "collection_concept_id"
780+
:content (:concept-id coll)}])
781+
granule-hits (:hits granule-results)]
782+
(is (= 2 granule-hits) "Granule search should find 2 granules in MultiPolygon")
783+
784+
(let [collection-response (search/find-refs-with-multi-part-form-post
785+
:collection
786+
[{:name "shapefile"
787+
:content (io/file (io/resource "shapefiles/multipolygon_test.geojson"))
788+
:mime-type mt/geojson}
789+
{:name "concept_id"
790+
:content (:concept-id coll)}
791+
{:name "include_granule_counts"
792+
:content "true"}])]
793+
(is (gran-counts/granule-counts-match? :xml {coll granule-hits} collection-response)
794+
"MultiPolygon shapefile: collection granule counts should match granule search hits"))))))

0 commit comments

Comments
 (0)