From eb1760353d40fbe4dce879fb95a8964d6caadc5b Mon Sep 17 00:00:00 2001 From: mattcasters Date: Sun, 19 Jul 2026 10:04:56 +0200 Subject: [PATCH 01/15] issue #2292 : Make the empty execution locations tree more welcoming --- .../execution/ExecutionPerspective.java | 221 +++++++++++------- .../messages/messages_en_US.properties | 3 + 2 files changed, 135 insertions(+), 89 deletions(-) diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java index 6f2293cde62..1f28d96f5a9 100644 --- a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java +++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java @@ -152,6 +152,9 @@ public class ExecutionPerspective implements IHopPerspective, TabClosable { public static final String CONST_ERROR1 = "Error"; public static final String FILTER_NAME_DATE_ID = "name - date - ID"; + /** Tree item data key for the empty-state "configure locations" placeholder. */ + public static final String CONFIGURE_LOCATIONS = "configure-locations"; + private static final String EXECUTION_AUDIT_TYPE = "execution-perspective-gui"; private static final String AUDIT_EXECUTION_TOOLBAR = "toolbar"; private static final String AUDIT_ONLY_PARENTS = "only-parents"; @@ -467,7 +470,9 @@ public void onNewViewer() { TreeItem treeItem = tree.getSelection()[0]; if (treeItem != null) { - if (treeItem.getData() instanceof Execution execution) { + if (Boolean.TRUE.equals(treeItem.getData(CONFIGURE_LOCATIONS))) { + createNewExecutionInfoLocation(); + } else if (treeItem.getData() instanceof Execution execution) { ExecutionInfoLocation location = (ExecutionInfoLocation) treeItem.getParentItem().getData(); ExecutionState executionState = @@ -483,6 +488,29 @@ public void onNewViewer() { } } + /** + * Opens a new Execution Information Location editor in the metadata perspective. Used when the + * user double-clicks the empty-state placeholder in the execution tree. + */ + private void createNewExecutionInfoLocation() { + try { + MetadataManager manager = + new MetadataManager<>( + hopGui.getVariables(), + hopGui.getMetadataProvider(), + ExecutionInfoLocation.class, + hopGui.getShell()); + manager.newMetadataWithEditor(""); + hopGui.getEventsHandler().fire(HopGuiEvents.MetadataCreated.name()); + } catch (Exception e) { + new ErrorDialog( + getShell(), + CONST_ERROR1, + BaseMessages.getString(PKG, "ExecutionPerspective.CreateLocation.Error.Message"), + e); + } + } + public void createExecutionViewer( String locationName, Execution execution, ExecutionState executionState) throws Exception { Cursor busyCursor = getBusyCursor(); @@ -731,110 +759,125 @@ public void refresh() { List locations = serializer.loadAll(); locations.sort(Comparator.comparing(HopMetadataBase::getName)); - ILogChannel log = hopGui.getLog(); - Metrics startLocationRefresh = - new Metrics(MetricsSnapshotType.START, SNAP_ID_EIL_REFRESH, "Refresh EIL tree"); - Metrics endLocationRefresh = - new Metrics(MetricsSnapshotType.STOP, SNAP_ID_EIL_REFRESH, "Refresh EIL tree end"); - - log.setGatheringMetrics(true); - for (ExecutionInfoLocation location : locations) { - log.snap(startLocationRefresh); - IExecutionInfoLocation iLocation = location.getExecutionInfoLocation(); - - try { - // Initialize the location first... - // - iLocation.initialize(hopGui.getVariables(), hopGui.getMetadataProvider()); + // When no locations are configured, show a helpful placeholder the user can double-click + // to create a new Execution Information Location in the metadata perspective. + // + if (locations.isEmpty()) { + TreeItem configureItem = new TreeItem(tree, SWT.NONE); + configureItem.setText( + 0, BaseMessages.getString(PKG, "ExecutionPerspective.Tree.ConfigureLocations")); + configureItem.setImage(GuiResource.getInstance().getImageLocation()); + configureItem.setData(CONFIGURE_LOCATIONS, true); + tree.setToolTipText( + BaseMessages.getString(PKG, "ExecutionPerspective.Tree.ConfigureLocations.Tooltip")); + } else { + ILogChannel log = hopGui.getLog(); + Metrics startLocationRefresh = + new Metrics(MetricsSnapshotType.START, SNAP_ID_EIL_REFRESH, "Refresh EIL tree"); + Metrics endLocationRefresh = + new Metrics(MetricsSnapshotType.STOP, SNAP_ID_EIL_REFRESH, "Refresh EIL tree end"); - // Keep the location around to close at the next refresh. - // - locationMap.put(location.getName(), location); + log.setGatheringMetrics(true); + tree.setToolTipText(null); - TreeItem locationItem = new TreeItem(tree, SWT.NONE); - locationItem.setText(0, Const.NVL(location.getName(), "")); - locationItem.setImage(GuiResource.getInstance().getImageLocation()); - TreeMemory.getInstance().storeExpanded(EXECUTION_PERSPECTIVE_TREE, locationItem, true); - locationItem.setData(location); + for (ExecutionInfoLocation location : locations) { + log.snap(startLocationRefresh); + IExecutionInfoLocation iLocation = location.getExecutionInfoLocation(); try { - // Get the data in the location. The plugins are supposed to prune as much of the IDs - // upfront. - // Below we'll run the isSelected() condition again to make sure. - // - IExecutionSelector executionSelector = - new DefaultExecutionSelector( - onlyShowingParents, - onlyShowingFailed, - onlyShowingRunning, - onlyShowingFinished, - onlyShowingWorkflows, - onlyShowingPipelines, - filterText, - timeFilter); - List ids = iLocation.findExecutionIDs(executionSelector); - - // Display the executions + // Initialize the location first... // - for (String id : ids) { - try { - Execution execution = iLocation.getExecution(id); - if (execution != null) { - // Apply an extra filter to make sure - // - if (!executionSelector.isSelected(execution)) { - continue; - } - // We only need to consider the state after the previous filtering - // - ExecutionState state = iLocation.getExecutionState(id); - if (!executionSelector.isSelected(state)) { - continue; - } + iLocation.initialize(hopGui.getVariables(), hopGui.getMetadataProvider()); - TreeItem executionItem = new TreeItem(locationItem, SWT.NONE); - switch (execution.getExecutionType()) { - case Pipeline: - decoratePipelineTreeItem(executionItem, location, execution, state); - break; - case Workflow: - decorateWorkflowTreeItem(executionItem, location, execution, state); - break; - default: - break; + // Keep the location around to close at the next refresh. + // + locationMap.put(location.getName(), location); + + TreeItem locationItem = new TreeItem(tree, SWT.NONE); + locationItem.setText(0, Const.NVL(location.getName(), "")); + locationItem.setImage(GuiResource.getInstance().getImageLocation()); + TreeMemory.getInstance().storeExpanded(EXECUTION_PERSPECTIVE_TREE, locationItem, true); + locationItem.setData(location); + + try { + // Get the data in the location. The plugins are supposed to prune as much of the IDs + // upfront. + // Below we'll run the isSelected() condition again to make sure. + // + IExecutionSelector executionSelector = + new DefaultExecutionSelector( + onlyShowingParents, + onlyShowingFailed, + onlyShowingRunning, + onlyShowingFinished, + onlyShowingWorkflows, + onlyShowingPipelines, + filterText, + timeFilter); + List ids = iLocation.findExecutionIDs(executionSelector); + + // Display the executions + // + for (String id : ids) { + try { + Execution execution = iLocation.getExecution(id); + if (execution != null) { + // Apply an extra filter to make sure + // + if (!executionSelector.isSelected(execution)) { + continue; + } + // We only need to consider the state after the previous filtering + // + ExecutionState state = iLocation.getExecutionState(id); + if (!executionSelector.isSelected(state)) { + continue; + } + + TreeItem executionItem = new TreeItem(locationItem, SWT.NONE); + switch (execution.getExecutionType()) { + case Pipeline: + decoratePipelineTreeItem(executionItem, location, execution, state); + break; + case Workflow: + decorateWorkflowTreeItem(executionItem, location, execution, state); + break; + default: + break; + } } + } catch (Exception e) { + TreeItem errorItem = new TreeItem(locationItem, SWT.NONE); + errorItem.setText("Error reading " + id + " (double click for details)"); + errorItem.setForeground(GuiResource.getInstance().getColorRed()); + errorItem.setData(CONST_ERROR, e); + errorItem.setImage(GuiResource.getInstance().getImageError()); } - } catch (Exception e) { - TreeItem errorItem = new TreeItem(locationItem, SWT.NONE); - errorItem.setText("Error reading " + id + " (double click for details)"); - errorItem.setForeground(GuiResource.getInstance().getColorRed()); - errorItem.setData(CONST_ERROR, e); - errorItem.setImage(GuiResource.getInstance().getImageError()); } + } catch (Exception e) { + // Error contacting location + // + TreeItem errorItem = new TreeItem(locationItem, SWT.NONE); + errorItem.setText("Not reachable (double click for details)"); + errorItem.setForeground(GuiResource.getInstance().getColorRed()); + errorItem.setData(CONST_ERROR, e); + errorItem.setImage(GuiResource.getInstance().getImageError()); } } catch (Exception e) { - // Error contacting location + // We couldn't initialize a location // - TreeItem errorItem = new TreeItem(locationItem, SWT.NONE); - errorItem.setText("Not reachable (double click for details)"); - errorItem.setForeground(GuiResource.getInstance().getColorRed()); - errorItem.setData(CONST_ERROR, e); - errorItem.setImage(GuiResource.getInstance().getImageError()); + TreeItem locationItem = new TreeItem(tree, SWT.NONE); + locationItem.setText( + 0, Const.NVL(location.getName(), "") + " (error: double click for details)"); + locationItem.setForeground(GuiResource.getInstance().getColorRed()); + locationItem.setImage(GuiResource.getInstance().getImageLocation()); + locationItem.setData(CONST_ERROR, e); } - } catch (Exception e) { - // We couldn't initialize a location - // - TreeItem locationItem = new TreeItem(tree, SWT.NONE); - locationItem.setText( - 0, Const.NVL(location.getName(), "") + " (error: double click for details)"); - locationItem.setForeground(GuiResource.getInstance().getColorRed()); - locationItem.setImage(GuiResource.getInstance().getImageLocation()); - locationItem.setData(CONST_ERROR, e); + log.snap(endLocationRefresh); } - log.snap(endLocationRefresh); + log.setGatheringMetrics(false); } - log.setGatheringMetrics(false); TreeUtil.setOptimalWidthOnColumns(tree); TreeMemory.setExpandedFromMemory(tree, EXECUTION_PERSPECTIVE_TREE); diff --git a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/execution/messages/messages_en_US.properties b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/execution/messages/messages_en_US.properties index c27c4de9393..59fe44d7663 100644 --- a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/execution/messages/messages_en_US.properties +++ b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/execution/messages/messages_en_US.properties @@ -17,6 +17,9 @@ ExecutionPerspective.Description=Shows information about executing pipelines and workflows ExecutionPerspective.Name=Execution information +ExecutionPerspective.Tree.ConfigureLocations=Please configure one or more locations. +ExecutionPerspective.Tree.ConfigureLocations.Tooltip=Double click to create an execution information location.\nConfigure a pipeline or workflow run configuration with it to see information appear here. +ExecutionPerspective.CreateLocation.Error.Message=Error creating a new Execution Information Location ExecutionPerspective.Refresh.Error.Header=Error ExecutionPerspective.Refresh.Error.Message=There was an error refreshing execution information: ExecutionPerspective.ToolbarElement.Delete.Tooltip=Delete From 7475207c1e0b8767227a2726a4e540939d63498d Mon Sep 17 00:00:00 2001 From: mattcasters Date: Sun, 19 Jul 2026 10:22:33 +0200 Subject: [PATCH 02/15] issue #2195 : Execution Information : Add a button to copy the folder URI to the clipboard --- .../execution/ExecutionPerspective.java | 206 ++++++++++++++++++ .../messages/messages_en_US.properties | 5 + 2 files changed, 211 insertions(+) diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java index 1f28d96f5a9..f768fae5f22 100644 --- a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java +++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java @@ -24,6 +24,7 @@ import java.util.List; import java.util.Map; import lombok.Getter; +import org.apache.commons.lang3.StringUtils; import org.apache.hop.core.Const; import org.apache.hop.core.Props; import org.apache.hop.core.exception.HopException; @@ -48,6 +49,8 @@ import org.apache.hop.execution.IExecutionInfoLocation; import org.apache.hop.execution.IExecutionSelector; import org.apache.hop.execution.LastPeriod; +import org.apache.hop.execution.caching.CachingFileExecutionInfoLocation; +import org.apache.hop.execution.local.FileExecutionInfoLocation; import org.apache.hop.history.AuditManager; import org.apache.hop.history.AuditState; import org.apache.hop.history.AuditStateMap; @@ -91,12 +94,16 @@ import org.eclipse.swt.custom.CTabItem; import org.eclipse.swt.custom.SashForm; import org.eclipse.swt.graphics.Cursor; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Rectangle; import org.eclipse.swt.layout.FormAttachment; import org.eclipse.swt.layout.FormData; import org.eclipse.swt.layout.FormLayout; import org.eclipse.swt.widgets.Combo; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Menu; +import org.eclipse.swt.widgets.MenuItem; import org.eclipse.swt.widgets.Shell; import org.eclipse.swt.widgets.Text; import org.eclipse.swt.widgets.ToolBar; @@ -121,6 +128,8 @@ public class ExecutionPerspective implements IHopPerspective, TabClosable { public static final String GUI_PLUGIN_TOOLBAR_PARENT_ID = "ExecutionPerspective-Toolbar"; + public static final String TOOLBAR_ITEM_COPY_FILENAME = + "ExecutionPerspective-Toolbar-10000-CopyFilename"; public static final String TOOLBAR_ITEM_EDIT = "ExecutionPerspective-Toolbar-10010-Edit"; public static final String TOOLBAR_ITEM_DUPLICATE = "ExecutionPerspective-Toolbar-10030-Duplicate"; @@ -330,6 +339,7 @@ protected void createTree(Composite parent) { tree = new Tree(composite, SWT.SINGLE | SWT.H_SCROLL | SWT.V_SCROLL); tree.setHeaderVisible(false); + tree.addListener(SWT.Selection, event -> updateSelection()); tree.addListener( SWT.DefaultSelection, event -> { @@ -339,6 +349,9 @@ protected void createTree(Composite parent) { } }); + // Copy-filename starts disabled until an execution with a filename is selected. + updateSelection(); + PropsUi.setLook(tree); FormData treeFormData = new FormData(); @@ -883,6 +896,7 @@ public void refresh() { TreeMemory.setExpandedFromMemory(tree, EXECUTION_PERSPECTIVE_TREE); tree.setRedraw(true); + updateSelection(); } catch (Exception e) { getShell().setCursor(null); new ErrorDialog( @@ -1120,6 +1134,198 @@ public boolean remove(IHopFileTypeHandler typeHandler) { return false; } + /** + * Enables toolbar items that depend on the current tree selection (copy is enabled when an + * execution is selected so UUID and other options can be offered). + */ + private void updateSelection() { + if (toolBarWidgets == null) { + return; + } + toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_COPY_FILENAME, getSelectedExecution() != null); + } + + /** + * @return the selected execution tree item data, or {@code null} if none / not an execution + */ + private Execution getSelectedExecution() { + if (tree == null || tree.isDisposed() || tree.getSelectionCount() != 1) { + return null; + } + Object data = tree.getSelection()[0].getData(); + if (data instanceof Execution execution) { + return execution; + } + return null; + } + + /** + * @return the parent {@link ExecutionInfoLocation} for the selected execution, or {@code null} + */ + private ExecutionInfoLocation getSelectedExecutionLocation() { + if (tree == null || tree.isDisposed() || tree.getSelectionCount() != 1) { + return null; + } + TreeItem item = tree.getSelection()[0]; + if (!(item.getData() instanceof Execution)) { + return null; + } + TreeItem parentItem = item.getParentItem(); + if (parentItem != null && parentItem.getData() instanceof ExecutionInfoLocation location) { + return location; + } + return null; + } + + /** + * Shows a popup menu under the copy toolbar button so the user can choose what to copy: full + * filename path, project-relative filename, on-disk storage path (file locations only), or UUID. + */ + @GuiToolbarElement( + root = GUI_PLUGIN_TOOLBAR_PARENT_ID, + id = TOOLBAR_ITEM_COPY_FILENAME, + toolTip = "i18n::ExecutionPerspective.ToolbarElement.Copy.Tooltip", + image = "ui/images/copy.svg") + public void copyToClipboard() { + Execution execution = getSelectedExecution(); + if (execution == null) { + return; + } + + String filename = execution.getFilename(); + String relativeFilename = toProjectRelativePath(filename); + String storagePath = resolveStoragePath(getSelectedExecutionLocation(), execution); + String uuid = execution.getId(); + + Menu menu = new Menu(getShell(), SWT.POP_UP); + + addCopyMenuItem( + menu, + BaseMessages.getString(PKG, "ExecutionPerspective.CopyMenu.Filename"), + filename, + StringUtils.isNotEmpty(filename)); + addCopyMenuItem( + menu, + BaseMessages.getString(PKG, "ExecutionPerspective.CopyMenu.RelativeFilename"), + relativeFilename, + StringUtils.isNotEmpty(relativeFilename)); + addCopyMenuItem( + menu, + BaseMessages.getString(PKG, "ExecutionPerspective.CopyMenu.StoragePath"), + storagePath, + StringUtils.isNotEmpty(storagePath)); + addCopyMenuItem( + menu, + BaseMessages.getString(PKG, "ExecutionPerspective.CopyMenu.Uuid"), + uuid, + StringUtils.isNotEmpty(uuid)); + + positionCopyMenu(menu); + menu.addListener(SWT.Hide, event -> menu.getDisplay().asyncExec(menu::dispose)); + menu.setVisible(true); + } + + private void addCopyMenuItem(Menu menu, String label, String value, boolean enabled) { + MenuItem item = new MenuItem(menu, SWT.PUSH); + item.setText(label); + item.setEnabled(enabled); + if (enabled && value != null) { + item.addListener(SWT.Selection, e -> GuiResource.getInstance().toClipboard(value)); + } + } + + private void positionCopyMenu(Menu menu) { + ToolItem toolItem = + toolBarWidgets != null ? toolBarWidgets.findToolItem(TOOLBAR_ITEM_COPY_FILENAME) : null; + if (toolItem != null && !toolItem.isDisposed() && toolItem.getParent() != null) { + ToolBar bar = toolItem.getParent(); + Rectangle bounds = toolItem.getBounds(); + menu.setLocation(bar.toDisplay(bounds.x, bounds.y + bounds.height)); + return; + } + Control control = + toolBarWidgets != null + ? toolBarWidgets.getControlForMenu(TOOLBAR_ITEM_COPY_FILENAME) + : null; + if (control != null && !control.isDisposed()) { + Rectangle bounds = control.getBounds(); + Point location = control.getParent().toDisplay(bounds.x, bounds.y + bounds.height); + menu.setLocation(location); + return; + } + menu.setLocation(getShell().getDisplay().getCursorLocation()); + } + + /** + * Expresses {@code path} relative to {@code PROJECT_HOME} as {@code ${PROJECT_HOME}/…} when + * possible. Returns {@code null} when the path cannot be relativized. + */ + private String toProjectRelativePath(String path) { + if (StringUtils.isEmpty(path)) { + return null; + } + // Already project-relative + if (path.startsWith(Const.VAR_PROJECT_HOME) || path.startsWith("${PROJECT_HOME}")) { + return path; + } + String projectHome = hopGui.getVariables().resolve(Const.VAR_PROJECT_HOME); + if (StringUtils.isEmpty(projectHome) || Const.VAR_PROJECT_HOME.equals(projectHome)) { + return null; + } + // Normalize trailing separators on project home for prefix matching + String home = projectHome; + while (home.endsWith("/") || home.endsWith("\\")) { + home = home.substring(0, home.length() - 1); + } + if (path.startsWith(home + "/") || path.startsWith(home + "\\") || path.equals(home)) { + String rel = path.substring(home.length()); + return Const.VAR_PROJECT_HOME + + (rel.isEmpty() || rel.startsWith("/") || rel.startsWith("\\") + ? rel.replace('\\', '/') + : "/" + rel.replace('\\', '/')); + } + return null; + } + + /** + * Builds the on-disk storage path for file-based execution locations, or {@code null} when the + * location type does not store execution information as files/folders. + */ + private String resolveStoragePath(ExecutionInfoLocation location, Execution execution) { + if (location == null || execution == null || StringUtils.isEmpty(execution.getId())) { + return null; + } + IExecutionInfoLocation iLocation = location.getExecutionInfoLocation(); + if (iLocation instanceof FileExecutionInfoLocation fileLocation) { + String root = hopGui.getVariables().resolve(fileLocation.getRootFolder()); + if (StringUtils.isEmpty(root)) { + return null; + } + if (root.endsWith("/") || root.endsWith("\\")) { + return root + execution.getId(); + } + return root + "/" + execution.getId(); + } + if (iLocation instanceof CachingFileExecutionInfoLocation cachingLocation) { + String root = cachingLocation.getActualRootFolder(); + if (StringUtils.isEmpty(root)) { + root = hopGui.getVariables().resolve(cachingLocation.getRootFolder()); + } + if (StringUtils.isEmpty(root)) { + return null; + } + // Match CacheEntry.calculateFilename: root + id + ".json" + if (root.endsWith("/") || root.endsWith("\\")) { + return root + execution.getId() + ".json"; + } + if (root.contains("://") || root.startsWith("file:")) { + return root + "/" + execution.getId() + ".json"; + } + return root + Const.FILE_SEPARATOR + execution.getId() + ".json"; + } + return null; + } + @GuiToolbarElement( root = GUI_PLUGIN_TOOLBAR_PARENT_ID, id = TOOLBAR_ITEM_DELETE, diff --git a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/execution/messages/messages_en_US.properties b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/execution/messages/messages_en_US.properties index 59fe44d7663..a214e6cca6c 100644 --- a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/execution/messages/messages_en_US.properties +++ b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/execution/messages/messages_en_US.properties @@ -22,6 +22,11 @@ ExecutionPerspective.Tree.ConfigureLocations.Tooltip=Double click to create an e ExecutionPerspective.CreateLocation.Error.Message=Error creating a new Execution Information Location ExecutionPerspective.Refresh.Error.Header=Error ExecutionPerspective.Refresh.Error.Message=There was an error refreshing execution information: +ExecutionPerspective.ToolbarElement.Copy.Tooltip=Copy path or ID to clipboard +ExecutionPerspective.CopyMenu.Filename=Copy filename path +ExecutionPerspective.CopyMenu.RelativeFilename=Copy relative filename path +ExecutionPerspective.CopyMenu.StoragePath=Copy storage path on disk +ExecutionPerspective.CopyMenu.Uuid=Copy ID ExecutionPerspective.ToolbarElement.Delete.Tooltip=Delete ExecutionPerspective.ToolbarElement.Refresh.Tooltip=Refresh information ExecutionPerspective.ToolbarElement.ForceRefresh.Tooltip=Force refresh information (clear caches) From 561a590413b1edf1f18e275885e2513e1fa20408 Mon Sep 17 00:00:00 2001 From: mattcasters Date: Sun, 19 Jul 2026 10:43:42 +0200 Subject: [PATCH 03/15] Issue #2069 : More Info Fields in Pipeline Execution Information --- .../hop/execution/ExecutionStateBuilder.java | 92 +++++++++++++++++++ .../ExecutionStateBuilderMetricsTest.java | 89 ++++++++++++++++++ .../execution/ExecutionPerspective.java | 37 ++++++-- .../execution/PipelineExecutionViewer.java | 20 +++- .../execution/WorkflowExecutionViewer.java | 2 + .../ui/hopgui/shared/BaseExecutionViewer.java | 28 ++++++ .../messages/messages_en_US.properties | 1 + 7 files changed, 261 insertions(+), 8 deletions(-) create mode 100644 engine/src/test/java/org/apache/hop/execution/ExecutionStateBuilderMetricsTest.java diff --git a/engine/src/main/java/org/apache/hop/execution/ExecutionStateBuilder.java b/engine/src/main/java/org/apache/hop/execution/ExecutionStateBuilder.java index 1f6ea6e36af..0b803d316aa 100644 --- a/engine/src/main/java/org/apache/hop/execution/ExecutionStateBuilder.java +++ b/engine/src/main/java/org/apache/hop/execution/ExecutionStateBuilder.java @@ -37,6 +37,13 @@ import org.apache.hop.workflow.engine.IWorkflowEngine; public final class ExecutionStateBuilder { + + /** Metric map key: component duration in milliseconds. */ + public static final String METRIC_HEADER_DURATION = "Duration"; + + /** Metric map key: component throughput in rows per second. */ + public static final String METRIC_HEADER_SPEED = "Speed"; + private ExecutionType executionType; private Date updateTime; private String statusDescription; @@ -122,6 +129,17 @@ public static ExecutionStateBuilder fromExecutor( addMetric(componentMetrics, engineMetrics, component, Pipeline.METRIC_DATA_VOLUME_IN); addMetric(componentMetrics, engineMetrics, component, Pipeline.METRIC_DATA_VOLUME_OUT); + // Duration (ms) and speed (rows/s) for the Metrics tab in the execution perspective. + // + long durationMs = resolveDurationMs(component); + if (durationMs > 0) { + componentMetrics.getMetrics().put(METRIC_HEADER_DURATION, durationMs); + } + Long speed = resolveSpeedRowsPerSecond(component, engineMetrics, durationMs); + if (speed != null) { + componentMetrics.getMetrics().put(METRIC_HEADER_SPEED, speed); + } + builder.addMetrics(componentMetrics); } } @@ -129,6 +147,80 @@ public static ExecutionStateBuilder fromExecutor( return builder; } + /** + * Resolves component duration in milliseconds. Prefers {@link + * IEngineComponent#getExecutionDuration()}; falls back to first/last row timestamps when duration + * is zero (same idea as the live pipeline metrics grid). + */ + static long resolveDurationMs(IEngineComponent component) { + long durationMs = component.getExecutionDuration(); + if (durationMs > 0) { + return durationMs; + } + Date first = component.getFirstRowReadDate(); + if (first == null) { + return 0; + } + Date last = component.getLastRowWrittenDate(); + if (last != null) { + return Math.max(0, last.getTime() - first.getTime()); + } + return Math.max(0, System.currentTimeMillis() - first.getTime()); + } + + /** + * Resolves throughput in rows per second. Prefers the engine speed map when a numeric value can + * be parsed; otherwise computes from row counts and duration (same formula as {@code + * TransformStatus}). + * + * @return rows/s as a long, or {@code null} when speed is not meaningful + */ + static Long resolveSpeedRowsPerSecond( + IEngineComponent component, EngineMetrics engineMetrics, long durationMs) { + if (engineMetrics != null) { + String speedText = engineMetrics.getComponentSpeedMap().get(component); + Long parsed = parseSpeedRowsPerSecond(speedText); + if (parsed != null) { + return parsed; + } + } + if (durationMs <= 0) { + return null; + } + long inProc = Math.max(component.getLinesInput(), component.getLinesRead()); + long outProc = + Math.max( + component.getLinesOutput() + component.getLinesUpdated(), + component.getLinesWritten() + component.getLinesRejected()); + double lapsedSec = durationMs / 1000.0; + if (lapsedSec <= 0) { + return null; + } + double inSpeed = inProc / lapsedSec; + double outSpeed = outProc / lapsedSec; + return Math.round(Math.max(inSpeed, outSpeed)); + } + + /** + * Parses a speed string as produced by the local pipeline engine / {@code TransformStatus} (e.g. + * {@code " 1,234"}). Returns {@code null} for empty, dash, or non-numeric values. + */ + static Long parseSpeedRowsPerSecond(String speedText) { + if (speedText == null) { + return null; + } + String cleaned = speedText.trim().replace(",", "").replace(" ", ""); + if (cleaned.isEmpty() || "-".equals(cleaned)) { + return null; + } + try { + // TransformStatus may use a decimal; store whole rows/s. + return Math.round(Double.parseDouble(cleaned)); + } catch (NumberFormatException e) { + return null; + } + } + public static ExecutionStateBuilder fromTransform( IPipelineEngine pipeline, IEngineComponent component) { return of().withExecutionType(ExecutionType.Transform) diff --git a/engine/src/test/java/org/apache/hop/execution/ExecutionStateBuilderMetricsTest.java b/engine/src/test/java/org/apache/hop/execution/ExecutionStateBuilderMetricsTest.java new file mode 100644 index 00000000000..18808fcd2a4 --- /dev/null +++ b/engine/src/test/java/org/apache/hop/execution/ExecutionStateBuilderMetricsTest.java @@ -0,0 +1,89 @@ +/* + * 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.execution; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import org.apache.hop.pipeline.engine.EngineMetrics; +import org.apache.hop.pipeline.engine.IEngineComponent; +import org.junit.jupiter.api.Test; + +class ExecutionStateBuilderMetricsTest { + + @Test + void resolveDurationMsPrefersExecutionDuration() { + IEngineComponent component = mock(IEngineComponent.class); + when(component.getExecutionDuration()).thenReturn(2500L); + + assertEquals(2500L, ExecutionStateBuilder.resolveDurationMs(component)); + } + + @Test + void resolveDurationMsFallsBackToFirstLastRowDates() { + IEngineComponent component = mock(IEngineComponent.class); + when(component.getExecutionDuration()).thenReturn(0L); + Date first = new Date(1_000_000L); + Date last = new Date(1_005_000L); + when(component.getFirstRowReadDate()).thenReturn(first); + when(component.getLastRowWrittenDate()).thenReturn(last); + + assertEquals(5000L, ExecutionStateBuilder.resolveDurationMs(component)); + } + + @Test + void parseSpeedRowsPerSecondHandlesFormattedValues() { + assertEquals(1234L, ExecutionStateBuilder.parseSpeedRowsPerSecond(" 1,234")); + assertEquals(10L, ExecutionStateBuilder.parseSpeedRowsPerSecond("10.4")); + assertNull(ExecutionStateBuilder.parseSpeedRowsPerSecond("-")); + assertNull(ExecutionStateBuilder.parseSpeedRowsPerSecond("")); + assertNull(ExecutionStateBuilder.parseSpeedRowsPerSecond(null)); + } + + @Test + void resolveSpeedFromEngineMap() { + IEngineComponent component = mock(IEngineComponent.class); + EngineMetrics metrics = new EngineMetrics(); + Map speedMap = new HashMap<>(); + speedMap.put(component, " 500"); + metrics.setComponentSpeedMap(speedMap); + + assertEquals(500L, ExecutionStateBuilder.resolveSpeedRowsPerSecond(component, metrics, 1000L)); + } + + @Test + void resolveSpeedFromRowCountsAndDuration() { + IEngineComponent component = mock(IEngineComponent.class); + when(component.getLinesInput()).thenReturn(0L); + when(component.getLinesRead()).thenReturn(1000L); + when(component.getLinesOutput()).thenReturn(0L); + when(component.getLinesUpdated()).thenReturn(0L); + when(component.getLinesWritten()).thenReturn(1000L); + when(component.getLinesRejected()).thenReturn(0L); + + // 1000 rows over 2 seconds => 500 r/s + assertEquals( + 500L, + ExecutionStateBuilder.resolveSpeedRowsPerSecond(component, new EngineMetrics(), 2000L)); + } +} diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java index f768fae5f22..05672e5cc38 100644 --- a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java +++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java @@ -1194,6 +1194,7 @@ public void copyToClipboard() { String filename = execution.getFilename(); String relativeFilename = toProjectRelativePath(filename); + String storageRoot = resolveStorageRootPath(getSelectedExecutionLocation()); String storagePath = resolveStoragePath(getSelectedExecutionLocation(), execution); String uuid = execution.getId(); @@ -1209,6 +1210,11 @@ public void copyToClipboard() { BaseMessages.getString(PKG, "ExecutionPerspective.CopyMenu.RelativeFilename"), relativeFilename, StringUtils.isNotEmpty(relativeFilename)); + addCopyMenuItem( + menu, + BaseMessages.getString(PKG, "ExecutionPerspective.CopyMenu.StorageRoot"), + storageRoot, + StringUtils.isNotEmpty(storageRoot)); addCopyMenuItem( menu, BaseMessages.getString(PKG, "ExecutionPerspective.CopyMenu.StoragePath"), @@ -1291,8 +1297,8 @@ private String toProjectRelativePath(String path) { * Builds the on-disk storage path for file-based execution locations, or {@code null} when the * location type does not store execution information as files/folders. */ - private String resolveStoragePath(ExecutionInfoLocation location, Execution execution) { - if (location == null || execution == null || StringUtils.isEmpty(execution.getId())) { + private String resolveStorageRootPath(ExecutionInfoLocation location) { + if (location == null) { return null; } IExecutionInfoLocation iLocation = location.getExecutionInfoLocation(); @@ -1301,10 +1307,7 @@ private String resolveStoragePath(ExecutionInfoLocation location, Execution exec if (StringUtils.isEmpty(root)) { return null; } - if (root.endsWith("/") || root.endsWith("\\")) { - return root + execution.getId(); - } - return root + "/" + execution.getId(); + return root; } if (iLocation instanceof CachingFileExecutionInfoLocation cachingLocation) { String root = cachingLocation.getActualRootFolder(); @@ -1314,6 +1317,28 @@ private String resolveStoragePath(ExecutionInfoLocation location, Execution exec if (StringUtils.isEmpty(root)) { return null; } + return root; + } + return null; + } + + /** + * Builds the on-disk storage path for file-based execution locations, or {@code null} when the + * location type does not store execution information as files/folders. + */ + private String resolveStoragePath(ExecutionInfoLocation location, Execution execution) { + String root = resolveStorageRootPath(location); + if (StringUtils.isEmpty(root)) { + return null; + } + IExecutionInfoLocation iLocation = location.getExecutionInfoLocation(); + if (iLocation instanceof FileExecutionInfoLocation fileLocation) { + if (root.endsWith("/") || root.endsWith("\\")) { + return root + execution.getId(); + } + return root + "/" + execution.getId(); + } + if (iLocation instanceof CachingFileExecutionInfoLocation cachingLocation) { // Match CacheEntry.calculateFilename: root + id + ".json" if (root.endsWith("/") || root.endsWith("\\")) { return root + execution.getId() + ".json"; diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/PipelineExecutionViewer.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/PipelineExecutionViewer.java index 14e8289aa3c..4c6a64cecd9 100644 --- a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/PipelineExecutionViewer.java +++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/PipelineExecutionViewer.java @@ -54,6 +54,7 @@ import org.apache.hop.core.row.IValueMeta; import org.apache.hop.core.row.RowBuffer; import org.apache.hop.core.row.RowMetaBuilder; +import org.apache.hop.core.util.Utils; import org.apache.hop.core.variables.IVariables; import org.apache.hop.core.variables.Variables; import org.apache.hop.core.xml.XmlHandler; @@ -63,6 +64,7 @@ import org.apache.hop.execution.ExecutionDataSetMeta; import org.apache.hop.execution.ExecutionInfoLocation; import org.apache.hop.execution.ExecutionState; +import org.apache.hop.execution.ExecutionStateBuilder; import org.apache.hop.execution.ExecutionStateComponentMetrics; import org.apache.hop.execution.ExecutionType; import org.apache.hop.execution.IExecutionInfoLocation; @@ -331,6 +333,7 @@ private void refreshStatus() { infoView.add("Registration", formatDate(execution.getRegistrationDate())); infoView.add("Start", formatDate(execution.getExecutionStartDate())); infoView.add("End", formatDate(executionState.getExecutionEndDate())); + infoView.add("Duration", formatExecutionDuration(execution, executionState)); infoView.add("Type", executionState.getExecutionType().name()); infoView.add("Status", statusDescription); infoView.add("Status Last updated", formatDate(executionState.getUpdateTime())); @@ -472,7 +475,7 @@ private void refreshMetrics() { // We display everything that makes sense: // name, copy, inits, input, read, written, output, updated, rejected, errors, buffer in, - // buffer out, data volume, data volume in, data volume out + // buffer out, data volume, data volume in, data volume out, duration, speed // List columns = new ArrayList<>(); columns.add(new ColumnInfo("Name", ColumnInfo.COLUMN_TYPE_TEXT, false, true)); @@ -491,6 +494,9 @@ private void refreshMetrics() { addColumn(columns, indexMap, metricNames, Pipeline.METRIC_DATA_VOLUME); addColumn(columns, indexMap, metricNames, Pipeline.METRIC_DATA_VOLUME_IN); addColumn(columns, indexMap, metricNames, Pipeline.METRIC_DATA_VOLUME_OUT); + // Always reserve Duration / Speed columns (blank when not recorded for a component). + addNamedColumn(columns, indexMap, ExecutionStateBuilder.METRIC_HEADER_DURATION, "Duration"); + addNamedColumn(columns, indexMap, ExecutionStateBuilder.METRIC_HEADER_SPEED, "Speed (r/s)"); metricsView = new TableView( @@ -512,7 +518,11 @@ private void refreshMetrics() { Integer index = indexMap.get(metricHeader); Long value = metrics.getMetrics().get(metricHeader); if (value != null && index != null && index > 1 && index <= columns.size()) { - item.setText(index, value.toString()); + if (ExecutionStateBuilder.METRIC_HEADER_DURATION.equals(metricHeader)) { + item.setText(index, Utils.getDurationHMS(value / 1000.0)); + } else { + item.setText(index, value.toString()); + } } } } @@ -540,6 +550,12 @@ private void addColumn( } } + private void addNamedColumn( + List columns, Map indexMap, String key, String displayHeader) { + columns.add(new ColumnInfo(displayHeader, ColumnInfo.COLUMN_TYPE_TEXT, true, true)); + indexMap.put(key, columns.size()); + } + /** An entry is selected in the data list. Show the corresponding rows. */ private void showDataRows() { int[] weights = dataSash.getWeights(); diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/WorkflowExecutionViewer.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/WorkflowExecutionViewer.java index 428a2ba7067..7cef9e27f7e 100644 --- a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/WorkflowExecutionViewer.java +++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/WorkflowExecutionViewer.java @@ -318,6 +318,8 @@ private void refreshStatus() { infoView.add("Parent ID", execution.getParentId()); infoView.add("Registration", formatDate(execution.getRegistrationDate())); infoView.add("Start", formatDate(execution.getExecutionStartDate())); + infoView.add("End", formatDate(executionState.getExecutionEndDate())); + infoView.add("Duration", formatExecutionDuration(execution, executionState)); infoView.add("Type", executionState.getExecutionType().name()); infoView.add("Status", statusDescription); infoView.add("Status Last updated", formatDate(executionState.getUpdateTime())); diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/shared/BaseExecutionViewer.java b/ui/src/main/java/org/apache/hop/ui/hopgui/shared/BaseExecutionViewer.java index e71411e76f6..316ce7327fa 100644 --- a/ui/src/main/java/org/apache/hop/ui/hopgui/shared/BaseExecutionViewer.java +++ b/ui/src/main/java/org/apache/hop/ui/hopgui/shared/BaseExecutionViewer.java @@ -32,6 +32,7 @@ import org.apache.hop.core.gui.DPoint; import org.apache.hop.core.gui.Point; import org.apache.hop.core.metadata.SerializableMetadataProvider; +import org.apache.hop.core.util.Utils; import org.apache.hop.core.variables.IVariables; import org.apache.hop.core.variables.Variables; import org.apache.hop.execution.Execution; @@ -141,6 +142,33 @@ protected String formatDate(Date date) { return new SimpleDateFormat("yyyy/MM/dd HH:mm:ss").format(date); } + /** + * Formats pipeline/workflow execution duration from start to end (or last update while still + * running). + * + * @return human-readable duration, or empty string when start is unknown + */ + protected String formatExecutionDuration(Execution execution, ExecutionState executionState) { + if (execution == null || execution.getExecutionStartDate() == null) { + return ""; + } + Date end = null; + if (executionState != null) { + end = executionState.getExecutionEndDate(); + if (end == null) { + end = executionState.getUpdateTime(); + } + } + if (end == null) { + return ""; + } + long durationMs = end.getTime() - execution.getExecutionStartDate().getTime(); + if (durationMs < 0) { + return ""; + } + return Utils.getDurationHMS(durationMs / 1000.0); + } + public abstract void drillDownOnLocation(Point location); @Override diff --git a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/execution/messages/messages_en_US.properties b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/execution/messages/messages_en_US.properties index a214e6cca6c..faecfc7af58 100644 --- a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/execution/messages/messages_en_US.properties +++ b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/execution/messages/messages_en_US.properties @@ -25,6 +25,7 @@ ExecutionPerspective.Refresh.Error.Message=There was an error refreshing executi ExecutionPerspective.ToolbarElement.Copy.Tooltip=Copy path or ID to clipboard ExecutionPerspective.CopyMenu.Filename=Copy filename path ExecutionPerspective.CopyMenu.RelativeFilename=Copy relative filename path +ExecutionPerspective.CopyMenu.StorageRoot=Copy storage root path on disk ExecutionPerspective.CopyMenu.StoragePath=Copy storage path on disk ExecutionPerspective.CopyMenu.Uuid=Copy ID ExecutionPerspective.ToolbarElement.Delete.Tooltip=Delete From 5ae737650afe6df424175a7361e691d30d387c0c Mon Sep 17 00:00:00 2001 From: mattcasters Date: Sun, 19 Jul 2026 11:01:43 +0200 Subject: [PATCH 04/15] Issue #2637 : Allow Custom Logging dialog to accept a variable to define logging level --- .../debug/action/ActionDebugGuiPlugin.java | 4 +- .../hop/debug/action/ActionDebugLevel.java | 42 ++++-- .../debug/action/ActionDebugLevelDialog.java | 18 +-- .../EditActionDebugLevelExtensionPoint.java | 2 +- .../ModifyActionLogLevelExtensionPoint.java | 8 +- ...EditTransformDebugLevelExtensionPoint.java | 3 +- .../SetTransformDebugLevelExtensionPoint.java | 11 +- .../transform/TransformDebugGuiPlugin.java | 3 +- .../debug/transform/TransformDebugLevel.java | 36 +++-- .../transform/TransformDebugLevelDialog.java | 17 ++- .../apache/hop/debug/util/DebugLevelUtil.java | 86 +++++++++++- .../hop/debug/util/DebugLevelUtilTest.java | 126 ++++++++++++++++++ 12 files changed, 308 insertions(+), 48 deletions(-) create mode 100644 plugins/misc/debug/src/test/java/org/apache/hop/debug/util/DebugLevelUtilTest.java diff --git a/plugins/misc/debug/src/main/java/org/apache/hop/debug/action/ActionDebugGuiPlugin.java b/plugins/misc/debug/src/main/java/org/apache/hop/debug/action/ActionDebugGuiPlugin.java index 97259f046ea..a90bc7fe80a 100644 --- a/plugins/misc/debug/src/main/java/org/apache/hop/debug/action/ActionDebugGuiPlugin.java +++ b/plugins/misc/debug/src/main/java/org/apache/hop/debug/action/ActionDebugGuiPlugin.java @@ -22,6 +22,7 @@ import org.apache.hop.core.action.GuiContextAction; import org.apache.hop.core.gui.plugin.GuiPlugin; import org.apache.hop.core.gui.plugin.action.GuiActionType; +import org.apache.hop.core.variables.IVariables; import org.apache.hop.debug.util.DebugLevelUtil; import org.apache.hop.debug.util.Defaults; import org.apache.hop.ui.core.dialog.ErrorDialog; @@ -67,6 +68,7 @@ public void applyCustomActionLogging(HopGuiWorkflowActionContext context) { try { WorkflowMeta workflowMeta = context.getWorkflowMeta(); ActionMeta action = context.getActionMeta(); + IVariables variables = context.getWorkflowGraph().getVariables(); Map> attributesMap = workflowMeta.getAttributesMap(); Map debugGroupAttributesMap = attributesMap.get(Defaults.DEBUG_GROUP); @@ -83,7 +85,7 @@ public void applyCustomActionLogging(HopGuiWorkflowActionContext context) { } ActionDebugLevelDialog dialog = - new ActionDebugLevelDialog(hopGui.getActiveShell(), debugLevel); + new ActionDebugLevelDialog(hopGui.getActiveShell(), debugLevel, variables); if (dialog.open()) { DebugLevelUtil.storeActionDebugLevel( debugGroupAttributesMap, action.toString(), debugLevel); diff --git a/plugins/misc/debug/src/main/java/org/apache/hop/debug/action/ActionDebugLevel.java b/plugins/misc/debug/src/main/java/org/apache/hop/debug/action/ActionDebugLevel.java index 32c0934e066..ee39bbee823 100644 --- a/plugins/misc/debug/src/main/java/org/apache/hop/debug/action/ActionDebugLevel.java +++ b/plugins/misc/debug/src/main/java/org/apache/hop/debug/action/ActionDebugLevel.java @@ -20,7 +20,7 @@ import org.apache.hop.core.logging.LogLevel; public class ActionDebugLevel implements Cloneable { - private LogLevel logLevel; + private String logLevel; private boolean loggingResult; private boolean loggingVariables; @@ -28,7 +28,7 @@ public class ActionDebugLevel implements Cloneable { private boolean loggingResultFiles; public ActionDebugLevel() { - logLevel = LogLevel.DEBUG; + logLevel = LogLevel.DEBUG.getCode(); loggingResult = false; loggingVariables = false; loggingResultRows = false; @@ -36,12 +36,17 @@ public ActionDebugLevel() { } public ActionDebugLevel(LogLevel logLevel) { + this(); + this.logLevel = logLevel != null ? logLevel.getCode() : LogLevel.DEBUG.getCode(); + } + + public ActionDebugLevel(String logLevel) { this(); this.logLevel = logLevel; } public ActionDebugLevel( - LogLevel logLevel, + String logLevel, boolean loggingResult, boolean loggingVariables, boolean loggingResultRows, @@ -53,6 +58,20 @@ public ActionDebugLevel( this.loggingResultFiles = loggingResultFiles; } + public ActionDebugLevel( + LogLevel logLevel, + boolean loggingResult, + boolean loggingVariables, + boolean loggingResultRows, + boolean loggingResultFiles) { + this( + logLevel != null ? logLevel.getCode() : LogLevel.DEBUG.getCode(), + loggingResult, + loggingVariables, + loggingResultRows, + loggingResultFiles); + } + @Override public ActionDebugLevel clone() { return new ActionDebugLevel( @@ -61,7 +80,7 @@ public ActionDebugLevel clone() { @Override public String toString() { - String s = logLevel.toString(); + String s = logLevel; if (loggingResult) { s += ", logging result"; } @@ -78,21 +97,28 @@ public String toString() { } /** - * Gets logLevel + * Gets logLevel (code or variable expression) * * @return value of logLevel */ - public LogLevel getLogLevel() { + public String getLogLevel() { return logLevel; } /** - * @param logLevel The logLevel to set + * @param logLevel The logLevel to set (code or variable expression) */ - public void setLogLevel(LogLevel logLevel) { + public void setLogLevel(String logLevel) { this.logLevel = logLevel; } + /** + * @param logLevel The logLevel enum to set (stores its code) + */ + public void setLogLevel(LogLevel logLevel) { + this.logLevel = logLevel != null ? logLevel.getCode() : null; + } + /** * Gets loggingResult * diff --git a/plugins/misc/debug/src/main/java/org/apache/hop/debug/action/ActionDebugLevelDialog.java b/plugins/misc/debug/src/main/java/org/apache/hop/debug/action/ActionDebugLevelDialog.java index 6db37f442e0..53ddb8ba6e4 100644 --- a/plugins/misc/debug/src/main/java/org/apache/hop/debug/action/ActionDebugLevelDialog.java +++ b/plugins/misc/debug/src/main/java/org/apache/hop/debug/action/ActionDebugLevelDialog.java @@ -17,20 +17,21 @@ package org.apache.hop.debug.action; -import org.apache.hop.core.Const; import org.apache.hop.core.logging.LogLevel; +import org.apache.hop.core.variables.IVariables; +import org.apache.hop.debug.util.DebugLevelUtil; import org.apache.hop.i18n.BaseMessages; import org.apache.hop.ui.core.PropsUi; import org.apache.hop.ui.core.dialog.BaseDialog; import org.apache.hop.ui.core.gui.GuiResource; import org.apache.hop.ui.core.gui.WindowProperty; +import org.apache.hop.ui.core.widget.ComboVar; import org.apache.hop.ui.pipeline.transform.BaseTransformDialog; import org.eclipse.swt.SWT; import org.eclipse.swt.layout.FormAttachment; import org.eclipse.swt.layout.FormData; import org.eclipse.swt.layout.FormLayout; import org.eclipse.swt.widgets.Button; -import org.eclipse.swt.widgets.Combo; import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Dialog; import org.eclipse.swt.widgets.Label; @@ -41,12 +42,13 @@ public class ActionDebugLevelDialog extends Dialog { private ActionDebugLevel input; private ActionDebugLevel debugLevel; + private IVariables variables; private Shell shell; // Connection properties // - private Combo wLogLevel; + private ComboVar wLogLevel; private Button wLoggingResult; private Button wLoggingVariables; private Button wLoggingRows; @@ -56,9 +58,10 @@ public class ActionDebugLevelDialog extends Dialog { private boolean ok; - public ActionDebugLevelDialog(Shell par, ActionDebugLevel debugLevel) { + public ActionDebugLevelDialog(Shell par, ActionDebugLevel debugLevel, IVariables variables) { super(par, SWT.NONE); this.input = debugLevel; + this.variables = variables; props = PropsUi.getInstance(); ok = false; @@ -90,7 +93,7 @@ public boolean open() { fdlName.left = new FormAttachment(0, 0); // First one in the left top corner fdlName.right = new FormAttachment(middle, -margin); wlName.setLayoutData(fdlName); - wLogLevel = new Combo(shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wLogLevel = new ComboVar(variables, shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER); wLogLevel.setItems(LogLevel.getLogLevelDescriptions()); PropsUi.setLook(wLogLevel); FormData fdName = new FormData(); @@ -196,7 +199,7 @@ public void dispose() { } public void getData() { - wLogLevel.setText(debugLevel.getLogLevel().getDescription()); + wLogLevel.setText(DebugLevelUtil.logLevelCodeToDisplay(debugLevel.getLogLevel())); wLoggingResult.setSelection(debugLevel.isLoggingResult()); wLoggingVariables.setSelection(debugLevel.isLoggingVariables()); wLoggingRows.setSelection(debugLevel.isLoggingResultRows()); @@ -218,8 +221,7 @@ public void ok() { // Get dialog info in securityService private void getInfo(ActionDebugLevel level) { - int index = Const.indexOfString(wLogLevel.getText(), LogLevel.getLogLevelDescriptions()); - level.setLogLevel(LogLevel.values()[index]); + level.setLogLevel(DebugLevelUtil.logLevelDisplayToCode(wLogLevel.getText())); level.setLoggingResult(wLoggingResult.getSelection()); level.setLoggingVariables(wLoggingVariables.getSelection()); level.setLoggingResultRows(wLoggingRows.getSelection()); diff --git a/plugins/misc/debug/src/main/java/org/apache/hop/debug/action/EditActionDebugLevelExtensionPoint.java b/plugins/misc/debug/src/main/java/org/apache/hop/debug/action/EditActionDebugLevelExtensionPoint.java index fe1a526bfff..ccf644d4513 100644 --- a/plugins/misc/debug/src/main/java/org/apache/hop/debug/action/EditActionDebugLevelExtensionPoint.java +++ b/plugins/misc/debug/src/main/java/org/apache/hop/debug/action/EditActionDebugLevelExtensionPoint.java @@ -48,7 +48,7 @@ public void callExtensionPoint( } ActionDebugLevel debugLevel = (ActionDebugLevel) ext.getAreaOwner().getOwner(); ActionDebugLevelDialog dialog = - new ActionDebugLevelDialog(HopGui.getInstance().getShell(), debugLevel); + new ActionDebugLevelDialog(HopGui.getInstance().getShell(), debugLevel, variables); if (dialog.open()) { WorkflowMeta workflowMeta = ext.getWorkflowGraph().getWorkflowMeta(); ActionMeta actionCopy = (ActionMeta) ext.getAreaOwner().getParent(); diff --git a/plugins/misc/debug/src/main/java/org/apache/hop/debug/action/ModifyActionLogLevelExtensionPoint.java b/plugins/misc/debug/src/main/java/org/apache/hop/debug/action/ModifyActionLogLevelExtensionPoint.java index c39a15659b5..483b57d4e64 100644 --- a/plugins/misc/debug/src/main/java/org/apache/hop/debug/action/ModifyActionLogLevelExtensionPoint.java +++ b/plugins/misc/debug/src/main/java/org/apache/hop/debug/action/ModifyActionLogLevelExtensionPoint.java @@ -144,10 +144,12 @@ public void beforeExecution( final ActionDebugLevel debugLevel = DebugLevelUtil.getActionDebugLevel(entryLevelMap, actionCopy.toString()); if (debugLevel != null) { - // Set the debug level for this one... + // Set the debug level for this one (resolve variables at runtime)... // - log.setLogLevel(debugLevel.getLogLevel()); - workflow.setLogLevel(debugLevel.getLogLevel()); + LogLevel resolvedLogLevel = + DebugLevelUtil.resolveLogLevel(workflow, debugLevel.getLogLevel()); + log.setLogLevel(resolvedLogLevel); + workflow.setLogLevel(resolvedLogLevel); } } } catch (Exception e) { diff --git a/plugins/misc/debug/src/main/java/org/apache/hop/debug/transform/EditTransformDebugLevelExtensionPoint.java b/plugins/misc/debug/src/main/java/org/apache/hop/debug/transform/EditTransformDebugLevelExtensionPoint.java index a82f80887ed..e45bab753af 100644 --- a/plugins/misc/debug/src/main/java/org/apache/hop/debug/transform/EditTransformDebugLevelExtensionPoint.java +++ b/plugins/misc/debug/src/main/java/org/apache/hop/debug/transform/EditTransformDebugLevelExtensionPoint.java @@ -53,7 +53,8 @@ public void callExtensionPoint( IRowMeta inputRowMeta = pipelineMeta.getPrevTransformFields(variables, transformMeta); TransformDebugLevelDialog dialog = - new TransformDebugLevelDialog(HopGui.getInstance().getShell(), debugLevel, inputRowMeta); + new TransformDebugLevelDialog( + HopGui.getInstance().getShell(), debugLevel, inputRowMeta, variables); if (dialog.open()) { DebugLevelUtil.storeTransformDebugLevel( pipelineMeta.getAttributesMap().get(Defaults.DEBUG_GROUP), diff --git a/plugins/misc/debug/src/main/java/org/apache/hop/debug/transform/SetTransformDebugLevelExtensionPoint.java b/plugins/misc/debug/src/main/java/org/apache/hop/debug/transform/SetTransformDebugLevelExtensionPoint.java index 58265e37ac9..50525e70051 100644 --- a/plugins/misc/debug/src/main/java/org/apache/hop/debug/transform/SetTransformDebugLevelExtensionPoint.java +++ b/plugins/misc/debug/src/main/java/org/apache/hop/debug/transform/SetTransformDebugLevelExtensionPoint.java @@ -81,6 +81,8 @@ public void callExtensionPoint( log.logDetailed("Found debug level info for transform " + transformName); List transformCopies = pipeline.getComponentCopies(transformName); + final LogLevel resolvedLogLevel = + DebugLevelUtil.resolveLogLevel(variables, debugLevel.getLogLevel()); if (debugLevel.getStartRow() < 0 && debugLevel.getEndRow() < 0 @@ -89,16 +91,15 @@ public void callExtensionPoint( "Set logging level for transform " + transformName + " to " - + debugLevel.getLogLevel().getDescription()); + + resolvedLogLevel.getDescription()); // Just a general log level on the transform // for (IEngineComponent transformCopy : transformCopies) { - LogLevel logLevel = debugLevel.getLogLevel(); - transformCopy.getLogChannel().setLogLevel(logLevel); + transformCopy.getLogChannel().setLogLevel(resolvedLogLevel); log.logDetailed( "Applied logging level " - + logLevel.getDescription() + + resolvedLogLevel.getDescription() + " on transform copy " + transformCopy.getName() + "." @@ -145,7 +146,7 @@ public void rowReadEvent(IRowMeta rowMeta, Object[] row) { } if (enabled) { - transformCopy.setLogLevel(debugLevel.getLogLevel()); + transformCopy.setLogLevel(resolvedLogLevel); } } diff --git a/plugins/misc/debug/src/main/java/org/apache/hop/debug/transform/TransformDebugGuiPlugin.java b/plugins/misc/debug/src/main/java/org/apache/hop/debug/transform/TransformDebugGuiPlugin.java index a86e3e1eaff..b2c8507b662 100644 --- a/plugins/misc/debug/src/main/java/org/apache/hop/debug/transform/TransformDebugGuiPlugin.java +++ b/plugins/misc/debug/src/main/java/org/apache/hop/debug/transform/TransformDebugGuiPlugin.java @@ -87,7 +87,8 @@ public void applyCustomTransformLogging(HopGuiPipelineTransformContext context) IRowMeta inputRowMeta = pipelineMeta.getPrevTransformFields(variables, transformMeta); TransformDebugLevelDialog dialog = - new TransformDebugLevelDialog(hopGui.getActiveShell(), debugLevel, inputRowMeta); + new TransformDebugLevelDialog( + hopGui.getActiveShell(), debugLevel, inputRowMeta, variables); if (dialog.open()) { DebugLevelUtil.storeTransformDebugLevel( debugGroupAttributesMap, transformMeta.getName(), debugLevel); diff --git a/plugins/misc/debug/src/main/java/org/apache/hop/debug/transform/TransformDebugLevel.java b/plugins/misc/debug/src/main/java/org/apache/hop/debug/transform/TransformDebugLevel.java index 4581bead224..e133fdd2bfc 100644 --- a/plugins/misc/debug/src/main/java/org/apache/hop/debug/transform/TransformDebugLevel.java +++ b/plugins/misc/debug/src/main/java/org/apache/hop/debug/transform/TransformDebugLevel.java @@ -21,33 +21,46 @@ import org.apache.hop.core.logging.LogLevel; public class TransformDebugLevel implements Cloneable { - private LogLevel logLevel; + private String logLevel; private int startRow; private int endRow; private Condition condition; public TransformDebugLevel() { condition = new Condition(); - logLevel = LogLevel.DEBUG; + logLevel = LogLevel.DEBUG.getCode(); startRow = -1; endRow = -1; } public TransformDebugLevel(LogLevel logLevel) { + this(); + this.logLevel = logLevel != null ? logLevel.getCode() : LogLevel.DEBUG.getCode(); + } + + public TransformDebugLevel(String logLevel) { this(); this.logLevel = logLevel; } - public TransformDebugLevel(LogLevel logLevel, int startRow, int endRow, Condition condition) { + public TransformDebugLevel(String logLevel, int startRow, int endRow, Condition condition) { this(logLevel); this.startRow = startRow; this.endRow = endRow; this.condition = condition; } + public TransformDebugLevel(LogLevel logLevel, int startRow, int endRow, Condition condition) { + this( + logLevel != null ? logLevel.getCode() : LogLevel.DEBUG.getCode(), + startRow, + endRow, + condition); + } + @Override public String toString() { - String s = logLevel.toString(); + String s = logLevel; if (startRow >= 0) { s += ", start row=" + startRow; } @@ -66,21 +79,28 @@ public TransformDebugLevel clone() { } /** - * Gets logLevel + * Gets logLevel (code or variable expression) * * @return value of logLevel */ - public LogLevel getLogLevel() { + public String getLogLevel() { return logLevel; } /** - * @param logLevel The logLevel to set + * @param logLevel The logLevel to set (code or variable expression) */ - public void setLogLevel(LogLevel logLevel) { + public void setLogLevel(String logLevel) { this.logLevel = logLevel; } + /** + * @param logLevel The logLevel enum to set (stores its code) + */ + public void setLogLevel(LogLevel logLevel) { + this.logLevel = logLevel != null ? logLevel.getCode() : null; + } + /** * Gets startRow * diff --git a/plugins/misc/debug/src/main/java/org/apache/hop/debug/transform/TransformDebugLevelDialog.java b/plugins/misc/debug/src/main/java/org/apache/hop/debug/transform/TransformDebugLevelDialog.java index 2e5b003ff40..ad1b397a6ab 100644 --- a/plugins/misc/debug/src/main/java/org/apache/hop/debug/transform/TransformDebugLevelDialog.java +++ b/plugins/misc/debug/src/main/java/org/apache/hop/debug/transform/TransformDebugLevelDialog.java @@ -20,11 +20,14 @@ import org.apache.hop.core.Const; import org.apache.hop.core.logging.LogLevel; import org.apache.hop.core.row.IRowMeta; +import org.apache.hop.core.variables.IVariables; +import org.apache.hop.debug.util.DebugLevelUtil; import org.apache.hop.i18n.BaseMessages; import org.apache.hop.ui.core.PropsUi; import org.apache.hop.ui.core.dialog.BaseDialog; import org.apache.hop.ui.core.gui.GuiResource; import org.apache.hop.ui.core.gui.WindowProperty; +import org.apache.hop.ui.core.widget.ComboVar; import org.apache.hop.ui.core.widget.ConditionEditor; import org.apache.hop.ui.pipeline.transform.BaseTransformDialog; import org.eclipse.swt.SWT; @@ -32,7 +35,6 @@ import org.eclipse.swt.layout.FormData; import org.eclipse.swt.layout.FormLayout; import org.eclipse.swt.widgets.Button; -import org.eclipse.swt.widgets.Combo; import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Dialog; import org.eclipse.swt.widgets.Label; @@ -45,12 +47,13 @@ public class TransformDebugLevelDialog extends Dialog { private TransformDebugLevel input; private TransformDebugLevel debugLevel; private IRowMeta inputRowMeta; + private IVariables variables; private Shell shell; // Connection properties // - private Combo wLogLevel; + private ComboVar wLogLevel; private Text wStartRow; private Text wEndRow; @@ -59,10 +62,11 @@ public class TransformDebugLevelDialog extends Dialog { private boolean ok; public TransformDebugLevelDialog( - Shell par, TransformDebugLevel debugLevel, IRowMeta inputRowMeta) { + Shell par, TransformDebugLevel debugLevel, IRowMeta inputRowMeta, IVariables variables) { super(par, SWT.NONE); this.input = debugLevel; this.inputRowMeta = inputRowMeta; + this.variables = variables; props = PropsUi.getInstance(); ok = false; @@ -94,7 +98,7 @@ public boolean open() { fdlName.left = new FormAttachment(0, 0); // First one in the left top corner fdlName.right = new FormAttachment(middle, -margin); wlName.setLayoutData(fdlName); - wLogLevel = new Combo(shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wLogLevel = new ComboVar(variables, shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER); wLogLevel.setItems(LogLevel.getLogLevelDescriptions()); PropsUi.setLook(wLogLevel); FormData fdName = new FormData(); @@ -184,7 +188,7 @@ public void dispose() { } public void getData() { - wLogLevel.setText(debugLevel.getLogLevel().getDescription()); + wLogLevel.setText(DebugLevelUtil.logLevelCodeToDisplay(debugLevel.getLogLevel())); wStartRow.setText( debugLevel.getStartRow() < 0 ? "" : Integer.toString(debugLevel.getStartRow())); wEndRow.setText(debugLevel.getEndRow() < 0 ? "" : Integer.toString(debugLevel.getEndRow())); @@ -205,8 +209,7 @@ public void ok() { // Get dialog info in securityService private void getInfo(TransformDebugLevel level) { - int index = Const.indexOfString(wLogLevel.getText(), LogLevel.getLogLevelDescriptions()); - level.setLogLevel(LogLevel.values()[index]); + level.setLogLevel(DebugLevelUtil.logLevelDisplayToCode(wLogLevel.getText())); level.setStartRow(Const.toInt(wStartRow.getText(), -1)); level.setEndRow(Const.toInt(wEndRow.getText(), -1)); level.setCondition(debugLevel.getCondition()); diff --git a/plugins/misc/debug/src/main/java/org/apache/hop/debug/util/DebugLevelUtil.java b/plugins/misc/debug/src/main/java/org/apache/hop/debug/util/DebugLevelUtil.java index e3b2d80ad30..b8ef3c53329 100644 --- a/plugins/misc/debug/src/main/java/org/apache/hop/debug/util/DebugLevelUtil.java +++ b/plugins/misc/debug/src/main/java/org/apache/hop/debug/util/DebugLevelUtil.java @@ -28,6 +28,7 @@ import org.apache.hop.core.exception.HopValueException; import org.apache.hop.core.exception.HopXmlException; import org.apache.hop.core.logging.LogLevel; +import org.apache.hop.core.variables.IVariables; import org.apache.hop.debug.action.ActionDebugLevel; import org.apache.hop.debug.transform.TransformDebugLevel; @@ -39,8 +40,7 @@ public static void storeTransformDebugLevel( TransformDebugLevel debugLevel) throws HopValueException, UnsupportedEncodingException { debugGroupAttributesMap.put( - transformName + " : " + Defaults.TRANSFORM_ATTR_LOGLEVEL, - debugLevel.getLogLevel().getCode()); + transformName + " : " + Defaults.TRANSFORM_ATTR_LOGLEVEL, debugLevel.getLogLevel()); debugGroupAttributesMap.put( transformName + " : " + Defaults.TRANSFORM_ATTR_START_ROW, Integer.toString(debugLevel.getStartRow())); @@ -75,7 +75,7 @@ public static TransformDebugLevel getTransformDebugLevel( } TransformDebugLevel debugLevel = new TransformDebugLevel(); - debugLevel.setLogLevel(LogLevel.lookupCode(logLevelCode)); + debugLevel.setLogLevel(logLevelCode); debugLevel.setStartRow(Const.toInt(startRowString, -1)); debugLevel.setEndRow(Const.toInt(endRowString, -1)); @@ -104,7 +104,7 @@ public static void clearDebugLevel( public static void storeActionDebugLevel( Map debugGroupAttributesMap, String entryName, ActionDebugLevel debugLevel) { debugGroupAttributesMap.put( - entryName + " : " + Defaults.ACTION_ATTR_LOGLEVEL, debugLevel.getLogLevel().getCode()); + entryName + " : " + Defaults.ACTION_ATTR_LOGLEVEL, debugLevel.getLogLevel()); debugGroupAttributesMap.put( entryName + " : " + Defaults.ACTION_ATTR_LOG_RESULT, debugLevel.isLoggingResult() ? "Y" : "N"); @@ -151,7 +151,7 @@ public static ActionDebugLevel getActionDebugLevel( } ActionDebugLevel debugLevel = new ActionDebugLevel(); - debugLevel.setLogLevel(LogLevel.lookupCode(logLevelCode)); + debugLevel.setLogLevel(logLevelCode); debugLevel.setLoggingResult(loggingResult); debugLevel.setLoggingVariables(loggingVariables); debugLevel.setLoggingResultRows(loggingResultRows); @@ -160,6 +160,82 @@ public static ActionDebugLevel getActionDebugLevel( return debugLevel; } + /** + * Resolve a log level specification (code, description, or variable expression) to a {@link + * LogLevel}. Variables are expanded first, then matched against codes (preferred) and + * descriptions. Falls back to {@link LogLevel#BASIC} when unresolved or unrecognized (same + * default as {@link LogLevel#lookupCode(String)}). + * + * @param variables variable space used to resolve expressions (may be null) + * @param logLevelSpec stored code, description, or variable expression + * @return resolved log level + */ + public static LogLevel resolveLogLevel(IVariables variables, String logLevelSpec) { + String resolved = + variables != null + ? variables.resolve(Const.NVL(logLevelSpec, "")) + : Const.NVL(logLevelSpec, ""); + if (StringUtils.isEmpty(resolved)) { + return LogLevel.BASIC; + } + // Prefer exact code match so unknown values do not silently become BASIC via lookupCode + for (LogLevel level : LogLevel.values()) { + if (level.getCode().equalsIgnoreCase(resolved)) { + return level; + } + } + for (LogLevel level : LogLevel.values()) { + if (level.getDescription().equalsIgnoreCase(resolved)) { + return level; + } + } + return LogLevel.BASIC; + } + + /** + * Map a stored log level code to its description for UI display. If the value is not a known code + * (e.g. a variable expression), return it unchanged. + * + * @param logLevelSpec stored code or variable expression + * @return description for a known code, otherwise the original string + */ + public static String logLevelCodeToDisplay(String logLevelSpec) { + if (StringUtils.isEmpty(logLevelSpec)) { + return Const.NVL(logLevelSpec, ""); + } + for (LogLevel level : LogLevel.values()) { + if (level.getCode().equalsIgnoreCase(logLevelSpec)) { + return level.getDescription(); + } + } + return logLevelSpec; + } + + /** + * Map dialog text to a value suitable for storage. If the text matches a log level description, + * store the stable code; otherwise store the text as-is (variable or free-form). + * + * @param displayText combo text from the dialog + * @return code for a known description, otherwise the original text + */ + public static String logLevelDisplayToCode(String displayText) { + if (StringUtils.isEmpty(displayText)) { + return displayText; + } + for (LogLevel level : LogLevel.values()) { + if (level.getDescription().equalsIgnoreCase(displayText)) { + return level.getCode(); + } + } + // Already a code? + for (LogLevel level : LogLevel.values()) { + if (level.getCode().equalsIgnoreCase(displayText)) { + return level.getCode(); + } + } + return displayText; + } + public static String getDurationHMS(double seconds) { int day = (int) TimeUnit.SECONDS.toDays((long) seconds); long hours = TimeUnit.SECONDS.toHours((long) seconds) - (day * 24); diff --git a/plugins/misc/debug/src/test/java/org/apache/hop/debug/util/DebugLevelUtilTest.java b/plugins/misc/debug/src/test/java/org/apache/hop/debug/util/DebugLevelUtilTest.java new file mode 100644 index 00000000000..733a15968b2 --- /dev/null +++ b/plugins/misc/debug/src/test/java/org/apache/hop/debug/util/DebugLevelUtilTest.java @@ -0,0 +1,126 @@ +/* + * 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.debug.util; + +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 java.util.HashMap; +import java.util.Map; +import org.apache.hop.core.logging.LogLevel; +import org.apache.hop.core.variables.Variables; +import org.apache.hop.debug.action.ActionDebugLevel; +import org.apache.hop.debug.transform.TransformDebugLevel; +import org.junit.jupiter.api.Test; + +class DebugLevelUtilTest { + + @Test + void storeAndLoadTransformDebugLevelWithCode() throws Exception { + Map map = new HashMap<>(); + TransformDebugLevel level = new TransformDebugLevel(LogLevel.DEBUG); + level.setStartRow(1); + level.setEndRow(10); + + DebugLevelUtil.storeTransformDebugLevel(map, "MyTransform", level); + TransformDebugLevel loaded = DebugLevelUtil.getTransformDebugLevel(map, "MyTransform"); + + assertNotNull(loaded); + assertEquals(LogLevel.DEBUG.getCode(), loaded.getLogLevel()); + assertEquals(1, loaded.getStartRow()); + assertEquals(10, loaded.getEndRow()); + } + + @Test + void storeAndLoadTransformDebugLevelWithVariable() throws Exception { + Map map = new HashMap<>(); + TransformDebugLevel level = new TransformDebugLevel("${MY_LOG_LEVEL}"); + + DebugLevelUtil.storeTransformDebugLevel(map, "MyTransform", level); + TransformDebugLevel loaded = DebugLevelUtil.getTransformDebugLevel(map, "MyTransform"); + + assertNotNull(loaded); + assertEquals("${MY_LOG_LEVEL}", loaded.getLogLevel()); + } + + @Test + void storeAndLoadActionDebugLevelWithVariable() { + Map map = new HashMap<>(); + ActionDebugLevel level = new ActionDebugLevel("${ACTION_LEVEL}"); + level.setLoggingResult(true); + + DebugLevelUtil.storeActionDebugLevel(map, "MyAction", level); + ActionDebugLevel loaded = DebugLevelUtil.getActionDebugLevel(map, "MyAction"); + + assertNotNull(loaded); + assertEquals("${ACTION_LEVEL}", loaded.getLogLevel()); + assertEquals(true, loaded.isLoggingResult()); + } + + @Test + void getDebugLevelReturnsNullWhenMissing() throws Exception { + Map map = new HashMap<>(); + assertNull(DebugLevelUtil.getTransformDebugLevel(map, "Missing")); + assertNull(DebugLevelUtil.getActionDebugLevel(map, "Missing")); + } + + @Test + void resolveLogLevelFromCode() { + Variables variables = new Variables(); + assertEquals(LogLevel.ROWLEVEL, DebugLevelUtil.resolveLogLevel(variables, "Rowlevel")); + assertEquals(LogLevel.DEBUG, DebugLevelUtil.resolveLogLevel(variables, "Debug")); + } + + @Test + void resolveLogLevelFromVariable() { + Variables variables = new Variables(); + variables.setVariable("MY_LEVEL", "Detailed"); + assertEquals(LogLevel.DETAILED, DebugLevelUtil.resolveLogLevel(variables, "${MY_LEVEL}")); + } + + @Test + void resolveLogLevelFromDescription() { + Variables variables = new Variables(); + String description = LogLevel.MINIMAL.getDescription(); + assertEquals(LogLevel.MINIMAL, DebugLevelUtil.resolveLogLevel(variables, description)); + } + + @Test + void resolveLogLevelUnknownFallsBackToBasic() { + Variables variables = new Variables(); + assertEquals(LogLevel.BASIC, DebugLevelUtil.resolveLogLevel(variables, "not-a-level")); + assertEquals(LogLevel.BASIC, DebugLevelUtil.resolveLogLevel(variables, "")); + assertEquals(LogLevel.BASIC, DebugLevelUtil.resolveLogLevel(null, null)); + } + + @Test + void logLevelCodeToDisplayMapsCodeToDescription() { + assertEquals(LogLevel.DEBUG.getDescription(), DebugLevelUtil.logLevelCodeToDisplay("Debug")); + assertEquals("${MY_LEVEL}", DebugLevelUtil.logLevelCodeToDisplay("${MY_LEVEL}")); + } + + @Test + void logLevelDisplayToCodeMapsDescriptionToCode() { + assertEquals( + LogLevel.DEBUG.getCode(), + DebugLevelUtil.logLevelDisplayToCode(LogLevel.DEBUG.getDescription())); + assertEquals("${MY_LEVEL}", DebugLevelUtil.logLevelDisplayToCode("${MY_LEVEL}")); + assertEquals("Debug", DebugLevelUtil.logLevelDisplayToCode("Debug")); + } +} From 78d5e466e3b83688ed8303aea44c1d6e2e52dcef Mon Sep 17 00:00:00 2001 From: mattcasters Date: Sun, 19 Jul 2026 11:33:02 +0200 Subject: [PATCH 05/15] Issue #2400 : As a Developer I would like to be able to generate database connection parameters --- .../database/generate-variables-proposals.png | Bin 0 -> 185460 bytes ...ables-save-as-env-config-file-question.png | Bin 0 -> 33504 bytes .../ROOT/pages/database/databases.adoc | 104 ++++++++- .../DatabaseConnectionVariablesHelper.java | 175 ++++++++++++++ .../ui/core/database/DatabaseMetaEditor.java | 213 +++++++++++++++++- .../messages/messages_en_US.properties | 18 ++ ...DatabaseConnectionVariablesHelperTest.java | 141 ++++++++++++ 7 files changed, 649 insertions(+), 2 deletions(-) create mode 100644 docs/hop-user-manual/modules/ROOT/assets/images/database/generate-variables-proposals.png create mode 100644 docs/hop-user-manual/modules/ROOT/assets/images/database/generate-variables-save-as-env-config-file-question.png create mode 100644 ui/src/main/java/org/apache/hop/ui/core/database/DatabaseConnectionVariablesHelper.java create mode 100644 ui/src/test/java/org/apache/hop/ui/core/database/DatabaseConnectionVariablesHelperTest.java diff --git a/docs/hop-user-manual/modules/ROOT/assets/images/database/generate-variables-proposals.png b/docs/hop-user-manual/modules/ROOT/assets/images/database/generate-variables-proposals.png new file mode 100644 index 0000000000000000000000000000000000000000..f2c6c95f2846e69a7551f4c877ad20607eb28e08 GIT binary patch literal 185460 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 ACjbBd literal 0 HcmV?d00001 diff --git a/docs/hop-user-manual/modules/ROOT/assets/images/database/generate-variables-save-as-env-config-file-question.png b/docs/hop-user-manual/modules/ROOT/assets/images/database/generate-variables-save-as-env-config-file-question.png new file mode 100644 index 0000000000000000000000000000000000000000..6afae6cc97778035b327fb5678538587607ca747 GIT binary patch literal 33504 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 Kmpps*?tcMI@w7_- literal 0 HcmV?d00001 diff --git a/docs/hop-user-manual/modules/ROOT/pages/database/databases.adoc b/docs/hop-user-manual/modules/ROOT/pages/database/databases.adoc index 4d3f0dfc710..238df15b292 100644 --- a/docs/hop-user-manual/modules/ROOT/pages/database/databases.adoc +++ b/docs/hop-user-manual/modules/ROOT/pages/database/databases.adoc @@ -18,7 +18,10 @@ under the License. :imagesdir: ../../assets/images :openvar: ${ :closevar: } -:description: Hop supports tens of databases out of the box. If your preferred database has no specific support, you can probably still connect through a generic database connection. +:description: Documentation about relational database connections + +:TOC: + = Database Plugins Apache Hop has optimized support for many database types. If you are using an unsupported database, you can always create a generic connection. @@ -28,6 +31,105 @@ To create a database connection go to the metadata perspective, right-click on ` The connection is saved in a central location and can then be used by all pipelines and workflows. If you have set your project to work with Hop, the database information will be in the `{openvar}PROJECT_HOME{closevar}/metadata/rdbms` folder. For each connection, a separate .json file will be generated in this folder. This json file will have the name of the connection and will contain all of the connection information. +TIP: Prefer variables for host, port, database name, username and password instead of hard-coded values. +The same connection metadata can then run unchanged in Development, Test, Acceptance and Production (DTAP) by loading different environment configuration files. +See xref:projects/index.adoc[Projects and environments] for how lifecycle environments work. + +== Generate environment variables + +Best practice is to parameterize relational database connections with environment variables. +The connection editor can generate those variables for you so you do not have to invent names or edit configuration files by hand in several places. + +=== How to use it + +. Open a relational database connection in the Metadata perspective and give it a name (for example `EDW`). +. Optionally fill in the current host, port, database, username, password and/or manual URL values. +. Click *Generate variables* in the button bar (next to Explore and Test). +. Review the proposed variables in the dialog. +You can change names, values and descriptions. +Leave values empty when you want a template without secrets or environment-specific data. ++ +image::database/generate-variables-proposals.png[Proposed environment variables for connection EDW,width="90%"] +. When asked, choose whether to create or update an environment configuration JSON file, and pick a filename (for example `{openvar}PROJECT_HOME{closevar}/EDW-config.json`). ++ +image::database/generate-variables-save-as-env-config-file-question.png[Prompt to create or update an environment configuration JSON file for DTAP,width="70%"] +. The editor replaces the connection fields with variable expressions such as `{openvar}EDW_HOSTNAME{closevar}`. + +For a connection named `EDW`, Hop typically proposes: + +[%header, width="90%", cols="2,3,3"] +|=== +|Variable|Purpose|When proposed +|`EDW_HOSTNAME`|Server host name|Always +|`EDW_PORT`|Port number|Always +|`EDW_DATABASE`|Database / catalog name|Always +|`EDW_USERNAME`|Username|When the username field is available for the connection type +|`EDW_PASSWORD`|Password|When the password field is available for the connection type +|`EDW_URL`|Manual JDBC URL|Only when a manual URL is filled in +|=== + +The connection name is turned into a variable prefix (upper case; characters other than letters, digits and underscore become `_`). +For example `My DB` becomes `MY_DB_HOSTNAME`, `MY_DB_PORT`, and so on. + +Each variable gets a short description (for example “Hostname for database connection EDW”). +In the review dialog you can encode sensitive values with the *Encode value* action if you store them encrypted in the configuration file. + +After the file is written, add it to the configuration files list of the relevant xref:projects/projects-environments.adoc[lifecycle environment] so Hop loads the variables when that environment is selected. +Paths in the file dialog may use variables such as `{openvar}PROJECT_HOME{closevar}`; Hop resolves them when reading or writing the file. + +=== DTAP and empty configuration files + +Development, Test, Acceptance and Production (DTAP) usually need the *same* variable names and the *same* connection metadata, but *different* host, credentials and sometimes database names. + +A practical pattern is: + +. *Generate once in the project* +Use *Generate variables* and create a configuration JSON that lists the variable names (and descriptions) for the connection. +Clear the values in the review dialog (or leave them empty) so the file contains structure only—no hostnames, passwords or other secrets. +Commit this empty or minimal template to version control with the project (or in a shared config location your team uses for templates). + +. *Fill values per environment outside the main project tree* +For each lifecycle stage (Development, Test, Acceptance, Production), maintain a copy of that configuration file with the real values for that stage. +Those files are typically *not* the empty template: they are deployed with the server, CI agent or runtime, or kept in a separate repository/secure store. +Point each Hop xref:projects/projects-environments.adoc[environment] at its own configuration file(s). + +. *Keep pipelines and connection metadata environment-agnostic* +Because the RDBMS connection stores only expressions like `{openvar}EDW_HOSTNAME{closevar}`, the same metadata under `{openvar}PROJECT_HOME{closevar}/metadata/rdbms` works in every stage. +You switch behavior by selecting a different environment (and therefore a different set of variable values), not by editing connections or pipelines. + +TIP: Store environment-specific configuration files *outside* the project home when they contain secrets or production endpoints. +See the tip in xref:projects/index.adoc#_environments[Environments]: keep them out of the project folder and consider a separate version control repository for environment configs. + +.Example empty / template environment config (structure only) +[source,json] +---- +{ + "variables" : [ { + "name" : "EDW_HOSTNAME", + "value" : "", + "description" : "Hostname for database connection EDW" + }, { + "name" : "EDW_PORT", + "value" : "", + "description" : "Port for database connection EDW" + }, { + "name" : "EDW_DATABASE", + "value" : "", + "description" : "Database name for database connection EDW" + }, { + "name" : "EDW_USERNAME", + "value" : "", + "description" : "Username for database connection EDW" + }, { + "name" : "EDW_PASSWORD", + "value" : "", + "description" : "Password for database connection EDW" + } ] +} +---- + +A Development environment file might set `EDW_HOSTNAME` to `localhost` and use local credentials; Production would point at the production host and credentials—same variable names, different values, same project metadata. + == Adding JDBC drivers Apache Hop ships the JDBC drivers it is allowed to redistribute - those under a license compatible with the Apache License - in the `lib/jdbc` folder of your installation. For databases whose driver has a restricted license, or that simply isn't bundled, Hop can download the driver for you on demand, or you can add it manually. diff --git a/ui/src/main/java/org/apache/hop/ui/core/database/DatabaseConnectionVariablesHelper.java b/ui/src/main/java/org/apache/hop/ui/core/database/DatabaseConnectionVariablesHelper.java new file mode 100644 index 00000000000..0db65bdc8c3 --- /dev/null +++ b/ui/src/main/java/org/apache/hop/ui/core/database/DatabaseConnectionVariablesHelper.java @@ -0,0 +1,175 @@ +/* + * 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.ui.core.database; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.apache.commons.lang3.StringUtils; +import org.apache.hop.core.Const; +import org.apache.hop.core.variables.DescribedVariable; + +/** + * Builds proposed environment variables for a named relational database connection (for example + * {@code EDW_HOSTNAME}) and maps dialog results back to connection fields by name suffix. + */ +public final class DatabaseConnectionVariablesHelper { + + public static final String SUFFIX_HOSTNAME = "_HOSTNAME"; + public static final String SUFFIX_PORT = "_PORT"; + public static final String SUFFIX_DATABASE = "_DATABASE"; + public static final String SUFFIX_USERNAME = "_USERNAME"; + public static final String SUFFIX_PASSWORD = "_PASSWORD"; + public static final String SUFFIX_URL = "_URL"; + + private static final String[] FIELD_SUFFIXES = { + SUFFIX_HOSTNAME, SUFFIX_PORT, SUFFIX_DATABASE, SUFFIX_USERNAME, SUFFIX_PASSWORD, SUFFIX_URL, + }; + + private DatabaseConnectionVariablesHelper() { + // Utility class + } + + /** + * Sanitize a connection name into a variable name prefix. + * + *

Example: {@code "My DB"} → {@code "MY_DB"}, {@code "EDW"} → {@code "EDW"}. + * + * @param name connection name + * @return uppercase prefix safe for variable names, or empty string if name is blank + */ + public static String sanitizeConnectionNamePrefix(String name) { + if (StringUtils.isBlank(name)) { + return ""; + } + String prefix = name.trim().toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9_]+", "_"); + prefix = prefix.replaceAll("_+", "_"); + return StringUtils.strip(prefix, "_"); + } + + /** + * @param varName variable name without braces + * @return Hop variable expression such as {@code ${EDW_HOSTNAME}} + */ + public static String expressionFor(String varName) { + return "${" + varName + "}"; + } + + /** + * Build proposed described variables from the current connection field values. + * + * @param connectionName human-readable connection name (used in descriptions) + * @param hostname current hostname value + * @param port current port value + * @param databaseName current database name value + * @param username current username value + * @param password current password value + * @param manualUrl current manual URL value (variable proposed only when non-blank) + * @param includeUsername whether username is part of the editor + * @param includePassword whether password is part of the editor + * @param descriptionsBySuffix map of suffix → description text (e.g. {@code _HOSTNAME} → "…") + * @return ordered list of proposed variables + */ + public static List buildProposedVariables( + String connectionName, + String hostname, + String port, + String databaseName, + String username, + String password, + String manualUrl, + boolean includeUsername, + boolean includePassword, + Map descriptionsBySuffix) { + String prefix = sanitizeConnectionNamePrefix(connectionName); + List variables = new ArrayList<>(); + if (StringUtils.isEmpty(prefix)) { + return variables; + } + + addVariable(variables, prefix, SUFFIX_HOSTNAME, hostname, descriptionsBySuffix); + addVariable(variables, prefix, SUFFIX_PORT, port, descriptionsBySuffix); + addVariable(variables, prefix, SUFFIX_DATABASE, databaseName, descriptionsBySuffix); + + if (includeUsername) { + addVariable(variables, prefix, SUFFIX_USERNAME, username, descriptionsBySuffix); + } + if (includePassword) { + addVariable(variables, prefix, SUFFIX_PASSWORD, password, descriptionsBySuffix); + } + if (StringUtils.isNotBlank(manualUrl)) { + addVariable(variables, prefix, SUFFIX_URL, manualUrl, descriptionsBySuffix); + } + + return variables; + } + + /** + * Map variable names from a (possibly edited) list by known field suffix. If multiple names share + * a suffix, the first match wins (stable order of the list). + * + * @param variables variables returned from the review dialog + * @return suffix → full variable name + */ + public static Map findVariableNamesBySuffix(List variables) { + Map bySuffix = new LinkedHashMap<>(); + if (variables == null) { + return bySuffix; + } + for (DescribedVariable variable : variables) { + if (variable == null || StringUtils.isBlank(variable.getName())) { + continue; + } + String name = variable.getName().trim(); + for (String suffix : FIELD_SUFFIXES) { + if (name.endsWith(suffix) && !bySuffix.containsKey(suffix)) { + bySuffix.put(suffix, name); + break; + } + } + } + return bySuffix; + } + + /** + * Suggest a default config filename for the connection prefix (not path-resolved). + * + * @param prefix sanitized connection prefix + * @return e.g. {@code EDW-config.json} + */ + public static String defaultConfigFilename(String prefix) { + if (StringUtils.isEmpty(prefix)) { + return "database-config.json"; + } + return prefix + "-config.json"; + } + + private static void addVariable( + List variables, + String prefix, + String suffix, + String value, + Map descriptionsBySuffix) { + String name = prefix + suffix; + String description = + descriptionsBySuffix != null ? Const.NVL(descriptionsBySuffix.get(suffix), "") : ""; + variables.add(new DescribedVariable(name, Const.NVL(value, ""), description)); + } +} diff --git a/ui/src/main/java/org/apache/hop/ui/core/database/DatabaseMetaEditor.java b/ui/src/main/java/org/apache/hop/ui/core/database/DatabaseMetaEditor.java index 865d9cf17f8..0d2fdbb23e4 100644 --- a/ui/src/main/java/org/apache/hop/ui/core/database/DatabaseMetaEditor.java +++ b/ui/src/main/java/org/apache/hop/ui/core/database/DatabaseMetaEditor.java @@ -22,12 +22,15 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.vfs2.FileObject; import org.apache.hop.core.Const; import org.apache.hop.core.Props; +import org.apache.hop.core.config.DescribedVariablesConfigFile; import org.apache.hop.core.database.BaseDatabaseMeta; import org.apache.hop.core.database.DatabaseMeta; import org.apache.hop.core.database.DatabasePluginType; @@ -38,11 +41,15 @@ import org.apache.hop.core.plugins.IPlugin; import org.apache.hop.core.plugins.PluginRegistry; import org.apache.hop.core.util.Utils; +import org.apache.hop.core.variables.DescribedVariable; import org.apache.hop.core.variables.IVariables; +import org.apache.hop.core.vfs.HopVfs; import org.apache.hop.i18n.BaseMessages; import org.apache.hop.ui.core.PropsUi; import org.apache.hop.ui.core.database.dialog.DatabaseExplorerDialog; +import org.apache.hop.ui.core.dialog.BaseDialog; import org.apache.hop.ui.core.dialog.ErrorDialog; +import org.apache.hop.ui.core.dialog.HopDescribedVariablesDialog; import org.apache.hop.ui.core.dialog.MessageBox; import org.apache.hop.ui.core.dialog.ShowMessageDialog; import org.apache.hop.ui.core.gui.GuiCompositeWidgets; @@ -1413,6 +1420,11 @@ private String[] getConnectionTypes() { @Override public Button[] createButtonsForButtonBar(Composite parent) { + Button wGenerateVariables = new Button(parent, SWT.PUSH); + wGenerateVariables.setText( + BaseMessages.getString(PKG, "DatabaseDialog.button.GenerateVariables")); + wGenerateVariables.addListener(SWT.Selection, e -> generateVariables()); + Button wExplore = new Button(parent, SWT.PUSH); wExplore.setText(BaseMessages.getString(PKG, "DatabaseDialog.button.Explore")); wExplore.addListener(SWT.Selection, e -> explore()); @@ -1421,7 +1433,206 @@ public Button[] createButtonsForButtonBar(Composite parent) { wTest.setText(BaseMessages.getString(PKG, "System.Button.Test")); wTest.addListener(SWT.Selection, e -> test()); - return new Button[] {wExplore, wTest}; + return new Button[] {wGenerateVariables, wExplore, wTest}; + } + + /** + * Propose connection-scoped environment variables (for example {@code EDW_HOSTNAME}), optionally + * write them to a described-variables JSON config file for DTAP environments, and replace the + * editor fields with {@code ${…}} expressions. + */ + private void generateVariables() { + String connectionName = wName.getText(); + if (StringUtils.isBlank(connectionName)) { + MessageBox box = new MessageBox(getShell(), SWT.OK | SWT.ICON_ERROR); + box.setText( + BaseMessages.getString(PKG, "DatabaseDialog.GenerateVariables.NameRequired.Title")); + box.setMessage( + BaseMessages.getString(PKG, "DatabaseDialog.GenerateVariables.NameRequired.Message")); + box.open(); + return; + } + + DatabaseMeta meta = getMetadata(); + getWidgetsContent(meta); + + boolean includeUsername = + wUsername != null && !excludedElementIds.contains(BaseDatabaseMeta.ELEMENT_ID_USERNAME); + boolean includePassword = + wPassword != null && !excludedElementIds.contains(BaseDatabaseMeta.ELEMENT_ID_PASSWORD); + + Map descriptions = new LinkedHashMap<>(); + descriptions.put( + DatabaseConnectionVariablesHelper.SUFFIX_HOSTNAME, + BaseMessages.getString( + PKG, "DatabaseDialog.GenerateVariables.Description.Hostname", connectionName)); + descriptions.put( + DatabaseConnectionVariablesHelper.SUFFIX_PORT, + BaseMessages.getString( + PKG, "DatabaseDialog.GenerateVariables.Description.Port", connectionName)); + descriptions.put( + DatabaseConnectionVariablesHelper.SUFFIX_DATABASE, + BaseMessages.getString( + PKG, "DatabaseDialog.GenerateVariables.Description.Database", connectionName)); + descriptions.put( + DatabaseConnectionVariablesHelper.SUFFIX_USERNAME, + BaseMessages.getString( + PKG, "DatabaseDialog.GenerateVariables.Description.Username", connectionName)); + descriptions.put( + DatabaseConnectionVariablesHelper.SUFFIX_PASSWORD, + BaseMessages.getString( + PKG, "DatabaseDialog.GenerateVariables.Description.Password", connectionName)); + descriptions.put( + DatabaseConnectionVariablesHelper.SUFFIX_URL, + BaseMessages.getString( + PKG, "DatabaseDialog.GenerateVariables.Description.Url", connectionName)); + + List proposed = + DatabaseConnectionVariablesHelper.buildProposedVariables( + connectionName, + meta.getHostname(), + meta.getPort(), + meta.getDatabaseName(), + meta.getUsername(), + meta.getPassword(), + meta.getManualUrl(), + includeUsername, + includePassword, + descriptions); + + if (proposed.isEmpty()) { + MessageBox box = new MessageBox(getShell(), SWT.OK | SWT.ICON_ERROR); + box.setText( + BaseMessages.getString(PKG, "DatabaseDialog.GenerateVariables.NameRequired.Title")); + box.setMessage( + BaseMessages.getString(PKG, "DatabaseDialog.GenerateVariables.NameRequired.Message")); + box.open(); + return; + } + + HopDescribedVariablesDialog variablesDialog = + new HopDescribedVariablesDialog( + getShell(), + BaseMessages.getString( + PKG, "DatabaseDialog.GenerateVariables.DialogMessage", connectionName), + proposed, + null); + List confirmed = variablesDialog.open(); + if (confirmed == null) { + return; + } + + // Optional: create or update a described-variables JSON file for DTAP / deployment use. + // Values may be left empty in the dialog to produce a VCS template; environment-specific + // copies are typically deployed per stage and not auto-registered on a lifecycle environment. + MessageBox createConfigBox = new MessageBox(getShell(), SWT.YES | SWT.NO | SWT.ICON_QUESTION); + createConfigBox.setText( + BaseMessages.getString(PKG, "DatabaseDialog.GenerateVariables.CreateConfig.Title")); + createConfigBox.setMessage( + BaseMessages.getString(PKG, "DatabaseDialog.GenerateVariables.CreateConfig.Message")); + if (createConfigBox.open() == SWT.YES) { + saveVariablesToConfigFile(confirmed, connectionName); + } + + applyVariableExpressionsToEditor(meta, confirmed); + setWidgetsContent(); + setChanged(); + MetadataPerspective.getInstance().updateEditor(this); + } + + private void saveVariablesToConfigFile(List variables, String connectionName) { + try { + String prefix = + DatabaseConnectionVariablesHelper.sanitizeConnectionNamePrefix(connectionName); + String defaultName = DatabaseConnectionVariablesHelper.defaultConfigFilename(prefix); + + FileObject startFile; + String projectHome = manager.getVariables().resolve(Const.VAR_PROJECT_HOME); + if (StringUtils.isNotEmpty(projectHome) + && !Const.VAR_PROJECT_HOME.equals(projectHome) + && HopVfs.fileExists(projectHome)) { + startFile = HopVfs.getFileObject(projectHome + Const.FILE_SEPARATOR + defaultName); + } else { + startFile = HopVfs.getFileObject(defaultName); + } + + String configFilename = + BaseDialog.presentFileDialog( + true, + getShell(), + null, + manager.getVariables(), + startFile, + new String[] {"*.json", "*"}, + new String[] { + BaseMessages.getString(PKG, "DatabaseDialog.GenerateVariables.FileFilter.Json"), + BaseMessages.getString(PKG, "DatabaseDialog.GenerateVariables.FileFilter.All") + }, + true); + if (StringUtils.isEmpty(configFilename)) { + return; + } + + // Resolve variables (e.g. ${PROJECT_HOME}) before VFS read/write + String realConfigFilename = manager.getVariables().resolve(configFilename); + + DescribedVariablesConfigFile configFile = + new DescribedVariablesConfigFile(realConfigFilename); + if (HopVfs.fileExists(realConfigFilename)) { + configFile.readFromFile(); + } + for (DescribedVariable variable : variables) { + if (variable != null && StringUtils.isNotBlank(variable.getName())) { + configFile.setDescribedVariable(variable); + } + } + configFile.saveToFile(); + + MessageBox done = new MessageBox(getShell(), SWT.OK | SWT.ICON_INFORMATION); + done.setText( + BaseMessages.getString(PKG, "DatabaseDialog.GenerateVariables.ConfigSaved.Title")); + done.setMessage( + BaseMessages.getString( + PKG, "DatabaseDialog.GenerateVariables.ConfigSaved.Message", realConfigFilename)); + done.open(); + } catch (Exception e) { + new ErrorDialog( + getShell(), + BaseMessages.getString(PKG, "DatabaseDialog.GenerateVariables.ConfigError.Title"), + BaseMessages.getString(PKG, "DatabaseDialog.GenerateVariables.ConfigError.Message"), + e); + } + } + + private void applyVariableExpressionsToEditor( + DatabaseMeta meta, List variables) { + Map namesBySuffix = + DatabaseConnectionVariablesHelper.findVariableNamesBySuffix(variables); + + String hostnameVar = namesBySuffix.get(DatabaseConnectionVariablesHelper.SUFFIX_HOSTNAME); + if (hostnameVar != null) { + meta.setHostname(DatabaseConnectionVariablesHelper.expressionFor(hostnameVar)); + } + String portVar = namesBySuffix.get(DatabaseConnectionVariablesHelper.SUFFIX_PORT); + if (portVar != null) { + meta.setPort(DatabaseConnectionVariablesHelper.expressionFor(portVar)); + } + String databaseVar = namesBySuffix.get(DatabaseConnectionVariablesHelper.SUFFIX_DATABASE); + if (databaseVar != null) { + meta.setDBName(DatabaseConnectionVariablesHelper.expressionFor(databaseVar)); + } + String usernameVar = namesBySuffix.get(DatabaseConnectionVariablesHelper.SUFFIX_USERNAME); + if (usernameVar != null) { + meta.setUsername(DatabaseConnectionVariablesHelper.expressionFor(usernameVar)); + } + String passwordVar = namesBySuffix.get(DatabaseConnectionVariablesHelper.SUFFIX_PASSWORD); + if (passwordVar != null) { + meta.setPassword(DatabaseConnectionVariablesHelper.expressionFor(passwordVar)); + } + String urlVar = namesBySuffix.get(DatabaseConnectionVariablesHelper.SUFFIX_URL); + if (urlVar != null) { + meta.setManualUrl(DatabaseConnectionVariablesHelper.expressionFor(urlVar)); + } } @Override diff --git a/ui/src/main/resources/org/apache/hop/ui/core/database/messages/messages_en_US.properties b/ui/src/main/resources/org/apache/hop/ui/core/database/messages/messages_en_US.properties index 563576b233b..36a0fc6b5e9 100644 --- a/ui/src/main/resources/org/apache/hop/ui/core/database/messages/messages_en_US.properties +++ b/ui/src/main/resources/org/apache/hop/ui/core/database/messages/messages_en_US.properties @@ -18,6 +18,24 @@ DatabaseDialog.AdvancedTab.title=Advanced DatabaseDialog.button.DownloadDriver=Download driver DatabaseDialog.button.Explore=Explore +DatabaseDialog.button.GenerateVariables=Generate variables +DatabaseDialog.GenerateVariables.NameRequired.Title=Connection name required +DatabaseDialog.GenerateVariables.NameRequired.Message=Please enter a connection name before generating variables. The name is used as a prefix (for example EDW_HOSTNAME). +DatabaseDialog.GenerateVariables.DialogMessage=Proposed environment variables for database connection ''{0}''. Review names, values and descriptions. Values can be left empty for a DTAP template checked into version control. +DatabaseDialog.GenerateVariables.CreateConfig.Title=Environment configuration file +DatabaseDialog.GenerateVariables.CreateConfig.Message=Create or update an environment configuration JSON file with these variables?\n\nThis is useful as a template for DTAP environments (checked into version control; environment-specific values are typically deployed separately). +DatabaseDialog.GenerateVariables.FileFilter.Json=Config JSON files +DatabaseDialog.GenerateVariables.FileFilter.All=All files +DatabaseDialog.GenerateVariables.ConfigSaved.Title=Configuration file saved +DatabaseDialog.GenerateVariables.ConfigSaved.Message=Variables were written to:\n{0}\n\nAdd this file to a Hop environment''s configuration files list so the variables are loaded at runtime. +DatabaseDialog.GenerateVariables.ConfigError.Title=Error +DatabaseDialog.GenerateVariables.ConfigError.Message=Unable to save the environment configuration file +DatabaseDialog.GenerateVariables.Description.Hostname=Hostname for database connection {0} +DatabaseDialog.GenerateVariables.Description.Port=Port for database connection {0} +DatabaseDialog.GenerateVariables.Description.Database=Database name for database connection {0} +DatabaseDialog.GenerateVariables.Description.Username=Username for database connection {0} +DatabaseDialog.GenerateVariables.Description.Password=Password for database connection {0} +DatabaseDialog.GenerateVariables.Description.Url=Manual JDBC URL for database connection {0} JdbcDriverDownloadDialog.Shell.Title=Download JDBC driver JdbcDriverDownloadDialog.Intro.Restricted=Download the {0} JDBC driver from Maven Central. Accept the vendor license below to continue. JdbcDriverDownloadDialog.Intro.Open=Download the {0} JDBC driver from Maven Central. diff --git a/ui/src/test/java/org/apache/hop/ui/core/database/DatabaseConnectionVariablesHelperTest.java b/ui/src/test/java/org/apache/hop/ui/core/database/DatabaseConnectionVariablesHelperTest.java new file mode 100644 index 00000000000..2229b9eda49 --- /dev/null +++ b/ui/src/test/java/org/apache/hop/ui/core/database/DatabaseConnectionVariablesHelperTest.java @@ -0,0 +1,141 @@ +/* + * 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.ui.core.database; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.hop.core.variables.DescribedVariable; +import org.junit.jupiter.api.Test; + +class DatabaseConnectionVariablesHelperTest { + + @Test + void sanitizeConnectionNamePrefix() { + assertEquals("EDW", DatabaseConnectionVariablesHelper.sanitizeConnectionNamePrefix("EDW")); + assertEquals("MY_DB", DatabaseConnectionVariablesHelper.sanitizeConnectionNamePrefix("My DB")); + assertEquals( + "MY_DB", DatabaseConnectionVariablesHelper.sanitizeConnectionNamePrefix(" my-db ")); + assertEquals("", DatabaseConnectionVariablesHelper.sanitizeConnectionNamePrefix("")); + assertEquals("", DatabaseConnectionVariablesHelper.sanitizeConnectionNamePrefix(" ")); + assertEquals( + "A_B_C", DatabaseConnectionVariablesHelper.sanitizeConnectionNamePrefix("a__b---c")); + } + + @Test + void expressionFor() { + assertEquals( + "${EDW_HOSTNAME}", DatabaseConnectionVariablesHelper.expressionFor("EDW_HOSTNAME")); + } + + @Test + void buildProposedVariablesIncludesStandardFields() { + Map descriptions = new HashMap<>(); + descriptions.put(DatabaseConnectionVariablesHelper.SUFFIX_HOSTNAME, "Host"); + descriptions.put(DatabaseConnectionVariablesHelper.SUFFIX_PORT, "Port"); + descriptions.put(DatabaseConnectionVariablesHelper.SUFFIX_DATABASE, "Database"); + descriptions.put(DatabaseConnectionVariablesHelper.SUFFIX_USERNAME, "User"); + descriptions.put(DatabaseConnectionVariablesHelper.SUFFIX_PASSWORD, "Password"); + + List variables = + DatabaseConnectionVariablesHelper.buildProposedVariables( + "EDW", + "localhost", + "5432", + "warehouse", + "alice", + "secret", + null, + true, + true, + descriptions); + + assertEquals(5, variables.size()); + assertEquals("EDW_HOSTNAME", variables.get(0).getName()); + assertEquals("localhost", variables.get(0).getValue()); + assertEquals("Host", variables.get(0).getDescription()); + assertEquals("EDW_PORT", variables.get(1).getName()); + assertEquals("5432", variables.get(1).getValue()); + assertEquals("EDW_DATABASE", variables.get(2).getName()); + assertEquals("warehouse", variables.get(2).getValue()); + assertEquals("EDW_USERNAME", variables.get(3).getName()); + assertEquals("alice", variables.get(3).getValue()); + assertEquals("EDW_PASSWORD", variables.get(4).getName()); + assertEquals("secret", variables.get(4).getValue()); + } + + @Test + void buildProposedVariablesIncludesUrlOnlyWhenFilled() { + List withoutUrl = + DatabaseConnectionVariablesHelper.buildProposedVariables( + "EDW", "h", "1", "d", "u", "p", " ", true, true, null); + assertFalse( + withoutUrl.stream() + .anyMatch(v -> v.getName().endsWith(DatabaseConnectionVariablesHelper.SUFFIX_URL))); + + List withUrl = + DatabaseConnectionVariablesHelper.buildProposedVariables( + "EDW", "h", "1", "d", "u", "p", "jdbc:test://x", true, true, null); + assertTrue( + withUrl.stream() + .anyMatch(v -> "EDW_URL".equals(v.getName()) && "jdbc:test://x".equals(v.getValue()))); + } + + @Test + void buildProposedVariablesSkipsUsernamePasswordWhenExcluded() { + List variables = + DatabaseConnectionVariablesHelper.buildProposedVariables( + "EDW", "h", "1", "d", "u", "p", null, false, false, null); + assertEquals(3, variables.size()); + assertFalse( + variables.stream() + .anyMatch( + v -> v.getName().endsWith(DatabaseConnectionVariablesHelper.SUFFIX_USERNAME))); + assertFalse( + variables.stream() + .anyMatch( + v -> v.getName().endsWith(DatabaseConnectionVariablesHelper.SUFFIX_PASSWORD))); + } + + @Test + void findVariableNamesBySuffix() { + List variables = + List.of( + new DescribedVariable("EDW_HOSTNAME", "a", ""), + new DescribedVariable("CUSTOM_PORT", "b", ""), + new DescribedVariable("other", "c", ""), + new DescribedVariable("EDW_HOSTNAME", "ignored", "")); + + Map bySuffix = + DatabaseConnectionVariablesHelper.findVariableNamesBySuffix(variables); + assertEquals("EDW_HOSTNAME", bySuffix.get(DatabaseConnectionVariablesHelper.SUFFIX_HOSTNAME)); + assertEquals("CUSTOM_PORT", bySuffix.get(DatabaseConnectionVariablesHelper.SUFFIX_PORT)); + assertFalse(bySuffix.containsKey(DatabaseConnectionVariablesHelper.SUFFIX_DATABASE)); + } + + @Test + void defaultConfigFilename() { + assertEquals("EDW-config.json", DatabaseConnectionVariablesHelper.defaultConfigFilename("EDW")); + assertEquals( + "database-config.json", DatabaseConnectionVariablesHelper.defaultConfigFilename("")); + } +} From 30ecf8c11c73eb4c375548d673ca55c2a4588a0b Mon Sep 17 00:00:00 2001 From: mattcasters Date: Sun, 19 Jul 2026 11:47:58 +0200 Subject: [PATCH 06/15] Issue #2351 : execution information perspective tabs are not stored correctly with the project --- .../hop/projects/gui/ProjectsGuiPlugin.java | 8 +- .../hopgui/delegates/HopGuiFileDelegate.java | 11 + .../execution/ExecutionPerspective.java | 265 ++++++++++++++++-- .../ExecutionPerspectiveDisabledTest.java | 6 + 4 files changed, 262 insertions(+), 28 deletions(-) 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 b4fd7e21305..a0b05938463 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 @@ -166,7 +166,12 @@ public static void enableHopGuiProject( return; } - // Close's all + // Save execution perspective state (toolbar filters + open tabs) under the current project + // namespace before closing tabs / switching namespace. + // + ExecutionPerspective.getInstance().saveState(); + + // Close's all (including execution information tabs) // hopGui.fileDelegate.closeAllFiles(); @@ -179,7 +184,6 @@ public static void enableHopGuiProject( // Save explorer perspective state for the current project before switching namespace // ExplorerPerspective.getInstance().saveExplorerStateOnShutdown(); - ExecutionPerspective.getInstance().saveState(); // This is called only in Hop GUI so we want to start with a new set of variables // It avoids variables from one project showing up in another diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java index a02835547aa..4b22a448486 100644 --- a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java +++ b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java @@ -277,6 +277,17 @@ public void closeAllFiles() { } } } + + // Execution Information tabs are not IHopFileTypeHandlers, so they never appear in + // getItems(). Close them explicitly so project switches (and File → Close All) do not leave + // viewers from the previous project open. Callers that need to remember tabs (project switch) + // must call ExecutionPerspective.saveState() first. + // + ExecutionPerspective executionPerspective = ExecutionPerspective.getInstance(); + if (executionPerspective != null) { + executionPerspective.closeAllTabs(); + } + this.isClosing = false; } diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java index 05672e5cc38..d18587f127e 100644 --- a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java +++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspective.java @@ -51,6 +51,7 @@ import org.apache.hop.execution.LastPeriod; import org.apache.hop.execution.caching.CachingFileExecutionInfoLocation; import org.apache.hop.execution.local.FileExecutionInfoLocation; +import org.apache.hop.history.AuditList; import org.apache.hop.history.AuditManager; import org.apache.hop.history.AuditState; import org.apache.hop.history.AuditStateMap; @@ -85,6 +86,7 @@ import org.apache.hop.ui.hopgui.perspective.IHopPerspective; import org.apache.hop.ui.hopgui.perspective.TabClosable; import org.apache.hop.ui.hopgui.perspective.TabCloseHandler; +import org.apache.hop.ui.hopgui.shared.BaseExecutionViewer; import org.apache.hop.ui.hopgui.shared.SashFormMemory; import org.apache.hop.workflow.WorkflowMeta; import org.eclipse.swt.SWT; @@ -165,6 +167,7 @@ public class ExecutionPerspective implements IHopPerspective, TabClosable { public static final String CONFIGURE_LOCATIONS = "configure-locations"; private static final String EXECUTION_AUDIT_TYPE = "execution-perspective-gui"; + private static final String EXECUTION_TABS_AUDIT_TYPE = "execution-perspective-tabs"; private static final String AUDIT_EXECUTION_TOOLBAR = "toolbar"; private static final String AUDIT_ONLY_PARENTS = "only-parents"; private static final String AUDIT_ONLY_FAILED = "only-failed"; @@ -174,6 +177,8 @@ public class ExecutionPerspective implements IHopPerspective, TabClosable { private static final String AUDIT_ONLY_PIPELINES = "only-pipelines"; private static final String AUDIT_FILTER_TEXT = "filter-text"; private static final String AUDIT_TIME_FILTER = "time-filter"; + private static final String AUDIT_ACTIVE_TAB = "active-tab"; + private static final String TAB_KEY_DELIMITER = "\t"; public static final String SNAP_ID_EIL_REFRESH = "EILRefresh"; @Getter private static ExecutionPerspective instance; @@ -196,6 +201,19 @@ public class ExecutionPerspective implements IHopPerspective, TabClosable { private final List viewers = new ArrayList<>(); + /** + * When true, {@link #addViewer(IExecutionViewer)} does not activate this perspective. Used while + * restoring open tabs on project switch so the data-orchestration perspective keeps focus. + */ + private boolean restoringTabs; + + /** + * When true, {@link #closeTab(CTabFolderEvent, CTabItem)} does not rewrite audit state. Used by + * {@link #closeAllTabs()} so a project switch that already called {@link #saveState()} is not + * overwritten with an empty open-tabs list. + */ + private boolean closingAllTabs; + /** * Gets locationMap * @@ -419,9 +437,11 @@ public void addViewer(IExecutionViewer viewer) { viewers.add(viewer); - // Activate the perspective + // Activate the perspective unless we are restoring tabs after a project switch // - this.activate(); + if (!restoringTabs) { + this.activate(); + } // Switch to the tab // @@ -1455,6 +1475,12 @@ public void closeTab(CTabFolderEvent event, CTabItem tabItem) { if (isRemoved) { hopGui.auditDelegate.writeLastOpenFiles(); + // Keep the per-project open-tabs audit in sync when the user closes a single tab. + // Skip during closeAllTabs so a prior saveState() for project switch is not wiped. + // + if (!closingAllTabs) { + saveState(); + } } if (!isRemoved && event != null) { event.doit = false; @@ -1467,6 +1493,33 @@ public void closeTab(CTabFolderEvent event, CTabItem tabItem) { } } + /** + * Close every open execution viewer tab. Does not write audit state — callers that need to + * remember tabs (project switch) must call {@link #saveState()} first. + */ + public void closeAllTabs() { + if (!isInitialized() || tabFolder == null || tabFolder.isDisposed()) { + return; + } + + closingAllTabs = true; + try { + // Copy the list — closeTab disposes items and mutates the folder + // + List items = new ArrayList<>(); + for (CTabItem item : tabFolder.getItems()) { + items.add(item); + } + for (CTabItem item : items) { + if (!item.isDisposed()) { + closeTab(null, item); + } + } + } finally { + closingAllTabs = false; + } + } + @Override public CTabFolder getTabFolder() { return tabFolder; @@ -1480,30 +1533,52 @@ public void saveState() { return; } try { + String namespace = HopNamespace.getNamespace(); + String activeKey = ""; + IExecutionViewer activeViewer = getActiveViewer(); + List openTabs = new ArrayList<>(); + + if (tabFolder != null && !tabFolder.isDisposed()) { + for (CTabItem item : tabFolder.getItems()) { + if (item.isDisposed()) { + continue; + } + Object data = item.getData(); + if (data instanceof BaseExecutionViewer baseViewer) { + String locationName = Const.NVL(baseViewer.getLocationName(), ""); + String executionId = + baseViewer.getExecution() != null + ? Const.NVL(baseViewer.getExecution().getId(), "") + : ""; + if (StringUtils.isEmpty(locationName) || StringUtils.isEmpty(executionId)) { + continue; + } + String tabKey = toTabKey(locationName, executionId); + openTabs.add(tabKey); + if (baseViewer == activeViewer) { + activeKey = tabKey; + } + } + } + } + + Map toolbarProps = new HashMap<>(); + toolbarProps.put(AUDIT_ONLY_PARENTS, onlyShowingParents); + toolbarProps.put(AUDIT_ONLY_FAILED, onlyShowingFailed); + toolbarProps.put(AUDIT_ONLY_RUNNING, onlyShowingRunning); + toolbarProps.put(AUDIT_ONLY_FINISHED, onlyShowingFinished); + toolbarProps.put(AUDIT_ONLY_WORKFLOWS, onlyShowingWorkflows); + toolbarProps.put(AUDIT_ONLY_PIPELINES, onlyShowingPipelines); + toolbarProps.put(AUDIT_FILTER_TEXT, Const.NVL(filterText, "")); + toolbarProps.put(AUDIT_TIME_FILTER, timeFilter.getCode()); + toolbarProps.put(AUDIT_ACTIVE_TAB, activeKey); + AuditStateMap stateMap = new AuditStateMap(); - stateMap.add( - new AuditState( - AUDIT_EXECUTION_TOOLBAR, - Map.of( - AUDIT_ONLY_PARENTS, - onlyShowingParents, - AUDIT_ONLY_FAILED, - onlyShowingFailed, - AUDIT_ONLY_RUNNING, - onlyShowingRunning, - AUDIT_ONLY_FINISHED, - onlyShowingFinished, - AUDIT_ONLY_WORKFLOWS, - onlyShowingWorkflows, - AUDIT_ONLY_PIPELINES, - onlyShowingPipelines, - AUDIT_FILTER_TEXT, - Const.NVL(filterText, ""), - AUDIT_TIME_FILTER, - timeFilter.getCode()))); + stateMap.add(new AuditState(AUDIT_EXECUTION_TOOLBAR, toolbarProps)); + AuditManager.getActive().saveAuditStateMap(namespace, EXECUTION_AUDIT_TYPE, stateMap); AuditManager.getActive() - .saveAuditStateMap(HopNamespace.getNamespace(), EXECUTION_AUDIT_TYPE, stateMap); + .storeList(namespace, EXECUTION_TABS_AUDIT_TYPE, new AuditList(openTabs)); } catch (Exception e) { hopGui.getLog().logError("Error saving execution perspective state", e); } @@ -1517,9 +1592,9 @@ public void restoreState() { return; } try { + String namespace = HopNamespace.getNamespace(); AuditStateMap stateMap = - AuditManager.getActive() - .loadAuditStateMap(HopNamespace.getNamespace(), EXECUTION_AUDIT_TYPE); + AuditManager.getActive().loadAuditStateMap(namespace, EXECUTION_AUDIT_TYPE); AuditState toolbarState = stateMap.get(AUDIT_EXECUTION_TOOLBAR); if (toolbarState == null) { toolbarState = new AuditState(); @@ -1533,12 +1608,150 @@ public void restoreState() { filterText = toolbarState.extractString(AUDIT_FILTER_TEXT, ""); String timeFilterName = toolbarState.extractString(AUDIT_TIME_FILTER, ""); timeFilter = IEnumHasCode.lookupCode(LastPeriod.class, timeFilterName, LastPeriod.ONE_HOUR); + String activeKey = toolbarState.extractString(AUDIT_ACTIVE_TAB, ""); updateGui(); refresh(); + + // Re-open execution viewer tabs that were open last time this project was used. + // Missing locations/executions fail silently — they are not important enough to block + // project activation. + // + restoreOpenTabs(namespace, activeKey); + } catch (Exception e) { + hopGui.getLog().logError("Error restoring execution perspective state", e); + } + } + + /** + * Restore previously open execution tabs for the given project namespace. Failures for individual + * tabs are logged and skipped. + */ + private void restoreOpenTabs(String namespace, String activeKey) { + if (tabFolder == null || tabFolder.isDisposed()) { + return; + } + + AuditList auditList; + try { + auditList = AuditManager.getActive().retrieveList(namespace, EXECUTION_TABS_AUDIT_TYPE); } catch (Exception e) { - hopGui.getLog().logError("Error restoring explorer perspective state", e); + hopGui.getLog().logError("Error loading open execution tabs for project restore", e); + return; + } + if (auditList == null || auditList.getNames() == null || auditList.getNames().isEmpty()) { + return; } + + restoringTabs = true; + IExecutionViewer activeViewer = null; + try { + for (String tabKey : auditList.getNames()) { + if (StringUtils.isEmpty(tabKey)) { + continue; + } + try { + String[] parts = tabKey.split(TAB_KEY_DELIMITER, 2); + if (parts.length < 2 || StringUtils.isEmpty(parts[0]) || StringUtils.isEmpty(parts[1])) { + hopGui + .getLog() + .logDebug("Skipping invalid execution tab key during restore: " + tabKey); + continue; + } + String locationName = parts[0]; + String executionId = parts[1]; + + ExecutionInfoLocation location = ensureLocationInitialized(locationName); + if (location == null) { + hopGui + .getLog() + .logDebug( + "Skipping restore of execution tab: location '" + + locationName + + "' not available"); + continue; + } + + IExecutionInfoLocation iLocation = location.getExecutionInfoLocation(); + Execution execution = iLocation.getExecution(executionId); + if (execution == null) { + hopGui + .getLog() + .logDebug( + "Skipping restore of execution tab: execution '" + + executionId + + "' not found in location '" + + locationName + + "'"); + continue; + } + + ExecutionState executionState = iLocation.getExecutionState(executionId); + createExecutionViewer(locationName, execution, executionState); + + if (tabKey.equals(activeKey)) { + activeViewer = findViewer(execution.getId(), execution.getName()); + } + } catch (Exception e) { + // Missing or corrupt execution information is not important enough to surface. + // + hopGui + .getLog() + .logDebug("Skipping restore of execution tab '" + tabKey + "': " + e.getMessage()); + } + } + + if (activeViewer != null) { + setActiveViewer(activeViewer); + } + } finally { + restoringTabs = false; + } + } + + /** + * Ensure the named execution information location is initialized and present in {@link + * #locationMap}. Does not require the perspective to be active (unlike {@link #refresh()}). + * + * @param locationName metadata name of the location + * @return the location, or null if it cannot be loaded/initialized + */ + private ExecutionInfoLocation ensureLocationInitialized(String locationName) { + if (StringUtils.isEmpty(locationName) || locationMap == null) { + return null; + } + + ExecutionInfoLocation location = locationMap.get(locationName); + if (location != null) { + return location; + } + + try { + IHopMetadataSerializer serializer = + hopGui.getMetadataProvider().getSerializer(ExecutionInfoLocation.class); + if (!serializer.exists(locationName)) { + return null; + } + location = serializer.load(locationName); + if (location == null || location.getExecutionInfoLocation() == null) { + return null; + } + location + .getExecutionInfoLocation() + .initialize(hopGui.getVariables(), hopGui.getMetadataProvider()); + locationMap.put(locationName, location); + return location; + } catch (Exception e) { + hopGui + .getLog() + .logDebug( + "Could not initialize execution location '" + locationName + "' for tab restore", e); + return null; + } + } + + private static String toTabKey(String locationName, String executionId) { + return locationName + TAB_KEY_DELIMITER + executionId; } public ExecutionInfoLocation lookupLocation(String locationName) { diff --git a/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspectiveDisabledTest.java b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspectiveDisabledTest.java index acda548165b..bc80b89ca5d 100644 --- a/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspectiveDisabledTest.java +++ b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/execution/ExecutionPerspectiveDisabledTest.java @@ -73,6 +73,12 @@ void closingAProjectDoesNotFail() { assertDoesNotThrow(() -> disabledPerspective.saveState()); } + @Test + void closingAllTabsDoesNotFail() { + // HopGuiFileDelegate#closeAllFiles closes execution tabs after file tabs. + assertDoesNotThrow(() -> disabledPerspective.closeAllTabs()); + } + @Test void theKeyboardShortcutDoesNotFail() { // The Ctrl-Shift-I shortcut on activate() is registered even though the perspective is not. From ea72c5a87b72dcb3b3fa31c1a3f4989f49d37caa Mon Sep 17 00:00:00 2001 From: mattcasters Date: Sun, 19 Jul 2026 12:12:40 +0200 Subject: [PATCH 07/15] Issue #2308 : environment Background Colour Configuration --- .../environment/LifecycleEnvironment.java | 19 ++++ .../LifecycleEnvironmentDialog.java | 23 +++++ .../DrawEnvironmentCanvasTextExtension.java | 89 +++++++++++++++++++ ...awEnvironmentOnPipelineExtensionPoint.java | 38 ++++++++ ...awEnvironmentOnWorkflowExtensionPoint.java | 38 ++++++++ .../messages/messages_en_US.properties | 2 + 6 files changed, 209 insertions(+) create mode 100644 plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/DrawEnvironmentCanvasTextExtension.java create mode 100644 plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/DrawEnvironmentOnPipelineExtensionPoint.java create mode 100644 plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/DrawEnvironmentOnWorkflowExtensionPoint.java diff --git a/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironment.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironment.java index 9dbf5ed793f..bb4b68e5c03 100644 --- a/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironment.java +++ b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironment.java @@ -30,6 +30,8 @@ public class LifecycleEnvironment { private String projectName; + private String canvasText; + private List configurationFiles; public LifecycleEnvironment() { @@ -48,6 +50,7 @@ public LifecycleEnvironment(LifecycleEnvironment env) { this.name = env.name; this.purpose = env.purpose; this.projectName = env.projectName; + this.canvasText = env.canvasText; this.configurationFiles = new ArrayList<>(env.configurationFiles); } @@ -116,6 +119,22 @@ public void setProjectName(String projectName) { this.projectName = projectName; } + /** + * Gets canvasText — optional large watermark drawn top-right on pipeline/workflow canvases. + * + * @return value of canvasText + */ + public String getCanvasText() { + return canvasText; + } + + /** + * @param canvasText The canvas text to set (empty/null means do not draw) + */ + public void setCanvasText(String canvasText) { + this.canvasText = canvasText; + } + /** * Gets configurationFiles * diff --git a/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java index 3a19b4c9171..41a39e104fa 100644 --- a/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java +++ b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java @@ -67,6 +67,7 @@ public class LifecycleEnvironmentDialog extends Dialog { private Text wName; private Combo wPurpose; private Combo wProject; + private Text wCanvasText; private TableView wConfigFiles; private IVariables variables; @@ -179,6 +180,26 @@ public String open() { wProject.addListener(SWT.Modify, e -> needingEnvironmentRefresh = true); lastControl = wProject; + Label wlCanvasText = new Label(shell, SWT.RIGHT); + PropsUi.setLook(wlCanvasText); + wlCanvasText.setText( + BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Label.CanvasText")); + FormData fdlCanvasText = new FormData(); + fdlCanvasText.left = new FormAttachment(0, 0); + fdlCanvasText.right = new FormAttachment(middle, 0); + fdlCanvasText.top = new FormAttachment(lastControl, margin); + wlCanvasText.setLayoutData(fdlCanvasText); + wCanvasText = new Text(shell, SWT.SINGLE | SWT.BORDER | SWT.LEFT); + PropsUi.setLook(wCanvasText); + wCanvasText.setToolTipText( + BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.ToolTip.CanvasText")); + FormData fdCanvasText = new FormData(); + fdCanvasText.left = new FormAttachment(middle, margin); + fdCanvasText.right = new FormAttachment(100, 0); + fdCanvasText.top = new FormAttachment(wlCanvasText, 0, SWT.CENTER); + wCanvasText.setLayoutData(fdCanvasText); + lastControl = wCanvasText; + Label wlConfigFiles = new Label(shell, SWT.LEFT); PropsUi.setLook(wlConfigFiles); wlConfigFiles.setText( @@ -420,6 +441,7 @@ private void getData() { wName.setText(Const.NVL(environment.getName(), "")); wPurpose.setText(Const.NVL(environment.getPurpose(), "")); wProject.setText(Const.NVL(environment.getProjectName(), "")); + wCanvasText.setText(Const.NVL(environment.getCanvasText(), "")); for (int i = 0; i < environment.getConfigurationFiles().size(); i++) { String configurationFile = environment.getConfigurationFiles().get(i); @@ -441,6 +463,7 @@ private void getInfo(LifecycleEnvironment env) { env.setName(wName.getText()); env.setPurpose(wPurpose.getText()); env.setProjectName(wProject.getText()); + env.setCanvasText(wCanvasText.getText()); env.getConfigurationFiles().clear(); for (TableItem item : wConfigFiles.getNonEmptyItems()) { diff --git a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/DrawEnvironmentCanvasTextExtension.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/DrawEnvironmentCanvasTextExtension.java new file mode 100644 index 00000000000..ff142adc525 --- /dev/null +++ b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/DrawEnvironmentCanvasTextExtension.java @@ -0,0 +1,89 @@ +/* + * 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.xp; + +import org.apache.commons.lang3.StringUtils; +import org.apache.hop.core.gui.BasePainter; +import org.apache.hop.core.gui.IGc; +import org.apache.hop.core.gui.Point; +import org.apache.hop.core.variables.IVariables; +import org.apache.hop.projects.config.ProjectsConfigSingleton; +import org.apache.hop.projects.environment.LifecycleEnvironment; +import org.apache.hop.projects.util.Defaults; + +/** + * Shared helper that draws optional environment canvas text as a large watermark in the top-right + * of the pipeline/workflow canvas (under graph content when used from *PainterStart). + */ +final class DrawEnvironmentCanvasTextExtension { + + private DrawEnvironmentCanvasTextExtension() { + // utility + } + + static void draw(BasePainter painter, IVariables variables) { + if (painter == null || variables == null) { + return; + } + + String environmentName = variables.getVariable(Defaults.VARIABLE_HOP_ENVIRONMENT_NAME); + if (StringUtils.isEmpty(environmentName)) { + return; + } + + LifecycleEnvironment environment = + ProjectsConfigSingleton.getConfig().findEnvironment(environmentName); + if (environment == null || StringUtils.isEmpty(environment.getCanvasText())) { + return; + } + + String text = variables.resolve(environment.getCanvasText()); + if (StringUtils.isEmpty(text)) { + return; + } + + IGc gc = painter.getGc(); + Point area = painter.getArea(); + if (gc == null || area == null || area.x <= 0 || area.y <= 0) { + return; + } + + // PainterStart runs under graph magnification. Draw in screen pixels so the label + // stays fixed-size while zooming (same approach as the navigation view). + float mag = gc.getMagnification(); + gc.setTransform(0.0f, 0.0f, 1.0f); + + try { + gc.setFont("Sans", 48, true, false); + Point extent = gc.textExtent(text); + + int margin = 16; + int x = area.x - extent.x - margin; + int y = margin; + + int oldAlpha = gc.getAlpha(); + gc.setAlpha(100); + gc.setForeground(IGc.EColor.DARKGRAY); + gc.drawText(text, x, y, true); + gc.setAlpha(oldAlpha); + } finally { + // Restore graph magnification for the rest of the painter + gc.setTransform(0.0f, 0.0f, mag); + } + } +} diff --git a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/DrawEnvironmentOnPipelineExtensionPoint.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/DrawEnvironmentOnPipelineExtensionPoint.java new file mode 100644 index 00000000000..5b702862b49 --- /dev/null +++ b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/DrawEnvironmentOnPipelineExtensionPoint.java @@ -0,0 +1,38 @@ +/* + * 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.xp; + +import org.apache.hop.core.exception.HopException; +import org.apache.hop.core.extension.ExtensionPoint; +import org.apache.hop.core.extension.IExtensionPoint; +import org.apache.hop.core.logging.ILogChannel; +import org.apache.hop.core.variables.IVariables; +import org.apache.hop.pipeline.PipelinePainter; + +@ExtensionPoint( + id = "DrawEnvironmentOnPipelineExtensionPoint", + description = "Draws optional environment canvas text top-right under pipeline graph content", + extensionPointId = "PipelinePainterStart") +public class DrawEnvironmentOnPipelineExtensionPoint implements IExtensionPoint { + + @Override + public void callExtensionPoint( + ILogChannel log, IVariables variables, PipelinePainter pipelinePainter) throws HopException { + DrawEnvironmentCanvasTextExtension.draw(pipelinePainter, variables); + } +} diff --git a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/DrawEnvironmentOnWorkflowExtensionPoint.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/DrawEnvironmentOnWorkflowExtensionPoint.java new file mode 100644 index 00000000000..518a7b20ad3 --- /dev/null +++ b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/DrawEnvironmentOnWorkflowExtensionPoint.java @@ -0,0 +1,38 @@ +/* + * 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.xp; + +import org.apache.hop.core.exception.HopException; +import org.apache.hop.core.extension.ExtensionPoint; +import org.apache.hop.core.extension.IExtensionPoint; +import org.apache.hop.core.logging.ILogChannel; +import org.apache.hop.core.variables.IVariables; +import org.apache.hop.workflow.WorkflowPainter; + +@ExtensionPoint( + id = "DrawEnvironmentOnWorkflowExtensionPoint", + description = "Draws optional environment canvas text top-right under workflow graph content", + extensionPointId = "WorkflowPainterStart") +public class DrawEnvironmentOnWorkflowExtensionPoint implements IExtensionPoint { + + @Override + public void callExtensionPoint( + ILogChannel log, IVariables variables, WorkflowPainter workflowPainter) throws HopException { + DrawEnvironmentCanvasTextExtension.draw(workflowPainter, variables); + } +} diff --git a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/environment/messages/messages_en_US.properties b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/environment/messages/messages_en_US.properties index 4bd84281148..9a4aaa48778 100644 --- a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/environment/messages/messages_en_US.properties +++ b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/environment/messages/messages_en_US.properties @@ -24,6 +24,8 @@ LifecycleEnvironmentDialog.Group.Label.ConfigurationFiles=Configuration files: LifecycleEnvironmentDialog.Label.EnvironmentName=Name LifecycleEnvironmentDialog.Label.EnvironmentPurpose=Purpose LifecycleEnvironmentDialog.Label.ReferencedProject=Project +LifecycleEnvironmentDialog.Label.CanvasText=Canvas text +LifecycleEnvironmentDialog.ToolTip.CanvasText=Large text drawn in the top-right of pipeline and workflow canvases when this environment is active LifecycleEnvironmentDialog.Purpose.Text.Acceptance=Acceptance LifecycleEnvironmentDialog.Purpose.Text.CB=Common Build LifecycleEnvironmentDialog.Purpose.Text.CI=Continuous Integration From a48e0c4cbd50dee44a760440d7f4b54c496a9815 Mon Sep 17 00:00:00 2001 From: mattcasters Date: Sun, 19 Jul 2026 12:20:03 +0200 Subject: [PATCH 08/15] Issue #2331 : Add variable PARENT_PROJECT_HOME --- .../modules/ROOT/pages/projects/advanced.adoc | 17 ++ .../hop/projects/gui/ProjectsGuiPlugin.java | 1 + .../apache/hop/projects/project/Project.java | 17 +- .../hop/projects/util/ProjectsUtil.java | 2 + ...sVariablesControlSpaceSortOrderPrefix.java | 2 + .../hop/projects/project/ProjectTest.java | 147 ++++++++++++++++++ 6 files changed, 185 insertions(+), 1 deletion(-) diff --git a/docs/hop-user-manual/modules/ROOT/pages/projects/advanced.adoc b/docs/hop-user-manual/modules/ROOT/pages/projects/advanced.adoc index 1e6bad85011..963160a975a 100644 --- a/docs/hop-user-manual/modules/ROOT/pages/projects/advanced.adoc +++ b/docs/hop-user-manual/modules/ROOT/pages/projects/advanced.adoc @@ -110,9 +110,26 @@ Let's take a look at it: You can define variables on a project level as well. This makes it handy to reference things like input and output folders which are not sensitive to being checked into version control. +Hop also sets a few built-in project variables when a project is activated: + +.Built-in project variables +[cols="30%,70%",options="header"] +|=== +|Variable|Description +|`PROJECT_HOME`|Home folder of the active project +|`HOP_PROJECT_NAME`|Name of the active project +|`PARENT_PROJECT_HOME`|Home folder of the immediate parent project (empty if none) +|`PARENT_PROJECT_NAME`|Name of the immediate parent project (empty if none) +|=== + +Use the parent variables to reference shared assets without fragile relative paths, for example: + +`{openvar}PARENT_PROJECT_HOME{closevar}/SHARED_PIPELINES/example.hpl` + ==== Parent projects As you can see from the project configuration file (`parentProjectName`), a project can have a parent from which it will inherit all the metadata objects as well as all the variables that are defined in it. +When a parent is configured, Hop also sets `{openvar}PARENT_PROJECT_HOME{closevar}` and `{openvar}PARENT_PROJECT_NAME{closevar}` so pipelines and environment config files can point into the parent project home (for example shared pipelines or secrets kept outside the child repository). === Environment configuration 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 a0b05938463..68001603d6e 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 @@ -1659,6 +1659,7 @@ public void menuProjectExport() { && !name.contains("HOP_AUDIT_FOLDER") && !name.contains("HOP_CONFIG_FOLDER") && !name.contains("PROJECT_HOME") + && !name.contains("PARENT_PROJECT_NAME") && !name.contains("HOP_PROJECTS") && !name.contains("HOP_PLATFORM_OS") && !name.contains("HOP_PROJECT_NAME") 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 7c76fa8872e..f2ceff0df1c 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 @@ -180,10 +180,11 @@ public void modifyVariables( // definition as well // Project parentProject = null; + ProjectConfig parentProjectConfig = null; String realParentProjectName = variables.resolve(parentProjectName); if (StringUtils.isNotEmpty(realParentProjectName)) { - ProjectConfig parentProjectConfig = + parentProjectConfig = ProjectsConfigSingleton.getConfig().findProjectConfig(realParentProjectName); if (parentProjectConfig != null) { try { @@ -199,6 +200,20 @@ public void modifyVariables( } } + // Expose the immediate parent project (if any) for path references like + // ${PARENT_PROJECT_HOME}/shared/pipeline.hpl. Clear when missing so project + // switches do not leave stale values. + // + if (parentProjectConfig != null && parentProject != null) { + variables.setVariable( + ProjectsUtil.VARIABLE_PARENT_PROJECT_NAME, Const.NVL(realParentProjectName, "")); + String parentHome = variables.resolve(parentProjectConfig.getProjectHome()); + variables.setVariable(ProjectsUtil.VARIABLE_PARENT_PROJECT_HOME, Const.NVL(parentHome, "")); + } else { + variables.setVariable(ProjectsUtil.VARIABLE_PARENT_PROJECT_NAME, ""); + variables.setVariable(ProjectsUtil.VARIABLE_PARENT_PROJECT_HOME, ""); + } + // Set the name of the active environment // variables.setVariable( 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 d6a6e709711..ac30f082a83 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 @@ -46,6 +46,8 @@ public class ProjectsUtil { 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"; public static final String VARIABLE_HOP_DATASETS_FOLDER = "HOP_DATASETS_FOLDER"; public static final String VARIABLE_HOP_UNIT_TESTS_FOLDER = "HOP_UNIT_TESTS_FOLDER"; diff --git a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/ProjectsVariablesControlSpaceSortOrderPrefix.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/ProjectsVariablesControlSpaceSortOrderPrefix.java index 26087335986..7ca0f82a69d 100644 --- a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/ProjectsVariablesControlSpaceSortOrderPrefix.java +++ b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/ProjectsVariablesControlSpaceSortOrderPrefix.java @@ -37,6 +37,8 @@ public void callExtensionPoint( ILogChannel log, IVariables variables, Map prefixMap) throws HopException { prefixMap.put(ProjectsUtil.VARIABLE_PROJECT_HOME, "310_"); + prefixMap.put(ProjectsUtil.VARIABLE_PARENT_PROJECT_HOME, "311_"); + prefixMap.put(ProjectsUtil.VARIABLE_PARENT_PROJECT_NAME, "312_"); prefixMap.put(Defaults.VARIABLE_HOP_PROJECT_NAME, "450_"); prefixMap.put(Defaults.VARIABLE_HOP_ENVIRONMENT_NAME, "450_"); prefixMap.put(ProjectsUtil.VARIABLE_HOP_DATASETS_FOLDER, "450_"); diff --git a/plugins/misc/projects/src/test/java/org/apache/hop/projects/project/ProjectTest.java b/plugins/misc/projects/src/test/java/org/apache/hop/projects/project/ProjectTest.java index 4cb65c6110c..a9e67cc5cbb 100644 --- a/plugins/misc/projects/src/test/java/org/apache/hop/projects/project/ProjectTest.java +++ b/plugins/misc/projects/src/test/java/org/apache/hop/projects/project/ProjectTest.java @@ -17,15 +17,46 @@ package org.apache.hop.projects.project; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; 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.Comparator; +import java.util.stream.Stream; +import org.apache.hop.core.variables.IVariables; +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.util.Defaults; +import org.apache.hop.projects.util.ProjectsUtil; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; public class ProjectTest { + private final java.util.List registeredProjectNames = new ArrayList<>(); + private Path tempRoot; + + @AfterEach + public void tearDown() throws Exception { + ProjectsConfig config = ProjectsConfigSingleton.getConfig(); + for (String name : registeredProjectNames) { + config.removeProjectConfig(name); + } + registeredProjectNames.clear(); + if (tempRoot != null && Files.exists(tempRoot)) { + try (Stream walk = Files.walk(tempRoot)) { + walk.sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete); + } + tempRoot = null; + } + } + @Test public void testEnforcingExecutionInHomeSerialization() throws Exception { File tempFile = Files.createTempFile("project-config-test", ".json").toFile(); @@ -54,4 +85,120 @@ public void testEnforcingExecutionInHomeSerialization() throws Exception { tempFile.delete(); } } + + @Test + public void testParentProjectVariablesWhenNoParent() throws Exception { + tempRoot = Files.createTempDirectory("hop-project-no-parent"); + Path home = tempRoot.resolve("child"); + Files.createDirectories(home); + writeMinimalConfig(home, null); + + ProjectConfig projectConfig = + new ProjectConfig( + "orphan", home.toString(), ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME); + registerProject(projectConfig); + + Project project = projectConfig.loadProject(new Variables()); + IVariables variables = new Variables(); + project.modifyVariables(variables, projectConfig, new ArrayList<>(), null); + + assertEquals("orphan", variables.getVariable(Defaults.VARIABLE_HOP_PROJECT_NAME)); + assertEquals(home.toString(), variables.getVariable(ProjectsUtil.VARIABLE_PROJECT_HOME)); + assertEquals("", variables.getVariable(ProjectsUtil.VARIABLE_PARENT_PROJECT_NAME)); + assertEquals("", variables.getVariable(ProjectsUtil.VARIABLE_PARENT_PROJECT_HOME)); + } + + @Test + public void testParentProjectVariablesWithParent() throws Exception { + tempRoot = Files.createTempDirectory("hop-project-with-parent"); + Path parentHome = tempRoot.resolve("parent"); + Path childHome = tempRoot.resolve("child"); + Files.createDirectories(parentHome); + Files.createDirectories(childHome); + writeMinimalConfig(parentHome, null); + writeMinimalConfig(childHome, "parent-proj"); + + ProjectConfig parentConfig = + new ProjectConfig( + "parent-proj", parentHome.toString(), ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME); + ProjectConfig childConfig = + new ProjectConfig( + "child-proj", childHome.toString(), ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME); + registerProject(parentConfig); + registerProject(childConfig); + + Project child = childConfig.loadProject(new Variables()); + IVariables variables = new Variables(); + child.modifyVariables(variables, childConfig, new ArrayList<>(), null); + + assertEquals("child-proj", variables.getVariable(Defaults.VARIABLE_HOP_PROJECT_NAME)); + assertEquals(childHome.toString(), variables.getVariable(ProjectsUtil.VARIABLE_PROJECT_HOME)); + assertEquals("parent-proj", variables.getVariable(ProjectsUtil.VARIABLE_PARENT_PROJECT_NAME)); + assertEquals( + parentHome.toString(), variables.getVariable(ProjectsUtil.VARIABLE_PARENT_PROJECT_HOME)); + } + + @Test + public void testParentProjectVariablesImmediateParentOnly() throws Exception { + tempRoot = Files.createTempDirectory("hop-project-chain"); + Path grandHome = tempRoot.resolve("grand"); + Path parentHome = tempRoot.resolve("parent"); + Path childHome = tempRoot.resolve("child"); + Files.createDirectories(grandHome); + Files.createDirectories(parentHome); + Files.createDirectories(childHome); + writeMinimalConfig(grandHome, null); + writeMinimalConfig(parentHome, "grand-proj"); + writeMinimalConfig(childHome, "parent-proj"); + + registerProject( + new ProjectConfig( + "grand-proj", grandHome.toString(), ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME)); + registerProject( + new ProjectConfig( + "parent-proj", parentHome.toString(), ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME)); + ProjectConfig childConfig = + new ProjectConfig( + "child-proj", childHome.toString(), ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME); + registerProject(childConfig); + + Project child = childConfig.loadProject(new Variables()); + IVariables variables = new Variables(); + child.modifyVariables(variables, childConfig, new ArrayList<>(), null); + + // Immediate parent only — not the grandparent + assertEquals("parent-proj", variables.getVariable(ProjectsUtil.VARIABLE_PARENT_PROJECT_NAME)); + assertEquals( + parentHome.toString(), variables.getVariable(ProjectsUtil.VARIABLE_PARENT_PROJECT_HOME)); + assertEquals("child-proj", variables.getVariable(Defaults.VARIABLE_HOP_PROJECT_NAME)); + assertEquals(childHome.toString(), variables.getVariable(ProjectsUtil.VARIABLE_PROJECT_HOME)); + } + + private void registerProject(ProjectConfig projectConfig) { + ProjectsConfigSingleton.getConfig().addProjectConfig(projectConfig); + registeredProjectNames.add(projectConfig.getProjectName()); + } + + private static void writeMinimalConfig(Path projectHome, String parentProjectName) + throws Exception { + String parentJson = + parentProjectName == null ? "null" : "\"" + parentProjectName.replace("\"", "\\\"") + "\""; + String json = + "{\n" + + " \"metadataBaseFolder\" : \"${PROJECT_HOME}/metadata\",\n" + + " \"unitTestsBasePath\" : \"${PROJECT_HOME}\",\n" + + " \"dataSetsCsvFolder\" : \"${PROJECT_HOME}/datasets\",\n" + + " \"enforcingExecutionInHome\" : true,\n" + + " \"parentProjectName\" : " + + parentJson + + ",\n" + + " \"config\" : {\n" + + " \"variables\" : [ ]\n" + + " }\n" + + "}\n"; + Files.writeString( + projectHome.resolve(ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME), + json, + StandardCharsets.UTF_8); + } } From 3830b5f8af3fe7cb492018f0d77d7943ea502608 Mon Sep 17 00:00:00 2001 From: mattcasters Date: Sun, 19 Jul 2026 12:29:37 +0200 Subject: [PATCH 09/15] Issue #2596 : add parent project support to Docker basic image Register an optional one-level parent project in the container via HOP_PARENT_PROJECT_NAME/FOLDER so child projects inherit parent metadata. Preserve existing parentProjectName when keeping config files. --- docker/Dockerfile | 4 ++ docker/resources/load-and-execute.sh | 67 +++++++++++++++---- docker/resources/run-web.sh | 67 ++++++++++++++++--- docker/unified.Dockerfile | 6 ++ docker/web.Dockerfile | 4 ++ .../modules/ROOT/pages/docker-container.adoc | 44 ++++++++++++ .../modules/ROOT/pages/hop-gui/hop-web.adoc | 6 ++ .../project/ManageProjectsOptionPlugin.java | 8 ++- 8 files changed, 182 insertions(+), 24 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 9a1f3d37eb5..a83a3c302e7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -42,6 +42,10 @@ ENV HOP_PROJECT_DIRECTORY= ENV HOP_PROJECT_FOLDER= # name of the project config file including file extension ENV HOP_PROJECT_CONFIG_FILE_NAME=project-config.json +# optional one-level parent project (see issue #2596) +ENV HOP_PARENT_PROJECT_NAME= +ENV HOP_PARENT_PROJECT_FOLDER= +ENV HOP_PARENT_PROJECT_CONFIG_FILE_NAME=project-config.json # environment to use with hop run ENV HOP_ENVIRONMENT_NAME=environment1 # comma separated list of paths to environment config files (including filename and file extension). diff --git a/docker/resources/load-and-execute.sh b/docker/resources/load-and-execute.sh index fa754fa6dba..91fc3e23a89 100755 --- a/docker/resources/load-and-execute.sh +++ b/docker/resources/load-and-execute.sh @@ -188,6 +188,36 @@ if [ -n "${HOP_SYSTEM_PROPERTIES}" ]; then HOP_EXEC_OPTIONS+=("--system-properties=${HOP_SYSTEM_PROPERTIES}") fi +# Register a project in hop-config if it is not already present. +# Usage: register_hop_project NAME FOLDER CONFIG_FILE [PARENT_NAME] +# +register_hop_project() { + local name="$1" + local home="$2" + local cfg="$3" + local parent="${4:-}" + + if $("${DEPLOYMENT_PATH}"/hop-conf.sh -pl | grep -q -E "^ ${name} :"); then + log "project ${name} already exists" + return 0 + fi + + local conf_args=( + --project="${name}" + --project-create + --project-home="${home}" + --project-config-file="${cfg}" + --project-keep-config-file + ) + if [ -n "${parent}" ]; then + conf_args+=(--project-parent="${parent}") + fi + + log "Registering project ${name} in the Hop container configuration (home=${home})" + log "${DEPLOYMENT_PATH}/hop-conf.sh ${conf_args[*]}" + "${DEPLOYMENT_PATH}"/hop-conf.sh "${conf_args[@]}" +} + # If a project folder is defined we assume that we want to create it in the container # if [ -n "${HOP_PROJECT_FOLDER}" ]; then @@ -208,20 +238,33 @@ if [ -n "${HOP_PROJECT_FOLDER}" ]; then log "The specified project folder exists" fi - log "Registering project ${HOP_PROJECT_NAME} in the Hop container configuration" - log "${DEPLOYMENT_PATH}/hop-conf.sh --project=${HOP_PROJECT_NAME} --project-create --project-home='${HOP_PROJECT_FOLDER}' --project-config-file='${HOP_PROJECT_CONFIG_FILE_NAME}' --project-keep-config-file" - - if $("${DEPLOYMENT_PATH}"/hop-conf.sh -pl | grep -q -E "^ ${HOP_PROJECT_NAME} :"); then - log "project ${HOP_PROJECT_NAME} already exists" - else - "${DEPLOYMENT_PATH}"/hop-conf.sh \ - --project="${HOP_PROJECT_NAME}" \ - --project-create \ - --project-home="${HOP_PROJECT_FOLDER}" \ - --project-config-file="${HOP_PROJECT_CONFIG_FILE_NAME}" \ - --project-keep-config-file + # Optional one-level parent project (issue #2596). Register the parent first so + # metadata inheritance and PARENT_PROJECT_HOME resolve when the child is enabled. + # + HOP_PARENT_PROJECT_CONFIG_FILE_NAME="${HOP_PARENT_PROJECT_CONFIG_FILE_NAME:-project-config.json}" + if [ -n "${HOP_PARENT_PROJECT_FOLDER}" ] || [ -n "${HOP_PARENT_PROJECT_NAME}" ]; then + if [ -z "${HOP_PARENT_PROJECT_FOLDER}" ] || [ -z "${HOP_PARENT_PROJECT_NAME}" ]; then + log "Error: both HOP_PARENT_PROJECT_NAME and HOP_PARENT_PROJECT_FOLDER must be set to register a parent project" + exitWithCode 9 + fi + if [ ! -d "${HOP_PARENT_PROJECT_FOLDER}" ]; then + log "Warning: the folder specified in variable HOP_PARENT_PROJECT_FOLDER does not exist in the container: ${HOP_PARENT_PROJECT_FOLDER}" + else + log "The specified parent project folder exists" + fi + register_hop_project \ + "${HOP_PARENT_PROJECT_NAME}" \ + "${HOP_PARENT_PROJECT_FOLDER}" \ + "${HOP_PARENT_PROJECT_CONFIG_FILE_NAME}" fi + HOP_PROJECT_CONFIG_FILE_NAME="${HOP_PROJECT_CONFIG_FILE_NAME:-project-config.json}" + register_hop_project \ + "${HOP_PROJECT_NAME}" \ + "${HOP_PROJECT_FOLDER}" \ + "${HOP_PROJECT_CONFIG_FILE_NAME}" \ + "${HOP_PARENT_PROJECT_NAME:-}" + HOP_EXEC_OPTIONS+=("--project=${HOP_PROJECT_NAME}") # If we have environment files specified we want to create an environment as well: diff --git a/docker/resources/run-web.sh b/docker/resources/run-web.sh index be86ae7e612..0308838341a 100755 --- a/docker/resources/run-web.sh +++ b/docker/resources/run-web.sh @@ -80,6 +80,36 @@ install_jdbc_drivers() { # HOP_EXEC_OPTIONS="--level=${HOP_LOG_LEVEL}" +# Register a project in hop-config if it is not already present. +# Usage: register_hop_project NAME FOLDER CONFIG_FILE [PARENT_NAME] +# +register_hop_project() { + local name="$1" + local home="$2" + local cfg="$3" + local parent="${4:-}" + + if $("${DEPLOYMENT_PATH}"/hop-conf.sh -pl | grep -q -E "^ ${name} :"); then + log "project ${name} already exists" + return 0 + fi + + local conf_args=( + --project="${name}" + --project-create + --project-home="${home}" + --project-config-file="${cfg}" + --project-keep-config-file + ) + if [ -n "${parent}" ]; then + conf_args+=(--project-parent="${parent}") + fi + + log "Registering project ${name} in the Hop container configuration (home=${home})" + log "${DEPLOYMENT_PATH}/hop-conf.sh ${conf_args[*]}" + "${DEPLOYMENT_PATH}"/hop-conf.sh "${conf_args[@]}" +} + # If a project folder is defined we assume that we want to create it in the container # if [ -n "${HOP_PROJECT_FOLDER}" ]; then @@ -101,19 +131,34 @@ if [ -n "${HOP_PROJECT_FOLDER}" ]; then log "The specified project folder exists" fi - log "Registering project ${HOP_PROJECT_NAME} in the Hop container configuration" - log "${DEPLOYMENT_PATH}/hop-conf.sh --project=${HOP_PROJECT_NAME} --project-create --project-home='${HOP_PROJECT_FOLDER}' --project-config-file='${HOP_PROJECT_CONFIG_FILE_NAME}'" - - if $("${DEPLOYMENT_PATH}"/hop-conf.sh -pl | grep -q -E "^ ${HOP_PROJECT_NAME} :"); then - log "project ${HOP_PROJECT_NAME} already exists" - else - "${DEPLOYMENT_PATH}"/hop-conf.sh \ - --project="${HOP_PROJECT_NAME}" \ - --project-create \ - --project-home="${HOP_PROJECT_FOLDER}" \ - --project-config-file="${HOP_PROJECT_CONFIG_FILE_NAME}" + # Optional one-level parent project (issue #2596). Register the parent first so + # metadata inheritance and PARENT_PROJECT_HOME resolve when the child is enabled. + # + HOP_PARENT_PROJECT_CONFIG_FILE_NAME="${HOP_PARENT_PROJECT_CONFIG_FILE_NAME:-project-config.json}" + if [ -n "${HOP_PARENT_PROJECT_FOLDER}" ] || [ -n "${HOP_PARENT_PROJECT_NAME}" ]; then + if [ -z "${HOP_PARENT_PROJECT_FOLDER}" ] || [ -z "${HOP_PARENT_PROJECT_NAME}" ]; then + log "Error: both HOP_PARENT_PROJECT_NAME and HOP_PARENT_PROJECT_FOLDER must be set to register a parent project" + exitWithCode 9 + fi + if [ ! -d "${HOP_PARENT_PROJECT_FOLDER}" ]; then + log "Error: the folder specified in variable HOP_PARENT_PROJECT_FOLDER does not exist: ${HOP_PARENT_PROJECT_FOLDER}" + exitWithCode 9 + else + log "The specified parent project folder exists" + fi + register_hop_project \ + "${HOP_PARENT_PROJECT_NAME}" \ + "${HOP_PARENT_PROJECT_FOLDER}" \ + "${HOP_PARENT_PROJECT_CONFIG_FILE_NAME}" fi + HOP_PROJECT_CONFIG_FILE_NAME="${HOP_PROJECT_CONFIG_FILE_NAME:-project-config.json}" + register_hop_project \ + "${HOP_PROJECT_NAME}" \ + "${HOP_PROJECT_FOLDER}" \ + "${HOP_PROJECT_CONFIG_FILE_NAME}" \ + "${HOP_PARENT_PROJECT_NAME:-}" + HOP_EXEC_OPTIONS="${HOP_EXEC_OPTIONS} --project=${HOP_PROJECT_NAME}" # If we have environment files specified we want to create an environment as well: diff --git a/docker/unified.Dockerfile b/docker/unified.Dockerfile index e2390351010..4fdc6d5d018 100644 --- a/docker/unified.Dockerfile +++ b/docker/unified.Dockerfile @@ -258,6 +258,9 @@ ENV HOP_PROJECT_NAME= ENV HOP_PROJECT_DIRECTORY= ENV HOP_PROJECT_FOLDER= ENV HOP_PROJECT_CONFIG_FILE_NAME=project-config.json +ENV HOP_PARENT_PROJECT_NAME= +ENV HOP_PARENT_PROJECT_FOLDER= +ENV HOP_PARENT_PROJECT_CONFIG_FILE_NAME=project-config.json ENV HOP_ENVIRONMENT_NAME= ENV HOP_ENVIRONMENT_CONFIG_FILE_NAME_PATHS= ENV HOP_RUN_CONFIG= @@ -334,6 +337,9 @@ ENV HOP_DRIVERS_MAVEN_REPO= ENV HOP_GUI_ZOOM_FACTOR=1.0 ENV HOP_PROJECT_FOLDER= ENV HOP_PROJECT_CONFIG_FILE_NAME=project-config.json +ENV HOP_PARENT_PROJECT_NAME= +ENV HOP_PARENT_PROJECT_FOLDER= +ENV HOP_PARENT_PROJECT_CONFIG_FILE_NAME=project-config.json ENV HOP_ENVIRONMENT_NAME=environment1 ENV HOP_ENVIRONMENT_CONFIG_FILE_NAME_PATHS= diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile index 7dfd19c72bf..70fed1a1d97 100644 --- a/docker/web.Dockerfile +++ b/docker/web.Dockerfile @@ -43,6 +43,10 @@ ENV HOP_GUI_ZOOM_FACTOR=1.0 ENV HOP_PROJECT_FOLDER= # name of the project config file including file extension ENV HOP_PROJECT_CONFIG_FILE_NAME=project-config.json +# optional one-level parent project (see issue #2596) +ENV HOP_PARENT_PROJECT_NAME= +ENV HOP_PARENT_PROJECT_FOLDER= +ENV HOP_PARENT_PROJECT_CONFIG_FILE_NAME=project-config.json # environment to use with hop run ENV HOP_ENVIRONMENT_NAME=environment1 # comma separated list of paths to environment config files (including filename and file extension). diff --git a/docs/hop-tech-manual/modules/ROOT/pages/docker-container.adoc b/docs/hop-tech-manual/modules/ROOT/pages/docker-container.adoc index 49bd5d46e6b..156f591e0a7 100644 --- a/docs/hop-tech-manual/modules/ROOT/pages/docker-container.adoc +++ b/docs/hop-tech-manual/modules/ROOT/pages/docker-container.adoc @@ -86,6 +86,22 @@ If you do not set this variable, no project or environment will be created. |`project-config.json` | Name of the project config file. +|```HOP_PARENT_PROJECT_NAME``` +| +| Optional name of a one-level parent project to register in the container before the main project. +Required together with `HOP_PARENT_PROJECT_FOLDER` when the main project inherits metadata from another project (`parentProjectName` in `project-config.json`). +Only a single parent level is supported in the image; deeper chains need a custom entrypoint. + +|```HOP_PARENT_PROJECT_FOLDER``` +| +| Path to the home of the parent Hop project inside the container. +Required together with `HOP_PARENT_PROJECT_NAME`. +Mount both parent and child project folders into the container. + +|```HOP_PARENT_PROJECT_CONFIG_FILE_NAME``` +|`project-config.json` +| Name of the parent project config file (relative to the parent home). + |```HOP_ENVIRONMENT_NAME``` | | The name of the Hop environment to create in the container. @@ -292,6 +308,34 @@ docker run -it --rm \ apache/hop: ---- +=== Projects with a parent + +When a project inherits metadata from another project (`parentProjectName` in `project-config.json`), both project homes must be mounted and the parent must be registered in the container as well. + +Set `HOP_PARENT_PROJECT_NAME` and `HOP_PARENT_PROJECT_FOLDER` (optionally `HOP_PARENT_PROJECT_CONFIG_FILE_NAME`). +The entrypoint registers the parent first, then the main project with `--project-parent`, so parent metadata is merged into `HOP_METADATA_FOLDER` and the built-in variables `PARENT_PROJECT_HOME` / `PARENT_PROJECT_NAME` resolve correctly. + +Only **one parent level** is supported through these environment variables. + +[source,bash] +---- +docker run -it --rm \ + --env HOP_LOG_LEVEL=Basic \ + --env HOP_FILE_PATH='jobs/load_projectB_data.hwf' \ + --env HOP_PROJECT_FOLDER=/files/projects/projectB \ + --env HOP_PROJECT_NAME=projectB \ + --env HOP_PARENT_PROJECT_FOLDER=/files/projects/projectA \ + --env HOP_PARENT_PROJECT_NAME=projectA \ + --env HOP_ENVIRONMENT_NAME=prod_docker_exec \ + --env HOP_ENVIRONMENT_CONFIG_FILE_NAME_PATHS=/files/projects/projectB/prod_docker_exec-config.json \ + --env HOP_RUN_CONFIG=docker_run_config \ + -v /path/to/projects:/files/projects \ + --name docker-exec-projectB \ + apache/hop: +---- + +The value of `HOP_PARENT_PROJECT_NAME` should match `parentProjectName` in the child project's `project-config.json`. + === Running the long-lived container (server) If you need a **long-lived container**, this option is also available. diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc index 1ceeb5d11be..2fedb3e159b 100644 --- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc +++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc @@ -65,6 +65,12 @@ If you do not set this variable, no project or environment will be created. |```HOP_PROJECT_FOLDER``` | Path to the home of the Hop project. +|```HOP_PARENT_PROJECT_NAME``` +| Optional name of a one-level parent project to register before the main project (required with `HOP_PARENT_PROJECT_FOLDER` when the project inherits metadata). + +|```HOP_PARENT_PROJECT_FOLDER``` +| Path to the home of the parent Hop project. + |```HOP_ENVIRONMENT_NAME``` | The name of the Hop environment to create in the container. If you do not set this variable, no environment will be created. 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 e925f03431e..6607ddc8648 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 @@ -386,7 +386,13 @@ private void createProject(ILogChannel log, ProjectsConfig config, IVariables va log.logBasic(CONST_PROJECT + projectName + "' was created for home folder : " + projectHome); Project project = projectConfig.loadProject(variables); - project.setParentProjectName(config.getStandardParentProject()); + // Keep an existing parent from a pre-existing project-config.json (Docker + // --project-keep-config-file). Only fall back to the standard parent when none is set. + // --project-parent still wins via modifyProjectSettings below. + // + if (StringUtils.isEmpty(project.getParentProjectName())) { + project.setParentProjectName(config.getStandardParentProject()); + } modifyProjectSettings(project); // Check to see if there's not a loop in the project parent hierarchy From 1c69b468290a8337f0a143c2e7548c3c03e44ec4 Mon Sep 17 00:00:00 2001 From: mattcasters Date: Sun, 19 Jul 2026 13:20:01 +0200 Subject: [PATCH 10/15] Issue #2931 : expanded integer notation for large count/size fields Add Const.expandIntegerString / toIntExpanded / toLongExpanded so users can enter large integers with grouping separators (100,000,000), k/m/g/b suffixes (100m), and scientific forms (1e8, 1x10^8). Opt-in at row limits, sort/buffer sizes, commit/batch sizes, and related parse sites without changing strict toInt/toLong. Add a TextVar # indicator (next to $) with tooltip documenting the formats on dialogs that use expanded parsing. --- .../main/java/org/apache/hop/core/Const.java | 231 ++++++++++++++++++ .../java/org/apache/hop/core/ConstTest.java | 58 +++++ .../cratedbbulkloader/CrateDBBulkLoader.java | 4 +- .../CrateDBBulkLoaderDialog.java | 1 + .../monetdbbulkloader/MonetDbBulkLoader.java | 2 +- .../MonetDbBulkLoaderDialog.java | 1 + .../mysqlbulkloader/MySqlBulkLoader.java | 2 +- .../MySqlBulkLoaderDialog.java | 1 + .../orabulkloader/OraBulkLoaderDialog.java | 4 + .../orabulkloader/OraBulkLoaderMeta.java | 24 +- .../bulkloader/SnowflakeBulkLoader.java | 4 +- .../bulkloader/SnowflakeBulkLoaderDialog.java | 1 + .../BeamFlinkPipelineRunConfiguration.java | 2 +- .../BeamSparkPipelineRunConfiguration.java | 4 +- .../BeamRowGeneratorTransformHandler.java | 4 +- .../transforms/avroinput/AvroFileInput.java | 2 +- .../avroinput/AvroFileInputDialog.java | 1 + .../eventhubs/listen/AzureListener.java | 8 +- .../eventhubs/listen/AzureListenerDialog.java | 2 + .../eventhubs/write/AzureWrite.java | 2 +- .../eventhubs/write/AzureWriterDialog.java | 1 + .../GoogleAnalyticsDialog.java | 2 +- .../hop/neo4j/transforms/cypher/Cypher.java | 2 +- .../neo4j/transforms/cypher/CypherDialog.java | 1 + .../cypherbuilder/CypherBuilder.java | 2 +- .../cypherbuilder/CypherBuilderDialog.java | 1 + .../neo4j/transforms/graph/GraphOutput.java | 2 +- .../transforms/graph/GraphOutputDialog.java | 1 + .../neo4j/transforms/output/Neo4JOutput.java | 2 +- .../transforms/output/Neo4JOutputDialog.java | 1 + .../transforms/output/ParquetOutput.java | 8 +- .../output/ParquetOutputDialog.java | 4 + .../salesforceinput/SalesforceInput.java | 2 +- .../SalesforceInputDialog.java | 1 + .../transforms/clonerow/CloneRow.java | 2 +- .../transforms/clonerow/CloneRowDialog.java | 1 + .../transforms/cubeinput/CubeInput.java | 2 +- .../databasejoin/DatabaseJoinDialog.java | 2 +- .../transforms/delete/DeleteDialog.java | 1 + .../transforms/delete/DeleteMeta.java | 4 +- .../dynamicsqlrow/DynamicSqlRowDialog.java | 2 +- .../excelinput/ExcelInputDialog.java | 2 +- .../transforms/filemetadata/FileMetadata.java | 4 +- .../filemetadata/FileMetadataDialog.java | 1 + .../getfilenames/GetFileNamesDialog.java | 2 +- .../getsubfolders/GetSubFoldersDialog.java | 2 +- .../insertupdate/InsertUpdateDialog.java | 1 + .../insertupdate/InsertUpdateMeta.java | 4 +- .../transforms/jsoninput/JsonInputDialog.java | 2 +- .../JsonNormalizeInputDialog.java | 2 +- .../kafka/consumer/KafkaConsumerInput.java | 4 +- .../consumer/KafkaConsumerInputDialog.java | 1 + .../consumer/KafkaConsumerInputMeta.java | 4 +- .../transforms/ldapinput/LdapInputDialog.java | 2 +- .../loadfileinput/LoadFileInputDialog.java | 2 +- .../pipelineexecutor/PipelineExecutor.java | 2 +- .../PipelineExecutorDialog.java | 3 +- .../propertyinput/PropertyInputDialog.java | 2 +- .../reservoirsampling/ReservoirSampling.java | 6 +- .../ReservoirSamplingDialog.java | 1 + .../transforms/rowgenerator/RowGenerator.java | 4 +- .../rowgenerator/RowGeneratorDialog.java | 1 + .../rowgenerator/RowGeneratorMeta.java | 2 +- .../transforms/sasinput/SasInput.java | 2 +- .../transforms/sasinput/SasInputDialog.java | 1 + .../pipeline/transforms/sort/SortRows.java | 2 +- .../transforms/sort/SortRowsDialog.java | 1 + .../SynchronizeAfterMerge.java | 5 +- .../SynchronizeAfterMergeDialog.java | 1 + .../transforms/tableinput/TableInput.java | 2 +- .../tableinput/TableInputDialog.java | 1 + .../transforms/tableoutput/TableOutput.java | 5 +- .../tableoutput/TableOutputDialog.java | 1 + .../transforms/csvinput/CsvInput.java | 6 +- .../transforms/csvinput/CsvInputDialog.java | 1 + .../fileinput/text/TextFileInputDialog.java | 2 +- .../pipeline/transforms/tika/TikaDialog.java | 2 +- .../transforms/update/UpdateDialog.java | 1 + .../transforms/update/UpdateMeta.java | 4 +- .../workflowexecutor/WorkflowExecutor.java | 2 +- .../WorkflowExecutorDialog.java | 3 +- .../xml/getxmldata/GetXmlDataDialog.java | 2 +- .../xml/xmlinputstream/XmlInputStream.java | 4 +- .../xmlinputstream/XmlInputStreamDialog.java | 2 + .../transforms/yamlinput/YamlInputDialog.java | 2 +- .../apache/hop/ui/core/gui/GuiResource.java | 15 ++ .../apache/hop/ui/core/widget/TextVar.java | 54 +++- .../widget/messages/messages_en_US.properties | 1 + ui/src/main/resources/ui/images/hash.svg | 8 + 89 files changed, 494 insertions(+), 92 deletions(-) create mode 100644 ui/src/main/resources/ui/images/hash.svg diff --git a/core/src/main/java/org/apache/hop/core/Const.java b/core/src/main/java/org/apache/hop/core/Const.java index 7394cd4b334..fa25fec12dc 100644 --- a/core/src/main/java/org/apache/hop/core/Const.java +++ b/core/src/main/java/org/apache/hop/core/Const.java @@ -1291,6 +1291,237 @@ public static long toLong(String str, long def) { return retval; } + /** + * Pattern for scientific / power-of-ten integer forms: {@code 1e8}, {@code 1.5E+6}, {@code + * 1x10^8}, {@code 1×10^8}, {@code 10^8}. + */ + private static final Pattern EXPANDED_SCIENTIFIC_PATTERN = + Pattern.compile( + "^([+-]?)(?:([0-9][0-9_ .,]*[0-9]|[0-9])\\s*[x×]\\s*10\\s*\\^\\s*|([0-9][0-9_ .,]*)\\s*[eE]|10\\s*\\^\\s*)([+-]?\\d+)$"); + + /** Pattern for optional trailing magnitude suffix: k / m / g / b (case-insensitive). */ + private static final Pattern EXPANDED_SUFFIX_PATTERN = + Pattern.compile("^([+-]?)(.*?)\\s*([kKmMgGbB])$"); + + /** + * Expand a human-friendly integer string into a plain digit form (optional leading sign). + * + *

Supports: + * + *

    + *
  • Grouping separators: spaces, underscores, commas; dots when used as thousands grouping + * (e.g. {@code 100.000.000}) + *
  • Trailing magnitude suffixes: {@code k}/{@code K} (×10³), {@code m}/{@code M} (×10⁶), + * {@code g}/{@code G}/{@code b}/{@code B} (×10⁹). A single decimal separator is allowed in + * the coefficient when a suffix is present (e.g. {@code 1.5m}, {@code 1,5m}). + *
  • Scientific / power forms: {@code 1e8}, {@code 1E+8}, {@code 1x10^8}, {@code 10^8} + *
+ * + * @param str the input string + * @return expanded digit string (with optional leading {@code -}), or {@code null} if the input + * cannot be expanded to an integer + */ + public static String expandIntegerString(String str) { + if (str == null) { + return null; + } + String trimmed = str.trim(); + if (trimmed.isEmpty()) { + return null; + } + + try { + // Scientific / power-of-ten first (e.g. 1e8, 1x10^8, 10^8) + Matcher sci = EXPANDED_SCIENTIFIC_PATTERN.matcher(trimmed); + if (sci.matches()) { + String sign = sci.group(1); + String coeffGroup = sci.group(2) != null ? sci.group(2) : sci.group(3); + String expGroup = sci.group(4); + BigDecimal coefficient; + if (coeffGroup == null || coeffGroup.isEmpty()) { + // Form: 10^N + coefficient = BigDecimal.ONE; + } else { + coefficient = parseExpandedCoefficient(coeffGroup, true); + } + int exp = Integer.parseInt(expGroup); + BigDecimal value = coefficient.multiply(BigDecimal.TEN.pow(exp)); + return formatExpandedLong(sign, value); + } + + // Trailing magnitude suffix: k / m / g / b + Matcher suffixMatcher = EXPANDED_SUFFIX_PATTERN.matcher(trimmed); + if (suffixMatcher.matches()) { + String sign = suffixMatcher.group(1); + String body = suffixMatcher.group(2).trim(); + char suffix = Character.toLowerCase(suffixMatcher.group(3).charAt(0)); + if (body.isEmpty()) { + return null; + } + BigDecimal coefficient = parseExpandedCoefficient(body, true); + long multiplier; + switch (suffix) { + case 'k': + multiplier = 1_000L; + break; + case 'm': + multiplier = 1_000_000L; + break; + case 'g': + case 'b': + multiplier = 1_000_000_000L; + break; + default: + return null; + } + BigDecimal value = coefficient.multiply(BigDecimal.valueOf(multiplier)); + return formatExpandedLong(sign, value); + } + + // Plain integer with optional grouping separators + String sign = ""; + String body = trimmed; + if (body.startsWith("+") || body.startsWith("-")) { + sign = body.startsWith("-") ? "-" : ""; + body = body.substring(1).trim(); + } + body = stripIntegerGrouping(body); + if (body.isEmpty() || !body.chars().allMatch(Character::isDigit)) { + return null; + } + // Normalize leading zeros via Long parse (overflows → BigInteger path not needed for plain) + long value = Long.parseLong(body); + return sign + Long.toString(value); + } catch (Exception e) { + return null; + } + } + + /** + * Parse a coefficient that may contain grouping separators and at most one decimal separator. + * + * @param body coefficient text without sign or magnitude suffix + * @param allowDecimal whether a single decimal point/comma is allowed + */ + private static BigDecimal parseExpandedCoefficient(String body, boolean allowDecimal) { + String s = body.trim(); + // Remove spaces and underscores always + s = s.replace(" ", "").replace("_", ""); + + int lastDot = s.lastIndexOf('.'); + int lastComma = s.lastIndexOf(','); + boolean hasDot = lastDot >= 0; + boolean hasComma = lastComma >= 0; + + if (allowDecimal && (hasDot || hasComma)) { + // Decide which separator is decimal: the last of '.' or ',' + int decimalPos = Math.max(lastDot, lastComma); + String intPart = s.substring(0, decimalPos).replace(",", "").replace(".", ""); + String fracPart = s.substring(decimalPos + 1).replace(",", "").replace(".", ""); + if (intPart.isEmpty()) { + intPart = "0"; + } + if (!intPart.chars().allMatch(Character::isDigit) + || !fracPart.chars().allMatch(Character::isDigit)) { + throw new NumberFormatException("Invalid coefficient: " + body); + } + return new BigDecimal(intPart + "." + fracPart); + } + + s = stripIntegerGrouping(s); + if (s.isEmpty() || !s.chars().allMatch(Character::isDigit)) { + throw new NumberFormatException("Invalid coefficient: " + body); + } + return new BigDecimal(s); + } + + /** + * Strip thousands grouping from a digit string. Removes commas always; removes dots only when + * they form thousands groups of three digits (e.g. {@code 100.000.000}). + */ + private static String stripIntegerGrouping(String body) { + String s = body.replace(" ", "").replace("_", ""); + if (s.indexOf(',') >= 0) { + s = s.replace(",", ""); + } + if (s.indexOf('.') >= 0) { + // Thousands pattern: digits with groups of 3 after each dot, no other characters + if (s.matches("\\d{1,3}(\\.\\d{3})+")) { + s = s.replace(".", ""); + } else if (s.chars().filter(c -> c == '.').count() > 1) { + // Multiple dots that are not a clean thousands pattern → invalid later + return s; + } else { + // Single dot without suffix path: treat as invalid for pure integers + // (leave the dot so digit check fails) + } + } + return s; + } + + private static String formatExpandedLong(String sign, BigDecimal value) { + // Truncate toward zero for fractional results (e.g. 1.5m is exact; 1e-1 would become 0) + BigDecimal truncated = value.setScale(0, RoundingMode.DOWN); + if (truncated.compareTo(BigDecimal.valueOf(Long.MAX_VALUE)) > 0 + || truncated.compareTo(BigDecimal.valueOf(Long.MIN_VALUE)) < 0) { + throw new NumberFormatException("Expanded value out of long range"); + } + long longValue = truncated.longValueExact(); + if ("-".equals(sign)) { + // Avoid Long.MIN_VALUE negation issues; value should already be positive magnitude + if (longValue == Long.MIN_VALUE) { + throw new NumberFormatException("Expanded value out of long range"); + } + return Long.toString(-longValue); + } + return Long.toString(longValue); + } + + /** + * Convert a human-friendly integer String into a {@code long}. If conversion fails, return the + * default. See {@link #expandIntegerString(String)} for accepted forms. + * + * @param str The String to convert + * @param def The default value + * @return The converted value or the default + */ + public static long toLongExpanded(String str, long def) { + try { + String expanded = expandIntegerString(str); + if (expanded == null) { + return def; + } + return Long.parseLong(expanded); + } catch (Exception e) { + return def; + } + } + + /** + * Convert a human-friendly integer String into an {@code int}. If conversion fails or the value + * is outside the {@code int} range, return the default. See {@link #expandIntegerString(String)} + * for accepted forms. + * + * @param str The String to convert + * @param def The default value + * @return The converted value or the default + */ + public static int toIntExpanded(String str, int def) { + try { + String expanded = expandIntegerString(str); + if (expanded == null) { + return def; + } + long value = Long.parseLong(expanded); + if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) { + return def; + } + return (int) value; + } catch (Exception e) { + return def; + } + } + /** * Convert a String into a double. If the conversion fails, assign a default value. * diff --git a/core/src/test/java/org/apache/hop/core/ConstTest.java b/core/src/test/java/org/apache/hop/core/ConstTest.java index 787a3dd951c..f94a6bb1705 100644 --- a/core/src/test/java/org/apache/hop/core/ConstTest.java +++ b/core/src/test/java/org/apache/hop/core/ConstTest.java @@ -3639,6 +3639,64 @@ void testToLong() { assertEquals(-1447252914241L, Const.toLong("1447252914241L", -1447252914241L)); } + @Test + void testExpandIntegerString() { + assertEquals("123", Const.expandIntegerString("123")); + assertEquals("100000000", Const.expandIntegerString("100,000,000")); + assertEquals("100000000", Const.expandIntegerString("100.000.000")); + assertEquals("100000000", Const.expandIntegerString("100_000_000")); + assertEquals("100000000", Const.expandIntegerString("100 000 000")); + assertEquals("100000", Const.expandIntegerString("100k")); + assertEquals("100000", Const.expandIntegerString("100K")); + assertEquals("100000000", Const.expandIntegerString("100m")); + assertEquals("1000000000", Const.expandIntegerString("1g")); + assertEquals("1000000000", Const.expandIntegerString("1b")); + assertEquals("1500000", Const.expandIntegerString("1.5m")); + assertEquals("1500000", Const.expandIntegerString("1,5m")); + assertEquals("100000000", Const.expandIntegerString("1e8")); + assertEquals("100000000", Const.expandIntegerString("1E8")); + assertEquals("1500000", Const.expandIntegerString("1.5e6")); + assertEquals("100000000", Const.expandIntegerString("1x10^8")); + assertEquals("100000000", Const.expandIntegerString("1×10^8")); + assertEquals("100000000", Const.expandIntegerString("10^8")); + assertEquals("-1000000", Const.expandIntegerString("-1m")); + assertNull(Const.expandIntegerString(null)); + assertNull(Const.expandIntegerString("")); + assertNull(Const.expandIntegerString("abc")); + assertNull(Const.expandIntegerString("1z")); + } + + @Test + void testToLongExpanded() { + assertEquals(123L, Const.toLongExpanded("123", -12)); + assertEquals(100_000_000L, Const.toLongExpanded("100,000,000", -1)); + assertEquals(100_000_000L, Const.toLongExpanded("100.000.000", -1)); + assertEquals(100_000_000L, Const.toLongExpanded("100m", -1)); + assertEquals(1_500_000L, Const.toLongExpanded("1.5m", -1)); + assertEquals(100_000_000L, Const.toLongExpanded("1e8", -1)); + assertEquals(100_000_000L, Const.toLongExpanded("1x10^8", -1)); + assertEquals(100_000_000L, Const.toLongExpanded("10^8", -1)); + assertEquals(1_000_000_000L, Const.toLongExpanded("1b", -1)); + assertEquals(-1_000_000L, Const.toLongExpanded("-1m", -1)); + assertEquals(-12L, Const.toLongExpanded("123f", -12)); + assertEquals(-12L, Const.toLongExpanded(null, -12)); + assertEquals(-12L, Const.toLongExpanded("", -12)); + // Plain digit strings match toLong + assertEquals(1447252914241L, Const.toLongExpanded("1447252914241", -12)); + } + + @Test + void testToIntExpanded() { + assertEquals(123, Const.toIntExpanded("123", -12)); + assertEquals(100_000, Const.toIntExpanded("100k", -12)); + assertEquals(100_000_000, Const.toIntExpanded("100m", -12)); + assertEquals(-12, Const.toIntExpanded("123f", -12)); + assertEquals(-12, Const.toIntExpanded(null, -12)); + // Overflow past Integer.MAX_VALUE returns default + assertEquals(-12, Const.toIntExpanded("3g", -12)); + assertEquals(-12, Const.toIntExpanded("10^12", -12)); + } + @Test void testToDouble() { Assertions.assertEquals(123.45, Const.toDouble("123.45", -12.34), 1e-15); diff --git a/plugins/databases/cratedb/src/main/java/org/apache/hop/pipeline/transforms/cratedbbulkloader/CrateDBBulkLoader.java b/plugins/databases/cratedb/src/main/java/org/apache/hop/pipeline/transforms/cratedbbulkloader/CrateDBBulkLoader.java index 24880db16b1..3a5542f6596 100644 --- a/plugins/databases/cratedb/src/main/java/org/apache/hop/pipeline/transforms/cratedbbulkloader/CrateDBBulkLoader.java +++ b/plugins/databases/cratedb/src/main/java/org/apache/hop/pipeline/transforms/cratedbbulkloader/CrateDBBulkLoader.java @@ -364,7 +364,9 @@ private void defineSelectedFieldsMetadataList() throws HopTransformException { private void writeIfBatchSizeRecordsAreReached() throws HopException, CrateDBHopException, IOException { - int maxBatchSize = Integer.parseInt(meta.getBatchSize()); + String batchSize = meta.getBatchSize(); + String expandedBatchSize = Const.expandIntegerString(batchSize); + int maxBatchSize = Integer.parseInt(expandedBatchSize != null ? expandedBatchSize : batchSize); if (data.httpBulkArgs.size() >= maxBatchSize) { String[] columns = meta.getFields().stream() diff --git a/plugins/databases/cratedb/src/main/java/org/apache/hop/pipeline/transforms/cratedbbulkloader/CrateDBBulkLoaderDialog.java b/plugins/databases/cratedb/src/main/java/org/apache/hop/pipeline/transforms/cratedbbulkloader/CrateDBBulkLoaderDialog.java index bdb1a896507..c41ef878584 100644 --- a/plugins/databases/cratedb/src/main/java/org/apache/hop/pipeline/transforms/cratedbbulkloader/CrateDBBulkLoaderDialog.java +++ b/plugins/databases/cratedb/src/main/java/org/apache/hop/pipeline/transforms/cratedbbulkloader/CrateDBBulkLoaderDialog.java @@ -662,6 +662,7 @@ public void widgetSelected(SelectionEvent arg0) { wlBatchSize.setLayoutData(fdlBatchSize); wBatchSize = new TextVar(variables, wMainComp, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wBatchSize.enableExpandedInteger(); PropsUi.setLook(wBatchSize); wBatchSize.addModifyListener(lsMod); wBatchSize.addFocusListener(lsFocusLost); diff --git a/plugins/databases/monetdb/src/main/java/org/apache/hop/pipeline/transforms/monetdbbulkloader/MonetDbBulkLoader.java b/plugins/databases/monetdb/src/main/java/org/apache/hop/pipeline/transforms/monetdbbulkloader/MonetDbBulkLoader.java index fd54cb56cb9..cc908976599 100644 --- a/plugins/databases/monetdb/src/main/java/org/apache/hop/pipeline/transforms/monetdbbulkloader/MonetDbBulkLoader.java +++ b/plugins/databases/monetdb/src/main/java/org/apache/hop/pipeline/transforms/monetdbbulkloader/MonetDbBulkLoader.java @@ -584,7 +584,7 @@ public boolean init() { data.monetNumberMeta.setDecimalSymbol("."); data.monetNumberMeta.setStringEncoding(encoding); - data.bufferSize = Const.toInt(resolve(meta.getBufferSize()), 100000); + data.bufferSize = Const.toIntExpanded(resolve(meta.getBufferSize()), 100000); // Allocate the buffer // diff --git a/plugins/databases/monetdb/src/main/java/org/apache/hop/pipeline/transforms/monetdbbulkloader/MonetDbBulkLoaderDialog.java b/plugins/databases/monetdb/src/main/java/org/apache/hop/pipeline/transforms/monetdbbulkloader/MonetDbBulkLoaderDialog.java index c260755acc0..75feb73d7df 100644 --- a/plugins/databases/monetdb/src/main/java/org/apache/hop/pipeline/transforms/monetdbbulkloader/MonetDbBulkLoaderDialog.java +++ b/plugins/databases/monetdb/src/main/java/org/apache/hop/pipeline/transforms/monetdbbulkloader/MonetDbBulkLoaderDialog.java @@ -277,6 +277,7 @@ public void widgetSelected(SelectionEvent arg0) { PropsUi.setLook(wlBufferSize); wBufferSize = new TextVar(variables, wGeneralSettingsComp, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wBufferSize.enableExpandedInteger(); PropsUi.setLook(wBufferSize); wBufferSize.addModifyListener(lsMod); diff --git a/plugins/databases/mysql/src/main/java/org/apache/hop/pipeline/transforms/mysqlbulkloader/MySqlBulkLoader.java b/plugins/databases/mysql/src/main/java/org/apache/hop/pipeline/transforms/mysqlbulkloader/MySqlBulkLoader.java index 213568569d5..e378370bbba 100644 --- a/plugins/databases/mysql/src/main/java/org/apache/hop/pipeline/transforms/mysqlbulkloader/MySqlBulkLoader.java +++ b/plugins/databases/mysql/src/main/java/org/apache/hop/pipeline/transforms/mysqlbulkloader/MySqlBulkLoader.java @@ -577,7 +577,7 @@ public boolean init() { data.bulkNumberMeta.setDecimalSymbol("."); data.bulkNumberMeta.setStringEncoding(realEncoding); - data.bulkSize = Const.toLong(resolve(meta.getBulkSize()), -1L); + data.bulkSize = Const.toLongExpanded(resolve(meta.getBulkSize()), -1L); // Schema-table combination... DatabaseMeta databaseMeta = getPipelineMeta().findDatabase(meta.getConnection(), variables); diff --git a/plugins/databases/mysql/src/main/java/org/apache/hop/pipeline/transforms/mysqlbulkloader/MySqlBulkLoaderDialog.java b/plugins/databases/mysql/src/main/java/org/apache/hop/pipeline/transforms/mysqlbulkloader/MySqlBulkLoaderDialog.java index 3baf7cf6f8c..f289e639a7d 100644 --- a/plugins/databases/mysql/src/main/java/org/apache/hop/pipeline/transforms/mysqlbulkloader/MySqlBulkLoaderDialog.java +++ b/plugins/databases/mysql/src/main/java/org/apache/hop/pipeline/transforms/mysqlbulkloader/MySqlBulkLoaderDialog.java @@ -351,6 +351,7 @@ public void widgetSelected(SelectionEvent se) { fdlBulkSize.top = new FormAttachment(wCharSet, margin); wlBulkSize.setLayoutData(fdlBulkSize); wBulkSize = new TextVar(variables, wGeneralComp, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wBulkSize.enableExpandedInteger(); PropsUi.setLook(wBulkSize); wBulkSize.addModifyListener(lsMod); FormData fdBulkSize = new FormData(); diff --git a/plugins/databases/oracle/src/main/java/org/apache/hop/pipeline/transforms/orabulkloader/OraBulkLoaderDialog.java b/plugins/databases/oracle/src/main/java/org/apache/hop/pipeline/transforms/orabulkloader/OraBulkLoaderDialog.java index dd001020d54..ea59f540c10 100644 --- a/plugins/databases/oracle/src/main/java/org/apache/hop/pipeline/transforms/orabulkloader/OraBulkLoaderDialog.java +++ b/plugins/databases/oracle/src/main/java/org/apache/hop/pipeline/transforms/orabulkloader/OraBulkLoaderDialog.java @@ -360,6 +360,7 @@ public void widgetSelected(SelectionEvent e) { fdlMaxErrors.right = new FormAttachment(middle, -margin); wlMaxErrors.setLayoutData(fdlMaxErrors); wMaxErrors = new TextVar(variables, wBulkLoaderComposite, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wMaxErrors.enableExpandedInteger(); PropsUi.setLook(wMaxErrors); wMaxErrors.addModifyListener(lsMod); FormData fdMaxErrors = new FormData(); @@ -378,6 +379,7 @@ public void widgetSelected(SelectionEvent e) { fdlCommit.right = new FormAttachment(middle, -margin); wlCommit.setLayoutData(fdlCommit); wCommit = new TextVar(variables, wBulkLoaderComposite, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wCommit.enableExpandedInteger(); PropsUi.setLook(wCommit); wCommit.addModifyListener(lsMod); FormData fdCommit = new FormData(); @@ -396,6 +398,7 @@ public void widgetSelected(SelectionEvent e) { fdlBindSize.right = new FormAttachment(middle, -margin); wlBindSize.setLayoutData(fdlBindSize); wBindSize = new TextVar(variables, wBulkLoaderComposite, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wBindSize.enableExpandedInteger(); PropsUi.setLook(wBindSize); wBindSize.addModifyListener(lsMod); FormData fdBindSize = new FormData(); @@ -414,6 +417,7 @@ public void widgetSelected(SelectionEvent e) { fdlReadSize.right = new FormAttachment(middle, -margin); wlReadSize.setLayoutData(fdlReadSize); wReadSize = new TextVar(variables, wBulkLoaderComposite, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wReadSize.enableExpandedInteger(); PropsUi.setLook(wReadSize); wReadSize.addModifyListener(lsMod); FormData fdReadSize = new FormData(); diff --git a/plugins/databases/oracle/src/main/java/org/apache/hop/pipeline/transforms/orabulkloader/OraBulkLoaderMeta.java b/plugins/databases/oracle/src/main/java/org/apache/hop/pipeline/transforms/orabulkloader/OraBulkLoaderMeta.java index 31da7dd594e..98482e514ee 100644 --- a/plugins/databases/oracle/src/main/java/org/apache/hop/pipeline/transforms/orabulkloader/OraBulkLoaderMeta.java +++ b/plugins/databases/oracle/src/main/java/org/apache/hop/pipeline/transforms/orabulkloader/OraBulkLoaderMeta.java @@ -256,11 +256,7 @@ public OraBulkLoaderMeta() { } public int getCommitSizeAsInt(IVariables variables) { - try { - return Integer.parseInt(variables.resolve(getCommitSize())); - } catch (NumberFormatException ex) { - return DEFAULT_COMMIT_SIZE; - } + return Const.toIntExpanded(variables.resolve(getCommitSize()), DEFAULT_COMMIT_SIZE); } /** @@ -797,11 +793,7 @@ public void setEraseFiles(boolean eraseFiles) { } public int getBindSizeAsInt(IVariables variables) { - try { - return Integer.parseInt(variables.resolve(getBindSize())); - } catch (NumberFormatException ex) { - return DEFAULT_BIND_SIZE; - } + return Const.toIntExpanded(variables.resolve(getBindSize()), DEFAULT_BIND_SIZE); } public String getBindSize() { @@ -813,11 +805,7 @@ public void setBindSize(String bindSize) { } public int getMaxErrorsAsInt(IVariables variables) { - try { - return Integer.parseInt(variables.resolve(getMaxErrors())); - } catch (NumberFormatException ex) { - return DEFAULT_MAX_ERRORS; - } + return Const.toIntExpanded(variables.resolve(getMaxErrors()), DEFAULT_MAX_ERRORS); } public String getMaxErrors() { @@ -829,11 +817,7 @@ public void setMaxErrors(String maxErrors) { } public int getReadSizeAsInt(IVariables variables) { - try { - return Integer.parseInt(variables.resolve(getReadSize())); - } catch (NumberFormatException ex) { - return DEFAULT_READ_SIZE; - } + return Const.toIntExpanded(variables.resolve(getReadSize()), DEFAULT_READ_SIZE); } public String getReadSize() { diff --git a/plugins/databases/snowflake/src/main/java/org/apache/hop/pipeline/transforms/snowflake/bulkloader/SnowflakeBulkLoader.java b/plugins/databases/snowflake/src/main/java/org/apache/hop/pipeline/transforms/snowflake/bulkloader/SnowflakeBulkLoader.java index dd910ac7836..250676674b0 100644 --- a/plugins/databases/snowflake/src/main/java/org/apache/hop/pipeline/transforms/snowflake/bulkloader/SnowflakeBulkLoader.java +++ b/plugins/databases/snowflake/src/main/java/org/apache/hop/pipeline/transforms/snowflake/bulkloader/SnowflakeBulkLoader.java @@ -144,8 +144,8 @@ public synchronized boolean processRow() throws HopException { // Create a new split? if ((row != null && data.outputCount > 0 - && Const.toInt(resolve(meta.getSplitSize()), 0) > 0 - && (data.outputCount % Const.toInt(resolve(meta.getSplitSize()), 0)) == 0)) { + && Const.toIntExpanded(resolve(meta.getSplitSize()), 0) > 0 + && (data.outputCount % Const.toIntExpanded(resolve(meta.getSplitSize()), 0)) == 0)) { // Done with this part or with everything. closeFile(); diff --git a/plugins/databases/snowflake/src/main/java/org/apache/hop/pipeline/transforms/snowflake/bulkloader/SnowflakeBulkLoaderDialog.java b/plugins/databases/snowflake/src/main/java/org/apache/hop/pipeline/transforms/snowflake/bulkloader/SnowflakeBulkLoaderDialog.java index 97a24089250..b47e7d58b78 100644 --- a/plugins/databases/snowflake/src/main/java/org/apache/hop/pipeline/transforms/snowflake/bulkloader/SnowflakeBulkLoaderDialog.java +++ b/plugins/databases/snowflake/src/main/java/org/apache/hop/pipeline/transforms/snowflake/bulkloader/SnowflakeBulkLoaderDialog.java @@ -585,6 +585,7 @@ public void focusGained(FocusEvent focusEvent) { wlSplitSize.setLayoutData(fdlSplitSize); wSplitSize = new TextVar(variables, wLoaderComp, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wSplitSize.enableExpandedInteger(); PropsUi.setLook(wSplitSize); wSplitSize.addModifyListener(lsMod); FormData fdSplitSize = new FormData(); diff --git a/plugins/engines/beam/src/main/java/org/apache/hop/beam/engines/flink/BeamFlinkPipelineRunConfiguration.java b/plugins/engines/beam/src/main/java/org/apache/hop/beam/engines/flink/BeamFlinkPipelineRunConfiguration.java index 181ab191904..652cf1bc91d 100644 --- a/plugins/engines/beam/src/main/java/org/apache/hop/beam/engines/flink/BeamFlinkPipelineRunConfiguration.java +++ b/plugins/engines/beam/src/main/java/org/apache/hop/beam/engines/flink/BeamFlinkPipelineRunConfiguration.java @@ -367,7 +367,7 @@ public PipelineOptions getPipelineOptions() throws HopException { // The maximum number of elements in a bundle.") if (StringUtils.isNotEmpty(getFlinkMaxBundleSize())) { - long value = Const.toLong(resolve(getFlinkMaxBundleSize()), -1L); + long value = Const.toLongExpanded(resolve(getFlinkMaxBundleSize()), -1L); if (value > 0) { options.setMaxBundleSize(value); } diff --git a/plugins/engines/beam/src/main/java/org/apache/hop/beam/engines/spark/BeamSparkPipelineRunConfiguration.java b/plugins/engines/beam/src/main/java/org/apache/hop/beam/engines/spark/BeamSparkPipelineRunConfiguration.java index e8a2670e054..0f46671df02 100644 --- a/plugins/engines/beam/src/main/java/org/apache/hop/beam/engines/spark/BeamSparkPipelineRunConfiguration.java +++ b/plugins/engines/beam/src/main/java/org/apache/hop/beam/engines/spark/BeamSparkPipelineRunConfiguration.java @@ -231,7 +231,7 @@ public PipelineOptions getPipelineOptions() throws HopException { } } if (StringUtils.isNotEmpty(getSparkMaxRecordsPerBatch())) { - long records = Const.toLong(resolve(getSparkMaxRecordsPerBatch()), -1L); + long records = Const.toLongExpanded(resolve(getSparkMaxRecordsPerBatch()), -1L); if (records >= 0) { options.setMaxRecordsPerBatch(records); } @@ -249,7 +249,7 @@ public PipelineOptions getPipelineOptions() throws HopException { } } if (StringUtils.isNotEmpty(getSparkBundleSize())) { - long bundleSize = Const.toLong(resolve(getSparkBundleSize()), -1L); + long bundleSize = Const.toLongExpanded(resolve(getSparkBundleSize()), -1L); if (bundleSize >= 0) { options.setBundleSize(bundleSize); } diff --git a/plugins/engines/beam/src/main/java/org/apache/hop/beam/pipeline/handler/BeamRowGeneratorTransformHandler.java b/plugins/engines/beam/src/main/java/org/apache/hop/beam/pipeline/handler/BeamRowGeneratorTransformHandler.java index 991d3bd39b4..1ad98fb7e5e 100644 --- a/plugins/engines/beam/src/main/java/org/apache/hop/beam/pipeline/handler/BeamRowGeneratorTransformHandler.java +++ b/plugins/engines/beam/src/main/java/org/apache/hop/beam/pipeline/handler/BeamRowGeneratorTransformHandler.java @@ -103,7 +103,7 @@ public void handleTransform( throw new HopException("Error encoding row as XML", e); } - long intervalMs = Const.toLong(variables.resolve(meta.getIntervalInMs()), -1L); + long intervalMs = Const.toLongExpanded(variables.resolve(meta.getIntervalInMs()), -1L); if (intervalMs < 0) { throw new HopException( "The interval in milliseconds is expected to be >= 0, not '" @@ -157,7 +157,7 @@ public void handleTransform( // A fixed number of records // - long numRecords = Const.toLong(variables.resolve(meta.getRowLimit()), -1L); + long numRecords = Const.toLongExpanded(variables.resolve(meta.getRowLimit()), -1L); if (numRecords < 0) { throw new HopException( "Please specify a valid number of records to generate, not '" diff --git a/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avroinput/AvroFileInput.java b/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avroinput/AvroFileInput.java index 4bf828f5e0d..8c0b2b6888a 100644 --- a/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avroinput/AvroFileInput.java +++ b/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avroinput/AvroFileInput.java @@ -82,7 +82,7 @@ public boolean processRow() throws HopException { + "' doesn't exist in the input of this transform"); } - data.rowsLimit = Const.toInt(resolve(meta.getRowsLimit()), -1); + data.rowsLimit = Const.toIntExpanded(resolve(meta.getRowsLimit()), -1); data.outputRowMeta = getInputRowMeta().clone(); meta.getFields(data.outputRowMeta, getTransformName(), null, null, this, metadataProvider); diff --git a/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avroinput/AvroFileInputDialog.java b/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avroinput/AvroFileInputDialog.java index e7ba1665547..7fafd88f6b4 100644 --- a/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avroinput/AvroFileInputDialog.java +++ b/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avroinput/AvroFileInputDialog.java @@ -107,6 +107,7 @@ public String open() { fdlRowsLimit.top = new FormAttachment(lastControl, margin); wlRowsLimit.setLayoutData(fdlRowsLimit); wRowsLimit = new TextVar(variables, shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wRowsLimit.enableExpandedInteger(); wRowsLimit.setText(transformName); PropsUi.setLook(wRowsLimit); FormData fdRowsLimit = new FormData(); diff --git a/plugins/tech/azure/src/main/java/org/apache/hop/pipeline/transforms/eventhubs/listen/AzureListener.java b/plugins/tech/azure/src/main/java/org/apache/hop/pipeline/transforms/eventhubs/listen/AzureListener.java index 2628e5c4530..fa0992eea4c 100644 --- a/plugins/tech/azure/src/main/java/org/apache/hop/pipeline/transforms/eventhubs/listen/AzureListener.java +++ b/plugins/tech/azure/src/main/java/org/apache/hop/pipeline/transforms/eventhubs/listen/AzureListener.java @@ -58,8 +58,8 @@ public AzureListener( @Override public boolean init() { - data.batchSize = Const.toInt(resolve(meta.getBatchSize()), 100); - data.prefetchSize = Const.toInt(resolve(meta.getPrefetchSize()), -1); + data.batchSize = Const.toIntExpanded(resolve(meta.getBatchSize()), 100); + data.prefetchSize = Const.toIntExpanded(resolve(meta.getPrefetchSize()), -1); data.list = new LinkedList<>(); return super.init(); @@ -209,11 +209,11 @@ public void rowWrittenEvent(IRowMeta rowMeta, Object[] row) options.setExceptionNotification(new AzureListenerErrorNotificationHandler(AzureListener.this)); if (!StringUtils.isNotEmpty(meta.getBatchSize())) { - options.setMaxBatchSize(Const.toInt(resolve(meta.getBatchSize()), 100)); + options.setMaxBatchSize(Const.toIntExpanded(resolve(meta.getBatchSize()), 100)); } if (!StringUtils.isNotEmpty(meta.getPrefetchSize())) { - options.setPrefetchCount(Const.toInt(resolve(meta.getPrefetchSize()), 100)); + options.setPrefetchCount(Const.toIntExpanded(resolve(meta.getPrefetchSize()), 100)); } data.executorService = Executors.newSingleThreadScheduledExecutor(); diff --git a/plugins/tech/azure/src/main/java/org/apache/hop/pipeline/transforms/eventhubs/listen/AzureListenerDialog.java b/plugins/tech/azure/src/main/java/org/apache/hop/pipeline/transforms/eventhubs/listen/AzureListenerDialog.java index f03702bcb98..4e7e8a322a4 100644 --- a/plugins/tech/azure/src/main/java/org/apache/hop/pipeline/transforms/eventhubs/listen/AzureListenerDialog.java +++ b/plugins/tech/azure/src/main/java/org/apache/hop/pipeline/transforms/eventhubs/listen/AzureListenerDialog.java @@ -249,6 +249,7 @@ public String open() { fdlBatchSize.top = new FormAttachment(lastControl, margin); wlBatchSize.setLayoutData(fdlBatchSize); wBatchSize = new TextVar(variables, wContent, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wBatchSize.enableExpandedInteger(); PropsUi.setLook(wBatchSize); wBatchSize.addModifyListener(lsMod); FormData fdBatchSize = new FormData(); @@ -267,6 +268,7 @@ public String open() { fdlPrefetchSize.top = new FormAttachment(lastControl, margin); wlPrefetchSize.setLayoutData(fdlPrefetchSize); wPrefetchSize = new TextVar(variables, wContent, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wPrefetchSize.enableExpandedInteger(); PropsUi.setLook(wPrefetchSize); wPrefetchSize.addModifyListener(lsMod); FormData fdPrefetchSize = new FormData(); diff --git a/plugins/tech/azure/src/main/java/org/apache/hop/pipeline/transforms/eventhubs/write/AzureWrite.java b/plugins/tech/azure/src/main/java/org/apache/hop/pipeline/transforms/eventhubs/write/AzureWrite.java index 1797b999aa5..63cc492f78b 100644 --- a/plugins/tech/azure/src/main/java/org/apache/hop/pipeline/transforms/eventhubs/write/AzureWrite.java +++ b/plugins/tech/azure/src/main/java/org/apache/hop/pipeline/transforms/eventhubs/write/AzureWrite.java @@ -48,7 +48,7 @@ public AzureWrite( @Override public boolean init() { - data.batchSize = Const.toLong(resolve(meta.getBatchSize()), 1); + data.batchSize = Const.toLongExpanded(resolve(meta.getBatchSize()), 1); data.list = new LinkedList<>(); return super.init(); diff --git a/plugins/tech/azure/src/main/java/org/apache/hop/pipeline/transforms/eventhubs/write/AzureWriterDialog.java b/plugins/tech/azure/src/main/java/org/apache/hop/pipeline/transforms/eventhubs/write/AzureWriterDialog.java index 33ea72d7602..8dacf4fe61f 100644 --- a/plugins/tech/azure/src/main/java/org/apache/hop/pipeline/transforms/eventhubs/write/AzureWriterDialog.java +++ b/plugins/tech/azure/src/main/java/org/apache/hop/pipeline/transforms/eventhubs/write/AzureWriterDialog.java @@ -175,6 +175,7 @@ public String open() { fdlBatchSize.top = new FormAttachment(lastControl, margin); wlBatchSize.setLayoutData(fdlBatchSize); wBatchSize = new TextVar(variables, wContent, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wBatchSize.enableExpandedInteger(); PropsUi.setLook(wBatchSize); wBatchSize.addModifyListener(lsMod); FormData fdBatchSize = new FormData(); diff --git a/plugins/tech/google/src/main/java/org/apache/hop/pipeline/transforms/googleanalytics/GoogleAnalyticsDialog.java b/plugins/tech/google/src/main/java/org/apache/hop/pipeline/transforms/googleanalytics/GoogleAnalyticsDialog.java index c7939c008f8..6a4b252d455 100644 --- a/plugins/tech/google/src/main/java/org/apache/hop/pipeline/transforms/googleanalytics/GoogleAnalyticsDialog.java +++ b/plugins/tech/google/src/main/java/org/apache/hop/pipeline/transforms/googleanalytics/GoogleAnalyticsDialog.java @@ -609,7 +609,7 @@ private void getInfo(GoogleAnalyticsMeta meta) { googleAnalyticsFields.add(field); } meta.setGoogleAnalyticsFields(googleAnalyticsFields); - meta.setRowLimit(Const.toInt(wLimit.getText(), 0)); + meta.setRowLimit(Const.toIntExpanded(wLimit.getText(), 0)); } // Preview the data diff --git a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/cypher/Cypher.java b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/cypher/Cypher.java index ca07694dc29..e1d6cb61643 100644 --- a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/cypher/Cypher.java +++ b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/cypher/Cypher.java @@ -97,7 +97,7 @@ public boolean init() { return false; } - data.batchSize = Const.toLong(resolve(meta.getBatchSize()), 1); + data.batchSize = Const.toLongExpanded(resolve(meta.getBatchSize()), 1); // Try at least once and then do retries as needed // diff --git a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/cypher/CypherDialog.java b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/cypher/CypherDialog.java index 89c57ed997f..ee321918953 100644 --- a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/cypher/CypherDialog.java +++ b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/cypher/CypherDialog.java @@ -186,6 +186,7 @@ private void addOptionsTab() { fdlBatchSize.top = new FormAttachment(lastControl, margin); wlBatchSize.setLayoutData(fdlBatchSize); wBatchSize = new TextVar(variables, wOptionsComp, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wBatchSize.enableExpandedInteger(); PropsUi.setLook(wBatchSize); FormData fdBatchSize = new FormData(); fdBatchSize.left = new FormAttachment(middle, 0); diff --git a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/cypherbuilder/CypherBuilder.java b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/cypherbuilder/CypherBuilder.java index 7d90265eaae..a4ae0ce1190 100644 --- a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/cypherbuilder/CypherBuilder.java +++ b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/cypherbuilder/CypherBuilder.java @@ -182,7 +182,7 @@ private void prepareOnFirstRow(Object[] row) throws HopException { data.neoTypes.add(neoType); } - data.batchSize = Const.toInt(resolve(meta.getBatchSize()), 1); + data.batchSize = Const.toIntExpanded(resolve(meta.getBatchSize()), 1); data.cypher = meta.getCypher(this); diff --git a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/cypherbuilder/CypherBuilderDialog.java b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/cypherbuilder/CypherBuilderDialog.java index 3e9097f6ab5..7f431f4b74c 100644 --- a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/cypherbuilder/CypherBuilderDialog.java +++ b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/cypherbuilder/CypherBuilderDialog.java @@ -205,6 +205,7 @@ private void addOptionsTab() { fdlBatchSize.top = new FormAttachment(lastControl, margin); wlBatchSize.setLayoutData(fdlBatchSize); wBatchSize = new TextVar(variables, wOptionsComp, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wBatchSize.enableExpandedInteger(); PropsUi.setLook(wBatchSize); FormData fdBatchSize = new FormData(); fdBatchSize.left = new FormAttachment(middle, 0); diff --git a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/graph/GraphOutput.java b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/graph/GraphOutput.java index 70a0489ab82..36546916c65 100644 --- a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/graph/GraphOutput.java +++ b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/graph/GraphOutput.java @@ -109,7 +109,7 @@ public boolean init() { return false; } - data.batchSize = Const.toLong(resolve(meta.getBatchSize()), 1); + data.batchSize = Const.toLongExpanded(resolve(meta.getBatchSize()), 1); } if (StringUtils.isEmpty(meta.getModel())) { diff --git a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/graph/GraphOutputDialog.java b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/graph/GraphOutputDialog.java index fb85a2bc819..f208eca0a91 100644 --- a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/graph/GraphOutputDialog.java +++ b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/graph/GraphOutputDialog.java @@ -172,6 +172,7 @@ public String open() { fdlBatchSize.top = new FormAttachment(lastControl, margin); wlBatchSize.setLayoutData(fdlBatchSize); wBatchSize = new TextVar(variables, shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wBatchSize.enableExpandedInteger(); PropsUi.setLook(wBatchSize); FormData fdBatchSize = new FormData(); fdBatchSize.left = new FormAttachment(middle, 0); diff --git a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/output/Neo4JOutput.java b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/output/Neo4JOutput.java index b0cc49bbefb..de3f76276c6 100644 --- a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/output/Neo4JOutput.java +++ b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/output/Neo4JOutput.java @@ -861,7 +861,7 @@ public boolean init() { return false; } - data.batchSize = Const.toLong(resolve(meta.getBatchSize()), 1); + data.batchSize = Const.toLongExpanded(resolve(meta.getBatchSize()), 1); } return super.init(); diff --git a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/output/Neo4JOutputDialog.java b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/output/Neo4JOutputDialog.java index ff2087e8381..b6327ac6aa6 100755 --- a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/output/Neo4JOutputDialog.java +++ b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/transforms/output/Neo4JOutputDialog.java @@ -178,6 +178,7 @@ public String open() { fdlBatchSize.top = new FormAttachment(lastControl, margin); wlBatchSize.setLayoutData(fdlBatchSize); wBatchSize = new TextVar(variables, shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wBatchSize.enableExpandedInteger(); PropsUi.setLook(wBatchSize); wBatchSize.addModifyListener(lsMod); FormData fdBatchSize = new FormData(); diff --git a/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutput.java b/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutput.java index 3e07a38f6de..704b28047a3 100644 --- a/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutput.java +++ b/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutput.java @@ -65,14 +65,14 @@ public boolean init() { // Pre-calculate some values... // data.pageSize = - Const.toInt(resolve(meta.getDataPageSize()), ParquetProperties.DEFAULT_PAGE_SIZE); + Const.toIntExpanded(resolve(meta.getDataPageSize()), ParquetProperties.DEFAULT_PAGE_SIZE); data.dictionaryPageSize = - Const.toInt( + Const.toIntExpanded( resolve(meta.getDictionaryPageSize()), ParquetProperties.DEFAULT_DICTIONARY_PAGE_SIZE); data.rowGroupSize = - Const.toInt( + Const.toIntExpanded( resolve(meta.getRowGroupSize()), ParquetProperties.DEFAULT_PAGE_ROW_COUNT_LIMIT); - data.maxSplitSizeRows = Const.toLong(resolve(meta.getFileSplitSize()), -1); + data.maxSplitSizeRows = Const.toLongExpanded(resolve(meta.getFileSplitSize()), -1); return super.init(); } diff --git a/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutputDialog.java b/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutputDialog.java index 2fac6d25799..b1cd5baf68a 100644 --- a/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutputDialog.java +++ b/plugins/tech/parquet/src/main/java/org/apache/hop/parquet/transforms/output/ParquetOutputDialog.java @@ -277,6 +277,7 @@ public String open() { fdlFilenameSplitSize.top = new FormAttachment(lastControl, margin); wlFilenameSplitSize.setLayoutData(fdlFilenameSplitSize); wFilenameSplitSize = new TextVar(variables, wFileGroup, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wFilenameSplitSize.enableExpandedInteger(); PropsUi.setLook(wFilenameSplitSize); FormData fdFilenameSplitSize = new FormData(); fdFilenameSplitSize.left = new FormAttachment(middle, 0); @@ -356,6 +357,7 @@ public String open() { fdlRowGroupSize.top = new FormAttachment(lastControl, margin); wlRowGroupSize.setLayoutData(fdlRowGroupSize); wRowGroupSize = new TextVar(variables, shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wRowGroupSize.enableExpandedInteger(); PropsUi.setLook(wRowGroupSize); FormData fdRowGroupSize = new FormData(); fdRowGroupSize.left = new FormAttachment(middle, 0); @@ -373,6 +375,7 @@ public String open() { fdlDataPageSize.top = new FormAttachment(lastControl, margin); wlDataPageSize.setLayoutData(fdlDataPageSize); wDataPageSize = new TextVar(variables, shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wDataPageSize.enableExpandedInteger(); PropsUi.setLook(wDataPageSize); FormData fdDataPageSize = new FormData(); fdDataPageSize.left = new FormAttachment(middle, 0); @@ -391,6 +394,7 @@ public String open() { fdlDictionaryPageSize.top = new FormAttachment(lastControl, margin); wlDictionaryPageSize.setLayoutData(fdlDictionaryPageSize); wDictionaryPageSize = new TextVar(variables, shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wDictionaryPageSize.enableExpandedInteger(); PropsUi.setLook(wDictionaryPageSize); FormData fdDictionaryPageSize = new FormData(); fdDictionaryPageSize.left = new FormAttachment(middle, 0); diff --git a/plugins/tech/salesforce/src/main/java/org/apache/hop/pipeline/transforms/salesforceinput/SalesforceInput.java b/plugins/tech/salesforce/src/main/java/org/apache/hop/pipeline/transforms/salesforceinput/SalesforceInput.java index 6c41242414e..aaf5bf10817 100644 --- a/plugins/tech/salesforce/src/main/java/org/apache/hop/pipeline/transforms/salesforceinput/SalesforceInput.java +++ b/plugins/tech/salesforce/src/main/java/org/apache/hop/pipeline/transforms/salesforceinput/SalesforceInput.java @@ -392,7 +392,7 @@ public boolean init() { } } - data.limit = Const.toLong(resolve(meta.getRowLimit()), 0); + data.limit = Const.toLongExpanded(resolve(meta.getRowLimit()), 0); // Do we have to query for all records included deleted records data.connection.setQueryAll(meta.isQueryAll()); diff --git a/plugins/tech/salesforce/src/main/java/org/apache/hop/pipeline/transforms/salesforceinput/SalesforceInputDialog.java b/plugins/tech/salesforce/src/main/java/org/apache/hop/pipeline/transforms/salesforceinput/SalesforceInputDialog.java index 11ff886bb28..2be97edc74e 100644 --- a/plugins/tech/salesforce/src/main/java/org/apache/hop/pipeline/transforms/salesforceinput/SalesforceInputDialog.java +++ b/plugins/tech/salesforce/src/main/java/org/apache/hop/pipeline/transforms/salesforceinput/SalesforceInputDialog.java @@ -1165,6 +1165,7 @@ public void widgetSelected(SelectionEvent e) { fdlLimit.right = new FormAttachment(middle, -margin); wlLimit.setLayoutData(fdlLimit); wLimit = new TextVar(variables, wContentComp, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wLimit.enableExpandedInteger(); PropsUi.setLook(wLimit); wLimit.addModifyListener(lsMod); FormData fdLimit = new FormData(); diff --git a/plugins/transforms/clonerow/src/main/java/org/apache/hop/pipeline/transforms/clonerow/CloneRow.java b/plugins/transforms/clonerow/src/main/java/org/apache/hop/pipeline/transforms/clonerow/CloneRow.java index 7a622ff1b79..668b369a240 100644 --- a/plugins/transforms/clonerow/src/main/java/org/apache/hop/pipeline/transforms/clonerow/CloneRow.java +++ b/plugins/transforms/clonerow/src/main/java/org/apache/hop/pipeline/transforms/clonerow/CloneRow.java @@ -101,7 +101,7 @@ public boolean processRow() throws HopException { } } else { String nrclonesString = resolve(meta.getNrClones()); - data.nrclones = Const.toInt(nrclonesString, 0); + data.nrclones = Const.toIntExpanded(nrclonesString, 0); if (isDebug()) { logDebug(BaseMessages.getString(PKG, "CloneRow.Log.NrClones", "" + data.nrclones)); } diff --git a/plugins/transforms/clonerow/src/main/java/org/apache/hop/pipeline/transforms/clonerow/CloneRowDialog.java b/plugins/transforms/clonerow/src/main/java/org/apache/hop/pipeline/transforms/clonerow/CloneRowDialog.java index 912903a366f..97bc24fa97e 100644 --- a/plugins/transforms/clonerow/src/main/java/org/apache/hop/pipeline/transforms/clonerow/CloneRowDialog.java +++ b/plugins/transforms/clonerow/src/main/java/org/apache/hop/pipeline/transforms/clonerow/CloneRowDialog.java @@ -90,6 +90,7 @@ public String open() { wlnrClone.setLayoutData(fdlnrClone); wnrClone = new TextVar(variables, shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wnrClone.enableExpandedInteger(); PropsUi.setLook(wnrClone); wnrClone.setToolTipText(BaseMessages.getString(PKG, "CloneRowDialog.nrClone.Tooltip")); wnrClone.addModifyListener(lsMod); diff --git a/plugins/transforms/cubeinput/src/main/java/org/apache/hop/pipeline/transforms/cubeinput/CubeInput.java b/plugins/transforms/cubeinput/src/main/java/org/apache/hop/pipeline/transforms/cubeinput/CubeInput.java index 55632567abf..06998a138bf 100644 --- a/plugins/transforms/cubeinput/src/main/java/org/apache/hop/pipeline/transforms/cubeinput/CubeInput.java +++ b/plugins/transforms/cubeinput/src/main/java/org/apache/hop/pipeline/transforms/cubeinput/CubeInput.java @@ -56,7 +56,7 @@ public boolean processRow() throws HopException { if (first) { first = false; - realRowLimit = Const.toInt(resolve(meta.getRowLimit()), 0); + realRowLimit = Const.toIntExpanded(resolve(meta.getRowLimit()), 0); } try { diff --git a/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinDialog.java b/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinDialog.java index a4304b77332..d11b0bd5a5c 100644 --- a/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinDialog.java +++ b/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinDialog.java @@ -454,7 +454,7 @@ private void ok() { input.setConnection(wConnection.getText()); input.setCached(wCache.getSelection()); input.setCacheSize(Const.toInt(wCacheSize.getText(), 0)); - input.setRowLimit(Const.toInt(wLimit.getText(), 0)); + input.setRowLimit(Const.toIntExpanded(wLimit.getText(), 0)); input.setSql(wSql.getText()); input.setOuterJoin(wOuter.getSelection()); input.setReplaceVariables(wUseVars.getSelection()); diff --git a/plugins/transforms/delete/src/main/java/org/apache/hop/pipeline/transforms/delete/DeleteDialog.java b/plugins/transforms/delete/src/main/java/org/apache/hop/pipeline/transforms/delete/DeleteDialog.java index cea01f0aa5c..1b664b0a107 100644 --- a/plugins/transforms/delete/src/main/java/org/apache/hop/pipeline/transforms/delete/DeleteDialog.java +++ b/plugins/transforms/delete/src/main/java/org/apache/hop/pipeline/transforms/delete/DeleteDialog.java @@ -244,6 +244,7 @@ private void addGeneralTab( fdlCommit.right = new FormAttachment(middle, -margin); wlCommit.setLayoutData(fdlCommit); wCommit = new TextVar(variables, composite, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wCommit.enableExpandedInteger(); PropsUi.setLook(wCommit); wCommit.addModifyListener(lsMod); FormData fdCommit = new FormData(); diff --git a/plugins/transforms/delete/src/main/java/org/apache/hop/pipeline/transforms/delete/DeleteMeta.java b/plugins/transforms/delete/src/main/java/org/apache/hop/pipeline/transforms/delete/DeleteMeta.java index abeefd633a3..e78a022c178 100644 --- a/plugins/transforms/delete/src/main/java/org/apache/hop/pipeline/transforms/delete/DeleteMeta.java +++ b/plugins/transforms/delete/src/main/java/org/apache/hop/pipeline/transforms/delete/DeleteMeta.java @@ -105,7 +105,9 @@ public String getCommitSizeVar() { public int getCommitSize(IVariables vs) { // this happens when the transform is created via API and no setDefaults was called commitSize = (commitSize == null) ? "0" : commitSize; - return Integer.parseInt(vs.resolve(commitSize)); + String resolved = vs.resolve(commitSize); + String expanded = Const.expandIntegerString(resolved); + return Integer.parseInt(expanded != null ? expanded : resolved); } public DeleteMeta(DeleteMeta obj) { diff --git a/plugins/transforms/dynamicsqlrow/src/main/java/org/apache/hop/pipeline/transforms/dynamicsqlrow/DynamicSqlRowDialog.java b/plugins/transforms/dynamicsqlrow/src/main/java/org/apache/hop/pipeline/transforms/dynamicsqlrow/DynamicSqlRowDialog.java index a0c76d2d8e1..f7c1c67e8aa 100644 --- a/plugins/transforms/dynamicsqlrow/src/main/java/org/apache/hop/pipeline/transforms/dynamicsqlrow/DynamicSqlRowDialog.java +++ b/plugins/transforms/dynamicsqlrow/src/main/java/org/apache/hop/pipeline/transforms/dynamicsqlrow/DynamicSqlRowDialog.java @@ -416,7 +416,7 @@ private void ok() { } input.setConnection(wConnection.getText()); - input.setRowLimit(Const.toInt(wLimit.getText(), 0)); + input.setRowLimit(Const.toIntExpanded(wLimit.getText(), 0)); input.setSql(wSqlComposite.getText()); input.setSqlFieldName(wSqlFieldName.getText()); input.setOuterJoin(wOuter.getSelection()); diff --git a/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelinput/ExcelInputDialog.java b/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelinput/ExcelInputDialog.java index a3f9a37a76e..9de9d58cc37 100644 --- a/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelinput/ExcelInputDialog.java +++ b/plugins/transforms/excel/src/main/java/org/apache/hop/pipeline/transforms/excelinput/ExcelInputDialog.java @@ -1210,7 +1210,7 @@ private void getInfo(ExcelInputMeta meta) { transformName = wTransformName.getText(); // return value // copy info to Meta class (input) - meta.setRowLimit(Const.toLong(wLimit.getText(), 0)); + meta.setRowLimit(Const.toLongExpanded(wLimit.getText(), 0)); meta.setEncoding(wEncoding.getText()); meta.setSchemaDefinition(wSchemaDefinition.getText()); meta.setIgnoreFields(wIgnoreFields.getSelection()); diff --git a/plugins/transforms/filemetadata/src/main/java/org/apache/hop/pipeline/transforms/filemetadata/FileMetadata.java b/plugins/transforms/filemetadata/src/main/java/org/apache/hop/pipeline/transforms/filemetadata/FileMetadata.java index 4a91610b2c1..6d32fd42643 100644 --- a/plugins/transforms/filemetadata/src/main/java/org/apache/hop/pipeline/transforms/filemetadata/FileMetadata.java +++ b/plugins/transforms/filemetadata/src/main/java/org/apache/hop/pipeline/transforms/filemetadata/FileMetadata.java @@ -30,6 +30,7 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import org.apache.hop.core.Const; import org.apache.hop.core.exception.HopException; import org.apache.hop.core.exception.HopFileException; import org.apache.hop.core.exception.HopRuntimeException; @@ -193,7 +194,8 @@ private void buildOutputRows() throws HopException { if (strLimitRows.trim().isEmpty()) { limitRows = 0; } else { - limitRows = Long.parseLong(strLimitRows); + String expandedLimit = Const.expandIntegerString(strLimitRows); + limitRows = Long.parseLong(expandedLimit != null ? expandedLimit : strLimitRows); } defaultCharset = Charset.forName(resolve(meta.getDefaultCharset())); diff --git a/plugins/transforms/filemetadata/src/main/java/org/apache/hop/pipeline/transforms/filemetadata/FileMetadataDialog.java b/plugins/transforms/filemetadata/src/main/java/org/apache/hop/pipeline/transforms/filemetadata/FileMetadataDialog.java index 22f70d418a0..8a91936a048 100644 --- a/plugins/transforms/filemetadata/src/main/java/org/apache/hop/pipeline/transforms/filemetadata/FileMetadataDialog.java +++ b/plugins/transforms/filemetadata/src/main/java/org/apache/hop/pipeline/transforms/filemetadata/FileMetadataDialog.java @@ -250,6 +250,7 @@ public void focusGained(FocusEvent e) { fdlLimit.top = new FormAttachment(0, margin); wlLimit.setLayoutData(fdlLimit); wLimit = new TextVar(variables, gDelimitedLayout, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wLimit.enableExpandedInteger(); wLimit.setToolTipText( BaseMessages.getString(PKG, "FileMetadata.methods.DELIMITED_FIELDS.limit.tooltip")); PropsUi.setLook(wLimit); diff --git a/plugins/transforms/getfilenames/src/main/java/org/apache/hop/pipeline/transforms/getfilenames/GetFileNamesDialog.java b/plugins/transforms/getfilenames/src/main/java/org/apache/hop/pipeline/transforms/getfilenames/GetFileNamesDialog.java index f88e1d32ed4..cea24ae3115 100644 --- a/plugins/transforms/getfilenames/src/main/java/org/apache/hop/pipeline/transforms/getfilenames/GetFileNamesDialog.java +++ b/plugins/transforms/getfilenames/src/main/java/org/apache/hop/pipeline/transforms/getfilenames/GetFileNamesDialog.java @@ -1019,7 +1019,7 @@ private void getInfo(GetFileNamesMeta in) { in.setDynamicExcludeWildcardField(wExcludeWildcardField.getText()); in.setFileField(wFileField.getSelection()); in.setRowNumberField(wInclRownum.getSelection() ? wInclRownumField.getText() : ""); - in.setRowLimit(Const.toLong(wLimit.getText(), 0L)); + in.setRowLimit(Const.toLongExpanded(wLimit.getText(), 0L)); in.setDynamicIncludeSubFolders(wIncludeSubFolder.getSelection()); in.setDoNotFailIfNoFile(wDoNotFailIfNoFile.getSelection()); in.setRaiseAnExceptionIfNoFile(wRaiseAnExceptionIfNoFile.getSelection()); diff --git a/plugins/transforms/getsubfolders/src/main/java/org/apache/hop/pipeline/transforms/getsubfolders/GetSubFoldersDialog.java b/plugins/transforms/getsubfolders/src/main/java/org/apache/hop/pipeline/transforms/getsubfolders/GetSubFoldersDialog.java index 3343eef2351..eecca4abdb9 100644 --- a/plugins/transforms/getsubfolders/src/main/java/org/apache/hop/pipeline/transforms/getsubfolders/GetSubFoldersDialog.java +++ b/plugins/transforms/getsubfolders/src/main/java/org/apache/hop/pipeline/transforms/getsubfolders/GetSubFoldersDialog.java @@ -584,7 +584,7 @@ private void getInfo(GetSubFoldersMeta in) { in.setDynamicFolderNameField(wFolderNameField.getText()); in.setFolderNameDynamic(wFolderField.getSelection()); in.setRowNumberField(wInclRowNumberField.getText()); - in.setRowLimit(Const.toLong(wLimit.getText(), 0L)); + in.setRowLimit(Const.toLongExpanded(wLimit.getText(), 0L)); } // Preview the data diff --git a/plugins/transforms/insertupdate/src/main/java/org/apache/hop/pipeline/transforms/insertupdate/InsertUpdateDialog.java b/plugins/transforms/insertupdate/src/main/java/org/apache/hop/pipeline/transforms/insertupdate/InsertUpdateDialog.java index 371fb7b8081..321a75df956 100644 --- a/plugins/transforms/insertupdate/src/main/java/org/apache/hop/pipeline/transforms/insertupdate/InsertUpdateDialog.java +++ b/plugins/transforms/insertupdate/src/main/java/org/apache/hop/pipeline/transforms/insertupdate/InsertUpdateDialog.java @@ -216,6 +216,7 @@ public void widgetSelected(SelectionEvent e) { PropsUi.setLook(wlCommit); wCommit = new TextVar(variables, composite, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wCommit.enableExpandedInteger(); wCommit.addModifyListener(lsMod); wCommit.setLayoutData( new FormDataBuilder().left(middle, 0).top(wTable, margin).right().result()); diff --git a/plugins/transforms/insertupdate/src/main/java/org/apache/hop/pipeline/transforms/insertupdate/InsertUpdateMeta.java b/plugins/transforms/insertupdate/src/main/java/org/apache/hop/pipeline/transforms/insertupdate/InsertUpdateMeta.java index e04276c45ad..d3fbee73023 100644 --- a/plugins/transforms/insertupdate/src/main/java/org/apache/hop/pipeline/transforms/insertupdate/InsertUpdateMeta.java +++ b/plugins/transforms/insertupdate/src/main/java/org/apache/hop/pipeline/transforms/insertupdate/InsertUpdateMeta.java @@ -113,7 +113,9 @@ public String getCommitSize() { public int getCommitSizeVar(IVariables vs) { // this happens when the transform is created via API and no setDefaults was called commitSize = (commitSize == null) ? "0" : commitSize; - return Integer.parseInt(vs.resolve(commitSize)); + String resolved = vs.resolve(commitSize); + String expanded = Const.expandIntegerString(resolved); + return Integer.parseInt(expanded != null ? expanded : resolved); } /** diff --git a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputDialog.java b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputDialog.java index eb6ad94bf9e..4f737f02e2f 100644 --- a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputDialog.java +++ b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsoninput/JsonInputDialog.java @@ -1293,7 +1293,7 @@ private void ok() { private void getInfo(JsonInputMeta in) { transformName = wTransformName.getText(); // return value - in.setRowLimit(Const.toLong(wLimit.getText(), 0L)); + in.setRowLimit(Const.toLongExpanded(wLimit.getText(), 0L)); in.setFilenameField(wInclFilenameField.getText()); in.setRowNumberField(wInclRownumField.getText()); in.setAddResultFile(wAddResult.getSelection()); diff --git a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsonnormalize/JsonNormalizeInputDialog.java b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsonnormalize/JsonNormalizeInputDialog.java index c2d4cfc28ef..a43d2efe4ef 100644 --- a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsonnormalize/JsonNormalizeInputDialog.java +++ b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/jsonnormalize/JsonNormalizeInputDialog.java @@ -1419,7 +1419,7 @@ private void ok() { private void getInfo(JsonNormalizeInputMeta in) { transformName = wTransformName.getText(); // return value - in.setRowLimit(Const.toLong(wLimit.getText(), 0L)); + in.setRowLimit(Const.toLongExpanded(wLimit.getText(), 0L)); in.setFilenameField(wInclFilenameField.getText()); in.setRowNumberField(wInclRownumField.getText()); in.setAddResultFile(wAddResult.getSelection()); diff --git a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInput.java b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInput.java index 7a49d845029..375e7dc521c 100644 --- a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInput.java +++ b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInput.java @@ -84,7 +84,7 @@ public boolean init() { data.incomingRowsBuffer = new ArrayList<>(); data.batchDuration = Const.toInt(resolve(meta.getBatchDuration()), 0); - data.batchSize = Const.toInt(resolve(meta.getBatchSize()), 0); + data.batchSize = Const.toIntExpanded(resolve(meta.getBatchSize()), 0); data.stopWhenIdle = meta.isStopWhenIdle(); data.maxIdleTimeMs = Const.toLong(resolve(meta.getMaxIdleTimeMs()), 500L); data.lastRecordTime = System.currentTimeMillis(); @@ -252,7 +252,7 @@ public static Consumer buildKafkaConsumer(IVariables variables, KafkaConsumerInp // The batch size : max poll size // - int batch = Const.toInt(variables.resolve(meta.getBatchSize()), 0); + int batch = Const.toIntExpanded(variables.resolve(meta.getBatchSize()), 0); if (batch > 0) { config.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, batch); } diff --git a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputDialog.java b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputDialog.java index 1a5c7e67a2c..fb3f48a5d4b 100644 --- a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputDialog.java +++ b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputDialog.java @@ -611,6 +611,7 @@ private void buildBatchTab() { wlBatchSize.setLayoutData(fdlBatchSize); wBatchSize = new TextVar(variables, wBatchComp, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wBatchSize.enableExpandedInteger(); PropsUi.setLook(wBatchSize); wBatchSize.addModifyListener(lsMod); FormData fdBatchSize = new FormData(); diff --git a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMeta.java b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMeta.java index c596d463d6e..303fc186715 100644 --- a/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMeta.java +++ b/plugins/transforms/kafka/src/main/java/org/apache/hop/pipeline/transforms/kafka/consumer/KafkaConsumerInputMeta.java @@ -401,7 +401,9 @@ public void check( long size = Long.MIN_VALUE; try { - size = Long.parseLong(variables.resolve(getBatchSize())); + String batchSizeResolved = variables.resolve(getBatchSize()); + String batchSizeExpanded = Const.expandIntegerString(batchSizeResolved); + size = Long.parseLong(batchSizeExpanded != null ? batchSizeExpanded : batchSizeResolved); } catch (NumberFormatException e) { remarks.add( new CheckResult( diff --git a/plugins/transforms/ldap/src/main/java/org/apache/hop/pipeline/transforms/ldapinput/LdapInputDialog.java b/plugins/transforms/ldap/src/main/java/org/apache/hop/pipeline/transforms/ldapinput/LdapInputDialog.java index 5a94cb5a94f..88d8b82dbfa 100644 --- a/plugins/transforms/ldap/src/main/java/org/apache/hop/pipeline/transforms/ldapinput/LdapInputDialog.java +++ b/plugins/transforms/ldap/src/main/java/org/apache/hop/pipeline/transforms/ldapinput/LdapInputDialog.java @@ -1381,7 +1381,7 @@ private void getInfo(LdapInputMeta in) { in.setTrustAllCertificates(wTrustAll.getSelection()); // copy info to TextFileInputMeta class (input) - in.setRowLimit(Const.toInt(wLimit.getText(), 0)); + in.setRowLimit(Const.toIntExpanded(wLimit.getText(), 0)); in.setTimeLimit(Const.toInt(wTimeLimit.getText(), 0)); in.setMultiValuedSeparator(wMultiValuedSeparator.getText()); in.setIncludeRowNumber(wInclRownum.getSelection()); diff --git a/plugins/transforms/loadfileinput/src/main/java/org/apache/hop/pipeline/transforms/loadfileinput/LoadFileInputDialog.java b/plugins/transforms/loadfileinput/src/main/java/org/apache/hop/pipeline/transforms/loadfileinput/LoadFileInputDialog.java index 6f2767b833c..f160346c716 100644 --- a/plugins/transforms/loadfileinput/src/main/java/org/apache/hop/pipeline/transforms/loadfileinput/LoadFileInputDialog.java +++ b/plugins/transforms/loadfileinput/src/main/java/org/apache/hop/pipeline/transforms/loadfileinput/LoadFileInputDialog.java @@ -1248,7 +1248,7 @@ private void getInfo(LoadFileInputMeta in) { transformName = wTransformName.getText(); // return value // copy info to TextFileInputMeta class (input) - in.setRowLimit(Const.toLong(wLimit.getText(), 0L)); + in.setRowLimit(Const.toLongExpanded(wLimit.getText(), 0L)); in.setEncoding(wEncoding.getText()); in.setRowNumberField(wInclRownumField.getText()); in.setAddingResultFile(wAddResult.getSelection()); diff --git a/plugins/transforms/pipelineexecutor/src/main/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutor.java b/plugins/transforms/pipelineexecutor/src/main/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutor.java index 07cef4bc097..92f613ad9e7 100644 --- a/plugins/transforms/pipelineexecutor/src/main/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutor.java +++ b/plugins/transforms/pipelineexecutor/src/main/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutor.java @@ -530,7 +530,7 @@ public boolean init() { // How many rows do we group together for the pipeline? if (!Utils.isEmpty(meta.getGroupSize())) { - pipelineExecutorData.groupSize = Const.toInt(resolve(meta.getGroupSize()), -1); + pipelineExecutorData.groupSize = Const.toIntExpanded(resolve(meta.getGroupSize()), -1); } else { pipelineExecutorData.groupSize = -1; } diff --git a/plugins/transforms/pipelineexecutor/src/main/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutorDialog.java b/plugins/transforms/pipelineexecutor/src/main/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutorDialog.java index c5fa2b36f33..7edf90a73a6 100644 --- a/plugins/transforms/pipelineexecutor/src/main/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutorDialog.java +++ b/plugins/transforms/pipelineexecutor/src/main/java/org/apache/hop/pipeline/transforms/pipelineexecutor/PipelineExecutorDialog.java @@ -741,6 +741,7 @@ private void addRowGroupTab() { wlGroupSize.setLayoutData(fdlGroupSize); wGroupSize = new TextVar(variables, wInputComposite, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wGroupSize.enableExpandedInteger(); PropsUi.setLook(wGroupSize); FormData fdGroupSize = new FormData(); fdGroupSize.right = new FormAttachment(100); @@ -1129,7 +1130,7 @@ private void setFlags() { || wGroupTime == null) { return; } - boolean enableSize = Const.toInt(variables.resolve(wGroupSize.getText()), -1) >= 0; + boolean enableSize = Const.toIntExpanded(variables.resolve(wGroupSize.getText()), -1) >= 0; boolean enableField = !Utils.isEmpty(wGroupField.getText()); wlGroupSize.setEnabled(true); diff --git a/plugins/transforms/propertyinput/src/main/java/org/apache/hop/pipeline/transforms/propertyinput/PropertyInputDialog.java b/plugins/transforms/propertyinput/src/main/java/org/apache/hop/pipeline/transforms/propertyinput/PropertyInputDialog.java index 0cf144f1222..9c595a6e3ce 100644 --- a/plugins/transforms/propertyinput/src/main/java/org/apache/hop/pipeline/transforms/propertyinput/PropertyInputDialog.java +++ b/plugins/transforms/propertyinput/src/main/java/org/apache/hop/pipeline/transforms/propertyinput/PropertyInputDialog.java @@ -1339,7 +1339,7 @@ private void getInfo(PropertyInputMeta in) { transformName = wTransformName.getText(); // return value // copy info to PropertyInputMeta class (input) - in.setRowLimit(Const.toLong(wLimit.getText(), 0L)); + in.setRowLimit(Const.toLongExpanded(wLimit.getText(), 0L)); in.setIncludingFilename(wInclFilename.getSelection()); in.setFilenameField(wInclFilenameField.getText()); in.setIncludeRowNumber(wInclRowNum.getSelection()); diff --git a/plugins/transforms/reservoirsampling/src/main/java/org/apache/hop/pipeline/transforms/reservoirsampling/ReservoirSampling.java b/plugins/transforms/reservoirsampling/src/main/java/org/apache/hop/pipeline/transforms/reservoirsampling/ReservoirSampling.java index 1f4330068cd..71561ee98b7 100644 --- a/plugins/transforms/reservoirsampling/src/main/java/org/apache/hop/pipeline/transforms/reservoirsampling/ReservoirSampling.java +++ b/plugins/transforms/reservoirsampling/src/main/java/org/apache/hop/pipeline/transforms/reservoirsampling/ReservoirSampling.java @@ -19,6 +19,7 @@ import java.util.Arrays; import java.util.List; +import org.apache.hop.core.Const; import org.apache.hop.core.exception.HopException; import org.apache.hop.core.exception.HopTransformException; import org.apache.hop.pipeline.Pipeline; @@ -91,7 +92,10 @@ public boolean processRow() throws HopException { data.setOutputRowMeta(getInputRowMeta().clone()); String sampleSize = resolve(meta.getSampleSize()); String seed = resolve(meta.getSeed()); - data.initialize(Integer.parseInt(sampleSize), Integer.parseInt(seed)); + String expandedSampleSize = Const.expandIntegerString(sampleSize); + data.initialize( + Integer.parseInt(expandedSampleSize != null ? expandedSampleSize : sampleSize), + Integer.parseInt(seed)); // no real reason to determine the output fields here // as we don't add/delete any fields diff --git a/plugins/transforms/reservoirsampling/src/main/java/org/apache/hop/pipeline/transforms/reservoirsampling/ReservoirSamplingDialog.java b/plugins/transforms/reservoirsampling/src/main/java/org/apache/hop/pipeline/transforms/reservoirsampling/ReservoirSamplingDialog.java index 9fa17ec5346..cf0a603afc3 100644 --- a/plugins/transforms/reservoirsampling/src/main/java/org/apache/hop/pipeline/transforms/reservoirsampling/ReservoirSamplingDialog.java +++ b/plugins/transforms/reservoirsampling/src/main/java/org/apache/hop/pipeline/transforms/reservoirsampling/ReservoirSamplingDialog.java @@ -89,6 +89,7 @@ public String open() { mWlSampleSize.setLayoutData(mFdlSampleSize); wSampleSize = new TextVar(variables, shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wSampleSize.enableExpandedInteger(); PropsUi.setLook(wSampleSize); wSampleSize.addModifyListener(lsMod); FormData mFdSampleSize = new FormData(); diff --git a/plugins/transforms/rowgenerator/src/main/java/org/apache/hop/pipeline/transforms/rowgenerator/RowGenerator.java b/plugins/transforms/rowgenerator/src/main/java/org/apache/hop/pipeline/transforms/rowgenerator/RowGenerator.java index 500c6bbe6bb..313ecd093ab 100644 --- a/plugins/transforms/rowgenerator/src/main/java/org/apache/hop/pipeline/transforms/rowgenerator/RowGenerator.java +++ b/plugins/transforms/rowgenerator/src/main/java/org/apache/hop/pipeline/transforms/rowgenerator/RowGenerator.java @@ -263,9 +263,9 @@ public boolean init() { if (super.init()) { // Determine the number of rows to generate... - data.rowLimit = Const.toLong(resolve(meta.getRowLimit()), -1L); + data.rowLimit = Const.toLongExpanded(resolve(meta.getRowLimit()), -1L); data.rowsWritten = 0L; - data.delay = Const.toLong(resolve(meta.getIntervalInMs()), -1L); + data.delay = Const.toLongExpanded(resolve(meta.getIntervalInMs()), -1L); if (!meta.isNeverEnding() && data.rowLimit < 0L) { // Unable to parse logError(BaseMessages.getString(PKG, "RowGenerator.Wrong.RowLimit.Number")); diff --git a/plugins/transforms/rowgenerator/src/main/java/org/apache/hop/pipeline/transforms/rowgenerator/RowGeneratorDialog.java b/plugins/transforms/rowgenerator/src/main/java/org/apache/hop/pipeline/transforms/rowgenerator/RowGeneratorDialog.java index b59130a565a..7a6a06f7b04 100644 --- a/plugins/transforms/rowgenerator/src/main/java/org/apache/hop/pipeline/transforms/rowgenerator/RowGeneratorDialog.java +++ b/plugins/transforms/rowgenerator/src/main/java/org/apache/hop/pipeline/transforms/rowgenerator/RowGeneratorDialog.java @@ -95,6 +95,7 @@ public String open() { fdlLimit.top = new FormAttachment(lastControl, margin); wlLimit.setLayoutData(fdlLimit); wLimit = new TextVar(variables, shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wLimit.enableExpandedInteger(); PropsUi.setLook(wLimit); FormData fdLimit = new FormData(); fdLimit.left = new FormAttachment(middle, 0); diff --git a/plugins/transforms/rowgenerator/src/main/java/org/apache/hop/pipeline/transforms/rowgenerator/RowGeneratorMeta.java b/plugins/transforms/rowgenerator/src/main/java/org/apache/hop/pipeline/transforms/rowgenerator/RowGeneratorMeta.java index 7bd6fd0679a..6be2aed62dd 100644 --- a/plugins/transforms/rowgenerator/src/main/java/org/apache/hop/pipeline/transforms/rowgenerator/RowGeneratorMeta.java +++ b/plugins/transforms/rowgenerator/src/main/java/org/apache/hop/pipeline/transforms/rowgenerator/RowGeneratorMeta.java @@ -168,7 +168,7 @@ public void check( remarks.add(cr); String strLimit = variables.resolve(rowLimit); - if (Const.toLong(strLimit, -1L) <= 0) { + if (Const.toLongExpanded(strLimit, -1L) <= 0) { cr = new CheckResult( ICheckResult.TYPE_RESULT_WARNING, diff --git a/plugins/transforms/sasinput/src/main/java/org/apache/hop/pipeline/transforms/sasinput/SasInput.java b/plugins/transforms/sasinput/src/main/java/org/apache/hop/pipeline/transforms/sasinput/SasInput.java index b7895785be1..6a3b0da60bc 100644 --- a/plugins/transforms/sasinput/src/main/java/org/apache/hop/pipeline/transforms/sasinput/SasInput.java +++ b/plugins/transforms/sasinput/src/main/java/org/apache/hop/pipeline/transforms/sasinput/SasInput.java @@ -93,7 +93,7 @@ public boolean processRow() throws HopException { data.outputRowMeta = getInputRowMeta().clone(); meta.getFields(data.outputRowMeta, getTransformName(), null, null, this, metadataProvider); - data.limit = Const.toLong(resolve(meta.getLimit()), -1); + data.limit = Const.toLongExpanded(resolve(meta.getLimit()), -1); } String rawFilename = getInputRowMeta().getString(fileRowData, meta.getAcceptingField(), null); diff --git a/plugins/transforms/sasinput/src/main/java/org/apache/hop/pipeline/transforms/sasinput/SasInputDialog.java b/plugins/transforms/sasinput/src/main/java/org/apache/hop/pipeline/transforms/sasinput/SasInputDialog.java index 7c5058785e1..b2d971b4f95 100644 --- a/plugins/transforms/sasinput/src/main/java/org/apache/hop/pipeline/transforms/sasinput/SasInputDialog.java +++ b/plugins/transforms/sasinput/src/main/java/org/apache/hop/pipeline/transforms/sasinput/SasInputDialog.java @@ -133,6 +133,7 @@ public String open() { fdlLimit.right = new FormAttachment(middle, -margin); wlLimit.setLayoutData(fdlLimit); wLimit = new TextVar(variables, shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wLimit.enableExpandedInteger(); wLimit.setToolTipText(BaseMessages.getString(PKG, "SASInputDialog.Limit.Tooltip")); PropsUi.setLook(wLimit); FormData fdLimit = new FormData(); diff --git a/plugins/transforms/sort/src/main/java/org/apache/hop/pipeline/transforms/sort/SortRows.java b/plugins/transforms/sort/src/main/java/org/apache/hop/pipeline/transforms/sort/SortRows.java index 025570177e6..1dec1ed67c5 100644 --- a/plugins/transforms/sort/src/main/java/org/apache/hop/pipeline/transforms/sort/SortRows.java +++ b/plugins/transforms/sort/src/main/java/org/apache/hop/pipeline/transforms/sort/SortRows.java @@ -518,7 +518,7 @@ public boolean init() { return false; } - data.sortSize = Const.toInt(resolve(meta.getSortSize()), -1); + data.sortSize = Const.toIntExpanded(resolve(meta.getSortSize()), -1); data.freeMemoryPctLimit = Const.toInt(meta.getFreeMemoryLimit(), -1); if (data.sortSize <= 0 && data.freeMemoryPctLimit <= 0) { // Prefer the memory limit as it should never fail diff --git a/plugins/transforms/sort/src/main/java/org/apache/hop/pipeline/transforms/sort/SortRowsDialog.java b/plugins/transforms/sort/src/main/java/org/apache/hop/pipeline/transforms/sort/SortRowsDialog.java index 02cd8d3e634..b73d57bd684 100644 --- a/plugins/transforms/sort/src/main/java/org/apache/hop/pipeline/transforms/sort/SortRowsDialog.java +++ b/plugins/transforms/sort/src/main/java/org/apache/hop/pipeline/transforms/sort/SortRowsDialog.java @@ -153,6 +153,7 @@ public String open() { fdlSortSize.top = new FormAttachment(wPrefix, margin); wlSortSize.setLayoutData(fdlSortSize); wSortSize = new TextVar(variables, shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wSortSize.enableExpandedInteger(); PropsUi.setLook(wSortSize); wSortSize.addModifyListener(lsMod); FormData fdSortSize = new FormData(); diff --git a/plugins/transforms/synchronizeaftermerge/src/main/java/org/apache/hop/pipeline/transforms/synchronizeaftermerge/SynchronizeAfterMerge.java b/plugins/transforms/synchronizeaftermerge/src/main/java/org/apache/hop/pipeline/transforms/synchronizeaftermerge/SynchronizeAfterMerge.java index 25a9d097d56..bba2613f9e4 100644 --- a/plugins/transforms/synchronizeaftermerge/src/main/java/org/apache/hop/pipeline/transforms/synchronizeaftermerge/SynchronizeAfterMerge.java +++ b/plugins/transforms/synchronizeaftermerge/src/main/java/org/apache/hop/pipeline/transforms/synchronizeaftermerge/SynchronizeAfterMerge.java @@ -972,7 +972,10 @@ public boolean init() { data.releaseSavepoint = false; } - data.commitSize = Integer.parseInt(resolve(meta.getCommitSize())); + String commitSize = resolve(meta.getCommitSize()); + String expandedCommitSize = Const.expandIntegerString(commitSize); + data.commitSize = + Integer.parseInt(expandedCommitSize != null ? expandedCommitSize : commitSize); data.batchMode = data.commitSize > 0 && meta.isUsingBatchUpdates(); // Batch updates are not supported on PostgreSQL (and look-a-likes) together with error diff --git a/plugins/transforms/synchronizeaftermerge/src/main/java/org/apache/hop/pipeline/transforms/synchronizeaftermerge/SynchronizeAfterMergeDialog.java b/plugins/transforms/synchronizeaftermerge/src/main/java/org/apache/hop/pipeline/transforms/synchronizeaftermerge/SynchronizeAfterMergeDialog.java index e33d9cc88aa..e49edb866b2 100644 --- a/plugins/transforms/synchronizeaftermerge/src/main/java/org/apache/hop/pipeline/transforms/synchronizeaftermerge/SynchronizeAfterMergeDialog.java +++ b/plugins/transforms/synchronizeaftermerge/src/main/java/org/apache/hop/pipeline/transforms/synchronizeaftermerge/SynchronizeAfterMergeDialog.java @@ -618,6 +618,7 @@ private void addGeneralTab( wlCommit.setLayoutData(fdlCommit); wCommit = new TextVar(variables, wGeneralComp, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wCommit.enableExpandedInteger(); PropsUi.setLook(wCommit); wCommit.addModifyListener(lsMod); FormData fdCommit = new FormData(); diff --git a/plugins/transforms/tableinput/src/main/java/org/apache/hop/pipeline/transforms/tableinput/TableInput.java b/plugins/transforms/tableinput/src/main/java/org/apache/hop/pipeline/transforms/tableinput/TableInput.java index ece2fcfec35..ce0d4c721c6 100644 --- a/plugins/transforms/tableinput/src/main/java/org/apache/hop/pipeline/transforms/tableinput/TableInput.java +++ b/plugins/transforms/tableinput/src/main/java/org/apache/hop/pipeline/transforms/tableinput/TableInput.java @@ -341,7 +341,7 @@ public boolean init() { DatabaseMeta databaseMeta = getPipelineMeta().findDatabase(meta.getConnection(), variables); data.db = new Database(this, this, databaseMeta); - data.db.setQueryLimit(Const.toInt(resolve(meta.getRowLimit()), 0)); + data.db.setQueryLimit(Const.toIntExpanded(resolve(meta.getRowLimit()), 0)); // Statement timeout is for transform dialog / pipeline preview only (Hop GUI sets preview). // Normal pipeline runs use JDBC driver default (0 = no explicit timeout on the statement). if (getPipeline() != null && getPipeline().isPreview()) { diff --git a/plugins/transforms/tableinput/src/main/java/org/apache/hop/pipeline/transforms/tableinput/TableInputDialog.java b/plugins/transforms/tableinput/src/main/java/org/apache/hop/pipeline/transforms/tableinput/TableInputDialog.java index 348dd338107..e613f837ab9 100644 --- a/plugins/transforms/tableinput/src/main/java/org/apache/hop/pipeline/transforms/tableinput/TableInputDialog.java +++ b/plugins/transforms/tableinput/src/main/java/org/apache/hop/pipeline/transforms/tableinput/TableInputDialog.java @@ -166,6 +166,7 @@ public String open() { fdlLimit.bottom = new FormAttachment(wOk, -margin); wlLimit.setLayoutData(fdlLimit); wLimit = new TextVar(variables, shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wLimit.enableExpandedInteger(); PropsUi.setLook(wLimit); wLimit.addModifyListener(lsMod); FormData fdLimit = new FormData(); diff --git a/plugins/transforms/tableoutput/src/main/java/org/apache/hop/pipeline/transforms/tableoutput/TableOutput.java b/plugins/transforms/tableoutput/src/main/java/org/apache/hop/pipeline/transforms/tableoutput/TableOutput.java index 197954d1597..104379385b4 100644 --- a/plugins/transforms/tableoutput/src/main/java/org/apache/hop/pipeline/transforms/tableoutput/TableOutput.java +++ b/plugins/transforms/tableoutput/src/main/java/org/apache/hop/pipeline/transforms/tableoutput/TableOutput.java @@ -491,7 +491,10 @@ public boolean init() { if (super.init()) { try { - data.commitSize = Integer.parseInt(resolve(meta.getCommitSize())); + String commitSize = resolve(meta.getCommitSize()); + String expandedCommitSize = Const.expandIntegerString(commitSize); + data.commitSize = + Integer.parseInt(expandedCommitSize != null ? expandedCommitSize : commitSize); if (Utils.isEmpty(meta.getConnection())) throw new HopException(BaseMessages.getString(PKG, "TableOutput.Init.ConnectionMissing")); diff --git a/plugins/transforms/tableoutput/src/main/java/org/apache/hop/pipeline/transforms/tableoutput/TableOutputDialog.java b/plugins/transforms/tableoutput/src/main/java/org/apache/hop/pipeline/transforms/tableoutput/TableOutputDialog.java index e074ecf46ce..b8fa0f0c5c6 100644 --- a/plugins/transforms/tableoutput/src/main/java/org/apache/hop/pipeline/transforms/tableoutput/TableOutputDialog.java +++ b/plugins/transforms/tableoutput/src/main/java/org/apache/hop/pipeline/transforms/tableoutput/TableOutputDialog.java @@ -296,6 +296,7 @@ public void widgetSelected(SelectionEvent e) { fdlCommit.top = new FormAttachment(wbTable, margin); wlCommit.setLayoutData(fdlCommit); wCommit = new TextVar(variables, wContentComposite, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wCommit.enableExpandedInteger(); PropsUi.setLook(wCommit); FormData fdCommit = new FormData(); fdCommit.left = new FormAttachment(middle, 0); diff --git a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/csvinput/CsvInput.java b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/csvinput/CsvInput.java index 5157b7a5e77..cc3072abe07 100644 --- a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/csvinput/CsvInput.java +++ b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/csvinput/CsvInput.java @@ -29,6 +29,7 @@ import org.apache.commons.io.input.BOMInputStream; import org.apache.commons.vfs2.FileObject; import org.apache.commons.vfs2.provider.local.LocalFile; +import org.apache.hop.core.Const; import org.apache.hop.core.ResultFile; import org.apache.hop.core.exception.HopConversionException; import org.apache.hop.core.exception.HopException; @@ -938,7 +939,10 @@ public boolean init() { if (super.init()) { // see if a variable is used as encoding value String realEncoding = resolve(meta.getEncoding()); - data.preferredBufferSize = Integer.parseInt(resolve(meta.getBufferSize())); + String bufferSize = resolve(meta.getBufferSize()); + String expandedBufferSize = Const.expandIntegerString(bufferSize); + data.preferredBufferSize = + Integer.parseInt(expandedBufferSize != null ? expandedBufferSize : bufferSize); // If the transform doesn't have any previous transforms, we just get the filename. // Otherwise, we'll grab the list of file names later... diff --git a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/csvinput/CsvInputDialog.java b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/csvinput/CsvInputDialog.java index 12553925168..a53bbc009e3 100644 --- a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/csvinput/CsvInputDialog.java +++ b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/csvinput/CsvInputDialog.java @@ -316,6 +316,7 @@ public void widgetSelected(SelectionEvent e) { fdlBufferSize.right = new FormAttachment(middle, -margin); wlBufferSize.setLayoutData(fdlBufferSize); wBufferSize = new TextVar(variables, shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wBufferSize.enableExpandedInteger(); PropsUi.setLook(wBufferSize); wBufferSize.addModifyListener(lsMod); FormData fdBufferSize = new FormData(); diff --git a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/fileinput/text/TextFileInputDialog.java b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/fileinput/text/TextFileInputDialog.java index 4c0be1472fb..20e72be6f6f 100644 --- a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/fileinput/text/TextFileInputDialog.java +++ b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/fileinput/text/TextFileInputDialog.java @@ -2522,7 +2522,7 @@ private void getInfo(TextFileInputMeta meta, boolean preview) { meta.getContent().setEnclosure(wEnclosure.getText()); meta.getContent().setEscapeCharacter(wEscape.getText()); meta.getContent().setBreakInEnclosureAllowed(wEnclBreaks.getSelection()); - meta.getContent().setRowLimit(Const.toLong(wLimit.getText(), 0L)); + meta.getContent().setRowLimit(Const.toLongExpanded(wLimit.getText(), 0L)); meta.getContent().setFilenameField(wInclFilenameField.getText()); meta.getContent().setRowNumberField(wInclRownumField.getText()); meta.getFileInput().setAddingResult(wAddResult.getSelection()); diff --git a/plugins/transforms/tika/src/main/java/org/apache/hop/pipeline/transforms/tika/TikaDialog.java b/plugins/transforms/tika/src/main/java/org/apache/hop/pipeline/transforms/tika/TikaDialog.java index 58aa4688bc5..965b3474353 100755 --- a/plugins/transforms/tika/src/main/java/org/apache/hop/pipeline/transforms/tika/TikaDialog.java +++ b/plugins/transforms/tika/src/main/java/org/apache/hop/pipeline/transforms/tika/TikaDialog.java @@ -841,7 +841,7 @@ private void getInfo(TikaMeta in) throws HopException { transformName = wTransformName.getText(); // return value // copy info to TikaMeta class (input) - in.setRowLimit(Const.toLong(wLimit.getText(), 0L)); + in.setRowLimit(Const.toLongExpanded(wLimit.getText(), 0L)); in.setEncoding(wEncoding.getText()); in.setOutputFormat(wOutputFormat.getText()); in.setAddingResultFile(wAddResult.getSelection()); diff --git a/plugins/transforms/update/src/main/java/org/apache/hop/pipeline/transforms/update/UpdateDialog.java b/plugins/transforms/update/src/main/java/org/apache/hop/pipeline/transforms/update/UpdateDialog.java index 79e398bb21f..e5d2fd89391 100644 --- a/plugins/transforms/update/src/main/java/org/apache/hop/pipeline/transforms/update/UpdateDialog.java +++ b/plugins/transforms/update/src/main/java/org/apache/hop/pipeline/transforms/update/UpdateDialog.java @@ -263,6 +263,7 @@ public void widgetSelected(SelectionEvent e) { fdlCommit.right = new FormAttachment(middle, -margin); wlCommit.setLayoutData(fdlCommit); wCommit = new TextVar(variables, composite, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wCommit.enableExpandedInteger(); PropsUi.setLook(wCommit); wCommit.addModifyListener(lsMod); FormData fdCommit = new FormData(); diff --git a/plugins/transforms/update/src/main/java/org/apache/hop/pipeline/transforms/update/UpdateMeta.java b/plugins/transforms/update/src/main/java/org/apache/hop/pipeline/transforms/update/UpdateMeta.java index 1a40147fa1e..79c7148e0e4 100644 --- a/plugins/transforms/update/src/main/java/org/apache/hop/pipeline/transforms/update/UpdateMeta.java +++ b/plugins/transforms/update/src/main/java/org/apache/hop/pipeline/transforms/update/UpdateMeta.java @@ -125,7 +125,9 @@ public String getCommitSizeVar() { public int getCommitSize(IVariables vs) { // this happens when the transform is created via API and no setDefaults was called commitSize = (commitSize == null) ? "0" : commitSize; - return Integer.parseInt(vs.resolve(commitSize)); + String resolved = vs.resolve(commitSize); + String expanded = Const.expandIntegerString(resolved); + return Integer.parseInt(expanded != null ? expanded : resolved); } public UpdateMeta() { diff --git a/plugins/transforms/workflowexecutor/src/main/java/org/apache/hop/pipeline/transforms/workflowexecutor/WorkflowExecutor.java b/plugins/transforms/workflowexecutor/src/main/java/org/apache/hop/pipeline/transforms/workflowexecutor/WorkflowExecutor.java index 33d16ec86ff..109e8d91fec 100644 --- a/plugins/transforms/workflowexecutor/src/main/java/org/apache/hop/pipeline/transforms/workflowexecutor/WorkflowExecutor.java +++ b/plugins/transforms/workflowexecutor/src/main/java/org/apache/hop/pipeline/transforms/workflowexecutor/WorkflowExecutor.java @@ -442,7 +442,7 @@ public boolean init() { // data.groupSize = -1; if (!Utils.isEmpty(meta.getGroupSize())) { - data.groupSize = Const.toInt(resolve(meta.getGroupSize()), -1); + data.groupSize = Const.toIntExpanded(resolve(meta.getGroupSize()), -1); } // Is there a grouping time set? diff --git a/plugins/transforms/workflowexecutor/src/main/java/org/apache/hop/pipeline/transforms/workflowexecutor/WorkflowExecutorDialog.java b/plugins/transforms/workflowexecutor/src/main/java/org/apache/hop/pipeline/transforms/workflowexecutor/WorkflowExecutorDialog.java index c47cae9232c..b6cb1402e9a 100644 --- a/plugins/transforms/workflowexecutor/src/main/java/org/apache/hop/pipeline/transforms/workflowexecutor/WorkflowExecutorDialog.java +++ b/plugins/transforms/workflowexecutor/src/main/java/org/apache/hop/pipeline/transforms/workflowexecutor/WorkflowExecutorDialog.java @@ -620,6 +620,7 @@ private void addRowGroupTab() { wlGroupSize.setLayoutData(fdlGroupSize); wGroupSize = new TextVar(variables, wInputComposite, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wGroupSize.enableExpandedInteger(); PropsUi.setLook(wGroupSize); FormData fdGroupSize = new FormData(); fdGroupSize.width = 250; @@ -1013,7 +1014,7 @@ private void setFlags() { || wGroupTime == null) { return; } - boolean enableSize = Const.toInt(variables.resolve(wGroupSize.getText()), -1) >= 0; + boolean enableSize = Const.toIntExpanded(variables.resolve(wGroupSize.getText()), -1) >= 0; boolean enableField = !Utils.isEmpty(wGroupField.getText()); wlGroupSize.setEnabled(true); diff --git a/plugins/transforms/xml/src/main/java/org/apache/hop/pipeline/transforms/xml/getxmldata/GetXmlDataDialog.java b/plugins/transforms/xml/src/main/java/org/apache/hop/pipeline/transforms/xml/getxmldata/GetXmlDataDialog.java index f14822bceb9..046e938c503 100644 --- a/plugins/transforms/xml/src/main/java/org/apache/hop/pipeline/transforms/xml/getxmldata/GetXmlDataDialog.java +++ b/plugins/transforms/xml/src/main/java/org/apache/hop/pipeline/transforms/xml/getxmldata/GetXmlDataDialog.java @@ -1804,7 +1804,7 @@ private void getInfo(GetXmlDataMeta in) { transformName = wTransformName.getText(); // return value // copy info to TextFileInputMeta class (input) - in.setRowLimit(Const.toLong(wLimit.getText(), 0L)); + in.setRowLimit(Const.toLongExpanded(wLimit.getText(), 0L)); in.setPrunePath(wPrunePath.getText()); in.setLoopXPath(wLoopXPath.getText()); in.setEncoding(wEncoding.getText()); diff --git a/plugins/transforms/xml/src/main/java/org/apache/hop/pipeline/transforms/xml/xmlinputstream/XmlInputStream.java b/plugins/transforms/xml/src/main/java/org/apache/hop/pipeline/transforms/xml/xmlinputstream/XmlInputStream.java index c712df93a25..7550606ff61 100644 --- a/plugins/transforms/xml/src/main/java/org/apache/hop/pipeline/transforms/xml/xmlinputstream/XmlInputStream.java +++ b/plugins/transforms/xml/src/main/java/org/apache/hop/pipeline/transforms/xml/xmlinputstream/XmlInputStream.java @@ -670,8 +670,8 @@ public boolean init() { data.filenames = null; } - data.nrRowsToSkip = Const.toLong(this.resolve(meta.getNrRowsToSkip()), 0); - data.rowLimit = Const.toLong(this.resolve(meta.getRowLimit()), 0); + data.nrRowsToSkip = Const.toLongExpanded(this.resolve(meta.getNrRowsToSkip()), 0); + data.rowLimit = Const.toLongExpanded(this.resolve(meta.getRowLimit()), 0); data.encoding = this.resolve(meta.getEncoding()); data.outputRowMeta = new RowMeta(); diff --git a/plugins/transforms/xml/src/main/java/org/apache/hop/pipeline/transforms/xml/xmlinputstream/XmlInputStreamDialog.java b/plugins/transforms/xml/src/main/java/org/apache/hop/pipeline/transforms/xml/xmlinputstream/XmlInputStreamDialog.java index 67a0266c359..c8ed7f678e0 100644 --- a/plugins/transforms/xml/src/main/java/org/apache/hop/pipeline/transforms/xml/xmlinputstream/XmlInputStreamDialog.java +++ b/plugins/transforms/xml/src/main/java/org/apache/hop/pipeline/transforms/xml/xmlinputstream/XmlInputStreamDialog.java @@ -307,6 +307,7 @@ public String open() { fdlRowsToSkip.right = new FormAttachment(middle, -margin); wlRowsToSkip.setLayoutData(fdlRowsToSkip); wRowsToSkip = new TextVar(variables, wContent, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wRowsToSkip.enableExpandedInteger(); PropsUi.setLook(wRowsToSkip); wRowsToSkip.addModifyListener(lsMod); FormData fdRowsToSkip = new FormData(); @@ -327,6 +328,7 @@ public String open() { fdlLimit.right = new FormAttachment(middle, -margin); wlLimit.setLayoutData(fdlLimit); wLimit = new TextVar(variables, wContent, SWT.SINGLE | SWT.LEFT | SWT.BORDER); + wLimit.enableExpandedInteger(); PropsUi.setLook(wLimit); wLimit.addModifyListener(lsMod); FormData fdLimit = new FormData(); diff --git a/plugins/transforms/yamlinput/src/main/java/org/apache/hop/pipeline/transforms/yamlinput/YamlInputDialog.java b/plugins/transforms/yamlinput/src/main/java/org/apache/hop/pipeline/transforms/yamlinput/YamlInputDialog.java index b2aacde4900..057634a7ca1 100644 --- a/plugins/transforms/yamlinput/src/main/java/org/apache/hop/pipeline/transforms/yamlinput/YamlInputDialog.java +++ b/plugins/transforms/yamlinput/src/main/java/org/apache/hop/pipeline/transforms/yamlinput/YamlInputDialog.java @@ -1117,7 +1117,7 @@ private void getInfo(YamlInputMeta in) { transformName = wTransformName.getText(); // copy info to TextFileInputMeta class (input) - in.setRowLimit(Const.toLong(wLimit.getText(), 0L)); + in.setRowLimit(Const.toLongExpanded(wLimit.getText(), 0L)); in.setFilenameField(wInclFilenameField.getText()); in.setRowNumberField(wInclRowNumField.getText()); in.setAddingResultFile(wAddResult.getSelection()); diff --git a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiResource.java b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiResource.java index 78bea972f9f..4c4ee136df8 100644 --- a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiResource.java +++ b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiResource.java @@ -143,6 +143,7 @@ public class GuiResource { private SwtUniversalImage imageMissing; private SwtUniversalImage imageDeprecated; private SwtUniversalImage imageVariable; + private SwtUniversalImage imageHash; private SwtUniversalImage imagePipeline; private SwtUniversalImage imagePipelineDisabled; private SwtUniversalImage imagePartitionSchema; @@ -444,6 +445,7 @@ private void dispose() { imageFolder.dispose(); imageMissing.dispose(); imageVariable.dispose(); + imageHash.dispose(); imagePipeline.dispose(); imagePipelineDisabled.dispose(); imagePartitionSchema.dispose(); @@ -829,6 +831,7 @@ private void loadCommonImages() { imageFalseDisabled = SwtSvgImageUtil.getImageAsResource(display, "ui/images/false-disabled.svg"); imageVariable = SwtSvgImageUtil.getImageAsResource(display, "ui/images/variable.svg"); + imageHash = SwtSvgImageUtil.getImageAsResource(display, "ui/images/hash.svg"); imageFile = SwtSvgImageUtil.getImageAsResource(display, "ui/images/file.svg"); imageFolder = SwtSvgImageUtil.getImageAsResource(display, "ui/images/folder.svg"); imagePartitionSchema = @@ -1188,6 +1191,18 @@ public Image getImageVariableMini() { return getZoomedImaged(imageVariable, display, 12, 12); } + /** + * @return the hash / expanded-integer indicator image at standard small icon size + */ + public Image getImageHash() { + return getZoomedImaged(imageHash, display, ConstUi.SMALL_ICON_SIZE, ConstUi.SMALL_ICON_SIZE); + } + + /** Mini hash icon for TextVar expanded-integer notation indicator (matches variable mini). */ + public Image getImageHashMini() { + return getZoomedImaged(imageHash, display, 12, 12); + } + public Image getImagePipeline() { return getZoomedImaged( imagePipeline, display, ConstUi.SMALL_ICON_SIZE, ConstUi.SMALL_ICON_SIZE); diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/TextVar.java b/ui/src/main/java/org/apache/hop/ui/core/widget/TextVar.java index 3a8e7c16e90..33623234234 100644 --- a/ui/src/main/java/org/apache/hop/ui/core/widget/TextVar.java +++ b/ui/src/main/java/org/apache/hop/ui/core/widget/TextVar.java @@ -59,6 +59,14 @@ public class TextVar extends Composite { protected Text wText; + /** Variable ($) indicator; always present. */ + protected Label wVariableImage; + + /** Optional expanded-integer (#) indicator; created by {@link #enableExpandedInteger()}. */ + protected Label wHashImage; + + protected FormData fdText; + protected ModifyListener modifyListenerTooltipText; public TextVar(IVariables variables, Composite composite, int flags) { @@ -158,22 +166,22 @@ protected void initialize( // Add the variable $ image on the top right of the control // - Label wImage = new Label(this, SWT.NONE); - PropsUi.setLook(wImage); - wImage.setImage(GuiResource.getInstance().getImageVariableMini()); - wImage.setToolTipText(BaseMessages.getString(PKG, "TextVar.tooltip.InsertVariable")); + wVariableImage = new Label(this, SWT.NONE); + PropsUi.setLook(wVariableImage); + wVariableImage.setImage(GuiResource.getInstance().getImageVariableMini()); + wVariableImage.setToolTipText(BaseMessages.getString(PKG, "TextVar.tooltip.InsertVariable")); FormData fdlImage = new FormData(); fdlImage.top = new FormAttachment(0, 0); fdlImage.right = new FormAttachment(100, 0); - wImage.setLayoutData(fdlImage); + wVariableImage.setLayoutData(fdlImage); // add a text field on it... wText = new Text(this, flags); PropsUi.setLook(wText); - FormData fdText = new FormData(); + fdText = new FormData(); fdText.top = new FormAttachment(0, 0); fdText.left = new FormAttachment(0, 0); - fdText.right = new FormAttachment(wImage, 0); + fdText.right = new FormAttachment(wVariableImage, 0); fdText.bottom = new FormAttachment(100, 0); wText.setLayoutData(fdText); @@ -186,6 +194,38 @@ protected void initialize( wText.addKeyListener(controlSpaceKeyAdapter); } + /** + * Show a mini {@code #} indicator that this field accepts expanded integer notation (grouping + * separators, k/m/g/b suffixes, scientific forms). Call after construction on fields that use + * {@link Const#toIntExpanded(String, int)} / {@link Const#toLongExpanded(String, long)}. + * + *

Layout becomes: {@code [ text ........ ] [#] [$]}. Safe to call more than once. + * + * @return this widget for chaining + */ + public TextVar enableExpandedInteger() { + if (wHashImage != null || wVariableImage == null || wText == null) { + return this; + } + + wHashImage = new Label(this, SWT.NONE); + PropsUi.setLook(wHashImage); + wHashImage.setImage(GuiResource.getInstance().getImageHashMini()); + wHashImage.setToolTipText(BaseMessages.getString(PKG, "TextVar.tooltip.ExpandedInteger")); + FormData fdlHash = new FormData(); + fdlHash.top = new FormAttachment(0, 0); + fdlHash.right = new FormAttachment(wVariableImage, 0); + wHashImage.setLayoutData(fdlHash); + + // Rebind text field to end at the hash icon (left of $) + if (fdText != null) { + fdText.right = new FormAttachment(wHashImage, 0); + wText.setLayoutData(fdText); + } + layout(true, true); + return this; + } + /** * @return the getCaretPositionInterface */ diff --git a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_en_US.properties b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_en_US.properties index 323a8ec2ba4..d8ac9eb19b0 100644 --- a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_en_US.properties +++ b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_en_US.properties @@ -95,6 +95,7 @@ TableView.ToolBarWidget.SetFilter.ToolTip=Select rows using a filter TableView.ToolBarWidget.UndoAction.ToolTip=Undo the last action TextVar.InternalVariable.Message=This is an internal variable. TextVar.tooltip.InsertVariable=Enter CTRL-SPACE to select a variable to insert +TextVar.tooltip.ExpandedInteger=This field accepts expanded integer values:\n- Grouping: 100,000,000 or 100.000.000 or 100_000_000\n- Suffixes: 100k, 100m, 1g, 1b (\u00d71,000 / \u00d71,000,000 / \u00d71,000,000,000)\n- Fractional: 1.5m\n- Scientific: 1e8, 1x10^8, 10^8\nVariables ($'{'...'}') are resolved before parsing. TextVar.VariableValue.Message=The value of variable ''{0}'' is \: \n\n\t{1}\n\n WidgetDialog.Styled.Copy=Copy\tCtrl+C WidgetDialog.Styled.Cut=Cut\tCtrl+X diff --git a/ui/src/main/resources/ui/images/hash.svg b/ui/src/main/resources/ui/images/hash.svg new file mode 100644 index 00000000000..f16ab252a4a --- /dev/null +++ b/ui/src/main/resources/ui/images/hash.svg @@ -0,0 +1,8 @@ + + + + + + + From 7d6da057e1104998122127f8ba1a41e566d83922 Mon Sep 17 00:00:00 2001 From: mattcasters Date: Sun, 19 Jul 2026 13:59:09 +0200 Subject: [PATCH 11/15] Issue #2710 : Provide a way to display and output variables a project depends on --- .../modules/ROOT/pages/projects/index.adoc | 6 + .../EnvironmentVariablesImportHelper.java | 398 ++++++++++++++++++ .../LifecycleEnvironmentDialog.java | 246 ++++++++++- .../messages/messages_en_US.properties | 15 + .../EnvironmentVariablesImportHelperTest.java | 193 +++++++++ 5 files changed, 836 insertions(+), 22 deletions(-) create mode 100644 plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/EnvironmentVariablesImportHelper.java create mode 100644 plugins/misc/projects/src/test/java/org/apache/hop/projects/environment/EnvironmentVariablesImportHelperTest.java diff --git a/docs/hop-user-manual/modules/ROOT/pages/projects/index.adoc b/docs/hop-user-manual/modules/ROOT/pages/projects/index.adoc index fcbfc44752e..f1e5371cd7b 100644 --- a/docs/hop-user-manual/modules/ROOT/pages/projects/index.adoc +++ b/docs/hop-user-manual/modules/ROOT/pages/projects/index.adoc @@ -72,3 +72,9 @@ Just like projects, environments are defined in one or a number of configuration TIP: store your environment configuration files outside of the project folder. You may even want to check them in to a separate version control repository. + +==== Import variables + +When you create or edit a lifecycle environment, the *Import variables* button scans the associated project for Unix-style variable expressions (`{openvar}VARIABLE_NAME{closevar}`) in pipelines, workflows and metadata objects (using the same project search infrastructure as the Hop Gui search perspective, with a regular expression for that pattern). + +Variables that are already defined in one of the environment's configuration files, as well as runtime/project managed names such as `{openvar}PROJECT_HOME{closevar}`, are omitted. The remaining names are shown in a dialog where you can set values and descriptions, then save them to an existing environment configuration file or create a new one that is added to the environment. diff --git a/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/EnvironmentVariablesImportHelper.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/EnvironmentVariablesImportHelper.java new file mode 100644 index 00000000000..5254ae366bf --- /dev/null +++ b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/EnvironmentVariablesImportHelper.java @@ -0,0 +1,398 @@ +/* + * 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.environment; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.commons.lang3.StringUtils; +import org.apache.hop.core.Const; +import org.apache.hop.core.config.DescribedVariablesConfigFile; +import org.apache.hop.core.exception.HopException; +import org.apache.hop.core.search.ISearchQuery; +import org.apache.hop.core.search.ISearchResult; +import org.apache.hop.core.search.ISearchable; +import org.apache.hop.core.search.ISearchableAnalyser; +import org.apache.hop.core.search.SearchQuery; +import org.apache.hop.core.variables.DescribedVariable; +import org.apache.hop.core.variables.IVariables; +import org.apache.hop.core.variables.Variables; +import org.apache.hop.core.vfs.HopVfs; +import org.apache.hop.metadata.api.IHopMetadataProvider; +import org.apache.hop.metadata.util.HopMetadataUtil; +import org.apache.hop.projects.project.Project; +import org.apache.hop.projects.project.ProjectConfig; +import org.apache.hop.projects.search.ProjectsSearchablesLocation; +import org.apache.hop.projects.util.Defaults; +import org.apache.hop.projects.util.ProjectsUtil; +import org.apache.hop.ui.hopgui.search.HopGuiSearchHelper; + +/** + * Finds {@code ${VARIABLE}} expressions used in a project's pipelines, workflows and metadata via + * the project search infrastructure, and prepares described variables that are not yet defined in + * environment configuration files. + */ +public final class EnvironmentVariablesImportHelper { + + /** + * Regex used with {@link SearchQuery} (regex mode) to find property values that contain a Unix + * variable expression. Partial match via {@link java.util.regex.Matcher#find()}. + */ + public static final String VARIABLE_EXPRESSION_REGEX = "\\$\\{[^}]+\\}"; + + /** Extracts the variable name from each {@code ${…}} occurrence in a string. */ + public static final Pattern VARIABLE_NAME_PATTERN = Pattern.compile("\\$\\{([^}]+)\\}"); + + /** JVM temporary directory system property often referenced as {@code ${java.io.tmpdir}}. */ + public static final String JAVA_IO_TMPDIR = "java.io.tmpdir"; + + /** Max distinct search locations kept in a variable description. */ + private static final int MAX_DESCRIPTION_LOCATIONS = 10; + + private static final Set MANAGED_VARIABLE_NAMES; + + static { + Set managed = new HashSet<>(); + managed.add(ProjectsUtil.VARIABLE_PROJECT_HOME); + managed.add(ProjectsUtil.VARIABLE_PARENT_PROJECT_HOME); + managed.add(ProjectsUtil.VARIABLE_PARENT_PROJECT_NAME); + managed.add(ProjectsUtil.VARIABLE_HOP_DATASETS_FOLDER); + managed.add(ProjectsUtil.VARIABLE_HOP_UNIT_TESTS_FOLDER); + managed.add(Defaults.VARIABLE_HOP_PROJECT_NAME); + managed.add(Defaults.VARIABLE_HOP_ENVIRONMENT_NAME); + managed.add(Const.HOP_METADATA_FOLDER); + managed.add(JAVA_IO_TMPDIR); + MANAGED_VARIABLE_NAMES = Collections.unmodifiableSet(managed); + } + + private EnvironmentVariablesImportHelper() { + // Utility class + } + + /** + * Extract distinct variable names from {@code ${NAME}} expressions in the given text. Only Unix + * style is considered; {@code %%NAME%%} and {@code #{…}} are ignored. + * + * @param text text that may contain variable expressions (may be null) + * @return ordered set of variable names (insertion order of first occurrence) + */ + public static Set extractVariableNames(String text) { + Set names = new LinkedHashSet<>(); + if (StringUtils.isEmpty(text)) { + return names; + } + Matcher matcher = VARIABLE_NAME_PATTERN.matcher(text); + while (matcher.find()) { + String name = matcher.group(1); + if (StringUtils.isNotBlank(name)) { + names.add(name.trim()); + } + } + return names; + } + + /** + * Whether the variable is managed by Hop / the project runtime and should not be proposed for an + * environment configuration file. + * + * @param name variable name + * @return true if the name should be skipped + */ + public static boolean isManagedVariable(String name) { + if (StringUtils.isBlank(name)) { + return true; + } + if (name.startsWith(Const.INTERNAL_VARIABLE_PREFIX)) { + return true; + } + return MANAGED_VARIABLE_NAMES.contains(name); + } + + /** + * Load variable names already defined in the given environment configuration files. + * + * @param configurationFiles paths (may contain variables) listed on the environment + * @param variables variables used to resolve paths + * @return set of configured variable names + * @throws HopException if a config file exists but cannot be read + */ + public static Set loadConfiguredVariableNames( + List configurationFiles, IVariables variables) throws HopException { + Set names = new HashSet<>(); + if (configurationFiles == null) { + return names; + } + for (String configurationFile : configurationFiles) { + if (StringUtils.isBlank(configurationFile)) { + continue; + } + String realFilename = variables.resolve(configurationFile); + try { + if (!HopVfs.fileExists(realFilename)) { + continue; + } + DescribedVariablesConfigFile configFile = new DescribedVariablesConfigFile(realFilename); + configFile.readFromFile(); + for (DescribedVariable describedVariable : configFile.getDescribedVariables()) { + if (describedVariable != null && StringUtils.isNotBlank(describedVariable.getName())) { + names.add(describedVariable.getName()); + } + } + } catch (Exception e) { + throw new HopException( + "Error reading variables from environment configuration file '" + realFilename + "'", + e); + } + } + return names; + } + + /** + * Crawl the project associated with the environment using project search and a regex for {@code + * ${…}}, then return described variables not already present in the environment configuration + * files. + * + * @param projectConfig project linked to the lifecycle environment + * @param variables parent variable space (typically HopGui variables) + * @param configurationFiles environment configuration file paths (as currently listed in the + * dialog) + * @param environmentName optional environment name for project variable setup + * @return sorted list of proposed described variables (empty values when unset) + * @throws HopException if the project cannot be loaded or search fails + */ + public static List findMissingVariables( + ProjectConfig projectConfig, + IVariables variables, + List configurationFiles, + String environmentName) + throws HopException { + if (projectConfig == null) { + throw new HopException( + "Please select a project for this environment before importing variables."); + } + + IVariables scanVariables = new Variables(); + scanVariables.initializeFrom(variables); + + Project project = projectConfig.loadProject(scanVariables); + List configFiles = + configurationFiles != null ? configurationFiles : Collections.emptyList(); + project.modifyVariables(scanVariables, projectConfig, configFiles, environmentName); + + IHopMetadataProvider metadataProvider = + HopMetadataUtil.getStandardHopMetadataProvider(scanVariables); + + ProjectsSearchablesLocation location = new ProjectsSearchablesLocation(projectConfig); + ISearchQuery query = new SearchQuery(VARIABLE_EXPRESSION_REGEX, true, true); + + @SuppressWarnings("rawtypes") + Map, ISearchableAnalyser> analysers = + HopGuiSearchHelper.loadSearchableAnalysers(); + List results = + HopGuiSearchHelper.searchLocation( + location, query, analysers, metadataProvider, scanVariables); + + // Preserve a stable order of names while collecting where each was found. + Map> locationsByName = new LinkedHashMap<>(); + Map canonicalNameByKey = new LinkedHashMap<>(); + for (ISearchResult result : results) { + if (result == null) { + continue; + } + // Prefer the full property value so multiple ${…} in one field are all captured. + String text = Const.NVL(result.getValue(), result.getMatchingString()); + Set namesInResult = extractVariableNames(text); + if (namesInResult.isEmpty()) { + continue; + } + String foundAt = formatFoundLocation(result); + for (String name : namesInResult) { + if (isManagedVariable(name)) { + continue; + } + String key = name.toLowerCase(Locale.ROOT); + canonicalNameByKey.putIfAbsent(key, name); + if (StringUtils.isNotEmpty(foundAt)) { + locationsByName.computeIfAbsent(key, k -> new LinkedHashSet<>()).add(foundAt); + } else { + locationsByName.computeIfAbsent(key, k -> new LinkedHashSet<>()); + } + } + } + + Set configured = loadConfiguredVariableNames(configFiles, scanVariables); + + // Sorted presentation order + Set sortedKeys = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + sortedKeys.addAll(canonicalNameByKey.keySet()); + + List proposed = new ArrayList<>(); + for (String key : sortedKeys) { + String name = canonicalNameByKey.get(key); + if (containsIgnoreCase(configured, name)) { + continue; + } + String value = Const.NVL(scanVariables.getVariable(name), ""); + String description = formatLocationsDescription(locationsByName.get(key)); + proposed.add(new DescribedVariable(name, value, description)); + } + return proposed; + } + + /** + * Build a short human-readable location for a search hit (type, object name, field/component). + * + * @param result a project-search match + * @return e.g. {@code Pipeline 'load-data' → Table input → filename}, or empty when unknown + */ + public static String formatFoundLocation(ISearchResult result) { + if (result == null || result.getMatchingSearchable() == null) { + return ""; + } + ISearchable searchable = result.getMatchingSearchable(); + StringBuilder sb = new StringBuilder(); + String type = Const.NVL(searchable.getType(), ""); + String name = Const.NVL(searchable.getName(), ""); + if (StringUtils.isNotEmpty(type) && StringUtils.isNotEmpty(name)) { + sb.append(type).append(" '").append(name).append("'"); + } else if (StringUtils.isNotEmpty(name)) { + sb.append(name); + } else if (StringUtils.isNotEmpty(type)) { + sb.append(type); + } + + String where = HopGuiSearchHelper.matchWhere(result); + if (StringUtils.isNotEmpty(where)) { + if (sb.length() > 0) { + sb.append(" → "); + } + sb.append(where); + } + return sb.toString(); + } + + /** + * Join unique found-at locations into a description string. + * + * @param locations distinct location strings (may be null/empty) + * @return description text, never null + */ + public static String formatLocationsDescription(Set locations) { + if (locations == null || locations.isEmpty()) { + return ""; + } + List list = new ArrayList<>(); + for (String location : locations) { + if (StringUtils.isNotEmpty(location)) { + list.add(location); + } + } + if (list.isEmpty()) { + return ""; + } + if (list.size() <= MAX_DESCRIPTION_LOCATIONS) { + return String.join("; ", list); + } + List head = list.subList(0, MAX_DESCRIPTION_LOCATIONS); + int remaining = list.size() - MAX_DESCRIPTION_LOCATIONS; + return String.join("; ", head) + "; (+" + remaining + " more)"; + } + + /** + * Merge the given variables into a described-variables JSON configuration file (create or + * update). + * + * @param configFilename resolved filesystem path + * @param variables variables to add or update + * @throws HopException on I/O errors + */ + public static void saveVariablesToConfigFile( + String configFilename, List variables) throws HopException { + if (StringUtils.isBlank(configFilename)) { + throw new HopException("Configuration filename is empty"); + } + try { + DescribedVariablesConfigFile configFile = new DescribedVariablesConfigFile(configFilename); + if (HopVfs.fileExists(configFilename)) { + configFile.readFromFile(); + } + if (variables != null) { + for (DescribedVariable variable : variables) { + if (variable != null && StringUtils.isNotBlank(variable.getName())) { + configFile.setDescribedVariable(variable); + } + } + } + configFile.saveToFile(); + } catch (Exception e) { + throw new HopException( + "Error saving variables to configuration file '" + configFilename + "'", e); + } + } + + private static boolean containsIgnoreCase(Set names, String name) { + if (names.contains(name)) { + return true; + } + for (String existing : names) { + if (existing != null && existing.equalsIgnoreCase(name)) { + return true; + } + } + return false; + } + + /** + * @return unmodifiable set of runtime/project managed variable names (for tests) + */ + static Set managedVariableNames() { + return MANAGED_VARIABLE_NAMES; + } + + /** + * Suggest a default config filename for an environment. + * + * @param environmentName environment name + * @return e.g. {@code dev-config.json} + */ + public static String defaultConfigFilename(String environmentName) { + if (StringUtils.isBlank(environmentName)) { + return "environment-config.json"; + } + String sanitized = + environmentName + .trim() + .toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9._-]+", "-") + .replaceAll("-+", "-"); + sanitized = StringUtils.strip(sanitized, "-"); + if (sanitized.isEmpty()) { + return "environment-config.json"; + } + return sanitized + "-config.json"; + } +} diff --git a/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java index 41a39e104fa..fb84dff5577 100644 --- a/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java +++ b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java @@ -17,11 +17,14 @@ package org.apache.hop.projects.environment; +import java.util.ArrayList; +import java.util.List; import org.apache.commons.lang3.StringUtils; import org.apache.commons.vfs2.FileObject; import org.apache.hop.core.Const; import org.apache.hop.core.config.DescribedVariablesConfigFile; import org.apache.hop.core.exception.HopException; +import org.apache.hop.core.variables.DescribedVariable; import org.apache.hop.core.variables.IVariables; import org.apache.hop.core.vfs.HopVfs; import org.apache.hop.i18n.BaseMessages; @@ -31,7 +34,9 @@ import org.apache.hop.ui.core.ConstUi; import org.apache.hop.ui.core.PropsUi; import org.apache.hop.ui.core.dialog.BaseDialog; +import org.apache.hop.ui.core.dialog.EnterSelectionDialog; import org.apache.hop.ui.core.dialog.ErrorDialog; +import org.apache.hop.ui.core.dialog.HopDescribedVariablesDialog; import org.apache.hop.ui.core.dialog.MessageBox; import org.apache.hop.ui.core.gui.GuiResource; import org.apache.hop.ui.core.gui.WindowProperty; @@ -72,6 +77,7 @@ public class LifecycleEnvironmentDialog extends Dialog { private IVariables variables; private Button wbEdit; + private Button wbImportVariables; private String originalName; @@ -210,15 +216,48 @@ public String open() { fdlConfigFiles.top = new FormAttachment(lastControl, margin); wlConfigFiles.setLayoutData(fdlConfigFiles); + // Bottons to the right. + // + wbImportVariables = new Button(shell, SWT.PUSH); + PropsUi.setLook(wbImportVariables); + wbImportVariables.setText( + BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Button.ImportVariables")); + FormData fdImportVariables = new FormData(); + fdImportVariables.right = new FormAttachment(100, 0); + fdImportVariables.top = new FormAttachment(wlConfigFiles, margin); + wbImportVariables.setLayoutData(fdImportVariables); + wbImportVariables.addListener(SWT.Selection, this::importVariables); + Button wbSelect = new Button(shell, SWT.PUSH); PropsUi.setLook(wbSelect); wbSelect.setText(BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Button.Select")); FormData fdAdd = new FormData(); + fdAdd.left = new FormAttachment(wbImportVariables, 0, SWT.LEFT); fdAdd.right = new FormAttachment(100, 0); - fdAdd.top = new FormAttachment(wlConfigFiles, margin); + fdAdd.top = new FormAttachment(wbImportVariables, margin); wbSelect.setLayoutData(fdAdd); wbSelect.addListener(SWT.Selection, this::addConfigFile); + Button wbNew = new Button(shell, SWT.PUSH); + PropsUi.setLook(wbNew); + wbNew.setText(BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Button.New")); + FormData fdNew = new FormData(); + fdNew.left = new FormAttachment(wbImportVariables, 0, SWT.LEFT); + fdNew.right = new FormAttachment(100, 0); + fdNew.top = new FormAttachment(wbSelect, margin); + wbNew.setLayoutData(fdNew); + wbNew.addListener(SWT.Selection, this::newConfigFile); + + wbEdit = new Button(shell, SWT.PUSH); + PropsUi.setLook(wbEdit); + wbEdit.setText(BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Button.Edit")); + FormData fdEdit = new FormData(); + fdEdit.left = new FormAttachment(wbImportVariables, 0, SWT.LEFT); + fdEdit.right = new FormAttachment(100, 0); + fdEdit.top = new FormAttachment(wbNew, margin); + wbEdit.setLayoutData(fdEdit); + wbEdit.addListener(SWT.Selection, this::editConfigFile); + ColumnInfo[] columnInfo = new ColumnInfo[] { new ColumnInfo( @@ -241,32 +280,12 @@ public String open() { PropsUi.setLook(wConfigFiles); FormData fdConfigFiles = new FormData(); fdConfigFiles.left = new FormAttachment(0, 0); - fdConfigFiles.right = new FormAttachment(wbSelect, -2 * margin); + fdConfigFiles.right = new FormAttachment(wbImportVariables, -2 * margin); fdConfigFiles.top = new FormAttachment(wlConfigFiles, margin); fdConfigFiles.bottom = new FormAttachment(wOK, -margin * 2); wConfigFiles.setLayoutData(fdConfigFiles); wConfigFiles.table.addListener(SWT.Selection, this::setButtonStates); - Button wbNew = new Button(shell, SWT.PUSH); - PropsUi.setLook(wbNew); - wbNew.setText(BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Button.New")); - FormData fdNew = new FormData(); - fdNew.left = new FormAttachment(wConfigFiles, 2 * margin); - fdNew.right = new FormAttachment(100, 0); - fdNew.top = new FormAttachment(wbSelect, margin); - wbNew.setLayoutData(fdNew); - wbNew.addListener(SWT.Selection, this::newConfigFile); - - wbEdit = new Button(shell, SWT.PUSH); - PropsUi.setLook(wbEdit); - wbEdit.setText(BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Button.Edit")); - FormData fdEdit = new FormData(); - fdEdit.left = new FormAttachment(wConfigFiles, 2 * margin); - fdEdit.right = new FormAttachment(100, 0); - fdEdit.top = new FormAttachment(wbNew, margin); - wbEdit.setLayoutData(fdEdit); - wbEdit.addListener(SWT.Selection, this::editConfigFile); - getData(); wName.setFocus(); @@ -392,6 +411,189 @@ private void setButtonStates(Event event) { wbEdit.setGrayed(index < 0); } + /** + * Crawl the project for {@code ${VARIABLE}} expressions not yet defined in environment config + * files, review them, and write into a new or existing configuration file. + */ + private void importVariables(Event event) { + shell.setCursor(shell.getDisplay().getSystemCursor(SWT.CURSOR_WAIT)); + try { + String projectName = wProject.getText(); + if (StringUtils.isEmpty(projectName)) { + MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_ERROR); + box.setText( + BaseMessages.getString( + PKG, "LifecycleEnvironmentDialog.ImportVariables.ProjectRequired.Title")); + box.setMessage( + BaseMessages.getString( + PKG, "LifecycleEnvironmentDialog.ImportVariables.ProjectRequired.Message")); + box.open(); + return; + } + + ProjectConfig projectConfig = + ProjectsConfigSingleton.getConfig().findProjectConfig(projectName); + if (projectConfig == null) { + throw new HopException( + "Project '" + projectName + "' is not configured in Hop (projects config)."); + } + + List configurationFiles = new ArrayList<>(); + for (TableItem item : wConfigFiles.getNonEmptyItems()) { + configurationFiles.add(item.getText(1)); + } + + List proposed = + EnvironmentVariablesImportHelper.findMissingVariables( + projectConfig, variables, configurationFiles, wName.getText()); + + // Restore the pointer before modal dialogs so the user can interact normally. + shell.setCursor(null); + + if (proposed.isEmpty()) { + MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION); + box.setText( + BaseMessages.getString( + PKG, "LifecycleEnvironmentDialog.ImportVariables.NoneFound.Title")); + box.setMessage( + BaseMessages.getString( + PKG, "LifecycleEnvironmentDialog.ImportVariables.NoneFound.Message")); + box.open(); + return; + } + + HopDescribedVariablesDialog variablesDialog = + new HopDescribedVariablesDialog( + shell, + BaseMessages.getString( + PKG, "LifecycleEnvironmentDialog.ImportVariables.DialogMessage", projectName), + proposed, + null); + List confirmed = variablesDialog.open(); + if (confirmed == null) { + return; + } + + String configFilename = chooseConfigFileForImport(configurationFiles, projectName); + if (StringUtils.isEmpty(configFilename)) { + return; + } + + shell.setCursor(shell.getDisplay().getSystemCursor(SWT.CURSOR_WAIT)); + String realConfigFilename = variables.resolve(configFilename); + EnvironmentVariablesImportHelper.saveVariablesToConfigFile(realConfigFilename, confirmed); + shell.setCursor(null); + + // Ensure the path is listed on the environment + boolean alreadyListed = false; + for (TableItem item : wConfigFiles.getNonEmptyItems()) { + if (configFilename.equals(item.getText(1)) + || realConfigFilename.equals(variables.resolve(item.getText(1)))) { + alreadyListed = true; + break; + } + } + if (!alreadyListed) { + TableItem item = new TableItem(wConfigFiles.table, SWT.NONE); + item.setText(1, configFilename); + wConfigFiles.removeEmptyRows(); + wConfigFiles.setRowNums(); + wConfigFiles.optWidth(true); + wConfigFiles.table.setSelection(item); + } + + needingEnvironmentRefresh = true; + + MessageBox done = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION); + done.setText( + BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.ImportVariables.Saved.Title")); + done.setMessage( + BaseMessages.getString( + PKG, + "LifecycleEnvironmentDialog.ImportVariables.Saved.Message", + Integer.toString(confirmed.size()), + realConfigFilename)); + done.open(); + } catch (Exception e) { + new ErrorDialog( + shell, + BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.ImportVariables.Error.Title"), + BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.ImportVariables.Error.Message"), + e); + } finally { + if (shell != null && !shell.isDisposed()) { + shell.setCursor(null); + } + } + } + + /** + * Pick an existing configuration file from the environment list, or create a new one. + * + * @return selected/created path (may contain variables), or null if cancelled + */ + private String chooseConfigFileForImport(List configurationFiles, String projectName) + throws Exception { + String createNewLabel = + BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.ImportVariables.CreateNewOption"); + + if (configurationFiles != null && !configurationFiles.isEmpty()) { + List choices = new ArrayList<>(configurationFiles); + choices.add(createNewLabel); + EnterSelectionDialog selectionDialog = + new EnterSelectionDialog( + shell, + choices.toArray(new String[0]), + BaseMessages.getString( + PKG, "LifecycleEnvironmentDialog.ImportVariables.SelectConfig.Title"), + BaseMessages.getString( + PKG, "LifecycleEnvironmentDialog.ImportVariables.SelectConfig.Message")); + selectionDialog.setAvoidQuickSearch(); + String selected = selectionDialog.open(); + if (selected == null) { + return null; + } + if (!createNewLabel.equals(selected)) { + return selected; + } + } + + return promptNewConfigFilename(projectName); + } + + private String promptNewConfigFilename(String projectName) throws Exception { + String environmentName = Const.NVL(wName.getText(), projectName); + String defaultName = EnvironmentVariablesImportHelper.defaultConfigFilename(environmentName); + + FileObject startFile; + ProjectConfig projectConfig = + ProjectsConfigSingleton.getConfig().findProjectConfig(projectName); + if (projectConfig != null) { + String filename = + projectConfig.getProjectHome() + + Const.FILE_SEPARATOR + + ".." + + Const.FILE_SEPARATOR + + defaultName; + startFile = HopVfs.getFileObject(variables.resolve(filename)); + } else { + startFile = HopVfs.getFileObject(defaultName); + } + + return BaseDialog.presentFileDialog( + true, + shell, + null, + variables, + startFile, + new String[] {"*.json", "*"}, + new String[] { + BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.ImportVariables.FileFilter.Json"), + BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.ImportVariables.FileFilter.All") + }, + true); + } + private void ok() { try { diff --git a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/environment/messages/messages_en_US.properties b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/environment/messages/messages_en_US.properties index 9a4aaa48778..4e571961c90 100644 --- a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/environment/messages/messages_en_US.properties +++ b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/environment/messages/messages_en_US.properties @@ -17,6 +17,7 @@ # LifecycleEnvironmentDialog.Button.Edit=Edit... +LifecycleEnvironmentDialog.Button.ImportVariables=Import variables LifecycleEnvironmentDialog.Button.New=New... LifecycleEnvironmentDialog.Button.Select=Select... LifecycleEnvironmentDialog.DetailTable.Label.Filename=Filename @@ -33,3 +34,17 @@ LifecycleEnvironmentDialog.Purpose.Text.Development=Development LifecycleEnvironmentDialog.Purpose.Text.Production=Production LifecycleEnvironmentDialog.Purpose.Text.Testing=Testing LifecycleEnvironmentDialog.Shell.Name=Environment Properties +LifecycleEnvironmentDialog.ImportVariables.ProjectRequired.Title=Project required +LifecycleEnvironmentDialog.ImportVariables.ProjectRequired.Message=Please select the project associated with this environment before importing variables. +LifecycleEnvironmentDialog.ImportVariables.NoneFound.Title=No variables to import +LifecycleEnvironmentDialog.ImportVariables.NoneFound.Message=No ${VARIABLE} expressions were found in the project that are not already defined in the environment configuration files. +LifecycleEnvironmentDialog.ImportVariables.DialogMessage=Variables used in project ''{0}'' that are not yet in an environment configuration file. Set values as needed, then OK to save. +LifecycleEnvironmentDialog.ImportVariables.SelectConfig.Title=Select configuration file +LifecycleEnvironmentDialog.ImportVariables.SelectConfig.Message=Choose an existing environment configuration file, or create a new one. +LifecycleEnvironmentDialog.ImportVariables.CreateNewOption=(Create new configuration file\u2026) +LifecycleEnvironmentDialog.ImportVariables.FileFilter.Json=Config JSON files +LifecycleEnvironmentDialog.ImportVariables.FileFilter.All=All files +LifecycleEnvironmentDialog.ImportVariables.Saved.Title=Variables saved +LifecycleEnvironmentDialog.ImportVariables.Saved.Message={0} variable(s) were saved to configuration file:\n{1} +LifecycleEnvironmentDialog.ImportVariables.Error.Title=Error +LifecycleEnvironmentDialog.ImportVariables.Error.Message=Error importing variables from the project diff --git a/plugins/misc/projects/src/test/java/org/apache/hop/projects/environment/EnvironmentVariablesImportHelperTest.java b/plugins/misc/projects/src/test/java/org/apache/hop/projects/environment/EnvironmentVariablesImportHelperTest.java new file mode 100644 index 00000000000..c5a9c2366c8 --- /dev/null +++ b/plugins/misc/projects/src/test/java/org/apache/hop/projects/environment/EnvironmentVariablesImportHelperTest.java @@ -0,0 +1,193 @@ +/* + * 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.environment; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; +import org.apache.hop.core.Const; +import org.apache.hop.core.config.DescribedVariablesConfigFile; +import org.apache.hop.core.search.ISearchResult; +import org.apache.hop.core.search.ISearchable; +import org.apache.hop.core.search.ISearchableCallback; +import org.apache.hop.core.search.SearchResult; +import org.apache.hop.core.variables.DescribedVariable; +import org.apache.hop.core.variables.Variables; +import org.apache.hop.projects.util.ProjectsUtil; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class EnvironmentVariablesImportHelperTest { + + @TempDir Path tempDir; + + @Test + void extractVariableNamesUnixOnly() { + String text = + "host=${DB_HOST} port=%%DB_PORT%% url=#{resolver:x} path=${PROJECT_HOME}/data nested=${A}${B}"; + Set names = EnvironmentVariablesImportHelper.extractVariableNames(text); + assertEquals(Set.of("DB_HOST", "PROJECT_HOME", "A", "B"), names); + assertFalse(names.contains("DB_PORT")); + assertFalse(names.contains("resolver:x")); + } + + @Test + void extractVariableNamesEmpty() { + assertTrue(EnvironmentVariablesImportHelper.extractVariableNames(null).isEmpty()); + assertTrue(EnvironmentVariablesImportHelper.extractVariableNames("").isEmpty()); + assertTrue( + EnvironmentVariablesImportHelper.extractVariableNames("no variables here").isEmpty()); + } + + @Test + void variableExpressionRegexMatchesPropertyValues() { + Pattern pattern = Pattern.compile(EnvironmentVariablesImportHelper.VARIABLE_EXPRESSION_REGEX); + assertTrue(pattern.matcher("jdbc:postgresql://${DB_HOST}:5432/db").find()); + assertTrue(pattern.matcher("${ONLY}").find()); + assertFalse(pattern.matcher("%%WINDOWS%%").find()); + assertFalse(pattern.matcher("#{resolver:x}").find()); + assertFalse(pattern.matcher("plain text").find()); + } + + @Test + void isManagedVariable() { + assertTrue( + EnvironmentVariablesImportHelper.isManagedVariable(ProjectsUtil.VARIABLE_PROJECT_HOME)); + assertTrue(EnvironmentVariablesImportHelper.isManagedVariable(Const.HOP_METADATA_FOLDER)); + assertTrue( + EnvironmentVariablesImportHelper.isManagedVariable( + Const.INTERNAL_VARIABLE_PIPELINE_FILENAME_DIRECTORY)); + assertTrue( + EnvironmentVariablesImportHelper.isManagedVariable( + EnvironmentVariablesImportHelper.JAVA_IO_TMPDIR)); + assertTrue(EnvironmentVariablesImportHelper.isManagedVariable("")); + assertFalse(EnvironmentVariablesImportHelper.isManagedVariable("DB_HOSTNAME")); + } + + @Test + void formatFoundLocationIncludesTypeNameAndField() { + ISearchable searchable = + new ISearchable<>() { + @Override + public String getLocation() { + return "project"; + } + + @Override + public String getName() { + return "load-data"; + } + + @Override + public String getType() { + return "Pipeline"; + } + + @Override + public String getFilename() { + return "/p/load-data.hpl"; + } + + @Override + public String getSearchableObject() { + return "meta"; + } + + @Override + public ISearchableCallback getSearchCallback() { + return null; + } + }; + ISearchResult result = + new SearchResult( + searchable, "${DB_HOST}", "hostname : host name", "Table input", "${DB_HOST}"); + assertEquals( + "Pipeline 'load-data' → Table input → host name", + EnvironmentVariablesImportHelper.formatFoundLocation(result)); + } + + @Test + void formatLocationsDescriptionJoinsAndCaps() { + assertEquals("", EnvironmentVariablesImportHelper.formatLocationsDescription(null)); + assertEquals( + "a; b", + EnvironmentVariablesImportHelper.formatLocationsDescription( + new LinkedHashSet<>(List.of("a", "b")))); + + Set many = new LinkedHashSet<>(); + for (int i = 1; i <= 12; i++) { + many.add("loc" + i); + } + String description = EnvironmentVariablesImportHelper.formatLocationsDescription(many); + assertTrue(description.startsWith("loc1; ")); + assertTrue(description.endsWith("(+2 more)")); + } + + @Test + void defaultConfigFilename() { + assertEquals("dev-config.json", EnvironmentVariablesImportHelper.defaultConfigFilename("dev")); + assertEquals( + "my-env-config.json", EnvironmentVariablesImportHelper.defaultConfigFilename("My Env")); + assertEquals( + "environment-config.json", EnvironmentVariablesImportHelper.defaultConfigFilename("")); + } + + @Test + void loadConfiguredVariableNamesAndSaveMerge() throws Exception { + Path configPath = tempDir.resolve("env-config.json"); + DescribedVariablesConfigFile created = new DescribedVariablesConfigFile(configPath.toString()); + created.setDescribedVariable(new DescribedVariable("EXISTING", "one", "already there")); + created.saveToFile(); + + Variables variables = new Variables(); + Set names = + EnvironmentVariablesImportHelper.loadConfiguredVariableNames( + List.of(configPath.toString()), variables); + assertTrue(names.contains("EXISTING")); + assertEquals(1, names.size()); + + List toAdd = new ArrayList<>(); + toAdd.add(new DescribedVariable("NEW_VAR", "two", "imported")); + toAdd.add(new DescribedVariable("EXISTING", "updated", "overwritten")); + EnvironmentVariablesImportHelper.saveVariablesToConfigFile(configPath.toString(), toAdd); + + DescribedVariablesConfigFile reloaded = new DescribedVariablesConfigFile(configPath.toString()); + reloaded.readFromFile(); + assertEquals("updated", reloaded.findDescribedVariableValue("EXISTING")); + assertEquals("two", reloaded.findDescribedVariableValue("NEW_VAR")); + assertEquals(2, reloaded.getDescribedVariables().size()); + assertTrue(Files.size(configPath) > 0); + } + + @Test + void loadConfiguredVariableNamesSkipsMissingFiles() throws Exception { + Variables variables = new Variables(); + Set names = + EnvironmentVariablesImportHelper.loadConfiguredVariableNames( + List.of(tempDir.resolve("does-not-exist.json").toString()), variables); + assertTrue(names.isEmpty()); + } +} From 16a6d7805a9c4d79915093df7e804d22853b1789 Mon Sep 17 00:00:00 2001 From: mattcasters Date: Sun, 19 Jul 2026 14:18:47 +0200 Subject: [PATCH 12/15] issue #2597 : replace browsed paths with matching env/project path variables Expand HopGui file/directory browse replacement beyond PROJECT_HOME so the longest matching path-like variable from the active project or environment configuration is rewritten (e.g. SOURCE_FILES). --- .../pages/projects/projects-environments.adoc | 2 + .../projects/util/PathVariableReplacer.java | 238 ++++++++++++++++++ .../HopGuiDirectoryReplaceHomeVariable.java | 45 +--- .../xp/HopGuiFileReplaceHomeVariable.java | 64 +++-- .../util/PathVariableReplacerTest.java | 171 +++++++++++++ 5 files changed, 453 insertions(+), 67 deletions(-) create mode 100644 plugins/misc/projects/src/main/java/org/apache/hop/projects/util/PathVariableReplacer.java create mode 100644 plugins/misc/projects/src/test/java/org/apache/hop/projects/util/PathVariableReplacerTest.java diff --git a/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc b/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc index 5ff70d0d60e..1da0e5ea46c 100644 --- a/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc +++ b/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc @@ -89,6 +89,8 @@ The edit button opens the environment file and lets you add variable key-value p image::hop-gui/environment/environment-variables.png[Environment Variables, width="80%"] +When you browse for a file or directory in Hop Gui with a project active, paths under a matching path-like variable are rewritten automatically. For example, `{openvar}PROJECT_HOME{closevar}` is used for files under the project home, and an environment variable such as `SOURCE_FILES=/data/incoming` rewrites a selection under that folder to `{openvar}SOURCE_FILES{closevar}/…`. If more than one variable matches, the longest (most specific) path wins. The same applies to path variables defined on the project itself. + After creating an environment the user interface will switch to it. diff --git a/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/PathVariableReplacer.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/PathVariableReplacer.java new file mode 100644 index 00000000000..52e7e3f13ba --- /dev/null +++ b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/PathVariableReplacer.java @@ -0,0 +1,238 @@ +/* + * 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.Locale; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.vfs2.FileObject; +import org.apache.hop.core.Const; +import org.apache.hop.core.variables.IVariables; +import org.apache.hop.core.vfs.HopVfs; + +/** + * Rewrites absolute paths to use the most specific matching path-like variable from the active + * variable space (for example {@code ${PROJECT_HOME}/file.csv} or {@code + * ${SOURCE_FILES}/input.csv}). + * + *

Used by Hop GUI file/directory browse extension points as a best-practice aid. + */ +public final class PathVariableReplacer { + + private static final Set SYSTEM_NAME_PREFIXES = + Set.of( + "java.", "sun.", "user.", "os.", "file.", "jdk.", "http.", "awt.", "path.", "line.", + "ftp."); + + private static final Set NON_PATH_VARIABLE_NAMES = + Set.of( + Defaults.VARIABLE_HOP_PROJECT_NAME, + Defaults.VARIABLE_HOP_ENVIRONMENT_NAME, + ProjectsUtil.VARIABLE_PARENT_PROJECT_NAME); + + private PathVariableReplacer() { + // Utility class + } + + /** + * If {@code path} is under one or more path-like variable values in {@code variables}, rewrite + * the longest matching prefix to {@code ${NAME}} or {@code ${NAME}/remainder}. + * + * @param variables active variables (may be null) + * @param path selected file or directory path (may be null/empty) + * @return rewritten path, or the original path when no replacement applies + */ + public static String replacePathWithVariable(IVariables variables, String path) { + if (variables == null || StringUtils.isEmpty(path)) { + return path; + } + + try { + String absolutePath = normalizeAbsolutePath(path); + if (StringUtils.isEmpty(absolutePath)) { + return path; + } + + PathMatch best = null; + for (String name : variables.getVariableNames()) { + if (!isCandidateVariableName(name)) { + continue; + } + String rawValue = variables.getVariable(name); + if (StringUtils.isEmpty(rawValue)) { + continue; + } + String resolved = variables.resolve(rawValue); + if (!isCandidatePathValue(resolved)) { + continue; + } + + String absoluteRoot; + try { + absoluteRoot = normalizeAbsolutePath(resolved); + } catch (Exception e) { + continue; + } + if (!isUsablePathRoot(absoluteRoot)) { + continue; + } + + if (absolutePath.equals(absoluteRoot)) { + PathMatch match = new PathMatch(name, absoluteRoot, ""); + best = betterMatch(best, match); + } else if (absolutePath.startsWith(absoluteRoot + "/")) { + String remainder = absolutePath.substring(absoluteRoot.length() + 1); + PathMatch match = new PathMatch(name, absoluteRoot, remainder); + best = betterMatch(best, match); + } + } + + if (best == null) { + return path; + } + if (StringUtils.isEmpty(best.remainder)) { + return "${" + best.variableName + "}"; + } + return "${" + best.variableName + "}/" + best.remainder; + } catch (Exception e) { + return path; + } + } + + /** + * Whether a variable name is eligible to be used as a path replacement root. + * + * @param name variable name + * @return true if the name may be considered + */ + public static boolean isCandidateVariableName(String name) { + if (StringUtils.isBlank(name)) { + return false; + } + if (name.startsWith(Const.INTERNAL_VARIABLE_PREFIX)) { + return false; + } + if (NON_PATH_VARIABLE_NAMES.contains(name)) { + return false; + } + String lower = name.toLowerCase(Locale.ROOT); + for (String prefix : SYSTEM_NAME_PREFIXES) { + if (lower.startsWith(prefix)) { + return false; + } + } + return true; + } + + /** + * Whether a resolved variable value looks like a usable path root before VFS normalization. + * + * @param value resolved variable value + * @return true if the value may be a path + */ + public static boolean isCandidatePathValue(String value) { + if (StringUtils.isBlank(value)) { + return false; + } + // Incomplete resolution or multi-folder lists (e.g. HOP_METADATA_FOLDER parent,child) + if (value.contains("${") || value.contains(",")) { + return false; + } + // Absolute local path, Windows drive path, UNC, or VFS URI + if (value.startsWith("/") || value.startsWith("\\")) { + return true; + } + if (value.length() >= 3 + && Character.isLetter(value.charAt(0)) + && value.charAt(1) == ':' + && (value.charAt(2) == '/' || value.charAt(2) == '\\')) { + return true; + } + // VFS / URL style: scheme:// + int schemeSep = value.indexOf("://"); + if (schemeSep > 0) { + return true; + } + // Relative paths and bare names are not used as replacement roots + return false; + } + + static boolean isUsablePathRoot(String absoluteRoot) { + if (StringUtils.isEmpty(absoluteRoot)) { + return false; + } + // Too broad: filesystem root only + if ("/".equals(absoluteRoot) || absoluteRoot.matches("^[A-Za-z]:$")) { + return false; + } + // Windows root like C:/ or C:\ + if (absoluteRoot.matches("^[A-Za-z]:[\\\\/]$")) { + return false; + } + return true; + } + + static String normalizeAbsolutePath(String path) throws Exception { + FileObject file = HopVfs.getFileObject(path); + String absolute = file.getName().getPath(); + // Strip trailing slash except for root + while (absolute.length() > 1 && absolute.endsWith("/")) { + absolute = absolute.substring(0, absolute.length() - 1); + } + return absolute; + } + + private static PathMatch betterMatch(PathMatch current, PathMatch candidate) { + if (current == null) { + return candidate; + } + int lengthCompare = Integer.compare(candidate.rootLength(), current.rootLength()); + if (lengthCompare > 0) { + return candidate; + } + if (lengthCompare < 0) { + return current; + } + // Equal length: prefer PROJECT_HOME, then stable name order + if (ProjectsUtil.VARIABLE_PROJECT_HOME.equals(candidate.variableName) + && !ProjectsUtil.VARIABLE_PROJECT_HOME.equals(current.variableName)) { + return candidate; + } + if (ProjectsUtil.VARIABLE_PROJECT_HOME.equals(current.variableName) + && !ProjectsUtil.VARIABLE_PROJECT_HOME.equals(candidate.variableName)) { + return current; + } + return candidate.variableName.compareTo(current.variableName) < 0 ? candidate : current; + } + + private static final class PathMatch { + final String variableName; + final String absoluteRoot; + final String remainder; + + PathMatch(String variableName, String absoluteRoot, String remainder) { + this.variableName = variableName; + this.absoluteRoot = absoluteRoot; + this.remainder = remainder; + } + + int rootLength() { + return absoluteRoot.length(); + } + } +} diff --git a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopGuiDirectoryReplaceHomeVariable.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopGuiDirectoryReplaceHomeVariable.java index 282520a36e5..53ca0a95489 100644 --- a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopGuiDirectoryReplaceHomeVariable.java +++ b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopGuiDirectoryReplaceHomeVariable.java @@ -17,25 +17,24 @@ package org.apache.hop.projects.xp; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.vfs2.FileObject; import org.apache.hop.core.extension.ExtensionPoint; import org.apache.hop.core.extension.IExtensionPoint; import org.apache.hop.core.logging.ILogChannel; import org.apache.hop.core.util.StringUtil; import org.apache.hop.core.variables.IVariables; -import org.apache.hop.core.vfs.HopVfs; import org.apache.hop.projects.config.ProjectsConfig; import org.apache.hop.projects.config.ProjectsConfigSingleton; import org.apache.hop.projects.project.ProjectConfig; -import org.apache.hop.projects.util.ProjectsUtil; +import org.apache.hop.projects.util.PathVariableReplacer; import org.apache.hop.ui.core.gui.HopNamespace; import org.apache.hop.ui.hopgui.delegates.HopGuiDirectorySelectedExtension; @ExtensionPoint( id = "HopGuiDirectoryReplaceHomeVariable", extensionPointId = "HopGuiDirectorySelected", - description = "Replace ${PROJECT_HOME} in selected directory as a best practice aid") + description = + "Replace path-like project/environment variables (e.g. ${PROJECT_HOME}, ${SOURCE_FILES})" + + " in selected directory as a best practice aid") public class HopGuiDirectoryReplaceHomeVariable implements IExtensionPoint { @@ -56,39 +55,17 @@ public void callExtensionPoint( if (projectConfig == null) { return; } - String homeFolder; - - if (variables != null) { - homeFolder = variables.resolve(projectConfig.getProjectHome()); - } else { - homeFolder = projectConfig.getProjectHome(); - } try { - if (StringUtils.isNotEmpty(homeFolder)) { - - FileObject file = HopVfs.getFileObject(ext.folderName); - String absoluteFile = file.getName().getPath(); - - FileObject home = HopVfs.getFileObject(homeFolder); - String absoluteHome = home.getName().getPath(); - // Make the URI always end with a / - if (!absoluteHome.endsWith("/")) { - absoluteHome += "/"; - } - - // Replace the project home variable in the filename - // - if (absoluteFile.startsWith(absoluteHome)) { - ext.folderName = - "${" - + ProjectsUtil.VARIABLE_PROJECT_HOME - + "}/" - + absoluteFile.substring(absoluteHome.length()); - } + IVariables vars = + HopGuiFileReplaceHomeVariable.ensureProjectHomeVariable( + variables, projectConfig, ext.variables); + String replaced = PathVariableReplacer.replacePathWithVariable(vars, ext.folderName); + if (replaced != null) { + ext.folderName = replaced; } } catch (Exception e) { - log.logError("Error setting default folder for project " + projectName, e); + log.logError("Error replacing path variables for project " + projectName, e); } } } diff --git a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopGuiFileReplaceHomeVariable.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopGuiFileReplaceHomeVariable.java index 8ccd78e4a89..2c1932c82dc 100644 --- a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopGuiFileReplaceHomeVariable.java +++ b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopGuiFileReplaceHomeVariable.java @@ -17,17 +17,16 @@ package org.apache.hop.projects.xp; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.vfs2.FileObject; import org.apache.hop.core.extension.ExtensionPoint; import org.apache.hop.core.extension.IExtensionPoint; import org.apache.hop.core.logging.ILogChannel; import org.apache.hop.core.util.StringUtil; import org.apache.hop.core.variables.IVariables; -import org.apache.hop.core.vfs.HopVfs; +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.project.ProjectConfig; +import org.apache.hop.projects.util.PathVariableReplacer; import org.apache.hop.projects.util.ProjectsUtil; import org.apache.hop.ui.core.gui.HopNamespace; import org.apache.hop.ui.hopgui.delegates.HopGuiFileOpenedExtension; @@ -35,7 +34,9 @@ @ExtensionPoint( id = "HopGuiFileReplaceHomeVariable", extensionPointId = "HopGuiFileOpenedDialog", - description = "Replace ${PROJECT_HOME} in selected filenames as a best practice aid") + description = + "Replace path-like project/environment variables (e.g. ${PROJECT_HOME}, ${SOURCE_FILES})" + + " in selected filenames as a best practice aid") public class HopGuiFileReplaceHomeVariable implements IExtensionPoint { // TODO make this optional @@ -55,39 +56,36 @@ public void callExtensionPoint( if (projectConfig == null) { return; } - String homeFolder; - - if (variables != null) { - homeFolder = variables.resolve(projectConfig.getProjectHome()); - } else { - homeFolder = projectConfig.getProjectHome(); - } try { - if (StringUtils.isNotEmpty(homeFolder)) { - - FileObject file = HopVfs.getFileObject(ext.filename); - String absoluteFile = file.getName().getPath(); - - FileObject home = HopVfs.getFileObject(homeFolder); - String absoluteHome = home.getName().getPath(); - // Make the URI always end with a / - if (!absoluteHome.endsWith("/")) { - absoluteHome += "/"; - } - - // Replace the project home variable in the filename - // - if (absoluteFile.startsWith(absoluteHome)) { - ext.filename = - "${" - + ProjectsUtil.VARIABLE_PROJECT_HOME - + "}/" - + absoluteFile.substring(absoluteHome.length()); - } + IVariables vars = ensureProjectHomeVariable(variables, projectConfig, ext.variables); + String replaced = PathVariableReplacer.replacePathWithVariable(vars, ext.filename); + if (replaced != null) { + ext.filename = replaced; } } catch (Exception e) { - log.logError("Error setting default folder for project " + projectName, e); + log.logError("Error replacing path variables for project " + projectName, e); + } + } + + /** + * Ensure {@code PROJECT_HOME} is available even when the dialog passed a null variable space. + * Prefer the extension's own variables when present. + */ + static IVariables ensureProjectHomeVariable( + IVariables callVariables, ProjectConfig projectConfig, IVariables extensionVariables) { + IVariables vars = extensionVariables != null ? extensionVariables : callVariables; + if (vars == null) { + vars = new Variables(); + vars.initializeFrom(null); + } + // Expose PROJECT_HOME from the active project config when missing so replacement works + // even if the dialog variable space is incomplete. + if (StringUtil.isEmpty(vars.getVariable(ProjectsUtil.VARIABLE_PROJECT_HOME)) + && projectConfig.getProjectHome() != null) { + vars.setVariable( + ProjectsUtil.VARIABLE_PROJECT_HOME, vars.resolve(projectConfig.getProjectHome())); } + return vars; } } diff --git a/plugins/misc/projects/src/test/java/org/apache/hop/projects/util/PathVariableReplacerTest.java b/plugins/misc/projects/src/test/java/org/apache/hop/projects/util/PathVariableReplacerTest.java new file mode 100644 index 00000000000..f917843086a --- /dev/null +++ b/plugins/misc/projects/src/test/java/org/apache/hop/projects/util/PathVariableReplacerTest.java @@ -0,0 +1,171 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.apache.hop.core.Const; +import org.apache.hop.core.variables.Variables; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PathVariableReplacerTest { + + @TempDir Path tempDir; + + private Path projectHome; + private Path sourceFiles; + private Variables variables; + + @BeforeEach + void setUp() throws Exception { + projectHome = tempDir.resolve("project"); + sourceFiles = tempDir.resolve("source"); + Files.createDirectories(projectHome.resolve("data")); + Files.createDirectories(sourceFiles.resolve("input")); + Files.writeString(projectHome.resolve("file.csv"), "a"); + Files.writeString(sourceFiles.resolve("input").resolve("data.csv"), "b"); + + variables = new Variables(); + // Avoid loading full system property set into candidates for most tests: + // set only the variables we care about without initializeFrom(). + variables.setVariable(ProjectsUtil.VARIABLE_PROJECT_HOME, projectHome.toString()); + variables.setVariable("SOURCE_FILES", sourceFiles.toString()); + } + + @Test + void replacesProjectHomeChildPath() { + String selected = projectHome.resolve("file.csv").toString(); + assertEquals( + "${PROJECT_HOME}/file.csv", + PathVariableReplacer.replacePathWithVariable(variables, selected)); + } + + @Test + void replacesProjectHomeNestedPath() { + String selected = projectHome.resolve("data").resolve("x.txt").toString(); + // create nested file path (dir already exists) + assertEquals( + "${PROJECT_HOME}/data/x.txt", + PathVariableReplacer.replacePathWithVariable(variables, selected)); + } + + @Test + void replacesEnvironmentVariableOutsideProject() { + String selected = sourceFiles.resolve("input").resolve("data.csv").toString(); + assertEquals( + "${SOURCE_FILES}/input/data.csv", + PathVariableReplacer.replacePathWithVariable(variables, selected)); + } + + @Test + void longestPrefixWinsOverProjectHome() throws Exception { + Path nested = projectHome.resolve("data"); + variables.setVariable("DATA_FOLDER", nested.toString()); + String selected = nested.resolve("report.csv").toString(); + assertEquals( + "${DATA_FOLDER}/report.csv", + PathVariableReplacer.replacePathWithVariable(variables, selected)); + } + + @Test + void exactDirectoryMatchUsesVariableOnly() { + assertEquals( + "${PROJECT_HOME}", + PathVariableReplacer.replacePathWithVariable(variables, projectHome.toString())); + assertEquals( + "${SOURCE_FILES}", + PathVariableReplacer.replacePathWithVariable(variables, sourceFiles.toString())); + } + + @Test + void noMatchLeavesPathUnchanged() { + Path outside = tempDir.resolve("other").resolve("file.txt"); + String selected = outside.toString(); + assertEquals(selected, PathVariableReplacer.replacePathWithVariable(variables, selected)); + } + + @Test + void nullOrEmptyInputsUnchanged() { + assertEquals("/tmp/x", PathVariableReplacer.replacePathWithVariable(null, "/tmp/x")); + assertEquals(null, PathVariableReplacer.replacePathWithVariable(variables, null)); + assertEquals("", PathVariableReplacer.replacePathWithVariable(variables, "")); + } + + @Test + void ignoresSystemPropertiesAsCandidates() { + Variables withSystem = new Variables(); + withSystem.setVariable(ProjectsUtil.VARIABLE_PROJECT_HOME, projectHome.toString()); + withSystem.setVariable("java.io.tmpdir", tempDir.toString()); + withSystem.setVariable("user.home", tempDir.toString()); + + // Path under tempDir (where java.io.tmpdir points) but not under project home + Path other = tempDir.resolve("not-in-project"); + String selected = other.toString(); + assertEquals(selected, PathVariableReplacer.replacePathWithVariable(withSystem, selected)); + } + + @Test + void ignoresNonPathManagedNames() { + assertFalse(PathVariableReplacer.isCandidateVariableName(Defaults.VARIABLE_HOP_PROJECT_NAME)); + assertFalse( + PathVariableReplacer.isCandidateVariableName(Defaults.VARIABLE_HOP_ENVIRONMENT_NAME)); + assertFalse( + PathVariableReplacer.isCandidateVariableName(ProjectsUtil.VARIABLE_PARENT_PROJECT_NAME)); + assertFalse( + PathVariableReplacer.isCandidateVariableName( + Const.INTERNAL_VARIABLE_PIPELINE_FILENAME_DIRECTORY)); + assertTrue(PathVariableReplacer.isCandidateVariableName(ProjectsUtil.VARIABLE_PROJECT_HOME)); + assertTrue(PathVariableReplacer.isCandidateVariableName("SOURCE_FILES")); + } + + @Test + void ignoresNonPathValues() { + assertFalse(PathVariableReplacer.isCandidatePathValue("")); + assertFalse(PathVariableReplacer.isCandidatePathValue("localhost")); + assertFalse(PathVariableReplacer.isCandidatePathValue("${PROJECT_HOME}/data")); + assertFalse(PathVariableReplacer.isCandidatePathValue("/meta1,/meta2")); + assertTrue(PathVariableReplacer.isCandidatePathValue("/tmp/source")); + assertTrue(PathVariableReplacer.isCandidatePathValue("file:///tmp/source")); + assertTrue(PathVariableReplacer.isCandidatePathValue("C:\\data\\files")); + } + + @Test + void resolvesNestedVariableValuesBeforeMatching() throws Exception { + Path dataDir = projectHome.resolve("data"); + variables.setVariable("DATA_FOLDER", "${PROJECT_HOME}/data"); + String selected = dataDir.resolve("report.csv").toString(); + assertEquals( + "${DATA_FOLDER}/report.csv", + PathVariableReplacer.replacePathWithVariable(variables, selected)); + } + + @Test + void trailingSlashOnVariableValueStillMatches() { + variables.setVariable("SOURCE_FILES", sourceFiles.toString() + "/"); + String selected = sourceFiles.resolve("input").resolve("data.csv").toString(); + assertEquals( + "${SOURCE_FILES}/input/data.csv", + PathVariableReplacer.replacePathWithVariable(variables, selected)); + } +} From d76c472cd7a93a85950829e97386f242c798aa7d Mon Sep 17 00:00:00 2001 From: mattcasters Date: Sun, 19 Jul 2026 14:55:19 +0200 Subject: [PATCH 13/15] Issue #2735 : default new environment name from project and purpose When creating a lifecycle environment, pre-fill the name with the project name and append a purpose suffix (e.g. -development, -test) until the user edits the name manually. --- .../LifecycleEnvironmentDialog.java | 127 ++++++++++++-- .../LifecycleEnvironmentNaming.java | 154 +++++++++++++++++ .../LifecycleEnvironmentNamingTest.java | 155 ++++++++++++++++++ 3 files changed, 426 insertions(+), 10 deletions(-) create mode 100644 plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentNaming.java create mode 100644 plugins/misc/projects/src/test/java/org/apache/hop/projects/environment/LifecycleEnvironmentNamingTest.java diff --git a/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java index fb84dff5577..d70f99078d5 100644 --- a/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java +++ b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java @@ -19,6 +19,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; import org.apache.commons.lang3.StringUtils; import org.apache.commons.vfs2.FileObject; import org.apache.hop.core.Const; @@ -83,6 +84,17 @@ public class LifecycleEnvironmentDialog extends Dialog { private boolean needingEnvironmentRefresh; + /** Last name we auto-suggested (for detecting when the user edits away from it). */ + private String lastSuggestedName; + + /** When true, project/purpose changes rewrite the name field (new environments only). */ + private boolean nameAutoManaged; + + private boolean updatingSuggestedName; + + /** Localized purpose label → fixed English suffix (for new-environment name suggestion). */ + private Map knownPurposeSuffixes; + public LifecycleEnvironmentDialog( Shell parent, LifecycleEnvironment environment, IVariables variables) { super(parent, SWT.DIALOG_TRIM | SWT.APPLICATION_MODAL | SWT.RESIZE); @@ -95,6 +107,9 @@ public LifecycleEnvironmentDialog( props = PropsUi.getInstance(); needingEnvironmentRefresh = false; + lastSuggestedName = null; + nameAutoManaged = StringUtils.isEmpty(originalName); + updatingSuggestedName = false; } public String open() { @@ -146,6 +161,7 @@ public String open() { fdName.right = new FormAttachment(100, 0); fdName.top = new FormAttachment(wlName, 0, SWT.CENTER); wName.setLayoutData(fdName); + wName.addListener(SWT.Modify, e -> onNameModified()); Control lastControl = wName; Label wlPurpose = new Label(shell, SWT.RIGHT); @@ -164,7 +180,12 @@ public String open() { fdPurpose.right = new FormAttachment(100, 0); fdPurpose.top = new FormAttachment(wlPurpose, 0, SWT.CENTER); wPurpose.setLayoutData(fdPurpose); - wPurpose.addListener(SWT.Modify, e -> needingEnvironmentRefresh = true); + wPurpose.addListener( + SWT.Modify, + e -> { + needingEnvironmentRefresh = true; + updateSuggestedName(); + }); lastControl = wPurpose; Label wlProject = new Label(shell, SWT.RIGHT); @@ -183,7 +204,12 @@ public String open() { fdProject.right = new FormAttachment(100, 0); fdProject.top = new FormAttachment(wlProject, 0, SWT.CENTER); wProject.setLayoutData(fdProject); - wProject.addListener(SWT.Modify, e -> needingEnvironmentRefresh = true); + wProject.addListener( + SWT.Modify, + e -> { + needingEnvironmentRefresh = true; + updateSuggestedName(); + }); lastControl = wProject; Label wlCanvasText = new Label(shell, SWT.RIGHT); @@ -631,20 +657,54 @@ public void dispose() { private void getData() { ProjectsConfig config = ProjectsConfigSingleton.getConfig(); + String developmentLabel = + BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Purpose.Text.Development"); + String testingLabel = + BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Purpose.Text.Testing"); + String acceptanceLabel = + BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Purpose.Text.Acceptance"); + String productionLabel = + BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Purpose.Text.Production"); + String continuousIntegrationLabel = + BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Purpose.Text.CI"); + String commonBuildLabel = + BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Purpose.Text.CB"); + + knownPurposeSuffixes = + LifecycleEnvironmentNaming.knownPurposeSuffixes( + developmentLabel, + testingLabel, + acceptanceLabel, + productionLabel, + continuousIntegrationLabel, + commonBuildLabel); + wProject.setItems(config.listProjectConfigNames().toArray(new String[0])); wPurpose.setItems( - BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Purpose.Text.Development"), - BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Purpose.Text.Testing"), - BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Purpose.Text.Acceptance"), - BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Purpose.Text.Production"), - BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Purpose.Text.CI"), - BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Purpose.Text.CB")); - - wName.setText(Const.NVL(environment.getName(), "")); + developmentLabel, + testingLabel, + acceptanceLabel, + productionLabel, + continuousIntegrationLabel, + commonBuildLabel); + + // Setting project/purpose may fire Modify and update the suggested name for new envs. wPurpose.setText(Const.NVL(environment.getPurpose(), "")); wProject.setText(Const.NVL(environment.getProjectName(), "")); wCanvasText.setText(Const.NVL(environment.getCanvasText(), "")); + if (StringUtils.isNotEmpty(environment.getName())) { + // Provided name (edit or rare pre-fill): do not auto-overwrite. + nameAutoManaged = false; + setNameText(environment.getName()); + } else if (isNewEnvironment()) { + nameAutoManaged = true; + // Project/purpose listeners may already have suggested a name; ensure we do so if not. + updateSuggestedName(); + } else { + setNameText(""); + } + for (int i = 0; i < environment.getConfigurationFiles().size(); i++) { String configurationFile = environment.getConfigurationFiles().get(i); TableItem item = wConfigFiles.table.getItem(i); @@ -661,6 +721,53 @@ private void getData() { } } + private boolean isNewEnvironment() { + return StringUtils.isEmpty(originalName); + } + + /** + * When creating a new environment, keep the name field in sync with project + purpose until the + * user edits the name manually. + */ + private void updateSuggestedName() { + if (!isNewEnvironment() + || !nameAutoManaged + || wName == null + || wProject == null + || wPurpose == null) { + return; + } + + String suggested = + LifecycleEnvironmentNaming.suggestEnvironmentName( + wProject.getText(), wPurpose.getText(), knownPurposeSuffixes); + lastSuggestedName = suggested; + setNameText(suggested); + } + + private void setNameText(String text) { + updatingSuggestedName = true; + try { + wName.setText(Const.NVL(text, "")); + } finally { + updatingSuggestedName = false; + } + } + + private void onNameModified() { + if (updatingSuggestedName || !isNewEnvironment()) { + return; + } + String current = wName.getText(); + // Cleared field or still matching last suggestion → keep auto-managing. + if (StringUtils.isEmpty(current) + || (lastSuggestedName != null && current.equals(lastSuggestedName))) { + nameAutoManaged = true; + } else { + nameAutoManaged = false; + } + } + private void getInfo(LifecycleEnvironment env) { env.setName(wName.getText()); env.setPurpose(wPurpose.getText()); diff --git a/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentNaming.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentNaming.java new file mode 100644 index 00000000000..0337957b462 --- /dev/null +++ b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentNaming.java @@ -0,0 +1,154 @@ +/* + * 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.environment; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import org.apache.commons.lang3.StringUtils; + +/** + * Suggests default environment names from a project name and purpose. Used when creating a new + * environment so the name field can be pre-filled and updated as the user picks a purpose. + */ +public final class LifecycleEnvironmentNaming { + + /** Fixed English technical suffixes for standard purposes. */ + public static final String SUFFIX_DEVELOPMENT = "development"; + + public static final String SUFFIX_TEST = "test"; + public static final String SUFFIX_ACCEPTANCE = "acceptance"; + public static final String SUFFIX_PRODUCTION = "production"; + public static final String SUFFIX_CONTINUOUS_INTEGRATION = "continuous-integration"; + public static final String SUFFIX_COMMON_BUILD = "common-build"; + + private LifecycleEnvironmentNaming() { + // utility + } + + /** + * Builds a map from known purpose display labels (localized) to fixed English suffixes. + * + * @param developmentLabel localized "Development" (or equivalent) + * @param testingLabel localized "Testing" + * @param acceptanceLabel localized "Acceptance" + * @param productionLabel localized "Production" + * @param continuousIntegrationLabel localized "Continuous Integration" + * @param commonBuildLabel localized "Common Build" + * @return unmodifiable map of label → suffix + */ + public static Map knownPurposeSuffixes( + String developmentLabel, + String testingLabel, + String acceptanceLabel, + String productionLabel, + String continuousIntegrationLabel, + String commonBuildLabel) { + Map map = new LinkedHashMap<>(); + putIfNotBlank(map, developmentLabel, SUFFIX_DEVELOPMENT); + putIfNotBlank(map, testingLabel, SUFFIX_TEST); + putIfNotBlank(map, acceptanceLabel, SUFFIX_ACCEPTANCE); + putIfNotBlank(map, productionLabel, SUFFIX_PRODUCTION); + putIfNotBlank(map, continuousIntegrationLabel, SUFFIX_CONTINUOUS_INTEGRATION); + putIfNotBlank(map, commonBuildLabel, SUFFIX_COMMON_BUILD); + return Collections.unmodifiableMap(map); + } + + private static void putIfNotBlank(Map map, String label, String suffix) { + if (StringUtils.isNotBlank(label)) { + map.put(label, suffix); + } + } + + /** + * Maps a purpose string to a technical suffix for use in an environment name. + * + *

Known purpose labels (from {@code knownPurposeLabelsToSuffix}) map to fixed English + * suffixes. Free-text purposes are sanitized to a lowercase slug. + * + * @param purpose purpose combo text (may be localized label or free text) + * @param knownPurposeLabelsToSuffix map of known display labels → fixed suffixes + * @return suffix without leading dash, or empty string if purpose is blank + */ + public static String purposeToSuffix( + String purpose, Map knownPurposeLabelsToSuffix) { + if (StringUtils.isBlank(purpose)) { + return ""; + } + String trimmed = purpose.trim(); + if (knownPurposeLabelsToSuffix != null) { + String known = knownPurposeLabelsToSuffix.get(trimmed); + if (known != null) { + return known; + } + } + return sanitizePurposeSuffix(trimmed); + } + + /** + * Suggests an environment name from project and purpose suffix. + * + * @param projectName project name (required for a non-empty suggestion) + * @param purposeSuffix technical suffix without leading dash; empty means project only + * @return suggested name, or empty string if project is blank + */ + public static String suggestEnvironmentName(String projectName, String purposeSuffix) { + if (StringUtils.isBlank(projectName)) { + return ""; + } + String project = projectName.trim(); + if (StringUtils.isBlank(purposeSuffix)) { + return project; + } + return project + "-" + purposeSuffix.trim(); + } + + /** + * Suggests an environment name from project and purpose text. + * + * @param projectName project name + * @param purpose purpose combo text + * @param knownPurposeLabelsToSuffix map of known display labels → fixed suffixes + * @return suggested environment name + */ + public static String suggestEnvironmentName( + String projectName, String purpose, Map knownPurposeLabelsToSuffix) { + return suggestEnvironmentName( + projectName, purposeToSuffix(purpose, knownPurposeLabelsToSuffix)); + } + + /** + * Sanitizes free-text purpose into a stable technical suffix (lowercase, hyphenated). + * + * @param purpose free-text purpose + * @return sanitized suffix, or empty if nothing usable remains + */ + public static String sanitizePurposeSuffix(String purpose) { + if (StringUtils.isBlank(purpose)) { + return ""; + } + String sanitized = + purpose + .trim() + .toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9._-]+", "-") + .replaceAll("-+", "-"); + return StringUtils.strip(sanitized, "-"); + } +} diff --git a/plugins/misc/projects/src/test/java/org/apache/hop/projects/environment/LifecycleEnvironmentNamingTest.java b/plugins/misc/projects/src/test/java/org/apache/hop/projects/environment/LifecycleEnvironmentNamingTest.java new file mode 100644 index 00000000000..2a75c2940ed --- /dev/null +++ b/plugins/misc/projects/src/test/java/org/apache/hop/projects/environment/LifecycleEnvironmentNamingTest.java @@ -0,0 +1,155 @@ +/* + * 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.environment; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class LifecycleEnvironmentNamingTest { + + private Map knownSuffixes; + + @BeforeEach + void setUp() { + knownSuffixes = + LifecycleEnvironmentNaming.knownPurposeSuffixes( + "Development", + "Testing", + "Acceptance", + "Production", + "Continuous Integration", + "Common Build"); + } + + @Test + void suggestProjectOnlyWhenPurposeEmpty() { + assertEquals( + "my-project", + LifecycleEnvironmentNaming.suggestEnvironmentName("my-project", "", knownSuffixes)); + assertEquals( + "my-project", + LifecycleEnvironmentNaming.suggestEnvironmentName("my-project", null, knownSuffixes)); + assertEquals( + "my-project", + LifecycleEnvironmentNaming.suggestEnvironmentName("my-project", " ", knownSuffixes)); + } + + @Test + void suggestEmptyWhenProjectBlank() { + assertEquals( + "", LifecycleEnvironmentNaming.suggestEnvironmentName(null, "Development", knownSuffixes)); + assertEquals( + "", LifecycleEnvironmentNaming.suggestEnvironmentName("", "Development", knownSuffixes)); + assertEquals( + "", LifecycleEnvironmentNaming.suggestEnvironmentName(" ", "Development", knownSuffixes)); + } + + @Test + void suggestKnownPurposesUseEnglishSuffixes() { + assertEquals( + "sales-development", + LifecycleEnvironmentNaming.suggestEnvironmentName("sales", "Development", knownSuffixes)); + assertEquals( + "sales-test", + LifecycleEnvironmentNaming.suggestEnvironmentName("sales", "Testing", knownSuffixes)); + assertEquals( + "sales-acceptance", + LifecycleEnvironmentNaming.suggestEnvironmentName("sales", "Acceptance", knownSuffixes)); + assertEquals( + "sales-production", + LifecycleEnvironmentNaming.suggestEnvironmentName("sales", "Production", knownSuffixes)); + assertEquals( + "sales-continuous-integration", + LifecycleEnvironmentNaming.suggestEnvironmentName( + "sales", "Continuous Integration", knownSuffixes)); + assertEquals( + "sales-common-build", + LifecycleEnvironmentNaming.suggestEnvironmentName("sales", "Common Build", knownSuffixes)); + } + + @Test + void knownLocalizedLabelsMapToEnglishSuffixes() { + Map italian = + LifecycleEnvironmentNaming.knownPurposeSuffixes( + "Sviluppo", + "Testing", + "Accettazione", + "Produzione", + "Continuous Integration", + "Common Build"); + + assertEquals( + "app-development", + LifecycleEnvironmentNaming.suggestEnvironmentName("app", "Sviluppo", italian)); + assertEquals( + "app-test", LifecycleEnvironmentNaming.suggestEnvironmentName("app", "Testing", italian)); + assertEquals( + "app-acceptance", + LifecycleEnvironmentNaming.suggestEnvironmentName("app", "Accettazione", italian)); + assertEquals( + "app-production", + LifecycleEnvironmentNaming.suggestEnvironmentName("app", "Produzione", italian)); + } + + @Test + void freeTextPurposeIsSanitized() { + assertEquals( + "my-project-staging", + LifecycleEnvironmentNaming.suggestEnvironmentName("my-project", "Staging", knownSuffixes)); + assertEquals( + "my-project-pre-prod", + LifecycleEnvironmentNaming.suggestEnvironmentName("my-project", "Pre Prod", knownSuffixes)); + assertEquals( + "my-project-qa.1", + LifecycleEnvironmentNaming.suggestEnvironmentName("my-project", "QA.1", knownSuffixes)); + } + + @Test + void purposeToSuffixKnownAndFreeText() { + assertEquals( + LifecycleEnvironmentNaming.SUFFIX_TEST, + LifecycleEnvironmentNaming.purposeToSuffix("Testing", knownSuffixes)); + assertEquals( + LifecycleEnvironmentNaming.SUFFIX_DEVELOPMENT, + LifecycleEnvironmentNaming.purposeToSuffix("Development", knownSuffixes)); + assertEquals( + "custom-env", LifecycleEnvironmentNaming.purposeToSuffix("Custom Env", knownSuffixes)); + assertEquals("", LifecycleEnvironmentNaming.purposeToSuffix(null, knownSuffixes)); + assertEquals("", LifecycleEnvironmentNaming.purposeToSuffix("", knownSuffixes)); + } + + @Test + void sanitizePurposeSuffix() { + assertEquals("hello-world", LifecycleEnvironmentNaming.sanitizePurposeSuffix(" Hello World ")); + assertEquals("a-b-c", LifecycleEnvironmentNaming.sanitizePurposeSuffix("A/B\\C")); + assertEquals("", LifecycleEnvironmentNaming.sanitizePurposeSuffix("@@@")); + assertEquals("", LifecycleEnvironmentNaming.sanitizePurposeSuffix(null)); + } + + @Test + void suggestWithSuffixDirectly() { + assertEquals( + "p-development", + LifecycleEnvironmentNaming.suggestEnvironmentName( + "p", LifecycleEnvironmentNaming.SUFFIX_DEVELOPMENT)); + assertEquals("p", LifecycleEnvironmentNaming.suggestEnvironmentName("p", "")); + } +} From 9407f008046f0950c4852a08b1bf7a2f03fcba27 Mon Sep 17 00:00:00 2001 From: mattcasters Date: Sun, 19 Jul 2026 20:40:16 +0200 Subject: [PATCH 14/15] issue #2195 : Execution Information : Add a button to copy the folder URI to the clipboard (properties file fix) --- .../hop/projects/environment/messages/messages_en_US.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/environment/messages/messages_en_US.properties b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/environment/messages/messages_en_US.properties index 4e571961c90..a4ee2c7c00a 100644 --- a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/environment/messages/messages_en_US.properties +++ b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/environment/messages/messages_en_US.properties @@ -37,7 +37,7 @@ LifecycleEnvironmentDialog.Shell.Name=Environment Properties LifecycleEnvironmentDialog.ImportVariables.ProjectRequired.Title=Project required LifecycleEnvironmentDialog.ImportVariables.ProjectRequired.Message=Please select the project associated with this environment before importing variables. LifecycleEnvironmentDialog.ImportVariables.NoneFound.Title=No variables to import -LifecycleEnvironmentDialog.ImportVariables.NoneFound.Message=No ${VARIABLE} expressions were found in the project that are not already defined in the environment configuration files. +LifecycleEnvironmentDialog.ImportVariables.NoneFound.Message=No '${VARIABLE}' expressions were found in the project that are not already defined in the environment configuration files. LifecycleEnvironmentDialog.ImportVariables.DialogMessage=Variables used in project ''{0}'' that are not yet in an environment configuration file. Set values as needed, then OK to save. LifecycleEnvironmentDialog.ImportVariables.SelectConfig.Title=Select configuration file LifecycleEnvironmentDialog.ImportVariables.SelectConfig.Message=Choose an existing environment configuration file, or create a new one. From a50bb8ec852152e2a4f5b08751183406b3c6bfd9 Mon Sep 17 00:00:00 2001 From: mattcasters Date: Sun, 19 Jul 2026 20:43:07 +0200 Subject: [PATCH 15/15] Issue #2400 : skip re-encoding values that already start with Encrypted Avoid nested encryption when Encode value is used twice in the described variables dialog (PR review feedback). --- .../hop/ui/core/dialog/HopDescribedVariablesDialog.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/src/main/java/org/apache/hop/ui/core/dialog/HopDescribedVariablesDialog.java b/ui/src/main/java/org/apache/hop/ui/core/dialog/HopDescribedVariablesDialog.java index af433170483..d89f5e48b19 100644 --- a/ui/src/main/java/org/apache/hop/ui/core/dialog/HopDescribedVariablesDialog.java +++ b/ui/src/main/java/org/apache/hop/ui/core/dialog/HopDescribedVariablesDialog.java @@ -229,6 +229,10 @@ private void encodeSelectedValue() { for (int index : wFields.getSelectionIndices()) { TableItem item = wFields.table.getItem(index); String value = item.getText(2); + // Already encrypted? Skip so double-click / re-encode does not nest encryption + if (value != null && value.startsWith(Encr.PASSWORD_ENCRYPTED_PREFIX)) { + continue; + } String encoded = encoder.encode(value, true); item.setText(2, Const.NVL(encoded, "")); }