Wednesday, January 16, 2013

Complex Formatting and Parsing

Complex Formatting and Parsing

Problem

In the area of internationalization (i18n) formatting and parsing is a typical use case. Unfortunately java.util.Locale as the only parameter controlling such a process has shown not to be sufficient. Refer to the following use cases:

  • Formatting of an monetary amount contains of a numeric part, as well as a currency part. Now both can be required to be formatted using different locales, e.g. 
    • German numeric format and 
    • English currency symbol.
  • But when considering display requirements for financial applications very different formats are required
    • based on the usage scenario, e.g. numbers included into balances may be totally different than numbers formatted on a account summary (different number groups, signs and symbol placements etc).
    • based on individual user settings.
    • based on the type of formatting, e.g. for display, print out or for textual representation within legacy systems.
  • When parsing a literal representation back into a type instance similar scenarios may be possible. Additionally, especially when parsing user input, the things can get even more complex:
    • the currency symbol may be English, similar to above, but...
    • the number formats supported can be in several formats, since users do not always enter the 100% correct format (which is totally OK from a user's perspective!).
    • additionally depending on use case different number precision may ve required, that must not match the fraction digits defined by the currency entered (e.g. 2 for Swiss Francs, but 0 for Japanese Yen). In some usage scenarios one even wants lenient fraction parsing to be possible.
Summarizing a Locale with its country, language, variant scheme is not sufficient to define the scenarios above. 

Solution: Defining a LocalizationStyle

I propose to model such things as LocalizationStyle, containing the following data:
  • an identifier defining the style. Different use cases (e.g. different display scenarios, as well as technical scenarios can be separated).
  • a target type, since a style typically is bound to a specific type and should not be mixed up.
  • a leading Locale, or translation Locale.
  • an (optional) number Locale, if missing falling back to the translation Locale
  • an (optional) date Locale, if missing falling back to the translation Locale
  • an (optional) time Locale, if missing falling back to the date Locale
  • any additional optional attributes
Additionally a Locale is an immutable instance. With a LocalizationStyle this would be also be feasible:
  • by defining a LocalizationStyleFactory to create immutable instances.
  • or by setting a LocaliuationStyle to read-only, when it is completely initialized and configured.
Consequently the most simple and common variant of a LocalizationStyle is basically very similar as a Locale:
  • it's identifier is set to "default". This is also visible by the corresponding method boolean isDefault(); which returns true.
  • Its leading translation Localeis set to the required Locale.
  • Since this functionaliy is quite common, it can be provided using a static factory method:
    LocalizationStyle style = LocalizationStyle .valueOf(Locale.GERMAN);

Using the LocalizationStyle for Configuring Complex Formatting and Parsing


As a consequence according formatters and parsers only must support LocalizationStyle as valid input parameters (Locale can still be supported for convenience), e.g.
  String format(T item, LocalizationStyle style);
or
  String getLocalized(LocalizationStyle style);

But now it is possible to pass much more detailed configuration what how formatter or parser must behave. Nevertheless in mny cases such formatters/parsers are not to be implemented as big and complex single data types. It is recommended to implement something like a FormatterManager/ParserManager that is able to manage the different implementations. Nevertheless since a Formatter/Parser instance can be identified by the duplet [style-id, target-type] this is not as complex as it seems on a first look.

Detailed interface

The proposed class would be as follows:

public class LocalizationStyle implements Serializable {

/**
* serialVersionUID.
*/
private static final long serialVersionUID = 8612440355369457473L;
/** The internal key used for a time locale set. */
public static final String TIME_LOCALE = "timeLocale";
/** The internal key used for a date locale set. */
public static final String DATE_LOCALE = "dateLocale";
/** The internal key used for a number locale set. */
public static final String NUMBER_LOCALE = "numberLocale";
/** The internal key used for a translation locale set (default). */
public static final String TRANSLATION_LOCALE = "locale";
/** The internal key used for a formatting/parsing style. */
private static final String DEFAULT_ID = "default";
/** The style's name, by default ({@link #DEFAULT_ID}. */
private String id;
/** The style's generic properties. */
private Map<String, Object> attributes = Collections
.synchronizedMap(new HashMap<String, Object>());

/**
* Flag to make a localization style read only, so it can be used (and
* cached) similar to a immutable object.
*/
private boolean readOnly = false;

/**
* Creates a new instance of a style. This method will use the Locale
* returned from {@link Locale#getDefault()} as the style's default locale.
* @param id
*            The style's identifier (not null).
*/
public LocalizationStyle(String id) {
this(id, Locale.getDefault());
}

/**
* Creates a new instance of a style.
* @param id
*            The style's identifier (not null).
* @param locale
*            the default locale to be used for all locale usages.
*/
public LocalizationStyle(String id, Locale locale) {
this(id, locale, locale);
}

/**
* Creates a new instance of a style.
* @param id
*            The style's identifier (not null).
* @param translationLocale
*            the default locale (translation locale) to be used for all
*            locale usages.
* @param numberLocale
*            the locale to be used for numbers.
*/
public LocalizationStyle(String id, Locale translationLocale,
Locale numberLocale) {
if (id == null) {
throw new IllegalArgumentException("ID must not be null.");
}
this.id = id;
}

/**
* Creates a new instance of a style. This method will copy all attributes
* and properties from the given style. The style created will not be
* read-only, even when the base style is read-only.
* @param baseStyle
*            The style to be used as a base style.
*/
public LocalizationStyle(LocalizationStyle baseStyle) {
this.attributes.putAll(baseStyle.getAttributes());
this.id = baseStyle.getId();
}

/**
* Allows to evaluate if a style is a default style. A style is a default
* style, if its id equals to {@link #DEFAULT_ID}.
* <p>
* Note that nevertheless multiple default style instances may be defined
* that are not equal, since its attributes may differ.
* @return true, if this style is a default style.
*/
public boolean isDefault() {
return DEFAULT_ID.equals(getId());
}

/**
* This method allows to check, if the given style can be changed or, if it
* read only.
* @return true, if the style is read-only.
*/
public final boolean isReadOnly() {
return readOnly;
}

/**
* This method renders this style instance into an immutable instance.
* Subsequent calls to {@link #setAttribute(String, Serializable)},
* {@link #setDateLocale(Locale)}, {@link #setNumberLocale(Locale)},
* {@link #setTimeLocale(Locale)}or {@link #removeAttribute(String)} will
* throw an {@link IllegalStateException}.
*/
public void setImmutable() {
this.readOnly = true;
}

/**
* Method used to simply create a {@link IllegalStateException}, if this
* instance is read-only. This prevents duplicating the corresponding code.
*/
private void throwsExceptionIfReadonly() {
if (readOnly) {
throw new IllegalStateException(
"This instance is immutable and can not be ^changed.");
}
}

/**
* Get the style's identifier, not null.
* @return the style's id.
*/
public String getId() {
return id;
}

/**
* Get the style's (default) locale used for translation of textual values,
* and (if not specified explicitly as a fallback) for date, time and
* numbers.
* @return the translation (default) locale
*/
public final Locale getTranslationLocale() {
Locale locale = (Locale) getAttribute(TRANSLATION_LOCALE);
if (locale != null) {
return locale;
}
return Locale.getDefault();
}

/**
* Get the style's locale used for formatting/parsing of numbers.
* @return the number locale
*/
public final Locale getNumberLocale() {
Locale locale = (Locale) getAttribute(NUMBER_LOCALE);
if (locale != null) {
return locale;
}
return getTranslationLocale();
}

/**
* Get the style's locale for formatting/parsing of date instances.
* @return the date locale
*/
public final Locale getDateLocale() {
Locale locale = (Locale) getAttribute(DATE_LOCALE);
if (locale != null) {
return locale;
}
return getTranslationLocale();
}

/**
* Set the style's locale for formatting/parsing of dates.
* @param locale
*            The date locale to be used, or null for falling back to the
*            translation locale.
* @return the date locale previously set, or null.
*/
public final Locale setDateLocale(Locale locale) {
return (Locale) setAttribute(DATE_LOCALE, locale);
}

/**
* Set the style's locale for formatting/parsing of time.
* @param locale
*            The time locale to be used, or null for falling back to the
*            translation locale.
* @return the time locale previously set, or null.
*/
public final Locale setTimeLocale(Locale locale) {
return (Locale) setAttribute(TIME_LOCALE, locale);
}

/**
* Set the style's locale for formatting/parsing of numbers.
* @param locale
*            The number locale to be used, or null for falling back to the
*            number locale.
* @return the number locale previously set, or null.
*/
public final Locale setNumberLocale(Locale locale) {
return (Locale) setAttribute(NUMBER_LOCALE, locale);
}

/**
* Get the style's locale for formatting/parsing of time data.
* @return the time locale
*/
public final Locale getTimeLocale() {
Locale locale = (Locale) getAttribute(TIME_LOCALE);
if (locale != null) {
return locale;
}
return getDateLocale();
}

/**
* Get the current defined properties fo this style.
* @return the properties defined
*/
public final Map<String, Object> getAttributes() {
synchronized (attributes) {
return new HashMap<String, Object>(attributes);
}
}

/**
* Sets the given property. This method is meant for adding custom
* properties. Setting a predefined property, e.g. {@link #DATE_LOCALE} will
* throw an {@link IllegalArgumentException}.
* @param key
*            The target key
* @param value
*            The target value
* @return The object previously set, or null.
* @throws IllegalArgumentException
*             if the key passed equals to a key used for a predefined
*             property.
*/
public Object setAttribute(String key, Serializable value) {
throwsExceptionIfReadonly();
synchronized (attributes) {
return attributes.put(key, value);
}
}

/**
* Read a property from this style.
* @param key
*            The property's key
* @return the current property value, or null.
*/
public Object getAttribute(String key) {
synchronized (attributes) {
return attributes.get(key);
}
}

/**
* Removes the given property. This method is meant for removing custom
* properties. Setting a predefined property, e.g. {@link #DATE_LOCALE} will
* throw an {@link IllegalArgumentException}.
* @param key
*            The key to be removed
* @return The object previously set, or null.
* @throws IllegalArgumentException
*             if the key passed equals to a key used for a predefined
*             property.
*/
public Object removeAttribute(String key) {
throwsExceptionIfReadonly();
synchronized (attributes) {
return attributes.remove(key);
}
}

/*
* (non-Javadoc)
* @see java.lang.Object#hashCode()
*/
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
synchronized (attributes) {
result = prime * result
+ ((attributes == null) ? 0 : attributes.hashCode());
}
return result;
}

/*
* (non-Javadoc)
* @see java.lang.Object#equals(java.lang.Object)
*/
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
LocalizationStyle other = (LocalizationStyle) obj;
synchronized (attributes) {
if (attributes == null) {
if (other.attributes != null)
return false;
} else if (!attributes.equals(other.attributes))
return false;
}
return true;
}

/*
* (non-Javadoc)
* @see java.lang.Object#toString()
*/
@Override
public String toString() {
synchronized (attributes) {
return "LocalizationContext [id=" + id + ", properties="
+ attributes + "]";
}
}

/**
* Factory method to create a {@link LocalizationStyle} using a single
* {@link Locale}.
* @param locale
* @return
*/
public static LocalizationStyle of(Locale locale) {
return new LocalizationStyle(DEFAULT_ID, locale);
}

}

2 comments:

  1. One may argue that the new Locale extensions support coming with JDK 7 may be also allow to model some of the aspects here, but in my opinion the mechanism there is far not flexible enough:
    * noy key/value pairs are possible
    * extensions may not more than 8 characters long
    * only digits and numbers are allowed within an extensions
    * extensions may be reordered alphabetically, so using something like 'de_DE#x-key-value-key-value' may not work.

    ReplyDelete
  2. I was pointed to a good talk, about the topic, by a colleague:
    https://www.youtube.com/watch?v=4ZXagCR9urg
    Focused on js, but I think worth to look.
    Cheers Sascha

    ReplyDelete