From 9c0437eba7683a99bcee1bfbb871bd471ce4a71e Mon Sep 17 00:00:00 2001 From: Konstantin Krivopustov Date: Fri, 27 Mar 2026 10:14:13 +0400 Subject: [PATCH 1/3] PoC --- jmix-bom/bom.gradle | 6 + .../java/io/jmix/core/ExtendedEntities.java | 4 + .../main/java/io/jmix/core/MetadataTools.java | 8 +- .../core/common/util/ReflectionHelper.java | 37 ++++- .../AdditionalStoreDescriptorProvider.java | 26 ++++ .../io/jmix/core/impl/JavaClassLoader.java | 34 ++++- .../jmix/core/impl/StandardSerialization.java | 8 +- .../core/impl/StoreDescriptorsRegistry.java | 27 ++-- .../model/SessionImplementation.java | 1 + .../model/impl/CloneableMetaProperty.java | 25 +++ .../metamodel/model/impl/MetaClassImpl.java | 26 +++- .../model/impl/MetaPropertyImpl.java | 9 +- .../metamodel/model/impl/SessionImpl.java | 6 + .../java/metadata/DynamicMetaProperty.java | 143 ++++++++++++++++++ .../java/metadata/MetadataMutabilityTest.java | 79 ++++++++++ .../bean/AbstractBeanValidator.java | 4 +- .../java/io/jmix/flowui/menu/MenuConfig.java | 8 + .../flowui/menu/MenuConfigCustomizer.java | 34 +++++ .../jmix/flowui/sys/ViewControllerMeta.java | 8 +- .../io/jmix/flowui/view/ViewRegistry.java | 36 +++-- .../ViewExtensionTest.groovy | 47 ++++++ .../view/conflict/UnrelatedView1.java | 24 +++ .../view/conflict/UnrelatedView2.java | 24 +++ .../view/extension/ExtendingView.java | 23 +++ .../view/extension/ParentView.java | 24 +++ 25 files changed, 620 insertions(+), 51 deletions(-) create mode 100644 jmix-core/core/src/main/java/io/jmix/core/datastore/AdditionalStoreDescriptorProvider.java create mode 100644 jmix-core/core/src/main/java/io/jmix/core/metamodel/model/impl/CloneableMetaProperty.java create mode 100644 jmix-core/core/src/test/java/metadata/DynamicMetaProperty.java create mode 100644 jmix-core/core/src/test/java/metadata/MetadataMutabilityTest.java create mode 100644 jmix-flowui/flowui/src/main/java/io/jmix/flowui/menu/MenuConfigCustomizer.java create mode 100644 jmix-flowui/flowui/src/test/groovy/view_registry_ext/ViewExtensionTest.groovy create mode 100644 jmix-flowui/flowui/src/test/java/view_registry_ext/view/conflict/UnrelatedView1.java create mode 100644 jmix-flowui/flowui/src/test/java/view_registry_ext/view/conflict/UnrelatedView2.java create mode 100644 jmix-flowui/flowui/src/test/java/view_registry_ext/view/extension/ExtendingView.java create mode 100644 jmix-flowui/flowui/src/test/java/view_registry_ext/view/extension/ParentView.java diff --git a/jmix-bom/bom.gradle b/jmix-bom/bom.gradle index 135953705a..1fdc4f10e6 100644 --- a/jmix-bom/bom.gradle +++ b/jmix-bom/bom.gradle @@ -152,6 +152,11 @@ dependencies { api "io.jmix.dynattr:jmix-dynattr-flowui-kit:$freeVersion" api "io.jmix.dynattr:jmix-dynattr-flowui-starter:$freeVersion" + api "io.jmix.dynmodel:jmix-dynmodel:$premiumVersion" + api "io.jmix.dynmodel:jmix-dynmodel-starter:$premiumVersion" + api "io.jmix.dynmodel:jmix-dynmodel-flowui:$premiumVersion" + api "io.jmix.dynmodel:jmix-dynmodel-flowui-starter:$premiumVersion" + api "io.jmix.email:jmix-email:$freeVersion" api "io.jmix.email:jmix-email-flowui:$freeVersion" api "io.jmix.email:jmix-email-starter:$freeVersion" @@ -350,6 +355,7 @@ dependencies { api "com.vaadin:vaadin-spreadsheet-flow:$vaadinFlowVersion" api "com.vaadin:vaadin-dashboard-flow:$vaadinFlowVersion" api "com.vaadin:flow-server:$vaadinFlowVersion" + api "com.vaadin:vaadin-dev:$vaadinFlowVersion" api 'org.spockframework:spock-core:2.4-groovy-5.0' api 'org.spockframework:spock-spring:2.4-groovy-5.0' diff --git a/jmix-core/core/src/main/java/io/jmix/core/ExtendedEntities.java b/jmix-core/core/src/main/java/io/jmix/core/ExtendedEntities.java index 644b2258fb..212107be98 100644 --- a/jmix-core/core/src/main/java/io/jmix/core/ExtendedEntities.java +++ b/jmix-core/core/src/main/java/io/jmix/core/ExtendedEntities.java @@ -232,4 +232,8 @@ public MetaClass getOriginalOrThisMetaClass(MetaClass metaClass) { public void registerReplacedMetaClass(MetaClass metaClass) { replacedMetaClasses.put(metaClass.getJavaClass(), metaClass); } + + public void unregisterReplacedMetaClass(MetaClass metaClass) { + + } } diff --git a/jmix-core/core/src/main/java/io/jmix/core/MetadataTools.java b/jmix-core/core/src/main/java/io/jmix/core/MetadataTools.java index 8eadb6acaa..aaedfc0154 100644 --- a/jmix-core/core/src/main/java/io/jmix/core/MetadataTools.java +++ b/jmix-core/core/src/main/java/io/jmix/core/MetadataTools.java @@ -80,6 +80,7 @@ public class MetadataTools { public static final String SYSTEM_ANN_NAME = "jmix.system"; public static final String STORE_ANN_NAME = "jmix.storeName"; public static final String LENGTH_ANN_NAME = "jmix.length"; + public static final String LOB_ANN_NAME = "jmix.lob"; public static final String CASCADE_TYPES_ANN_NAME = "jmix.cascadeTypes"; public static final String CASCADE_PROPERTIES_ANN_NAME = "jmix.cascadeProperties"; public static final String EMBEDDED_PROPERTIES_ANN_NAME = "jmix.embeddedProperties"; @@ -418,7 +419,8 @@ public boolean isJpa(MetaPropertyPath metaPropertyPath) { */ public boolean isJpa(MetaProperty metaProperty) { Objects.requireNonNull(metaProperty, "metaProperty is null"); - return metaProperty.getStore().getDescriptor().isJpa(); + return metaProperty.getStore().getDescriptor().isJpa() + && metaProperty.getDeclaringClass() != null; // not a dynamic property } /** @@ -436,8 +438,8 @@ public boolean isMethodBased(MetaProperty metaProperty) { */ public boolean isLob(MetaProperty metaProperty) { Objects.requireNonNull(metaProperty, "metaProperty is null"); - return metaProperty.getAnnotatedElement() != null - && metaProperty.getAnnotatedElement().isAnnotationPresent(Lob.class); + return metaProperty.getAnnotatedElement().isAnnotationPresent(Lob.class) + || Boolean.TRUE.equals(metaProperty.getAnnotations().get(LOB_ANN_NAME)); } /** diff --git a/jmix-core/core/src/main/java/io/jmix/core/common/util/ReflectionHelper.java b/jmix-core/core/src/main/java/io/jmix/core/common/util/ReflectionHelper.java index 459078c2bf..9e967c3b30 100644 --- a/jmix-core/core/src/main/java/io/jmix/core/common/util/ReflectionHelper.java +++ b/jmix-core/core/src/main/java/io/jmix/core/common/util/ReflectionHelper.java @@ -27,6 +27,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; @@ -39,6 +40,8 @@ */ public final class ReflectionHelper { + private static final Set classLoaders = new CopyOnWriteArraySet<>(); + private static final LoadingCache, Map> fieldsCache = CacheBuilder.newBuilder() .weakKeys() .build(CacheLoader.from(ReflectionHelper::getDeclaredFields)); @@ -46,6 +49,27 @@ public final class ReflectionHelper { private ReflectionHelper() { } + /** + * Add an additional class loader to be used by {@link #loadClass(String)}. + */ + public static void addClassLoader(ClassLoader classLoader) { + classLoaders.add(classLoader); + } + + /** + * Remove an additional class loader. + */ + public static void removeClassLoader(ClassLoader classLoader) { + classLoaders.remove(classLoader); + } + + /** + * Clear all additional class loaders. + */ + public static void clearClassLoaders() { + classLoaders.clear(); + } + /** * Load class by name. * @@ -78,7 +102,18 @@ public static Class loadClass(String name) throws ClassNotFoundException { "Consider setting it in a new thread using 'Thread.currentThread().setContextClassLoader()' " + "to the classloader of the parent thread or executing class."); } - return contextClassLoader.loadClass(name); + try { + return contextClassLoader.loadClass(name); + } catch (ClassNotFoundException e) { + for (ClassLoader classLoader : classLoaders) { + try { + return classLoader.loadClass(name); + } catch (ClassNotFoundException e1) { + // ignore + } + } + throw e; + } } /** diff --git a/jmix-core/core/src/main/java/io/jmix/core/datastore/AdditionalStoreDescriptorProvider.java b/jmix-core/core/src/main/java/io/jmix/core/datastore/AdditionalStoreDescriptorProvider.java new file mode 100644 index 0000000000..50e14f8166 --- /dev/null +++ b/jmix-core/core/src/main/java/io/jmix/core/datastore/AdditionalStoreDescriptorProvider.java @@ -0,0 +1,26 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.jmix.core.datastore; + +import io.jmix.core.metamodel.model.StoreDescriptor; + +public interface AdditionalStoreDescriptorProvider { + + String getStoreName(); + + StoreDescriptor getStoreDescriptor(); +} diff --git a/jmix-core/core/src/main/java/io/jmix/core/impl/JavaClassLoader.java b/jmix-core/core/src/main/java/io/jmix/core/impl/JavaClassLoader.java index aebd092461..f0da2c0a94 100644 --- a/jmix-core/core/src/main/java/io/jmix/core/impl/JavaClassLoader.java +++ b/jmix-core/core/src/main/java/io/jmix/core/impl/JavaClassLoader.java @@ -20,15 +20,16 @@ import com.google.common.collect.Sets; import io.jmix.core.CoreProperties; import io.jmix.core.TimeSource; +import io.jmix.core.common.util.ReflectionHelper; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; -import org.apache.commons.io.FileUtils; +import jakarta.annotation.PreDestroy; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; -import org.springframework.beans.factory.annotation.Autowired; import java.io.File; import java.io.IOException; import java.lang.management.ManagementFactory; @@ -73,6 +74,7 @@ public JavaClassLoader(CoreProperties coreProperties) { for (String dir : this.rootDirs) { this.classFilesProviders.put(dir, new ClassFilesProvider(dir)); } + ReflectionHelper.addClassLoader(this); } //Please use this constructor only in tests @@ -88,12 +90,18 @@ public JavaClassLoader(CoreProperties coreProperties) { for (String dir : this.rootDirs) { this.classFilesProviders.put(dir, new ClassFilesProvider(dir)); } + ReflectionHelper.addClassLoader(this); } public void clearCache() { loaded.clear(); } + @PreDestroy + public void destroy() { + ReflectionHelper.removeClassLoader(this); + } + @Override public Class loadClass(final String fullClassName, boolean resolve) throws ClassNotFoundException { String containerClassName = StringUtils.substringBefore(fullClassName, "$"); @@ -107,6 +115,10 @@ public Class loadClass(final String fullClassName, boolean resolve) throws Class for (ClassFilesProvider classFilesProvider : classFilesProviders.values()) { File classFile = classFilesProvider.getClassFile(containerClassName); if (classFile.exists()) { + TimestampClass timestampClass = loaded.get(containerClassName); + if (timestampClass != null && classFile.lastModified() <= timestampClass.timestamp.getTime()) { + return timestampClass.clazz; + } return loadClassFromClassFile(fullClassName, containerClassName, classFile); } } @@ -121,10 +133,6 @@ public Class loadClass(final String fullClassName, boolean resolve) throws Class } protected Class loadClassFromClassFile(String fullClassName, String containerClassName, File classFile) { - TimestampClass timestampClass = loaded.get(containerClassName); - if (timestampClass != null && !FileUtils.isFileNewer(classFile, timestampClass.timestamp)) { - return timestampClass.clazz; - } Map loadedClasses = new HashMap<>(); Map modifiedClassFiles = new HashMap<>(); Map fileClassLoaders = new HashMap<>(); @@ -144,7 +152,16 @@ protected Class loadClassFromClassFile(String fullClassName, String containerCla throw new RuntimeException("Class not found", e); } loadedClasses.put(fqn, clazz); - loaded.put(fqn, new TimestampClass(clazz, getCurrentTimestamp())); + + Date timestamp = getCurrentTimestamp(); + for (ClassFilesProvider classFilesProvider : classFilesProviders.values()) { + File file = classFilesProvider.getClassFile(fqn); + if (file.exists()) { + timestamp = new Date(file.lastModified()); + break; + } + } + loaded.put(fqn, new TimestampClass(clazz, timestamp)); } springBeanLoader.updateContext(loadedClasses.values()); return loadedClasses.get(fullClassName); @@ -165,7 +182,8 @@ protected Set collectModifiedClassFiles(String rootDir) { String fqn = root.relativize(path).toString(); fqn = fqn.substring(0, fqn.length() - 6).replace(File.separator, "."); TimestampClass timeStampClass = getTimestampClass(fqn); - if (timeStampClass == null || FileUtils.isFileNewer(path.toFile(), timeStampClass.timestamp)) { + long lastModified = path.toFile().lastModified(); + if (timeStampClass == null || lastModified > timeStampClass.timestamp.getTime()) { result.add(fqn); } }); diff --git a/jmix-core/core/src/main/java/io/jmix/core/impl/StandardSerialization.java b/jmix-core/core/src/main/java/io/jmix/core/impl/StandardSerialization.java index 000d5fa90b..86f8f7ea07 100644 --- a/jmix-core/core/src/main/java/io/jmix/core/impl/StandardSerialization.java +++ b/jmix-core/core/src/main/java/io/jmix/core/impl/StandardSerialization.java @@ -16,7 +16,7 @@ package io.jmix.core.impl; -import org.apache.commons.lang3.ClassUtils; +import io.jmix.core.ClassManager; import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -32,6 +32,9 @@ public class StandardSerialization { @Autowired protected BeanFactory beanFactory; + @Autowired + protected ClassManager classManager; + public void serialize(Object object, OutputStream os) { ObjectOutputStream out = null; boolean isObjectStream = os instanceof ObjectOutputStream; @@ -67,7 +70,8 @@ public Object deserialize(InputStream is) { ois = new ObjectInputStream(is) { @Override protected Class resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { - return ClassUtils.getClass(StandardSerialization.class.getClassLoader(), desc.getName()); + Class clazz = classManager.findClass(desc.getName()); + return clazz != null ? clazz : super.resolveClass(desc); } }; } diff --git a/jmix-core/core/src/main/java/io/jmix/core/impl/StoreDescriptorsRegistry.java b/jmix-core/core/src/main/java/io/jmix/core/impl/StoreDescriptorsRegistry.java index 8f55492864..dd76c7d85a 100644 --- a/jmix-core/core/src/main/java/io/jmix/core/impl/StoreDescriptorsRegistry.java +++ b/jmix-core/core/src/main/java/io/jmix/core/impl/StoreDescriptorsRegistry.java @@ -18,6 +18,7 @@ import com.google.common.base.Splitter; import com.google.common.base.Strings; +import io.jmix.core.datastore.AdditionalStoreDescriptorProvider; import io.jmix.core.metamodel.model.StoreDescriptor; import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Autowired; @@ -25,9 +26,7 @@ import org.jspecify.annotations.Nullable; import org.springframework.stereotype.Component; -import java.util.Collections; -import java.util.List; -import java.util.Map; +import java.util.*; import static io.jmix.core.Stores.*; @@ -44,20 +43,23 @@ public class StoreDescriptorsRegistry { @Autowired protected NoopStoreDescriptor noopStoreDescriptor; @Autowired - protected Map descriptors; - @Autowired protected Environment environment; + @Autowired + protected Map storeDescriptorBeans; + @Autowired(required = false) + protected List additionalStoreDescriptorProviders; protected static final Splitter SPLITTER = Splitter.on(",").omitEmptyStrings().trimResults(); - protected List additionalDataStoreNames; + protected Map descriptors = new HashMap<>(); + protected List additionalDataStoreNames = new ArrayList<>(); @PostConstruct protected void initialize() { String property = environment.getProperty("jmix.core.additional-stores"); - additionalDataStoreNames = !Strings.isNullOrEmpty(property) + additionalDataStoreNames.addAll(!Strings.isNullOrEmpty(property) ? SPLITTER.splitToList(property) - : Collections.emptyList(); + : Collections.emptyList()); initUndefinedStoreDescriptor(); initMainStoreDescriptor(); @@ -66,6 +68,13 @@ protected void initialize() { for (String storeName : additionalDataStoreNames) { initAdditionalStoreDescriptor(storeName); } + + if (additionalStoreDescriptorProviders != null) { + for (AdditionalStoreDescriptorProvider provider : additionalStoreDescriptorProviders) { + descriptors.put(provider.getStoreName(), provider.getStoreDescriptor()); + additionalDataStoreNames.add(provider.getStoreName()); + } + } } public StoreDescriptor getStoreDescriptor(String storeName) { @@ -101,7 +110,7 @@ protected void initStoreDescriptor(String storeName, StoreDescriptor defaultStor protected StoreDescriptor resolveStoreDescriptor(String storeName) { String descriptorName = environment.getProperty("jmix.core.store-descriptor-" + storeName); if (descriptorName != null) { - StoreDescriptor descriptor = descriptors.get(descriptorName); + StoreDescriptor descriptor = storeDescriptorBeans.get(descriptorName); if (descriptor != null) { return descriptor; } else { diff --git a/jmix-core/core/src/main/java/io/jmix/core/metamodel/model/SessionImplementation.java b/jmix-core/core/src/main/java/io/jmix/core/metamodel/model/SessionImplementation.java index 1a8218d106..74e36650de 100644 --- a/jmix-core/core/src/main/java/io/jmix/core/metamodel/model/SessionImplementation.java +++ b/jmix-core/core/src/main/java/io/jmix/core/metamodel/model/SessionImplementation.java @@ -25,4 +25,5 @@ public interface SessionImplementation extends Session { void registerClass(String name, Class javaClass, MetaClass metaClass); + void unregisterClass(MetaClass metaClass); } diff --git a/jmix-core/core/src/main/java/io/jmix/core/metamodel/model/impl/CloneableMetaProperty.java b/jmix-core/core/src/main/java/io/jmix/core/metamodel/model/impl/CloneableMetaProperty.java new file mode 100644 index 0000000000..51b3c31039 --- /dev/null +++ b/jmix-core/core/src/main/java/io/jmix/core/metamodel/model/impl/CloneableMetaProperty.java @@ -0,0 +1,25 @@ +/* + * Copyright 2025 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.jmix.core.metamodel.model.impl; + +import io.jmix.core.metamodel.model.MetaClass; +import io.jmix.core.metamodel.model.MetaProperty; + +public interface CloneableMetaProperty { + + MetaProperty makeClone(MetaClass metaClass); +} diff --git a/jmix-core/core/src/main/java/io/jmix/core/metamodel/model/impl/MetaClassImpl.java b/jmix-core/core/src/main/java/io/jmix/core/metamodel/model/impl/MetaClassImpl.java index b3ad9c028e..c54263afa2 100644 --- a/jmix-core/core/src/main/java/io/jmix/core/metamodel/model/impl/MetaClassImpl.java +++ b/jmix-core/core/src/main/java/io/jmix/core/metamodel/model/impl/MetaClassImpl.java @@ -19,11 +19,12 @@ import io.jmix.core.metamodel.model.*; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; public class MetaClassImpl extends MetadataObjectImpl implements MetaClass { - private Map propertyByName = new HashMap<>(); - private Map ownPropertyByName = new HashMap<>(); + private Map propertyByName = new ConcurrentHashMap<>(); + private Map ownPropertyByName = new ConcurrentHashMap<>(); private final Session session; private Class javaClass; @@ -71,7 +72,7 @@ public Class getJavaClass() { @Override public Collection getProperties() { - return propertyByName.values(); + return new ArrayList<>(propertyByName.values()); } @Override @@ -127,7 +128,7 @@ public MetaPropertyPath getPropertyPath(String propertyPath) { @Override public Collection getOwnProperties() { - return ownPropertyByName.values(); + return new ArrayList<>(ownPropertyByName.values()); } public void setJavaClass(Class javaClass) { @@ -155,13 +156,24 @@ public void registerProperty(MetaProperty metaProperty) { public void registerAncestorProperty(MetaProperty metaProperty) { MetaProperty prop = propertyByName.get(metaProperty.getName()); - if (prop == null) { - MetaPropertyImpl clone = new MetaPropertyImpl((MetaPropertyImpl) metaProperty); - clone.setDomain(this); + if (prop == null && metaProperty instanceof CloneableMetaProperty cloneableMetaProperty) { + MetaProperty clone = cloneableMetaProperty.makeClone(this); propertyByName.put(metaProperty.getName(), clone); } } + public void unregisterProperty(MetaProperty metaProperty) { + propertyByName.remove(metaProperty.getName()); + ownPropertyByName.remove(metaProperty.getName()); + for (MetaClass descendant : descendants) { + ((MetaClassImpl) descendant).unregisterAncestorProperty(metaProperty); + } + } + + private void unregisterAncestorProperty(MetaProperty metaProperty) { + propertyByName.remove(metaProperty.getName()); + } + @Override public String toString() { return name; diff --git a/jmix-core/core/src/main/java/io/jmix/core/metamodel/model/impl/MetaPropertyImpl.java b/jmix-core/core/src/main/java/io/jmix/core/metamodel/model/impl/MetaPropertyImpl.java index cffff4ada0..3bce4a8485 100644 --- a/jmix-core/core/src/main/java/io/jmix/core/metamodel/model/impl/MetaPropertyImpl.java +++ b/jmix-core/core/src/main/java/io/jmix/core/metamodel/model/impl/MetaPropertyImpl.java @@ -23,7 +23,7 @@ import java.util.Map; import java.util.function.Consumer; -public class MetaPropertyImpl extends MetadataObjectImpl implements MetaProperty { +public class MetaPropertyImpl extends MetadataObjectImpl implements MetaProperty, CloneableMetaProperty { private Store store; private MetaClass domain; @@ -64,6 +64,13 @@ public MetaPropertyImpl(MetaPropertyImpl prototype) { declaringClass = prototype.declaringClass; } + @Override + public MetaPropertyImpl makeClone(MetaClass metaClass) { + MetaPropertyImpl metaProperty = new MetaPropertyImpl(this); + metaProperty.setDomain(metaClass); + return metaProperty; + } + @Override public MetaClass getDomain() { return domain; diff --git a/jmix-core/core/src/main/java/io/jmix/core/metamodel/model/impl/SessionImpl.java b/jmix-core/core/src/main/java/io/jmix/core/metamodel/model/impl/SessionImpl.java index 746fab81a4..55f4111c38 100644 --- a/jmix-core/core/src/main/java/io/jmix/core/metamodel/model/impl/SessionImpl.java +++ b/jmix-core/core/src/main/java/io/jmix/core/metamodel/model/impl/SessionImpl.java @@ -85,4 +85,10 @@ public void registerClass(String name, Class javaClass, MetaClass metaClass) { classByName.put(name, metaClass); classByClass.put(javaClass, metaClass); } + + @Override + public void unregisterClass(MetaClass metaClass) { + classByName.remove(metaClass.getName()); + classByClass.remove(metaClass.getJavaClass()); + } } diff --git a/jmix-core/core/src/test/java/metadata/DynamicMetaProperty.java b/jmix-core/core/src/test/java/metadata/DynamicMetaProperty.java new file mode 100644 index 0000000000..165a6f8724 --- /dev/null +++ b/jmix-core/core/src/test/java/metadata/DynamicMetaProperty.java @@ -0,0 +1,143 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package metadata; + +import io.jmix.core.metamodel.model.*; +import io.jmix.core.metamodel.model.impl.MetadataObjectImpl; + +import java.io.Serializable; +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; + +public class DynamicMetaProperty extends MetadataObjectImpl implements MetaProperty { + + protected final MetaClass metaClass; + protected final Range range; + protected final Class javaClass; + protected final Boolean mandatory; + protected final AnnotatedElement annotatedElement = new FakeAnnotatedElement(); + protected final Type type; + protected Store store; + + public DynamicMetaProperty(MetaClass metaClass, String name, Class javaClass, Range range, Type type) { + this.metaClass = metaClass; + this.name = name; + this.range = range; + this.javaClass = javaClass; + this.type = type; + this.mandatory = false; + this.store = metaClass.getStore(); + } + + @Override + public Session getSession() { + return metaClass.getSession(); + } + + @Override + public MetaClass getDomain() { + return metaClass; + } + + @Override + public Range getRange() { + return range; + } + + @Override + public Type getType() { + return type; + } + + @Override + public boolean isMandatory() { + return Boolean.TRUE.equals(mandatory); + } + + @Override + public boolean isReadOnly() { + return false; + } + + @Override + public MetaProperty getInverse() { + return null; + } + + @Override + public AnnotatedElement getAnnotatedElement() { + return annotatedElement; + } + + @Override + public Class getJavaType() { + return javaClass; + } + + @Override + public Class getDeclaringClass() { + return null; + } + + @Override + public Store getStore() { + return store; + } + + public void setStore(Store store) { + this.store = store; + } + + protected static class FakeAnnotatedElement implements AnnotatedElement, Serializable { + + @Override + public boolean isAnnotationPresent(Class annotationClass) { + return false; + } + + @Override + public T getAnnotation(Class annotationClass) { + return null; + } + + @Override + public Annotation[] getAnnotations() { + return new Annotation[0]; + } + + @Override + public Annotation[] getDeclaredAnnotations() { + return new Annotation[0]; + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof DynamicMetaProperty)) return false; + + DynamicMetaProperty that = (DynamicMetaProperty) o; + + return metaClass.equals(that.metaClass) && name.equals(that.name); + + } + + @Override + public int hashCode() { + return 31 * metaClass.hashCode() + name.hashCode(); + } +} diff --git a/jmix-core/core/src/test/java/metadata/MetadataMutabilityTest.java b/jmix-core/core/src/test/java/metadata/MetadataMutabilityTest.java new file mode 100644 index 0000000000..a07b12f5cf --- /dev/null +++ b/jmix-core/core/src/test/java/metadata/MetadataMutabilityTest.java @@ -0,0 +1,79 @@ +/* + * Copyright 2025 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package metadata; + +import io.jmix.core.*; +import io.jmix.core.metamodel.datatype.DatatypeRegistry; +import io.jmix.core.metamodel.model.MetaClass; +import io.jmix.core.metamodel.model.MetaProperty; +import io.jmix.core.metamodel.model.SessionImplementation; +import io.jmix.core.metamodel.model.impl.DatatypeRange; +import io.jmix.core.metamodel.model.impl.MetaClassImpl; +import io.jmix.core.metamodel.model.impl.MetaPropertyImpl; +import io.jmix.core.repository.JmixDataRepositoryUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import test_support.addon1.TestAddon1Configuration; +import test_support.app.TestAppConfiguration; +import test_support.app.entity.Pet; +import test_support.app.entity.sales.Customer; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = {CoreConfiguration.class, TestAddon1Configuration.class, TestAppConfiguration.class}) +public class MetadataMutabilityTest { + + @Autowired + Metadata metadata; + @Autowired + ExtendedEntities extendedEntities; + @Autowired + DatatypeRegistry datatypeRegistry; + + @Test + void test() { + // when + + MetaClass metaClass = metadata.getClass(Customer.class); + + MetaClassImpl dynMetaClass = new MetaClassImpl(metadata.getSession(), "CustomerDynamic"); + dynMetaClass.setJavaClass(Customer.class); + dynMetaClass.setStore(metaClass.getStore()); + dynMetaClass.addAncestor(metaClass); + + MetaProperty dynMetaProperty = new DynamicMetaProperty(dynMetaClass, "address", String.class, + new DatatypeRange(datatypeRegistry.get(String.class)), MetaProperty.Type.DATATYPE); + dynMetaClass.registerProperty(dynMetaProperty); + + ((SessionImplementation) metadata.getSession()).registerClass(metaClass.getName(), metaClass.getJavaClass(), dynMetaClass); + + extendedEntities.registerReplacedMetaClass(dynMetaClass); + + // then + + assertSame(dynMetaClass, metadata.getClass(Customer.class)); + assertSame(dynMetaClass, metadata.getClass("core_Customer")); + + MetaProperty metaProperty = dynMetaClass.findProperty("name"); + assertNotNull(metaProperty); + } +} diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/validation/bean/AbstractBeanValidator.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/validation/bean/AbstractBeanValidator.java index 9b2b8133c7..d1656f8aba 100644 --- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/validation/bean/AbstractBeanValidator.java +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/validation/bean/AbstractBeanValidator.java @@ -20,6 +20,7 @@ import io.jmix.core.Messages; import io.jmix.core.Metadata; import io.jmix.core.metamodel.model.MetaClass; +import io.jmix.core.metamodel.model.MetaProperty; import io.jmix.core.validation.group.UiComponentChecks; import io.jmix.flowui.component.validation.Validator; import io.jmix.flowui.exception.CompositeValidationException; @@ -87,7 +88,8 @@ public void accept(Object value) { groups = new Class[]{Default.class, UiComponentChecks.class}; } - if (metadata.getClass(beanClass).findProperty(beanProperty) != null) { + MetaProperty metaProperty = metadata.getClass(beanClass).findProperty(beanProperty); + if (metaProperty != null && metaProperty.getDeclaringClass() != null) { // TODO dynmod: implement validation for dynamic attributes @SuppressWarnings("unchecked") Set violations = validator.validateValue(beanClass, beanProperty, value, groups); diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/menu/MenuConfig.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/menu/MenuConfig.java index ba3fd310fa..8b91de37cf 100644 --- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/menu/MenuConfig.java +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/menu/MenuConfig.java @@ -174,6 +174,14 @@ protected void init() { for (Element rootElement : rootElements) { loadMenuItems(rootElement, null, menusByIdPaths); } + + Map customizerBeans = applicationContext.getBeansOfType(MenuConfigCustomizer.class); + List customizers = new ArrayList<>(customizerBeans.values()); + for (MenuConfigCustomizer customizer : customizers) { + customizer.customize(rootItems); + } + + initialized = true; } /** diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/menu/MenuConfigCustomizer.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/menu/MenuConfigCustomizer.java new file mode 100644 index 0000000000..dce80d90de --- /dev/null +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/menu/MenuConfigCustomizer.java @@ -0,0 +1,34 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.jmix.flowui.menu; + +import java.util.List; + +/** + * Interface to be implemented by beans that want to customize the main menu structure. + * + * @see MenuConfig + */ +public interface MenuConfigCustomizer { + + /** + * Customizes the main menu structure. + * + * @param rootItems root menu items + */ + void customize(List rootItems); +} diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/sys/ViewControllerMeta.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/sys/ViewControllerMeta.java index 40f4d56c48..4e4844e7f0 100644 --- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/sys/ViewControllerMeta.java +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/sys/ViewControllerMeta.java @@ -110,18 +110,18 @@ protected String getViewClassName() { protected Map getControllerAnnotationAttributes(String annotationName, Class viewClass) { for (Annotation annotation : viewClass.getAnnotations()) { - Class annotationClass = annotation.getClass(); - if (!annotationClass.getName().equals(annotationName)) { + Class annotationType = annotation.annotationType(); + if (!annotationType.getName().equals(annotationName)) { continue; } Map annotationAttributes = new HashMap<>(); - for (Method method : annotationClass.getDeclaredMethods()) { + for (Method method : annotationType.getDeclaredMethods()) { try { annotationAttributes.put(method.getName(), method.invoke(annotation)); } catch (IllegalAccessException | InvocationTargetException e) { log.warn("Failed to get '{}#{}' property value for class '{}'", - annotationClass.getName(), method.getName(), viewClass.getName(), e); + annotationType.getName(), method.getName(), viewClass.getName(), e); } } return annotationAttributes; diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/view/ViewRegistry.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/view/ViewRegistry.java index df62bed1d9..5580dd1bca 100644 --- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/view/ViewRegistry.java +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/view/ViewRegistry.java @@ -191,32 +191,38 @@ protected void loadViewConfigurations() { for (ViewControllersConfiguration provider : configurations) { List viewControllers = provider.getViewControllers(); - Map projectViews = new HashMap<>(viewControllers.size()); + Map configurationViews = new LinkedHashMap<>(viewControllers.size()); for (ViewControllerDefinition definition : viewControllers) { String viewId = definition.getId(); String controllerClassName = definition.getControllerClassName(); - String existingViewController = projectViews.get(viewId); - if (existingViewController != null - && !Objects.equals(existingViewController, controllerClassName)) { - throw new RuntimeException( - String.format("Project contains views with the same id: '%s'. See '%s' and '%s'", - viewId, - controllerClassName, - existingViewController)); - } else { - projectViews.put(viewId, controllerClassName); - } - Class> controllerClass = loadDefinedViewClass(controllerClassName); String templatePath = ViewDescriptorUtils.resolveTemplatePath(controllerClass); ViewInfo viewInfo = new ViewInfo(viewId, controllerClassName, controllerClass, templatePath); - registerView(viewId, viewInfo); + ViewInfo existingViewInfo = configurationViews.get(viewId); + if (existingViewInfo != null + && !Objects.equals(existingViewInfo.getControllerClassName(), controllerClassName)) { + + Class> existingClass = existingViewInfo.getControllerClass(); + if (existingClass.isAssignableFrom(controllerClass)) { + configurationViews.put(viewId, viewInfo); + } else if (!controllerClass.isAssignableFrom(existingClass)) { + throw new RuntimeException( + String.format("Project contains views with the same id: '%s'. See '%s' and '%s'", + viewId, + controllerClassName, + existingViewInfo.getControllerClassName())); + } + } else { + configurationViews.put(viewId, viewInfo); + } } - projectViews.clear(); + for (ViewInfo viewInfo : configurationViews.values()) { + registerView(viewInfo.getId(), viewInfo); + } } } diff --git a/jmix-flowui/flowui/src/test/groovy/view_registry_ext/ViewExtensionTest.groovy b/jmix-flowui/flowui/src/test/groovy/view_registry_ext/ViewExtensionTest.groovy new file mode 100644 index 0000000000..6d736d4a3c --- /dev/null +++ b/jmix-flowui/flowui/src/test/groovy/view_registry_ext/ViewExtensionTest.groovy @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package view_registry_ext + +import io.jmix.flowui.view.ViewRegistry +import view_registry_ext.view.extension.ExtendingView +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import test_support.spec.FlowuiTestSpecification + +@SpringBootTest +class ViewExtensionTest extends FlowuiTestSpecification { + + @Autowired + ViewRegistry viewRegistry + + def "extended view in the same package should be registered"() { + when: + registerViewBasePackages("view_registry_ext.view.extension") + + then: + def viewInfo = viewRegistry.getViewInfo("test_View") + viewInfo.controllerClass == ExtendingView + } + + def "unrelated views with same id should throw exception"() { + when: + registerViewBasePackages("view_registry_ext.view.conflict") + + then: + thrown(RuntimeException) + } +} diff --git a/jmix-flowui/flowui/src/test/java/view_registry_ext/view/conflict/UnrelatedView1.java b/jmix-flowui/flowui/src/test/java/view_registry_ext/view/conflict/UnrelatedView1.java new file mode 100644 index 0000000000..63422a973d --- /dev/null +++ b/jmix-flowui/flowui/src/test/java/view_registry_ext/view/conflict/UnrelatedView1.java @@ -0,0 +1,24 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package view_registry_ext.view.conflict; + +import io.jmix.flowui.view.StandardView; +import io.jmix.flowui.view.ViewController; + +@ViewController("conflict_View") +public class UnrelatedView1 extends StandardView { +} diff --git a/jmix-flowui/flowui/src/test/java/view_registry_ext/view/conflict/UnrelatedView2.java b/jmix-flowui/flowui/src/test/java/view_registry_ext/view/conflict/UnrelatedView2.java new file mode 100644 index 0000000000..88aa6621d1 --- /dev/null +++ b/jmix-flowui/flowui/src/test/java/view_registry_ext/view/conflict/UnrelatedView2.java @@ -0,0 +1,24 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package view_registry_ext.view.conflict; + +import io.jmix.flowui.view.StandardView; +import io.jmix.flowui.view.ViewController; + +@ViewController("conflict_View") +public class UnrelatedView2 extends StandardView { +} diff --git a/jmix-flowui/flowui/src/test/java/view_registry_ext/view/extension/ExtendingView.java b/jmix-flowui/flowui/src/test/java/view_registry_ext/view/extension/ExtendingView.java new file mode 100644 index 0000000000..9ac0d5d665 --- /dev/null +++ b/jmix-flowui/flowui/src/test/java/view_registry_ext/view/extension/ExtendingView.java @@ -0,0 +1,23 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package view_registry_ext.view.extension; + +import io.jmix.flowui.view.ViewController; + +@ViewController("test_View") +public class ExtendingView extends ParentView { +} diff --git a/jmix-flowui/flowui/src/test/java/view_registry_ext/view/extension/ParentView.java b/jmix-flowui/flowui/src/test/java/view_registry_ext/view/extension/ParentView.java new file mode 100644 index 0000000000..403171c4c5 --- /dev/null +++ b/jmix-flowui/flowui/src/test/java/view_registry_ext/view/extension/ParentView.java @@ -0,0 +1,24 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package view_registry_ext.view.extension; + +import io.jmix.flowui.view.StandardView; +import io.jmix.flowui.view.ViewController; + +@ViewController("test_View") +public class ParentView extends StandardView { +} From f6416717879a15e8870dafd0ad9afc898c9220a3 Mon Sep 17 00:00:00 2001 From: Konstantin Krivopustov Date: Fri, 27 Mar 2026 15:26:56 +0400 Subject: [PATCH 2/3] InstanceName --- .../java/io/jmix/core/InstanceNameProvider.java | 6 ++++++ .../jmix/core/impl/InstanceNameProviderImpl.java | 14 +++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/jmix-core/core/src/main/java/io/jmix/core/InstanceNameProvider.java b/jmix-core/core/src/main/java/io/jmix/core/InstanceNameProvider.java index 665d9ceb8b..622c8a3999 100644 --- a/jmix-core/core/src/main/java/io/jmix/core/InstanceNameProvider.java +++ b/jmix-core/core/src/main/java/io/jmix/core/InstanceNameProvider.java @@ -78,4 +78,10 @@ public interface InstanceNameProvider { * @return collection of the name pattern properties */ Collection getInstanceNameRelatedProperties(MetaClass metaClass, boolean useOriginal); + + /** + * Evicts cached instance name metadata for all entities. + */ + default void evictInstanceNameCache() { + } } diff --git a/jmix-core/core/src/main/java/io/jmix/core/impl/InstanceNameProviderImpl.java b/jmix-core/core/src/main/java/io/jmix/core/impl/InstanceNameProviderImpl.java index 72d6857022..585a11550e 100644 --- a/jmix-core/core/src/main/java/io/jmix/core/impl/InstanceNameProviderImpl.java +++ b/jmix-core/core/src/main/java/io/jmix/core/impl/InstanceNameProviderImpl.java @@ -249,6 +249,11 @@ public Collection getInstanceNameRelatedProperties(MetaClass metaC return optional.map(instanceNameRec -> Arrays.asList(instanceNameRec.nameProperties)).orElse(Collections.emptyList()); } + @Override + public void evictInstanceNameCache() { + instanceNameRecCache.invalidateAll(); + } + protected Collection getInstanceNameProperties(MetaClass metaClass, @Nullable Method nameMethod, @Nullable MetaProperty nameProperty) { final Collection properties = new HashSet<>(); if (nameMethod != null) { @@ -278,7 +283,7 @@ public InstanceNameRec parseNamePattern(MetaClass metaClass) { .filter(m -> AnnotatedElementUtils.findMergedAnnotation(m, InstanceName.class) != null) .collect(Collectors.toList()); List nameProperties = metaClass.getProperties().stream() - .filter(p -> p.getAnnotatedElement().getAnnotation(InstanceName.class) != null) + .filter(this::isInstanceNameProperty) .filter(p -> !metadataTools.isMethodBased(p)) .collect(Collectors.toList()); if (!instanceNameMethods.isEmpty()) { @@ -317,6 +322,13 @@ public InstanceNameRec parseNamePattern(MetaClass metaClass) { .toArray(MetaProperty[]::new)); } + protected boolean isInstanceNameProperty(MetaProperty metaProperty) { + if (metaProperty.getAnnotatedElement().getAnnotation(InstanceName.class) != null) { + return true; + } + return Boolean.TRUE.equals(metadataTools.getMetaAnnotationValue(metaProperty, InstanceName.class)); + } + private void validateInstanceNameAnnotation(MetaClass metaClass, List instanceNameMethods, List nameProperties, From 0afffb74383c3e9dd036f477eedd6a68aed1fd95 Mon Sep 17 00:00:00 2001 From: Konstantin Krivopustov Date: Sun, 29 Mar 2026 20:17:30 +0400 Subject: [PATCH 3/3] Support for IDENTITY and composite primary keys --- .../core/datastore/AbstractDataStore.java | 3 + .../DataStoreBeforeSaveCommitEvent.java | 60 ++++++ .../datastore/DataStoreEventListener.java | 3 + .../AbstractDataStoreSaveLifecycleTest.java | 179 ++++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 jmix-core/core/src/main/java/io/jmix/core/datastore/DataStoreBeforeSaveCommitEvent.java create mode 100644 jmix-core/core/src/test/java/datastore/AbstractDataStoreSaveLifecycleTest.java diff --git a/jmix-core/core/src/main/java/io/jmix/core/datastore/AbstractDataStore.java b/jmix-core/core/src/main/java/io/jmix/core/datastore/AbstractDataStore.java index 6cd3b93af0..d2ed2efb30 100644 --- a/jmix-core/core/src/main/java/io/jmix/core/datastore/AbstractDataStore.java +++ b/jmix-core/core/src/main/java/io/jmix/core/datastore/AbstractDataStore.java @@ -229,6 +229,9 @@ public Set save(SaveContext context) { fireEvent(deletingEvent); beforeSaveTransactionCommit(context, savedEntities, deletedEntities); + DataStoreBeforeSaveCommitEvent beforeSaveCommitEvent = + new DataStoreBeforeSaveCommitEvent(context, savedEntities, deletedEntities, saveState); + fireEvent(beforeSaveCommitEvent); commitTransaction(transaction); } finally { beforeSaveTransactionRollback(context); diff --git a/jmix-core/core/src/main/java/io/jmix/core/datastore/DataStoreBeforeSaveCommitEvent.java b/jmix-core/core/src/main/java/io/jmix/core/datastore/DataStoreBeforeSaveCommitEvent.java new file mode 100644 index 0000000000..d8eb9b829f --- /dev/null +++ b/jmix-core/core/src/main/java/io/jmix/core/datastore/DataStoreBeforeSaveCommitEvent.java @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.jmix.core.datastore; + +import io.jmix.core.SaveContext; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public class DataStoreBeforeSaveCommitEvent extends BaseDataStoreEvent { + private static final long serialVersionUID = -6940314788251718595L; + + protected final EventSharedState eventState; + protected final List savedEntities; + protected final List removedEntities; + + public DataStoreBeforeSaveCommitEvent(SaveContext saveContext, Collection savedEntities, + Collection removedEntities, EventSharedState eventState) { + super(saveContext); + this.eventState = eventState; + this.savedEntities = new ArrayList<>(savedEntities); + this.removedEntities = new ArrayList<>(removedEntities); + } + + public SaveContext getSaveContext() { + return (SaveContext) getSource(); + } + + public EventSharedState getEventState() { + return eventState; + } + + public List getSavedEntities() { + return savedEntities; + } + + public List getRemovedEntities() { + return removedEntities; + } + + @Override + public void sendTo(DataStoreEventListener listener) { + listener.beforeSaveCommit(this); + } +} diff --git a/jmix-core/core/src/main/java/io/jmix/core/datastore/DataStoreEventListener.java b/jmix-core/core/src/main/java/io/jmix/core/datastore/DataStoreEventListener.java index e77678286d..1dfc6b6f5b 100644 --- a/jmix-core/core/src/main/java/io/jmix/core/datastore/DataStoreEventListener.java +++ b/jmix-core/core/src/main/java/io/jmix/core/datastore/DataStoreEventListener.java @@ -41,6 +41,9 @@ default void entitySaving(DataStoreEntitySavingEvent event) { default void entityDeleting(DataStoreEntityDeletingEvent event) { } + default void beforeSaveCommit(DataStoreBeforeSaveCommitEvent event) { + } + default void entityReload(DataStoreEntityReloadEvent event) { } diff --git a/jmix-core/core/src/test/java/datastore/AbstractDataStoreSaveLifecycleTest.java b/jmix-core/core/src/test/java/datastore/AbstractDataStoreSaveLifecycleTest.java new file mode 100644 index 0000000000..fed9544afa --- /dev/null +++ b/jmix-core/core/src/test/java/datastore/AbstractDataStoreSaveLifecycleTest.java @@ -0,0 +1,179 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package datastore; + +import io.jmix.core.LoadContext; +import io.jmix.core.SaveContext; +import io.jmix.core.ValueLoadContext; +import io.jmix.core.datastore.AbstractDataStore; +import io.jmix.core.datastore.DataStoreBeforeSaveCommitEvent; +import io.jmix.core.datastore.DataStoreEntityDeletingEvent; +import io.jmix.core.datastore.DataStoreEntitySavingEvent; +import io.jmix.core.datastore.DataStoreEventListener; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +public class AbstractDataStoreSaveLifecycleTest { + + @Test + void testBeforeSaveCommitEventOrderAndDiscardSaved() { + Object entity = new Object(); + SaveContext saveContext = new SaveContext().saving(entity).setDiscardSaved(true); + List eventOrder = new ArrayList<>(); + TestDataStore dataStore = new TestDataStore(entity, eventOrder); + + dataStore.registerInterceptor(new DataStoreEventListener() { + @Override + public void entitySaving(DataStoreEntitySavingEvent event) { + eventOrder.add("entitySaving"); + assertFalse(dataStore.beforeSaveTransactionCommitCalled); + assertFalse(dataStore.commitCalled); + } + + @Override + public void entityDeleting(DataStoreEntityDeletingEvent event) { + eventOrder.add("entityDeleting"); + } + + @Override + public void beforeSaveCommit(DataStoreBeforeSaveCommitEvent event) { + eventOrder.add("beforeSaveCommit"); + assertSame(saveContext, event.getSaveContext()); + assertEquals(List.of(entity), event.getSavedEntities()); + assertTrue(event.getRemovedEntities().isEmpty()); + assertTrue(dataStore.beforeSaveTransactionCommitCalled); + assertFalse(dataStore.commitCalled); + } + }); + + Set result = dataStore.save(saveContext); + + assertTrue(result.isEmpty()); + assertEquals(List.of( + "saveAll", + "entitySaving", + "deleteAll", + "entityDeleting", + "beforeSaveTransactionCommit", + "beforeSaveCommit", + "commit" + ), eventOrder); + assertTrue(dataStore.rollbackCalled); + } + + private static class TestDataStore extends AbstractDataStore { + private final Object savedEntity; + private final List eventOrder; + private boolean beforeSaveTransactionCommitCalled; + private boolean commitCalled; + private boolean rollbackCalled; + + private TestDataStore(Object savedEntity, List eventOrder) { + this.savedEntity = savedEntity; + this.eventOrder = eventOrder; + } + + @Override + protected Object loadOne(LoadContext context) { + return null; + } + + @Override + protected List loadAll(LoadContext context) { + return Collections.emptyList(); + } + + @Override + protected long countAll(LoadContext context) { + return 0; + } + + @Override + protected Set saveAll(SaveContext context) { + eventOrder.add("saveAll"); + return Set.of(savedEntity); + } + + @Override + protected Set deleteAll(SaveContext context) { + eventOrder.add("deleteAll"); + return Collections.emptySet(); + } + + @Override + protected List loadAllValues(ValueLoadContext context) { + return Collections.emptyList(); + } + + @Override + protected long countAllValues(ValueLoadContext context) { + return 0; + } + + @Override + protected Object beginLoadTransaction(boolean joinTransaction) { + return new Transaction(); + } + + @Override + protected Object beginSaveTransaction(boolean joinTransaction) { + return new Transaction(); + } + + @Override + protected void commitTransaction(Object transaction) { + eventOrder.add("commit"); + commitCalled = true; + } + + @Override + protected void rollbackTransaction(Object transaction) { + rollbackCalled = true; + } + + @Override + protected TransactionContextState getTransactionContextState(boolean isJoinTransaction) { + return new TransactionContextState() { + }; + } + + @Override + protected void beforeSaveTransactionCommit(SaveContext context, java.util.Collection savedEntities, + java.util.Collection removedEntities) { + eventOrder.add("beforeSaveTransactionCommit"); + beforeSaveTransactionCommitCalled = true; + } + + @Override + public String getName() { + return "test"; + } + + @Override + public void setName(String name) { + } + + private static class Transaction { + } + } +}