Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
eb17603
issue #2292 : Make the empty execution locations tree more welcoming
mattcasters Jul 19, 2026
7475207
issue #2195 : Execution Information : Add a button to copy the folder…
mattcasters Jul 19, 2026
561a590
Issue #2069 : More Info Fields in Pipeline Execution Information
mattcasters Jul 19, 2026
5ae7376
Issue #2637 : Allow Custom Logging dialog to accept a variable to def…
mattcasters Jul 19, 2026
78d5e46
Issue #2400 : As a Developer I would like to be able to generate data…
mattcasters Jul 19, 2026
30ecf8c
Issue #2351 : execution information perspective tabs are not stored c…
mattcasters Jul 19, 2026
ea72c5a
Issue #2308 : environment Background Colour Configuration
mattcasters Jul 19, 2026
a48e0c4
Issue #2331 : Add variable PARENT_PROJECT_HOME
mattcasters Jul 19, 2026
3830b5f
Issue #2596 : add parent project support to Docker basic image
mattcasters Jul 19, 2026
1c69b46
Issue #2931 : expanded integer notation for large count/size fields
mattcasters Jul 19, 2026
7d6da05
Issue #2710 : Provide a way to display and output variables a project…
mattcasters Jul 19, 2026
16a6d78
issue #2597 : replace browsed paths with matching env/project path va…
mattcasters Jul 19, 2026
d76c472
Issue #2735 : default new environment name from project and purpose
mattcasters Jul 19, 2026
9407f00
issue #2195 : Execution Information : Add a button to copy the folder…
mattcasters Jul 19, 2026
a50bb8e
Issue #2400 : skip re-encoding values that already start with Encrypted
mattcasters Jul 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 231 additions & 0 deletions core/src/main/java/org/apache/hop/core/Const.java
Original file line number Diff line number Diff line change
Expand Up @@ -1291,6 +1291,237 @@ public static long toLong(String str, long def) {
return retval;
}

/**
* Pattern for scientific / power-of-ten integer forms: {@code 1e8}, {@code 1.5E+6}, {@code
* 1x10^8}, {@code 1×10^8}, {@code 10^8}.
*/
private static final Pattern EXPANDED_SCIENTIFIC_PATTERN =
Pattern.compile(
"^([+-]?)(?:([0-9][0-9_ .,]*[0-9]|[0-9])\\s*[x×]\\s*10\\s*\\^\\s*|([0-9][0-9_ .,]*)\\s*[eE]|10\\s*\\^\\s*)([+-]?\\d+)$");

/** Pattern for optional trailing magnitude suffix: k / m / g / b (case-insensitive). */
private static final Pattern EXPANDED_SUFFIX_PATTERN =
Pattern.compile("^([+-]?)(.*?)\\s*([kKmMgGbB])$");

/**
* Expand a human-friendly integer string into a plain digit form (optional leading sign).
*
* <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.
*
Expand Down
58 changes: 58 additions & 0 deletions core/src/test/java/org/apache/hop/core/ConstTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -3639,6 +3639,64 @@ void testToLong() {
assertEquals(-1447252914241L, Const.toLong("1447252914241L", -1447252914241L));
}

@Test
void testExpandIntegerString() {
assertEquals("123", Const.expandIntegerString("123"));
assertEquals("100000000", Const.expandIntegerString("100,000,000"));
assertEquals("100000000", Const.expandIntegerString("100.000.000"));
assertEquals("100000000", Const.expandIntegerString("100_000_000"));
assertEquals("100000000", Const.expandIntegerString("100 000 000"));
assertEquals("100000", Const.expandIntegerString("100k"));
assertEquals("100000", Const.expandIntegerString("100K"));
assertEquals("100000000", Const.expandIntegerString("100m"));
assertEquals("1000000000", Const.expandIntegerString("1g"));
assertEquals("1000000000", Const.expandIntegerString("1b"));
assertEquals("1500000", Const.expandIntegerString("1.5m"));
assertEquals("1500000", Const.expandIntegerString("1,5m"));
assertEquals("100000000", Const.expandIntegerString("1e8"));
assertEquals("100000000", Const.expandIntegerString("1E8"));
assertEquals("1500000", Const.expandIntegerString("1.5e6"));
assertEquals("100000000", Const.expandIntegerString("1x10^8"));
assertEquals("100000000", Const.expandIntegerString("1×10^8"));
assertEquals("100000000", Const.expandIntegerString("10^8"));
assertEquals("-1000000", Const.expandIntegerString("-1m"));
assertNull(Const.expandIntegerString(null));
assertNull(Const.expandIntegerString(""));
assertNull(Const.expandIntegerString("abc"));
assertNull(Const.expandIntegerString("1z"));
}

@Test
void testToLongExpanded() {
assertEquals(123L, Const.toLongExpanded("123", -12));
assertEquals(100_000_000L, Const.toLongExpanded("100,000,000", -1));
assertEquals(100_000_000L, Const.toLongExpanded("100.000.000", -1));
assertEquals(100_000_000L, Const.toLongExpanded("100m", -1));
assertEquals(1_500_000L, Const.toLongExpanded("1.5m", -1));
assertEquals(100_000_000L, Const.toLongExpanded("1e8", -1));
assertEquals(100_000_000L, Const.toLongExpanded("1x10^8", -1));
assertEquals(100_000_000L, Const.toLongExpanded("10^8", -1));
assertEquals(1_000_000_000L, Const.toLongExpanded("1b", -1));
assertEquals(-1_000_000L, Const.toLongExpanded("-1m", -1));
assertEquals(-12L, Const.toLongExpanded("123f", -12));
assertEquals(-12L, Const.toLongExpanded(null, -12));
assertEquals(-12L, Const.toLongExpanded("", -12));
// Plain digit strings match toLong
assertEquals(1447252914241L, Const.toLongExpanded("1447252914241", -12));
}

@Test
void testToIntExpanded() {
assertEquals(123, Const.toIntExpanded("123", -12));
assertEquals(100_000, Const.toIntExpanded("100k", -12));
assertEquals(100_000_000, Const.toIntExpanded("100m", -12));
assertEquals(-12, Const.toIntExpanded("123f", -12));
assertEquals(-12, Const.toIntExpanded(null, -12));
// Overflow past Integer.MAX_VALUE returns default
assertEquals(-12, Const.toIntExpanded("3g", -12));
assertEquals(-12, Const.toIntExpanded("10^12", -12));
}

@Test
void testToDouble() {
Assertions.assertEquals(123.45, Const.toDouble("123.45", -12.34), 1e-15);
Expand Down
4 changes: 4 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
67 changes: 55 additions & 12 deletions docker/resources/load-and-execute.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
Loading
Loading