StatelessSession.upsert corrupts data for @DynamicInsert, @DynamicUpdate and custom-SQL entities

XMLWordPrintableJSON

    • Type: Bug
    • Resolution: Unresolved
    • Priority: Unknown
    • None
    • Affects Version/s: None
    • Component/s: Mutation
    • None
    • None
    • None
    • None
    • None
    • None
    • None
    • None

      Symptom

      StatelessSession.upsert on an entity annotated @DynamicUpdate or @DynamicInsert, or mapped with custom insert, update or delete SQL, writes to the wrong document and never creates the intended one. Nothing is thrown.

      The entity:

      @Entity(name = "DynItem")
      @Table(name = "dynItems")
      @DynamicUpdate
      static class DynItem {
          @Id
          String id;
      
          String label;
      
          DynItem() {}
      
          DynItem(String id, String label) {
              this.id = id;
              this.label = label;
          }
      }
      
      // driver code
      collection.insertOne(BsonDocument.parse("{\"_id\": \"victim\", \"label\": \"untouched\"}"));
      // Hibernate code
      session.upsert(new DynItem("mine", "victim"));   // id "mine", label "victim"
      

      The collection afterwards holds

      {"_id": "victim", "label": "mine"}
      

      The pre-existing document was overwritten, and no document with _id of "mine" exists.

      The emitted command has the id and label values transposed:

      {"update": "dynItems", "updates": [
        {"q": {"_id": {"$eq": "victim"}}, "u": {"$set": {"label": "mine"}}, "multi": true}]}
      

      Cause

      OptionalTableUpdate.createMutationOperation returns Hibernate's own OptionalTableUpdateOperation without consulting the dialect whenever the table mapping carries custom insert, update or delete SQL, or isDynamicMutation() is true. That operation executes itself and derives JDBC parameter positions from OptionalTableUpdate.forEachParameter, whose order is value bindings, then key bindings, then optimistic-lock bindings. It ignores the parameterBinders list the translator supplies.

      AbstractMqlTranslator.visitOptionalTableUpdate renders q, the key, before $set, the values, so the positions are rotated by one group. A SQL dialect renders SET before WHERE and therefore matches this contract; MQL does not. The multi: true on that statement turns a misdirected update into a multi-document write.

      The dialect's pre-checks are bypassed as well

      Because the dialect is never consulted, none of the guards in MongoDialect.createOptionalTableUpdateOperation run. Observed with @DynamicUpdate added to each otherwise-rejected mapping:

      Mapping Expected Actual
      @Version rejected wrote the document, version column included
      entity whose only persistent attribute is its identifier rejected wrote the document
      @Column(insertable = false) rejected wrote the non-insertable column

      A second upsert onto an existing row surfaces a driver duplicate-key error rather than any message of ours, because the misdirected update matches nothing and the fallback insert then runs against the row that is already present.

      This predates upsert support

      Rejection of unsupported upserts is implemented as a performMutation override on the operation MongoDialect.createOptionalTableUpdateOperation returns. Hibernate never constructs that operation for dynamic or custom-SQL mappings, so the override is never called. visitOptionalTableUpdate translates unconditionally for a non-optional table, so the command is still produced and Hibernate's self-executing operation runs it with its own parameter positions. Reproduced on main before any upsert support existed, with the same emitted command and the same corrupted document.

      Fix

      Containment: throw FeatureNotSupportedException on the path this fallback reaches, or reject at boot, which is more discoverable. A complete fix makes the rendered parameter order match forEachParameter; confirm nothing else depends on the current order first.

      Worth reporting upstream as well that OptionalTableUpdateOperation's positional contract is implicit, since a dialect that does not render SQL cannot satisfy it without knowing the rule.

      Sequencing, and why it constrains the choice above

      Expected to be picked up after HIBERNATE-218.

      AstUpdateStatement.render writes q before u, and every construction site shares that one method: the plain UPDATE path, createAstUpdateCommand which this bug's path reaches through createMutationResult, the upsert branch added by HIBERNATE-217, and the conflict-clause upsert added by HIBERNATE-218. Matching forEachParameter, whose order is value bindings then key bindings, means rendering u first. Those other paths visit the key filter before the update operators and pair binders to placeholders by position, so flipping the shared render without flipping their visit order in the same change would misalign every one of them, reintroducing this same class of bug elsewhere.

      So the full reorder is a coordinated change across four sites once HIBERNATE-218 lands, and it wants its own regression coverage on each. Scoping the reorder to this bug's path alone avoids the coupling at the cost of two render orders in the codebase, which would need a comment saying why. Containment carries no coupling at all.

      Until this is fixed, upsert on a dynamic or custom-SQL mapping silently writes to the wrong document. Worth calling out in the release notes of any release that ships upsert support.

            Assignee:
            Unassigned
            Reporter:
            Jeffrey Yemin
            None
            Votes:
            0 Vote for this issue
            Watchers:
            1 Start watching this issue

              Created:
              Updated: