Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 73 additions & 2 deletions dotCMS/src/main/java/com/dotcms/graphql/CustomFieldType.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -61,7 +66,49 @@ public String getTypeName() {

private static Map<String, GraphQLObjectType> 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<String, GraphQLOutputType> 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<String, TypeFetcher> 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<String, GraphQLOutputType> binaryTypeFields = new HashMap<>();
binaryTypeFields.put("versionPath", GraphQLString);
binaryTypeFields.put("idPath", GraphQLString);
Expand All @@ -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<String, GraphQLOutputType> categoryTypeFields = new HashMap<>();
categoryTypeFields.put("inode", GraphQLID);
Expand Down Expand Up @@ -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<String, TypeFetcher> siteTypeFields = new HashMap<>(ContentFields.getContentFields());
siteTypeFields.remove(HOST_KEY); // remove myself
Expand Down Expand Up @@ -188,6 +245,20 @@ public static Collection<GraphQLObjectType> 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<GraphQLInterfaceType> getCustomFieldInterfaces() {
return Collections.singletonList(dotBinaryLikeInterface);
}

public static boolean isCustomFieldType(final GraphQLType type) {
boolean isCustomField = false;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ private Set<GraphQLType> getContentAPITypes() throws DotDataException {
final Set<GraphQLType> contentAPITypes = new HashSet<>(InterfaceType.valuesAsSet());

contentAPITypes.addAll(CustomFieldType.getCustomFieldTypes());
contentAPITypes.addAll(CustomFieldType.getCustomFieldInterfaces());

List<ContentType> allTypes = APILocator.getContentTypeAPI(APILocator.systemUser())
.search("", null, 100000, 0);
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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<Object> {

@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<String, Object> 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<String, Object> 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<String, Object>) transformer.asMap().get(var + "Map");
}
}
23 changes: 22 additions & 1 deletion dotCMS/src/main/java/com/dotcms/graphql/util/TypeUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,30 @@ public class TypeUtil {

public static GraphQLObjectType createObjectType(final String typeName, final Map<String, GraphQLOutputType> typeFields,
final DataFetcher dataFetcher) {
return createObjectType(typeName, typeFields, dataFetcher, (GraphQLInterfaceType[]) null);
}

public static GraphQLObjectType createObjectType(final String typeName,
final Map<String, GraphQLOutputType> typeFields,
final DataFetcher dataFetcher,
final GraphQLInterfaceType... interfaces) {

Map<String, TypeUtil.TypeFetcher> 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<String, TypeFetcher> fieldsTypesAndFetchers) {
return createObjectType(typeName, fieldsTypesAndFetchers, (GraphQLInterfaceType[]) null);
}

public static GraphQLObjectType createObjectType(final String typeName,
final Map<String, TypeFetcher> fieldsTypesAndFetchers,
final GraphQLInterfaceType... interfaces) {

final GraphQLObjectType.Builder builder = GraphQLObjectType.newObject().name(typeName);

Expand All @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<String> 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");
}
}
Loading