Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions ats_form_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ package main
import (
"encoding/json"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
Expand Down Expand Up @@ -138,3 +139,67 @@ func TestATSInterpolationRefusalReachesTheClient(t *testing.T) {
})
}
}

// /conf/movingfeatures/param-subtemporalvalue-response — the subTemporalValue
// parameter is processed rather than advertised.
//
// ⛔ THE FLAG IS NOT A SYNONYM FOR PASSING `datetime`. Part 1 states that under it
// the datetime parameter IS a bounded interval and `leaf` is not used, so a request
// carrying the flag without a bounded interval is refused, and one carrying `leaf`
// beside it is answered on the interval. A parameter that changes nothing a client
// can observe is advertised, not implemented.
func TestATSSubTemporalValueIsProcessed(t *testing.T) {
const col = "v"
for _, c := range []struct {
what string
query string
wantErr bool
wantExpr string
}{
{"absent, so the other parameters select as before", "", false, "v"},
{"absent with an interval, which clips on its own", "datetime=A/B", false, "atTime(v, CAST($1 AS tstzspan))"},
{"set, with a bounded interval", "subTemporalValue=true&datetime=A/B", false, "atTime(v, CAST($1 AS tstzspan))"},
{"set, lower case as OGC writes it", "subtemporalvalue=true&datetime=A/B", false, "atTime(v, CAST($1 AS tstzspan))"},
{"set beside leaf, which it takes out of the selection", "subTemporalValue=true&datetime=A/B&leaf=T", false, "atTime(v, CAST($1 AS tstzspan))"},
{"set with no datetime at all", "subTemporalValue=true", true, ""},
{"set with an instant rather than an interval", "subTemporalValue=true&datetime=A", true, ""},
{"set with an open-ended interval", "subTemporalValue=true&datetime=A/..", true, ""},
} {
t.Run(c.what, func(t *testing.T) {
q, err := url.ParseQuery(c.query)
if err != nil {
t.Fatal(err)
}
expr, _, cerr := clipSub(col, "subTemporalValue", q, nil)
if c.wantErr {
if cerr == nil {
t.Fatalf("%s answers %q, want a refusal", c.what, expr)
}
return
}
if cerr != nil {
t.Fatalf("%s is refused: %v", c.what, cerr)
}
if expr != c.wantExpr {
t.Errorf("%s selects %q, want %q", c.what, expr, c.wantExpr)
}
})
}
}

// The temporal-geometry route answers to no such flag: Part 1 defines
// subTemporalValue for a temporal property, and a route that honoured a parameter
// its resource does not define would be the same defect in the other direction.
func TestATSSubTemporalValueIsNotAGeometryFlag(t *testing.T) {
q, err := url.ParseQuery("subTemporalValue=true")
if err != nil {
t.Fatal(err)
}
expr, _, cerr := clip("trip", q, nil)
if cerr != nil {
t.Fatalf("the temporal geometry route refuses a parameter it does not define: %v", cerr)
}
if expr != "trip" {
t.Errorf("the temporal geometry route selects %q for a property flag, want the value unclipped", expr)
}
}
68 changes: 68 additions & 0 deletions ats_live_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package main

import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
Expand Down Expand Up @@ -191,3 +192,70 @@ func TestATSLiveFixtureIsRestored(t *testing.T) {
t.Errorf("the second feature reads %d, want 200", rec.Code)
}
}

// /conf/movingfeatures/param-subtemporalvalue-response: the subTemporalValue
// parameter is processed, against the fixture's own property.
//
// ⛔ THE ASSERTION IS THAT THE ANSWER MOVES. A parameter that is read and changes
// nothing a client receives is indistinguishable from one that is ignored, which
// is the state this test exists to keep the tier out of: the clipped answer holds
// strictly fewer instants than the unclipped one, and none outside the interval.
func TestATSLiveSubTemporalValue(t *testing.T) {
mux, done := atsLiveMux(t)
defer done()
const path = "/collections/conformance/items/1/tproperties/speed"

instants := func(rec *httptest.ResponseRecorder) []string {
t.Helper()
var doc struct {
ValueSequence []struct {
Datetimes []string `json:"datetimes"`
} `json:"valueSequence"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
t.Fatalf("the TemporalProperty document is not JSON: %v (%s)", err, rec.Body.String())
}
var out []string
for _, s := range doc.ValueSequence {
out = append(out, s.Datetimes...)
}
return out
}

whole := atsDo(t, mux, "GET", path, "")
if whole.Code != 200 {
t.Fatalf("GET the property = %d, want 200 (%s)", whole.Code, whole.Body.String())
}
all := instants(whole)
if len(all) < 3 {
t.Fatalf("the fixture property holds %d instants, too few for a clip to be visible", len(all))
}

// The fixture's speed runs 08:00 to 08:45; this interval ends before the last
// two instants, so a processed parameter cannot return all of them.
sub := atsDo(t, mux, "GET",
path+"?subTemporalValue=true&datetime=2026-01-01T08:00:00Z/2026-01-01T08:20:00Z", "")
if sub.Code != 200 {
t.Fatalf("GET with subTemporalValue = %d, want 200 (%s)", sub.Code, sub.Body.String())
}
clipped := instants(sub)
if len(clipped) == 0 {
t.Fatal("subTemporalValue returns no instants at all")
}
if len(clipped) >= len(all) {
t.Errorf("subTemporalValue returns %d instants against %d unclipped, so the parameter "+
"changes nothing a client receives", len(clipped), len(all))
}
for _, d := range clipped {
if d > "2026-01-01T08:20:01" {
t.Errorf("subTemporalValue returns %s, which is outside the interval it names", d)
}
}

// Part 1 states the datetime IS a bounded interval under the flag, so a request
// without one is not a request this route can answer.
if rec := atsDo(t, mux, "GET", path+"?subTemporalValue=true", ""); rec.Code != 400 {
t.Errorf("subTemporalValue without a bounded interval = %d, want 400 (%s)",
rec.Code, rec.Body.String())
}
}
44 changes: 42 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -856,7 +856,43 @@ func ensureTPropTable(ctx context.Context, q interface {

// clip wraps a temporal expression with atTime for the OGC leaf (instant set)
// or datetime (interval) selector, binding the selector value as a parameter.
// clip restricts an expression to what the OGC time parameters select.
func clip(expr string, q url.Values, args []any) (string, []any, error) {
return clipSub(expr, "", q, args)
}

// clipSub is clip under a sub-resource flag. Part 1 states the flag's effect for
// both of the pair it defines — `subTrajectory` for a temporal geometry and
// `subTemporalValue` for a temporal property — in the same words: "Only a
// subsequence … clipped to the datetime interval is returned. The datetime
// parameter is then a bounded interval and the leaf parameter is not used."
//
// So the flag is not a synonym for passing `datetime`. It makes the bounded
// interval REQUIRED, which is what lets a client rely on receiving a subsequence
// rather than whatever the parameters happened to select, and it takes `leaf` out
// of the selection. subParam empty means the caller's resource defines no such
// flag, and the parameter is then not one this route answers to.
//
// ⛔ OGC WRITES THE NAME IN LOWER CASE IN ITS OWN EXAMPLES, so both spellings are
// read, as the temporal-geometry side already does.
func clipSub(expr, subParam string, q url.Values, args []any) (string, []any, error) {
if subParam != "" {
sub := first(q, strings.ToLower(subParam))
if sub == "" {
sub = first(q, subParam)
}
if sub == "true" {
dt := q.Get("datetime")
s, e, ok := splitInterval(dt)
if !ok || strings.TrimSpace(s) == "" || strings.TrimSpace(e) == "" ||
strings.Contains(dt, "..") {
return "", nil, errors.New(subParam +
" requires the datetime parameter to be a bounded interval")
}
args = append(args, "["+strings.TrimSpace(s)+", "+strings.TrimSpace(e)+"]")
return "atTime(" + expr + ", CAST($" + itoa(len(args)) + " AS tstzspan))", args, nil
}
}
if lf := q.Get("leaf"); lf != "" {
set, err := tstzSet(lf)
if err != nil {
Expand Down Expand Up @@ -1012,7 +1048,7 @@ func getTProperty(w http.ResponseWriter, r *http.Request) {
httpErr(w, 500, "stored property has an unknown type: "+ptype)
return
}
expr, args, cerr := clip(tt.col, r.URL.Query(), []any{cid, fid, name})
expr, args, cerr := clipSub(tt.col, "subTemporalValue", r.URL.Query(), []any{cid, fid, name})
if cerr != nil {
httpErr(w, 400, cerr.Error())
return
Expand Down Expand Up @@ -1208,8 +1244,12 @@ func apiDoc(w http.ResponseWriter, r *http.Request) {
},
"/collections/{cid}/items/{fid}/tgsequence/{tgid}/{qtype}": get("Derived query on a member geometry: distance | velocity (acceleration → 501, not derivable for this motion model)"),
"/collections/{cid}/items/{fid}/tproperties": map[string]any{
// The document this route answers carries each property's name, type,
// unit and links, and no valueSequence — which `temporalProperty`
// makes optional. A flag that clips values has nothing to clip here,
// so this route does not advertise one.
"get": withParams(op("Stored temporal properties of a feature"),
limitParam, datetimeParam, subTemporalValueParam),
limitParam, datetimeParam),
"post": op("Add one or more temporal properties (TReal | TInteger | TText | TBoolean) to a feature"),
},
"/collections/{cid}/items/{fid}/tproperties/{pname}": map[string]any{
Expand Down
12 changes: 0 additions & 12 deletions samples/api-definition.json
Original file line number Diff line number Diff line change
Expand Up @@ -386,18 +386,6 @@
"type": "string"
},
"style": "form"
},
{
"description": "Only a subsequence of the temporal property clipped to the datetime interval is returned. The datetime parameter is then a bounded interval and the leaf parameter is not used.",
"explode": false,
"in": "query",
"name": "subTemporalValue",
"required": false,
"schema": {
"default": false,
"type": "boolean"
},
"style": "form"
}
],
"responses": {
Expand Down
2 changes: 1 addition & 1 deletion samples/temporal-properties.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,6 @@
"type": "TText"
}
],
"timeStamp": "2026-08-29T17:29:23Z"
"timeStamp": "2026-08-29T21:58:37Z"
}

Loading