You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hop.apache.org by ha...@apache.org on 2026/07/20 10:42:48 UTC
(hop) branch main updated: Reduce friction (#7572)
This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 1f54fa024e Reduce friction (#7572)
1f54fa024e is described below
commit 1f54fa024e00fcde596f6b4b2e332abf5122eab4
Author: Matt Casters <ma...@gmail.com>
AuthorDate: Mon Jul 20 12:42:42 2026 +0200
Reduce friction (#7572)
* issue #2292 : Make the empty execution locations tree more welcoming
* issue #2195 : Execution Information : Add a button to copy the folder URI to the clipboard
* Issue #2069 : More Info Fields in Pipeline Execution Information
* Issue #2637 : Allow Custom Logging dialog to accept a variable to define logging level
* Issue #2400 : As a Developer I would like to be able to generate database connection parameters
* Issue #2351 : execution information perspective tabs are not stored correctly with the project
* Issue #2308 : environment Background Colour Configuration
* Issue #2331 : Add variable PARENT_PROJECT_HOME
* 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.
* 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.
* Issue #2710 : Provide a way to display and output variables a project depends on
* 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).
* 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.
* issue #2195 : Execution Information : Add a button to copy the folder URI to the clipboard (properties file fix)
* 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).
---
core/src/main/java/org/apache/hop/core/Const.java | 231 +++++++
.../test/java/org/apache/hop/core/ConstTest.java | 58 ++
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 ++
.../database/generate-variables-proposals.png | Bin 0 -> 185460 bytes
...-variables-save-as-env-config-file-question.png | Bin 0 -> 33504 bytes
.../modules/ROOT/pages/database/databases.adoc | 104 ++-
.../modules/ROOT/pages/hop-gui/hop-web.adoc | 6 +
.../modules/ROOT/pages/projects/advanced.adoc | 17 +
.../modules/ROOT/pages/projects/index.adoc | 6 +
.../ROOT/pages/projects/projects-environments.adoc | 2 +
.../hop/execution/ExecutionStateBuilder.java | 92 +++
.../ExecutionStateBuilderMetricsTest.java | 89 +++
.../cratedbbulkloader/CrateDBBulkLoader.java | 4 +-
.../cratedbbulkloader/CrateDBBulkLoaderDialog.java | 1 +
.../monetdbbulkloader/MonetDbBulkLoader.java | 2 +-
.../monetdbbulkloader/MonetDbBulkLoaderDialog.java | 1 +
.../mysqlbulkloader/MySqlBulkLoader.java | 2 +-
.../mysqlbulkloader/MySqlBulkLoaderDialog.java | 1 +
.../orabulkloader/OraBulkLoaderDialog.java | 4 +
.../orabulkloader/OraBulkLoaderMeta.java | 24 +-
.../snowflake/bulkloader/SnowflakeBulkLoader.java | 4 +-
.../bulkloader/SnowflakeBulkLoaderDialog.java | 1 +
.../flink/BeamFlinkPipelineRunConfiguration.java | 2 +-
.../spark/BeamSparkPipelineRunConfiguration.java | 4 +-
.../handler/BeamRowGeneratorTransformHandler.java | 4 +-
.../hop/debug/action/ActionDebugGuiPlugin.java | 4 +-
.../apache/hop/debug/action/ActionDebugLevel.java | 42 +-
.../hop/debug/action/ActionDebugLevelDialog.java | 18 +-
.../action/EditActionDebugLevelExtensionPoint.java | 2 +-
.../action/ModifyActionLogLevelExtensionPoint.java | 8 +-
.../EditTransformDebugLevelExtensionPoint.java | 3 +-
.../SetTransformDebugLevelExtensionPoint.java | 11 +-
.../debug/transform/TransformDebugGuiPlugin.java | 3 +-
.../hop/debug/transform/TransformDebugLevel.java | 36 +-
.../debug/transform/TransformDebugLevelDialog.java | 17 +-
.../org/apache/hop/debug/util/DebugLevelUtil.java | 86 ++-
.../apache/hop/debug/util/DebugLevelUtilTest.java | 126 ++++
.../EnvironmentVariablesImportHelper.java | 398 ++++++++++++
.../projects/environment/LifecycleEnvironment.java | 19 +
.../environment/LifecycleEnvironmentDialog.java | 396 +++++++++++-
.../environment/LifecycleEnvironmentNaming.java | 154 +++++
.../apache/hop/projects/gui/ProjectsGuiPlugin.java | 9 +-
.../project/ManageProjectsOptionPlugin.java | 8 +-
.../org/apache/hop/projects/project/Project.java | 17 +-
.../hop/projects/util/PathVariableReplacer.java | 238 +++++++
.../org/apache/hop/projects/util/ProjectsUtil.java | 2 +
.../xp/DrawEnvironmentCanvasTextExtension.java | 89 +++
...> DrawEnvironmentOnPipelineExtensionPoint.java} | 23 +-
...> DrawEnvironmentOnWorkflowExtensionPoint.java} | 23 +-
.../xp/HopGuiDirectoryReplaceHomeVariable.java | 45 +-
.../projects/xp/HopGuiFileReplaceHomeVariable.java | 64 +-
...ojectsVariablesControlSpaceSortOrderPrefix.java | 2 +
.../environment/messages/messages_en_US.properties | 17 +
.../EnvironmentVariablesImportHelperTest.java | 193 ++++++
.../LifecycleEnvironmentNamingTest.java | 155 +++++
.../apache/hop/projects/project/ProjectTest.java | 147 +++++
.../projects/util/PathVariableReplacerTest.java | 171 +++++
.../avro/transforms/avroinput/AvroFileInput.java | 2 +-
.../transforms/avroinput/AvroFileInputDialog.java | 1 +
.../transforms/eventhubs/listen/AzureListener.java | 8 +-
.../eventhubs/listen/AzureListenerDialog.java | 2 +
.../transforms/eventhubs/write/AzureWrite.java | 2 +-
.../eventhubs/write/AzureWriterDialog.java | 1 +
.../googleanalytics/GoogleAnalyticsDialog.java | 2 +-
.../apache/hop/neo4j/transforms/cypher/Cypher.java | 2 +-
.../hop/neo4j/transforms/cypher/CypherDialog.java | 1 +
.../transforms/cypherbuilder/CypherBuilder.java | 2 +-
.../cypherbuilder/CypherBuilderDialog.java | 1 +
.../hop/neo4j/transforms/graph/GraphOutput.java | 2 +-
.../neo4j/transforms/graph/GraphOutputDialog.java | 1 +
.../hop/neo4j/transforms/output/Neo4JOutput.java | 2 +-
.../neo4j/transforms/output/Neo4JOutputDialog.java | 1 +
.../parquet/transforms/output/ParquetOutput.java | 8 +-
.../transforms/output/ParquetOutputDialog.java | 4 +
.../salesforceinput/SalesforceInput.java | 2 +-
.../salesforceinput/SalesforceInputDialog.java | 1 +
.../hop/pipeline/transforms/clonerow/CloneRow.java | 2 +-
.../transforms/clonerow/CloneRowDialog.java | 1 +
.../pipeline/transforms/cubeinput/CubeInput.java | 2 +-
.../databasejoin/DatabaseJoinDialog.java | 2 +-
.../pipeline/transforms/delete/DeleteDialog.java | 1 +
.../hop/pipeline/transforms/delete/DeleteMeta.java | 4 +-
.../dynamicsqlrow/DynamicSqlRowDialog.java | 2 +-
.../transforms/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 +
.../transforms/insertupdate/InsertUpdateMeta.java | 4 +-
.../transforms/jsoninput/JsonInputDialog.java | 2 +-
.../jsonnormalize/JsonNormalizeInputDialog.java | 2 +-
.../kafka/consumer/KafkaConsumerInput.java | 4 +-
.../kafka/consumer/KafkaConsumerInputDialog.java | 1 +
.../kafka/consumer/KafkaConsumerInputMeta.java | 4 +-
.../transforms/ldapinput/LdapInputDialog.java | 2 +-
.../loadfileinput/LoadFileInputDialog.java | 2 +-
.../pipelineexecutor/PipelineExecutor.java | 2 +-
.../pipelineexecutor/PipelineExecutorDialog.java | 3 +-
.../propertyinput/PropertyInputDialog.java | 2 +-
.../reservoirsampling/ReservoirSampling.java | 6 +-
.../reservoirsampling/ReservoirSamplingDialog.java | 1 +
.../transforms/rowgenerator/RowGenerator.java | 4 +-
.../rowgenerator/RowGeneratorDialog.java | 1 +
.../transforms/rowgenerator/RowGeneratorMeta.java | 2 +-
.../hop/pipeline/transforms/sasinput/SasInput.java | 2 +-
.../transforms/sasinput/SasInputDialog.java | 1 +
.../hop/pipeline/transforms/sort/SortRows.java | 2 +-
.../pipeline/transforms/sort/SortRowsDialog.java | 1 +
.../SynchronizeAfterMerge.java | 5 +-
.../SynchronizeAfterMergeDialog.java | 1 +
.../pipeline/transforms/tableinput/TableInput.java | 2 +-
.../transforms/tableinput/TableInputDialog.java | 1 +
.../transforms/tableoutput/TableOutput.java | 5 +-
.../transforms/tableoutput/TableOutputDialog.java | 1 +
.../hop/pipeline/transforms/csvinput/CsvInput.java | 6 +-
.../transforms/csvinput/CsvInputDialog.java | 1 +
.../fileinput/text/TextFileInputDialog.java | 2 +-
.../hop/pipeline/transforms/tika/TikaDialog.java | 2 +-
.../pipeline/transforms/update/UpdateDialog.java | 1 +
.../hop/pipeline/transforms/update/UpdateMeta.java | 4 +-
.../workflowexecutor/WorkflowExecutor.java | 2 +-
.../workflowexecutor/WorkflowExecutorDialog.java | 3 +-
.../xml/getxmldata/GetXmlDataDialog.java | 2 +-
.../xml/xmlinputstream/XmlInputStream.java | 4 +-
.../xml/xmlinputstream/XmlInputStreamDialog.java | 2 +
.../transforms/yamlinput/YamlInputDialog.java | 2 +-
.../DatabaseConnectionVariablesHelper.java | 175 +++++
.../hop/ui/core/database/DatabaseMetaEditor.java | 213 +++++-
.../core/dialog/HopDescribedVariablesDialog.java | 4 +
.../org/apache/hop/ui/core/gui/GuiResource.java | 15 +
.../org/apache/hop/ui/core/widget/TextVar.java | 54 +-
.../ui/hopgui/delegates/HopGuiFileDelegate.java | 11 +
.../execution/ExecutionPerspective.java | 717 +++++++++++++++++----
.../execution/PipelineExecutionViewer.java | 20 +-
.../execution/WorkflowExecutionViewer.java | 2 +
.../hop/ui/hopgui/shared/BaseExecutionViewer.java | 28 +
.../database/messages/messages_en_US.properties | 18 +
.../core/widget/messages/messages_en_US.properties | 1 +
.../execution/messages/messages_en_US.properties | 9 +
ui/src/main/resources/ui/images/hash.svg | 8 +
.../DatabaseConnectionVariablesHelperTest.java | 141 ++++
.../ExecutionPerspectiveDisabledTest.java | 6 +
148 files changed, 4549 insertions(+), 415 deletions(-)
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 7394cd4b33..fa25fec12d 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 class Const {
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).
+ *
+ * <p>Supports:
+ *
+ * <ul>
+ * <li>Grouping separators: spaces, underscores, commas; dots when used as thousands grouping
+ * (e.g. {@code 100.000.000})
+ * <li>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}).
+ * <li>Scientific / power forms: {@code 1e8}, {@code 1E+8}, {@code 1x10^8}, {@code 10^8}
+ * </ul>
+ *
+ * @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 787a3dd951..f94a6bb170 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 @@ class ConstTest {
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/docker/Dockerfile b/docker/Dockerfile
index 9a1f3d37eb..a83a3c302e 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 fa754fa6db..91fc3e23a8 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 be86ae7e61..0308838341 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 e239035101..4fdc6d5d01 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 7dfd19c72b..70fed1a1d9 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 49bd5d46e6..156f591e0a 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:<tag>
----
+=== 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:<tag>
+----
+
+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/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 0000000000..f2c6c95f28
Binary files /dev/null and b/docs/hop-user-manual/modules/ROOT/assets/images/database/generate-variables-proposals.png differ
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 0000000000..6afae6cc97
Binary files /dev/null and b/docs/hop-user-manual/modules/ROOT/assets/images/database/generate-variables-save-as-env-config-file-question.png differ
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 4d3f0dfc71..238df15b29 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/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 1ceeb5d11b..2fedb3e159 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/docs/hop-user-manual/modules/ROOT/pages/projects/advanced.adoc b/docs/hop-user-manual/modules/ROOT/pages/projects/advanced.adoc
index 1e6bad8501..963160a975 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/docs/hop-user-manual/modules/ROOT/pages/projects/index.adoc b/docs/hop-user-manual/modules/ROOT/pages/projects/index.adoc
index fcbfc44752..f1e5371cd7 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/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc b/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc
index 5ff70d0d60..1da0e5ea46 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 defi [...]
+
After creating an environment the user interface will switch to it.
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 1f6ea6e36a..0b803d316a 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.WorkflowMeta;
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 final class ExecutionStateBuilder {
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 final class ExecutionStateBuilder {
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<PipelineMeta> 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 0000000000..18808fcd2a
--- /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<IEngineComponent, String> 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/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 24880db16b..3a5542f659 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 @@ public class CrateDBBulkLoader extends BaseTransform<CrateDBBulkLoaderMeta, Crat
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 bdb1a89650..c41ef87858 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 class CrateDBBulkLoaderDialog extends BaseTransformDialog {
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 fd54cb56cb..cc90897659 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 class MonetDbBulkLoader extends BaseTransform<MonetDbBulkLoaderMeta, Mone
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 c260755acc..75feb73d7d 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 class MonetDbBulkLoaderDialog extends BaseTransformDialog {
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 213568569d..e378370bbb 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 class MySqlBulkLoader extends BaseTransform<MySqlBulkLoaderMeta, MySqlBul
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 3baf7cf6f8..f289e639a7 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 class MySqlBulkLoaderDialog extends BaseTransformDialog {
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 dd001020d5..ea59f540c1 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 class OraBulkLoaderDialog extends BaseTransformDialog {
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 class OraBulkLoaderDialog extends BaseTransformDialog {
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 class OraBulkLoaderDialog extends BaseTransformDialog {
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 class OraBulkLoaderDialog extends BaseTransformDialog {
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 31da7dd594..98482e514e 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 class OraBulkLoaderMeta extends BaseTransformMeta<OraBulkLoader, OraBulkL
}
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 class OraBulkLoaderMeta extends BaseTransformMeta<OraBulkLoader, OraBulkL
}
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 class OraBulkLoaderMeta extends BaseTransformMeta<OraBulkLoader, OraBulkL
}
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 class OraBulkLoaderMeta extends BaseTransformMeta<OraBulkLoader, OraBulkL
}
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 dd910ac783..250676674b 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 class SnowflakeBulkLoader
// 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 97a2408925..b47e7d58b7 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 class SnowflakeBulkLoaderDialog extends BaseTransformDialog {
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 181ab19190..652cf1bc91 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 class BeamFlinkPipelineRunConfiguration extends BeamPipelineRunConfigurat
// 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 e8a2670e05..0f46671df0 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 class BeamSparkPipelineRunConfiguration extends BeamPipelineRunConfigurat
}
}
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 class BeamSparkPipelineRunConfiguration extends BeamPipelineRunConfigurat
}
}
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 991d3bd39b..1ad98fb7e5 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 class BeamRowGeneratorTransformHandler extends BeamBaseTransformHandler
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 class BeamRowGeneratorTransformHandler extends BeamBaseTransformHandler
// 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/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 97259f046e..a90bc7fe80 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 java.util.Map;
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 class ActionDebugGuiPlugin {
try {
WorkflowMeta workflowMeta = context.getWorkflowMeta();
ActionMeta action = context.getActionMeta();
+ IVariables variables = context.getWorkflowGraph().getVariables();
Map<String, Map<String, String>> attributesMap = workflowMeta.getAttributesMap();
Map<String, String> debugGroupAttributesMap = attributesMap.get(Defaults.DEBUG_GROUP);
@@ -83,7 +85,7 @@ public class ActionDebugGuiPlugin {
}
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 32c0934e06..ee39bbee82 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 @@ package org.apache.hop.debug.action;
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 class ActionDebugLevel implements Cloneable {
}
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 class ActionDebugLevel implements Cloneable {
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 class ActionDebugLevel implements Cloneable {
@Override
public String toString() {
- String s = logLevel.toString();
+ String s = logLevel;
if (loggingResult) {
s += ", logging result";
}
@@ -78,21 +97,28 @@ public class ActionDebugLevel implements Cloneable {
}
/**
- * 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 6db37f442e..53ddb8ba6e 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 class ActionDebugLevelDialog extends Dialog {
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 class ActionDebugLevelDialog extends Dialog {
}
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 class ActionDebugLevelDialog extends Dialog {
// 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 fe1a526bff..ccf644d451 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 class EditActionDebugLevelExtensionPoint
}
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 c39a15659b..483b57d4e6 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 class ModifyActionLogLevelExtensionPoint
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 a82f80887e..e45bab753a 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 class EditTransformDebugLevelExtensionPoint
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 58265e37ac..50525e7005 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 class SetTransformDebugLevelExtensionPoint
log.logDetailed("Found debug level info for transform " + transformName);
List<IEngineComponent> transformCopies = pipeline.getComponentCopies(transformName);
+ final LogLevel resolvedLogLevel =
+ DebugLevelUtil.resolveLogLevel(variables, debugLevel.getLogLevel());
if (debugLevel.getStartRow() < 0
&& debugLevel.getEndRow() < 0
@@ -89,16 +91,15 @@ public class SetTransformDebugLevelExtensionPoint
"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 class SetTransformDebugLevelExtensionPoint
}
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 a86e3e1eaf..b2c8507b66 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 class TransformDebugGuiPlugin {
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 4581bead22..e133fdd2bf 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.Condition;
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 class TransformDebugLevel implements Cloneable {
}
/**
- * 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 2e5b003ff4..ad1b397a6a 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 @@ package org.apache.hop.debug.transform;
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.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;
@@ -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 class TransformDebugLevelDialog extends Dialog {
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 class TransformDebugLevelDialog extends Dialog {
}
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 class TransformDebugLevelDialog extends Dialog {
// 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 e3b2d80ad3..b8ef3c5332 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.Const;
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 class DebugLevelUtil {
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 class DebugLevelUtil {
}
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 class DebugLevelUtil {
public static void storeActionDebugLevel(
Map<String, String> 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 class DebugLevelUtil {
}
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 class DebugLevelUtil {
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 0000000000..733a15968b
--- /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<String, String> 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<String, String> 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<String, String> 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<String, String> 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"));
+ }
+}
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 0000000000..5254ae366b
--- /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<String> MANAGED_VARIABLE_NAMES;
+
+ static {
+ Set<String> 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<String> extractVariableNames(String text) {
+ Set<String> 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<String> loadConfiguredVariableNames(
+ List<String> configurationFiles, IVariables variables) throws HopException {
+ Set<String> 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<DescribedVariable> findMissingVariables(
+ ProjectConfig projectConfig,
+ IVariables variables,
+ List<String> 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<String> 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<Class<ISearchableAnalyser>, ISearchableAnalyser> analysers =
+ HopGuiSearchHelper.loadSearchableAnalysers();
+ List<ISearchResult> results =
+ HopGuiSearchHelper.searchLocation(
+ location, query, analysers, metadataProvider, scanVariables);
+
+ // Preserve a stable order of names while collecting where each was found.
+ Map<String, Set<String>> locationsByName = new LinkedHashMap<>();
+ Map<String, String> 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<String> 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<String> configured = loadConfiguredVariableNames(configFiles, scanVariables);
+
+ // Sorted presentation order
+ Set<String> sortedKeys = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
+ sortedKeys.addAll(canonicalNameByKey.keySet());
+
+ List<DescribedVariable> 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<String> locations) {
+ if (locations == null || locations.isEmpty()) {
+ return "";
+ }
+ List<String> 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<String> 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<DescribedVariable> 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<String> 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<String> 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/LifecycleEnvironment.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironment.java
index 9dbf5ed793..bb4b68e5c0 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<String> configurationFiles;
public LifecycleEnvironment() {
@@ -48,6 +50,7 @@ public class LifecycleEnvironment {
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 class LifecycleEnvironment {
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 3a19b4c917..d70f99078d 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,15 @@
package org.apache.hop.projects.environment;
+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;
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 +35,9 @@ import org.apache.hop.projects.project.ProjectConfig;
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;
@@ -67,15 +73,28 @@ public class LifecycleEnvironmentDialog extends Dialog {
private Text wName;
private Combo wPurpose;
private Combo wProject;
+ private Text wCanvasText;
private TableView wConfigFiles;
private IVariables variables;
private Button wbEdit;
+ private Button wbImportVariables;
private String originalName;
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<String, String> knownPurposeSuffixes;
+
public LifecycleEnvironmentDialog(
Shell parent, LifecycleEnvironment environment, IVariables variables) {
super(parent, SWT.DIALOG_TRIM | SWT.APPLICATION_MODAL | SWT.RESIZE);
@@ -88,6 +107,9 @@ public class LifecycleEnvironmentDialog extends Dialog {
props = PropsUi.getInstance();
needingEnvironmentRefresh = false;
+ lastSuggestedName = null;
+ nameAutoManaged = StringUtils.isEmpty(originalName);
+ updatingSuggestedName = false;
}
public String open() {
@@ -139,6 +161,7 @@ public class LifecycleEnvironmentDialog extends Dialog {
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);
@@ -157,7 +180,12 @@ public class LifecycleEnvironmentDialog extends Dialog {
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);
@@ -176,9 +204,34 @@ public class LifecycleEnvironmentDialog extends Dialog {
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);
+ 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(
@@ -189,15 +242,48 @@ public class LifecycleEnvironmentDialog extends Dialog {
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(
@@ -220,32 +306,12 @@ public class LifecycleEnvironmentDialog extends Dialog {
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();
@@ -371,6 +437,189 @@ public class LifecycleEnvironmentDialog extends Dialog {
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<String> configurationFiles = new ArrayList<>();
+ for (TableItem item : wConfigFiles.getNonEmptyItems()) {
+ configurationFiles.add(item.getText(1));
+ }
+
+ List<DescribedVariable> 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<DescribedVariable> 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<String> configurationFiles, String projectName)
+ throws Exception {
+ String createNewLabel =
+ BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.ImportVariables.CreateNewOption");
+
+ if (configurationFiles != null && !configurationFiles.isEmpty()) {
+ List<String> 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 {
@@ -408,18 +657,53 @@ public class LifecycleEnvironmentDialog extends Dialog {
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);
@@ -437,10 +721,58 @@ public class LifecycleEnvironmentDialog extends Dialog {
}
}
+ 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());
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/environment/LifecycleEnvironmentNaming.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentNaming.java
new file mode 100644
index 0000000000..0337957b46
--- /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<String, String> knownPurposeSuffixes(
+ String developmentLabel,
+ String testingLabel,
+ String acceptanceLabel,
+ String productionLabel,
+ String continuousIntegrationLabel,
+ String commonBuildLabel) {
+ Map<String, String> 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<String, String> 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.
+ *
+ * <p>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<String, String> 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<String, String> 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/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
index b4fd7e2130..68001603d6 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 class ProjectsGuiPlugin {
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 class ProjectsGuiPlugin {
// 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
@@ -1655,6 +1659,7 @@ public class ProjectsGuiPlugin {
&& !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/ManageProjectsOptionPlugin.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ManageProjectsOptionPlugin.java
index e925f03431..6607ddc864 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 @@ public class ManageProjectsOptionPlugin implements IConfigOptions {
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
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 7c76fa8872..f2ceff0df1 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 class Project extends ConfigFile implements IConfigFile {
// 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 class Project extends ConfigFile implements IConfigFile {
}
}
+ // 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/PathVariableReplacer.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/PathVariableReplacer.java
new file mode 100644
index 0000000000..52e7e3f13b
--- /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}).
+ *
+ * <p>Used by Hop GUI file/directory browse extension points as a best-practice aid.
+ */
+public final class PathVariableReplacer {
+
+ private static final Set<String> SYSTEM_NAME_PREFIXES =
+ Set.of(
+ "java.", "sun.", "user.", "os.", "file.", "jdk.", "http.", "awt.", "path.", "line.",
+ "ftp.");
+
+ private static final Set<String> 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/util/ProjectsUtil.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsUtil.java
index d6a6e70971..ac30f082a8 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 @@ import org.apache.hop.ui.hopgui.HopGui;
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/DrawEnvironmentCanvasTextExtension.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/DrawEnvironmentCanvasTextExtension.java
new file mode 100644
index 0000000000..ff142adc52
--- /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/ProjectsVariablesControlSpaceSortOrderPrefix.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/DrawEnvironmentOnPipelineExtensionPoint.java
similarity index 56%
copy from plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/ProjectsVariablesControlSpaceSortOrderPrefix.java
copy to plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/DrawEnvironmentOnPipelineExtensionPoint.java
index 2608733598..5b702862b4 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/DrawEnvironmentOnPipelineExtensionPoint.java
@@ -17,29 +17,22 @@
package org.apache.hop.projects.xp;
-import java.util.Map;
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.projects.util.Defaults;
-import org.apache.hop.projects.util.ProjectsUtil;
+import org.apache.hop.pipeline.PipelinePainter;
@ExtensionPoint(
- id = "ProjectsVariablesControlSpaceSortOrderPrefix",
- extensionPointId = "HopGuiGetControlSpaceSortOrderPrefix",
- description = "Set a prefix sort order for the projects variables, push to front of the list")
-public class ProjectsVariablesControlSpaceSortOrderPrefix
- implements IExtensionPoint<Map<String, String>> {
+ id = "DrawEnvironmentOnPipelineExtensionPoint",
+ description = "Draws optional environment canvas text top-right under pipeline graph content",
+ extensionPointId = "PipelinePainterStart")
+public class DrawEnvironmentOnPipelineExtensionPoint implements IExtensionPoint<PipelinePainter> {
+
@Override
public void callExtensionPoint(
- ILogChannel log, IVariables variables, Map<String, String> prefixMap) throws HopException {
-
- prefixMap.put(ProjectsUtil.VARIABLE_PROJECT_HOME, "310_");
- prefixMap.put(Defaults.VARIABLE_HOP_PROJECT_NAME, "450_");
- prefixMap.put(Defaults.VARIABLE_HOP_ENVIRONMENT_NAME, "450_");
- prefixMap.put(ProjectsUtil.VARIABLE_HOP_DATASETS_FOLDER, "450_");
- prefixMap.put(ProjectsUtil.VARIABLE_HOP_UNIT_TESTS_FOLDER, "460_");
+ 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/ProjectsVariablesControlSpaceSortOrderPrefix.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/DrawEnvironmentOnWorkflowExtensionPoint.java
similarity index 56%
copy from plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/ProjectsVariablesControlSpaceSortOrderPrefix.java
copy to plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/DrawEnvironmentOnWorkflowExtensionPoint.java
index 2608733598..518a7b20ad 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/DrawEnvironmentOnWorkflowExtensionPoint.java
@@ -17,29 +17,22 @@
package org.apache.hop.projects.xp;
-import java.util.Map;
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.projects.util.Defaults;
-import org.apache.hop.projects.util.ProjectsUtil;
+import org.apache.hop.workflow.WorkflowPainter;
@ExtensionPoint(
- id = "ProjectsVariablesControlSpaceSortOrderPrefix",
- extensionPointId = "HopGuiGetControlSpaceSortOrderPrefix",
- description = "Set a prefix sort order for the projects variables, push to front of the list")
-public class ProjectsVariablesControlSpaceSortOrderPrefix
- implements IExtensionPoint<Map<String, String>> {
+ id = "DrawEnvironmentOnWorkflowExtensionPoint",
+ description = "Draws optional environment canvas text top-right under workflow graph content",
+ extensionPointId = "WorkflowPainterStart")
+public class DrawEnvironmentOnWorkflowExtensionPoint implements IExtensionPoint<WorkflowPainter> {
+
@Override
public void callExtensionPoint(
- ILogChannel log, IVariables variables, Map<String, String> prefixMap) throws HopException {
-
- prefixMap.put(ProjectsUtil.VARIABLE_PROJECT_HOME, "310_");
- prefixMap.put(Defaults.VARIABLE_HOP_PROJECT_NAME, "450_");
- prefixMap.put(Defaults.VARIABLE_HOP_ENVIRONMENT_NAME, "450_");
- prefixMap.put(ProjectsUtil.VARIABLE_HOP_DATASETS_FOLDER, "450_");
- prefixMap.put(ProjectsUtil.VARIABLE_HOP_UNIT_TESTS_FOLDER, "460_");
+ ILogChannel log, IVariables variables, WorkflowPainter workflowPainter) throws HopException {
+ DrawEnvironmentCanvasTextExtension.draw(workflowPainter, variables);
}
}
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 282520a36e..53ca0a9548 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<HopGuiDirectorySelectedExtension> {
@@ -56,39 +55,17 @@ public class HopGuiDirectoryReplaceHomeVariable
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 8ccd78e4a8..2c1932c82d 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 @@ import org.apache.hop.ui.hopgui.delegates.HopGuiFileOpenedExtension;
@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<HopGuiFileOpenedExtension> {
// TODO make this optional
@@ -55,39 +56,36 @@ public class HopGuiFileReplaceHomeVariable implements IExtensionPoint<HopGuiFile
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/main/java/org/apache/hop/projects/xp/ProjectsVariablesControlSpaceSortOrderPrefix.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/ProjectsVariablesControlSpaceSortOrderPrefix.java
index 2608733598..7ca0f82a69 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 class ProjectsVariablesControlSpaceSortOrderPrefix
ILogChannel log, IVariables variables, Map<String, String> 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/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 4bd8428114..a4ee2c7c00 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
@@ -24,6 +25,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
@@ -31,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 0000000000..c5a9c2366c
--- /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<String> 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<String> 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<String> 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<String> names =
+ EnvironmentVariablesImportHelper.loadConfiguredVariableNames(
+ List.of(configPath.toString()), variables);
+ assertTrue(names.contains("EXISTING"));
+ assertEquals(1, names.size());
+
+ List<DescribedVariable> 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<String> names =
+ EnvironmentVariablesImportHelper.loadConfiguredVariableNames(
+ List.of(tempDir.resolve("does-not-exist.json").toString()), variables);
+ assertTrue(names.isEmpty());
+ }
+}
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 0000000000..2a75c2940e
--- /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<String, String> 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<String, String> 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", ""));
+ }
+}
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 4cb65c6110..a9e67cc5cb 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<String> 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<Path> 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 class ProjectTest {
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);
+ }
}
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 0000000000..f917843086
--- /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));
+ }
+}
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 4bf828f5e0..8c0b2b6888 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 class AvroFileInput extends BaseTransform<AvroFileInputMeta, AvroFileInpu
+ "' 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 e7ba166554..7fafd88f6b 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 class AvroFileInputDialog extends BaseTransformDialog {
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 2628e5c453..fa0992eea4 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 class AzureListener extends BaseTransform<AzureListenerMeta, AzureListene
@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 class AzureListener extends BaseTransform<AzureListenerMeta, AzureListene
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 f03702bcb9..4e7e8a322a 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 class AzureListenerDialog extends BaseTransformDialog {
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 class AzureListenerDialog extends BaseTransformDialog {
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 1797b999aa..63cc492f78 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 class AzureWrite extends BaseTransform<AzureWriterMeta, AzureWriterData>
@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 33ea72d760..8dacf4fe61 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 class AzureWriterDialog extends BaseTransformDialog {
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 c7939c008f..6a4b252d45 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 @@ public class GoogleAnalyticsDialog extends BaseTransformDialog {
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 ca07694dc2..e1d6cb6164 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 class Cypher extends BaseTransform<CypherMeta, CypherData> {
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 89c57ed997..ee32191895 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 @@ public class CypherDialog extends BaseTransformDialog {
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 7d90265eaa..a4ae0ce119 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 @@ public class CypherBuilder extends BaseTransform<CypherBuilderMeta, CypherBuilde
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 3e9097f6ab..7f431f4b74 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 @@ public class CypherBuilderDialog extends BaseTransformDialog {
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 70a0489ab8..36546916c6 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 class GraphOutput extends BaseNeoTransform<GraphOutputMeta, GraphOutputDa
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 fb85a2bc81..f208eca0a9 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 class GraphOutputDialog extends BaseTransformDialog {
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 b0cc49bbef..de3f76276c 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 class Neo4JOutput extends BaseNeoTransform<Neo4JOutputMeta, Neo4JOutputDa
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 ff2087e838..b6327ac6aa 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 class Neo4JOutputDialog extends BaseTransformDialog {
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 3e07a38f6d..704b28047a 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 class ParquetOutput extends BaseTransform<ParquetOutputMeta, ParquetOutpu
// 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 2fac6d2579..b1cd5baf68 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 class ParquetOutputDialog extends BaseTransformDialog {
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 class ParquetOutputDialog extends BaseTransformDialog {
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 class ParquetOutputDialog extends BaseTransformDialog {
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 class ParquetOutputDialog extends BaseTransformDialog {
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 6c41242414..aaf5bf1081 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 class SalesforceInput extends SalesforceTransform<SalesforceInputMeta, Sa
}
}
- 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 11ff886bb2..2be97edc74 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 class SalesforceInputDialog extends SalesforceTransformDialog {
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 7a622ff1b7..668b369a24 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 class CloneRow extends BaseTransform<CloneRowMeta, CloneRowData> {
}
} 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 912903a366..97bc24fa97 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 class CloneRowDialog extends BaseTransformDialog {
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 55632567ab..06998a138b 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 class CubeInput extends BaseTransform<CubeInputMeta, CubeInputData> {
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 a4304b7733..d11b0bd5a5 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 @@ public class DatabaseJoinDialog extends BaseTransformDialog {
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 cea01f0aa5..1b664b0a10 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 @@ public class DeleteDialog extends BaseTransformDialog {
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 abeefd633a..e78a022c17 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 class DeleteMeta extends BaseTransformMeta<Delete, DeleteData> {
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 a0c76d2d8e..f7c1c67e8a 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 @@ public class DynamicSqlRowDialog extends BaseTransformDialog {
}
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 a3f9a37a76..9de9d58cc3 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 @@ public class ExcelInputDialog extends BaseTransformDialog {
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 4a91610b2c..6d32fd4264 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.io.InputStreamReader;
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 @@ public class FileMetadata extends BaseTransform<FileMetadataMeta, FileMetadataDa
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 22f70d418a..8a91936a04 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 class FileMetadataDialog extends BaseTransformDialog {
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 f88e1d32ed..cea24ae311 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 @@ public class GetFileNamesDialog extends BaseTransformDialog {
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 3343eef235..eecca4abdb 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 @@ public class GetSubFoldersDialog extends BaseTransformDialog {
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 371fb7b808..321a75df95 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 class InsertUpdateDialog extends BaseTransformDialog {
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 e04276c45a..d3fbee7302 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 class InsertUpdateMeta extends BaseTransformMeta<InsertUpdate, InsertUpda
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 eb6ad94bf9..4f737f02e2 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 @@ public class JsonInputDialog extends BaseTransformDialog {
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 c2d4cfc28e..a43d2efe4e 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 @@ public class JsonNormalizeInputDialog extends BaseTransformDialog {
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 7a49d84502..375e7dc521 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 class KafkaConsumerInput
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 class KafkaConsumerInput
// 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 1a5c7e67a2..fb3f48a5d4 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 @@ public class KafkaConsumerInputDialog extends BaseTransformDialog {
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 c596d463d6..303fc18671 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 class KafkaConsumerInputMeta
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 5a94cb5a94..88d8b82dbf 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 @@ public class LdapInputDialog extends BaseTransformDialog {
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 6f2767b833..f160346c71 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 @@ public class LoadFileInputDialog extends BaseTransformDialog {
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 07cef4bc09..92f613ad9e 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 class PipelineExecutor extends BaseTransform<PipelineExecutorMeta, Pipeli
// 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 c5fa2b36f3..7edf90a73a 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 @@ public class PipelineExecutorDialog extends BaseTransformDialog {
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 @@ public class PipelineExecutorDialog extends BaseTransformDialog {
|| 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 0cf144f122..9c595a6e3c 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 @@ public class PropertyInputDialog extends BaseTransformDialog {
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 1f4330068c..71561ee98b 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 @@ package org.apache.hop.pipeline.transforms.reservoirsampling;
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 class ReservoirSampling extends BaseTransform<ReservoirSamplingMeta, Rese
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 9fa17ec534..cf0a603afc 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 class ReservoirSamplingDialog extends BaseTransformDialog {
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 500c6bbe6b..313ecd093a 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 class RowGenerator extends BaseTransform<RowGeneratorMeta, RowGeneratorDa
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 b59130a565..7a6a06f7b0 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 class RowGeneratorDialog extends BaseTransformDialog {
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 7bd6fd0679..6be2aed62d 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 class RowGeneratorMeta extends BaseTransformMeta<RowGenerator, RowGenerat
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 b7895785be..6a3b0da60b 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 class SasInput extends BaseTransform<SasInputMeta, SasInputData> {
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 7c5058785e..b2d971b4f9 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 class SasInputDialog extends BaseTransformDialog {
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 025570177e..1dec1ed67c 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 class SortRows extends BaseTransform<SortRowsMeta, SortRowsData> {
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 02cd8d3e63..b73d57bd68 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 class SortRowsDialog extends BaseTransformDialog {
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 25a9d097d5..bba2613f9e 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 class SynchronizeAfterMerge
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 e33d9cc88a..e49edb866b 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 @@ public class SynchronizeAfterMergeDialog extends BaseTransformDialog {
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 ece2fcfec3..ce0d4c721c 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 class TableInput extends BaseTransform<TableInputMeta, TableInputData> {
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 348dd33810..e613f837ab 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 class TableInputDialog extends BaseTransformDialog {
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 197954d159..104379385b 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 class TableOutput extends BaseTransform<TableOutputMeta, TableOutputData>
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 e074ecf46c..b8fa0f0c5c 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 class TableOutputDialog extends BaseTransformDialog {
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 5157b7a5e7..cc3072abe0 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.ByteOrderMark;
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 class CsvInput extends BaseTransform<CsvInputMeta, CsvInputData> {
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 1255392516..a53bbc009e 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 class CsvInputDialog extends BaseTransformDialog
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 4c0be1472f..20e72be6f6 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 @@ public class TextFileInputDialog extends BaseTransformDialog
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 58aa4688bc..965b347435 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 @@ public class TikaDialog extends BaseTransformDialog {
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 79e398bb21..e5d2fd8939 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 class UpdateDialog extends BaseTransformDialog {
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 1a40147fa1..79c7148e0e 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 class UpdateMeta extends BaseTransformMeta<Update, UpdateData> {
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 33d16ec86f..109e8d91fe 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 class WorkflowExecutor extends BaseTransform<WorkflowExecutorMeta, Workfl
//
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 c47cae9232..b6cb1402e9 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 @@ public class WorkflowExecutorDialog extends BaseTransformDialog {
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 @@ public class WorkflowExecutorDialog extends BaseTransformDialog {
|| 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 f14822bceb..046e938c50 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 @@ public class GetXmlDataDialog extends BaseTransformDialog {
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 c712df93a2..7550606ff6 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 class XmlInputStream extends BaseTransform<XmlInputStreamMeta, XmlInputSt
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 67a0266c35..c8ed7f678e 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 class XmlInputStreamDialog extends BaseTransformDialog {
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 class XmlInputStreamDialog extends BaseTransformDialog {
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 b2aacde490..057634a7ca 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 @@ public class YamlInputDialog extends BaseTransformDialog {
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/database/DatabaseConnectionVariablesHelper.java b/ui/src/main/java/org/apache/hop/ui/core/database/DatabaseConnectionVariablesHelper.java
new file mode 100644
index 0000000000..0db65bdc8c
--- /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.
+ *
+ * <p>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<DescribedVariable> buildProposedVariables(
+ String connectionName,
+ String hostname,
+ String port,
+ String databaseName,
+ String username,
+ String password,
+ String manualUrl,
+ boolean includeUsername,
+ boolean includePassword,
+ Map<String, String> descriptionsBySuffix) {
+ String prefix = sanitizeConnectionNamePrefix(connectionName);
+ List<DescribedVariable> 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<String, String> findVariableNamesBySuffix(List<DescribedVariable> variables) {
+ Map<String, String> 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<DescribedVariable> variables,
+ String prefix,
+ String suffix,
+ String value,
+ Map<String, String> 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 865d9cf17f..0d2fdbb23e 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.ArrayList;
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.gui.plugin.GuiPlugin;
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 @@ public class DatabaseMetaEditor extends MetadataEditor<DatabaseMeta> {
@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 class DatabaseMetaEditor extends MetadataEditor<DatabaseMeta> {
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<String, String> 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<DescribedVariable> 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<DescribedVariable> 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<DescribedVariable> 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<DescribedVariable> variables) {
+ Map<String, String> 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/java/org/apache/hop/ui/core/dialog/HopDescribedVariablesDialog.java b/ui/src/main/java/org/apache/hop/ui/core/dialog/HopDescribedVariablesDialog.java
index af43317048..d89f5e48b1 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 @@ public class HopDescribedVariablesDialog extends Dialog {
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, ""));
}
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 78bea972f9..4c4ee136df 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 @@ public class GuiResource {
imageFolder.dispose();
imageMissing.dispose();
imageVariable.dispose();
+ imageHash.dispose();
imagePipeline.dispose();
imagePipelineDisabled.dispose();
imagePartitionSchema.dispose();
@@ -829,6 +831,7 @@ public class GuiResource {
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 class GuiResource {
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 3a8e7c16e9..3362323423 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 @@ public class TextVar extends Composite {
// 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 @@ public class TextVar extends Composite {
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)}.
+ *
+ * <p>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/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java
index a02835547a..4b22a44848 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 class HopGuiFileDelegate {
}
}
}
+
+ // 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 6f2293cde6..d18587f127 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.HashMap;
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,9 @@ import org.apache.hop.execution.ExecutionType;
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.AuditList;
import org.apache.hop.history.AuditManager;
import org.apache.hop.history.AuditState;
import org.apache.hop.history.AuditStateMap;
@@ -82,6 +86,7 @@ import org.apache.hop.ui.hopgui.perspective.HopPerspectivePlugin;
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;
@@ -91,12 +96,16 @@ import org.eclipse.swt.custom.CTabFolderEvent;
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 +130,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";
@@ -152,7 +163,11 @@ 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 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";
@@ -162,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;
@@ -184,6 +201,19 @@ public class ExecutionPerspective implements IHopPerspective, TabClosable {
private final List<IExecutionViewer> 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
*
@@ -327,6 +357,7 @@ public class ExecutionPerspective implements IHopPerspective, TabClosable {
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 -> {
@@ -336,6 +367,9 @@ public class ExecutionPerspective implements IHopPerspective, TabClosable {
}
});
+ // Copy-filename starts disabled until an execution with a filename is selected.
+ updateSelection();
+
PropsUi.setLook(tree);
FormData treeFormData = new FormData();
@@ -403,9 +437,11 @@ public class ExecutionPerspective implements IHopPerspective, TabClosable {
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
//
@@ -467,7 +503,9 @@ public class ExecutionPerspective implements IHopPerspective, TabClosable {
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 +521,29 @@ public class ExecutionPerspective implements IHopPerspective, TabClosable {
}
}
+ /**
+ * 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<ExecutionInfoLocation> 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,115 +792,131 @@ public class ExecutionPerspective implements IHopPerspective, TabClosable {
List<ExecutionInfoLocation> 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<String> 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<String> 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);
tree.setRedraw(true);
+ updateSelection();
} catch (Exception e) {
getShell().setCursor(null);
new ErrorDialog(
@@ -1077,6 +1154,223 @@ public class ExecutionPerspective implements IHopPerspective, TabClosable {
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 storageRoot = resolveStorageRootPath(getSelectedExecutionLocation());
+ 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.StorageRoot"),
+ storageRoot,
+ StringUtils.isNotEmpty(storageRoot));
+ 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 resolveStorageRootPath(ExecutionInfoLocation location) {
+ if (location == null) {
+ return null;
+ }
+ IExecutionInfoLocation iLocation = location.getExecutionInfoLocation();
+ if (iLocation instanceof FileExecutionInfoLocation fileLocation) {
+ String root = hopGui.getVariables().resolve(fileLocation.getRootFolder());
+ if (StringUtils.isEmpty(root)) {
+ return null;
+ }
+ return root;
+ }
+ 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;
+ }
+ 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";
+ }
+ 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,
@@ -1181,6 +1475,12 @@ public class ExecutionPerspective implements IHopPerspective, TabClosable {
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;
@@ -1193,6 +1493,33 @@ public class ExecutionPerspective implements IHopPerspective, TabClosable {
}
}
+ /**
+ * 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<CTabItem> 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;
@@ -1206,30 +1533,52 @@ public class ExecutionPerspective implements IHopPerspective, TabClosable {
return;
}
try {
+ String namespace = HopNamespace.getNamespace();
+ String activeKey = "";
+ IExecutionViewer activeViewer = getActiveViewer();
+ List<String> 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<String, Object> 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);
}
@@ -1243,9 +1592,9 @@ public class ExecutionPerspective implements IHopPerspective, TabClosable {
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();
@@ -1259,14 +1608,152 @@ public class ExecutionPerspective implements IHopPerspective, TabClosable {
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 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<ExecutionInfoLocation> 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().logError("Error restoring explorer perspective state", 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) {
ExecutionInfoLocation location = locationMap.get(locationName);
if (location == null) {
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 14e8289aa3..4c6a64cecd 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.IRowMeta;
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.ExecutionDataBuilder;
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 @@ public class PipelineExecutionViewer extends BaseExecutionViewer
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 @@ public class PipelineExecutionViewer extends BaseExecutionViewer
// 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<ColumnInfo> columns = new ArrayList<>();
columns.add(new ColumnInfo("Name", ColumnInfo.COLUMN_TYPE_TEXT, false, true));
@@ -491,6 +494,9 @@ public class PipelineExecutionViewer extends BaseExecutionViewer
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 @@ public class PipelineExecutionViewer extends BaseExecutionViewer
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 @@ public class PipelineExecutionViewer extends BaseExecutionViewer
}
}
+ private void addNamedColumn(
+ List<ColumnInfo> columns, Map<String, Integer> 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 428a2ba706..7cef9e27f7 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 @@ public class WorkflowExecutionViewer extends BaseExecutionViewer
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 e71411e76f..316ce7327f 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.AreaOwner;
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 @@ public abstract class BaseExecutionViewer extends DragViewZoomBase
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/core/database/messages/messages_en_US.properties b/ui/src/main/resources/org/apache/hop/ui/core/database/messages/messages_en_US.properties
index 563576b233..36a0fc6b5e 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/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 323a8ec2ba..d8ac9eb19b 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/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 c27c4de939..faecfc7af5 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,8 +17,17 @@
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.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
ExecutionPerspective.ToolbarElement.Refresh.Tooltip=Refresh information
ExecutionPerspective.ToolbarElement.ForceRefresh.Tooltip=Force refresh information (clear caches)
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 0000000000..f16ab252a4
--- /dev/null
+++ b/ui/src/main/resources/ui/images/hash.svg
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
+ <!-- Same diamond plate as variable.svg -->
+ <path fill="#0e3a5a" d="M6.5-.005L13.005 6.5 6.5 13.005-.005 6.5z"/>
+ <!-- Hash (#) glyph, white, centered on diamond -->
+ <path fill="none" stroke="#ffffff" stroke-width="1.15" stroke-linecap="round"
+ d="M4.6 4.2 L5.6 8.8 M7.4 4.2 L8.4 8.8 M3.9 5.6 H9.1 M4.4 7.4 H9.6"/>
+</svg>
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 0000000000..2229b9eda4
--- /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<String, String> 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<DescribedVariable> 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<DescribedVariable> withoutUrl =
+ DatabaseConnectionVariablesHelper.buildProposedVariables(
+ "EDW", "h", "1", "d", "u", "p", " ", true, true, null);
+ assertFalse(
+ withoutUrl.stream()
+ .anyMatch(v -> v.getName().endsWith(DatabaseConnectionVariablesHelper.SUFFIX_URL)));
+
+ List<DescribedVariable> 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<DescribedVariable> 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<DescribedVariable> variables =
+ List.of(
+ new DescribedVariable("EDW_HOSTNAME", "a", ""),
+ new DescribedVariable("CUSTOM_PORT", "b", ""),
+ new DescribedVariable("other", "c", ""),
+ new DescribedVariable("EDW_HOSTNAME", "ignored", ""));
+
+ Map<String, String> 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(""));
+ }
+}
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 acda548165..bc80b89ca5 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 @@ class ExecutionPerspectiveDisabledTest {
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.