From dfeea23a99e89309dda2b69cf294c738322d5ff7 Mon Sep 17 00:00:00 2001 From: nollymarlonga Date: Thu, 16 Apr 2026 16:28:02 -0500 Subject: [PATCH 1/2] test(graphql): failing tests for unified DotBinaryLike interface #34540 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a unit test suite that pins down the target contract for unifying GraphQL queries across BinaryField, FileField, and ImageField: both DotBinary and DotFileasset must implement a shared DotBinaryLike interface and expose the same 12 common properties (name, title, size, mime, versionPath, idPath, path, sha256, isImage, width, height, modDate). Tests currently red — implementation follows in a subsequent commit. Existing DotBinary assertions and DotFileasset backward-compat assertions are already green and guard against regressions. Co-Authored-By: Claude Opus 4.6 --- .../CustomFieldTypeBinaryLikeTest.java | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 dotCMS/src/test/java/com/dotcms/graphql/CustomFieldTypeBinaryLikeTest.java diff --git a/dotCMS/src/test/java/com/dotcms/graphql/CustomFieldTypeBinaryLikeTest.java b/dotCMS/src/test/java/com/dotcms/graphql/CustomFieldTypeBinaryLikeTest.java new file mode 100644 index 000000000000..4181f7d37fff --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/graphql/CustomFieldTypeBinaryLikeTest.java @@ -0,0 +1,128 @@ +package com.dotcms.graphql; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import graphql.Scalars; +import graphql.scalars.ExtendedScalars; +import graphql.schema.GraphQLFieldDefinition; +import graphql.schema.GraphQLInterfaceType; +import graphql.schema.GraphQLObjectType; +import graphql.schema.GraphQLOutputType; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Tests for issue #34540: unify GraphQL queries across Binary, File, and Image fields. + * + *

Today, a {@code BinaryField} maps to the {@code DotBinary} GraphQL type and a + * {@code FileField}/{@code ImageField} maps to the {@code DotFileasset} GraphQL type. The two + * types expose different sub-fields, so a client cannot query them uniformly. This test + * asserts the new contract: both concrete types implement a shared {@code DotBinaryLike} + * interface and expose the same common set of binary properties at the top level. + */ +class CustomFieldTypeBinaryLikeTest { + + private static final String INTERFACE_NAME = "DotBinaryLike"; + + /** The 12 properties the shared interface must expose on every binary-bearing type. */ + private static final List COMMON_BINARY_FIELDS = Arrays.asList( + "name", "title", "size", "mime", "versionPath", "idPath", "path", + "sha256", "isImage", "width", "height", "modDate"); + + @Test + void dotBinary_implementsDotBinaryLikeInterface() { + final GraphQLObjectType dotBinary = CustomFieldType.BINARY.getType(); + assertTrue( + dotBinary.getInterfaces().stream() + .anyMatch(i -> INTERFACE_NAME.equals(((GraphQLInterfaceType) i).getName())), + "DotBinary must implement the " + INTERFACE_NAME + " interface"); + } + + @Test + void dotFileasset_implementsDotBinaryLikeInterface() { + final GraphQLObjectType dotFileasset = CustomFieldType.FILEASSET.getType(); + assertTrue( + dotFileasset.getInterfaces().stream() + .anyMatch(i -> INTERFACE_NAME.equals(((GraphQLInterfaceType) i).getName())), + "DotFileasset must implement the " + INTERFACE_NAME + " interface"); + } + + @Test + void dotBinary_exposesAllCommonBinaryFields() { + final GraphQLObjectType dotBinary = CustomFieldType.BINARY.getType(); + for (final String field : COMMON_BINARY_FIELDS) { + assertNotNull(dotBinary.getFieldDefinition(field), + "DotBinary must expose common field: " + field); + } + } + + @Test + void dotFileasset_exposesAllCommonBinaryFields() { + final GraphQLObjectType dotFileasset = CustomFieldType.FILEASSET.getType(); + for (final String field : COMMON_BINARY_FIELDS) { + assertNotNull(dotFileasset.getFieldDefinition(field), + "DotFileasset must expose common field: " + field); + } + } + + @Test + void commonBinaryFields_useConsistentScalarTypes() { + final GraphQLObjectType dotBinary = CustomFieldType.BINARY.getType(); + final GraphQLObjectType dotFileasset = CustomFieldType.FILEASSET.getType(); + for (final String field : COMMON_BINARY_FIELDS) { + final GraphQLFieldDefinition binaryDef = dotBinary.getFieldDefinition(field); + final GraphQLFieldDefinition fileAssetDef = dotFileasset.getFieldDefinition(field); + assertEquals(binaryDef.getType(), fileAssetDef.getType(), + "Scalar type for common field '" + field + + "' must be consistent across DotBinary and DotFileasset"); + } + } + + @Test + void commonBinaryFields_useExpectedScalarTypes() { + final GraphQLObjectType dotBinary = CustomFieldType.BINARY.getType(); + assertScalar(dotBinary, "name", Scalars.GraphQLString); + assertScalar(dotBinary, "title", Scalars.GraphQLString); + assertScalar(dotBinary, "size", ExtendedScalars.GraphQLLong); + assertScalar(dotBinary, "mime", Scalars.GraphQLString); + assertScalar(dotBinary, "versionPath", Scalars.GraphQLString); + assertScalar(dotBinary, "idPath", Scalars.GraphQLString); + assertScalar(dotBinary, "path", Scalars.GraphQLString); + assertScalar(dotBinary, "sha256", Scalars.GraphQLString); + assertScalar(dotBinary, "isImage", Scalars.GraphQLBoolean); + assertScalar(dotBinary, "width", ExtendedScalars.GraphQLLong); + assertScalar(dotBinary, "height", ExtendedScalars.GraphQLLong); + assertScalar(dotBinary, "modDate", ExtendedScalars.GraphQLLong); + } + + @Test + void dotBinary_keepsTypeSpecificField_focalPoint() { + final GraphQLObjectType dotBinary = CustomFieldType.BINARY.getType(); + assertNotNull(dotBinary.getFieldDefinition("focalPoint"), + "focalPoint remains a DotBinary-specific field"); + } + + @Test + void dotFileasset_keepsBackwardCompatibleFields() { + final GraphQLObjectType dotFileasset = CustomFieldType.FILEASSET.getType(); + // These were the existing fields before #34540; they must still be present so + // existing queries keep working. + assertNotNull(dotFileasset.getFieldDefinition("fileName")); + assertNotNull(dotFileasset.getFieldDefinition("description")); + assertNotNull(dotFileasset.getFieldDefinition("fileAsset")); + assertNotNull(dotFileasset.getFieldDefinition("metaData")); + assertNotNull(dotFileasset.getFieldDefinition("showOnMenu")); + assertNotNull(dotFileasset.getFieldDefinition("sortOrder")); + } + + private static void assertScalar(final GraphQLObjectType type, final String field, + final GraphQLOutputType expected) { + final GraphQLFieldDefinition def = type.getFieldDefinition(field); + assertNotNull(def, "Missing field: " + field); + assertEquals(expected, def.getType(), + "Field '" + field + "' on " + type.getName() + " has unexpected scalar type"); + } +} From 99926626774880f8f59a56f1be0fd08aff1cb664 Mon Sep 17 00:00:00 2001 From: nollymarlonga Date: Thu, 16 Apr 2026 16:44:20 -0500 Subject: [PATCH 2/2] feat(graphql): unify Binary, File, and Image field queries via DotBinaryLike #34540 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BinaryField maps to DotBinary and FileField/ImageField map to DotFileasset. The two object types exposed completely different sub-fields, forcing clients to know which dotCMS field type backed a property and to write queries like `myfile { fileAsset { versionPath } }` while the equivalent `mybinary { versionPath }` worked directly. Introduce a shared DotBinaryLike GraphQL interface implemented by both DotBinary and DotFileasset. It declares the 12 common binary properties (name, title, size, mime, versionPath, idPath, path, sha256, isImage, width, height, modDate). DotBinary resolves them from the binary map already produced by BinaryToMapTransformer (no behavior change). DotFileasset gets a new FileAssetBinaryPropertyDataFetcher that runs the same transformer against the FileAsset/DotAsset contentlet, so the values returned are identical across all three field types. Type-specific extras stay on the concrete type — DotBinary still exposes focalPoint, DotFileasset still exposes fileName, description, fileAsset, metaData, showOnMenu, sortOrder. Existing queries continue to work. Includes: - New TypeUtil.createObjectType overloads accepting GraphQLInterfaceType varargs. - DotBinaryLike registered via ContentAPIGraphQLTypesProvider so introspection sees it. - TypeResolver dispatches on source type: Map -> DotBinary, Contentlet -> DotFileasset. Co-Authored-By: Claude Opus 4.6 --- .../com/dotcms/graphql/CustomFieldType.java | 75 ++++++++++++++++++- .../ContentAPIGraphQLTypesProvider.java | 1 + .../FileAssetBinaryPropertyDataFetcher.java | 56 ++++++++++++++ .../com/dotcms/graphql/util/TypeUtil.java | 23 +++++- 4 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 dotCMS/src/main/java/com/dotcms/graphql/datafetcher/FileAssetBinaryPropertyDataFetcher.java diff --git a/dotCMS/src/main/java/com/dotcms/graphql/CustomFieldType.java b/dotCMS/src/main/java/com/dotcms/graphql/CustomFieldType.java index b3270d541190..86238e56514b 100644 --- a/dotCMS/src/main/java/com/dotcms/graphql/CustomFieldType.java +++ b/dotCMS/src/main/java/com/dotcms/graphql/CustomFieldType.java @@ -3,6 +3,7 @@ import com.dotcms.contenttype.model.type.BaseContentType; import com.dotcms.graphql.datafetcher.BinaryFieldDataFetcher; import com.dotcms.graphql.datafetcher.FieldDataFetcher; +import com.dotcms.graphql.datafetcher.FileAssetBinaryPropertyDataFetcher; import com.dotcms.graphql.datafetcher.KeyValueFieldDataFetcher; import com.dotcms.graphql.datafetcher.MapFieldPropertiesDataFetcher; import com.dotcms.graphql.datafetcher.MultiValueFieldDataFetcher; @@ -11,15 +12,19 @@ import com.dotmarketing.portlets.contentlet.model.Contentlet; import com.dotmarketing.portlets.fileassets.business.FileAsset; import graphql.scalars.ExtendedScalars; +import graphql.schema.GraphQLInterfaceType; import graphql.schema.GraphQLList; import graphql.schema.GraphQLObjectType; import graphql.schema.GraphQLOutputType; import graphql.schema.GraphQLType; import graphql.schema.GraphQLTypeReference; import graphql.schema.PropertyDataFetcher; +import graphql.schema.TypeResolver; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import java.util.function.Function; @@ -61,7 +66,49 @@ public String getTypeName() { private static Map customFieldTypes = new HashMap<>(); + /** Name of the shared interface exposed by every binary-bearing custom type. See #34540. */ + public static final String DOT_BINARY_LIKE_INTERFACE_NAME = "DotBinaryLike"; + + private static GraphQLInterfaceType dotBinaryLikeInterface; + static { + // Build the shared DotBinaryLike interface first so the concrete types below can + // declare it as an implemented interface. The TypeResolver picks the concrete + // GraphQL type at query time based on the Java type of the source value: + // - Map -> DotBinary (produced by BinaryToMapTransformer) + // - Contentlet -> DotFileasset (produced by FileFieldDataFetcher) + final Map commonBinaryFields = new LinkedHashMap<>(); + commonBinaryFields.put("name", GraphQLString); + commonBinaryFields.put("title", GraphQLString); + commonBinaryFields.put("size", GraphQLLong); + commonBinaryFields.put("mime", GraphQLString); + commonBinaryFields.put("versionPath", GraphQLString); + commonBinaryFields.put("idPath", GraphQLString); + commonBinaryFields.put("path", GraphQLString); + commonBinaryFields.put("sha256", GraphQLString); + commonBinaryFields.put("isImage", GraphQLBoolean); + commonBinaryFields.put("width", GraphQLLong); + commonBinaryFields.put("height", GraphQLLong); + commonBinaryFields.put("modDate", GraphQLLong); + + final Map dotBinaryLikeFields = new LinkedHashMap<>(); + commonBinaryFields.forEach((name, type) -> + dotBinaryLikeFields.put(name, new TypeFetcher(type))); + + final TypeResolver dotBinaryLikeResolver = env -> { + final Object source = env.getObject(); + if (source instanceof Map) { + return customFieldTypes.get("BINARY"); + } + if (source instanceof Contentlet) { + return customFieldTypes.get("FILEASSET"); + } + return null; + }; + + dotBinaryLikeInterface = TypeUtil.createInterfaceType( + DOT_BINARY_LIKE_INTERFACE_NAME, dotBinaryLikeFields, dotBinaryLikeResolver); + final Map binaryTypeFields = new HashMap<>(); binaryTypeFields.put("versionPath", GraphQLString); binaryTypeFields.put("idPath", GraphQLString); @@ -77,7 +124,7 @@ public String getTypeName() { binaryTypeFields.put("modDate", GraphQLLong); binaryTypeFields.put("focalPoint", GraphQLString); customFieldTypes.put("BINARY", TypeUtil.createObjectType(BINARY.getTypeName(), binaryTypeFields, - new MapFieldPropertiesDataFetcher())); + new MapFieldPropertiesDataFetcher(), dotBinaryLikeInterface)); final Map categoryTypeFields = new HashMap<>(); categoryTypeFields.put("inode", GraphQLID); @@ -158,7 +205,17 @@ public String getTypeName() { new TypeFetcher(list(CustomFieldType.KEY_VALUE.getType()), new KeyValueFieldDataFetcher())); fileAssetTypeFields.put(FILEASSET_SHOW_ON_MENU_FIELD_VAR, new TypeFetcher(list(GraphQLString), new MultiValueFieldDataFetcher())); fileAssetTypeFields.put(FILEASSET_SORT_ORDER_FIELD_VAR, new TypeFetcher(GraphQLInt, new FieldDataFetcher())); - customFieldTypes.put("FILEASSET", TypeUtil.createObjectType(FILEASSET.getTypeName(), fileAssetTypeFields)); + + // DotBinaryLike interface fields — resolved from the underlying FileAsset/DotAsset + // Contentlet via BinaryToMapTransformer so the values match those exposed by + // the equivalent query against a raw BinaryField. See issue #34540. + final FileAssetBinaryPropertyDataFetcher binaryPropertyFetcher = + new FileAssetBinaryPropertyDataFetcher(); + commonBinaryFields.forEach((name, type) -> + fileAssetTypeFields.put(name, new TypeFetcher(type, binaryPropertyFetcher))); + + customFieldTypes.put("FILEASSET", TypeUtil.createObjectType(FILEASSET.getTypeName(), + fileAssetTypeFields, dotBinaryLikeInterface)); final Map siteTypeFields = new HashMap<>(ContentFields.getContentFields()); siteTypeFields.remove(HOST_KEY); // remove myself @@ -188,6 +245,20 @@ public static Collection getCustomFieldTypes() { return customFieldTypes.values(); } + /** + * Returns the shared {@code DotBinaryLike} interface implemented by {@code DotBinary} and + * {@code DotFileasset}. Callers registering the GraphQL schema must include this in the set + * of known types so introspection exposes it. See issue #34540. + */ + public static GraphQLInterfaceType getDotBinaryLikeInterface() { + return dotBinaryLikeInterface; + } + + /** @return all GraphQL interface types owned by this enum (currently just DotBinaryLike). */ + public static Collection getCustomFieldInterfaces() { + return Collections.singletonList(dotBinaryLikeInterface); + } + public static boolean isCustomFieldType(final GraphQLType type) { boolean isCustomField = false; diff --git a/dotCMS/src/main/java/com/dotcms/graphql/business/ContentAPIGraphQLTypesProvider.java b/dotCMS/src/main/java/com/dotcms/graphql/business/ContentAPIGraphQLTypesProvider.java index c8d01befb8d6..704a4303784f 100644 --- a/dotCMS/src/main/java/com/dotcms/graphql/business/ContentAPIGraphQLTypesProvider.java +++ b/dotCMS/src/main/java/com/dotcms/graphql/business/ContentAPIGraphQLTypesProvider.java @@ -153,6 +153,7 @@ private Set getContentAPITypes() throws DotDataException { final Set contentAPITypes = new HashSet<>(InterfaceType.valuesAsSet()); contentAPITypes.addAll(CustomFieldType.getCustomFieldTypes()); + contentAPITypes.addAll(CustomFieldType.getCustomFieldInterfaces()); List allTypes = APILocator.getContentTypeAPI(APILocator.systemUser()) .search("", null, 100000, 0); diff --git a/dotCMS/src/main/java/com/dotcms/graphql/datafetcher/FileAssetBinaryPropertyDataFetcher.java b/dotCMS/src/main/java/com/dotcms/graphql/datafetcher/FileAssetBinaryPropertyDataFetcher.java new file mode 100644 index 000000000000..cca16c322edc --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/graphql/datafetcher/FileAssetBinaryPropertyDataFetcher.java @@ -0,0 +1,56 @@ +package com.dotcms.graphql.datafetcher; + +import static com.dotcms.contenttype.model.type.BaseContentType.DOTASSET; + +import com.dotcms.contenttype.model.type.FileAssetContentType; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.contentlet.transform.BinaryToMapTransformer; +import com.dotmarketing.util.Logger; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.Map; + +/** + * Resolves a single {@code DotBinaryLike} interface field (e.g. {@code versionPath}, + * {@code size}, {@code mime}) against a {@link Contentlet} that represents a FileAsset or a + * DotAsset. + * + *

The backing data is produced by {@link BinaryToMapTransformer}, which is the same + * transformer used by {@code BinaryFieldDataFetcher} for raw {@code BinaryField}s. That guarantees + * the values returned here are identical to those a client would get by querying the equivalent + * {@code mybinary} field on a Binary-typed content property — which is the whole point of the + * unified interface. + * + *

The binary map key depends on the underlying contentlet's base type: {@code "assetMap"} for + * {@code DotAsset} and {@code "fileAssetMap"} for {@code FileAsset}. + */ +public class FileAssetBinaryPropertyDataFetcher implements DataFetcher { + + @Override + public Object get(final DataFetchingEnvironment environment) throws Exception { + final Contentlet contentlet = environment.getSource(); + if (contentlet == null) { + return null; + } + try { + final String fieldName = environment.getField().getName(); + final Map binaryMap = resolveBinaryMap(contentlet); + return binaryMap != null ? binaryMap.get(fieldName) : null; + } catch (IllegalArgumentException e) { + Logger.warn(this, "Binary is null for field: " + environment.getField().getName()); + return null; + } catch (Exception e) { + Logger.error(this, e.getMessage(), e); + throw e; + } + } + + @SuppressWarnings("unchecked") + private Map resolveBinaryMap(final Contentlet contentlet) { + final String var = contentlet.getContentType().baseType() == DOTASSET + ? "asset" + : FileAssetContentType.FILEASSET_FILEASSET_FIELD_VAR; + final BinaryToMapTransformer transformer = new BinaryToMapTransformer(contentlet); + return (Map) transformer.asMap().get(var + "Map"); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/graphql/util/TypeUtil.java b/dotCMS/src/main/java/com/dotcms/graphql/util/TypeUtil.java index f1f83e72932a..04bc166b6682 100644 --- a/dotCMS/src/main/java/com/dotcms/graphql/util/TypeUtil.java +++ b/dotCMS/src/main/java/com/dotcms/graphql/util/TypeUtil.java @@ -32,17 +32,30 @@ public class TypeUtil { public static GraphQLObjectType createObjectType(final String typeName, final Map typeFields, final DataFetcher dataFetcher) { + return createObjectType(typeName, typeFields, dataFetcher, (GraphQLInterfaceType[]) null); + } + + public static GraphQLObjectType createObjectType(final String typeName, + final Map typeFields, + final DataFetcher dataFetcher, + final GraphQLInterfaceType... interfaces) { Map fieldsTypesAndFetchersMap = typeFields.entrySet() .stream() .collect(Collectors.toMap(Map.Entry::getKey, entry -> new TypeFetcher(entry.getValue(), dataFetcher))); - return createObjectType(typeName, fieldsTypesAndFetchersMap); + return createObjectType(typeName, fieldsTypesAndFetchersMap, interfaces); } public static GraphQLObjectType createObjectType(final String typeName, final Map fieldsTypesAndFetchers) { + return createObjectType(typeName, fieldsTypesAndFetchers, (GraphQLInterfaceType[]) null); + } + + public static GraphQLObjectType createObjectType(final String typeName, + final Map fieldsTypesAndFetchers, + final GraphQLInterfaceType... interfaces) { final GraphQLObjectType.Builder builder = GraphQLObjectType.newObject().name(typeName); @@ -51,6 +64,14 @@ public static GraphQLObjectType createObjectType(final String typeName, builder.fields(fieldDefinitionList); + if (interfaces != null) { + for (final GraphQLInterfaceType iface : interfaces) { + if (iface != null) { + builder.withInterface(iface); + } + } + } + return builder.build(); }