You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@openjpa.apache.org by rz...@apache.org on 2026/10/05 18:13:01 UTC

(openjpa) 01/01: [OPENJPA-2953] Keep converter overrides out of shared embeddable and superclass meta

This is an automated email from the ASF dual-hosted git repository.

rzo1 pushed a commit to branch OPENJPA-2953
in repository https://gitbox.apache.org/repos/asf/openjpa.git

commit fcc923bb3601a8d2374d62a76094b99d2e3c4d1f
Author: Richard Zowalla <rz...@apache.org>
AuthorDate: Mon Oct 5 19:40:41 2026 +0200

    [OPENJPA-2953] Keep converter overrides out of shared embeddable and superclass meta
    
    propagateEmbeddedConverters() set the converter both on the per-embedding
    embedded metadata copy and on the repository-level ClassMetaData of the
    embeddable type. The latter is shared by every entity embedding that
    embeddable, so a @Convert(attributeName=...) declared by one entity leaked
    into all other embeddings (last resolved wins). Only the per-embedding copy
    is updated now. A converter declared on the embeddable's own attribute is
    unaffected and still applies everywhere.
    
    A class-level @Convert(attributeName=...) for an attribute inherited from a
    MappedSuperclass leaked the same way: the override was written to the field
    metadata owned by the superclass, which getFields() splices into every
    sibling subclass. The override is applied to the subclass' own redefinition
    of the inherited field now, after defineSuperclassFields() created it.
    
    For a collection or map of embeddables the embedded metadata hangs off the
    element resp. key value, not off the field's value, so such overrides were
    collected and then silently dropped. All three value slots are considered
    now, with "key." and "value." addressing a map's key resp. value.
---
 .../org/apache/openjpa/meta/ClassMetaData.java     |  16 +-
 .../org/apache/openjpa/meta/FieldMetaData.java     |  58 +++---
 .../convert/ConvertDoubleEmbedEntity.java          |  88 +++++++++
 .../convert/ConvertEmbedCollectionEntity.java      |  66 +++++++
 .../persistence/convert/ConvertEmbedEntity.java    |  11 ++
 .../persistence/convert/ConvertInheritedBase.java  |  55 ++++++
 .../convert/ConvertInheritedEntity.java            |  40 ++++
 ...bedEntity.java => ConvertPlainEmbedEntity.java} |  54 +++---
 .../convert/ConvertPlainInheritedEntity.java       |  38 ++++
 .../persistence/convert/ConvertSelfAddress.java    |  58 ++++++
 .../convert/TestConvertEmbeddableSharing.java      | 210 +++++++++++++++++++++
 .../persistence/convert/TestConvertTck.java        |   1 +
 .../AnnotationPersistenceMetaDataParser.java       |  15 +-
 13 files changed, 644 insertions(+), 66 deletions(-)

diff --git a/openjpa-kernel/src/main/java/org/apache/openjpa/meta/ClassMetaData.java b/openjpa-kernel/src/main/java/org/apache/openjpa/meta/ClassMetaData.java
index fe4a21e75..b2345645e 100644
--- a/openjpa-kernel/src/main/java/org/apache/openjpa/meta/ClassMetaData.java
+++ b/openjpa-kernel/src/main/java/org/apache/openjpa/meta/ClassMetaData.java
@@ -1405,6 +1405,11 @@ public class ClassMetaData
         // etc set correctly
         clearAllFieldCache();
         cacheFields();
+
+        // the redefinitions of the superclass fields exist now, so any
+        // class-level converter override for an inherited field can be
+        // applied to them
+        applyConverterOverrides();
     }
 
     /**
@@ -2914,7 +2919,16 @@ public class ClassMetaData
         }
         for (Map.Entry<String, Class> entry
                 : _converterOverrides.entrySet()) {
-            FieldMetaData fmd = getField(entry.getKey());
+            FieldMetaData fmd = getDeclaredField(entry.getKey());
+            if (fmd == null) {
+                // The field is inherited. Its metadata is owned by the
+                // superclass and shared by all sibling subclasses, so the
+                // override may only be applied to this class' own
+                // redefinition of it (OPENJPA-2953). It does not exist
+                // before defineSuperclassFields() ran; this method is
+                // called again from there.
+                fmd = getDefinedSuperclassField(entry.getKey());
+            }
             if (fmd != null) {
                 fmd.setConverter(entry.getValue());
             }
diff --git a/openjpa-kernel/src/main/java/org/apache/openjpa/meta/FieldMetaData.java b/openjpa-kernel/src/main/java/org/apache/openjpa/meta/FieldMetaData.java
index 83220b745..0aa576e32 100644
--- a/openjpa-kernel/src/main/java/org/apache/openjpa/meta/FieldMetaData.java
+++ b/openjpa-kernel/src/main/java/org/apache/openjpa/meta/FieldMetaData.java
@@ -127,6 +127,11 @@ public class FieldMetaData
     private static final Localizer _loc = Localizer.forPackage
         (FieldMetaData.class);
 
+    // prefixes addressing a map's key resp. value in the attributeName of
+    // an embedded converter override
+    private static final String KEY_PREFIX = "key.";
+    private static final String VALUE_PREFIX = "value.";
+
     private static final int DFG_FALSE = 1;
     private static final int DFG_TRUE = 2;
     private static final int DFG_EXPLICIT = 4;
@@ -2171,33 +2176,36 @@ public class FieldMetaData
      * specifying converters for embedded attributes.
      */
     private void propagateEmbeddedConverters() {
-        // Always set converters on the "real" type metadata so that
-        // the mapping template has the correct strategy/column types
-        ClassMetaData typeMeta = getRepository().getMetaData(
-            getDeclaredType(), null, false);
-        if (typeMeta != null) {
-            for (Map.Entry<String, Class> entry
-                    : _embeddedConverters.entrySet()) {
-                String attrName = entry.getKey();
-                Class convClass = entry.getValue();
-                FieldMetaData typeField = typeMeta.getField(attrName);
-                if (typeField != null) {
-                    typeField.setConverter(convClass);
-                }
+        // Only set the converters on the per-embedding embedded metadata
+        // copy. The repository-level metadata of the embeddable type is
+        // shared by every entity embedding it, so writing the override
+        // there would leak it into all other embeddings (OPENJPA-2953).
+        for (Map.Entry<String, Class> entry
+                : _embeddedConverters.entrySet()) {
+            String attrName = entry.getKey();
+            Class convClass = entry.getValue();
+
+            // For collections and maps of embeddables the embedded metadata
+            // hangs off the element resp. key value, not off this field's
+            // value. A "key." or "value." prefix addresses a map's key resp.
+            // value explicitly, an unqualified name the value or element.
+            ClassMetaData embeddedMeta;
+            if (attrName.startsWith(KEY_PREFIX)) {
+                attrName = attrName.substring(KEY_PREFIX.length());
+                embeddedMeta = _key.getEmbeddedMetaData();
+            } else {
+                if (attrName.startsWith(VALUE_PREFIX))
+                    attrName = attrName.substring(VALUE_PREFIX.length());
+                embeddedMeta = _val.getEmbeddedMetaData();
+                if (embeddedMeta == null)
+                    embeddedMeta = _elem.getEmbeddedMetaData();
             }
-        }
+            if (embeddedMeta == null)
+                continue;
 
-        // Also set on the embedded metadata copy if it exists
-        ClassMetaData embeddedMeta = _val.getEmbeddedMetaData();
-        if (embeddedMeta != null) {
-            for (Map.Entry<String, Class> entry
-                    : _embeddedConverters.entrySet()) {
-                String attrName = entry.getKey();
-                Class convClass = entry.getValue();
-                FieldMetaData embField = embeddedMeta.getField(attrName);
-                if (embField != null) {
-                    embField.setConverter(convClass);
-                }
+            FieldMetaData embField = embeddedMeta.getField(attrName);
+            if (embField != null) {
+                embField.setConverter(convClass);
             }
         }
     }
diff --git a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertDoubleEmbedEntity.java b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertDoubleEmbedEntity.java
new file mode 100644
index 000000000..84ca49652
--- /dev/null
+++ b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertDoubleEmbedEntity.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.openjpa.persistence.convert;
+
+import jakarta.persistence.AttributeOverride;
+import jakarta.persistence.AttributeOverrides;
+import jakarta.persistence.Column;
+import jakarta.persistence.Convert;
+import jakarta.persistence.Converts;
+import jakarta.persistence.Embedded;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+
+/**
+ * Entity embedding the same embeddable twice, with a converter override
+ * on one of the two embeddings only.
+ */
+@Entity
+@Table(name = "CONV_DOUBLE_EMBED")
+public class ConvertDoubleEmbedEntity {
+
+    @Id
+    private String id;
+
+    @Embedded
+    @Converts(value = {
+        @Convert(attributeName = "street", converter = DotConverter.class)
+    })
+    private ConvertAddress converted;
+
+    @Embedded
+    @AttributeOverrides({
+        @AttributeOverride(name = "street",
+            column = @Column(name = "PLAIN_STREET")),
+        @AttributeOverride(name = "city",
+            column = @Column(name = "PLAIN_CITY")),
+        @AttributeOverride(name = "state",
+            column = @Column(name = "PLAIN_STATE"))
+    })
+    private ConvertAddress plain;
+
+    public ConvertDoubleEmbedEntity() {
+    }
+
+    public ConvertDoubleEmbedEntity(String id, ConvertAddress converted,
+            ConvertAddress plain) {
+        this.id = id;
+        this.converted = converted;
+        this.plain = plain;
+    }
+
+    public String getId() {
+        return id;
+    }
+
+    public ConvertAddress getConverted() {
+        return converted;
+    }
+
+    public void setConverted(ConvertAddress converted) {
+        this.converted = converted;
+    }
+
+    public ConvertAddress getPlain() {
+        return plain;
+    }
+
+    public void setPlain(ConvertAddress plain) {
+        this.plain = plain;
+    }
+}
diff --git a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertEmbedCollectionEntity.java b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertEmbedCollectionEntity.java
new file mode 100644
index 000000000..2d83730f9
--- /dev/null
+++ b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertEmbedCollectionEntity.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.openjpa.persistence.convert;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import jakarta.persistence.CollectionTable;
+import jakarta.persistence.Convert;
+import jakarta.persistence.Converts;
+import jakarta.persistence.ElementCollection;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.Table;
+
+/**
+ * Entity with an element collection of embeddables and a converter
+ * override on an attribute of the collection's elements.
+ */
+@Entity
+@Table(name = "CONV_EMBED_COLL")
+public class ConvertEmbedCollectionEntity {
+
+    @Id
+    private String id;
+
+    @ElementCollection
+    @CollectionTable(name = "CONV_EMBED_COLL_ADDR",
+        joinColumns = @JoinColumn(name = "ENTITY_ID"))
+    @Converts(value = {
+        @Convert(attributeName = "street", converter = DotConverter.class)
+    })
+    private List<ConvertAddress> addresses = new ArrayList<>();
+
+    public ConvertEmbedCollectionEntity() {
+    }
+
+    public ConvertEmbedCollectionEntity(String id) {
+        this.id = id;
+    }
+
+    public String getId() {
+        return id;
+    }
+
+    public List<ConvertAddress> getAddresses() {
+        return addresses;
+    }
+}
diff --git a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertEmbedEntity.java b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertEmbedEntity.java
index 0715ac5af..0d9a839dc 100644
--- a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertEmbedEntity.java
+++ b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertEmbedEntity.java
@@ -52,6 +52,9 @@ public class ConvertEmbedEntity implements java.io.Serializable {
     })
     protected ConvertAddress address;
 
+    @Embedded
+    protected ConvertSelfAddress selfAddress;
+
     public ConvertEmbedEntity() {
     }
 
@@ -97,6 +100,14 @@ public class ConvertEmbedEntity implements java.io.Serializable {
         this.address = address;
     }
 
+    public ConvertSelfAddress getSelfAddress() {
+        return selfAddress;
+    }
+
+    public void setSelfAddress(ConvertSelfAddress selfAddress) {
+        this.selfAddress = selfAddress;
+    }
+
     @Override
     public String toString() {
         return "ConvertEmbedEntity[id=" + id
diff --git a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertInheritedBase.java b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertInheritedBase.java
new file mode 100644
index 000000000..ece05a80f
--- /dev/null
+++ b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertInheritedBase.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.openjpa.persistence.convert;
+
+import jakarta.persistence.Id;
+import jakarta.persistence.MappedSuperclass;
+
+/**
+ * MappedSuperclass declaring an attribute that subclasses may override
+ * with a class-level {@code @Convert}.
+ */
+@MappedSuperclass
+public abstract class ConvertInheritedBase {
+
+    @Id
+    protected String id;
+
+    protected String street;
+
+    protected ConvertInheritedBase() {
+    }
+
+    protected ConvertInheritedBase(String id, String street) {
+        this.id = id;
+        this.street = street;
+    }
+
+    public String getId() {
+        return id;
+    }
+
+    public String getStreet() {
+        return street;
+    }
+
+    public void setStreet(String street) {
+        this.street = street;
+    }
+}
diff --git a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertInheritedEntity.java b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertInheritedEntity.java
new file mode 100644
index 000000000..aec6e5802
--- /dev/null
+++ b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertInheritedEntity.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.openjpa.persistence.convert;
+
+import jakarta.persistence.Convert;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+
+/**
+ * Entity overriding the converter of an attribute inherited from a
+ * MappedSuperclass.
+ */
+@Entity
+@Table(name = "CONV_INHERIT_CONV")
+@Convert(attributeName = "street", converter = DotConverter.class)
+public class ConvertInheritedEntity extends ConvertInheritedBase {
+
+    public ConvertInheritedEntity() {
+    }
+
+    public ConvertInheritedEntity(String id, String street) {
+        super(id, street);
+    }
+}
diff --git a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertEmbedEntity.java b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertPlainEmbedEntity.java
similarity index 64%
copy from openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertEmbedEntity.java
copy to openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertPlainEmbedEntity.java
index 0715ac5af..9bf0d97da 100644
--- a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertEmbedEntity.java
+++ b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertPlainEmbedEntity.java
@@ -19,20 +19,22 @@
 package org.apache.openjpa.persistence.convert;
 
 import jakarta.persistence.Basic;
-import jakarta.persistence.Convert;
-import jakarta.persistence.Converts;
 import jakarta.persistence.Embedded;
 import jakarta.persistence.Entity;
 import jakarta.persistence.Id;
 import jakarta.persistence.Table;
 
 /**
- * Entity with @Converts on an embedded field.
- * Mirrors TCK B entity.
+ * Entity embedding the same embeddable as {@link ConvertEmbedEntity} but
+ * without declaring any converter override. Used to verify that the
+ * per-embedding overrides of another entity do not leak into the shared
+ * embeddable metadata.
  */
 @Entity
-@Table(name = "CONV_B_EMBED")
-public class ConvertEmbedEntity implements java.io.Serializable {
+@Table(name = "CONV_PLAIN_EMBED")
+public class ConvertPlainEmbedEntity implements java.io.Serializable {
+
+    private static final long serialVersionUID = 1L;
 
     @Id
     protected String id;
@@ -40,32 +42,19 @@ public class ConvertEmbedEntity implements java.io.Serializable {
     @Basic
     protected String name;
 
-    @Basic
-    protected Integer value;
-
     @Embedded
-    @Converts(value = {
-        @Convert(attributeName = "street",
-            converter = DotConverter.class),
-        @Convert(attributeName = "state",
-            converter = NumberToStateConverter.class)
-    })
     protected ConvertAddress address;
 
-    public ConvertEmbedEntity() {
-    }
+    @Embedded
+    protected ConvertSelfAddress selfAddress;
 
-    public ConvertEmbedEntity(String id, String name, int value) {
-        this.id = id;
-        this.name = name;
-        this.value = value;
+    public ConvertPlainEmbedEntity() {
     }
 
-    public ConvertEmbedEntity(String id, String name, int value,
+    public ConvertPlainEmbedEntity(String id, String name,
             ConvertAddress addr) {
         this.id = id;
         this.name = name;
-        this.value = value;
         this.address = addr;
     }
 
@@ -81,14 +70,6 @@ public class ConvertEmbedEntity implements java.io.Serializable {
         this.name = name;
     }
 
-    public Integer getValue() {
-        return value;
-    }
-
-    public void setValue(Integer value) {
-        this.value = value;
-    }
-
     public ConvertAddress getAddress() {
         return address;
     }
@@ -97,11 +78,18 @@ public class ConvertEmbedEntity implements java.io.Serializable {
         this.address = address;
     }
 
+    public ConvertSelfAddress getSelfAddress() {
+        return selfAddress;
+    }
+
+    public void setSelfAddress(ConvertSelfAddress selfAddress) {
+        this.selfAddress = selfAddress;
+    }
+
     @Override
     public String toString() {
-        return "ConvertEmbedEntity[id=" + id
+        return "ConvertPlainEmbedEntity[id=" + id
             + ", name=" + name
-            + ", value=" + value
             + ", address=" + address + "]";
     }
 }
diff --git a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertPlainInheritedEntity.java b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertPlainInheritedEntity.java
new file mode 100644
index 000000000..5040f2c94
--- /dev/null
+++ b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertPlainInheritedEntity.java
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.openjpa.persistence.convert;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+
+/**
+ * Sibling of {@link ConvertInheritedEntity} that declares no converter
+ * override for the inherited attribute.
+ */
+@Entity
+@Table(name = "CONV_INHERIT_PLAIN")
+public class ConvertPlainInheritedEntity extends ConvertInheritedBase {
+
+    public ConvertPlainInheritedEntity() {
+    }
+
+    public ConvertPlainInheritedEntity(String id, String street) {
+        super(id, street);
+    }
+}
diff --git a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertSelfAddress.java b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertSelfAddress.java
new file mode 100644
index 000000000..ef5460bf6
--- /dev/null
+++ b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/ConvertSelfAddress.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.openjpa.persistence.convert;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Convert;
+import jakarta.persistence.Embeddable;
+
+/**
+ * Embeddable that declares a converter on its own attribute. Such a
+ * converter belongs to the embeddable itself and must therefore apply to
+ * every entity embedding it.
+ */
+@Embeddable
+public class ConvertSelfAddress implements java.io.Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @Convert(converter = DotConverter.class)
+    @Column(name = "SELF_STREET")
+    protected String street;
+
+    public ConvertSelfAddress() {
+    }
+
+    public ConvertSelfAddress(String street) {
+        this.street = street;
+    }
+
+    public String getStreet() {
+        return street;
+    }
+
+    public void setStreet(String street) {
+        this.street = street;
+    }
+
+    @Override
+    public String toString() {
+        return "ConvertSelfAddress[street=" + street + "]";
+    }
+}
diff --git a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/TestConvertEmbeddableSharing.java b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/TestConvertEmbeddableSharing.java
new file mode 100644
index 000000000..50e3597d0
--- /dev/null
+++ b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/TestConvertEmbeddableSharing.java
@@ -0,0 +1,210 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.openjpa.persistence.convert;
+
+import java.util.List;
+
+import jakarta.persistence.EntityManager;
+
+import org.apache.openjpa.persistence.test.SingleEMFTestCase;
+
+/**
+ * A <code>@Convert(attributeName=...)</code> override is declared by a
+ * single embedding of an embeddable, resp. by a single subclass of a
+ * MappedSuperclass. The override must stay local to that embedding resp.
+ * subclass and must not leak into the metadata shared with the other
+ * embeddings and siblings (OPENJPA-2953).
+ */
+public class TestConvertEmbeddableSharing extends SingleEMFTestCase {
+
+    @Override
+    public void setUp() {
+        setUp(DROP_TABLES,
+            ConvertEmbedEntity.class,
+            ConvertPlainEmbedEntity.class,
+            ConvertDoubleEmbedEntity.class,
+            ConvertEmbedCollectionEntity.class,
+            ConvertInheritedBase.class,
+            ConvertInheritedEntity.class,
+            ConvertPlainInheritedEntity.class,
+            ConvertAddress.class,
+            ConvertSelfAddress.class,
+            DotConverter.class,
+            NumberToStateConverter.class);
+    }
+
+    /**
+     * The converting entity still converts: DotConverter turns "." into "#"
+     * on the way to the database and "#" into "_" on the way back.
+     */
+    public void testOverrideAppliesToDeclaringEntity() {
+        EntityManager em = emf.createEntityManager();
+        em.getTransaction().begin();
+        em.persist(new ConvertEmbedEntity("c1", "name1", 1,
+            new ConvertAddress("500.Oracle.Parkway", "Redwood Shores", 1)));
+        em.getTransaction().commit();
+        em.clear();
+        emf.getCache().evictAll();
+
+        List<?> raw = em.createNativeQuery(
+            "SELECT street FROM CONV_B_EMBED WHERE id = 'c1'")
+            .getResultList();
+        assertEquals("500#Oracle#Parkway", raw.get(0));
+
+        ConvertEmbedEntity found =
+            em.find(ConvertEmbedEntity.class, "c1");
+        assertEquals("500_Oracle_Parkway", found.getAddress().getStreet());
+        em.close();
+    }
+
+    /**
+     * The entity that declares no override must store the embeddable
+     * attributes verbatim, no matter which entity's metadata was resolved
+     * first.
+     */
+    public void testOverrideDoesNotLeakToOtherEntity() {
+        EntityManager em = emf.createEntityManager();
+        em.getTransaction().begin();
+        // resolve the converting entity first, so that a leaking override
+        // would already be in place for the plain one
+        em.persist(new ConvertEmbedEntity("c2", "name2", 2,
+            new ConvertAddress("500.Oracle.Parkway", "Redwood Shores", 1)));
+        em.persist(new ConvertPlainEmbedEntity("p1", "name3",
+            new ConvertAddress("200.Main.Street", "Springfield", 7)));
+        em.getTransaction().commit();
+        em.clear();
+        emf.getCache().evictAll();
+
+        List<?> raw = em.createNativeQuery(
+            "SELECT street, state FROM CONV_PLAIN_EMBED WHERE id = 'p1'")
+            .getResultList();
+        Object[] row = (Object[]) raw.get(0);
+        assertEquals("200.Main.Street", row[0]);
+        assertEquals(7, ((Number) row[1]).intValue());
+
+        ConvertPlainEmbedEntity found =
+            em.find(ConvertPlainEmbedEntity.class, "p1");
+        assertEquals("200.Main.Street", found.getAddress().getStreet());
+        assertEquals(7, found.getAddress().getState());
+        em.close();
+    }
+
+    /**
+     * A converter declared on the embeddable's own attribute is not an
+     * override of a single embedding, so it must still apply to every
+     * entity embedding that embeddable.
+     */
+    public void testEmbeddableOwnConverterAppliesEverywhere() {
+        EntityManager em = emf.createEntityManager();
+        em.getTransaction().begin();
+        ConvertEmbedEntity b = new ConvertEmbedEntity("c3", "name4", 3,
+            new ConvertAddress("500.Oracle.Parkway", "Redwood Shores", 1));
+        b.setSelfAddress(new ConvertSelfAddress("1.2.3"));
+        em.persist(b);
+        ConvertPlainEmbedEntity p = new ConvertPlainEmbedEntity("p2",
+            "name5", new ConvertAddress("200.Main.Street", "Springfield", 7));
+        p.setSelfAddress(new ConvertSelfAddress("4.5.6"));
+        em.persist(p);
+        em.getTransaction().commit();
+        em.clear();
+        emf.getCache().evictAll();
+
+        assertEquals("1#2#3", em.createNativeQuery(
+            "SELECT SELF_STREET FROM CONV_B_EMBED WHERE id = 'c3'")
+            .getSingleResult());
+        assertEquals("4#5#6", em.createNativeQuery(
+            "SELECT SELF_STREET FROM CONV_PLAIN_EMBED WHERE id = 'p2'")
+            .getSingleResult());
+        em.close();
+    }
+
+    /**
+     * One entity embedding the same embeddable twice, with the override
+     * declared on one of the two embeddings only. The second embedding
+     * must store its attributes verbatim.
+     */
+    public void testOverrideDoesNotLeakToSecondEmbedding() {
+        EntityManager em = emf.createEntityManager();
+        em.getTransaction().begin();
+        em.persist(new ConvertDoubleEmbedEntity("d1",
+            new ConvertAddress("1.2.3", "Springfield", 1),
+            new ConvertAddress("4.5.6", "Shelbyville", 2)));
+        em.getTransaction().commit();
+        em.clear();
+        emf.getCache().evictAll();
+
+        Object[] row = (Object[]) em.createNativeQuery(
+            "SELECT street, PLAIN_STREET FROM CONV_DOUBLE_EMBED "
+            + "WHERE id = 'd1'").getSingleResult();
+        assertEquals("1#2#3", row[0]);
+        assertEquals("4.5.6", row[1]);
+        em.close();
+    }
+
+    /**
+     * For an element collection of embeddables the override addresses an
+     * attribute of the collection's elements.
+     */
+    public void testOverrideAppliesToCollectionElements() {
+        EntityManager em = emf.createEntityManager();
+        em.getTransaction().begin();
+        ConvertEmbedCollectionEntity e =
+            new ConvertEmbedCollectionEntity("e1");
+        e.getAddresses().add(
+            new ConvertAddress("1.2.3", "Springfield", 1));
+        em.persist(e);
+        em.getTransaction().commit();
+        em.clear();
+        emf.getCache().evictAll();
+
+        assertEquals("1#2#3", em.createNativeQuery(
+            "SELECT street FROM CONV_EMBED_COLL_ADDR").getSingleResult());
+        em.close();
+    }
+
+    /**
+     * A class-level override of an attribute inherited from a
+     * MappedSuperclass must not reach the sibling entities, which share
+     * the superclass' field metadata.
+     */
+    public void testInheritedOverrideDoesNotLeakToSibling() {
+        EntityManager em = emf.createEntityManager();
+        em.getTransaction().begin();
+        // resolve the overriding entity first, so that a leaking override
+        // would already be in place for the plain one
+        em.persist(new ConvertInheritedEntity("i1", "1.2.3"));
+        em.persist(new ConvertPlainInheritedEntity("i2", "4.5.6"));
+        em.getTransaction().commit();
+        em.clear();
+        emf.getCache().evictAll();
+
+        assertEquals("1#2#3", em.createNativeQuery(
+            "SELECT street FROM CONV_INHERIT_CONV WHERE id = 'i1'")
+            .getSingleResult());
+        assertEquals("4.5.6", em.createNativeQuery(
+            "SELECT street FROM CONV_INHERIT_PLAIN WHERE id = 'i2'")
+            .getSingleResult());
+
+        assertEquals("1_2_3",
+            em.find(ConvertInheritedEntity.class, "i1").getStreet());
+        assertEquals("4.5.6",
+            em.find(ConvertPlainInheritedEntity.class, "i2").getStreet());
+        em.close();
+    }
+}
diff --git a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/TestConvertTck.java b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/TestConvertTck.java
index f5e4d2c11..81937e3d2 100644
--- a/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/TestConvertTck.java
+++ b/openjpa-persistence-jdbc/src/test/java/org/apache/openjpa/persistence/convert/TestConvertTck.java
@@ -46,6 +46,7 @@ public class TestConvertTck extends SingleEMFTestCase {
             ConvertEmbedEntity.class,
             ConvertRepeatableEntity.class,
             ConvertAddress.class,
+            ConvertSelfAddress.class,
             SalaryConverter.class,
             CharArrayConverter.class,
             DotConverter.class,
diff --git a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/AnnotationPersistenceMetaDataParser.java b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/AnnotationPersistenceMetaDataParser.java
index d8912c73a..51c79603a 100644
--- a/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/AnnotationPersistenceMetaDataParser.java
+++ b/openjpa-persistence/src/main/java/org/apache/openjpa/persistence/AnnotationPersistenceMetaDataParser.java
@@ -1438,20 +1438,21 @@ public class AnnotationPersistenceMetaDataParser
                 fmd.addEmbeddedConverter(embAttr, convert.converter());
             }
         } else {
-            // Simple attribute name - first check if it is a direct field
-            // (including inherited fields from mapped superclass)
+            // Simple attribute name - first check if it is a field declared
+            // by this class. An inherited field is deliberately not touched
+            // here: its metadata is owned by the (mapped) superclass and
+            // shared by all sibling entities, so the override is recorded
+            // and later applied to this class' own redefinition of the
+            // field only (OPENJPA-2953).
             FieldMetaData directField = meta.getDeclaredField(attrName);
-            if (directField == null) {
-                directField = meta.getField(attrName);
-            }
             if (directField != null) {
-                // Direct field or inherited field - override converter
+                // Direct field - override converter
                 directField.setConverter(convert.converter());
             }
             // Also store as a pending override in case the field doesn't
             // exist yet (MappedSuperclass fields are inherited later)
             meta.addConverterOverride(attrName, convert.converter());
-            if (directField == null) {
+            if (directField == null && meta.getField(attrName) == null) {
                 // Not a direct field - applies to embedded field attributes
                 for (FieldMetaData fmd : meta.getDeclaredFields()) {
                     if (fmd.isEmbedded()) {