diff --git a/ats_form_test.go b/ats_form_test.go index 934a1be..adf5222 100644 --- a/ats_form_test.go +++ b/ats_form_test.go @@ -13,6 +13,7 @@ package main import ( "encoding/json" "net/http/httptest" + "net/url" "strings" "testing" ) @@ -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) + } +} diff --git a/ats_live_test.go b/ats_live_test.go index 87b83bf..e1ab1e3 100644 --- a/ats_live_test.go +++ b/ats_live_test.go @@ -15,6 +15,7 @@ package main import ( + "encoding/json" "net/http" "net/http/httptest" "os" @@ -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()) + } +} diff --git a/main.go b/main.go index 1b9c275..21390ca 100644 --- a/main.go +++ b/main.go @@ -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 { @@ -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 @@ -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{ diff --git a/samples/api-definition.json b/samples/api-definition.json index 9194e91..525bb66 100644 --- a/samples/api-definition.json +++ b/samples/api-definition.json @@ -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": { diff --git a/samples/temporal-properties.json b/samples/temporal-properties.json index 42d29cc..824c9e5 100644 --- a/samples/temporal-properties.json +++ b/samples/temporal-properties.json @@ -55,6 +55,6 @@ "type": "TText" } ], - "timeStamp": "2026-08-29T17:29:23Z" + "timeStamp": "2026-08-29T21:58:37Z" }