Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion core/src/main/java/org/apache/hop/core/Condition.java
Original file line number Diff line number Diff line change
Expand Up @@ -847,7 +847,7 @@ public IValueMeta createValueMeta() throws HopPluginException {
IValueMeta valueMeta = ValueMetaFactory.createValueMeta(name, getHopType());
valueMeta.setLength(length, precision);
valueMeta.setConversionMask(mask);
valueMeta.setDecimalSymbol(String.valueOf(Const.DEFAULT_DECIMAL_SEPARATOR));
valueMeta.setDecimalSymbol(String.valueOf(Const.getDefaultDecimalSeparator()));
valueMeta.setGroupingSymbol(null);
valueMeta.setCurrencySymbol(null);
return valueMeta;
Expand Down
86 changes: 82 additions & 4 deletions core/src/main/java/org/apache/hop/core/Const.java
Original file line number Diff line number Diff line change
Expand Up @@ -280,22 +280,100 @@ public String getMessage() {
/** The default locale for the hop environment (system defined) */
public static final Locale DEFAULT_LOCALE = Locale.getDefault();

/** The default decimal separator . or , */
/**
* The default decimal separator . or ,
*
* @deprecated captured at class-load time from {@link #DEFAULT_LOCALE}; use {@link
* #getDefaultDecimalSeparator()} to read the active regional settings at call time.
*/
@Deprecated(since = "2.20")
public static final char DEFAULT_DECIMAL_SEPARATOR =
(new DecimalFormatSymbols(DEFAULT_LOCALE)).getDecimalSeparator();

/** The default grouping separator , or . */
/**
* The default grouping separator , or .
*
* @deprecated captured at class-load time from {@link #DEFAULT_LOCALE}; use {@link
* #getDefaultGroupingSeparator()} to read the active regional settings at call time.
*/
@Deprecated(since = "2.20")
public static final char DEFAULT_GROUPING_SEPARATOR =
(new DecimalFormatSymbols(DEFAULT_LOCALE)).getGroupingSeparator();

/** The default currency symbol */
/**
* The default currency symbol
*
* @deprecated captured at class-load time from {@link #DEFAULT_LOCALE}; use {@link
* #getDefaultCurrencySymbol()} to read the active regional settings at call time.
*/
@Deprecated(since = "2.20")
public static final String DEFAULT_CURRENCY_SYMBOL =
(new DecimalFormatSymbols(DEFAULT_LOCALE)).getCurrencySymbol();

/** The default number format */
/**
* The default number format
*
* @deprecated captured at class-load time from {@link #DEFAULT_LOCALE}; use {@link
* #getDefaultNumberFormat()} to read the active regional settings at call time.
*/
@Deprecated(since = "2.20")
public static final String DEFAULT_NUMBER_FORMAT =
((DecimalFormat) (NumberFormat.getInstance())).toPattern();

/**
* Cached symbols for the regional locale they were built from.
*
* <p>These accessors are called from the {@code ValueMetaBase} constructor, so they sit on a hot
* path: building a {@link DecimalFormatSymbols} on every call would be a real cost — the same one
* {@code ValueMetaBase.getDecimalFormat()} already warns about for {@code DecimalFormat}. The
* symbols are therefore cached and rebuilt only when the FORMAT locale actually changes.
*
* <p>Both fields are written together under {@code synchronized} and read together, so a racing
* reader can never pair one locale's symbols with another locale's marker.
*/
private static DecimalFormatSymbols cachedFormatSymbols;

private static Locale cachedFormatSymbolsLocale;

private static synchronized DecimalFormatSymbols getFormatSymbols() {
Locale formatLocale = Locale.getDefault(Locale.Category.FORMAT);
if (cachedFormatSymbols == null || !formatLocale.equals(cachedFormatSymbolsLocale)) {
cachedFormatSymbols = new DecimalFormatSymbols(formatLocale);
cachedFormatSymbolsLocale = formatLocale;
}
return cachedFormatSymbols;
}

/**
* The decimal separator of the active regional settings, read at call time.
*
* <p>Prefer this over {@link #DEFAULT_DECIMAL_SEPARATOR}, which is captured when the class is
* loaded and therefore predates the regional settings being installed.
*/
public static char getDefaultDecimalSeparator() {
return getFormatSymbols().getDecimalSeparator();
}

/** The grouping separator of the active regional settings, read at call time. */
public static char getDefaultGroupingSeparator() {
return getFormatSymbols().getGroupingSeparator();
}

/** The currency symbol of the active regional settings, read at call time. */
public static String getDefaultCurrencySymbol() {
return getFormatSymbols().getCurrencySymbol();
}

/**
* The number format pattern of the active regional settings, read at call time. In practice the
* returned pattern is locale-invariant (locale-specific separators are applied later via
* DecimalFormatSymbols), so callers do not generally need to re-read it when the locale changes.
*/
public static String getDefaultNumberFormat() {
return ((DecimalFormat) NumberFormat.getInstance(Locale.getDefault(Locale.Category.FORMAT)))
.toPattern();
}

/** Default string representing Null String values (empty) */
public static final String NULL_STRING = "";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,10 +313,13 @@ protected ValueMetaBase(
this.storageType = STORAGE_TYPE_NORMAL;
this.sortedDescending = false;
this.outputPaddingEnabled = false;
this.decimalSymbol = "" + Const.DEFAULT_DECIMAL_SEPARATOR;
this.groupingSymbol = "" + Const.DEFAULT_GROUPING_SEPARATOR;
this.currencySymbol = "" + Const.DEFAULT_CURRENCY_SYMBOL;
this.dateFormatLocale = Locale.getDefault();
this.decimalSymbol = "" + Const.getDefaultDecimalSeparator();
this.groupingSymbol = "" + Const.getDefaultGroupingSeparator();
this.currencySymbol = "" + Const.getDefaultCurrencySymbol();
// FORMAT, not Locale.getDefault(): the latter is the interface language once DISPLAY and
// FORMAT are split, and a field with no explicit date locale must follow the regional
// settings rather than the GUI language.
this.dateFormatLocale = Locale.getDefault(Locale.Category.FORMAT);
this.collatorDisabled = true;
this.collatorLocale = Locale.getDefault();
this.collator = Collator.getInstance(this.collatorLocale);
Expand Down Expand Up @@ -1296,7 +1299,13 @@ private synchronized SimpleDateFormat getDateFormat(int valueMetaType) {

// Do we have a locale?
//
if (dateFormatLocale == null || dateFormatLocale.equals(Locale.getDefault())) {
// Compared against the FORMAT category, not against Locale.getDefault(): that one carries the
// interface language, so a locale deliberately picked on the field would be dismissed as "no
// locale set" whenever it happened to match the language, and the field would silently follow
// the regional settings instead of the choice.
//
if (dateFormatLocale == null
|| dateFormatLocale.equals(Locale.getDefault(Locale.Category.FORMAT))) {
if (mask != null) {
dateFormat = new SimpleDateFormat(mask);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -666,7 +666,13 @@ private synchronized SimpleDateFormat getDateFormat(int valueMetaType) {

// Do we have a locale?
//
if (dateFormatLocale == null || dateFormatLocale.equals(Locale.getDefault())) {
// Compared against the FORMAT category, not against Locale.getDefault(): that one carries the
// interface language, so a locale deliberately picked on the field would be dismissed as "no
// locale set" whenever it happened to match the language, and the field would silently follow
// the regional settings instead of the choice.
//
if (dateFormatLocale == null
|| dateFormatLocale.equals(Locale.getDefault(Locale.Category.FORMAT))) {
dateFormat = new SimpleTimestampFormat(mask);
} else {
dateFormat = new SimpleTimestampFormat(mask, dateFormatLocale);
Expand Down
216 changes: 216 additions & 0 deletions core/src/main/java/org/apache/hop/i18n/RegionalSettings.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/*
* 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.i18n;

import java.util.Arrays;
import java.util.Locale;
import java.util.TimeZone;
import org.apache.hop.core.config.HopConfig;
import org.apache.hop.core.logging.ILogChannel;
import org.apache.hop.core.logging.LogChannel;
import org.apache.hop.core.util.EnvUtil;
import org.apache.hop.core.util.Utils;

/**
* Holds the regional settings (decimal and grouping separators, currency, date formats) as a
* concern separate from the interface language, which stays under {@link LanguageChoice}.
*
* <p>The effective locale is installed in the JVM as {@link Locale.Category#FORMAT}, while {@link
* Locale#getDefault()} — the locale {@code ResourceBundle} resolves messages with — keeps carrying
* the interface language.
*/
public class RegionalSettings {

/** Where the regional settings come from. */
public enum Source {
/**
* Follow the selected interface language, so that changing the language changes the formats
* with it. This is a deliberate choice a user makes, not the source an unconfigured
* installation falls back to.
*/
LANGUAGE,
/** Inherit them from the operating system Hop is running on. */
OPERATING_SYSTEM,
/** Use an explicitly selected locale. */
CUSTOM
}

public static final String STRING_REGIONAL_SETTINGS_SOURCE = "RegionalSettingsSource";
public static final String STRING_REGIONAL_SETTINGS_LOCALE = "RegionalSettingsLocale";

/**
* The locale the JVM started with, captured before anything can overwrite it. The first {@code
* Locale.setDefault(language)} destroys this value and it cannot be recovered afterwards, so
* {@link Source#OPERATING_SYSTEM} would have nothing to read without this field.
*/
private static final Locale OPERATING_SYSTEM_LOCALE = Locale.getDefault();

private static RegionalSettings instance;

private Source source;
private Locale customLocale;

private RegionalSettings() {
reload();
}

public static synchronized RegionalSettings getInstance() {
if (instance == null) {
instance = new RegionalSettings();
}
return instance;
}

/**
* Re-reads the configuration, degrading to {@link Source#OPERATING_SYSTEM} on anything unusable.
*/
public void reload() {
String sourceValue =
HopConfig.readOptionString(STRING_REGIONAL_SETTINGS_SOURCE, Source.OPERATING_SYSTEM.name());
try {
source = Source.valueOf(sourceValue);
} catch (IllegalArgumentException e) {
LogChannel.GENERAL.logBasic(
"Unknown value '"
+ sourceValue
+ "' for option "
+ STRING_REGIONAL_SETTINGS_SOURCE
+ ", deriving regional settings from the operating system instead.");
source = Source.OPERATING_SYSTEM;
}

String localeValue = HopConfig.readOptionString(STRING_REGIONAL_SETTINGS_LOCALE, null);
customLocale = Utils.isEmpty(localeValue) ? null : EnvUtil.createLocale(localeValue);

if (source == Source.CUSTOM && !isUsable(customLocale)) {
LogChannel.GENERAL.logBasic(
"Regional settings locale '"
+ localeValue
+ "' is not available in this JVM, deriving regional settings from the operating"
+ " system instead.");
source = Source.OPERATING_SYSTEM;
}
}

/** Persists the current source and custom locale. */
public void save() {
HopConfig.getInstance().saveOption(STRING_REGIONAL_SETTINGS_SOURCE, source.name());
HopConfig.getInstance()
.saveOption(
STRING_REGIONAL_SETTINGS_LOCALE, customLocale == null ? null : customLocale.toString());
}

/** The locale actually used to format numbers, currencies and dates. */
public Locale getEffectiveLocale() {
return switch (source) {
case OPERATING_SYSTEM -> OPERATING_SYSTEM_LOCALE;
case CUSTOM -> customLocale;
case LANGUAGE -> LanguageChoice.getInstance().getDefaultLocale();
};
}

/**
* Applies the regional settings for a headless run (hop-run, hop-server, REST), so those runs
* honour the configuration of the machine they run on.
*
* <p>Distributed Beam and Spark workers are not covered by this method: they never load a {@code
* hop-config.json} in the first place, so they fall back to the default source and format with
* their own operating system locale regardless of what this method would apply.
*/
public void applyHeadless() {
// Under the default source this writes OPERATING_SYSTEM_LOCALE, which was captured from the
// JVM's own initial default — precisely what a headless run already carries, including when it
// was set with -Duser.language. Writing it back is therefore a no-op in practice.
Locale formatLocale = getEffectiveLocale();
if (formatLocale == null) {
LogChannel.GENERAL.logBasic(
"No usable regional settings locale is configured; leaving the format settings alone.");
return;
}
Locale.setDefault(Locale.Category.FORMAT, formatLocale);
logEffective(LogChannel.GENERAL, "installation:" + source.name());
}

/**
* Applies the interface language and then the regional settings, in that order.
*
* <p>The order is mandatory: {@code Locale.setDefault(Locale)} writes all three categories, so
* setting the language after the regional settings would wipe the FORMAT category. For the same
* reason the FORMAT category is always written back, even when the regional settings are derived
* from the language and the two carry the same value.
*/
public void applyGui() {
Locale.setDefault(LanguageChoice.getInstance().getDefaultLocale());
Locale formatLocale = getEffectiveLocale();
if (formatLocale == null) {
LogChannel.GENERAL.logBasic(
"No usable regional settings locale is configured; leaving the format settings alone.");
return;
}
Locale.setDefault(Locale.Category.FORMAT, formatLocale);
logEffective(LogChannel.GENERAL, "installation:" + source.name());
}

/**
* Writes the language, FORMAT locale and default timezone currently in effect. hop-gui, hop-run
* and hop-server all log this so a machine-local mismatch is visible without inspecting
* configuration files.
*
* @param log channel to write to; {@link LogChannel#GENERAL} when none is available yet
* @param sourceDescription where the FORMAT locale came from, for example {@code
* installation:CUSTOM}
*/
public static void logEffective(ILogChannel log, String sourceDescription) {
if (log == null) {
return;
}
log.logBasic(
"Regional settings: language="
+ Locale.getDefault()
+ " format="
+ Locale.getDefault(Locale.Category.FORMAT)
+ " timezone="
+ TimeZone.getDefault().getID()
+ " source="
+ sourceDescription);
}

private static boolean isUsable(Locale locale) {
return locale != null && Arrays.asList(Locale.getAvailableLocales()).contains(locale);
}

public Source getSource() {
return source;
}

public void setSource(Source source) {
this.source = source;
}

public Locale getCustomLocale() {
return customLocale;
}

public void setCustomLocale(Locale customLocale) {
this.customLocale = customLocale;
}

public Locale getOperatingSystemLocale() {
return OPERATING_SYSTEM_LOCALE;
}
}
Loading
Loading