This is an automated email from the ASF dual-hosted git repository.
mattcasters 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 2dfd972c4e harden project delete/rename, fixes #4275 (#8583)
2dfd972c4e is described below
commit 2dfd972c4eed92329aef6772d272b89b79f88160
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Sat Sep 26 14:29:45 2026 +0200
harden project delete/rename, fixes #4275 (#8583)
* harden project delete/rename, fixes #4275
* fix remarks and code hardening
---
.../apache/hop/projects/config/ProjectsConfig.java | 56 ++-
.../gui/CloneFromVersionControlDialog.java | 2 +-
.../hop/projects/gui/ProjectConfigDialog.java | 29 +-
.../apache/hop/projects/gui/ProjectsGuiPlugin.java | 27 +-
.../hop/projects/gui/SelectProjectsDialog.java | 8 +-
.../project/ManageProjectsOptionPlugin.java | 10 +-
.../org/apache/hop/projects/project/Project.java | 4 +-
.../apache/hop/projects/project/ProjectDialog.java | 78 +++--
.../util/ProjectRenameBlockedException.java | 43 +++
.../org/apache/hop/projects/util/ProjectsUtil.java | 307 ++++++++++++++---
.../hop/projects/xp/HopGuiStartProjectLoad.java | 5 +-
.../project/messages/messages_en_US.properties | 9 +
.../projects/project/ProjectReferencesTest.java | 375 +++++++++++++++++++++
13 files changed, 846 insertions(+), 107 deletions(-)
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsConfig.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsConfig.java
index 1d2d53899f..2ec1ed084f 100644
---
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsConfig.java
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsConfig.java
@@ -100,7 +100,7 @@ public class ProjectsConfig {
List<LifecycleEnvironment> list = new ArrayList<>();
lifecycleEnvironments.forEach(
e -> {
- if (e.getProjectName().equals(projectName)) {
+ if (projectName != null &&
projectName.equalsIgnoreCase(e.getProjectName())) {
list.add(e);
}
});
@@ -128,8 +128,13 @@ public class ProjectsConfig {
* @param projectConfig updated registration (may have a new projectName)
*/
public void updateProjectConfig(String originalName, ProjectConfig
projectConfig) {
- if (StringUtils.isEmpty(originalName)
- || originalName.equalsIgnoreCase(projectConfig.getProjectName())) {
+ if (StringUtils.isEmpty(originalName)) {
+ addProjectConfig(projectConfig);
+ return;
+ }
+ renameProjectReferences(originalName, projectConfig.getProjectName());
+ if (originalName.equalsIgnoreCase(projectConfig.getProjectName())) {
+ // Same registration, possibly with a different case
addProjectConfig(projectConfig);
return;
}
@@ -168,15 +173,60 @@ public class ProjectsConfig {
new ProjectConfig(projectName, null, null)); // Only considers the name
}
+ /**
+ * Remove a project registration. The default project and standard parent
project settings are
+ * cleared when they point to the removed project, so they never name a
project that doesn't
+ * exist.
+ *
+ * @param projectName the name of the project to remove
+ * @return the removed project registration or null if it wasn't found
+ */
public ProjectConfig removeProjectConfig(String projectName) {
int index = indexOfProjectConfig(projectName);
if (index >= 0) {
+ renameProjectReferences(projectName, null);
return projectConfigurations.remove(index);
} else {
return null;
}
}
+ /**
+ * Point the default project, standard parent project and lifecycle
environments to a renamed
+ * project. When the project is removed (newName is null) the default and
standard parent project
+ * settings are cleared but the environments are left alone: an environment
without a project is
+ * available for every project.
+ *
+ * @param oldName the previous name of the project
+ * @param newName the new name of the project, null when the project is
removed
+ */
+ public void renameProjectReferences(String oldName, String newName) {
+ if (StringUtils.isEmpty(oldName)) {
+ return;
+ }
+ if (oldName.equalsIgnoreCase(defaultProject)) {
+ defaultProject = newName;
+ }
+ if (oldName.equalsIgnoreCase(standardParentProject)) {
+ standardParentProject = newName;
+ }
+ if (StringUtils.isNotEmpty(newName)) {
+ for (LifecycleEnvironment environment : lifecycleEnvironments) {
+ if (oldName.equalsIgnoreCase(environment.getProjectName())) {
+ environment.setProjectName(newName);
+ }
+ }
+ }
+ }
+
+ /**
+ * @return the standard parent project for new projects or null if no
project with that name is
+ * registered
+ */
+ public String findRegisteredStandardParentProject() {
+ return findProjectConfig(standardParentProject) == null ? null :
standardParentProject;
+ }
+
public List<String> listProjectConfigNames() {
List<String> names = new ArrayList<>();
projectConfigurations.forEach(config ->
names.add(config.getProjectName()));
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/CloneFromVersionControlDialog.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/CloneFromVersionControlDialog.java
index a92ec921a9..0cb71ee43f 100644
---
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/CloneFromVersionControlDialog.java
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/CloneFromVersionControlDialog.java
@@ -319,7 +319,7 @@ public class CloneFromVersionControlDialog extends Dialog {
String defaultConfigFile =
variables.resolve(config.getDefaultProjectConfigFile());
ProjectConfig projectConfig = new ProjectConfig(projectName, clonePath,
defaultConfigFile);
Project project = new Project();
- project.setParentProjectName(config.getStandardParentProject());
+
project.setParentProjectName(config.findRegisteredStandardParentProject());
String configFilename =
projectConfig.getActualProjectConfigFilename(variables);
FileObject configFile = HopVfs.getFileObject(configFilename);
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectConfigDialog.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectConfigDialog.java
index 848c4c30d2..6b3b9f69e1 100644
---
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectConfigDialog.java
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectConfigDialog.java
@@ -20,11 +20,15 @@ package org.apache.hop.projects.gui;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import org.apache.hop.core.Const;
+import org.apache.hop.core.logging.LogChannel;
import org.apache.hop.core.variables.IVariables;
import org.apache.hop.i18n.BaseMessages;
import org.apache.hop.projects.config.ProjectsConfig;
import org.apache.hop.projects.config.ProjectsConfigSingleton;
+import org.apache.hop.projects.project.Project;
import org.apache.hop.projects.project.ProjectConfig;
+import org.apache.hop.projects.util.ProjectRenameBlockedException;
+import org.apache.hop.projects.util.ProjectsUtil;
import org.apache.hop.ui.core.ConstUi;
import org.apache.hop.ui.core.PropsUi;
import org.apache.hop.ui.core.dialog.BaseDialog;
@@ -43,6 +47,7 @@ import org.eclipse.swt.widgets.Button;
import org.eclipse.swt.widgets.Control;
import org.eclipse.swt.widgets.Dialog;
import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.MessageBox;
import org.eclipse.swt.widgets.Shell;
import org.eclipse.swt.widgets.Text;
@@ -264,11 +269,15 @@ public class ProjectConfigDialog extends Dialog {
ProjectsConfig config = ProjectsConfigSingleton.getConfig();
if (!name.equals(originalName)) {
+ // Project names are unique regardless of case, a case-only rename is
fine
ProjectConfig clash = config.findProjectConfig(name);
- if (clash != null) {
+ if (clash != null &&
!clash.getProjectName().equalsIgnoreCase(originalName)) {
throw new IllegalArgumentException(
BaseMessages.getString(PKG,
"ProjectConfigDialog.Error.NameExists", name));
}
+ if (StringUtils.isNotEmpty(originalName) && !checkRename(name)) {
+ return;
+ }
}
projectConfig.setProjectName(name);
@@ -289,6 +298,24 @@ public class ProjectConfigDialog extends Dialog {
}
}
+ /**
+ * Verify that the projects using this one as their parent can follow the
rename.
+ *
+ * @return false when the rename is blocked, the reason was shown to the user
+ */
+ private boolean checkRename(String newName) {
+ try {
+ ProjectsUtil.checkProjectRename(originalName, newName, variables,
LogChannel.UI);
+ return true;
+ } catch (ProjectRenameBlockedException e) {
+ MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_ERROR);
+ box.setText(BaseMessages.getString(Project.class,
"ProjectRename.Blocked.Header"));
+ box.setMessage(e.getUserMessage());
+ box.open();
+ return false;
+ }
+ }
+
private void cancel() {
ok = false;
dispose();
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
index 01a0278824..350a41848c 100644
---
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
@@ -720,15 +720,16 @@ public class ProjectsGuiPlugin {
new ProjectDialog(
hopGui.getActiveShell(), project, projectConfig,
hopGui.getVariables(), true);
if (projectDialog.open() != null) {
- config.addProjectConfig(projectConfig);
-
- if (!projectName.equals(projectConfig.getProjectName())) {
- // Project got renamed
- projectName = projectConfig.getProjectName();
- }
-
- // Persist project registration (name, home, config path, read-only)
in hop-config.json
- HopConfig.getInstance().saveToFile();
+ // Persist project registration (name, home, config path, read-only)
in hop-config.json.
+ // A rename also updates the projects using this one as their parent,
all or nothing.
+ //
+ ProjectsUtil.saveProjectConfig(
+ projectName,
+ projectConfig,
+ hopGui.getVariables(),
+ hopGui.getLog(),
+ ProjectsConfigSingleton::saveConfig);
+ projectName = projectConfig.getProjectName();
// Do not write project-config.json for read-only projects (archives,
HTTP, ...).
//
@@ -887,7 +888,7 @@ public class ProjectsGuiPlugin {
MenuItem item = new MenuItem(menu, SWT.NONE);
item.setText(name);
item.addListener(SWT.Selection, e -> selectProject(name));
- if (currentProjectName.equals(name)) {
+ if (currentProjectName.equalsIgnoreCase(name)) {
item.setImage(GuiResource.getInstance().getImageCheck());
}
if (++count == LAST_USED_PROJECTS_MAX_ENTRIES) break;
@@ -943,7 +944,7 @@ public class ProjectsGuiPlugin {
LifecycleEnvironment environment = config.findEnvironment(name);
if (environment != null
&& (Utils.isEmpty(environment.getProjectName())
- || currentProjectName.equals(environment.getProjectName()))) {
+ ||
currentProjectName.equalsIgnoreCase(environment.getProjectName()))) {
// Create a final copy of the name variable for the lambda closure
// This is critical for RAP/web compatibility - each menu item needs
its own copy
final String environmentName = name;
@@ -1023,7 +1024,7 @@ public class ProjectsGuiPlugin {
if (environment != null) {
// See that the project belongs to the environment
//
- if (projectName.equals(environment.getProjectName())) {
+ if (projectName.equalsIgnoreCase(environment.getProjectName())) {
// We found what we've been looking for
break;
} else {
@@ -1168,7 +1169,7 @@ public class ProjectsGuiPlugin {
new ProjectConfig("", standardProjectsFolder,
defaultProjectConfigFilename);
Project project = new Project();
- project.setParentProjectName(config.getStandardParentProject());
+
project.setParentProjectName(config.findRegisteredStandardParentProject());
ProjectDialog projectDialog =
new ProjectDialog(hopGui.getActiveShell(), project, projectConfig,
variables, false);
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/SelectProjectsDialog.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/SelectProjectsDialog.java
index e37fcbe1b0..ba8059819c 100644
---
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/SelectProjectsDialog.java
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/SelectProjectsDialog.java
@@ -350,8 +350,12 @@ public class SelectProjectsDialog extends Dialog {
ProjectConfigDialog editDialog = new ProjectConfigDialog(shell,
projectConfig, variables);
if (editDialog.open()) {
try {
- config.updateProjectConfig(editDialog.getOriginalName(),
projectConfig);
- ProjectsConfigSingleton.saveConfig();
+ ProjectsUtil.saveProjectConfig(
+ editDialog.getOriginalName(),
+ projectConfig,
+ variables,
+ LogChannel.UI,
+ ProjectsConfigSingleton::saveConfig);
refreshTable();
selectProjectByName(projectConfig.getProjectName());
updateButtons();
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ManageProjectsOptionPlugin.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ManageProjectsOptionPlugin.java
index c3ce448dd8..161a61bb0b 100644
---
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ManageProjectsOptionPlugin.java
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ManageProjectsOptionPlugin.java
@@ -252,6 +252,14 @@ public class ManageProjectsOptionPlugin implements
IConfigOptions {
if (projectConfig == null) {
throw new HopException(CONST_PROJECT + projectName + "' doesn't exist,
it can't be deleted");
}
+ List<String> references =
ProjectsUtil.getParentProjectReferences(projectName, variables, log);
+ if (!references.isEmpty()) {
+ throw new HopException(
+ CONST_PROJECT
+ + projectName
+ + "' can't be deleted, it is the parent project of: "
+ + String.join(", ", references));
+ }
config.removeProjectConfig(projectName);
ProjectsConfigSingleton.saveConfig();
@@ -388,7 +396,7 @@ public class ManageProjectsOptionPlugin implements
IConfigOptions {
// --project-parent still wins via modifyProjectSettings below.
//
if (StringUtils.isEmpty(project.getParentProjectName())) {
- project.setParentProjectName(config.getStandardParentProject());
+
project.setParentProjectName(config.findRegisteredStandardParentProject());
}
modifyProjectSettings(project);
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/Project.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/Project.java
index 00f1010dc8..c04ab290cf 100644
---
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/Project.java
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/Project.java
@@ -344,7 +344,7 @@ public class Project extends ConfigFile implements
IConfigFile {
return;
}
- if (parentProjectName.equals(projectName)) {
+ if (parentProjectName.equalsIgnoreCase(projectName)) {
throw new HopException(
"Parent project '" + parentProjectName + "' can not be the same as
the project itself");
}
@@ -381,7 +381,7 @@ public class Project extends ConfigFile implements
IConfigFile {
realParentProjectName =
variables.resolve(parentProject.parentProjectName);
// See if we've had this one before...
if (StringUtils.isNotEmpty(realParentProjectName)
- && projectsList.contains(realParentProjectName)) {
+ &&
projectsList.stream().anyMatch(realParentProjectName::equalsIgnoreCase)) {
throw new HopException(
"There is a loop in the parent projects hierarchy: project "
+ realParentProjectName
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
index 17abc6b8ea..c8d0795079 100644
---
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectDialog.java
@@ -37,6 +37,7 @@ import org.apache.hop.projects.config.ProjectsConfig;
import org.apache.hop.projects.config.ProjectsConfigSingleton;
import org.apache.hop.projects.gui.ProjectsGuiPlugin;
import org.apache.hop.projects.util.Defaults;
+import org.apache.hop.projects.util.ProjectRenameBlockedException;
import org.apache.hop.projects.util.ProjectsUtil;
import org.apache.hop.ui.core.ConstUi;
import org.apache.hop.ui.core.PropsUi;
@@ -842,38 +843,53 @@ public class ProjectDialog extends Dialog {
if (wParentProject.getText() != null
&& !wParentProject.getText().isEmpty()
- && projectName.equals(wParentProject.getText())) {
+ && projectName.equalsIgnoreCase(wParentProject.getText())) {
throw new HopException(
CONST_PROJECT + projectName + "' cannot be set as a parent project
of itself");
}
+ // Project names are unique regardless of case, a case-only rename is
fine
+ //
ProjectsConfig prjsCfg = ProjectsConfigSingleton.getConfig();
- List<String> prjs = prjsCfg.listProjectConfigNames();
-
- if (StringUtils.isEmpty(oriProjectName)
- || (StringUtils.isNotEmpty(oriProjectName) &&
!projectName.equals(oriProjectName))) {
- for (String prj : prjs) {
- if (projectName.equals(prj)) {
- throw new HopException(
- CONST_PROJECT + projectName + "' already exists. Project name
must be unique!");
- }
- }
+ ProjectConfig sameName = prjsCfg.findProjectConfig(projectName);
+ if (sameName != null
+ && (StringUtils.isEmpty(oriProjectName)
+ || !sameName.getProjectName().equalsIgnoreCase(oriProjectName)))
{
+ throw new HopException(
+ CONST_PROJECT
+ + projectName
+ + "' already exists as '"
+ + sameName.getProjectName()
+ + "'. Project names must be unique, regardless of case!");
}
HopGui hopGui = HopGui.getInstance();
- if (!Utils.isEmpty(wParentProject.getText())) {
-
- boolean parentPrjExists =
ProjectsUtil.projectExists(wParentProject.getText());
- if (!parentPrjExists)
- throw new HopException(
- CONST_PROJECT
- + wParentProject.getText()
- + "' cannot be set as parent project because it does not
exists!");
+ if (!Utils.isEmpty(wParentProject.getText())
+ && !ProjectsUtil.projectExists(wParentProject.getText())) {
+ // The parent project was deleted or renamed: offer to drop the
reference
+ //
+ MessageBox box = new MessageBox(shell, SWT.YES | SWT.NO |
SWT.ICON_WARNING);
+ box.setText(
+ BaseMessages.getString(PKG,
"ProjectDialog.MissingParentProject.Dialog.Header"));
+ box.setMessage(
+ BaseMessages.getString(
+ PKG,
+ "ProjectDialog.MissingParentProject.Dialog.Message",
+ wParentProject.getText()));
+ if (box.open() != SWT.YES) {
+ wParentProject.setFocus();
+ return;
+ }
+ wParentProject.setText("");
+ }
+ if (!Utils.isEmpty(wParentProject.getText())) {
ProjectConfig parentPrjCfg =
prjsCfg.findProjectConfig(wParentProject.getText());
Project parentPrj = parentPrjCfg.loadProject(hopGui.getVariables());
- if (parentPrj.getParentProjectName() != null
- && parentPrj.getParentProjectName().equals(projectName))
+ String grandParentName = parentPrj.getParentProjectName();
+ if (grandParentName != null
+ && (grandParentName.equalsIgnoreCase(projectName)
+ || grandParentName.equalsIgnoreCase(oriProjectName)))
throw new HopException(
CONST_PROJECT
+ projectName
@@ -894,14 +910,24 @@ public class ProjectDialog extends Dialog {
int anwser = box.open();
if ((anwser & SWT.NO) != 0) {
wName.setText(oriProjectName);
+ projectName = oriProjectName;
}
}
- if (!oriProjectName.equals(projectName)) {
- List<String> refs =
ProjectsUtil.getParentProjectReferences(oriProjectName);
-
- if (!refs.isEmpty()) {
- ProjectsUtil.changeParentProjectReferences(oriProjectName,
projectName);
+ // Verify that the projects using this one as their parent can follow
the rename. The
+ // rename itself is saved by the caller, all or nothing, once the dialog
is closed.
+ //
+ if (this.editMode
+ && StringUtils.isNotEmpty(oriProjectName)
+ && !oriProjectName.equals(projectName)) {
+ try {
+ ProjectsUtil.checkProjectRename(oriProjectName, projectName,
variables, hopGui.getLog());
+ } catch (ProjectRenameBlockedException e) {
+ MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_ERROR);
+ box.setText(BaseMessages.getString(PKG,
"ProjectRename.Blocked.Header"));
+ box.setMessage(e.getUserMessage());
+ box.open();
+ return;
}
}
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectRenameBlockedException.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectRenameBlockedException.java
new file mode 100644
index 0000000000..91f21019de
--- /dev/null
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectRenameBlockedException.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.hop.projects.util;
+
+import java.util.List;
+import lombok.Getter;
+import org.apache.hop.core.exception.HopException;
+
+/**
+ * A project can't be renamed because the parent project reference in one or
more other projects
+ * can't be updated. Nothing was changed when this is thrown. The message
names every blocking
+ * project and the reason, so it can be shown to the user as is.
+ */
+@Getter
+public class ProjectRenameBlockedException extends HopException {
+
+ /** The names of the projects which block the rename */
+ private final List<String> blockingProjects;
+
+ /** The user facing explanation, without the decoration {@link HopException}
adds */
+ private final String userMessage;
+
+ public ProjectRenameBlockedException(String userMessage, List<String>
blockingProjects) {
+ super(userMessage);
+ this.userMessage = userMessage;
+ this.blockingProjects = List.copyOf(blockingProjects);
+ }
+}
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsUtil.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsUtil.java
index 8132cfe2f0..2d68d60181 100644
---
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsUtil.java
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsUtil.java
@@ -18,9 +18,9 @@
package org.apache.hop.projects.util;
import java.util.ArrayList;
-import java.util.Iterator;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.commons.vfs2.FileObject;
import org.apache.commons.vfs2.FileSystemException;
import org.apache.hop.core.AttributesContext;
@@ -34,6 +34,7 @@ import org.apache.hop.core.logging.ILogChannel;
import org.apache.hop.core.variables.IVariables;
import org.apache.hop.core.vfs.HopVfs;
import org.apache.hop.history.AuditManager;
+import org.apache.hop.i18n.BaseMessages;
import org.apache.hop.metadata.api.IHasHopMetadataProvider;
import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
import org.apache.hop.metadata.util.HopMetadataInstance;
@@ -49,6 +50,8 @@ import org.apache.hop.ui.hopgui.HopGui;
public class ProjectsUtil {
+ private static final Class<?> PKG = Project.class;
+
public static final String VARIABLE_PROJECT_HOME = "PROJECT_HOME";
public static final String VARIABLE_PARENT_PROJECT_HOME =
"PARENT_PROJECT_HOME";
public static final String VARIABLE_PARENT_PROJECT_NAME =
"PARENT_PROJECT_NAME";
@@ -338,79 +341,271 @@ public class ProjectsUtil {
* @return
*/
public static boolean projectExists(String projectName) {
+ return ProjectsConfigSingleton.getConfig().findProjectConfig(projectName)
!= null;
+ }
- boolean prjFound = false;
+ /**
+ * Find the registered projects which have the given project as their parent
project.
+ *
+ * @param projectName the name of the parent project
+ * @return the names of the child projects
+ */
+ public static List<String> getParentProjectReferences(String projectName)
throws HopException {
+ HopGui hopGui = HopGui.getInstance();
+ return getParentProjectReferences(projectName, hopGui.getVariables(),
hopGui.getLog());
+ }
+ /**
+ * Find the registered projects which have the given project as their parent
project. Projects
+ * which can't be loaded are logged and skipped.
+ *
+ * @param projectName the name of the parent project
+ * @param variables the variables to resolve the project locations with
+ * @param log the log channel to report projects which can't be loaded
+ * @return the names of the child projects
+ */
+ public static List<String> getParentProjectReferences(
+ String projectName, IVariables variables, ILogChannel log) {
+ List<String> references = new ArrayList<>();
+ if (StringUtils.isEmpty(projectName)) {
+ return references;
+ }
ProjectsConfig config = ProjectsConfigSingleton.getConfig();
- List<String> prjs = config.listProjectConfigNames();
- Iterator<String> iPrj = prjs.iterator();
-
- while (!prjFound && iPrj.hasNext()) {
- String p = iPrj.next();
- prjFound = p.equals(projectName);
+ for (String name : config.listProjectConfigNames()) {
+ if (name.equalsIgnoreCase(projectName)) {
+ continue;
+ }
+ Project project = loadProject(config.findProjectConfig(name), variables,
log);
+ if (project != null &&
projectName.equalsIgnoreCase(project.getParentProjectName())) {
+ references.add(name);
+ }
}
+ return references;
+ }
- return prjFound;
+ /** Saves the projects configuration in hop-config.json after a project
registration changed. */
+ @FunctionalInterface
+ public interface IProjectsConfigSaver {
+ void save() throws HopException;
}
- public static List<String> getParentProjectReferences(String projectName)
throws HopException {
+ /** A project which uses the renamed project as its parent project. */
+ private record ChildProject(String name, Project project) {}
+
+ /**
+ * Verify that a project can be renamed. Every project which uses it as its
parent project must be
+ * writable, and every registered project must be readable to know whether
it's one of them.
+ * Nothing is changed. Projects with a home folder which doesn't exist are
skipped: they have no
+ * configuration to update.
+ *
+ * @param currentName the current name of the project
+ * @param newName the new name of the project
+ * @param variables the variables to resolve the project locations with
+ * @param log the log channel
+ * @return the names of the projects which will get the new parent project
name
+ * @throws ProjectRenameBlockedException naming every project which blocks
the rename and why
+ */
+ public static List<String> checkProjectRename(
+ String currentName, String newName, IVariables variables, ILogChannel
log)
+ throws ProjectRenameBlockedException {
+ return findChildProjects(currentName, newName, variables, log).stream()
+ .map(ChildProject::name)
+ .toList();
+ }
+ /**
+ * Save a project registration, renaming the project when its name changed.
A rename is all or
+ * nothing: the default project, standard parent project and lifecycle
environments follow the new
+ * name, the registration is saved and then the projects which use it as
their parent project are
+ * updated and saved. The registration is saved first so that no project
ever points to an
+ * unregistered parent project. If a project can't be saved, everything is
restored to the
+ * previous name.
+ *
+ * @param currentName the name the project is registered with
+ * @param projectConfig the updated registration, possibly with a new name.
This can be the
+ * registered instance itself.
+ * @param variables the variables to resolve the project locations with
+ * @param log the log channel
+ * @param saver saves the projects configuration
+ * @return the names of the projects which now use the new name as their
parent project
+ * @throws ProjectRenameBlockedException when the rename can't be done,
nothing was changed
+ * @throws HopException when saving failed, the rename was undone
+ */
+ public static List<String> saveProjectConfig(
+ String currentName,
+ ProjectConfig projectConfig,
+ IVariables variables,
+ ILogChannel log,
+ IProjectsConfigSaver saver)
+ throws HopException {
ProjectsConfig config = ProjectsConfigSingleton.getConfig();
- List<String> prjs = config.listProjectConfigNames();
+ String newName = projectConfig.getProjectName();
+ if (StringUtils.isEmpty(currentName) || currentName.equals(newName)) {
+ config.updateProjectConfig(currentName, projectConfig);
+ saver.save();
+ return List.of();
+ }
- HopGui hopGui = HopGui.getInstance();
- List<String> parentProjectReferences = new ArrayList<>();
- ProjectConfig currentProjectConfig = config.findProjectConfig(projectName);
-
- if (currentProjectConfig == null) {
- parentProjectReferences = List.of();
- } else {
- for (String prj : prjs) {
- if (!prj.equals(projectName)) {
- ProjectConfig prjCfg = config.findProjectConfig(prj);
- Project thePrj = prjCfg.loadProject(hopGui.getVariables());
- if (thePrj != null) {
- if (thePrj.getParentProjectName() != null
- && thePrj.getParentProjectName().equals(projectName)) {
- parentProjectReferences.add(prj);
- }
- } else {
- hopGui.getLog().logError("Unable to load project '" + prj + "'
from its configuration");
- }
+ List<ChildProject> children;
+ try {
+ children = findChildProjects(currentName, newName, variables, log);
+ } catch (ProjectRenameBlockedException e) {
+ projectConfig.setProjectName(currentName);
+ throw e;
+ }
+
+ config.updateProjectConfig(currentName, projectConfig);
+ try {
+ saver.save();
+ } catch (HopException e) {
+ revertRename(config, projectConfig, currentName, newName);
+ throw e;
+ }
+
+ List<ChildProject> updated = new ArrayList<>();
+ for (ChildProject child : children) {
+ try {
+ child.project().setParentProjectName(newName);
+ child.project().saveToFile();
+ updated.add(child);
+ } catch (Exception e) {
+ // The failed save may have left a partial file behind: write the old
name back if we can
+ restoreParentProjectName(List.of(child), currentName, log);
+ List<String> notRestored = restoreParentProjectName(updated,
currentName, log);
+ revertRename(config, projectConfig, currentName, newName);
+ try {
+ saver.save();
+ } catch (HopException saveException) {
+ log.logError(
+ "Unable to save the restored registration of project '" +
currentName + "'",
+ saveException);
}
+ String message =
+ notRestored.isEmpty()
+ ? BaseMessages.getString(
+ PKG, "ProjectRename.SaveFailed.Message", currentName,
newName, child.name())
+ : BaseMessages.getString(
+ PKG,
+ "ProjectRename.SaveFailed.NotRestored",
+ currentName,
+ newName,
+ child.name(),
+ String.join(", ", notRestored));
+ throw new HopException(message, e);
}
}
- return parentProjectReferences;
+ return updated.stream().map(ChildProject::name).toList();
}
- public static List<String> changeParentProjectReferences(String currentName,
String newName)
- throws HopException {
-
+ private static List<ChildProject> findChildProjects(
+ String currentName, String newName, IVariables variables, ILogChannel
log)
+ throws ProjectRenameBlockedException {
ProjectsConfig config = ProjectsConfigSingleton.getConfig();
- List<String> prjs = config.listProjectConfigNames();
-
- HopGui hopGui = HopGui.getInstance();
- List<String> parentProjectReferences = new ArrayList<>();
- ProjectConfig currentProjectConfig = config.findProjectConfig(currentName);
-
- if (currentProjectConfig == null) {
- parentProjectReferences = List.of();
- } else {
- for (String prj : prjs) {
- if (!prj.equals(currentName)) {
- ProjectConfig prjCfg = config.findProjectConfig(prj);
- Project thePrj = prjCfg.loadProject(hopGui.getVariables());
- if (thePrj != null) {
- if (thePrj.getParentProjectName() != null
- && thePrj.getParentProjectName().equals(currentName)) {
- thePrj.setParentProjectName(newName);
- }
- } else {
- hopGui.getLog().logError("Unable to load project '" + prj + "'
from its configuration");
- }
+ List<ChildProject> children = new ArrayList<>();
+ List<String> blocking = new ArrayList<>();
+ List<String> reasons = new ArrayList<>();
+ for (String name : config.listProjectConfigNames()) {
+ if (name.equalsIgnoreCase(currentName)) {
+ continue;
+ }
+ ProjectConfig projectConfig = config.findProjectConfig(name);
+ String home = variables.resolve(projectConfig.getProjectHome());
+ Project project;
+ try {
+ if (StringUtils.isEmpty(home) || !HopVfs.getFileObject(home).exists())
{
+ log.logDetailed(
+ "Project '" + name + "' is skipped, its home folder '" + home +
"' doesn't exist");
+ continue;
}
+ project = projectConfig.loadProject(variables);
+ } catch (Exception e) {
+ blocking.add(name);
+ reasons.add(
+ BaseMessages.getString(
+ PKG, "ProjectRename.Blocked.Unreadable", name, currentName,
describeCause(e)));
+ continue;
+ }
+ if (!currentName.equalsIgnoreCase(project.getParentProjectName())) {
+ continue;
}
+ if (projectConfig.isReadOnly()) {
+ blocking.add(name);
+ reasons.add(
+ BaseMessages.getString(PKG, "ProjectRename.Blocked.ReadOnly",
name, currentName));
+ } else if (ProjectConfig.isArchiveUri(home)) {
+ blocking.add(name);
+ reasons.add(
+ BaseMessages.getString(PKG, "ProjectRename.Blocked.Archive", name,
currentName, home));
+ } else {
+ children.add(new ChildProject(name, project));
+ }
+ }
+ if (!blocking.isEmpty()) {
+ throw new ProjectRenameBlockedException(
+ BaseMessages.getString(
+ PKG,
+ "ProjectRename.Blocked.Message",
+ currentName,
+ newName,
+ String.join(Const.CR, reasons)),
+ blocking);
+ }
+ return children;
+ }
+
+ /**
+ * @return the names of the projects which couldn't be restored
+ */
+ private static List<String> restoreParentProjectName(
+ List<ChildProject> children, String parentProjectName, ILogChannel log) {
+ List<String> notRestored = new ArrayList<>();
+ for (ChildProject child : children) {
+ try {
+ child.project().setParentProjectName(parentProjectName);
+ child.project().saveToFile();
+ } catch (Exception e) {
+ log.logError(
+ "Unable to restore parent project '"
+ + parentProjectName
+ + "' of project '"
+ + child.name()
+ + "'",
+ e);
+ notRestored.add(child.name());
+ }
+ }
+ return notRestored;
+ }
+
+ private static void revertRename(
+ ProjectsConfig config, ProjectConfig projectConfig, String currentName,
String newName) {
+ ProjectConfig registered = config.findProjectConfig(newName);
+ if (registered != null) {
+ registered.setProjectName(currentName);
+ }
+ projectConfig.setProjectName(currentName);
+ config.renameProjectReferences(newName, currentName);
+ }
+
+ /** The first line of the root cause message: enough to tell the user what's
wrong. */
+ private static String describeCause(Exception e) {
+ Throwable root = ExceptionUtils.getRootCause(e);
+ String message = root == null ? null : root.getMessage();
+ if (StringUtils.isBlank(message)) {
+ return (root == null ? e : root).getClass().getSimpleName();
+ }
+ return message.strip().lines().findFirst().orElse(message).strip();
+ }
+
+ private static Project loadProject(
+ ProjectConfig projectConfig, IVariables variables, ILogChannel log) {
+ try {
+ return projectConfig.loadProject(variables);
+ } catch (Exception e) {
+ log.logError(
+ "Unable to load project '" + projectConfig.getProjectName() + "'
from its configuration",
+ e);
+ return null;
}
- return parentProjectReferences;
}
}
diff --git
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopGuiStartProjectLoad.java
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopGuiStartProjectLoad.java
index aaa0b610ac..5216c1e228 100644
---
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopGuiStartProjectLoad.java
+++
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopGuiStartProjectLoad.java
@@ -136,7 +136,7 @@ public class HopGuiStartProjectLoad implements
IExtensionPoint {
LifecycleEnvironment lastEnvironment = null;
if (cliLifecycleEnvironment != null
- &&
lastProjectName.equals(cliLifecycleEnvironment.getProjectName())) {
+ &&
lastProjectName.equalsIgnoreCase(cliLifecycleEnvironment.getProjectName())) {
lastEnvironment = cliLifecycleEnvironment;
} else {
List<AuditEvent> envEvents =
@@ -149,7 +149,8 @@ public class HopGuiStartProjectLoad implements
IExtensionPoint {
for (AuditEvent envEvent : envEvents) {
LifecycleEnvironment environment =
config.findEnvironment(envEvent.getName());
- if (environment != null &&
lastProjectName.equals(environment.getProjectName())) {
+ if (environment != null
+ &&
lastProjectName.equalsIgnoreCase(environment.getProjectName())) {
lastEnvironment = environment;
break;
}
diff --git
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
index 90f4f1e8b0..5addd1abf1 100644
---
a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
+++
b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/project/messages/messages_en_US.properties
@@ -63,6 +63,8 @@ ProjectDialog.Label.ProjectName=Name
ProjectDialog.Label.ReadOnly=This project is read only
ProjectDialog.Label.UnitTestBaseFolder=Unit tests base path
(HOP_UNIT_TESTS_FOLDER)
ProjectDialog.Label.Version=Version
+ProjectDialog.MissingParentProject.Dialog.Header=Parent project not found
+ProjectDialog.MissingParentProject.Dialog.Message=Parent project ''{0}''
doesn''t exist. It may have been deleted or renamed.\n\nDo you want to remove
the parent project and continue?\nChoose No to select another parent project.
ProjectDialog.ProjectConfigError.Error.Dialog.Header=Error
ProjectDialog.ProjectConfigError.Error.Dialog.Message=There is a configuration
error in the project
ProjectDialog.ProjectDefinitionError.Error.Dialog.Header=Error
@@ -74,3 +76,10 @@ ProjectDialog.ReadOnly.HomeMissing.Error=This project is
read only, so the home
ProjectDialog.Shell.Name=Project Properties
ProjectGuiPlugin.WrongConfigPath.Dialog.Header=Wrong project''s root
ProjectGuiPlugin.WrongConfigPath.Dialog.Message=The path to selected
project-config.json is outside the boundaries of this project and that is not
permitted!
+ProjectRename.Blocked.Archive=- ''{0}'' uses ''{1}'' as its parent project,
but it is stored in an archive which can''t be written to: {2}
+ProjectRename.Blocked.Header=Project can''t be renamed
+ProjectRename.Blocked.Message=Project ''{0}'' can''t be renamed to ''{1}''
because the following projects can''t be checked or updated:\n\n{2}\n\nFix
these projects or change their parent project, then try again. Nothing was
changed.
+ProjectRename.Blocked.ReadOnly=- ''{0}'' uses ''{1}'' as its parent project,
but it is read-only.
+ProjectRename.Blocked.Unreadable=- ''{0}'': its configuration can''t be read,
so it can''t be checked whether it uses ''{1}'' as its parent project. Reason:
{2}
+ProjectRename.SaveFailed.Message=Project ''{0}'' wasn''t renamed to ''{1}'':
the parent project of ''{2}'' couldn''t be saved. The rename was undone.
+ProjectRename.SaveFailed.NotRestored=Project ''{0}'' wasn''t renamed to
''{1}'': the parent project of ''{2}'' couldn''t be saved. The rename was
undone, but these projects couldn''t be restored and still use ''{1}'' as their
parent project: {3}
diff --git
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/project/ProjectReferencesTest.java
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/project/ProjectReferencesTest.java
new file mode 100644
index 0000000000..86f615f69f
--- /dev/null
+++
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/project/ProjectReferencesTest.java
@@ -0,0 +1,375 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.hop.projects.project;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.projects.config.ProjectsConfig;
+import org.apache.hop.projects.config.ProjectsConfigSingleton;
+import org.apache.hop.projects.environment.LifecycleEnvironment;
+import org.apache.hop.projects.util.ProjectRenameBlockedException;
+import org.apache.hop.projects.util.ProjectsUtil;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/** Parent project, default project and standard parent project references
(#4275). */
+class ProjectReferencesTest {
+
+ @TempDir Path tempRoot;
+
+ private final List<String> registeredProjectNames = new ArrayList<>();
+ private final List<String> registeredEnvironmentNames = new ArrayList<>();
+ private int saveCount;
+ private String originalDefaultProject;
+ private String originalStandardParentProject;
+
+ @BeforeAll
+ static void beforeAll() {
+ HopLogStore.init();
+ }
+
+ @BeforeEach
+ void setUp() {
+ ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+ originalDefaultProject = config.getDefaultProject();
+ originalStandardParentProject = config.getStandardParentProject();
+ }
+
+ @AfterEach
+ void tearDown() {
+ ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+ registeredProjectNames.forEach(config::removeProjectConfig);
+ registeredEnvironmentNames.forEach(config::removeEnvironment);
+ config.setDefaultProject(originalDefaultProject);
+ config.setStandardParentProject(originalStandardParentProject);
+ }
+
+ @Test
+ void removingProjectClearsDefaultAndStandardParentProject() throws Exception
{
+ registerProject("ref-parent", null);
+ ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+ config.setDefaultProject("ref-parent");
+ config.setStandardParentProject("ref-parent");
+ assertEquals("ref-parent", config.findRegisteredStandardParentProject());
+
+ config.removeProjectConfig("ref-parent");
+
+ assertNull(config.getDefaultProject());
+ assertNull(config.getStandardParentProject());
+ assertNull(config.findRegisteredStandardParentProject());
+ }
+
+ @Test
+ void standardParentProjectMustBeRegistered() {
+ ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+ config.setStandardParentProject("ref-does-not-exist");
+
+ assertNull(config.findRegisteredStandardParentProject());
+ }
+
+ @Test
+ void renamingRegistrationInPlaceRenamesDefaultAndStandardParentProject()
throws Exception {
+ ProjectConfig projectConfig = registerProject("ref-old", null);
+ ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+ config.setDefaultProject("ref-old");
+ config.setStandardParentProject("ref-old");
+
+ // The projects browser dialog renames the registered instance before
updating it
+ projectConfig.setProjectName("ref-new");
+ registeredProjectNames.add("ref-new");
+ config.updateProjectConfig("ref-old", projectConfig);
+
+ assertEquals("ref-new", config.getDefaultProject());
+ assertEquals("ref-new", config.getStandardParentProject());
+ }
+
+ @Test
+ void parentProjectReferencesSkipProjectsWhichCantBeLoaded() throws Exception
{
+ registerProject("ref-parent", null);
+ registerProject("ref-child", "ref-parent");
+ registerProject("ref-broken", null);
+ Files.writeString(
+
tempRoot.resolve("ref-broken").resolve(ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME),
+ "{ not json",
+ StandardCharsets.UTF_8);
+
+ List<String> references =
+ ProjectsUtil.getParentProjectReferences("ref-parent", new Variables(),
LogChannel.GENERAL);
+
+ assertEquals(List.of("ref-child"), references);
+ }
+
+ @Test
+ void renamingProjectUpdatesChildProjectsEnvironmentsAndDefaults() throws
Exception {
+ ProjectConfig parentConfig = registerProject("ref-parent", null);
+ ProjectConfig childConfig = registerProject("ref-child", "ref-parent");
+ registerProject("ref-other", null);
+ LifecycleEnvironment environment = registerEnvironment("ref-env",
"ref-parent");
+ ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+ config.setDefaultProject("ref-parent");
+
+ List<String> changed = rename(parentConfig, "ref-parent", "ref-renamed");
+
+ assertEquals(List.of("ref-child"), changed);
+ assertEquals(1, saveCount);
+ assertEquals("ref-renamed", childConfig.loadProject(new
Variables()).getParentProjectName());
+ assertEquals("ref-renamed", environment.getProjectName());
+ assertEquals("ref-renamed", config.getDefaultProject());
+ assertNotNull(config.findProjectConfig("ref-renamed"));
+ assertNull(config.findProjectConfig("ref-parent"));
+ }
+
+ @Test
+ void caseOnlyRenameUpdatesReferences() throws Exception {
+ ProjectConfig parentConfig = registerProject("ref-parent", null);
+ ProjectConfig childConfig = registerProject("ref-child", "ref-parent");
+ LifecycleEnvironment environment = registerEnvironment("ref-env",
"ref-parent");
+ ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+ config.setStandardParentProject("ref-parent");
+
+ List<String> changed = rename(parentConfig, "ref-parent", "REF-Parent");
+
+ assertEquals(List.of("ref-child"), changed);
+ assertEquals("REF-Parent", childConfig.loadProject(new
Variables()).getParentProjectName());
+ assertEquals("REF-Parent", environment.getProjectName());
+ assertEquals("REF-Parent", config.getStandardParentProject());
+ assertEquals("REF-Parent",
config.findProjectConfig("ref-parent").getProjectName());
+ }
+
+ @Test
+ void environmentsOfProjectAreFoundRegardlessOfCase() {
+ registerEnvironment("ref-env", "Ref-Parent");
+
+ List<LifecycleEnvironment> environments =
+
ProjectsConfigSingleton.getConfig().findEnvironmentsOfProject("ref-parent");
+
+ assertEquals(1, environments.size());
+ assertEquals("ref-env", environments.get(0).getName());
+ }
+
+ @Test
+ void removingProjectLeavesEnvironmentsAlone() throws Exception {
+ registerProject("ref-parent", null);
+ LifecycleEnvironment environment = registerEnvironment("ref-env",
"ref-parent");
+
+ ProjectsConfigSingleton.getConfig().removeProjectConfig("ref-parent");
+
+ // A null project would make the environment show up for every project
+ assertEquals("ref-parent", environment.getProjectName());
+ }
+
+ @Test
+ void readOnlyChildProjectBlocksRenameAndNothingChanges() throws Exception {
+ ProjectConfig parentConfig = registerProject("ref-parent", null);
+ ProjectConfig writableChild = registerProject("ref-child-a", "ref-parent");
+ registerProject("ref-child-b", "ref-parent").setReadOnly(true);
+ ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+ config.setDefaultProject("ref-parent");
+
+ ProjectRenameBlockedException exception =
+ assertThrows(
+ ProjectRenameBlockedException.class,
+ () -> rename(parentConfig, "ref-parent", "ref-renamed"));
+
+ assertEquals(List.of("ref-child-b"), exception.getBlockingProjects());
+ assertTrue(
+ exception
+ .getUserMessage()
+ .contains("Project 'ref-parent' can't be renamed to
'ref-renamed'"),
+ exception.getUserMessage());
+ assertTrue(
+ exception
+ .getUserMessage()
+ .contains("'ref-child-b' uses 'ref-parent' as its parent project,
but it is read-only"),
+ exception.getUserMessage());
+ assertEquals(0, saveCount);
+ assertEquals("ref-parent", parentConfig.getProjectName());
+ assertEquals("ref-parent", config.getDefaultProject());
+ assertEquals("ref-parent", writableChild.loadProject(new
Variables()).getParentProjectName());
+ }
+
+ @Test
+ void unreadableProjectBlocksRename() throws Exception {
+ ProjectConfig parentConfig = registerProject("ref-parent", null);
+ registerProject("ref-broken", null);
+ Files.writeString(
+
tempRoot.resolve("ref-broken").resolve(ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME),
+ "{ not json",
+ StandardCharsets.UTF_8);
+
+ ProjectRenameBlockedException exception =
+ assertThrows(
+ ProjectRenameBlockedException.class,
+ () ->
+ ProjectsUtil.checkProjectRename(
+ "ref-parent", "ref-renamed", new Variables(),
LogChannel.GENERAL));
+
+ assertEquals(List.of("ref-broken"), exception.getBlockingProjects());
+ assertTrue(
+ exception.getUserMessage().contains("'ref-broken': its configuration
can't be read"),
+ exception.getUserMessage());
+ assertEquals("ref-parent", parentConfig.getProjectName());
+ }
+
+ @Test
+ void projectWithoutHomeFolderDoesNotBlockRename() throws Exception {
+ registerProject("ref-parent", null);
+ ProjectConfig gone =
+ new ProjectConfig(
+ "ref-gone",
+ tempRoot.resolve("does-not-exist").toString(),
+ ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME);
+ ProjectsConfigSingleton.getConfig().addProjectConfig(gone);
+ registeredProjectNames.add("ref-gone");
+
+ assertEquals(
+ List.of(),
+ ProjectsUtil.checkProjectRename(
+ "ref-parent", "ref-renamed", new Variables(), LogChannel.GENERAL));
+ }
+
+ @Test
+ void failingRegistrationSaveUndoesRename() throws Exception {
+ ProjectConfig parentConfig = registerProject("ref-parent", null);
+ ProjectConfig childConfig = registerProject("ref-child", "ref-parent");
+ LifecycleEnvironment environment = registerEnvironment("ref-env",
"ref-parent");
+
+ parentConfig.setProjectName("ref-renamed");
+ registeredProjectNames.add("ref-renamed");
+ assertThrows(
+ HopException.class,
+ () ->
+ ProjectsUtil.saveProjectConfig(
+ "ref-parent",
+ parentConfig,
+ new Variables(),
+ LogChannel.GENERAL,
+ () -> {
+ throw new HopException("disk full");
+ }));
+
+ assertEquals("ref-parent", parentConfig.getProjectName());
+ assertEquals("ref-parent", environment.getProjectName());
+ assertEquals("ref-parent", childConfig.loadProject(new
Variables()).getParentProjectName());
+ }
+
+ @Test
+ void failingChildSaveRestoresEveryProject() throws Exception {
+ ProjectConfig parentConfig = registerProject("ref-parent", null);
+ ProjectConfig firstChild = registerProject("ref-child-a", "ref-parent");
+ registerProject("ref-child-b", "ref-parent");
+ // A home folder which can't be written makes saving the second child fail
+ File secondHome = tempRoot.resolve("ref-child-b").toFile();
+ File secondConfig = new File(secondHome,
ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME);
+ assumeTrue(secondConfig.setWritable(false) && !secondConfig.canWrite());
+ assumeTrue(secondHome.setWritable(false) && !secondHome.canWrite());
+
+ try {
+ HopException exception =
+ assertThrows(HopException.class, () -> rename(parentConfig,
"ref-parent", "ref-renamed"));
+
+ assertTrue(
+ exception
+ .getMessage()
+ .contains(
+ "Project 'ref-parent' wasn't renamed to 'ref-renamed': the
parent project of"
+ + " 'ref-child-b' couldn't be saved. The rename was
undone."),
+ exception.getMessage());
+ assertEquals("ref-parent", parentConfig.getProjectName());
+ assertEquals("ref-parent", firstChild.loadProject(new
Variables()).getParentProjectName());
+ // Registration saved with the new name, then saved again with the old
one
+ assertEquals(2, saveCount);
+ } finally {
+ secondHome.setWritable(true);
+ secondConfig.setWritable(true);
+ }
+ }
+
+ @Test
+ void commandLineDeleteRefusesParentProject() throws Exception {
+ registerProject("ref-parent", null);
+ registerProject("ref-child", "ref-parent");
+
+ ManageProjectsOptionPlugin plugin = new ManageProjectsOptionPlugin();
+ plugin.setProjectName("ref-parent");
+ plugin.setDeleteProject(true);
+
+ HopException exception =
+ assertThrows(
+ HopException.class,
+ () -> plugin.handleOption(LogChannel.GENERAL, null, new
Variables()));
+
+
assertNotNull(ProjectsConfigSingleton.getConfig().findProjectConfig("ref-parent"));
+ assertTrue(
+ exception
+ .getCause()
+ .getMessage()
+ .contains("'ref-parent' can't be deleted, it is the parent project
of: ref-child"));
+ }
+
+ /** Rename the registered instance in place, the way the project dialogs do.
*/
+ private List<String> rename(ProjectConfig projectConfig, String currentName,
String newName)
+ throws HopException {
+ projectConfig.setProjectName(newName);
+ registeredProjectNames.add(newName);
+ return ProjectsUtil.saveProjectConfig(
+ currentName, projectConfig, new Variables(), LogChannel.GENERAL, () ->
saveCount++);
+ }
+
+ private LifecycleEnvironment registerEnvironment(String name, String
projectName) {
+ LifecycleEnvironment environment =
+ new LifecycleEnvironment(name, "Testing", projectName, List.of());
+ ProjectsConfigSingleton.getConfig().addEnvironment(environment);
+ registeredEnvironmentNames.add(name);
+ return environment;
+ }
+
+ private ProjectConfig registerProject(String name, String parentProjectName)
throws Exception {
+ Path home = tempRoot.resolve(name);
+ Files.createDirectories(home);
+ String parentJson = parentProjectName == null ? "null" : "\"" +
parentProjectName + "\"";
+ Files.writeString(
+ home.resolve(ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME),
+ "{ \"parentProjectName\" : " + parentJson + " }",
+ StandardCharsets.UTF_8);
+
+ ProjectConfig projectConfig =
+ new ProjectConfig(name, home.toString(),
ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME);
+ ProjectsConfigSingleton.getConfig().addProjectConfig(projectConfig);
+ registeredProjectNames.add(name);
+ return projectConfig;
+ }
+}