Repository: victools/jsonschema-generator (java)

Issue:
MAP_VALUES_AS_ADDITIONAL_PROPERTIES and DEFINITIONS_FOR_ALL_OBJECTS duplicates additional properties
Given the following model(s):

```java
@JsonPropertyOrder({"string_value", "int_value"})
public static class ValueSchema {
    @JsonProperty("int_value")
    public Integer intValue;
    @JsonProperty("string_value")
    public String stringValue;
}

public static class MapSchema {
    @JsonProperty("map_value")
    public Map<String, ValueSchema> mapValue;
}
```

Using the following generator:

```java
new SchemaGenerator(
    new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2019_09, OptionPreset.PLAIN_JSON)
            .with(new JacksonModule())
            .with(Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES)
            .with(Option.DEFINITIONS_FOR_ALL_OBJECTS)
            .build());
````

I expect a schema as following:

```json
{
  "$schema" : "https://json-schema.org/draft/2019-09/schema",
  "$defs" : {
    "Map(String,ValueSchema)" : {
      "type" : "object",
      "additionalProperties" : {
        "$ref" : "#/$defs/ValueSchema"
      }
    },
    "ValueSchema" : {
      "type" : "object",
      "properties" : {
        "int_value" : {
          "type" : "integer"
        },
        "string_value" : {
          "type" : "string"
        }
      }
    }
  },
  "type" : "object",
  "properties" : {
    "map_value" : {
      "$ref" : "#/$defs/Map(String,ValueSchema)"
    }
  }
}
```

Nevertheless, I found the following schema:

```json
{
  "$schema" : "https://json-schema.org/draft/2019-09/schema",
  "$defs" : {
    "Map(String,ValueSchema)" : {
      "type" : "object",
      "additionalProperties" : {
        "$ref" : "#/$defs/ValueSchema"
      }
    },
    "ValueSchema" : {
      "type" : "object",
      "properties" : {
        "int_value" : {
          "type" : "integer"
        },
        "string_value" : {
          "type" : "string"
        }
      }
    }
  },
  "type" : "object",
  "properties" : {
    "map_value" : {
      "$ref" : "#/$defs/Map(String,ValueSchema)",
      "additionalProperties" : {
        "$ref" : "#/$defs/ValueSchema"
      }
    }
  }
}
```

Note that it contains the `additionalProperties` twice: one on the `$defs` that is referenced by `map_value` and also on the `map_value` itself.

Pull request description:
feat: Support removal of duplicate attributes from member schemas
Introduce `Option.DUPLICATE_MEMBER_ATTRIBUTE_CLEANUP_AT_THE_END` for discarding attributes contained both in property/member schemas and the common definitions being referenced from the same property/member schema.

Closes #420.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d7fd173..9037607 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
-*-*
+### `jsonschema-generator`
+#### Added
+- new `Option.DUPLICATE_MEMBER_ATTRIBUTE_CLEANUP_AT_THE_END` discard duplicate elements from member sub-schemas
+
+#### Changed
+- new `Option.DUPLICATE_MEMBER_ATTRIBUTE_CLEANUP_AT_THE_END` by default included in standard `OptionPreset`s
 
 ## [4.33.1] - 2023-12-19
 ### `jsonschema-module-jackson`
diff --git a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/Option.java b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/Option.java
index d154891..6ccbf91 100644
--- a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/Option.java
+++ b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/Option.java
@@ -322,6 +322,13 @@ public enum Option {
      * @since 4.6.0
      */
     ALLOF_CLEANUP_AT_THE_END(null, null),
+    /**
+     * Whether at the end of the schema generation, all member sub-schemas referencing a common definition should be checked for any duplicated
+     * attributes, which should be removed from the inline member sub-schemas in favor of the equivalent in the single common definition.
+     *
+     * @since 4.34.0
+     */
+    DUPLICATE_MEMBER_ATTRIBUTE_CLEANUP_AT_THE_END(null, null),
     /**
      * Whether at the end of the schema generation, all sub-schemas without an explicit "type" indication should be augmented by the implied "type"
      * based on the other tags in the respective schema.
diff --git a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/OptionPreset.java b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/OptionPreset.java
index 7d8634c..ad60275 100644
--- a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/OptionPreset.java
+++ b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/OptionPreset.java
@@ -46,7 +46,8 @@ public class OptionPreset {
             Option.DEFINITIONS_FOR_ALL_OBJECTS,
             Option.NULLABLE_FIELDS_BY_DEFAULT,
             Option.NULLABLE_METHOD_RETURN_VALUES_BY_DEFAULT,
-            Option.ALLOF_CLEANUP_AT_THE_END
+            Option.ALLOF_CLEANUP_AT_THE_END,
+            Option.DUPLICATE_MEMBER_ATTRIBUTE_CLEANUP_AT_THE_END
     );
 
     /**
@@ -63,7 +64,8 @@ public class OptionPreset {
             Option.PUBLIC_NONSTATIC_FIELDS,
             Option.NONPUBLIC_NONSTATIC_FIELDS_WITH_GETTERS,
             Option.NONPUBLIC_NONSTATIC_FIELDS_WITHOUT_GETTERS,
-            Option.ALLOF_CLEANUP_AT_THE_END
+            Option.ALLOF_CLEANUP_AT_THE_END,
+            Option.DUPLICATE_MEMBER_ATTRIBUTE_CLEANUP_AT_THE_END
     );
 
     /**
@@ -79,7 +81,8 @@ public class OptionPreset {
             Option.NONSTATIC_NONVOID_NONGETTER_METHODS,
             Option.SIMPLIFIED_ENUMS,
             Option.SIMPLIFIED_OPTIONALS,
-            Option.ALLOF_CLEANUP_AT_THE_END
+            Option.ALLOF_CLEANUP_AT_THE_END,
+            Option.DUPLICATE_MEMBER_ATTRIBUTE_CLEANUP_AT_THE_END
     );
 
     private final Set<Option> defaultEnabledOptions;
diff --git a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/SchemaBuilder.java b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/SchemaBuilder.java
index 7c83a6f..2af1993 100644
--- a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/SchemaBuilder.java
+++ b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/SchemaBuilder.java
@@ -119,7 +119,8 @@ public class SchemaBuilder {
             this.generationContext.addReference(mainType, jsonSchemaResult, null, false);
         }
         String definitionsTagName = this.config.getKeyword(SchemaKeyword.TAG_DEFINITIONS);
-        ObjectNode definitionsNode = this.buildDefinitionsAndResolveReferences(definitionsTagName, mainKey);
+        String referenceKeyPrefix = this.getReferenceKeyPrefix(definitionsTagName);
+        ObjectNode definitionsNode = this.buildDefinitionsAndResolveReferences(referenceKeyPrefix, mainKey);
         if (!definitionsNode.isEmpty()) {
             jsonSchemaResult.set(definitionsTagName, definitionsNode);
         }
@@ -128,7 +129,7 @@ public class SchemaBuilder {
             jsonSchemaResult.setAll(mainSchemaNode);
             this.schemaNodes.add(jsonSchemaResult);
         }
-        this.performCleanup();
+        this.performCleanup(definitionsNode, referenceKeyPrefix);
         this.config.resetAfterSchemaGenerationFinished();
         return jsonSchemaResult;
     }
@@ -167,25 +168,36 @@ public class SchemaBuilder {
      * @see #createSchemaReference(Type, Type...)
      */
     public ObjectNode collectDefinitions(String designatedDefinitionPath) {
-        ObjectNode definitionsNode = this.buildDefinitionsAndResolveReferences(designatedDefinitionPath, null);
-        this.performCleanup();
+        String referenceKeyPrefix = this.getReferenceKeyPrefix(designatedDefinitionPath);
+        ObjectNode definitionsNode = this.buildDefinitionsAndResolveReferences(referenceKeyPrefix, null);
+        this.performCleanup(definitionsNode, referenceKeyPrefix);
         return definitionsNode;
     }
 
+    private String getReferenceKeyPrefix(String designatedDefinitionPath) {
+        return this.config.getKeyword(SchemaKeyword.TAG_REF_MAIN) + '/' + designatedDefinitionPath + '/';
+    }
+
     /**
      * Reduce unnecessary structures in the generated schema definitions. Assumption being that this method is being invoked as the very last action
      * of the schema generation.
      *
+     * @param definitionsNode object node containing common schema definitions
+     * @param referenceKeyPrefix designated prefix to the entries in the returned definitions node (i.e., on {@link SchemaKeyword#TAG_REF} values)
+     *
      * @see SchemaGeneratorConfig#shouldCleanupUnnecessaryAllOfElements()
      * @see SchemaCleanUpUtils#reduceAllOfNodes(List)
      * @see SchemaCleanUpUtils#reduceAnyOfNodes(List)
      */
-    private void performCleanup() {
+    private void performCleanup(ObjectNode definitionsNode, String referenceKeyPrefix) {
         SchemaCleanUpUtils cleanUpUtils = new SchemaCleanUpUtils(this.config);
         if (this.config.shouldCleanupUnnecessaryAllOfElements()) {
             cleanUpUtils.reduceAllOfNodes(this.schemaNodes);
         }
         cleanUpUtils.reduceAnyOfNodes(this.schemaNodes);
+        if (this.config.shouldDiscardDuplicateMemberAttributes()) {
+            cleanUpUtils.reduceRedundantMemberAttributes(this.schemaNodes, definitionsNode, referenceKeyPrefix);
+        }
         if (this.config.shouldIncludeStrictTypeInfo()) {
             cleanUpUtils.setStrictTypeInfo(this.schemaNodes, true);
         }
@@ -195,12 +207,11 @@ public class SchemaBuilder {
      * Finalisation Step: collect the entries for the generated schema's "definitions" and ensure that all references are either pointing to the
      * appropriate definition or contain the respective (sub) schema directly inline.
      *
-     * @param designatedDefinitionPath designated path to the returned definitions node (to be incorporated in {@link SchemaKeyword#TAG_REF} values)
+     * @param referenceKeyPrefix designated prefix to the entries in the returned definitions node (i.e., on {@link SchemaKeyword#TAG_REF} values)
      * @param mainSchemaKey definition key identifying the main type for which createSchemaReference() was invoked
      * @return node representing the main schema's "definitions" (may be empty)
      */
-    private ObjectNode buildDefinitionsAndResolveReferences(String designatedDefinitionPath, DefinitionKey mainSchemaKey) {
-        final String referenceKeyPrefix = this.config.getKeyword(SchemaKeyword.TAG_REF_MAIN) + '/' + designatedDefinitionPath + '/';
+    private ObjectNode buildDefinitionsAndResolveReferences(String referenceKeyPrefix, DefinitionKey mainSchemaKey) {
         final ObjectNode definitionsNode = this.config.createObjectNode();
 
         final AtomicBoolean considerOnlyDirectReferences = new AtomicBoolean(false);
diff --git a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/SchemaGeneratorConfig.java b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/SchemaGeneratorConfig.java
index 842a5c2..de98d86 100644
--- a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/SchemaGeneratorConfig.java
+++ b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/SchemaGeneratorConfig.java
@@ -130,6 +130,16 @@ public interface SchemaGeneratorConfig extends StatefulConfig {
      */
     boolean shouldCleanupUnnecessaryAllOfElements();
 
+    /**
+     * Determine whether duplicate elements should be removed from member/property sub-schemas if equal elements are contained in the referenced
+     * common definition.
+     *
+     * @return whether to discard duplicate elements from property schemas during the last schema generation step
+     *
+     * @since 4.34.0
+     */
+    boolean shouldDiscardDuplicateMemberAttributes();
+
     /**
      * Determine whether sub schemas should get the {@link SchemaKeyword#TAG_TYPE} added implicitly based on other contained tags, if it is missing.
      *
diff --git a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/SchemaCleanUpUtils.java b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/SchemaCleanUpUtils.java
index c877e2b..57b6596 100644
--- a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/SchemaCleanUpUtils.java
+++ b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/SchemaCleanUpUtils.java
@@ -25,6 +25,7 @@ import com.github.victools.jsonschema.generator.SchemaKeyword;
 import com.github.victools.jsonschema.generator.SchemaVersion;
 import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
@@ -127,6 +128,30 @@ public class SchemaCleanUpUtils {
         this.finaliseSchemaParts(jsonSchemas, nodeToCheck -> this.reduceAnyOfWrappersIfPossible(nodeToCheck, anyOfTagName));
     }
 
+    /**
+     * Discard attributes on member schemas, that also reference an entry in the common definitions, which contains those exact same attributes.
+     *
+     * @param jsonSchemas generated schemas that may contain redundant attributes in nested object member schemas
+     * @param definitionsNode object node containing common schema definitions
+     * @param referenceKeyPrefix designated prefix to the entries in the returned definitions node (i.e., on {@link SchemaKeyword#TAG_REF} values)
+     */
+    public void reduceRedundantMemberAttributes(List<ObjectNode> jsonSchemas, ObjectNode definitionsNode, String referenceKeyPrefix) {
+        final Map<String, Map<String, JsonNode>> definitions = new HashMap<>();
+        for (Iterator<Map.Entry<String, JsonNode>> defIt = definitionsNode.fields(); defIt.hasNext(); ) {
+            Map.Entry<String, JsonNode> definition = defIt.next();
+            Map<String, JsonNode> attributes = new HashMap<>();
+            for (Iterator<Map.Entry<String, JsonNode>> attIt = definition.getValue().fields(); attIt.hasNext(); ) {
+                Map.Entry<String, JsonNode> attriibute = attIt.next();
+                attributes.put(attriibute.getKey(), attriibute.getValue());
+            }
+            definitions.put(referenceKeyPrefix + definition.getKey(), attributes);
+        }
+        final String propertiesKeyword = this.config.getKeyword(SchemaKeyword.TAG_PROPERTIES);
+        final String refKeyWord = this.config.getKeyword(SchemaKeyword.TAG_REF);
+        this.finaliseSchemaParts(jsonSchemas,
+                nodeToCheck -> this.reduceRedundantMemberAttributesIfPossible(nodeToCheck, propertiesKeyword, refKeyWord, definitions));
+    }
+
     /**
      * Go through all sub-schemas and look for those without a {@link SchemaKeyword#TAG_TYPE} indication. Then try to derive the appropriate type
      * indication from the other present tags (e.g., "properties" implies it is an "object").
@@ -553,6 +578,62 @@ public class SchemaCleanUpUtils {
         }
     }
 
+    /**
+     * Discard attributes on member schemas, that also reference an entry in the common definitions, which contains those exact same attributes.
+     *
+     * @param schemaNode single schema to check for properties, which in turn contain redundant attributes to be removed
+     * @param propertiesKeyword keyword under which to find an object schema's properties
+     * @param refKeyword keyword containing the reference to a common schema definition
+     * @param definitions object node containing common schema definitions
+     */
+    private void reduceRedundantMemberAttributesIfPossible(ObjectNode schemaNode,
+            String propertiesKeyword, String refKeyword, Map<String, Map<String, JsonNode>> definitions) {
+        JsonNode propertiesNode = schemaNode.get(propertiesKeyword);
+        if (propertiesNode == null || !propertiesNode.isObject()) {
+            return;
+        }
+        for (Iterator<JsonNode> it = propertiesNode.elements(); it.hasNext(); ) {
+            JsonNode memberSchema = it.next();
+            JsonNode reference = memberSchema.get(refKeyword);
+            if (reference == null || !(memberSchema instanceof ObjectNode)) {
+                // only considering a property/member schema containing a direct reference to a common definition
+                continue;
+            }
+            if (definitions.containsKey(reference.asText())) {
+                this.reduceRedundantAttributesIfPossible((ObjectNode) memberSchema, definitions.get(reference.asText()));
+            }
+        }
+    }
+
+    /**
+     * Discard attributes on the given member schema, if those same attributes are already contained in the common definition being referenced.
+     *
+     * @param memberSchema single schema to discard redundant attributes from
+     * @param referencedDefinition attribute names and values in the common definition, which don't need to be repeated
+     */
+    private void reduceRedundantAttributesIfPossible(ObjectNode memberSchema, Map<String, JsonNode> referencedDefinition) {
+        Set<String> skippedKeywords = new HashSet<>();
+        String ifKeyword = this.config.getKeyword(SchemaKeyword.TAG_IF);
+        String thenKeyword = this.config.getKeyword(SchemaKeyword.TAG_THEN);
+        String elseKeyword = this.config.getKeyword(SchemaKeyword.TAG_ELSE);
+        boolean shouldSkipConditionals = !Util.nullSafeEquals(memberSchema.get(ifKeyword), referencedDefinition.get(ifKeyword))
+                || !Util.nullSafeEquals(memberSchema.get(thenKeyword), referencedDefinition.get(thenKeyword))
+                || !Util.nullSafeEquals(memberSchema.get(elseKeyword), referencedDefinition.get(elseKeyword));
+        if (shouldSkipConditionals) {
+            skippedKeywords.add(ifKeyword);
+            skippedKeywords.add(thenKeyword);
+            skippedKeywords.add(elseKeyword);
+        }
+        for (Iterator<Map.Entry<String, JsonNode>> it = memberSchema.fields(); it.hasNext(); ) {
+            Map.Entry<String, JsonNode> memberAttribute = it.next();
+            String keyword = memberAttribute.getKey();
+            if (!skippedKeywords.contains(keyword) && memberAttribute.getValue().equals(referencedDefinition.get(keyword))) {
+                // remove member attribute, that also exists on the referenced definition
+                it.remove();
+            }
+        }
+    }
+
     /**
      * Add the {@link SchemaKeyword#TAG_TYPE} where it is missing and it can be implied from other present tags.
      *
diff --git a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/SchemaGeneratorConfigImpl.java b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/SchemaGeneratorConfigImpl.java
index 5a835f3..a05d94a 100644
--- a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/SchemaGeneratorConfigImpl.java
+++ b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/SchemaGeneratorConfigImpl.java
@@ -158,6 +158,11 @@ public class SchemaGeneratorConfigImpl implements SchemaGeneratorConfig {
         return this.isOptionEnabled(Option.ALLOF_CLEANUP_AT_THE_END);
     }
 
+    @Override
+    public boolean shouldDiscardDuplicateMemberAttributes() {
+        return this.isOptionEnabled(Option.DUPLICATE_MEMBER_ATTRIBUTE_CLEANUP_AT_THE_END);
+    }
+
     @Override
     public boolean shouldIncludeStrictTypeInfo() {
         return this.isOptionEnabled(Option.STRICT_TYPE_INFO);
diff --git a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/Util.java b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/Util.java
index 50032d2..2dd472f 100644
--- a/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/Util.java
+++ b/jsonschema-generator/src/main/java/com/github/victools/jsonschema/generator/impl/Util.java
@@ -92,4 +92,22 @@ public final class Util {
         }
         return list;
     }
+
+    /**
+     * Ensure the two given values are either both {@code null} or equal to each other.
+     *
+     * @param one first value to check
+     * @param other second value to check
+     * @return whether the two given values are equal
+     */
+    public static boolean nullSafeEquals(Object one, Object other) {
+        if (one == null) {
+            return other == null;
+        }
+        if (other == null) {
+            return false;
+        }
+        return one.hashCode() == other.hashCode()
+               && one.equals(other);
+    }
 }


Test patch:
diff --git a/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/AbstractTypeAwareTest.java b/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/AbstractTypeAwareTest.java
index 6766280..ffcad10 100644
--- a/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/AbstractTypeAwareTest.java
+++ b/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/AbstractTypeAwareTest.java
@@ -45,7 +45,7 @@ public class AbstractTypeAwareTest {
      * @param schemaVersion designated JSON Schema version
      */
     protected void prepareContextForVersion(SchemaVersion schemaVersion) {
-        TypeContext typeContext = Mockito.spy(TypeContextFactory.createDefaultTypeContext());
+        TypeContext typeContext = Mockito.spy(TypeContextFactory.createDefaultTypeContext(Mockito.mock(SchemaGeneratorConfig.class)));
         ResolvedType resolvedTestClass = typeContext.resolve(this.testClass);
         this.declarationDetails = new MemberScope.DeclarationDetails(resolvedTestClass, typeContext.resolveWithMembers(resolvedTestClass));
         this.context = Mockito.mock(SchemaGenerationContext.class, Mockito.RETURNS_DEEP_STUBS);
diff --git a/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/SchemaGeneratorMemberCleanUpTest.java b/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/SchemaGeneratorMemberCleanUpTest.java
new file mode 100644
index 0000000..5bd3df4
--- /dev/null
+++ b/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/SchemaGeneratorMemberCleanUpTest.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2024 VicTools.
+ *
+ * Licensed 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 com.github.victools.jsonschema.generator;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Test for {@link SchemaGenerator} class.
+ */
+public class SchemaGeneratorMemberCleanUpTest {
+
+    private static Stream<Arguments> testMemberCleanup() {
+        return Stream.of(
+                Arguments.of(true, Arrays.asList("$ref")),
+                Arguments.of(false, Arrays.asList("$ref", "additionalProperties"))
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource
+    public void testMemberCleanup(boolean enableCleanup, List<String> expectedMemberAttributes) {
+        SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2019_09, OptionPreset.PLAIN_JSON)
+                .with(Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES, Option.DEFINITIONS_FOR_ALL_OBJECTS);
+        if (enableCleanup) {
+            configBuilder.with(Option.DUPLICATE_MEMBER_ATTRIBUTE_CLEANUP_AT_THE_END);
+        } else {
+            configBuilder.without(Option.DUPLICATE_MEMBER_ATTRIBUTE_CLEANUP_AT_THE_END);
+        }
+        SchemaGeneratorConfig generatorConfig = configBuilder.build();
+        ObjectNode schema = new SchemaGenerator(generatorConfig).generateSchema(TestClass.class);
+        JsonNode memberSchema = schema.get(generatorConfig.getKeyword(SchemaKeyword.TAG_PROPERTIES)).get("mapValue");
+        Assertions.assertEquals(expectedMemberAttributes.size(), memberSchema.size());
+        memberSchema.fieldNames()
+                .forEachRemaining(fieldName -> Assertions.assertTrue(expectedMemberAttributes.contains(fieldName)));
+    }
+
+    private static class TestClass {
+        @JsonProperty("map_value")
+        public Map<String, ValueClass> mapValue;
+    }
+
+    private static class ValueClass {
+        @JsonProperty("int_value")
+        public Integer intValue;
+        @JsonProperty("string_value")
+        public String stringValue;
+    }
+}
diff --git a/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/impl/AttributeCollectorTest.java b/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/impl/AttributeCollectorTest.java
index ff39c3f..e6da2af 100644
--- a/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/impl/AttributeCollectorTest.java
+++ b/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/impl/AttributeCollectorTest.java
@@ -37,6 +37,7 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.EnumSource;
 import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.Mockito;
 
 /**
  * Test for the {@link AttributeCollector} class.
@@ -69,7 +70,8 @@ public class AttributeCollectorTest {
                 new SchemaGeneratorConfigPart<>(),
                 new SchemaGeneratorConfigPart<>(),
                 Collections.emptyMap());
-        return new SchemaGenerationContextImpl(generatorConfig, TypeContextFactory.createDefaultTypeContext());
+        return new SchemaGenerationContextImpl(generatorConfig,
+                TypeContextFactory.createDefaultTypeContext(Mockito.mock(SchemaGeneratorConfig.class)));
     }
 
     @ParameterizedTest
diff --git a/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/naming/SchemaDefinitionNamingStrategyTest.java b/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/naming/SchemaDefinitionNamingStrategyTest.java
index 56a06c8..dc0c0bf 100644
--- a/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/naming/SchemaDefinitionNamingStrategyTest.java
+++ b/jsonschema-generator/src/test/java/com/github/victools/jsonschema/generator/naming/SchemaDefinitionNamingStrategyTest.java
@@ -20,6 +20,7 @@ import com.fasterxml.classmate.ResolvedType;
 import com.fasterxml.jackson.databind.PropertyNamingStrategies;
 import com.fasterxml.jackson.databind.PropertyNamingStrategies.NamingBase;
 import com.github.victools.jsonschema.generator.SchemaGenerationContext;
+import com.github.victools.jsonschema.generator.SchemaGeneratorConfig;
 import com.github.victools.jsonschema.generator.TypeContext;
 import com.github.victools.jsonschema.generator.impl.DefinitionKey;
 import com.github.victools.jsonschema.generator.impl.SchemaCleanUpUtils;
@@ -40,7 +41,7 @@ import org.mockito.Mockito;
  */
 public class SchemaDefinitionNamingStrategyTest {
 
-    private static TypeContext typeContext = TypeContextFactory.createDefaultTypeContext();
+    private static TypeContext typeContext = TypeContextFactory.createDefaultTypeContext(Mockito.mock(SchemaGeneratorConfig.class));
     private DefinitionKey key;
     private SchemaGenerationContext generationContext;
 
diff --git a/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/AbstractTypeAwareTest.java b/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/AbstractTypeAwareTest.java
index 3ad0acc..4664fcf 100644
--- a/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/AbstractTypeAwareTest.java
+++ b/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/AbstractTypeAwareTest.java
@@ -53,7 +53,7 @@ public class AbstractTypeAwareTest {
      * @param schemaVersion designated JSON Schema version
      */
     protected void prepareContextForVersion(SchemaVersion schemaVersion) {
-        TypeContext typeContext = TypeContextFactory.createDefaultTypeContext();
+        TypeContext typeContext = TypeContextFactory.createDefaultTypeContext(Mockito.mock(SchemaGeneratorConfig.class));
         ResolvedType resolvedTestClass = typeContext.resolve(this.testClass);
         this.declarationDetails = new MemberScope.DeclarationDetails(resolvedTestClass, typeContext.resolveWithMembers(resolvedTestClass));
         this.context = Mockito.mock(SchemaGenerationContext.class, Mockito.RETURNS_DEEP_STUBS);
diff --git a/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/CustomEnumDefinitionProviderTest.java b/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/CustomEnumDefinitionProviderTest.java
index 56c1f31..5cbcba8 100644
--- a/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/CustomEnumDefinitionProviderTest.java
+++ b/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/CustomEnumDefinitionProviderTest.java
@@ -26,6 +26,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode;
 import com.fasterxml.jackson.databind.node.ObjectNode;
 import com.github.victools.jsonschema.generator.CustomDefinition;
 import com.github.victools.jsonschema.generator.SchemaGenerationContext;
+import com.github.victools.jsonschema.generator.SchemaGeneratorConfig;
 import com.github.victools.jsonschema.generator.SchemaKeyword;
 import com.github.victools.jsonschema.generator.SchemaVersion;
 import com.github.victools.jsonschema.generator.TypeContext;
@@ -47,7 +48,7 @@ import org.mockito.Mockito;
  */
 public class CustomEnumDefinitionProviderTest {
 
-    private final TypeContext typeContext = TypeContextFactory.createDefaultTypeContext();
+    private final TypeContext typeContext = TypeContextFactory.createDefaultTypeContext(Mockito.mock(SchemaGeneratorConfig.class));
     private SchemaGenerationContext generationContext;
 
     @BeforeEach
diff --git a/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/TestType.java b/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/TestType.java
index aee7067..e35820e 100644
--- a/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/TestType.java
+++ b/jsonschema-module-jackson/src/test/java/com/github/victools/jsonschema/module/jackson/TestType.java
@@ -23,9 +23,11 @@ import com.fasterxml.classmate.members.ResolvedMethod;
 import com.github.victools.jsonschema.generator.FieldScope;
 import com.github.victools.jsonschema.generator.MemberScope;
 import com.github.victools.jsonschema.generator.MethodScope;
+import com.github.victools.jsonschema.generator.SchemaGeneratorConfig;
 import com.github.victools.jsonschema.generator.TypeContext;
 import com.github.victools.jsonschema.generator.impl.TypeContextFactory;
 import java.util.stream.Stream;
+import org.mockito.Mockito;
 
 /**
  * Helper class for constructing {@link FieldScope} and {@link MethodScope} instances in tests.
@@ -36,7 +38,7 @@ public class TestType {
     private final MemberScope.DeclarationDetails declarationDetails;
 
     public TestType(Class<?> testClass) {
-        this.context = TypeContextFactory.createDefaultTypeContext();
+        this.context = TypeContextFactory.createDefaultTypeContext(Mockito.mock(SchemaGeneratorConfig.class));
         ResolvedType resolvedTestClass = this.context.resolve(testClass);
         this.declarationDetails = new MemberScope.DeclarationDetails(resolvedTestClass, this.context.resolveWithMembers(resolvedTestClass));
     }
diff --git a/jsonschema-module-jakarta-validation/src/test/java/com/github/victools/jsonschema/module/jakarta/validation/TestType.java b/jsonschema-module-jakarta-validation/src/test/java/com/github/victools/jsonschema/module/jakarta/validation/TestType.java
index d642664..f989e8e 100644
--- a/jsonschema-module-jakarta-validation/src/test/java/com/github/victools/jsonschema/module/jakarta/validation/TestType.java
+++ b/jsonschema-module-jakarta-validation/src/test/java/com/github/victools/jsonschema/module/jakarta/validation/TestType.java
@@ -23,9 +23,11 @@ import com.fasterxml.classmate.members.ResolvedMethod;
 import com.github.victools.jsonschema.generator.FieldScope;
 import com.github.victools.jsonschema.generator.MemberScope;
 import com.github.victools.jsonschema.generator.MethodScope;
+import com.github.victools.jsonschema.generator.SchemaGeneratorConfig;
 import com.github.victools.jsonschema.generator.TypeContext;
 import com.github.victools.jsonschema.generator.impl.TypeContextFactory;
 import java.util.stream.Stream;
+import org.mockito.Mockito;
 
 /**
  * Helper class for constructing {@link FieldScope} and {@link MethodScope} instances in tests.
@@ -36,7 +38,7 @@ public class TestType {
     private final MemberScope.DeclarationDetails declarationDetails;
 
     public TestType(Class<?> testClass) {
-        this.context = TypeContextFactory.createDefaultTypeContext();
+        this.context = TypeContextFactory.createDefaultTypeContext(Mockito.mock(SchemaGeneratorConfig.class));
         ResolvedType resolvedTestClass = this.context.resolve(testClass);
         this.declarationDetails = new MemberScope.DeclarationDetails(resolvedTestClass, this.context.resolveWithMembers(resolvedTestClass));
     }
diff --git a/jsonschema-module-javax-validation/src/test/java/com/github/victools/jsonschema/module/javax/validation/TestType.java b/jsonschema-module-javax-validation/src/test/java/com/github/victools/jsonschema/module/javax/validation/TestType.java
index e55748d..fff270b 100644
--- a/jsonschema-module-javax-validation/src/test/java/com/github/victools/jsonschema/module/javax/validation/TestType.java
+++ b/jsonschema-module-javax-validation/src/test/java/com/github/victools/jsonschema/module/javax/validation/TestType.java
@@ -23,9 +23,11 @@ import com.fasterxml.classmate.members.ResolvedMethod;
 import com.github.victools.jsonschema.generator.FieldScope;
 import com.github.victools.jsonschema.generator.MemberScope;
 import com.github.victools.jsonschema.generator.MethodScope;
+import com.github.victools.jsonschema.generator.SchemaGeneratorConfig;
 import com.github.victools.jsonschema.generator.TypeContext;
 import com.github.victools.jsonschema.generator.impl.TypeContextFactory;
 import java.util.stream.Stream;
+import org.mockito.Mockito;
 
 /**
  * Helper class for constructing {@link FieldScope} and {@link MethodScope} instances in tests.
@@ -36,7 +38,7 @@ public class TestType {
     private final MemberScope.DeclarationDetails declarationDetails;
 
     public TestType(Class<?> testClass) {
-        this.context = TypeContextFactory.createDefaultTypeContext();
+        this.context = TypeContextFactory.createDefaultTypeContext(Mockito.mock(SchemaGeneratorConfig.class));
         ResolvedType resolvedTestClass = this.context.resolve(testClass);
         this.declarationDetails = new MemberScope.DeclarationDetails(resolvedTestClass, this.context.resolveWithMembers(resolvedTestClass));
     }
diff --git a/jsonschema-module-swagger-1.5/src/test/java/com/github/victools/jsonschema/module/swagger15/TestType.java b/jsonschema-module-swagger-1.5/src/test/java/com/github/victools/jsonschema/module/swagger15/TestType.java
index 80cefa1..e22248e 100644
--- a/jsonschema-module-swagger-1.5/src/test/java/com/github/victools/jsonschema/module/swagger15/TestType.java
+++ b/jsonschema-module-swagger-1.5/src/test/java/com/github/victools/jsonschema/module/swagger15/TestType.java
@@ -23,9 +23,11 @@ import com.fasterxml.classmate.members.ResolvedMethod;
 import com.github.victools.jsonschema.generator.FieldScope;
 import com.github.victools.jsonschema.generator.MemberScope;
 import com.github.victools.jsonschema.generator.MethodScope;
+import com.github.victools.jsonschema.generator.SchemaGeneratorConfig;
 import com.github.victools.jsonschema.generator.TypeContext;
 import com.github.victools.jsonschema.generator.impl.TypeContextFactory;
 import java.util.stream.Stream;
+import org.mockito.Mockito;
 
 /**
  * Helper class for constructing {@link FieldScope} and {@link MethodScope} instances in tests.
@@ -36,7 +38,7 @@ public class TestType {
     private final MemberScope.DeclarationDetails declarationDetails;
 
     public TestType(Class<?> testClass) {
-        this.context = TypeContextFactory.createDefaultTypeContext();
+        this.context = TypeContextFactory.createDefaultTypeContext(Mockito.mock(SchemaGeneratorConfig.class));
         ResolvedType resolvedTestClass = this.context.resolve(testClass);
         this.declarationDetails = new MemberScope.DeclarationDetails(resolvedTestClass, this.context.resolveWithMembers(resolvedTestClass));
     }