-
Notifications
You must be signed in to change notification settings - Fork 4.5k
chore: Dynamic class based projections for nested fields in jsonb column #36988
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
8d16e63
chore: Add class based projections for nested jsonb fields
abhvsn c0b7010
chore: Minor refactors
abhvsn c242dae
chore: Refactors and comments to make code readable
abhvsn 0765c47
chore: Throw exception in case of cyclical dependency within projecti…
abhvsn 2629b66
chore: Fix serialisation in UserRecentlyUsedEntitiesProjection
abhvsn 63fb7bf
chore: Add check for out of bound index exception
abhvsn File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
21 changes: 21 additions & 0 deletions
21
app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/FieldInfo.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package com.appsmith.server.dtos; | ||
|
|
||
| /** | ||
| * This class is used to store information about a field in a class. It stores the full path of the field and the type | ||
| * of the field. | ||
| * e.g. If we have a class with the following structure: | ||
| * class A { | ||
| * Field1 field1; | ||
| * Field2 field2; | ||
| * } | ||
| * class Field1 { | ||
| * String field3; | ||
| * Boolean field4; | ||
| * } | ||
| * This will result in the following FieldInfo objects: | ||
| * field3 => FieldInfo("field1.field3", String.class) | ||
| * field4 => FieldInfo("field1.field4", Boolean.class) | ||
| * @param fullPath | ||
| * @param type | ||
| */ | ||
| public record FieldInfo(String fullPath, Class<?> type) {} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
22 changes: 22 additions & 0 deletions
22
app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/AppsmithClassUtils.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package com.appsmith.server.helpers; | ||
|
|
||
| public class AppsmithClassUtils { | ||
|
|
||
| /** | ||
| * This method checks if the given class is Appsmith defined projection class. | ||
| * @param clazz The class to be checked | ||
| * @return True if the class is a projection class, false otherwise | ||
| */ | ||
| public static boolean isAppsmithProjections(Class<?> clazz) { | ||
| return clazz.getPackageName().matches(".*appsmith.*projections"); | ||
| } | ||
|
|
||
| /** | ||
| * This method checks if the given class is an Appsmith defined class. | ||
| * @param clazz The class to be checked | ||
| * @return True if the class is an Appsmith defined class, false otherwise | ||
| */ | ||
| public static boolean isAppsmithDefinedClass(Class<?> clazz) { | ||
| return clazz.getPackageName().startsWith("com.appsmith"); | ||
| } | ||
| } |
172 changes: 151 additions & 21 deletions
172
...erver/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/ReflectionHelpers.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,60 +1,190 @@ | ||
| package com.appsmith.server.helpers.ce; | ||
|
|
||
| import com.appsmith.server.dtos.FieldInfo; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import org.springframework.util.CollectionUtils; | ||
|
|
||
| import java.lang.reflect.Constructor; | ||
| import java.lang.reflect.Field; | ||
| import java.util.ArrayList; | ||
| import java.util.Arrays; | ||
| import java.util.Collection; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
|
|
||
| import static com.appsmith.external.helpers.StringUtils.dotted; | ||
| import static com.appsmith.server.helpers.AppsmithClassUtils.isAppsmithDefinedClass; | ||
| import static com.appsmith.server.helpers.AppsmithClassUtils.isAppsmithProjections; | ||
| import static org.modelmapper.internal.util.Primitives.isPrimitiveWrapper; | ||
|
|
||
| public class ReflectionHelpers { | ||
|
|
||
| private static final ObjectMapper objectMapper = new ObjectMapper(); | ||
|
|
||
| /** | ||
| * Maps a tuple to an object of the given type using the constructor of the type. The order of the tuple should be | ||
| * the same as the order of the fields in the type constructor. | ||
| * Maps objects to the given type using the constructor of the type. The order of the objects should be the same as | ||
| * the order of the fields in the type constructor. | ||
| * @param objects The objects to be mapped to the type. This holds the values of the fields of type objectTypes | ||
| * @param type The type of the object to be created | ||
| * @param tuple The tuple to be mapped to the object | ||
| * @param tupleTypes The types of the tuple elements. If not provided, the types of the fields of the type are used. | ||
| * @param objectTypes The types of the objects elements. If not provided, the types of the fields of the type are | ||
| * used. | ||
| * | ||
| * @return The object of the given type | ||
| * @param <T> The type of the object to be created | ||
| */ | ||
| private static <T> T map(Object[] tuple, Class<T> type, List<Class<?>> tupleTypes) { | ||
| if (CollectionUtils.isEmpty(tupleTypes)) { | ||
| tupleTypes = new ArrayList<>(); | ||
| for (Field field : type.getDeclaredFields()) { | ||
| tupleTypes.add(field.getType()); | ||
| } | ||
| private static <T> T map(ArrayList<Object> objects, Class<T> type, List<Class<?>> objectTypes) { | ||
| if (CollectionUtils.isEmpty(objectTypes)) { | ||
| objectTypes = fetchAllFieldTypes(type); | ||
| } | ||
| try { | ||
| Constructor<T> constructor = type.getConstructor(tupleTypes.toArray(new Class<?>[tuple.length])); | ||
| return constructor.newInstance(tuple); | ||
| // Create a deep copy of the objects | ||
| ArrayList<Object> modified = new ArrayList<>(objects.size()); | ||
| for (Class<?> objectType : objectTypes) { | ||
| // In case of Appsmith based projection loop through each field to avoid mapping all the fields from | ||
| // the entity class | ||
| // e.g. class EntityClass { | ||
| // private String field1; | ||
| // private String field2; | ||
| // } | ||
| // class ProjectionClass { | ||
| // private String field1; | ||
| // } | ||
| // In the above example, we only need to map field1 from EntityClass to ProjectionClass. This is | ||
| // because in the objects param we expect only field1 value to be present. | ||
| if (isAppsmithProjections(objectType)) { | ||
| modified.add(map(objects, objectType, null)); | ||
| } else { | ||
| Object value = null; | ||
| if (!CollectionUtils.isEmpty(objects)) { | ||
| value = objects.get(0) != null | ||
| && (isCollectionType(objectType) || isAppsmithDefinedClass(objectType)) | ||
| ? objectMapper.readValue(objectMapper.writeValueAsString(objects.get(0)), objectType) | ||
| : objects.get(0); | ||
| // Drop the first element from objects as it has been processed | ||
| objects.remove(0); | ||
| } | ||
| modified.add(value); | ||
| } | ||
| } | ||
| Constructor<T> constructor = type.getConstructor(objectTypes.toArray(new Class<?>[0])); | ||
| return constructor.newInstance(modified.toArray()); | ||
| } catch (Exception e) { | ||
| throw new RuntimeException(e); | ||
| } | ||
| } | ||
|
|
||
| public static <T> T map(Object[] tuple, Class<T> type) { | ||
| return map(tuple, type, null); | ||
| /** | ||
| * Maps a row from the database to an object of the given type using the constructor of the type. | ||
| * @param row The row to be mapped to the object | ||
| * @param type The type of the object to be created | ||
| * | ||
| * @return The object of the given type | ||
| * @param <T> The type of the object to be created | ||
| */ | ||
| public static <T> T map(Object[] row, Class<T> type) { | ||
| ArrayList<Object> update = new ArrayList<>(Arrays.asList(row)); | ||
| return map(update, type, null); | ||
| } | ||
|
|
||
| /** | ||
| * Maps a list of tuples to a list of objects of the given type using the constructor of the type. | ||
| * @param type The type of the object to be created | ||
| * @param clazz The type of the object to be created | ||
| * @param records The list of tuples to be mapped to the objects | ||
| * | ||
| * @return The list of objects of the given type | ||
| * @param <T> The type of the object to be created | ||
| */ | ||
| public static <T> List<T> map(List<Object[]> records, Class<T> type) { | ||
| public static <T> List<T> map(List<Object[]> records, Class<T> clazz) { | ||
| List<T> result = new ArrayList<>(); | ||
| List<Class<?>> tupleTypes = new ArrayList<>(); | ||
| for (Field field : type.getDeclaredFields()) { | ||
| tupleTypes.add(field.getType()); | ||
| } | ||
| // In case of multiple records avoid fetching the field types for each record | ||
| List<Class<?>> fieldTypes = fetchAllFieldTypes(clazz); | ||
| for (Object[] record : records) { | ||
| result.add(map(record, type, tupleTypes)); | ||
| ArrayList<Object> update = new ArrayList<>(Arrays.asList(record)); | ||
| result.add(map(update, clazz, fieldTypes)); | ||
| } | ||
| return result; | ||
| } | ||
|
|
||
| /** | ||
| * Fetches all the field types of a class and its superclasses. | ||
| * @param clazz The class whose field types are to be fetched | ||
| * | ||
| * @return The list of field types | ||
| */ | ||
| private static List<Class<?>> fetchAllFieldTypes(Class<?> clazz) { | ||
| List<Class<?>> tupleTypes = new ArrayList<>(); | ||
|
|
||
| // Traverse the class hierarchy to get fields from the class and its superclasses | ||
| while (clazz != null) { | ||
| // Get declared fields from the current class | ||
| for (Field field : clazz.getDeclaredFields()) { | ||
| // Ensure access to private fields | ||
| field.setAccessible(true); | ||
| tupleTypes.add(field.getType()); | ||
| } | ||
| // Move to the superclass | ||
| clazz = clazz.getSuperclass(); | ||
| } | ||
|
|
||
| return tupleTypes; | ||
| } | ||
|
|
||
| /** | ||
| * Check if the class is a Java container class e.g. List, Set, Map etc. | ||
| * @param clazz The class to be checked | ||
| * | ||
| * @return True if the class is a container class, false otherwise | ||
| */ | ||
| private static boolean isCollectionType(Class<?> clazz) { | ||
| // Check if the class is a subtype of Collection or Map | ||
| return Collection.class.isAssignableFrom(clazz) || Map.class.isAssignableFrom(clazz); | ||
| } | ||
|
|
||
| /** | ||
| * Extracts all the field paths along-with the field type of the projection class. | ||
| * @param projectionClass The projection class whose field paths are to be extracted | ||
| * | ||
| * @return The list of field paths | ||
| */ | ||
| public static List<FieldInfo> extractFieldPaths(Class<?> projectionClass) { | ||
| List<FieldInfo> fieldPaths = new ArrayList<>(); | ||
| List<Class<?>> visitedClasses = new ArrayList<>(); | ||
| extractFieldPathsRecursively(projectionClass, "", fieldPaths, visitedClasses); | ||
| return fieldPaths; | ||
| } | ||
|
|
||
| private static void extractFieldPathsRecursively( | ||
| Class<?> clazz, String parentPath, List<FieldInfo> fieldPaths, List<Class<?>> visitedClasses) { | ||
| // Check if the class has already been visited to prevent cyclic dependencies | ||
| if (visitedClasses.contains(clazz)) { | ||
| String cyclicChain = String.join( | ||
| " -> ", visitedClasses.stream().map(Class::getName).toArray(String[]::new)); | ||
| throw new RuntimeException("Cyclical dependency detected for: " + cyclicChain); | ||
| } | ||
| // Process the class and its superclasses | ||
| while (clazz != null && clazz != Object.class) { | ||
| for (Field field : clazz.getDeclaredFields()) { | ||
| field.setAccessible(true); // Ensure access to private fields | ||
| String fieldName = field.getName(); | ||
| String fullPath = parentPath.isEmpty() ? fieldName : dotted(parentPath, fieldName); | ||
| Class<?> fieldType = field.getType(); | ||
|
|
||
| if (isAppsmithProjections(fieldType)) { | ||
| visitedClasses.add(fieldType); | ||
| extractFieldPathsRecursively(field.getType(), fullPath, fieldPaths, visitedClasses); | ||
| } else { | ||
| // Check if the field type is part of JdbcType.getDdlTypeCode if not assign the Object as the type | ||
| if (isPrimitiveWrapper(field.getType()) || String.class.equals(field.getType())) { | ||
| fieldPaths.add(new FieldInfo(fullPath, field.getType())); | ||
| } else { | ||
| fieldPaths.add(new FieldInfo(fullPath, Object.class)); | ||
| } | ||
| } | ||
| } | ||
| // Move to superclass (if any) | ||
| clazz = clazz.getSuperclass(); | ||
| } | ||
| // Remove the class from the visited set to allow revisiting in different branches | ||
| visitedClasses.remove(clazz); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
19 changes: 18 additions & 1 deletion
19
...ver/src/main/java/com/appsmith/server/projections/UserRecentlyUsedEntitiesProjection.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,24 @@ | ||
| package com.appsmith.server.projections; | ||
|
|
||
| import com.appsmith.server.dtos.RecentlyUsedEntityDTO; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import lombok.Getter; | ||
|
|
||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
|
|
||
| public record UserRecentlyUsedEntitiesProjection(List<RecentlyUsedEntityDTO> recentlyUsedEntityIds) {} | ||
| @Getter | ||
| public class UserRecentlyUsedEntitiesProjection { | ||
| List<RecentlyUsedEntityDTO> recentlyUsedEntityIds = new ArrayList<>(); | ||
|
|
||
| public UserRecentlyUsedEntitiesProjection(List<Object> recentlyUsedEntityIds) { | ||
| if (recentlyUsedEntityIds == null) { | ||
| return; | ||
| } | ||
| // TODO Abhijeet: This is a temporary fix to convert the list of Object to list of RecentlyUsedEntityDTO | ||
| recentlyUsedEntityIds.forEach(recentlyUsedEntityId -> { | ||
| this.recentlyUsedEntityIds.add( | ||
| new ObjectMapper().convertValue(recentlyUsedEntityId, RecentlyUsedEntityDTO.class)); | ||
| }); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider more graceful handling of cyclic dependencies
While the cyclic dependency detection is good, throwing a runtime exception might be too severe. Consider logging a warning and skipping the cyclic path instead.
Suggested modification:
📝 Committable suggestion
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's better to fail early in such scenario so that devs can come up with better datastructure.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@abhvsn, understood. Failing early in such scenarios makes sense to encourage better data structures. Thanks for clarifying.
(_/)
( -_-) thinking...
✏️ Learnings added