This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 55966ec753 Fix metadata tree full reload on editor dirty, fixes #7791
(#7792)
55966ec753 is described below
commit 55966ec753ac66c799eb8098443c8a8f7660d574
Author: Matt Casters <[email protected]>
AuthorDate: Wed Aug 5 20:25:06 2026 +0200
Fix metadata tree full reload on editor dirty, fixes #7791 (#7792)
Marking a metadata editor dirty no longer reloads the entire metadata
tree or fires MetadataChanged. Tab decoration updates only; the event
fires after a successful save. Tree refresh peeks virtualPath from JSON
instead of fully deserializing every object.
---
.../hop/metadata/api/IHopMetadataSerializer.java | 18 +++++++
.../serializer/json/JsonMetadataSerializer.java | 60 ++++++++++++++++++++++
.../memory/MemoryMetadataSerializer.java | 13 +++++
.../serializer/multi/MultiMetadataSerializer.java | 15 ++++++
.../json/JsonMetadataSerializerTest.java | 45 ++++++++++++++++
.../hop/ui/core/metadata/MetadataEditor.java | 12 ++---
.../perspective/metadata/MetadataPerspective.java | 25 +++++----
7 files changed, 169 insertions(+), 19 deletions(-)
diff --git
a/core/src/main/java/org/apache/hop/metadata/api/IHopMetadataSerializer.java
b/core/src/main/java/org/apache/hop/metadata/api/IHopMetadataSerializer.java
index f8e3560b51..94a7d2068f 100644
--- a/core/src/main/java/org/apache/hop/metadata/api/IHopMetadataSerializer.java
+++ b/core/src/main/java/org/apache/hop/metadata/api/IHopMetadataSerializer.java
@@ -67,6 +67,24 @@ public interface IHopMetadataSerializer<T extends
IHopMetadata> {
*/
List<String> listObjectNames() throws HopException;
+ /**
+ * Read only the virtual path of the named object. Implementations should
prefer a cheap path
+ * (e.g. streaming a single JSON field) over a full {@link #load(String)}
when possible. Used by
+ * the metadata perspective tree to avoid deserializing every object on
refresh.
+ *
+ * @param name The name of the object
+ * @return The virtual path, never {@code null} (empty string when unset)
+ * @throws HopException if the object does not exist or cannot be read
+ */
+ default String readVirtualPath(String name) throws HopException {
+ T object = load(name);
+ if (object == null) {
+ throw new HopException("Object '" + name + "' does not exist");
+ }
+ String virtualPath = object.getVirtualPath();
+ return virtualPath == null ? "" : virtualPath;
+ }
+
/**
* See if an object with the given name exists.
*
diff --git
a/core/src/main/java/org/apache/hop/metadata/serializer/json/JsonMetadataSerializer.java
b/core/src/main/java/org/apache/hop/metadata/serializer/json/JsonMetadataSerializer.java
index a4b95333fd..fde873cf1f 100644
---
a/core/src/main/java/org/apache/hop/metadata/serializer/json/JsonMetadataSerializer.java
+++
b/core/src/main/java/org/apache/hop/metadata/serializer/json/JsonMetadataSerializer.java
@@ -303,4 +303,64 @@ public class JsonMetadataSerializer<T extends
IHopMetadata> implements IHopMetad
}
return HopVfs.fileExists(calculateFilename(name));
}
+
+ /**
+ * Stream-parse the JSON file and extract only the top-level {@code
virtualPath} field without
+ * constructing the managed metadata type. Used by the metadata perspective
tree refresh so large
+ * projects do not fully deserialize every object (issue #7791).
+ */
+ @Override
+ public String readVirtualPath(String name) throws HopException {
+ if (name == null) {
+ throw new HopException("Error: you need to specify the name of the
metadata object to load");
+ }
+ validateBaseFolder(false);
+ if (!baseFolderExists || !exists(name)) {
+ throw new HopException("Object '" + name + "' does not exist");
+ }
+
+ String filename = calculateFilename(name);
+ try (InputStream fileInputStream = HopVfs.getInputStream(filename)) {
+ JsonFactory jsonFactory = new JsonFactory();
+ try (com.fasterxml.jackson.core.JsonParser jsonParser =
+ jsonFactory.createParser(fileInputStream)) {
+ if (jsonParser.nextToken() !=
com.fasterxml.jackson.core.JsonToken.START_OBJECT) {
+ throw new HopException("Expected a JSON object in file '" + filename
+ "'");
+ }
+ while (jsonParser.nextToken() !=
com.fasterxml.jackson.core.JsonToken.END_OBJECT
+ && jsonParser.currentToken() != null) {
+ if (jsonParser.currentToken() !=
com.fasterxml.jackson.core.JsonToken.FIELD_NAME) {
+ continue;
+ }
+ String fieldName = jsonParser.currentName();
+ jsonParser.nextToken(); // move to value
+ if ("virtualPath".equals(fieldName)) {
+ if (jsonParser.currentToken() ==
com.fasterxml.jackson.core.JsonToken.VALUE_NULL) {
+ return "";
+ }
+ if (jsonParser.currentToken() ==
com.fasterxml.jackson.core.JsonToken.VALUE_STRING) {
+ return Const.NVL(jsonParser.getText(), "");
+ }
+ // Unexpected type for virtualPath — treat as empty rather than
failing the tree.
+ jsonParser.skipChildren();
+ return "";
+ }
+ // Skip nested objects/arrays for other fields; scalars are no-ops
for skipChildren.
+ jsonParser.skipChildren();
+ }
+ // Field absent: same as HopMetadataBase default.
+ return "";
+ }
+ } catch (HopException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new HopException(
+ "Error reading virtualPath of metadata object '"
+ + name
+ + "' from file '"
+ + filename
+ + "'",
+ e);
+ }
+ }
}
diff --git
a/core/src/main/java/org/apache/hop/metadata/serializer/memory/MemoryMetadataSerializer.java
b/core/src/main/java/org/apache/hop/metadata/serializer/memory/MemoryMetadataSerializer.java
index 1cb2180af4..e1022a3b9e 100644
---
a/core/src/main/java/org/apache/hop/metadata/serializer/memory/MemoryMetadataSerializer.java
+++
b/core/src/main/java/org/apache/hop/metadata/serializer/memory/MemoryMetadataSerializer.java
@@ -109,6 +109,19 @@ public class MemoryMetadataSerializer<T extends
IHopMetadata> implements IHopMet
return names;
}
+ @Override
+ public String readVirtualPath(String name) throws HopException {
+ if (name == null) {
+ throw new HopException("Error: you need to specify the name of the
metadata object to load");
+ }
+ T object = objectMap.get(name);
+ if (object == null) {
+ throw new HopException("Object '" + name + "' does not exist");
+ }
+ String virtualPath = object.getVirtualPath();
+ return virtualPath == null ? "" : virtualPath;
+ }
+
@Override
public boolean exists(String name) throws HopException {
return objectMap.containsKey(name);
diff --git
a/core/src/main/java/org/apache/hop/metadata/serializer/multi/MultiMetadataSerializer.java
b/core/src/main/java/org/apache/hop/metadata/serializer/multi/MultiMetadataSerializer.java
index 4df9e39339..c2dde189a3 100644
---
a/core/src/main/java/org/apache/hop/metadata/serializer/multi/MultiMetadataSerializer.java
+++
b/core/src/main/java/org/apache/hop/metadata/serializer/multi/MultiMetadataSerializer.java
@@ -141,6 +141,21 @@ public class MultiMetadataSerializer<T extends
IHopMetadata> implements IHopMeta
return new ArrayList<>(set);
}
+ /** Same provider order as {@link #load(String)}: child providers override
parent projects. */
+ @Override
+ public String readVirtualPath(String name) throws HopException {
+ List<IHopMetadataProvider> providers = multiProvider.getProviders();
+ ListIterator<IHopMetadataProvider> providerIterator =
providers.listIterator(providers.size());
+ while (providerIterator.hasPrevious()) {
+ IHopMetadataProvider provider = providerIterator.previous();
+ IHopMetadataSerializer<T> serializer =
provider.getSerializer(managedClass);
+ if (serializer.exists(name)) {
+ return serializer.readVirtualPath(name);
+ }
+ }
+ throw new HopException("Object '" + name + "' does not exist");
+ }
+
@Override
public boolean exists(String name) throws HopException {
for (IHopMetadataProvider provider : multiProvider.getProviders()) {
diff --git
a/core/src/test/java/org/apache/hop/metadata/serializer/json/JsonMetadataSerializerTest.java
b/core/src/test/java/org/apache/hop/metadata/serializer/json/JsonMetadataSerializerTest.java
index a2606d931a..900e25aba6 100644
---
a/core/src/test/java/org/apache/hop/metadata/serializer/json/JsonMetadataSerializerTest.java
+++
b/core/src/test/java/org/apache/hop/metadata/serializer/json/JsonMetadataSerializerTest.java
@@ -138,4 +138,49 @@ class JsonMetadataSerializerTest {
//
assertThrows(HopException.class, () -> personSerializer.load("Gardener"));
}
+
+ /**
+ * Tree refresh only needs name + virtualPath. readVirtualPath must not
fully deserialize and must
+ * return the path (or empty) even for complex objects (issue #7791).
+ */
+ @Test
+ void testReadVirtualPath(@TempDir Path folder) throws Exception {
+ JsonMetadataProvider provider =
+ new JsonMetadataProvider(
+ new HopTwoWayPasswordEncoder(),
+ folder.toString(),
+ Variables.getADefaultVariableSpace());
+
+ Occupation occupation = new Occupation("Occupation1", "Description of
occupation1", 2001);
+ occupation.setVirtualPath("jobs/tech");
+ provider.getSerializer(Occupation.class).save(occupation);
+
+ IHopMetadataSerializer<Occupation> serializer =
provider.getSerializer(Occupation.class);
+ assertEquals("jobs/tech", serializer.readVirtualPath("Occupation1"));
+
+ // Field absent → empty string (HopMetadataBase default).
+ Occupation noPath = new Occupation("NoPath", "desc", 2002);
+ serializer.save(noPath);
+ assertEquals("", serializer.readVirtualPath("NoPath"));
+
+ assertThrows(HopException.class, () ->
serializer.readVirtualPath("Missing"));
+ }
+
+ @Test
+ void testReadVirtualPathCorruptFile(@TempDir Path folder) throws Exception {
+ JsonMetadataProvider provider =
+ new JsonMetadataProvider(
+ new HopTwoWayPasswordEncoder(),
+ folder.toString(),
+ Variables.getADefaultVariableSpace());
+
+ IHopMetadataSerializer<Occupation> serializer =
provider.getSerializer(Occupation.class);
+ Occupation occupation = new Occupation("Broken", "desc", 2001);
+ serializer.save(occupation);
+
+ Path jsonFile = folder.resolve("occupation").resolve("Broken.json");
+ Files.write(jsonFile, "not-valid-json{".getBytes(UTF_8));
+
+ assertThrows(HopException.class, () ->
serializer.readVirtualPath("Broken"));
+ }
}
diff --git
a/ui/src/main/java/org/apache/hop/ui/core/metadata/MetadataEditor.java
b/ui/src/main/java/org/apache/hop/ui/core/metadata/MetadataEditor.java
index 9d8017ca52..5a06c165da 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/metadata/MetadataEditor.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/metadata/MetadataEditor.java
@@ -24,7 +24,6 @@ import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
import org.apache.hop.core.Const;
import org.apache.hop.core.exception.HopException;
-import org.apache.hop.core.exception.HopRuntimeException;
import org.apache.hop.core.extension.ExtensionPointHandler;
import org.apache.hop.core.extension.HopExtensionPoint;
import org.apache.hop.core.plugins.IPlugin;
@@ -198,12 +197,9 @@ public abstract class MetadataEditor<T extends
IHopMetadata> extends MetadataFil
public void setChanged() {
if (!this.isChanged) {
this.isChanged = true;
+ // Update tab decoration and toolbar only. Do not fire MetadataChanged
and do not reload
+ // the metadata tree: dirty is not a persisted store change (see issue
#7791).
MetadataPerspective.getInstance().updateEditor(this);
- try {
- hopGui.getEventsHandler().fire(HopGuiEvents.MetadataChanged.name());
- } catch (HopException e) {
- throw new HopRuntimeException(e);
- }
}
}
@@ -335,6 +331,10 @@ public abstract class MetadataEditor<T extends
IHopMetadata> extends MetadataFil
}
MetadataPerspective.getInstance().updateEditor(this);
+
+ // Notify the GUI that the metadata store changed (tree refresh, plugins).
Fire only after a
+ // successful persist — not when the editor is merely marked dirty (issue
#7791).
+ hopGui.getEventsHandler().fire(HopGuiEvents.MetadataChanged.name());
}
@Override
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
index a07e57e31d..d52ba3c69a 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
@@ -742,8 +742,8 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
hopGui.getVariables(),
HopExtensionPoint.HopGuiMetadataObjectUpdated.id,
metadata);
+ // Listener on MetadataChanged refreshes the tree once.
hopGui.getEventsHandler().fire(HopGuiEvents.MetadataChanged.name());
- refresh();
} catch (Exception e) {
new ErrorDialog(
getShell(),
@@ -760,7 +760,7 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
try {
MetadataManager<IHopMetadata> manager = getMetadataManager(objectKey);
manager.editWithEditor(name);
- hopGui.getEventsHandler().fire(HopGuiEvents.MetadataChanged.name());
+ // Opening an editor is not a store change — do not fire MetadataChanged
(issue #7791).
} catch (Exception e) {
new ErrorDialog(
getShell(),
@@ -1331,8 +1331,7 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
try {
MetadataManager<IHopMetadata> manager =
getMetadataManager(objectKey);
manager.editWithEditor(objectName);
-
- hopGui.getEventsHandler().fire(HopGuiEvents.MetadataChanged.name());
+ // Opening an editor is not a store change — do not fire
MetadataChanged (issue #7791).
} catch (Exception e) {
new ErrorDialog(getShell(), ERROR, "Error editing metadata", e);
}
@@ -2286,7 +2285,9 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
if (editor == null || !isInitialized()) return;
- // Update TabItem
+ // Update TabItem title and dirty font only. Do not reload the metadata
tree here:
+ // that is expensive on large projects and dirty ≠ a persisted store
change (issue #7791).
+ // Tree content is refreshed via MetadataChanged after
save/delete/rename/etc.
//
for (CTabItem item : tabFolder.getItems()) {
if (editor.equals(item.getData())) {
@@ -2297,10 +2298,6 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
}
}
- // Update TreeItem
- //
- this.refresh();
-
// Update HOP GUI menu and toolbar...
//
this.updateGui();
@@ -2384,9 +2381,9 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
/**
* Loads the metadata model from disk into memory: one {@link
MetadataTypeModel} per metadata type
- * with its items (name + virtual path). This is the only place that reads
metadata objects from
- * the provider, so search filtering (handled entirely by {@link
#renderTree()}) never touches
- * disk.
+ * with its items (name + virtual path via {@link
IHopMetadataSerializer#readVirtualPath(String)},
+ * not a full object load). Search filtering (handled entirely by {@link
#renderTree()}) never
+ * touches disk.
*/
private void reloadModel() {
typeModels.clear();
@@ -2419,7 +2416,9 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
for (String name : names) {
String virtualPath;
try {
- virtualPath = Const.NVL(serializer.load(name).getVirtualPath(),
"");
+ // Cheap path: name + virtualPath only (JSON peeks a single
field). Avoid full
+ // serializer.load() for every object on each tree refresh (issue
#7791).
+ virtualPath = Const.NVL(serializer.readVirtualPath(name), "");
} catch (Exception e) {
// We can't load this one: a missing plugin, corrupt JSON, ... .
Rather than hiding it,
// we list it under the "Unknown" category where it can be
inspected and deleted.