Search code examples
hibernategrailsgrails-orm

MappingException when migrating from Hibernate 3 / Grails 2.2.4 to Hiberate 5 / Grails 3.2.4


I have a project that uses grails 2.2.4 and hibernate 3. I am migrating it over to grails 3.2.4, which uses hibernate 5. I started by creating a grails 3.2.4 project from scratch, and now I am slowly copying over small bits of the code from the old project and getting it working. At first, I only copied over my User, UserRole, and Role classes and got my entire spring security login flow working. Then, I copied over my remaining domain objects and tried to start my project. My domain objects use GORM mappings, rather than JPA annotations. The project won't even start now and gets the following exception on start up (I linked to a public gist because the content is too long for what stack overflow allows).

https://gist.github.com/schmickie/10522d3a2b8a66b6fb79f76e2af0fd72

I did some searching online for the error and everything I can find says it's related to issues with setting up composite primary keys. However, I am not using any composite primary keys. Anyone have any ideas what's going wrong? The domain objects referenced in the error, Api and Method, are shown below.

class Api implements Serializable {

    String name
    boolean enabled
    String apiVersion
    String swaggerVersion
    String resourcePath
    String jsonData
    boolean https = true
    String regionSource
    String contractName
    String contractBasePath

    Date dateCreated
    Date lastUpdated
    Date deprecationDate

    static hasMany = [methods: Method, models: Model, apiServers: ApiServer]

    static mapping = {
        cache true
    }

    static constraints = {
        name(nullable: false, blank: false)
        enabled()
        apiVersion(nullable: true, blank:  true)
        resourcePath(nullable: true, blank: true)
        swaggerVersion(nullable: true, blank:  true)
        jsonData(type: 'text', nullable: true, blank: true)
        dateCreated()
        lastUpdated()
        deprecationDate(nullable: true)
        regionSource(nullable: true)
        contractName(nullable: true)
        contractBasePath(nullable: true)
    }

    public Method getMethod(String name) {
        for (Method method : methods) {
            if (method.name.equals(name)) {
                return method
            }
        }
        return null
    }
}

class Method implements Serializable, Comparable<Method> {

    String name
    boolean enabled
    String edgePath
    HttpMethodEnum edgeHttpMethod
    String servicePath
    HttpMethodEnum serviceHttpMethod
    String summary
    String notes
    boolean deprecatedMethod
    String responseClass
    SortedSet<EdgeParameter> edgeParameters
    SortedSet<ServiceParameter> serviceParameters
    Date dateCreated
    Date lastUpdated
    String publicResponseTransformScript
    String baseResponseTransformScript
    String regionFieldName
    String platformFieldName
    String apiKeyFieldName
    String uriTransformScript
    long cacheExpiry

    static belongsTo = [api: Api]
    static hasMany = [edgeParameters: EdgeParameter, serviceParameters: ServiceParameter, errorResponses: ApiErrorResponse]


    static mapping = {
        cache true
        errorResponses sort: 'code', order: "asc"
    }

    static constraints = {
        name(blank: false)
        enabled()
        edgePath(nullable: false, blank: false)
        edgeHttpMethod(nullable: false, blank: false)
        servicePath(nullable: false, blank: false)
        serviceHttpMethod(nullable: false, blank: false)
        summary(nullable: false, blank: false)
        notes(nullable: true, blank: true)
        deprecatedMethod()
        responseClass(nullable: true, blank: true)
        publicResponseTransformScript(nullable: true)
        baseResponseTransformScript(nullable: true)
        regionFieldName(nullable: true)
        platformFieldName(nullable: true)
        apiKeyFieldName(nullable: true)
        uriTransformScript(nullable: true)
        cacheExpiry(nullable: false, blank: true)
    }
}

Solution

  • Well, I figured it out. I did a lot of stepping through the hibernate/GORM initialization code. I found that the problem is actually with an unrelated mapping in the same class. In the Method class, there is another mapping shown above called errorResponses. When the method DefaultGrailsDomainClass.establishRelationshipForCollection is called to establish the relationship between the Api and Method classes, it calls another method GrailsClassUtils.getPropertiesOfType. This class iterates over all of the properties of the Method class and tries to find any whose type matches the type of the api association. Here is the method implementation:

    public static PropertyDescriptor[] getPropertiesOfType(Class<?> clazz, Class<?> propertyType) {
        if (clazz == null || propertyType == null) {
            return new PropertyDescriptor[0];
        }
    
        Set<PropertyDescriptor> properties = new HashSet<PropertyDescriptor>();
        try {
            for (PropertyDescriptor descriptor : BeanUtils.getPropertyDescriptors(clazz)) {
                Class<?> currentPropertyType = descriptor.getPropertyType();
                if (isTypeInstanceOfPropertyType(propertyType, currentPropertyType)) {
                    properties.add(descriptor);
                }
            }
        }
        catch (Exception e) {
            // if there are any errors in instantiating just return null for the moment
            return new PropertyDescriptor[0];
        }
        return properties.toArray(new PropertyDescriptor[properties.size()]);
    }
    

    The first property it finds is the api property, which passes the isTypeInstanceOfPropertyTypecheck and is added to the properties Set. Due to a poorly named helper method named getApiErrorResponse, the call to BeanUtils.getPropertyDescriptors(clazz) included a field called apiErrorResponse in the returned collection. However, there is no such field in my Method class (there is a mapped association called errorResponses, but nothing called apiErrorResponse). Thus, the propertyType of the PropertyDescriptor for this non-existent field is null. Then, when isTypeInstanceOfPropertyType is called with the null value for currentPropertyType, it throws a NullPointerException. This results in the getPropertiesOfType method returning new PropertyDescriptor[0] as shown in the catch clause. Later down the line, this failure to return the property descriptor that maps to api causes an incorrect OneToOne mapping type to be generated for the association and that mapping is missing a mapped field as well.

    Basically this was a case of the grails code not providing enough information about what really went wrong. It silently suppressed the NullPointerException that was occuring on another field, and the error on that other field was causing improper initialization of the api field. That meant the later error messages were improperly complaining because information/configuration was presumed to be missing when it wasn't, leading to a very confused engineer.

    My ultimate fix was to move the getApiErrorResponse helper method out of the Method class and into MethodService. Note that having this helper method in my domain object worked fine in my old Grails project (Grails 2.2.4 with Hiberate 3) that I was migrating from, so it is presumably new behavior in the latest version of Grails/GORM/Spring/Groovy to assume that all get* methods map to fields. I don't know which of these would be the culprit, but they are all on newer versions than my old project.

    I also submitted a PR to grails-core to log helpful error messages when these kinds of exceptions occur instead of silently swallowing them.

    https://github.com/grails/grails-core/pull/10400