Skip to content

support transactions#1627

Open
cportele wants to merge 38 commits into
masterfrom
transactions
Open

support transactions#1627
cportele wants to merge 38 commits into
masterfrom
transactions

Conversation

@cportele
Copy link
Copy Markdown
Collaborator

@cportele cportele commented May 24, 2026

Pull request checklist

  • Added/updated unit tests
  • Updated documentation
  • All checks are passing

Changes introduced by this PR

Closes #1623.

Depends on ldproxy/xtraplatform-spatial#512 and #1622.

Performance test with inserting 10 000 AX_Flurstueck features:

Format No validation handling=strict
application/ogc-tx+json with GeoJSON 3.7 s 4.0 s
application/xml (wfs:Transaction with GML) 24.4 s 27.2 s

cportele added 30 commits May 4, 2026 13:48
Add configurable GML encoding options to support AdV/NAS-style output in ogcapi-features-gml.

- add feature reference templating for xlink:href (e.g. urn:adv:oid:{{value}})

- add optional temporal gml:id suffixing for datetime-interval requests (no separator)

- add configurable gml:identifier emission (codeSpace + value template)

- add codelist reference encoding via xlink:href and xlink:title

- add configurable srsName template mappings (e.g. EPSG:25832 -> urn:adv:crs:ETRS89_UTM32)

- add configurable uom template mappings (e.g. m -> urn:adv:uom:m)

Includes writer/context/config wiring required to apply these options.
Add a new GmlConfiguration option `valueWrap` that maps property paths
(post-rename, matching FeatureSchema#getFullPathAsString()) to an ordered
list of XML element names to wrap around scalar values. This supports
NAS/ALKIS output patterns such as gco:DateTime and aaa:AA_Lebenszeitintervall
wrapping without any NAS-specific encoder logic.

Changes:
- GmlConfiguration: new getValueWrap() method with full @langEn/@langDe/@SInCE v4.9 docs
- FeatureTransformationContextGml: propagate valueWrap from config to context
- FeaturesFormatGml: wire valueWrap into the transformation context builder
- GmlWriterProperties.onValue(): emit wrapper elements outer-to-inner around the value
- ValueWrapSpec: three Spock tests covering single wrap, multi-level wrap, and no-wrap cases
When a format configuration sets useAlias: true, properties of the
feature schema that declare an `alias` are encoded under their alias
instead of their schema name. Useful for application schemas where each
property carries both a short technical name and a longer mnemonic name
(e.g. AdV NAS: `anl`/`anlass`). An explicit `rename` transformation still
takes precedence over the alias. The flag only affects feature encoding
output; queryables, sortables and other schema-derivation paths always
use the schema names.

AliasConfiguration is a standalone interface that contributes the
useAlias toggle independently of PropertyTransformations. The actual
alias-to-rename conversion happens once, at the format-extension
boundary in FeatureFormatExtension.getPropertyTransformations, via the
xtraplatform-spatial FeatureSchemaAliases helper. Downstream the
pipeline sees plain renames, so the existing rename cascade machinery
covers wrap transformers, DATETIME formatters, value transformers, etc.
automatically.

Format configurations wired up: GML, GeoJSON, CityJSON, SfFlat
(CSV, FlatGeobuf, Tiles inherit it). JSON-FG composes on top of GeoJSON
and inherits the alias behavior without declaring its own option.

Includes a NAS-oriented example in GmlConfiguration's class-level
JavaDoc demonstrating useAlias alongside the other GML options
typically configured for the AAA-NAS profile.
Property elements are now placed in the namespace of their containing
object type (declared via objectTypeNamespaces), matching the XML
Schema convention elementFormDefault="qualified". This removes the
need to put namespace prefixes into the provider schema's `alias`
field, which leaked into other encodings (e.g. GeoJSON saw
`gmd:processStep` as a property name).

Explicit `prefix:name` in the schema name or alias still takes
precedence over the inherited namespace.
Adds GML as a supported request content type for POST/PUT on the CRUD
building block and refactors validation so each FeatureFormatExtension owns
its own validation against a format-agnostic ValidatorContext. Resurrects
the two live smoke specs against Groovy 4.

CRUD endpoint and command handler:
  * EndpointCrud now passes the request MediaType through to the command
    handler (replacing the boolean jsonFg flag) and forwards the Link
    header list for downstream profile parsing.
  * CommandHandlerCrud / CommandHandlerCrudImpl: replace the boolean jsonFg
    field with a MediaType contentType and a linkHeaders list. Drop the
    in-handler JSON-Schema validation pipeline (and its codelist/schema
    cache wiring); validation is dispatched to the resolved
    FeatureFormatExtension.validate(content, ValidatorContext). Build the
    ValidatorContext from request material (api/collection/media type,
    request context, profiles parsed from Link headers, default
    schema-validation profiles) and pass it in. SchemaCacheCrud is moved
    out of the CRUD module.

features-core domain:
  * DecoderContext now exposes a jakarta MediaType (was ApiMediaType) -
    the decoder only needs the parsed media type, not the API-level
    wrapper.
  * FeatureFormatExtension grows a default validate(content,
    ValidatorContext) that formats override.
  * New ValidatorContext immutable: apiData, collectionId, mediaType,
    type (RETURNABLES | RECEIVABLES), requestContext, declaredProfiles
    (parsed from Link headers), defaultProfiles. Format-agnostic so
    GeoJSON and GML share the same call shape.

GeoJSON format:
  * Adds xtraplatform-jsonschema as a provided dependency so the schema
    cache types are visible at compile time.
  * Pulls the JSON-Schema-driven validation up from CommandHandlerCrudImpl
    into FeaturesFormatGeoJson.validate: resolves the right validation
    profile (returnables/receivables x geojson/jsonfg) from declared
    profiles, derives a schema via the appropriate cache, and rejects
    invalid bodies with IllegalArgumentException ("feature mutation is
    rejected").
  * Splits the relocated schema cache by scope: new
    ReceivablesJsonSchemaCache (renamed from SchemaCacheCrud) and new
    ReturnablesJsonSchemaCache.

GML format:
  * FeaturesFormatGml.validate implements XSD validation: composes a
    Schema from all schemaLocations on the GmlConfiguration and runs the
    body through a Validator. Missing/unparseable schemas log a warning
    and skip validation rather than failing the request.
  * Switches the decoder factory from FeatureTokenDecoderGmlForSql to
    FeatureTokenDecoderGml (the SQL-targeted variant is no longer
    appropriate for the CRUD input path).
  * Drops featureCollectionElementName / featureMemberElementName from
    the input profile - bare-feature only on input; collection-level
    names are encoder concerns.
  * toInputProfile becomes package-private so the new
    ToInputProfileSpec.groovy can lock the GmlConfiguration ->
    input-profile mapping (list-to-map conversions for srsName/uom and
    per-entry direction reversal for variableObjectElementNames).
  * FeatureTransformationContextGml gains a rootElementWritten flag so
    endDocument() is a no-op on a stream that never opened a root. A
    single-feature item GET on a missing id previously left the wstx
    writer with no root and surfaced 500; with this change the stream
    completes cleanly and failIfNoFeatures turns the empty result into
    a 404.
  * GmlConfiguration JavaDoc gains a section listing which options are
    honoured on the decoder side and which remain output-only, with the
    rationale (no input counterpart, or permissive decoder).

Live smoke specs (ogcapi-crud):
  * Drops the unmaintained, Groovy-2-era http-builder 0.7 testProvided
    dep from ogcapi-crud/build.gradle.
  * Rewrites TransactionalRESTApiSpec (GeoJSON) and adds
    TransactionalGmlRESTApiSpec (GML) on java.net.http.HttpClient so
    both work under Groovy 4. Env-var driven (SUT_URL, SUT_PATH,
    SUT_COLLECTION, SUT_BODY_FILE; optional SUT_ID, SUT_CONTENT_CRS,
    SUT_FRESH_ID), self-adapts to the collection's
    supportsNonAutogeneratedResourceIds (POST vs PUT-fresh-id mode), and
    cleans up the fresh-id resource after every iteration and in
    cleanupSpec. Covers create->201, PUT-replace->2xx, PATCH with wrong
    content type->415, multi-feature body->400, create->GET round-trip;
    the GML spec also covers single-feature mixed-CRS->400.
New ogcapi-transactions building block (draft) exposing a service-level
POST /transactions endpoint that executes Insert, Replace, Update, and
Delete actions against a FeatureProvider as either an atomic multi-action
transaction or an independent-per-action batch. Conforms to OGC API
Features Part 11: Atomic and Batch Transactions conformance classes -
except asynchronous transactions; both modes are individually toggleable
in TransactionsConfiguration.

- WfsTransactionParser: streaming parser for WFS 2.0 wfs:Transaction XML
  payloads. wfs:Insert children are bundled per consecutive-same-
  collection group into one TxInsert with pre-buffered payloads exposed
  via items(); wfs:Replace buffers one feature, wfs:Update and wfs:Delete
  parse metadata only. Filters are restricted to fes:ResourceId/@Rid;
  other predicates are a hard error.

- JsonTransactionParser: parser for the OGC API JSON transaction action
  format, mirroring the same TxAction model.

- TransactionExecutorImpl:
    * Atomic mode opens one Session per FeatureProvider, runs every
      action through it, and commits at the end; any action failure
      rolls the whole transaction back.
    * Batch mode opens a fresh Session per action — each commits or
      rolls back independently, and the response carries per-action
      status.
    * Insert path accumulates decoded per-feature FeatureTokenSources up
      to INSERT_BATCH_SIZE=100 and calls the multi-source
      Session.createFeatures overload so the SQL session can fold
      consecutive same-shape main INSERTs into one multi-row INSERT.
    * Update is GET-merge-write: fetch the current feature as GeoJSON,
      apply an RFC 7396 JSON Merge Patch, and write the full merged
      document through Session.updateFeature(..., partial=true). Above
      BULK_GET_THRESHOLD=16 target ids, per-target GETs are replaced by a
      single FEATURES query with an IN-filter served from an in-memory
      map.
    * Same-transaction chaining (an Update targeting an id touched
      earlier in the same atomic transaction) is rejected up front
      because the GET goes through the provider's query pool at READ
      COMMITTED and cannot see the session's uncommitted writes. A
      same-connection read would lift this; future work.

- Domain model: Transaction, TxAction sealed hierarchy (TxInsert,
  TxReplace, TxUpdate, TxDelete), TxActionType, TxSemantic, ActionResult,
  ActionStatus, ExecutionResult.

- Tests: WfsTransactionParserSpec and JsonTransactionParserSpec for the
  two parsers, TransactionalWfsRESTApiSpec for end-to-end behaviour, plus
  ALKIS NAS sample features under test resources.

Picks up the multi-action SQL mutation Session added in xtraplatform-spatial.
Parse-time feature identity (gml:id for GML, the id member for GeoJSON)
and the 1-based position within wfs:Insert / items[] are now captured
in a new InsertItem and surfaced as Iterator<InsertItem> from
TxInsert.items(). When session.createFeatures rejects a batch, the
executor builds a FAILED ActionResult that lists every candidate
feature id and index in the failing batch.

EndpointTransactions now returns the OGC API Features Part 11
Transaction Response shape on both success and atomic failure, with an
exceptions[] array of RFC 7807 problem objects. Each entry carries
type, title, status, detail and the Part 11 extension members
collectionId, action, actionId, featureIds, featureIndexes.

Atomic failures previously responded with a flat problem+json envelope
whose detail string referenced only the collection — callers could
neither parse it as a Transaction Response nor identify the offending
feature.
Vue 3's template parser rejected the generated transactions.html.vue
with "Element is missing end tag" because the docs processor inlined
<ul>/<li> tags from the JavaDoc without closing </li>. Switch to the
<p><code>...</code> + markdown-list pattern used by GltfBuildingBlock,
which renders as proper markdown lists in the output.
Extract Prefer header parsing from EndpointTransactions into a
package-private PreferHeader helper. Recognised tokens:

  Prefer: respond-async              -> 501 Not Implemented (no body parse)
  Prefer: return=representation      -> full body (default)
  Prefer: return=minimal             -> summary only, per-action arrays omitted
  Prefer: return=none                -> 204 No Content on success
                                        (full body still returned on failure
                                        so exceptions can be reported)

Every non-501 response now echoes Preference-Applied: return=<value>.

Declare the header on the OpenAPI operation by introducing
HeaderPreferTransaction (mirrors HeaderPreferFeature for CRUD) and
wiring getHeaders(...) into EndpointTransactions.computeDefinition.
The schema lists the four atomic Prefer values with
return=representation as the default.

Bug fix in TransactionExecutorImpl.runDelete: only count features as
deleted when the underlying SQL DELETE actually matched a row.
Previously every rid in the filter was reported as deleted even if no
feature existed, so totalDeleted and deleteResults overstated the
result and made delete responses unreliable for clients.

Tests:
- PreferHeaderSpec: 24 cases covering parseReturn fallback, mixed-case,
  multi-token / multi-header forms, unknown values, and
  containsPreferToken substring rejection.
- TransactionalWfsRESTApiSpec: four new phases exercise Prefer over
  wfs:Replace and assert both response body shape and the
  Preference-Applied header. Existing phase 3 updated to lock the now
  working wfs:Update path (200 + totalUpdated == 1).
- Make httpClient @shared so cleanupSpec can reliably delete leftover
  test features.
* PreferHeader: parse handling=strict|lenient (default lenient).
* TransactionParser: new validateEnvelope hook; EndpointTransactions
  buffers the request body and calls it before parsing when strict.
  IllegalArgumentException from the hook maps to 400.
* JsonTransactionParser: validate the envelope against a bundled JSON
  Schema derived from the draft OGC API Features Part 11 schemas; the external GeoJSON ref and cql2.yaml ref are
  replaced with {type: object} at bundle time (per-feature schemas
  are checked separately by the format validator below), links are not supported.
* TransactionExecutor: thread the strict flag through execute().
  TransactionExecutorImpl validates each insert item and replace
  payload via FeatureFormatExtension.validate before any provider
  write. Atomic transactions abort on the first invalid feature
  (existing rollback fires). Batch transactions skip invalid items
  and continue.
* ActionResult.failedFeatureErrors: one validation message per
  rejected feature, parallel to failedFeatureIds/Indexes.
  EndpointTransactions.renderBody emits one exceptions[] entry per
  rejected feature carrying that feature's own message in detail.
* HeaderPreferTransaction: declare handling=strict|lenient in the
  Prefer header schema and document them.
* TransactionalWfsRESTApiSpec: new phases 3e-3i drive strict mode
  end-to-end (WFS-valid happy path, JSON envelope accept/reject,
  JSON insert with all items invalid, JSON insert with one valid
  plus two invalid items).
Rename TransactionalWfsRESTApiSpec to TransactionalRESTApiSpec — the
spec already exercises both wfs:Transaction XML and ogc-tx+json
envelopes — and add five JSON-envelope phases against the running NAS
API:

  * phase 5 (Req 22): insert with Content-Crs = storage CRS, coords
    round-trip unchanged.
  * phase 6 (Req 22 contrast): insert with Content-Crs = CRS84 and
    CRS84 coords; storage-CRS GET-back bbox matches the reference
    feature within sub-meter reprojection error.
  * phase 7 (Req 23): same body as phase 6 but no Content-Crs header;
    result matches phase 6, proving the default is CRS84.
  * phase 8 (Req 24B): Content-Crs declaring an unsupported CRS is
    rejected with 4xx, and a follow-up GET 404s.
  * phase 9 (Req 27): GET surfaces the primary-geometry property as the
    top-level "geometry" member, not as a properties entry. Reads the
    JSON Schema to find the property name so it survives renames.

New helpers: postJson (controllable Content-Crs), bbox / approx
comparison from GeoJSON coordinates, jsonInsertEnvelope, and
fetchReceivableFeatureInCrs. Phase 8's rejected id is included in the
cleanup delete filter (idempotent if absent); phase 4 asserts an exact
deleted count, so a regression that lets phase 8 insert would fail
loudly there too.
Pull request-header parsing into ApiHeader classes:
- HeaderContentCrsTransaction.parse() handles header parsing, default-CRS
  lookup, and CRS-supported validation.
- HeaderPreferTransaction absorbs the former PreferHeader helpers and exposes
  PreferReturn / PreferHandling enums with static parseReturn / parseHandling /
  containsToken methods. PreferHeader[.Spec] removed.

Split the endpoint into a JAX-RS shell plus a CommandHandlerTransactions /
Impl that owns transaction execution. The transaction body is no longer
buffered eagerly; envelope validation moves out of the TransactionParser
interface (default no-op removed) into the JSON handler path.

Derive the OpenAPI request schema for application/ogc-tx+json from the bundled
transaction-envelope.json instead of an empty ObjectSchema():
- top-level allOf becomes the content schema
- $defs entries lift into referencedSchemas with an ogc-tx- prefix
- #/$defs/X refs are rewritten to #/components/schemas/ogc-tx-X
- JSON-Schema-2020-12 type arrays are normalised for OpenAPI 3.0
  (type: [X, null] -> nullable: true; otherwise oneOf of single types)
EndpointTransactionsEnvelopeSchemaSpec locks the lift / rewrite / cache
behaviour.

Envelope schema tidy-up: DRY the action-metadata block via a $defs reference
and tighten the feature shape (type / properties / geometry required). Bump
@SInCE v4.5 -> v4.8 on TransactionsConfiguration option docs.

Modernise TransactionExecutorImpl#runAction to a Java switch expression.
Per RFC 7240, servers should echo the preferences that were both
understood and honored. The /transactions handler was only emitting
`return=…` and silently dropping the `handling` preference, so clients
had no way to confirm that strict per-feature validation was actually
applied. Now `Preference-Applied` carries `handling=strict` alongside
the return preference whenever strict was applied; lenient (the default)
is omitted.
FeaturesFormatGml.validate() was a silent no-op for several overlapping
reasons:

- The JAXP SchemaFactory was given `http://schemas.opengis.net/...`
  URLs that fail to resolve.
- The top-level StreamSource carried only a bare filename, so relative
  imports inside the loaded XSDs could not resolve to a base URL.
- The validator received the inline XML as a systemId, so JAXP tried
  to parse it as a URL.
- TransactionExecutorImpl built the ValidatorContext with the WFS-form
  collection id (mixed case) instead of the canonical lowercase id, so
  the lookup of GmlConfiguration on the FeatureTypeConfiguration
  failed.

Fixes:

- Install an LSResourceResolver on the SchemaFactory that upgrades any
  http:// systemId to https://, and pre-upgrade configured top-level
  schema URLs the same way.
- Pass the full URL as the StreamSource systemId so relative imports
  resolve against it.
- Wrap content in a StringReader so it is parsed as inline XML.
- Cache the compiled Schema per (api hashCode, collectionId) à la
  JsonSchemaCache; each call only creates a fresh Validator.
- Canonicalize the collection id inside
  TransactionExecutorImpl.buildValidatorContext.
Per-feature JSON validation re-serialized the cached
JsonSchemaDocument to a String, re-parsed it to a JsonNode, and
re-compiled a networknt JsonSchema. This dominated runtime for large
/transactions bodies in strict mode, even though the underlying
JsonSchemaDocument is already cached via JsonSchemaCache.

- Add a CompiledJsonSchema interface in ogcapi-foundation/domain as
  an opaque, thread-safe handle.
- Extend SchemaValidator with compile(String) and
  validate(CompiledJsonSchema, String). SchemaValidatorImpl wraps
  networknt's JsonSchema in a private impl and uses a static
  ObjectMapper instead of allocating one per call.
- FeaturesFormatGeoJson caches CompiledJsonSchema per
  (apiData.hashCode(), collectionId, validator type, jsonFg flag),
  mirroring the JsonSchemaCache structure.

Each feature now only parses its own JSON body and runs the validator.
Move the generic RFC 7240 Prefer-header parsing into HeaderPrefer
(handling=… and return=… enums, parseHandling, parseReturn,
containsToken, parseParameterised) so all endpoints share one
implementation. The engine collapses ambiguous mutually-exclusive
values to the caller-supplied fallback per RFC 7240 §2. Drop the
ad-hoc Endpoint.strictHandling regex helper and migrate the six
callers in CRUD, Styles, Search, Resources, and Transactions.
Move the shared schema-building, header metadata, and request-side
parser into a new abstract HeaderContentCrs in the crs module's
domain package. The three concrete classes (Features for read,
Crud and Transaction for write) now keep only the per-building-block
bits: id, description, applicability, configuration, and spec
maturity. Rename the existing crs-module class to
HeaderContentCrsFeatures to match the sibling naming pattern and
free up the HeaderContentCrs name for the abstract base.
…ingBlock

Extend @scopeEn/@scopeDe with three paragraphs covering the request
headers the endpoint honours and the Part 11 Transaction Response shape
returned on both success and atomic failure. These were previously only
documented per-header in the OpenAPI output and per-condition in the
spec, leaving config authors without a single place to learn what the
endpoint accepts and returns.
@azahnen
Copy link
Copy Markdown
Collaborator

azahnen commented May 24, 2026

Surge PR preview deployment succeeded. View it at https://ldproxy-ldproxy-pr-1627.surge.sh

cportele added 4 commits May 25, 2026 09:30
Both the JSON and WFS transaction parsers now merge consecutive identity-free
inserts targeting the same collection into one TxInsert. This lets the
executor's INSERT_BATCH_SIZE path cover all features from a run of same-type
wfs:Insert siblings (or same-collection JSON insert actions) in one
Session.createFeatures call rather than N individual calls.

Coalescing is skipped when an action carries id/title/description (JSON) or
handle (WFS) so that per-action ActionResult mapping is preserved in the
response for callers that set those fields.
FeaturesFormatGml.getFeatureDecoder() rebuilt SchemaMapping per feature
via SchemaMapping.of(featureSchema). For schemas with many concat
branches this dominated the pre-SQL phase of wfs:Transaction inserts
(ImmutableSchemaMapping init's getParentSchemasBySourcePath walks the
branches with streams), inflating per-feature time by ~100-150x for the
heaviest types versus narrow schemas.

Add a schemaMappingCache keyed by (apiData.hashCode(), collectionId),
mirroring the existing schemaCache for the XSD validation Schema. The
hash-based key invalidates automatically when entity config reloads.
The executor now dispatches featureProvider.changes().handle(...) after
each successful transaction (atomic post-commit, batch post-loop),
mirroring CommandHandlerCrudImpl. Successful actions are aggregated per
(collection, mapped action) into one FeatureChange per group with the
union of feature ids, new bounding boxes, and new intervals — so a batch
of N same-collection inserts produces one CREATE event, not N.

Without this, itemCount, spatial/temporal extent, and lastModified on
the collection stayed stale after a transaction. The new
TransactionalRESTApiSpec phases A0/A1/A2 round-trip the metadata
end-to-end against a live ldproxy.
@cportele cportele marked this pull request as ready for review May 27, 2026 10:08
@cportele cportele requested a review from azahnen as a code owner May 27, 2026 10:08
@cportele
Copy link
Copy Markdown
Collaborator Author

I added more updates after trying to load ALKIS Grundriss data from Bonn (~3 million features, ~25 minutes) and Wiesbaden (~2 million features, ~15 minutes) via transactions. After these fixes, all transactions complete successfully.

The auth-check of the body has been disabled for the /transactions endpoint to allow payload > 2 GB. If auth-checks based on the payload contents should be enabled for transactions, we need to identify a strategy that supports the checks while streaming the payload.

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.

add support for OGC API Features Part 11: Atomic and Batch Transactions

2 participants