Using custom datatype from another module/add-on

I tried to use a custom datatype implemented in an module/add-on (base) in another module/add-on (api) which provides a non-JPA read-only store consuming data from a REST API. This store defines DTO entities and one of these entities should have an entity property of the custom datatype provided by the base add-on.

Everything is compiling fine but the test loading the application context in the API module/add-on fails because the custom datatype cannot be loaded from the datatype registry. Is that because it’s in another add-on module? Do I have to publish or register them in some way? Do I follow some package conventions?

The test failure reads:
org.springframework.beans.BeanInstantiationException: Failed to instantiate [io.jmix.core.impl.MetadataLoader]: Constructor threw exception; nested exception is java.lang.IllegalArgumentException:

java.lang.IllegalArgumentException: Datatype ‘instant’ is not found
at io.jmix.core.impl.DatatypeRegistryImpl.get(DatatypeRegistryImpl.java:64)
at io.jmix.core.impl.MetaModelLoader.getAdaptiveDatatype(MetaModelLoader.java:818)
at io.jmix.core.impl.MetaModelLoader.loadProperty(MetaModelLoader.java:378)
at io.jmix.core.impl.MetaModelLoader.initProperties(MetaModelLoader.java:282)
at io.jmix.core.impl.MetaModelLoader.loadClass(MetaModelLoader.java:173)
at io.jmix.core.impl.MetaModelLoader.loadModel(MetaModelLoader.java:135)
at io.jmix.core.impl.MetadataLoader.(MetadataLoader.java:61)

The DTO entity which uses the custom datatype within the api add-on:

@Store("transientapistore")
@JmixEntity("api_Announcement")
public class Announcement
{
    @PropertyDatatype(InstantDatatype.ID)
    private Instant time;

    private String message;

    public Instant getTime()
    {
        return time;
    }

    public void setTime(final Instant someTime)
    {
        time = someTime;
    }

	// ...
}

The attribute converter (if I may use the custom datatype within a JPA store as well) within the base add-on:

@Converter(autoApply = true)
public class InstantConverter implements AttributeConverter<Instant, Long>
{
    @Override
    public Long convertToDatabaseColumn(final Instant anAttribute)
    {
        return anAttribute == null ? null : anAttribute.toEpochMilli();
    }

    @Override
    public Instant convertToEntityAttribute(final Long someDBData)
    {
        return someDBData == null ? null : Instant.ofEpochMilli(someDBData.longValue());
    }
}

The custom datatype implementation within the base add-on:

@DatatypeDef(id = InstantDatatype.ID, javaClass = Instant.class, defaultForClass = true, value = InstantDatatype.VALUE)
@Ddl("TIMESTAMP")
public class InstantDatatype extends AbstractTemporalDatatype<Instant> implements TimeZoneAwareDatatype
{
    /**
     * Defines the datatype definition identifier.
     */
    public static final String ID = "instant";

    /**
     * Defines the datatype definition value.
     */
    public static final String VALUE = IBaseModule.PROJECT_PREFIX + "InstantDatatype";

    /**
     * Public default constructor.
     */
    public InstantDatatype()
    {
        super(DateTimeFormatter.ISO_INSTANT);
    }

    @Override
    public String format(@Nullable final Object aValue, final Locale aLocale, @Nullable final TimeZone aTimeZone)
    {
        final String result;
        if (aValue instanceof Instant)
        {
            if (aTimeZone == null)
            {
                result = format(aValue, aLocale);
            }
            else
            {
                final LocalDateTime localDateTime = ((Instant) aValue).atZone(aTimeZone.toZoneId()).toLocalDateTime();

                result = format(localDateTime, aLocale);
            }
        }
        else
        {
            result = null;
        }

        return result;
    }

    @Override
    protected DateTimeFormatter getDateTimeFormatter()
    {
        return DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).withZone(ZoneId.systemDefault());
    }

    @Override
    protected DateTimeFormatter getDateTimeFormatter(final FormatStrings someFormatStrings, final Locale aLocale)
    {
        return DateTimeFormatter.ofPattern(someFormatStrings.getDateTimeFormat(), aLocale)
                        .withZone(ZoneId.systemDefault());
    }

    @Override
    protected TemporalQuery<Instant> newInstance()
    {
        return Instant::from;
    }
}

First, make sure your “api” add-on configuration class contains dependency on the “base” add-on in the @JmixModule annotation, for example:

@JmixModule(dependsOn = {
    EclipselinkConfiguration.class, 
    UiConfiguration.class, 
    BaseConfiguration.class})

Also, for proper bytecode enhancement of entities that use your JPA converter defined in an add-on, the project with entities should explicitly specify the converter in build.gradle like this:

jmix {
    bomVersion = '1.1.2'
    projectId = 'api'
    entitiesEnhancing {
        jpaConverters = ['com.company.base.entity.InstantConverter']
    }
}

@krivopustov Thank you for your response. Adding the module configuration class to the module annotation makes sense.

But unfortunately that didn’t change the missing datatype issue.

I also wonder why the defaultForClass = true attribute of the DatatypeDef annotation at the custom datatype does not apply.

So I added the @PropertyDatatype(InstantDatatype.ID) annotation to the entity property explicitly, which sounds a bit ambiguous.

When I remove the property datatype annotation the exception read a little different:
Failed to instantiate [io.jmix.core.impl.MetadataLoader]: Constructor threw exception; nested exception is java.lang.IllegalStateException: Can’t find range class ‘java.time.Instant’ for property ‘api_Announcement.time’

Do I have to add something to the BaseConfiguration to populate the datatypes?

And did I get you right: The add-on consuming the datatypes needs the entities enhancing gradle closure, not the providing add-on, right?

Hi,

I have the same problem with the custom datatypes as described by @bank. Here’s the use-case I have:

The entity soft reference addon: https://github.com/mariodavid/jmix-entity-soft-reference/tree/cuba-compatability (branch cuba-compatability).

There I have two modules:

The CUBA compatibility module jmix-entity-soft-reference-cuba has a dependency on the entity-soft-reference module as you mentioned:

@Configuration
@ComponentScan
@ConfigurationPropertiesScan
@JmixModule(dependsOn = {
        EclipselinkConfiguration.class,
        UiConfiguration.class,
        SoftReferenceConfiguration.class
})
@PropertySource(name = "de.diedavids.jmix.softreference.cuba", value = "classpath:/de/diedavids/jmix/softreference/cuba/module.properties")
public class SoftReferenceCubaConfiguration {

// ....
}

Additionally, I added the JPA entity enhancing information in the cuba module:

    entitiesEnhancing {
        jpaConverters = ['de.diedavids.jmix.softreference.entity.SoftReferenceConverter']
    }

In the jmix-entity-soft-reference module there is a custom datatype SoftReference and the jmix-entity-soft-reference-cuba module has the old CUBA custom datatype: EntitySoftReference.

Now I have set up an integration test in the cuba module, that has a Document entity that contains now references to both datatypes.

The test fails as the application context cannot be started:

ava.lang.IllegalStateException: Failed to load ApplicationContext
	at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContext(DefaultCacheAwareContextLoaderDelegate.java:132)
	at org.springframework.test.context.support.DefaultTestContext.getApplicationContext(DefaultTestContext.java:124)
	at 

// ...

Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [io.jmix.core.impl.MetadataLoader]: Constructor threw exception; nested exception is java.lang.IllegalArgumentException: Datatype 'SoftReference' is not found
	at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:224)
	at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:117)
	at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:311)
	... 157 more
Caused by: java.lang.IllegalArgumentException: Datatype 'SoftReference' is not found
	at io.jmix.core.impl.DatatypeRegistryImpl.get(DatatypeRegistryImpl.java:64)
	at io.jmix.core.impl.MetaModelLoader.getAdaptiveDatatype(MetaModelLoader.java:818)
	at ...

See also CI run.

So for me, the error persists with the configurations you proposed @krivopustov . Can you elaborate on why the problem still exists?

That being said: In a scenario without multi module this problem is not present. If you take a look at the running example: https://github.com/mariodavid/jmix-entity-soft-reference-example - it works without any further customizations other than:

Thanks for the help!
Mario

I’ve reproduced the problem on the Mario’s multi-module project https://github.com/mariodavid/jmix-entity-soft-reference/tree/cuba-compatability.
Here it’s caused by simple inability of Spring to find beans from different module in tests (no matter if it’s a datatype or a regular @Component). When you run tests in the child module (jmix-entity-soft-reference-cuba), it looks for beans in its own sources because of @ComponentScan on SoftReferenceCubaConfiguration, and in dependencies declared in build.gradle because of their auto-configurations. But the parent module (jmix-entity-soft-reference) is not scanned and its beans are not added to the context.
The straightforward solution is to import the parent’s configuration class in the test configuration of the child module:

@Import({
    SoftReferenceConfiguration.class, // added
    SoftReferenceCubaConfiguration.class})
public class SoftReferenceCubaTestConfiguration {

After that, the contextLoads() test passes. Maybe you can find a better way to build the context in tests correctly.

In single-module add-ons, I couldn’t reproduce the problem. See these projects: parent, child. The parent project includes the Instant datatatype and converter, the child project’s entities use them. Tests in the child project pass. I didn’t add any imports because the child project depends on the parent’s starter with auto-configuration.

Regards,
Konstantin

Also, I should note that entitiesEnhancing.jpaConverters property in build.gradle is actually not needed if the converter is located in one of Jmix modules (not in a plain dependency). My mistake.

@krivopustov Unfortunately, I still could not find the reason, it does not work in my use-case. I downloaded sample-composite-project and could reproduce that the InstantDatatype is loaded in the customers add-on.

In my api add-on it does not. The major difference between the datatype defining add-on in your sample project and my base add-on is, that my base add-on does not define own entities but datatypes and converters only - it is ment to be a company core…ish add-on.

I also don’t understand how attribute converters and datatypes are related to each other. In my api add-on the store is a non-JPA store so attribute converters are not required at all.

I wonder what makes a datatype implementation being loaded in the beanlist which is a constructor argument of DatatypeRegistryImpl?

Any things I could or should try?

Could you publish your add-on projects in a Github repo or just zip and attach here?

Converters are needed only for persistence, if your entity is not JPA, you don’t need a converter.

Datatype implementations are Spring beans, so it’s a standard constructor injection - Spring collects all beans of the required type and injects the list.

@krivopustov I sent you a message containing the two add-ons for further investigation 6 days ago. Did you receive that one?

Hi Steffen,

Thanks for the test add-ons, they helped to quickly spot the problem.

The main cause of your problem is that you include dependency on the functional module of the Base add-on instead of its starter. So the beans of the Base add-on are not added to the context when an app or tests start. I described this problem above, but for a different situation.

So you just need to fix the dependency in company-api.gradle as follows:

implementation('com.company:company-base-starter:1.+') { changing true }

Also, add the dependency on BaseConfiguration in your API add-on, it will ensure the correct order of dependencies:

@JmixModule(dependsOn = {
  EclipselinkConfiguration.class, 
  UiConfiguration.class, 
  BaseConfiguration.class
})
public class APIConfiguration { }

Regards,
Konstantin

1 Like