Skip to content

Commit

Permalink
Merge pull request #117 from PDOK/ogc_api_features/filter_by_referenc…
Browse files Browse the repository at this point in the history
…e_date

Filter collection features by reference date
  • Loading branch information
kad-korpem authored Feb 22, 2024
2 parents 01bbad5 + 370195a commit 84c8534
Show file tree
Hide file tree
Showing 22 changed files with 216 additions and 57 deletions.
4 changes: 3 additions & 1 deletion assets/i18n/active.en.toml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ Format = "Format"

# Collections/Collection page
ViewCollectionAs = "View collection as"
Extent = "Geographic extent"
GeographicExtent = "Geographic extent"
TemporalExtent = "Temporal extent"
GoTo = "Go to the"
ViewIn = "View in the"
Browse = "Browse through the"
Expand All @@ -103,3 +104,4 @@ Geometry = "geometry"
Prev = "Previous"
Next = "Next"
Items = "items"
ReferenceDate = "Date"
4 changes: 3 additions & 1 deletion assets/i18n/active.nl.toml
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ Format = "Formaat"

# Collections/Collection page
ViewCollectionAs = "Bekijk collectie als"
Extent = "Geografische begrenzing"
GeographicExtent = "Geografische begrenzing"
TemporalExtent = "Temporele begrenzing"
GoTo = "Ga naar de"
ViewIn = "Bekijk in de"
Browse = "Blader door de"
Expand All @@ -109,3 +110,4 @@ Geometry = "geometrie"
Prev = "Vorige"
Next = "Volgende"
Items = "items"
ReferenceDate = "Peildatum"
46 changes: 35 additions & 11 deletions engine/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,24 @@ func validate(config *Config) error {
errMessages = append(errMessages, valErr.Error()+"\n")
}
}
return fmt.Errorf("invalid config provided:\n %v", errMessages)
return fmt.Errorf("invalid config provided:\n%v", errMessages)
}
// custom validations
if config.OgcAPI.Features != nil {
return validateCollectionsTemporalConfig(config.OgcAPI.Features.Collections)
}
return nil
}

func validateCollectionsTemporalConfig(collections GeoSpatialCollections) error {
var errMessages []string
for _, collection := range collections {
if collection.Metadata != nil && collection.Metadata.TemporalProperties != nil && collection.Metadata.Extent.Interval == nil {
errMessages = append(errMessages, fmt.Sprintf("validation failed for collection '%s'; field 'Extent.Interval' is required with field 'TemporalProperties'\n", collection.ID))
}
}
if len(errMessages) > 0 {
return fmt.Errorf("invalid config provided:\n%v", errMessages)
}
return nil
}
Expand Down Expand Up @@ -215,13 +232,14 @@ type GeoSpatialCollection struct {
}

type GeoSpatialCollectionMetadata struct {
Title *string `yaml:"title"`
Description *string `yaml:"description"`
Thumbnail *string `yaml:"thumbnail"`
Keywords []string `yaml:"keywords"`
LastUpdated *string `yaml:"lastUpdated"`
LastUpdatedBy string `yaml:"lastUpdatedBy"`
Extent *Extent `yaml:"extent"`
Title *string `yaml:"title"`
Description *string `yaml:"description" validate:"required"`
Thumbnail *string `yaml:"thumbnail"`
Keywords []string `yaml:"keywords"`
LastUpdated *string `yaml:"lastUpdated"`
LastUpdatedBy string `yaml:"lastUpdatedBy"`
TemporalProperties *TemporalProperties `yaml:"temporalProperties" validate:"omitempty,required_with=Extent.Interval"`
Extent *Extent `yaml:"extent"`
}

type CollectionEntry3dGeoVolumes struct {
Expand Down Expand Up @@ -294,7 +312,7 @@ type OgcAPIFeatures struct {
Limit Limit `yaml:"limit"`
Datasources *Datasources `yaml:"datasources"` // optional since you can also define datasources at the collection level
Basemap string `yaml:"basemap" default:"OSM"`
Collections GeoSpatialCollections `yaml:"collections" validate:"required"`
Collections GeoSpatialCollections `yaml:"collections" validate:"required,dive"`

// Whether GeoJSON/JSON-FG responses will be validated against the OpenAPI spec
// since it has significant performance impact when dealing with large JSON payloads.
Expand Down Expand Up @@ -464,8 +482,14 @@ type ZoomLevelRange struct {
}

type Extent struct {
Srs string `yaml:"srs" validate:"required,startswith=EPSG:"`
Bbox []string `yaml:"bbox"`
Srs string `yaml:"srs" validate:"required,startswith=EPSG:"`
Bbox []string `yaml:"bbox"`
Interval []string `yaml:"interval" validate:"omitempty,len=2"`
}

type TemporalProperties struct {
StartDate string `yaml:"startDate" validate:"required"`
EndDate string `yaml:"endDate" validate:"required"`
}

type License struct {
Expand Down
2 changes: 1 addition & 1 deletion engine/templates/openapi/features.go.json
Original file line number Diff line number Diff line change
Expand Up @@ -1036,7 +1036,7 @@
"datetime": {
"name": "datetime",
"in": "query",
"description": "Either a date-time or an interval. Date and time expressions adhere to RFC 3339.\nIntervals may be bounded or half-bounded (double-dots at start or end).\n\nExamples:\n\n* A date-time: \"2018-02-12T23:20:50Z\"\n* A bounded interval: \"2018-02-12T00:00:00Z/2018-03-18T12:31:12Z\"\n* Half-bounded intervals: \"2018-02-12T00:00:00Z/..\" or \"../2018-03-18T12:31:12Z\"\n\nOnly features that have a temporal property that intersects the value of\n`datetime` are selected.\n\nIf a feature has multiple temporal properties, it is the decision of the\nserver whether only a single temporal property is used to determine\nthe extent or all relevant temporal properties.",
"description": "A date-time (intervals are currently not supported). Date and time expressions adhere to RFC 3339.\n\nExamples:\n\n* A date-time: \"2018-02-12T23:20:50Z\"\n\nOnly features that have a temporal property that intersects the value of\n`datetime` are selected.\n\nIf a feature has multiple temporal properties, it is the decision of the\nserver whether only a single temporal property is used to determine\nthe extent or all relevant temporal properties.",
"required": false,
"style": "form",
"explode": false,
Expand Down
4 changes: 4 additions & 0 deletions examples/config_features_local.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ ogcApi:
- Address
thumbnail: old.png
lastUpdated: "2030-01-02T12:00:00Z"
temporalProperties:
startDate: validfrom
endDate: validto
extent:
srs: EPSG:4326
bbox: [ "50.2129", "2.52713", "55.7212", "7.37403" ]
interval: [ "\"1970-01-01T00:00:00Z\"", "null" ]
Binary file modified examples/resources/addresses-crs84.gpkg
Binary file not shown.
Binary file modified examples/resources/addresses-etrs89.gpkg
Binary file not shown.
Binary file modified examples/resources/addresses-rd.gpkg
Binary file not shown.
10 changes: 9 additions & 1 deletion ogc/common/geospatial/templates/collection.go.html
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,19 @@ <h5 class="card-title">Features</h5>
{{ end }}
{{ if and .Params.Metadata .Params.Metadata.Extent }}
<li class="list-group-item">
<strong>{{ i18n "Extent" }}</strong>
<strong>{{ i18n "GeographicExtent" }}</strong>
(<a href="http://www.opengis.net/def/crs/EPSG/0/{{ trimPrefix "EPSG:" .Params.Metadata.Extent.Srs }}" target="_blank">{{ .Params.Metadata.Extent.Srs }}</a>):
{{ .Params.Metadata.Extent.Bbox | join ", " }}
</li>
{{ end }}
{{ if and .Params.Metadata .Params.Metadata.Extent.Interval }}
<li class="list-group-item">
<strong>{{ i18n "TemporalExtent" }}</strong>
(<a href="http://www.opengis.net/def/uom/ISO-8601/0/Gregorian" target="_blank">ISO-8601</a>):
{{ toDate "2006-01-02T15:04:05Z" ((first .Params.Metadata.Extent.Interval) | replace "\"" "") | date "2006-01-02" }} /
{{ if not (contains "null" (last .Params.Metadata.Extent.Interval)) }}{{ toDate "2006-01-02T15:04:05Z" ((last .Params.Metadata.Extent.Interval) | replace "\"" "") | date "2006-01-02" }}{{ else }}..{{ end }}
</li>
{{ end }}
</ul>
</div>
</div>
Expand Down
6 changes: 5 additions & 1 deletion ogc/common/geospatial/templates/collection.go.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
"spatial": {
"bbox": [ [ {{ .Params.Metadata.Extent.Bbox | join "," }} ] ],
"crs" : "http://www.opengis.net/def/crs/EPSG/0/{{ trimPrefix "EPSG:" .Params.Metadata.Extent.Srs }}"
}
}{{ if and .Params.Metadata .Params.Metadata.Extent.Interval }},
"temporal": {
"interval": [ [ {{ .Params.Metadata.Extent.Interval | join ", " }} ] ],
"trs" : "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"
}{{ end }}
},
{{ end }}
{{ if and .Config.OgcAPI.Features .Config.OgcAPI.Features.Collections }}
Expand Down
10 changes: 9 additions & 1 deletion ogc/common/geospatial/templates/collections.go.html
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,19 @@ <h5 class="card-header">
{{ end }}
{{ if and $coll.Metadata $coll.Metadata.Extent }}
<li class="list-group-item">
<strong>{{ i18n "Extent" }}</strong>
<strong>{{ i18n "GeographicExtent" }}</strong>
(<a href="http://www.opengis.net/def/crs/EPSG/0/{{ trimPrefix "EPSG:" $coll.Metadata.Extent.Srs }}" target="_blank">{{ $coll.Metadata.Extent.Srs }}</a>):
{{ $coll.Metadata.Extent.Bbox | join ", " }}
</li>
{{ end }}
{{ if and $coll.Metadata $coll.Metadata.Extent.Interval }}
<li class="list-group-item">
<strong>{{ i18n "TemporalExtent" }}</strong>
(<a href="http://www.opengis.net/def/uom/ISO-8601/0/Gregorian" target="_blank">ISO-8601</a>):
{{ toDate "2006-01-02T15:04:05Z" ((first $coll.Metadata.Extent.Interval) | replace "\"" "") | date "2006-01-02" }} /
{{ if not (contains "null" (last $coll.Metadata.Extent.Interval)) }}{{ toDate "2006-01-02T15:04:05Z" ((last $coll.Metadata.Extent.Interval) | replace "\"" "") | date "2006-01-02" }}{{ else }}..{{ end }}
</li>
{{ end }}
</ul>
{{ if and $coll.Metadata $coll.Metadata.Thumbnail }}
<img src="resources/{{ $coll.Metadata.Thumbnail }}" class="card-img-bottom" alt="Tumbnail of collection {{ $coll.ID }}">
Expand Down
6 changes: 5 additions & 1 deletion ogc/common/geospatial/templates/collections.go.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@
"spatial": {
"bbox": [ [ {{ $coll.Metadata.Extent.Bbox | join "," }} ] ],
"crs" : "http://www.opengis.net/def/crs/EPSG/0/{{ trimPrefix "EPSG:" $coll.Metadata.Extent.Srs }}"
}
}{{ if and $coll.Metadata $coll.Metadata.Extent.Interval }},
"temporal": {
"interval": [ [ {{ $coll.Metadata.Extent.Interval | join ", " }} ] ],
"trs" : "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"
}{{ end }}
}
{{ end }}
{{ if and $cfg.OgcAPI.Features $cfg.OgcAPI.Features.Collections }}
Expand Down
13 changes: 13 additions & 0 deletions ogc/features/datasources/datasource.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package datasources

import (
"context"
"time"

"github.com/PDOK/gokoala/ogc/features/domain"
"github.com/go-spatial/geom"
Expand Down Expand Up @@ -43,6 +44,9 @@ type FeaturesCriteria struct {
// filtering by bounding box
Bbox *geom.Extent

// filtering by reference date
TemporalCriteria TemporalCriteria

// filtering by properties
PropertyFilters map[string]string

Expand All @@ -51,6 +55,15 @@ type FeaturesCriteria struct {
FilterLang string
}

type TemporalCriteria struct {
// reference date
ReferenceDate time.Time

// startDate and endDate properties
StartDateProperty string
EndDateProperty string
}

// FeatureTableMetadata abstraction to access metadata of a feature table (aka attribute table)
type FeatureTableMetadata interface {

Expand Down
8 changes: 8 additions & 0 deletions ogc/features/datasources/geopackage/asserts.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ func assertIndexesExist(
return err
}

// assert temporal columns are indexed if configured
if coll.Metadata != nil && coll.Metadata.TemporalProperties != nil {
temporalBtreeColumns := strings.Join([]string{coll.Metadata.TemporalProperties.StartDate, coll.Metadata.TemporalProperties.EndDate}, ",")
if err := assertIndexExists(table.TableName, db, temporalBtreeColumns); err != nil {
return err
}
}

// assert the column for each property filter is indexed.
for _, propertyFilter := range coll.Features.Filters.Properties {
if err := assertIndexExists(table.TableName, db, propertyFilter.Name); err != nil {
Expand Down
39 changes: 27 additions & 12 deletions ogc/features/datasources/geopackage/geopackage.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,21 +281,23 @@ func (g *GeoPackage) makeFeaturesQuery(ctx context.Context, table *featureTable,

func (g *GeoPackage) makeDefaultQuery(table *featureTable, criteria datasources.FeaturesCriteria) (string, map[string]any) {
pfClause, pfNamedParams := propertyFiltersToSQL(criteria.PropertyFilters)
temporalClause, temporalNamedParams := temporalCriteriaToSQL(criteria.TemporalCriteria)

defaultQuery := fmt.Sprintf(`
with
next as (select * from %[1]s where %[2]s >= :fid %[3]s order by %[2]s asc limit :limit + 1),
prev as (select * from %[1]s where %[2]s < :fid %[3]s order by %[2]s desc limit :limit),
next as (select * from %[1]s where %[2]s >= :fid %[3]s %[4]s order by %[2]s asc limit :limit + 1),
prev as (select * from %[1]s where %[2]s < :fid %[3]s %[4]s order by %[2]s desc limit :limit),
nextprev as (select * from next union all select * from prev),
nextprevfeat as (select *, lag(%[2]s, :limit) over (order by %[2]s) as prevfid, lead(%[2]s, :limit) over (order by %[2]s) as nextfid from nextprev)
select * from nextprevfeat where %[2]s >= :fid %[3]s limit :limit
`, table.TableName, g.fidColumn, pfClause) // don't add user input here, use named params for user input!
select * from nextprevfeat where %[2]s >= :fid %[3]s %[4]s limit :limit
`, table.TableName, g.fidColumn, temporalClause, pfClause) // don't add user input here, use named params for user input!

namedParams := map[string]any{
"fid": criteria.Cursor.FID,
"limit": criteria.Limit,
}
maps.Copy(namedParams, pfNamedParams)
maps.Copy(namedParams, temporalNamedParams)
return defaultQuery, namedParams
}

Expand All @@ -313,6 +315,7 @@ func (g *GeoPackage) makeBboxQuery(table *featureTable, onlyFIDs bool, criteria
// whether to use the BTree index or the property filter index
btreeIndexHint = ""
}
temporalClause, temporalNamedParams := temporalCriteriaToSQL(criteria.TemporalCriteria)

bboxQuery := fmt.Sprintf(`
with
Expand All @@ -325,37 +328,37 @@ with
from %[1]s f inner join rtree_%[1]s_%[4]s rf on f.%[2]s = rf.id
where rf.minx <= :maxx and rf.maxx >= :minx and rf.miny <= :maxy and rf.maxy >= :miny
and st_intersects((select * from given_bbox), castautomagic(f.%[4]s)) = 1
and f.%[2]s >= :fid %[6]s
and f.%[2]s >= :fid %[6]s %[7]s
order by f.%[2]s asc
limit (select iif(bbox_size == 'small', :limit + 1, 0) from bbox_size)),
next_bbox_btree as (select f.*
from %[1]s f %[7]s
from %[1]s f %[8]s
where f.minx <= :maxx and f.maxx >= :minx and f.miny <= :maxy and f.maxy >= :miny
and st_intersects((select * from given_bbox), castautomagic(f.%[4]s)) = 1
and f.%[2]s >= :fid %[6]s
and f.%[2]s >= :fid %[6]s %[7]s
order by f.%[2]s asc
limit (select iif(bbox_size == 'big', :limit + 1, 0) from bbox_size)),
next as (select * from next_bbox_rtree union all select * from next_bbox_btree),
prev_bbox_rtree as (select f.*
from %[1]s f inner join rtree_%[1]s_%[4]s rf on f.%[2]s = rf.id
where rf.minx <= :maxx and rf.maxx >= :minx and rf.miny <= :maxy and rf.maxy >= :miny
and st_intersects((select * from given_bbox), castautomagic(f.%[4]s)) = 1
and f.%[2]s < :fid %[6]s
and f.%[2]s < :fid %[6]s %[7]s
order by f.%[2]s desc
limit (select iif(bbox_size == 'small', :limit, 0) from bbox_size)),
prev_bbox_btree as (select f.*
from %[1]s f %[7]s
from %[1]s f %[8]s
where f.minx <= :maxx and f.maxx >= :minx and f.miny <= :maxy and f.maxy >= :miny
and st_intersects((select * from given_bbox), castautomagic(f.%[4]s)) = 1
and f.%[2]s < :fid %[6]s
and f.%[2]s < :fid %[6]s %[7]s
order by f.%[2]s desc
limit (select iif(bbox_size == 'big', :limit, 0) from bbox_size)),
prev as (select * from prev_bbox_rtree union all select * from prev_bbox_btree),
nextprev as (select * from next union all select * from prev),
nextprevfeat as (select *, lag(%[2]s, :limit) over (order by %[2]s) as prevfid, lead(%[2]s, :limit) over (order by %[2]s) as nextfid from nextprev)
select %[5]s from nextprevfeat where %[2]s >= :fid %[6]s limit :limit
select %[5]s from nextprevfeat where %[2]s >= :fid %[6]s %[7]s limit :limit
`, table.TableName, g.fidColumn, g.maxBBoxSizeToUseWithRTree, table.GeometryColumnName,
selectClause, pfClause, btreeIndexHint) // don't add user input here, use named params for user input!
selectClause, temporalClause, pfClause, btreeIndexHint) // don't add user input here, use named params for user input!

bboxAsWKT, err := wkt.EncodeString(criteria.Bbox)
if err != nil {
Expand All @@ -371,6 +374,7 @@ select %[5]s from nextprevfeat where %[2]s >= :fid %[6]s limit :limit
"miny": criteria.Bbox.MinY(),
"bboxSrid": criteria.InputSRID}
maps.Copy(namedParams, pfNamedParams)
maps.Copy(namedParams, temporalNamedParams)
return bboxQuery, namedParams, nil
}

Expand Down Expand Up @@ -406,3 +410,14 @@ func propertyFiltersToSQL(pf map[string]string) (sql string, namedParams map[str
}
return sql, namedParams
}

func temporalCriteriaToSQL(temporalCriteria datasources.TemporalCriteria) (sql string, namedParams map[string]any) {
namedParams = make(map[string]any)
if !temporalCriteria.ReferenceDate.IsZero() {
namedParams["referenceDate"] = temporalCriteria.ReferenceDate
startDate := temporalCriteria.StartDateProperty
endDate := temporalCriteria.EndDateProperty
sql = fmt.Sprintf(" and \"%[1]s\" <= :referenceDate and (\"%[2]s\" >= :referenceDate or \"%[2]s\" is null)", startDate, endDate)
}
return sql, namedParams
}
11 changes: 9 additions & 2 deletions ogc/features/html.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package features
import (
"net/http"
"strconv"
"time"

"github.com/PDOK/gokoala/engine"
"github.com/PDOK/gokoala/ogc/features/domain"
Expand Down Expand Up @@ -46,6 +47,7 @@ type featureCollectionPage struct {
PrevLink string
NextLink string
Limit int
ReferenceDate *time.Time
PropertyFilters map[string]string
}

Expand All @@ -59,8 +61,8 @@ type featurePage struct {
}

func (hf *htmlFeatures) features(w http.ResponseWriter, r *http.Request, collectionID string,
cursor domain.Cursors, featuresURL featureCollectionURL, limit int, propertyFilters map[string]string,
fc *domain.FeatureCollection) {
cursor domain.Cursors, featuresURL featureCollectionURL, limit int, referenceDate *time.Time,
propertyFilters map[string]string, fc *domain.FeatureCollection) {

collectionMetadata := collections[collectionID]

Expand All @@ -76,6 +78,10 @@ func (hf *htmlFeatures) features(w http.ResponseWriter, r *http.Request, collect
},
}...)

if referenceDate.IsZero() {
referenceDate = nil
}

pageContent := &featureCollectionPage{
*fc,
collectionID,
Expand All @@ -84,6 +90,7 @@ func (hf *htmlFeatures) features(w http.ResponseWriter, r *http.Request, collect
featuresURL.toPrevNextURL(collectionID, cursor.Prev, engine.FormatHTML),
featuresURL.toPrevNextURL(collectionID, cursor.Next, engine.FormatHTML),
limit,
referenceDate,
propertyFilters,
}

Expand Down
Loading

0 comments on commit 84c8534

Please sign in to comment.