Skip to content

transactions: improve update, support versioned feature mutations#525

Merged
cportele merged 17 commits into
masterfrom
tx-2
Jun 15, 2026
Merged

transactions: improve update, support versioned feature mutations#525
cportele merged 17 commits into
masterfrom
tx-2

Conversation

@cportele

@cportele cportele commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Improves transactions and adds the provider-side foundation for versioned features (per-feature version selection, version history, immutable mementoes) plus a generic mechanism to represent properties as web links.

Mutations

Property-level partial updates

FeatureTransactions.PropertyUpdate and Session.patchFeature (default: unsupported). SqlMutationSession implements it as native SQL on the session's open connection:

  • VALUE and GEOMETRY columns are updated in the main feature table
  • VALUE_ARRAY / OBJECT_ARRAY junction tables: DELETE existing rows, INSERT new rows
  • OBJECT_ARRAY elements with nested OBJECT children, M:N junctions and FEATURE_REF(_ARRAY) are rejected with an error

Session API for versioned-feature mutations

Session-level extensions for retire-and-insert (Replace), retire-in-place / clone-and-patch (Update) and retire-only (Delete) flows on versioned collections, implemented as statements on the session's JDBC connection so they share the atomic
transaction with the existing insert/patch flows:

  • retireFeature and patchOpenVersion accept an optional expectedStart predicate (If-Unmodified-Since-style guard)
  • createFeatures gains a post-INSERT pass that updates role-bound columns the encoder cannot reach (read+filter scoped, not writable; e.g. denormalized predecessor pointers maintained by the session, not by clients)
  • New SchemaBase roles PREDECESSOR_INTERVAL_START and SUCCESSOR_INTERVAL_START declare such denormalized pointer columns

Queries

Pipeline support for versioned features

  • The two new roles map to the predecessor-version / successor-version link relations; FeatureTokenTransformerPropertyLinks strips the values from the token stream and surfaces them per feature (for the format writers) and per result (for the Link headers of single-feature responses)
  • FeatureTokenTransformerExtension SPI: a FeatureQueryExtension can contribute a token-stream transformer (used by ldproxy for composite version ids); ModifiableContext gains canonicalFeatureId()
  • The transformer is only wired into a feature stream when the resolved schema of the queried type has a property with an effective link
  • containsIdFilter now recognizes And(In(_ID_), …), so single-feature queries with an additional predicate (e.g. the datetime parameter) no longer produce an empty surrogate-key range

Properties as web links via schema configuration

A property can declare a link object with a mandatory link relation type and URI template:

link:
  rel: related
  uriTemplate: 'https://example.com/register/{{value}}'

Such a property is not emitted inline; it is captured as a structured PropertyLink (rel, URI template, value, label) so the API layer can represent it as a web link. {{value}}, {{featureUri}}, {{collectionUri}} and {{serviceUri}} are resolved in the API layer. The two versioning roles derive a default link ({{featureUri}}?datetime={{value}}); an explicit link overrides it.

DATETIME values are normalized to ISO instants, DATE values stay dates. Includes a new documentation page for link, covering the boundary to feature references.

Fixes

  • The temporal extent of query results was silently dropped for DATE-typed primary instant/interval properties (Instant.parse failure); dates are now interpreted as start of day UTC
  • The original object type of a property is remembered in the schema (needed for wfs:Update value capture)

Adds FeatureTransactions.PropertyUpdate and Session.patchFeature with a
default unsupported implementation. SqlMutationSession implements it as
native SQL on the session's open connection so partial updates see prior
writes from the same transaction:

  - main-table SET for scalar/datetime/boolean columns
  - geometry via the existing toWkt path: GeoJSON in -> Geometry ->
    ST_GeomFromText (with ST_ForcePolygonCW for POLYGON / MULTI_POLYGON),
    matching the encoding the INSERT path emits
  - VALUE_ARRAY and OBJECT_ARRAY junctions: DELETE existing rows by
    parent_pk + INSERT new rows on the same session connection

OBJECT_ARRAY elements with nested OBJECT children, M:N junctions and
FEATURE_REF arrays remain unsupported and are rejected with a clear error.
Adds the Session-level extensions the executor needs to drive
retire-and-insert (Replace), retire-in-place / clone-and-patch
(Update), and retire-only (Delete) flows on versioned collections:

  - createFeatures(featureType, sources, crs, roleOverrides)
  - retireFeature(featureType, id, ts[, expectedStart])
  - patchOpenVersion(featureType, id, updates, idFilter[, expectedStart])
  - assertNoConflictingVersion(featureType, id, ts)
  - getOpenVersionStart(featureType, id)

SqlMutationSession implements these as hand-built UPDATE / SELECT
statements on the session's JDBC connection so they share the atomic
transaction with the executor's pre-existing patch/insert flows.
retireFeature and patchOpenVersion accept an optional expectedStart
predicate (If-Unmodified-Since-style guard for composite-id flows).
createFeatures gains a post-INSERT pass that emits a follow-up UPDATE
for role-bound columns the encoder can't reach because the property
is scoped read+filter but not write (e.g. denorm predecessor pointers
populated by the session, not by clients).

Two new SchemaBase roles, PREDECESSOR_INTERVAL_START and
SUCCESSOR_INTERVAL_START, let versioned collections declare their
denorm pointer columns to the same lookup machinery the existing
interval roles use.
@cportele cportele changed the title transactions: property-level partial update transactions: improve update, support versioned feature mutations Jun 7, 2026
cportele added 13 commits June 9, 2026 09:29
Each FeatureSchema property now carries an optional originObjectType:
the objectType of the schema fragment that originally listed the property.
LocalSchemaFragmentResolver tags every property contributed by a merged
fragment with the fragment's objectType (recursively, with outer-fragment
tagging winning over inner). The tag is @JsonIgnore + @DocIgnore: it is
populated only by schema resolution, not from YAML.

FeatureTokenDecoderGml's namespace-expectation chain now reads the
property's own originObjectType first, then — only when no origin is set
and the parent is a NESTED object (not the feature root) — falls back to
the parent's objectType. The feature root's objectType no longer
propagates down to property children, which is what lets a feature in a
domain namespace nest standard properties inherited from a base fragment
in the application's default namespace without dragging the feature's
prefix onto them.

Existing nested-object behaviour is unchanged: a property declared inline
under, say, an ISO 19115 metadata object still inherits the nested
object's namespace via the parent walk.
The versioned-insert pre-flight is now a plain id-existence check
(SELECT 1 FROM main WHERE idCol = ? LIMIT 1). The previous
three-predicate SQL silently allowed Insert on a retired feature id.
Clients add new versions through Replace / Update / Delete; Insert is
reserved for brand-new ids.

cloneAndPatchFeature ships (was throwing UnsupportedOperationException):
capture the open row's PK + start, clone the main row with inline
overrides (start = ts, end = NULL, predecessor, successor) and
main-table scalar patches, clone each junction table's rows redirecting
the FK to the new PK, retire the old row with the same startCol < ts
guard as retireFeature, then apply junction-backed patches via the
existing patchInternal path. An expectedStart overload threads the
composite-id If-Unmodified-Since predicate through; empty OLD result
surfaces as 409 or 412.

Specs cover the new SQL shape and fail-fast contracts.
- SchemaBase.Role.getLinkRelation() with PREDECESSOR_INTERVAL_START and
  SUCCESSOR_INTERVAL_START mapped to the predecessor-version /
  successor-version link relations.
- FeatureTokenTransformerLinkRoles strips role-as-link values from the
  token stream and surfaces them via Result.getRoleLinks() / context.
- FeatureTokenTransformerVersionIntervals captures the
  (PRIMARY_INTERVAL_START, PRIMARY_INTERVAL_END) tuples per feature for
  the Time Map endpoint.
- FeatureTokenTransformerExtension SPI lets a FeatureQueryExtension
  contribute a token-stream transformer; FeatureStreamImpl wires
  contributed transformers in the pre-format slot alongside LinkRoles.
- FeatureEventHandler ModifiableContext gains roleLinks() and
  canonicalFeatureId() (with the mirroring setters on
  FeatureEventHandlerSimple).
- FeatureStream Result and ResultReduced expose getRoleLinks() and
  getVersionIntervals() so the queries handler can build HTTP Link
  headers without re-decoding the response.
- FeatureTokenTransformerMappings propagates the per-feature context
  state (roleLinks, canonicalFeatureId) into its newContext before
  flushing the buffer; without that propagation upstream transformer
  state was dropped at the format-transformation boundary.
- DeterminePipelineStepsThatCannotBeSkipped keeps MAPPING_VALUES when a
  schema property carries a versioned-features role (ID,
  PRIMARY_INTERVAL_START/END, or a role that declares a link relation)
  so the default DATETIME_FORMAT transformer runs and the captured
  timestamps reach the new transformers in ISO 8601.
…-id bounds

SqlQueryTemplatesDeriver only treated a bare In(_ID_, …) as id-bounded
and computed a separate surrogate-key range guard otherwise. When a
single-feature query carries an extra predicate (e.g. TIntersects for
the datetime parameter), the filter becomes And(In, TIntersects) and
the id-filter check missed it, so meta-skip mode emitted
"A.id >= 0 AND A.id <= 0" — a closed empty range — and returned no
rows. Recurse into And's args so the id-list short-circuits even when
combined with other predicates.
The LinkRoles transformer fed every feature's role values into the result
builder via `putAllRoleLinks`, which is backed by `ImmutableMap.Builder`.
For a multi-version single-feature stream, repeated keys
(`predecessor-version` etc.) collided at `.build()` time and the whole
query aborted with `IllegalArgumentException: Multiple entries with same
key …` — the response handler then surfaced a generic 404.

Switch the setter to `roleLinks(map)` (replace) and choose explicitly
which feature's roles drive the result-level map: the one with the
greatest `PRIMARY_INTERVAL_START`, i.e. the latest version. For
non-versioned single-feature responses (no start) the only feature wins.
The result-level emission is gated on `context.metadata().isSingleFeature()`
so list responses no longer pollute `Result.getRoleLinks()` with
arbitrary per-feature data. Per-feature roles on the context
(`context.setRoleLinks`) — what writers use for per-feature link items —
are unchanged.
The Time Map endpoint (in ldproxy) now owns per-feature decoding via a
dedicated FeatureEncoderTimeMap, so the result-level versionIntervals
accessor and its feeder transformer are no longer used.

- delete FeatureTokenTransformerVersionIntervals
- unwire it from FeatureStreamImpl on both runWith branches
- drop getVersionIntervals from Result and ResultReduced
- Wire FeatureTokenTransformerLinkRoles into the pipeline only when the
  resolved schema of a queried type has a property whose role declares
  a link relation; for all other types the transformer was a per-token
  no-op in every feature stream.
- Drop the latest-version arbitration from the transformer: a
  single-feature response carries exactly one feature version (the
  datetime parameter resolves to a single instant), so the links of the
  only feature in the stream are the links of the response.
- Clarify in the javadoc that the per-feature captures serve every
  response that streams features of a versioned type, while the
  result-level map only drives the Link headers of the single-feature
  endpoint.
A property can now declare a `link` object with a mandatory link
relation type and URI template:

    link:
      rel: related
      uriTemplate: 'https://example.com/register/{{value}}'

Such a property is not emitted as an inline feature property; it is
captured per feature so the API layer can represent it as a web link.
The template parameters {{value}}, {{featureUri}}, {{collectionUri}}
and {{serviceUri}} are resolved in the API layer, which knows the
request URIs.

- The roles PREDECESSOR_INTERVAL_START and SUCCESSOR_INTERVAL_START
  derive a default link ({{featureUri}}?datetime={{value}} with the
  rel of the role); an explicit `link` overrides the default. The
  previously hard-wired role-to-link behavior is now a special case
  of the generic mechanism.
- The capture is a list of structured PropertyLink entries (rel, URI
  template, value, label) instead of a rel-to-value map, so arrays
  and repeated rels are supported; only DATETIME-typed values are
  normalized to ISO instants, DATE values stay dates.
- FeatureTokenTransformerLinkRoles is renamed to
  FeatureTokenTransformerPropertyLinks; it is only wired into the
  pipeline when a queried type has a property with an effective link.
- New documentation page for the `link` object, including the
  boundary to feature references; the two versioning roles are now
  covered by the user documentation of `role`.
Comments and javadoc referenced section numbers and phase labels of
internal planning documents that are meaningless to readers of the
code. The comments now state the rules themselves (no-backdating,
composite-id convention, denorm pointer maintenance) or describe
pending work without a roadmap position.
…rties

The temporal extent of a query result was populated via Instant.parse,
which throws on date-only values; the exception was silently swallowed,
so results from collections with a DATE-typed primary instant or
interval never carried a temporal extent. Dates are now interpreted as
start of day UTC, consistent with the temporal handling elsewhere in
the pipeline.
New provider option `globallyUniqueFeatureIds` (default false), documented for the
generated configuration docs. It is exposed through
`FeatureInfo.featureIdsAreGloballyUnique()` and implemented in `AbstractFeatureProvider`
from the provider data.

This lets consumers that merge features from several feature types into one response
keep feature ids unchanged instead of qualifying them with the collection to avoid
collisions.
A query with a large limit is split into limit/chunkSize query sets, each carrying its
own meta query. Previously every chunk re-ran the full numberMatched count and, with
computeNumberMatched enabled, meta queries kept being issued even after the result was
exhausted - producing hundreds of identical counts for a single page.

numberMatched is now computed only on the first chunk of each collection (it is
invariant across chunks); later chunks reuse that value. Meta queries also stop once
the limit is reached or a collection is exhausted, independent of computeNumberMatched.

MetaQueryTemplate gains a withNumberMatched flag; the now-obsolete allowSkipMetaQueries
flag is removed.
azahnen
azahnen previously approved these changes Jun 13, 2026

@azahnen azahnen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cportele
The code looks good enough for now. As discussed, we should target a refactoring of the SQL mutations as a whole.
The most obvious design violation is the usage of JsonNode in PropertyUpdate and the related geometry parsing, but that can be addressed as part of the refactoring.

Some discussion points regarding the user-facing configuration:

  1. Minor nitpick: new options should use {apiUri} instead of {serviceUr(i|l)}, I would regard the latter legacy. Also see for example refUriTemplate.
  2. You removed the documentation for objectType: Link (didn't check yet if the ldproxy code still supports it). I am pretty sure this is still in use und we should deprecate instead of remove?
  3. Regarding the new link option, do you really expect this to be of use to others? Do we ourselves even need it in the configuration right now? Or could it be internal like originObjectType?
  4. Possible compromise regarding 2 and 3: introduce link, but by default it is a replacement for objectType: Link, so links are written to the content. Existing usage of objectType: Link is auto-migrated. Additionally there will be a flag under link, when enabled links are written to metadata instead of content.

@cportele

Copy link
Copy Markdown
Contributor Author

@azahnen, thanks. Regarding the discussion points:

  1. Good catch. I will fix that.
  2. I thought that we got rid of the special code for Link objects in v4, but I hadn't checked that.
  • I just did and found three mentions: in GmlWriterProperties, in FeatureEncoderHtml (in an unused method that could be deleted) and FeatureSchemaToTypeVisitor (in a code block marked as "no longer needed" which can be deleted as far as I can see since LINKHREF and LINKTITLE are nowhere used).
  • In #970 we stated: "NOTE: The Link object approach can still be used. Existing configurations will continue to work without changes" and there is no mention of a change in the v4 release notes. So, that may be the case, but I have not found special code for that - except in the GML module. Maybe I have looked in the wrong places or no special code is needed.
  • We could restore the sentence in the documentation and mark the concept for deprecation. In any case, that is unrelated to a capability to map properties to proper Web Links in headers / the "links" array.
  1. I think it is potentially useful for other cases, too, but I do not have a concrete use case. So, we could make it internal for now.
  2. FEATURE_REF(_ARRAY) was the replacement for the typical uses of objectType: Link. Links in the sense of RFC 8288 are a special case because of the additional, mandatory link relation type and the serialization in headers.

Let's discuss that and the mutation refactoring tomorrow.

@cportele cportele merged commit 372195e into master Jun 15, 2026
3 checks passed
@cportele cportele deleted the tx-2 branch June 15, 2026 10:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants