Skip to content

Commit 91bdfd9

Browse files
authored
Add support for Filter Extension s_intersects operator (#854)
* add support for Filter Extension s_intersects
1 parent 13eba9f commit 91bdfd9

File tree

6 files changed

+200
-69
lines changed

6 files changed

+200
-69
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
99

1010
### Added
1111

12-
- Added support for the "in" and "between" operators of the Filter Extension
12+
- Support for the "in" and "between" operators of the Filter Extension
13+
- Support for "s_intersects" opeartor of the Filter Extension. This implements both the
14+
"Basic Spatial Functions" and "Basic Spatial Functions with additional Spatial Literals"
15+
conformance classes, supporting operands for s_intersects of either bbox or GeoJSON
16+
Geometry literals.
1317

1418
## [3.10.0] - 2025-03-21
1519

src/lib/api.js

Lines changed: 4 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { pickBy, assign, get as getNested } from 'lodash-es'
2-
import extent from '@mapbox/extent'
32
import { DateTime } from 'luxon'
43
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
54
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'
65
import { ValidationError } from './errors.js'
76
import { isIndexNotFoundError } from './database.js'
87
import logger from './logger.js'
8+
import { bboxToPolygon } from './geo-utils.js'
99

1010
// max number of collections to retrieve
1111
const COLLECTION_LIMIT = process.env['STAC_SERVER_COLLECTION_LIMIT'] || 100
@@ -79,32 +79,7 @@ export const extractIntersects = function (params) {
7979

8080
export const extractBbox = function (params, httpMethod = 'GET') {
8181
const { bbox } = params
82-
if (bbox) {
83-
let bboxArray
84-
if (httpMethod === 'GET' && typeof bbox === 'string') {
85-
try {
86-
bboxArray = bbox.split(',').map(parseFloat).filter((x) => !Number.isNaN(x))
87-
} catch (_) {
88-
throw new ValidationError('Invalid bbox')
89-
}
90-
} else if (httpMethod === 'POST' && Array.isArray(bbox)) {
91-
bboxArray = bbox
92-
} else {
93-
throw new ValidationError('Invalid bbox')
94-
}
95-
96-
if (bboxArray.length !== 4 && bboxArray.length !== 6) {
97-
throw new ValidationError('Invalid bbox, must have 4 or 6 points')
98-
}
99-
100-
if ((bboxArray.length === 4 && bboxArray[1] > bboxArray[3])
101-
|| (bboxArray.length === 6 && bboxArray[1] > bboxArray[4])) {
102-
throw new ValidationError('Invalid bbox, SW latitude must be less than NE latitude')
103-
}
104-
105-
return extent(bboxArray).polygon()
106-
}
107-
return undefined
82+
return bboxToPolygon(bbox, httpMethod === 'GET')
10883
}
10984

11085
export const extractLimit = function (params) {
@@ -984,6 +959,8 @@ const getConformance = async function (txnEnabled) {
984959
'http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/features-filter',
985960
'http://www.opengis.net/spec/cql2/1.0/conf/basic-cql2',
986961
'http://www.opengis.net/spec/cql2/1.0/conf/cql2-json',
962+
'http://www.opengis.net/spec/cql2/1.0/conf/basic-spatial-functions',
963+
'http://www.opengis.net/spec/cql2/1.0/conf/basic-spatial-functions-plus',
987964
]
988965

989966
if (txnEnabled) {

src/lib/database.js

Lines changed: 86 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { isEmpty } from 'lodash-es'
22
import { dbClient as _client, createIndex } from './database-client.js'
33
import logger from './logger.js'
44
import { ValidationError } from './errors.js'
5+
import { bboxToPolygon } from './geo-utils.js'
56

67
const COLLECTIONS_INDEX = process.env['COLLECTIONS_INDEX'] || 'collections'
78
const DEFAULT_INDICES = ['*', '-.*', '-collections']
@@ -19,6 +20,7 @@ const OP = {
1920
IN: 'in',
2021
BETWEEN: 'between',
2122
LIKE: 'like',
23+
S_INTERSECTS: 's_intersects',
2224
}
2325
const RANGE_TRANSLATION = {
2426
'<': 'lt',
@@ -32,6 +34,8 @@ const UNPREFIXED_FIELDS = [
3234
'geometry',
3335
'bbox'
3436
]
37+
const GEOMETRY_TYPES = ['Point', 'LineString', 'Polygon', 'MultiPoint',
38+
'MultiLineString', 'MultiPolygon', 'GeometryCollection']
3539

3640
let collectionToIndexMapping = null
3741
let unrestrictedIndices = null
@@ -101,6 +105,84 @@ export function buildDatetimeQuery(parameters) {
101105
return dateQuery
102106
}
103107

108+
function IN(cql2Field, cql2Value) {
109+
if (!Array.isArray(cql2Value) || cql2Value.length === 0) {
110+
throw new ValidationError("Operand for 'in' must be a non-empty array")
111+
}
112+
if (!cql2Value.every((x) => x !== Object(x))) {
113+
throw new ValidationError(
114+
"Operand for 'in' must contain only string, number, or boolean types"
115+
)
116+
}
117+
118+
return {
119+
terms: {
120+
[cql2Field]: cql2Value
121+
}
122+
}
123+
}
124+
125+
function between(cql2Field, filterArgs) {
126+
if (filterArgs.length < 3) {
127+
throw new ValidationError("Two operands must be provided for the 'between' operator")
128+
}
129+
130+
const cql2Value1 = filterArgs[1]
131+
const cql2Value2 = filterArgs[2]
132+
if (!(typeof cql2Value1 === 'number' && typeof cql2Value2 === 'number')) {
133+
throw new ValidationError("Operands for 'between' must be numbers")
134+
}
135+
136+
if (cql2Value1 > cql2Value2) {
137+
throw new ValidationError(
138+
"For the 'between' operator, the first operand must be less than or equal "
139+
+ 'to the second operand'
140+
)
141+
}
142+
143+
return {
144+
range: {
145+
[cql2Field]: {
146+
gte: cql2Value1,
147+
lte: cql2Value2
148+
}
149+
}
150+
}
151+
}
152+
153+
function sIntersects(cql2Field, cql2Value) {
154+
// cql2Value can be either:
155+
// 1) { "bbox": [swLon, swLat, neLon, neLat] }
156+
// 2) geojson geometry
157+
158+
let geom = null
159+
160+
if (cql2Value.bbox) {
161+
geom = bboxToPolygon(cql2Value.bbox, true)
162+
}
163+
164+
if (cql2Value.type && cql2Value.coordinates) {
165+
if (!GEOMETRY_TYPES.includes(cql2Value.type)) {
166+
throw new ValidationError(
167+
`Operand for 's_intersects' must be a GeoJSON geometry: type was '${cql2Value.type}'`
168+
)
169+
}
170+
geom = cql2Value
171+
}
172+
173+
if (!geom) {
174+
throw new ValidationError(
175+
"Operand for 's_intersects' must be a bbox literal or GeoJSON geometry"
176+
)
177+
}
178+
179+
return {
180+
geo_shape: {
181+
[cql2Field]: { shape: geom }
182+
}
183+
}
184+
}
185+
104186
function buildQueryExtQuery(query) {
105187
const eq = 'eq'
106188
const inop = 'in'
@@ -280,49 +362,13 @@ function buildFilterExtQuery(filter) {
280362
}
281363
}
282364
case OP.IN:
283-
if (!Array.isArray(cql2Value) || cql2Value.length === 0) {
284-
throw new ValidationError("Operand for 'in' must be a non-empty array")
285-
}
286-
if (!cql2Value.every((x) => x !== Object(x))) {
287-
throw new ValidationError(
288-
"Operand for 'in' must contain only string, number, or boolean types"
289-
)
290-
}
291-
292-
return {
293-
terms: {
294-
[cql2Field]: cql2Value
295-
}
296-
}
365+
return IN(cql2Field, cql2Value)
297366
case OP.BETWEEN:
298-
if (filter.args.length < 3) {
299-
throw new ValidationError("Two operands must be provided for the 'between' operator")
300-
}
301-
302-
// eslint-disable-next-line no-case-declarations
303-
const cql2Value2 = filter.args[2]
304-
if (!(typeof cql2Value === 'number' && typeof cql2Value2 === 'number')) {
305-
throw new ValidationError("Operands for 'between' must be numbers")
306-
}
307-
308-
if (cql2Value > cql2Value2) {
309-
throw new ValidationError(
310-
"For the 'between' operator, the first operand must be less than or equal "
311-
+ 'to the second operand'
312-
)
313-
}
314-
315-
return {
316-
range: {
317-
[cql2Field]: {
318-
gte: cql2Value,
319-
lte: cql2Value2
320-
}
321-
}
322-
}
323-
367+
return between(cql2Field, filter.args)
324368
case OP.LIKE:
325369
throw new ValidationError("The 'like' operator is not currently supported")
370+
case OP.S_INTERSECTS:
371+
return sIntersects(cql2Field, cql2Value)
326372

327373
// should not get here
328374
default:

src/lib/geo-utils.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import extent from '@mapbox/extent'
2+
import { ValidationError } from './errors.js'
3+
4+
// eslint-disable-next-line import/prefer-default-export
5+
export const bboxToPolygon = function (bbox, fromString) {
6+
if (bbox) {
7+
let bboxArray
8+
if (fromString && typeof bbox === 'string') {
9+
try {
10+
bboxArray = bbox.split(',').map(parseFloat).filter((x) => !Number.isNaN(x))
11+
} catch (_) {
12+
throw new ValidationError('Invalid bbox')
13+
}
14+
} else {
15+
bboxArray = bbox
16+
}
17+
18+
if (!Array.isArray(bboxArray)) {
19+
throw new ValidationError('Invalid bbox')
20+
}
21+
22+
if (bboxArray.length !== 4 && bboxArray.length !== 6) {
23+
throw new ValidationError('Invalid bbox, must have 4 or 6 points')
24+
}
25+
26+
if ((bboxArray.length === 4 && bboxArray[1] > bboxArray[3])
27+
|| (bboxArray.length === 6 && bboxArray[1] > bboxArray[4])) {
28+
throw new ValidationError('Invalid bbox, SW latitude must be less than NE latitude')
29+
}
30+
31+
return extent(bboxArray).polygon()
32+
}
33+
34+
return undefined
35+
}

tests/system/test-api-get-conformance.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ test.after.always(async (t) => {
1313

1414
test('GET /conformance returns the expected conformsTo list', async (t) => {
1515
const response = await t.context.api.client.get('conformance')
16-
t.is(response.conformsTo.length, 22)
16+
t.is(response.conformsTo.length, 24)
1717
})
1818

1919
test('GET /conformance has a content type of "application/json', async (t) => {

tests/system/test-api-search-post.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { loadJson, setup } from '../helpers/system-tests.js'
1313
const __filename = fileURLToPath(import.meta.url)
1414
const __dirname = path.dirname(__filename) // eslint-disable-line no-unused-vars
1515
const intersectsGeometry = fs.readFileSync(path.resolve(__dirname, '../fixtures/stac/intersectsGeometry.json'), 'utf8')
16+
const noIntersectsGeometry = fs.readFileSync(path.resolve(__dirname, '../fixtures/stac/noIntersectsGeometry.json'), 'utf8')
1617

1718
const fixture = (filepath) => fs.readFileSync(path.resolve(__dirname, filepath), 'utf8')
1819

@@ -1290,3 +1291,71 @@ test('/search - filter extension - BETWEEN - failure for number args reversed',
12901291
// eslint-disable-next-line max-len
12911292
/.*For the 'between' operator, the first operand must be less than or equal to the second operand*/)
12921293
})
1294+
1295+
test('/search - filter extension - s_intersects - no matches for bbox', async (t) => {
1296+
const response = await t.context.api.client.post('search', {
1297+
json: {
1298+
filter: {
1299+
op: 's_intersects',
1300+
args: [{ property: 'geometry' }, { bbox: [-1, -1, 0, 0] }]
1301+
}
1302+
}
1303+
})
1304+
t.is(response.features.length, 0)
1305+
})
1306+
1307+
test('/search - filter extension - s_intersects - no matches for polygon', async (t) => {
1308+
const response = await t.context.api.client.post('search', {
1309+
json: {
1310+
filter: {
1311+
op: 's_intersects',
1312+
args: [{ property: 'geometry' }, JSON.parse(noIntersectsGeometry)]
1313+
}
1314+
}
1315+
})
1316+
t.is(response.features.length, 0)
1317+
})
1318+
1319+
test('/search - filter extension - s_intersects - matches for polygon', async (t) => {
1320+
const response = await t.context.api.client.post('search', {
1321+
json: {
1322+
filter: {
1323+
op: 's_intersects',
1324+
args: [{ property: 'geometry' }, JSON.parse(intersectsGeometry)]
1325+
}
1326+
}
1327+
})
1328+
t.is(response.features.length, 3)
1329+
})
1330+
1331+
test('/search - filter extension - s_intersects - failure for not bbox or geometry', async (t) => {
1332+
const error = await t.throwsAsync(async () => t.context.api.client.post('search', {
1333+
json: {
1334+
filter: {
1335+
op: 's_intersects',
1336+
args: [{ property: 'geometry' }, { not_bbox: 'foo' }]
1337+
}
1338+
}
1339+
}))
1340+
t.is(error.response.statusCode, 400)
1341+
t.is(error.response.body.code, 'BadRequest')
1342+
t.regex(error.response.body.description,
1343+
// eslint-disable-next-line max-len
1344+
/.*Operand for 's_intersects' must be a bbox literal or GeoJSON geometry*/)
1345+
})
1346+
1347+
test('/search - filter extension - s_intersects - non-existent geometry type', async (t) => {
1348+
const error = await t.throwsAsync(async () => t.context.api.client.post('search', {
1349+
json: {
1350+
filter: {
1351+
op: 's_intersects',
1352+
args: [{ property: 'geometry' }, { type: 'notPolygon', coordinates: [] }]
1353+
}
1354+
}
1355+
}))
1356+
t.is(error.response.statusCode, 400)
1357+
t.is(error.response.body.code, 'BadRequest')
1358+
t.regex(error.response.body.description,
1359+
// eslint-disable-next-line max-len
1360+
/.*Operand for 's_intersects' must be a GeoJSON geometry: type was 'notPolygon'*/)
1361+
})

0 commit comments

Comments
 (0)