# HG changeset patch # User Tomas Zeman # Date 1386172831 -3600 # Node ID 3060119b129236f0c6f0a90d68286c5efa38e7cb Imported web4j 4.10.0 diff -r 000000000000 -r 3060119b1292 LICENSE.txt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/LICENSE.txt Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,25 @@ + Copyright (c) 2002-2013, Hirondelle Systems + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Hirondelle Systems nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY HIRONDELLE SYSTEMS ''AS IS'' AND ANY + EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL HIRONDELLE SYSTEMS BE LIABLE FOR ANY + DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff -r 000000000000 -r 3060119b1292 README.txt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/README.txt Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,29 @@ +Thank you for your interest in WEB4J. + +This is a distribution of the web4j source code. + +You may use any build tool you wish with this code. + +An ANT script is provided as a convenience to those +who wish to use it as a starting point. + +For more information on WEB4J, see: +http://www.web4j.com/ + +-- VERSION OF JDK AND SERVLET SPEC ------------- +Web4j is compiled against : +- JDK 1.5+ +- Servlet 2.4+ +- Java Server Pages 2.0+ + +-- LICENSE ----------------------------------------- +WEB4J is open source software, released under a BSD License. +See LICENSE.txt for more information. + +-- REQUIRED JARS --------------------------------- + +The jars that web4j is being compiled against are included. +Be aware that the junit.jar provided in the lib directory is not modern. + +A MANIFEST.MF file is included; you may want to edit it's content. + diff -r 000000000000 -r 3060119b1292 build.public.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/build.public.xml Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,138 @@ + + + + + + Web4j.jar + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Application: ${app.name} ${app.version} + Build File : ${ant.file} + Run Date : ${build.time} + Run by : ${user.name} + Build Dir : ${build} + Base Dir : ${basedir} + Java Home : ${java.home} + Connected to the web : ${jdk.javadoc.visible} + + + + + + + + + + + + + + + + + + + + + + + Classpath: ${toString:compile.classpath} + + + + + + + + + + + + + + + + + + +
${app.name} ${app.version}]]>
+
+
+ + + + + + + + + + + + + + + + + Finished creating all build artifacts. + + +
\ No newline at end of file diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ApplicationInfo.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ApplicationInfo.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,67 @@ +package hirondelle.web4j; + +import hirondelle.web4j.model.DateTime; + +/** + Static, descriptive information for a web application. + +

Implemenations of this interface are usually quite simple. + Here is the + implementation + used in the WEB4J example application. + +

See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. + {@link hirondelle.web4j.BuildImpl#forApplicationInfo()} + returns the configured implementation of this interface. + +

Upon startup, WEB4J creates an object which implements this interface, + and places it into application scope under a {@link ApplicationInfo#KEY}, + where it is accessible to response pages. + (For example, it may be referenced in a footer.) + For access in code, the caller can simply refer directly to + {@link hirondelle.web4j.BuildImpl#forApplicationInfo()}. + In addition, this data is logged during startup, and contributes to + the content of {@link hirondelle.web4j.webmaster.TroubleTicket} emails. + +

No method in this interface returns a null object reference. +*/ +public interface ApplicationInfo { + + /** + The name of this web application. + */ + String getName(); + + /** + The version of this web application. + +

The content is arbitrary, and make take any desired form. Examples : + "1.2.3", "Build 1426". + */ + String getVersion(); + + /** + The author of this web application. + */ + String getAuthor(); + + /** + The date which this binary version of the web application was built. + */ + DateTime getBuildDate(); + + /** + URL for more information regarding this application. + */ + String getLink(); + + /** + A general message of arbitrary content. + */ + String getMessage(); + + /** + Key for the ApplicationInfo object placed in application scope. + */ + public static final String KEY = "web4j_key_for_app_info"; +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/BuildImpl.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/BuildImpl.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,668 @@ +package hirondelle.web4j; + +import hirondelle.web4j.database.ConnectionSource; +import hirondelle.web4j.database.ConvertColumn; +import hirondelle.web4j.database.ConvertColumnImpl; +import hirondelle.web4j.model.AppException; +import hirondelle.web4j.model.ConvertParam; +import hirondelle.web4j.model.ConvertParamError; +import hirondelle.web4j.model.ConvertParamImpl; +import hirondelle.web4j.model.ModelCtorException; +import hirondelle.web4j.model.ModelCtorUtil; +import hirondelle.web4j.request.DateConverter; +import hirondelle.web4j.request.LocaleSource; +import hirondelle.web4j.request.LocaleSourceImpl; +import hirondelle.web4j.request.RequestParser; +import hirondelle.web4j.request.RequestParserImpl; +import hirondelle.web4j.request.TimeZoneSource; +import hirondelle.web4j.request.TimeZoneSourceImpl; +import hirondelle.web4j.security.ApplicationFirewall; +import hirondelle.web4j.security.ApplicationFirewallImpl; +import hirondelle.web4j.security.LoginTasks; +import hirondelle.web4j.security.PermittedCharacters; +import hirondelle.web4j.security.PermittedCharactersImpl; +import hirondelle.web4j.security.SpamDetector; +import hirondelle.web4j.security.SpamDetectorImpl; +import hirondelle.web4j.security.UntrustedProxyForUserId; +import hirondelle.web4j.security.UntrustedProxyForUserIdImpl; +import hirondelle.web4j.ui.translate.Translator; +import hirondelle.web4j.util.TimeSource; +import hirondelle.web4j.util.TimeSourceImpl; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.webmaster.Emailer; +import hirondelle.web4j.webmaster.EmailerImpl; +import hirondelle.web4j.webmaster.LoggingConfig; +import hirondelle.web4j.webmaster.LoggingConfigImpl; + +import java.lang.reflect.Constructor; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; + +import javax.servlet.ServletConfig; + +/** + Return concrete instances of configured implementation classes. This is a Service Locator class. + +

WEB4J requires the application programmer to supply concrete implementations of a number of + interfaces and a single abstract class. BuildImpl returns instances of those abstractions. Over half + of these items have default implementations, which can be used by the application programmer + without any configuration effort at all. + +

When the framework needs a specific implementation, it uses the services of this class. + (If the application programmer needs to refer to such an implementation, they have the option of using + the methods in this class, instead of referring directly to their implementation.) + +

Configuration Styles

+ Concrete implementation classes can be configured in three ways: + + +

The {@link #init(Map)} method will look for implementations in the reverse of the above order. + That is, +

    +
  1. an explicit ImplementationFor.* setting in web.xml +
  2. a class of a conventional package and name +
  3. the default WEB4J implementation (if a default implementation exists) +
+ +

Listing of Interfaces and Conventional Names

+ Here's a listing of all interfaces used by WEB4J, along with conventional class names, and either a default + or example implementation. The package for conventional class names is always 'hirondelle.web4j.config'. + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
QuestionInterfaceConventional Impl Name, in hirondelle.web4j.configDefault/Example Implementation
What is the application's name, version, build date, and so on?{@link hirondelle.web4j.ApplicationInfo}AppInfoexample
What tasks need to be performed during startup?{@link hirondelle.web4j.StartupTasks}Startupexample
What tasks need to be performed after user login?{@link hirondelle.web4j.security.LoginTasks}Loginexample
What Action is related to each request?{@link hirondelle.web4j.request.RequestParser} (an ABC)RequestToAction{@link hirondelle.web4j.request.RequestParserImpl}
Which requests should be treated as malicious attacks?{@link hirondelle.web4j.security.ApplicationFirewall}AppFirewall{@link hirondelle.web4j.security.ApplicationFirewallImpl}
Which requests use untrusted proxies for the user id?{@link hirondelle.web4j.security.UntrustedProxyForUserId}OwnerFirewall{@link hirondelle.web4j.security.UntrustedProxyForUserIdImpl}
How is spam distinguished from regular user input?{@link hirondelle.web4j.security.SpamDetector}SpamDetect{@link hirondelle.web4j.security.SpamDetectorImpl}
How is a request param translated into a given target type?{@link hirondelle.web4j.model.ConvertParam}ConvertParams{@link hirondelle.web4j.model.ConvertParamImpl}
How does the application respond when a low level conversion error takes place when parsing user input?{@link hirondelle.web4j.model.ConvertParamError}ConvertParamErrorImplexample
What characters are permitted for text input fields?{@link hirondelle.web4j.security.PermittedCharacters}PermittedChars{@link hirondelle.web4j.security.PermittedCharactersImpl}
How is a date formatted and parsed?{@link hirondelle.web4j.request.DateConverter}DateConverterImplexample
How is a Locale derived from the request?{@link hirondelle.web4j.request.LocaleSource}LocaleSrc{@link hirondelle.web4j.request.LocaleSourceImpl}
How is the system clock defined?{@link hirondelle.web4j.util.TimeSource}TimeSrc{@link hirondelle.web4j.util.TimeSourceImpl}
How is a TimeZone derived from the request?{@link hirondelle.web4j.request.TimeZoneSource}TimeZoneSrc{@link hirondelle.web4j.request.TimeZoneSourceImpl}
What is the translation of this text, for a given Locale?{@link hirondelle.web4j.ui.translate.Translator}TranslatorImplexample
How does the application obtain a database Connection?{@link hirondelle.web4j.database.ConnectionSource}ConnectionSrcexample
How is a ResultSet column translated into a given target type?{@link hirondelle.web4j.database.ConvertColumn}ColToObject{@link hirondelle.web4j.database.ConvertColumnImpl}
How should an email be sent when a problem occurs?{@link hirondelle.web4j.webmaster.Emailer}Email{@link hirondelle.web4j.webmaster.EmailerImpl}
How should the logging system be configured?{@link hirondelle.web4j.webmaster.LoggingConfig}LogConfig{@link hirondelle.web4j.webmaster.LoggingConfigImpl}
Does this request/operation have a data ownership constraint?{@link hirondelle.web4j.security.UntrustedProxyForUserId}OwnerFirewall{@link hirondelle.web4j.security.UntrustedProxyForUserIdImpl}
+ +

No conflict between the classes of different + applications will result, if application code is placed in the usual locations + under WEB-INF, and not in shared locations accessible + to multiple web applications. Since version 2.2 of the Servlet API, each + web application gets its own {@link java.lang.ClassLoader}, so no conflict + will result, as long as classes are placed in non-shared locations (which + is almost always the case). + +

This class does not cache objects in any way. +*/ +public final class BuildImpl { + + /* + Note: some might make better use of generics here. BUT from the point of view of the + caller, the forXXX methods should remain. That way the caller does not have to + remember the interface class literal. (As well, the config settings for + intf/impl are text, not class literals.) + */ + + /** + Called by the framework upon startup. + +

Extract all configuration which maps names of abstractions to names of corresponding + concrete implementations. Confirm both that all required interfaces + have configured implementations, and that they can be loaded. + +

The implementation of {@link TimeSource} and {@link LoggingConfig} are treated slightly + differently than the rest. Their implementations are found and used earlier than the others, since they + are of immediate use. + +

See class comment for more information. + @param aConfig contains information regarding custom implementations, and information + for logging config (if any); in a servlet context, this information is extracted by the framework + from settings in web.xml. + */ + public static void init(Map aConfig) throws AppException { + useConfigSettingsFirst(aConfig); + doTimeSourceConfig(); + doLoggingConfig(aConfig); + fLogger.config("________________________ STARTUP :Initializing WEB4J Controller. Reading in settings in web.xml._________"); + useStandardOrDefaultNameSecond(); + fLogger.config("Mapping of implementation classes : " + Util.logOnePerLine(fClassMapping)); + } + + /** + Intended for contexts outside of normal servlet operation, where the caller wants to use the + web4j database and model layer only. (For example, a command-line application.) + This method uses these interfaces, in the stated order: +

+ Usually, the caller will need to supply only one of the above - ConnectionSource. + For the other items, the default implementations will usually be adequate, and no action is required. + */ + public static void initDatabaseLayer(Map aConfig) throws AppException { + useConfigSettingsFirst(aConfig); + //two items done early + doTimeSourceConfig(); + doLoggingConfig(aConfig); + fLogger.config("For items *not* specified in config, searching for implementations with 'standard' name."); + fLogger.config("If no 'standard' implementation found, then will use the WEB4J 'default' implementation."); + //does NOT include the items done 'early' + addStandardDefaultIfNotInConfig(CONNECTION_SOURCE); + addStandardDefaultIfNotInConfig(CONVERT_COLUMN); + addStandardDefaultIfNotInConfig(CONVERT_PARAM); //for supported types + addStandardDefaultIfNotInConfig(PERMITTED_CHARACTERS); //for Id class + addStandardDefaultIfNotInConfig(DATE_CONVERTER); //for reports; there's no default impl for this + addStandardDefaultIfNotInConfig(SPAM_DETECTOR); //for Checking spam + fLogger.config("Mapping of implementation classes : " + Util.logOnePerLine(fClassMapping)); + } + + /** + Map a fully-qualified aAbstractionName into a concrete implementation. + +

This method should only be used for 'non-standard' items not covered by more + specific methods in this class. For example, when looking for the implementation of {@link LocaleSource}, + the {@link #forLocaleSource()} method should always be used instead of this method. + +

Implementation classes accessed by this method must have a public no-argument + constructor. (This method is best suited for interfaces, and not abstract base classes.) + +

Uses {@link Class#newInstance}, with no arguments. If a problem occurs, a + {@link RuntimeException} is thrown. + + @param aAbstractionName package-qualified name of an interface or abstract base class, as in + "hirondelle.web4j.ApplicationInfo". + */ + public static Object forAbstraction(String aAbstractionName){ + Class implementationClass = fClassMapping.get(aAbstractionName); + if ( implementationClass == null ) { + throw new IllegalArgumentException( + "No mapping to an implementation class found, for interface or abstract base class named " + Util.quote(aAbstractionName) + ); + } + Object result = null; + try { + result = implementationClass.newInstance(); + } + catch (InstantiationException ex){ + handleCtorProblem(ex, implementationClass); + } + catch (IllegalAccessException ex) { + handleCtorProblem(ex, implementationClass); + } + return result; + } + + /** + Map a fully-qualified aAbstractBaseClassName into a concrete implementation. + +

Intended for abstract base classes (ABC's) having a public + constructor with known arguments. For example, this method is used by + the {@link hirondelle.web4j.Controller} to build an implementation of + {@link hirondelle.web4j.request.RequestParser}, by passing in a request + and response object. (Implementations of that ABC are always expected to + take those two particular constructor arguments.) + +

If a problem occurs, a {@link RuntimeException} is thrown. + + @param aAbstractBaseClassName package-qualified name of an Abstract Base Class, as in + "hirondelle.web4j.ui.RequestParser". + @param aCtorArguments List of arguments to be passed to the constructor of an + implementation class; the size of this list determines the selected constructor (by + matching the number of parameters), and the iteration order of its items corresponds + to the order of appearance of the formal constructor parameters. + */ + public static Object forAbstractionPassCtorArgs(String aAbstractBaseClassName, List aCtorArguments){ + Object result = null; + Class implClass = fClassMapping.get(aAbstractBaseClassName); + Constructor ctor = ModelCtorUtil.getConstructor(implClass, aCtorArguments.size()); + try { + result = ModelCtorUtil.buildModelObject(ctor, aCtorArguments); + } + catch (ModelCtorException ex){ + handleCtorProblem(ex, implClass); + } + return result; + } + + /** Return the configured implementation of {@link ApplicationInfo}. */ + public static ApplicationInfo forApplicationInfo(){ + return (ApplicationInfo)forAbstraction(APPLICATION_INFO.getAbstraction()); + } + + /** Return the configured implementation of {@link StartupTasks}. */ + public static StartupTasks forStartupTasks(){ + return (StartupTasks)forAbstraction(STARTUP_TASKS.getAbstraction()); + } + + /** Return the configured implementation of {@link LoginTasks}. */ + public static LoginTasks forLoginTasks(){ + return (LoginTasks)forAbstraction(LOGIN_TASKS.getAbstraction()); + } + + /** Return the configured implementation of {@link ConvertParamError}. */ + public static ConvertParamError forConvertParamError(){ + return (ConvertParamError)forAbstraction(CONVERT_PARAM_ERROR.getAbstraction()); + } + + /** Return the configured implementation of {@link ConvertColumn}. */ + public static ConvertColumn forConvertColumn(){ + return (ConvertColumn)forAbstraction(CONVERT_COLUMN.getAbstraction()); + } + + /** Return the configured implementation of {@link PermittedCharacters}. */ + public static PermittedCharacters forPermittedCharacters(){ + return (PermittedCharacters)forAbstraction(PERMITTED_CHARACTERS.getAbstraction()); + } + + /** Return the configured implementation of {@link ConnectionSource}. */ + public static ConnectionSource forConnectionSource(){ + return (ConnectionSource)forAbstraction(CONNECTION_SOURCE.getAbstraction()); + } + + /** Return the configured implementation of {@link LocaleSource}. */ + public static LocaleSource forLocaleSource(){ + return (LocaleSource)forAbstraction(LOCALE_SRC.getAbstraction()); + } + + /** + Return the configured implementation of {@link TimeSource}. + +

When testing, an application may call this method in order to use a 'fake' + system time. + +

Internally, WEB4J will always use this method when it needs the current time. + This allows a fake system time to be shared between your application and WEB4J. + */ + public static TimeSource forTimeSource(){ + return (TimeSource)forAbstraction(TIME_SRC.getAbstraction()); + } + + /** Return the configured implementation of {@link TimeZoneSource}. */ + public static TimeZoneSource forTimeZoneSource(){ + return (TimeZoneSource)forAbstraction(TIME_ZONE_SRC.getAbstraction()); + } + + /** Return the configured implementation of {@link DateConverter}. */ + public static DateConverter forDateConverter(){ + return (DateConverter)forAbstraction(DATE_CONVERTER.getAbstraction()); + } + + /** Return the configured implementation of {@link Translator}. */ + public static Translator forTranslator(){ + return (Translator)forAbstraction(TRANSLATOR.getAbstraction()); + } + + /** Return the configured implementation of {@link ApplicationFirewall}. */ + public static ApplicationFirewall forApplicationFirewall() { + return (ApplicationFirewall)forAbstraction(APP_FIREWALL.getAbstraction()); + } + + /** Return the configured implementation of {@link SpamDetector}. */ + public static SpamDetector forSpamDetector() { + return (SpamDetector)forAbstraction(SPAM_DETECTOR.getAbstraction()); + } + + /** Return the configured implementation of {@link Emailer}. */ + public static Emailer forEmailer() { + return (Emailer)forAbstraction(EMAILER.getAbstraction()); + } + + /** Return the configured implementation of {@link ConvertParam}. */ + public static ConvertParam forConvertParam() { + return (ConvertParam)forAbstraction(CONVERT_PARAM.getAbstraction()); + } + + /** Return the configured implementation of {@link UntrustedProxyForUserId}. */ + public static UntrustedProxyForUserId forOwnershipFirewall() { + return (UntrustedProxyForUserId)forAbstraction(OWNER_FIREWALL.getAbstraction()); + } + + /** + Add an implementation - intended for testing only. + +

This method allows testing code to configure a specific implementation class. + Example:

BuildImpl.adHocImplementationAdd(TimeSource.class, MyTimeSource.class);
+ Calls to this method (often in a JUnit setUp() method) should be paired with a + subsequent call to {@link #adHocImplementationRemove(Class)}. + */ + public static void adHocImplementationAdd(Class aInterface, Class aImplementationClass){ + fClassMapping.put(aInterface.getName(), aImplementationClass); + } + + /** + Remove an implementation - intended for testing only. + +

This method allows testing code to configure a specific implementation class. + Example:

BuildImpl.adHocImplementationRemove(TimeSource.class);
+ Calls to this method (often in a JUnit tearDown() method) should be paired with + a previous call to {@link #adHocImplementationAdd(Class, Class)}. + */ + public static void adHocImplementationRemove(Class aInterface){ + fClassMapping.remove(aInterface.getName()); + } + + // PRIVATE + + /** + Key - interface name (String) + Value - implementation class (Class) + */ + private static final Map> fClassMapping = new LinkedHashMap>(); + + private static final String IMPLEMENTATION_FOR = "ImplementationFor."; + private static final Logger fLogger = Util.getLogger(BuildImpl.class); + + private BuildImpl() { + //prevent construction by caller + } + + /* + Implementation Note. + Early versions of this class did not work with class literals. Problem disappeared? + */ + + private static final String STANDARD_PACKAGE = "hirondelle.web4j.config."; + + //Items with no WEB4J default + private static final StandardDefault APPLICATION_INFO = new StandardDefault(ApplicationInfo.class.getName(),"AppInfo"); + private static final StandardDefault STARTUP_TASKS = new StandardDefault(StartupTasks.class.getName(), "Startup"); + private static final StandardDefault LOGIN_TASKS = new StandardDefault(LoginTasks.class.getName(), "Login"); + private static final StandardDefault CONNECTION_SOURCE = new StandardDefault(ConnectionSource.class.getName(), "ConnectionSrc"); + private static final StandardDefault CONVERT_PARAM_ERROR = new StandardDefault(ConvertParamError.class.getName(), "ConvertParamErrorImpl"); + private static final StandardDefault TRANSLATOR = new StandardDefault(Translator.class.getName(), "TranslatorImpl"); + private static final StandardDefault DATE_CONVERTER = new StandardDefault(DateConverter.class.getName(), "DateConverterImpl"); + + //Items with a WEB4J default + private static final StandardDefault LOGGING_CONFIG = new StandardDefault(LoggingConfig.class.getName(), "LogConfig", LoggingConfigImpl.class.getName()); + private static final StandardDefault REQUEST_PARSER = new StandardDefault(RequestParser.class.getName(), "RequestToAction", RequestParserImpl.class.getName()); + private static final StandardDefault APP_FIREWALL = new StandardDefault(ApplicationFirewall.class.getName(), "AppFirewall", ApplicationFirewallImpl.class.getName()); + private static final StandardDefault CONVERT_COLUMN = new StandardDefault(ConvertColumn.class.getName(), "ConvertColumns", ConvertColumnImpl.class.getName()); + private static final StandardDefault LOCALE_SRC = new StandardDefault(LocaleSource.class.getName(), "LocaleSrc", LocaleSourceImpl.class.getName()); + private static final StandardDefault TIME_SRC = new StandardDefault(TimeSource.class.getName(), "TimeSrc", TimeSourceImpl.class.getName()); + private static final StandardDefault TIME_ZONE_SRC = new StandardDefault(TimeZoneSource.class.getName(), "TimeZoneSrc", TimeZoneSourceImpl.class.getName()); + private static final StandardDefault SPAM_DETECTOR = new StandardDefault(SpamDetector.class.getName(), "SpamDetect", SpamDetectorImpl.class.getName()); + private static final StandardDefault EMAILER = new StandardDefault(Emailer.class.getName(), "Email", EmailerImpl.class.getName()); + private static final StandardDefault CONVERT_PARAM = new StandardDefault(ConvertParam.class.getName(), "ConvertParams", ConvertParamImpl.class.getName()); + private static final StandardDefault PERMITTED_CHARACTERS = new StandardDefault(PermittedCharacters.class.getName(), "PermittedChars", PermittedCharactersImpl.class.getName()); + private static final StandardDefault OWNER_FIREWALL = new StandardDefault(UntrustedProxyForUserId.class.getName(), "OwnerFirewall", UntrustedProxyForUserIdImpl.class.getName()); + + //OTHERS? MUST add below as well... + + private static void useConfigSettingsFirst(Map aConfig) throws AppException { + for(String name : aConfig.keySet()){ + if ( name.startsWith(IMPLEMENTATION_FOR) ) { + String interfaceName = name.substring(IMPLEMENTATION_FOR.length()); + String className = aConfig.get(name); + fClassMapping.put(interfaceName, buildClassFromConfig(className)); + } + } + } + + private static void handleCtorProblem(Exception ex, Class aImplementationClass){ + String message = "Object construction by reflection failed for " + aImplementationClass.toString(); + fLogger.severe(message); + throw new RuntimeException(message, ex); + } + + /** The system time must be done first, since used everywhere, including the logging system. */ + private static void doTimeSourceConfig() throws AppException { + addStandardDefaultIfNotInConfig(TIME_SRC); + } + + /** Return the configured implementation of {@link LoggingConfig}. */ + private static LoggingConfig forLoggingConfig(){ + return (LoggingConfig)forAbstraction(LOGGING_CONFIG.getAbstraction()); + } + + /** Extract and execute the LoggingConfig, earlier than all others. */ + private static void doLoggingConfig(Map aConfig) throws AppException { + addStandardDefaultIfNotInConfig(LOGGING_CONFIG); + executeLoggingConfig(aConfig); + } + + private static void useStandardOrDefaultNameSecond() throws AppException { + fLogger.config("For items *not* specified in config, searching for implementations with 'standard' name."); + fLogger.config("If no 'standard' implementation found, then will use the WEB4J 'default' implementation."); + //does NOT include the items done 'early' + addStandardDefaultIfNotInConfig(APPLICATION_INFO ); + addStandardDefaultIfNotInConfig(CONNECTION_SOURCE ); + addStandardDefaultIfNotInConfig(CONVERT_PARAM_ERROR ); + addStandardDefaultIfNotInConfig(TRANSLATOR ); + addStandardDefaultIfNotInConfig(DATE_CONVERTER ); + addStandardDefaultIfNotInConfig(STARTUP_TASKS ); + addStandardDefaultIfNotInConfig(LOGIN_TASKS ); + addStandardDefaultIfNotInConfig(REQUEST_PARSER ); + addStandardDefaultIfNotInConfig(APP_FIREWALL ); + addStandardDefaultIfNotInConfig(CONVERT_COLUMN ); + addStandardDefaultIfNotInConfig(LOCALE_SRC ); + addStandardDefaultIfNotInConfig(TIME_ZONE_SRC ); + addStandardDefaultIfNotInConfig(SPAM_DETECTOR ); + addStandardDefaultIfNotInConfig(EMAILER ); + addStandardDefaultIfNotInConfig(CONVERT_PARAM ); + addStandardDefaultIfNotInConfig(PERMITTED_CHARACTERS ); + addStandardDefaultIfNotInConfig(OWNER_FIREWALL ); + } + + private static boolean isAlreadySpecified(String aInterfaceName){ + return fClassMapping.keySet().contains(aInterfaceName); + } + + private static Class buildClassFromConfig(String aClassName) throws AppException { + Class result = null; + try { + result = Class.forName(aClassName); + } + catch (ClassNotFoundException ex){ + throw new AppException( + "Load of configured (or default) implementation class has failed. Class.forName() failed for " + Util.quote(aClassName), ex + ); + } + return result; + } + + private static void addStandardDefaultIfNotInConfig(StandardDefault aNames) throws AppException { + if ( ! isAlreadySpecified(aNames.getAbstraction()) ){ + fClassMapping.put(aNames.getAbstraction(), buildStandardOrDefaultClass(aNames.getStandard(), aNames.getDefault())); + } + } + + private static Class buildStandardOrDefaultClass(String aStandardName, String aDefaultName) throws AppException { + Class result = null; + try { + result = Class.forName(aStandardName); + } + catch (ClassNotFoundException ex){ + if( Util.textHasContent(aDefaultName) ){ + fLogger.config("Cannot see any class with standard name " + Util.quote(aStandardName) + ". Will use default WEB4J implementation instead, named " + Util.quote(aDefaultName)); + result = useDefault(aDefaultName); + } + else { + reportMissing(aStandardName, ex); + } + } + return result; + } + + private static Class useDefault(String aDefaultName) throws AppException { + Class result = null; + try { + result = Class.forName(aDefaultName); + } + catch (ClassNotFoundException exception){ + throw new AppException( + "Load of default implementation has failed. Class.forName() failed for " + Util.quote(aDefaultName), exception + ); + } + return result; + } + + private static void reportMissing(String aStandardName, ClassNotFoundException ex) throws AppException { + String msg = "Load of configured implementation class has failed. Class.forName() failed for " + Util.quote(aStandardName); + throw new AppException(msg, ex); + } + + /** Execute the configured implementation of {@link LoggingConfig}. */ + private static void executeLoggingConfig(Map aConfig) throws AppException { + LoggingConfig loggingConfig = forLoggingConfig(); + loggingConfig.setup(aConfig); + } + + /** Gathers the standard and default class names related to an abstraction. */ + private static final class StandardDefault { + StandardDefault(String aAbstraction, String aStandard){ + fAbstraction = aAbstraction; + fStandard = STANDARD_PACKAGE + aStandard; + } + StandardDefault(String aAbstraction, String aStandard, String aDefault){ + fAbstraction = aAbstraction; + fStandard = STANDARD_PACKAGE + aStandard; + fDefault = aDefault; + } + String getAbstraction() { return fAbstraction; } + String getDefault() { return fDefault; } + String getStandard() { return fStandard; } + private String fAbstraction; + private String fStandard; + private String fDefault; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/CheckModelObjects.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/CheckModelObjects.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,119 @@ +package hirondelle.web4j; + +import hirondelle.web4j.security.SafeText; +import hirondelle.web4j.model.ModelCtorException; +import hirondelle.web4j.readconfig.ConfigReader; +import java.util.*; +import java.lang.reflect.*; +import java.util.logging.*; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.model.ConvertParam; +import hirondelle.web4j.BuildImpl; + +/** + Scan Model Objects for Cross-Site Scripting vulnerabilities, and use of unsupported base objects. + +

Here, 'base object' refers to Integer, Date, + and so on -- the basic building blocks passed to a Model Object constructor. + +

Model Objects are defined as public concrete classes having a constructor that throws + {@link hirondelle.web4j.model.ModelCtorException}. + +

The checks performed by this class are : +

    +
  • any public getXXX methods that return a String are identified as + Cross-Site Scripting vulnerabilities. Model Objects that return text in general should use {@link SafeText} instead + of {@link String}. +
  • any constructors taking an argument whose class is not supported according to + {@link hirondelle.web4j.model.ConvertParam#isSupported(Class)} are identified as errors. +
+ +

When one of these checks fails, the incident is logged as a warning. +*/ +final class CheckModelObjects { + + /** Performs checks on all Model Objects. */ + void performChecks(){ + fLogger.config("Performing checks on Model Objects for Cross-Site Scripting vulnerabilities and unsupported constructor arguments."); + Set allClasses = ConfigReader.fetchConcreteClasses(); + for (Class implClass: allClasses){ + if ( isPublicModelObject(implClass) ){ + scanForMethodsReturningString(implClass); + scanForUnsupportedCtorArgs(implClass); + } + } + logResults(); + } + + // PRIVATE // + private final ConvertParam fConvertParam = BuildImpl.forConvertParam(); + private static final Logger fLogger = Util.getLogger(CheckModelObjects.class); + private int fCountXSS; + private int fCountUnsupportedCtorArg; + + private boolean isPublicModelObject(Class aConcreteClass){ + boolean result = false; + if ( isPublic(aConcreteClass) ) { + Constructor[] ctors = aConcreteClass.getDeclaredConstructors(); + for (Constructor ctor : ctors){ + List exceptionClasses = Arrays.asList(ctor.getExceptionTypes()); + if (exceptionClasses.contains(ModelCtorException.class)) { + result = true; + break; + } + } + } + return result; + } + + private boolean isPublic(Class aClass){ + return Modifier.isPublic(aClass.getModifiers()); + } + + private boolean isPublic(Method aMethod){ + return Modifier.isPublic(aMethod.getModifiers()); + } + + private void scanForMethodsReturningString(Class aModelObject){ + Method[] methods = aModelObject.getDeclaredMethods(); + for(Method method : methods){ + if( isVulnerableMethod(method) ) { + fLogger.warning("Public Model Object named " + aModelObject.getName() + " has a public method named " + Util.quote(method.getName()) + " that returns a String. Should it return SafeText instead? Possible vulnerability to Cross-Site Scripting attack."); + fCountXSS++; + } + } + } + + private boolean isVulnerableMethod(Method aMethod){ + return isPublic(aMethod) && aMethod.getName().startsWith("get") && aMethod.getReturnType().equals(String.class); + } + + private void scanForUnsupportedCtorArgs(Class aClass){ + Constructor[] ctors = aClass.getDeclaredConstructors(); + for (Constructor ctor : ctors){ + List argClasses = Arrays.asList(ctor.getParameterTypes()); + for(Class argClass: argClasses){ + if( ! fConvertParam.isSupported(argClass) ){ + fLogger.warning("Model Object: " + aClass + ". Constructor has argument not supported by ConvertParam: " + argClass); + fCountUnsupportedCtorArg++; + } + } + } + } + + private void logResults(){ + if(fCountXSS == 0){ + fLogger.config("** SUCCESS *** : Scanned Model Objects for Cross-Site Scripting vulnerabilities. Found 0 incidents."); + } + else { + fLogger.warning("Scanned Model Objects for Cross-Site Scripting vulnerabilities. Found " + fCountXSS + " incident(s)."); + } + + if( fCountUnsupportedCtorArg == 0 ) { + fLogger.config("** SUCCESS *** : Scanned Model Objects for unsupported constructor arguments. Found 0 incidents."); + } + else { + fLogger.warning("Scanned Model Objects for unsupported constructor arguments. Found " + fCountUnsupportedCtorArg + " incident(s)."); + } + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/Controller.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/Controller.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,840 @@ +package hirondelle.web4j; + +import static hirondelle.web4j.util.Consts.NEW_LINE; +import hirondelle.web4j.action.Action; +import hirondelle.web4j.action.ResponsePage; +import hirondelle.web4j.database.ConnectionSource; +import hirondelle.web4j.database.DAOException; +import hirondelle.web4j.database.DbConfig; +import hirondelle.web4j.model.AppException; +import hirondelle.web4j.model.BadRequestException; +import hirondelle.web4j.model.DateTime; +import hirondelle.web4j.model.Id; +import hirondelle.web4j.readconfig.Config; +import hirondelle.web4j.readconfig.ConfigReader; +import hirondelle.web4j.request.RequestParser; +import hirondelle.web4j.request.RequestParserImpl; +import hirondelle.web4j.security.ApplicationFirewall; +import hirondelle.web4j.security.ApplicationFirewallImpl; +import hirondelle.web4j.security.FetchIdentifierOwner; +import hirondelle.web4j.security.UntrustedProxyForUserId; +import hirondelle.web4j.util.Stopwatch; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.util.WebUtil; +import hirondelle.web4j.webmaster.TroubleTicket; + +import java.io.IOException; +import java.util.Enumeration; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TimeZone; +import java.util.logging.Level; +import java.util.logging.Logger; + +import javax.servlet.RequestDispatcher; +import javax.servlet.ServletConfig; +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import javax.servlet.jsp.JspFactory; + +/** + Single point of entry for serving dynamic pages. + +

The application can serve content both directly (by simple, direct reference to + a JSP's URL), and indirectly, through this Controller. + +

Like almost all servlets, this class is safe for multi-threaded environments. + +

Validates user input and request parameters, interacts with a datastore, + and places problem domain model objects in scope for eventual rendering by a JSP. + Performs either a forward or a redirect, according to the instructions of the + {@link Action}. + +

Emails are sent to the webmaster when : +

    +
  • an unexpected problem occurs (the email will include extensive diagnostic + information, including a stack trace) +
  • servlet response times degrade to below a configured level +
+ +

This class is in a distinct package for two reasons : +

    +
  • to make it easier to find, since it is at the very top of the hierarchy +
  • to force the Controller to use only the public aspects of + the ui package. This ensures it remains at a high level of abstraction. +
+ +

There are key-names defined in this class (see below). Their names need to be + long-winded (web4j_key_for_...), unfortunately, in order to + avoid conflict with other tools, including your application. +*/ +public class Controller extends HttpServlet { + + /** + Name and version number of the WEB4J API. + +

Value: {@value}. +

Upon startup, this item is logged at CONFIG level. (This item is + is simply a hard-coded field in this class. It is not configured in web.xml.) + */ + public static final String WEB4J_VERSION = "WEB4J/4.10.0"; + + /** + Key name for the application's character encoding, placed in application scope + as a String upon startup. This character encoding (charset) is set + as an HTTP header for every reponse. + +

Key name: {@value}. +

Configured in web.xml. The value UTF-8 is highly recommended. + */ + public static final String CHARACTER_ENCODING = "web4j_key_for_character_encoding"; + + /** + Key name for the webmaster email address, placed in application scope + as a String upon startup. + +

Key name: {@value}. +

Configured in web.xml. + */ + public static final String WEBMASTER = "web4j_key_for_webmaster"; + + /** + Key name for the default {@link Locale}, placed in application scope + as a Locale upon startup. + +

Key name: {@value}. +

The application programmer is encouraged to use this key for any + Locale stored in session scope : the default implementation + of {@link hirondelle.web4j.request.LocaleSource} will always search for this + key in increasingly larges scopes. Thus, the default mechanism will + automatically use the user-specific Locale as an override to + the default one. + +

Configured in web.xml. + */ + public static final String LOCALE = "web4j_key_for_locale"; + + /** + Key name for the default {@link TimeZone}, placed in application scope + as a TimeZone upon startup. + +

Key name: {@value}. +

The application programmer is encouraged to use this key for any + TimeZone stored in session scope : the default implementation + of {@link hirondelle.web4j.request.TimeZoneSource} will always search for this + key in increasingly larges scopes. Thus, the default mechanism will + automatically use the user-specific TimeZone as an override to + the default one. + +

Configured in web.xml. + */ + public static final String TIME_ZONE = "web4j_key_for_time_zone"; + + /** + Key name for the most recent {@link TroubleTicket}, placed in application scope when a + problem occurs. +

Key name: {@value}. + */ + public static final String MOST_RECENT_TROUBLE_TICKET = "web4j_key_for_most_recent_trouble_ticket"; + + /** + Key name for the startup time, placed in application scope as a {@link DateTime} upon startup. +

Key name: {@value}. + */ + public static final String START_TIME = "web4j_key_for_start_time"; + + /** + Key name for the URI for the current request, placed in request scope as a String. + +

Key name: {@value}. +

Somewhat bizarrely, the servlet API does not allow direct access to this item. + */ + public static final String CURRENT_URI = "web4j_key_for_current_uri"; + + /** + Perform operations to be executed only upon startup of + this application, and not during its regular operation. + +

Operations include : +

    +
  • log version and configuration information +
  • distribute configuration information in web.xml to the various + parts of WEB4J +
  • place an {@link ApplicationInfo} object into application scope +
  • place the configured character encoding into application scope, for use in JSPs +
  • call {@link StartupTasks#startApplication(ServletConfig, String)}, to + allow the application to perform its own startup tasks +
  • perform various validations +
+ +

One or more of the application's databases may not be running when + the web application starts. Upon startup, this Controller first queries each database + for simple name and version information. If that query fails, then the database is + assumed to be "down", and the app's implementation of {@link StartupTasks} + (which usually fetches code tables from the database) is not called. + +

The web app, however, will not terminate. Instead, this Controller will keep + attempting to connect for each incoming request. When all databases are + determined to be healthy, the Controller will perform the database initialization + tasks it usually performs upon startup, and the app will then function normally. + +

If the database subsequently goes down again, then this Controller will not take + any special action. Instead, the container's connection pool should be configured to + attempt to reconnect automatically on the application's behalf. + */ + @Override public final void init(ServletConfig aConfig) throws ServletException { + super.init(aConfig); + fServletConfig = aConfig; + try { + Stopwatch stopwatch = new Stopwatch(); + stopwatch.start(); + + Map configMap = asMap(aConfig); + //the Config class stores settings internally as static items + //after this point, any class can get its config data just by using Config as a normal object + Config.init(configMap); + fConfig = new Config(); + + //any use of logging before this line will fail + //first load of application-specific classes; configures and begins logging as well + BuildImpl.init(configMap); + + displaySystemProperties(); + displayConfigInfo(aConfig); //all items, for both app and framework + setCharacterEncodingAndPutIntoAppScope(aConfig); + putWebmasterEmailAddressIntoAppScope(aConfig); + putDefaultLocaleIntoAppScope(aConfig); + putDefaultTimeZoneIntoAppScope(aConfig); + putStartTimeIntoAppScope(aConfig); + fLogger.fine("System properties and first app-scope items completed " + stopwatch + " after start."); + + /* + Implementation Note + There are strong order dependencies here: ConfigReader is used later in the + init of SqlStatement, for example. + */ + ConfigReader.init(aConfig.getServletContext()); + WebUtil.init(aConfig); + + //This will be the first loading of application-specific classes. + //This will cause static fields to be initialized. + ApplicationInfo appInfo = BuildImpl.forApplicationInfo(); + displayVersionInfo(aConfig, appInfo); + placeAppInfoIntoAppScope(aConfig, appInfo); + + TroubleTicket.init(aConfig); + + fLogger.config("Calling ConnectionSource.init(ServletConfig)."); + ConnectionSource connSource = BuildImpl.forConnectionSource(); + connSource.init(configMap); + fLogger.fine("Init of internal classes, ConnectionSource completed " + stopwatch + " after start."); + Config.checkDbNamesInSettings(BuildImpl.forConnectionSource().getDatabaseNames()); + + tryDatabaseInitAndStartupTasks(connSource); + fLogger.fine("Database init and startup tasks " + stopwatch + " after start."); + + CheckModelObjects checkModelObjects = new CheckModelObjects(); + checkModelObjects.performChecks(); + stopwatch.stop(); + fLogger.fine("Cross-Site Scripting scan completed " + stopwatch + " after start."); + + fLogger.config("*** SUCCESS : STARTUP COMPLETED SUCCESSFULLY for " + appInfo + ". Total startup time : " + stopwatch ); + } + catch (AppException ex) { + throw new ServletException(ex); + } + } + + /** Log the name and version of the application. */ + @Override public void destroy() { + ApplicationInfo appInfo = BuildImpl.forApplicationInfo(); + fLogger.config("Shutting Down Controller for " + appInfo.getName() + "/" + appInfo.getVersion()); + } + + /** Call {@link #processRequest}. */ + @Override public final void doGet(HttpServletRequest aRequest, HttpServletResponse aResponse) throws IOException { + logClasses(aRequest, aResponse); + processRequest(aRequest, aResponse); + } + + /** Call {@link #processRequest}. */ + @Override public final void doPost(HttpServletRequest aRequest, HttpServletResponse aResponse) throws IOException { + logClasses(aRequest, aResponse); + processRequest(aRequest, aResponse); + } + + /** Call {@link #processRequest}. PUT can be called by XmlHttpRequest. */ + @Override public final void doPut(HttpServletRequest aRequest, HttpServletResponse aResponse) throws IOException { + logClasses(aRequest, aResponse); + processRequest(aRequest, aResponse); + } + + /** Call {@link #processRequest}. DELETE can be called by XmlHttpRequest. */ + @Override public final void doDelete(HttpServletRequest aRequest, HttpServletResponse aResponse) throws IOException { + logClasses(aRequest, aResponse); + processRequest(aRequest, aResponse); + } + + /** + Handle all HTTP requests for GET, POST, PUT, and DELETE requests. + All of these HTTP methods will funnel through this Java method; any other methods will be handled by the container. + If a subclass needs to know the underlying HTTP method, then it must call {@link HttpServletRequest#getMethod()}. + +

This method can be overridden, if desired. The great majority of applications will not need + to override this method. + +

Operations include : +

    +
  • set the request character encoding (using the value configured in web.xml) +
  • set the charset HTTP header for the response (using the value configured in web.xml) +
  • react to a successful user login, using the configured implementation of {@link hirondelle.web4j.security.LoginTasks} +
  • get an instance of {@link RequestParser} +
  • get its {@link Action}, and execute it +
  • check for an ownership constraint (see {@link UntrustedProxyForUserId}) +
  • perform either a forward or a redirect to the Action's {@link hirondelle.web4j.action.ResponsePage} +
  • if an unexpected problem occurs, create a {@link TroubleTicket}, log it, and + email it to the webmaster email address configured in web.xml +
  • if the response time exceeds a configured threshold, build a + {@link TroubleTicket}, log it, and email it to the webmaster address configured in web.xml +
+ */ + protected void processRequest(HttpServletRequest aRequest, HttpServletResponse aResponse) throws IOException { + Stopwatch stopwatch = new Stopwatch(); + stopwatch.start(); + + aRequest.setCharacterEncoding(fConfig.getCharacterEncoding()); + aResponse.setCharacterEncoding(fConfig.getCharacterEncoding()); + + addCurrentUriToRequest(aRequest, aResponse); + RequestParser requestParser = RequestParser.getInstance(aRequest, aResponse); + try { + LoginTasksHelper loginHelper = new LoginTasksHelper(); + loginHelper.reactToNewLogins(aRequest); + Action action = requestParser.getWebAction(); + ApplicationFirewall appFirewall = BuildImpl.forApplicationFirewall(); + appFirewall.doHardValidation(action, requestParser); + logAttributesForAllScopes(aRequest); + recheckBadDatabasesAndFinishStartup(); + ResponsePage responsePage = checkOwnershipThenExecuteAction(action, requestParser); + if ( responsePage.hasBinaryData() ) { + fLogger.fine("Serving binary data. Controller not performing a forward or redirect."); + } + else { + if ( responsePage.getIsRedirect() ) { + redirect(responsePage, aResponse); + } + else { + forward(responsePage, aRequest, aResponse); + } + } + stopwatch.stop(); + if ( stopwatch.toValue() >= fConfig.getPoorPerformanceThreshold() ) { + logAndEmailPerformanceProblem(stopwatch.toString(), aRequest); + } + } + catch (BadRequestException ex){ + //NOTE : sendError() commits the response. + if( Util.textHasContent(ex.getErrorMessage()) ){ + aResponse.sendError(ex.getStatusCode(), ex.getErrorMessage()); + } + else { + aResponse.sendError(ex.getStatusCode()); + } + } + catch (Throwable ex) { + //Includes AppException, Bugs, or rare conditions, for example datastore failure + logAndEmailSeriousProblem(ex, aRequest); + aResponse.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE); + } + } + + /** + Change the {@link ResponsePage} according to {@link Locale}. + +

This overridable default implementation does nothing, and returns null. + If the return value of this method is null, then the nominal ResponsePage + will be used without alteration. If the return value of this method is not null, + then it will be used to override the nominal ResponsePage. + +

This method is intended for applications that use different JSPs for different Locales. + For example, if the nominal response is a forward to Blah_en.jsp, and the "real" + response should be Blah_fr.jsp, then this method can be overridden to return the + appropriate {@link ResponsePage}. This method is called only for + forward operations. If it is overridden, then its return value must also correspond to a forward + operation. + +

This style of implementing translation is not recommended. + Instead, please use the services of the hirondelle.web4j.ui.translate package. + */ + protected ResponsePage swapResponsePage(ResponsePage aResponsePage, Locale aLocale){ + return null; //does nothing + } + + /** + Inform the webmaster of an unexpected problem with the deployed application. + +

Typically called when an unexpected Exception occurs in + {@link #processRequest}. Uses {@link TroubleTicket#mailToRecipients()}. + +

Also, stores the trouble ticket in application scope, for possible + later examination. + */ + protected final void logAndEmailSeriousProblem (Throwable aException, HttpServletRequest aRequest) { + TroubleTicket troubleTicket = new TroubleTicket(aException, aRequest); + fLogger.severe("TOP LEVEL CATCHING Throwable"); + fLogger.severe( troubleTicket.toString() ); + log("SERIOUS PROBLEM OCCURRED."); + log( troubleTicket.toString() ); + fServletConfig.getServletContext().setAttribute(MOST_RECENT_TROUBLE_TICKET, troubleTicket); + try { + troubleTicket.mailToRecipients(); + } + catch (AppException exception){ + fLogger.severe("Unable to send email: " + exception.getMessage()); + } + } + + /** + Inform the webmaster of a performance problem. + +

Called only when the response time of a request is above the threshold + value configured in web.xml. + +

Builds a Throwable with a description of the problem, then creates and + emails a {@link TroubleTicket} to the webmaster. + + @param aMilliseconds response time of a request in milliseconds + */ + protected final void logAndEmailPerformanceProblem(String aMilliseconds, HttpServletRequest aRequest) { + String message = + "Response time of web application exceeds configured performance threshold." + NEW_LINE + + "Time : " + aMilliseconds + ; + Throwable ex = new Throwable(message); + TroubleTicket troubleTicket = new TroubleTicket(ex, aRequest); + fLogger.severe("Poor response time : " + aMilliseconds); + fLogger.severe( troubleTicket.toString() ); + log("Poor response time : " + aMilliseconds + " milliseconds"); + log( troubleTicket.toString() ); + try { + troubleTicket.mailToRecipients(); + } + catch(AppException exception){ + fLogger.severe("Unable to send email: " + exception.getMessage()); + } + } + + // PRIVATE + + /** + Mutable field. Must be accessed in a thread-safe way after init finished. + Assumes that all databases are initially down; each is removed from this set, when it + has been detected as being up. Possibly empty. + */ + private Set fBadDatabases = new LinkedHashSet(); + + /** Mutable field. Must be accessed in a thread-safe way after init finished. */ + private StartupTasks fStartupTasks; + + /** + The config must be saved. It is not accessible from a request, or from the context. + It may be needed after startup, should no db connections be initially available. + */ + private static ServletConfig fServletConfig; + private Config fConfig; + +// /** Item configured in web.xml. */ +// private static final InitParam fPoorPerformanceThreshold = new InitParam( +// "PoorPerformanceThreshold", "20" +// ); +// /** +// If any request takes longer than this many nanoseconds to be processed, then +// an email is sent to the webmaster. The web.xml states this configured time in +// seconds, but nanoseconds is used by this class to perform the comparison. +// */ +// private static long fPOOR_PERFORMANCE_THRESHOLD; +// +// /** Item configured in web.xml. */ +// private static final InitParam fCharacterEncoding = new InitParam( +// "CharacterEncoding", "UTF-8" +// ); +// /** +// Character encoding for this application. +// +//

The Controller will assume that every request will have this +// character encoding. In addition, this value will be placed in an +// application scope attribute named {@link Controller#CHARACTER_ENCODING}; +// */ +// private static String fCHARACTER_ENCODING; +// +// /** Item configured in web.xml. */ +// private static final InitParam fDefaultLocale = new InitParam( +// "DefaultLocale", "en" +// ); +// /** +// Default Locale for this application. +// +//

Placed in an app scope attribute named {@link Controller#LOCALE} (as a +// Locale object, not as a String). +// */ +// private static String fDEFAULT_LOCALE; +// +// private static final InitParam fDefaultTimeZone = new InitParam( +// "DefaultUserTimeZone", "GMT" +// ); +// /** +// Default TimeZone for this application. +// +//

Placed in an app scope attribute named {@link Controller#TIME_ZONE} (as a +// TimeZone object, not as a String). +// */ +// private static String fDEFAULT_TIME_ZONE; +// +// /** Item configured in web.xml. */ +// private static final InitParam fWebmaster = new InitParam("Webmaster"); +// +// /** +// Webmaster email address for this application. +// +//

This value will be placed in an application scope attribute +// named {@link Controller#WEBMASTER}; +// */ +// private static String fWEBMASTER; + + private static final boolean DO_NOT_CREATE_SESSION = false; + + private static final String OWNERSHIP_NO_SESSION = + "According to the configured UntrustedProxyForUserId implementation, the requested operation has an ownership constraint. " + + "However, this request has no session, and ownership constraints work only when the user has logged in." + ; + + private static final String OWNERSHIP_NO_LOGIN = + "According to the configured UntrustedProxyForUserId implementation, the requested operation has an ownership constraint. " + + "A session exists, but there is no valid login, and ownership constraints work only when the user has logged in." + ; + + private static final Logger fLogger = Util.getLogger(Controller.class); + + private void logClasses(HttpServletRequest aRequest, HttpServletResponse aResponse) { + fLogger.finest("Request class :" + aRequest.getClass()); + fLogger.finest("Response class :" + aResponse.getClass()); + } + + private void redirect ( + ResponsePage aDestinationPage, HttpServletResponse aResponse + ) throws IOException { + String urlWithSessionID = aResponse.encodeRedirectURL(aDestinationPage.toString()); + fLogger.fine("REDIRECT: " + Util.quote(urlWithSessionID)); + aResponse.sendRedirect( urlWithSessionID ); + } + + private void forward ( + ResponsePage aResponsePage, HttpServletRequest aRequest, HttpServletResponse aResponse + ) throws ServletException, IOException { + ResponsePage responsePage = possiblyAlterForLocale(aResponsePage, aRequest); + RequestDispatcher dispatcher = aRequest.getRequestDispatcher(responsePage.toString()); + fLogger.fine("Forward : " + responsePage); + dispatcher.forward(aRequest, aResponse); + } + + private ResponsePage possiblyAlterForLocale(ResponsePage aNominalForward, HttpServletRequest aRequest){ + Locale locale = BuildImpl.forLocaleSource().get(aRequest); + ResponsePage langSpecificForward = swapResponsePage(aNominalForward, locale); + if ( langSpecificForward != null && langSpecificForward.getIsRedirect() ){ + throw new RuntimeException( + "A 'forward' ResponsePage has been altered for Locale, but is no longer a forward : " + langSpecificForward + ); + } + return (langSpecificForward != null) ? langSpecificForward : aNominalForward; + } + + private Map asMap(ServletConfig aConfig){ + Map result = new LinkedHashMap(); + Enumeration initParamNames = aConfig.getInitParameterNames(); + while ( initParamNames.hasMoreElements() ){ + String name = (String)initParamNames.nextElement(); + String value = aConfig.getInitParameter(name); + result.put(name, value); + } + return result; + } + + private void displaySystemProperties(){ + String sysProps = Util.logOnePerLine(System.getProperties()); + fLogger.config("System Properties " + sysProps); + } + + private void displayVersionInfo(ServletConfig aConfig, ApplicationInfo aAppInfo){ + ServletContext context = aConfig.getServletContext(); + Map info = new LinkedHashMap(); + info.put("Application", aAppInfo.getName() + "/" + aAppInfo.getVersion()); + info.put("Server", context.getServerInfo()); + info.put("Servlet API Version", context.getMajorVersion() + "." + context.getMinorVersion() ); + if( JspFactory.getDefaultFactory() != null) { + //this item is null when outside the normal runtime environment. + info.put("Java Server Page API Version", JspFactory.getDefaultFactory().getEngineInfo().getSpecificationVersion()); + } + info.put("Java Runtime Environment (JRE)", System.getProperty("java.version")); + info.put("Operating System", System.getProperty("os.name") + "/" + System.getProperty("os.version") ); + info.put("WEB4J Version", WEB4J_VERSION); + fLogger.config("Versions" + Util.logOnePerLine(info)); + } + + private void displayConfigInfo(ServletConfig aConfig){ + fLogger.config( + "Context Name : " + Util.quote(aConfig.getServletContext().getServletContextName()) + ); + + Enumeration ctxParamNames = aConfig.getServletContext().getInitParameterNames(); + Map ctxParams = new LinkedHashMap(); + while ( ctxParamNames.hasMoreElements() ){ + String name = (String)ctxParamNames.nextElement(); + String value = aConfig.getServletContext().getInitParameter(name); + ctxParams.put(name, value); + } + fLogger.config( "Context Params : " + Util.logOnePerLine(ctxParams)); + + Enumeration initParamNames = aConfig.getInitParameterNames(); + Map initParams = new LinkedHashMap(); + while ( initParamNames.hasMoreElements() ){ + String name = (String)initParamNames.nextElement(); + String value = aConfig.getInitParameter(name); + initParams.put(name, value); + } + fLogger.config( "Servlet Params : " + Util.logOnePerLine(initParams)); + } + + private void setCharacterEncodingAndPutIntoAppScope(ServletConfig aConfig){ + aConfig.getServletContext().setAttribute(CHARACTER_ENCODING, fConfig.getCharacterEncoding()); + } + + private void putWebmasterEmailAddressIntoAppScope(ServletConfig aConfig){ + aConfig.getServletContext().setAttribute(WEBMASTER, fConfig.getWebmaster()); + } + + private void putDefaultLocaleIntoAppScope(ServletConfig aConfig){ + aConfig.getServletContext().setAttribute(LOCALE, fConfig.getDefaultLocale()); + } + + private void putDefaultTimeZoneIntoAppScope(ServletConfig aConfig){ + aConfig.getServletContext().setAttribute(TIME_ZONE, fConfig.getDefaultUserTimeZone()); + } + + private void putStartTimeIntoAppScope(ServletConfig aConfig){ + aConfig.getServletContext().setAttribute(START_TIME, DateTime.now(fConfig.getDefaultUserTimeZone())); + } + + private void placeAppInfoIntoAppScope(ServletConfig aConfig, ApplicationInfo aAppInfo){ + aConfig.getServletContext().setAttribute( + ApplicationInfo.KEY, aAppInfo + ); + } + + /** + Log attributes stored in the various scopes. + */ + private void logAttributesForAllScopes(HttpServletRequest aRequest){ + //the following style is conservative, and is meant to avoid calls which may be expensive + //remember that the level of the HANDLER affects whether the item is emitted as well. + if( fLogger.getLevel() != null && fLogger.getLevel().equals(Level.FINEST) ) { + fLogger.finest("Application Scope Items " + Util.logOnePerLine(getApplicationScopeObjectsForLogging(aRequest))); + fLogger.finest("Session Scope Items " + Util.logOnePerLine(getSessionScopeObjectsForLogging(aRequest))); + fLogger.finest("Request Parameter Names " + Util.logOnePerLine(getRequestParamNamesForLogging(aRequest))); + } + } + + /** + Return Map of name-value pairs of items in application scope. + +

In many cases, the actual data will be quite lengthy. For instance, translation data is often + sizeable. Thus, this should be called only when logging at the highest level. + Logging should only be performed after the {@link ApplicationFirewall} has executed. + */ + private Map getApplicationScopeObjectsForLogging(HttpServletRequest aRequest){ + Map result = new LinkedHashMap(); + HttpSession session = aRequest.getSession(DO_NOT_CREATE_SESSION); + if ( session != null ){ + ServletContext appScope = session.getServletContext(); + Enumeration objNames = appScope.getAttributeNames(); + while ( objNames.hasMoreElements() ){ + String name = (String)objNames.nextElement(); + result.put(name, appScope.getAttribute(name)); + } + } + return result; + } + + /** + Return a Map of keys and objects for each session attribute. + Logging should only be performed after the {@link ApplicationFirewall} has executed. + */ + private Map getSessionScopeObjectsForLogging(HttpServletRequest aRequest){ + Map result = new LinkedHashMap(); + HttpSession session = aRequest.getSession(DO_NOT_CREATE_SESSION); + if ( session != null ){ + result.put( "(Session Created) : ", DateTime.forInstant(session.getCreationTime(), fConfig.getDefaultUserTimeZone())); + result.put( "(Session Timeout - seconds) : ", new Integer(session.getMaxInactiveInterval()) ); + Enumeration objNames = session.getAttributeNames(); + while ( objNames.hasMoreElements() ){ + String name = (String)objNames.nextElement(); + result.put(name, session.getAttribute(name)); + } + } + return result; + } + + /** + Return a Map of key names, objects for each request scope attribute. + Logging should only be performed after the {@link ApplicationFirewall} has executed. + */ + private Map getRequestParamNamesForLogging(HttpServletRequest aRequest) { + Map result = new LinkedHashMap(); + Map input = aRequest.getParameterMap(); + Iterator iter = input.keySet().iterator(); + while( iter.hasNext() ) { + String key = (String)iter.next(); + result.put(key, aRequest.getAttribute(key)); + } + return result; + } + + private void addCurrentUriToRequest(HttpServletRequest aRequest, HttpServletResponse aResponse){ + String currentURI = WebUtil.getOriginalRequestURL(aRequest, aResponse); + aRequest.setAttribute(CURRENT_URI, currentURI); + } + + private void tryDatabaseInitAndStartupTasks(ConnectionSource aConnSrc) throws DAOException, AppException { + fLogger.config("Trying database init and startup tasks."); + fStartupTasks = BuildImpl.forStartupTasks(); + Set dbNames = aConnSrc.getDatabaseNames(); + fBadDatabases.addAll(dbNames); //guilty till proven innocent + if (aConnSrc.getDatabaseNames().isEmpty()) { + fLogger.config("No databases in this application, since ConnectionSource returns an empty Set for database names."); + startTasksWithNoDb(); + } + else { + fLogger.config("Attempting data layer startup tasks."); + Set healthyDbs = DbConfig.initDataLayer(); //reads in .sql + for (String healthyDb : healthyDbs){ + fBadDatabases.remove(healthyDb); + } + startTasksWithNoDb(); + //start-tasks for the good databases can be run now; the bad ones run later, when they get healthy + for(String dbName : dbNames){ + if (! fBadDatabases.contains(dbName)){ + fLogger.config("Startup tasks for database: " + dbName); + fStartupTasks.startApplication(fServletConfig, dbName); + } + } + if (! fBadDatabases.isEmpty()){ + fLogger.config("Databases seen to be down at startup: " + fBadDatabases); + } + } + } + + private void startTasksWithNoDb() throws AppException{ + initDefaultImplementations(); + fLogger.config("Startup tasks not needing a database."); + fStartupTasks.startApplication(fServletConfig, ""); //tasks not related to a database at all are done first + } + + /** + Warning - this method is called after startup. Therefore it must be thread-safe. + When a database goes from 'bad' to 'good', then this Controller needs to acquire + a lock on an object; in a sense, it temporarily goes back to 'init-mode', which is + single-threaded. That is, it's possible that N callers can detect a + bad-to-good transition quasi-simultaneously; they will need to compete for the lock. + But this only happens when there's a change; it doesn't happen for every + invocation of this method. In practice, this small amount of possible blocking + will be acceptable. + */ + private void recheckBadDatabasesAndFinishStartup() throws AppException{ + if (! fBadDatabases.isEmpty()){ + Iterator bad = fBadDatabases.iterator(); + while(bad.hasNext()){ + String thisDb = bad.next(); + boolean healthy = DbConfig.checkHealthOf(thisDb); + if (healthy) { + synchronized (fBadDatabases) { + if(fBadDatabases.contains(thisDb)){ //note the second check, to avoid race conditions + fStartupTasks.startApplication(fServletConfig, thisDb); + bad.remove(); + } + } + } + } + } + } + + /** + Must call just before {@link StartupTasks}. + +

This ensures WEB4J does not mistakenly perform such initialization at a time other than + that available to {@link StartupTasks}. If custom impl's are used, the only place to init them + is in StartupTasks. It is prudent to do the init of default impls at the same place, to ensure + the defaults don't 'cheat', or have any unfair advantage over custom impls. + */ + private void initDefaultImplementations(){ + fLogger.config("Initializing web4j default implementations."); + ApplicationFirewallImpl.init(); + RequestParserImpl.initWebActionMappings(); + } + + private ResponsePage checkOwnershipThenExecuteAction(Action aAction, RequestParser aRequestParser) throws AppException, BadRequestException { + UntrustedProxyForUserId ownershipFirewall = BuildImpl.forOwnershipFirewall(); + if ( ownershipFirewall.usesUntrustedIdentifier(aRequestParser) ) { + fLogger.fine("This request has an ownership constraint."); + enforceOwnershipConstraint(aAction, aRequestParser); + } + else { + fLogger.fine("No ownership constraint detected."); + if(aAction instanceof FetchIdentifierOwner) { + fLogger.warning("Action implements FetchIdentifierOwner, but no ownership constraint is defined in web.xml for this specific operation."); + } + } + return aAction.execute(); + } + + private void enforceOwnershipConstraint(Action aAction, RequestParser aRequestParser) throws AppException, BadRequestException { + if (aAction instanceof FetchIdentifierOwner ) { + FetchIdentifierOwner constraint = (FetchIdentifierOwner)aAction; + Id owner = constraint.fetchOwner(); + String ownerText = (owner == null ? null : owner.getRawString()); + HttpSession session = aRequestParser.getRequest().getSession(DO_NOT_CREATE_SESSION); + if( session == null ) { + ownershipConstraintNotImplementedCorrectly(OWNERSHIP_NO_SESSION); + } + if( aRequestParser.getRequest().getUserPrincipal() == null ) { + ownershipConstraintNotImplementedCorrectly(OWNERSHIP_NO_LOGIN); + } + String loggedInUserName = aRequestParser.getRequest().getUserPrincipal().getName(); + if ( ! loggedInUserName.equals(ownerText) ) { + fLogger.severe( + "Violation of an ownership constraint! " + + "The currently logged in user-name ('" + loggedInUserName + "') does not match the name of the data-owner ('" + ownerText + "')." + ); + throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST, "Ownership Constraint has been violated."); + } + } + else { + ownershipConstraintNotImplementedCorrectly( + "According to the configured UntrustedProxyForUserId implementation, the requested operation has an ownership constraint. " + + "Such constraints require the Action to implement the FetchIdentifierOwner interface, but this Action doesn't implement that interface." + ); + } + fLogger.fine("Ownership constraint has been validated."); + } + + private void ownershipConstraintNotImplementedCorrectly(String aMessage){ + fLogger.severe(aMessage + " Please see the User Guide for more information on Ownership Constraints."); + throw new RuntimeException("Ownership Constraint not implemented correctly."); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/LoginTasksHelper.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/LoginTasksHelper.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,39 @@ +package hirondelle.web4j; + +import hirondelle.web4j.model.AppException; +import hirondelle.web4j.security.LoginTasks; +import hirondelle.web4j.util.Util; + +import java.util.logging.Logger; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; + +final class LoginTasksHelper { + + void reactToNewLogins(HttpServletRequest aRequest) throws AppException { + HttpSession session = aRequest.getSession(DO_NOT_CREATE); + if ( sessionExists(session) ){ + if( userHasLoggedIn(aRequest) ){ + LoginTasks loginTasks = BuildImpl.forLoginTasks(); + if(! loginTasks.hasAlreadyReacted(session)){ + fLogger.fine("New login detected."); + loginTasks.reactToUserLogin(session, aRequest); + } + } + } + } + + // PRIVATE + + private static final boolean DO_NOT_CREATE = false; + private static final Logger fLogger = Util.getLogger(Controller.class); + + private boolean sessionExists(HttpSession aSession){ + return aSession != null; + } + + private boolean userHasLoggedIn(HttpServletRequest aRequest){ + return aRequest.getUserPrincipal() != null; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/StartupTasks.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/StartupTasks.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,79 @@ +package hirondelle.web4j; + +import javax.servlet.ServletConfig; + +import hirondelle.web4j.model.AppException; +import hirondelle.web4j.database.ConnectionSource; + +/** + Perform startup tasks. + +

See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. + {@link hirondelle.web4j.BuildImpl#forStartupTasks()} + returns the configured implementation of this interface. + +

Allows the application programmer to perform any needed initialization tasks. + +

These tasks are performed only once, upon startup (caveat below). + For example, the application may need to place items into application scope. Here's an example + implementation + taken from an example application. + +

+ See {@link hirondelle.web4j.database.ConnectionSource#init(java.util.Map)}, for startup + tasks related to database connections. + +

Startup tasks often depend on a database. If all of your databases are running when your app + starts up, then all startup tasks will be completed during start-up. + +

However, sometimes a database may be down when your application starts up. + In that case, web4j will keep track of which databases are down. Later, when a request comes into its Controller, + it will re-test each offending database, to see if it's now running; when the database is seen to be running, then + web4j will then pass the name of the database to {@link #startApplication(ServletConfig, String)}. + (This processing takes place after startup, and thus, in a multi-threaded environment. The framework performs + the necessary external synchronization of this class.) +*/ +public interface StartupTasks { + + /** + Perform any startup tasks needed by the application. +

Possible tasks include: +

    +
  • disseminate values configured in web.xml +
  • place items into application scope, such as code tables read from the database +
  • set the default {@link java.util.TimeZone} +
+ +

This method is called by WEB4J near the end of startup processing. + It's first called with an empty String for the database name; this is intended for any + tasks that may be unrelated to any database at all. + It's then called again, once for each database defined by {@link ConnectionSource#getDatabaseNames()} (in + the iteration order of the Set returned by that method). + +

Example of a typical implementation: +

+public void startApplication(ServletConfig aConfig, String aDbName) throws DAOException {
+  if (!Util.textHasContent(aDbName)){
+    //tasks not related to any db
+  }
+  else if (ConnectionSrc.DEFAULT.equals(aDbName)){
+    //tasks related to this db
+  }
+  else if (ConnectionSrc.TRANSLATION.equals(aDbName)){
+    //tasks related to this db
+  }
+}
+ +

This method is called N+1 times by the framework, where N is the number of databases. + The framework has confirmed that the database is running before it calls this method. + + @param aConfig is not a Map, as in other interfaces, since startup tasks + often need more than just settings in web.xml - for example, placing code tables in app scope will + need access to the ServletContext. + @param aDatabaseName refers to one of the names defined by {@link ConnectionSource#getDatabaseNames()}, + or an empty string. Implementations of this method should reference those names directly, instead of + hard-coding strings. + */ + void startApplication(ServletConfig aConfig, String aDatabaseName) throws AppException; + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/TESTAll.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/TESTAll.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,177 @@ +package hirondelle.web4j; + +import hirondelle.web4j.model.DateTime; +import hirondelle.web4j.model.TESTCheck; +import hirondelle.web4j.model.TESTComparePossiblyNull; +import hirondelle.web4j.model.TESTDateTime; +import hirondelle.web4j.model.TESTDateTimeFormatter; +import hirondelle.web4j.model.TESTDateTimeInterval; +import hirondelle.web4j.model.TESTDecimal; +import hirondelle.web4j.model.TESTEqualsUtil; +import hirondelle.web4j.model.TESTHashCodeUtil; +import hirondelle.web4j.model.TESTMessageSerialization; +import hirondelle.web4j.model.TESTPerformanceSnapshot; +import hirondelle.web4j.readconfig.Config; +import hirondelle.web4j.readconfig.TESTDbConfigParser; +import hirondelle.web4j.request.DateConverter; +import hirondelle.web4j.security.PermittedCharacters; +import hirondelle.web4j.security.PermittedCharactersImpl; +import hirondelle.web4j.security.TESTPermittedCharactersImpl; +import hirondelle.web4j.ui.tag.TESTFormPopulator; +import hirondelle.web4j.ui.tag.TESTHighlightCurrentPage; +import hirondelle.web4j.ui.translate.TESTText; +import hirondelle.web4j.ui.translate.TESTTranslateTextFlow; +import hirondelle.web4j.ui.translate.TESTTranslateTooltip; +import hirondelle.web4j.util.Regex; +import hirondelle.web4j.util.TESTEscapeChars; +import hirondelle.web4j.util.TESTRegex; +import hirondelle.web4j.util.TESTUtil; +import hirondelle.web4j.util.TESTWebUtil; +import hirondelle.web4j.util.TimeSource; +import hirondelle.web4j.util.TimeSourceImpl; + +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.TimeZone; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import junit.framework.Test; +import junit.framework.TestCase; +import junit.framework.TestSuite; + +/** + Runs all JUnit tests defined for WEB4J. + +

This class is not useful for the application programmer. + +

Note that: +

    +
  • not all classes have an associated JUnit test. +
  • these tests may depend on details regarding the development environment, such as + file locations. +
+*/ +public final class TESTAll extends TestCase { + + /** Run the test cases. */ + public static void main(String args[]) { + //just use the default implementations + BuildImpl.adHocImplementationAdd(TimeSource.class, TimeSourceImpl.class); + BuildImpl.adHocImplementationAdd(PermittedCharacters.class, PermittedCharactersImpl.class); + //use an impl for testing + BuildImpl.adHocImplementationAdd(DateConverter.class, DateConverterImpl.class); + + String[] testCaseName = { TESTAll.class.getName()}; + + //Select one of several types of interfaces. + junit.textui.TestRunner.main(testCaseName); + //junit.swingui.TestRunner.main(testCaseName); + //junit.ui.TestRunner.main(testCaseName); + + //turn off logging if desired : + //fLogger.setLevel(Level.OFF); + } + + public static Test suite ( ) { + TestSuite suite= new TestSuite("All JUnit Tests"); + + //model package + suite.addTest(new TestSuite(TESTCheck.class)); + suite.addTest(new TestSuite(TESTPerformanceSnapshot.class)); + suite.addTest(new TestSuite(TESTDecimal.class)); + suite.addTest(new TestSuite(TESTDateTime.class)); + suite.addTest(new TestSuite(TESTDateTimeFormatter.class)); + suite.addTest(new TestSuite(TESTDateTimeInterval.class)); + suite.addTest(new TestSuite(TESTComparePossiblyNull.class)); + suite.addTest(new TestSuite(TESTHashCodeUtil.class)); + suite.addTest(new TestSuite(TESTEqualsUtil.class)); + suite.addTest(new TestSuite(TESTMessageSerialization.class)); + + //util package + suite.addTest(new TestSuite(TESTRegex.class)); + suite.addTest(new TestSuite(TESTEscapeChars.class)); + suite.addTest(new TestSuite(TESTUtil.class)); + + //webUtil package + suite.addTest(new TestSuite(TESTWebUtil.class)); + + //ui.tag package + suite.addTest(new TestSuite(TESTHighlightCurrentPage.class)); + suite.addTest(new TestSuite(TESTFormPopulator.class)); + + //ui.translate + suite.addTest(new TestSuite(TESTTranslateTextFlow.class)); + suite.addTest(new TestSuite(TESTTranslateTooltip.class)); + suite.addTest(new TestSuite(TESTText.class)); + + //database package + suite.addTest(new TestSuite(TESTDbConfigParser.class)); + + //security package + suite.addTest(new TestSuite(TESTPermittedCharactersImpl.class)); + + return suite; + } + + /** An implementation of DateConverter, used during unit testing. */ + public static final class DateConverterImpl implements DateConverter { + //Dates: + public String formatEyeFriendly(Date aDate, Locale aLocale, TimeZone aTimeZone) { + SimpleDateFormat format = new SimpleDateFormat("MM/dd/yyyy"); + //format.setTimeZone(aTimeZone); + return format.format(aDate); + } + public Date parseHandFriendly(String aInputValue, Locale aLocale, TimeZone aTimeZone) { + return parseDate(aInputValue, HAND_FRIENDLY_REGEX); + } + public Date parseEyeFriendly(String aInputValue, Locale aLocale, TimeZone aTimeZone) { + return parseDate(aInputValue, EYE_FRIENDLY_REGEX); + } + //DateTimes: + public String formatEyeFriendlyDateTime(DateTime aDateTime, Locale aLocale) { + return aDateTime.format("MM/DD/YYYY", aLocale); + } + public DateTime parseEyeFriendlyDateTime(String aInputValue, Locale aLocale) { + return parseDateTime(aInputValue, EYE_FRIENDLY_REGEX); + } + public DateTime parseHandFriendlyDateTime(String aInputValue, Locale aLocale) { + return parseDateTime(aInputValue, HAND_FRIENDLY_REGEX); + } + private static final Pattern HAND_FRIENDLY_REGEX = + Pattern.compile(Regex.MONTH + Regex.DAY_OF_MONTH + "(\\d\\d\\d\\d)"); + ; + private static final Pattern EYE_FRIENDLY_REGEX = + Pattern.compile(Regex.MONTH + "/" + Regex.DAY_OF_MONTH + "/" + "(\\d\\d\\d\\d)") + ; + private Date parseDate(String aInputValue, Pattern aRegex){ + Date result = null; + Matcher matcher = aRegex.matcher(aInputValue); + if( matcher.matches() ) { + Integer month = new Integer(matcher.group(Regex.FIRST_GROUP)); + Integer day = new Integer(matcher.group(Regex.SECOND_GROUP)); + Integer year = new Integer( matcher.group(Regex.THIRD_GROUP) ); + Calendar cal = new GregorianCalendar(year.intValue(), month.intValue() - 1, day.intValue(), 0,0,0); + result = cal.getTime(); + } + return result; + } + private DateTime parseDateTime(String aInputValue, Pattern aRegex){ + DateTime result = null; + Matcher matcher = aRegex.matcher(aInputValue); + if( matcher.matches() ) { + Integer month = new Integer(matcher.group(Regex.FIRST_GROUP)); + Integer day = new Integer(matcher.group(Regex.SECOND_GROUP)); + Integer year = new Integer( matcher.group(Regex.THIRD_GROUP) ); + result = DateTime.forDateOnly(year, month, day); + } + return result; + } + } + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/action/Action.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/action/Action.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,36 @@ +package hirondelle.web4j.action; + +import hirondelle.web4j.model.AppException; + +/** + + Process an HTTP request, and return a {@link ResponsePage}. + + +

This interface is likely the most important + abstraction in WEB4J. Almost every feature implemented by the programmer will + need an implementation of this interface. + +

Typically, one of the ActionXXX abstract base classes are used to + build implementations of this interface. +*/ +public interface Action { + + /** + Execute desired operation. + +

Typical operations include : +

    +
  • validate user input +
  • interact with the database +
  • place required objects in the appropriate scope +
  • set the appropriate {@link ResponsePage}. +
+ +

Returns an identifier for the resource (for example a JSP) which + will display the end result of this Action (using either a + forward or a redirect). + */ + ResponsePage execute() throws AppException; + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/action/ActionImpl.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/action/ActionImpl.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,583 @@ +package hirondelle.web4j.action; + +import static hirondelle.web4j.util.Consts.SPACE; +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.database.DynamicSql; +import hirondelle.web4j.model.AppException; +import hirondelle.web4j.model.Id; +import hirondelle.web4j.model.MessageList; +import hirondelle.web4j.model.MessageListImpl; +import hirondelle.web4j.request.LocaleSource; +import hirondelle.web4j.request.RequestParameter; +import hirondelle.web4j.request.RequestParser; +import hirondelle.web4j.request.TimeZoneSource; +import hirondelle.web4j.security.CsrfFilter; +import hirondelle.web4j.security.SafeText; +import hirondelle.web4j.util.Args; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.util.WebUtil; +import java.security.Principal; +import java.util.Collection; +import java.util.Locale; +import java.util.TimeZone; +import java.util.logging.Logger; +import javax.servlet.ServletException; +import javax.servlet.http.HttpSession; + +/** + Abstract Base Class (ABC) for implementations of the {@link Action} interface. + +

This ABC provides concise methods for common operations, which will make + implementations read more clearly, concisely, and at a higher level of abstraction. + +

A simple fetch-and-display operation can often be implemented using this class + as a base class. However, operations involving user input and/or edits to the + datastore should very likely use other abstract base classes, such as + {@link ActionTemplateListAndEdit}, {@link ActionTemplateSearch}, and + {@link hirondelle.web4j.action.ActionTemplateShowAndApply}. + +

This class places success/fail messages in session scope, not request scope. + This is because such messages often need to survive a redirect operation. + For example, when a successful edit to the database occurs, a redirect is + usually performed, to avoid problems with browser reloads. + The only way a success message can survive a redirect is by being placed + in session scope. + +

This class assumes that a session already exists. + If a session does not already exist, then calling such methods will result in an error. + In practice, the user will almost always have already logged in, and this will not + be a problem. As a backup, actions can always explicitly create a session, if needed, + by calling +

getRequestParser.getRequest().getSession(true);
+*/ +public abstract class ActionImpl implements Action { + + + /** + Value {@value} - identifies a {@link hirondelle.web4j.model.MessageList}, placed + in session scope, to hold error information for the end user. + These errors are used by both WEB4J and the application programmer. + */ + public static final String ERRORS = "web4j_key_for_errors"; + + /** + Value {@value} - identifies a {@link hirondelle.web4j.model.MessageList}, placed + in session scope, to hold messages for the end user. These messages are + used by both WEB4J and the application programmer. They typically hold success + and information messages. + */ + public static final String MESSAGES = "web4j_key_for_messages"; + + /** + Value {@value} - identifies the user's id, placed in session scope. + +

Many applications will benefit from having both the user id and the user login name + placed in session scope upon login. The Servlet Container will place the user login name + in session scope upon login, but it will not place the corresponding user id + (the database's primary key of the user record) in session scope. + +

If an application chooses to place the user's underlying database id into session scope under + this USER_ID key, then the user's id will be returned by {@link #getUserId()}. + */ + public static final String USER_ID = "web4j_key_for_user_id"; + + /** + Value {@value} - generic key for an object placed in scope for a JSP. +

Not mandatory to use this generic key. Provided simply as a convenience. + */ + public static final String ITEM_FOR_EDIT = "itemForEdit"; + + /** + Value {@value} - generic key for a collection of objects placed in scope for a JSP. +

Not mandatory to use this generic key. Provided simply as a convenience. + */ + public static final String ITEMS_FOR_LISTING = "itemsForListing"; + + /** + Value {@value} - generic key for a single 'data' object placed in scope for a JSP. + Usually used with structured data, such as JSON, XML, CSV, and so on. +

Not mandatory to use this generic key. Provided simply as a convenience. + */ + public static final String DATA = "data"; + + /** + Constructor. + +

This constructor will add an attribute named 'Operation' to the request. Its + value is deduced as specified by {@link #getOperation()}. This attribute is intended for JSPs, + which can use it to access the Operation regardless of its original source. + + @param aNominalPage simply one of the possible {@link ResponsePage}s, + arbitrarily chosen as a "default". It may be changed after construction + by calling {@link #setResponsePage}. Recommended that the "success" page + be chosen as the nominal page. If not, then selection of any of the + possible {@link ResponsePage}s is acceptable. + + @param aRequestParser allows parsing of request parameters into higher level java objects. + */ + protected ActionImpl(ResponsePage aNominalPage, RequestParser aRequestParser) { + fFinalResponsePage = aNominalPage; + fRequestParser = aRequestParser; + fErrors = new AppException(); + fMessages = new MessageListImpl(); + fLocale = BuildImpl.forLocaleSource().get(fRequestParser.getRequest()); + fTimeZone = BuildImpl.forTimeZoneSource().get(fRequestParser.getRequest()); + fOperation = parseOperation(); + addToRequest("Operation", fOperation); + fLogger.fine("Operation: " + fOperation); + } + + public abstract ResponsePage execute() throws AppException; + + /** Return the resource which will render the final result. */ + public final ResponsePage getResponsePage(){ + return fFinalResponsePage; + } + + /** + Return the {@link Operation} associated with this Action, if any. + +

The Operation is found as follows : +

    +
  1. if there is a request parameter named 'Operation', and it has a value, pass its value to + {@link Operation#valueFor(String)} +
  2. if the above style fails, then the 'extension' is examined. For example, a request to .../MyAction.list?x=1 + would result in {@link Operation#List} being added to request scope, since the extension value list is known to + {@link Operation#valueFor(String)}. + This style is useful for implementing fine-grained <security-constraint> + items in web.xml. See the User Guide for more information. +
  3. if both of the above methods fail, return null +
+ +

When using the 'extension' style, please note that web.xml contains related servlet-mapping settings. + Such settings control which HTTP requests (as defined by a url-pattern) are passed from the Servlet Container to + your application in the first place. Thus, any 'extensions' which your application intends to use must have a corresponding + servlet-mapping setting in your web.xml. + */ + protected final Operation getOperation(){ + return fOperation; + } + + /** + Return the name of the logged in user. + +

By definition in the servlet specification, a successfully logged in user + will always have a non-null return value for + {@link javax.servlet.http.HttpServletRequest#getUserPrincipal()}. + +

If the user is not logged in, this method will always return null. + +

This method returns {@link SafeText}, not a String. + The user name is often rendered in the view. Since in general the user name + may contain special characters, it is appropriate to model it as + SafeText. + */ + protected final SafeText getLoggedInUserName(){ + Principal principal = fRequestParser.getRequest().getUserPrincipal(); + return principal == null ? null : new SafeText(principal.getName()); + } + + /** + Return the {@link Id} stored in session scope under the key {@link #USER_ID}. + If no such item is found, then return null. + +

The user id should only be used in server-side code, and never + presented to the user in a JSP. + */ + protected final Id getUserId(){ + Id result = (Id) getFromSession(USER_ID); + return result; + } + + /** + Called by subclasses if the final {@link ResponsePage} + differs from the nominal one passed to the constructor. + +

If an implementation calls this method, it is usually + called in {@link #execute}. + */ + protected final void setResponsePage(ResponsePage aNewResponsePage){ + fFinalResponsePage = aNewResponsePage; + } + + /** + Add a name-object pair to request scope. + +

If the pair already exists, it is updated with aObject. + + @param aName satisfies {@link hirondelle.web4j.util.Util#textHasContent(String)}. + @param aObject if null and a corresponding name-object pair exists, then + the pair is removed from request scope. + */ + protected final void addToRequest(String aName, Object aObject){ + Args.checkForContent(aName); + fRequestParser.getRequest().setAttribute(aName, aObject); + } + + /** + Return the existing session. +

If a session does not already exist, then an error will result. + */ + protected final HttpSession getExistingSession(){ + HttpSession result = fRequestParser.getRequest().getSession(DO_NOT_CREATE); + if ( result == null ) { + String MESSAGE = "No session currently exists. Either require user to login, or create a session explicitly."; + fLogger.severe(MESSAGE); + throw new UnsupportedOperationException(MESSAGE); + } + return result; + } + + /** + Add a name-object pair to an existing session. + +

If the pair already exists, it is updated with aObject. + + @param aName satisfies {@link hirondelle.web4j.util.Util#textHasContent(String)}. + @param aObject if null and a corresponding name-object pair exists, then + the pair is removed from session scope. + */ + protected final void addToSession(String aName, Object aObject){ + Args.checkForContent(aName); + getExistingSession().setAttribute(aName, aObject); + } + + /** Synonym for addToSession(aName, null). */ + protected final void removeFromSession(String aName){ + addToSession(aName, null); + } + + /** + Retrieve an object from an existing session, or null if no + object is paired with aName. + + @param aName satisfies {@link hirondelle.web4j.util.Util#textHasContent(String)}. + */ + protected final Object getFromSession(String aName){ + Args.checkForContent(aName); + return getExistingSession().getAttribute(aName); + } + + /** + Place an object which is in an existing session into request scope + as well. + +

When serving the last page in a session, some session + items may still be needed for rendering the final page. + +

For example, a log off page in a mutlilingual application might present a + "goodbye" message in the language that the user was using. Since the + session is being destroyed, the {@link Locale} stored in the session must be + first copied into request scope before the session is killed. + + @param aName identifies an Object which is currently in session scope, and satisfies + {@link hirondelle.web4j.util.Util#textHasContent(String)}. If no attribute of the given name + is found in the current session, then a null is added to the request scope + under this name. + */ + protected final void copyFromSessionToRequest(String aName){ + addToRequest( aName, getFromSession(aName) ); + } + + /** + If a session exists, then it is invalidated. + This method should be called only when the user is logging out. + */ + protected final void endSession(){ + if ( hasExistingSession() ) { + fLogger.fine("Session exists, and will now be ended."); + getExistingSession().invalidate(); + } + else { + fLogger.fine("Session does not currently exist, so cannot be ended."); + } + } + + /** + Add a simple {@link hirondelle.web4j.model.AppResponseMessage} describing a + failed validation of user input, or a failed datastore operation. +

One of the addError methods must be called when a failure occurs. + */ + protected final void addError(String aMessage){ + fErrors.add(aMessage); + placeErrorsInSession(); + } + + /** + Add a compound {@link hirondelle.web4j.model.AppResponseMessage} describing a + failed validation of user input, or a failed datastore operation. +

One of the addError methods must be called when a failure occurs. + */ + protected final void addError(String aMessage, Object... aParams){ + fErrors.add(aMessage, aParams); + placeErrorsInSession(); + } + + /** + Add all the error messages attached to aEx. +

One of the addError methods must be called when a failure occurs. + */ + protected final void addError(AppException aEx){ + fErrors.add(aEx); + placeErrorsInSession(); + } + + /** + Return all the errors passed to all addError methods. + */ + protected final MessageList getErrors(){ + return fErrors; + } + + /** + Return true only if at least one addError method has been called. + */ + protected final boolean hasErrors(){ + return fErrors.isNotEmpty(); + } + + /** + Add a simple {@link hirondelle.web4j.model.AppResponseMessage}, to be displayed + to the end user. + */ + protected final void addMessage(String aMessage){ + fMessages.add(aMessage); + placeMessagesInSession(); + } + + /** + Add a compound {@link hirondelle.web4j.model.AppResponseMessage}, to be displayed + to the end user. + */ + protected final void addMessage(String aMessage, Object... aParams){ + fMessages.add(aMessage, aParams); + placeMessagesInSession(); + } + + /** + Return all messages passed to all addMessage methods + */ + protected final MessageList getMessages(){ + return fMessages; + } + + /** + Return the {@link Locale} associated with the underlying request. + +

The configured implementation of {@link LocaleSource} defines how + Locale is looked up. + */ + protected final Locale getLocale(){ + return fLocale; + } + + /** + Return the {@link TimeZone} associated with the underlying request. + +

The configured implementation of {@link TimeZoneSource} defines how + TimeZone is looked up. + */ + protected final TimeZone getTimeZone(){ + return fTimeZone; + } + + /** Return the {@link RequestParser} passed to the constructor. */ + protected final RequestParser getRequestParser(){ + return fRequestParser; + } + + /** + Convenience method for retrieving a parameter as a simple Id. + +

Synonym for getRequestParser().toId(RequestParameter). + */ + protected final Id getIdParam(RequestParameter aReqParam){ + return fRequestParser.toId(aReqParam); + } + + /** + Convenience method for retrieving a multivalued parameter as a simple {@code Collection}. + +

Synonym for getRequestParser().toIds(RequestParameter). + */ + protected final Collection getIdParams(RequestParameter aReqParam){ + return fRequestParser.toIds(aReqParam); + } + + /** + Convenience method for retrieving a parameter as {@link SafeText}. + +

Synonym for getRequestParser().toSafeText(RequestParameter). + */ + protected final SafeText getParam(RequestParameter aReqParam){ + return fRequestParser.toSafeText(aReqParam); + } + + /** + Convenience method for retrieving a parameter as raw text, with no escaped + characters. + +

This method call is unsafe in the sense that it returns String + instead of {@link SafeText}. It is usually preferable to use {@link SafeText}, + since it protects against Cross-Site Scripting attacks. + +

If, however, the caller needs to use a request parameter + value to perform a computation, as opposed to presenting user + data in markup, then this method is provided as a convenience. + */ + protected final String getParamUnsafe(RequestParameter aReqParam){ + SafeText result = fRequestParser.toSafeText(aReqParam); + return result == null ? null : result.getRawString(); + } + + /** + Return an ORDER BY clause for an SQL statement. + +

Provided as a convenience for the common task of creating an + ORDER BY clause from request parameters. + + @param aSortColumn carries a ResultSet column identifer, either a + numeric column index, or the name of the column itself. + @param aOrder carries the value ASC or DESC (ignores case). + @param aDefaultOrderBy default text to be used if the request parameters are not + present, or have no content. + */ + protected final DynamicSql getOrderBy(RequestParameter aSortColumn, RequestParameter aOrder, String aDefaultOrderBy){ + String result = aDefaultOrderBy; + String column = getRequestParser().getRawParamValue(aSortColumn); + String order = getRequestParser().getRawParamValue(aOrder); + if ( Util.textHasContent(column) && Util.textHasContent(order) ) { + if ( ! "ASC".equalsIgnoreCase(order) && ! "DESC".equalsIgnoreCase(order)) { + String message = "Sort Order must take value 'ASC' or 'DESC' (ignoring case). Actual value :" + Util.quote(order); + fLogger.severe(message); + throw new RuntimeException(message); + } + result = DynamicSql.ORDER_BY + column + SPACE + order; + } + return new DynamicSql(result); + } + + /** + Create a new session (if one doesn't already exist) outside of the usual user login, + and add a CSRF token to the new session to defend against Cross-Site Request Forgery (CSRF) attacks. + +

This method exists to extend the {@link CsrfFilter}, to allow it to apply to a form/action that does not already have a + user logged in. + +

Warning: you can only call this method in Actions for which the + {@link hirondelle.web4j.security.SuppressUnwantedSessions} filter is NOT in effect. + +

Warning: This method should be used with care when using Tomcat. + This method creates an 'anonymous' session, unattached to any user login. + Should the user log in afterwards, a robust web application should assign a new + session id. (See OWASP for more information.) + The problem is that Tomcat 5 and 6 do not follow this rule, and will retain any existing + session id when the user logs in. + +

This method is needed only when the user has not yet logged in. + An excellent example of operations not requiring a login are operations that deal with + account management on a typical public web site : +

    +
  • registering users +
  • regaining lost passwords +
+ +

For such forms, it's strongly recommended that corresponding Actions call this method. + This will allow the {@link CsrfFilter} mechanism to be used to defend such forms against CSRF attack. + As a second benefit, it will also allow information messages sent to the end user to survive redirect operations. + */ + protected final void createSessionAndCsrfToken(){ + boolean CREATE_IF_MISSING = true; + HttpSession session = getRequestParser().getRequest().getSession(DO_NOT_CREATE); + if( session == null ) { + fLogger.fine("No session exists. Creating new session, outside of regular login."); + session = getRequestParser().getRequest().getSession(CREATE_IF_MISSING); + fLogger.fine("Adding CSRF token to the new session, to defend against CSRF attacks."); + CsrfFilter csrfFilter = new CsrfFilter(); + try { + csrfFilter.addCsrfToken(getRequestParser().getRequest()); + } + catch (ServletException ex){ + throw new RuntimeException(ex); + } + } + else { + fLogger.fine("Not creating a new session, since one already exists. Assuming the session contains a CSRF token."); + } + } + + // PRIVATE // + + /* + Design Note : + This abstract base class (ABC) does not use protected fields. + Instead, its fields are private, and subclasses which need to operate on + fields do so indirectly, by calling final convenience methods + such as {@link #addToRequest}. + + This style was chosen because, in this case, it seems to be simpler. + Subclasses need only a small number of interactions with these fields. If a + a large number of interactions were needed, then changing field scope to + protected would become more attractive. + + As well, note how most methods are declared as final, except + for the abstract ones. + */ + + private ResponsePage fFinalResponsePage; + private final RequestParser fRequestParser; + private final Locale fLocale; + private final TimeZone fTimeZone; + private final Operation fOperation; + + /* Control the creation of sessions. */ + private static final boolean DO_NOT_CREATE = false; + + private final MessageList fErrors; + private final MessageList fMessages; + + private static final Logger fLogger = Util.getLogger(ActionImpl.class); + + /** + Fetch first from request parameter; if not there, use the 'file extension' instead. + If still none, return null. + */ + private Operation parseOperation(){ + String opValue = getRequestParser().getRequest().getParameter("Operation"); + if( ! Util.textHasContent(opValue) ) { + opValue = getFileExtension(); + } + return Operation.valueFor(opValue); + } + + private String getFileExtension(){ + String uri = getRequestParser().getRequest().getRequestURI(); + fLogger.finest("URI : " + uri); + return WebUtil.getFileExtension(uri); + } + + private boolean hasExistingSession() { + return fRequestParser.getRequest().getSession(DO_NOT_CREATE) != null; + } + + /** + If {@link #getErrors} has content, place it in session scope under the name {@value #ERRORS}. + If it is already in the session, then it is updated. + */ + private void placeErrorsInSession(){ + if ( getErrors().isNotEmpty() ) { + addToSession(ERRORS, getErrors()); + } + } + + /** + If {@link #getMessages} has content, place it in session scope under the name {@value #MESSAGES}. + If it is already in the session, then it is updated. + */ + private void placeMessagesInSession(){ + if ( getMessages().isNotEmpty() ) { + addToSession(MESSAGES, getMessages()); + } + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/action/ActionTemplateListAndEdit.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/action/ActionTemplateListAndEdit.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,220 @@ +package hirondelle.web4j.action; + +import hirondelle.web4j.model.AppException; +import hirondelle.web4j.request.RequestParameter; +import hirondelle.web4j.request.RequestParser; +import hirondelle.web4j.database.DAOException; + +/** + Template for "all-in-one" {@link hirondelle.web4j.action.Action}s, which perform + common operations on a Model Object. + +

Typically a single JSP is used, for displaying both a listing of Model Objects, + and an accompanying form for editing these Model Objects one at a time. This style + is practical when : +

    +
  • the number of items in the listing is not excessively large. +
  • the Model Objects can be rendered reasonably well in a "one-per-line" style. + (If the Model Object itself has a large number of items, it may be difficult to + render them well in such a listing.) +
+ +

The {@link #SupportedOperation}s for this template are a subset of the members of the + {@link Operation} enumeration. If other operations are desired, then this template class cannot be used. + +

This class interacts a bit with its JSP - the form changes from "Add" mode to "Change" mode + according to the value of the {@link Operation}. + +

If an operation is not appropriate in a given case, then simply provide an empty implementation of + its corresponding abstract method (or an implementation that throws an + {@link java.lang.UnsupportedOperationException}). + +

To communicate messages to the end user, the implementation + must use the various addMessage and addError methods. +*/ +public abstract class ActionTemplateListAndEdit extends ActionImpl { + + /** + Constructor. + + @param aForward used for {@link Operation#List} and {@link Operation#FetchForChange} + operations, and also for failed {@link Operation#Add}, {@link Operation#Change}, + and {@link Operation#Delete} operations. This is the default response. + @param aRedirect used for successful {@link Operation#Add}, + {@link Operation#Change}, and {@link Operation#Delete} operations. + @param aRequestParser passed to the superclass constructor. + */ + protected ActionTemplateListAndEdit ( + ResponsePage aForward, ResponsePage aRedirect, RequestParser aRequestParser + ){ + super(aForward, aRequestParser); + fRedirect = aRedirect; + } + + /** + The operations supported by this template. + +

This action supports : +

    +
  • {@link Operation#List} +
  • {@link Operation#Add} +
  • {@link Operation#FetchForChange} +
  • {@link Operation#Change} +
  • {@link Operation#Delete} +
+ + The source of the Operation is described by {@link ActionImpl#getOperation()}. + */ + public static final RequestParameter SupportedOperation = RequestParameter.withRegexCheck( + "Operation", "(" + + Operation.List + "|" + Operation.Add + "|" + Operation.FetchForChange + "|" + + Operation.Change + "|" + Operation.Delete + + ")" + ); + + /** + Template method. + +

In order to clearly understand the operation of this method, here is the + core of its implementation, with all abstract methods in italics : +

+    if (Operation.List == getOperation() ){
+      doList();
+    }
+    else if (Operation.FetchForChange == getOperation()){
+      attemptFetchForChange();
+    }
+    else if (Operation.Add == getOperation()) {
+      validateUserInput();
+      if ( ! hasErrors() ){
+        attemptAdd();
+        ifNoErrorsRedirectToListing();
+      }
+    }
+    else if (Operation.Change == getOperation()) {
+      validateUserInput();
+      if ( ! hasErrors() ){
+        attemptChange();
+        ifNoErrorsRedirectToListing();
+      }
+    }
+    else if(Operation.Delete == getOperation()) {
+      attemptDelete();
+      ifNoErrorsRedirectToListing();
+    }
+    //Fresh listing WITHOUT a redirect is required if there is an error, 
+    //and for successful FetchForChange operations.
+    if( hasErrors() || Operation.FetchForChange == getOperation() ){
+      doList();
+    }
+   
+ */ + @Override public final ResponsePage execute() throws AppException { + //the default operation is a forward to the nominal page + if (Operation.List == getOperation()){ + doList(); + } + else if (Operation.FetchForChange == getOperation()){ + attemptFetchForChange(); + } + else if (Operation.Add == getOperation()) { + validateUserInput(); + if ( ! hasErrors() ){ + attemptAdd(); + ifNoErrorsRedirectToListing(); + } + } + else if (Operation.Change == getOperation()) { + validateUserInput(); + if ( ! hasErrors() ){ + attemptChange(); + ifNoErrorsRedirectToListing(); + } + } + else if(Operation.Delete == getOperation()) { + attemptDelete(); + ifNoErrorsRedirectToListing(); + } + else { + throw new AssertionError("Unexpected kind of Operation for this Action template : " + getOperation()); + } + + //A fresh listing WITHOUT a redirect is required if there is an error, and for + //successful FetchForChange operations. + if( hasErrors() || Operation.FetchForChange == getOperation() ){ + doList(); + } + + return getResponsePage(); + } + + /** + Validate items input by the user into a form. + +

Applied to {@link Operation#Add} and {@link Operation#Change}. If an error occurs, then + addError must be called. + +

Example of a typical implementation : +

+  protected void validateUserInput() {
+    try {
+      ModelFromRequest builder = new ModelFromRequest(getRequestParser());
+      fResto = builder.build(Resto.class, RESTO_ID, NAME, LOCATION, PRICE, COMMENT);
+    }
+    catch (ModelCtorException ex){
+      addError(ex);
+    }    
+  }
+   
+ +

Note that the Model Object constructed in this example (fResto) is retained + as a field, for later use when applying an edit to the database. This is the recommended style. + */ + protected abstract void validateUserInput(); + + /** + Retrieve a listing of Model Objects from the database (SELECT operation). + */ + protected abstract void doList() throws DAOException; + + /** + Attempt an INSERT operation on the database. The data will first be validated using + {@link #validateUserInput}. + */ + protected abstract void attemptAdd() throws DAOException; + + /** + Attempt to fetch a single Model Object from the database, in preparation for + editing it (SELECT operation). + */ + protected abstract void attemptFetchForChange() throws DAOException; + + /** + Attempt an UPDATE operation on the database. The data will first be validated + using {@link #validateUserInput()}. + */ + protected abstract void attemptChange() throws DAOException; + + /** + Attempt a DELETE operation on the database. + */ + protected abstract void attemptDelete() throws DAOException; + + /** + Add a dynamic query parameter to the redirect {@link ResponsePage}. + +

This method will URL-encode the name and value. + */ + protected void addDynamicParameterToRedirectPage(String aParamName, String aParamValue){ + fRedirect = fRedirect.appendQueryParam(aParamName, aParamValue); //ResponsePage is immutable + } + + // PRIVATE // + private ResponsePage fRedirect; + + private void ifNoErrorsRedirectToListing(){ + if ( ! hasErrors() ) { + setResponsePage(fRedirect); + } + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/action/ActionTemplateSearch.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/action/ActionTemplateSearch.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,115 @@ +package hirondelle.web4j.action; + +import java.util.logging.*; +import java.util.regex.Pattern; + +import hirondelle.web4j.request.RequestParameter; +import hirondelle.web4j.request.RequestParser; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.model.AppException; + +/** + Template for search screens. + +

Here, a search action has the following : +

    +
  • it uses a form that allows the user to input search criteria +
  • the form must have GET as its method, not POST +
  • the underlying database operation is a SELECT, and does not edit the database in any way +
+ +

Search operations never require a redirect operation (since they do not edit the database). + +

Search operations have an interesting property : if you build a Model Object to validate and represent + user input into the search form, then its getXXX methods can usually be made package-private, instead + of public. The reason is that such Model Objects are usually not used by JSPs directly. + If desired, such methods can safely return String instead of + {@link hirondelle.web4j.security.SafeText}. (The Model Objects themselves cannot be made package-private, since + the {@link hirondelle.web4j.model.ModelFromRequest} class works only with public classes.) +*/ +public abstract class ActionTemplateSearch extends ActionImpl { + + /** + Constructor. + + @param aForward renders the result of the search + @param aRequestParser passed to the superclass constructor. + */ + protected ActionTemplateSearch(ResponsePage aForward, RequestParser aRequestParser){ + super(aForward, aRequestParser); + } + + /** + The operations supported by this template. + +

The supported operations are : +

    +
  • {@link Operation#Show} +
  • {@link Operation#Search} +
+ + The source of the Operation is described by {@link ActionImpl#getOperation()}. + */ + public static final RequestParameter SUPPORTED_OPERATION = RequestParameter.withRegexCheck( + "Operation", Pattern.compile("(" + Operation.Show + "|" + Operation.Search + ")") + ); + + /** + Template method. + +

In order to clearly understand the operation of this method, here is the + core of its implementation, with all abstract methods in italics : +

+    if (Operation.Show == getOperation() ){
+      //default forward
+    }
+    else if ( Operation.Search == getOperation() ){
+      validateUserInput();
+      if( ! hasErrors() ){
+        listSearchResults();
+        if ( ! hasErrors() ){
+          fLogger.fine("List executed successfully.");
+        }
+      }
+    }
+  
+ */ + @Override public ResponsePage execute() throws AppException { + if (Operation.Show == getOperation() ){ + //default forward + } + else if ( Operation.Search == getOperation() ){ + fLogger.fine("'Search' Operation."); + validateUserInput(); + if( ! hasErrors() ){ + fLogger.fine("Passes validation."); + listSearchResults(); + if ( ! hasErrors() ){ + fLogger.fine("List executed successfully."); + } + } + } + else { + throw new AssertionError("Unexpected value for Operation : " + getOperation()); + } + return getResponsePage(); + } + + /** + Validate items input by the user into a form. + +

The form is used to define the criteria for the search (if any). + +

Applies only for {@link Operation#Search}. If an error occurs, then + addError must be called. + */ + protected abstract void validateUserInput() throws AppException; + + /** + Query the database, and place the results (usually) into request scope. + */ + protected abstract void listSearchResults() throws AppException; + + // PRIVATE // + private static final Logger fLogger = Util.getLogger(ActionTemplateSearch.class); +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/action/ActionTemplateShowAndApply.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/action/ActionTemplateShowAndApply.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,121 @@ +package hirondelle.web4j.action; + +import java.util.logging.*; +import java.util.regex.Pattern; +import hirondelle.web4j.request.RequestParameter; +import hirondelle.web4j.request.RequestParser; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.model.AppException; + +/** + Template for "first show, then validate and apply" groups of operations. + +

A good example is a page allowing the user to change their preferences : from the + point of view of each user, there is only one set of user preferences. Often, a single form can be used + to allow editing of preferences. Here, there is no listing of multiple items. + +

There are two operations in such cases : +

    +
  • show me the form with the current data (if any) +
  • allow me to post my (validated) changes +
+*/ +public abstract class ActionTemplateShowAndApply extends ActionImpl { + + /** + Constructor. + + @param aForward used for {@link Operation#Show} operations, and also for failed + {@link Operation#Apply} operations. This is the default response. + @param aRedirect used for successful {@link Operation#Apply} operations. + @param aRequestParser passed to the superclass constructor. + */ + protected ActionTemplateShowAndApply(ResponsePage aForward, ResponsePage aRedirect, RequestParser aRequestParser){ + super(aForward, aRequestParser); + fRedirect = aRedirect; + } + + /** + The operations supported by this template. + +

The supported operations are : +

    +
  • {@link Operation#Show} +
  • {@link Operation#Apply} +
+ + The source of the Operation is described by {@link ActionImpl#getOperation()}. + */ + public static final RequestParameter SUPPORTED_OPERATION = RequestParameter.withRegexCheck( + "Operation", Pattern.compile("(" + Operation.Show + "|" + Operation.Apply + ")") + ); + + /** + Template method. + +

In order to clearly understand the operation of this method, here is the + core of its implementation, with all abstract methods in italics : +

+    if ( Operation.Show == getOperation() ){
+      show();
+    }
+    else if ( Operation.Apply == getOperation() ){
+      validateUserInput();
+      if( ! hasErrors() ){
+        apply();
+        if ( ! hasErrors() ){
+          setResponsePage(fRedirect);
+        }
+      }
+    }
+   
+ */ + @Override public ResponsePage execute() throws AppException { + if ( Operation.Show == getOperation() ){ + fLogger.fine("'Show' Operation."); + show(); + } + else if ( Operation.Apply == getOperation() ){ + fLogger.fine("'Apply' Operation."); + validateUserInput(); + if( ! hasErrors() ){ + fLogger.fine("Passes validation."); + apply(); + if ( ! hasErrors() ){ + fLogger.fine("Applied successfully."); + setResponsePage(fRedirect); + } + } + } + else { + throw new AssertionError("Unexpected value for Operation : " + getOperation()); + } + + return getResponsePage(); + } + + /** + Show the input form. + +

The form may or may not be populated. + */ + protected abstract void show() throws AppException; + + /** + Validate items input by the user into a form. + +

Applied to {@link Operation#Apply}. + If an error occurs, then addError must be called. + */ + protected abstract void validateUserInput() throws AppException; + + /** + Validate the user input, and then apply an edit to the database. + If an error occurs, then addError must be called. + */ + protected abstract void apply() throws AppException; + + // PRIVATE // + private ResponsePage fRedirect; + private static final Logger fLogger = Util.getLogger(ActionTemplateShowAndApply.class); +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/action/Operation.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/action/Operation.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,207 @@ +package hirondelle.web4j.action; + +import hirondelle.web4j.util.Util; +import java.util.logging.*; + +/** + Type-safe enumeration for common operations. + +

This type-safe enumeration + is somewhat unusual, since its elements are not meant to form a strictly distinct + set. For example, one might choose to identify the same operation using + either {@link #Save} or {@link #Add}, simply according to taste. Elements may be used as + desired, but please note that {@link #isDatastoreEdit} and {@link #hasSideEffects()} return + true only for specified items. + +

Many {@link Action} implementations can benefit from using a request parameter named + 'Operation', whose value corresponds to a specific subset of the members of this enumeration. + For example, {@link hirondelle.web4j.action.ActionTemplateSearch} expects only {@link #Show} and {@link #Search} + operations. See {@link hirondelle.web4j.action.ActionImpl#getOperation()} as well. + +

Occasionally, you may need to define just such a subset of operations for your own actions. + Typically, your code would use the following code snippets. +

+ Define an Operation {@link hirondelle.web4j.request.RequestParameter} in the {@link Action} using : +

+  {@code
+public static final RequestParameter SUPPORTED_OP = RequestParameter.withRegexCheck(
+    "Operation", 
+    Pattern.compile("(" + Operation.Show + "|" + Operation.Search +  ")") 
+  );
+  }
+
+ Set the value of a corresponding Operation field in the {@link Action} constructor using : +
+  {@code
+fOperation = Operation.valueOf(aRequestParser.toString(SUPPORTED_OP));
+  }
+
+ +

Your {@link Action#execute} method will then branch according to the value of + the fOperation field. + +

Note regarding forms submitted by hitting the Enter key.
+ One must exercise care for the possible submission of forms by hitting the Enter key. + Browser behavior is not specified exactly by HTML 4, and various browsers exhibit + different behaviors. A common workaround is to place the Operation in a + HIDDEN form item, to ensure that it is always submitted, regardless of + the submission mechanism. + +

See as well this + discussion of Submit + buttons in multilingual applications. +*/ +public enum Operation { + + /** Add an item to the datastore. */ + Add, + + /** Post a change to an item to the datastore. */ + Change, + + /** Delete an item from the datastore. */ + Delete, + + /** Delete all items from the datastore. */ + DeleteAll, + + /** Save an edit to the datastore. */ + Save, + + /** Apply an edit to the datastore. */ + Apply, + + /** + Activate an item. + +

The reverse of {@link #Inactivate} + */ + Activate, + + /** + Inactivate an item. + +

Often used to implement an abstract delete operation, where + an item is nominally removed (perhaps by setting the value of a certain column), + but no physical deletion of records occurs. + */ + Inactivate, + + /** + Start some process. + +

The reverse of {@link #Stop}. + */ + Start, + + /** + Stop some process. + +

The reverse of {@link #Start}. + */ + Stop, + + /** Retrieve a single item, for read-only display. */ + Fetch, + + /** Fetch a single item, in preparation for editing the item. */ + FetchForChange, + + /** + Fetch items needed before adding a new item. + An example use case is adding a new item which has a parent of some sort. + The parent will need to be fetched before showing the form for adding the child. + */ + FetchForAdd, + + /** Retrieve what is usually a list of many items, for read-only display. */ + List, + + /** Retrieve what is usually a list of many items, in preparation for editing the items. */ + ListForChange, + + /** Show an item. */ + Show, + + /** Generate a result. */ + Generate, + + /** Render a result. */ + Render, + + /** Display a result. */ + Display, + + /** Search for one or more items. */ + Search, + + /** Fetch the next item in a list. */ + Next, + + /** Fetch the previous item in a list. */ + Previous, + + /** Fetch the first item in a list. */ + First, + + /** Fetch the last item in a list. */ + Last, + + /** Generic operation meant as a catch-all. */ + Do; + + /** + Return true only if this Operation represents an action which + has edited the datastore : {@link #Add}, {@link #Change}, {@link #Delete}, + {@link #DeleteAll}, {@link #Save}, {@link #Apply}, {@link #Inactivate}, or {@link #Activate}. + +

Intended to identify actions which very likely require + a redirect instead of a forward. + */ + public boolean isDatastoreEdit(){ + return ( + this == Add || this == Change || this == Delete || this == DeleteAll || + this == Save || this == Apply || this == Inactivate || this == Activate + ); + } + + /** + Returns true only if this Operation isDataStoreEdit(), + or is {@link #Start} or {@link #Stop}. + +

Intended to identify cases that need a POST request. + */ + public boolean hasSideEffects(){ + return isDatastoreEdit() || this == Start || this == Stop; + } + + /** + Parse a parameter value into an Operation (not case-sensitive). + +

Similar to {@link #valueOf}, but not case-sensitive, and has alternate behavior when a problem is found. + If aOperation has no content, or has an unknown value, then a message + is logged at SEVERE level, and null is returned. + */ + public static Operation valueFor(String aOperation) { + Operation result = null; + if ( Util.textHasContent(aOperation) ) { + for (Operation operation : Operation.values()) { + if ( operation.name().equalsIgnoreCase(aOperation)) { + result = operation; + break; + } + } + if( result == null ) { + fLogger.severe("'Operation' has an unknown value: " + Util.quote(aOperation)); + } + } + else { + String message = "'Operation' has an empty or null value."; + fLogger.severe(message); + } + return result; + } + + // PRIVATE // + private static final Logger fLogger = Util.getLogger(Operation.class); +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/action/ResponsePage.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/action/ResponsePage.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,243 @@ +package hirondelle.web4j.action; + +import hirondelle.web4j.model.ModelUtil; +import hirondelle.web4j.util.WebUtil; + +/** + Response page served to the user at the end of an {@link Action}. + +

Identifies the page as a resource. Does not include its content, but is rather + a reference to the page. + +

The {@link hirondelle.web4j.Controller} will either redirect or forward to the + resource identified by {@link #toString}, according to the value of {@link #getIsRedirect}. + The default value of {@link #getIsRedirect} varies according to constructor: +

    +
  • {@link #ResponsePage(String)} defaults to a redirect +
  • all other constructors default to a forward +
+ +

These defaults are almost always the desired values. They can be changed, if + necessary, by calling {@link #setIsRedirect}. It's important to note that that method + returns a brand new object - it doesn't change the state of an existing object. + +

See the forward-versus-redirect + topic on javapractices.com for more information. +*/ +public final class ResponsePage { + + /** + Constructor which uses the WEB4J template mechanism. + + @param aTitle text of the <TITLE> tag to be presented in the + template page ; appended to aTemplateURL as a query parameter + @param aBodyJsp body of the templated page, where most of the content of interest + lies ; appended to aTemplateURL as a query parameter + @param aTemplateURL identifies the templated page which dynamically includes + aBodyJsp in its content. Some applications will have only a single + template for the entire application. + */ + public ResponsePage(String aTitle, String aBodyJsp, String aTemplateURL){ + this(aTitle, aBodyJsp, aTemplateURL, null); + } + + /** + Constructor which uses the WEB4J template mechanism, with a conventional template. +

As in {@link #ResponsePage(String, String, String, Class)}, + but with the template URL taking the conventional value of '../Template.jsp'. + */ + public ResponsePage(String aTitle, String aBodyJsp, Class aRepresentativeClass) { + this(aTitle, aBodyJsp, "../Template.jsp", aRepresentativeClass); + } + + /** + Constructor which uses the WEB4J template mechanism. + +

+ This constructor allows for an unusual but useful policy : placing JSPs in the + same directory as related code, under WEB-INF/classes, instead + of under the document root of the application. + Such a style is beneficial since it allows all (or nearly all) items related + to a given feature to be placed in the same directory - JSPs, Action, + Model Objects, Data Access Objects, and .sql files. + This is the recommended style. It allows + package-by-feature. + + @param aTitle text of the <TITLE> tag to be presented in the + template page ; appended to aTemplateURL as a query parameter + @param aBodyJsp body of the templated page, where most of the content of interest + lies ; appended to aTemplateURL as a query parameter + @param aTemplateURL identifies the templated page which dynamically includes + aBodyJsp in its content + @param aClass class literal of any java class related to the given feature; the + package of this class will be used to construct the 'real' path to aBodyJsp, + as in 'WEB-INF/classes/<package-as-directory-path>/<aBodyJsp>'. These + paths are completely internal, are known only to the {@link hirondelle.web4j.Controller}, and are + never visible to the user in the URL. + */ + public ResponsePage(String aTitle, String aBodyJsp, String aTemplateURL, Class aClass){ + fIsRedirect = FOR_FORWARD; + String url = WebUtil.setQueryParam(aTemplateURL, "TTitle", aTitle); + url = WebUtil.setQueryParam(url, "TBody", getPathPrefix(aClass) + aBodyJsp); + fURL = url; + fIsBinary = false; + } + + /** + Constructor for a non-templated response. + +

This constructor does not use the WEB4J template mechanism. + This constructor is used both for response pages that require a redirect, and for + serving pages that do not use the templating mechanism. + +

aURL identifies the resource which will be used by an {@link Action} + as its destination page. + Example values for aURL : +

    +
  • ViewAccount.do (suitable for a redirect) +
  • /ProblemHasBeenLogged.html (suitable for a forward to an item + which is not templated) +
+ +

This constructor returns a redirect. If a forward is desired, call {@link #setIsRedirect(Boolean)}. + */ + public ResponsePage(String aURL) { + this(aURL, FOR_REDIRECT); + } + + /** + Constructor for a non-templated response located under /WEB-INF/. + +

+ This constructor allows for an unusual but useful policy : placing JSPs in the + same directory as related code, under WEB-INF/classes, instead + of under the document root of the application. + Such a style is beneficial since it allows all (or nearly all) items related + to a given feature to be placed in the same directory - JSPs, Action, + Model Objects, Data Access Objects, and .sql files. + This is the recommended style. It allows + package-by-feature. + +

This constructor defaults the response to a forward operation. + + @param aJsp simple name of a JSP, as in 'view.jsp' + @param aClass class literal of any java class in the same package as the given JSP. + The package of this class will be used to construct the 'real' path to the JSP, + as in

WEB-INF/classes/<package-as-directory-path>/<aJsp>
These + paths are completely internal, are known only to the {@link hirondelle.web4j.Controller}, and are + never visible to the user in the URL. + */ + public ResponsePage(String aJsp, Class aClass){ + fIsRedirect = FOR_FORWARD; + fURL = getPathPrefix(aClass) + aJsp; + fIsBinary = false; + } + + /** + Factory method for binary responses. + An example of a binary reponse is serving a report in .pdf format. + +

With such ResponsePages, the normal templating mechanism is not + available (since it is based on text), and no forward/redirect is performed by the + Controller. In essence, the Action becomes entirely responsible + for generating the response. + +

When serving a binary response, your Action will typically : +

    +
  • set the content-type response header. +
  • generate the output stream containing the desired binary content. +
  • close any resources, if necessary. For example, if generating a chart dynamically, then you + may need to recover resources used in the generation of an image. +
+ */ + public static ResponsePage withBinaryData(){ + return new ResponsePage(); + } + + /** Return true only if {@link #withBinaryData()} was called. */ + public Boolean hasBinaryData(){ + return fIsBinary; + } + + /** + Return the URL passed to the constructor, with any query parameters added by + {@link #appendQueryParam} appended on the end. + */ + @Override public String toString() { + return fURL; + } + + /** See class comment. */ + public Boolean getIsRedirect(){ + return fIsRedirect; + } + + /** + Return a new ResponsePage, having the specified forward/redirect behavior. + See class comment. + +

This method returns a new ResponsePage having the updated URL. + (This will ensure that ResponsePage objects are immutable.) + */ + public ResponsePage setIsRedirect(Boolean aValue){ + return new ResponsePage(fURL, aValue); + } + + /** + Append a query parameter to the URL of a ResponsePage. + +

This method returns a new ResponsePage having the updated URL. + (This will ensure that ResponsePage objects are immutable.) +

This method uses {@link WebUtil#setQueryParam}. + */ + public ResponsePage appendQueryParam(String aParamName, String aParamValue){ + String newURL = WebUtil.setQueryParam(fURL, aParamName, aParamValue); + return new ResponsePage(newURL, fIsRedirect); + } + + @Override public boolean equals(Object aThat){ + Boolean result = ModelUtil.quickEquals(this, aThat); + if ( result == null ){ + ResponsePage that = (ResponsePage) aThat; + result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields()); + } + return result; + } + + @Override public int hashCode(){ + return ModelUtil.hashCodeFor(getSignificantFields()); + } + + // PRIVATE + private final String fURL; + private final Boolean fIsRedirect; + private final Boolean fIsBinary; + private static final Boolean FOR_REDIRECT = Boolean.TRUE; + private static final Boolean FOR_FORWARD = Boolean.FALSE; + + private String getPathPrefix(Class aClass){ + String result = ""; + if ( aClass != null ){ + result = "/WEB-INF/classes/" + aClass.getPackage().getName().replace('.', '/') + "/"; + } + return result; + } + + /** Private constructor. */ + private ResponsePage(String aURL, Boolean aIsRedirect){ + fIsRedirect = aIsRedirect; + fURL = aURL; + fIsBinary = false; + } + + /** Private constructor for binary responses. */ + private ResponsePage(){ + fIsRedirect = FOR_FORWARD; //not really used + fURL = null; //not usd + fIsBinary = true; + } + + private Object[] getSignificantFields(){ + return new Object[] {fURL, fIsRedirect, fIsBinary}; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/action/package.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/action/package.html Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,30 @@ + + + + + + + User Interface + + +Execute the desired operation. + +

In general, Action classes are the main public face of each feature. In addition, +they are also the 'glue' that brings together all the other items related to the +feature - Model Object, DAO, JSP, and SQL statements. Thus, an Action typically +references all of these kinds of items, in some way. + +

{@link hirondelle.web4j.action.ActionImpl} is a base implementation of Action, +and has a number of methods for common operations. It in turn has several +template subclasses (ActionTemplateXXX), corresponding to specific combinations of operations. + +

An important point to understand is the separation of validation into two distinct parts - +hard validation, and soft validation - see {@link hirondelle.web4j.security.ApplicationFirewall} +for more information. + +

Mapping of incoming requests to an {@link hirondelle.web4j.action.Action} is done by +{@link hirondelle.web4j.request.RequestParserImpl}. By default, it uses a simple mapping scheme which requires no configuration. + +

The main tool for building Model Objects out of submitted forms is {@link hirondelle.web4j.model.ModelFromRequest}. + + diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/ConnectionSource.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/ConnectionSource.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,108 @@ +package hirondelle.web4j.database; + +import java.sql.Connection; +import java.util.Map; +import java.util.Set; + +/** + Return a {@link Connection} to a database, using a policy defined by the application. + +

See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. + {@link hirondelle.web4j.BuildImpl#forConnectionSource()} + returns the configured implementation of this interface. + +

Here is an example + implementation, + taken from the WEB4J example application. + +

There are many alternatives for creating or accessing database connections. + The application programmer can use any technique whatsoever to implement this + interface. For example, an implementation may : +

    +
  • use a connection pool configured in the server environment (the style + used in the example application) +
  • create a {@link Connection} directly, using the database driver. (Some modern + drivers have connection pooling built directly into the driver.) +
  • use some other means +
+ +

The design of this interface is slightly skewed towards applications + that use more than one database. + If only one database is used in an application, then this interface is simply implemented + 'as if' there were more than one. In practice, this should be only a minor nuisance. In addition, + if a single-database application is extended to use more than one database, the changes will + be absorbed naturally by the existing implementation. + +

If no database is used at all, then {@link #getDatabaseNames()} returns an emtpy Set. + +

See {@link SqlId} for related information. +*/ +public interface ConnectionSource { + + /** + Read in any necessary configuration parameters. + +

This method is called by the framework early during startup processing. + If your implementation of this interface does not use any sort of config + information, then it can simply ignore the given Map parameter. + +

In the context of a web application, the given Map is populated + with the servlet's init-param settings in web.xml. + */ + void init(Map aConfig); + + /** + Return the database names accepted by {@link #getConnection(String)}. + +

Return a Set with one or more entries. Each entry must have content. + If your application does not use a database at all, then you must return an empty Set. + */ + Set getDatabaseNames(); + + /** + Return a {@link Connection} to the default database. + +

The default database is selected by the application programmer as representing + the principal or main database in the application, against which the majority of + SQL statements are executed. (For most applications, this choice will be obvious.) + In .sql files, SQL statements against the default database + do not need an extra qualifier in their identifiers, while those against a non-default + database do need an extra qualifier. + +

Implementations must translate any exceptions into a {@link DAOException}. + Examples of exceptions that may need translation are SQLException + and javax.naming.NamingException. + */ + Connection getConnection() throws DAOException; + + /** + Return a {@link Connection} for a specified database (default or non-default). + +

The {@link #getConnection()} method is intended for the "default" or + principal database used in the application, while this method is + intended for all other databases (but may be used for the default as well). + +

See {@link SqlId} for related information. + + Here, aDatabaseName is a prefix used + in .sql files as a qualifer to a SQL statement identifier. + For example, in an .sql file, the SQL statement identifier: +

    +
  • LIST_MEMBERS refers to the default database (no qualifier) +
  • TRANSLATION.LOCALE_LIST refers to the 'TRANSLATION' database. + It is this string - 'TRANSLATION' - which is passed to this method. +
+ +

The values taken by aDatabaseName are those returned by {@link #getDatabaseNames()}. + They may be chosen by the application programmer in any desired way, to clarify the SQL + statement identifiers appearing in the .sql file(s). + Typically, an implementation of this interface will internally map these database + identifiers into specific connection strings, such as + 'java:comp/env/jdbc/blah' (or whatever is required). + +

Implementations must translate any exceptions into a {@link DAOException}. + Examples of exceptions that may need translation are SQLException + and javax.naming.NamingException. + */ + Connection getConnection(String aDatabaseName) throws DAOException; +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/ConvertColumn.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/ConvertColumn.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,39 @@ +package hirondelle.web4j.database; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + Convert ResultSet column values into common 'building block' objects. +

+ Here, a building block class is one of the 'base' objects from which Model + Objects can in turn be built - Integer, BigDecimal, Date, + and so on. + +

See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. + {@link hirondelle.web4j.BuildImpl#forConvertColumn()} + returns the configured implementation of this interface. + +

{@link ConvertColumnImpl} is provided as a default implementation. It is likely that + most applications will find this implementation adequate. +*/ +public interface ConvertColumn { + + /** + Translate a single column value of a ResultSet into a possibly-null 'building block' object. + +

A building block object is like Integer, BigDecimal, Date, and so on. + +

It is required that implementations use + {@link hirondelle.web4j.model.ConvertParam#isSupported(Class)} to + verify that aSupportedTargetType is indeed supported. + This ensures the front end and back end are synchronized, and support the same set of classes. + + @param aRow from iteration over a ResultSet. + @param aColumnIdx specific column in aRow. + @param aSupportedTargetType class of the desired return value. Implementations are not required to + support all possible target classes. + */ + public T convert(ResultSet aRow, int aColumnIdx, Class aSupportedTargetType) throws SQLException; + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/ConvertColumnImpl.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/ConvertColumnImpl.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,183 @@ +package hirondelle.web4j.database; + +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.model.ConvertParam; +import hirondelle.web4j.model.DateTime; +import hirondelle.web4j.model.Decimal; +import hirondelle.web4j.model.Id; +import hirondelle.web4j.readconfig.Config; +import hirondelle.web4j.security.SafeText; +import hirondelle.web4j.util.Util; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.sql.Clob; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Types; +import java.util.Locale; +import java.util.TimeZone; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + Default implementation of {@link ConvertColumn}, suitable for most applications. + +

This class converts non-null items using : + + + + + + + + + + + + + + + +
Target ClassUse
SafeText ResultSet.getString(), or ResultSet.getClob()
String (if allowed) ResultSet.getString(), or ResultSet.getClob()
Integer ResultSet.getInt()
Long ResultSet.getLong()
Boolean ResultSet.getBoolean()
BigDecimal ResultSet.getBigDecimal()
Decimal ResultSet.getBigDecimal()
Id ResultSet.getString(), new Id(String)
DateTime ResultSet.getString(), pass to {@link DateTime#DateTime(String)}
Date ResultSet.getTimestamp(), possibly with hint provided in web.xml
Locale ResultSet.getString(), {@link hirondelle.web4j.util.Util#buildLocale(String)}
TimeZone ResultSet.getString(), {@link hirondelle.web4j.util.Util#buildTimeZone(String)}
InputStream ResultSet.getBinaryStream()
+ +

This implementation supports the same building block classes defined by another + default implementation : {@link hirondelle.web4j.model.ConvertParamImpl#isSupported(Class)}. + See that class for important information on the conditional support of String. +*/ +public class ConvertColumnImpl implements ConvertColumn { + + /** + Defines policies for converting a column of a ResultSet into a possibly-null + Object. + + @param aRow of a ResultSet + @param aColumnIdx particular column of aRow + @param aSupportedTargetType is a class supported by the configured implementation of + {@link ConvertParam#isSupported(Class)}. + */ + public T convert(ResultSet aRow, int aColumnIdx, Class aSupportedTargetType) throws SQLException { + if( ! fConvertParam.isSupported(aSupportedTargetType) ){ + throw new IllegalArgumentException("Unsupported Target Type : " + Util.quote(aSupportedTargetType)); + } + + Object result = null; + if (aSupportedTargetType == SafeText.class){ + result = convertToSafeText(aRow, aColumnIdx); + } + else if (aSupportedTargetType == String.class) { + result = convertToString(aRow, aColumnIdx); + } + else if (aSupportedTargetType == Integer.class || aSupportedTargetType == int.class){ + int value = aRow.getInt(aColumnIdx); + result = aRow.wasNull() ? null : new Integer(value); + } + else if (aSupportedTargetType == Boolean.class || aSupportedTargetType == boolean.class){ + boolean value = aRow.getBoolean(aColumnIdx); + result = aRow.wasNull() ? null : Boolean.valueOf(value); + } + else if (aSupportedTargetType == BigDecimal.class){ + result = aRow.getBigDecimal(aColumnIdx); + } + else if (aSupportedTargetType == java.util.Date.class){ + result = getDate(aRow, aColumnIdx); + } + else if (aSupportedTargetType == DateTime.class){ + result = getDateTime(aRow, aColumnIdx); + } + else if (aSupportedTargetType == Long.class || aSupportedTargetType == long.class){ + long value = aRow.getLong(aColumnIdx); + result = aRow.wasNull() ? null : new Long(value); + } + else if (aSupportedTargetType == Id.class){ + String value = aRow.getString(aColumnIdx); + result = aRow.wasNull() ? null : new Id(value); + } + else if (aSupportedTargetType == Locale.class){ + String value = aRow.getString(aColumnIdx); + result = value == null ? null : Util.buildLocale(value); + } + else if (TimeZone.class == aSupportedTargetType){ + String value = aRow.getString(aColumnIdx); + result = value == null ? null : Util.buildTimeZone(value); + } + else if (aSupportedTargetType == Decimal.class){ + BigDecimal value = aRow.getBigDecimal(aColumnIdx); + result = value == null ? null : new Decimal(value); + } + else if (InputStream.class.isAssignableFrom(aSupportedTargetType)){ + InputStream value = aRow.getBinaryStream(aColumnIdx); + result = value == null ? null : value; + } + else { + throw new AssertionError( + "Unsupported type cannot be translated to an object:" + aSupportedTargetType + ); + } + fLogger.finest( + "Successfully converted ResultSet column idx " + Util.quote(aColumnIdx) + + " into a " + aSupportedTargetType.getName() + ); + return (T)result; //this cast is unavoidable, and safe. + } + + // PRIVATE + private final ConvertParam fConvertParam = BuildImpl.forConvertParam(); + private static final Logger fLogger = Util.getLogger(ConvertColumnImpl.class); + + private SafeText convertToSafeText(ResultSet aRow, int aColumnIdx) throws SQLException { + String result = convertToString(aRow, aColumnIdx); + return result == null ? null : new SafeText(result); + } + + private String convertToString(ResultSet aRow, int aColumnIdx) throws SQLException { + String result = null; + if ( isClob(aRow, aColumnIdx) ) { + result = convertClobToString(aRow, aColumnIdx); + } + else { + result = aRow.getString(aColumnIdx); + } + return aRow.wasNull() ? null : result; + } + + private static boolean isClob(ResultSet aRow, int aColumnIdx) throws SQLException { + ResultSetMetaData metaData = aRow.getMetaData(); + boolean result = metaData.getColumnType(aColumnIdx) == Types.CLOB; + return result; + } + + private static String convertClobToString(ResultSet aRow, int aColumnIdx) throws SQLException { + String result = null; + Clob clob = aRow.getClob(aColumnIdx); + if (clob != null){ + int length = new Long(clob.length()).intValue(); + result = clob.getSubString(1, length); + } + return result; + } + + private java.util.Date getDate(ResultSet aRow, int aColumnIdx) throws SQLException { + java.util.Date result = null; + Config config = new Config(); + if ( config.hasTimeZoneHint() ){ + result = aRow.getTimestamp(aColumnIdx, config.getTimeZoneHint()); + } + else { + result = aRow.getTimestamp(aColumnIdx); + } + return result; + } + + private DateTime getDateTime(ResultSet aRow, int aColumnIdx) throws SQLException { + //the ctor takes any String; if malformed, the DateTime will blow up later, when you call most other methods + String rawDateTime = aRow.getString(aColumnIdx); + if(fLogger.getLevel() == Level.FINEST){ + fLogger.finest("DateTime column, raw value from database is: " + Util.quote(rawDateTime) + + ". SQL date-time formatting functions can be used to render the format compatible with the hirondelle.web4.model.DateTime class, if needed." + ); + } + return rawDateTime == null ? null : new DateTime(rawDateTime); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/DAOException.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/DAOException.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,37 @@ +package hirondelle.web4j.database; + +import hirondelle.web4j.model.AppException; + +/** + The only checked exception (excluding subclasses of this class) emitted + by the data layer. + +

This class is an example of + Data Exception Wrapping, + and hides the various exceptions which arise from the various flavors + of datastore implementation, such as SQLException, + IOException, and BackingStoreException. + +

Thrown when a low-level, unusual problem is encountered with the data store. + Examples of such a problem might include : +

    +
  • faulty db connection +
  • failed file input-output +
  • inaccesible network connection +
+*/ +public class DAOException extends AppException { + + /** + Constructor. + +

Both arguements are passed to + {@link AppException#AppException(String, Throwable)}. + + @param aMessage text describing the problem. Must have content. + @param aThrowable root cause underlying the problem. + */ + public DAOException(String aMessage, Throwable aThrowable){ + super(aMessage, aThrowable); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/Db.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/Db.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,356 @@ +package hirondelle.web4j.database; + +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.model.AppException; +import hirondelle.web4j.model.Id; +import hirondelle.web4j.readconfig.Config; +import hirondelle.web4j.readconfig.ConfigReader; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + Utility class for the most common database tasks. + +

This class allows many DAO methods to be implemented in one or two lines + of simple code. + +

SQL Parameters

+ SQL statement parameters are passed to this class using an Object... + sequence parameter. The objects in these arrays must be one of the classes + supported by {@link hirondelle.web4j.database.ConvertColumn}. + + The number and order of these parameter objects must match + the number and order of the '?' parameters in the underlying SQL + statement. + +

For Id objects, the underlying column must be modeled as text, not a number. If + the underlying column is numeric, then the caller must convert an {@link Id} into a numeric form + using {@link Id#asInteger} or {@link Id#asLong}. + +

Locale and TimeZone objects represent a bit of a special case. + Take for example a table storing user preferences, which stores locale and time zone. + How would you store that information? There are 2 basic styles: +

    +
  • just place the Locale and TimeZone identifiers (en_CA, America/Montreal) in their own + columns, as text; this style will repeat the identifiers, and will not be 'normalized data', in database terminology. +
  • define code tables for Locale and TimeZone, to define the accepted values, and link the user preferences + table to them, using a foreign key. +
+ +

The second form is usually more robust, since it's normalized. However, when it is used, passing Locale and + TimeZone objects directly to an INSERT statement, for example, using this class, is not appropriate. + Instead, you'll need to treat them as any other code table, and translate the en_CA (for example) into + a corresponding foreign key identifier. In this case, the fact that Db supports Locale and TimeZone + becomes irrelevant, since you will translate them into an Id anyway. + +

Parsing Columns Into Objects

+ For operations involving a ResultSet, this class will always use the application's + {@link ConvertColumn} implementation to convert columns into various building block objects. + +

In addition, it uses an ordering convention to map ResultSet columns to Model + Object constructor arguments. See the package overview for more information on this important + point. + +

Compound Objects

+ Occasionally, it is desirable to present a large amount of summary information to the user on a single page. + In this case, an application needs a single large Model Object (a parent) containing collections + of other Model Objects (children). Here, these are called Compound Objects. + +

Constructing an arbitrary Compound Object can always be performed in multiple steps: first + fetch the children, and then construct the parent, by passing the children to + the parent's constructor. + +

For the simplest cases, this can be performed conveniently in a single step, using + the fetchCompound and listCompound methods of this class. These + methods process a ResultSet in a fundamentally different way : instead of translating a + single row into a single Model Object, they can translate groups of 1..N rows into + a single Model Object instead. + +

Here is an illustration. The target Model Object constructor has the form (for example): +

+ public UserRole (String aUserName, List<Id> aRoles)  {
+   ...
+ }
+ 
+ That is, the constructor takes a single {@link List} of Model Objects at + the end of its list of arguments. Here, a List of {@link Id} objects appears at the end. + The List can be a List of Model Objects, or a List of Base Objects supported by + {@link hirondelle.web4j.model.ConvertParam}. + +

The underlying SELECT statement returns data across a 0..N relation, with data + in the first N columns repeating the parent data, and with the remaining M columns containing the child data. + For example: +

+ SELECT Name, Role FROM UserRole ORDER BY Role
+ 
+ which has a ResultSet of the form : + + + + + + +
NameRole
kenarnoldaccess-control
kenarnolduser-general
kenarnolduser-president
davidholmesuser-general
+ +

That is, the repeated parent data (Name) comes first and is attached to the parent, while the + child data (Role) appears only in the final columns. In addition, changes to the + value in the first column must indicate that a new parent has started. + +

If the above requirements are satisfied, then a {@code List} is built using + {@link #listCompound(Class, Class, int, SqlId, Object[])}, as in: +

+ Db.listCompound(UserRole.class, Id.class, 1, ROLES_LIST_SQL);
+ 
+*/ +public final class Db { + + /** + SELECT operation which returns a single Model Object. + + @param aClass class of the returned Model Object. + @param aSqlId identifies the underlying SQL statement. + @param aParams parameters for the SQL statement. + @return null if no record is found. + */ + public static T fetch(Class aClass, SqlId aSqlId, Object... aParams) throws DAOException { + SqlFetcher fetcher = SqlFetcher.forSingleOp(aSqlId, aParams); + ModelFromRow builder = new ModelFromRow(aClass); + return fetcher.fetchObject(builder); + } + + /** + SELECT operation which returns a single 'building block' value such as Integer, BigDecimal, and so on. + + @param aSupportedTargetClass class supported by the configured + implementation of {@link ConvertColumn}. + @param aSqlId identifies the underlying SQL statement. + @param aParams parameters for the SQL statement. + @return null if no record is found. + */ + public static T fetchValue(Class aSupportedTargetClass, SqlId aSqlId, Object... aParams) throws DAOException { + SqlFetcher fetcher = SqlFetcher.forSingleOp(aSqlId, aParams); + ModelBuilder builder = new ValueFromRow(aSupportedTargetClass); + return fetcher.fetchObject(builder); + } + + /** + SELECT operation which returns 0..N Model Objects, one per row. + + @param aClass class of the returned Model Objects. + @param aSqlId identifies the underlying SQL statement. + @param aParams parameters for the SQL statement. + @return an unmodifiable {@link List} of Model Objects. The list may be empty. + */ + public static List list(Class aClass, SqlId aSqlId, Object... aParams) throws DAOException { + List result = new ArrayList(); + SqlFetcher fetcher = SqlFetcher.forSingleOp(aSqlId, aParams); + ModelBuilder builder = new ModelFromRow(aClass); + fetcher.fetchObjects(builder, result); + return Collections.unmodifiableList(result); + } + + /** + SELECT operation which returns a List of 'building block' values such + as Integer, BigDecimal, and so on. + + @param aSupportedTargetClass class supported by the configured + implementation of {@link ConvertColumn}. + @param aSqlId identifies the underlying SQL statement. + @param aParams parameters for the SQL statement. + @return an unmodifiable {@link List} of building block objects. The list may be empty. + */ + public static List listValues(Class aSupportedTargetClass, SqlId aSqlId, Object... aParams) throws DAOException { + List result = new ArrayList(); + SqlFetcher fetcher = SqlFetcher.forSingleOp(aSqlId, aParams); + ModelBuilder builder = new ValueFromRow(aSupportedTargetClass); + fetcher.fetchObjects(builder, result); + return Collections.unmodifiableList(result); + } + + /** + SELECT operation that returns a List of Model Objects "subsetted" to + a particular range of rows. + +

This method is intended for paging through long listings. When the underlying + SELECT returns many pages of items, the records can be "subsetted" by + calling this method. + +

See {@link hirondelle.web4j.ui.tag.Pager}. + @param aClass class of the returned Model Objects. + @param aSqlId identifies the underlying SQL statement. + @param aStartIndex 1-based index indentifying the first row to be returned. + @param aPageSize number of records to be returned. + @param aParams parameters for the SQL statement. + @return an unmodifiable {@link List} of Model Objects. The list may be empty. + */ + public static List listRange(Class aClass, SqlId aSqlId, Integer aStartIndex, Integer aPageSize, Object... aParams) throws DAOException { + List result = new ArrayList(); + SqlFetcher fetcher = SqlFetcher.forSingleOp(aSqlId, aParams); + fetcher.limitRowsToRange(aStartIndex, aPageSize); + ModelBuilder builder = new ModelFromRow(aClass); + fetcher.fetchObjects(builder, result); + return Collections.unmodifiableList(result); + } + + /** + SELECT operation for listing the result of a user's search with the given {@link DynamicSql} + and corresponding parameter values. + +

This method is called only if the exact underlying criteria are not known beforehand, but are rather + determined dynamically by user selections. See {@link DynamicSql} for more information. + + @param aClass class of the returned Model Objects. + @param aSqlId identifies the underlying SQL statement. + @param aSearchCriteria criteria for the given search, containing WHERE and ORDER BY clauses. + @param aParams parameters for the SQL statement, corresponding to the given criteria. + @return an unmodifiable {@link List} of Model Objects, corresponding to the input criteria. The list may be empty. + */ + public static List search(Class aClass, SqlId aSqlId, DynamicSql aSearchCriteria, Object... aParams) throws DAOException { + List result = new ArrayList(); + SqlFetcher fetcher = SqlFetcher.forSearch(aSqlId, aSearchCriteria, aParams); + ModelBuilder builder = new ModelFromRow(aClass); + fetcher.fetchObjects(builder, result); + return Collections.unmodifiableList(result); + } + + /** + INSERT, UPDATE, or DELETE operations which take parameters. + + @param aSqlId identifies the underlying SQL statement. + @param aParams parameters for the SQL statement. + @return the number of records affected by this edit operation. + */ + public static int edit(SqlId aSqlId, Object... aParams) throws DAOException, DuplicateException { + SqlEditor change = SqlEditor.forSingleOp(aSqlId, aParams); + return change.editDatabase(); + } + + /** + INSERT operation which returns the database identifier of the added record. + +

This operation is not supported by all databases. See + {@link java.sql.Statement} for more information. + + @param aSqlId identifies the underlying SQL statement. + @param aParams parameters for the SQL statement. + */ + public static Id add(SqlId aSqlId, Object... aParams) throws DAOException, DuplicateException { + SqlEditor add = SqlEditor.forSingleOp(aSqlId, aParams); + return new Id(add.addRecord()); + } + + /** + DELETE operation which takes parameters. + + @param aSqlId identifies the underlying SQL statement. + @param aParams identifies the item to be deleted. Often 1 or more {@link Id} objects. + @return the number of deleted records. + */ + public static int delete(SqlId aSqlId, Object... aParams) throws DAOException { + SqlEditor delete = SqlEditor.forSingleOp(aSqlId, aParams); + return delete.editDatabase(); + } + + /** + SELECT operation which typically returns a single item with a 0..N relation. + +

The ResultSet is parsed into a single parent Model Object having a List of + 0..N child Model Objects. + See note on compound objects for more information. + + @param aClassParent class of the parent Model Object. + @param aClassChild class of the child Model Object. + @param aNumTrailingColsForChildList number of columns appearing at the end of the ResultSet which + are passed to the child constructor. + @param aSqlId identifies the underlying SQL statement. + @param aParams parameters to the underlying SQL statement. + */ + public static T fetchCompound(Class aClassParent, Class aClassChild, int aNumTrailingColsForChildList, SqlId aSqlId, Object... aParams) throws DAOException { + SqlFetcher fetcher = SqlFetcher.forSingleOp(aSqlId, aParams); + ModelBuilder builder = new ModelFromRow(aClassParent, aClassChild, aNumTrailingColsForChildList); + return fetcher.fetchObject(builder); + } + + /** + SELECT operation which typically returns mutliple items item with a 0..N relation. + +

The ResultSet is parsed into a List of parent Model Objects, each having 0..N + child Model Objects. See note on compound objects for more information. + + @param aClassParent class of the parent Model Object. + @param aClassChild class of the child Model Object. + @param aNumTrailingColsForChildList number of columns appearing at the end of the ResultSet which + are passed to the child constructor. + @param aSqlId identifies the underlying SQL statement. + @param aParams parameters to the underlying SQL statement. + */ + public static List listCompound(Class aClassParent, Class aClassChild, int aNumTrailingColsForChildList, SqlId aSqlId, Object... aParams) throws DAOException { + List result = new ArrayList(); + SqlFetcher fetcher = SqlFetcher.forSingleOp(aSqlId, aParams); + ModelBuilder builder = new ModelFromRow(aClassParent, aClassChild, aNumTrailingColsForChildList); + fetcher.fetchObjects(builder, result); + return result; + } + + /** + Add an Id to a list of parameters already extracted from a Model Object. + +

This method exists to avoid repetition in your DAOs regarding the parameters + passed to add and change operations. + +

Take the following example : +

+  INSERT INTO Resto (Name, Location, Price, Comment) VALUES (?,?,?,?)
+  UPDATE Resto SET Name=?, Location=?, Price=?, Comment=? WHERE Id=?
+  
+ In this case, the parameters are exactly the same, and appear in the same order, + except for the Id at the end of the UPDATE statement. + +

In such cases, this method can be used to simply append the Id to an + already existing list of parameters. + + @param aBaseParams all parameters used in an INSERT statement + @param aId the Id parameter to append to aBaseParams, + @return parameters needed for a change operation + */ + public static Object[] addIdTo(Object[] aBaseParams, Id aId){ + List result = new ArrayList(); + for(Object thing: aBaseParams){ + result.add(thing); + } + result.add(aId); + return result.toArray(); + } + + /** + Initialize the web4j database layer only. + This method is intended for using the web4j database layer outside of a servlet container (for example, + in a command-line application). + +

The idea is that the services of the web4j data layer can be used in any application, not just web applications. + Callers will also need to supply an implementation of {@link ConnectionSource}, in the usual way, by either + making use of the standard package name and class name ('hirondelle.web4j.config.ConnectionSrc'), or by specifying + the class name in the provided settings parameter. Callers that make use of the Reports class may also need to + supply an implementation of {@link hirondelle.web4j.request.DateConverter}. + + @param aSettings correspond to the same name-value pairs defined in web.xml, and take the same values. + Callers should pay particular attention to the settings related to the database. + See the User Guide for more information. + @param aRawSql each String in this list corresponds to the contents of a single .sql file. + */ + public static void initStandalone(Map aSettings, List aRawSql) throws AppException{ + Config.init(aSettings); + BuildImpl.initDatabaseLayer(aSettings); //not all interfaces init-ed + Map processedSql = ConfigReader.processRawSql(aRawSql); + SqlStatement.initSqlStatementsManually(processedSql); + } + + // PRIVATE + + private Db() { + //prevent construction by the caller + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/DbConfig.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/DbConfig.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,167 @@ +package hirondelle.web4j.database; + +import static hirondelle.web4j.util.Consts.FAILURE; +import static hirondelle.web4j.util.Consts.NEW_LINE; +import static hirondelle.web4j.util.Consts.SUCCESS; +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.util.Util; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.logging.Logger; + +/** + (UNPUBLISHED) Initialize the data layer and return related configuration information. + +

Acts as the single source of configuration information needed by the + data layer. The intent is to centralize the dependencies of the data + layer on its environment. For example, this is the only class in this package + which knows about ServletConfig. + +

This class carries simple static, immutable data, populated upon startup. + It is safe to use this class in a multi-threaded environment. + +

In addition to reading in web.xml settings, this class : +

    +
  • initializes connection sources +
  • logs the name and version of both the database and the database driver +
  • logs the support for transaction isolation levels (see {@link TxIsolationLevel}) +
  • reads in the *.sql file(s) (see package summary for more information) +
+ +

See web.xml for more information on the items encapsulated by this class. + + @un.published +*/ +public final class DbConfig { + + /** + Configure the data layer. Called upon startup. + +

If aIndicator is YES, then + hard-coded test settings will be used. (This is intended solely as a developer + convenience for testing database connectivity. If called outside of a web + container, then aConfig must be null.) + + @return the names of all databases that were seen to be up. + */ + public static Set initDataLayer() throws DAOException { + Set result = new LinkedHashSet(); + //there are order dependencies here!! + ConnectionSource connSrc = BuildImpl.forConnectionSource(); + for (String dbName : connSrc.getDatabaseNames()){ + boolean healthy = checkHealthOf(dbName); + if(healthy) { + result.add(dbName); + } + } + + int numDbs = connSrc.getDatabaseNames().size(); + if((numDbs > 0) && (result.size() == numDbs)) { + fLogger.config("*** SUCCESS : ALL DATABASES DETECTED OK! *** Num databases: " + numDbs); + } + + SqlStatement.readSqlFile(); + return result; + } + + /** + Check the health of the given database connection. For healthy connections, log + basic info as a side-effect. Return true only if healthy. + */ + public static boolean checkHealthOf(String dbName) throws DAOException { + boolean result = logDatabaseAndDriverNames(dbName); + if(result) { + fLogger.config("Success : Database named " + Util.quote(dbName) + " detected OK."); + queryTxIsolationLevelSupport(dbName); + } + else { + fLogger.severe("Cannot connect to database named " + Util.quote(dbName) + ". Is database running?"); + } + return result; + } + + // PRIVATE + + private static final Logger fLogger = Util.getLogger(DbConfig.class); + + private DbConfig(){ + //empty - prevent construction by the caller + } + + /** + Log name and version info, for both the database and driver. + +

This is the first place where the database is exercised. Returns false only if an error occurs. + This indicates to the caller that db init tasks have not completed normally. + The caller must check the return value. + */ + private static boolean logDatabaseAndDriverNames(String aDbName) throws DAOException { + boolean result = SUCCESS; + Connection connection = null; + try { + connection = BuildImpl.forConnectionSource().getConnection(aDbName); + DatabaseMetaData db = connection.getMetaData(); + String dbName = db.getDatabaseProductName() + "/" + db.getDatabaseProductVersion(); + String dbDriverName = db.getDriverName() + "/" + db.getDriverVersion(); + String message = NEW_LINE; + message = message + " Database Id passed to ConnectionSource: " + aDbName + NEW_LINE; + message = message + " Database name: " + dbName + NEW_LINE; + message = message + " Database driver name: " + dbDriverName + NEW_LINE; + message = message + " Database URL: " + db.getURL() + NEW_LINE; + boolean supportsScrollable = db.supportsResultSetConcurrency(ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY); + message = message + " Supports scrollable ResultSets (TYPE_SCROLL_INSENSITIVE, CONCUR_READ_ONLY): " + supportsScrollable; + if ( ! supportsScrollable ){ + fLogger.severe("Database/driver " + aDbName + " does not support scrollable ResultSets. Parsing of ResultSets by ModelFromRow into 'Parent+List' kinds of objects will not work, since it depends on scrollable ResultSets. Parsing into 'ordinary' Model Objects will still work, however, since they do not depend on scrollable ResultSets.)"); + } + fLogger.config(message); + } + catch (Throwable ex) { + result = FAILURE; + } + finally { + DbUtil.close(connection); + } + return result; + } + + /** For each {@link TxIsolationLevel}, log if it is supported (true/false). */ + private static void queryTxIsolationLevelSupport(String aDbName) throws DAOException { + Connection connection = BuildImpl.forConnectionSource().getConnection(aDbName); + try { + int defaultTxLevel = connection.getMetaData().getDefaultTransactionIsolation(); + DatabaseMetaData db = connection.getMetaData(); + for(TxIsolationLevel level: TxIsolationLevel.values()){ + fLogger.config(getTxIsolationLevelMessage(db, level, defaultTxLevel) ); + } + } + catch (SQLException ex) { + throw new DAOException("Cannot query database for transaction level support", ex); + } + finally { + DbUtil.close(connection); + } + } + + /** Create a message describing support for aTxIsolationLevel. */ + private static String getTxIsolationLevelMessage (DatabaseMetaData aDb, TxIsolationLevel aLevel, int aDefaultTxLevel) { + StringBuilder result = new StringBuilder(); + result.append("Supports Tx Isolation Level " + aLevel.toString() + ": "); + try { + boolean supportsLevel = aDb.supportsTransactionIsolationLevel(aLevel.getInt()); + result.append(supportsLevel); + } + catch(SQLException ex){ + fLogger.warning("Database driver doesn't support calla to supportsTransactionalIsolationLevel(int)."); + result.append( "Unknown"); + } + if ( aLevel.getInt() == aDefaultTxLevel ) { + result.append(" (default)"); + } + return result.toString(); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/DbTx.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/DbTx.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,205 @@ +package hirondelle.web4j.database; + +import hirondelle.web4j.model.Id; +import java.sql.Connection; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + Version of {@link Db} for use in a transaction. + +

The {@link Db} class uses an internal connection to execute single operations. + If more than one operation needs to be performed as an atomic transaction, then + this class is used instead. + +

This class is identical to {@link Db}, except that it uses a {@link Connection} passed + by the caller. + +

SQL Parameters

+ The parameters for SQL statements used by this class have the same behavior as defined by + the Db class. +*/ +public final class DbTx { + + /** + SELECT operation which returns a single Model Object. + + @param aConnection single connection shared by all operations in the transaction. + @param aClass class of the returned Model Object. + @param aSqlId identifies the underlying SQL statement. + @param aParams parameters for the SQL statement. + */ + public static T fetch(Connection aConnection, Class aClass, SqlId aSqlId, Object... aParams) throws DAOException { + SqlFetcher fetcher = SqlFetcher.forTx(aSqlId, aConnection, aParams); + ModelFromRow builder = new ModelFromRow(aClass); + return fetcher.fetchObject(builder); + } + + /** + SELECT operation which returns a single 'building block' value such as Integer, BigDecimal, and so on. + + @param aConnection single connection shared by all operations in the transaction. + @param aSupportedTargetClass class supported by the configured + implementation of {@link ConvertColumn}. + @param aSqlId identifies the underlying SQL statement. + @param aParams parameters for the SQL statement. + */ + public static T fetchValue(Connection aConnection, Class aSupportedTargetClass, SqlId aSqlId, Object... aParams) throws DAOException { + SqlFetcher fetcher = SqlFetcher.forTx(aSqlId, aConnection, aParams); + ModelBuilder builder = new ValueFromRow(aSupportedTargetClass); + return fetcher.fetchObject(builder); + } + + /** + SELECT operation which returns 0..N Model Objects, one per row. + + @param aConnection single connection shared by all operations in the transaction. + @param aClass class of the returned Model Objects. + @param aSqlId identifies the underlying SQL statement. + @param aParams parameters for the SQL statement. + @return an unmodifiable {@link List} of Model Objects. The list may be empty. + */ + public static List list(Connection aConnection, Class aClass, SqlId aSqlId, Object... aParams) throws DAOException { + List result = new ArrayList(); + SqlFetcher fetcher = SqlFetcher.forTx(aSqlId, aConnection, aParams); + ModelBuilder builder = new ModelFromRow(aClass); + fetcher.fetchObjects(builder, result); + return Collections.unmodifiableList(result); + } + + /** + SELECT operation which returns a List of 'building block' values such + as Integer, BigDecimal, and so on. + + @param aConnection single connection shared by all operations in the transaction. + @param aSupportedTargetClass class supported by the configured + implementation of {@link ConvertColumn}. + @param aSqlId identifies the underlying SQL statement. + @param aParams parameters for the SQL statement. + @return an unmodifiable {@link List} of building block objects. The list may be empty. + */ + public static List listValues(Connection aConnection, Class aSupportedTargetClass, SqlId aSqlId, Object... aParams) throws DAOException { + List result = new ArrayList(); + SqlFetcher fetcher = SqlFetcher.forTx(aSqlId, aConnection, aParams); + ModelBuilder builder = new ValueFromRow(aSupportedTargetClass); + fetcher.fetchObjects(builder, result); + return Collections.unmodifiableList(result); + } + + /** + SELECT operation that returns a List of Model Objects "subsetted" to + a particular range of rows. + +

This method is intended for paging through long listings. When the underlying + SELECT returns many pages of items, the records can be "subsetted" by + calling this method. + +

See {@link hirondelle.web4j.ui.tag.Pager}. + @param aConnection single connection shared by all operations in the transaction. + @param aClass class of the returned Model Objects. + @param aSqlId identifies the underlying SQL statement. + @param aStartIndex 1-based index indentifying the first row to be returned. + @param aPageSize number of records to be returned. + @param aParams parameters for the SQL statement. + @return an unmodifiable {@link List} of Model Objects. The list may be empty. + */ + public static List listRange(Connection aConnection, Class aClass, SqlId aSqlId, Integer aStartIndex, Integer aPageSize, Object... aParams) throws DAOException { + List result = new ArrayList(); + SqlFetcher fetcher = SqlFetcher.forTx(aSqlId, aConnection, aParams); + fetcher.limitRowsToRange(aStartIndex, aPageSize); + ModelBuilder builder = new ModelFromRow(aClass); + fetcher.fetchObjects(builder, result); + return Collections.unmodifiableList(result); + } + + /** + INSERT, UPDATE, or DELETE operations which take parameters. + + @param aConnection single connection shared by all operations in the transaction. + @param aSqlId identifies the underlying SQL statement. + @param aParams parameters for the SQL statement. + @return the number of records affected by this edit operation. + */ + public static int edit(Connection aConnection, SqlId aSqlId, Object... aParams) throws DAOException, DuplicateException { + SqlEditor change = SqlEditor.forTx(aSqlId, aConnection, aParams); + return change.editDatabase(); + } + + /** + INSERT operation which returns the database identifier of the added record. + +

This operation is not supported by all databases. See + {@link java.sql.Statement} for more information. + + @param aConnection single connection shared by all operations in the transaction. + @param aSqlId identifies the underlying SQL statement. + @param aParams parameters for the SQL statement. + */ + public static Id add(Connection aConnection, SqlId aSqlId, Object... aParams) throws DAOException, DuplicateException { + SqlEditor add = SqlEditor.forTx(aSqlId, aConnection, aParams); + return new Id(add.addRecord()); + } + + /** + DELETE operation which takes parameters. + + @param aConnection single connection shared by all operations in the transaction. + @param aSqlId identifies the underlying SQL statement. + @param aParams identifies the item to be deleted. Often 1 or more {@link Id} objects. + @return the number of deleted records. + */ + public static int delete(Connection aConnection, SqlId aSqlId, Object... aParams) throws DAOException { + SqlEditor delete = SqlEditor.forTx(aSqlId, aConnection, aParams); + return delete.editDatabase(); + } + + /** + SELECT operation which typically returns a single item with a 0..N relation. + +

The ResultSet is parsed into a single parent Model Object having a List of + 0..N child Model Objects. + See note on compound objects for more information. + + @param aConnection single connection shared by all operations in the transaction. + @param aClassParent class of the parent Model Object. + @param aClassChild class of the child Model Object. + @param aNumTrailingColsForChildList number of columns appearing at the end of the ResultSet which + are passed to the child constructor. + @param aSqlId identifies the underlying SQL statement. + @param aParams parameters to the underlying SQL statement. + */ + public static T fetchCompound(Connection aConnection, Class aClassParent, Class aClassChild, int aNumTrailingColsForChildList, SqlId aSqlId, Object... aParams) throws DAOException { + SqlFetcher fetcher = SqlFetcher.forTx(aSqlId, aConnection, aParams); + ModelBuilder builder = new ModelFromRow(aClassParent, aClassChild, aNumTrailingColsForChildList); + return fetcher.fetchObject(builder); + } + + /** + SELECT operation which typically returns mutliple items item with a 0..N relation. + +

The ResultSet is parsed into a List of parent Model Objects, each having 0..N + child Model Objects. See note on compound objects for more information. + + @param aConnection single connection shared by all operations in the transaction. + @param aClassParent class of the parent Model Object. + @param aClassChild class of the child Model Object. + @param aNumTrailingColsForChildList number of columns appearing at the end of the ResultSet which + are passed to the child constructor. + @param aSqlId identifies the underlying SQL statement. + @param aParams parameters to the underlying SQL statement. + */ + public static List listCompound(Connection aConnection, Class aClassParent, Class aClassChild, int aNumTrailingColsForChildList, SqlId aSqlId, Object... aParams) throws DAOException { + List result = new ArrayList(); + SqlFetcher fetcher = SqlFetcher.forTx(aSqlId, aConnection, aParams); + ModelBuilder builder = new ModelFromRow(aClassParent, aClassChild, aNumTrailingColsForChildList); + fetcher.fetchObjects(builder, result); + return result; + } + + // PRIVATE + + private DbTx() { + //prevent construction by the caller + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/DbUtil.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/DbUtil.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,86 @@ +package hirondelle.web4j.database; + +import java.sql.*; +import java.util.logging.*; + +import hirondelle.web4j.util.Util; + +/** + Static methods for relational databases, which avoid code repetition. +*/ +final class DbUtil { + + /** + Close a Statement first, then its associated Connection. +

Parameters are permitted to be null. + */ + static void close(PreparedStatement aStatement, Connection aConnection) throws DAOException { + try { + if (aStatement != null) aStatement.close(); + if (aConnection != null) aConnection.close(); + } + catch (SQLException ex){ + throw new DAOException("Cannot close statement or connection: " + ex, ex); + } + } + + /** + Close a possibly-null Connection. + */ + static void close(Connection aConnection) throws DAOException { + close(null, aConnection); + } + + /** Close a possibly-null Statement. */ + static void close(PreparedStatement aStatement) throws DAOException { + close(aStatement, null); + } + + /** + If aStatement has any warnings, then log them using "severe" level. + */ + static void logWarnings(Statement aStatement) { + try { + SQLWarning warning = aStatement.getWarnings(); + logWarning(warning); + } + catch (SQLException exception) { + //do nothing, since not critical + } + } + + /** + If aConnection has any warnings, then log them using "severe" level. + */ + static void logWarnings(Connection aConnection) { + try { + SQLWarning warning = aConnection.getWarnings(); + logWarning(warning); + } + catch (SQLException exception) { + //do nothing, since not critical + } + } + + // PRIVATE // + + private static final Logger fLogger = Util.getLogger(DbUtil.class); + + private static void logWarning(SQLWarning aWarning){ + //minor optimisation for the nominal case + if (aWarning == null) return; + + final StringBuilder message = new StringBuilder(); + SQLWarning warning = aWarning; + while ( warning!= null ){ + message.append("***SQL WARNING***: "); + message.append( warning ); + message.append(" SQL ErrorId Code:"); + message.append( warning.getErrorCode() ); + message.append(" SQL State:"); + message.append( warning.getSQLState()); + warning = warning.getNextWarning(); + } + fLogger.severe(message.toString()); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/DuplicateException.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/DuplicateException.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,62 @@ +package hirondelle.web4j.database; + +/** + Thrown when a uniqueness problem occurs in the datastore + during an ADD or CHANGE operation. + +

This type of exception is singled out since it is so common. It allows + {@link hirondelle.web4j.action.Action}s to catch this specific kind of exception. + +

For relational databases, this exception should be thrown for INSERT + and UPDATE operations which may violate a UNIQUE or + PRIMARY KEY constraint, or similar item. + {@link Db}, {@link DbTx}, and {@link TxTemplate} will throw a DuplicateException + exception for {@link java.sql.SQLException}s having an error code matching the + ErrorCodeForDuplicateKey configured in web.xml. + See web.xml for more information. + +

Typical Use Case

+ Here, an {@link hirondelle.web4j.action.Action} is calling a DAO method which may throw + a DuplicateException: +
+private void addSomething throws DAOException {
+  //this try..catch is needed only if the operation 
+  //can have a duplicate problem
+  try {
+    dao.addSomething();
+  }
+  catch (DuplicateException ex){
+    addError("Cannot add. That item already exists.");
+  }
+}
+
+

Note that if the operation cannot have a duplicate problem, then the Action + should not attempt to catch DuplicateException. +

+ Here is the DAO operation which may have a duplicate problem. +

+//It is highly recommended, but optional, to declare 
+//DuplicateException in this method header, to bring 
+//it to the attention of the caller
+public addSomething() throws DAOException, DuplicateException {
+  //...elided
+}
+
+ Again, if the operation cannot have a duplicate problem, then the DAO should not + declare a DuplicateException in its throws clause. + +

The {@link Db#add(SqlId, Object[])} and {@link Db#edit(SqlId, Object[])} methods can throw a + DuplicateException. +*/ +public final class DuplicateException extends DAOException { + + /** + Constructor. + +

Arguments are passed to {@link DAOException#DAOException(String, Throwable)}. + */ + public DuplicateException(String aMessage, Throwable aRootCause) { + super(aMessage, aRootCause); + } + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/DynamicSql.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/DynamicSql.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,137 @@ +package hirondelle.web4j.database; + +import hirondelle.web4j.util.Util; +import static hirondelle.web4j.util.Consts.NEW_LINE; + +/** + Dynamic SQL statement created in code. The SQL statement can be either: +

    +
  • a complete statement +
  • a fragment of a statement, to be appended to the end of a static base statement +
+ +

This class is intended for two use cases: +

    +
  • creating a statement entirely in code +
  • creating WHERE and ORDER BY clauses dynamically, by building sort and filter criteria from + user input +
+ +

The creation of SQL in code is dangerous. + You have to exercise care that your code will not be subject to + SQL Injection attacks. + If you don't know what such attacks are all about, then you are in danger of creating + very large, dangerous security flaws in your application. + +

The main means of protecting yourself from these attacks is to ensure that the sql strings you pass to + this class never contain data that has come directly from the user, in an unescaped form. + You achieve this by parameterizing user input, and proceeding in 2 steps: +

    +
  1. create an SQL statement that always uses a ? placeholder for data entered by the user +
  2. pass all user-entered data as parameters to the above statement +
+ + The above corresponds to the correct use of a PreparedStatement. + After you have built your dynamic SQL, you will usually pass it to + {@link hirondelle.web4j.database.Db#search(Class, SqlId, DynamicSql, Object[])} to retrieve the data. + +

Entries in .sql Files

+

The SQL string you pass to this class is always appended (using {@link #toString}) to + a (possibly-empty) base SQL statement already defined (as usual), in your .sql file. + That entry can take several forms. The criteria on the entry are: +

    +
  • it can be precompiled by WEB4J upon startup, if desired. +
  • it contains only static elements of the final SQL statement +
+ + It's important to note that the static base SQL can be completely empty. + For example, the entry in your .sql file can look something like this: +
MY_DYNAMIC_REPORT {
+    -- this sql is generated in code
+}
+ As you can see, there's only a comment here; there's no real SQL. + In this case, you will need to build the entire SQL statement in code. + (Even though the above entry is empty, it's still necessary, since it's where you specify any + non-default target database name. It also ensures that the same mechanism web4j applies + to processing SqlId objects will remain in effect, which is useful.) + +

You are encouraged to implement joins between tables using the JOIN syntax. + The alternative is to implement joins using expressions in the WHERE clause. + This usually isn't desirable, since it mixes up two distinct items - joins and actual criteria. + Using JOIN allows these items to remain separate and distinct. + +

See Also

+ Other items closely related to this class are : +
    +
  • {@link hirondelle.web4j.action.ActionImpl#getOrderBy(hirondelle.web4j.request.RequestParameter,hirondelle.web4j.request.RequestParameter, String)} - + convenience method for constructing an ORDER BY clause from request parameters. +
  • {@link hirondelle.web4j.database.Db#search(Class, SqlId, DynamicSql, Object[])} +
  • the {@link hirondelle.web4j.database.Report} class. +
+ +

Constants

+ The {@link #WHERE}, {@link #AND}, and other constants are included in this class as a simple + convenience. Note that each value includes a leading a trailing space, to avoid trivial spacing errors. + +

This class is non-final, and can be overridden, if desired. The reason is that some applications may wish to try to + validate that the SQL passed to this class has been properly parameterized. +*/ +public class DynamicSql {; + + /** Value - {@value}, convenience value for building a WHERE clause.*/ + public static final String WHERE = " WHERE "; + + /** Value - {@value}, convenience value for building a WHERE clause. */ + public static final String AND = " AND "; + + /** Value - {@value}, convenience value for building a WHERE clause. */ + public static final String OR = " OR "; + + /** Value - {@value}, convenience value for building an ORDER BY clause. */ + public static final String ORDER_BY = " ORDER BY "; + + /** Value - {@value}, convenience value for building an ORDER BY clause. */ + public static final String ASC = " ASC "; + + /** Value - {@value}, convenience value for building an ORDER BY clause. */ + public static final String DESC = " DESC "; + + /** + Represents the absence of any criteria. The value of this item is simply null. +

If a method allows a null object to indicate the absence of any criteria, + then it is recommended that this reference be used instead of null. + */ + public static final DynamicSql NONE = null; + + /** + Constructor. +

This constructor will slightly modify the given parameter: it will trim it, and prepend a new line to the result. + @param aSql must have content; it will be trimmed by this method. + */ + public DynamicSql(String aSql){ + if( ! Util.textHasContent(aSql) ){ + throw new IllegalArgumentException("The SQL text has no content."); + } + fSql = NEW_LINE + aSql.trim(); + } + + + /** Convenience constructor, forwards to {@link #DynamicSql(String)}. */ + public DynamicSql(StringBuilder aSql){ + this(aSql.toString()); + } + + /** + Return the String passed to the constructor, trimmed. + +

The returned value is appended by the framework to an existing (possibly empty) entry in an .sql file. + */ + @Override final public String toString(){ + return fSql; + } + + // PRIVATE + + private String fSql = ""; + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/ForeignKeyException.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/ForeignKeyException.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,54 @@ +package hirondelle.web4j.database; + +/** + Thrown when a violation of a foreign key constraint occurs in the datastore + during an ADD, CHANGE, or DELETE operation. + +

This type of exception is singled out since it is so common. It allows + {@link hirondelle.web4j.action.Action}s to catch this specific kind of exception. + +

For relational databases, this exception should be thrown for INSERT, + UPDATE, or DELETE operation which may violate a foreign key constraint. + {@link Db}, {@link DbTx}, and {@link TxTemplate} will throw a ForeignKeyException + exception for {@link java.sql.SQLException}s having an error code matching the + ErrorCodeForForeignKey setting configured in web.xml. + See web.xml for more information. + +

Typical Use Case

+ Here, an {@link hirondelle.web4j.action.Action} is calling a DAO method which may throw + a ForeignKeyException: +
+private void deleteSomething throws DAOException {
+  //this try..catch is needed only if the operation 
+  //can have a foreign key problem
+  try {
+    dao.deleteSomething();
+  }
+  catch (ForeignKeyException ex){
+    addError("Cannot delete. Referenced by some other item.");
+  }
+}
+
+

+ Here is the DAO operation which may have a foreign key constraint problem. +

+//It is highly recommended, but optional, to declare 
+//ForeignKeyException in this method header, to bring 
+//it to the attention of the caller
+public deleteSomething() throws DAOException, ForeignKeyException {
+  //...elided
+}
+
+*/ +public final class ForeignKeyException extends DAOException { + + /** + Constructor. + +

Arguments are passed to {@link DAOException#DAOException(String, Throwable)}. + */ + public ForeignKeyException(String aMessage, Throwable aRootCause) { + super(aMessage, aRootCause); + } + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/ModelBuilder.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/ModelBuilder.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,53 @@ +package hirondelle.web4j.database; + +import java.sql.ResultSet; +import java.sql.SQLException; +import hirondelle.web4j.model.ModelCtorException; + +/** + Abstract Base Class for building Model Objects from a ResultSet. + +

This class exists for two reasons : +

    +
  • to translate exceptions +
  • to allow the construction of both Model Objects and building block + objects such as Date, Integer, and so on. These two differ + since building block objects usually have multiple constructors with the same + number of arguments. +
+*/ +abstract class ModelBuilder { + + /** + Template Method for parsing a single row into an arbitrary Object. + The returned Object often represents a Model Object, but can also + represent an Integer value, a BigDecimal value, a Map, + or any other object whatsoever. + +

This template method calls the abstract method {@link #build}. This + method exists only to translate any exceptions thrown by + {@link #buildObject(ResultSet)} into a {@link DAOException}. + + @param aRow contains data which is to be parsed into an Object of type E. + */ + final E buildObject(ResultSet aRow) throws DAOException { + E result = null; + try { + result = build(aRow); + } + catch (SQLException ex){ + throw new DAOException("Database problem. Cannot parse row into Object. " + ex.getMessage(), ex); + } + catch(ModelCtorException ex){ + throw new DAOException("Constructor problem. Cannot parse into Object. " + ex.getMessage(), ex); + } + return result; + } + + /** + Parse a single row into an arbitrary Object, throwing any exceptions. + +

The argument and return value satisfy the same conditions as in {@link #buildObject}. + */ + abstract E build(ResultSet aRow) throws SQLException, ModelCtorException; +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/ModelFromRow.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/ModelFromRow.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,206 @@ +package hirondelle.web4j.database; + +import java.util.*; +import java.util.logging.*; +import java.sql.*; +import java.lang.reflect.Constructor; + +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.model.ConvertParam; +import hirondelle.web4j.model.Id; +import hirondelle.web4j.model.ModelCtorException; +import hirondelle.web4j.model.ModelCtorUtil; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.util.Args; + +/** + Construct a Model Object from a ResultSet. + +

Uses all columns. See package level comment for more details. +

Can construct either simple Model Objects, passing only building block objects to the constructor, + or may construct limited forms of compound objects as well. See {@link Db} for more details. +*/ +class ModelFromRow extends ModelBuilder { + + /** + Simple case of a typical Model Object, whose constructor takes only building block values. + + @param aClass class literal for the target Model Object. + */ + ModelFromRow(Class aClass){ + fClass = aClass; + } + + /** + Extended case of a compound Model Object, which takes a single List of child objects at the + end of its constructor. + + @param aParentClass class literal for the target parent Model Object. + @param aChildClass class literal for the target child Model Object. The child object appears as a List + at the end of the parent's constructor. + @param aNumTrailingColsForChildList number of columns at the end of the ResultSet that are + used to construct a child object. + */ + ModelFromRow(Class aParentClass, Class aChildClass, int aNumTrailingColsForChildList){ + fClass = aParentClass; + fChildClass = aChildClass; + Args.checkForPositive(aNumTrailingColsForChildList); + fNumColsForChildList = aNumTrailingColsForChildList; + } + + E build(ResultSet aRow) throws SQLException, ModelCtorException { + E result = null; + if( isSimple() ){ + result = buildSimple(aRow); + } + else { + result = buildWithChildList(aRow); + } + return result; + } + + // PRIVATE // + + private final Class fClass; + private Class fChildClass; + private int fNumColsForChildList; + + private ConvertColumn fColumnToObject = BuildImpl.forConvertColumn(); + private static final boolean INCLUDE_LAST_CTOR_ARG = true; + private static final boolean EXCLUDE_LAST_CTOR_ARG = false; + private static final int FIRST_COLUMN = 1; + private static final Logger fLogger = Util.getLogger(ModelFromRow.class); + + private boolean isSimple(){ + return fChildClass == null; + } + + private E buildSimple(ResultSet aRow) throws SQLException, ModelCtorException { + Constructor modelCtor = ModelCtorUtil.getConstructor(fClass, getNumColsInResultSet(aRow)); + List ctorArgValues = getCtorArgValues(modelCtor, aRow, INCLUDE_LAST_CTOR_ARG); + return ModelCtorUtil.buildModelObject(modelCtor, ctorArgValues); + } + + private int getNumColsInResultSet(ResultSet aRow) throws SQLException { + return aRow.getMetaData().getColumnCount(); + } + + private List getCtorArgValues(Constructor aConstructor, ResultSet aRow, boolean aIncludeLastArg) throws SQLException { + List result = new ArrayList(); + int columnIdx = 1; + Class[] targetTypes = aConstructor.getParameterTypes(); + for(Class argType: targetTypes){ + if( isNotLastArg(columnIdx, targetTypes) ){ + result.add(fColumnToObject.convert(aRow, columnIdx, argType)); + } + else { + if(aIncludeLastArg){ + result.add(fColumnToObject.convert(aRow, columnIdx, argType)); + } + } + ++columnIdx; + } + return result; + } + + private boolean isNotLastArg(int aColIndex, Class[] aTargetTypes){ + return aColIndex != aTargetTypes.length; + } + + /* + Remaining methods all deal with children. + */ + + private E buildWithChildList(ResultSet aRow) throws SQLException, ModelCtorException { + fLogger.fine("Building with Child List at end of constructor."); + //first gather parent args (but do not construct yet) + Constructor parentCtor = ModelCtorUtil.getConstructor(fClass, getNumColsForParent(aRow) + 1); + List ctorArgsMinusChildren = getCtorArgValues(parentCtor, aRow, EXCLUDE_LAST_CTOR_ARG); + fLogger.finest("Parent constructor arg values, minus children : " + ctorArgsMinusChildren); + + //build a child for each collection of rows that belongs to the given parent + //identify new parents using changes in FIRST col only + Constructor childCtor = ModelCtorUtil.getConstructor(fChildClass, fNumColsForChildList); + Id parentId = new Id(aRow.getString(FIRST_COLUMN)); + fLogger.finest("Building Child List for Parent Id: " + parentId); + Id currentId = null; + List childList = new ArrayList(); + do { + addChildToList(childCtor, childList, aRow); + aRow.next(); + if ( ! aRow.isAfterLast() ){ + currentId = new Id(aRow.getString(FIRST_COLUMN)); + fLogger.finest("Updated current id: " + currentId); + } + } + while( !aRow.isAfterLast() && currentId.equals(parentId) ); + //back up to PREVIOUS row, in preparation for next section/full model object, if any. + aRow.previous(); + return buildFinalModelObjectWithChildList(parentCtor, ctorArgsMinusChildren, childList); + } + + private int getNumColsForParent(ResultSet aRow) throws SQLException { + int numCols = aRow.getMetaData().getColumnCount(); + int result = numCols - fNumColsForChildList; + if ( result < 1){ + throw new RuntimeException("Number of columns available for Parent constructor < 1 : " + Util.quote(result)); + } + return result; + } + + private void addChildToList(Constructor aChildCtor, List aChildren, ResultSet aRow) throws SQLException, ModelCtorException { + ConvertParam convertParam = BuildImpl.forConvertParam(); + if( convertParam.isSupported(aChildCtor.getDeclaringClass())) { + addBaseChildToList(aChildCtor.getDeclaringClass(), aChildren, aRow); + } + else { + //regular model object, not a base object + addRegularChildToList(aChildCtor, aChildren, aRow); + } + } + + /** Child object is a regular Model Object, whose ctor takes Base Objects supported by ConvertColumn. */ + private void addRegularChildToList(Constructor aChildCtor, List aChildren, ResultSet aRow) throws SQLException, ModelCtorException { + fLogger.fine("Building a regular child, and adding to the child list."); + Class[] targetTypes = aChildCtor.getParameterTypes(); + List argValues = new ArrayList(); + int columnIdx = getNumColsForParent(aRow) + 1; //ignore the leading columns belonging to parent + //some outer joins will have all child columns null + boolean atLeastOneHasContent = false; + for (Class argType: targetTypes){ + Object value = fColumnToObject.convert(aRow, columnIdx, argType); + if ( value != null ){ + atLeastOneHasContent = true; + } + argValues.add(value); + ++columnIdx; + } + if( atLeastOneHasContent ) { + fLogger.finest("At least one child column has content."); + Object child = ModelCtorUtil.buildModelObject(aChildCtor, argValues); + aChildren.add(child); + } + else { + //do not add anthing to the list of children + fLogger.finest("ALL columns for Child object are empty/null - no child object to construct."); + } + } + + /** Child object is simply a Base Object, supported by ConvertColumn. */ + private void addBaseChildToList(Class aBaseClass, List aChildren, ResultSet aRow) throws SQLException { + //Note : this extra branch was added when String was removed from supported Base Objects. + fLogger.fine("Building a base object child, and adding to the child list."); + int lastColumn = getNumColsInResultSet(aRow); + T baseObject = fColumnToObject.convert(aRow, lastColumn, aBaseClass); //possibly-null + aChildren.add(baseObject); + } + + private E buildFinalModelObjectWithChildList(Constructor aParentCtor, List aArgValuesMinusChildren, List aChildren) throws ModelCtorException { + fLogger.fine("Building complete Parent with Child List."); + List allArgs = new ArrayList(); + allArgs.addAll(aArgValuesMinusChildren); + allArgs.add(aChildren); + fLogger.finest("Building complete Parent Model Object."); + return ModelCtorUtil.buildModelObject(aParentCtor, allArgs); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/Report.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/Report.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,173 @@ +package hirondelle.web4j.database; + +import java.util.*; + +import hirondelle.web4j.request.Formats; +import hirondelle.web4j.security.SafeText; +import hirondelle.web4j.util.Util; + +/** + Utility for producing reports quickly from a ResultSet. + +

Most web applications need to produce reports of summary or transaction information. + If a Model Object already exists for the given data, then it may be used to fetch and render the data, likely + with {@link Db}. + +

If a Model Object does not already exist for the given report data, then this class may + be used to quickly implement the report, without the need to create a full Model Object. + +

This class translates each ResultSet row into a Map of some form. This Map is meant not as a + robust Model Object, but rather as a rather dumb data carrier, built only for the purpose of reporting. + The Map key is always the column name, and the Map value takes various forms, according + to how the ResultSet is processed : +

    +
  • formatted - column values are parsed using + {@link ConvertColumn} into Integer, Date, and so on, and then standard formatting is applied + using {@link Formats#objectToText(Object)}. (This is the recommended style.) +
  • unformatted - column values are parsed using {@link ConvertColumn} into + Integer, Date, and so on, but formatting is deferred to the JSP. +
  • raw - column values are all treated as simple text. To allow for various escaping styles in the view, + the text is actually returned as {@link SafeText}. By default, the text will be properly escaped for presentation + in HTML. If desired, some formatting can be applied directly in the underlying SQL statement itself, using database + formatting functions. +
+ +

Example of using the Map in a JSP. Here, column values are assumed to be already formatted, using + either raw or formatted: +

+{@code
+
+ 
+  ${row['Name']}  
+  ${row['Visits']}  
+ 
+
+  }
+
+ +

If unformatted is used to build the Map, then formatting + of the resulting objects must be applied in the JSP. + +

Recommended Style

+ The recommended style is to use formatted. + +

If raw or unformatted is used, then + the question usually arises of where to apply formatting: +

    +
  • format with database formatting functions - then there will often be much repetition of formatting function calls + across different SELECTs. +
  • format in the JSP (usually with JSTL) - be aware that there is often + significant work involved. Not one but three operations can be necessary: a parse operation, + a format operation, and possibly a check-for-null. +
+ +

Empty Values

+ When the Map returned by this class has values as text, then any Strings which do not satisfy + {@link Util#textHasContent(String)} are replaced with the + return value of {@link hirondelle.web4j.request.Formats#getEmptyOrNullText} (which is in + turn configured in web.xml). This is a workaround for the fact that most browsers do + not render empty TD tags very well when the cell has a border. An alternate + (and likely superior) workaround is to set the + empty-cells + property of Cascading Style Sheets to 'show'. +*/ +public final class Report { + + /** + Return column values without any processing. + +

For the returned {@code Map} objects, +

    +
  • key is the column name +
  • value is the unprocessed column value, passed to a {@link SafeText}. SafeText is + used instead of String to allow easy escaping of special characters in the view. +
+ + @param aSqlId identifies the underlying SELECT statement + @param aCriteria possible dynamic WHERE or ORDER BY clause. If no dynamic criteria, then + just pass {@link DynamicSql#NONE}. + @param aParams parameters for the SELECT statement, in the same order as in the underlying SELECT statement + */ + public static List> raw(SqlId aSqlId, DynamicSql aCriteria, Object... aParams) throws DAOException { + List> result = new ArrayList>(); + ModelBuilder> builder = new ReportBuilder(); + SqlFetcher fetcher = getFetcher(aSqlId, aCriteria, aParams); + fetcher.fetchObjects(builder, result); + return result; + } + + /** + Return column values after processing into formatted building block objects. + +

For the returned {@code Map} objects, +

    +
  • key is the column name +
  • value is the processed column value, as SafeText. Column values are first parsed into + building block objects using {@link ConvertColumn}. Then the objects are formatted in a 'standard' + way using the configured {@link Formats}. +
+ + @param aTargetClasses defines the target class for each column. + The order of the classes in the array corresponds one-to-one with the column order of the underlying ResultSet. + The size of the array matches the number of columns. + Each class in the array must be supported by the configured {@link ConvertColumn}. + @param aLocale Locale returned by {@link hirondelle.web4j.request.LocaleSource} + @param aTimeZone TimeZone returned by {@link hirondelle.web4j.request.TimeZoneSource} + @param aSqlId identifies the underlying SELECT statement + @param aCriteria possible dynamic WHERE or ORDER BY clause. If no dynamic criteria, then + just pass {@link DynamicSql#NONE}. + @param aParams parameters for the SELECT statement, in the same order as in the underlying SELECT statement + */ + public static List> formatted(Class[] aTargetClasses, Locale aLocale, TimeZone aTimeZone, SqlId aSqlId, DynamicSql aCriteria, Object... aParams) throws DAOException { + List> result = new ArrayList>(); + //any date columns must be formatted in a Locale-sensitive manner + Formats formats = new Formats(aLocale, aTimeZone); + ModelBuilder> builder = new ReportBuilder(aTargetClasses, formats); + SqlFetcher fetcher = getFetcher(aSqlId, aCriteria, aParams); + fetcher.fetchObjects(builder, result); + return result; + } + + /** + Return column values as unformatted building block objects. + +

For the returned {@code Map} objects, +

    +
  • key is the column name +
  • value is the processed column value, parsed into a building block Object using {@link ConvertColumn}. +
+ + @param aTargetClasses defines the target class for each column. + The order of the classes in the array corresponds one-to-one with the column order of the underlying ResultSet. + The size of the array matches the number of columns. + Each class in the array must be supported by the configured {@link ConvertColumn}. + @param aSqlId identifies the underlying SELECT statement + @param aCriteria possible dynamic WHERE or ORDER BY clause. If no dynamic criteria, then + just pass {@link DynamicSql#NONE}. + @param aParams parameters for the SELECT statement, in the same order as in the underlying SELECT statement + */ + public static List> unformatted(Class[] aTargetClasses, SqlId aSqlId, DynamicSql aCriteria, Object... aParams) throws DAOException { + List> result = new ArrayList>(); + ModelBuilder> builder = new ReportBuilderUnformatted(aTargetClasses); + SqlFetcher fetcher = getFetcher(aSqlId, aCriteria, aParams); + fetcher.fetchObjects(builder, result); + return result; + } + + // PRIVATE // + + private Report(){ + //empty - prevent construction by caller + } + + private static SqlFetcher getFetcher(SqlId aSqlId, DynamicSql aCriteria, Object[] aParams) throws DAOException { + SqlFetcher result = null; + if( DynamicSql.NONE == aCriteria ) { + result = SqlFetcher.forSingleOp(aSqlId, aParams); + } + else { + result = SqlFetcher.forSearch(aSqlId, aCriteria, aParams); + } + return result; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/ReportBuilder.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/ReportBuilder.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,143 @@ +package hirondelle.web4j.database; + +import java.util.*; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.ResultSetMetaData; + +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.request.Formats; +import hirondelle.web4j.security.SafeText; +import hirondelle.web4j.util.Args; + +/** + Translates a ResultSet row into a Map. + +

The returned {@code Map} has : +

    +
  • key - column name. +
  • value - column value as {@link SafeText}. The column values may be formatted + using {@link Formats#objectToTextForReport(Object)}, according to which constructor + is called. +
+ +

SafeText is used to protect the caller against unescaped special characters, + and Cross Site Scripting attacks. +*/ +final class ReportBuilder extends ModelBuilder> { + + /** + Builds a Map whose values are simply the raw, unprocessed text of the + underlying ResultSet. These values are placed into {@link SafeText} + objects, to allow for various styles of escaping special characters when presenting + the data in the view. + +

Database formatting functions can be used in the underlying SELECT statement + to format values to the desired form. + */ + ReportBuilder(){ + /* + Implementation Note + This is a relatively rare instance where is it makes sense to + declare an empty constructor. + */ + } + + /** + Constructor for applying some processing to column values, instead of using the raw text. + +

aColumnTypes is used to convert each column into an Object. + The supported types are the same as for {@link ConvertColumn}. + +

After conversion into the desired class, + {@link hirondelle.web4j.request.Formats#objectToTextForReport(Object)} is used to apply + formats to the various objects. + + @param aColumnTypes defines the type of each column, in order from left to right, as + they appear in the ResultSet; contains N class literals, + where N is the number of columns in the ResultSet; contains only + the classes supported by {@link ConvertColumn}; this constructor will create a defensive + copy of this parameter. + @param aFormats used to apply the + formats specified in web.xml and + {@link hirondelle.web4j.request.Formats#objectToTextForReport(Object)}, + localized using {@link hirondelle.web4j.request.LocaleSource}. + */ + ReportBuilder(Class[] aColumnTypes, Formats aFormats){ + Args.checkForPositive(aColumnTypes.length); + fColumnTypes = defensiveCopy(aColumnTypes); + fFormats = aFormats; + fColumnToObject = BuildImpl.forConvertColumn(); + } + + /** + Returns an unmodifiable Map. + +

See class description. + */ + Map build(ResultSet aRow) throws SQLException { + LinkedHashMap result = new LinkedHashMap(); + SafeText value = null; + ResultSetMetaData metaData = aRow.getMetaData(); + for (int columnIdx = 1; columnIdx <= metaData.getColumnCount(); ++columnIdx) { + if ( doNotTranslateToObjects() ) { + value = getSafeText(aRow, columnIdx); + //fLogger.finest("Raw unformatted text value: " + value.getRawString()); + } + else { + if ( fColumnTypes.length != metaData.getColumnCount() ) { + throw new IllegalArgumentException( + "Class[] has different number of elements than ResultSet: " + fColumnTypes.length + + " versus " + metaData.getColumnCount () + ", respectively." + ); + } + value = getFormattedObject(aRow, columnIdx); + //security risk to log data: fLogger.fine("Formatted object: " + value); + } + result.put(metaData.getColumnName(columnIdx), value); + } + return Collections.unmodifiableMap(result); + } + + // PRIVATE // + + /** The types to which ResultSet columns will be converted. */ + private Class[] fColumnTypes; + + /** Applies formatting to objects created from ResultSet columns. */ + private Formats fFormats; + + /** Translates ResultSet columns into desired Objects. */ + private ConvertColumn fColumnToObject; + + private Class[] defensiveCopy(Class[] aClassArray){ + Class[] result = new Class[aClassArray.length]; + System.arraycopy(aClassArray, 0, result, 0, aClassArray.length); + return result; + } + + private boolean doNotTranslateToObjects() { + return fColumnTypes == null; + } + + private SafeText getSafeText(ResultSet aRow, int aColumn) throws SQLException { + SafeText result = null; + String text = aRow.getString(aColumn); + if ( text == null ) { + result = new SafeText(Formats.getEmptyOrNullText()); + } + else { + result = new SafeText(text); + } + return result; + } + + private SafeText getFormattedObject(ResultSet aRow, int aColumnIdx) throws SQLException { + Object translatedObject = getUnformattedObject(aRow, aColumnIdx); + return fFormats.objectToTextForReport(translatedObject); + } + + private Object getUnformattedObject(ResultSet aRow, int aColumnIdx) throws SQLException { + return fColumnToObject.convert(aRow, aColumnIdx, fColumnTypes[aColumnIdx-1]); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/ReportBuilderUnformatted.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/ReportBuilderUnformatted.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,79 @@ +package hirondelle.web4j.database; + +import java.util.*; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.ResultSetMetaData; +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.util.Args; + +/** + Translates a ResultSet row into a Map. + +

The returned {@code Map} has : +

    +
  • key - column name. +
  • value - column value as an unformatted object. The class of each object is controlled + by the class array passed to the constructor. +
+*/ +final class ReportBuilderUnformatted extends ModelBuilder> { + + /** + Constructor for applying some processing to raw column values. + +

aColumnTypes is used to convert each column into an Object. + The supported types are the same as for {@link ConvertColumn}. + + @param aColumnTypes defines the type of each column, in order from left to right, as + they appear in the ResultSet; contains N class literals, + where N is the number of columns in the ResultSet; contains only + the classes supported by {@link ConvertColumn}; this constructor will create a defensive + copy of this parameter. + */ + ReportBuilderUnformatted(Class[] aColumnTypes){ + Args.checkForPositive(aColumnTypes.length); + fColumnTypes = defensiveCopy(aColumnTypes); + fColumnToObject = BuildImpl.forConvertColumn(); + } + + /** + Returns an unmodifiable Map. + +

See class constructor. + */ + Map build(ResultSet aRow) throws SQLException { + LinkedHashMap result = new LinkedHashMap(); + Object value = null; + ResultSetMetaData metaData = aRow.getMetaData(); + for (int columnIdx = 1; columnIdx <= metaData.getColumnCount(); ++columnIdx) { + if ( fColumnTypes.length != metaData.getColumnCount() ) { + throw new IllegalArgumentException( + "Class[] has different number of elements than ResultSet: " + fColumnTypes.length + + " versus " + metaData.getColumnCount () + ", respectively." + ); + } + value = getUnformattedObject(aRow, columnIdx); + result.put(metaData.getColumnName(columnIdx), value); + } + return Collections.unmodifiableMap(result); + } + + // PRIVATE // + + /** The types to which ResultSet columns will be converted. */ + private Class[] fColumnTypes; + + /** Translates ResultSet columns into desired Objects. */ + private ConvertColumn fColumnToObject; + + private Class[] defensiveCopy(Class[] aClassArray){ + Class[] result = new Class[aClassArray.length]; + System.arraycopy(aClassArray, 0, result, 0, aClassArray.length); + return result; + } + + private Object getUnformattedObject(ResultSet aRow, int aColumnIdx) throws SQLException { + return fColumnToObject.convert(aRow, aColumnIdx, fColumnTypes[aColumnIdx-1]); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/SqlEditor.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/SqlEditor.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,300 @@ +package hirondelle.web4j.database; + +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.readconfig.Config; +import hirondelle.web4j.util.Args; +import hirondelle.web4j.util.Consts; +import hirondelle.web4j.util.Util; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.logging.Logger; + +/** + Perform an edit on the database corresponding to a single SQL command, and return + the number of affected records. + +

Here, an edit usually corresponds to a single INSERT, UPDATE, or DELETE operation. + (Note that {@link SqlFetcher} is used for SELECT operations.) + +

The convenient utilities in the {@link Db} class should always be considered as a + simpler alternative to this class. + +

This class can use either an internal connection, or an external connection. + This important distinction is made explicit through the static factory methods + provided by this class. It corresponds to whether or not this action takes place + in a transaction. When tying together several operations in a transaction, the + caller will often use {@link TxTemplate}, combined with multiple SqlEditor + objects returned by the forTx factory methods. + +

If an internal {@link Connection} is used, then it will use the isolation + level configured in web.xml by default. The level can be overridden + by calling {@link #setTxIsolationLevel}. + +

Internal Connections are obtained from + {@link hirondelle.web4j.database.ConnectionSource}, using {@link SqlId#getDatabaseName()} + to identify the database. + +

INSERT operations have a special character, since they often create + identifiers used elsewhere. An INSERT can be performed in various ways : +

    +
  • one of the two addRecord methods, in which auto-generated + keys, if present, are returned to the caller +
  • editDatabase, in which auto-generated keys are NOT returned + to the caller +
+ +

The user of this class is able to edit the database in a very + compact style, without concern for details regarding +

    +
  • database connections +
  • manipulating raw SQL text +
  • inserting parameters into SQL statements +
  • retrieving auto-generated keys +
  • error handling +
  • logging of warnings +
  • closing statements and connections +
+

Example use case taken from a DAO : +

+void add(Message aNewMessage) throws DAOException {
+  List params = Util.asList(
+    aNewMessage.getLoginName(),
+    aNewMessage.getBody(),
+    aNewMessage.getDate()
+  );
+  SqlEditor add = SqlEditor.forSingleOp(ADD_MESSAGE, params);
+  add.editDatabase();
+}
+
+

Note that, given the reusable database utility classes in this package, + the above feature is implemented using only +

    +
  • one entry in an .sql text file +
  • one public static final SqlId constant (ADD_MESSAGE in the example) +
  • three lines of straight-line code in a data access object (DAO) +
+ Given these database utility classes, many DAO implementations + become impressively compact. (The {@link Db} class can often reduce the size of + implementations even further.) + +

The forSingleOp methods may actually refer to a stored procedure. + In that case, many operations can actually be performed, not just one. + +

If the operation throws a {@link SQLException} having the specific error code + configured in web.xml, then the {@link DAOException} thrown by + this class will be a {@link DuplicateException}. DAOs for operations which may + have such an exception should declare both DAOException and + DuplicateException in their throws clauses. If the operation cannot + have a duplicate problem, then the DAO must not declare DuplicateException + in its throws clause. + + @used.By {@link Db}, DAO implementation classes which edit the database in some way, usually with + an INSERT, UPDATE, or DELETE command. + @author javapractices.com +*/ +final class SqlEditor { + + /** + Factory method for a single INSERT, DELETE, or UPDATE + operation having parameters. + */ + static SqlEditor forSingleOp(SqlId aSqlId, Object... aParams){ + return new SqlEditor(aSqlId, INTERNAL_CONNECTION, aParams); + } + + /** + Factory method for a single INSERT, DELETE, or UPDATE + operation having parameters, and participating in a transaction. + + @param aConnection used by all operations participating in the given transaction; + this connection is owned by the caller. + */ + static SqlEditor forTx(SqlId aSqlId, Connection aConnection, Object... aParams ){ + return new SqlEditor(aSqlId, aConnection, aParams); + } + + /** + Perform a single INSERT, DELETE, or UPDATE operation. + Return the number of records edited by this operation. + */ + int editDatabase() throws DAOException { + return editDatabase(null, null); + } + + /** + Perform an INSERT of a single new record, and return its auto-generated id. + Return {@link Consts#EMPTY_STRING} if no record can be added. + + @param aColumnIdx in range 1..100, and identifies the auto-generated key; + see {@link Statement#getGeneratedKeys}. + */ + String addRecord(int aColumnIdx) throws DAOException { + fLogger.fine("Adding record, and returning autogenerated id : " + fSql); + StringBuilder result = new StringBuilder(); + editDatabase(result, new Integer(aColumnIdx)); + fLogger.finest("Primary key of item just added : " + result); + return result.toString(); + } + + /** + Convenience method calls {@link #addRecord(int)} with a default value of + 1 for its single argument. + */ + String addRecord() throws DAOException { + return addRecord(fDEFAULT_AUTO_GENERATED_COLUMN_IDX); + } + + /** + Override the default transaction isolation level specified in web.xml. + +

This setting is applied only if this class is using its own internal connection, and + has not received a connection from the caller. + +

If the user passed an external + Connection to this class, then this method + cannot be called. Changing the isolation level after a transaction + has started is not permitted - see {@link Connection}. + + @see TxIsolationLevel + */ + void setTxIsolationLevel(TxIsolationLevel aTransactionIsolationLevel) { + if ( ! fConnectionIsInternal ) { + throw new IllegalStateException ( + "Cannot set transaction isolation level after transaction has started." + ); + } + fLogger.fine("Setting transaction isolation level to " + aTransactionIsolationLevel); + fExplicitTxIsolationLevel = aTransactionIsolationLevel; + } + + // PRIVATE + + private SqlStatement fSql; + + private Connection fConnection; + private boolean fConnectionIsInternal; + /** + Isolation level for an internal connection only. + If the caller does not customize this setting, then the default level for the + given database name is used. + */ + private TxIsolationLevel fExplicitTxIsolationLevel; + private Config fConfig = new Config(); + + private static final Connection INTERNAL_CONNECTION = null; + private static final int fDEFAULT_AUTO_GENERATED_COLUMN_IDX = 1; + private static final Logger fLogger = Util.getLogger(SqlEditor.class); + + private SqlEditor(SqlId aSqlId, Connection aConnection, Object... aParams){ + fSql = new SqlStatement(aSqlId, null, aParams); + if ( aConnection != null ) { + fConnectionIsInternal = false; + fConnection = aConnection; + } + else { + fConnectionIsInternal = true; + } + } + + private int editDatabase(StringBuilder aOutAutoGeneratedKey, Integer aAutoGeneratedColIdx) throws DAOException { + if ( aAutoGeneratedColIdx != null ) { + Args.checkForRange(aAutoGeneratedColIdx.intValue(), 1, 100); + } + if ( + aOutAutoGeneratedKey != null && + Util.textHasContent(aOutAutoGeneratedKey.toString()) + ) { + throw new IllegalArgumentException("'Out' param for autogenerated key must be empty"); + } + + int result = 0; + PreparedStatement statement = null; + try { + if ( fConnectionIsInternal ){ + initConnection(); + } + statement = fSql.getPreparedStatement(fConnection); + //Not implemented by MySql 3.23, nor in MySql 5.0. + //fLogger.fine("Num args : " + statement.getParameterMetaData().getParameterCount()); + result = statement.executeUpdate(); + populateAutoGenKey(result, aOutAutoGeneratedKey, aAutoGeneratedColIdx, statement); + } + catch (SQLException rootCause){ + String message = + "Cannot execute edit. Error Id code : " + rootCause.getErrorCode() + Consts.SPACE + + rootCause + Consts.SPACE + fSql + ; + Integer errorCode = rootCause.getErrorCode(); + String dbName = fSql.getSqlId().getDatabaseName(); + if (fConfig.getErrorCodesForDuplicateKey(dbName).contains(errorCode)){ + throw new DuplicateException(message, rootCause); + } + else if (fConfig.getErrorCodesForForeignKey(dbName).contains(errorCode)){ + throw new ForeignKeyException(message, rootCause); + } + throw new DAOException(message, rootCause); + } + finally { + if ( fConnectionIsInternal ) { + DbUtil.close(statement, fConnection); + } + else { + DbUtil.close(statement); + } + } + return result; + } + + private void initConnection() throws DAOException { + String dbName = fSql.getSqlId().getDatabaseName(); + if( Util.textHasContent(dbName) ){ + fConnection = BuildImpl.forConnectionSource().getConnection(dbName); + } + else { + fConnection = BuildImpl.forConnectionSource().getConnection(); + } + TxIsolationLevel.set(getIsolationLevel(dbName), fConnection); + } + + private TxIsolationLevel getIsolationLevel(String aDbName){ + TxIsolationLevel result = null; + if(fExplicitTxIsolationLevel != null) { + result = fExplicitTxIsolationLevel; + } + else { + result = fConfig.getSqlEditorDefaultTxIsolationLevel(aDbName); + } + return result; + } + + private void populateAutoGenKey( + int aNumEdits, StringBuilder aOutAutoGenKey, Integer aAutoGenColIdx, Statement aStatement + ) throws SQLException { + if ( aOutAutoGenKey != null ){ + if ( Util.isSuccess(aNumEdits) ) { + aOutAutoGenKey.append(getAutoGeneratedKey(aAutoGenColIdx.intValue(), aStatement)); + } + else { + aOutAutoGenKey.append(Consts.EMPTY_STRING); + } + } + } + + private String getAutoGeneratedKey(int aColumnIdx, Statement aStatement) throws SQLException { + String result = null; + ResultSet keys = aStatement.getGeneratedKeys(); + if ( keys.next() ) { + result = keys.getString(aColumnIdx); + } + else { + throw new IllegalArgumentException( + "Invalid column for auto-generated key. Idx: " + aColumnIdx + ); + } + return result; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/SqlFetcher.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/SqlFetcher.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,359 @@ +package hirondelle.web4j.database; + +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.readconfig.Config; +import hirondelle.web4j.util.Args; +import hirondelle.web4j.util.Consts; +import hirondelle.web4j.util.Util; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.logging.Logger; + +/** + Perform a single SELECT command. + +

To perform single INSERT, DELETE, or UPDATE operation, use {@link SqlEditor} instead. + +

The utilities in the {@link Db} class should always + be considered as a simpler alternative to this class. + +

The user of this class is able to fetch records in a very + compact style, without concern for details regarding +

    +
  • database connections +
  • manipulating raw SQL text +
  • inserting parameters into SQL statements +
  • error handling +
  • logging of warnings +
  • closing statements and connections +
+

Example use case in a Data Access Object(DAO), where a Person object +is fetched using aName as a simple business identifier : +

+Person fetch(String aName) throws DAOException {
+  Args.checkForContent(aName);
+  SqlFetcher3 fetcher = SqlFetcher3.forSingleOp(FETCH_PERSON, Util.asList(aName));
+  ModelBuilder3 builder = new ModelFromRow(Person.class);
+  return (Person)fetcher.fetchObject( builder );
+}
+
+

Note that the above feature is implemented using only +

    +
  • one entry in a .sql text file, which stores the underlying SQL + statement as text, outside compiled code +
  • one public static final {@link SqlId} field declared in some class + (usually the {@link hirondelle.web4j.action.Action} of the DAO itself) +
  • a short method in a DAO. (In addition, if this method uses + {@link ModelFromRow}, then the length of this method is almost always 3 or 4 lines, + regardless of the number of fields in the underlying Model Object) +
+ Given these database utility classes, many DAO implementations + become very compact. (The {@link Db} class can reduce the size of + implementations even further.) + +

Almost all callers of SqlFetcher will also use a {@link ModelBuilder}, + which is closely related to this class. + +

SqlFetcher can participate in a transaction by passing a + {@link Connection} to one of its static factory methods. Otherwise, an internal + Connection is obtained from {@link hirondelle.web4j.database.ConnectionSource}, + using {@link SqlId#getDatabaseName()} to identify the database. +*/ +final class SqlFetcher { + + /** + Factory method for single SELECT operation which takes parameters. + */ + static SqlFetcher forSingleOp(SqlId aSqlId, Object... aParams){ + return new SqlFetcher(aSqlId, INTERNAL_CONNECTION, NO_SEARCH_CRITERIA, aParams); + } + + /** + Factory method for SELECT operation which takes parameters and participates in + a transaction. + + @param aConnection used by all operations participating in the given transaction; + this connection is owned by the caller. + */ + static SqlFetcher forTx(SqlId aSqlId, Connection aConnection, Object... aParams){ + return new SqlFetcher(aSqlId, aConnection, NO_SEARCH_CRITERIA, aParams); + } + + /** + Factory method for a SELECT operation which takes parameters, and whose criteria are dynamic. + */ + static SqlFetcher forSearch(SqlId aSqlId, DynamicSql aSearchCriteria, Object... aParams) { + return new SqlFetcher(aSqlId, INTERNAL_CONNECTION, aSearchCriteria, aParams); + } + + /** + Perform a SELECT and return the first row as a Model Object. + +

Intended for use with SELECT statements which usually return one row.
+ If 1..N rows are returned, parse the first row into a Model Object and + return it. Ignore any other rows.
If no rows are returned, return null. + (This behavior can occasionally be used to limit the result set to a single record. + It is a convenient alternative when the database has no robust or simple method of + limiting the number of returned rows. However, this technique should be used + only if the number of returned rows is not excessively large.) + + @param aModelBuilder parses a row into a corresponding Model Object. + */ + T fetchObject(ModelBuilder aModelBuilder) throws DAOException { + List list = new ArrayList(); + fetchObjects(aModelBuilder, list); + return list.isEmpty()? null : list.get(0); + } + + /** + Perform a SELECT and return a corresponding Collection of Model Objects. + +

Intended for use with SELECT statements which usually return multiple rows.
+ If 1..N rows are found, parse each row into a Model Object and + place it in aResult.
Items are added to aResult + in the order returned from the underlying ResultSet.
+ If 0 rows are found, then aResult will remain empty. + + @param aModelBuilder parses a row into a corresponding Model Object. + @param aResult acts as an "out" parameter, and is initially empty. + */ + void fetchObjects(ModelBuilder aModelBuilder, Collection aResult) throws DAOException { + checkIsEmpty(aResult); + + ResultSet resultSet = fetchRows(); + RowCycler cycler = new CollectionRowCycler(aResult); + cycler.cycleRows(resultSet, aModelBuilder); + if (aResult.size() == 0){ + fLogger.fine("No Results Found for query: " + fSql); + } + } + + /** + Set parameters for limiting returned items to a specific block of rows. + +

This method is not usually called. It is intended only for those + cases in which only a subset of the underlying ResultSet is to be + returned. Usually, all rows of the underlying result set are of interest, + and none should be eliminated from processing. + + @param aStartIdx 1 or more + @param aPageSize 1 or more, the number of items returned to the caller + */ + void limitRowsToRange(Integer aStartIdx, Integer aPageSize){ + Args.checkForPositive(aStartIdx.intValue()); + Args.checkForPositive(aPageSize.intValue()); + fStartIdx = aStartIdx; + fEndIdx = getEndIdx(aStartIdx, aPageSize); + } + + /** + Override the default transaction isolation level specified in web.xml. + +

This setting is applied only if this class is using its own internal connection, and + has not received a connection from the caller. + +

If the user passed an external + Connection to a constructor of this class, then this method + must not be called. (Changing the isolation level after a transaction + has started is not permitted - see {@link Connection}). + + @see TxIsolationLevel + */ + void setTxIsolationLevel(TxIsolationLevel aTxIsolationLevel) { + if ( ! fConnectionIsInternal ) { + throw new IllegalStateException ( + "Cannot set transaction isolation level after transaction has started." + ); + } + fLogger.fine("Setting transaction isolation level to " + aTxIsolationLevel); + fExplicitTxIsolationLevel = aTxIsolationLevel; + } + + // PRIVATE + + private SqlStatement fSql; + private PreparedStatement fStatement; + + private Connection fConnection; + private boolean fConnectionIsInternal; + /** + Explicit Isolation level, for internal connections only. + If null, then the isolation level defined by {@link Config} for the given db name will be used. + */ + private TxIsolationLevel fExplicitTxIsolationLevel; + + private Integer fStartIdx; + private Integer fEndIdx; + + private static final Connection INTERNAL_CONNECTION = null; + private static final DynamicSql NO_SEARCH_CRITERIA = null; + + private static final Logger fLogger = Util.getLogger(SqlFetcher.class); + + private SqlFetcher(SqlId aSqlId, Connection aConnection, DynamicSql aSearchCriteria, Object... aParams){ + fSql = new SqlStatement(aSqlId, aSearchCriteria, aParams); + if ( aConnection != null ) { + fConnectionIsInternal = false; + fConnection = aConnection; + } + else { + fConnectionIsInternal = true; + } + } + + private ResultSet fetchRows() throws DAOException { + ResultSet result = null; + Connection connection = null; + try { + connection = getConnection(); + fStatement = fSql.getPreparedStatement(connection); + result = fStatement.executeQuery(); + DbUtil.logWarnings(fStatement); + } + catch (SQLException rootCause){ + String message = + "Cannot execute fetch: " + rootCause + Consts.SPACE + rootCause.getMessage() + + Consts.SPACE + fSql + ; + fLogger.severe(message); + //unusual: the close is not in a finally block, since it must only be closed if there's a problem + close(fStatement, connection); + throw new DAOException(message, rootCause); + } + return result; + } + + /** + Cycles over a ResultSet, but what is done to each row is left + up to subclasses. + + (This private class exists in order to eliminate code repetition between the + Collection and Map styles used above.) + */ + private abstract class RowCycler { + void cycleRows(ResultSet aResultSet, ModelBuilder aModelBuilder) throws DAOException { + try { + while (aResultSet.next()){ + if ( ! isLimitingRows() ) { + addItemToResult(aModelBuilder, aResultSet); + } + else { + if ( isAfterStart(aResultSet) ){ + if ( ! isAfterEnd(aResultSet) ) { + addItemToResult(aModelBuilder, aResultSet); + } + else { + fLogger.fine( + "Finished processing ResultSet. This row is passed the end : " + + aResultSet.getRow() + ); + break; + } + } + else { + fLogger.fine( + "Skipping row. Index not yet high enough to start processing : " + + aResultSet.getRow() + ); + } + } + } + } + catch (SQLException rootCause){ + String message = + "Cannot execute fetch: " + rootCause + Consts.SPACE + + rootCause.getErrorCode() + Consts.SPACE + fSql + ; + throw new DAOException(message, rootCause); + } + finally { + close(fStatement, fConnection); + } + } + abstract void addItemToResult(ModelBuilder aModelBuilder, ResultSet aResultSet) throws DAOException, SQLException; + } + + private final class CollectionRowCycler extends RowCycler { + CollectionRowCycler(Collection aCollection){ + fCollection = aCollection; + } + void addItemToResult(ModelBuilder aModelBuilder, ResultSet aResultSet) throws DAOException { + T item = aModelBuilder.buildObject(aResultSet); + fCollection.add( item ); + } + private Collection fCollection; + } + + private void checkIsEmpty(Collection aOutParam){ + if ( ! aOutParam.isEmpty() ) { + throw new IllegalArgumentException("Out parameter is not initially empty."); + } + } + + /** + Return either the external connection, passed by the caller, or an internal one with + the appropriate isolation level. + */ + private Connection getConnection() throws DAOException { + if ( fConnectionIsInternal ) { + String dbName = fSql.getSqlId().getDatabaseName(); + if( Util.textHasContent(dbName) ){ + fConnection = BuildImpl.forConnectionSource().getConnection(dbName); + } + else{ + fConnection = BuildImpl.forConnectionSource().getConnection(); + } + TxIsolationLevel.set(getIsolationLevel(dbName), fConnection); + } + else { + //use the external connection passed to the constructor + } + return fConnection; + } + + private TxIsolationLevel getIsolationLevel(String aDbName){ + TxIsolationLevel result = null; + if(fExplicitTxIsolationLevel != null) { + result = fExplicitTxIsolationLevel; + } + else { + result = new Config().getSqlFetcherDefaultTxIsolationLevel(aDbName); + } + return result; + } + + private void close( + PreparedStatement aStatement, Connection aConnection + ) throws DAOException { + if ( fConnectionIsInternal ) { + DbUtil.close(aStatement, aConnection); + } + else { + //do not close the connection, since it is owned by the caller, and is + //needed by other statements in the transaction + DbUtil.close(aStatement); + } + } + + private Integer getEndIdx(Integer aStartIdx, Integer aPageSize){ + return new Integer( aStartIdx.intValue() + aPageSize.intValue() - 1); + } + + private boolean isLimitingRows() { + return fStartIdx != null; + } + + private boolean isAfterStart(ResultSet aResultSet) throws SQLException { + return aResultSet.getRow() >= fStartIdx.intValue(); + } + + private boolean isAfterEnd(ResultSet aResultSet) throws SQLException { + return aResultSet.getRow() > fEndIdx.intValue(); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/SqlId.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/SqlId.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,220 @@ +package hirondelle.web4j.database; + +import java.util.regex.Pattern; +import hirondelle.web4j.model.Check; +import hirondelle.web4j.model.ModelCtorException; +import hirondelle.web4j.model.ModelUtil; +import hirondelle.web4j.util.EscapeChars; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.util.Consts; +import hirondelle.web4j.util.Regex; + +/** +Identifier of an SQL statement block in an .sql file. +(Such identifiers must be unique.) + +

This class does not contain the text of the underlying SQL statement. + Rather, this class allows a code friendly way of referencing SQL statements. + Since .sql files are simple text files, there is a need to build a bridge between these text files + and java code. This class is that bridge. + +

Please see the package summary for important information regarding .sql files. + +

Typical use case : +

public static final SqlId MEMBER_FETCH = new SqlId("MEMBER_FETCH");
+This corresponds to an entry in an .sql file : +
+MEMBER_FETCH {
+  SELECT Id, Name, IsActive, DispositionFK 
+  FROM Member WHERE Id=?
+}
+
+ +

This class is unusual, since there is only one way to use these objects. + That is, they must be declared + as public static final fields in a public class. + They should never appear only as local objects in the body of a method. (This unusual restriction + exists to allow the framework to find and examine such fields using reflection.) + The text passed to the constructor must correspond to the identifier of some SQL + statement block in an .sql file. Such identifiers must match a specific + {@link #FORMAT}. + +

Startup Checks
+ To discover simple typographical errors as quickly as possible, + the framework will run diagnostics upon startup : there must be an exact, one-to-one + correspondence between the SQL statement identifiers defined in the .sql file(s), + and the public static final SqlId fields declared by the + application. Any mismatch will result in an error. (Running such diagnostics + upon startup is highly advantageous, since the only alternative is discovery during + actual use, upon the first execution of a particular operation.) + +

Where To Declare SqlId Fields
+ Where should SqlId fields be declared? The only real restriction is that + they must be declared in a public class. With the most recommended first, one may declare + SqlId fields in : +

    +
  • a public {@link hirondelle.web4j.action.Action} +
  • a public Data Access Object +
  • a public constants class, one per package/feature. +
  • a public constants class, one per application. If more than one developer at a time + works on the application, then this style will result in a lot of developer contention. It is not recommended. +
+ +

Design Note +
The justification for recommending that SqlId fields appear in a + {@link hirondelle.web4j.action.Action} is as follows : +

    +
  • it is highly satisfying to have mostly package-private classes in an application, since it + takes advantage of a principal technique for "information hiding" - one of the guiding principles of + lasting value in object programming. For instance, it is usually possible to have a Data Access Object + (DAO) as package-private. If a SqlId is declared in a DAO, however, then that DAO must be + changed to public, just to render the SqlId fields accessible by reflection, + which is distasteful. +
  • the {@link hirondelle.web4j.action.Action} is always public anyway, so adding a SqlId will + not change its scope. +
  • {@link hirondelle.web4j.action.Action} is intended as the public face of each feature. Therefore, + all important items related to the feature should be documented there - what it does, when is it called, and + how it shows a response. One can argue with some force that the single most important thing about a + feature is "What does it do?". In a typical database application, the answer to that + question is usually "these SQL operations". +
+*/ +public final class SqlId { + + /** + Format of SQL statement identifiers. + +

Matching examples include : +

    +
  • ADD_MESSAGE +
  • fetch_member +
  • LIST_RESTAURANTS_2 +
+ +

One or more letters/underscores, with possible trailing digits. +

To scope an SQL statement to a particular database, simply prefix the identifier with a second + such identifier to represent the database, separated by a period, + as in 'TRANSLATION_DB.ADD_BASE_TEXT'. + */ + public static final String FORMAT = Regex.SIMPLE_IDENTIFIER; + + /** + Constructor for statement against the default database. + + @param aStatementName identifier of an SQL statement, satisfies {@link #FORMAT}, + and matches the name attached to an SQL statement appearing in an .sql file. + */ + public SqlId(String aStatementName) { + fStatementName = aStatementName; + fDatabaseName = null; + validateState(); + } + + /** + Constructor for statement against a named database. + + @param aDatabaseName identifier for the target database, + satisfies {@link #FORMAT}, + matches one of the return values of {@link ConnectionSource#getDatabaseNames()}, + and also matches the prefix for a aStatementName. See package overview for more information. + @param aStatementName identifier of an SQL statement, satisfies {@link #FORMAT}, + and matches the name attached to an SQL statement appearing in an .sql file. + */ + public SqlId(String aDatabaseName, String aStatementName) { + fStatementName = aStatementName; + fDatabaseName = aDatabaseName; + validateState(); + } + + /** + Factory method for building an SqlId from a String which may or may + not be qualified by the database name. + + @param aSqlId which may or may not be qualified by the database name. + */ + public static SqlId fromStringId(String aSqlId){ + SqlId result = null; + String SEPARATOR = "."; + if( aSqlId.contains(SEPARATOR) ){ + String[] parts = aSqlId.split(EscapeChars.forRegex(SEPARATOR)); + String database = parts[0]; + String statement = parts[1]; + result = new SqlId(database, statement); + } + else { + result = new SqlId(aSqlId); + } + return result; + } + + /** + Return aDatabaseName passed to the constructor. + +

If no database name was passed to the constructor, then return an empty {@link String} + (corresponds to the 'default' database). + + */ + public String getDatabaseName(){ + return Util.textHasContent(fDatabaseName) ? fDatabaseName : Consts.EMPTY_STRING; + } + + /** Return aStatementName passed to the constructor. */ + public String getStatementName(){ + return fStatementName; + } + + /** + Return the SQL statement identifier as it appears in the .sql file. + +

Example return values : +

    +
  • MEMBER_FETCH (against the default database) +
  • TRANSLATION.FETCH_ALL_TRANSLATIONS (against a database named TRANSLATION) +
+ */ + @Override public String toString() { + return Util.textHasContent(fDatabaseName) ? fDatabaseName + "." + fStatementName : fStatementName; + } + + @Override public boolean equals(Object aThat){ + Boolean result = ModelUtil.quickEquals(this, aThat); + if( result == null ) { + SqlId that = (SqlId) aThat; + result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields()); + } + return result; + } + + @Override public int hashCode(){ + if(fHashCode == 0){ + fHashCode = ModelUtil.hashCodeFor(getSignificantFields()); + } + return fHashCode; + } + + // PRIVATE // + private final String fStatementName; + private final String fDatabaseName; + private int fHashCode; + + /** + Does NOT throw ModelCtorException, since errors here represent bugs. + */ + private void validateState(){ + ModelCtorException ex = new ModelCtorException(); + Pattern simpleId = Pattern.compile(FORMAT); + if ( ! Check.required(fStatementName, Check.pattern(simpleId)) ) { + ex.add("Statement Name is required, and must match SqlId.FORMAT."); + } + if ( ! Check.optional(fDatabaseName, Check.pattern(simpleId)) ) { + ex.add("Database Name is optional, and must match SqlId.FORMAT."); + } + if ( ! ex.isEmpty() ) { + throw new IllegalArgumentException(Util.logOnePerLine(ex.getMessages())); + } + } + + private Object[] getSignificantFields(){ + return new Object[]{fStatementName, fDatabaseName}; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/SqlStatement.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/SqlStatement.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,543 @@ +package hirondelle.web4j.database; + +import static hirondelle.web4j.util.Consts.NEW_LINE; +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.model.AppException; +import hirondelle.web4j.model.DateTime; +import hirondelle.web4j.model.DateTime.Unit; +import hirondelle.web4j.model.Decimal; +import hirondelle.web4j.model.Id; +import hirondelle.web4j.readconfig.Config; +import hirondelle.web4j.readconfig.ConfigReader; +import hirondelle.web4j.security.SafeText; +import hirondelle.web4j.util.Util; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Enumeration; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.TimeZone; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + Encapsulates an SQL statement used in the application. + +

See package overview for important information. + +

This class hides details regarding all SQL statements used by the application. These items + are hidden from the caller of this class : +

    +
  • the retrieval of SQL statements from an underlying textual .sql file or + files +
  • the textual content of SQL statements +
  • the details of placing parameters into a {@link PreparedStatement} +
+ +

Only {@link PreparedStatement} objects are used here, since they are usually preferable to + {@link Statement} objects. + */ +final class SqlStatement { + + /** + Called by the framework upon startup, to read and validate all SQL statements from the + underlying *.sql text file(s). + +

Verifies that there is no mismatch + whatsoever between the public static final {@link SqlId} fields used in the + application, and the keys of the corresponding *.sql file(s). If there is a + mismatch, then an exception is thrown when this class loads, to ensure that errors are + reported as soon as possible. + +

Upon startup, the framework can optionally + attempt a test precompile of each SQL statement, by calling + {@link Connection#prepareStatement(String)}. If the SQL text is not syntactically + correct, then a call to Connection.prepareStatement() + might throw + an {@link SQLException}, according to the implementation of the driver/database. (For + example, JavaDB/Derby will throw an {@link SQLException}, while MySql and Oracle will not.) + If such an error is detected, then it is logged as SEVERE. + +

A setting in web.xml can disable this pre-compilation, if desired. + */ + static void readSqlFile() { + readSqlText(); + checkStoredProcedures(); + checkSqlFilesVersusSqlIdFields(); + precompileAll(); + } + + public static void initSqlStatementsManually(Map aStatements){ + if (fSqlProperties == null){ + fSqlProperties = new Properties(); + } + fSqlProperties.clear(); + fSqlProperties.putAll(aStatements); + } + + /** + SQL statement which takes parameters. + +

This class supports the same classes as parameters as {@link ConvertColumnImpl}. + That is, only objects of the those classes can be present in the aSqlParams + list. A parameter may also be null. + +

For Id objects, in particular, the underlying column must modeled as text, + not a number. If the underlying column is numeric, then the caller must convert an + {@link Id} into a numeric form using {@link Id#asInteger} or {@link Id#asLong}. + + @param aSqlId corresponds to a key in the underlying .sql file + @param aSearchCriteria is possibly null, and represents the criteria input + by the user during a search operation for a particular record (or records). If present, + then {@link DynamicSql#toString()} is appended to the text of the underlying SQL + statement from the .sql files. + @param aSqlParams contains at least one object of the supported classes noted above; + the number and order of these parameter objects matches the + number and order of "?" parameters in the underlying SQL. + */ + SqlStatement(SqlId aSqlId, DynamicSql aSearchCriteria, Object... aSqlParams) { + fSqlId = aSqlId; + fSqlText = getSqlTextFromId(aSqlId); + if (aSearchCriteria != null) { + fSqlText = fSqlText + aSearchCriteria.toString(); + } + checkNumParamsMatches(aSqlParams); + checkParamsOfSupportedType(aSqlParams); + fParams = aSqlParams; + fLogger.finest(this.toString()); + } + + /** + Return a {@link PreparedStatement} whose parameters, if any, have been populated using + the aSqlParams passed to the constructor. + +

If the underlying database auto-generates any keys by executing the returned + PreparedStatement, they will be available from the returned value using + {@link Statement#getGeneratedKeys}. + +

If the returned statement is a SELECT, then a limit, as configured in + web.xml, is placed on the maximum number of rows which can be returned. + This is meant as a defensive safety measure, to avoid returning an excessively large + number of rows. + */ + PreparedStatement getPreparedStatement(Connection aConnection) throws SQLException { + PreparedStatement result = null; + result = getPS(fSqlText, aConnection, fSqlId); + populateParamsUsingPS(result); + result.setMaxRows(fConfig.getMaxRows(fSqlId.getDatabaseName()).intValue()); + result.setFetchSize(fConfig.getFetchSize(fSqlId.getDatabaseName()).intValue()); + return result; + } + + /** Return the {@link SqlId} passed to the constructor. */ + public SqlId getSqlId() { + return fSqlId; + } + + /** + Return the number of '?' placeholders appearing in the underlying SQL + statement. + */ + static int getNumParameters(SqlId aSqlId) { + int result = 0; + String sqlText = getSqlTextFromId(aSqlId); + result = getNumParams(fQUESTION_MARK, sqlText); + return result; + } + + /** Intended for debugging only. */ + @Override public String toString() { + StringBuilder result = new StringBuilder(); + result.append(fSqlId); + result.append(" {"); + result.append(NEW_LINE); + result.append(" fSqlText = ").append(fSqlText).append(NEW_LINE); + List params = Arrays.asList(fParams); + result.append(" Params = ").append(params).append(NEW_LINE); + result.append("}"); + result.append(NEW_LINE); + return result.toString(); + } + + // PRIVATE + + /** The id of the SQL statement, as named in the underlying .sql file. */ + private final SqlId fSqlId; + + /** Parameter values to be placed into a SQL statement. */ + private final Object[] fParams; + + /** + The raw text of the SQL statement, as retrieved from the underlying *.sql file(s) (for + example "SELECT Name FROM Blah"). + */ + private String fSqlText; + + /** Contents of the underlying *.sql file(s). */ + private static Properties fSqlProperties; + private static final Pattern fQUESTION_MARK = Pattern.compile("\\?"); + private static final Pattern fSQL_PROPERTIES_FILE_NAME_PATTERN = Pattern.compile("(?:.)*\\.sql"); + private static final String fTESTING_SQL_PROPERTIES = "C:\\johanley\\Projects\\web4j-jar-trunk\\classes\\hirondelle\\web4j\\database\\mysql.sql"; + private static final String fSTORED_PROC = "{call"; + private static final Pattern fSELECT_PATTERN = Pattern.compile("^SELECT", Pattern.CASE_INSENSITIVE); + private static final String fUNSUPPORTED_STORED_PROC = "{?="; + + private Config fConfig = new Config(); + private static final Logger fLogger = Util.getLogger(SqlStatement.class); + + private static PreparedStatement getPS(String aSqlText, Connection aConnection, SqlId aSqlId) throws SQLException { + PreparedStatement result = null; + if (isStoredProcedure(aSqlText)) { + result = aConnection.prepareCall(aSqlText); + } + else { + if (isSelect(aSqlText)) { + // allow scrolling of SELECT result sets; this is needed by ModelFromRow, to create lists with children + result = aConnection.prepareStatement(aSqlText, ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY); + } + else { + Config config = new Config(); + if (config.getHasAutoGeneratedKeys(aSqlId.getDatabaseName())) { + result = aConnection.prepareStatement(aSqlText, Statement.RETURN_GENERATED_KEYS); + } + else { + result = aConnection.prepareStatement(aSqlText); + } + } + } + return result; + } + + private void populateParamsUsingPS(PreparedStatement aStatement) throws SQLException { + // parameter indexes are 1-based, not 0-based. + for (int idx = 1; idx <= fParams.length; ++idx) { + Object param = fParams[idx - 1]; + if (param == null) { + fLogger.finest("Param" + idx + ": null"); + // is there a better way of doing this? + // setNull needs the type of the underlying column, which is not available + aStatement.setString(idx, null); + } + else if (param instanceof String) { + fLogger.finest("Param" + idx + ": String"); + aStatement.setString(idx, (String)param); + } + else if (param instanceof Integer) { + fLogger.finest("Param" + idx + ": Integer"); + Integer paramVal = (Integer)param; + aStatement.setInt(idx, paramVal.intValue()); + } + else if (param instanceof Boolean) { + fLogger.finest("Param" + idx + ": Boolean"); + Boolean paramVal = (Boolean)param; + aStatement.setBoolean(idx, paramVal.booleanValue()); + } + else if (param instanceof hirondelle.web4j.model.DateTime) { + fLogger.finest("Param" + idx + ": hirondelle.web4j.model.DateTime"); + setDateTime(param, aStatement, idx); + } + else if (param instanceof java.util.Date) { + fLogger.finest("Param" + idx + ": Date"); + setDate(param, aStatement, idx); + } + else if (param instanceof java.math.BigDecimal) { + fLogger.finest("Param" + idx + ": BigDecimal"); + aStatement.setBigDecimal(idx, (BigDecimal)param); + } + else if (param instanceof Decimal) { + fLogger.finest("Param" + idx + ": Decimal"); + Decimal value = (Decimal)param; + aStatement.setBigDecimal(idx, value.getAmount()); + } + else if (param instanceof Long) { + fLogger.finest("Param" + idx + ": Long"); + Long paramVal = (Long)param; + aStatement.setLong(idx, paramVal.longValue()); + } + else if (param instanceof Id) { + fLogger.finest("Param" + idx + ": Id"); + Id paramId = (Id)param; + aStatement.setString(idx, paramId.getRawString()); + } + else if (param instanceof SafeText) { + fLogger.finest("Param" + idx + ": SafeText"); + SafeText paramText = (SafeText)param; + aStatement.setString(idx, paramText.getRawString()); + } + else if (param instanceof Locale){ + fLogger.finest("Param" + idx + ": Locale"); + Locale locale = (Locale)param; + String nonLocalizedId = locale.toString(); //en_US_south; independent of any JRE locale + aStatement.setString(idx, nonLocalizedId); + } + else if (param instanceof TimeZone){ + fLogger.finest("Param" + idx + ": TimeZone"); + TimeZone timeZone = (TimeZone)param; + String nonLocalizedId = timeZone.getID(); //America/Montreal + aStatement.setString(idx, nonLocalizedId); + } + else if (param instanceof InputStream){ + fLogger.finest("Param" + idx + ": InputStream"); + InputStream stream = (InputStream)param; + aStatement.setBinaryStream(idx, stream); //jdk1.6 ; 1.5 needs an extra int, the stream length + } + else { + throw new IllegalArgumentException("Unsupported type of parameter: " + param.getClass()); + } + } + } + + private void setDate(Object aParam, PreparedStatement aStatement, int aIdx) throws SQLException { + // java.sql.Date has date only, and java.sql.Time has time only + java.util.Date dateUtil = (java.util.Date)aParam; + java.sql.Timestamp timestampSql = new java.sql.Timestamp(dateUtil.getTime()); + if (fConfig.hasTimeZoneHint()) { + aStatement.setTimestamp(aIdx, timestampSql, fConfig.getTimeZoneHint()); + } + else { + aStatement.setTimestamp(aIdx, timestampSql); + } + } + + private void setDateTime(Object aParam, PreparedStatement aStatement, int aIdx) throws SQLException { + DateTime dateTime = (DateTime)aParam; + String formattedDateTime = ""; + if ( + dateTime.unitsAllPresent(Unit.YEAR, Unit.MONTH, Unit.DAY) && + dateTime.unitsAllAbsent(Unit.HOUR, Unit.MINUTE, Unit.SECOND) + ){ + fLogger.finest("Treating DateTime as a date (year-month-day)."); + formattedDateTime = dateTime.format(fConfig.getDateFormat(fSqlId.getDatabaseName())); + } + else if ( + dateTime.unitsAllAbsent(Unit.YEAR, Unit.MONTH, Unit.DAY) && + dateTime.unitsAllPresent(Unit.HOUR, Unit.MINUTE, Unit.SECOND) + ){ + fLogger.finest("Treating DateTime as a time (hour-minute-second)."); + formattedDateTime = dateTime.format(fConfig.getTimeFormat(fSqlId.getDatabaseName())); + } + else if ( + dateTime.unitsAllPresent(Unit.YEAR, Unit.MONTH, Unit.DAY) && + dateTime.unitsAllPresent(Unit.HOUR, Unit.MINUTE, Unit.SECOND) + ) { + fLogger.finest("Treating DateTime as a date+time (year-month-day-hour-minute-second)."); + formattedDateTime = dateTime.format(fConfig.getDateTimeFormat(fSqlId.getDatabaseName())); + } + else { + String message = + "Unable to format DateTime using the DateTimeFormatForPassingParamsToDb setting in web.xml." + + " The units present in the DateTime object do not match any of the expected combinations. " + + "If needed, you can always format the DateTime manually in your DAO, and pass a String to the database instead of a DateTime." + ; + fLogger.severe(message); + throw new IllegalArgumentException(message); + } + aStatement.setString(aIdx, formattedDateTime); + } + + private static String getSqlTextFromId(SqlId aSqlId) { + return fSqlProperties.getProperty(aSqlId.toString()); + } + + private void checkNumParamsMatches(Object[] aSqlParams) { + checkNumParams(fQUESTION_MARK, aSqlParams); + } + + private static boolean isStoredProcedure(String aSqlText) { + return aSqlText.startsWith(fSTORED_PROC); + } + + private static boolean isSelect(String aSqlText) { + return Util.contains(fSELECT_PATTERN, aSqlText); + } + + private void checkNumParams(Pattern aPattern, Object[] aParams) { + Matcher matcher = aPattern.matcher(fSqlText); + int numParams = 0; + while (matcher.find()) { + ++numParams; + } + if (numParams != aParams.length) { + throw new IllegalArgumentException(aParams.length + " params should be " + numParams); + } + } + + private static int getNumParams(Pattern aPlaceholderPattern, String aSqlText) { + int result = 0; + Matcher matcher = aPlaceholderPattern.matcher(aSqlText); + while (matcher.find()) { + ++result; + } + return result; + } + + private void checkParamsOfSupportedType(Object[] aSqlParams) { + for (Object param : aSqlParams) { + if (!isSupportedType(param)) { + throw new IllegalArgumentException("Unsupported type of SQL parameter: " + param.getClass()); + } + } + } + + private boolean isSupportedType(Object aParam) { + return aParam == null || BuildImpl.forConvertParam().isSupported(aParam.getClass()); + } + + private static void readSqlText() { + if (fSqlProperties != null) { + fSqlProperties.clear(); + } + Config config = new Config(); + if (!config.isTesting()) { + fSqlProperties = ConfigReader.fetchMany(fSQL_PROPERTIES_FILE_NAME_PATTERN, ConfigReader.FileType.TEXT_BLOCK); + } + else { + fSqlProperties = ConfigReader.fetchForTesting(fTESTING_SQL_PROPERTIES, ConfigReader.FileType.TEXT_BLOCK); + } + } + + private static void checkSqlFilesVersusSqlIdFields() { + Map sqlIdFields = ConfigReader.fetchPublicStaticFinalFields(SqlId.class); + Set sqlIdStrings = convertToSetOfStrings(sqlIdFields); + fLogger.config("SqlId fields " + Util.logOnePerLine(sqlIdStrings)); + AppException mismatches = getMismatches(sqlIdStrings, fSqlProperties.keySet()); + if (mismatches.isNotEmpty()) { + fLogger.severe("MISMATCH found between .sql files and SqlId fields. " + Util.logOnePerLine(mismatches.getMessages())); + throw new IllegalStateException(Util.logOnePerLine(mismatches.getMessages())); + } + fLogger.config("No mismatches found between .sql files and SqlId fields."); + } + + /** + Map aSqlIdFields contains KEY - containing Class VALUE - Set of SqlId Fields +

+ In this case, we are interested only in the "global" set of SqlId fields, unrelated to + any particular class. This method will doubly iterate through its argument, and return + a Set of Strings extracted from the SqlId.toString() method. This is to allow + comparison with the identifiers in the .sql files. + */ + private static Set convertToSetOfStrings(Map, Set> aSqlIdFields) { + Set result = new LinkedHashSet(); + Set classes = aSqlIdFields.keySet(); + Iterator classesIter = classes.iterator(); + while (classesIter.hasNext()) { + Class containingClass = (Class)classesIter.next(); + Set fields = aSqlIdFields.get(containingClass); + result.addAll(getSqlIdFieldsAsStrings(fields)); + } + return result; + } + + private static Set getSqlIdFieldsAsStrings(Set aSqlIds) { + Set result = new LinkedHashSet(); + for (SqlId sqlId : aSqlIds) { + result.add(sqlId.toString()); + } + return result; + } + + private static AppException getMismatches(Set aSqlIdStrings, + Collection aSqlTextFileKeys) { + AppException result = new AppException(); + for (String fieldValue : aSqlIdStrings) { + if (!aSqlTextFileKeys.contains(fieldValue)) { + result.add("SqlId field " + fieldValue + " is not present in any underlying .sql file."); + } + } + for (Object sqlFileKey : aSqlTextFileKeys) { + if (!aSqlIdStrings.contains(sqlFileKey)) { + result.add("The key " + sqlFileKey + " in a .sql file does not match any corresponding public static final SqlId field in any class."); + } + } + return result; + } + + private static void checkStoredProcedures() { + AppException errors = new AppException(); + + Enumeration allSqlIds = fSqlProperties.propertyNames(); + while (allSqlIds.hasMoreElements()) { + String sqlId = (String)allSqlIds.nextElement(); + String sql = (String)fSqlProperties.get(sqlId); + if (sql.startsWith(fUNSUPPORTED_STORED_PROC)) { + errors.add( + "The stored procedured called " + Util.quote(sqlId) + " has an explict return " + + "value since it begins with " + fUNSUPPORTED_STORED_PROC + ". " + + "A *.sql file can contain stored procedures, but only if they do not " + + "have any OUT or INOUT parameters, including *explicit* return values (which " + + "would need registration as an OUT parameter). See hirondelle.web4j.database " + + "package overview for more information." + ); + } + } + + if (errors.isNotEmpty()) { throw new IllegalStateException(errors.getMessages().toString()); } + } + + /** + Attempt a precompile of all statements. +

Precompilation is not supported by some drivers/databases. + */ + private static void precompileAll() { + fLogger.config("Attempting precompile of all SQL statements by calling Connection.prepareStatement(String). Precompilation is not supported by all drivers/databases. If not supported, then this checking is not useful. See web.xml."); + ConnectionSource connSrc = BuildImpl.forConnectionSource(); + Connection connection = null; + PreparedStatement preparedStatement = null; + String sqlText = null; + SqlId sqlId = null; + String sqlIdString = null; + List successIds = new ArrayList(); + List failIds = new ArrayList(); + Set statementIds = fSqlProperties.keySet(); + Iterator iter = statementIds.iterator(); + while (iter.hasNext()) { + sqlId = SqlId.fromStringId((String) iter.next()); + sqlIdString = sqlId.toString(); + sqlText = fSqlProperties.getProperty(sqlIdString); + String dbName = sqlId.getDatabaseName(); + Config config = new Config(); + if(config.getIsSQLPrecompilationAttempted(dbName)){ + try { + connection = (Util.textHasContent(dbName)) ? connSrc.getConnection(dbName) : connSrc.getConnection(); + preparedStatement = getPS(sqlText, connection, sqlId); // SQLException depends on driver/db + successIds.add(sqlIdString); + } + catch (SQLException ex) { + failIds.add(sqlIdString); + fLogger.severe("SQLException occurs for attempted precompile of " + sqlId + " " + ex.getMessage() + NEW_LINE + sqlText); + } + catch (DAOException ex) { + fLogger.severe("Error encountered during attempts to precompile SQL statements : " + ex); + } + finally { + try { + DbUtil.close(preparedStatement, connection); + } + catch (DAOException ex) { + fLogger.severe("Cannot close connection and/or statement : " + ex); + } + } + } + } + fLogger.config("Attempted SQL precompile, and found no failure for : " + Util.logOnePerLine(successIds)); + if (!failIds.isEmpty()) { + fLogger.config("Attempted SQL precompile, and found *** FAILURE *** for : " + Util.logOnePerLine(failIds)); + } + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/StoredProcedureTemplate.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/StoredProcedureTemplate.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,254 @@ +package hirondelle.web4j.database; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.CallableStatement; + +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.util.Consts; +import hirondelle.web4j.util.Args; +import hirondelle.web4j.util.Util; + +/** + Template for using CallableStatements. + +

The purpose of this class is to reduce code repetition related to + CallableStatements : getting a connection, catching and translating exceptions, + and closing statements and connections. As a second benefit, concrete + implementations of this class have simpler, "straight-line" code, + which is easier to both read and write. + +

This abstract base class is an example of the template design pattern. + +

The two constructors of this class correspond to whether or not this task + is being performed as part of a transaction. + +

Use of this class requires creating a subclass. Typically, such a class would likely + be nested within a Data Access Object. If an inner or local class is used, then input parameters + defined in the enclosing class (the DAO) can be referenced directly. For example : +

+ //defined in the body of some enclosing DAO  :
+ class DoPayRun extends StoredProcedureTemplate {
+   DoPayRun(){ 
+     super( "{call do_pay_run(?)}" );
+   }
+   void executeStoredProc(CallableStatement aCallableStatement) throws SQLException {
+     //set param values, register out params, 
+     //get results, etc.
+     //fBlah is defined in the enclosing class:
+     aCallableStatement.setInt(1, fBlah);
+     fResult = aCallableStatement.executeUpdate();
+   }
+   //one way of returning a result, but there are many others :
+   int getResult(){
+     return fResult;
+   }
+   private int fResult;
+ }
+ ...
+ //in the body of a DAO method, use a DoPayRun object
+ DoPayRun doPayRun = new DoPayRun();
+ doPayRun.execute();
+ int result = doPayRun.getResult(); 
+
+ +

There are many ways to retrieve data from a call to a stored procedure, and + this task is left entirely to subclasses of StoredProcedureTemplate. + +

In the rare cases where the default ResultSet properties are not adequate, + the customizeResultSet methods may be used to alter them. + +

Design Note :
+ Although this class is still useful, it is not completely satisfactory for two + reasons : +

    +
  • there are many different ways to return values from stored procedures : + a return value expliclty defined by the stored procedure itself, + OUT parameters, INOUT parameters, the executeUpdate method, + and the executeQuery method. There is probably no simple technique for + returning all of these possible return values in a generic way. That is, it does not + seem possible to create a reasonable method which will return all such values. +
  • although this class does eliminate code repetition, the amount of code which the + caller needs is still a bit large. +
+*/ +public abstract class StoredProcedureTemplate { + + /** + Constructor for case where this task is not part of a transaction. + + @param aTextForCallingStoredProc text such as '{call do_this(?,?)}' (for + more information on valid values, see + + CallableStatement) + */ + protected StoredProcedureTemplate(String aTextForCallingStoredProc) { + fTextForCallingStoredProc = aTextForCallingStoredProc; + } + + /** + Constructor for case where this task is part of a transaction. + +

The task performed by {@link #executeStoredProc} will use aConnection, + and will thus participate in any associated transaction being used by the caller. + + @param aTextForCallingStoredProc text such as '{call do_this(?,?)}' (for + more information on valid values, see + + CallableStatement). + @param aSharedConnection pre-existing connection created by the caller for including + multiple operations in the same transaction. + */ + protected StoredProcedureTemplate(String aTextForCallingStoredProc, Connection aSharedConnection) { + fTextForCallingStoredProc = aTextForCallingStoredProc; + fSharedConnection = aSharedConnection; + } + + /** Template method which calls {@link #executeStoredProc}. */ + public void execute() throws DAOException { + Connection connection = getConnection(); + CallableStatement callableStatement = null; + try { + if ( isUncustomizedRS() ) { + callableStatement = connection.prepareCall(fTextForCallingStoredProc); + } + else if ( isPartiallyCustomizedRS() ) { + callableStatement = connection.prepareCall( + fTextForCallingStoredProc, + fRSType, + fRSConcurrency + ); + } + else { + //fully customised ResultSet + callableStatement = connection.prepareCall( + fTextForCallingStoredProc, + fRSType, + fRSConcurrency, + fRSHoldability + ); + } + executeStoredProc(callableStatement); + } + catch (SQLException ex) { + throw new DAOException( getErrorMessage(ex) , ex); + } + finally { + close(callableStatement, connection); + } + } + + /** + Perform the core task. + +

Implementations of this method do not fetch a connection, catch exceptions, or + call close methods. Those tasks are handled by this base class. + +

See class description for an example. + */ + protected abstract void executeStoredProc(CallableStatement aCallableStatement) throws SQLException; + + /** + Change to a non-default database. + +

Use this method to force this class to use an + internal connection a non-default database. It does not make sense to call this method when using + an external {@link Connection} - that is, when using {@link StoredProcedureTemplate#StoredProcedureTemplate(String, Connection)}. + +

See {@link ConnectionSource} for more information on database names. + + @param aDatabaseName one of the values returned by {@link ConnectionSource#getDatabaseNames()} + */ + protected final void setDatabaseName( String aDatabaseName ){ + Args.checkForContent(aDatabaseName); + fDatabaseName = aDatabaseName; + } + + + /** + Change the properties of the default ResultSet, in exactly the same manner as + {@link java.sql.Connection#prepareCall(java.lang.String, int, int)}. + +

In the rare cases where this method is used, it must be called before + {@link #execute}. + */ + protected final void customizeResultSet(int aResultSetType, int aResultSetConcurrency){ + fRSType = aResultSetType; + fRSConcurrency = aResultSetConcurrency; + } + + /** + Change the properties of the default ResultSet, in exactly the same manner as + {@link java.sql.Connection#prepareCall(java.lang.String, int, int, int)}. + +

In the rare cases where this method is used, it must be called before {@link #execute}. + */ + protected final void customizeResultSet(int aResultSetType, int aResultSetConcurrency, int aResultSetHoldability){ + fRSType = aResultSetType; + fRSConcurrency = aResultSetConcurrency; + fRSHoldability = aResultSetHoldability; + } + + // PRIVATE // + + private final String fTextForCallingStoredProc; + private Connection fSharedConnection; + + /* + These three items are passed to the various overloads of Connection.prepareCall, and + carry the same meaning as defined by those methods. + + A value of 0 for any of these items indicates that they have not been set by the + caller. + */ + private int fRSType; + private int fRSConcurrency; + private int fRSHoldability; + + private String fDatabaseName = Consts.EMPTY_STRING; + + private Connection getConnection() throws DAOException { + Connection result = null; + if ( isSharedConnection() ) { + result = fSharedConnection; + } + else { + if ( Util.textHasContent(fDatabaseName) ){ + result = BuildImpl.forConnectionSource().getConnection(fDatabaseName); + } + else { + result = BuildImpl.forConnectionSource().getConnection(); + } + } + return result; + } + + private boolean isSharedConnection() { + return fSharedConnection != null; + } + + private String getErrorMessage(SQLException ex){ + return + "Cannot execute CallableStatement in the expected manner : " + + fTextForCallingStoredProc + Consts.SPACE + + ex.getMessage() + ; + } + + private void close(CallableStatement aStatement, Connection aConnection) throws DAOException { + if ( isSharedConnection() ) { + DbUtil.close(aStatement); + } + else { + DbUtil.close(aStatement, aConnection); + } + } + + private boolean isUncustomizedRS(){ + return fRSType == 0; + } + + private boolean isPartiallyCustomizedRS(){ + return fRSType != 0 && fRSHoldability == 0; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/Tx.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/Tx.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,15 @@ +package hirondelle.web4j.database; + +/** + Execute a database transaction. + +

Should be applied only to operations involving more than one SQL statement. +*/ +public interface Tx { + + /** + Execute a database transaction, and return the number of edited records. + */ + int executeTx() throws DAOException; + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/TxIsolationLevel.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/TxIsolationLevel.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,183 @@ +package hirondelle.web4j.database; + +import java.util.logging.*; +import java.sql.Connection; +import java.sql.SQLException; + +import hirondelle.web4j.util.Util; + +/** + Type-safe enumeration for transaction isolation levels. + +

For more information on transaction isolation levels, see {@link Connection} and the + wikipedia + article. + +

See {@link TxTemplate}, which is closely related to this class. + +

Permitted Values

+

In order of decreasing strictness (and increasing performance), the levels are : +

    +
  • SERIALIZABLE (most strict, slowest) +
  • REPEATABLE_READ +
  • READ_COMMITTED +
  • READ_UNCOMMITTED (least strict, fastest) +
+ +

In addition, this class includes another item called DATABASE_DEFAULT. It + indicates to the WEB4J data layer that, unless instructed otherwise, + the default isolation level defined by the database instance is to be used. + +

Differences In The Top 3 Levels

+ It is important to understand that the top 3 levels + listed above differ only in one principal respect : behavior for any + re-SELECTs performed in a transaction. If no re-SELECT is + performed in a given transaction, then the there is no difference in the + behavior of the top three levels (except for performance). + +

If a SELECT is repeated in a given transaction, it may see a different + ResultSet, since some second transaction may have committed changes + to the underlying data. Three questions can be asked of the second ResultSet, + and each isolation level responds to these three questions in a different way : +

+ + + + + + + + + +
Level1. Can a new record appear?2. Can an old record disappear?3. Can an old record change?
SERIALIZABLENeverNeverNever
REPEATABLE_READPossiblyNeverNever
READ_COMMITTEDPossiblyPossiblyPossibly
+

(Note : 1 is called a phantom read, while both 2 and 3 are called a + non-repeatable read.) + +

Configuration In web.xml

+ When no external Connection is passed by the application, then + the WEB4J data layer will use an internal Connection + set to the isolation level configured in web.xml. + +

General Guidelines

+
    +
  • consult both your database administrator and your database documentation + for guidance regarding these levels +
  • since support for these levels is highly variable, + setting the transaction isolation level explicitly has low portability + (see {@link #set} for some help in this regard). The DATABASE_DEFAULT + setting is an attempt to hide these variations in support +
  • for a WEB4J application, it is likely a good choice to use the + DATABASE_DEFAULT, and to alter that level only under special circumstances +
  • selecting a specific level is always a trade-off between level of data + integrity and execution speed +
+ +

Support For Some Popular Databases

+ (Taken from + SQL + in a Nutshell, by Kline, 2004. Please confirm with + your database documentation).

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 DB2MySQLOraclePostgreSQLSQL Server
SERIALIZABLEYYYYY
REPEATABLE_READYY*NNY
READ_COMMITTEDYYY*Y*Y*
READ_UNCOMMITTEDYYNNY
+ ∗ Database Default
+*/ +public enum TxIsolationLevel { + + SERIALIZABLE("SERIALIZABLE", Connection.TRANSACTION_SERIALIZABLE), + REPEATABLE_READ("REPEATABLE_READ", Connection.TRANSACTION_REPEATABLE_READ), + READ_COMMITTED("READ_COMMITTED", Connection.TRANSACTION_READ_COMMITTED), + READ_UNCOMMITTED("READ_UNCOMMITTED", Connection.TRANSACTION_READ_UNCOMMITTED), + DATABASE_DEFAULT ("DATABASE_DEFAULT", -1); + + /** + Return the same underlying int value used by {@link Connection} to identify the isolation level. + +

For {@link #DATABASE_DEFAULT}, return -1. + */ + public int getInt(){ + return fIntValue; + } + + /** Return one of the permitted values, including 'DATABASE_DEFAULT'. */ + public String toString(){ + return fText; + } + + /** + Set a particular isolation level for aConnection. + +

This method exists because database support for isolation levels varies + widely. If any error occurs because aLevel is not supported, then + the error will be logged at a SEVERE level, but + the application will continue to run. This policy treats isolation levels + as important, but non-critical. Porting an application to a database which + does not support all levels will not cause an application to fail. The transaction + will simply execute at the database's default isolation level. + +

Passing in the special value {@link #DATABASE_DEFAULT} will cause a no-operation. + */ + public static void set(TxIsolationLevel aLevel, Connection aConnection){ + if( aLevel != DATABASE_DEFAULT ) { + try { + aConnection.setTransactionIsolation(aLevel.getInt()); + } + catch (SQLException ex) { + fLogger.severe( + "Cannot set transaction isolation level. Database does " + + "not apparently support '" + aLevel + + "'. You will likely need to choose a different isolation level. " + + "Please see your database documentation, and the javadoc for TxIsolationLevel." + ); + } + } + } + + // PRIVATE // + private TxIsolationLevel(String aText, int aIntValue){ + fText = aText; + fIntValue = aIntValue; + } + private String fText; + private int fIntValue; + private static final Logger fLogger = Util.getLogger(TxIsolationLevel.class); +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/TxSimple.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/TxSimple.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,141 @@ +package hirondelle.web4j.database; + +import java.util.*; +import java.util.logging.*; +import java.sql.Connection; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.util.Args; + +/** + Perform the simplest kinds of transactions. + +

Executes a number of SQL statements, in the same order as passed to the constructor. + Each SQL statement is executed once and only once, without any iteration. + Each SQL statement must perform an INSERT, DELETE, or UPDATE operation. + No SELECTs are allowed. + +

It is likely that a significant fraction (perhaps as high as 50%) of all + transactions can be implemented with this class. + +

Example use case: +

+ SqlId[] sqlIds = {ADD_THIS, DELETE_THAT};
+ Object[] params = {blah.getX(), blah.getY(), blah.getZ(), blah.getQ()}; 
+ Tx doStuff = new TxSimple(sqlIds, params);
+ int numRecordsAffected = doStuff.executeTx();
+ 
+ +

In this example, blah will usually represent a Model Object. The data in params + is divided up among the various sqlIds. (This division is performed internally by this class.) + +

This class uses strong ordering conventions. The order of + items in each array passed to the constructor is very important (see {@link #TxSimple(SqlId[], Object[])}). +*/ +public final class TxSimple implements Tx { + + /** + Constructor. + +

aAllParams includes the parameters for all operations, concatenated in a single array. Has at least + one member. + +

In general, successive pieces of aAllParams are used to populate each corresponding + statement, in their order of execution. The order of items is here doubly important: the + first N parameters are used against the first SQL statement, the next M parameters are + used against the second SQL statement, and so on. (This breakdown is deduced internally.) + +

And as usual, within each subset of aAllParams + corresponding to an SQL statement, the order of parameters matches the order + of '?' placeholders appearing in the underlying SQL statement. + + @param aSqlIds identifiers for the operations to be performed, in the order of their intended execution. Has + at least one member. All of the operations are against the same database. + @param aAllParams the parameters for all operations, concatenated in a single array. Has at least + one member. (See above for important requirements on their order.) + */ + public TxSimple(SqlId[] aSqlIds, Object[] aAllParams){ + Args.checkForPositive(aSqlIds.length); + Args.checkForPositive(aAllParams.length); + + fSqlIds = aSqlIds; + fParams = aAllParams; + fParamsForStatement = new LinkedHashMap(); + divideUpParams(); + } + + public int executeTx() throws DAOException { + Tx transaction = new SimpleTransaction(getDbName()); + return transaction.executeTx(); + } + + // PRIVATE // + private SqlId[] fSqlIds; + private Object[] fParams; + private Map fParamsForStatement; + private static final Object[] EMPTY = new Object[0]; + private static final Logger fLogger = Util.getLogger(TxSimple.class); + + private final class SimpleTransaction extends TxTemplate { + SimpleTransaction(String aDbName){ + super(aDbName); + } + public int executeMultipleSqls(Connection aConnection) throws DAOException { + int result = 0; + for (SqlId sqlId : fSqlIds){ + Object[] params = fParamsForStatement.get(sqlId); + SqlEditor editor = null; + if( params.length == 0 ){ + editor = SqlEditor.forTx(sqlId, aConnection); + } + else { + editor = SqlEditor.forTx(sqlId, aConnection, params); + } + result = result + editor.editDatabase(); + } + return result; + } + } + + private String getDbName() { + return fSqlIds[0].getDatabaseName(); + } + + private void divideUpParams(){ + int startWith = 0; + int totalNumExpectedParams = 0; + for (SqlId sqlId : fSqlIds){ + int numExpectedParams = SqlStatement.getNumParameters(sqlId); + totalNumExpectedParams = totalNumExpectedParams + numExpectedParams; + chunkParams(startWith, numExpectedParams, sqlId); + startWith = startWith + numExpectedParams; + } + if ( totalNumExpectedParams != fParams.length ){ + throw new IllegalArgumentException( + "Size mismatch. Number of parameters passed as data: " + fParams.length + + ". Number of '?' items appearing in underlying SQL statements: " + totalNumExpectedParams + ); + } + fLogger.finest("Chunked params " + Util.logOnePerLine(fParamsForStatement)); + } + + private void chunkParams(int aStartWithIdx, int aNumExpectedParams, SqlId aSqlId){ + int endWithIdx = aStartWithIdx + aNumExpectedParams - 1; + int maxIdx = fParams.length - 1; + fLogger.fine("Chunking params for " + aSqlId + ". Start with index " + aStartWithIdx + ", end with index " + endWithIdx); + if( aStartWithIdx > maxIdx ){ + throw new IllegalArgumentException("Error chunking parameter data. Starting-index (" + aStartWithIdx + ") greater than expected maximum (" + maxIdx + "). Mismatch between number of params passed as data, and number of params expected by underlying SQL statements."); + } + if ( endWithIdx > maxIdx ){ + throw new IllegalArgumentException("Error chunking parameter data. Ending-index (" + endWithIdx + ") greater than expected maximum (" + maxIdx + "). Mismatch between number of params passed as data, and number of params expected by underlying SQL statements."); + } + + if( aNumExpectedParams == 0 ){ + fParamsForStatement.put(aSqlId, EMPTY); + } + else { + List list = Arrays.asList(fParams); + List sublist = list.subList(aStartWithIdx, endWithIdx + 1); + fParamsForStatement.put(aSqlId, sublist.toArray(EMPTY)); + } + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/TxTemplate.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/TxTemplate.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,245 @@ +package hirondelle.web4j.database; + +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.readconfig.Config; +import hirondelle.web4j.util.Consts; +import hirondelle.web4j.util.Util; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.logging.Logger; + +/** + Template for executing a local, non-distributed transaction versus a single database, + using a single connection. + +

This abstract base class implements the template method design pattern. + +

The {@link TxSimple} class should be the first choice for implementing a transaction. + If it is not suitable (for example, if iteration is involved), then this class can always be used. + The benefits of using this class to implement transactions is that the caller avoids + repeated code involving connections, commit/rollback, handling exceptions and errors, and so on. + +

See {@link TxIsolationLevel} for remarks on selection of correct isolation level. The {@link DbTx} class + is often useful for implementors. + +

Do not use this class in the context of a UserTransaction. + +

Example Use Case

+ A DAO method which uses a TxTemplate called AddAllUnknowns to perform multiple INSERT operations : +
+{@code 
+public int addAll(Set aUnknowns) throws DAOException {
+   Tx addTx = new AddAllUnknowns(aUnknowns);
+   return addTx.executeTx();
+}
+  }
+
+ + The TxTemplate class itself, defined inside the same DAO, as an inner class : +
+{@code 
+  private static final class AddAllUnknowns extends TxTemplate {
+    AddAllUnknowns(Set aUnknowns){
+      super(ConnectionSrc.TRANSLATION);
+      fUnknowns = aUnknowns; 
+    }
+    &Override public int executeMultipleSqls(Connection aConnection) throws SQLException, DAOException {
+      int result = 0;
+      for(String unknown: fUnknowns){
+        addUnknown(unknown, aConnection);
+        result = result + 1;
+      }
+      return result;
+    }
+    private Set fUnknowns;
+    private void addUnknown(String aUnknown, Connection aConnection) throws DAOException {
+      DbTx.edit(aConnection, UnknownBaseTextEdit.ADD, aUnknown);
+    }
+  }
+ }
+
+*/ +public abstract class TxTemplate implements Tx { + + /** + Constructor for a transaction versus the default database, at the + default isolation level. + +

The default transaction isolation level is configured in web.xml. + */ + public TxTemplate(){ + fDatabaseName = DEFAULT_DB; + fTxIsolationLevel = fConfig.getSqlEditorDefaultTxIsolationLevel(DEFAULT_DB); + } + + /** + Constructor for transaction versus the default database, at a custom + isolation level. + +

The default transaction isolation level is configured in web.xml. + */ + public TxTemplate(TxIsolationLevel aTxIsolationLevel){ + fDatabaseName = DEFAULT_DB; + fTxIsolationLevel = aTxIsolationLevel; + } + + /** + Constructor for a transaction versus a non-default database, at its + isolation level, as configured in web.xml. + + @param aDatabaseName one of the return values of {@link ConnectionSource#getDatabaseNames()} + */ + public TxTemplate(String aDatabaseName){ + fDatabaseName = aDatabaseName; + fTxIsolationLevel = fConfig.getSqlEditorDefaultTxIsolationLevel(aDatabaseName); + } + + /** + Constructor for a transaction versus a non-default database, at a custom + isolation level. + +

The default transaction isolation level is configured in web.xml. + + @param aDatabaseName one of the return values of {@link ConnectionSource#getDatabaseNames()} + */ + public TxTemplate(String aDatabaseName, TxIsolationLevel aTxIsolationLevel){ + fDatabaseName = aDatabaseName; + fTxIsolationLevel = aTxIsolationLevel; + } + + /** + Template method calls the abstract method {@link #executeMultipleSqls}. +

Returns the same value as executeMultipleSqls. + +

A rollback is performed if executeMultipleSqls throws a {@link SQLException} or + {@link DAOException}, or if {@link #executeMultipleSqls(Connection)} returns {@link #BUSINESS_RULE_FAILURE}. + */ + public final int executeTx() throws DAOException { + int result = 0; + fLogger.fine( + "Editing within a local transaction, with isolation level : " + fTxIsolationLevel + ); + ConnectionSource connSource = BuildImpl.forConnectionSource(); + if(Util.textHasContent(fDatabaseName)){ + fConnection = connSource.getConnection(fDatabaseName); + } + else { + fConnection = connSource.getConnection(); + } + + try { + TxIsolationLevel.set(fTxIsolationLevel, fConnection); + startTx(); + result = executeMultipleSqls(fConnection); + endTx(result); + } + catch(SQLException rootCause){ + //if SqlEditor is used, this branch will not be exercised, since it throws only + //DAOExceptions + fLogger.fine("Transaction throws SQLException."); + rollbackTx(); + String message = + "Cannot execute edit. Error code : " + rootCause.getErrorCode() + + Consts.SPACE + rootCause + ; + Integer errorCode = new Integer(rootCause.getErrorCode()); + if (fConfig.getErrorCodesForDuplicateKey(fDatabaseName).contains(errorCode)){ + throw new DuplicateException(message, rootCause); + } + else if (fConfig.getErrorCodesForForeignKey(fDatabaseName).contains(errorCode)){ + throw new ForeignKeyException(message, rootCause); + } + throw new DAOException(message, rootCause); + } + catch (DAOException ex){ + //if SqlEditor is used, it will always throw a DAOException, not SQLException + fLogger.fine("Transaction throws DAOException."); + rollbackTx(); + throw ex; + } + finally { + DbUtil.logWarnings(fConnection); + DbUtil.close(fConnection); + } + fLogger.fine("Total number of edited records: " + result); + return result; + } + + /** + Execute multiple SQL operations in a single local transaction. + +

This method returns the number of records edited. If a business rule determines that a + rollback should be performed, then it is recommended that the special value + {@link #BUSINESS_RULE_FAILURE} be returned by the implementation. This will signal to + {@link #executeTx()} that a rollback must be performed. (Another option for + signalling that a rollback is desired is to throw a checked exception.) + +

Design Note: allowing SQLException in the throws + clause simplifies the implementor significantly, since no try-catch blocks are + needed. Thus, the caller has simple, "straight-line" code. + + @param aConnection must be used by all SQL statements participating in this transaction + @return number of records edited by this operation. Implementations may return + {@link #BUSINESS_RULE_FAILURE} if there is a business rule failure. + */ + public abstract int executeMultipleSqls(Connection aConnection) throws SQLException, DAOException; + + /** + Value {@value}. Special value returned by {@link #executeMultipleSqls(Connection)} to indicate that + a business rule has been violated. Such a return value indicates to this class that a rollback must be + performed. + */ + public static final int BUSINESS_RULE_FAILURE = -1; + + // PRIVATE + + /** + The connection through which all SQL statements attached to this + transaction are executed. This connection may be for the default + database, or any other defined database. See {@link #fDatabaseName}. + */ + private Connection fConnection; + + /** + Identifier for the database. The connection taken from the default + database only if this item has no content. An empty string implies the default + database. + */ + private String fDatabaseName; + private static final String DEFAULT_DB = ""; + + /** The transaction isolation level, set only during the constructor. */ + private final TxIsolationLevel fTxIsolationLevel; + + private static final boolean fOFF = false; + private static final boolean fON = true; + private Config fConfig = new Config(); + private static final Logger fLogger = Util.getLogger(TxTemplate.class); + + private void startTx() throws SQLException { + fConnection.setAutoCommit(fOFF); + } + + private void endTx(int aNumEdits) throws SQLException, DAOException { + if ( BUSINESS_RULE_FAILURE == aNumEdits ) { + fLogger.severe("Business rule failure occured. Cannot commit transaction."); + rollbackTx(); + } + else { + fLogger.fine("Commiting transaction."); + fConnection.commit(); + fConnection.setAutoCommit(fON); + } + } + + private void rollbackTx() throws DAOException { + fLogger.severe("ROLLING BACK TRANSACTION."); + try { + fConnection.rollback(); + } + catch(SQLException ex){ + throw new DAOException("Cannot rollback transaction", ex); + } + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/ValueFromRow.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/ValueFromRow.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,30 @@ +package hirondelle.web4j.database; + +import java.sql.ResultSet; +import java.sql.SQLException; +import hirondelle.web4j.BuildImpl; + +/** + Construct a building block object from the first column of a ResultSet. + +

This implementation violates the rule that all columns must be used to construct the + target object. +*/ +final class ValueFromRow extends ModelBuilder{ + + ValueFromRow(Class aSupportedTargetClass){ + fClass = aSupportedTargetClass; + } + + E build(ResultSet aRow) throws SQLException { + return fColumnToObject.convert(aRow, FIRST_COLUMN, fClass); + } + + // PRIVATE // + private final Class fClass; + + /** Translates column values into desired objects. */ + private ConvertColumn fColumnToObject = BuildImpl.forConvertColumn(); + + private static final int FIRST_COLUMN = 1; +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/mysql.sql --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/mysql.sql Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,366 @@ +-- SQL statements used by this application, other than +-- CREATE TABLE statements. + +-- If the underlying database and driver support stored procedures, then +-- calls to *simple* stored procedures can also be placed in this file. +-- As long as the CallableStatement can be treated as a PreparedStatement, +-- then they may be placed in this file. +-- Thus, no items needing registration as an OUT parameter are allowed. +-- This restriction disallows EXPLICIT return values, but allows the +-- IMPLICIT return values available from CallableStatement.executeUpdate() +-- and CallableStatement.executeQuery(). That is, this file can include +-- stored procedures which implicitly return either a single int or a +-- single ResultSet. If more flexibility is needed, (which is very often +-- the case) please use StoredProcedureTemplate.java. + +-- If a statement has parameters, then the style used by +-- PreparedStatement is used, in which question marks acts as +-- placeholders; this style avoids the use of vendor-specific +-- quoting styles. +-- +-- If a statement cannot use parameters in the usual way, then +-- placeholders of the form {i} are used as an alternative (i=0..9). +-- This case is common for SELECTs with many criteria in the +-- WHERE clause. The {i} are used as placeholders for simple +-- textual replacement at runtime. This style requires that +-- any quotes be stated explicitly. This style is not applicable +-- to stored procedures. + +-- The Mimer validator was used to verify compliance: +-- http://developer.mimer.se/validator/parser92/index.tml--parser +-- Keywords which do not comply with the ANSI standard are +-- BINARY (used to force case-sensitive string comparison + +-- For SELECTs which are used by the ReportBuilder class to build +-- reports, it is highly recommended to use the AS keyword, to protect +-- the application from changes to column names. This is the only +-- area in which web4j uses column names. More typically, the +-- column *index* is significant, not the column name. Note that +-- Oracle will preserve case when AS is used only if the identifier +-- is quoted : SELECT Descr AS "Description" FROM SOME_TABLE + +-- Constants are intended mainly for any "magic numbers" that appear +-- in your SQL statements (especially in WHERE clauses). +-- They can also be used to define short SQL snippets, if desired. +-- Each constant must appear on one line only. +-- No empty lines are permitted in a constants block. +-- Any number of such blocks can appear in each file. +-- Constants must be defined before they are referenced as '${blah}' +-- substitution variables. +-- Constants are not currently shared between .sql files. (If you +-- would like such a feature, please send a refreshingly pleasant +-- note of gentle encouragement to javapractices.com.) +constants { + num_neurons_for_smart_people = 1000000000 + num_neurons_per_replicant = 5000000000 + -- The following two items define policies for rendering + -- boolean values in web pages. (This is actually better implemented + -- elsewhere. See web.xml). + render_false_html = + render_true_html = +} + +-- MyUser +-- ------------------------- +ADD_USER { +INSERT INTO MyUser +(LoginName, LoginPassword, EmailAddress, StarRating, FavoriteTheory, SendCard, Age, DesiredSalary, BirthDate) +VALUES (?,?,?,?,?,?,?,?,?) +} + +-- In this case, ModelBuilder is used to parse the ResultSet. +-- Since ModelBuilder requires that the order of the columns +-- of the ResultSet match the order of arguments passed to the +-- corresponding Model Object (MyUser), it seems prudent to explicitly +-- specify the column names, instead of using the "SELECT * FROM X" style. +-- +-- The LEFT JOIN is used to ensure that even NULL values for +-- FavoriteTheory are returned. +FETCH_USER { +SELECT +LoginName, +LoginPassword, +EmailAddress, +StarRating, +MyQuantumTheory.Text AS FavoriteTheory, +SendCard, +Age, +DesiredSalary, +BirthDate +FROM MyUser +LEFT JOIN MyQuantumTheory ON +MyUser.FavoriteTheory = MyQuantumTheory.Id +WHERE LoginName=? +} + +CHANGE_USER { +UPDATE MyUser SET +EmailAddress=?, +StarRating=?, +FavoriteTheory=?, +SendCard=?, +Age=?, +DesiredSalary=?, +BirthDate=? +WHERE LoginName=? +} + +DELETE_USER { +DELETE FROM MyUser WHERE LoginName=? +} + +FETCH_USER_THEORIES { +SELECT MyQuantumTheory.Text AS TheoryName +FROM MyQuantumTheory, MyUserTheories +WHERE MyUserTheories.QuantumTheory=MyQuantumTheory.Id +AND MyUserTheories.LoginName=? +} + +DELETE_USER_THEORIES { +DELETE FROM MyUserTheories WHERE LoginName=? +} + +ADD_USER_THEORY { +INSERT INTO MyUserTheories +(LoginName, QuantumTheory) +VALUES (?,?) +} + + +-- Pick Lists +-- +-- See MyPickListDAO for more information. +-- +-- In this implementation, the structure of the various +-- underlying tables does not need to be uniform. +-- However, associated *ResultSets* follow the convention +-- that columns named 'Id' and 'Text' must be present. +-- As well, "extended" pick lists also use OrderIdx and +-- Description columns. +-- If the underlying tables do not use these column names, +-- then the AS keyword can always be used to create the +-- necessary alias. +-- ------------------------------------------------------------- + +-- Pick List : Pizza Toppings +FETCH_PIZZA_TOPPINGS { +SELECT Text, Id +FROM MyPizzaTopping +ORDER BY Text +} + +ADD_PIZZA_TOPPING { +INSERT INTO MyPizzaTopping (Text) VALUES (?) +} + +-- The BINARY cast serves to force a case-sensitive comparision. +CHANGE_PIZZA_TOPPING { +UPDATE MyPizzaTopping SET Text=? WHERE BINARY Text=? +} + +-- Pick List : Artists +FETCH_ARTISTS { +SELECT FullName AS Text, Id +FROM MyArtist +ORDER BY FullName +} + +ADD_ARTIST { +INSERT INTO MyArtist (FullName) VALUES (?) +} + +-- The BINARY cast serves to force a case-sensitive comparision. +CHANGE_ARTIST { +UPDATE MyArtist SET FullName=? WHERE BINARY FullName=? +} + +-- Pick List : Quantum Theories +-- This is an "extended" pick list, which includes a Description and +-- explicit OrderIdx, in addition to basic Text. +FETCH_QUANTUM_THEORIES { +SELECT Text, Description, OrderIdx, Id +FROM MyQuantumTheory +ORDER BY OrderIdx +} + +ADD_QUANTUM_THEORY { +INSERT INTO MyQuantumTheory (Text, Description, OrderIdx) VALUES (?,?,?) +} + +-- Pick Lists which include a column devoted to specifying an +-- explicit sort order need to allow complete rearrangement +-- of the OrderIdx +INCREMENT_QUANTUM_THEORY_ORDER_IDX { +UPDATE MyQuantumTheory SET OrderIdx = OrderIdx + 1 WHERE OrderIdx >= ? +} + +DECREMENT_QUANTUM_THEORY_ORDER_IDX { +UPDATE MyQuantumTheory SET OrderIdx = OrderIdx - 1 WHERE OrderIdx > ? +} + +CHANGE_QUANTUM_THEORY { +UPDATE MyQuantumTheory SET Text=?, Description=? WHERE BINARY Text=? +} + +CHANGE_QUANTUM_THEORY_ORDER_IDX { +UPDATE MyQuantumTheory SET OrderIdx=? WHERE BINARY Text=? +} + +-- MyPerson +-- This table is very similar to MyUser. It was +-- created to allow unfettered access and editing of +-- simple toy data. +-- ----------------------------------------------------- +ADD_PERSON { +INSERT INTO MyPerson +(Name, StarRating, FavoriteTheory, SendCard, Age, DesiredSalary, BirthDate, BodyTemperature, NumNeurons) +VALUES (?,?,?,?,?,?,?,?,?) +} + +-- In this case, ModelBuilder is used to parse the ResultSet. +-- Since ModelBuilder requires that the order of the columns +-- of the ResultSet match the order of arguments passed to the +-- corresponding Model Object (MyUser), it seems prudent to expicitly +-- specify the column names, instead of using the "SELECT * FROM X" style. +-- +-- The LEFT JOIN is used to ensure that even NULL values for +-- FavoriteTheory are returned. +FETCH_PERSON { +SELECT +Name, +StarRating, +MyQuantumTheory.Text AS FavoriteTheory, +SendCard, +Age, +DesiredSalary, +BirthDate, +BodyTemperature, +NumNeurons +FROM MyPerson +LEFT JOIN MyQuantumTheory ON +MyPerson.FavoriteTheory = MyQuantumTheory.Id +WHERE Name=? +} + +CHANGE_PERSON { +UPDATE MyPerson SET +StarRating=?, +FavoriteTheory=?, +SendCard=?, +Age=?, +DesiredSalary=?, +BirthDate=?, +BodyTemperature=?, +NumNeurons=? +WHERE Name=? +} + +DELETE_PERSON { +DELETE FROM MyPerson WHERE Name=? +} + +-- Used to exercise ValueBuilder, which is used to extract simple +-- values (often statistics) from the database. +FETCH_NUM_PERSONS_WITH_SIMILAR_NAME { +SELECT COUNT(*) AS NUM_PERSONS +FROM MyPerson +WHERE Name LIKE '{0}%' +} + +-- Used to exercise the rather unusual case of performing a +-- database update without any criteria +UPDATE_NUM_NEURONS { +UPDATE MyPerson SET NumNeurons=${num_neurons_per_replicant} +} + +-- The following items are used in a report +-- Note that all columns use the 'AS' keyword. This is important +-- here since the column names are used directly in the report. +-- In other SELECTSs, it is not column name which is significant, +-- but column index. +FETCH_NUM_USERS { +SELECT COUNT(*) AS NumUsers FROM MyUser; +} + +FETCH_DATE_MOST_RECENT_MESSAGE { +SELECT MAX(CreationDate) AS MostRecentMessage FROM MyMessage +} + +FETCH_NUM_MSGS_PER_USER { +SELECT +MyUser.LoginName AS userName, +COUNT(MyMessage.Id) AS numMessages +FROM MyUser, MyMessage +WHERE MyMessage.LoginName=MyUser.LoginName +GROUP BY MyUser.LoginName +} + +FETCH_SMART_PERSONS { +SELECT +Name as Name, +StarRating AS StarRating, +MyQuantumTheory.Text AS FavoriteTheory, +SendCard AS SendCard, +Age AS Age, +DesiredSalary AS DesiredSalary, +BirthDate AS BirthDate, +BodyTemperature AS BodyTemperature, +NumNeurons AS NumNeurons +FROM MyPerson +LEFT JOIN MyQuantumTheory ON +MyPerson.FavoriteTheory = MyQuantumTheory.Id +WHERE NumNeurons >= ${num_neurons_for_smart_people} +} + +-- Reports often need to state details and corresponding totals. +-- In particular, the "details" query can often +-- be re-used directly as a sub-query in the "totals" query. +-- This is artificial here, since the version of MySQL this is +-- running against does not support sub-queries, unfortunately. +-- Please see the Oracle version of this query for a demonstration +-- of how one query can be used in another *without repeating the query*, +-- by using a substitution variable. +FETCH_NUM_SMART_PERSONS { +SELECT COUNT(*) +FROM MyPerson +LEFT JOIN MyQuantumTheory ON +MyPerson.FavoriteTheory = MyQuantumTheory.Id +WHERE NumNeurons >= ${num_neurons_for_smart_people} +} + +-- A repeat of the above query, but with database formatting +-- functions applied to the result. Note the rather bizarre CASE +-- statement used for booleans, for generating an HTML checkbox. +-- The CASE could be simplified, if desired, to something simpler, +-- as in : +-- CASE SendCard WHEN 0 THEN 'false' ELSE 'true' END +FETCH_SMART_PERSONS_ADD_FORMATTING { +SELECT +Name as Name, +StarRating AS StarRating, +MyQuantumTheory.Text AS FavoriteTheory, +CASE SendCard + WHEN 0 THEN '${render_false_html}' + ELSE '${render_true_html}' +END AS SendCard, +Age AS Age, +FORMAT(DesiredSalary,2) AS DesiredSalary, +DATE_FORMAT(BirthDate, '%m/%d/%Y') AS BirthDate, +FORMAT(BodyTemperature,2) AS BodyTemperature, +FORMAT(NumNeurons,0) AS NumNeurons +FROM MyPerson +LEFT JOIN MyQuantumTheory ON +MyPerson.FavoriteTheory = MyQuantumTheory.Id +WHERE NumNeurons >= ${num_neurons_for_smart_people} +} + +-- Not actually called for MySql, since MySql does not +-- implement stored procedures. (The reason it +-- appears here, in a file intended only for MySql, is +-- merely to keep the items in .sql files +-- synchronized with the elements of the SqlId enumeration. +-- See the oracle version for a "real" implementation.) +UPDATE_NUM_NEURONS_STORED_PROC { +{call update_num_neurons} +} + diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/database/package.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/database/package.html Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,300 @@ + + + + + + + Datastore + + +Interaction with the database(s). + +

The WEB4J data layer assumes the datastore is one or more relational databases. +If some other means of persistence is required, then this package will +not help the application programmer to implement persistence. + +

An application is not required to use this package to implement +persistence. Other persistence tools can be used instead, if desired. + +

Please see the Data Access Object (DAOs) of the +example application for effective illustration of how to use the services of +this package. + +

Many DAO methods can be implemented using only two classes : +

    +
  • {@link hirondelle.web4j.database.Db} - common utility methods +
  • {@link hirondelle.web4j.database.SqlId} - identifiers for underlying SQL statements in .sql files +
+ + +The WEB4J data layer : +
    +
  • uses one or more relational databases +
  • uses simple .sql text files, containing SQL statements +
  • does not use any .xml files +
  • does not use any object-relational mapping +
  • does not use any annotations +
  • is instructed on how to obtain a Connection through the application's implementation of {@link hirondelle.web4j.database.ConnectionSource} +
  • uses only PreparedStatements, since they are preferred +
  • is configured using some items in web.xml +
  • can precompile SQL statements upon startup (if supported by the driver). This allows syntax errors to be found upon startup. +
  • allows many DAO methods to be implemented in just one or two lines of code +
+ +

This package does not currently use distributed transactions internally. However, since a +{@link hirondelle.web4j.database.ConnectionSource} can return a Connection for any database, +the caller can still implement operations against multiple databases, including distributed +transactions, if desired. + +

Ordering Convention

+When creating Model Objects from a ResultSet, WEB4J is unusual since it does not use a +naming convention to map columns. Instead, it uses a much more effective ordering +convention. This turns out to be a significant advantage for the caller, since trivial column +mapping is no longer required. + +

(Ordering conventions seem to be very effective for human understanding. For example, would you rather +do arithmetic with roman numerals, which use a naming convention to define magnitudes, +or hindu-arabic numerals, which use an ordering convention to define place value?) + +

The convention is that the N columns +of the ResultSet must map, in order, to the N arguments passed to +a constructor of the Model Object. This is not as restrictive as it first sounds, +since the order of the ResultSet columns is controlled by the application's +SQL statement, and not by the structure of the underlying table. + +

More specifically : when building a Model Object from a ResultSet, the number of columns in the +ResultSet is used to find the appropriate public constructor of the +target Model Object. (Usually, a Model Object will have only a single constructor, one that +takes all fields associated with the object.) Then, the columns are mapped in order, one-to-one, +to the constructor arguments. Using the configured implementation of +{@link hirondelle.web4j.database.ConvertColumn}, the columns are translated into Integer, +Date, and so on, and passed to the Model Object constructor. + +

Many authors recommended that the SELECT * FROM X style be avoided : it does not explicitly +name what columns are returned, so it is unclear to the reader. In addition, the order of the returned columns +is not specified, and may change if the definition of the underlying table is altered. +In WEB4J, following this recommendation is doubly important, because of the ordering convention mentioned +above. + + +

The .sql Files

+Basic Idea
+Benefits Of This Design
+File Name And Location
+Startup Processing
+Portability
+Passing Parameters
+Detailed Syntax For .sql Files
+Stored Procedures
+ +

Basic Idea

+The WEB4J data layer uses text .sql files. SQL statements are not placed directly in classes. +Instead, they are placed in regular text files (of a certain format), and referenced +from code using {@link hirondelle.web4j.database.SqlId} objects. + +

Here is a quick example. + +

An entry in an .sql file : +

+MEMBER_FETCH {
+  SELECT Id, Name, IsActive FROM Member WHERE Id=?
+}
+
+This SQL statement is referenced in code using an {@link hirondelle.web4j.database.SqlId} object, created using a +corresponding String identifier "MEMBER_FETCH". Each SqlId must be defined as a +public static final field: +
+public static final SqlId MEMBER_FETCH = new SqlId("MEMBER_FETCH");
+...
+public Member fetch(Id aMemberId) throws DAOException {
+  return Db.fetch(Member.class, MEMBER_FETCH, aMemberId);
+}
+
+ +That is the basic idea. + +

(When this technique is used, most implementations of DAO methods are compact - usually just +one or two lines, as shown above.) + +

Benefits Of This Design

+This design has many benefits : +
    +
  • it uses no tedious object-relational mapping, no .xml files, and no annotations. + This greatly reduces the effort needed to implement DAOs. +
  • SQL statements created and verified in other tools can be easily copied over, + with very few textual edits +
  • if an application is upgraded to an entirely new implementation, the SQL statements + can be easily reused in the new environment. This is a major benefit when migrating an application. +
  • programmers or database administrators unfamiliar with the details of an + application - or even unfamiliar with Java itself - can easily find, read, + and edit the SQL statements used by the application. Thus, a larger pool of maintainers + can contribute meaningfully to an application. +
  • SQL statements are not hard-coded into classes. If desired, SQL statements can be edited easily after + deployment, and these edits are pulled in through a simple restart. (In production, this should be used with + care, or even avoided altogether. For example, it might be used to change something minor, such as sort + order of a SELECT. It might also be used in an emergency.) +
+ +

File Name And Location

+All files under the WEB-INF directory whose name matches the regular +expression "(?:.)*\\.sql" +will be treated as .sql files, and will be read in upon startup. + +

Examples of valid *.sql file names include : +

    +
  • statements.sql +
  • config.sql +
  • REPORTS.sql +
  • pick-lists.sql +
  • accounts.payable.sql +
  • 2005-06-15blah0-_$blah.sql +
+Any other files under WEB-INF will be ignored by this mechanism. Note that +a file named something.SQL (upper case .SQL), will not be +read by WEB4J upon startup. (This is a quick way of disabling a given file.) + +

It is recommended to use more than one *.sql file, to allow : +

    +
  • placing .sql files in the same package as the Data Access Object + that uses it (package-by-feature) +
  • eliminating or reducing developer contention for commonly needed files +
  • splitting up a large file into various parts of more reasonable size +
  • swapping files that target different databases +
+ +

If you use one .sql file per directory/feature, then it's recommended to consider using a +fixed, conventional name such as statements.sql. + +

Startup Processing Of .sql Files

+Upon startup, WEB4J will read in all .sql files under the WEB-INF directory, and will +match entries to {@link hirondelle.web4j.database.SqlId} objects - more info. + +

In addition, WEB4J can perform a 'test precompile' of each SQL statement, to see if it +is syntactically correct. (This is not supported by all databases/drivers.) There is a +setting in web.xml called IsSQLPrecompilationAttempted which +turns this behavior on and off. + +

Portability

+To maximize portability, SQL statements should be validated using a tool such as +the Mimer SQL-92 +validator. + +

Passing Parameters

+There are constraints on how an application passes parameters to SQL statements. +They are listed in Db. + +

Detailed Syntax For .sql Files

+Here is an example of the syntax expected by WEB4J for .sql files. +
+-- This is a comment 
+ ADD_MESSAGE   {
+ INSERT INTO MyMessage -- another comment
+  (LoginName, Body, CreationDate)
+  -- another comment
+  VALUES (?,?,?)
+ }
+
+-- Any number of 'constants' blocks can be defined, anywhere 
+-- in the file. Such constants must be defined before being
+-- referenced later in a SQL statement, however.
+constants {
+  num_messages_to_view = 5
+}
+
+-- Example of referring to a constant defined above.
+FETCH_RECENT_MESSAGES {
+ SELECT 
+ LoginName, Body, CreationDate 
+ FROM MyMessage 
+ ORDER BY Id DESC LIMIT ${num_messages_to_view}
+}
+
+FETCH_NUM_MSGS_FOR_USER {
+ SELECT COUNT(Id) FROM MyMessage WHERE LoginName=?
+}
+
+DELETE_USER_MESSAGES {
+ DELETE FROM MyMessage WHERE LoginName=?
+}
+
+constants {
+  -- Each constant must appear on a *single line* only, which
+  -- is not the best for long SQL statements.
+  -- Typically, if a previously defined *sub-query* is needed, 
+  -- then a SQL statment may simply refer directly to that previously 
+  -- defined SQL statement. 
+  base_query = SELECT Id, LoginName, Body, CreationDate FROM MyMessage 
+}
+
+BROWSE_MESSAGES {
+ ${base_query}
+ ORDER BY 1
+}
+
+-- Some simple stored procedures may be referenced as well
+UPDATE_NUM_NEURONS_STORED_PROC {
+ {call update_num_neurons}
+}
+
+ +

+To describe the syntax more precisely, the following terminology is used here : +

    +
  • Block - a multiline block of text with within braces, with an associated + identifier (similar to a typical Java block) +
  • Block Name - the identifier of a block, appearing on the first line, before the + opening brace +
  • Block Body - the text appearing between the opening and closing braces. +
+ +

The format details are as follows : +

    +
  • empty lines can appear only outside of blocks +
  • the '--' character denotes a comment +
  • there are no multiline comments. (Such comments are easier for the writer, + but are much less clear for the reader.) +
  • the body of each item is placed in a named block, bounded by + '<name> {' on an initial line, and '}' on an end line. + The name given to the block (ADD_MESSAGE for example) is how + WEB4J identifies each block. The Block Name must correspond + to a public static final SqlId field. Upon startup, this allows verification + that all items defined in the text source have a corresponding item in code. +
  • '--' comments can appear in the Block Body as well +
  • if desired, the Block Body may be indented, to make the Block more legible +
  • the Block Name of 'constants' is reserved. A constants + block defines one or more simple textual substitution constants, as + name = value pairs, one per line, that may be + referenced later on in the file (see example). They are defined only to allow + such substitution to occur later in the file. Any number of constants + blocks can appear in a file. +
  • Block Names and the names of constants satisfy + {@link hirondelle.web4j.database.SqlId#FORMAT}. +
  • inside a Block Body, substitutions are denoted by the common + syntax '${blah}', where blah refers to an item appearing + earlier in the file, either the content + of a previously defined Block, or the value of a constant defined in a + constants Block +
  • an item must be defined before it can be used in a substitution ; that is, + it must appear earlier in the file +
  • no substitutions are permitted in a constants Block +
  • Block Names, and the names of constants, should be unique. +
+ +

Stored Procedures

+For calling stored procedures in general, please see {@link hirondelle.web4j.database.StoredProcedureTemplate}. + +

If the stored procedure is particularly simple in nature, it may also be referenced in an +.sql file. In short, if the stored procedure can be treated as a {@link java.sql.PreparedStatement}, +without using methods specific to {@link java.sql.CallableStatement}, then it may appear in an .sql file, +and be treated by WEB4J as any other SQL statement. + +

More specifically, such stored procedures have no + OUT parameters of any kind, either OUT, INOUT, or an + explicit return value (which must be registered as an OUT). On the + other hand, they must have a single implicit return value. Here, 'implicit + return value' refers to the return values of executeQuery and + executeUpdate (either a ResultSet or an int count, + respectively). + + + diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/AppException.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/AppException.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,76 @@ +package hirondelle.web4j.model; + +import java.io.Serializable; +import java.util.List; + +/** + Base class for most exceptions defined by WEB4J. + +

Differs from most exception classes in that multiple error + messages may be used, instead of just one. Used in JSPs to inform the user of + error conditions, usually related to user input validations. + +

This class is {@link Serializable}, since all {@link Throwable}s are serializable. +*/ +public class AppException extends Exception implements MessageList { + + /** No-argument constructor. */ + public AppException(){ + super(); + } + + /** + Constructor. + + @param aMessage text describing the problem. Must have content. + @param aThrowable root cause underlying the problem. + */ + public AppException(String aMessage, Throwable aThrowable){ + super(aMessage, aThrowable); + add(aMessage); + //using instanceof is distasteful, but overloading constructors, + //that is, defining a second ctor(String, AppException), does not work + if ( aThrowable instanceof AppException ) { + add( (AppException)aThrowable ); + } + } + + public final void add(String aErrorMessage){ + fErrorMessages.add(aErrorMessage); + } + + public final void add(String aErrorMessage, Object... aParams){ + fErrorMessages.add(aErrorMessage, aParams); + } + + public final void add(AppException ex){ + fErrorMessages.add(ex); + } + + public boolean isEmpty(){ + return fErrorMessages.isEmpty(); + } + + public final boolean isNotEmpty(){ + return !fErrorMessages.isEmpty(); + } + + public final List getMessages(){ + return fErrorMessages.getMessages(); + } + + /** Intended for debugging only. */ + @Override public String toString(){ + return fErrorMessages.toString(); + } + + // PRIVATE + + /** + List of error messages attached to this exception. +

Implementation Note : + This class is a wrapper of MessageListImpl, and simply forwards + related method calls to this field. This avoids code repetition. + */ + private MessageList fErrorMessages = new MessageListImpl(); +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/AppResponseMessage.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/AppResponseMessage.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,287 @@ +package hirondelle.web4j.model; + +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.request.Formats; +import hirondelle.web4j.request.RequestParameter; +import hirondelle.web4j.ui.translate.Translator; +import hirondelle.web4j.util.Args; +import hirondelle.web4j.util.EscapeChars; +import hirondelle.web4j.util.Util; + +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.Serializable; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + Informative message presented to the end user. + +

This class exists in order to hide the difference between simple and + compound messages. + +

Simple Messages
+ Simple messages are a single {@link String}, such as 'Item deleted successfully.'. + They are created using {@link #forSimple(String)}. + +

Compound Messages
+ Compound messages are made up of several parts, and have parameters. They are created + using {@link #forCompound(String, Object...)}. A compound message + is usually implemented in Java using {@link java.text.MessageFormat}. + However, MessageFormat is not used by this class, to avoid the following issues : +

    +
  • the dreaded apostrophe problem. In MessageFormat, the apostrophe is a special + character, and must be escaped. This is highly unnatural for translators, and has been a + source of continual, bothersome errors. (This is the principal reason for not + using MessageFormat.) +
  • the {0} placeholders start at 0, not 1. Again, this is + unnatural for translators. +
  • the number of parameters cannot exceed 10. (Granted, it is not often + that a large number of parameters are needed, but there is no reason why this + restriction should exist.) +
  • in general, {@link MessageFormat} is rather complicated in its details. +
+ +

Format of Compound Messages
+ This class defines an alternative format to that defined by {@link java.text.MessageFormat}. + For example, +

+  "At this restaurant, the _1_ meal costs _2_."
+  "On _2_, I am going to Manon's place to see _1_."
+ 
+ Here, +
    +
  • the placeholders appear as _1_, _2_, and so on. + They start at 1, not 0, and have no upper limit. There is no escaping + mechanism to allow the placeholder text to appear in the message 'as is'. The _i_ + placeholders stand for an Object, and carry no format information. +
  • apostrophes can appear anywhere, and do not need to be escaped. +
  • the formats applied to the various parameters are taken from {@link Formats}. + If the default formatting applied by {@link Formats} is not desired, then the caller + can always manually format the parameter as a {@link String}. (The {@link Translator} may be used when + a different pattern is needed for different Locales.) +
  • the number of parameters passed at runtime must match exactly the number of _i_ + placeholders +
+ +

Multilingual Applications
+ Multilingual applications will need to ensure that messages can be successfully translated when + presented in JSPs. In particular, some care must be exercised to not create + a simple message out of various pieces of data when a compound message + should be used instead. See {@link #getMessage(Locale, TimeZone)}. + As well, see the hirondelle.web4j.ui.translate + package for more information, in particular the + {@link hirondelle.web4j.ui.translate.Messages} tag used for rendering AppResponseMessages, + even in single language applications. + +

Serialization
+ This class implements {@link Serializable} to allow messages stored in session scope to + be transferred over a network, and thus survive a failover operation. + However, this class's implementation of Serializable interface has a minor defect. + This class accepts Objects as parameters to messages. These objects almost always represent + data - String, Integer, Id, DateTime, and so on, and all such building block classes are Serializable. + If, however, the caller passes an unusual message parameter object which is not Serializable, then the + serialization of this object (if it occurs), will fail. + +

The above defect will likely not be fixed since it has large ripple effects, and would seem to cause + more problems than it would solve. In retrospect, this the message parameters passed to + {@link #forCompound(String, Object[])} should likely have been typed as Serializable, not Object. +*/ +public final class AppResponseMessage implements Serializable { + + /** + Simple message having no parameters. + aSimpleText must have content. + */ + public static AppResponseMessage forSimple(String aSimpleText){ + return new AppResponseMessage(aSimpleText, NO_PARAMS); + } + + /** + Compound message having parameters. + +

aPattern follows the custom format defined by this class. + {@link Formats#objectToTextForReport} will be used to format all parameters. + + @param aPattern must be in the style of the custom format, and + the number of placeholders must match the number of items in aParams. + @param aParams must have at least one member; all members must be non-null, but may be empty + {@link String}s. + */ + public static AppResponseMessage forCompound(String aPattern, Object... aParams){ + if ( aParams.length < 1 ){ + throw new IllegalArgumentException("Compound messages must have at least one parameter."); + } + return new AppResponseMessage(aPattern, aParams); + } + + /** + Return either the 'simple text' or the formatted pattern with all parameter data rendered, + according to which factory method was called. + +

The configured {@link Translator} is used to localize +

    +
  • the text passed to {@link #forSimple(String)} +
  • the pattern passed to {@link #forCompound(String, Object...)} +
  • any {@link hirondelle.web4j.request.RequestParameter} parameters passed to {@link #forCompound(String, Object...)} + are localized by using {@link Translator} on the return value of {@link RequestParameter#getName()} + (This is intended for displaying localized versions of control names.) +
+ +

It is highly recommended that this method be called late in processing, in a JSP. + +

The Locale should almost always come from + {@link hirondelle.web4j.BuildImpl#forLocaleSource()}. + The aLocale parameter is always required, even though there are cases when it + is not actually used to render the result. + */ + public String getMessage(Locale aLocale, TimeZone aTimeZone){ + String result = null; + Translator translator = BuildImpl.forTranslator(); + Formats formats = new Formats(aLocale, aTimeZone); + if( fParams.isEmpty() ){ + result = translator.get(fText, aLocale); + } + else { + String localizedPattern = translator.get(fText, aLocale); + List formattedParams = new ArrayList(); + for (Object param : fParams){ + if ( param instanceof RequestParameter ){ + RequestParameter reqParam = (RequestParameter)param; + String translatedParamName = translator.get(reqParam.getName(), aLocale); + formattedParams.add( translatedParamName ); + } + else { + //this will escape any special HTML chars in params : + formattedParams.add( formats.objectToTextForReport(param).toString() ); + } + } + result = populateParamsIntoCustomFormat(localizedPattern, formattedParams); + } + return result; + } + + /** + Return an unmodifiable List corresponding to the aParams passed to + the constructor. + +

If no parameters are being used, then return an empty list. + */ + public List getParams(){ + return Collections.unmodifiableList(fParams); + } + + /** + Return either the 'simple text' or the pattern, according to which factory method + was called. Typically, this method is not used to present text to the user (see {@link #getMessage}). + */ + @Override public String toString(){ + return fText; + } + + @Override public boolean equals(Object aThat){ + Boolean result = ModelUtil.quickEquals(this, aThat); + if ( result == null ){ + AppResponseMessage that = (AppResponseMessage) aThat; + result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields()); + } + return result; + } + + @Override public int hashCode(){ + return ModelUtil.hashCodeFor(getSignificantFields()); + } + + // PRIVATE + + /** Holds either the simple text, or the custom pattern. */ + private final String fText; + + /** List of Objects holds the parameters. Empty List if no parameters used. */ + private final List fParams; + + private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("_(\\d)+_"); + private static final Object[] NO_PARAMS = new Object[0]; + private static final Logger fLogger = Util.getLogger(AppResponseMessage.class); + + private static final long serialVersionUID = 1000L; + + private AppResponseMessage(String aText, Object... aParams){ + fText = aText; + fParams = Arrays.asList(aParams); + validateState(); + } + + private void validateState(){ + Args.checkForContent(fText); + if (fParams != null && fParams.size() > 0){ + for(Object item : fParams){ + if ( item == null ){ + throw new IllegalArgumentException("Parameters to compound messages must be non-null."); + } + } + } + } + + /** + @param aFormattedParams contains Strings ready to be placed in to the pattern. The index i of the + List matches the _i_ placeholder. The size of aFormattedParams must match the number of + placeholders. + */ + private String populateParamsIntoCustomFormat(String aPattern, List aFormattedParams){ + StringBuffer result = new StringBuffer(); + fLogger.finest("Populating " + Util.quote(aPattern) + " with params " + Util.logOnePerLine(aFormattedParams)); + Matcher matcher = PLACEHOLDER_PATTERN.matcher(aPattern); + int numMatches = 0; + while ( matcher.find() ) { + ++numMatches; + if(numMatches > aFormattedParams.size()){ + String message = "The number of placeholders exceeds the number of available parameters (" + aFormattedParams.size() + ")"; + fLogger.severe(message); + throw new IllegalArgumentException(message); + } + matcher.appendReplacement(result, getReplacement(matcher, aFormattedParams)); + } + if(numMatches < aFormattedParams.size()){ + String message = "The number of placeholders (" + numMatches + ") is less than the number of available parameters (" + aFormattedParams.size() + ")"; + fLogger.severe(message); + throw new IllegalArgumentException(message); + } + matcher.appendTail(result); + return result.toString(); + } + + private String getReplacement(Matcher aMatcher, List aFormattedParams){ + String result = null; + String digit = aMatcher.group(1); + int idx = Integer.parseInt(digit); + if(idx <= 0){ + throw new IllegalArgumentException("Placeholder digit should be 1,2,3... but takes value " + idx); + } + if(idx > aFormattedParams.size()){ + throw new IllegalArgumentException("Placeholder index for _" + idx + "_ exceeds the number of available parameters (" + aFormattedParams.size() + ")"); + } + result = aFormattedParams.get(idx - 1); + return EscapeChars.forReplacementString(result); + } + + private Object[] getSignificantFields(){ + return new Object[] {fText, fParams}; + } + + /** + Always treat de-serialization as a full-blown constructor, by validating the final state of the deserialized object. + */ + private void readObject(ObjectInputStream aInputStream) throws ClassNotFoundException, IOException { + aInputStream.defaultReadObject(); + validateState(); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/BadRequestException.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/BadRequestException.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,69 @@ +package hirondelle.web4j.model; + +/** + Thrown by {@link hirondelle.web4j.security.ApplicationFirewall} + when a problem with an incoming HTTP request is detected. + +

This class is intended only for bugs and malicious attacks. + It is not intended for normal business logic. If a BadRequestException is + thrown by {@link hirondelle.web4j.security.ApplicationFirewall}, then the + {@link hirondelle.web4j.Controller} will reply with a short, unpolished + response (often a default page defined by the server). Under normal operating conditions, + the end user should not see such a response. + +

See {@link hirondelle.web4j.security.ApplicationFirewall} for more information. + +

Design Note +
This class is not an {@link AppException}, since it is meant to encapsulate only a + single item at a time. +*/ +public final class BadRequestException extends Exception { + + /** + Construct using a standard HTTP status code. + +

The caller is highly encouraged to pass a field defined in + {@link javax.servlet.http.HttpServletResponse} to this constructor + ({@link javax.servlet.http.HttpServletResponse#SC_NOT_IMPLEMENTED}, and so on). + +

See W3C HTTP Specification + for more information on status codes. + */ + public BadRequestException(int aHTTPStatusCode){ + fStatusCode = aHTTPStatusCode; + fErrorMessage = null; + } + + /** + Construct using a standard HTTP status code and an error message to be presented to the + user. + + See {@link #BadRequestException(int)} for more information. + */ + public BadRequestException(int aHTTPStatusCode, String aErrorMessage){ + fStatusCode = aHTTPStatusCode; + fErrorMessage = aErrorMessage; + } + + /** Return the status code passed to the constructor. */ + public int getStatusCode(){ + return fStatusCode; + } + + /** + Return the error message passed to the constructor. If constructed without a message, then + return null. + */ + public String getErrorMessage(){ + return fErrorMessage; + } + + /** Intended for debugging only. */ + @Override public String toString(){ + return ToStringUtil.getText(this); + } + + //PRIVATE // + private final int fStatusCode; + private final String fErrorMessage; +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/Check.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/Check.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,682 @@ +package hirondelle.web4j.model; + +import java.util.*; +import java.util.logging.Logger; +import java.util.regex.*; +import java.math.BigDecimal; + +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.security.SafeText; +import hirondelle.web4j.security.SpamDetector; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.util.WebUtil; +import static hirondelle.web4j.util.Consts.PASSES; +import static hirondelle.web4j.util.Consts.FAILS; +import hirondelle.web4j.util.Args; + +/** + Returns commonly needed {@link Validator} objects. + +

In general, the number of possible validations is very large. It is not appropriate + for a framework to attempt to implement all possible validations. Rather, a framework should + provide the most common validations, and allow the application programmer to extend + the validation mechanism as needed. + +

Validations are important parts of your program's logic. + Using tools such as JUnit to test your validation code is highly recommended. + Since your Model Objects have no dependencies on heavyweight objects, they're almost always easy to test. + +

If a specific validation is not provided here, other options include : +

    +
  • performing the validation directly in the Model Object, without using Check or + a {@link Validator} +
  • create a new {@link Validator}, and pass it to either {@link #required(Object, Validator...)} + or {@link #optional(Object, Validator...)}. This option is especially attractive when it will eliminate + code repetition. +
  • subclassing this class, and adding new static methods +
+ +

The {@link #range(long, long)}, {@link #min(long)} and {@link #max(long)} methods return {@link Validator}s + that perform checks on a long value. The source of the long value varies + according to the type of Object passed to the {@link Validator}, and is taken as follows + (int is internally converted to long when necessary) : +

    +
  • {@link Integer#intValue()} +
  • {@link Long#longValue()} +
  • length of a trimmed {@link String} having content; the same is applied to {@link Id#toString()}, + {@link Code#getText()}, and {@link SafeText#getRawString()}. +
  • {@link Collection#size()} +
  • {@link Map#size()} +
  • {@link Date#getTime()} - underlying millisecond value +
  • {@link Calendar#getTimeInMillis()} - underlying millisecond value +
  • any other class will cause an exception to be thrown by {@link #min(long)} and {@link #max(long)} +
+ +

The {@link #required(Object)}, {@link #required(Object, Validator...)} and {@link #optional(Object, Validator...)} + methods are important, and are separated out as distinct validations. In addition, the + required/optional character of a field is always the first validation performed (see examples below). + +

In general, it is highly recommended that applications + aggressively perform all possible validations. + +

Note that when validation is performed in a Model Object, then it will apply both to objects created from + user input, and to objects created from a database ResultSet. + +

Example 1
+ Example of a required field in a Model Object (that is, the field is of any type, and + must be non-null) : +

+if ( ! Check.required(fStartDate) ) {
+  ex.add("Start Date is Required.");
+}
+ 
+ + +

Example 2
+ Example of a required text field, which must have visible content + (as in {@link Util#textHasContent(String)}) : +

+if ( ! Check.required(fTitle) ) {
+  ex.add("Title is required, and must have content.");
+}
+ 
+ +

Example 3
+ Example of a required text field, whose length must be in the range 2..50 : +

+if ( ! Check.required(fTitle, Check.range(2,50)) ) {
+  ex.add("Title is required, and must have between 2 and 50 characters.");
+}
+ 
+ +

Example 4
+ Example of an optional String field that matches the format '1234-5678' : +

+//compile the pattern once, when the class is loaded
+private static final Pattern fID_PATTERN = Pattern.compile("(\\d){4}-(\\d){4}");
+...
+if ( ! Check.optional(fSomeId, Check.pattern(fID_PATTERN)) ) {
+  ex.add("Id is optional, and must have the form '1234-5678'.");
+}
+ 
+ +

Example 5
+ The initial ! negation operator is easy to forget. Many will prefer a more explicit style, which seems + to be more legible : +

+import static hirondelle.web4j.util.Consts.FAILS;
+...
+if ( FAILS == Check.required(fStartDate) ) {
+  ex.add("Start Date is Required.");
+}
+ 
+ +

Here is one style for implementing custom validations for your application : +

+//Checks that a person's age is in the range 0..150
+public static Validator ageRange(){
+  class CheckAge implements Validator {
+    public boolean isValid(Object aObject) {
+      Integer age = (Integer)aObject;
+      return 0 <= age <= 150; 
+    }
+  }
+  return new CheckAge();
+}
+ 
+*/ +public class Check { + + /** + Return true only if aObject is non-null. + +

String and {@link SafeText} objects are a special case : instead of just + being non-null, they must have content according to {@link Util#textHasContent(String)}. + + @param aObject possibly-null field of a Model Object. + */ + public static boolean required(Object aObject){ + boolean result = FAILS; + if ( isText(aObject) ) { + result = Util.textHasContent(getText(aObject)); + } + else { + result = (aObject != null); + } + return result; + } + + /** + Return true only if aObject satisfies {@link #required(Object)}, + and it passes all given validations + + @param aObject possibly-null field of a Model Object. + */ + public static boolean required(Object aObject, Validator... aValidators){ + boolean result = PASSES; + if ( ! required(aObject) ) { + result = FAILS; + } + else { + result = passesValidations(aObject, aValidators); + } + return result; + } + + /** + Return true only if aObject is null, OR if aObject is non-null + and passes all validations. + +

String and {@link SafeText} objects are a special case : instead of just + being non-null, they must have content according to {@link Util#textHasContent(String)}. + + @param aObject possibly-null field of a Model Object. + */ + public static boolean optional(Object aObject, Validator... aValidators){ + boolean result = PASSES; + if ( aObject != null ){ + if( isText(aObject) ) { + result = Util.textHasContent(getText(aObject)) && passesValidations(aObject, aValidators); + } + else { + result = passesValidations(aObject, aValidators); + } + } + return result; + } + + /** + Return a {@link Validator} to check that all passed booleans are true. + Note that the single parameter is a sequence parameter, so you may pass in many booleans, not just one. +

This is a bizarre method, but it's actually useful. It allows checks on an object's state to be treated as any other check. + */ + public static Validator isTrue(Boolean... aPredicates){ + class AllTrue implements Validator{ + AllTrue(Boolean... aPreds){ + fPredicates = aPreds; + } + public boolean isValid(Object aObject) { + //aObject is ignored here + boolean result = true; + for (Boolean predicate: fPredicates){ + result = result && predicate; + } + return result; + }; + private Boolean[] fPredicates; + } + return new AllTrue(aPredicates); + } + + /** + Return a {@link Validator} to check that all passed booleans are false. + Note that the single parameter is a sequence parameter, so you may pass in many booleans, not just one. +

This is a bizarre method, but it's actually useful. It allows checks on an object's state to be treated as any other check. + */ + public static Validator isFalse(Boolean... aPredicates){ + class AllFalse implements Validator{ + AllFalse(Boolean... aPreds){ + fPredicates = aPreds; + } + public boolean isValid(Object aObject) { + //aObject is ignored here + boolean result = true; + for (Boolean predicate: fPredicates){ + result = result && !predicate; + } + return result; + }; + private Boolean[] fPredicates; + } + return new AllFalse(aPredicates); + } + + /** + Return a {@link Validator} to check that a field's value is greater than or equal to aMinimumValue. + See class comment for the kind of objects which may be checked by the returned {@link Validator}. + */ + public static Validator min(final long aMinimumValue){ + class Minimum implements Validator { + Minimum(long aMinValue){ + fMinValue = aMinValue; + } + public boolean isValid(Object aObject){ + long value = getValueAsLong(aObject); + return value >= fMinValue; + } + private final long fMinValue; + } + return new Minimum(aMinimumValue); + } + + /** + Return a {@link Validator} to check that a {@link BigDecimal} field is greater than or equal + to aMinimumValue. + */ + public static Validator min(final BigDecimal aMinimumValue){ + class Minimum implements Validator { + Minimum(BigDecimal aMinValue){ + fMinValue = aMinValue; + } + public boolean isValid(Object aObject){ + BigDecimal value = (BigDecimal)aObject; + return value.compareTo(fMinValue) >= 0; + } + private final BigDecimal fMinValue; + } + return new Minimum(aMinimumValue); + } + + /** + Return a {@link Validator} to check that a {@link Decimal} amount is greater than or equal + to aMinimumValue. This methods allows comparisons between + Money objects of different currency. + */ + public static Validator min(final Decimal aMinimumValue){ + class Minimum implements Validator { + Minimum(Decimal aMinValue){ + fMinValue = aMinValue; + } + public boolean isValid(Object aObject){ + Decimal value = (Decimal)aObject; + return value.gteq(fMinValue); + } + private final Decimal fMinValue; + } + return new Minimum(aMinimumValue); + } + + /** + Return a {@link Validator} to check that a {@link DateTime} is greater than or equal + to aMinimumValue. + */ + public static Validator min(final DateTime aMinimumValue){ + class Minimum implements Validator { + Minimum(DateTime aMinValue){ + fMinValue = aMinValue; + } + public boolean isValid(Object aObject){ + DateTime value = (DateTime)aObject; + return value.gteq(fMinValue); + } + private final DateTime fMinValue; + } + return new Minimum(aMinimumValue); + } + + /** + Return a {@link Validator} to check that a field's value is less than or equal to aMaximumValue. + See class comment for the kind of objects which may be checked by the returned {@link Validator}. + */ + public static Validator max(final long aMaximumValue){ + class Maximum implements Validator { + Maximum(long aMaxValue){ + fMaxValue = aMaxValue; + } + public boolean isValid(Object aObject){ + long value = getValueAsLong(aObject); + return value <= fMaxValue; + } + private final long fMaxValue; + } + return new Maximum(aMaximumValue); + } + + /** + Return a {@link Validator} to check that a {@link BigDecimal} field is less than or equal + to aMaximumValue. + */ + public static Validator max(final BigDecimal aMaximumValue){ + class Maximum implements Validator { + Maximum(BigDecimal aMaxValue){ + fMaxValue = aMaxValue; + } + public boolean isValid(Object aObject){ + BigDecimal value = (BigDecimal)aObject; + return value.compareTo(fMaxValue) <= 0; + } + private final BigDecimal fMaxValue; + } + return new Maximum(aMaximumValue); + } + + /** + Return a {@link Validator} to check that a {@link Decimal} amount is less than or equal + to aMaximumValue. This methods allows comparisons between + Money objects of different currency. + */ + public static Validator max(final Decimal aMaximumValue){ + class Maximum implements Validator { + Maximum(Decimal aMaxValue){ + fMaxValue = aMaxValue; + } + public boolean isValid(Object aObject){ + Decimal value = (Decimal)aObject; + return value.lteq(fMaxValue); + } + private final Decimal fMaxValue; + } + return new Maximum(aMaximumValue); + } + + /** + Return a {@link Validator} to check that a {@link DateTime} is less than or equal + to aMaximumValue. + */ + public static Validator max(final DateTime aMaximumValue){ + class Maximum implements Validator { + Maximum(DateTime aMaxValue){ + fMaxValue = aMaxValue; + } + public boolean isValid(Object aObject){ + DateTime value = (DateTime)aObject; + return value.lteq(fMaxValue); + } + private final DateTime fMaxValue; + } + return new Maximum(aMaximumValue); + } + + /** + Return a {@link Validator} to check that a field's value is in a certain range (inclusive). + See class comment for the kind of objects which may be checked by the returned {@link Validator}. + */ + public static Validator range(final long aMinimumValue, final long aMaximumValue){ + class Range implements Validator { + Range(long aMin, long aMax){ + fMinValue = aMin; + fMaxValue = aMax; + } + public boolean isValid(Object aObject){ + long value = getValueAsLong(aObject); + return fMinValue <= value && value <= fMaxValue; + } + private final long fMinValue; + private final long fMaxValue; + } + return new Range(aMinimumValue, aMaximumValue); + } + + /** + Return a {@link Validator} to check that a {@link BigDecimal} value is in a certain range (inclusive). + */ + public static Validator range(final BigDecimal aMinimumValue, final BigDecimal aMaximumValue){ + class Range implements Validator { + Range(BigDecimal aMin, BigDecimal aMax){ + fMinValue = aMin; + fMaxValue = aMax; + } + public boolean isValid(Object aObject){ + BigDecimal value = (BigDecimal)aObject; + return value.compareTo(fMinValue) >= 0 && value.compareTo(fMaxValue) <= 0; + } + private final BigDecimal fMinValue; + private final BigDecimal fMaxValue; + } + return new Range(aMinimumValue, aMaximumValue); + } + + /** + Return a {@link Validator} to check that a {@link Decimal} amount is in a certain range (inclusive). + This methods allows comparisons between Money objects of different currency. + */ + public static Validator range(final Decimal aMinimumValue, final Decimal aMaximumValue){ + class Range implements Validator { + Range(Decimal aMin, Decimal aMax){ + fMinValue = aMin; + fMaxValue = aMax; + } + public boolean isValid(Object aObject){ + Decimal value = (Decimal)aObject; + return value.gteq(fMinValue) && value.lteq(fMaxValue); + } + private final Decimal fMinValue; + private final Decimal fMaxValue; + } + return new Range(aMinimumValue, aMaximumValue); + } + + /** + Return a {@link Validator} to check that a {@link DateTime} is in a certain range (inclusive). + */ + public static Validator range(final DateTime aMinimumValue, final DateTime aMaximumValue){ + class Range implements Validator { + Range(DateTime aMin, DateTime aMax){ + fMinValue = aMin; + fMaxValue = aMax; + } + public boolean isValid(Object aObject){ + DateTime value = (DateTime)aObject; + boolean isInRange = value.gt(fMinValue) && value.lt(fMaxValue); + boolean isAtAnEndpoint = value.equals(fMinValue) || value.equals(fMaxValue); + return isInRange || isAtAnEndpoint; + } + private final DateTime fMinValue; + private final DateTime fMaxValue; + } + return new Range(aMinimumValue, aMaximumValue); + } + + /** + Return a {@link Validator} to check that the number of decimal places of a {@link Decimal} or + {@link BigDecimal} is less than or equal to aMaxNumberOfDecimalPlaces. + + @param aMaxNumberOfDecimalPlaces is greater than or equal to 1. + */ + public static Validator numDecimalsMax(final int aMaxNumberOfDecimalPlaces){ + Args.checkForPositive(aMaxNumberOfDecimalPlaces); + class MaxNumPlaces implements Validator { + MaxNumPlaces(int aMaxNumberOfDecimals){ + Args.checkForPositive(aMaxNumberOfDecimals); + fMaxNumPlaces = aMaxNumberOfDecimals; + } + public boolean isValid(Object aObject){ + return Util.hasMaxDecimals(extractNumber(aObject), fMaxNumPlaces); + } + private final int fMaxNumPlaces; + } + return new MaxNumPlaces(aMaxNumberOfDecimalPlaces); + } + + /** + Return a {@link Validator} to check that the number of decimal places of a {@link Decimal} + or {@link BigDecimal} is exactly equal to aNumDecimals. + + @param aNumDecimals is 0 or more. + */ + public static Validator numDecimalsAlways(final int aNumDecimals){ + class NumPlaces implements Validator { + NumPlaces(int aNumberOfDecimalPlaces){ + fNumPlaces = aNumberOfDecimalPlaces; + } + public boolean isValid(Object aObject){ + return Util.hasNumDecimals(extractNumber(aObject), fNumPlaces); + } + private final int fNumPlaces; + } + return new NumPlaces(aNumDecimals); + } + + /** + Return a {@link Validator} that checks a {@link String} or {@link SafeText} + field versus a regular expression {@link Pattern}. + +

This method might be used to validate a zip code, phone number, and so on - any text which has a + well defined format. + +

There must be a complete match versus the whole text, as in {@link Matcher#matches()}. + In addition, the returned {@link Validator} will not trim the text before performing the validation. + + @param aRegex is a {@link Pattern}, which holds a regular expression. + */ + public static Validator pattern(final Pattern aRegex){ + class PatternValidator implements Validator { + PatternValidator(Pattern aSomeRegex){ + fRegex = aSomeRegex; + } + public boolean isValid(Object aObject) { + Matcher matcher = fRegex.matcher(getText(aObject)); + return matcher.matches(); + } + private final Pattern fRegex; + } + return new PatternValidator(aRegex); + } + + /** + Return a {@link Validator} to verify a {@link String} or {@link SafeText} field is a syntactically + valid email address. + +

See {@link WebUtil#isValidEmailAddress(String)}. The text is not trimmed by the returned + {@link Validator}. + */ + public static Validator email(){ + class EmailValidator implements Validator { + public boolean isValid(Object aObject) { + return WebUtil.isValidEmailAddress(getText(aObject)); + } + } + return new EmailValidator(); + } + + /** + Return a {@link Validator} to check that a {@link String} or {@link SafeText} field is not spam, + according to {@link SpamDetector}. + */ + public static Validator forSpam(){ + class SpamValidator implements Validator { + public boolean isValid(Object aObject) { + SpamDetector spamDetector = BuildImpl.forSpamDetector(); + return !spamDetector.isSpam(getText(aObject)); + } + } + return new SpamValidator(); + } + + /* + Note : forURL() method was attempted, but abandoned. + The JDK implementation of the URL constructor seems very flaky. + */ + + /** + This no-argument constructor is empty. + +

This constructor exists only because of it has protected scope. + Having protected scope has two desirable effects: +

    +
  • typical callers cannot create Check objects. This is appropriate since this class contains + only static methods. +
  • if needed, this class may be subclassed. This is useful when you need to add custom validations. +
+ */ + protected Check(){ + //empty - prevent construction by caller, but allow it for subclasses + } + + // PRIVATE // + + private static final Logger fLogger = Util.getLogger(Check.class); + + private static boolean passesValidations(Object aObject, Validator... aValidators) { + boolean result = PASSES; + for(Validator validator: aValidators){ + if ( ! validator.isValid(aObject) ) { + result = FAILS; + fLogger.fine("Failed a validation."); + break; + } + } + return result; + } + + private static long getValueAsLong(Object aObject){ + long result = 0; + if ( aObject instanceof Integer){ + Integer value = (Integer)aObject; + result = value.intValue(); + } + else if (aObject instanceof Long) { + Long value = (Long)aObject; + result = value.longValue(); + } + else if (aObject instanceof String){ + String text = (String)aObject; + if ( Util.textHasContent(text) ) { + result = text.trim().length(); + } + } + else if (aObject instanceof Id){ + Id id = (Id)aObject; + if ( Util.textHasContent(id.toString()) ) { + result = id.toString().trim().length(); + } + } + else if (aObject instanceof SafeText){ + SafeText text = (SafeText)aObject; + if ( Util.textHasContent(text.getRawString()) ) { + result = text.getRawString().trim().length(); + } + } + else if (aObject instanceof Code){ + Code code = (Code)aObject; + if ( Util.textHasContent(code.getText()) ) { + result = code.getText().getRawString().trim().length(); + } + } + else if (aObject instanceof Collection) { + Collection collection = (Collection)aObject; + result = collection.size(); + } + else if (aObject instanceof Map) { + Map map = (Map)aObject; + result = map.size(); + } + else if (aObject instanceof Date) { + Date date = (Date)aObject; + result = date.getTime(); + } + else if (aObject instanceof Calendar){ + Calendar calendar = (Calendar)aObject; + result = calendar.getTimeInMillis(); + } + else { + String message = "Unexpected type of Object: " + aObject.getClass().getName(); + fLogger.severe(message); + throw new AssertionError(message); + } + return result; + } + + private static boolean isText(Object aObject){ + return (aObject instanceof String) || (aObject instanceof SafeText); + } + + private static String getText(Object aObject){ + String result = null; + if ( aObject instanceof String ){ + String text = (String) aObject; + result = text.toString(); + } + else if (aObject instanceof SafeText ){ + SafeText text = (SafeText)aObject; + result = text.getRawString(); + } + return result; + } + + /** aObject must be a BigDecimal or a Money object. */ + private static BigDecimal extractNumber(Object aObject){ + BigDecimal result = null; + if( aObject instanceof BigDecimal){ + result = (BigDecimal)aObject; + } + else if (aObject instanceof Decimal) { + Decimal decimal = (Decimal)aObject; + result = decimal.getAmount(); + } + else { + throw new IllegalArgumentException("Unexpected class: " + aObject.getClass()); + } + return result; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/Code.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/Code.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,230 @@ +package hirondelle.web4j.model; + +import hirondelle.web4j.model.Id; +import hirondelle.web4j.model.ModelCtorException; +import hirondelle.web4j.model.ModelUtil; +import hirondelle.web4j.model.Check; +import hirondelle.web4j.security.SafeText; + +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; + +/** + An item in a code table. + +

Here, a value in a code table is modeled with one required item (text), and four optional items + (id, short form, long form, and order index). The id is optional, since for 'add' operations it is not yet + specified. This class is offered as a convenience for implementing Code Tables. + Applications are not required to use it. + +

Please see the example application for an example of one way of implementing code tables. + +

Code Tables

+

A code table is an informal term describing a set of related values. It resembles + an enumeration. Simple examples : +

    +
  • geographical divisions - countries, states or provinces +
  • list of accepted credit cards - Mastercard, Visa, and so on +
  • the account types offered by a bank - chequing, savings, and so on +
+ + Other important aspects of code tables : +
    +
  • most applications use them. +
  • they often appear in the user interface as drop-down SELECT controls. +
  • they usually don't change very often. +
  • they may have a specific sort order, unrelated to alphabetical ordering. +
  • it is almost always desirable that the user-visible text attached to a code be unique (in a given code table), + since the user will usually have no means to distinguish the identical items. +
  • it is often desirable to present the same code in different ways, according to context. + For example, report listings may present codes in an abbreviated style, while a tool-tips may present a + codes in a more verbose style, to explain their meaning. +
  • they are usually closely related to various foreign keys in the database. +
+ +

Underlying Data

+ Code tables may be implemented in various ways, including +
    +
  • database tables constructed explicitly for that purpose +
  • database tables that have already been constructed to hold problem domain items. That is, + data from an existing table may be extracted in a shortened form, to be used as a code table. +
  • simple in-memory data. For example, a 1..10 rating system might use simple Integer + objects created upon startup. +
+ +

Avoiding Double-Escaping

+ This class uses {@link SafeText}, which escapes special characters. + When rendering a Code object in a JSP, some care must be taken to ensure that + special characters are not mistakenly escaped twice. + +

In a single language app, it's usually safe to render a Code by simply using ${code}. This + calls {@link #toString()}, which returns escaped text, safe for direct rendering in a JSP. + +

In a multilingual app, however, the various translation tags + (<w:txt>, <w:txtFlow>, <w:tooltip>) already escape special characters. + So, if a translation tag encounters a Code somewhere its body, the Code must be in + an unescaped form, otherwise it wil be escaped twice, which undesirable. + In a multilingual app, you should usually render a Code using ${code.text.rawString}. + +

This asymmetry between single-language and many-language apps is somewhat displeasing, and + constitutes a pitfall of using this class. If desired, you could define an alternate Code class + whose toString returns a String instead of {@link SafeText}. +*/ +public final class Code implements Serializable { + + /** + Full constructor. + + @param aId underlying database identifier for the code value. Optional, 1..50 characters. + @param aText default text for presentation to the end user. Required, 1..50 characters. + @param aShortText short form for text presented to the end user. This item is useful for terse + presentation in reports, often as an abbreviation of one or two letters. Optional, 1..10 characters. + @param aLongText long form for text presented to the user. For example, this may be a description + or definition of the meaning of the code. Optional, 1..200 characters. + @param aOrderIdx defines the order of appearance of this item in a listing. Optional, range 1..1000. + This item is used to provide explicit control over the order of appearance of items as presented to + the user, and will often be related to an ORDER BY clause in an SQL statement. + */ + public Code(Id aId, SafeText aText, SafeText aShortText, SafeText aLongText, Integer aOrderIdx) throws ModelCtorException { + fId = aId; + fText = aText; + fShortText = aShortText; + fLongText = aLongText; + fOrderIdx = aOrderIdx; + validateState(); + } + + /** As in the full constructor, but without a short description, long description, or an order index. */ + public Code(Id aId, SafeText aText) throws ModelCtorException { + fId = aId; + fText = aText; + fShortText = null; + fLongText = null; + fOrderIdx = null; + validateState(); + } + + /** As in the full constructor, but without a long description or order index. */ + public Code(Id aId, SafeText aText, SafeText aShortText) throws ModelCtorException { + fId = aId; + fText = aText; + fShortText = aShortText; + fLongText = null; + fOrderIdx = null; + validateState(); + } + + /** As in the full constructor, but without an order index. */ + public Code(Id aId, SafeText aText, SafeText aShortText, SafeText aLongText) throws ModelCtorException { + fId = aId; + fText = aText; + fShortText = aShortText; + fLongText = aLongText; + fOrderIdx = null; + validateState(); + } + + /** Return the Id passed to the constructor. */ + public Id getId() { return fId; } + /** Return the Text passed to the constructor. */ + public SafeText getText() { return fText; } + /** Return the Short Text passed to the constructor. */ + public SafeText getShortText() { return fShortText; } + /** Return the Long Text passed to the constructor. */ + public SafeText getLongText() { return fLongText; } + /** Return the Order Index passed to the constructor. */ + public Integer getOrderIdx() { return fOrderIdx; } + + /** + Returns {@link #getText()}.toString(). + +

This is the most user-friendly form of a code, and is useful for rendering in JSPs. + */ + @Override public String toString(){ + return fText.toString(); + } + + @Override public boolean equals(Object aThat){ + Boolean result = ModelUtil.quickEquals(this, aThat); + if ( result == null ){ + Code that = (Code) aThat; + result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields()); + } + return result; + } + + @Override public int hashCode() { + if ( fHashCode == 0 ) { + fHashCode = ModelUtil.hashCodeFor(getSignificantFields()); + } + return fHashCode; + } + + // PRIVATE + + /** @serial */ + private final Id fId; + /** @serial */ + private final SafeText fText; + /** @serial */ + private final SafeText fShortText; + /** @serial */ + private final SafeText fLongText; + /** @serial */ + private final Integer fOrderIdx; + /** @serial */ + private int fHashCode; + + /** + For evolution of this class, see Sun guidelines : + http://java.sun.com/j2se/1.5.0/docs/guide/serialization/spec/version.html#6678 + */ + private static final long serialVersionUID = 8856876119383545215L; + + private Object[] getSignificantFields(){ + return new Object[] {fId, fText, fShortText, fLongText, fOrderIdx}; + } + + private void validateState() throws ModelCtorException { + ModelCtorException ex = new ModelCtorException(); + if ( ! Check.optional(fId, Check.min(1), Check.max(50))) { + ex.add("Code Id is optional, 1..50 chars."); + } + if ( ! Check.required(fText, Check.range(1,50))) { + ex.add("Text is required, 1..50 chars."); + } + if ( ! Check.optional(fShortText, Check.range(1,10))) { + ex.add("Short Text is optional, 1..10 chars."); + } + if ( ! Check.optional(fLongText, Check.range(1,200))) { + ex.add("Long Text is optional, 1..200 chars."); + } + if ( ! Check.optional(fOrderIdx, Check.range(1,1000))) { + ex.add("Order Idx is optional, 1..1000."); + } + if ( ! ex.isEmpty() ) throw ex; + } + + /** + Always treat de-serialization as a full-blown constructor, by + validating the final state of the de-serialized object. + */ + private void readObject(ObjectInputStream aInputStream) throws ClassNotFoundException, IOException, ModelCtorException { + //always perform the default de-serialization first + aInputStream.defaultReadObject(); + //make defensive copy of mutable fields (none here) + //ensure that object state has not been corrupted or tampered with maliciously + validateState(); + } + + /** + This is the default implementation of writeObject. + Customise if necessary. + */ + private void writeObject(ObjectOutputStream aOutputStream) throws IOException { + //perform the default serialization for all non-transient, non-static fields + aOutputStream.defaultWriteObject(); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/ConvertParam.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/ConvertParam.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,83 @@ +package hirondelle.web4j.model; + +import java.util.Locale; +import java.util.TimeZone; + +import hirondelle.web4j.model.ModelCtorException; + +/** + Convert request parameters into common 'building block' objects. + +

See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. + {@link hirondelle.web4j.BuildImpl#forConvertParam()} + returns the configured implementation of this interface. + +

Building Block Classes

+ Here, a building block class is one of the 'base' objects from which Model + Objects can in turn be built - Integer, BigDecimal, Date, + and so on. {@link #isSupported(Class)} defines which classes are supported as + building block classes, and {@link #convert(String, Class, Locale, TimeZone)} defines + how to construct them from request parameters. + +

{@link ConvertParamImpl} is provided as a default implementation, and is likely + suitable for many applications. + +

+ In addition, implementations may optionally choose to apply universal pre-processing + for Strings. For example, an implementation may decide to trim all Strings, or to force capitalization for + all Strings. Such policies may be centralized here, by including them in the implementation of + {@link #filter(String)} or {@link #convert(String, Class, Locale, TimeZone)}. +*/ +public interface ConvertParam { + + /** + Determine if aTargetClass is a supported building block class. + +

If aTargetClass is supported, then an underlying request parameter can be converted into + an object of that class. + +

The framework will always call this method first, before calling the other methods in + this interface. + */ + boolean isSupported(Class aTargetClass); + + /** + Apply a policy for parameter values which are null, empty, or equal to + IgnorableParamValue in web.xml. +

+ When a 'blank' form is submitted, items are not treated in a + uniform manner. For example, a popular browser exhibits this behavior : +

    +
  • blank text area : param submitted, empty String +
  • radio button, with none selected : no param submitted (null) +
  • checkbox, unselected : no param submitted (null) +
  • drop-down selection, with first pre-selected by browser : param submitted, with value +
+

+ Moreover, the W3C spec seems + to allow for some ambiguity in what exactly is posted, so the above behavior may not be + seen for all browsers. + +

This method is used to impose uniformity upon all such 'blank' items. + +

This method can return null. Any non-null + values returned by this method must have content. (That is, an implementation cannot return a + String which, when trimmed, is empty.) + + @param aRawParamValue the raw, unchanged parameter value, as submitted in the underlying request. + */ + String filter(String aRawParamValue); + + /** + Convert a request parameter value into a building block object. +

+ The value passed to this method is first filtered, using {@link #filter(String)}. + Implementations must throw a {@link ModelCtorException} when a parsing problem occurs. +

+ @param aFilteredParamValue parameter value as returned by {@link #filter(String)} + @param aSupportedTargetClass supported target building block class in a Model Object constructor + @param aLocale {@link Locale} returned by the implementation of {@link hirondelle.web4j.request.LocaleSource} + @param aTimeZone {@link TimeZone} returned by the implementation of {@link hirondelle.web4j.request.TimeZoneSource} + */ + T convert(String aFilteredParamValue, Class aSupportedTargetClass, Locale aLocale, TimeZone aTimeZone) throws ModelCtorException; +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/ConvertParamError.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/ConvertParamError.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,48 @@ +package hirondelle.web4j.model; + +import hirondelle.web4j.action.Action; +import hirondelle.web4j.request.RequestParameter; +import hirondelle.web4j.request.RequestParser; + +/** + Instructs WEB4J how to respond to any errors found during parsing of raw user input into + 'base' types such as Integer, Date, and so on. + +

See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. + {@link hirondelle.web4j.BuildImpl#forConvertParamError()} + returns the configured implementation of this interface. + +

On behalf of the application, {@link RequestParser} and related classes will parse + user input into various target types such as Integer, Date and so on, using + the configured implementation of {@link hirondelle.web4j.model.ConvertParam}. + This centralizes such repetitive parsing into the framework, and allows the application programmer to + mostly ignore the possibility of such errors - the try..catch block has + already been written. + +

WEB4J performs such parsing as a soft validation. + For example, if user input should be an {@link Integer} in the range 0..150, then + WEB4J will attempt to parse the raw user input (a String) first into an + {@link Integer}. If that parse succeeds, + then WEB4J will pass the Integer on to an {@link Action}, which will then perform further + "business validation" using a business domain Model Object - that is, it will check that its value is in the range 0..150. + So, an {@link Action} and its Model Object can almost always assume that an object of the correct 'base' type already exists, + without worrying about low level parsing problems. + +

However, if user input cannot be parsed by WEB4J into the desired class, then an error message must + be displayed to the user. Implementations of this interface allow an application + programmer to specify the content of that response message. + +

Please see the example application for an example implementation. +*/ +public interface ConvertParamError { + + /** + Return a {@link ModelCtorException} for a given parsing error, suitable for presentation to the end user. + + @param aSupportedTargetClass identifies the class into which the user input cannot be successfully parsed + @param aUserInputValue value input by the user + @param aRequestParameter the request parameter + */ + public ModelCtorException get(Class aSupportedTargetClass, String aUserInputValue, RequestParameter aRequestParameter); + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/ConvertParamImpl.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/ConvertParamImpl.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,327 @@ +package hirondelle.web4j.model; + +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.readconfig.Config; +import hirondelle.web4j.request.DateConverter; +import hirondelle.web4j.request.Formats; +import hirondelle.web4j.security.SafeText; +import hirondelle.web4j.util.Util; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; +import java.util.logging.Logger; +import java.util.regex.Pattern; + +/** Default implementation of {@link ConvertParam}.*/ +public class ConvertParamImpl implements ConvertParam { + + /** + Return true only if aTargetClass is supported by this implementation. +

+ The following classes are supported by this implementation as building block classes: +

    +
  • {@link SafeText} +
  • String (conditionally, see below) +
  • Integer +
  • Long +
  • Boolean +
  • BigDecimal +
  • {@link Decimal} +
  • {@link Id} +
  • {@link DateTime} +
  • java.util.Date +
  • Locale +
  • TimeZone +
  • InputStream +
+ +

You are not obliged to use this class to model Locale and TimeZone. + Many will choose to implement them as just another + code table + instead. In this case, your model object constructors would usually take an {@link Id} parameter for these + items, and translate them into a {@link Code}. See the example apps for a demonstration of this technique. + +

String is supported only when explicitly allowed. + The AllowStringAsBuildingBlock setting in web.xml + controls whether or not this class allows String as a supported class. + By default, its value is FALSE, since {@link SafeText} is the recommended + replacement for String. + */ + public final boolean isSupported(Class aTargetClass){ + boolean result = false; + if (String.class.equals(aTargetClass)){ + result = fConfig.getAllowStringAsBuildingBlock(); + } + else { + for (Class standardClass : STANDARD_CLASSES){ + if (standardClass.isAssignableFrom(aTargetClass)){ + result = true; + break; + } + } + } + return result; + } + + /** + Coerce all parameters with no visible content to null. + +

In addition, any raw input value that matches IgnorableParamValue in web.xml is + also coerced to null. See web.xml for more information. + +

Any non-null result is trimmed. + This method can be overridden, if desired. + */ + public String filter(String aRawInputValue){ + String result = aRawInputValue; + if ( ! Util.textHasContent(aRawInputValue) || aRawInputValue.equals(getIgnorableParamValue()) ){ + result = null; + } + return Util.trimPossiblyNull(result); //some apps may elect to trim elsewhere + } + + /** + Apply reasonable parsing policies, suitable for most applications. + +

Roughly, the policies are: +

    +
  • SafeText uses {@link SafeText#SafeText(String)} +
  • String just return the filtered value as is +
  • Integer uses {@link Integer#Integer(String)} +
  • BigDecimal uses {@link Formats#getDecimalInputFormat()} +
  • Decimal uses {@link Formats#getDecimalInputFormat()} +
  • Boolean uses {@link Util#parseBoolean(String)} +
  • DateTime uses {@link DateConverter#parseEyeFriendlyDateTime(String, Locale)} + and {@link DateConverter#parseHandFriendlyDateTime(String, Locale)} +
  • Date uses {@link DateConverter#parseEyeFriendly(String, Locale, TimeZone)} + and {@link DateConverter#parseHandFriendly(String, Locale, TimeZone)} +
  • Long uses {@link Long#Long(String)} +
  • Id uses {@link Id#Id(String)} +
  • Locale uses {@link Locale#getAvailableLocales()} and {@link Locale#toString()}, case sensitive. +
  • TimeZone uses {@link TimeZone#getAvailableIDs()}, case sensitive. +
+ InputStreams are not converted by this class, and need to be handled separately by the caller. + */ + public final T convert(String aFilteredInputValue, Class aSupportedTargetClass, Locale aLocale, TimeZone aTimeZone) throws ModelCtorException { + // Defensive : this check should have already been performed by the calling framework class. + if( ! isSupported(aSupportedTargetClass) ) { + throw new AssertionError("Unsupported type cannot be translated to an object: " + aSupportedTargetClass + ". If you're trying to use String, consider using SafeText instead. Otherwise, change the AllowStringAsBuildingBlock setting in web.xml."); + } + + Object result = null; + if (aSupportedTargetClass == SafeText.class){ + //no translation needed; some impl's might trim here, or force CAPS + result = parseSafeText(aFilteredInputValue); + } + else if (aSupportedTargetClass == String.class) { + result = aFilteredInputValue; //no translation needed; some impl's might trim here, or force CAPS + } + else if (aSupportedTargetClass == Integer.class || aSupportedTargetClass == int.class){ + result = parseInteger(aFilteredInputValue); + } + else if (aSupportedTargetClass == Boolean.class || aSupportedTargetClass == boolean.class){ + result = Util.parseBoolean(aFilteredInputValue); + } + else if (aSupportedTargetClass == BigDecimal.class){ + result = parseBigDecimal(aFilteredInputValue, aLocale, aTimeZone); + } + else if (aSupportedTargetClass == Decimal.class){ + result = parseDecimal(aFilteredInputValue, aLocale, aTimeZone); + } + else if (aSupportedTargetClass == java.util.Date.class){ + result = parseDate(aFilteredInputValue, aLocale, aTimeZone); + } + else if (aSupportedTargetClass == DateTime.class){ + result = parseDateTime(aFilteredInputValue, aLocale); + } + else if (aSupportedTargetClass == Long.class || aSupportedTargetClass == long.class){ + result = parseLong(aFilteredInputValue); + } + else if (aSupportedTargetClass == Id.class){ + result = new Id(aFilteredInputValue.trim()); + } + else if (aSupportedTargetClass == Locale.class){ + result = parseLocale(aFilteredInputValue); + } + else if (TimeZone.class.isAssignableFrom(aSupportedTargetClass)){ + //the above style is needed since TimeZone is abstract + result = parseTimeZone(aFilteredInputValue); + } + else { + throw new AssertionError("Failed to build object for ostensibly supported class: " + aSupportedTargetClass); + } + fLogger.finer("Converted request param into a " + aSupportedTargetClass.getName()); + return (T)result; //this cast is unavoidable, and safe. + } + + /** + Return the IgnorableParamValue configured in web.xml. + See web.xml for more information. + */ + public final String getIgnorableParamValue(){ + return fConfig.getIgnorableParamValue(); + } + + // PRIVATE + + private Config fConfig = new Config(); + private static List> STANDARD_CLASSES; //always the same + private static final ModelCtorException PROBLEM_FOUND = new ModelCtorException(); + private static final Logger fLogger = Util.getLogger(ConvertParamImpl.class); + static { + STANDARD_CLASSES = new ArrayList>(); + STANDARD_CLASSES.add(Integer.class); + STANDARD_CLASSES.add(int.class); + STANDARD_CLASSES.add(Boolean.class); + STANDARD_CLASSES.add(boolean.class); + STANDARD_CLASSES.add(BigDecimal.class); + STANDARD_CLASSES.add(java.util.Date.class); + STANDARD_CLASSES.add(Long.class); + STANDARD_CLASSES.add(long.class); + STANDARD_CLASSES.add(Id.class); + STANDARD_CLASSES.add(SafeText.class); + STANDARD_CLASSES.add(Locale.class); + STANDARD_CLASSES.add(TimeZone.class); + STANDARD_CLASSES.add(Decimal.class); + STANDARD_CLASSES.add(DateTime.class); + STANDARD_CLASSES.add(InputStream.class); + } + + private Integer parseInteger(String aUserInputValue) throws ModelCtorException { + try { + return new Integer(aUserInputValue); + } + catch (NumberFormatException ex){ + throw PROBLEM_FOUND; + } + } + + private BigDecimal parseBigDecimal(String aUserInputValue, Locale aLocale, TimeZone aTimeZone) throws ModelCtorException { + BigDecimal result = null; + Formats formats = new Formats(aLocale, aTimeZone); + Pattern pattern = formats.getDecimalInputFormat(); + if ( Util.matches(pattern, aUserInputValue)) { + //BigDecimal ctor only takes '.' as decimal sign, never ',' + result = new BigDecimal(aUserInputValue.replace(',', '.')); + } + else { + throw PROBLEM_FOUND; + } + return result; + } + + private Decimal parseDecimal(String aUserInputValue, Locale aLocale, TimeZone aTimeZone) throws ModelCtorException { + Decimal result = null; + BigDecimal amount = null; + Formats formats = new Formats(aLocale, aTimeZone); + Pattern pattern = formats.getDecimalInputFormat(); + if ( Util.matches(pattern, aUserInputValue)) { + //BigDecimal ctor only takes '.' as decimal sign, never ',' + amount = new BigDecimal(aUserInputValue.replace(',', '.')); + try { + result = new Decimal(amount); + } + catch(IllegalArgumentException ex){ + throw PROBLEM_FOUND; + } + } + else { + throw PROBLEM_FOUND; + } + return result; + } + + private Date parseDate(String aUserInputValue, Locale aLocale, TimeZone aTimeZone) throws ModelCtorException { + Date result = null; + DateConverter dateConverter = BuildImpl.forDateConverter(); + result = dateConverter.parseHandFriendly(aUserInputValue, aLocale, aTimeZone); + if ( result == null ){ + result = dateConverter.parseEyeFriendly(aUserInputValue, aLocale, aTimeZone); + } + if ( result == null ) { + throw PROBLEM_FOUND; + } + return result; + } + + private DateTime parseDateTime(String aUserInputValue, Locale aLocale) throws ModelCtorException { + DateTime result = null; + DateConverter dateConverter = BuildImpl.forDateConverter(); + result = dateConverter.parseHandFriendlyDateTime(aUserInputValue, aLocale); + if ( result == null ){ + result = dateConverter.parseEyeFriendlyDateTime(aUserInputValue, aLocale); + } + if ( result == null ) { + throw PROBLEM_FOUND; + } + return result; + } + + private Long parseLong(String aUserInputValue) throws ModelCtorException { + Long result = null; + if ( Util.textHasContent(aUserInputValue) ){ + try { + result = new Long(aUserInputValue); + } + catch (NumberFormatException ex){ + throw PROBLEM_FOUND; + } + } + return result; + } + + private SafeText parseSafeText(String aUserInputValue) throws ModelCtorException { + SafeText result = null; + if( Util.textHasContent(aUserInputValue) ) { + try { + result = new SafeText(aUserInputValue); + } + catch(IllegalArgumentException ex){ + throw PROBLEM_FOUND; + } + } + return result; + } + + /** Translate user input into a known time zone id. Case sensitive. */ + private TimeZone parseTimeZone(String aUserInputValue) throws ModelCtorException { + TimeZone result = null; + if ( Util.textHasContent(aUserInputValue) ){ + List allTimeZoneIds = Arrays.asList(TimeZone.getAvailableIDs()); + for(String id : allTimeZoneIds){ + if (id.equals(aUserInputValue)){ + result = TimeZone.getTimeZone(id); + break; + } + } + if(result == null){ //has content, but no match found + throw PROBLEM_FOUND; + } + } + return result; + } + + /** Translate user input into a known Locale id. Case sensitive. */ + private Locale parseLocale(String aUserInputValue) throws ModelCtorException { + Locale result = null; + if ( Util.textHasContent(aUserInputValue) ){ + List allLocales = Arrays.asList(Locale.getAvailableLocales()); + for(Locale locale: allLocales){ + if (locale.toString().equals(aUserInputValue)){ + result = locale; + break; + } + } + if(result == null){ //has content, but no match found + throw PROBLEM_FOUND; + } + } + return result; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/DateTime.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/DateTime.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,1692 @@ +package hirondelle.web4j.model; + +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.action.ActionImpl; +import hirondelle.web4j.model.ModelUtil.NullsGo; +import hirondelle.web4j.request.TimeZoneSource; +import hirondelle.web4j.util.TimeSource; +import hirondelle.web4j.util.Util; + +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.util.Calendar; +import java.util.GregorianCalendar; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; + +/** + Building block class for an immutable date-time, with no time zone. + +

+ This class is provided as an alternative to java.util.{@link java.util.Date}. + You're strongly encouraged to use this class in your WEB4J applications, but you can still use java.util.{@link java.util.Date} if you wish. + +

This class can hold : +

  • a date-and-time : 1958-03-31 18:59:56.123456789
  • a date only : 1958-03-31
  • a time only : 18:59:56.123456789 +
+ +

+ Examples
+ Justification For This Class
+ Dates and Times In General
+ The Approach Used By This Class
+ Two Sets Of Operations
+ Parsing DateTime - Accepted Formats
+ Mini-Language for Formatting
+ Interaction with {@link TimeSource}
+ Passing DateTime Objects to the Database + + +

Examples

+ Some quick examples of using this class : +
+  DateTime dateAndTime = new DateTime("2010-01-19 23:59:59");
+  //highest precision is nanosecond, not millisecond:
+  DateTime dateAndTime = new DateTime("2010-01-19 23:59:59.123456789");
+  
+  DateTime dateOnly = new DateTime("2010-01-19");
+  DateTime timeOnly = new DateTime("23:59:59");
+  
+  DateTime dateOnly = DateTime.forDateOnly(2010,01,19);
+  DateTime timeOnly = DateTime.forTimeOnly(23,59,59,0);
+  
+  DateTime dt = new DateTime("2010-01-15 13:59:15");
+  boolean leap = dt.isLeapYear(); //false
+  dt.getNumDaysInMonth(); //31
+  dt.getStartOfMonth(); //2010-01-01, 00:00:00
+  dt.getEndOfDay(); //2010-01-15, 23:59:59
+  dt.format("YYYY-MM-DD"); //formats as '2010-01-15'
+  dt.plusDays(30); //30 days after Jan 15
+  dt.numDaysFrom(someDate); //returns an int
+  dueDate.lt(someDate); //less-than
+  dueDate.lteq(someDate); //less-than-or-equal-to
+  
+  //{@link ActionImpl#getTimeZone()} is readily available in most Actions
+  DateTime.now(getTimeZone());
+  DateTime.today(getTimeZone());
+  DateTime fromMilliseconds = DateTime.forInstant(31313121L, getTimeZone());
+  birthday.isInFuture(getTimeZone());
+ 
+ + +

Justification For This Class

+ The fundamental reasons why this class exists are : +
    +
  • to avoid the embarrassing number of distasteful inadequacies in the JDK's date classes +
  • to oppose the very "mental model" of the JDK's date-time classes with something significantly simpler +
+ + +

There are 2 distinct mental models for date-times, and they don't play well together : +

    +
  • timeline - an instant on the timeline, as a physicist would picture it, representing the number of + seconds from some epoch. In this picture, such a date-time can have many, many different + representations according to calendar and time zone. That is, the date-time, as seen and understood by + the end user, can change according to "who's looking at it". It's important to understand that a timeline instant, + before being presented to the user, must always have an associated time zone - even in the case of + a date only, with no time. +
  • everyday - a date-time in the Gregorian calendar, such as '2009-05-25 18:25:00', + which never changes according to "who's looking at it". Here, the time zone is always both implicit and immutable. +
+ +

The problem is that java.util.{@link java.util.Date} uses only the timeline style, while most users, most + of the time, think in terms of the other mental model - the 'everday' style. + + In particular, there are a large number of applications which experience + problems with time zones, because the timeline model + is used instead of the everday model. + Such problems are often seen by end users as serious bugs, because telling people the wrong date or time is often a serious issue. + These problems make you look stupid. + + +

Date Classes in the JDK are Mediocre

+ The JDK's classes related to dates are widely regarded as frustrating to work with, for various reasons: +
    +
  • mistakes regarding time zones are very common +
  • month indexes are 0-based, leading to off-by-one errors +
  • difficulty of calculating simple time intervals +
  • java.util.Date is mutable, but 'building block' classes should be + immutable +
  • numerous other minor nuisances +
+ + +

Joda Time Has Drawbacks As Well

+ The Joda Time library is used by some programmers as an alternative + to the JDK classes. Joda Time has the following drawbacks : +
    +
  • it limits precision to milliseconds. Database timestamp values almost always have a precision of microseconds + or even nanoseconds. This is a serious defect: a library should never truncate your data, for any reason. +
  • it's large, with well over 100 items in its javadoc +
  • in order to stay current, it needs to be manually updated occasionally with fresh time zone data +
  • it has mutable versions of classes +
  • it always coerces March 31 + 1 Month to April 30 (for example), without giving you any choice in the matter +
  • some databases allow invalid date values such as '0000-00-00', but Joda Time doesn't seem to be able to handle them +
+ + + +

Dates and Times in General

+ +

Civil Timekeeping Is Complex

+ Civil timekeeping is a byzantine hodge-podge of arcane and arbitrary rules. Consider the following : +
    +
  • months have varying numbers of days +
  • one month (February) has a length which depends on the year +
  • not all years have the same number of days +
  • time zone rules spring forth arbitrarily from the fecund imaginations of legislators +
  • summer hours mean that an hour is 'lost' in the spring, while another hour must + repeat itself in the autumn, during the switch back to normal time +
  • summer hour logic varies widely across various jurisdictions +
  • the cutover from the Julian calendar to the Gregorian calendar happened at different times in + different places, which causes a varying number of days to be 'lost' during the cutover +
  • occasional insertion of leap seconds are used to ensure synchronization with the + rotating Earth (whose speed of rotation is gradually slowing down, in an irregular way) +
  • there is no year 0 (1 BC is followed by 1 AD), except in the reckoning used by + astronomers +
+ +

How Databases Treat Dates

+ Most databases model dates and times using the Gregorian Calendar in an aggressively simplified form, + in which : +
    +
  • the Gregorian calendar is extended back in time as if it was in use previous to its + inception (the 'proleptic' Gregorian calendar) +
  • the transition between Julian and Gregorian calendars is entirely ignored +
  • leap seconds are entirely ignored +
  • summer hours are entirely ignored +
  • often, even time zones are ignored, in the sense that the underlying database + column doesn't usually explicitly store any time zone information. +
+ +

The final point requires elaboration. + Some may doubt its veracity, since they have seen date-time information "change time zone" when + retrieved from a database. But this sort of change is usually applied using logic which is external to the data + stored in the particular column. + +

For example, the following items might be used in the calculation of a time zone difference : +

    +
  • time zone setting for the client (or JDBC driver) +
  • time zone setting for the client's connection to the database server +
  • time zone setting of the database server +
  • time zone setting of the host where the database server resides +
+ +

(Note as well what's missing from the above list: your own application's logic, and the user's time zone preference.) + +

When an end user sees such changes to a date-time, all they will say to you is + "Why did you change it? That's not what I entered" - and this is a completely valid question. + Why did you change it? Because you're using the timeline model instead of the everyday model. + Perhaps you're using a inappropriate abstraction for what the user really wants. + + +

The Approach Used By This Class

+ + This class takes the following design approach : +
    +
  • it models time in the "everyday" style, not in the "timeline" style (see above) +
  • its precision matches the highest precision used by databases (nanosecond) +
  • it uses only the proleptic Gregorian Calendar, over the years 1..9999 +
  • it ignores all non-linearities: summer-hours, leap seconds, and the cutover + from Julian to Gregorian calendars +
  • it ignores time zones. Most date-times are stored in columns whose type + does not include time zone information (see note above). +
  • it has (very basic) support for wonky dates, such as the magic value 0000-00-00 used by MySQL +
  • it's immutable +
  • it lets you choose among 4 policies for 'day overflow' conditions during calculations +
  • it talks to your {@link TimeSource} implementation when returning the current moment, allowing you to customise dates during testing +
+ +

Even though the above list may appear restrictive, it's very likely true that + DateTime can handle the dates and times you're currently storing in your database. + + +

Two Sets Of Operations

+ This class allows for 2 sets of operations: a few "basic" operations, and many "computational" ones. + +

Basic operations model the date-time as a simple, dumb String, with absolutely no parsing or substructure. + This will always allow your application to reflect exactly what is in a ResultSet, with + absolutely no modification for time zone, locale, or for anything else. + +

This is meant as a back-up, to ensure that your application will always be able + to, at the very least, display a date-time exactly as it appears in your + ResultSet from the database. This style is particularly useful for handling invalid + dates such as 2009-00-00, which can in fact be stored by some databases (MySQL, for + example). It can also be used to handle unusual items, such as MySQL's + TIME datatype. + +

The basic operations are represented by {@link #DateTime(String)}, {@link #toString()}, and {@link #getRawDateString()}. + +

Computational operations allow for calculations and formatting. + If a computational operation is performed by this class (for example, if the caller asks for the month), + then any underlying date-time String must be parseable by this class into its components - year, month, day, and so on. + Computational operations require such parsing, while the basic operations do not. Almost all methods in this class + are categorized as computational operations. + + +

Parsing DateTime - Accepted Formats

+ The {@link #DateTime(String)} constructor accepts a String representation of a date-time. + The format of the String can take a number of forms. When retrieving date-times from a database, the + majority of cases will have little problem in conforming to these formats. If necessary, your SQL statements + can almost always use database formatting functions to generate a String whose format conforms to one of the + many formats accepted by the {@link #DateTime(String)} constructor. + + +

Mini-Language for Formatting

+ This class defines a simple mini-language for formatting a DateTime, used by the various format methods. + +

The following table defines the symbols used by this mini-language, and the corresponding text they + would generate given the date: +

1958-04-09 Wednesday, 03:05:06.123456789 AM
+ in an English Locale. (Items related to date are in upper case, and items related to time are in lower case.) + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FormatOutput DescriptionNeeds Locale?
YYYY 1958 Year...
YY 58 Year without century...
M 4 Month 1..12...
MM 04 Month 01..12...
MMM Apr Month Jan..DecYes
MMMM April Month January..DecemberYes
DD 09 Day 01..31...
D 9 Day 1..31...
WWWW Wednesday Weekday Sunday..SaturdayYes
WWW Wed Weekday Sun..SatYes
hh 03 Hour 01..23...
h 3 Hour 1..23...
hh12 03 Hour 01..12...
h12 3 Hour 1..12...
a AM AM/PM IndicatorYes
mm 05 Minutes 01..59...
m 5 Minutes 1..59...
ss 06 Seconds 01..59...
s 6 Seconds 1..59...
f 1 Fractional Seconds, 1 decimal...
ff 12 Fractional Seconds, 2 decimals...
fff 123 Fractional Seconds, 3 decimals...
ffff 1234 Fractional Seconds, 4 decimals...
fffff 12345 Fractional Seconds, 5 decimals...
ffffff 123456 Fractional Seconds, 6 decimals...
fffffff 1234567 Fractional Seconds, 7 decimals...
ffffffff 12345678 Fractional Seconds, 8 decimals...
fffffffff 123456789 Fractional Seconds, 9 decimals...
| (no example) Escape character...
+ +

As indicated above, some of these symbols can only be used with an accompanying Locale. + In general, if the output is text, not a number, then a Locale will be needed. + For example, 'September' is localizable text, while '09' is a numeric representation, which doesn't require a Locale. + Thus, the symbol 'MM' can be used without a Locale, while 'MMMM' and 'MMM' both require a Locale, since they + generate text, not a number. + +

The fractional seconds 'f' does not perform any rounding. + +

The escape character '|' allows you to insert arbitrary text. + The escape character always appears in pairs; these pairs define a range of characters over which + the text will not be interpreted using the special format symbols defined above. + +

Examples : + + + + + + + + + + +
FormatOutput
YYYY-MM-DD hh:mm:ss.fffffffff a 1958-04-09 03:05:06.123456789 AM
YYYY-MM-DD hh:mm:ss.fff a 1958-04-09 03:05:06.123 AM
YYYY-MM-DD 1958-04-09
hh:mm:ss.fffffffff 03:05:06.123456789
hh:mm:ss 03:05:06
YYYY-M-D h:m:s 1958-4-9 3:5:6
WWWW, MMMM D, YYYY Wednesday, April 9, 1958
WWWW, MMMM D, YYYY |at| D a Wednesday, April 9, 1958 at 3 AM
+ +

In the last example, the escape characters are needed only because 'a', the formating symbol for am/pm, appears in the text. + + +

Interaction with {@link TimeSource}

+ The methods of this class related to the current date interact with your configured implementation + of {@link hirondelle.web4j.util.TimeSource}. That is, {@link #now(TimeZone)} and {@link #today(TimeZone)} + will return values derived from {@link TimeSource}. Thus, callers of this class automatically use any + 'fake' system clock that you may want to define. + + +

Passing DateTime Objects to the Database

+ When a DateTime is passed as a parameter to an SQL statement, the DateTime is + formatted into a String of a form accepted by the database. There are two mechanisms to + accomplish this +
  • in your DAO code, format the DateTime explicitly as a String, using one of the format methods, + and pass the String as the parameter to the SQL statement, and not the actual DateTime
  • pass the DateTime itself. In this case, WEB4J will use the setting in web.xml named + DateTimeFormatForPassingParamsToDb to perform the formatting for you. If the formats defined by this + setting are not appropriate for a given case, you will need to format the DateTime as a String explicitly instead.
+ */ +public final class DateTime implements Comparable, Serializable { + + /** The seven parts of a DateTime object. The DAY represents the day of the month (1..31), not the weekday. */ + public enum Unit { + YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, NANOSECONDS; + } + + /** + Policy for treating 'day-of-the-month overflow' conditions encountered during some date calculations. + +

Months are different from other units of time, since the length of a month is not fixed, but rather varies with + both month and year. This leads to problems. Take the following simple calculation, for example : + +

May 31 + 1 month = ?
+ +

What's the answer? Since there is no such thing as June 31, the result of this operation is inherently ambiguous. + This DayOverflow enumeration lists the various policies for treating such situations, as supported by + DateTime. + +

This table illustrates how the policies behave : +

+ + + + + + + + + + + + + + + + + + + + + + + + + +
DateDayOverflowResult
May 31 + 1 MonthLastDayJune 30
May 31 + 1 MonthFirstDayJuly 1
December 31, 2001 + 2 MonthsSpilloverMarch 3
May 31 + 1 MonthAbortRuntimeException
+ */ + public enum DayOverflow { + /** Coerce the day to the last day of the month. */ + LastDay, + /** Coerce the day to the first day of the next month. */ + FirstDay, + /** Spillover the day into the next month. */ + Spillover, + /** Throw a RuntimeException. */ + Abort; + } + + /** + Constructor taking a date-time as a String. + +

This constructor is called when WEB4J's data layer needs to translate a column in a ResultSet + into a DateTime. + +

When this constructor is called, the underlying text can be in an absolutely arbitrary + form, since it will not, initially, be parsed in any way. This policy of extreme + leniency allows you to use dates in an arbitrary format, without concern over possible + transformations of the date (time zone in particular), and without concerns over possibly bizarre content, such + as '2005-00-00', as seen in some databases, such as MySQL. + +

However, the moment you attempt to call almost any method + in this class, an attempt will be made to parse + the given date-time string into its constituent parts. Then, if the date-time string does not match one of the + example formats listed below, a RuntimeException will be thrown. + +

Before calling this constructor, you may wish to call {@link #isParseable(String)} to explicitly test whether a + given String is parseable by this class. + +

The full date format expected by this class is 'YYYY-MM-YY hh:mm:ss.fffffffff'. + All fields except for the fraction of a second have a fixed width. + In addition, various portions of this format are also accepted by this class. + +

All of the following dates can be parsed by this class to make a DateTime : +

    +
  • 2009-12-31 00:00:00.123456789 +
  • 2009-12-31T00:00:00.123456789 +
  • 2009-12-31 00:00:00.12345678 +
  • 2009-12-31 00:00:00.1234567 +
  • 2009-12-31 00:00:00.123456 +
  • 2009-12-31 23:59:59.12345 +
  • 2009-01-31 16:01:01.1234 +
  • 2009-01-01 16:59:00.123 +
  • 2009-01-01 16:00:01.12 +
  • 2009-02-28 16:25:17.1 +
  • 2009-01-01 00:01:01 +
  • 2009-01-01 16:01 +
  • 2009-01-01 16 +
  • 2009-01-01 +
  • 2009-01 +
  • 2009 +
  • 0009 +
  • 9 +
  • 00:00:00.123456789 +
  • 00:00:00.12345678 +
  • 00:00:00.1234567 +
  • 00:00:00.123456 +
  • 23:59:59.12345 +
  • 01:59:59.1234 +
  • 23:01:59.123 +
  • 00:00:00.12 +
  • 00:59:59.1 +
  • 23:59:00 +
  • 23:00:10 +
  • 00:59 +
+ +

The range of each field is : +

    +
  • year: 1..9999 (leading zeroes are optional) +
  • month: 01..12 +
  • day: 01..31 +
  • hour: 00..23 +
  • minute: 00..59 +
  • second: 00..59 +
  • nanosecond: 0..999999999 +
+ +

Note that database format functions are an option when dealing with date formats. + Since your application is always in control of the SQL used to talk to the database, you can, if needed, usually + use database format functions to alter the format of dates returned in a ResultSet. + */ + public DateTime(String aDateTime) { + fIsAlreadyParsed = false; + if (aDateTime == null) { + throw new IllegalArgumentException("String passed to DateTime constructor is null. You can use an empty string, but not a null reference."); + } + fDateTime = aDateTime; + } + + /** + Return true only if the given String follows one of the formats documented by {@link #DateTime(String)}. +

If the text is not from a trusted source, then the caller may use this method to validate whether the text + is in a form that's parseable by this class. + */ + public static boolean isParseable(String aCandidateDateTime){ + boolean result = true; + try { + DateTime dt = new DateTime(aCandidateDateTime); + dt.ensureParsed(); + } + catch (RuntimeException ex){ + result = false; + } + return result; + } + + + /** + Constructor taking each time unit explicitly. + +

Although all parameters are optional, many operations on this class require year-month-day to be + present. + + @param aYear 1..9999, optional + @param aMonth 1..12 , optional + @param aDay 1..31, cannot exceed the number of days in the given month/year, optional + @param aHour 0..23, optional + @param aMinute 0..59, optional + @param aSecond 0..59, optional + @param aNanoseconds 0..999,999,999, optional (allows for databases that store timestamps up to + nanosecond precision). + */ + public DateTime(Integer aYear, Integer aMonth, Integer aDay, Integer aHour, Integer aMinute, Integer aSecond, Integer aNanoseconds) { + fIsAlreadyParsed = true; + fYear = aYear; + fMonth = aMonth; + fDay = aDay; + fHour = aHour; + fMinute = aMinute; + fSecond = aSecond; + fNanosecond = aNanoseconds; + validateState(); + } + + /** + Factory method returns a DateTime having year-month-day only, with no time portion. +

See {@link #DateTime(Integer, Integer, Integer, Integer, Integer, Integer, Integer)} for constraints on the parameters. + */ + public static DateTime forDateOnly(Integer aYear, Integer aMonth, Integer aDay) { + return new DateTime(aYear, aMonth, aDay, null, null, null, null); + } + + /** + Factory method returns a DateTime having hour-minute-second-nanosecond only, with no date portion. +

See {@link #DateTime(Integer, Integer, Integer, Integer, Integer, Integer, Integer)} for constraints on the parameters. + */ + public static DateTime forTimeOnly(Integer aHour, Integer aMinute, Integer aSecond, Integer aNanoseconds) { + return new DateTime(null, null, null, aHour, aMinute, aSecond, aNanoseconds); + } + + /** + Constructor taking a millisecond value and a {@link TimeZone}. + This constructor may be use to convert a java.util.Date into a DateTime. + +

To use nanosecond precision, please use {@link #forInstantNanos(long, TimeZone)} instead. + + @param aMilliseconds must be in the range corresponding to the range of dates supported by this class (year 1..9999); corresponds + to a millisecond instant on the timeline, measured from the epoch used by {@link java.util.Date}. + */ + public static DateTime forInstant(long aMilliseconds, TimeZone aTimeZone) { + Calendar calendar = new GregorianCalendar(aTimeZone); + calendar.setTimeInMillis(aMilliseconds); + int year = calendar.get(Calendar.YEAR); + int month = calendar.get(Calendar.MONTH) + 1; // 0-based + int day = calendar.get(Calendar.DAY_OF_MONTH); + int hour = calendar.get(Calendar.HOUR_OF_DAY); // 0..23 + int minute = calendar.get(Calendar.MINUTE); + int second = calendar.get(Calendar.SECOND); + int milliseconds = calendar.get(Calendar.MILLISECOND); + int nanoseconds = milliseconds * 1000 * 1000; + return new DateTime(year, month, day, hour, minute, second, nanoseconds); + } + + /** + For the given time zone, return the corresponding time in milliseconds-since-epoch for this DateTime. + +

This method is meant to help you convert between a DateTime and the + JDK's date-time classes, which are based on the combination of a time zone and a + millisecond value from the Java epoch. +

Since DateTime can go to nanosecond accuracy, the return value can + lose precision. The nanosecond value is truncated to milliseconds, not rounded. + To retain nanosecond accuracy, please use {@link #getNanosecondsInstant(TimeZone)} instead. +

Requires year-month-day to be present; if not, a runtime exception is thrown. + */ + public long getMilliseconds(TimeZone aTimeZone){ + Integer year = getYear(); + Integer month = getMonth(); + Integer day = getDay(); + //coerce missing times to 0: + Integer hour = getHour() == null ? 0 : getHour(); + Integer minute = getMinute() == null ? 0 : getMinute(); + Integer second = getSecond() == null ? 0 : getSecond(); + Integer nanos = getNanoseconds() == null ? 0 : getNanoseconds(); + + Calendar calendar = new GregorianCalendar(aTimeZone); + calendar.set(Calendar.YEAR, year); + calendar.set(Calendar.MONTH, month-1); // 0-based + calendar.set(Calendar.DAY_OF_MONTH, day); + calendar.set(Calendar.HOUR_OF_DAY, hour); // 0..23 + calendar.set(Calendar.MINUTE, minute); + calendar.set(Calendar.SECOND, second); + calendar.set(Calendar.MILLISECOND, nanos/1000000); + + return calendar.getTimeInMillis(); + } + + /** + Constructor taking a nanosecond value and a {@link TimeZone}. + +

To use milliseconds instead of nanoseconds, please use {@link #forInstant(long, TimeZone)}. + + @param aNanoseconds must be in the range corresponding to the range of dates supported by this class (year 1..9999); corresponds + to a nanosecond instant on the time-line, measured from the epoch used by {@link java.util.Date}. + */ + public static DateTime forInstantNanos(long aNanoseconds, TimeZone aTimeZone) { + //these items can be of either sign + long millis = aNanoseconds / MILLION; //integer division truncates towards 0, doesn't round + long nanosRemaining = aNanoseconds % MILLION; //size 0..999,999 + //when negative: go to the previous millis, and take the complement of nanosRemaining + if(aNanoseconds < 0){ + millis = millis - 1; + nanosRemaining = MILLION + nanosRemaining; //-1 remaining coerced to 999,999 + } + + //base calculation in millis + Calendar calendar = new GregorianCalendar(aTimeZone); + calendar.setTimeInMillis(millis); + int year = calendar.get(Calendar.YEAR); + int month = calendar.get(Calendar.MONTH) + 1; // 0-based + int day = calendar.get(Calendar.DAY_OF_MONTH); + int hour = calendar.get(Calendar.HOUR_OF_DAY); // 0..23 + int minute = calendar.get(Calendar.MINUTE); + int second = calendar.get(Calendar.SECOND); + int milliseconds = calendar.get(Calendar.MILLISECOND); + + DateTime withoutNanos = new DateTime(year, month, day, hour, minute, second, milliseconds * MILLION); + //adjust for nanos - this cast is acceptable, because the value's range is 0..999,999: + DateTime withNanos = withoutNanos.plus(0, 0, 0, 0, 0, 0, (int)nanosRemaining, DayOverflow.Spillover); + return withNanos; + } + + /** + For the given time zone, return the corresponding time in nanoseconds-since-epoch for this DateTime. + +

For conversion between a DateTime and the JDK's date-time classes, + you should likely use {@link #getMilliseconds(TimeZone)} instead. +

Requires year-month-day to be present; if not, a runtime exception is thrown. + */ + public long getNanosecondsInstant(TimeZone aTimeZone){ + // these are always positive: + Integer year = getYear(); + Integer month = getMonth(); + Integer day = getDay(); + //coerce missing times to 0: + Integer hour = getHour() == null ? 0 : getHour(); + Integer minute = getMinute() == null ? 0 : getMinute(); + Integer second = getSecond() == null ? 0 : getSecond(); + Integer nanos = getNanoseconds() == null ? 0 : getNanoseconds(); + + int millis = nanos / MILLION; //integer division truncates, doesn't round + int nanosRemaining = nanos % MILLION; //0..999,999 - always positive + + //base calculation in millis + Calendar calendar = new GregorianCalendar(aTimeZone); + calendar.set(Calendar.YEAR, year); + calendar.set(Calendar.MONTH, month-1); // 0-based + calendar.set(Calendar.DAY_OF_MONTH, day); + calendar.set(Calendar.HOUR_OF_DAY, hour); // 0..23 + calendar.set(Calendar.MINUTE, minute); + calendar.set(Calendar.SECOND, second); + calendar.set(Calendar.MILLISECOND, millis); + + long baseResult = calendar.getTimeInMillis() * MILLION; // either sign + //the adjustment for nanos is always positive, toward the future: + return baseResult + nanosRemaining; + } + + /** + Return the raw date-time String passed to the {@link #DateTime(String)} constructor. + Returns null if that constructor was not called. See {@link #toString()} as well. + */ + public String getRawDateString() { + return fDateTime; + } + + /** Return the year, 1..9999. */ + public Integer getYear() { + ensureParsed(); + return fYear; + } + + /** Return the Month, 1..12. */ + public Integer getMonth() { + ensureParsed(); + return fMonth; + } + + /** Return the Day of the Month, 1..31. */ + public Integer getDay() { + ensureParsed(); + return fDay; + } + + /** Return the Hour, 0..23. */ + public Integer getHour() { + ensureParsed(); + return fHour; + } + + /** Return the Minute, 0..59. */ + public Integer getMinute() { + ensureParsed(); + return fMinute; + } + + /** Return the Second, 0..59. */ + public Integer getSecond() { + ensureParsed(); + return fSecond; + } + + /** Return the Nanosecond, 0..999999999. */ + public Integer getNanoseconds() { + ensureParsed(); + return fNanosecond; + } + + /** + Return the Modified Julian Day Number. +

The Modified Julian Day Number is defined by astronomers for simplifying the calculation of the number of days between 2 dates. + Returns a monotonically increasing sequence number. + Day 0 is November 17, 1858 00:00:00 (whose Julian Date was 2400000.5). + +

Using the Modified Julian Day Number instead of the Julian Date has 2 advantages: +

    +
  • it's a smaller number +
  • it starts at midnight, not noon (Julian Date starts at noon) +
+ +

Does not reflect any time portion, if present. + +

(In spite of its name, this method, like all other methods in this class, uses the + proleptic Gregorian calendar - not the Julian calendar.) + +

Requires year-month-day to be present; if not, a runtime exception is thrown. + */ + public Integer getModifiedJulianDayNumber() { + ensureHasYearMonthDay(); + int result = calculateJulianDayNumberAtNoon() - 1 - EPOCH_MODIFIED_JD; + return result; + } + + /** + Return an index for the weekday for this DateTime. + Returns 1..7 for Sunday..Saturday. +

Requires year-month-day to be present; if not, a runtime exception is thrown. + */ + public Integer getWeekDay() { + ensureHasYearMonthDay(); + int dayNumber = calculateJulianDayNumberAtNoon() + 1; + int index = dayNumber % 7; + return index + 1; + } + + /** + Return an integer in the range 1..366, representing a count of the number of days from the start of the year. + January 1 is counted as day 1. +

Requires year-month-day to be present; if not, a runtime exception is thrown. + */ + public Integer getDayOfYear() { + ensureHasYearMonthDay(); + int k = isLeapYear() ? 1 : 2; + Integer result = ((275 * fMonth) / 9) - k * ((fMonth + 9) / 12) + fDay - 30; // integer division + return result; + } + + /** + Returns true only if the year is a leap year. +

Requires year to be present; if not, a runtime exception is thrown. + */ + public Boolean isLeapYear() { + ensureParsed(); + Boolean result = null; + if (isPresent(fYear)) { + result = isLeapYear(fYear); + } + else { + throw new MissingItem("Year is absent. Cannot determine if leap year."); + } + return result; + } + + /** + Return the number of days in the month which holds this DateTime. +

Requires year-month-day to be present; if not, a runtime exception is thrown. + */ + public int getNumDaysInMonth() { + ensureHasYearMonthDay(); + return getNumDaysInMonth(fYear, fMonth); + } + + /** + Return The week index of this DateTime with respect to a given starting DateTime. +

The single parameter to this method defines first day of week number 1. + See {@link #getWeekIndex()} as well. +

Requires year-month-day to be present; if not, a runtime exception is thrown. + */ + public Integer getWeekIndex(DateTime aStartingFromDate) { + ensureHasYearMonthDay(); + aStartingFromDate.ensureHasYearMonthDay(); + int diff = getModifiedJulianDayNumber() - aStartingFromDate.getModifiedJulianDayNumber(); + return (diff / 7) + 1; // integer division + } + + /** + Return The week index of this DateTime, taking day 1 of week 1 as Sunday, January 2, 2000. +

See {@link #getWeekIndex(DateTime)} as well, which takes an arbitrary date to define + day 1 of week 1. +

Requires year-month-day to be present; if not, a runtime exception is thrown. + */ + public Integer getWeekIndex() { + DateTime start = DateTime.forDateOnly(2000, 1, 2); + return getWeekIndex(start); + } + + /** + Return true only if this DateTime has the same year-month-day as the given parameter. + Time is ignored by this method. +

Requires year-month-day to be present, both for this DateTime and for + aThat; if not, a runtime exception is thrown. + */ + public boolean isSameDayAs(DateTime aThat) { + boolean result = false; + ensureHasYearMonthDay(); + aThat.ensureHasYearMonthDay(); + result = (fYear.equals(aThat.fYear) && fMonth.equals(aThat.fMonth) && fDay.equals(aThat.fDay)); + return result; + } + + /** + 'Less than' comparison. + Return true only if this DateTime comes before the given parameter, according to {@link #compareTo(DateTime)}. + */ + public boolean lt(DateTime aThat) { + return compareTo(aThat) < EQUAL; + } + + /** + 'Less than or equal to' comparison. + Return true only if this DateTime comes before the given parameter, according to {@link #compareTo(DateTime)}, + or this DateTime equals the given parameter. + */ + public boolean lteq(DateTime aThat) { + return compareTo(aThat) < EQUAL || equals(aThat); + } + + /** + 'Greater than' comparison. + Return true only if this DateTime comes after the given parameter, according to {@link #compareTo(DateTime)}. + */ + public boolean gt(DateTime aThat) { + return compareTo(aThat) > EQUAL; + } + + /** + 'Greater than or equal to' comparison. + Return true only if this DateTime comes after the given parameter, according to {@link #compareTo(DateTime)}, + or this DateTime equals the given parameter. + */ + public boolean gteq(DateTime aThat) { + return compareTo(aThat) > EQUAL || equals(aThat); + } + + /** Return the smallest non-null time unit encapsulated by this DateTime. */ + public Unit getPrecision() { + ensureParsed(); + Unit result = null; + if (isPresent(fNanosecond)) { + result = Unit.NANOSECONDS; + } + else if (isPresent(fSecond)) { + result = Unit.SECOND; + } + else if (isPresent(fMinute)) { + result = Unit.MINUTE; + } + else if (isPresent(fHour)) { + result = Unit.HOUR; + } + else if (isPresent(fDay)) { + result = Unit.DAY; + } + else if (isPresent(fMonth)) { + result = Unit.MONTH; + } + else if (isPresent(fYear)) { + result = Unit.YEAR; + } + return result; + } + + /** + Truncate this DateTime to the given precision. +

The return value will have all items lower than the given precision simply set to + null. In addition, the return value will not include any date-time String passed to the + {@link #DateTime(String)} constructor. + + @param aPrecision takes any value except {@link Unit#NANOSECONDS} (since it makes no sense to truncate to the highest + available precision). + */ + public DateTime truncate(Unit aPrecision) { + ensureParsed(); + DateTime result = null; + if (Unit.NANOSECONDS == aPrecision) { + throw new IllegalArgumentException("It makes no sense to truncate to nanosecond precision, since that's the highest precision available."); + } + else if (Unit.SECOND == aPrecision) { + result = new DateTime(fYear, fMonth, fDay, fHour, fMinute, fSecond, null); + } + else if (Unit.MINUTE == aPrecision) { + result = new DateTime(fYear, fMonth, fDay, fHour, fMinute, null, null); + } + else if (Unit.HOUR == aPrecision) { + result = new DateTime(fYear, fMonth, fDay, fHour, null, null, null); + } + else if (Unit.DAY == aPrecision) { + result = new DateTime(fYear, fMonth, fDay, null, null, null, null); + } + else if (Unit.MONTH == aPrecision) { + result = new DateTime(fYear, fMonth, null, null, null, null, null); + } + else if (Unit.YEAR == aPrecision) { + result = new DateTime(fYear, null, null, null, null, null, null); + } + return result; + } + + /** + Return true only if all of the given units are present in this DateTime. + If a unit is not included in the argument list, then no test is made for its presence or absence + in this DateTime by this method. + */ + public boolean unitsAllPresent(Unit... aUnits) { + boolean result = true; + ensureParsed(); + for (Unit unit : aUnits) { + if (Unit.NANOSECONDS == unit) { + result = result && fNanosecond != null; + } + else if (Unit.SECOND == unit) { + result = result && fSecond != null; + } + else if (Unit.MINUTE == unit) { + result = result && fMinute != null; + } + else if (Unit.HOUR == unit) { + result = result && fHour != null; + } + else if (Unit.DAY == unit) { + result = result && fDay != null; + } + else if (Unit.MONTH == unit) { + result = result && fMonth != null; + } + else if (Unit.YEAR == unit) { + result = result && fYear != null; + } + } + return result; + } + + /** + Return true only if this DateTime has a non-null values for year, month, and day. + */ + public boolean hasYearMonthDay() { + return unitsAllPresent(Unit.YEAR, Unit.MONTH, Unit.DAY); + } + + /** + Return true only if this DateTime has a non-null values for hour, minute, and second. + */ + public boolean hasHourMinuteSecond() { + return unitsAllPresent(Unit.HOUR, Unit.MINUTE, Unit.SECOND); + } + + /** + Return true only if all of the given units are absent from this DateTime. + If a unit is not included in the argument list, then no test is made for its presence or absence + in this DateTime by this method. + */ + public boolean unitsAllAbsent(Unit... aUnits) { + boolean result = true; + ensureParsed(); + for (Unit unit : aUnits) { + if (Unit.NANOSECONDS == unit) { + result = result && fNanosecond == null; + } + else if (Unit.SECOND == unit) { + result = result && fSecond == null; + } + else if (Unit.MINUTE == unit) { + result = result && fMinute == null; + } + else if (Unit.HOUR == unit) { + result = result && fHour == null; + } + else if (Unit.DAY == unit) { + result = result && fDay == null; + } + else if (Unit.MONTH == unit) { + result = result && fMonth == null; + } + else if (Unit.YEAR == unit) { + result = result && fYear == null; + } + } + return result; + } + + /** + Return this DateTime with the time portion coerced to '00:00:00.000000000'. +

Requires year-month-day to be present; if not, a runtime exception is thrown. + */ + public DateTime getStartOfDay() { + ensureHasYearMonthDay(); + return getStartEndDateTime(fDay, 0, 0, 0, 0); + } + + /** + Return this DateTime with the time portion coerced to '23:59:59.999999999'. +

Requires year-month-day to be present; if not, a runtime exception is thrown. + */ + public DateTime getEndOfDay() { + ensureHasYearMonthDay(); + return getStartEndDateTime(fDay, 23, 59, 59, 999999999); + } + + /** + Return this DateTime with the time portion coerced to '00:00:00.000000000', + and the day coerced to 1. +

Requires year-month-day to be present; if not, a runtime exception is thrown. + */ + public DateTime getStartOfMonth() { + ensureHasYearMonthDay(); + return getStartEndDateTime(1, 0, 0, 0, 0); + } + + /** + Return this DateTime with the time portion coerced to '23:59:59.999999999', + and the day coerced to the end of the month. +

Requires year-month-day to be present; if not, a runtime exception is thrown. + */ + public DateTime getEndOfMonth() { + ensureHasYearMonthDay(); + return getStartEndDateTime(getNumDaysInMonth(), 23, 59, 59, 999999999); + } + + /** + Create a new DateTime by adding an interval to this one. + +

See {@link #plusDays(Integer)} as well. + +

Changes are always applied by this class in order of decreasing units of time: + years first, then months, and so on. After changing both the year and month, a check on the month-day combination is made before + any change is made to the day. If the day exceeds the number of days in the given month/year, then + (and only then) the given {@link DayOverflow} policy applied, and the day-of-the-month is adusted accordingly. + +

Afterwards, the day is then changed in the usual way, followed by the remaining items (hour, minute, second, and nanosecond). + +

The mental model for this method is very similar to that of a car's odometer. When a limit is reach for one unit of time, + then a rollover occurs for a neighbouring unit of time. + +

The returned value cannot come after 9999-12-13 23:59:59. + +

This class works with DateTime's having the following items present : +

    +
  • year-month-day and hour-minute-second (and optional nanoseconds) +
  • year-month-day only. In this case, if a calculation with a time part is performed, that time part + will be initialized by this class to 00:00:00.0, and the DateTime returned by this class will include a time part. +
  • hour-minute-second (and optional nanoseconds) only. In this case, the calculation is done starting with the + the arbitrary date 0001-01-01 (in order to remain within a valid state space of DateTime). +
+ + @param aNumYears positive, required, in range 0...9999 + @param aNumMonths positive, required, in range 0...9999 + @param aNumDays positive, required, in range 0...9999 + @param aNumHours positive, required, in range 0...9999 + @param aNumMinutes positive, required, in range 0...9999 + @param aNumSeconds positive, required, in range 0...9999 + @param aNumNanoseconds positive, required, in range 0...999999999 + */ + public DateTime plus(Integer aNumYears, Integer aNumMonths, Integer aNumDays, Integer aNumHours, Integer aNumMinutes, Integer aNumSeconds, Integer aNumNanoseconds, DayOverflow aDayOverflow) { + DateTimeInterval interval = new DateTimeInterval(this, aDayOverflow); + return interval.plus(aNumYears, aNumMonths, aNumDays, aNumHours, aNumMinutes, aNumSeconds, aNumNanoseconds); + } + + /** + Create a new DateTime by subtracting an interval to this one. + +

See {@link #minusDays(Integer)} as well. +

This method has nearly the same behavior as {@link #plus(Integer, Integer, Integer, Integer, Integer, Integer, Integer, DayOverflow)}, + except that the return value cannot come before 0001-01-01 00:00:00. + */ + public DateTime minus(Integer aNumYears, Integer aNumMonths, Integer aNumDays, Integer aNumHours, Integer aNumMinutes, Integer aNumSeconds, Integer aNumNanoseconds, DayOverflow aDayOverflow) { + DateTimeInterval interval = new DateTimeInterval(this, aDayOverflow); + return interval.minus(aNumYears, aNumMonths, aNumDays, aNumHours, aNumMinutes, aNumSeconds, aNumNanoseconds); + } + + /** + Return a new DateTime by adding an integral number of days to this one. + +

Requires year-month-day to be present; if not, a runtime exception is thrown. + @param aNumDays can be either sign; if negative, then the days are subtracted. + */ + public DateTime plusDays(Integer aNumDays) { + ensureHasYearMonthDay(); + int thisJDAtNoon = getModifiedJulianDayNumber() + 1 + EPOCH_MODIFIED_JD; + int resultJD = thisJDAtNoon + aNumDays; + DateTime datePortion = fromJulianDayNumberAtNoon(resultJD); + return new DateTime(datePortion.getYear(), datePortion.getMonth(), datePortion.getDay(), fHour, fMinute, fSecond, fNanosecond); + } + + /** + Return a new DateTime by subtracting an integral number of days from this one. + +

Requires year-month-day to be present; if not, a runtime exception is thrown. + @param aNumDays can be either sign; if negative, then the days are added. + */ + public DateTime minusDays(Integer aNumDays) { + return plusDays(-1 * aNumDays); + } + + /** + The whole number of days between this DateTime and the given parameter. +

Requires year-month-day to be present, both for this DateTime and for the aThat + parameter; if not, a runtime exception is thrown. + */ + public int numDaysFrom(DateTime aThat) { + return aThat.getModifiedJulianDayNumber() - this.getModifiedJulianDayNumber(); + } + + /** + The number of seconds between this DateTime and the given argument. +

If only time information is present in both this DateTime and aThat, then there are + no restrictions on the values of the time units. +

If any date information is present, in either this DateTime or aThat, + then full year-month-day must be present in both; if not, then the date portion will be ignored, and only the + time portion will contribute to the calculation. + */ + public long numSecondsFrom(DateTime aThat) { + long result = 0; + if(hasYearMonthDay() && aThat.hasYearMonthDay()){ + result = numDaysFrom(aThat) * 86400; //just the day portion + } + result = result - this.numSecondsInTimePortion() + aThat.numSecondsInTimePortion(); + return result; + } + + /** + Output this DateTime as a formatted String using numbers, with no localizable text. + +

Example: +

dt.format("YYYY-MM-DD hh:mm:ss");
+ would generate text of the form +
2009-09-09 18:23:59
+ +

If months, weekdays, or AM/PM indicators are output as localizable text, you must use {@link #format(String, Locale)}. + @param aFormat uses the formatting mini-language defined in the class comment. + */ + public String format(String aFormat) { + DateTimeFormatter format = new DateTimeFormatter(aFormat); + return format.format(this); + } + + /** + Output this DateTime as a formatted String using numbers and/or localizable text. + +

This method is intended for alphanumeric output, such as 'Sunday, November 14, 1858 10:00 AM'. +

If months and weekdays are output as numbers, you are encouraged to use {@link #format(String)} instead. + + @param aFormat uses the formatting mini-language defined in the class comment. + @param aLocale used to generate text for Month, Weekday and AM/PM indicator; required only by patterns which return localized + text, instead of numeric forms. + */ + public String format(String aFormat, Locale aLocale) { + DateTimeFormatter format = new DateTimeFormatter(aFormat, aLocale); + return format.format(this); + } + + /** + Output this DateTime as a formatted String using numbers and explicit text for months, weekdays, and AM/PM indicator. + +

Use of this method is likely relatively rare; it should be used only if the output of {@link #format(String, Locale)} is + inadequate. + + @param aFormat uses the formatting mini-language defined in the class comment. + @param aMonths contains text for all 12 months, starting with January; size must be 12. + @param aWeekdays contains text for all 7 weekdays, starting with Sunday; size must be 7. + @param aAmPmIndicators contains text for A.M and P.M. indicators (in that order); size must be 2. + */ + public String format(String aFormat, List aMonths, List aWeekdays, List aAmPmIndicators) { + DateTimeFormatter format = new DateTimeFormatter(aFormat, aMonths, aWeekdays, aAmPmIndicators); + return format.format(this); + } + + /** + Return the current date-time. +

Combines the configured implementation of {@link TimeSource} with the given {@link TimeZone}. + The TimeZone will typically come from your implementation of {@link TimeZoneSource}. + +

In an Action, the current date-time date can be referenced using +

DateTime.now(getTimeZone())
+ See {@link ActionImpl#getTimeZone()}. + +

Only millisecond precision is possible for this method. + */ + public static DateTime now(TimeZone aTimeZone) { + TimeSource timesource = BuildImpl.forTimeSource(); + return forInstant(timesource.currentTimeMillis(), aTimeZone); + } + + /** + Return the current date. +

As in {@link #now(TimeZone)}, but truncates the time portion, leaving only year-month-day. +

In an Action, today's date can be referenced using +

DateTime.today(getTimeZone())
+ See {@link ActionImpl#getTimeZone()}. + */ + public static DateTime today(TimeZone aTimeZone) { + DateTime result = now(aTimeZone); + return result.truncate(Unit.DAY); + } + + /** Return true only if this date is in the future, with respect to {@link #now(TimeZone)}. */ + public boolean isInTheFuture(TimeZone aTimeZone) { + return now(aTimeZone).lt(this); + } + + /** Return true only if this date is in the past, with respect to {@link #now(TimeZone)}. */ + public boolean isInThePast(TimeZone aTimeZone) { + return now(aTimeZone).gt(this); + } + + /** + Return a DateTime corresponding to a change from one {@link TimeZone} to another. + +

A DateTime object has an implicit and immutable time zone. + If you need to change the implicit time zone, you can use this method to do so. + +

Example : +

+TimeZone fromUK = TimeZone.getTimeZone("Europe/London");
+TimeZone toIndonesia = TimeZone.getTimeZone("Asia/Jakarta");
+DateTime newDt = oldDt.changeTimeZone(fromUK, toIndonesia);
+    
+ +

Requires year-month-day-hour to be present; if not, a runtime exception is thrown. + @param aFromTimeZone the implicit time zone of this object. + @param aToTimeZone the implicit time zone of the DateTime returned by this method. + @return aDateTime corresponding to the change of time zone implied by the 2 parameters. + */ + public DateTime changeTimeZone(TimeZone aFromTimeZone, TimeZone aToTimeZone){ + DateTime result = null; + ensureHasYearMonthDay(); + if (unitsAllAbsent(Unit.HOUR)){ + throw new IllegalArgumentException("DateTime does not include the hour. Cannot change the time zone if no hour is present."); + } + Calendar fromDate = new GregorianCalendar(aFromTimeZone); + fromDate.set(Calendar.YEAR, getYear()); + fromDate.set(Calendar.MONTH, getMonth()-1); + fromDate.set(Calendar.DAY_OF_MONTH, getDay()); + fromDate.set(Calendar.HOUR_OF_DAY, getHour()); + if(getMinute() != null) { + fromDate.set(Calendar.MINUTE, getMinute()); + } + else { + fromDate.set(Calendar.MINUTE, 0); + } + //other items zeroed out here, since they don't matter for time zone calculations + fromDate.set(Calendar.SECOND, 0); + fromDate.set(Calendar.MILLISECOND, 0); + + //millisecond precision is OK here, since the seconds/nanoseconds are not part of the calc + Calendar toDate = new GregorianCalendar(aToTimeZone); + toDate.setTimeInMillis(fromDate.getTimeInMillis()); + //needed if this date has hour, but no minute (bit of an oddball case) : + Integer minute = getMinute() != null ? toDate.get(Calendar.MINUTE) : null; + result = new DateTime( + toDate.get(Calendar.YEAR), toDate.get(Calendar.MONTH) + 1, toDate.get(Calendar.DAY_OF_MONTH), + toDate.get(Calendar.HOUR_OF_DAY), minute, getSecond(), getNanoseconds() + ); + return result; + } + + /** + Compare this object to another, for ordering purposes. +

Uses the 7 date-time elements (year..nanosecond). The Year is considered the most + significant item, and the Nanosecond the least significant item. Null items are placed first in this comparison. + */ + public int compareTo(DateTime aThat) { + if (this == aThat) return EQUAL; + ensureParsed(); + aThat.ensureParsed(); + + NullsGo nullsGo = NullsGo.FIRST; + int comparison = ModelUtil.comparePossiblyNull(this.fYear, aThat.fYear, nullsGo); + if (comparison != EQUAL) return comparison; + + comparison = ModelUtil.comparePossiblyNull(this.fMonth, aThat.fMonth, nullsGo); + if (comparison != EQUAL) return comparison; + + comparison = ModelUtil.comparePossiblyNull(this.fDay, aThat.fDay, nullsGo); + if (comparison != EQUAL) return comparison; + + comparison = ModelUtil.comparePossiblyNull(this.fHour, aThat.fHour, nullsGo); + if (comparison != EQUAL) return comparison; + + comparison = ModelUtil.comparePossiblyNull(this.fMinute, aThat.fMinute, nullsGo); + if (comparison != EQUAL) return comparison; + + comparison = ModelUtil.comparePossiblyNull(this.fSecond, aThat.fSecond, nullsGo); + if (comparison != EQUAL) return comparison; + + comparison = ModelUtil.comparePossiblyNull(this.fNanosecond, aThat.fNanosecond, nullsGo); + if (comparison != EQUAL) return comparison; + + return EQUAL; + } + + /** + Equals method for this object. + +

Equality is determined by the 7 date-time elements (year..nanosecond). + */ + @Override public boolean equals(Object aThat) { + /* + * Implementation note: it was considered branching this method, according to whether + * the objects are already parsed. That was rejected, since maintaining 'synchronicity' + * with hashCode would not then be possible, since hashCode is based only on one object, + * not two. + */ + ensureParsed(); + Boolean result = ModelUtil.quickEquals(this, aThat); + if (result == null) { + DateTime that = (DateTime)aThat; + that.ensureParsed(); + result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields()); + } + return result; + } + + /** + Hash code for this object. + +

Uses the same 7 date-time elements (year..nanosecond) as used by + {@link #equals(Object)}. + */ + @Override public int hashCode() { + if (fHashCode == 0) { + ensureParsed(); + fHashCode = ModelUtil.hashCodeFor(getSignificantFields()); + } + return fHashCode; + } + + /** + Intended for debugging and logging only. + +

To format this DateTime for presentation to the user, see the various format methods. + +

If the {@link #DateTime(String)} constructor was called, then return that String. + +

Otherwise, the return value is constructed from each date-time element, in a fixed format, depending + on which time units are present. Example values : +

    +
  • 2011-04-30 13:59:59.123456789 +
  • 2011-04-30 13:59:59 +
  • 2011-04-30 +
  • 2011-04-30 13:59 +
  • 13:59:59.123456789 +
  • 13:59:59 +
  • and so on... +
+ +

In the great majority of cases, this will give reasonable output for debugging and logging statements. + +

In cases where a bizarre combinations of time units is present, the return value is presented in a verbose form. + For example, if all time units are present except for minutes, the return value has this form: +

Y:2001 M:1 D:31 h:13 m:null s:59 f:123456789
+ */ + @Override public String toString() { + String result = ""; + if (Util.textHasContent(fDateTime)) { + result = fDateTime; + } + else { + String format = calcToStringFormat(); + if(format != null){ + result = format(calcToStringFormat()); + } + else { + StringBuilder builder = new StringBuilder(); + addToString("Y", fYear, builder); + addToString("M", fMonth, builder); + addToString("D", fDay, builder); + addToString("h", fHour, builder); + addToString("m", fMinute, builder); + addToString("s", fSecond, builder); + addToString("f", fNanosecond, builder); + result = builder.toString().trim(); + } + } + return result; + } + + // PACKAGE-PRIVATE (for unit testing, mostly) + + static final class ItemOutOfRange extends RuntimeException { + ItemOutOfRange(String aMessage) { + super(aMessage); + } + } + + static final class MissingItem extends RuntimeException { + MissingItem(String aMessage) { + super(aMessage); + } + } + + /** Intended as internal tool, for testing only. Not scope is not public! */ + void ensureParsed() { + if (!fIsAlreadyParsed) { + parseDateTimeText(); + } + } + + /** + Return the number of days in the given month. The returned value depends on the year as + well, because of leap years. Returns null if either year or month are + absent. WRONG - should be public?? + Package-private, needed for interval calcs. + */ + static Integer getNumDaysInMonth(Integer aYear, Integer aMonth) { + Integer result = null; + if (aYear != null && aMonth != null) { + if (aMonth == 1) { + result = 31; + } + else if (aMonth == 2) { + result = isLeapYear(aYear) ? 29 : 28; + } + else if (aMonth == 3) { + result = 31; + } + else if (aMonth == 4) { + result = 30; + } + else if (aMonth == 5) { + result = 31; + } + else if (aMonth == 6) { + result = 30; + } + else if (aMonth == 7) { + result = 31; + } + else if (aMonth == 8) { + result = 31; + } + else if (aMonth == 9) { + result = 30; + } + else if (aMonth == 10) { + result = 31; + } + else if (aMonth == 11) { + result = 30; + } + else if (aMonth == 12) { + result = 31; + } + else { + throw new AssertionError("Month is out of range 1..12:" + aMonth); + } + } + return result; + } + + static DateTime fromJulianDayNumberAtNoon(int aJDAtNoon) { + //http://www.hermetic.ch/cal_stud/jdn.htm + int l = aJDAtNoon + 68569; + int n = (4 * l) / 146097; + l = l - (146097 * n + 3) / 4; + int i = (4000 * (l + 1)) / 1461001; + l = l - (1461 * i) / 4 + 31; + int j = (80 * l) / 2447; + int d = l - (2447 * j) / 80; + l = j / 11; + int m = j + 2 - (12 * l); + int y = 100 * (n - 49) + i + l; + return DateTime.forDateOnly(y, m, d); + } + + // PRIVATE + + /* + There are 2 representations of a date - a text form, and a 'parsed' form, in which all + of the elements of the date are separated out. A DateTime starts out with one of these + forms, and may need to generate the other. + */ + + /** The text form of a date. @serial */ + private String fDateTime; + + /* The following 7 items represent the parsed form of a DateTime. */ + /** @serial */ + private Integer fYear; + /** @serial */ + private Integer fMonth; + /** @serial */ + private Integer fDay; + /** @serial */ + private Integer fHour; + /** @serial */ + private Integer fMinute; + /** @serial */ + private Integer fSecond; + /** @serial */ + private Integer fNanosecond; + + /** Indicates if this DateTime has been parsed into its 7 constituents. @serial */ + private boolean fIsAlreadyParsed; + + /** @serial */ + private int fHashCode; + + private static final int EQUAL = 0; + + private static int EPOCH_MODIFIED_JD = 2400000; + + private static final int MILLION = 1000000; + + private static final long serialVersionUID = -1300068157085493891L; + + /** + Return a the whole number, with no fraction. + The JD at noon is 1 more than the JD at midnight. + */ + private int calculateJulianDayNumberAtNoon() { + //http://www.hermetic.ch/cal_stud/jdn.htm + int y = fYear; + int m = fMonth; + int d = fDay; + int result = (1461 * (y + 4800 + (m - 14) / 12)) / 4 + (367 * (m - 2 - 12 * ((m - 14) / 12))) / 12 - (3 * ((y + 4900 + (m - 14) / 12) / 100)) / 4 + d - 32075; + return result; + } + + private void ensureHasYearMonthDay() { + ensureParsed(); + if (!hasYearMonthDay()) { + throw new MissingItem("DateTime does not include year/month/day."); + } + } + + /** Return the number of seconds in any existing time portion of the date. */ + private int numSecondsInTimePortion() { + int result = 0; + if (fSecond != null) { + result = result + fSecond; + } + if (fMinute != null) { + result = result + 60 * fMinute; + } + if (fHour != null) { + result = result + 3600 * fHour; + } + return result; + } + + private void validateState() { + checkRange(fYear, 1, 9999, "Year"); + checkRange(fMonth, 1, 12, "Month"); + checkRange(fDay, 1, 31, "Day"); + checkRange(fHour, 0, 23, "Hour"); + checkRange(fMinute, 0, 59, "Minute"); + checkRange(fSecond, 0, 59, "Second"); + checkRange(fNanosecond, 0, 999999999, "Nanosecond"); + checkNumDaysInMonth(fYear, fMonth, fDay); + } + + private void checkRange(Integer aValue, int aMin, int aMax, String aName) { + if (!Check.optional(aValue, Check.range(aMin, aMax))) { + throw new ItemOutOfRange(aName + " is not in the range " + aMin + ".." + aMax + ". Value is:" + aValue); + } + } + + private void checkNumDaysInMonth(Integer aYear, Integer aMonth, Integer aDay) { + if (hasYearMonthDay(aYear, aMonth, aDay) && aDay > getNumDaysInMonth(aYear, aMonth)) { + throw new ItemOutOfRange("The day-of-the-month value '" + aDay + "' exceeds the number of days in the month: " + getNumDaysInMonth(aYear, aMonth)); + } + } + + private void parseDateTimeText() { + DateTimeParser parser = new DateTimeParser(); + DateTime dateTime = parser.parse(fDateTime); + /* + * This is unusual - we essentially copy from one object to another. This could be + * avoided by building another interface, But defining a top-level interface for this + * simple task is too high a price. + */ + fYear = dateTime.fYear; + fMonth = dateTime.fMonth; + fDay = dateTime.fDay; + fHour = dateTime.fHour; + fMinute = dateTime.fMinute; + fSecond = dateTime.fSecond; + fNanosecond = dateTime.fNanosecond; + validateState(); + } + + private boolean hasYearMonthDay(Integer aYear, Integer aMonth, Integer aDay) { + return isPresent(aYear, aMonth, aDay); + } + + private static boolean isLeapYear(Integer aYear) { + boolean result = false; + if (aYear % 100 == 0) { + // this is a century year + if (aYear % 400 == 0) { + result = true; + } + } + else if (aYear % 4 == 0) { + result = true; + } + return result; + } + + private Object[] getSignificantFields() { + return new Object[]{fYear, fMonth, fDay, fHour, fMinute, fSecond, fNanosecond}; + } + + private void addToString(String aName, Object aValue, StringBuilder aBuilder) { + aBuilder.append(aName + ":" + String.valueOf(aValue) + " "); + } + + /** Return true only if all the given arguments are non-null. */ + private boolean isPresent(Object... aItems) { + boolean result = true; + for (Object item : aItems) { + if (item == null) { + result = false; + break; + } + } + return result; + } + + private DateTime getStartEndDateTime(Integer aDay, Integer aHour, Integer aMinute, Integer aSecond, Integer aNanosecond) { + ensureHasYearMonthDay(); + return new DateTime(fYear, fMonth, aDay, aHour, aMinute, aSecond, aNanosecond); + } + + private String calcToStringFormat(){ + String result = null; //caller will check for this; null means the set of units is bizarre + if(unitsAllPresent(Unit.YEAR) && unitsAllAbsent(Unit.MONTH, Unit.DAY, Unit.HOUR, Unit.MINUTE, Unit.SECOND, Unit.NANOSECONDS)){ + result = "YYYY"; + } + else if (unitsAllPresent(Unit.YEAR, Unit.MONTH) && unitsAllAbsent(Unit.DAY, Unit.HOUR, Unit.MINUTE, Unit.SECOND, Unit.NANOSECONDS)){ + result = "YYYY-MM"; + } + else if (unitsAllPresent(Unit.YEAR, Unit.MONTH, Unit.DAY) && unitsAllAbsent(Unit.HOUR, Unit.MINUTE, Unit.SECOND, Unit.NANOSECONDS)){ + result = "YYYY-MM-DD"; + } + else if (unitsAllPresent(Unit.YEAR, Unit.MONTH, Unit.DAY, Unit.HOUR) && unitsAllAbsent(Unit.MINUTE, Unit.SECOND, Unit.NANOSECONDS)){ + result = "YYYY-MM-DD hh"; + } + else if (unitsAllPresent(Unit.YEAR, Unit.MONTH, Unit.DAY, Unit.HOUR, Unit.MINUTE) && unitsAllAbsent(Unit.SECOND, Unit.NANOSECONDS)){ + result = "YYYY-MM-DD hh:mm"; + } + else if (unitsAllPresent(Unit.YEAR, Unit.MONTH, Unit.DAY, Unit.HOUR, Unit.MINUTE, Unit.SECOND) && unitsAllAbsent(Unit.NANOSECONDS)){ + result = "YYYY-MM-DD hh:mm:ss"; + } + else if (unitsAllPresent(Unit.YEAR, Unit.MONTH, Unit.DAY, Unit.HOUR, Unit.MINUTE, Unit.SECOND, Unit.NANOSECONDS)){ + result = "YYYY-MM-DD hh:mm:ss.fffffffff"; + } + else if (unitsAllAbsent(Unit.YEAR, Unit.MONTH, Unit.DAY) && unitsAllPresent(Unit.HOUR, Unit.MINUTE, Unit.SECOND, Unit.NANOSECONDS)){ + result = "hh:mm:ss.fffffffff"; + } + else if (unitsAllAbsent(Unit.YEAR, Unit.MONTH, Unit.DAY, Unit.NANOSECONDS) && unitsAllPresent(Unit.HOUR, Unit.MINUTE, Unit.SECOND)){ + result = "hh:mm:ss"; + } + else if (unitsAllAbsent(Unit.YEAR, Unit.MONTH, Unit.DAY, Unit.SECOND, Unit.NANOSECONDS) && unitsAllPresent(Unit.HOUR, Unit.MINUTE)){ + result = "hh:mm"; + } + return result; + } + + /** + Always treat de-serialization as a full-blown constructor, by + validating the final state of the de-serialized object. + */ + private void readObject(ObjectInputStream aInputStream) throws ClassNotFoundException, IOException { + //always perform the default de-serialization first + aInputStream.defaultReadObject(); + //no mutable fields in this case + validateState(); + } + + /** + This is the default implementation of writeObject. + Customise if necessary. + */ + private void writeObject(ObjectOutputStream aOutputStream) throws IOException { + //perform the default serialization for all non-transient, non-static fields + aOutputStream.defaultWriteObject(); + } + +} \ No newline at end of file diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/DateTimeFormatter.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/DateTimeFormatter.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,611 @@ +package hirondelle.web4j.model; + +import hirondelle.web4j.util.Util; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collection; +import java.util.GregorianCalendar; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + Formats a {@link DateTime}, and implements {@link DateTime#format(String)}. + +

This class defines a mini-language for defining how a {@link DateTime} is formatted. + See {@link DateTime#format(String)} for details regarding the formatting mini-language. + +

The DateFormatSymbols class might be used to grab the locale-specific text, but the arrays it + returns are wonky and weird, so I have avoided it. +*/ +final class DateTimeFormatter { + + /** + Constructor used for patterns that represent date-time elements using only numbers, and no localizable text. + @param aFormat uses the syntax described by {@link DateTime#format(String)}. + */ + DateTimeFormatter(String aFormat){ + fFormat = aFormat; + fLocale = null; + fCustomLocalization = null; + validateState(); + } + + /** + Constructor used for patterns that represent date-time elements using not only numbers, but text as well. + The text needs to be localizable. + @param aFormat uses the syntax described by {@link DateTime#format(String)}. + @param aLocale used to generate text for Month, Weekday, and AM-PM indicator; required only by patterns which return localized + text, instead of numeric forms for date-time elements. + */ + DateTimeFormatter(String aFormat, Locale aLocale){ + fFormat = aFormat; + fLocale = aLocale; + fCustomLocalization = null; + validateState(); + } + + /** + Constructor used for patterns that represent using not only numbers, but customized text as well. + +

This constructor exists mostly since SimpleDateFormat doesn't support all locales, and it has a + policy of N letters for text, where N != 3. + + @param aFormat must match the syntax described by {@link DateTime#format(String)}. + @param aMonths contains text for all 12 months, starting with January; size must be 12. + @param aWeekdays contains text for all 7 weekdays, starting with Sunday; size must be 7. + @param aAmPmIndicators contains text for A.M and P.M. indicators (in that order); size must be 2. + */ + DateTimeFormatter(String aFormat, List aMonths, List aWeekdays, List aAmPmIndicators){ + fFormat = aFormat; + fLocale = null; + fCustomLocalization = new CustomLocalization(aMonths, aWeekdays, aAmPmIndicators); + validateState(); + } + + /** Format a {@link DateTime}. */ + String format(DateTime aDateTime){ + fEscapedRanges = new ArrayList(); + fInterpretedRanges = new ArrayList(); + findEscapedRanges(); + interpretInput(aDateTime); + return produceFinalOutput(); + } + + // PRIVATE + private final String fFormat; + private final Locale fLocale; + private Collection fInterpretedRanges; + private Collection fEscapedRanges; + + /** + Table mapping a Locale to the names of the months. + Initially empty, populated only when a specific Locale is needed for presenting such text. + Used for MMMM and MMM tokens. + */ + private final Map> fMonths = new LinkedHashMap>(); + + /** + Table mapping a Locale to the names of the weekdays. + Initially empty, populated only when a specific Locale is needed for presenting such text. + Used for WWWW and WWW tokens. + */ + private final Map> fWeekdays = new LinkedHashMap>(); + + /** + Table mapping a Locale to the text used to indicate a.m. and p.m. + Initially empty, populated only when a specific Locale is needed for presenting such text. + Used for the 'a' token. + */ + private final Map> fAmPm = new LinkedHashMap>(); + + private final CustomLocalization fCustomLocalization; + + private final class CustomLocalization{ + CustomLocalization(List aMonths, List aWeekdays, List aAmPm){ + if(aMonths.size() != 12){ + throw new IllegalArgumentException("Your List of custom months must have size 12, but its size is " + aMonths.size()); + } + if(aWeekdays.size() != 7){ + throw new IllegalArgumentException("Your List of custom weekdays must have size 7, but its size is " + aWeekdays.size()); + } + if(aAmPm.size() != 2){ + throw new IllegalArgumentException("Your List of custom a.m./p.m. indicators must have size 2, but its size is " + aAmPm.size()); + } + Months = aMonths; + Weekdays = aWeekdays; + AmPmIndicators = aAmPm; + } + List Months; + List Weekdays; + List AmPmIndicators; + } + + /** A section of fFormat containing a token that must be interpreted. */ + private static final class InterpretedRange { + int Start; + int End; + String Text; + @Override public String toString(){ return "Start:" + Start + " End:" + End + " '" + Text + "'";}; + } + + /** A section of fFormat bounded by a pair of escape characters; such ranges contain uninterpreted text. */ + private static final class EscapedRange { + int Start; + int End; + } + + /** Special character used to escape the interpretation of parts of fFormat. */ + private static final String ESCAPE_CHAR = "|"; + private static final Pattern ESCAPED_RANGE = Pattern.compile("\\|[^\\|]*\\|"); + + /* Here, 'token' means an item in the mini-language, having special meaning (defined below). */ + + //all date-related tokens are in upper case + private static final String YYYY = "YYYY"; + private static final String YY = "YY"; + private static final String M = "M"; + private static final String MM = "MM"; + private static final String MMM = "MMM"; + private static final String MMMM = "MMMM"; + private static final String D = "D"; + private static final String DD = "DD"; + private static final String WWW = "WWW"; + private static final String WWWW = "WWWW"; + + //all time-related tokens are in lower case + private static final String hh = "hh"; + private static final String h = "h"; + private static final String m = "m"; + private static final String mm = "mm"; + private static final String s = "s"; + private static final String ss = "ss"; + + /** + The 12-hour clock style. + + 12:00 am is midnight, 12:30am is 30 minutes past midnight, 12:00 pm is 12 noon. + This item is almost always used with 'a' to indicate am/pm. + */ + private static final String h12 = "h12"; + + /** As {@link #h12}, but with leading zero. */ + private static final String hh12 = "hh12"; + + private static final int AM = 0; //a.m. comes first in lists used by this class + private static final int PM = 1; + + /** + A.M./P.M. text is sensitive to Locale, in the same way that names of months and weekdays are + sensitive to Locale. + */ + private static final String a = "a"; + + private static final Pattern FRACTIONALS = Pattern.compile("f{1,9}"); + + private static final String EMPTY_STRING = ""; + + /** + The order of these items is significant, and is critical for how fFormat is interpreted. + The 'longer' tokens must come first, in any group of related tokens. + */ + private static final List TOKENS = new ArrayList(); + static { + TOKENS.add(YYYY); + TOKENS.add(YY); + TOKENS.add(MMMM); + TOKENS.add(MMM); + TOKENS.add(MM); + TOKENS.add(M); + TOKENS.add(DD); + TOKENS.add(D); + TOKENS.add(WWWW); + TOKENS.add(WWW); + TOKENS.add(hh12); + TOKENS.add(h12); + TOKENS.add(hh); + TOKENS.add(h); + TOKENS.add(mm); + TOKENS.add(m); + TOKENS.add(ss); + TOKENS.add(s); + TOKENS.add(a); + //should these be constants too? + TOKENS.add("fffffffff"); + TOKENS.add("ffffffff"); + TOKENS.add("fffffff"); + TOKENS.add("ffffff"); + TOKENS.add("fffff"); + TOKENS.add("ffff"); + TOKENS.add("fff"); + TOKENS.add("ff"); + TOKENS.add("f"); + } + + /** Escaped ranges are bounded by a PAIR of {@link #ESCAPE_CHAR} characters. */ + private void findEscapedRanges(){ + Matcher matcher = ESCAPED_RANGE.matcher(fFormat); + while (matcher.find()){ + EscapedRange escapedRange = new EscapedRange(); + escapedRange.Start = matcher.start(); //first pipe + escapedRange.End = matcher.end() - 1; //second pipe + fEscapedRanges.add(escapedRange); + } + } + + /** Return true only if the start of the interpreted range is in an escaped range. */ + private boolean isInEscapedRange(InterpretedRange aInterpretedRange){ + boolean result = false; //innocent till shown guilty + for(EscapedRange escapedRange : fEscapedRanges){ + //checking only the start is sufficient, because the tokens never contain the escape char + if(escapedRange.Start <= aInterpretedRange.Start && aInterpretedRange.Start <= escapedRange.End ){ + result = true; + break; + } + } + return result; + } + + /** + Scan fFormat for all tokens, in a specific order, and interpret them with the given DateTime. + The interpreted tokens are saved for output later. + */ + private void interpretInput(DateTime aDateTime){ + String format = fFormat; + for(String token : TOKENS){ + Pattern pattern = Pattern.compile(token); + Matcher matcher = pattern.matcher(format); + while(matcher.find()){ + InterpretedRange interpretedRange = new InterpretedRange(); + interpretedRange.Start = matcher.start(); + interpretedRange.End = matcher.end() - 1; + if(! isInEscapedRange(interpretedRange)){ + interpretedRange.Text = interpretThe(matcher.group(), aDateTime); + fInterpretedRanges.add(interpretedRange); + } + } + format = format.replace(token, withCharDenotingAlreadyInterpreted(token)); + } + } + + /** + Return a temp placeholder string used to identify sections of fFormat that have already been interpreted. + The returned string is a list of "@" characters, whose length is the same as aToken. + */ + private String withCharDenotingAlreadyInterpreted(String aToken){ + StringBuilder result = new StringBuilder(); + for(int idx = 1; idx <= aToken.length(); ++idx){ + //any character that isn't interpreted, or a special regex char, will do here + //the fact that it's interpreted at location x is stored elsewhere; + //this is meant only to prevent multiple interpretations of the same text + result.append("@"); + } + return result.toString(); + } + + /** Render the final output returned to the caller. */ + private String produceFinalOutput(){ + StringBuilder result = new StringBuilder(); + int idx = 0; + while ( idx < fFormat.length() ) { + String letter = nextLetter(idx); + InterpretedRange interpretation = getInterpretation(idx); + if (interpretation != null){ + result.append(interpretation.Text); + idx = interpretation.End; + } + else { + if(!ESCAPE_CHAR.equals(letter)){ + result.append(letter); + } + } + ++idx; + } + return result.toString(); + } + + private InterpretedRange getInterpretation(int aIdx){ + InterpretedRange result = null; + for(InterpretedRange interpretedRange : fInterpretedRanges){ + if(interpretedRange.Start == aIdx ){ + result = interpretedRange; + } + } + return result; + } + + private String nextLetter(int aIdx){ + return fFormat.substring(aIdx, aIdx+1); + } + + private String interpretThe(String aCurrentToken, DateTime aDateTime){ + String result = EMPTY_STRING; + if(YYYY.equals(aCurrentToken)) { + result = valueStr(aDateTime.getYear()); + } + else if (YY.equals(aCurrentToken)){ + result = noCentury(valueStr(aDateTime.getYear())); + } + else if (MMMM.equals(aCurrentToken)){ + int month = aDateTime.getMonth(); + result = fullMonth(month); + } + else if (MMM.equals(aCurrentToken)){ + int month = aDateTime.getMonth(); + result = firstThreeChars(fullMonth(month)); + } + else if (MM.equals(aCurrentToken)){ + result = addLeadingZero(valueStr(aDateTime.getMonth())); + } + else if (M.equals(aCurrentToken)){ + result = valueStr(aDateTime.getMonth()); + } + else if(DD.equals(aCurrentToken)){ + result = addLeadingZero(valueStr(aDateTime.getDay())); + } + else if(D.equals(aCurrentToken)){ + result = valueStr(aDateTime.getDay()); + } + else if(WWWW.equals(aCurrentToken)){ + int weekday = aDateTime.getWeekDay(); + result = fullWeekday(weekday); + } + else if(WWW.equals(aCurrentToken)){ + int weekday = aDateTime.getWeekDay(); + result = firstThreeChars(fullWeekday(weekday)); + } + else if(hh.equals(aCurrentToken)){ + result = addLeadingZero(valueStr(aDateTime.getHour())); + } + else if(h.equals(aCurrentToken)){ + result = valueStr(aDateTime.getHour()); + } + else if (h12.equals(aCurrentToken)){ + result = valueStr(twelveHourStyle(aDateTime.getHour())); + } + else if (hh12.equals(aCurrentToken)){ + result = addLeadingZero(valueStr(twelveHourStyle(aDateTime.getHour()))); + } + else if (a.equals(aCurrentToken)){ + int hour = aDateTime.getHour(); + result = amPmIndicator(hour); + } + else if(mm.equals(aCurrentToken)){ + result = addLeadingZero(valueStr(aDateTime.getMinute())); + } + else if(m.equals(aCurrentToken)){ + result = valueStr(aDateTime.getMinute()); + } + else if(ss.equals(aCurrentToken)){ + result = addLeadingZero(valueStr(aDateTime.getSecond())); + } + else if(s.equals(aCurrentToken)){ + result = valueStr(aDateTime.getSecond()); + } + else if(aCurrentToken.startsWith("f")){ + Matcher matcher = FRACTIONALS.matcher(aCurrentToken); + if ( matcher.matches() ) { + String nanos = nanosWithLeadingZeroes(aDateTime.getNanoseconds()); + int numDecimalsToShow = aCurrentToken.length(); + result = firstNChars(nanos, numDecimalsToShow); + } + else { + throw new IllegalArgumentException("Unknown token in date formatting pattern: " + aCurrentToken); + } + } + else { + throw new IllegalArgumentException("Unknown token in date formatting pattern: " + aCurrentToken); + } + + return result; + } + + private String valueStr(Object aItem){ + String result = EMPTY_STRING; + if(aItem != null){ + result = String.valueOf(aItem); + } + return result; + } + + private String noCentury(String aItem){ + String result = EMPTY_STRING; + if(Util.textHasContent(aItem)){ + result = aItem.substring(2); + } + return result; + } + + private String nanosWithLeadingZeroes(Integer aNanos){ + String result = valueStr(aNanos); + while(result.length() < 9){ + result = "0" + result; + } + return result; + } + + + /** Pad 0..9 with a leading zero. */ + private String addLeadingZero(String aTimePart){ + String result = aTimePart; + if(Util.textHasContent(aTimePart) && aTimePart.length() ==1){ + result = "0" + result; + } + return result; + } + + private String firstThreeChars(String aText){ + String result = aText; + if(Util.textHasContent(aText) && aText.length()>=3){ + result = aText.substring(0,3); + } + return result; + } + + private String fullMonth(Integer aMonth){ + String result = ""; + if(aMonth != null){ + if(fCustomLocalization != null){ + result = lookupCustomMonthFor(aMonth); + } + else if (fLocale != null){ + result = lookupMonthFor(aMonth); + } + else { + throw new IllegalArgumentException("Your date pattern requires either a Locale, or your own custom localizations for text:" + Util.quote(fFormat)) ; + } + } + return result; + } + + private String lookupCustomMonthFor(Integer aMonth){ + return fCustomLocalization.Months.get(aMonth-1); + } + + private String lookupMonthFor(Integer aMonth){ + String result = EMPTY_STRING; + if (! fMonths.containsKey(fLocale) ){ + List months = new ArrayList(); + SimpleDateFormat format = new SimpleDateFormat("MMMM", fLocale); + for(int idx = Calendar.JANUARY; idx <= Calendar.DECEMBER; ++idx){ + Calendar firstDayOfMonth = new GregorianCalendar(); + firstDayOfMonth.set(Calendar.YEAR, 2000); + firstDayOfMonth.set(Calendar.MONTH, idx); + firstDayOfMonth.set(Calendar.DAY_OF_MONTH, 15); + String monthText = format.format(firstDayOfMonth.getTime()); + months.add(monthText); + } + fMonths.put(fLocale, months); + } + result = fMonths.get(fLocale).get(aMonth-1); //list is 0-based + return result; + } + + private String fullWeekday(Integer aWeekday){ + String result = ""; + if(aWeekday != null){ + if (fCustomLocalization != null){ + result = lookupCustomWeekdayFor(aWeekday); + } + else if(fLocale != null ){ + result = lookupWeekdayFor(aWeekday); + } + else { + throw new IllegalArgumentException("Your date pattern requires either a Locale, or your own custom localizations for text:" + Util.quote(fFormat)) ; + } + } + return result; + } + + private String lookupCustomWeekdayFor(Integer aWeekday){ + return fCustomLocalization.Weekdays.get(aWeekday-1); + } + + private String lookupWeekdayFor(Integer aWeekday){ + String result = EMPTY_STRING; + if (! fWeekdays.containsKey(fLocale) ){ + List weekdays = new ArrayList(); + SimpleDateFormat format = new SimpleDateFormat("EEEE", fLocale); + //Feb 8, 2009..Feb 14, 2009 runs Sun..Sat + for(int idx = 8; idx <= 14; ++idx){ + Calendar firstDayOfWeek = new GregorianCalendar(); + firstDayOfWeek.set(Calendar.YEAR, 2009); + firstDayOfWeek.set(Calendar.MONTH, 1); //month is 0-based + firstDayOfWeek.set(Calendar.DAY_OF_MONTH, idx); + String weekdayText = format.format(firstDayOfWeek.getTime()); + weekdays.add(weekdayText); + } + fWeekdays.put(fLocale, weekdays); + } + result = fWeekdays.get(fLocale).get(aWeekday-1); //list is 0-based + return result; + } + + private String firstNChars(String aText, int aN){ + String result = aText; + if(Util.textHasContent(aText) && aText.length()>=aN){ + result = aText.substring(0,aN); + } + return result; + } + + /** Coerce the hour to match the number used in the 12-hour style. */ + private Integer twelveHourStyle(Integer aHour){ + Integer result = aHour; + if(aHour != null){ + if (aHour == 0) { + result = 12; //eg 12:30 am + } + else if (aHour > 12){ + result = aHour - 12; //eg 14:00 -> 2:00 + } + } + return result; + } + + private String amPmIndicator(Integer aHour){ + String result = ""; + if(aHour != null){ + if(fCustomLocalization != null){ + result = lookupCustomAmPmFor(aHour); + } + else if (fLocale != null) { + result = lookupAmPmFor(aHour); + } + else { + throw new IllegalArgumentException( + "Your date pattern requires either a Locale, or your own custom localizations for text:" + Util.quote(fFormat) + ) ; + } + } + return result; + } + + private String lookupCustomAmPmFor(Integer aHour){ + String result = EMPTY_STRING; + if(aHour < 12 ){ + result = fCustomLocalization.AmPmIndicators.get(AM); + } + else { + result = fCustomLocalization.AmPmIndicators.get(PM); + } + return result; + } + + private String lookupAmPmFor(Integer aHour){ + String result = EMPTY_STRING; + if (! fAmPm.containsKey(fLocale) ){ + List indicators = new ArrayList(); + indicators.add(getAmPmTextFor(6)); + indicators.add(getAmPmTextFor(18)); + fAmPm.put(fLocale, indicators); + } + if (aHour < 12 ){ + result = fAmPm.get(fLocale).get(AM); + } + else { + result = fAmPm.get(fLocale).get(PM); + } + return result; + } + + private String getAmPmTextFor(Integer aHour){ + SimpleDateFormat format = new SimpleDateFormat("a", fLocale); + Calendar someDay = new GregorianCalendar(); + someDay.set(Calendar.YEAR, 2000); + someDay.set(Calendar.MONTH, 6); + someDay.set(Calendar.DAY_OF_MONTH, 15); + someDay.set(Calendar.HOUR_OF_DAY, aHour); + return format.format(someDay.getTime()); + } + + private void validateState(){ + if(! Util.textHasContent(fFormat)){ + throw new IllegalArgumentException("DateTime format has no content."); + } + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/DateTimeInterval.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/DateTimeInterval.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,335 @@ +package hirondelle.web4j.model; + +import static hirondelle.web4j.model.DateTime.DayOverflow; +import static hirondelle.web4j.model.DateTime.Unit; + +/** + Helper class for adding intervals of time. + The mental model of this class is similar to that of a car's odometer, except + in reverse. + */ +final class DateTimeInterval { + + /** Constructor. */ + DateTimeInterval(DateTime aFrom, DayOverflow aMonthOverflow){ + fFrom = aFrom; + checkUnits(); + fYear = fFrom.getYear() == null ? 1 : fFrom.getYear(); + fMonth = fFrom.getMonth() == null ? 1 : fFrom.getMonth(); + fDay = fFrom.getDay() == null ? 1 : fFrom.getDay(); + fHour = fFrom.getHour() == null ? 0 : fFrom.getHour(); + fMinute = fFrom.getMinute() == null ? 0 : fFrom.getMinute(); + fSecond = fFrom.getSecond() == null ? 0 : fFrom.getSecond(); + fNanosecond = fFrom.getNanoseconds() == null ? 0 : fFrom.getNanoseconds(); + fDayOverflow = aMonthOverflow; + } + + DateTime plus(int aYear, int aMonth, int aDay, int aHour, int aMinute, int aSecond, int aNanosecond){ + return plusOrMinus(PLUS, aYear, aMonth, aDay, aHour, aMinute, aSecond, aNanosecond); + } + + DateTime minus(int aYear, int aMonth, int aDay, int aHour, int aMinute, int aSecond, int aNanosecond){ + return plusOrMinus(MINUS, aYear, aMonth, aDay, aHour, aMinute, aSecond, aNanosecond); + } + + // PRIVATE + + //the base date to which the interval is calculated + private final DateTime fFrom; + + private boolean fIsPlus; + private DateTime.DayOverflow fDayOverflow; + + //the various increments + private int fYearIncr; + private int fMonthIncr; + private int fDayIncr; + private int fHourIncr; + private int fMinuteIncr; + private int fSecondIncr; + private int fNanosecondIncr; + + //work area for the final result - starts off with values from base date fFrom + private Integer fYear; + private Integer fMonth; + private Integer fDay; + private Integer fHour; + private Integer fMinute; + private Integer fSecond; + private Integer fNanosecond; + + private static final int MIN = 0; + private static final int MAX = 9999; + private static final int MIN_NANOS = 0; + private static final int MAX_NANOS = 999999999; + private static final boolean PLUS = true; + private static final boolean MINUS = false; + + private void checkUnits(){ + boolean success = false; + if(fFrom.unitsAllPresent(Unit.YEAR, Unit.MONTH, Unit.DAY, Unit.HOUR, Unit.MINUTE, Unit.SECOND) ){ + success = true; + } + else if( fFrom.unitsAllPresent(Unit.YEAR, Unit.MONTH, Unit.DAY) && fFrom.unitsAllAbsent(Unit.HOUR, Unit.MINUTE, Unit.SECOND) ){ + success = true; + } + else if ( fFrom.unitsAllAbsent(Unit.YEAR, Unit.MONTH, Unit.DAY) && fFrom.unitsAllPresent(Unit.HOUR, Unit.MINUTE, Unit.SECOND) ){ + success = true; + } + else { + success = false; + } + if(! success ){ + throw new IllegalArgumentException("For interval calculations, DateTime must have year-month-day, or hour-minute-second, or both."); + } + } + + private DateTime plusOrMinus(boolean aIsPlus, Integer aYear, Integer aMonth, Integer aDay, Integer aHour, Integer aMinute, Integer aSecond, Integer aNanosecond){ + fIsPlus = aIsPlus; + fYearIncr = aYear; + fMonthIncr = aMonth; + fDayIncr = aDay; + fHourIncr = aHour; + fMinuteIncr = aMinute; + fSecondIncr = aSecond; + fNanosecondIncr = aNanosecond; + + checkRange(fYearIncr, "Year"); + checkRange(fMonthIncr, "Month"); + checkRange(fDayIncr, "Day"); + checkRange(fHourIncr, "Hour"); + checkRange(fMinuteIncr, "Minute"); + checkRange(fSecondIncr, "Second"); + checkRangeNanos(fNanosecondIncr); + + changeYear(); + changeMonth(); + handleMonthOverflow(); + changeDay(); + changeHour(); + changeMinute(); + changeSecond(); + changeNanosecond(); + + return new DateTime(fYear, fMonth, fDay, fHour, fMinute, fSecond, fNanosecond); + } + + private void checkRange(Integer aValue, String aName) { + if ( aValue < MIN || aValue > MAX ) { + throw new IllegalArgumentException(aName + " is not in the range " + MIN + ".." + MAX); + } + } + + private void checkRangeNanos(Integer aValue) { + if ( aValue < MIN_NANOS || aValue > MAX_NANOS ) { + throw new IllegalArgumentException("Nanosecond interval is not in the range " + MIN_NANOS + ".." + MAX_NANOS); + } + } + + private void changeYear(){ + if(fIsPlus){ + fYear = fYear + fYearIncr; + } + else { + fYear = fFrom.getYear() - fYearIncr; + } + //the DateTime ctor will check the range of the year + } + + private void changeMonth(){ + int count = 0; + while (count < fMonthIncr){ + stepMonth(); + count++; + } + } + + private void changeDay(){ + int count = 0; + while (count < fDayIncr){ + stepDay(); + count++; + } + } + + private void changeHour(){ + int count = 0; + while (count < fHourIncr){ + stepHour(); + count++; + } + } + + private void changeMinute(){ + int count = 0; + while (count < fMinuteIncr){ + stepMinute(); + count++; + } + } + + private void changeSecond(){ + int count = 0; + while (count < fSecondIncr){ + stepSecond(); + count++; + } + } + + /** + Nanos are different from other items. They don't cycle one step at a time. + They are just added. If they under/over flow, then extra math is performed. + They don't over/under by more than 1 second, since the size of the increment is limited. + */ + private void changeNanosecond(){ + if (fIsPlus){ + fNanosecond = fNanosecond + fNanosecondIncr; + } + else { + fNanosecond = fNanosecond - fNanosecondIncr; + } + if(fNanosecond > MAX_NANOS){ + stepSecond(); + fNanosecond = fNanosecond - MAX_NANOS - 1; + } + else if (fNanosecond < MIN_NANOS){ + stepSecond(); + fNanosecond = MAX_NANOS + fNanosecond + 1; + } + } + + private void stepYear() { + if(fIsPlus) { + fYear = fYear + 1; + } + else { + fYear = fYear - 1; + } + } + + private void stepMonth() { + if(fIsPlus){ + fMonth = fMonth + 1; + } + else { + fMonth = fMonth - 1; + } + if(fMonth > 12) { + fMonth = 1; + stepYear(); + } + else if(fMonth < 1){ + fMonth = 12; + stepYear(); + } + } + + private void stepDay() { + if(fIsPlus){ + fDay = fDay + 1; + } + else { + fDay = fDay - 1; + } + if(fDay > numDaysInMonth()){ + fDay = 1; + stepMonth(); + } + else if (fDay < 1){ + fDay = numDaysInPreviousMonth(); + stepMonth(); + } + } + + private int numDaysInMonth(){ + return DateTime.getNumDaysInMonth(fYear, fMonth); + } + + private int numDaysInPreviousMonth(){ + int result = 0; + if(fMonth > 1) { + result = DateTime.getNumDaysInMonth(fYear, fMonth - 1); + } + else { + result = DateTime.getNumDaysInMonth(fYear - 1 , 12); + } + return result; + } + + private void stepHour() { + if(fIsPlus){ + fHour = fHour + 1; + } + else { + fHour = fHour - 1; + } + if(fHour > 23){ + fHour = 0; + stepDay(); + } + else if (fHour < 0){ + fHour = 23; + stepDay(); + } + } + + private void stepMinute() { + if(fIsPlus){ + fMinute = fMinute + 1; + } + else { + fMinute = fMinute - 1; + } + if(fMinute > 59){ + fMinute = 0; + stepHour(); + } + else if (fMinute < 0){ + fMinute = 59; + stepHour(); + } + } + + private void stepSecond() { + if(fIsPlus){ + fSecond = fSecond + 1; + } + else { + fSecond = fSecond - 1; + } + if (fSecond > 59){ + fSecond = 0; + stepMinute(); + } + else if (fSecond < 0){ + fSecond = 59; + stepMinute(); + } + } + + private void handleMonthOverflow(){ + int daysInMonth = numDaysInMonth(); + if( fDay > daysInMonth ){ + if(DayOverflow.Abort == fDayOverflow) { + throw new RuntimeException( + "Day Overflow: Year:" + fYear + " Month:" + fMonth + " has " + daysInMonth + " days, but day has value:" + fDay + + " To avoid these exceptions, please specify a different DayOverflow policy." + ); + } + else if (DayOverflow.FirstDay == fDayOverflow) { + fDay = 1; + stepMonth(); + } + else if (DayOverflow.LastDay == fDayOverflow) { + fDay = daysInMonth; + } + else if (DayOverflow.Spillover == fDayOverflow) { + int overflowAmount = fDay - daysInMonth; + fDay = overflowAmount; + stepMonth(); + } + } + } + + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/DateTimeParser.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/DateTimeParser.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,204 @@ +package hirondelle.web4j.model; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import static hirondelle.web4j.util.Consts.SPACE; + +/** + Convert a date-time from a string into a {@link DateTime}. + The primary use case for this class is converting date-times from a database ResultSet + into a {@link DateTime}. It can also parse an ISO date-time containing a 'T' separator. +*/ +final class DateTimeParser { + + /** + Thrown when the given string cannot be converted into a DateTime, since it doesn't + have a format allowed by this class. + An unchecked exception. + */ + static final class UnknownDateTimeFormat extends RuntimeException { + UnknownDateTimeFormat(String aMessage){ super(aMessage); } + UnknownDateTimeFormat(String aMessage, Throwable aEx){ super(aMessage, aEx); } + } + + DateTime parse(String aDateTime) { + if(aDateTime == null){ + throw new NullPointerException("DateTime string is null"); + } + String dateTime = aDateTime.trim(); + Parts parts = splitIntoDateAndTime(dateTime); + if (parts.hasTwoParts()) { + parseDate(parts.datePart); + parseTime(parts.timePart); + } + else if (parts.hasDateOnly()){ + parseDate(parts.datePart); + } + else if (parts.hasTimeOnly()){ + parseTime(parts.timePart); + } + DateTime result = new DateTime(fYear, fMonth, fDay, fHour, fMinute, fSecond, fNanosecond); + return result; + } + + // PRIVATE + + /** + Gross pattern for dates. + Detailed validation is done by DateTime. + The Group index VARIES for y-m-d according to which option is selected + Year: Group 1, 4, 6 + Month: Group 2, 5 + Day: Group 3 + */ + private static final Pattern DATE = Pattern.compile("(\\d{1,4})-(\\d\\d)-(\\d\\d)|(\\d{1,4})-(\\d\\d)|(\\d{1,4})"); + + /** + Gross pattern for times. + Detailed validation is done by DateTime. + The Group index VARIES for h-m-s-f according to which option is selected + Hour: Group 1, 5, 8, 10 + Minute: Group 2, 6, 9 + Second: Group 3, 7 + Microsecond: Group 4 + */ + private static final String CL = "\\:"; //colon is a special character + private static final String TT = "(\\d\\d)"; //colon is a special character + private static final String NUM_DIGITS_FOR_FRACTIONAL_SECONDS = "9"; + private static final Integer NUM_DIGITS = Integer.valueOf(NUM_DIGITS_FOR_FRACTIONAL_SECONDS); + private static final Pattern TIME = Pattern.compile("" + + TT+CL+TT+CL+TT+ "\\." + "(\\d{1," + NUM_DIGITS_FOR_FRACTIONAL_SECONDS + "})" + "|" + + TT+CL+TT+CL+TT+ "|" + + TT+CL+TT+ "|" + + TT + ); + + private static final String COLON = ":"; + private static final int THIRD_POSITION = 2; + + private Integer fYear; + private Integer fMonth; + private Integer fDay; + private Integer fHour; + private Integer fMinute; + private Integer fSecond; + private Integer fNanosecond; + + private class Parts { + String datePart; + String timePart; + boolean hasTwoParts(){ + return datePart != null && timePart != null; + } + boolean hasDateOnly(){ + return timePart == null; + } + boolean hasTimeOnly(){ + return datePart == null; + } + } + + /** Date and time can be separated with a single space, or with a 'T' character (case-sensitive). */ + private Parts splitIntoDateAndTime(String aDateTime){ + Parts result = new Parts(); + int dateTimeSeparator = getDateTimeSeparator(aDateTime); + boolean hasDateTimeSeparator = 0 < dateTimeSeparator && dateTimeSeparator < aDateTime.length(); + if (hasDateTimeSeparator){ + result.datePart = aDateTime.substring(0, dateTimeSeparator); + result.timePart = aDateTime.substring(dateTimeSeparator+1); + } + else if(hasColonInThirdPlace(aDateTime)){ + result.timePart = aDateTime; + } + else { + result.datePart = aDateTime; + } + return result; + } + + /** Return the index of a space character, or of a 'T' character. If not found, return -1.*/ + int getDateTimeSeparator(String aDateTime){ + int NOT_FOUND = -1; + int result = NOT_FOUND; + result = aDateTime.indexOf(SPACE); + if(result == NOT_FOUND){ + result = aDateTime.indexOf("T"); + } + return result; + } + + private boolean hasColonInThirdPlace(String aDateTime){ + boolean result = false; + if(aDateTime.length() >= THIRD_POSITION){ + result = COLON.equals(aDateTime.substring(THIRD_POSITION,THIRD_POSITION+1)); + } + return result; + } + + private void parseDate(String aDate) { + Matcher matcher = DATE.matcher(aDate); + if (matcher.matches()){ + String year = getGroup(matcher, 1, 4, 6); + if(year !=null ){ + fYear = Integer.valueOf(year); + } + String month = getGroup(matcher, 2, 5); + if(month !=null ){ + fMonth = Integer.valueOf(month); + } + String day = getGroup(matcher, 3); + if(day !=null ){ + fDay = Integer.valueOf(day); + } + } + else { + throw new DateTimeParser.UnknownDateTimeFormat("Unexpected format for date:" + aDate); + } + } + + private String getGroup(Matcher aMatcher, int... aGroupIds){ + String result = null; + for(int id: aGroupIds){ + result = aMatcher.group(id); + if(result!=null) break; + } + return result; + } + + private void parseTime(String aTime) { + Matcher matcher = TIME.matcher(aTime); + if (matcher.matches()){ + String hour = getGroup(matcher, 1, 5, 8, 10); + if(hour !=null ){ + fHour = Integer.valueOf(hour); + } + String minute = getGroup(matcher, 2, 6, 9); + if(minute !=null ){ + fMinute = Integer.valueOf(minute); + } + String second = getGroup(matcher, 3, 7); + if(second !=null ){ + fSecond = Integer.valueOf(second); + } + String decimalSeconds = getGroup(matcher, 4); + if(decimalSeconds !=null ){ + fNanosecond = Integer.valueOf(convertToNanoseconds(decimalSeconds)); + } + } + else { + throw new DateTimeParser.UnknownDateTimeFormat("Unexpected format for time:" + aTime); + } + } + + /** + Convert any number of decimals (1..9) into the form it would have taken if nanos had been used, + by adding any 0's to the right side. + */ + private String convertToNanoseconds(String aDecimalSeconds){ + StringBuilder result = new StringBuilder(aDecimalSeconds); + while( result.length( ) < NUM_DIGITS ){ + result.append("0"); + } + return result.toString(); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/Decimal.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/Decimal.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,629 @@ +package hirondelle.web4j.model; + +import java.util.*; +import java.io.Serializable; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + Represent an immutable number, using a natural, compact syntax. + The number may have a decimal portion, or it may not. + +

Decimal amounts are typically used to represent two kinds of items : +

    +
  • monetary amounts +
  • measurements such as temperature, distance, and so on +
+ +

Your applications are not obliged to use this class to represent decimal amounts. + You may choose to use {@link BigDecimal} instead (perhaps along with an Id + to store a currency, if needed). + +

This class exists for these reasons: +

    +
  • to simplify calculations, and build on top of what's available from the {@link BigDecimal} class +
  • to allow your code to read at a higher level +
  • to define a more natural, pleasing syntax +
  • to help you avoid floating-point types, + which have many pitfalls +
+ +

Decimal objects are immutable. + Many operations return new Decimal objects. + +

Currency Is Unspecified

+ This class can be used to model amounts of money. +

Many will be surprised that this class does not make any reference to currency. + The reason for this is adding currency would render this class a poor building block. + Building block objects such as Date, Integer, and so on, are + atomic, in the sense of representing a single piece of data. + They correspond to a single column in a table, or a single form control. If the currency + were included in this class, then it would no longer be atomic, and it could not be + treated by WEB4J as any other building block class. + However, allowing this class to be treated like any other building block class is + highly advantageous. + +

If a feature needs to explicitly distinguish between multiple currencies + such as US Dollars and Japanese Yen, then a Decimal object + will need to be paired by the caller with a second item representing the + underlying currency (perhaps modeled as an Id). + See the {@link Currency} class for more information. + +

Number of Decimal Places

+ To validate the number of decimals in your Model Objects, + call the {@link Check#numDecimalsAlways(int)} or {@link Check#numDecimalsMax(int)} methods. + +

Different Numbers of Decimals

+

As usual, operations can be performed on two items having a different number of decimal places. + For example, these operations are valid (using an informal, ad hoc notation) : +

10 + 1.23 = 11.23
+10.00 + 1.23 = 11.23
+10 - 1.23 = 8.77
+(10 > 1.23) => true 
+ This corresponds to typical user expectations. + +

The {@link #eq(Decimal)} is usually to be preferred over the {@link #equals(Object)} method. + The {@link #equals(Object)} is unusual, in that it's the only method sensitive to the exact + number of decimal places, while {@link #eq(Decimal)} is not. That is, +

10.equals(10.00) => false
+10.eq(10.00) => true
+ +

Terse Method Names

+ Various methods in this class have unusually terse names, such as + lt for 'less than', and gt for 'greater than', and so on. + The intent of such names is to improve the legibility of mathematical + expressions. + +

Example: +

if (amount.lt(hundred)) {
+  cost = amount.times(price); 
+}
+ +

Prefer Decimal forms

+

Many methods in this class are overloaded to perform the same operation with various types: +

    +
  • Decimal +
  • long (which will also accept an int) +
  • double (which will also accept a float) +
+ Usually, you should prefer the Decimal form. The long and double forms are usually convenience methods, which simply call the Decimal + version as part of their implementations; they're intended for cases when you simply wish to specify a hard-coded constant value. + +

Extends Number

+

This class extends {@link Number}. This allows other parts of the JDK to treat a Decimal just like any other + Number. +*/ +public final class Decimal extends Number implements Comparable, Serializable { + + /** + The default rounding mode used by this class (HALF_EVEN). + This rounding style results in the least bias. + */ + public static final RoundingMode ROUNDING = RoundingMode.HALF_EVEN; + + /** + Constructor. + @param aAmount required, any number of decimals. + */ + public Decimal(BigDecimal aAmount){ + fAmount = aAmount; + validateState(); + } + + /** + Convenience factory method. Leading zeroes are allowed. +

Instead of : +

Decimal decimal = new Decimal(new BigDecimal("100"));
+ one may instead use this more compact form: +
Decimal decimal = Decimal.from("100");
+ which is a bit more legible. + */ + public static Decimal from(String aAmount){ + return new Decimal(new BigDecimal(aAmount)); + } + + /** Convenience factory method. */ + public static Decimal from(long aAmount){ + return new Decimal(new BigDecimal(aAmount)); + } + + /** Convenience factory method. */ + public static Decimal from(double aAmount){ + return new Decimal(BigDecimal.valueOf(aAmount)); + } + + /** + Renders this Decimal in a style suitable for debugging. + Intended for debugging only. + +

Returns the amount in the format defined by {@link BigDecimal#toPlainString()}. +*/ + public String toString(){ + return fAmount.toPlainString(); + } + + /** + Equals, sensitive to scale of the underlying BigDecimal. + +

That is, 10 and 10.00 are not + considered equal by this method. Such behavior is often undesired; in most + practical cases, it's likely best to use the {@link #eq(Decimal)} method instead., + which has no such monkey business. + +

This implementation imitates {@link BigDecimal#equals(java.lang.Object)}, + which is also sensitive to the number of decimals (or 'scale'). + */ + public boolean equals(Object aThat){ + if (this == aThat) return true; + if (! (aThat instanceof Decimal) ) return false; + Decimal that = (Decimal)aThat; + //the object fields are never null : + boolean result = (this.fAmount.equals(that.fAmount) ); + return result; + } + + public int hashCode(){ + if ( fHashCode == 0 ) { + fHashCode = HASH_SEED; + fHashCode = HASH_FACTOR * fHashCode + fAmount.hashCode(); + } + return fHashCode; + } + + /** + Implements the {@link Comparable} interface. + +

It's possible to use this method as a general replacement for a large number of methods which compare numbers: + lt, eq, lteq, and so on. However, it's recommended that you use those other methods, since they have greater clarity + and concision. + */ + public int compareTo(Decimal aThat) { + final int EQUAL = 0; + if ( this == aThat ) return EQUAL; + //the object field is never null + int comparison = this.fAmount.compareTo(aThat.fAmount); + if ( comparison != EQUAL ) return comparison; + return EQUAL; + } + + /** Return the amount as a BigDecimal. */ + public BigDecimal getAmount() { return fAmount; } + + /** The suffix is needed to distinguish from the public field. Declared 'early' since compiler complains.*/ + private static final BigDecimal ZERO_BD = BigDecimal.ZERO; + private static final BigDecimal ONE_BD = BigDecimal.ONE; + private static final BigDecimal MINUS_ONE_BD = new BigDecimal("-1"); + + /** + Zero Decimal amount, a simple convenience constant. + +

Like {@link BigDecimal#ZERO}, this item has no explicit decimal. + In most cases that will not matter, since only the {@link #equals(Object)} method is sensitive to + exact decimals. All other methods, including {@link #eq(Decimal)}, are not sensitive to exact decimals. + */ + public static final Decimal ZERO = new Decimal(ZERO_BD); + + /** Convenience constant. */ + public static final Decimal ONE = new Decimal(ONE_BD); + + /** Convenience constant. */ + public static final Decimal MINUS_ONE = new Decimal(MINUS_ONE_BD); + + /** + An approximation to the number pi, to 15 decimal places. + Pi is the ratio of the circumference of a circle to its radius. + It's also rumoured to taste good as well. + */ + public static final Decimal PI = new Decimal(new BigDecimal("3.141592653589793")); + + /** + An approximation to Euler's number, to 15 decimal places. + Euler's number is the base of the natural logarithms. + */ + public static final Decimal E = new Decimal(new BigDecimal( "2.718281828459045")); + + /** + Return the number of decimals in this value. More accurately, this returns the 'scale' of the + underlying BigDecimal. Negative scales are possible; they represent the number of zeros to be + adding on to the end of an integer. + */ + public int getNumDecimals(){ + return fAmount.scale(); + } + + /** + Return true only if this Decimal is an integer. + For example, 2 and 2.00 are integers, but 2.01 is not. + */ + public boolean isInteger(){ + return round().minus(this).eq(ZERO); + } + + /** Return true only if the amount is positive. */ + public boolean isPlus(){ + return fAmount.compareTo(ZERO_BD) > 0; + } + + /** Return true only if the amount is negative. */ + public boolean isMinus(){ + return fAmount.compareTo(ZERO_BD) < 0; + } + + /** Return true only if the amount is zero. */ + public boolean isZero(){ + return fAmount.compareTo(ZERO_BD) == 0; + } + + /** + Equals (insensitive to number of decimals). + That is, 10 and 10.00 are considered equal by this method. + +

Return true only if the amounts are equal. + This method is not synonymous with the equals method, + since the {@link #equals(Object)} method is sensitive to the exact number of decimal places (or, more + precisely, the scale of the underlying BigDecimal.) + */ + public boolean eq(Decimal aThat) { + return compareAmount(aThat) == 0; + } + public boolean eq(long aThat) { + return eq(Decimal.from(aThat)); + } + public boolean eq(double aThat) { + return eq(Decimal.from(aThat)); + } + + /** + Greater than. +

Return true only if 'this' amount is greater than + 'that' amount. + */ + public boolean gt(Decimal aThat) { + return compareAmount(aThat) > 0; + } + public boolean gt(long aThat) { + return gt(Decimal.from(aThat)); + } + public boolean gt(double aThat) { + return gt(Decimal.from(aThat)); + } + + /** + Greater than or equal to. +

Return true only if 'this' amount is + greater than or equal to 'that' amount. + */ + public boolean gteq(Decimal aThat) { + return compareAmount(aThat) >= 0; + } + public boolean gteq(long aThat) { + return gteq(Decimal.from(aThat)); + } + public boolean gteq(double aThat) { + return gteq(Decimal.from(aThat)); + } + + /** + Less than. +

Return true only if 'this' amount is less than + 'that' amount. + */ + public boolean lt(Decimal aThat) { + return compareAmount(aThat) < 0; + } + public boolean lt(long aThat) { + return lt(Decimal.from(aThat)); + } + public boolean lt(double aThat) { + return lt(Decimal.from(aThat)); + } + + /** + Less than or equal to. +

Return true only if 'this' amount is less than or equal to + 'that' amount. + */ + public boolean lteq(Decimal aThat) { + return compareAmount(aThat) <= 0; + } + public boolean lteq(long aThat) { + return lteq(Decimal.from(aThat)); + } + public boolean lteq(double aThat) { + return lteq(Decimal.from(aThat)); + } + + /** + Add aThat Decimal to this Decimal. + */ + public Decimal plus(Decimal aThat){ + return new Decimal(fAmount.add(aThat.fAmount)); + } + public Decimal plus(long aThat){ + return plus(Decimal.from(aThat)); + } + public Decimal plus(double aThat){ + return plus(Decimal.from(aThat)); + } + + /** + Subtract aThat Decimal from this Decimal. + */ + public Decimal minus(Decimal aThat){ + return new Decimal(fAmount.subtract(aThat.fAmount)); + } + public Decimal minus(long aThat){ + return minus(Decimal.from(aThat)); + } + public Decimal minus(double aThat){ + return minus(Decimal.from(aThat)); + } + + /** + Sum a collection of Decimal objects. + + @param aDecimals collection of Decimal objects. + If the collection is empty, then a zero value is returned. + */ + public static Decimal sum(Collection aDecimals){ + Decimal sum = new Decimal(ZERO_BD); + for(Decimal decimal : aDecimals){ + sum = sum.plus(decimal); + } + return sum; + } + + /** Multiply this Decimal by a factor. */ + public Decimal times(Decimal aFactor){ + BigDecimal newAmount = fAmount.multiply(aFactor.getAmount()); + return new Decimal(newAmount); + } + public Decimal times(long aFactor){ + return times(Decimal.from(aFactor)); + } + public Decimal times(double aFactor){ + return times(Decimal.from(aFactor)); + } + + /** + Divide this Decimal by a divisor. +

If the division results in a number which will never terminate, then this method + will round the result to 20 decimal places, using the default {@link #ROUNDING}. + */ + public Decimal div(Decimal aDivisor){ + BigDecimal newAmount = null; + try { + newAmount = fAmount.divide(aDivisor.fAmount); + } + catch(ArithmeticException ex){ + // non-terminating decimal + // need to apply a policy for where and how to round + newAmount = fAmount.divide(aDivisor.fAmount, DECIMALS, ROUNDING); + } + return new Decimal(newAmount); + } + public Decimal div(long aDivisor){ + return div(Decimal.from(aDivisor)); + } + public Decimal div(double aDivisor){ + return div(Decimal.from(aDivisor)); + } + + /** Return the absolute value of the amount. */ + public Decimal abs(){ + return isPlus() ? this : times(-1); + } + + /** Return this amount x (-1). */ + public Decimal negate(){ + return times(-1); + } + + /** Round to an integer value, using the default {@link #ROUNDING} style. */ + public Decimal round(){ + BigDecimal amount = fAmount.setScale(0, ROUNDING); + return new Decimal(amount); + } + + /** + Round to 0 or more decimal places, using the default {@link #ROUNDING} style. + @param aNumberOfDecimals must 0 or more. + */ + public Decimal round(int aNumberOfDecimals){ + if( aNumberOfDecimals < 0 ){ + throw new IllegalArgumentException("Number of decimals is negative: " + quote(aNumberOfDecimals)); + } + BigDecimal amount = fAmount.setScale(aNumberOfDecimals, ROUNDING); + return new Decimal(amount); + } + + /** + Round to 0 or more decimal places, using the given rounding style. + @param aNumberOfDecimals must 0 or more. + */ + public Decimal round(int aNumberOfDecimals, RoundingMode aRoundingMode){ + if( aNumberOfDecimals < 0 ){ + throw new IllegalArgumentException("Number of decimals is negative: " + quote(aNumberOfDecimals)); + } + BigDecimal amount = fAmount.setScale(aNumberOfDecimals, aRoundingMode); + return new Decimal(amount); + } + + /** + Round a number to the nearest multiple of the given interval. + For example: + + Decimal amount = Decimal.from("1710.12"); + amount.round2(0.05); // 1710.10 + amount.round2(100); // 1700 + + @param aInterval must be greater than zero + */ + public Decimal round2(Decimal aInterval){ + if( ! aInterval.isPlus() ){ + throw new IllegalArgumentException("Interval is negative or zero : " + quote(aInterval)); + } + BigDecimal result = fAmount.divide(aInterval.fAmount).setScale(0, ROUNDING).multiply(aInterval.fAmount); + return new Decimal(result); + } + public Decimal round2(long aInterval){ + return round2(Decimal.from(aInterval)); + } + public Decimal round2(double aInterval){ + return round2(Decimal.from(aInterval)); + } + + /** + Raise this number to an integral power; the power can be of either sign. + +

Special cases regarding 0: +

    +
  • 0^-n is undefined (n > 0). +
  • x^0 always returns 1, even for x = 0. +
+ + @param aPower is in the range -999,999,999..999,999,999, inclusive. (This reflects a restriction on + the underlying {@link BigDecimal#pow(int)} method. + */ + public Decimal pow(int aPower){ + BigDecimal newAmount = null; + if (aPower == 0){ + newAmount = ONE_BD; + } + else if (aPower == 1){ + newAmount = fAmount; + } + else if (aPower > 0){ + newAmount = fAmount.pow(aPower); + } + else if (aPower < 0 && this.eq(ZERO)){ + throw new RuntimeException("Raising 0 to a negative power is undefined."); + } + else if (aPower < 0){ + newAmount = fAmount.pow(-1 * aPower); + newAmount = ONE_BD.divide(newAmount); + } + return new Decimal(newAmount); + } + + /** This implementation uses {@link Math#pow(double, double)}. */ + public Decimal pow(double aPower){ + double value = Math.pow(fAmount.doubleValue(), aPower); + return Decimal.from(value); + } + + /** + Raise this Decimal to a Decimal power. +

This method calls either {@link #pow(int)} or {@link #pow(double)}, according to the return value of + {@link #isInteger()}. + */ + public Decimal pow(Decimal aPower){ + Decimal result = ZERO; + if (aPower.isInteger()){ + result = pow(aPower.intValue()); + } + else { + result = pow(aPower.doubleValue()); + } + return result; + } + + /** + Required by {@link Number}. + +

Use of floating point data is highly discouraged. + This method is provided only because it's required by Number. + */ + @Override public double doubleValue() { + return fAmount.doubleValue(); + } + + /** + Required by {@link Number}. + +

Use of floating point data is highly discouraged. + This method is provided only because it's required by Number. + */ + @Override public float floatValue() { + return fAmount.floatValue(); + } + + /** Required by {@link Number}. */ + @Override public int intValue() { + return fAmount.intValue(); + } + + /** Required by {@link Number}. */ + @Override public long longValue() { + return fAmount.longValue(); + } + + // PRIVATE + + /** + The decimal amount. + Never null. + @serial + */ + private BigDecimal fAmount; + + /** Number of decimals to use when a division operation blows up into a non-terminating decimal. */ + private static final int DECIMALS = 20; + + /** @serial */ + private int fHashCode; + private static final int HASH_SEED = 23; + private static final int HASH_FACTOR = 37; + + /** + Determines if a deserialized file is compatible with this class. + + Maintainers must change this value if and only if the new version + of this class is not compatible with old versions. See Sun docs + for details. + + Not necessary to include in first version of the class, but + included here as a reminder of its importance. + */ + private static final long serialVersionUID = 7526471155622776147L; + + /** + Always treat de-serialization as a full-blown constructor, by + validating the final state of the de-serialized object. + */ + private void readObject( + ObjectInputStream aInputStream + ) throws ClassNotFoundException, IOException { + //always perform the default de-serialization first + aInputStream.defaultReadObject(); + //defensive copy for mutable date field + //BigDecimal is not technically immutable, since its non-final + fAmount = new BigDecimal( fAmount.toPlainString() ); + //ensure that object state has not been corrupted or tampered with maliciously + validateState(); + } + + private void writeObject(ObjectOutputStream aOutputStream) throws IOException { + //perform the default serialization for all non-transient, non-static fields + aOutputStream.defaultWriteObject(); + } + + private void validateState(){ + if( fAmount == null ) { + throw new IllegalArgumentException("Amount cannot be null"); + } + } + + /** Ignores scale: 0 same as 0.00 */ + private int compareAmount(Decimal aThat){ + return this.fAmount.compareTo(aThat.fAmount); + } + + private static String quote(Object aText){ + return "'" + String.valueOf(aText) + "'"; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/Id.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/Id.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,171 @@ +package hirondelle.web4j.model; + +import java.io.Serializable; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.IOException; +import hirondelle.web4j.model.ModelUtil; +import hirondelle.web4j.util.Consts; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.security.SafeText; + +/** + Building block class for identifiers. + +

Identifiers are both common and important. Unfortunately, there is no class in the + JDK specifically for identifiers. + +

An Id class is useful for these reasons : +

    +
  • it allows model classes to read at a higher level of abstraction. Identifiers are + labeled as such, and stand out very clearly from other items +
  • it avoids a common problem + in modeling identifiers as numbers. +
+ +

The underlying database column may be modeled as either text or as a number. + If the underlying column is of a numeric type, however, then a Data Access Object + will need to pass Id parameters to {@link hirondelle.web4j.database.Db} + using {@link #asInteger} or {@link #asLong}. + +

Design Note :
+ This class is final, immutable, {@link Serializable}, + and {@link Comparable}, in imitation of the other building block classes + such as {@link String}, {@link Integer}, and so on. +*/ +public final class Id implements Serializable, Comparable { + + /** + Construct an identifier using an arbitrary {@link String}. + + This class uses a {@link SafeText} object internally. + @param aText is non-null, and contains characters that are allowed + by {@link hirondelle.web4j.security.PermittedCharacters}. + */ + public Id(String aText) { + fId = new SafeText(aText); + validateState(); + } + + /** + Factory method. + + Simply a slightly more compact way of building an object, as opposed to 'new'. + */ + public static Id from(String aText){ + return new Id(aText); + } + + /** + Return this id as an {@link Integer}, if possible. + +

See class comment. + +

If this Id is not convertible to an {@link Integer}, then a {@link RuntimeException} is + thrown. + */ + public Integer asInteger(){ + return new Integer(fId.getRawString()); + } + + /** + Return this id as a {@link Long}, if possible. + +

See class comment. + +

If this Id is not convertible to a {@link Long}, + then a {@link RuntimeException} is thrown. + */ + public Long asLong(){ + return new Long(fId.getRawString()); + } + + /** + Return the id, with special characters escaped. + +

The return value either has content (with no leading or trailing spaces), + or is empty. + See {@link hirondelle.web4j.util.EscapeChars#forHTML(String)} for a list of escaped + characters. + */ + @Override public String toString(){ + return fId.toString(); + } + + /** Return the text passed to the constructor. */ + public String getRawString(){ + return fId.getRawString(); + } + + /** Return the text with special XML characters esacped. See {@link SafeText#getXmlSafe()}. */ + public String getXmlSafe() { + return fId.getXmlSafe(); + } + + @Override public boolean equals(Object aThat){ + Boolean result = ModelUtil.quickEquals(this, aThat); + if ( result == null ){ + Id that = (Id) aThat; + result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields()); + } + return result; + } + + @Override public int hashCode(){ + return ModelUtil.hashCodeFor(getSignificantFields()); + } + + public int compareTo(Id aThat) { + final int EQUAL = 0; + if ( this == aThat ) return EQUAL; + int comparison = this.fId.compareTo(aThat.fId); + if ( comparison != EQUAL ) return comparison; + return EQUAL; + } + + // PRIVATE + + /** @serial */ + private SafeText fId; + + /** + For evolution of this class, see Sun guidelines : + http://java.sun.com/j2se/1.5.0/docs/guide/serialization/spec/version.html#6678 + */ + private static final long serialVersionUID = 7526472295633676147L; + + /** + Always treat de-serialization as a full-blown constructor, by + validating the final state of the de-serialized object. + */ + private void readObject(ObjectInputStream aInputStream) throws ClassNotFoundException, IOException { + //always perform the default de-serialization first + aInputStream.defaultReadObject(); + //make defensive copy of mutable fields (none here) + //ensure that object state has not been corrupted or tampered with maliciously + validateState(); + } + + /** + This is the default implementation of writeObject. + Customise if necessary. + */ + private void writeObject(ObjectOutputStream aOutputStream) throws IOException { + //perform the default serialization for all non-transient, non-static fields + aOutputStream.defaultWriteObject(); + } + + private void validateState() { + if( ! Util.textHasContent(fId) ) { + if ( ! Consts.EMPTY_STRING.equals(fId.getRawString()) ) { + throw new IllegalArgumentException( + "Id must have content, or be the empty String. Erroneous Value : " + Util.quote(fId) + ); + } + } + } + + private Object[] getSignificantFields(){ + return new Object[] {fId}; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/MessageList.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/MessageList.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,69 @@ +package hirondelle.web4j.model; + +import java.util.List; + +/** + List of {@link AppResponseMessage} objects to be shown to the user. + +

Used for error messages, success messages, or any such item communicated + to the user. See displayMessages.tag in the example application + for an illustration of rendering a MessageList. + +

If a message needs to survive a redirect, it must be placed in + session scope, not request scope. Typically, successful edits to the + database use a redirect (to avoid the + duplicate-upon-browser-reload + problem), so success messages will almost always be placed in session + scope. Conversely, failure messages will usually be placed in request + scope. + +

Design Note
+ The forces which WEB4J resolves are : +

    +
  • messages can be shown to the user upon both success and failure +
  • redirect/forward behavior can occur for either success or failure +
  • rendering may be the same for both success and failure messages +
  • rendering may differ between success and failure messages +
  • the message may or may not include parameters +
+ +

This item is provided as an interface because + {@link AppException} needs to be both an Exception + and a MessageList. +*/ +public interface MessageList { + + /** + Add a simple {@link AppResponseMessage} to this list. + +

The argument satisfies the same conditions as {@link AppResponseMessage#forSimple}. + */ + void add(String aErrorMessage); + + /** + Add a compound {@link AppResponseMessage} to this list. + +

The arguments satisfy the same conditions as {@link AppResponseMessage#forCompound}. + */ + void add(String aErrorMessage, Object... aParams); + + /** Add all {@link AppResponseMessage}s attached to aAppEx to this list. */ + void add(AppException aAppEx); + + /** + Return true only if there are no messages in this list. + +

Note that this method name conflicts with the empty keyword + of JSTL. Thus, {@link #isNotEmpty} is supplied as an alternative. + */ + boolean isEmpty(); + + /** Return the negation of {@link #isEmpty}. */ + boolean isNotEmpty(); + + /** + Return an unmodifiable List of {@link AppResponseMessage}s. + */ + List getMessages(); + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/MessageListImpl.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/MessageListImpl.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,83 @@ +package hirondelle.web4j.model; + +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** General implementation of {@link MessageList}. */ +public class MessageListImpl implements MessageList, Serializable { + + /** Create an empty {@link MessageList}. */ + public MessageListImpl(){ + //empty + } + + /** + Create a {@link MessageList} having one simple {@link AppResponseMessage}. + +

The argument satisfies the same conditions as {@link AppResponseMessage#forSimple}. + */ + public MessageListImpl(String aMessage) { + add(aMessage); + } + + /** + Create a {@link MessageList} having one compound {@link AppResponseMessage}. + +

The arguments satisfy the same conditions as + {@link AppResponseMessage#forCompound}. + */ + public MessageListImpl(String aMessage, Object... aParams) { + add(aMessage, aParams); + } + + public final void add(String aMessage){ + fAppResponseMessages.add(AppResponseMessage.forSimple(aMessage)); + } + + public final void add(String aMessage, Object... aParams){ + fAppResponseMessages.add(AppResponseMessage.forCompound(aMessage, aParams)); + } + + public final void add (AppException ex) { + fAppResponseMessages.addAll( ex.getMessages() ); + } + + public final List getMessages () { + return Collections.unmodifiableList(fAppResponseMessages); + } + + public final boolean isNotEmpty () { + return !isEmpty(); + } + + public final boolean isEmpty () { + return fAppResponseMessages.isEmpty(); + } + + /** Intended for debugging only. */ + @Override public String toString(){ + return + "Messages : + " + fAppResponseMessages.toString() + + " Has Been Displayed : " + fHasBeenDisplayed + ; + } + + // PRIVATE + + /** List of {@link AppResponseMessage}s attached to this exception. */ + private final List fAppResponseMessages = new ArrayList(); + + /** Controls the display-once-only behavior needed for JSPs. */ + private boolean fHasBeenDisplayed = false; + + private static final long serialVersionUID = 1000L; + + /** Always treat de-serialization as a full-blown constructor, by validating the final state of the deserialized object. */ + private void readObject(ObjectInputStream aInputStream) throws ClassNotFoundException, IOException { + aInputStream.defaultReadObject(); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/ModelCtorException.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/ModelCtorException.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,43 @@ +package hirondelle.web4j.model; + +/** + Thrown when a Model Object (MO) cannot be constructed because of invalid + constructor arguments. + +

Arguments to a MO constructor have two sources: the end user and the + the database. In both cases, errors in the values of these arguments + are outside the immediate control of the application. Hence, MO constructors + should throw a checked exception - (ModelCtorException). + +

Using a checked exception has the advantage that + it cannot be ignored by the caller. + + Example use case:
+

+  //a Model Object constructor 
+  Blah(String aText, int aID) throws ModelCtorException {
+    //check validity of all params.
+    //if one or more params is invalid, throw a ModelCtorException.
+    //for each invalid param, add a corresponding error message to 
+    //ModelCtorException. 
+  }
+
+  //call the Model Object constructor 
+  try {
+    Blah blah = new Blah(text, id);
+  }
+  catch(ModelCtorException ex){
+    //place the exception in scope, for subsequent 
+    //display to the user in a JSP
+  }
+
+ +

In the case of an error, the problem arises of how to redisplay the original, + erroneous user input. The {@link hirondelle.web4j.ui.tag.Populate} tag + accomplishes this in an elegant manner, simply by recycling the original + request parameters. +*/ +public final class ModelCtorException extends AppException { + //empty +} + diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/ModelCtorUtil.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/ModelCtorUtil.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,152 @@ +package hirondelle.web4j.model; + +import java.util.*; +import java.lang.reflect.*; +import hirondelle.web4j.util.Args; + +/** + UNPUBLISHED - Utilities for constructing Model Objects. + +

Instead of using this class directly, the application programmer will + use {@link hirondelle.web4j.model.ModelFromRequest} instead. +*/ +public final class ModelCtorUtil { + + /** + Return the {@link java.lang.reflect.Constructor} having the given number of arguments. + +

In WEB4J, Model Objects have public constructors. + Any no-argument constructors are ignored by WEB4J. Constructors which take arguments + are used by WEB4J to build Model Objects out of both user input and database + {@link java.sql.ResultSet}s. + +

In WEB4J, the recommended style is to define Model Objects as immutable, + with a single constructor taking all necessary data. + + @param aMOClass has a public constructor which takes aNumArguments + @param aNumArguments number of arguments taken by a constructor, must be greater + than 0 + */ + public static Constructor getConstructor(Class aMOClass, int aNumArguments) { + Args.checkForPositive(aNumArguments); + Constructor result = null; + //Original line did not compile under JDK 6 Constructor[] allCtors = aMOClass.getConstructors(); + Constructor[] allCtors = (Constructor[]) aMOClass.getConstructors(); + if (allCtors.length == 0) { + throw new IllegalArgumentException( + "Model Object class has no public constructor : " + aMOClass.getName() + ); + } + //cycle thru, and take the first constructor with the matching number of arguments + for ( Constructor ctor : allCtors) { + if (ctor.getParameterTypes().length == aNumArguments) { + result = ctor; + break; + } + } + if (result == null){ + throw new IllegalArgumentException( + "Model Object class " + aMOClass.getName() + " does not have a public " + + "constructor having " + aNumArguments + " arguments." + ); + } + return result; + } + + /** + Return the public {@link java.lang.reflect.Constructor} whose + exact argument types are listed, in order, in aArgClasses. + +

This method is intended only for cases in which specifying the number of + arguments would not uniquely identify the desired public constructor. + +

Example values for aArgClasses :

+ + + + + +
public ConstructoraArgClasses array
Account(String, int){String.class, int.class}
Account(String, BigDecimal){String.class, BigDecimal.class}
+
+ @param aArgClasses has the same restrictions as the arguments to + {@link Class#getConstructor(Class[])}, and must have at least one element. + */ + public static Constructor getExactConstructor(Class aMOClass, Class[] aArgClasses) { + Args.checkForPositive(aArgClasses.length); + Constructor result = null; + try { + result = aMOClass.getConstructor(aArgClasses); + } + catch (NoSuchMethodException ex){ + throw new IllegalArgumentException( + "Model Object class " + aMOClass.getName() + " does not have a public " + + "constructor having as arguments " + getClassNames(aArgClasses) + ); + } + return result; + } + + /** + Build and return a Model Object, by passing the given argument values + to the given constructor. + + @param aMOCtor Model Object constructor having at least one argument + @param aArgValues contains possibly-null Objects, to be passed to aMOCtor ; + if the Model Object constructor takes primitives, and not objects, then the primitives + are transparently "unwrapped" from corresponding wrapper objects. + See {@link Constructor#newInstance}. + */ + public static T buildModelObject(Constructor aMOCtor, List aArgValues) throws ModelCtorException { + T result = null; + try { + result = aMOCtor.newInstance( aArgValues.toArray() ); + } + catch (InstantiationException ex){ + vomit(aMOCtor, aArgValues, ex); + } + catch (IllegalAccessException ex){ + vomit(aMOCtor, aArgValues, ex); + } + catch(IllegalArgumentException ex){ + vomit(aMOCtor, aArgValues, ex); + } + catch (InvocationTargetException ex){ + //If the underlying MO ctor throws an exception, then this branch is exercised. + //Curiously, it is appropriate here to unwrap the underlying ModelCtorException, + //just to re-throw it! + Throwable cause = ex.getCause(); + if ( cause instanceof ModelCtorException ) { + //the MO cannot accept the given arguments + throw (ModelCtorException)cause; + } + //a bug has occurred + throw (RuntimeException)cause; + } + return result; + } + + // PRIVATE // + + private ModelCtorUtil(){ + //empty - prevent construction by the caller + } + + /** + This will not be called during normal operation of the program, even + when the user has input invalid values. That is, a call to this method + means a bug in the program or its environment, and not a user input error. + */ + private static void vomit(Constructor aMOCtor, List aArgValues, Exception aEx) { + throw new RuntimeException( + "Cannot reflectively construct Model Object of class " + + aMOCtor.getDeclaringClass().getName() + + ", using arguments " + aArgValues + ". " + aEx.toString(), + aEx + ); + } + + private static String getClassNames(Class[] aClasses){ + List names = Arrays.asList(aClasses); + return names.toString(); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/ModelFromRequest.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/ModelFromRequest.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,205 @@ +package hirondelle.web4j.model; + +import java.util.*; +import java.util.logging.*; +import java.lang.reflect.Constructor; + +import hirondelle.web4j.request.RequestParameter; +import hirondelle.web4j.request.RequestParser; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.action.Action; +import hirondelle.web4j.model.ModelCtorException; + +/** + Parse a set of request parameters into a Model Object. + +

Since HTTP is entirely textual, the problem always arises in a web application of + building Model Objects (whose constructors may take arguments of any type) out of + the text taken from HTTP request parameters. (See the hirondelle.web4j.database + package for the similar problem of translating rows of a ResultSet into a Model Object.) + +

Somewhat surprisingly, some web application frameworks do not assist the programmer + in this regard. That is, they leave the programmer to always translate raw HTTP request + parameters (Strings) into target types (Integer, Boolean, + etc), and then to in turn build complete Model Objects. This usually results in + much code repetition. + +

This class, along with implementations of {@link ConvertParam} and {@link RequestParser}, + help an {@link Action} build a Model Object by defining such "type translation" + policies in one place. + +

Example use case of building a 'Visit' Model Object out of four + {@link hirondelle.web4j.request.RequestParameter} objects (ID, RESTAURANT, etc.): +

+protected void validateUserInput() {
+  try {
+    ModelFromRequest builder = new ModelFromRequest(getRequestParser());
+    //pass RequestParameters (or any object) using a sequence (varargs)
+    fVisit = builder.build(Visit.class, ID, RESTAURANT, LUNCH_DATE, MESSAGE);
+  }
+  catch (ModelCtorException ex){
+    addError(ex);
+  }    
+}
+ 
+ +

The order of the sequence params passed to {@link #build(Class, Object...)} + must match the order of arguments passed to the Model Object constructor. + This mechanism is quite effective and compact. + +

The sequence parameters passed to {@link #build(Class, Object...)} need not be a {@link RequestParameter}. + They can be any object whatsoever. Before calling the Model Object constructor, the sequence + parameters are examined and treated as follows : +

+ if the item is not an instance of RequestParameter
+    - do not alter it in any way
+    - it will be passed to the MO ctor 'as is'
+ else 
+   - fetch the corresponding param value from the request
+   - attempt to translate its text to the target type required
+     by the corresponding MO ctor argument, using policies 
+     defined by RequestParser and ConvertParam
+     if the translation attempt fails
+      - create a ModelCtorException 
+ 
+ +

If no {@link ModelCtorException} has been constructed, then the MO constructor is + called using reflection. Note that the MO constructor may itself in turn throw + a ModelCtorException. + In fact, in order for this class to be well-behaved, the MO + constructor cannot throw anything other than a ModelCtorException as part of + its contract. This includes + RuntimeExceptions. For example, if a null is not permitted + by a MO constructor, it should not throw a NullPointerException (unchecked). + Rather, it should throw a ModelCtorException (checked). This allows the caller to + be notified of all faulty user input in a uniform manner. It also makes MO constructors + simpler, since all irregular input will result in a ModelCtorException, instead + of a mixture of checked and unchecked exceptions. + +

This unusual policy is related to the unusual character of Model Objects, + which attempt to build an object out of arbitrary user input. + Unchecked exceptions should be thrown only if a bug is present. + However, irregular user input is not a bug. + +

When converting from a {@link hirondelle.web4j.request.RequestParameter} into a building block class, + this class supports only the types supported by the implementation of {@link ConvertParam}. + +

In summary, to work with this class, a Model Object must : +

    +
  • be public +
  • have a public constructor, whose number of arguments matches the number of Object[] params + passed to {@link #build(Class, Object...)} +
  • the constructor is allowed to throw only {@link hirondelle.web4j.model.ModelCtorException} - no + unchecked exceptions should be (knowingly) permitted +
+*/ +public final class ModelFromRequest { + + /*Design Note (for background only) : + The design of this mechanism is a result of the following issues : +
    +
  • model objects (MO's) need to be constructed out of a textual source +
  • that textual source (the HTTP request) is not necessarily the sole + source of data; that is, a MO may be constructed entirely out of the parameters in + a request, or may also be constructed out of an arbitrary combination of both + request params and java objects. For example, a time-stamp may be passed to a + MO constructor alongside other information extracted from the request. +
  • the HTTP request may lack explicit data needed to create a MO. For example, an + unchecked checkbox will not cause a request param to be sent to the server. +
  • users do not always have to make an explicit selection for every field in a form. + This corresponds to a MO constructor having optional arguments, and to absent or empty + request parameters. +
  • error messages should use names meaningful to the user; for example + 'Number of planetoids is not an integer' is preferred over the more + generic 'Item is not an integer'. +
  • since construction of MOs is tedious and repetitive, this class should make + the caller's task as simple as possible. This class should not force the caller to + select particular methods based on the target type of a constructor argument. +
  • error messages should be gathered for all erroneous fields, and presented to the + user in a single listing. This gives the user the chance to make all corrections at once, + instead of in sequence. This class is not completely successful in this regard, since + it is possible, in a few cases, to not see all possible error messages after the first + submission : a ModelCtorException can be thrown first by this class after + a failure to translate into a target type, and then subsequently by the MO + constructor itself. Thus, there are thus two flavours of error message : + 'bad translation from text to type x', and 'bad call to a MO constructor'. +
+ */ + + /** + Constructor. + + @param aRequestParser translates parameter values into Integer, + Date, and so on, using the implementation of {@link ConvertParam}. + */ + public ModelFromRequest(RequestParser aRequestParser){ + fRequestParser = aRequestParser; + fModelCtorException = new ModelCtorException(); + } + + /** + Return a Model Object constructed out of request parameters (and possibly + other Java objects). + + @param aMOClass class of the target Model Object to be built. + @param aCandidateArgs represents the ordered list of items to be passed + to the Model Object's constructor, and can contain null elements. Usually contains {@link RequestParameter} + objects, but may contain objects of any type, as long as they are expected by the target Model Object constructor. + @throws ModelCtorException if either an element of aCandidateArgs + cannot be translated into the target type, or if all such translations succeed, + but the call to the MO constructor itself fails. + */ + public T build(Class aMOClass, Object... aCandidateArgs) throws ModelCtorException { + fLogger.finest("Constructing a Model Object using request param values."); + Constructor ctor = ModelCtorUtil.getConstructor(aMOClass, aCandidateArgs.length); + Class[] targetClasses = ctor.getParameterTypes(); + + List argValues = new ArrayList(); //may contain nulls! + int argIdx = 0; + for( Class targetClass : targetClasses ){ + argValues.add( convertCandidateArg(aCandidateArgs[argIdx], targetClass) ); + ++argIdx; + } + fLogger.finest("Candidate args: " + argValues); + if ( fModelCtorException.isNotEmpty() ) { + fLogger.finest("Failed to convert request param(s) into types expected by ctor."); + throw fModelCtorException; + } + return ModelCtorUtil.buildModelObject(ctor, argValues); + } + + // PRIVATE // + + /** Provides access to the underlying request. */ + private final RequestParser fRequestParser; + + /** + Holds all error messages, for either failed translation of a param into an Object, + or for a failed call to a constructor. + */ + private final ModelCtorException fModelCtorException; + private static final Logger fLogger = Util.getLogger(ModelFromRequest.class); + + private Object convertCandidateArg(Object aCandidateArg, Class aTargetClass){ + Object result = null; + if ( ! (aCandidateArg instanceof RequestParameter) ) { + result = aCandidateArg; + } + else { + RequestParameter reqParam = (RequestParameter)aCandidateArg; + result = translateParam(reqParam, aTargetClass); + } + return result; + } + + private Object translateParam(RequestParameter aReqParam, Class aTargetClass){ + Object result = null; + try { + result = fRequestParser.toSupportedObject(aReqParam, aTargetClass); + } + catch (ModelCtorException ex){ + fModelCtorException.add(ex); + } + return result; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/ModelUtil.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/ModelUtil.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,554 @@ +package hirondelle.web4j.model; + +import hirondelle.web4j.util.Util; + +import java.lang.reflect.Array; + +/** + Collected utilities for overriding {@link Object#toString}, {@link Object#equals}, + and {@link Object#hashCode}, and implementing {@link Comparable}. + +

All Model Objects should override the above {@link Object} methods. + All Model Objects that are being sorted in code should implement {@link Comparable}. + +

In general, it is easier to use this class with object fields (String, Date, + BigDecimal, and so on), instead of primitive fields (int, boolean, and so on). + +

See below for example implementations of : +

+ +

toString()
+ This class is intended for the most common case, where toString is used in + an informal manner (usually for logging and stack traces). That is, + the caller should not rely on the toString() text returned by this class to define program logic. + +

Typical example : +

+  @Override public String toString() {
+    return ModelUtil.toStringFor(this);
+  }
+
+ +

There is one occasional variation, used only when two model objects reference each other. To avoid + a problem with cyclic references and infinite looping, implement as : +

 
+  @Override public String toString() {
+    return ModelUtil.toStringAvoidCyclicRefs(this, Product.class, "getId");
+  }
+ 
+ + Here, the usual behavior is overridden for any method in 'this' object + which returns a Product : instead of calling Product.toString(), + the return value of Product.getId() is used instead. + +

hashCode()
+ Example of the simplest style : +

+  @Override public int hashCode() {
+    return ModelUtil.hashFor(getSignificantFields());
+  }
+  ...
+  private String fName;
+  private Boolean fIsActive;
+  private Object[] getSignificantFields(){
+    //any primitive fields can be placed in a wrapper Object
+    return new Object[]{fName, fIsActive};
+  }
+ 
+ +

Since the {@link Object#equals} and + {@link Object#hashCode} methods are so closely related, and should always refer to the same fields, + defining a private method to return the Object[] of significant fields is highly + recommended. Such a method would be called by both equals and hashCode. + +

If an object is immutable, + then the result may be calculated once, and then cached, as a small performance + optimization : +

+  @Override public int hashCode() {
+    if ( fHashCode == 0 ) {
+      fHashCode = ModelUtil.hashFor(getSignificantFields());
+    }
+    return fHashCode;
+  }
+  ...
+  private String fName;
+  private Boolean fIsActive;
+  private int fHashCode;
+  private Object[] getSignificantFields(){
+    return new Object[]{fName, fIsActive};
+  }
+ 
+ + The most verbose style does not require wrapping primitives in an Object array: +
+  @Override public int hashCode(){
+    int result = ModelUtil.HASH_SEED;
+    //collect the contributions of various fields
+    result = ModelUtil.hash(result, fPrimitive);
+    result = ModelUtil.hash(result, fObject);
+    result = ModelUtil.hash(result, fArray);
+    return result;
+  }
+ 
+ +

equals()
+ Simplest example, in a class called Visit (this is the recommended style): +

+  @Override public boolean equals(Object aThat) {
+    Boolean result = ModelUtil.quickEquals(this, aThat);
+    if ( result == null ){
+      Visit that = (Visit) aThat;
+      result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields());
+    }
+    return result;
+  }
+  ...
+  private final Code fRestaurantCode;
+  private final Date fLunchDate;
+  private final String fMessage;
+  private Object[] getSignificantFields(){
+    return new Object[] {fRestaurantCode, fLunchDate, fMessage};
+  }
+ 
+ + Second example, in a class called Member : +
+  @Override public boolean equals( Object aThat ) {
+    if ( this == aThat ) return true;
+    if ( !(aThat instanceof Member) ) return false;
+    Member that = (Member)aThat;
+    return ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields());
+  }
+  ...
+  private final String fName;
+  private final Boolean fIsActive;
+  private final Code fDisposition;
+  private Object[] getSignificantFields(){
+    return new Object[]{fName, fIsActive, fDisposition};
+  }
+ 
+ See note above regarding
getSignificantFields(). + +

More verbose example, in a class called Planet : +

 
+  @Override public boolean equals(Object aThat){
+    if ( this == aThat ) return true;
+    if ( !(aThat instanceof Planet) ) return false;
+    Planet that = (Planet)aThat;
+    return 
+      EqualsUtil.areEqual(this.fPossiblyNullObject, that.fPossiblyNullObject) &&
+      EqualsUtil.areEqual(this.fCollection, that.fCollection) &&
+      EqualsUtil.areEqual(this.fPrimitive, that.fPrimitive) &&
+      Arrays.equals(this.fArray, that.fArray); //arrays are different!
+  }
+ 
+ +

compareTo()
+ The {@link Comparable} interface is distinct, since it is not an overridable method of the + {@link Object} class. + +

Example use case of using comparePossiblyNull, + (where EQUAL takes the value 0) : +

+  public int compareTo(Movie aThat) {
+    if ( this == aThat ) return EQUAL;
+    
+    int comparison = ModelUtil.comparePossiblyNull(this.fDateViewed, aThat.fDateViewed, NullsGo.LAST);
+    if ( comparison != EQUAL ) return comparison;
+
+    //this field is never null
+    comparison = this.fTitle.compareTo(aThat.fTitle);
+    if ( comparison != EQUAL ) return comparison;
+    
+    comparison = ModelUtil.comparePossiblyNull(this.fRating, aThat.fRating, NullsGo.LAST);
+    if ( comparison != EQUAL ) return comparison;
+   
+    comparison = ModelUtil.comparePossiblyNull(this.fComment, aThat.fComment, NullsGo.LAST);
+    if ( comparison != EQUAL ) return comparison;
+    
+    return EQUAL;
+  }
+ 
+ + @author Hirondelle Systems + @author with a contribution by an anonymous user of javapractices.com +*/ +public final class ModelUtil { + + // TO STRING // + + /** + Implements an override of Object.toString() (see class comment). + +

Example output format, for an Rsvp object with 4 fields : +

+  hirondelle.fish.main.rsvp.Rsvp {
+  Response: null
+  MemberId: 4
+  MemberName: Tom Thumb
+  VisitId: 13
+  }
+   
+ (There is no indentation since it causes problems when there is nesting.) + +

The only items which contribute to the result are : +

    +
  • the full class name +
  • all no-argument public methods which return a value +
+ +

These items are excluded from the result : +

    +
  • methods defined in {@link Object} +
  • factory methods which return an object of the native class ("getInstance()" methods) +
+ +

Reflection is used to access field values. Items are converted to a String simply by calling + their toString method, with the following exceptions : +

    +
  • for arrays, the {@link Util#getArrayAsString(Object)} is used +
  • for methods whose name contains the text "password" (case-insensitive), + their return values hard-coded to '****'. +
+ +

If the method name follows the pattern 'getXXX', then the word 'get' + is removed from the result. + +

WARNING: If two classes have cyclic references + (that is, each has a reference to the other), then infinite looping will result + if both call this method! To avoid this problem, use toStringFor + for one of the classes, and {@link #toStringAvoidCyclicRefs} for the other class. + + @param aObject the object for which a toString() result is required. + */ + public static String toStringFor(Object aObject) { + return ToStringUtil.getText(aObject); + } + + /** + As in {@link #toStringFor}, but avoid problems with cyclic references. + +

Cyclic references occur when one Model Object references another, and both Model Objects have + their toString() methods implemented with this utility class. + +

Behaves as in {@link #toStringFor}, with one exception: for methods of aObject that + return instances of aSpecialClass, then call aMethodName on such instances, + instead of toString(). + */ + public static String toStringAvoidCyclicRefs(Object aObject, Class aSpecialClass, String aMethodName) { + return ToStringUtil.getTextAvoidCyclicRefs(aObject, aSpecialClass, aMethodName); + } + + // HASH CODE // + + /** + Return the hash code in a single step, using all significant fields passed in an {@link Object} sequence parameter. + +

(This is the recommended way of implementing hashCode.) + +

Each element of aFields must be an {@link Object}, or an array containing + possibly-null Objects. These items will each contribute to the + result. (It is not a requirement to use all fields related to an object.) + +

If the caller is using a primitive field, then it must be converted to a corresponding + wrapper object to be included in aFields. For example, an int field would need + conversion to an {@link Integer} before being passed to this method. + */ + public static final int hashCodeFor(Object... aFields){ + int result = HASH_SEED; + for(Object field: aFields){ + result = hash(result, field); + } + return result; + } + + /** + Initial seed value for a hashCode. + + Contributions from individual fields are 'added' to this initial value. + (Using a non-zero value decreases collisons of hashCode values.) + */ + public static final int HASH_SEED = 23; + + /** Hash code for boolean primitives. */ + public static int hash( int aSeed, boolean aBoolean ) { + return firstTerm( aSeed ) + ( aBoolean ? 1 : 0 ); + } + + /** Hash code for char primitives. */ + public static int hash( int aSeed, char aChar ) { + return firstTerm( aSeed ) + aChar; + } + + /** + Hash code for int primitives. +

Note that byte and short are also handled by this method, through implicit conversion. + */ + public static int hash( int aSeed , int aInt ) { + return firstTerm( aSeed ) + aInt; + } + + /** Hash code for long primitives. */ + public static int hash( int aSeed , long aLong ) { + return firstTerm(aSeed) + (int)( aLong ^ (aLong >>> 32) ); + } + + /** Hash code for float primitives. */ + public static int hash( int aSeed , float aFloat ) { + return hash( aSeed, Float.floatToIntBits(aFloat) ); + } + + /** Hash code for double primitives. */ + public static int hash( int aSeed , double aDouble ) { + return hash( aSeed, Double.doubleToLongBits(aDouble) ); + } + + /** + Hash code for an Object. + +

aObject is a possibly-null object field, and possibly an array. + +

Arrays can contain possibly-null objects or primitives; no circular references are permitted. + */ + public static int hash(int aSeed , Object aObject) { + int result = aSeed; + if (aObject == null) { + result = hash(result, 0); + } + else if ( ! isArray(aObject) ) { + //ordinary objects + result = hash(result, aObject.hashCode()); + } + else { + int length = Array.getLength(aObject); + for ( int idx = 0; idx < length; ++idx ) { + Object item = Array.get(aObject, idx); //wraps any primitives! + //recursive call - no circular object graphs allowed + result = hash(result, item); + } + } + return result; + } + + // EQUALS // + + /** + Quick checks for possibly determining equality of two objects. + +

This method exists to make equals implementations read more legibly, + and to avoid multiple return statements. + +

It cannot be used by itself to fully implement equals. + It uses == and instanceof to determine if equality can be + found cheaply, without the need to examine field values in detail. It is + always paired with some other method + (usually {@link #equalsFor(Object[], Object[])}), as in the following example : +

+   public boolean equals(Object aThat){
+     Boolean result = ModelUtil.quickEquals(this, aThat);
+     if ( result == null ){
+       //quick checks not sufficient to determine equality,
+       //so a full field-by-field check is needed :
+       This this = (This) aThat; //will not fail 
+       result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields());
+     }
+     return result;
+   }
+   
+ +

This method is unusual since it returns a Boolean that takes + 3 values : true, false, and null. Here, + true and false mean that a simple quick check was able to + determine equality. The null case means that the + quick checks were not able to determine if the objects are equal or not, and that + further field-by-field examination is necessary. The caller must always perform a + check-for-null on the return value. + */ + static public Boolean quickEquals(Object aThis, Object aThat){ + Boolean result = null; + if ( aThis == aThat ) { + result = Boolean.TRUE; + } + else { + Class thisClass = aThis.getClass(); + if ( ! thisClass.isInstance(aThat) ) { + result = Boolean.FALSE; + } + } + return result; + } + + /** + Return the result of comparing all significant fields. + +

Both Object[] parameters are the same size. Each includes all fields that have been + deemed by the caller to contribute to the equals method. None of those fields are + array fields. The order is the same in both arrays, in the sense that the Nth item + in each array corresponds to the same underlying field. The caller controls the order in which fields are + compared simply through the iteration order of these two arguments. + +

If a primitive field is significant, then it must be converted to a corresponding + wrapper Object by the caller. + */ + static public boolean equalsFor(Object[] aThisSignificantFields, Object[] aThatSignificantFields){ + //(varargs can be used for final arg only) + if (aThisSignificantFields.length != aThatSignificantFields.length) { + throw new IllegalArgumentException( + "Array lengths do not match. 'This' length is " + aThisSignificantFields.length + + ", while 'That' length is " + aThatSignificantFields.length + "." + ); + } + + boolean result = true; + for(int idx=0; idx < aThisSignificantFields.length; ++idx){ + if ( ! areEqual(aThisSignificantFields[idx], aThatSignificantFields[idx]) ){ + result = false; + break; + } + } + return result; + } + + /** Equals for boolean fields. */ + static public boolean areEqual(boolean aThis, boolean aThat){ + return aThis == aThat; + } + + /** Equals for char fields. */ + static public boolean areEqual(char aThis, char aThat){ + return aThis == aThat; + } + + /** + Equals for long fields. + +

Note that byte, short, and int are handled by this method, through + implicit conversion. + */ + static public boolean areEqual(long aThis, long aThat){ + return aThis == aThat; + } + + /** Equals for float fields. */ + static public boolean areEqual(float aThis, float aThat){ + return Float.floatToIntBits(aThis) == Float.floatToIntBits(aThat); + } + + /** Equals for double fields. */ + static public boolean areEqual(double aThis, double aThat){ + return Double.doubleToLongBits(aThis) == Double.doubleToLongBits(aThat); + } + + /** + Equals for an Object. +

The objects are possibly-null, and possibly an array. +

Arrays can contain possibly-null objects or primitives; no circular references are permitted. + */ + static public boolean areEqual(Object aThis, Object aThat){ + if (aThis == aThat){ + return true; + } + if (aThis == null || aThat == null){ + return (aThis == null && aThat == null); + } + + boolean result = false; + if (! isArray(aThis)){ + //ordinary objects + result = aThis.equals(aThat); + } + else { + //two arrays, with possible sub-structure + int length = Array.getLength(aThis); + for (int idx = 0; idx < length; ++idx) { + Object thisItem = Array.get(aThis, idx); //wraps any primitives! + Object thatItem = Array.get(aThat, idx); //wraps any primitives! + //recursive call - no circular object graphs allowed + result = areEqual(thisItem, thatItem); + } + } + return result; + } + + + //Comparable + + /** + Define hows null items are treated in a comparison. Controls if null + items appear first or last. + +

See comparePossiblyNull. + */ + public enum NullsGo {FIRST,LAST} + + /** + Utility for implementing {@link Comparable}. See class example + for illustration. + +

The {@link Comparable} interface specifies that +

+   blah.compareTo(null)
+   
should throw a {@link NullPointerException}. You should follow that + guideline. Note that this utility method itself + accepts nulls without throwing a {@link NullPointerException}. + In this way, this method can handle nullable fields just like any other field. + +

There are + special issues + for sorting {@link String}s regarding case, {@link java.util.Locale}, + and accented characters. + + @param aThis an object that implements {@link Comparable} + @param aThat an object of the same type as aThis + @param aNullsGo defines if null items should be placed first or last + */ + static public > int comparePossiblyNull(T aThis, T aThat, NullsGo aNullsGo){ + int EQUAL = 0; + int BEFORE = -1; + int AFTER = 1; + int result = EQUAL; + + if(aThis != null && aThat != null){ + result = aThis.compareTo(aThat); + } + else { + //at least one reference is null - special handling + if(aThis == null && aThat == null) { + //not distinguishable, so treat as equal + } + else if(aThis == null && aThat != null) { + result = BEFORE; + } + else if( aThis != null && aThat == null) { + result = AFTER; + } + if(NullsGo.LAST == aNullsGo){ + result = (-1) * result; + } + } + return result; + } + + // PRIVATE // + + private ModelUtil(){ + //prevent object construction + } + + private static final int fODD_PRIME_NUMBER = 37; + + private static int firstTerm( int aSeed ){ + return fODD_PRIME_NUMBER * aSeed; + } + + private static boolean isArray(Object aObject){ + return aObject != null && aObject.getClass().isArray(); + } + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/TESTCheck.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/TESTCheck.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,286 @@ +package hirondelle.web4j.model; + +import hirondelle.web4j.security.SafeText; +import java.util.*; +import java.math.*; +import java.util.regex.*; +import junit.framework.*; +import java.io.FileNotFoundException; + +public final class TESTCheck extends TestCase { + + /** Run the test cases. */ + public static void main(String args[]) throws AppException, FileNotFoundException { + String[] testCaseName = { TESTCheck.class.getName() }; + junit.textui.TestRunner.main(testCaseName); + } + + public TESTCheck( String aName) { + super(aName); + } + + // TEST CASES // + + public void testRequired(){ + Object thing = null; + assertFalse(Check.required(thing)); + thing = new Object(); + assertTrue(Check.required(thing)); + } + + public void testIntegerRange(){ + Validator[] validators = {Check.min(1), Check.max(10)}; + assertTrue(Check.required(new Integer(1), validators)); + assertTrue(Check.required(new Integer(2), validators)); + assertTrue(Check.required(new Integer(10), validators)); + + assertFalse(Check.required(new Integer(0), validators)); + assertFalse(Check.required(new Integer(-1), validators)); + assertFalse(Check.required(new Integer(-50), validators)); + assertFalse(Check.required(new Integer(11), validators)); + assertFalse(Check.required(new Integer(50), validators)); + } + + public void testLongRange(){ + Validator[] validators = {Check.min(1L), Check.max(10L)}; + assertTrue(Check.required(new Long(1L), validators)); + assertTrue(Check.required(new Long(2L), validators)); + assertTrue(Check.required(new Long(10L), validators)); + assertFalse(Check.required(new Long(0L), validators)); + assertFalse(Check.required(new Long(-1L), validators)); + assertFalse(Check.required(new Long(-50L), validators)); + assertFalse(Check.required(new Long(11L), validators)); + assertFalse(Check.required(new Long(50L), validators)); + } + + public void testString(){ + Validator[] validators = {Check.min(1), Check.max(10)}; + assertTrue(Check.required("1", validators)); + assertTrue(Check.required("123", validators)); + assertTrue(Check.required("123456789", validators)); + assertTrue(Check.required("1234567890", validators)); + assertTrue(Check.required("This is th", validators)); + assertTrue(Check.required("This is", validators)); + + assertFalse(Check.required(null, validators)); + assertFalse(Check.required("", validators)); + assertFalse(Check.required(" ", validators)); + assertFalse(Check.required(" ", validators)); + } + + public void testId(){ + Validator[] validators = {Check.min(1), Check.max(10)}; + assertTrue(Check.required(new Id("1"), validators)); + assertTrue(Check.required(new Id("123"), validators)); + assertTrue(Check.required(new Id("123456789"), validators)); + assertTrue(Check.required(new Id("1234567890"), validators)); + assertTrue(Check.required(new Id("Yes What"), validators)); + + assertFalse(Check.required(null, validators)); + assertFalse(Check.required(new Id("12345678901"), validators)); + assertFalse(Check.required(new Id(""), validators)); + } + + public void testFreeFormText(){ + Validator[] validators = {Check.min(1), Check.max(10)}; + assertTrue(Check.required(new SafeText("1"), validators)); + assertTrue(Check.required(new SafeText("123"), validators)); + assertTrue(Check.required(new SafeText("123456789"), validators)); + assertTrue(Check.required(new SafeText("1234567890"), validators)); + assertTrue(Check.required(new SafeText("Yes What"), validators)); + assertTrue(Check.required(new SafeText("Yes"), validators)); + assertTrue(Check.required(new SafeText("That's it."), validators)); + assertTrue(Check.required(new SafeText("& co."), validators)); + + assertFalse(Check.required(null, validators)); + assertFalse(Check.required(new SafeText("12345678901"), validators)); + assertFalse(Check.required(new SafeText(""), validators)); + } + + + public void testCollection() { + Validator[] validators = {Check.min(1), Check.max(3)}; + assertTrue(Check.required(getList("1"), validators)); + assertTrue(Check.required(getList("1", "2"), validators)); + assertTrue(Check.required(getList("1", "2", "3"), validators)); + + assertFalse(Check.required(null, validators)); + assertFalse(Check.required(Collections.EMPTY_LIST, validators)); + assertFalse(Check.required(getList("1", "2", "3", "4"), validators)); + } + + public void testMap() { + Validator[] validators = {Check.min(1), Check.max(3)}; + Map map = new HashMap(); + map.put("A", "1"); + assertTrue(Check.required(map, validators)); + map.put("B", "2"); + assertTrue(Check.required(map, validators)); + map.put("C", "3"); + assertTrue(Check.required(map, validators)); + + assertFalse(Check.required(null, validators)); + assertFalse(Check.required(Collections.EMPTY_MAP, validators)); + map.put("D", "4"); + assertFalse(Check.required(map, validators)); + } + + public void testDate() { + Date start = new Date(62,2,1); + Date end = new Date(62,2,3); + Validator[] validators = {Check.min(start.getTime()), Check.max(end.getTime())}; + Date test = new Date(62,2,1); + assertTrue(Check.required(test, validators)); + test = new Date(62,2,2); + assertTrue(Check.required(test, validators)); + test = new Date(62,2,3); + assertTrue(Check.required(test, validators)); + + assertFalse(Check.required(null, validators)); + test = new Date(62,1,28); + assertFalse(Check.required(test, validators)); + test = new Date(62,2,4); + assertFalse(Check.required(test, validators)); + } + + public void testCalendar() { + Calendar start = new GregorianCalendar(); + start.set(2006, 8, 9); + Calendar end = new GregorianCalendar(); + end.set(2006, 8, 11); + + Validator[] validators = {Check.min(start.getTimeInMillis()), Check.max(end.getTimeInMillis())}; + Calendar test = new GregorianCalendar(); + test.set(2006,8,9); + assertTrue(test.toString(), Check.required(test, validators)); + test.set(2006,8,10); + assertTrue(test.toString(), Check.required(test, validators)); + test.set(2006,8,11); + assertTrue(test.toString(), Check.required(test, validators)); + + assertFalse("Versus null", Check.required(null, validators)); + test.set(2006,8,8); + assertFalse(test.toString(), Check.required(test, validators)); + test.set(2006,8,12); + assertFalse(test.toString(), Check.required(test, validators)); + } + + public void testOptional(){ + Validator[] validators = {Check.min(1), Check.max(10)}; + assertTrue(Check.optional(null, validators)); + assertTrue(Check.optional(new Integer(1), validators)); + assertTrue(Check.optional(new Integer(2), validators)); + assertTrue(Check.optional(new Integer(10), validators)); + + assertFalse(Check.optional(new Integer(0), validators)); + assertFalse(Check.optional(new Integer(-1), validators)); + assertFalse(Check.optional(new Integer(-50), validators)); + assertFalse(Check.optional(new Integer(11), validators)); + assertFalse(Check.optional(new Integer(50), validators)); + } + + public void testBigDecimal(){ + Validator[] validators = {Check.min(new BigDecimal("1.25")), Check.max(new BigDecimal("10.75")) }; + assertTrue(Check.required(new BigDecimal("1.25"), validators)); + assertTrue(Check.required(new BigDecimal("10.75"), validators)); + assertTrue(Check.required(new BigDecimal("1.26"), validators)); + assertTrue(Check.required(new BigDecimal("10.74"), validators)); + assertTrue(Check.required(new BigDecimal("9"), validators)); + + assertFalse(Check.required(new BigDecimal("1.24"), validators)); + assertFalse(Check.required(new BigDecimal("10.750001"), validators)); + assertFalse(Check.required(new BigDecimal("1.249999"), validators)); + assertFalse(Check.required(new BigDecimal("1"), validators)); + assertFalse(Check.required(new BigDecimal("10.76"), validators)); + assertFalse(Check.required(new BigDecimal("-1"), validators)); + assertFalse(Check.required(new BigDecimal("-1.25"), validators)); + assertFalse(Check.required(new BigDecimal("1000"), validators)); + } + + public void testPattern(){ + Pattern idPattern = Pattern.compile("(\\d){4}-(\\d){4}"); + Validator validator = Check.pattern(idPattern); + assertTrue(Check.required("1234-1234", validator)); + assertTrue(Check.required("9845-9852", validator)); + assertTrue(Check.required("0000-0000", validator)); + assertTrue(Check.required("9999-9999", validator)); + + assertFalse(Check.required(null, validator)); + assertFalse(Check.required("12345678", validator)); + assertFalse(Check.required("", validator)); + assertFalse(Check.required(" ", validator)); + assertFalse(Check.required("A", validator)); + assertFalse(Check.required("A234-9955", validator)); + assertFalse(Check.required("Blah", validator)); + assertFalse(Check.required("1234-5678 ", validator)); + assertFalse(Check.required(" 1234-5678", validator)); + assertFalse(Check.required("1 234-5678", validator)); + assertFalse(Check.required("1234- 5678", validator)); + assertFalse(Check.required("1234 - 5678", validator)); + assertFalse(Check.required("1234 -5678", validator)); + } + + public void testCheckEmail(){ + Validator validator = Check.email(); + assertTrue(Check.required("john@blah.com", validator)); + assertTrue(Check.required("x@y.c", validator)); + assertTrue(Check.required("john@blah", validator)); + assertTrue(Check.required("john@blah.com ", validator)); + assertTrue(Check.required(" john@blah.com", validator)); + + assertFalse(Check.required("john", validator)); + assertFalse(Check.required(null, validator)); + assertFalse(Check.required(" ", validator)); + assertFalse(Check.required("", validator)); + assertFalse(Check.required("john@", validator)); + //These are actually OK : + assertFalse(Check.required("john @blah.com", validator)); + assertFalse(Check.required("john@ blah.com", validator)); + assertFalse(Check.required("john@blah. com", validator)); + assertFalse(Check.required("john@blah .com", validator)); + } + + public void testLongRange2(){ + Validator validator = Check.range(1,10); + assertTrue( validator.isValid(new Long(1))); + assertTrue( validator.isValid(new Long(2))); + assertTrue( validator.isValid(new Long(9))); + assertTrue( validator.isValid(new Long(10))); + assertTrue( Check.required(new Long(5), validator)); + + assertFalse( validator.isValid(new Long(0))); + assertFalse( validator.isValid(new Long(11))); + assertFalse( Check.required(new Long(0), validator)); + } + + public void testRangeBigDecimal(){ + Validator validator = Check.range(new BigDecimal("0.01"), new BigDecimal("10.00")); + assertTrue( Check.required( new BigDecimal("0.01"), validator)); + assertTrue( Check.required( new BigDecimal("0.02"), validator)); + assertTrue( Check.required( new BigDecimal("1.00"), validator)); + assertTrue( Check.required( new BigDecimal("5.00"), validator)); + assertTrue( Check.required( new BigDecimal("9.99"), validator)); + assertTrue( Check.required( new BigDecimal("10.00"), validator)); + + assertFalse( Check.required( new BigDecimal("0.00"), validator)); + assertFalse( Check.required( new BigDecimal("10.01"), validator)); + assertFalse( Check.required( new BigDecimal("1000.00"), validator)); + assertFalse( Check.required( new BigDecimal("-0.00"), validator)); + assertFalse( Check.required( new BigDecimal("-0.01"), validator)); + } + + + // FIXTURE // + + protected void setUp(){ + } + + protected void tearDown() { + } + + // PRIVATE // + + private List getList(String... aStrings){ + return Arrays.asList(aStrings); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/TESTComparePossiblyNull.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/TESTComparePossiblyNull.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,194 @@ +package hirondelle.web4j.model; + +import java.util.*; +import java.io.FileNotFoundException; +import java.math.*; +import java.util.regex.*; +import junit.framework.*; +import static hirondelle.web4j.model.ModelUtil.NullsGo; + +public class TESTComparePossiblyNull extends TestCase { + + /** Run the test cases. */ + public static void main(String args[]) throws AppException, FileNotFoundException { + String[] testCaseName = { TESTComparePossiblyNull.class.getName() }; + junit.textui.TestRunner.main(testCaseName); + } + + public TESTComparePossiblyNull( String aName) { + super(aName); + } + + + // TEST CASES // + + public void testStrings(){ + assertTrue(compareStringsNullsLast("aaa", "bbb", BEFORE)); + assertTrue(compareStringsNullsLast("ccc", "bbb", AFTER)); + assertTrue(compareStringsNullsLast("ccc", null , BEFORE)); + assertTrue(compareStringsNullsLast(null, "ccc" , AFTER)); + assertTrue(compareStringsNullsLast("ccc", "ccc", EQUAL)); + assertTrue(compareStringsNullsLast("ccc", "CCC", AFTER)); + + assertTrue(compareStringsNullsFirst("aaa", "bbb", BEFORE)); + assertTrue(compareStringsNullsFirst("ccc", "bbb", AFTER)); + assertTrue(compareStringsNullsFirst("ccc", null , AFTER)); + assertTrue(compareStringsNullsFirst(null, "ccc" , BEFORE)); + assertTrue(compareStringsNullsFirst("ccc", "ccc", EQUAL)); + assertTrue(compareStringsNullsFirst("ccc", "CCC", AFTER)); + } + + public void testTypicalObject(){ + Person bob = new Person(); + assertTrue(compareObjectsNullsLast(bob, bob, EQUAL)); + + Person peter = new Person(); + peter.setName("Peter"); + assertTrue(compareObjectsNullsLast(bob, peter, BEFORE)); + + Person andrea = new Person(); + andrea.setName("Andrea"); + assertTrue(compareObjectsNullsLast(bob, andrea, AFTER)); + assertTrue(compareObjectsNullsLast(andrea, bob, BEFORE)); + + assertTrue(compareObjectsNullsLast(bob, null, BEFORE)); + assertTrue(compareObjectsNullsLast(null, bob, AFTER)); + + Person noName = new Person(); + noName.setName(null); + assertTrue(compareObjectsNullsLast(bob, noName, BEFORE)); + assertTrue(compareObjectsNullsLast(noName, bob, AFTER)); + + Person bobTwo = new Person(); + bobTwo.setName("Bob"); + assertTrue(compareObjectsNullsLast(bob, bobTwo, EQUAL)); + assertTrue(compareObjectsNullsLast(bobTwo, bob, EQUAL)); + + Person bobOld = new Person(); + bobOld.setBirthDate(new Date(22,2,1)); + assertTrue(compareObjectsNullsLast(bob, bobOld, AFTER)); + assertTrue(compareObjectsNullsLast(bobOld, bob, BEFORE)); + + Person bobSame = new Person(); + bobSame.setBirthDate(new Date(45,3,3)); + assertTrue(compareObjectsNullsLast(bob, bobSame, EQUAL)); + assertTrue(compareObjectsNullsLast(bobSame, bob, EQUAL)); + + Person bobYoung = new Person(); + bobYoung.setBirthDate(new Date(63,2,1)); + assertTrue(compareObjectsNullsLast(bob, bobYoung, BEFORE)); + + Person bobYoung2 = new Person(); + bobYoung2.setAge(10); + assertTrue(compareObjectsNullsLast(bob, bobYoung2, AFTER)); + + Person bobOld2 = new Person(); + bobOld2.setAge(100); + assertTrue(compareObjectsNullsLast(bob, bobOld2, BEFORE)); + + Person poor = new Person(); + poor.setSalary(new BigDecimal("10")); + assertTrue(compareObjectsNullsLast(bob, poor, AFTER)); + + Person rich = new Person(); + rich.setSalary(new BigDecimal("1000")); + assertTrue(compareObjectsNullsLast(bob, rich, BEFORE)); + } + + //test how a collection will sort items? worth it... + public void testCollection(){ + Set items = new TreeSet(); //sorts the items + + Person bob = new Person(); + + Person andrea = new Person(); + andrea.setName("Andrea"); + + Person noName = new Person(); + noName.setName(""); + + Person nobody = new Person(); + nobody.setName(null); + + items.add(bob); + items.add(andrea); + items.add(noName); + items.add(nobody); + + //Informal : just print it out + //System.out.println(items); + } + + // PRIVATE // + private static final int BEFORE = -1; + private static final int AFTER = 1; + private static final int EQUAL = 0; + + private boolean compareStringsNullsLast(String aThis, String aThat, int aExpectedResult) { + int result = ModelUtil.comparePossiblyNull(aThis, aThat, NullsGo.LAST); + return sign(result) == aExpectedResult; + } + + private boolean compareStringsNullsFirst(String aThis, String aThat, int aExpectedResult) { + int result = ModelUtil.comparePossiblyNull(aThis, aThat, NullsGo.FIRST); + return sign(result) == aExpectedResult; + } + + private boolean compareObjectsNullsLast(Comparable aThis, Comparable aThat, int aExpectedResult) { + int result = ModelUtil.comparePossiblyNull(aThis, aThat, NullsGo.LAST); + return sign(result) == aExpectedResult; + } + + private int sign(int aNumber){ + int result = 0; + if (aNumber > 0){ + result = 1; + } + else if (aNumber < 0){ + result = -1; + } + return result; + } + + private static final class Person implements Comparable { + Person(String aName, Integer aAge, Date aBirthDate, BigDecimal aSalary){ + fName = aName; + fAge = aAge; + fBirthDate = aBirthDate; + fSalary = aSalary; + } + Person(){ + fName = "Bob"; + fAge = new Integer(65); + fBirthDate = new Date(45,3,3); + fSalary = new BigDecimal("123.45"); + } + void setName(String aName){ fName = aName; } + void setAge(Integer aAge){ fAge = aAge; } + void setBirthDate(Date aBirthDate){ fBirthDate = aBirthDate; } + void setSalary(BigDecimal aSalary){ fSalary = aSalary; } + public String toString() { return fName; } + public int compareTo(Person aThat) { + if ( this == aThat ) return EQUAL; + + int comparison = ModelUtil.comparePossiblyNull(this.fName, aThat.fName, NullsGo.LAST); + if ( comparison != EQUAL ) return comparison; + + comparison = ModelUtil.comparePossiblyNull(this.fAge, aThat.fAge, NullsGo.LAST); + if ( comparison != EQUAL ) return comparison; + + comparison = ModelUtil.comparePossiblyNull(this.fBirthDate, aThat.fBirthDate, NullsGo.LAST); + if ( comparison != EQUAL ) return comparison; + + comparison = ModelUtil.comparePossiblyNull(this.fSalary, aThat.fSalary, NullsGo.LAST); + if ( comparison != EQUAL ) return comparison; + + return EQUAL; + } + //assume all these can be null + private String fName; + private Integer fAge; + private Date fBirthDate; + private BigDecimal fSalary; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/TESTDateTime.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/TESTDateTime.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,1157 @@ +package hirondelle.web4j.model; + +import hirondelle.web4j.util.Util; +import java.util.TimeZone; +import junit.framework.TestCase; + +/** JUnit tests. */ +public final class TESTDateTime extends TestCase { + + /** Run the test cases. */ + public static void main(String args[]) { + String[] testCaseName = { TESTDateTime.class.getName() }; + junit.textui.TestRunner.main(testCaseName); + } + + public TESTDateTime( String aName) { + super(aName); + } + + // TEST CASES + + public void testCtorWithStringStandardFormat(){ + //note that you can pass just about anything to the constructor - except null + testStandardFormatCtorSuccess("A"); + testStandardFormatCtorSuccess("ABC"); + testStandardFormatCtorSuccess(""); + testStandardFormatCtorSuccess(" "); + testStandardFormatCtorSuccess("2009"); + testStandardFormatCtorSuccess("2009-01"); + testStandardFormatCtorSuccess("2009-01-01"); + testStandardFormatCtorSuccess("2009-01-01 23"); + testStandardFormatCtorSuccess("2009-01-01 23:40"); + testStandardFormatCtorSuccess("2009-01-01 23:40:19"); + testStandardFormatCtorSuccess("2009-01-01 23:40:19.123456789"); + testStandardFormatCtorFail(null); + + //accepts with (ISO-8601) AND without (human hand) leading zeroes. + testStandardFormatCtorPlusParseSuccess("0009-01-01 23:40:19.123456789", 9, 1, 1, 23, 40, 19, 123456789) ; + testStandardFormatCtorPlusParseSuccess("9-01-01 23:40:19.123456789", 9, 1, 1, 23, 40, 19, 123456789) ; + + testStandardFormatCtorPlusParseSuccess("2009-01-01 23:40:19.123456789", 2009, 1, 1, 23, 40, 19, 123456789) ; + testStandardFormatCtorPlusParseSuccess("2009-01-01 23:40:19.12345678", 2009, 1, 1, 23, 40, 19, 123456780) ; + testStandardFormatCtorPlusParseSuccess("2009-01-01 23:40:19.1234567", 2009, 1, 1, 23, 40, 19, 123456700) ; + testStandardFormatCtorPlusParseSuccess("2009-01-01 23:40:19.123456", 2009, 1, 1, 23, 40, 19, 123456000) ; + testStandardFormatCtorPlusParseSuccess("2009-01-01 23:40:19.12345", 2009, 1, 1, 23, 40,19,123450000); + testStandardFormatCtorPlusParseSuccess("2009-01-01 23:40:19.1234", 2009, 1, 1, 23, 40,19,123400000); + testStandardFormatCtorPlusParseSuccess("2009-01-01 23:40:19.123", 2009, 1, 1, 23, 40,19,123000000); + testStandardFormatCtorPlusParseSuccess("2009-01-01 23:40:19.12", 2009, 1, 1, 23, 40,19,120000000); + testStandardFormatCtorPlusParseSuccess("2009-01-01 23:40:19.1", 2009, 1, 1, 23, 40,19,100000000); + testStandardFormatCtorPlusParseSuccess("2009-01-01 23:40:19", 2009, 1, 1, 23, 40,19,null); + testStandardFormatCtorPlusParseSuccess("2009-01-01 23:40", 2009, 1, 1, 23, 40,null,null); + testStandardFormatCtorPlusParseSuccess("2009-01-01 23", 2009, 1, 1, 23, null,null,null); + + testStandardFormatCtorPlusParseSuccess("2009-01-01", 2009, 1, 1, null, null,null,null); + testStandardFormatCtorPlusParseSuccess("2009-01", 2009, 1, null, null,null,null,null); + testStandardFormatCtorPlusParseSuccess("2009", 2009, null, null, null, null,null,null); + testStandardFormatCtorPlusParseSuccess("1", 1, null, null, null, null,null,null); + testStandardFormatCtorPlusParseSuccess("0001", 1, null, null, null, null,null,null); + + testStandardFormatCtorPlusParseSuccess("23:40:19.123456789", null, null, null, 23, 40, 19, 123456789) ; + testStandardFormatCtorPlusParseSuccess("23:40:19.12345678", null, null, null, 23, 40, 19, 123456780) ; + testStandardFormatCtorPlusParseSuccess("23:40:19.1234567", null, null, null, 23, 40, 19, 123456700) ; + testStandardFormatCtorPlusParseSuccess("23:40:19.123456", null, null, null, 23, 40, 19, 123456000) ; + testStandardFormatCtorPlusParseSuccess("23:40:19.12345", null, null, null, 23, 40, 19, 123450000) ; + testStandardFormatCtorPlusParseSuccess("23:40:19.1234", null, null, null, 23, 40, 19, 123400000) ; + testStandardFormatCtorPlusParseSuccess("23:40:19.123", null, null, null, 23, 40, 19, 123000000) ; + testStandardFormatCtorPlusParseSuccess("23:40:19.12", null, null, null, 23, 40, 19, 120000000) ; + testStandardFormatCtorPlusParseSuccess("23:40:19.1", null, null, null, 23, 40, 19, 100000000) ; + testStandardFormatCtorPlusParseSuccess("23:40:19", null, null, null, 23, 40, 19, null) ; + testStandardFormatCtorPlusParseSuccess("23:40", null, null, null, 23, 40, null, null) ; + + testStandardFormatCtorPlusParseFail("A") ; + testStandardFormatCtorPlusParseFail(" ") ; + testStandardFormatCtorPlusParseFail("") ; + testStandardFormatCtorPlusParseFail("12345-01-01 23:40:19.123456") ; + testStandardFormatCtorPlusParseFail("0-01-01 23:40:19.123456") ; + testStandardFormatCtorPlusParseFail("2009-01-01 12h:40:19.123456") ; + testStandardFormatCtorPlusParseFail("2009-01-01 12 pm") ; + testStandardFormatCtorPlusParseFail("2009-01-01 45:40:60") ; + testStandardFormatCtorPlusParseFail("2009-01-01 16:40:19.1234567890") ; + testStandardFormatCtorPlusParseFail("2009-01-01 24:40:19.123456") ; + testStandardFormatCtorPlusParseFail("2009-01-01 16:60:19.123456") ; + testStandardFormatCtorPlusParseFail("2009-13-01 16:40:19.123456") ; + testStandardFormatCtorPlusParseFail("2009-01-32 16:40:19.123456") ; + testStandardFormatCtorPlusParseFail("-1-01-01 16:40:19.123456") ; + + testStandardFormatCtorPlusParseFail("2009-1-01 16:40:19.123456") ; + testStandardFormatCtorPlusParseFail("2009-01-1 16:40:19.123456") ; + testStandardFormatCtorPlusParseFail("2009-01-01 6:40:19.123456") ; + testStandardFormatCtorPlusParseFail("2009-01-01 16:0:19.123456") ; + testStandardFormatCtorPlusParseFail("2009-01-01 16:40:1.123456") ; + + } + + public void testCtorWithStringStandardFormatWithT(){ + testStandardFormatCtorPlusParseSuccess("2009-01-01T23:40:19.123456789", 2009, 1, 1, 23, 40, 19, 123456789) ; + testStandardFormatCtorPlusParseSuccess("2009-01-01T23:40:19.12345678", 2009, 1, 1, 23, 40, 19, 123456780) ; + testStandardFormatCtorPlusParseSuccess("2009-01-01T23:40:19.1234567", 2009, 1, 1, 23, 40, 19, 123456700) ; + testStandardFormatCtorPlusParseSuccess("2009-01-01T23:40:19.123456", 2009, 1, 1, 23, 40, 19, 123456000) ; + testStandardFormatCtorPlusParseSuccess("2009-01-01T23:40:19.12345", 2009, 1, 1, 23, 40,19,123450000); + testStandardFormatCtorPlusParseSuccess("2009-01-01T23:40:19.1234", 2009, 1, 1, 23, 40,19,123400000); + testStandardFormatCtorPlusParseSuccess("2009-01-01T23:40:19.123", 2009, 1, 1, 23, 40,19,123000000); + testStandardFormatCtorPlusParseSuccess("2009-01-01T23:40:19.12", 2009, 1, 1, 23, 40,19,120000000); + testStandardFormatCtorPlusParseSuccess("2009-01-01T23:40:19.1", 2009, 1, 1, 23, 40,19,100000000); + testStandardFormatCtorPlusParseSuccess("2009-01-01T23:40:19", 2009, 1, 1, 23, 40,19,null); + testStandardFormatCtorPlusParseSuccess("2009-01-01T23:40", 2009, 1, 1, 23, 40,null,null); + testStandardFormatCtorPlusParseSuccess("2009-01-01T23", 2009, 1, 1, 23, null,null,null); + + testStandardFormatCtorPlusParseSuccess("0009-01-01T23:40:19.123456789", 9, 1, 1, 23, 40, 19, 123456789) ; + testStandardFormatCtorPlusParseSuccess("9-01-01T23:40:19.123456789", 9, 1, 1, 23, 40, 19, 123456789) ; + + testStandardFormatCtorPlusParseFail("2009-01-01A23:40:19.123456789") ; + testStandardFormatCtorPlusParseFail("12345-01-01T23:40:19.123456") ; + testStandardFormatCtorPlusParseFail("0-01-01T23:40:19.123456") ; + testStandardFormatCtorPlusParseFail("2009-01-01T12h:40:19.123456") ; + testStandardFormatCtorPlusParseFail("2009-01-01T12 pm") ; + testStandardFormatCtorPlusParseFail("2009-01-01T45:40:60") ; + testStandardFormatCtorPlusParseFail("2009-01-01T16:40:19.1234567890") ; + testStandardFormatCtorPlusParseFail("2009-01-01T24:40:19.123456") ; + testStandardFormatCtorPlusParseFail("2009-01-01T16:60:19.123456") ; + testStandardFormatCtorPlusParseFail("2009-13-01T16:40:19.123456") ; + testStandardFormatCtorPlusParseFail("2009-01-32T16:40:19.123456") ; + testStandardFormatCtorPlusParseFail("-1-01-01T16:40:19.123456") ; + + testStandardFormatCtorPlusParseFail("2009-1-01T16:40:19.123456") ; + testStandardFormatCtorPlusParseFail("2009-01-1T16:40:19.123456") ; + testStandardFormatCtorPlusParseFail("2009-01-01T6:40:19.123456") ; + testStandardFormatCtorPlusParseFail("2009-01-01T16:0:19.123456") ; + testStandardFormatCtorPlusParseFail("2009-01-01T16:40:1.123456") ; + } + + public void testParseable(){ + testParseable(SUCCESS, "2000-01-01 01:01:01.000000000"); + testParseable(SUCCESS, "2000-01-01"); + testParseable(SUCCESS, "01:01:01.000000000"); + + + testParseable(SUCCESS, "2009-12-31 00:00:00.123456789"); + testParseable(SUCCESS, "2009-12-31T00:00:00.123456789"); + testParseable(SUCCESS, "2009-12-31 00:00:00.12345678"); + testParseable(SUCCESS, "2009-12-31 00:00:00.1234567"); + testParseable(SUCCESS, "2009-12-31 00:00:00.123456"); + testParseable(SUCCESS, "2009-12-31 23:59:59.12345"); + testParseable(SUCCESS, "2009-01-31 16:01:01.1234"); + testParseable(SUCCESS, "2009-01-01 16:59:00.123"); + testParseable(SUCCESS, "2009-01-01 16:00:01.12"); + testParseable(SUCCESS, "2009-02-28 16:25:17.1"); + testParseable(SUCCESS, "2009-01-01 00:01:01"); + testParseable(SUCCESS, "2009-01-01T00:01:01"); + testParseable(SUCCESS, "2009-01-01 16:01"); + testParseable(SUCCESS, "2009-01-01 16"); + testParseable(SUCCESS, "2009-01-01"); + testParseable(SUCCESS, "2009-01"); + testParseable(SUCCESS, "2009"); + testParseable(SUCCESS, "0009"); + testParseable(SUCCESS, "9"); + testParseable(SUCCESS, "00:00:00.123456789"); + testParseable(SUCCESS, "00:00:00.12345678"); + testParseable(SUCCESS, "00:00:00.1234567"); + testParseable(SUCCESS, "00:00:00.123456"); + testParseable(SUCCESS, "23:59:59.12345"); + testParseable(SUCCESS, "01:59:59.1234"); + testParseable(SUCCESS, "23:01:59.123"); + testParseable(SUCCESS, "00:00:00.12"); + testParseable(SUCCESS, "00:59:59.1"); + testParseable(SUCCESS, "23:59:00"); + testParseable(SUCCESS, "23:00:10"); + testParseable(SUCCESS, "00:59"); + testParseable(SUCCESS, "9999-12-31 23:59:59.999999999"); + testParseable(SUCCESS, "0001-01-01 00:00:00.000000000"); + testParseable(SUCCESS, "0001-01-01 00:00:00.000000000 "); // extra trailing/leading spaces + testParseable(SUCCESS, " 0001-01-01 00:00:00.000000000 "); + testParseable(SUCCESS, " 0001-01-01 00:00:00.000000000"); + testParseable(SUCCESS, " 0001-01-01 00:00:00.000000000"); + + testParseable(FAIL, "0000-01-01 00:00:00.000000000"); + testParseable(FAIL, "19999-01-01 00:00:00.000000000"); + testParseable(FAIL, "10000-01-01 00:00:00.000000000"); + + testParseable(FAIL, "02000-01-01 01:01:01.000000000"); + testParseable(FAIL, ""); + testParseable(FAIL, " "); + testParseable(FAIL, "blah"); + testParseable(FAIL, "A2000-01-01 01:01:01.000000000"); + testParseable(FAIL, "2000-01-01 01:01:01.000000000A"); + testParseable(FAIL, "2000-01-01A"); + testParseable(FAIL, "A2000-01-01"); + } + + public void testRangeYear(){ + testRange(SUCCESS, "2009-01-01"); + testRange(SUCCESS, "1-01-01"); + testRange(SUCCESS, "01-01-01"); + testRange(SUCCESS, "0001-01-01"); + testRange(SUCCESS, "0010-01-01"); + testRange(SUCCESS, "10-01-01"); + testRange(SUCCESS, "100-01-01"); + testRange(SUCCESS, "0100-01-01"); + testRange(SUCCESS, "9999-01-01"); + testRange(SUCCESS, "2300-01-01"); + testRange(SUCCESS, "7998-01-01"); + + testRange(FAIL, "99999-01-01"); + testRange(FAIL, "0-01-01"); + testRange(FAIL, "10000-01-01"); + testRange(FAIL, "10001-01-01"); + } + + public void testRangeMonth(){ + testRange(SUCCESS, "2009-01-01"); + testRange(SUCCESS, "2009-02-01"); + testRange(SUCCESS, "2009-10-01"); + testRange(SUCCESS, "2009-11-01"); + testRange(SUCCESS, "2009-12-01"); + + testRange(FAIL, "2009-00-01"); + testRange(FAIL, "2009-13-01"); + testRange(FAIL, "2009-99-01"); + } + + public void testRangeDay(){ + testRange(SUCCESS, "2009-01-01"); + testRange(SUCCESS, "2009-02-02"); + testRange(SUCCESS, "2009-10-10"); + testRange(SUCCESS, "2009-11-30"); + testRange(SUCCESS, "2009-12-31"); + + testRange(FAIL, "2009-10-00"); + testRange(FAIL, "2009-10-32"); + } + + public void testRangeHour(){ + testRange(SUCCESS, "2009-01-01 00:01:01"); + testRange(SUCCESS, "2009-01-01 01:01:01"); + testRange(SUCCESS, "2009-01-01 23:01:01"); + + testRange(FAIL, "2009-01-01 24:01:01"); + testRange(FAIL, "2009-01-01 99:01:01"); + } + + public void testRangeMinute(){ + testRange(SUCCESS, "2009-01-01 00:00:01"); + testRange(SUCCESS, "2009-01-01 01:01:01"); + testRange(SUCCESS, "2009-01-01 23:59:01"); + + testRange(FAIL, "2009-01-01 23:60:01"); + testRange(FAIL, "2009-01-01 23:99:01"); + } + + public void testRangeSecond(){ + testRange(SUCCESS, "2009-01-01 00:00:00"); + testRange(SUCCESS, "2009-01-01 01:01:01"); + testRange(SUCCESS, "2009-01-01 23:59:59"); + + testRange(FAIL, "2009-01-01 23:59:60"); + testRange(FAIL, "2009-01-01 23:59:99"); + } + + public void testRangeNanosecond(){ + testRange(SUCCESS, "2009-01-01 00:00:00.000000000"); + testRange(SUCCESS, "2009-01-01 01:01:01.000000001"); + testRange(SUCCESS, "2009-01-01 01:01:01.000001000"); + testRange(SUCCESS, "2009-01-01 23:59:59.123456789"); + testRange(SUCCESS, "2009-01-01 23:59:59.999999999"); + + testRange(FAIL, "2009-01-01 23:59:59.9999999999"); + testRange(FAIL, "2009-01-01 23:59:59.0000000000"); + testRange(FAIL, "2009-01-01 23:59:59.0000010000"); + testRange(FAIL, "2009-01-01 23:59:59.1234567890"); + } + + public void testMonthOverflow(){ + testStandardFormatCtorPlusParseFail("2009-01-32 23:40:19.123456789") ; + testStandardFormatCtorPlusParseFail("2009-02-29 23:40:19.123456789") ; + testStandardFormatCtorPlusParseFail("2009-03-32 23:40:19.123456789") ; + testStandardFormatCtorPlusParseFail("2009-04-31 23:40:19.123456789") ; + testStandardFormatCtorPlusParseFail("2009-05-32 23:40:19.123456789") ; + testStandardFormatCtorPlusParseFail("2009-06-31 23:40:19.123456789") ; + testStandardFormatCtorPlusParseFail("2009-07-32 23:40:19.123456789") ; + testStandardFormatCtorPlusParseFail("2009-08-32 23:40:19.123456789") ; + testStandardFormatCtorPlusParseFail("2009-09-31 23:40:19.123456789") ; + testStandardFormatCtorPlusParseFail("2009-10-32 23:40:19.123456789") ; + testStandardFormatCtorPlusParseFail("2009-11-31 23:40:19.123456789") ; + testStandardFormatCtorPlusParseFail("2009-12-32 23:40:19.123456789") ; + + testStandardFormatCtorPlusParseFail("2008-02-30 23:40:19.123456789") ; //leap year + } + + public void testLeapYear(){ + testLeapYear(SUCCESS, "2008-01-01") ; + testLeapYear(SUCCESS, "2004-01-01") ; + testLeapYear(SUCCESS, "1996-01-01") ; + testLeapYear(SUCCESS, "2000-01-01") ; + testLeapYear(SUCCESS, "1600-01-01") ; + testLeapYear(SUCCESS, "1200-01-01") ; + testLeapYear(SUCCESS, "800-01-01") ; + testLeapYear(SUCCESS, "400-01-01") ; + + testLeapYear(FAIL, "2009-01-01") ; + testLeapYear(FAIL, "1900-01-01") ; + testLeapYear(FAIL, "1800-01-01") ; + testLeapYear(FAIL, "1700-01-01") ; + testLeapYear(FAIL, "1900-01-01") ; + testLeapYear(FAIL, "1500-01-01") ; + testLeapYear(FAIL, "1400-01-01") ; + testLeapYear(FAIL, "1300-01-01") ; + testLeapYear(FAIL, "1100-01-01") ; + testLeapYear(FAIL, "1000-01-01") ; + testLeapYear(FAIL, "900-01-01") ; + testLeapYear(FAIL, "700-01-01") ; + testLeapYear(FAIL, "600-01-01") ; + testLeapYear(FAIL, "500-01-01") ; + testLeapYear(FAIL, "300-01-01") ; + testLeapYear(FAIL, "200-01-01") ; + testLeapYear(FAIL, "100-01-01") ; + + testLeapYearFails("11:18:.16.23"); + } + + public void testToString(){ + testToString(SUCCESS, "2001-01-01 12:34:56.123456789", "2001-01-01 12:34:56.123456789"); + testToString(SUCCESS, "2001-01-01 12:34:56.12345678", "2001-01-01 12:34:56.12345678"); + testToString(SUCCESS, "2001-01-01 12:34:56.1234567", "2001-01-01 12:34:56.1234567"); + testToString(SUCCESS, "2001-01-01 12:34:56.123456", "2001-01-01 12:34:56.123456"); + testToString(SUCCESS, "2001-01-01 12:34:56.12345", "2001-01-01 12:34:56.12345"); + testToString(SUCCESS, "2001-01-01 12:34:56.1234", "2001-01-01 12:34:56.1234"); + testToString(SUCCESS, "2001-01-01 12:34:56.123", "2001-01-01 12:34:56.123"); + testToString(SUCCESS, "2001-01-01 12:34:56.12", "2001-01-01 12:34:56.12"); + testToString(SUCCESS, "2001-01-01 12:34:56.1", "2001-01-01 12:34:56.1"); + + testToString(SUCCESS, "2001-01-01 12:34:56", "2001-01-01 12:34:56"); + testToString(SUCCESS, "2001-01-01 12:34", "2001-01-01 12:34"); + testToString(SUCCESS, "2001-01-01 12", "2001-01-01 12"); + testToString(SUCCESS, "2001-01-01", "2001-01-01"); + testToString(SUCCESS, "2001-12", "2001-12"); + testToString(SUCCESS, "2001", "2001"); + + // The String constructor is lenient in the extreme: + testToString(SUCCESS, "BLAH", "BLAH"); + testToString(SUCCESS, "Humpday Jan 1, 2001 12:34:56.1234567890123456", "Humpday Jan 1, 2001 12:34:56.1234567890123456"); + + testToString(FAIL, "2001-01-01", "2001-01-01 "); + testToString(FAIL, "2001-12-01", " 2001-12-01 "); + + //date only + testToString(SUCCESS, 2001, 1, 1, null, null, null, null, "2001-01-01"); + testToString(SUCCESS, 2001, 1, 31, null, null, null, null, "2001-01-31"); + testToString(SUCCESS, 2001, 11, 30, null, null, null, null, "2001-11-30"); + testToString(SUCCESS, 1, 11, 30, null, null, null, null, "1-11-30"); + testToString(SUCCESS, 2001, 1, null, null, null, null, null, "2001-01"); + testToString(SUCCESS, 2001, null, null, null, null, null, null, "2001"); + //date and time + testToString(SUCCESS, 2001, 1, 31, 13, 30, 59, 123456789, "2001-01-31 13:30:59.123456789"); + testToString(SUCCESS, 2001, 1, 31, 13, 30, 59, 12, "2001-01-31 13:30:59.000000012"); + testToString(SUCCESS, 2001, 1, 31, 13, 30, 59, null, "2001-01-31 13:30:59"); + testToString(SUCCESS, 2001, 1, 31, 13, 30, null, null, "2001-01-31 13:30"); + testToString(SUCCESS, 2001, 1, 31, 13, null, null, null, "2001-01-31 13"); + //time only + testToString(SUCCESS, null, null, null, 13, 30, 59, 123456789, "13:30:59.123456789"); + testToString(SUCCESS, null, null, null, 13, 30, 59, null, "13:30:59"); + + //bizarre combination of formats + testToString(SUCCESS, 2001, 1, 31, 13, null, 59, 123456789, "Y:2001 M:1 D:31 h:13 m:null s:59 f:123456789"); + + //extra trailing space + testToString(FAIL, 2001, 1, 31, 13, 30, 59, 123456789, "2001-01-31 13:30:59.123456789 "); + } + + public void testDayOfWeek(){ + testDayOfWeek(SUCCESS, "2009-01-01", 5); + testDayOfWeek(SUCCESS, "2009-01-02", 6); + testDayOfWeek(SUCCESS, "2009-01-03", 7); + testDayOfWeek(SUCCESS, "2009-01-04", 1); + testDayOfWeek(SUCCESS, "2009-01-05", 2); + testDayOfWeek(SUCCESS, "2009-01-06", 3); + testDayOfWeek(SUCCESS, "2012-02-28", 3); + testDayOfWeek(SUCCESS, "2012-02-29", 4); + testDayOfWeek(SUCCESS, "1921-11-03", 5); + testDayOfWeek(SUCCESS, "1928-05-29", 3); + testDayOfWeek(SUCCESS, "1954-06-30", 4); + testDayOfWeek(FAIL, "1954-06", 4); + testDayOfWeek(FAIL, "1954", 4); + testDayOfWeek(FAIL, "11:05:13", 4); + } + + public void testDayOfYear(){ + testDayOfYear(SUCCESS, "1978-11-14", 318); + testDayOfYear(SUCCESS, "1988-04-22", 113); + testDayOfYear(SUCCESS, "1978-01-01", 1); + testDayOfYear(SUCCESS, "1962-12-31", 365); + testDayOfYear(SUCCESS, "1960-12-31", 366); + testDayOfYear(FAIL, "1988-04", 113); + testDayOfYear(FAIL, "1988", 113); + testDayOfYear(FAIL, "11:13:59.123", 113); + } + + public void testSameDayAs(){ + testSameDayAs(SUCCESS, "1955-04-30", "1955-04-30"); + testSameDayAs(SUCCESS, "1955-04-30 01:23:15", "1955-04-30"); + testSameDayAs(SUCCESS, "1955-04-30 01:23:15", "1955-04-30 16:56:07"); + testSameDayAs(SUCCESS, "1955-04-30 01:23", "1955-04-30 16:56:07"); + testSameDayAs(SUCCESS, "1955-04-30 01", "1955-04-30 16:56:07"); + testSameDayAs(SUCCESS, "1955-04-30 01:23:15", "1955-04-30 16:56:07.23"); + testSameDayAs(SUCCESS, "1955-04-30 16:56:07.23321", "1955-04-30 16:56:07.23"); + + testSameDayAs(FAIL, "1955-04-30", "1955-05-30"); + testSameDayAs(FAIL, "1955-04-30", "1955-04-03"); + } + + public void testCompare(){ + testCompare("1959-05-08", "1965-08-01", LESS); + testCompare("1965-08-01", "1965-08-02", LESS); + testCompare("1965-08-01", "1965-08-30", LESS); + testCompare("1965-08-01", "1965-12-01", LESS); + testCompare("1965-08-01", "1966-08-01", LESS); + testCompare("1959-05-08 13:13:59", "1965-08-01", LESS); + testCompare("1959-05-08", "1959-05-08 13:13:59", LESS); + testCompare("1959-05-08", "1959-05-08 00:00:00", LESS); + testCompare("1959-05-08", "1959-05-08 00:00", LESS); + testCompare("1959-05-08", "1959-05-08 00", LESS); + + testCompare("1965-08-01", "1959-05-08", MORE); + testCompare("900-12-31", "1-01-01", MORE); + testCompare("1900-12-31", "1801-01-01", MORE); + testCompare("5000-12-31", "1801-01-01", MORE); + } + + public void testBefore(){ + testBefore("1579-05-31", "5856-03-01"); + testBefore("1579-05-31", "1579-06-01"); + testBefore("900-05-01", "1578-05-01"); + } + + public void testAfter(){ + testAfter("5856-03-01", "1579-05-31" ); + testAfter("1579-06-01", "1579-05-31"); + testAfter("1901-05-01", "1579-05-01"); + } + + public void testTruncate(){ + testTruncate(SUCCESS, "1999-05-15 11:34:19.123456789", "1999-05-15 11:34:19", DateTime.Unit.SECOND); + testTruncate(SUCCESS, "1999-05-15 11:34:19.123456789", "1999-05-15 11:34", DateTime.Unit.MINUTE); + testTruncate(SUCCESS, "1999-05-15 11:34:19.123456789", "1999-05-15 11", DateTime.Unit.HOUR); + testTruncate(SUCCESS, "1999-05-15 11:34:19.123456789", "1999-05-15", DateTime.Unit.DAY); + testTruncate(SUCCESS, "1999-05-15 11:34:19.123456789", "1999-05", DateTime.Unit.MONTH); + testTruncate(SUCCESS, "1999-05-15 11:34:19.123456789", "1999", DateTime.Unit.YEAR); + } + + public void testNumDaysInMonth(){ + testNumDaysInMonth("2001-01-01", 31); + testNumDaysInMonth("2001-02-01", 28); + testNumDaysInMonth("2001-03-01", 31); + testNumDaysInMonth("2001-04-01", 30); + testNumDaysInMonth("2001-05-01", 31); + testNumDaysInMonth("2001-06-01", 30); + testNumDaysInMonth("2001-07-01", 31); + testNumDaysInMonth("2001-08-01", 31); + testNumDaysInMonth("2001-09-01", 30); + testNumDaysInMonth("2001-10-01", 31); + testNumDaysInMonth("2001-11-01", 30); + testNumDaysInMonth("2001-12-01", 31); + + testNumDaysInMonth("2000-01-01", 31); + testNumDaysInMonth("2000-02-01", 29); + testNumDaysInMonth("2000-03-01", 31); + testNumDaysInMonth("2000-04-01", 30); + testNumDaysInMonth("2000-05-01", 31); + testNumDaysInMonth("2000-06-01", 30); + testNumDaysInMonth("2000-07-01", 31); + testNumDaysInMonth("2000-08-01", 31); + testNumDaysInMonth("2000-09-01", 30); + testNumDaysInMonth("2000-10-01", 31); + testNumDaysInMonth("2000-11-01", 30); + testNumDaysInMonth("2000-12-01", 31); + + testNumDaysInMonth("1960-01-01", 31); + testNumDaysInMonth("1960-02-01", 29); + testNumDaysInMonth("1960-03-01", 31); + testNumDaysInMonth("1960-04-01", 30); + testNumDaysInMonth("1960-05-01", 31); + testNumDaysInMonth("1960-06-01", 30); + testNumDaysInMonth("1960-07-01", 31); + testNumDaysInMonth("1960-08-01", 31); + testNumDaysInMonth("1960-09-01", 30); + testNumDaysInMonth("1960-10-01", 31); + testNumDaysInMonth("1960-11-01", 30); + testNumDaysInMonth("1960-12-01", 31); + } + + public void testModifiedJulianDate(){ + testModifiedJD("1858-11-17", 0 ); + testModifiedJD("2004-01-01", 53005); + testModifiedJD("1900-12-15", 15368); + testModifiedJD("1995-09-27", 49987 ); + testModifiedJD("1858-11-17", 0 ); + testModifiedJD("1858-11-18", 1 ); + testModifiedJD("1858-11-16", -1 ); + testModifiedJD("1858-11-01", -16 ); + testModifiedJD("2005-05-24", 53514); + testModifiedJD("2006-12-19", 54088); + testModifiedJD("1834-12-15", -8738); + testModifiedJD("1957-10-05", 36116); + testModifiedJD("2000-01-01", 51544); + testModifiedJD("1987-01-27", 46822); + testModifiedJD("1987-06-19", 46965); + testModifiedJD("1988-01-27", 47187); + testModifiedJD("1988-06-19", 47331); + testModifiedJD("1900-01-01", 15020); + testModifiedJD("1600-01-01", -94553); + testModifiedJD("1600-12-31", -94188); + testModifiedJD("837-04-10", -373133); //Proleptic Gregorian (B=-4), not Julian (B=0) + } + + public void testStartOfDay(){ + testStartOfDay("2005-08-13 13:15:58", "2005-08-13 00:00:00.0"); + testStartOfDay("2005-08-13 00:00:00", "2005-08-13 00:00:00.0"); + testStartOfDay("2005-08-13 00:00:00.0", "2005-08-13 00:00:00.0"); + testStartOfDay("2005-08-13 00:00:00.000000000", "2005-08-13 00:00:00.0"); + testStartOfDay("2005-08-13", "2005-08-13 00:00:00.0"); + } + public void testEndOfDay(){ + testEndOfDay("2005-08-13 13:15:58", "2005-08-13 23:59:59.999999999"); + testEndOfDay("2005-08-13", "2005-08-13 23:59:59.999999999"); + } + + public void testStartOfMonth(){ + testStartOfMonth("2005-08-13 13:15:58", "2005-08-01 00:00:00.0"); + testStartOfMonth("2005-08-13", "2005-08-01 00:00:00.0"); + } + + public void testEndOfMonth(){ + testEndOfMonth("2005-08-13 13:15:58", "2005-08-31 23:59:59.999999999"); + testEndOfMonth("2005-01-13 13:15:58", "2005-01-31 23:59:59.999999999"); + testEndOfMonth("2005-02-13 13:15:58", "2005-02-28 23:59:59.999999999"); + testEndOfMonth("2005-02-13", "2005-02-28 23:59:59.999999999"); + testEndOfMonth("2005-02-13 13:15", "2005-02-28 23:59:59.999999999"); + testEndOfMonth("2005-02-13 13:15:12.23", "2005-02-28 23:59:59.999999999"); + } + + public void testDateFromJD(){ + //http://isotropic.org/cgi-bin/date.pl?date=333-01-27 + + testDateFromJD("2000-01-01", 2451545); + testDateFromJD("1957-10-04", 2436116); + testDateFromJD("1987-06-19", 2446966); + testDateFromJD("1988-06-19", 2447332); + testDateFromJD("1988-06-19", 2447332); + testDateFromJD("2009-12-23", 2455189); + testDateFromJD("1987-01-27", 2446823); + + testDateFromJD("1582-10-16", 2299162); + testDateFromJD("1582-10-15", 2299161); + testDateFromJD("1582-10-14", 2299160); + testDateFromJD("1582-10-13", 2299159); + + testDateFromJD("333-01-27", 1842712); + } + + public void testFutureAndPast(){ + TimeZone tz = TimeZone.getTimeZone("America/Halifax"); + //DateTime now = DateTime.now(tz); + //log(now); + + //DateTime today = DateTime.today(tz); + //log(today); + + DateTime future = new DateTime("2500-01-31"); + assertTrue(future.isInTheFuture(tz)); + + DateTime past = new DateTime("1900-01-31"); + assertTrue(past.isInThePast(tz)); + } + + public void testEquals(){ + testEquals("1938-01-31", "1938-01-31", SUCCESS); + testEquals("1938-01-31 18:13:15", "1938-01-31 18:13:15", SUCCESS); + testEquals("1938-01-31 18:13:15.0", "1938-01-31 18:13:15.0", SUCCESS); + testEquals("1938-01-31 18:13:15.123", "1938-01-31 18:13:15.123", SUCCESS); + testEquals("1938-01-31 18:13:15.123456", "1938-01-31 18:13:15.123456", SUCCESS); + testEquals("1938-01-31 18:13:15.123456789", "1938-01-31 18:13:15.123456789", SUCCESS); + testEquals("18:13:15.123456789", "18:13:15.123456789", SUCCESS); + testEquals("18:13:15.1", "18:13:15.1", SUCCESS); + testEquals("18:13:15", "18:13:15", SUCCESS); + testEquals("18:13", "18:13", SUCCESS); + + testEquals("1938-01-31 00:00:00", "1938-01-31", FAIL); + testEquals("1938-01-31 18:31:25", "1938-01-31", FAIL); + + testEquals(new DateTime(2156, 1, 3, 18,31,25,0), new DateTime(2156, 1,3,18,31,25,0), SUCCESS); + testEquals(new DateTime(2156, 1, 3, 18,31,25,null), new DateTime(2156, 1,3,18,31,25,null), SUCCESS); + testEquals(new DateTime(2156, 1, 3, 18,31,null,null), new DateTime(2156, 1,3,18,31,null,null), SUCCESS); + testEquals(new DateTime(2156, 1, 3, null,null,null,null), new DateTime(2156, 1,3,null,null,null,null), SUCCESS); + + testEquals(new DateTime(2156, 1, 3, 18,31,25,null), new DateTime(2156, 1,3,18,31,25,0), FAIL); + testEquals(new DateTime(null, 1, 3, 18,31,25,0), new DateTime(2156, 1,3,18,31,25,0), FAIL); + testEquals(new DateTime(2156, 1, 3, null,null,null,null), new DateTime(2156, 1,3,18,31,25,0), FAIL); + } + + public void testChangeTimeZone(){ + TimeZone from = TimeZone.getTimeZone("America/Halifax"); + TimeZone to = TimeZone.getTimeZone("America/Montreal"); + testChangeTimeZone("2010-01-15 18:13:59.123456789", "2010-01-15 17:13:59.123456789", from, to); + testChangeTimeZone("2010-01-15 18:13:59.1", "2010-01-15 17:13:59.1", from, to); + testChangeTimeZone("2010-01-15 18:13:59", "2010-01-15 17:13:59", from, to); + testChangeTimeZone("2010-01-15 01:13:59", "2010-01-15 00:13:59", from, to); + testChangeTimeZone("2010-01-15 00:13:59", "2010-01-14 23:13:59", from, to); + testChangeTimeZone("2010-01-15 23:13:59", "2010-01-15 22:13:59", from, to); + testChangeTimeZone("2010-01-15 18:13", "2010-01-15 17:13", from, to); + testChangeTimeZone("2010-01-15 18", "2010-01-15 17", from, to); + + from = TimeZone.getTimeZone("Europe/London"); + to = TimeZone.getTimeZone("America/Montreal"); + testChangeTimeZone("2010-01-15 18:00", "2010-01-15 13:00", from, to); + testChangeTimeZone("2010-01-15 23:00", "2010-01-15 18:00", from, to); + testChangeTimeZone("2010-01-16 00:00", "2010-01-15 19:00", from, to); + testChangeTimeZone("2010-01-16 01:00", "2010-01-15 20:00", from, to); + testChangeTimeZone("2010-01-16 02:00", "2010-01-15 21:00", from, to); + + testChangeTimeZone("2010-08-15 18:00", "2010-08-15 13:00", from, to); + testChangeTimeZone("2010-08-15 23:00", "2010-08-15 18:00", from, to); + testChangeTimeZone("2010-08-16 00:00", "2010-08-15 19:00", from, to); + testChangeTimeZone("2010-08-16 01:00", "2010-08-15 20:00", from, to); + testChangeTimeZone("2010-08-16 02:00", "2010-08-15 21:00", from, to); + + from = TimeZone.getTimeZone("Europe/London"); + to = TimeZone.getTimeZone("Asia/Jakarta"); //+7 hours, no summer hour in Indonesia + testChangeTimeZone("2010-01-15 15:00", "2010-01-15 22:00", from, to); + testChangeTimeZone("2010-01-15 16:00", "2010-01-15 23:00", from, to); + testChangeTimeZone("2010-01-15 17:00", "2010-01-16 00:00", from, to); + testChangeTimeZone("2010-01-15 18:00", "2010-01-16 01:00", from, to); + testChangeTimeZone("2010-08-15 15:00", "2010-08-15 21:00", from, to); + testChangeTimeZone("2010-08-15 16:00", "2010-08-15 22:00", from, to); + testChangeTimeZone("2010-08-15 17:00", "2010-08-15 23:00", from, to); + testChangeTimeZone("2010-08-15 18:00", "2010-08-16 00:00", from, to); + testChangeTimeZone("2010-08-15 19:00", "2010-08-16 01:00", from, to); + + from = TimeZone.getTimeZone("Europe/London"); + to = TimeZone.getTimeZone("America/St_Johns"); //-3h30m, Newfoundland is offset by a non-integral number of hours + testChangeTimeZone("2010-01-15 15:00", "2010-01-15 11:30", from, to); + testChangeTimeZone("2010-08-15 15:00", "2010-08-15 11:30", from, to); + testChangeTimeZone("2010-08-15 23:00", "2010-08-15 19:30", from, to); + testChangeTimeZone("2010-08-16 00:00", "2010-08-15 20:30", from, to); + testChangeTimeZone("2010-08-16 01:00", "2010-08-15 21:30", from, to); + + testChangeTimeZoneFails("2010-01-15", "2010-01-15", from, to); + testChangeTimeZoneFails("2010-01-15", "2010-01-14", from, to); + testChangeTimeZoneFails("2010-01", "2010-01", from, to); + testChangeTimeZoneFails("2010", "2010", from, to); + } + + public void testGetMilliseconds(){ + testGetMillis("1970-01-01", TimeZone.getTimeZone("UTC"), 0); + testGetMillis("1970-01-02", TimeZone.getTimeZone("UTC"), 86400000); + testGetMillis("1969-12-31", TimeZone.getTimeZone("UTC"), -86400000); + testGetMillis("1970-01-01 00:00:00.0", TimeZone.getTimeZone("UTC"), 0); + testGetMillis("1970-01-01 00:00:00.001", TimeZone.getTimeZone("UTC"), 1); + testGetMillis("1970-01-01 00:00:00.1", TimeZone.getTimeZone("UTC"), 100); + testGetMillis("1970-01-01 00:00:01.0", TimeZone.getTimeZone("UTC"), 1000); + testGetMillis("1970-01-02 00:00:00.0", TimeZone.getTimeZone("UTC"), 86400000); + testGetMillis("1969-12-31 00:00:00.0", TimeZone.getTimeZone("UTC"), -86400000); + testGetMillis("1970-01-01 01:00", TimeZone.getTimeZone("UTC"), 3600000); + + testGetMillis("1970-01-01 00:00:00.0", TimeZone.getTimeZone("America/Montreal"), 18000000); + + //nanos are truncated, not rounded + testGetMillis("1970-01-01 00:00:00.0001", TimeZone.getTimeZone("UTC"), 0); + testGetMillis("1970-01-01 00:00:00.000000001", TimeZone.getTimeZone("UTC"), 0); + testGetMillis("1970-01-01 00:00:00.001999", TimeZone.getTimeZone("UTC"), 1); + + //TO-DO + //consistency of 2 related methods + //dt from millis + //millis from dt + //compare + } + + public void testInterConversion(){ + testInterConversion("2010-01-15 19:53:22.123", TimeZone.getTimeZone("UTC")); + testInterConversion("2010-01-15 19:53:22.123", TimeZone.getTimeZone("Asia/Jakarta")); + testInterConversion("2010-01-15 19:53:22.123", TimeZone.getTimeZone("America/Montreal")); + testInterConversion("2010-09-15 19:53:22.123", TimeZone.getTimeZone("America/Montreal")); + testInterConversion("2010-09-15 19:53:22.1", TimeZone.getTimeZone("America/Montreal")); + testInterConversion("1802-09-15 19:53:22.1", TimeZone.getTimeZone("America/Montreal")); + testInterConversion("9875-09-15 19:53:22.1", TimeZone.getTimeZone("America/Montreal")); + } + + public void testForInstant(){ + TimeZone utc = TimeZone.getTimeZone("UTC"); + testForInstant(0, utc, "1970-01-01 00:00:00.000000000"); + testForInstant(86400000, utc, "1970-01-02 00:00:00.000000000"); + testForInstant(-86400000, utc, "1969-12-31 00:00:00.000000000"); + testForInstant(1, utc, "1970-01-01 00:00:00.00100000"); + testForInstant(100, utc, "1970-01-01 00:00:00.100000000"); + testForInstant(1000, utc, "1970-01-01 00:00:01.000000000"); + testForInstant(3600000, utc, "1970-01-01 01:00:00.000000000"); + testForInstant(18000000, TimeZone.getTimeZone("America/Montreal"), "1970-01-01 00:00:00.000000000"); + } + + public void testForInstantNanos(){ + TimeZone utc = TimeZone.getTimeZone("UTC"); + testForInstantNanos(0, utc, "1970-01-01 00:00:00.000000000"); + testForInstantNanos(1, utc, "1970-01-01 00:00:00.000000001"); + testForInstantNanos(1000, utc, "1970-01-01 00:00:00.000001000"); + testForInstantNanos(1000000, utc, "1970-01-01 00:00:00.001000000"); + testForInstantNanos(-1, utc, "1969-12-31 23:59:59.999999999"); + testForInstantNanos(-2, utc, "1969-12-31 23:59:59.999999998"); + testForInstantNanos(-1000, utc, "1969-12-31 23:59:59.999999000"); + testForInstantNanos(-1001, utc, "1969-12-31 23:59:59.999998999"); + testForInstantNanos(-1000000, utc, "1969-12-31 23:59:59.999000000"); + testForInstantNanos(86400000000000L, utc, "1970-01-02 00:00:00.000000000"); + testForInstantNanos(-86400000000000L, utc, "1969-12-31 00:00:00.000000000"); + testForInstantNanos(1000000L, utc, "1970-01-01 00:00:00.00100000"); + testForInstantNanos(100000000L, utc, "1970-01-01 00:00:00.100000000"); + testForInstantNanos(1000000000L, utc, "1970-01-01 00:00:01.000000000"); + testForInstantNanos(3600000000000L, utc, "1970-01-01 01:00:00.000000000"); + testForInstantNanos(18000000000000L, TimeZone.getTimeZone("America/Montreal"), "1970-01-01 00:00:00.000000000"); + testForInstantNanos(18000000000001L, TimeZone.getTimeZone("America/Montreal"), "1970-01-01 00:00:00.000000001"); + testForInstantNanos(17999999999999L, TimeZone.getTimeZone("America/Montreal"), "1969-12-31 23:59:59.999999999"); + } + + public void testGetNanosecondsFromEpoch(){ + TimeZone utc = TimeZone.getTimeZone("UTC"); + testGetNanosecondsFromEpoch("1970-01-01 00:00:00.000000000", utc, 0L); + testGetNanosecondsFromEpoch("1970-01-01 00:00:00.000000001", utc, 1L); + testGetNanosecondsFromEpoch("1970-01-01 00:00:00.000001000", utc, 1000L); + testGetNanosecondsFromEpoch("1970-01-01 00:00:00.001000000", utc, 1000000L); + testGetNanosecondsFromEpoch("1970-01-01 00:00:01.000000000", utc, 1000000000L); + testGetNanosecondsFromEpoch("1970-01-01 00:01:00.000000000", utc, 60*1000000000L); + testGetNanosecondsFromEpoch("1970-01-01 01:00:00.000000000", utc, 60*60*1000000000L); + testGetNanosecondsFromEpoch("1970-01-02 00:00:00.000000000", utc, 24*60*60*1000000000L); + testGetNanosecondsFromEpoch("1970-01-02 00:00:00.000000001", utc, 24*60*60*1000000000L + 1); + + TimeZone montreal = TimeZone.getTimeZone("America/Montreal"); + testGetNanosecondsFromEpoch("1970-01-01 00:00:00.000000000", montreal, 5*60*60*1000000000L); + testGetNanosecondsFromEpoch("1970-01-01 00:00:00.000000001", montreal, 5*60*60*1000000000L + 1); + + testGetNanosecondsFromEpoch("1969-12-31 23:59:59.999999999", utc, -1L); + testGetNanosecondsFromEpoch("1969-12-31 23:59:59.999999000", utc, -1000L); + testGetNanosecondsFromEpoch("1969-12-31 23:59:59.000000000", utc, -1000000000L); + testGetNanosecondsFromEpoch("1969-12-31 23:59:00.000000000", utc, -1000000000L*60); + testGetNanosecondsFromEpoch("1969-12-31 23:00:00.000000000", utc, -1000000000L*60*60); + testGetNanosecondsFromEpoch("1969-12-31 00:00:00.000000000", utc, (-1000000000L)*60*60*24); + testGetNanosecondsFromEpoch("1969-12-31 00:00:00.000000001", utc, (-1000000000L)*60*60*24 + 1); + testGetNanosecondsFromEpoch("1969-12-30 23:59:59.999999999", utc, (-1000000000L)*60*60*24 -1); + } + + public void testNanosecondRange(){ + testNanosecondRange("0001-01-01 00:00:00.000000000"); + testNanosecondRange("9999-12-31 23:59:59.999999999"); + } + + // PRIVATE + + private static final boolean SUCCESS = true; + private static final boolean FAIL = false; + private static final boolean LESS = true; + private static final boolean MORE = false; + + private static void log(Object aThing){ + System.out.println(String.valueOf(aThing)); + } + + private void testStandardFormatCtorSuccess(String aDate){ + try { + DateTime dateTime = new DateTime(aDate); + } + catch (Throwable ex){ + fail("Cannot construct using standard format: " + Util.quote(aDate)); + } + } + + private void testStandardFormatCtorPlusParseSuccess(String aDate, Integer y, Integer m, Integer d, Integer h, Integer min, Integer sec, Integer frac){ + try { + DateTime dt = new DateTime(aDate); + assertTrue("y", eq(dt.getYear(), y)); + assertTrue("m", eq(dt.getMonth(), m)); + assertTrue("d", eq(dt.getDay(), d)); + assertTrue("h", eq(dt.getHour(), h)); + assertTrue("min", eq(dt.getMinute(), min)); + assertTrue("sec", eq(dt.getSecond(), sec)); + assertTrue("frac", eq(dt.getNanoseconds(), frac)); + } + catch (Throwable ex){ + fail("Cannot construct/parse using standard format: " + Util.quote(aDate) + ex); + } + } + + private void testStandardFormatCtorPlusParseFail(String aDate){ + try { + DateTime dt = new DateTime(aDate); + dt.ensureParsed(); + fail("Expected failure to parse: " + Util.quote(aDate)); + } + catch (DateTime.ItemOutOfRange ex){ + //good branch + } + catch (DateTimeParser.UnknownDateTimeFormat ex){ + //good branch + } + } + + + private boolean eq(Integer aThis, Integer aThat){ + boolean result = false; + if(aThis == null && aThat == null){ + result = true; + } + else if (aThis != null && aThat != null){ + result = aThis.equals(aThat); + } + else { + log("At least one is null"); + } + return result; + } + + private static void log(String aMsg) { + System.out.println(aMsg); + } + + private void testStandardFormatCtorFail(String aDate){ + try { + DateTime dateTime = new DateTime(aDate); + fail("Expected failure didn't happen."); + } + catch (Throwable ex){ + //good branch + } + } + + private void testRange(boolean aSuccess, String aDateTime){ + if(aSuccess){ + try { + DateTime dateTime = new DateTime(aDateTime); + dateTime.ensureParsed(); + } + catch (IllegalArgumentException ex){ + fail("Item out of range:" + aDateTime); + } + catch (DateTimeParser.UnknownDateTimeFormat ex){ + fail("Unknown format:" + aDateTime); + } + } + if(!aSuccess){ + try { + DateTime dateTime = new DateTime(aDateTime); + dateTime.ensureParsed(); + fail("Item out of range?:" + aDateTime); + } + catch (DateTime.ItemOutOfRange ex){ + //do nothing - expected + } + catch (DateTimeParser.UnknownDateTimeFormat ex){ + //do nothing - expected + } + } + } + + private void testLeapYear(boolean aSuccess, String aDate) { + DateTime dt = new DateTime(aDate); + if(aSuccess){ + if(! dt.isLeapYear()){ + fail("Expected leap year, but isn't"); + } + } + else { + if (dt.isLeapYear()){ + fail("Expected non-leap year, but actually is a leap year."); + } + } + } + + private void testLeapYearFails(String aDate) { + DateTime dt = new DateTime(aDate); + try { + dt.isLeapYear(); + fail(); + } + catch (RuntimeException ex){ + //OK - expected branch + } + } + + private void testToString(boolean aSuccess, String aDate, String aExpected) { + DateTime dt = new DateTime(aDate); + if(aSuccess){ + if(!dt.toString().equals(aExpected)){ + fail("Expected toString of " + aExpected + " but really is " + dt.toString()); + } + } + else { + if(dt.toString().equals(aExpected)){ + fail("Expected failure for toString being " + aExpected); + } + } + } + + private void testToString(boolean aSuccess, Integer y, Integer m, Integer d, Integer h, Integer min, Integer sec, Integer frac, String aExpected) { + DateTime dt = new DateTime(y,m,d,h,min,sec,frac); + if(aSuccess){ + if(!dt.toString().equals(aExpected)){ + fail("Expected toString of " + aExpected + " but really is " + dt.toString()); + } + } + else { + if(dt.toString().equals(aExpected)){ + fail("Expected failure for toString being " + aExpected); + } + } + } + + private void testDayOfWeek(boolean aSuccess, String aDateTime, int aWeekday){ + DateTime dt = new DateTime(aDateTime); + if(aSuccess){ + if( dt.getWeekDay() != aWeekday) { + fail("Expected weekday '" + aWeekday + "', but was actually " + dt.getWeekDay()); + } + } + else { + try { + int dayOfWeek = dt.getWeekDay(); + fail("Expected failure"); + } + catch(RuntimeException ex){ + //OK - expected + } + } + } + + private void testDayOfYear(boolean aSuccess, String aDateTime, int aExpectedDay){ + DateTime dt = new DateTime(aDateTime); + if(aSuccess){ + if( dt.getDayOfYear() != aExpectedDay) { + fail("Expected weekday '" + aExpectedDay + "', but was actually " + dt.getDayOfYear()); + } + } + else { + try { + dt.getDayOfYear(); + fail(); + } + catch (RuntimeException ex){ + //OK - expected branch + } + } + } + + private void testSameDayAs(boolean aSuccess, String aA, String aB){ + DateTime a = new DateTime(aA); + DateTime b = new DateTime(aB); + if(aSuccess){ + if(!a.isSameDayAs(b)){ + fail("The date " + aA + " should be the same day as this, but isn't: " + aB); + } + } + if(!aSuccess){ + if(a.isSameDayAs(b)){ + fail("The date " + aA + " should NOT be the same day as this, but it is : " + aB); + } + } + } + + + private void testCompare(String aThis, String aThat, boolean aIsLess){ + DateTime a = new DateTime(aThis); + DateTime b = new DateTime(aThat); + if(aIsLess){ + if ( a.compareTo(b) >= 0 ) { + fail(aThis + " is meant to be less than this date, but isn't: " + aThat); + } + } + } + + private void testBefore(String aThis, String aThat){ + DateTime a = new DateTime(aThis); + DateTime b = new DateTime(aThat); + assertTrue(a.lt(b)); + } + + private void testAfter(String aThis, String aThat){ + DateTime a = new DateTime(aThis); + DateTime b = new DateTime(aThat); + assertTrue(a.gt(b)); + } + + private void testTruncate(boolean aSuccess, String aInput, String aExpected, DateTime.Unit aPrecision){ + DateTime a = new DateTime(aInput); + DateTime aTruncated = a.truncate(aPrecision); + DateTime expected = new DateTime(aExpected); + if(aSuccess){ + assertTrue(aTruncated.equals(expected)); + } + else { + assertFalse(aTruncated.equals(expected)); + } + } + + private void testNumDaysInMonth(String aInput, int aExpected){ + DateTime a = new DateTime(aInput); + assertTrue(a.getNumDaysInMonth() == aExpected); + } + + private void testModifiedJD(String aDateTime, Integer aExpected){ + DateTime dt = new DateTime(aDateTime); + if( ! dt.getModifiedJulianDayNumber().equals(aExpected) ){ + fail("Expected: " + aExpected + " Actual: " + dt.getModifiedJulianDayNumber()); + } + } + + private void testStartOfDay(String aDateTime, String aExpected){ + DateTime dt = new DateTime(aDateTime); + DateTime expected = new DateTime(aExpected); + DateTime calc = dt.getStartOfDay(); + if ( ! calc.equals(expected)){ + fail("Expected: " + aExpected + " Actual: " + dt.getStartOfDay()); + } + } + + private void testEndOfDay(String aDateTime, String aExpected){ + DateTime dt = new DateTime(aDateTime); + DateTime expected = new DateTime(aExpected); + if ( ! dt.getEndOfDay().equals(expected)){ + fail("Expected: " + aExpected + " Actual: " + dt.getStartOfDay()); + } + } + + private void testStartOfMonth(String aDateTime, String aExpected){ + DateTime dt = new DateTime(aDateTime); + DateTime expected = new DateTime(aExpected); + DateTime calc = dt.getStartOfMonth(); + if ( ! calc.equals(expected)){ + fail("Expected: " + aExpected + " Actual: " + dt.getStartOfDay()); + } + } + + private void testEndOfMonth(String aDateTime, String aExpected){ + DateTime dt = new DateTime(aDateTime); + DateTime expected = new DateTime(aExpected); + DateTime calc = dt.getEndOfMonth(); + if ( ! calc.equals(expected)){ + fail("Expected: " + aExpected + " Actual: " + dt.getStartOfDay()); + } + } + + private void testDateFromJD(String aExpected, int aJD){ + DateTime expected = new DateTime(aExpected); + if( ! DateTime.fromJulianDayNumberAtNoon(aJD).equals(expected) ){ + fail("Expected: " + aExpected + " Actual: " + DateTime.fromJulianDayNumberAtNoon(aJD).format("YYYY-MM-DD")); + } + } + + + private void testEquals(String aThis, String aThat, boolean aSuccess){ + DateTime thisDt = new DateTime(aThis); + DateTime thatDt = new DateTime(aThat); + if(aSuccess){ + assertTrue(thisDt.equals(thatDt)); + } + else { + assertFalse(thisDt.equals(thatDt)); + } + } + + private void testEquals(DateTime aThis, DateTime aThat, boolean aSuccess){ + if(aSuccess){ + assertTrue(aThis.equals(aThat)); + } + else { + assertFalse(aThis.equals(aThat)); + } + } + + private void testChangeTimeZone(String aForDate, String aExpected, TimeZone aFrom, TimeZone aTo){ + DateTime from = new DateTime(aForDate); + DateTime actual = from.changeTimeZone(aFrom, aTo); + DateTime expected = new DateTime(aExpected); + if( !actual.equals(expected) ){ + fail("Expected:" + aExpected + " Actual:" + actual); + } + } + + private void testChangeTimeZoneFails(String aForDate, String aExpected, TimeZone aFrom, TimeZone aTo){ + DateTime from = new DateTime(aForDate); + try { + DateTime actual = from.changeTimeZone(aFrom, aTo); + fail(); + } + catch(RuntimeException ex){ + //ok + } + } + + private void testGetMillis(String aDate, TimeZone aTimeZone, int aExpected){ + DateTime dt = new DateTime(aDate); + long actual = dt.getMilliseconds(aTimeZone); + if(actual != aExpected){ + fail("Actual: " + actual + " Expected: " + aExpected); + } + } + + private void testForInstant(int aMillis, TimeZone aTimeZone, String aExpected){ + DateTime dtInstant = DateTime.forInstant(aMillis, aTimeZone); + DateTime dt = new DateTime(aExpected); + if (! dtInstant.equals(dt)){ + fail("Time from millis : " + dtInstant + " not agreeing with expected: " + dt); + } + } + + private void testForInstantNanos(long aNanos, TimeZone aTimeZone, String aExpected){ + DateTime dtInstant = DateTime.forInstantNanos(aNanos, aTimeZone); + DateTime dt = new DateTime(aExpected); + if (! dtInstant.equals(dt)){ + fail("Time from nanos : " + dtInstant + " not agreeing with expected: " + aExpected); + } + } + + private void testGetNanosecondsFromEpoch(String aDateTime, TimeZone aTimeZone, long aExpected){ + DateTime dt = new DateTime(aDateTime); + if ( dt.getNanosecondsInstant(aTimeZone) != aExpected ){ + fail("DateTime nanos : " + dt.getNanosecondsInstant(aTimeZone) + " not agreeing with expected: " + aExpected); + } + } + + private void testNanosecondRange(String aDateTime){ + DateTime dt = new DateTime(aDateTime); + try { + dt.getNanosecondsInstant(TimeZone.getTimeZone("UTC")); + } + catch (Throwable ex){ + fail("Nanoseconds out of range for: " + aDateTime); + } + } + + private void testInterConversion(String aDateTime, TimeZone aTimeZone){ + DateTime dt = new DateTime(aDateTime); + //loss of precision : + long millis = dt.getMilliseconds(aTimeZone); + DateTime again = DateTime.forInstant(millis, aTimeZone); + if( dt.compareTo(again) != 0){ + fail(aDateTime + " not the same as " + again.format("YYYY-MM-DD hh:mm:ss.fffffffff")); + } + } + + private void testParseable(boolean aSuccess, String aText){ + if(aSuccess){ + if (! DateTime.isParseable(aText)){ + fail("Expecting text to be parseable, but it's not: " + aText); + } + } + else { + if ( DateTime.isParseable(aText)){ + fail("Expecting text to be un-parseable, but it is: " + aText); + } + } + } + +} \ No newline at end of file diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/TESTDateTimeFormatter.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/TESTDateTimeFormatter.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,215 @@ +package hirondelle.web4j.model; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +import junit.framework.TestCase; + +/** JUnit tests. */ +public final class TESTDateTimeFormatter extends TestCase { + + /** Run the test cases. */ + public static void main(String args[]) { + String[] testCaseName = { TESTDateTimeFormatter.class.getName() }; + junit.textui.TestRunner.main(testCaseName); + } + + public TESTDateTimeFormatter(String aName) { + super(aName); + } + + // TEST CASES // + + public void testSpeed(){ + //.046 + testDate(SUCCESS, "2009-10-28 01:59:01", "YYYY-MM-DD h12:mm:ss", "2009-10-28 1:59:01"); + } + + public void testSpeedJDK() throws ParseException { + String dateTime = "2009-10-28 01:59:01"; + //.063 - slower than my classes - at least in this simple test + SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss"); + Date result = format.parse(dateTime); + String dateResurrected = format.format(result); + } + + public void testDate(){ + testDate(SUCCESS, "2009-10-28", "YYYY-MM-DD", "2009-10-28"); + testDate(SUCCESS, "2009-01-28", "YYYY-MM-DD", "2009-01-28"); + testDate(SUCCESS, "2009-12-01", "YYYY-MM-DD", "2009-12-01"); + testDate(SUCCESS, "2009-01-01", "YYYY-MM-DD", "2009-01-01"); + + testDate(SUCCESS, "2009-10-28", "YYYY-M-D", "2009-10-28"); + testDate(SUCCESS, "2009-10-03", "YYYY-M-D", "2009-10-3"); + testDate(SUCCESS, "2009-05-28", "YYYY-M-D", "2009-5-28"); + + testDate(SUCCESS, "2009-10-28", "YYYY-MM-D", "2009-10-28"); + testDate(SUCCESS, "2009-10-03", "YYYY-MM-D", "2009-10-3"); + testDate(SUCCESS, "2009-05-28", "YYYY-MM-D", "2009-05-28"); + + testDate(SUCCESS, "2009-10-28", "YYYY-M-DD", "2009-10-28"); + testDate(SUCCESS, "2009-10-03", "YYYY-M-DD", "2009-10-03"); + testDate(SUCCESS, "2009-05-28", "YYYY-M-DD", "2009-5-28"); + + testDate(SUCCESS, "2009-10-28", "YY-M-DD", "09-10-28"); + testDate(SUCCESS, "2009-10-03", "YY-M-DD", "09-10-03"); + testDate(SUCCESS, "2099-05-28", "YY-M-DD", "99-5-28"); + + testDate(SUCCESS, "2099-05-28", "YYYYMMDD", "20990528"); + testDate(SUCCESS, "2099-11-28", "WWW MMM DD, YYYY", Locale.CANADA, "Sat Nov 28, 2099"); + testDate(SUCCESS, "2099-11-28", "WWW-DD|th|", Locale.CANADA, "Sat-28th"); + + testDate(SUCCESS, "2009-12-31", "MM-DD-YYYY", "12-31-2009"); + + testDate(SUCCESS, "2009-12-31", "***YYYY-MM-DD", "***2009-12-31"); + testDate(SUCCESS, "2009-12-31", "***YYYY-MM-DD*", "***2009-12-31*"); + testDate(SUCCESS, "2009-12-31", "* YYYY-MM-DD *", "* 2009-12-31 *"); + + testDate(SUCCESS, "2009-12-31", "WWWW, MMM D, YYYY", Locale.CANADA, "Thursday, Dec 31, 2009"); + testDate(SUCCESS, "2009-12-31", "WWW, MMM D, YYYY", Locale.CANADA, "Thu, Dec 31, 2009"); + + testDate(SUCCESS, "01:59:59", "hh:mm:ss", "01:59:59"); + testDate(SUCCESS, "01:59:59", "h:mm:ss", "1:59:59"); + testDate(SUCCESS, "01:59:59", "hh:m:ss", "01:59:59"); + testDate(SUCCESS, "01:01:59", "hh:m:ss", "01:1:59"); + testDate(SUCCESS, "01:59:59", "hh:mm:s", "01:59:59"); + testDate(SUCCESS, "01:59:01", "hh:mm:s", "01:59:1"); + testDate(SUCCESS, "01:59:01.123456789", "hh:mm:ss", "01:59:01"); + testDate(SUCCESS, "01:59:01.123456789", "hh:mm:ss.f", "01:59:01.1"); + testDate(SUCCESS, "01:59:01.123456789", "hh:mm:ss.ff", "01:59:01.12"); + testDate(SUCCESS, "01:59:01.123456789", "hh:mm:ss.fff", "01:59:01.123"); + testDate(SUCCESS, "01:59:01.123456789", "hh:mm:ss.ffff", "01:59:01.1234"); + testDate(SUCCESS, "01:59:01.123456789", "hh:mm:ss.fffff", "01:59:01.12345"); + testDate(SUCCESS, "01:59:01.123456789", "hh:mm:ss.ffffff", "01:59:01.123456"); + testDate(SUCCESS, "01:59:01.123456789", "hh:mm:ss.fffffff", "01:59:01.1234567"); + testDate(SUCCESS, "01:59:01.123456789", "hh:mm:ss.ffffffff", "01:59:01.12345678"); + testDate(SUCCESS, "01:59:01.123456789", "hh:mm:ss.fffffffff", "01:59:01.123456789"); + + testDate(SUCCESS, "01:59:01", "hh", "01"); + testDate(SUCCESS, "01:59:01", "mm", "59"); + testDate(SUCCESS, "01:59:01", "ss", "01"); + testDate(SUCCESS, "01:59:01", "hh ", "01 "); + testDate(SUCCESS, "01:59:01", "hh:mm", "01:59"); + testDate(SUCCESS, "01:59:01", "h:mm", "1:59"); + testDate(SUCCESS, "01:59:01", "mm:ss", "59:01"); + testDate(SUCCESS, "01:59:01", "mm:s", "59:1"); + + testDate(SUCCESS, "2009-10-28 01:59:01", "YYYY-MM-DD hh:mm:ss", "2009-10-28 01:59:01"); + testDate(SUCCESS, "2009-10-28 01:59:01", "YYYYMMDDThh:mm:ss", "20091028T01:59:01"); + testDate(SUCCESS, "2009-10-28 01:59:01", "YYYY-MMM-DD hh:mm:ss", Locale.CANADA, "2009-Oct-28 01:59:01"); + testDate(SUCCESS, "2009-10-28 01:59:01", "YYYY-MMM-DD", Locale.CANADA, "2009-Oct-28"); + testDate(SUCCESS, "2009-10-28 01:59:01", ":hh:mm:ss:", ":01:59:01:"); + testDate(SUCCESS, "2009-04-28 13:59:01", "DD MMM, YYYY hh:mm:ss", Locale.CANADA_FRENCH, "28 avr, 2009 13:59:01"); + + + testDate(SUCCESS, "01:59:01.01", "hh:mm:ss.ff", "01:59:01.01"); + testDate(SUCCESS, "01:59:01.01", "hh:mm:ss.fff", "01:59:01.010"); + testDate(SUCCESS, "01:59:01.01", "hh:mm:ss.ffff", "01:59:01.0100"); + testDate(SUCCESS, "01:59:01.01", "hh:mm:ss.fffff", "01:59:01.01000"); + testDate(SUCCESS, "01:59:01.01", "hh:mm:ss.ffffff", "01:59:01.010000"); + testDate(SUCCESS, "01:59:01.01", "hh:mm:ss.fffffff", "01:59:01.0100000"); + testDate(SUCCESS, "01:59:01.01", "hh:mm:ss.ffffffff", "01:59:01.01000000"); + testDate(SUCCESS, "01:59:01.01", "hh:mm:ss.fffffffff", "01:59:01.010000000"); + + testDate(SUCCESS, "01:59:01.000000001", "hh:mm:ss", "01:59:01"); + testDate(SUCCESS, "01:59:01.000000001", "hh:mm:ss.f", "01:59:01.0"); + testDate(SUCCESS, "01:59:01.000000001", "hh:mm:ss.ff", "01:59:01.00"); + testDate(SUCCESS, "01:59:01.000000001", "hh:mm:ss.fff", "01:59:01.000"); + testDate(SUCCESS, "01:59:01.000000001", "hh:mm:ss.ffff", "01:59:01.0000"); + testDate(SUCCESS, "01:59:01.000000001", "hh:mm:ss.fffff", "01:59:01.00000"); + testDate(SUCCESS, "01:59:01.000000001", "hh:mm:ss.ffffff", "01:59:01.000000"); + testDate(SUCCESS, "01:59:01.000000001", "hh:mm:ss.fffffff", "01:59:01.0000000"); + testDate(SUCCESS, "01:59:01.000000001", "hh:mm:ss.ffffffff", "01:59:01.00000000"); + testDate(SUCCESS, "01:59:01.000000001", "hh:mm:ss.fffffffff", "01:59:01.000000001"); + + testDate(SUCCESS, "01:59:01.000000000", "hh:mm:ss.fffffffff", "01:59:01.000000000"); + + testDate(SUCCESS, "2009-10-28 01:59:01", "YYYY-MM-DD h12:mm:ss", "2009-10-28 1:59:01"); + testDate(SUCCESS, "2009-10-28 13:59:01", "YYYY-MM-DD h12:mm:ss", "2009-10-28 1:59:01"); + + testDate(SUCCESS, "2009-10-28 01:59:01", "YYYY-MM-DD hh12:mm:ss", "2009-10-28 01:59:01"); + testDate(SUCCESS, "2009-10-28 12:59:01", "YYYY-MM-DD hh12:mm:ss", "2009-10-28 12:59:01"); + testDate(SUCCESS, "2009-10-28 13:59:01", "YYYY-MM-DD hh12:mm:ss", "2009-10-28 01:59:01"); + testDate(SUCCESS, "2009-10-28 23:59:01", "YYYY-MM-DD hh12:mm:ss", "2009-10-28 11:59:01"); + + testDate(SUCCESS, "2009-10-28 01:59:01", "YYYY-MM-DD h12:mm:ss a", Locale.CANADA, "2009-10-28 1:59:01 AM"); + testDate(SUCCESS, "2009-10-28 13:59:01", "YYYY-MM-DD h12:mm:ss a", Locale.CANADA, "2009-10-28 1:59:01 PM"); + + //WRONG : + //this fails - tokens cannot appear next to each other: + //testDate(SUCCESS, "2009-10-28 13:59:01", "YYYY-MM-DD h12:mm:ssa", Locale.CANADA, "2009-10-28 1:59:01PM"); + //the workaround for the above is to use the escape character, with nothing in between: + testDate(SUCCESS, "2009-10-28 13:59:01", "YYYY-MM-DD h12:mm:ss||a", Locale.CANADA, "2009-10-28 1:59:01PM"); + } + + public void testEscapeChar(){ + testDate(SUCCESS, "2009-10-28 01:59:01", "|Date:|YYYY-MM-DD |Time:|hh12:mm:ss", "Date:2009-10-28 Time:01:59:01"); + testDate(SUCCESS, "15:59:59", "h12| o'clock| a", Locale.CANADA, "3 o'clock PM"); + //inside escaped regions, the tokens are uninterpreted : + testDate(SUCCESS, "2009-10-28 01:59:01", "|Date(YYYY-MM-DD):|YYYY-MM-DD |Timehh12:mm:ss:|hh12:mm:ss", "Date(YYYY-MM-DD):2009-10-28 Timehh12:mm:ss:01:59:01"); + } + + public void testCustomFormats() { + List months = Arrays.asList("J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"); + List weekdays = Arrays.asList("sunday", "monday", "tuesday", "humpday", "thursday", "friday", "saturday"); + List amPm = Arrays.asList("am", "pm"); + testDate(SUCCESS, "2009-10-28 01:59:01", "YYYY-MM-DD hh:mm:ss a", months, weekdays, amPm, "2009-10-28 01:59:01 am"); + testDate(SUCCESS, "2009-10-28 16:59:01", "YYYY-MM-DD h12:mm:ss a", months, weekdays, amPm, "2009-10-28 4:59:01 pm"); + + testDate(SUCCESS, "2009-10-28 01:59:01", "YYYY-MMMM-DD hh:mm:ss a", months, weekdays, amPm, "2009-O-28 01:59:01 am"); + testDate(SUCCESS, "2009-10-28 01:59:01", "YYYY-MM-DD WWWW hh:mm:ss a", months, weekdays, amPm, "2009-10-28 humpday 01:59:01 am"); + } + + // PRIVATE + + private static final boolean SUCCESS = true; + + private void testDate(boolean aSuccess, String aDate , String aFormat, String aExpectedResult){ + DateTimeParser parser = new DateTimeParser(); + DateTime dateTime = parser.parse(aDate); + DateTimeFormatter formatter = new DateTimeFormatter(aFormat); + String result = formatter.format(dateTime); + if(aSuccess){ + if(! result.equals(aExpectedResult)){ + throw new AssertionError("Expected:" + aExpectedResult + ", but result was:" + result); + } + } + else { + assertFalse(result.equals(aExpectedResult)); + } + } + + private void testDate(boolean aSuccess, String aDate , String aFormat, Locale aLocale, String aExpectedResult){ + DateTimeParser parser = new DateTimeParser(); + DateTime dateTime = parser.parse(aDate); + DateTimeFormatter formatter = new DateTimeFormatter(aFormat, aLocale); + String result = formatter.format(dateTime); + if(aSuccess){ + if(! result.equals(aExpectedResult)){ + throw new AssertionError("Expected:" + aExpectedResult + ", but result was:" + result); + } + } + else { + assertFalse(result.equals(aExpectedResult)); + } + } + + private void testDate(boolean aSuccess, String aDate , String aFormat, List aMonths, List aWeekdays, List aAmPm, String aExpectedResult){ + DateTimeParser parser = new DateTimeParser(); + DateTime dateTime = parser.parse(aDate); + DateTimeFormatter formatter = new DateTimeFormatter(aFormat, aMonths, aWeekdays, aAmPm); + String result = formatter.format(dateTime); + if(aSuccess){ + if(! result.equals(aExpectedResult)){ + throw new AssertionError("Expected:" + aExpectedResult + ", but result was:" + result); + } + } + else { + assertFalse(result.equals(aExpectedResult)); + } + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/TESTDateTimeInterval.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/TESTDateTimeInterval.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,403 @@ +package hirondelle.web4j.model; + +import static hirondelle.web4j.model.DateTime.DayOverflow; +import junit.framework.TestCase; + +public class TESTDateTimeInterval extends TestCase { + + /** Run the test cases. */ + public static void main(String args[]) { + String[] testCaseName = { TESTDateTimeInterval.class.getName() }; + junit.textui.TestRunner.main(testCaseName); + } + + public TESTDateTimeInterval( String aName) { + super(aName); + } + + // TEST CASES // + + public void testRanges(){ + testRange(SUCCESS, 0,0,0,0,0,0,0); + testRange(SUCCESS, 9998,0,0,0,0,0,0); + testRange(SUCCESS, 0,9999,0,0,0,0,0); + testRange(SUCCESS, 0,0,9999,0,0,0,0); + testRange(SUCCESS, 0,0,0,9999,0,0,0); + testRange(SUCCESS, 0,0,0,0,9999,0,0); + testRange(SUCCESS, 0,0,0,0,0,9999,0); + testRange(SUCCESS, 0,0,0,0,0,0,999999999); + + testRange(FAIL, -1,0,0,0,0,0,0); + testRange(FAIL, 0,-1,0,0,0,0,0); + testRange(FAIL, 0,0,-1,0,0,0,0); + testRange(FAIL, 0,0,0,-1,0,0,0); + testRange(FAIL, 0,0,0,0,-1,0,0); + testRange(FAIL, 0,0,0,0,0,-1,0); + testRange(FAIL, 0,0,0,0,0,0,-1); + + testRange(FAIL, 10000,0,0,0,0,0,0); + testRange(FAIL, 0,10000,0,0,0,0,0); + testRange(FAIL, 0,0,10000,0,0,0,0); + testRange(FAIL, 0,0,0,10000,0,0,0); + testRange(FAIL, 0,0,0,0,10000,10000,0); + testRange(FAIL, 0,0,0,0,0,0,1000000000); + } + + + public void testSingleField(){ + testDate("2001-01-01 23:45:19.0", "2001-01-01 23:45:19.0", 0,0,0,0,0,0,0); + + testDate("2001-01-01 23:45:19.0", "2002-01-01 23:45:19.0", 1,0,0,0,0,0,0); + testDate("2001-01-01 23:45:19.0", "2001-02-01 23:45:19.0", 0,1,0,0,0,0,0); + testDate("2001-01-01 23:45:19.0", "2001-01-02 23:45:19.0", 0,0,1,0,0,0,0); + + testDate("2001-01-01 13:45:19.0", "2001-01-01 14:45:19.0", 0,0,0,1,0,0,0); + testDate("2001-01-01 13:45:19.0", "2001-01-01 13:46:19.0", 0,0,0,0,1,0,0); + testDate("2001-01-01 13:45:19.0", "2001-01-01 13:45:20.0", 0,0,0,0,0,1,0); + testDate("2001-01-01 13:45:19.0", "2001-01-01 13:45:19.000000001", 0,0,0,0,0,0,1); + + testDate("2001-01-01 13:45:56.0", "2001-01-01 13:46:00.0", 0,0,0,0,0,4,0); + testDate("2001-01-01 13:45:56.0", "2001-01-01 13:46:01.0", 0,0,0,0,0,5,0); + testDate("2001-01-01 13:45:56.0", "2001-01-01 13:47:01.0", 0,0,0,0,0,65,0); + + testDate("2001-01-01 13:45:56.0", "2001-01-01 13:45:56.999999999", 0,0,0,0,0,0,999999999); + testDate("2001-01-01 13:45:56.000000001", "2001-01-01 13:45:57.0", 0,0,0,0,0,0,999999999); + testDate("2001-01-01 13:45:56.000000002", "2001-01-01 13:45:57.000000001", 0,0,0,0,0,0,999999999); + + testDate("2001-01-01 13:45:19.0", "2001-01-01 14:00:19.0", 0,0,0,0,15,0,0); + testDate("2001-01-01 13:45:19.0", "2001-01-01 15:01:19.0", 0,0,0,0,76,0,0); + + testDate("2001-01-01 10:45:19.0", "2001-01-01 11:45:19.0", 0,0,0,1,0,0,0); + testDate("2001-01-01 10:45:19.0", "2001-01-01 13:45:19.0", 0,0,0,3,0,0,0); + testDate("2001-01-01 23:45:19.0", "2001-01-02 01:45:19.0", 0,0,0,2,0,0,0); + + testDate("2001-01-01 13:45:19.0", "2001-01-21 13:45:19.0", 0,0,20,0,0,0,0); + testDate("2001-01-01 13:45:19.0", "2001-01-31 13:45:19.0", 0,0,30,0,0,0,0); + testDate("2001-01-01 13:45:19.0", "2001-02-01 13:45:19.0", 0,0,31,0,0,0,0); + testDate("2001-01-01 13:45:19.0", "2001-02-28 13:45:19.0", 0,0,58,0,0,0,0); + testDate("2001-01-01 13:45:19.0", "2001-03-01 13:45:19.0", 0,0,59,0,0,0,0); + testDate("2001-01-01 13:45:19.0", "2001-03-02 13:45:19.0", 0,0,60,0,0,0,0); + + testDate("2001-01-01 13:45:19.0", "2001-04-01 13:45:19.0", 0,3,0,0,0,0,0); + testDate("2001-01-01 13:45:19.0", "2002-01-01 13:45:19.0", 0,12,0,0,0,0,0); + testDate("2001-01-01 13:45:19.0", "2002-04-01 13:45:19.0", 0,15,0,0,0,0,0); + + testDate("2001-01-01 13:45:19.0", "2004-01-01 13:45:19.0", 3,0,0,0,0,0,0); + testDate("2001-01-01 13:45:19.0", "2011-01-01 13:45:19.0", 10,0,0,0,0,0,0); + testDate("2001-01-01 13:45:19.0", "3501-01-01 13:45:19.0", 1500,0,0,0,0,0,0); + + testDate("2001-01-01", "2002-01-01 00:00:00.0", 1,0,0,0,0,0,0); + testDate("2001-01-01", "2001-02-01 00:00:00.0", 0,1,0,0,0,0,0); + testDate("2001-01-01", "2001-01-02 00:00:00.0", 0,0,1,0,0,0,0); + testDate("2001-01-01", "2001-01-01 01:00:00.0", 0,0,0,1,0,0,0); + testDate("2001-01-01", "2001-01-01 00:01:00.0", 0,0,0,0,1,0,0); + testDate("2001-01-01", "2001-01-01 00:00:01.0", 0,0,0,0,0,1,0); + testDate("2001-01-01", "2001-01-01 00:00:00.0", 0,0,0,0,0,0,0); + testDate("2001-01-01 15:14:10.123456789", "2001-01-01 15:14:20.123456789", 0,0,0,0,0,10,0); + + testDate("10:12:14", "0002-01-01 10:12:14.0", 1,0,0,0,0,0,0); + testDate("10:12:14", "0001-02-01 10:12:14.0", 0,1,0,0,0,0,0); + testDate("10:12:14", "0001-01-02 10:12:14.0", 0,0,1,0,0,0,0); + testDate("10:12:14", "0001-01-01 11:12:14.0", 0,0,0,1,0,0,0); + testDate("10:12:14", "0001-01-01 10:13:14.0", 0,0,0,0,1,0,0); + testDate("10:12:14", "0001-01-01 10:12:15.0", 0,0,0,0,0,1,0); + } + + public void testMultipleFields(){ + testDate("2001-01-01 23:45:19.0", "2002-02-01 23:45:19.0", 1,1,0,0,0,0,0); + testDate("2001-01-01 23:45:19.0", "2002-02-02 23:45:19.0", 1,1,1,0,0,0,0); + testDate("2001-01-01 23:45:19.0", "2002-02-03 00:45:19.0", 1,1,1,1,0,0,0); + testDate("2001-01-01 23:45:19.0", "2002-02-03 00:46:19.0", 1,1,1,1,1,0,0); + testDate("2001-01-01 23:45:19.0", "2002-02-03 00:46:20.0", 1,1,1,1,1,1,0); + testDate("2001-01-01 23:45:19.0", "2002-02-03 00:46:20.000000001", 1,1,1,1,1,1,1); + + testDate("2001-01-01", "2002-02-01 00:00:00.0", 1,1,0,0,0,0,0); + testDate("2001-01-01", "2002-02-02 00:00:00.0", 1,1,1,0,0,0,0); + testDate("2001-01-01", "2002-02-02 01:00:00.0", 1,1,1,1,0,0,0); + testDate("2001-01-01", "2002-02-02 01:01:00.0", 1,1,1,1,1,0,0); + testDate("2001-01-01", "2002-02-02 01:01:01.0", 1,1,1,1,1,1,0); + testDate("2001-01-01", "2002-02-02 01:01:01.000000001", 1,1,1,1,1,1,1); + + testDate("10:12:14", "0002-02-01 10:12:14.0", 1,1,0,0,0,0,0); + testDate("10:12:14", "0002-02-02 10:12:14.0", 1,1,1,0,0,0,0); + testDate("10:12:14", "0002-02-02 11:12:14.0", 1,1,1,1,0,0,0); + testDate("10:12:14", "0002-02-02 11:13:14.0", 1,1,1,1,1,0,0); + testDate("10:12:14", "0002-02-02 11:13:15.0", 1,1,1,1,1,1,0); + testDate("10:12:14", "0002-02-02 11:13:15.000000001", 1,1,1,1,1,1,1); + } + + public void testMissingParts(){ + testMissingParts("2001-01-01 23:45"); + testMissingParts("2001-01-01 23"); + testMissingParts("2001-01"); + testMissingParts("2001"); + testMissingParts("00:59"); + } + + public void testMultipleFieldsWithRollovers(){ + testDate("2001-01-01 23:45:19.0", "2012-02-01 23:45:19.0", 10,13,0,0,0,0,0); + testDate("2001-01-01 23:45:19.0", "2012-02-29 23:45:19.0", 10,13,28,0,0,0,0); + testDate("2001-01-01 23:45:19.0", "2012-03-01 23:45:19.0", 10,13,29,0,0,0,0); + testDate("2001-01-01 23:45:19.0", "2012-03-02 00:45:19.0", 10,13,29,1,0,0,0); + testDate("2001-01-01 23:45:19.0", "2012-03-03 00:45:19.0", 10,13,29,25,0,0,0); + testDate("2001-01-01 23:45:19.0", "2012-03-03 03:45:19.0", 10,13,29,28,0,0,0); + testDate("2001-01-01 23:45:19.0", "2012-03-03 04:00:19.0", 10,13,29,28,15,0,0); + testDate("2001-01-01 23:45:19.0", "2012-03-03 04:15:19.0", 10,13,29,28,30,0,0); + testDate("2001-01-01 23:45:19.0", "2012-03-03 04:15:29.0", 10,13,29,28,30,10,0); + testDate("2001-01-01 23:45:19.0", "2012-03-03 04:15:59.0", 10,13,29,28,30,40,0); + testDate("2001-01-01 23:45:19.0", "2012-03-03 04:16:00.0", 10,13,29,28,30,41,0); + testDate("2001-01-01 23:45:19.0", "2012-03-03 04:16:00.000000001", 10,13,29,28,30,41,1); + testDate("2001-01-01 23:45:19.0", "2012-03-03 04:16:00.999999999", 10,13,29,28,30,41,999999999); + testDate("2001-01-01 23:45:19.000000001", "2012-03-03 04:16:01.000000000", 10,13,29,28,30,41,999999999); + testDate("2001-01-01 23:45:19.000000002", "2012-03-03 04:16:01.000000001", 10,13,29,28,30,41,999999999); + + //date only + testDate("2001-01-01", "2012-02-01 00:00:00.0", 10,13,0,0,0,0,0); + testDate("2001-01-01", "2012-02-28 00:00:00.0", 10,13,27,0,0,0,0); + testDate("2001-01-01", "2012-02-29 00:00:00.0", 10,13,28,0,0,0,0); + testDate("2001-01-01", "2012-03-01 00:00:00.0", 10,13,29,0,0,0,0); + testDate("2001-01-01", "2012-03-01 23:00:00.0", 10,13,29,23,0,0,0); + testDate("2001-01-01", "2012-03-02 00:00:00.0", 10,13,29,24,0,0,0); + testDate("2001-01-01", "2012-03-02 01:00:00.0", 10,13,29,25,0,0,0); + testDate("2001-01-01", "2012-03-02 01:59:00.0", 10,13,29,25,59,0,0); + testDate("2001-01-01", "2012-03-02 02:01:00.0", 10,13,29,25,61,0,0); + testDate("2001-01-01", "2012-03-02 02:01:59.0", 10,13,29,25,61,59,0); + testDate("2001-01-01", "2012-03-02 02:02:01.0", 10,13,29,25,61,61,0); + testDate("2001-01-01", "2012-03-02 02:02:01.999999999", 10,13,29,25,61,61,999999999); + + //time only + testDate("00:00:00", "0001-01-01 00:00:00.0", 0,0,0,0,0,0,0); + testDate("00:00:00", "0001-01-01 23:00:00.0", 0,0,0,23,0,0,0); + testDate("00:00:00", "0001-01-02 00:00:00.0", 0,0,0,24,0,0,0); + testDate("00:00:00", "0001-01-02 01:00:00.0", 0,0,0,25,0,0,0); + testDate("00:00:00", "0001-01-02 01:00:00.0", 0,0,0,25,0,0,0); + testDate("00:00:00", "0001-01-02 01:59:00.0", 0,0,0,25,59,0,0); + testDate("00:00:00", "0001-01-02 02:00:00.0", 0,0,0,25,60,0,0); + testDate("00:00:00", "0001-01-02 02:01:00.0", 0,0,0,25,61,0,0); + testDate("00:00:00", "0001-01-02 02:01:59.0", 0,0,0,25,61,59,0); + testDate("00:00:00", "0001-01-02 02:02:01.0", 0,0,0,25,61,61,0); + testDate("00:00:00", "0001-01-02 02:02:01.999999999", 0,0,0,25,61,61,999999999); + testDate("00:00:00.000000001", "0001-01-02 02:02:02.0", 0,0,0,25,61,61,999999999); + testDate("00:00:00.000000002", "0001-01-02 02:02:02.000000001", 0,0,0,25,61,61,999999999); + } + + public void testDayOverflow(){ + testDayOverflow("2001-01-31 10:20:30.0", DateTime.DayOverflow.LastDay, "2001-02-28 10:20:30.0", 0,1,0,0,0,0,0); + testDayOverflow("2001-01-31 10:20:30.0", DateTime.DayOverflow.FirstDay, "2001-03-01 10:20:30.0", 0,1,0,0,0,0,0); + testDayOverflow("2001-12-31 10:20:30.0", DateTime.DayOverflow.Spillover, "2002-03-03 10:20:30.0", 0,2,0,0,0,0,0); + + testDayOverflowAbort(SUCCESS, "2001-01-31 10:20:30.0", 0,1,0,0,0,0,0); + testDayOverflowAbort(SUCCESS, "2001-03-31 10:20:30.0", 0,1,0,0,0,0,0); + testDayOverflowAbort(SUCCESS, "2001-03-31 10:20:30.0", 0,1,0,0,0,0,0); + testDayOverflowAbort(SUCCESS, "2001-05-31 10:20:30.0", 0,1,0,0,0,0,0); + testDayOverflowAbort(SUCCESS, "2001-10-31 10:20:30.0", 0,1,0,0,0,0,0); + testDayOverflowAbort(FAIL, "2001-02-28 10:20:30.0", 0,1,0,0,0,0,0); + testDayOverflowAbort(FAIL, "2001-04-30 10:20:30.0", 0,1,0,0,0,0,0); + testDayOverflowAbort(FAIL, "2001-05-01 10:20:30.0", 0,1,0,0,0,0,0); + testDayOverflowAbort(FAIL, "2001-07-31 10:20:30.0", 0,1,0,0,0,0,0); + testDayOverflowAbort(FAIL, "2001-12-31 10:20:30.0", 0,1,0,0,0,0,0); + } + + public void testSingleFieldMinus(){ + testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:45:19.0", 0,0,0,0,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2000-04-15 23:45:19.0", 1,0,0,0,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2001-03-15 23:45:19.0", 0,1,0,0,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2001-04-14 23:45:19.0", 0,0,1,0,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 22:45:19.0", 0,0,0,1,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:44:19.0", 0,0,0,0,1,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:45:18.0", 0,0,0,0,0,1,0); + + testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:45:18.999999999", 0,0,0,0,0,0,1); + testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:45:18.000000001", 0,0,0,0,0,0,999999999); + testDateMinus("2001-04-15 23:45:19.999999999", "2001-04-15 23:45:19.0", 0,0,0,0,0,0,999999999); + + testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:45:09.0", 0,0,0,0,0,10,0); + testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:45:00.0", 0,0,0,0,0,19,0); + testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:44:59.0", 0,0,0,0,0,20,0); + testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:44:49.0", 0,0,0,0,0,30,0); + testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:44:19.0", 0,0,0,0,0,60,0); + + testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:35:19.0", 0,0,0,0,10,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 23:00:19.0", 0,0,0,0,45,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 22:59:19.0", 0,0,0,0,46,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 22:49:19.0", 0,0,0,0,56,0,0); + + testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 13:45:19.0", 0,0,0,10,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2001-04-15 00:45:19.0", 0,0,0,23,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2001-04-14 23:45:19.0", 0,0,0,24,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2001-04-14 22:45:19.0", 0,0,0,25,0,0,0); + + testDateMinus("2001-04-15 23:45:19.0", "2001-04-05 23:45:19.0", 0,0,10,0,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2001-04-01 23:45:19.0", 0,0,14,0,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2001-03-31 23:45:19.0", 0,0,15,0,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2001-03-30 23:45:19.0", 0,0,16,0,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2001-03-01 23:45:19.0", 0,0,45,0,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2001-02-28 23:45:19.0", 0,0,46,0,0,0,0); + + testDateMinus("2001-04-15 23:45:19.0", "2001-01-15 23:45:19.0", 0,3,0,0,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2000-12-15 23:45:19.0", 0,4,0,0,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2000-01-15 23:45:19.0", 0,15,0,0,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "1999-12-15 23:45:19.0", 0,16,0,0,0,0,0); + + testDateMinus("2001-04-15 23:45:19.0", "2000-04-15 23:45:19.0", 1,0,0,0,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "1999-04-15 23:45:19.0", 2,0,0,0,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "1989-04-15 23:45:19.0", 12,0,0,0,0,0,0); + + testDateMinus("1900-04-15 23:45:19.0", "900-04-15 23:45:19.0", 1000,0,0,0,0,0,0); + } + + public void testMultipleFieldsMinus(){ + testDateMinus("2001-04-15 23:45:19.0", "2000-04-15 23:45:19.0", 1,0,0,0,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2000-03-15 23:45:19.0", 1,1,0,0,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2000-03-14 23:45:19.0", 1,1,1,0,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2000-03-14 22:45:19.0", 1,1,1,1,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2000-03-14 22:44:19.0", 1,1,1,1,1,0,0); + testDateMinus("2001-04-15 23:45:19.0", "2000-03-14 22:44:18.0", 1,1,1,1,1,1,0); + testDateMinus("2001-04-15 23:45:19.0", "2000-03-14 22:44:17.999999999", 1,1,1,1,1,1,1); + } + + public void testMultipleFieldsWithRolloversMinus(){ + testDateMinus("2001-04-15 23:45:19.0", "1999-12-15 23:45:19.0", 1,4,0,0,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "1999-11-30 23:45:19.0", 1,4,15,0,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "1999-11-30 00:45:19.0", 1,4,15,23,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "1999-11-29 23:45:19.0", 1,4,15,24,0,0,0); + testDateMinus("2001-04-15 23:45:19.0", "1999-11-29 23:00:19.0", 1,4,15,24,45,0,0); + testDateMinus("2001-04-15 23:45:19.0", "1999-11-29 22:59:19.0", 1,4,15,24,46,0,0); + testDateMinus("2001-04-15 23:45:19.0", "1999-11-29 22:59:00.0", 1,4,15,24,46,19,0); + testDateMinus("2001-04-15 23:45:19.0", "1999-11-29 22:58:59.0", 1,4,15,24,46,20,0); + testDateMinus("2001-04-15 23:45:19.0", "1999-11-29 22:58:58.999999999", 1,4,15,24,46,20,1); + } + + public void testDayOverflowMinus(){ + testDayOverflowMinus("2001-03-31 10:20:30.0", DateTime.DayOverflow.LastDay, "2001-02-28 10:20:30.0", 0,1,0,0,0,0,0); + testDayOverflowMinus("2001-12-31 10:20:30.0", DateTime.DayOverflow.LastDay, "2001-11-30 10:20:30.0", 0,1,0,0,0,0,0); + testDayOverflowMinus("2001-10-31 10:20:30.0", DateTime.DayOverflow.LastDay, "2001-09-30 10:20:30.0", 0,1,0,0,0,0,0); + testDayOverflowMinus("2001-07-31 10:20:30.0", DateTime.DayOverflow.LastDay, "2001-06-30 10:20:30.0", 0,1,0,0,0,0,0); + testDayOverflowMinus("2001-05-31 10:20:30.0", DateTime.DayOverflow.LastDay, "2001-04-30 10:20:30.0", 0,1,0,0,0,0,0); + testDayOverflowMinus("2001-05-31 10:20:30.0", DateTime.DayOverflow.LastDay, "2001-02-28 10:20:30.0", 0,3,0,0,0,0,0); + } + + public void testWeekIssue(){ + testWeekIndex("2009-02-01", "2009-02-01", 1); + testWeekIndex("2009-02-01", "2009-02-02", 1); + testWeekIndex("2009-02-01", "2009-02-03", 1); + testWeekIndex("2009-02-01", "2009-02-04", 1); + testWeekIndex("2009-02-01", "2009-02-05", 1); + testWeekIndex("2009-02-01", "2009-02-06", 1); + testWeekIndex("2009-02-01", "2009-02-07", 1); + testWeekIndex("2009-02-01", "2009-02-08", 2); + testWeekIndex("2009-02-01", "2009-02-09", 2); + testWeekIndex("2009-02-01", "2009-02-10", 2); + testWeekIndex("2009-02-01", "2009-02-11", 2); + testWeekIndex("2009-02-01", "2009-02-12", 2); + testWeekIndex("2009-02-01", "2009-02-13", 2); + testWeekIndex("2009-02-01", "2009-02-14", 2); + testWeekIndex("2009-02-01", "2009-02-15", 3); + testWeekIndex("2009-02-01", "2009-02-16", 3); + + testWeekIndex("2009-04-26", "2009-04-26", 1); + testWeekIndex("2009-04-26", "2009-04-27", 1); + testWeekIndex("2009-04-26", "2009-04-28", 1); + testWeekIndex("2009-04-26", "2009-04-29", 1); + testWeekIndex("2009-04-26", "2009-04-30", 1); + testWeekIndex("2009-04-26", "2009-05-01", 1); + testWeekIndex("2009-04-26", "2009-05-02", 1); + testWeekIndex("2009-04-26", "2009-05-03", 2); + } + + // PRIVATE + + private static final boolean SUCCESS = true; + private static final boolean FAIL = false; + private static final String FORMAT = "YYYY-MM-DD hh:mm:ss.fffffffff"; + + private void testDate(String aInput, String aExpected, int aYearIncr, int aMonthIncr, int aDayIncr, int aHourIncr, int aMinIncr, int aSecIncr, int aNanosIncr){ + DateTime from = new DateTime(aInput); + DateTime result = from.plus(aYearIncr, aMonthIncr, aDayIncr, aHourIncr, aMinIncr, aSecIncr, aNanosIncr, DayOverflow.LastDay); + DateTime expectedResult = new DateTime(aExpected); + if (!result.equals(expectedResult)){ + fail("Expected " + aExpected + ", but actual:" + result.format(FORMAT)); + } + } + + private void testRange(boolean aSuccess, int aYearIncr, int aMonthIncr, int aDayIncr, int aHourIncr, int aMinIncr, int aSecIncr, int aNanosIncr){ + DateTime from = new DateTime("0001-02-28 11:23:56.0"); + try { + DateTime result = from.plus(aYearIncr, aMonthIncr, aDayIncr, aHourIncr, aMinIncr, aSecIncr, aNanosIncr, DateTime.DayOverflow.LastDay); + if(!aSuccess){ + fail(); + } + } + catch(IllegalArgumentException ex){ + if(aSuccess){ + fail(); + } + } + } + + private void testDayOverflow(String aInput, DateTime.DayOverflow aOverflow, String aExpected, int aYearIncr, int aMonthIncr, int aDayIncr, int aHourIncr, int aMinIncr, int aSecIncr, int aNanosIncr){ + DateTime from = new DateTime(aInput); + DateTime expectedResult = new DateTime(aExpected); + DateTime result = from.plus(aYearIncr, aMonthIncr, aDayIncr, aHourIncr, aMinIncr, aSecIncr, aNanosIncr, aOverflow); + if (!result.equals(expectedResult)){ + fail("Expected " + aExpected + ", but actual:" + result.format(FORMAT)); + } + } + + private void testDayOverflowMinus(String aInput, DayOverflow aOverflow, String aExpected, int aYearIncr, int aMonthIncr, int aDayIncr, int aHourIncr, int aMinIncr, int aSecIncr, int aNanosIncr){ + DateTime from = new DateTime(aInput); + DateTime expectedResult = new DateTime(aExpected); + DateTime result = from.minus(aYearIncr, aMonthIncr, aDayIncr, aHourIncr, aMinIncr, aSecIncr, aNanosIncr, aOverflow); + if (!result.equals(expectedResult)){ + fail("Expected " + aExpected + ", but actual:" + result.format(FORMAT)); + } + } + + private void testDayOverflowAbort(boolean aShouldAbort, String aInput, int aYearIncr, int aMonthIncr, int aDayIncr, int aHourIncr, int aMinIncr, int aSecIncr, int aNanosIncr){ + DateTime from = new DateTime(aInput); + try { + DateTime result = from.plus(aYearIncr, aMonthIncr, aDayIncr, aHourIncr, aMinIncr, aSecIncr, aNanosIncr, DayOverflow.Abort); + if(aShouldAbort){ + fail(); + } + } + catch(RuntimeException ex){ + if (! aShouldAbort ){ + fail(); + } + } + } + + private void testDateMinus(String aInput, String aExpected, int aYearIncr, int aMonthIncr, int aDayIncr, int aHourIncr, int aMinIncr, int aSecIncr, int aNanosIncr){ + DateTime from = new DateTime(aInput); + DateTime result = from.minus(aYearIncr, aMonthIncr, aDayIncr, aHourIncr, aMinIncr, aSecIncr, aNanosIncr, DayOverflow.LastDay); + DateTime expectedResult = new DateTime(aExpected); + if (!result.equals(expectedResult)){ + fail("Expected " + aExpected + ", but actual:" + result.format(FORMAT)); + } + } + + private void testMissingParts(String aInput){ + DateTime from = new DateTime(aInput); + boolean hasFailed = true; + try { + from.plus(0,0,0,0,0,0,0,DayOverflow.LastDay); + hasFailed = false; + } + catch (Throwable ex){ + hasFailed = true; + } + if(!hasFailed){ + fail(); + } + } + + private void testWeekIndex(String aStartDate, String aEndDate, int aExpected){ + DateTime start = new DateTime(aStartDate); + DateTime end = new DateTime(aEndDate); + if( end.getWeekIndex(start) != aExpected) { + fail("Expected:" + aExpected + " Actual:" + end.getWeekIndex(start) ); + } + + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/TESTDecimal.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/TESTDecimal.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,830 @@ +package hirondelle.web4j.model; + +import java.util.*; +import java.math.*; + +import junit.framework.*; + +public final class TESTDecimal extends TestCase { + public static void main(String args[]) { + junit.textui.TestRunner.run(TESTDecimal.class); + } + + public void testCtor(){ + testCtorSuccess("0"); + testCtorSuccess("-0"); + testCtorSuccess("0.00"); + testCtorSuccess("4.432672E+7"); + + testCtorSuccess("1" ); + testCtorSuccess("1.0"); + testCtorSuccess("1.00"); + testCtorSuccess("10.00"); + testCtorSuccess("-10.00"); + testCtorSuccess("123456.78"); + testCtorSuccess("9999.99"); + testCtorSuccess("10000000000000000000000.00"); + testCtorSuccess("-10000000000000000000000.00"); + + testCtorSuccess("1.23"); + testCtorSuccess("123213"); + testCtorSuccess("10.0032131"); + testCtorSuccess("1654640.0"); + testCtorSuccess("3132.321313"); + + testCtorSuccess("03132.321313"); // leading zero + testCtorSuccess("01"); + testCtorSuccess("010"); + testCtorSuccess("01.0"); + testCtorSuccess("10.0"); + + testCtorSuccess(0); + testCtorSuccess(+0); + testCtorSuccess(-0); + testCtorSuccess(1); + testCtorSuccess(+1); + testCtorSuccess(-1); + testCtorSuccess(123); + testCtorSuccess(0123); + testCtorSuccess(123456789); + testCtorSuccess(-123456789); + + testCtorSuccess(0.0); + testCtorSuccess(+0.0); + testCtorSuccess(-0.0); + testCtorSuccess(1.0); + testCtorSuccess(+1.0); + testCtorSuccess(-1.0); + testCtorSuccess(0123.0); + testCtorSuccess(123456789.0); + testCtorSuccess(-123456789.0); + + testCtorBDSuccess("0"); + testCtorBDSuccess("-0"); + testCtorBDSuccess("0.00"); + testCtorBDSuccess("4.432672E+7"); + + testCtorBDSuccess("1" ); + testCtorBDSuccess("1.0"); + testCtorBDSuccess("1.00"); + testCtorBDSuccess("010.00"); + testCtorBDSuccess("-10.00"); + testCtorBDSuccess("123456.78"); + testCtorBDSuccess("9999.99"); + testCtorBDSuccess("10000000000000000000000.00"); + testCtorBDSuccess("-10000000000000000000000.00"); + + testCtorBDSuccess("1.23"); + testCtorBDSuccess("123213"); + testCtorBDSuccess("10.0032131"); + testCtorBDSuccess("1654640.0"); + testCtorBDSuccess("3132.321313"); + + testMoneyCtorFail(null); + } + + public void testEquals(){ + Decimal us = Decimal.from("9.89"); + Decimal us2 = Decimal.from("9.89"); + Decimal us3 = Decimal.from("+9.89"); + Decimal lz = Decimal.from("09.89"); + + Decimal us4 = Decimal.from("9.88"); + + Decimal us5 = Decimal.from("10"); + Decimal us6 = Decimal.from("10.00"); + + assertTrue( us.equals(us2) ); + assertTrue( us.equals(us3) ); + assertTrue( us.equals(lz) ); + + assertFalse( us.equals(us4) ); + assertFalse( us5.equals(us6) ); + } + + public void testCompareTo(){ + Decimal us = Decimal.from("9.89"); + Decimal us2 = Decimal.from("9.89"); + Decimal us4 = Decimal.from("9.88"); + Decimal lz = Decimal.from("09.89"); + + Decimal us5 = Decimal.from("10"); + Decimal us6 = Decimal.from("10.00"); + + assertTrue( us.compareTo(us2) == 0); + assertTrue( us.compareTo(lz) == 0); + assertTrue( us.compareTo(us4) > 0); + assertTrue( us5.compareTo(us6) == 0); + } + + public void testAmount(){ + testAmount("1", "1"); + testAmount("0", "0"); + testAmount("0.00", "0.00"); + testAmount("00.00", "00.00"); + testAmount("-1", "-1"); + testAmount("1.0", "1.0"); + testAmount("23", "23"); + testAmount("123000", "123000"); + testAmount("123000.0001", "123000.0001"); + testAmount("-123000", "-123000"); + } + + public void testNumOfDecimals(){ + testNumOfDecimals("12.13", 2); + testNumOfDecimals("12", 0); + testNumOfDecimals("123456", 0); + testNumOfDecimals("12.1", 1); + testNumOfDecimals("12.0", 1); + testNumOfDecimals("-12.9846", 4); + testNumOfDecimals("-123456", 0); + testNumOfDecimals("-12.1", 1); + testNumOfDecimals("-12.0", 1); + testNumOfDecimals("-12.9846", 4); + testNumOfDecimals("123000", 0); + } + + public void testIsInteger(){ + testIsInteger("0", PASS); + testIsInteger("0.0", PASS); + testIsInteger("0.000", PASS); + testIsInteger("100", PASS); + testIsInteger("100.0", PASS); + testIsInteger("100.00000000", PASS); + testIsInteger("-100", PASS); + testIsInteger("-100.0", PASS); + testIsInteger("-100.00000000", PASS); + + testIsInteger("0.0000000001", FAIL); + testIsInteger("100.0000000001", FAIL); + testIsInteger("-100.0000000001", FAIL); + testIsInteger("2.3216549873211", FAIL); + } + + public void testIsPlus(){ + testIsPlusFor("9.89", PASS); + testIsPlusFor("989", PASS); + testIsPlusFor("98900", PASS); + testIsPlusFor("0.01", PASS); + + testIsPlusFor("+9.89", PASS); + testIsPlusFor("+989", PASS); + testIsPlusFor("+98900", PASS); + testIsPlusFor("+0.01", PASS); + + testIsPlusFor("0.00", FAIL); + testIsPlusFor("-0.01", FAIL); + testIsPlusFor("-9.89", FAIL); + testIsPlusFor("9", PASS); + } + + public void testIsMinus(){ + testIsMinusFor("9.89", FAIL); + testIsMinusFor("-989", PASS); + testIsMinusFor("-989000", PASS); + testIsMinusFor("0.01", FAIL); + testIsMinusFor("0.00", FAIL); + testIsMinusFor("-0.01", PASS); + testIsMinusFor("-9.89", PASS); + testIsMinusFor("-100", PASS); + } + + public void testIsZero(){ + testIsZeroFor("0.00", PASS); + testIsZeroFor("0", PASS); + testIsZeroFor("0.0", PASS); + testIsZeroFor("00.00", PASS); + testIsZeroFor(".00", PASS); + testIsZeroFor(".0", PASS); + + testIsZeroFor("+0.00", PASS); + testIsZeroFor("+0", PASS); + testIsZeroFor("+0.0", PASS); + testIsZeroFor("+00.00", PASS); + testIsZeroFor("+.00", PASS); + testIsZeroFor("+.0", PASS); + + testIsZeroFor("0.01", FAIL); + testIsZeroFor("1.01", FAIL); + } + + public void testEq(){ + Decimal us1 = Decimal.from("10.00"); + Decimal us2 = Decimal.from("10.20"); + Decimal us3 = Decimal.from("10.20"); + Decimal us4 = Decimal.from("99"); + Decimal us5 = Decimal.from("99.00"); + Decimal us6 = Decimal.from("00099.00"); + Decimal val1 = Decimal.from("-5.00"); + Decimal val2 = Decimal.from("-5"); + assertTrue( us2.eq(us3) ); + assertTrue( us4.eq(us5) ); + assertTrue( us4.eq(us6) ); + assertTrue( val1.eq(val2) ); + assertFalse( us1.eq(us2) ); + } + + public void testGt(){ + Decimal us1 = Decimal.from("1.00"); + Decimal us2 = Decimal.from("10.20"); + Decimal us3 = Decimal.from("10.200"); + Decimal us4 = Decimal.from("1"); + Decimal val1 = Decimal.from("-5"); + Decimal val2 = Decimal.from("-5.00"); + Decimal val3 = Decimal.from("-5.50"); + + assertTrue( us2.gt(us1) ); + assertTrue( us3.gt(us1) ); + assertTrue( val1.gt(val3) ); + assertTrue( val1.gt(val3) ); + assertTrue( us1.gt(val1) ); + + assertFalse( us2.gt(us3) ); + assertFalse( us1.gt(us4) ); + assertFalse( us4.gt(us1) ); + assertFalse( val2.gt(val1) ); + } + + public void testGteq(){ + Decimal us1 = Decimal.from("1.00"); + Decimal us2 = Decimal.from("10.20"); + Decimal us3 = Decimal.from("10.200"); + Decimal us4 = Decimal.from("1"); + Decimal val1 = Decimal.from("-53"); + Decimal val2 = Decimal.from("-53.0000001"); + Decimal val3 = Decimal.from("-54"); + assertTrue( us2.gteq(us1) ); + assertTrue( us2.gteq(us3) ); + assertTrue( us3.gteq(us1) ); + assertTrue( us1.gteq(us4) ); + assertTrue( val1.gteq(val2) ); + assertTrue( val1.gteq(val3) ); + assertTrue( us3.gteq(us2) ); + + assertFalse( val2.gteq(val1) ); + assertFalse( val3.gteq(val1) ); + } + + public void testLt(){ + Decimal us1 = Decimal.from("1.00"); + Decimal us2 = Decimal.from("10.20"); + Decimal us3 = Decimal.from("10.200"); + Decimal us4 = Decimal.from("1"); + Decimal val1 = Decimal.from("-23"); + Decimal val2 = Decimal.from("-023"); + Decimal val3 = Decimal.from("-23.00"); + Decimal val4 = Decimal.from("-23.001"); + assertTrue(val4.lt(val1)); + assertFalse(val3.lt(val2)); + assertFalse( us2.lt(us1) ); + assertFalse( us2.lt(us3) ); + assertFalse( us3.lt(us1) ); + assertFalse( us1.lt(us4) ); + } + + public void testLteq(){ + Decimal us1 = Decimal.from("1.00"); + Decimal us2 = Decimal.from("10.20"); + Decimal us3 = Decimal.from("10.20"); + Decimal us4 = Decimal.from("1"); + Decimal val1 = Decimal.from("-16"); + Decimal val2 = Decimal.from("-16.00"); + Decimal val3 = Decimal.from("-16.001"); + assertTrue( us1.lteq(us2) ); + assertTrue( us2.lteq(us3) ); + assertTrue( val3.lteq(val2) ); + assertTrue( val1.lteq(val2) ); + assertTrue( val3.lteq(val1) ); + assertTrue( us1.lteq(us4) ); + + assertFalse( us3.lteq(us1) ); + assertFalse( us3.lteq(us1) ); + assertFalse( val1.lteq(val3) ); + } + + public void testPlus(){ + testPlus("1", "2", "3"); + testPlus("1.0", "2", "3.0"); + testPlus("0", "55.5", "55.5"); + testPlus("-1", "5", "4"); + testPlus("-1", "5.0", "4.0"); + testPlus("00010", "6", "16"); + testPlus("00010", "0006", "00016"); + testPlus("-11", "11", "0"); + testPlus("123", "400", "523"); + } + + public void testMinus(){ + testMinus("1", "2", "-1"); + testMinus("1.0", "2", "-1.0"); + testMinus("0", "55.5", "-55.5"); + testMinus("-1", "5", "-6"); + testMinus("-1", "5.0", "-6.0"); + testMinus("00010", "6", "4"); + testMinus("00010", "0006", "0004"); + testMinus("-11", "11", "-22"); + testMinus("523", "123", "400"); + testMinus("523", "123.0", "400.0"); + testMinus("523", "123.00", "400.00"); + } + + public void testSum(){ + testSum("0", "0"); + testSum("0.0", "0.0"); + testSum("00.0", "00.0"); + testSum("1", "0", "1"); + testSum("1.0", "0", "1.0"); + testSum("1.00", "0", "1.00"); + testSum("0.00", "-1", "1.00"); + testSum("0.000", "-1.000", "1.00"); + testSum("0.00000", "12.12345", "-12.12345"); + testSum("90", "12", "-12", "45", "45"); + testSum("66", "-12", "-12", "45", "45"); + testSum("66.0001", "-12", "-12", "45", "45.0001"); + } + + public void testTimes(){ + testTimes("10", "2", "20"); + testTimes("10", "3", "30"); + testTimes("25", "2", "50"); + testTimes("-25", "10", "-250"); + testTimes("+12", "3", "36"); + testTimes("8", "7", "56"); + + testTimes("10.00", "2", "20.00"); + testTimes("10.00", "3", "30.00"); + testTimes("10.00", "9", "90.00"); + testTimes("10.00", "-2", "-20.00"); + testTimes("100000.00", "-2", "-200000.00"); + testTimes("10.0000", "2", "20.0000"); + + testTimes("10", "2.0", "20.0"); + testTimes("10", "3.0", "30.0"); + testTimes("25", "2.0", "50.0"); + testTimes("-25", "10.0", "-250.0"); + testTimes("+12", "3.0", "36.0"); + testTimes("8", "7.0", "56.0"); + + testTimes("987.65", "7.0", "6913.550"); + testTimes("987.65", "7.123", "7035.03095"); + testTimes("987.65", "-0.0125", "-12.345625"); + + testTimes("0008", "7.0", "56.0"); + testTimes("0008", "07.0", "56.0"); + testTimes("0008", "7.0", "0056.0"); + testTimes("-8", "-7.0", "56.0"); + testTimes("-8000000", "-70.0", "560000000.0"); + } + + public void testDiv(){ + testDiv("10", "2", "5"); + testDiv("10.00", "2", "5.00"); + testDiv("-10", "2", "-5"); + testDiv("10.0", "2", "5.0"); + testDiv("10", "3", "3.33333333333333333333"); + testDiv("-10", "3", "-3.33333333333333333333"); + testDiv("10.0", "3", "3.33333333333333333333"); + testDiv("10.00", "3", "3.33333333333333333333"); + + testDiv("100.00", "3", "33.33333333333333333333"); + testDiv("1025.25", "1", "1025.25"); + testDiv("1025.25", "2", "512.625"); + testDiv("1025.25", "3", "341.75"); + testDiv("1025.25", "4", "256.3125"); + testDiv("1025.25", "5", "205.05"); + testDiv("1025.25", "6", "170.875"); + testDiv("1025.25", "7", "146.46428571428571428571"); + testDiv("1025.25", "8", "128.15625"); + testDiv("1025.25", "9", "113.91666666666666666667"); + testDiv("1025.25", "10", "102.525"); + + testDiv("10", "2.0", "5"); + testDiv("10.00", "2.0", "5.0"); + testDiv("-10", "2.0", "-5"); + testDiv("10.0", "2.0", "5"); + testDiv("10", "3.0", "3.33333333333333333333"); + testDiv("-10", "3.00", "-3.33333333333333333333"); + testDiv("10.0", "3.000", "3.33333333333333333333"); + testDiv("10.00", "3.0000", "3.33333333333333333333"); + + testDiv("123456", "0.00003213", "3842390289.44911297852474323063"); + + testDiv("100.00", "3.0", "33.33333333333333333333"); + testDiv("1025.25", "1.0", "1025.25"); + testDiv("1025.25", "2.0", "512.625"); + testDiv("1025.25", "3.0", "341.75"); + testDiv("1025.25", "4.0", "256.3125"); + testDiv("1025.25", "5.0", "205.05"); + testDiv("1025.25", "6.0", "170.875"); + testDiv("1025.25", "7.0", "146.46428571428571428571"); + testDiv("1025.25", "8.0000", "128.15625"); + testDiv("1025.25", "9.0", "113.91666666666666666667"); + testDiv("1025.25", "10.00", "102.525"); + + testDiv("1025.25", "0.125", "8202"); + testDiv("1025.25", "10.125", "101.25925925925925925926"); + + testDiv("1025.25", "0.001", "1025250"); + } + + public void testAbs(){ + testAbs("0","0"); + testAbs("0","+0"); + testAbs("0","-0"); + testAbs("12","-12"); + testAbs("12.753","-12.753"); + testAbs("12.753","-12.753"); + testAbs("12.753","012.753"); + testAbs("12.753","12.753"); + } + + public void testNegate(){ + testNegate("0","0"); + testNegate("-1","1"); + testNegate("-1.0","1.0"); + testNegate("1","-1"); + testNegate("1.00","-1.00"); + testNegate("-1.00","01.00"); + } + + public void testTypicalOperations(){ + Decimal price = Decimal.from("125.48"); + Decimal expectedCost = Decimal.from("627.40"); + Decimal expectedCostWithTax = Decimal.from("658.77"); + Decimal factor1 = Decimal.from("10"); + Decimal expectedProduct1 = Decimal.from("1254.80"); + Decimal factor2 = Decimal.from("0.125"); + Decimal expectedProduct2 = Decimal.from("15.685"); + + Decimal denom = Decimal.from("0.125"); + Decimal expectedQuotient = Decimal.from("1003.84"); + + Decimal cost = price.times(5); + assertTrue(cost.equals(expectedCost)); + + Decimal costWithTax = cost.times(1.05); + assertTrue(costWithTax.eq(expectedCostWithTax)); + + Decimal product = price.times(factor1); + assertTrue(product.eq(expectedProduct1)); + + product = price.times(factor2); + assertTrue(product.eq(expectedProduct2)); + + Decimal quotient = price.div(denom); + assertTrue(quotient.eq(expectedQuotient)); + } + + public void testToString(){ + Decimal us1 = Decimal.from("1000.00"); + assertTrue( us1.toString().equals("1000.00")); + } + + public void testRoundToInteger(){ + testRound("10.25", "10"); + testRound("10", "10"); + testRound("10.45", "10"); + testRound("10.50", "10"); + testRound("10.51", "11"); + testRound("10.52", "11"); + testRound("10.99", "11"); + testRound("11.00", "11"); + testRound("1000000", "1000000"); + + testRound("-10.25", "-10"); + testRound("-10", "-10"); + testRound("-10.45", "-10"); + testRound("-10.50", "-10"); + testRound("-10.51", "-11"); + testRound("-10.52", "-11"); + testRound("-10.99", "-11"); + } + + public void testRoundToNDecimals(){ + testRound("10.25", 0, "10"); + testRound("10.25", 1, "10.2"); + testRound("10.25", 2, "10.25"); + testRound("10.25", 3, "10.250"); + } + + public void testRound2(){ + testRound2("1710.10", "0.05", "1710.10"); + testRound2("1710.11", "0.05", "1710.10"); + testRound2("1710.12", "0.05", "1710.10"); + testRound2("1710.13", "0.05", "1710.15"); + testRound2("1710.14", "0.05", "1710.15"); + testRound2("1710.15", "0.05", "1710.15"); + testRound2("1710.16", "0.05", "1710.15"); + testRound2("1710.17", "0.05", "1710.15"); + testRound2("1710.18", "0.05", "1710.20"); + testRound2("1710.19", "0.05", "1710.20"); + testRound2("1710.20", "0.05", "1710.20"); + + testRound2("1710.101", "0.05", "1710.10"); + testRound2("1710.1099999", "0.05", "1710.10"); + testRound2("1710.121", "0.05", "1710.10"); + testRound2("1710.124", "0.05", "1710.10"); + testRound2("1710.125", "0.05", "1710.10"); + + testRound2("1710.1250000000001", "0.05", "1710.15"); + testRound2("1710.126", "0.05", "1710.15"); + testRound2("1710.129", "0.05", "1710.15"); + + testRound2("1710.20", "100", "1700"); + testRound2("1710.20", "1000", "2000"); + testRound2("171256789", "100", "171256800"); + testRound2("171256789", "1000", "171257000"); + testRound2("171256789", "1000000", "171000000"); + + //same, but using overloads + + testRound2("1710.10", 0.05, "1710.10"); + testRound2("1710.11", 0.05, "1710.10"); + testRound2("1710.12", 0.05, "1710.10"); + testRound2("1710.13", 0.05, "1710.15"); + testRound2("1710.14", 0.05, "1710.15"); + testRound2("1710.15", 0.05, "1710.15"); + testRound2("1710.16", 0.05, "1710.15"); + testRound2("1710.17", 0.05, "1710.15"); + testRound2("1710.18", 0.05, "1710.20"); + testRound2("1710.19", 0.05, "1710.20"); + testRound2("1710.20", 0.05, "1710.20"); + + testRound2("1710.101", 0.05, "1710.10"); + testRound2("1710.1099999", 0.05, "1710.10"); + testRound2("1710.121", 0.05, "1710.10"); + testRound2("1710.124", 0.05, "1710.10"); + testRound2("1710.125", 0.05, "1710.10"); + + testRound2("1710.1250000000001", 0.05, "1710.15"); + testRound2("1710.126", 0.05, "1710.15"); + testRound2("1710.129", 0.05, "1710.15"); + + testRound2("1710.20", 100, "1700"); + testRound2("1710.20", 1000, "2000"); + testRound2("171256789", 100, "171256800"); + testRound2("171256789", 1000, "171257000"); + testRound2("171256789", 1000000, "171000000"); + } + + public void testPow(){ + testPow("1", 1, "1"); + testPow("1", 0, "1"); + testPow("1", -1, "1"); + + testPow("0", 0, "1"); + testPow("0", 1, "0"); + + testPow("10", 0, "1"); + testPow("10.00", 0, "1"); + testPow("010.00", 0, "1"); + testPow("10", 1, "10"); + testPow("10.00", 1, "10.00"); + testPow("-10.00", 1, "-10.00"); + testPow("-10.123", 1, "-10.123"); + testPow("10", 2, "100"); + testPow("10.0", 2, "100.0"); + testPow("5", 2, "25"); + testPow("5.0", 2, "25.0"); + testPow("10", -1, "0.1"); + testPow("10", -2, "0.01"); + testPow("10", -20, "0.00000000000000000001"); + testPow("-123456.789", 0, "1"); + testPow("-123456.789", 1, "-123456.789"); + + testPow("2", 4, "16"); + testPow("2.000", 4, "16.000"); + testPow("2.0001", 4, "16.0032002400080001"); + testPow("2", -4, "0.0625"); + + testPow("1", "0" ,"1"); + testPow("1", "0.0" ,"1"); + testPow("1", "2" ,"1"); + testPow("1", "3" ,"1"); + testPow("1", "2.0" ,"1"); + testPow("1", "2.00" ,"1"); + testPow("1", "-1" ,"1"); + testPow("1", "0.5" ,"1"); + + testPow("2", "0" ,"1"); + testPow("2", "0.0" ,"1"); + testPow("2", "1" ,"2"); + testPow("2", "1.0" ,"2"); + testPow("2", "2.0" ,"4"); + testPow("2", "2.5" ,"5.65685424949238"); + testPow("2", "-2.5" ,"0.17677669529663687"); + testPow("25", "0.5" ,"5"); + testPow("25", "-0.5" ,"0.2"); + testPow("81", "0.5" ,"9"); + testPow("81.0000", "0.5" ,"9"); + + } + + // PRIVATE + + private void testCtorSuccess(String aAmount){ + @SuppressWarnings("unused") + Decimal decimal = Decimal.from(aAmount); + } + + private void testCtorBDSuccess(String aAmount){ + @SuppressWarnings("unused") + Decimal decimal = new Decimal(new BigDecimal(aAmount)); + } + + private void testCtorSuccess(long aAmount){ + @SuppressWarnings("unused") + Decimal decimal = Decimal.from(aAmount); + } + + private void testCtorSuccess(double aAmount){ + @SuppressWarnings("unused") + Decimal decimal = Decimal.from(aAmount); + } + + private static final boolean PASS = true; + private static final boolean FAIL = false; + + private void testMoneyCtorFail(String aAmount){ + BigDecimal amount = aAmount == null ? null : new BigDecimal(aAmount); + try { + @SuppressWarnings("unused") + Decimal money = new Decimal(amount); + fail("Should have failed."); + } + catch (IllegalArgumentException ex){ + //expected + //log("Problemo : " + ex); + } + } + + private void testIsPlusFor(String aAmount, boolean aSucceed){ + Decimal money = Decimal.from(aAmount); + if ( aSucceed ) { + assertTrue( money.isPlus() ); + } + else { + assertFalse( money.isPlus() ); + } + } + + private void testIsMinusFor(String aAmount, boolean aSucceed){ + Decimal money = Decimal.from(aAmount); + if ( aSucceed ) { + assertTrue( money.isMinus() ); + } + else { + assertFalse( money.isMinus() ); + } + } + + private void testIsZeroFor(String aAmount, boolean aSucceed){ + Decimal money = Decimal.from(aAmount); + if ( aSucceed ) { + assertTrue( money.isZero() ); + } + else { + assertFalse( money.isZero() ); + } + } + + private void testTimes(String aValue, String aFactor, String aExpectedAnswer){ + Decimal expected = Decimal.from(aExpectedAnswer); + Decimal value = Decimal.from(aValue); + Decimal factor = Decimal.from(aFactor); + Decimal actual = value.times(factor); + if (! actual.equals(expected) ){ + throw new RuntimeException("Actual " + actual + " Expected: " + expected); + } + } + + private void testDiv(String aTop, String aBottom, String aExpectedAnswer){ + Decimal expected = Decimal.from(aExpectedAnswer); + Decimal top = Decimal.from(aTop); + Decimal bottom = Decimal.from(aBottom); + Decimal actual = top.div(bottom); + /* Uses eq(), not equals()! + * Found this case in which the values are the same, but the scales differ. + * actual 102525, with scale -1 (that is, add a zero to the end of the integer) + * expec 1025250, with scale 0 + */ + if (! actual.eq(expected) ){ + throw new RuntimeException("Actual " + actual + " Expected: " + expected); + } + } + + private void testRound(String aInput, String aExpectedAnswer){ + Decimal expected = Decimal.from(aExpectedAnswer); + Decimal money = Decimal.from(aInput); + Decimal calc = money.round(); + assertTrue(calc.equals(expected)); + assertTrue(calc.getAmount().scale() == 0); + } + + private void testRound(String aInput, int aNumDecimals, String aExpectedAnswer){ + Decimal expected = Decimal.from(aExpectedAnswer); + Decimal money = Decimal.from(aInput); + Decimal calc = money.round(aNumDecimals); + assertTrue(calc.equals(expected)); + assertTrue(calc.getAmount().scale() == aNumDecimals); + } + + private void testRound2(String aInput, String aInterval, String aExpectedAnswer){ + Decimal input = Decimal.from(aInput); + Decimal interval = Decimal.from(aInterval); + Decimal actual = input.round2(interval); + Decimal expected = Decimal.from(aExpectedAnswer); + assertTrue(actual.equals(expected)); + } + + private void testRound2(String aInput, long aInterval, String aExpectedAnswer){ + Decimal input = Decimal.from(aInput); + Decimal actual = input.round2(aInterval); + Decimal expected = Decimal.from(aExpectedAnswer); + assertTrue(actual.equals(expected)); + } + + private void testRound2(String aInput, double aInterval, String aExpectedAnswer){ + Decimal input = Decimal.from(aInput); + Decimal actual = input.round2(aInterval); + Decimal expected = Decimal.from(aExpectedAnswer); + assertTrue(actual.equals(expected)); + } + + private void testNumOfDecimals(String aAmount, int aNumDecimals){ + Decimal money = Decimal.from(aAmount); + assertTrue(money.getNumDecimals() == aNumDecimals); + } + + private void testIsInteger(String aValue, boolean aPass){ + if(aPass){ + assertTrue(Decimal.from(aValue).isInteger()); + } + else { + assertFalse(Decimal.from(aValue).isInteger()); + } + } + + private void testAmount(String aInput, String aExpectedAnswer){ + BigDecimal expected = new BigDecimal(aExpectedAnswer); + Decimal input = Decimal.from(aInput); + assertTrue(input.getAmount().equals(expected)); + } + + private void testPlus(String a, String b, String aExpected){ + Decimal expected = Decimal.from(aExpected); + Decimal actual = Decimal.from(a).plus(Decimal.from(b)); + assertTrue( actual.equals( expected) ); + } + + private void testMinus(String a, String b, String aExpected){ + Decimal expected = Decimal.from(aExpected); + Decimal actual = Decimal.from(a).minus(Decimal.from(b)); + assertTrue( actual.equals( expected) ); + } + + public void testSum(String aExpected, String... aValues){ + List values = new ArrayList(); + for(String value : aValues){ + values.add(Decimal.from(value)); + } + Decimal expected = Decimal.from(aExpected); + Decimal actual = Decimal.sum(values); + assertTrue( actual.equals(expected) ); + } + + public void testAbs(String aExpected, String aValue){ + Decimal value = Decimal.from(aValue); + Decimal actual = value.abs(); + Decimal expected = Decimal.from(aExpected); + assertTrue( actual.equals(expected) ); + } + + public void testNegate(String aExpected, String aValue){ + Decimal value = Decimal.from(aValue); + Decimal actual = value.negate(); + Decimal expected = Decimal.from(aExpected); + assertTrue( actual.equals(expected) ); + } + + private void testPow(String aBase, int aExponent, String aExpected){ + Decimal base = Decimal.from(aBase); + Decimal actual = base.pow(aExponent); + Decimal expected = Decimal.from(aExpected); + if (! actual.eq(expected) ){ + throw new RuntimeException("Actual " + actual + " Expected: " + expected); + } + } + + private void testPow(String aBase, String aExponent, String aExpected){ + Decimal base = Decimal.from(aBase); + Decimal exponent = Decimal.from(aExponent); + Decimal actual = base.pow(exponent); + Decimal expected = Decimal.from(aExpected); + if (! actual.eq(expected) ){ + throw new RuntimeException("Actual " + actual + " Expected: " + expected); + } + } + + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/TESTEqualsUtil.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/TESTEqualsUtil.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,197 @@ +package hirondelle.web4j.model; + +import junit.framework.*; +//import junit.ui.TestRunner; +//import junit.textui.TestRunner; +//import junit.swingui.TestRunner; +import java.util.*; + +/** + JUnit test cases for {@link ModelUtil}. +*/ +public final class TESTEqualsUtil extends TestCase { + + /** + Run the test cases. + */ + public static void main(String args[]) { + String[] testCaseName = { TESTEqualsUtil.class.getName()}; + //Select one of several types of interfaces. + junit.textui.TestRunner.main(testCaseName); + //junit.swingui.TestRunner.main(testCaseName); + //junit.ui.TestRunner.main(testCaseName); + } + + /** + Canonical form of constructor. + */ + public TESTEqualsUtil( String aName) { + super( aName ); + } + + // TEST CASES // + + public void testBoolean(){ + assertTrue( ModelUtil.areEqual(true, true) ); + assertTrue( ModelUtil.areEqual(false, false) ); + assertTrue( !ModelUtil.areEqual(false, true) ); + assertTrue( !ModelUtil.areEqual(true, false) ); + } + + public void testChar(){ + assertTrue( ModelUtil.areEqual('A', 'A') ); + assertTrue( ! ModelUtil.areEqual('A', 'B') ); + } + + public void testByte(){ + byte zero = 0; + byte one = 1; + assertTrue( ModelUtil.areEqual(one, one) ); + assertTrue( ! ModelUtil.areEqual(one, zero) ); + } + + public void testShort(){ + short ten = 10; + short twenty = 20; + assertTrue( ModelUtil.areEqual(ten, ten) ); + assertTrue( !ModelUtil.areEqual(ten, twenty) ); + } + + public void testInt(){ + int hundred = 100; + int thousand = 1000; + assertTrue( ModelUtil.areEqual(hundred, hundred) ); + assertTrue( !ModelUtil.areEqual(hundred, thousand) ); + } + + public void testLong(){ + long hundred = 100; + long thousand = 1000; + assertTrue( ModelUtil.areEqual(hundred, hundred) ); + assertTrue( !ModelUtil.areEqual(hundred, thousand) ); + } + + public void testFloat(){ + float hundred = 100.0F; + float thousand = 1000.0F; + assertTrue( ModelUtil.areEqual(hundred, hundred) ); + assertTrue( !ModelUtil.areEqual(hundred, thousand) ); + } + + public void testDouble(){ + double hundred = 100.0D; + double thousand = 1000.0D; + assertTrue( ModelUtil.areEqual(hundred, hundred) ); + assertTrue( !ModelUtil.areEqual(hundred, thousand) ); + } + + public void testNullObject(){ + Object one = null; + Object two = null; + assertTrue( ModelUtil.areEqual(one, two) ); + one = new Object(); + assertTrue( ! ModelUtil.areEqual(one, two) ); + } + + public void testStrings(){ + testObjects("one", "one", Outcome.SUCCESS); + testObjects("o", "o", Outcome.SUCCESS); + testObjects("", "", Outcome.SUCCESS); + testObjects(" ", " ", Outcome.SUCCESS); + testObjects(" ", " ", Outcome.SUCCESS); + testObjects("\u00E9", "\u00E9", Outcome.SUCCESS); + testObjects(" \u00E9", " \u00E9", Outcome.SUCCESS); + testObjects("\u00E9", "é", Outcome.SUCCESS); + + testObjects("one", "two", Outcome.FAIL); + testObjects("one", "one ", Outcome.FAIL); + testObjects("one", " one", Outcome.FAIL); + testObjects("", " ", Outcome.FAIL); + testObjects("\u00E9", "é ", Outcome.FAIL); + testObjects("\u00E9", "e", Outcome.FAIL); + } + + public void testIntegers(){ + Integer one = new Integer(1); + Integer two = new Integer(2); + assertTrue( ModelUtil.areEqual(one, one) ); + assertTrue( ! ModelUtil.areEqual(one, two) ); + } + + public void testLists(){ + List bands = new ArrayList(); + bands.add("French Funk Federation"); + bands.add("Les Rita Mitsouko"); + + List books = new ArrayList(); + books.add("L'Education Sentimentale"); + + List books2 = new ArrayList(); + books2.add("L'Education Sentimentale"); + + List books3 = new ArrayList(); + books3.add(" L'Education Sentimentale"); + + testObjects(bands, bands, Outcome.SUCCESS); + testObjects(books, books, Outcome.SUCCESS); + testObjects(books, books2, Outcome.SUCCESS); + + testObjects(bands, books, Outcome.FAIL); + testObjects(books, books3, Outcome.FAIL); + testObjects(bands, null, Outcome.FAIL); + } + + public void testArrays(){ + String[] a = {"1"}; + String[] a2 = {"1"}; + String[] b = {"1 "}; + String[] c = {"1", "2"}; + String[] c2 = {"1", "2"}; + String[] d = {"1", "2 "}; + testObjects(a, a, Outcome.SUCCESS); + testObjects(a, a2, Outcome.SUCCESS); + testObjects(a, b, Outcome.FAIL); + testObjects(c, c2, Outcome.SUCCESS); + testObjects(null, null, Outcome.SUCCESS); + + testObjects(c, d, Outcome.FAIL); + testObjects(c, null, Outcome.FAIL); + + //nested arrays + Object[] n1 = {a, c}; + Object[] n2 = {a2, c2}; + Object[] n3 = {a2, d}; + testObjects(n1, n2, Outcome.SUCCESS); + testObjects(n1, n3, Outcome.FAIL); + } + + // FIXTURE // + + /** + Build a fixture of test objects. This method is called anew for + each test, such that the tests will always start with the same + set of test objects, and the execution of one test will not interfere + with the execution of another. + */ + protected void setUp(){ + } + + /** + Re-set test objects. + */ + protected void tearDown() { + } + + // PRIVATE // + + private enum Outcome {SUCCESS, FAIL}; + + private void testObjects(Object a, Object b, Outcome outcome){ + if (Outcome.SUCCESS == outcome){ + assertTrue(ModelUtil.areEqual(a, b)); + } + else { + assertFalse(ModelUtil.areEqual(a, b)); + } + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/TESTHashCodeUtil.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/TESTHashCodeUtil.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,154 @@ +package hirondelle.web4j.model; + +import junit.framework.*; +//import junit.ui.TestRunner; +//import junit.textui.TestRunner; +import java.util.*; + +/** + JUnit test cases for {@link ModelUtil}. + +

These tests are rather odd, since the hashCode contract is rather odd: it + doesn't specify the precise values to be returned. Hence, these test cases + have knowledge of implementation details which are usually ignored by tests. +*/ +public final class TESTHashCodeUtil extends TestCase { + + /** + Run the test cases. + */ + public static void main(String args[]) { + String[] testCaseName = { TESTHashCodeUtil.class.getName()}; + //Select one of several types of interfaces. + junit.textui.TestRunner.main(testCaseName); + //junit.swingui.TestRunner.main(testCaseName); + //junit.ui.TestRunner.main(testCaseName); + } + + /** + Canonical form of constructor. + */ + public TESTHashCodeUtil( String aName) { + super( aName ); + } + + // TEST CASES // + + public void testBoolean(){ + int value = ModelUtil.hash(fSEED, true); + assertTrue(value == (fODD_PRIME*fSEED + 1)); + + value = ModelUtil.hash(fSEED, false); + assertTrue(value == (fODD_PRIME*fSEED + 0)); + } + + public void testChar(){ + char val = 'A'; + int value = ModelUtil.hash(fSEED, val); + assertTrue(value == (fODD_PRIME*fSEED + val)); + } + + public void testInt(){ + int val = 1003; + int value = ModelUtil.hash(fSEED,val); + assertTrue( value == (fODD_PRIME*fSEED + val)); + } + + public void testNullObject(){ + Object item = null; + int value = ModelUtil.hash(fSEED, item); + assertTrue( value == fODD_PRIME*fSEED + 0); + } + + public void testString(){ + String blah = "blah"; + int blahHashCode = blah.hashCode(); + int value = ModelUtil.hash(fSEED, blah); + assertTrue( value == (fODD_PRIME*fSEED + blahHashCode)); + } + + public void testList(){ + List girlfriends = new ArrayList(); + girlfriends.add("Gilberte"); + girlfriends.add("Albertine"); + int girlfriendsHashCode = girlfriends.hashCode(); + + int value = ModelUtil.hash(fSEED, girlfriends); + assertTrue(value == (fODD_PRIME*fSEED + girlfriendsHashCode)); + } + + public void testNullArray(){ + String[] stuff = null; + int value = ModelUtil.hash(fSEED, stuff); + assertTrue(value == fODD_PRIME*fSEED + 0); + } + + public void testStringArray(){ + String gilberte = "Gilberte"; + String albertine = "Albertine"; + String[] names = new String[2]; + names[0] = gilberte; + names[1] = albertine; + + int expectedVal = fODD_PRIME*fSEED + gilberte.hashCode(); + expectedVal = fODD_PRIME*expectedVal + albertine.hashCode(); + + int value = ModelUtil.hash(fSEED, names); + assertTrue(value == expectedVal); + } + + public void testIntArray(){ + int[] ages = new int[2]; + ages[0] = 41; + ages[1] = 57; + int expectedVal = fODD_PRIME*fSEED + 41; + expectedVal = fODD_PRIME*expectedVal + 57; + + int value = ModelUtil.hash(fSEED, ages); + assertTrue(value == expectedVal); + } + + public void testArrayOfNulls(){ + Object[] stuff = new Object[2]; + //all elements are now initialized to null + int expectedVal = fODD_PRIME*fSEED + 0; + expectedVal = fODD_PRIME*expectedVal + 0; + + int value = ModelUtil.hash(fSEED, stuff); + assertTrue(value == expectedVal); + } + + public void testNestedArray(){ + Object[] stuff = new Object[2]; + Object[] otherStuff = new Object[2]; + + otherStuff[0] = "otherthing1"; + otherStuff[1] = "otherthing2"; + stuff[0] = "thing"; + stuff[1] = otherStuff; + //just makes sure it doesn't blow up, or go into a loop + int value = ModelUtil.hash(fSEED, stuff); + assertTrue(value == 1757769324); + } + + // FIXTURE // + + /** + Build a fixture of test objects. This method is called anew for + each test, such that the tests will always start with the same + set of test objects, and the execution of one test will not interfere + with the execution of another. + */ + protected void setUp(){ + } + + /** + Re-set test objects. + */ + protected void tearDown() { + } + + // PRIVATE // + private int fSEED = ModelUtil.HASH_SEED; + private static final int fODD_PRIME = 37; +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/TESTMessageSerialization.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/TESTMessageSerialization.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,80 @@ +package hirondelle.web4j.model; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.ObjectInput; +import java.io.ObjectInputStream; +import java.io.ObjectOutput; +import java.io.ObjectOutputStream; +import java.io.OutputStream; + +import junit.framework.TestCase; + +/** + Test several classes that implement end user messages. +

This test class is unusual, in that it is meant for several target classes, not one. + */ +public final class TESTMessageSerialization extends TestCase { + + /** Run the test cases. */ + public static void main(String args[]) { + String[] testCaseName = { TESTMessageSerialization.class.getName() }; + junit.textui.TestRunner.main(testCaseName); + } + + public TESTMessageSerialization( String aName) { + super(aName); + } + + + public void testAppResponseMessage() throws FileNotFoundException, IOException, ClassNotFoundException { + AppResponseMessage arm = AppResponseMessage.forSimple("Test"); + testSerializable(arm); + arm = AppResponseMessage.forCompound("Test", "param1"); + testSerializable(arm); + arm = AppResponseMessage.forCompound("Test", "param1", new Integer(56)); + testSerializable(arm); + //fails, since StringTokenizer is not Serializable + //arm = AppResponseMessage.forCompound("Test", "param1", new Integer(56) , new StringTokenizer("")); + //testSerializable(arm); + } + + public void testMessageListImpl() throws FileNotFoundException, IOException, ClassNotFoundException { + MessageListImpl mli = new MessageListImpl(); + testSerializable(mli); + } + + public void testAppException() throws FileNotFoundException, IOException, ClassNotFoundException { + AppException appEx = new AppException("Blah", new IllegalArgumentException()); + testSerializable(appEx); + } + + // PRIVATE + + private void testSerializable(T aThing) throws FileNotFoundException, IOException, ClassNotFoundException { + OutputStream file = new FileOutputStream( "C:\\Temp\\test.ser" ); + OutputStream buffer = new BufferedOutputStream( file ); + ObjectOutput output = new ObjectOutputStream( buffer ); + try{ + output.writeObject(aThing); + } + finally{ + output.close(); + } + + InputStream inFile = new FileInputStream( "C:\\Temp\\test.ser" ); + InputStream inBuffer = new BufferedInputStream( inFile ); + ObjectInput input = new ObjectInputStream ( inBuffer ); + try{ + T recovered = (T)input.readObject(); + } + finally{ + input.close(); + } + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/TESTPerformanceSnapshot.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/TESTPerformanceSnapshot.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,100 @@ +package hirondelle.web4j.model; + +import junit.framework.*; +//import junit.ui.TestRunner; +//import junit.textui.TestRunner; + +import hirondelle.web4j.util.Consts; +import hirondelle.web4j.webmaster.PerformanceSnapshot; + +/** + JUnit tests for the + {@link PerformanceSnapshot} class. + +

Testing the 'end time', and its correct rollover to the next + time frame, is left out here. +*/ +public final class TESTPerformanceSnapshot extends TestCase { + + /** + Run the test cases. + */ + public static void main(String args[]) { + String[] testCaseName = { TESTPerformanceSnapshot.class.getName() }; + //Select one of several types of interfaces. + junit.textui.TestRunner.main(testCaseName); + //junit.swingui.TestRunner.main(testCaseName); + //junit.ui.TestRunner.main(testCaseName); + } + + /** + Canonical form of constructor. + */ + public TESTPerformanceSnapshot( String aName) { + super(aName); + } + + // TEST CASES // + + public void testCtor(){ + assertState(fSnapshot, EXPOSURE_TIME.intValue(), 0, 0, 0, Consts.EMPTY_STRING); + } + + public void testAddResponses() { + PerformanceSnapshot snapshot1 = fSnapshot.addResponseTime(1000, URL); + assertState(snapshot1, EXPOSURE_TIME.intValue(), 1, 1000, 1000, URL); + + PerformanceSnapshot snapshot2 = snapshot1.addResponseTime(3000, URL); + assertState(snapshot2, EXPOSURE_TIME.intValue(), 2, 3000, 2000, URL); + + PerformanceSnapshot snapshot3 = snapshot2.addResponseTime(1000, URL); + assertState(snapshot3, EXPOSURE_TIME.intValue(), 3, 3000, 1666, URL); + + PerformanceSnapshot snapshot4 = snapshot3.addResponseTime(6000, MAX_URL); + assertState(snapshot4, EXPOSURE_TIME.intValue(), 4, 6000, 2749, MAX_URL); + } + + // FIXTURE // + + /** + Build a fixture of test objects. This method is called anew for + each test, such that the tests will always start with the same + set of test objects, and the execution of one test will not interfere + with the execution of another. + */ + protected void setUp(){ + fSnapshot = new PerformanceSnapshot(EXPOSURE_TIME); + } + + /** + Re-set test objects. + */ + protected void tearDown() { + } + + // PRIVATE // + private PerformanceSnapshot fSnapshot; + private static final Integer EXPOSURE_TIME = new Integer(10); + private static final String URL = "std-url"; + private static final String MAX_URL = "max-url"; + + private void log(Object aThing){ + System.out.println(aThing.toString()); + } + + private void assertState( + PerformanceSnapshot aSnapshot, + int aExposureTime, + int aNumRequests, + int aMaxResponseTime, + int aAvgResponseTime, + String aMaxURL + ){ + //log(aSnapshot); + assertTrue(aSnapshot.getExposureTime().intValue() == aExposureTime); + assertTrue(aSnapshot.getNumRequests().intValue() == aNumRequests); + assertTrue(aSnapshot.getMaxResponseTime().intValue() == aMaxResponseTime); + assertTrue(aSnapshot.getAvgResponseTime().intValue() == aAvgResponseTime); + assertTrue(aSnapshot.getURLWithMaxResponseTime().equals(aMaxURL)); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/ToStringUtil.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/ToStringUtil.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,342 @@ +package hirondelle.web4j.model; + +import java.util.*; +import java.util.logging.*; +import java.util.regex.*; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.InvocationTargetException; + +import static hirondelle.web4j.util.Consts.NEW_LINE; +import static hirondelle.web4j.util.Consts.EMPTY_STRING; +import hirondelle.web4j.util.Util; + +/** + Implements the toString method for some common cases. + +

This class is intended only for cases where toString is used in + an informal manner (usually for logging and stack traces). It is especially + suited for public classes which model domain objects. + + Here is an example of a return value of the {@link #getText} method : +

+hirondelle.web4j.model.MyUser {
+LoginName: Bob
+LoginPassword: ****
+EmailAddress: bob@blah.com
+StarRating: 1
+FavoriteTheory: Quantum Chromodynamics
+SendCard: true
+Age: 42
+DesiredSalary: 42000
+BirthDate: Sat Feb 26 13:45:43 EST 2005
+}
+ 
+ (Previous versions of this classes used indentation within the braces. That has + been removed, since it displays poorly when nesting occurs.) + +

Here are two more examples, using classes taken from the JDK : +

+java.util.StringTokenizer {
+nextElement: This
+hasMoreElements: true
+countTokens: 3
+nextToken: is
+hasMoreTokens: true
+}
+
+java.util.ArrayList {
+size: 3
+toArray: [blah, blah, blah]
+isEmpty: false
+}
+ 
+ + There are two use cases for this class. The typical use case is : +
+  public String toString() {
+    return ToStringUtil.getText(this);
+  }
+ 
+ + However, there is a case where this typical style can + fail catastrophically : when two objects reference each other, and each + has toString implemented as above, then the program will loop + indefinitely! + +

As a remedy for this problem, the following variation is provided : +

+  public String toString() {
+    return ToStringUtil.getTextAvoidCyclicRefs(this, Product.class, "getId");
+  }
+ 
+ Here, the usual behavior is overridden for any method + which returns a Product : instead of calling Product.toString, + the return value of Product.getId() is used to textually represent + the object. +*/ +final class ToStringUtil { + + /** + Return an informal textual description of an object. +

It is highly recommened that the caller not rely on details + of the returned String. See class description for examples of return + values. + +

WARNING: If two classes have cyclic references + (that is, each has a reference to the other), then infinite looping will result + if both call this method! To avoid this problem, use getText + for one of the classes, and {@link #getTextAvoidCyclicRefs} for the other class. + +

The only items which contribute to the result are the class name, and all + no-argument public methods which return a value. As well, methods + defined by the Object class, and factory methods which return an + Object of the native class ("getInstance" methods) do not contribute. + +

Items are converted to a String simply by calling their + toString method, with these exceptions : +

    +
  • {@link Util#getArrayAsString(Object)} is used for arrays +
  • a method whose name contain the text "password" (not case-sensitive) have + their return values hard-coded to "****". +
+ +

If the method name follows the pattern getXXX, then the word 'get' + is removed from the presented result. + + @param aObject the object for which a toString result is required. + */ + static String getText(Object aObject) { + return getTextAvoidCyclicRefs(aObject, null, null); + } + + /** + As in {@link #getText}, but, for return values which are instances of + aSpecialClass, then call aMethodName instead of toString. + +

If aSpecialClass and aMethodName are null, then the + behavior is exactly the same as calling {@link #getText}. + */ + static String getTextAvoidCyclicRefs(Object aObject, Class aSpecialClass, String aMethodName) { + StringBuilder result = new StringBuilder(); + addStartLine(aObject, result); + + Method[] methods = aObject.getClass().getDeclaredMethods(); + for(Method method: methods){ + if ( isContributingMethod(method, aObject.getClass()) ){ + addLineForGetXXXMethod(aObject, method, result, aSpecialClass, aMethodName); + } + } + + addEndLine(result); + return result.toString(); + } + + // PRIVATE // + + /* + Names of methods in the Object class which are ignored. + */ + private static final String fGET_CLASS = "getClass"; + private static final String fCLONE = "clone"; + private static final String fHASH_CODE = "hashCode"; + private static final String fTO_STRING = "toString"; + + private static final String fGET = "get"; + private static final Object[] fNO_ARGS = new Object[0]; + private static final Class[] fNO_PARAMS = new Class[0]; + /* + Previous versions of this class indented the data within a block. + That style breaks when one object references another. The indentation + has been removed, but this variable has been retained, since others might + prefer the indentation anyway. + */ + private static final String fINDENT = EMPTY_STRING; + private static final String fAVOID_CIRCULAR_REFERENCES = "[circular reference]"; + private static final Logger fLogger = Util.getLogger(ToStringUtil.class); + + private static Pattern PASSWORD_PATTERN = Pattern.compile("password", Pattern.CASE_INSENSITIVE); + private static String HIDDEN_PASSWORD_VALUE = "****"; + + //prevent construction by the caller + private ToStringUtil() { + //empty + } + + private static void addStartLine(Object aObject, StringBuilder aResult){ + aResult.append( aObject.getClass().getName() ); + aResult.append(" {"); + aResult.append(NEW_LINE); + } + + private static void addEndLine(StringBuilder aResult){ + aResult.append("}"); + aResult.append(NEW_LINE); + } + + /** + Return true only if aMethod is public, takes no args, + returns a value whose class is not the native class, is not a method of + Object. + */ + private static boolean isContributingMethod(Method aMethod, Class aNativeClass){ + boolean isPublic = Modifier.isPublic( aMethod.getModifiers() ); + boolean hasNoArguments = aMethod.getParameterTypes().length == 0; + boolean hasReturnValue = aMethod.getReturnType() != Void.TYPE; + boolean returnsNativeObject = aMethod.getReturnType() == aNativeClass; + boolean isMethodOfObjectClass = + aMethod.getName().equals(fCLONE) || + aMethod.getName().equals(fGET_CLASS) || + aMethod.getName().equals(fHASH_CODE) || + aMethod.getName().equals(fTO_STRING) + ; + return + isPublic && + hasNoArguments && + hasReturnValue && + ! isMethodOfObjectClass && + ! returnsNativeObject; + } + + private static void addLineForGetXXXMethod( + Object aObject, + Method aMethod, + StringBuilder aResult, + Class aCircularRefClass, + String aCircularRefMethodName + ){ + aResult.append(fINDENT); + aResult.append( getMethodNameMinusGet(aMethod) ); + aResult.append(": "); + Object returnValue = getMethodReturnValue(aObject, aMethod); + if ( returnValue != null && returnValue.getClass().isArray() ) { + aResult.append( Util.getArrayAsString(returnValue) ); + } + else { + if (aCircularRefClass == null) { + aResult.append( returnValue ); + } + else { + if (aCircularRefClass == returnValue.getClass()) { + Method method = getMethodFromName(aCircularRefClass, aCircularRefMethodName); + if ( isContributingMethod(method, aCircularRefClass)){ + returnValue = getMethodReturnValue(returnValue, method); + aResult.append( returnValue ); + } + else { + aResult.append(fAVOID_CIRCULAR_REFERENCES); + } + } + } + } + aResult.append( NEW_LINE ); + } + + private static String getMethodNameMinusGet(Method aMethod){ + String result = aMethod.getName(); + if (result.startsWith(fGET) ) { + result = result.substring(fGET.length()); + } + return result; + } + + /** Return value is possibly-null. */ + private static Object getMethodReturnValue(Object aObject, Method aMethod){ + Object result = null; + try { + result = aMethod.invoke(aObject, fNO_ARGS); + } + catch (IllegalAccessException ex){ + vomit(aObject, aMethod); + } + catch (InvocationTargetException ex){ + vomit(aObject, aMethod); + } + result = dontShowPasswords(result, aMethod); + return result; + } + + private static Method getMethodFromName(Class aSpecialClass, String aMethodName){ + Method result = null; + try { + result = aSpecialClass.getMethod(aMethodName, fNO_PARAMS); + } + catch ( NoSuchMethodException ex){ + vomit(aSpecialClass, aMethodName); + } + return result; + } + + + private static void vomit(Object aObject, Method aMethod){ + fLogger.severe( + "Cannot get return value using reflection. Class: " + + aObject.getClass().getName() + + " Method: " + + aMethod.getName() + ); + } + + private static void vomit(Class aSpecialClass, String aMethodName){ + fLogger.severe( + "Reflection fails to get no-arg method named: " + + Util.quote(aMethodName) + + " for class: " + + aSpecialClass.getName() + ); + } + + private static Object dontShowPasswords(Object aReturnValue, Method aMethod){ + Object result = aReturnValue; + Matcher matcher = PASSWORD_PATTERN.matcher(aMethod.getName()); + if ( matcher.find()) { + result = HIDDEN_PASSWORD_VALUE; + } + return result; + } + + /* + Two informal classes with cyclic references, used for testing. + */ + private static final class Ping { + public void setPong(Pong aPong){fPong = aPong; } + public Pong getPong(){ return fPong; } + public Integer getId() { return new Integer(123); } + public String getUserPassword(){ return "blah"; } + public String toString() { + return getText(this); + } + private Pong fPong; + } + private static final class Pong { + public void setPing(Ping aPing){ fPing = aPing; } + public Ping getPing() { return fPing; } + public String toString() { + return getTextAvoidCyclicRefs(this, Ping.class, "getId"); + //to see the infinite looping, use this instead : + //return getText(this); + } + private Ping fPing; + } + + /** + Informal test harness. + */ + public static void main (String... args) { + List list = new ArrayList(); + list.add("blah"); + list.add("blah"); + list.add("blah"); + System.out.println( ToStringUtil.getText(list) ); + + StringTokenizer parser = new StringTokenizer("This is the end."); + System.out.println( ToStringUtil.getText(parser) ); + + Ping ping = new Ping(); + Pong pong = new Pong(); + ping.setPong(pong); + pong.setPing(ping); + System.out.println( ping ); + System.out.println( pong ); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/Validator.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/Validator.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,27 @@ +package hirondelle.web4j.model; + +/** + Validate a field in a Model Object. + +

Model Objects are not required to use this interface when validating data, but it is + often convenient to do so. In combination with {@link Check}, this interface is meant to reduce + code repetition related to validation. + +

This interface is appropriate only for checks on a single field. + +

Please see {@link Check} for more information, and for some useful implementations. +*/ +public interface Validator { + + /** + Return true only if aObject passes this validation. + +

aObject is a field in some Model Object, being validated in a constructor. If the + field is a primitive value (such as int), then it must be converted by the caller into + a corresponding wrapper object (such as {@link Integer}). + + @param aObject may be null. + */ + boolean isValid(Object aObject); + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/model/package.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/model/package.html Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,65 @@ + + + + + + + Model + + +Tools for building business domain Model Objects (MOs). + +

The important things about Model Objects in WEB4J are : +

    +
  • Model Objects are immutable (highly recommended, but not required) +
  • Model Objects implement their own validation logic +
  • in general, Model Objects are not dumb data carriers. +
  • if desired, Model Objects can easily avoid the Java Beans anti-pattern. +
+ +

Restrictions on Model Objects

+These are the restrictions on Model Objects in WEB4J : +
    +
  • they must be public classes +
  • the constructors must be public, and always take one or more arguments +
  • the constructors perform all validation, and must throw + {@link hirondelle.web4j.model.ModelCtorException} if any problems occur +
  • to be easily used with WEB4J utility classes, the constructor arguments must belong + to a set of common building block classes (Integer, BigDecimal, and so on) +
+ +

These items can be added to Model Objects, if desired, but they are never used by WEB4J : +

    +
  • no-argument constructors +
  • setXXX methods +
+ +

Validation

+Validation in Model Objects is always in the constructor, and can usually be implemented using the +{@link hirondelle.web4j.model.Check} class, which provides commonly needed implementions of the {@link hirondelle.web4j.model.Validator} interface. + When a problem occurs, an error message is added to {@link hirondelle.web4j.model.ModelCtorException}, for + later presentation to the user. + +

Object Methods

+

It is highly recommended that all Model Objects override equals, hashCode, and +toString. ({@link hirondelle.web4j.model.ModelUtil} can help you implement these methods.) + +

Building Model Objects

+In general, there are two sources for the data attached to Model Objects : manual entry by the end user, and the database. +It is interesting that both of these sources should be treated as unreliable. Incoming HTTP request +parameters are not constrained, and must always be aggressively validated on the server. Databases are independent +servers, and cannot be assumed to be 'owned' by the application. Other applications and processes can interact +with the data as well. Thus, an application cannot, in general, make any assumptions regarding the +content of the database. + +

Thus, a Model Object must allow for all possible input when creating objects from both these sources. + +

WEB4J has two main tools for this task : +

    +
  • {@link hirondelle.web4j.model.ModelFromRequest}, for building Model Objects from underlying request parameters +
  • {@link hirondelle.web4j.database.Db}, for building Model Objects from an underlying ResultSet +
+ +

Both of these tools are simple to use because they use effective ordering conventions for data. + + diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/package.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/package.html Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,16 @@ + + + + + + + WEB4J + + +Important items which apply to the whole application. + +

The top of a package hierarchy (here, the hirondelle.web4j +package) is a good place for such items, since it is easy to find, and +is often the first package examined by the reader. + + diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/readconfig/Config.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/readconfig/Config.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,597 @@ +package hirondelle.web4j.readconfig; + +import hirondelle.web4j.database.ConnectionSource; +import hirondelle.web4j.database.TxIsolationLevel; +import hirondelle.web4j.util.Util; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Collections; +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.StringTokenizer; +import java.util.TimeZone; +import java.util.regex.Pattern; + +/** + (UNPUBLISHED) Access the config values needed by the framework. + +

Assumptions: +

    +
  • init is called only once, upon startup, when the system is in single-threaded mode +
  • there is no re-init or reload mechanism +
  • after init is called, the data will never change, and this class is safe to be used + in a multi-threaded environment +
  • all data returned by the getXXX methods is immutable, or a defensive copy is passed +
+ +

The typical user of this class is simple: +

Config config = new Config();
+  String blah = config.getBlah;
+
+ The caller usually has no need to store the data in their own static private field. + +

The hard-coding in this class is desirable and necessary. Note that it makes no + sense to add config items at runtime, since no code will be able to talk to it. + +

Default values are defined internally by this class. Callers performing tests + may use default values, or they may override any number of default values by calling init. + There is no method to revert to the original defaults. + +

+ When an app as a whole is reloaded, it's a full reload, with new classes/classloaders. + When the Deployment Descriptor (DD) is touched, often the app reloads partially, with the same classes/classloaders, + but a new Servlet object. (This depends on the Container.) + Thus, static blocks are not re-executed in that case. + +

This means that classes should not copy/store data from Config in a static block, since that static block + will not see partial reloads (tweaks to the DD). This is a bit tricky. + As well, any 'first-use-initialization' logic in class X cannot talk to the config, since it will not be executed on a partial reload. + +

If all the parsing/caching is done by this class, then callers don't have to worry about partial reloads, and whether or + not they are out of date. In addition, if the syntax is the same, then the parsing is centralized here. +*/ +public final class Config { + + /** Value - {@value} - special value for some settings, denoting an absent value. */ + public static final String NONE = "NONE"; + + /* NOTE: Most of this code was generated by a one-off script. */ + + /** + Must be called once and only once, upon startup, when the system is in + single-threaded mode. Throws an exception if a problem with the syntax is detected. + +

Note than this takes a generic map, not ServletContext. That's important, + since it allows this Config to be used in any context, not just a servlet container. +

The key is case-sensitive. + */ + public static void init(Map aKeyValuePairs){ + initAllItems(aKeyValuePairs); + } + + /** + Confirm that database settings in web.xml are known to {@link ConnectionSource}. + Order dependency: this method is called after init, since it needs to know the database names, + which in turn requires a call to BuildImpl.init. + */ + public static void checkDbNamesInSettings(Set aValidDbNames){ + Set namesInSettings = new LinkedHashSet(); + + namesInSettings.addAll(fHasAutoGeneratedKeys.getDbNames()); + namesInSettings.addAll(fSqlFetcherDefaultTxIsolationLevel.getDbNames()); + namesInSettings.addAll(fSqlEditorDefaultTxIsolationLevel.getDbNames()); + namesInSettings.addAll(fErrorCodeForDuplicateKey.getDbNames()); + namesInSettings.addAll(fErrorCodeForForeignKey.getDbNames()); + namesInSettings.addAll(fMaxRows.getDbNames()); + namesInSettings.addAll(fFetchSize.getDbNames()); + namesInSettings.addAll(fIsSQLPrecompilationAttempted.getDbNames()); + namesInSettings.addAll(fDateTimeFormatForPassingParamsToDb.getDbNames()); + + Set unknownNames = new LinkedHashSet(); + for(String name: namesInSettings){ + if(Util.textHasContent(name) && ! aValidDbNames.contains(name)){ + unknownNames.add(name); + } + } + + if(! unknownNames.isEmpty() ) { + throw new IllegalArgumentException("Web.xml contains settings that refer to databases that are not known to your implementation of ConnectionSource.getDatabaseNames(). Please check spelling and case for : " + Util.logOnePerLine(unknownNames)); + } + } + + /** Intended for logging and trouble tickets. */ + public Map getRawMap(){ + return Collections.unmodifiableMap(fMap); + } + + public Boolean isTesting(){ + return fIsTesting; + } + + /** Value is present, and is not 'NONE'. */ + public boolean isEnabled(String aValue){ + return Util.textHasContent(aValue) && ! NONE.equalsIgnoreCase(aValue); + } + + public String getWebmaster(){ + return fWebmaster; + } + + public String getImplicitMappingRemoveBasePackage(){ + return fImplicitMappingRemoveBasePackage; + } + + public String getMailServerConfig(){ + return fMailServerConfig; + } + + public String getMailServerCredentials(){ + return fMailServerCredentials; + } + + public String getLoggingDirectory (){ + return fLoggingDirectory; + } + + public List getLoggingLevels(){ + return fLoggingLevels; + } + + public List getTroubleTicketMailingList(){ + return Collections.unmodifiableList(fTroubleTicketMailingList); + } + + public Long getMinimumIntervalBetweenTroubleTickets(){ + return fMinimumIntervalBetweenTroubleTickets; + } + + /** Returns the value in nanos, not seconds. */ + public Long getPoorPerformanceThreshold(){ + long NANOSECONDS_PER_SECOND = 1000 *1000 * 1000; + return fPoorPerformanceThreshold * NANOSECONDS_PER_SECOND; + } + + public Long getMaxHttpRequestSize(){ + return fMaxHttpRequestSize; + } + + public Long getMaxFileUploadRequestSize(){ + return fMaxFileUploadRequestSize; + } + + public Long getMaxRequestParamValueSize(){ + return fMaxRequestParamValueSize; + } + + public Boolean getSpamDetectionInFirewall(){ + return fSpamDetectionInFirewall; + } + + public Boolean getFullyValidateFileUploads(){ + return fFullyValidateFileUploads; + } + + public Boolean getAllowStringAsBuildingBlock(){ + return fAllowStringAsBuildingBlock; + } + + public Map> getUntrustedProxyForUserId(){ + return fUntrustedProxyForUserId; + } + + public String getCharacterEncoding(){ + return fCharacterEncoding; + } + + public Locale getDefaultLocale(){ + return fDefaultLocale; + } + + public TimeZone getDefaultUserTimeZone(){ + return TimeZone.getTimeZone(fDefaultUserTimeZone.getID()); //mutable class + } + + public Boolean hasTimeZoneHint(){ + return fTimeZoneHint != null; + } + + public Calendar getTimeZoneHint(){ + Calendar result = null; + if( hasTimeZoneHint() ) { + result = Calendar.getInstance(fTimeZoneHint); + } + return result; + } + + //this could be deleted ? - no callers + public String getDecimalSeparator(){ + return fDecimalSeparator; + } + + public Pattern getDecimalInputPattern(){ + return fDecimalInputPattern; //derived from decimal separator + } + + public String getBigDecimalDisplayFormat(){ + return fBigDecimalDisplayFormat; + } + + public String getBooleanTrueDisplayFormat(){ + return fBooleanTrueDisplayFormat; + } + + public String getBooleanFalseDisplayFormat(){ + return fBooleanFalseDisplayFormat; + } + + public String getEmptyOrNullDisplayFormat(){ + return fEmptyOrNullDisplayFormat; + } + + public String getIntegerDisplayFormat(){ + return fIntegerDisplayFormat; + } + + public String getIgnorableParamValue(){ + return fIgnorableParamValue; + } + + public Boolean getIsSQLPrecompilationAttempted(String aDbName){ + return asBoolean(fIsSQLPrecompilationAttempted.getValue(aDbName)); + } + + public Integer getMaxRows(String aDbName){ + return asInteger(fMaxRows.getValue(aDbName)); + } + + public Integer getFetchSize(String aDbName){ + return asInteger(fFetchSize.getValue(aDbName)); + } + + public Boolean getHasAutoGeneratedKeys(String aDbName){ + return asBoolean(fHasAutoGeneratedKeys.getValue(aDbName)); + } + + public List getErrorCodesForDuplicateKey(String aDbName){ + return asIntegerList(fErrorCodeForDuplicateKey.getValue(aDbName)); + } + + public List getErrorCodesForForeignKey(String aDbName){ + return asIntegerList(fErrorCodeForForeignKey.getValue(aDbName)); + } + + public TxIsolationLevel getSqlFetcherDefaultTxIsolationLevel(String aDbName){ + return TxIsolationLevel.valueOf(fSqlFetcherDefaultTxIsolationLevel.getValue(aDbName)); + } + + public TxIsolationLevel getSqlEditorDefaultTxIsolationLevel(String aDbName){ + return TxIsolationLevel.valueOf(fSqlEditorDefaultTxIsolationLevel.getValue(aDbName)); + } + + public String getDateFormat(String aDbName){ + return asDateTimeFormat(fDateTimeFormatForPassingParamsToDb.getValue(aDbName), 0); + } + + public String getTimeFormat(String aDbName){ + return asDateTimeFormat(fDateTimeFormatForPassingParamsToDb.getValue(aDbName), 1); + } + + public String getDateTimeFormat(String aDbName){ + return asDateTimeFormat(fDateTimeFormatForPassingParamsToDb.getValue(aDbName), 2); + } + + // PRIVATE + + private static List fErrors = new ArrayList(); + + private static void initAllItems(Map aKeyValuePairs) { + fMap = aKeyValuePairs; + + fWebmaster = initThisStringNoDefault("Webmaster", aKeyValuePairs); + fImplicitMappingRemoveBasePackage = initThisStringNoDefault("ImplicitMappingRemoveBasePackage", aKeyValuePairs); + + if (hasValue("MailServerConfig", aKeyValuePairs)) { + fMailServerConfig = initThisString("MailServerConfig", aKeyValuePairs); + } + if (hasValue("MailServerCredentials", aKeyValuePairs)) { + fMailServerCredentials = initThisString("MailServerCredentials", aKeyValuePairs); + } + if (hasValue("LoggingDirectory", aKeyValuePairs)) { + fLoggingDirectory = initThisString("LoggingDirectory", aKeyValuePairs); + } + if (hasValue("LoggingLevels", aKeyValuePairs)) { + fLoggingLevels = initThisStringList("LoggingLevels", aKeyValuePairs); + } + if (hasValueNotNone("TroubleTicketMailingList", aKeyValuePairs)) { + fTroubleTicketMailingList = initThisStringList("TroubleTicketMailingList", aKeyValuePairs); + } + if (hasValue("MinimumIntervalBetweenTroubleTickets", aKeyValuePairs)) { + fMinimumIntervalBetweenTroubleTickets = initThisLong("MinimumIntervalBetweenTroubleTickets", aKeyValuePairs); + } + if (hasValue("PoorPerformanceThreshold", aKeyValuePairs)) { + fPoorPerformanceThreshold = initThisLong("PoorPerformanceThreshold", aKeyValuePairs); + } + if (hasValue("MaxHttpRequestSize", aKeyValuePairs)) { + fMaxHttpRequestSize = initThisLong("MaxHttpRequestSize", aKeyValuePairs); + checkTooLow(fMaxHttpRequestSize, 1000L, "MaxHttpRequestSize"); + } + if (hasValue("MaxFileUploadRequestSize", aKeyValuePairs)) { + fMaxFileUploadRequestSize = initThisLong("MaxFileUploadRequestSize", aKeyValuePairs); + checkTooLow(fMaxFileUploadRequestSize, 1000L, "MaxFileUploadRequestSize"); + } + if (hasValue("MaxRequestParamValueSize", aKeyValuePairs)) { + fMaxRequestParamValueSize = initThisLong("MaxRequestParamValueSize", aKeyValuePairs); + checkTooLow(fMaxRequestParamValueSize, 1000L, "MaxRequestParamValueSize"); + } + if (hasValue("SpamDetectionInFirewall", aKeyValuePairs)) { + fSpamDetectionInFirewall = initThisBoolean("SpamDetectionInFirewall", aKeyValuePairs); + } + if (hasValue("FullyValidateFileUploads", aKeyValuePairs)) { + fFullyValidateFileUploads = initThisBoolean("FullyValidateFileUploads", aKeyValuePairs); + } + if (hasValue("AllowStringAsBuildingBlock", aKeyValuePairs)) { + fAllowStringAsBuildingBlock = initThisBoolean("AllowStringAsBuildingBlock", aKeyValuePairs); + } + if (hasValue("UntrustedProxyForUserId", aKeyValuePairs)) { + ParseUntrustedProxy untrusted = new ParseUntrustedProxy(); + fUntrustedProxyForUserId = untrusted.parse(aKeyValuePairs.get("UntrustedProxyForUserId")); + } + if (hasValue("CharacterEncoding", aKeyValuePairs)) { + fCharacterEncoding = initThisString("CharacterEncoding", aKeyValuePairs); + } + if (hasValue("DefaultLocale", aKeyValuePairs)) { + fDefaultLocale = initThisLocale("DefaultLocale", aKeyValuePairs); + } + if (hasValue("DefaultUserTimeZone", aKeyValuePairs)) { + fDefaultUserTimeZone = initThisTimeZone("DefaultUserTimeZone", aKeyValuePairs); + } + if (hasValueNotNone("TimeZoneHint", aKeyValuePairs)) { + fTimeZoneHint = initThisTimeZone("TimeZoneHint", aKeyValuePairs); + } + if (hasValue("DecimalSeparator", aKeyValuePairs)) { + fDecimalSeparator = initThisString("DecimalSeparator", aKeyValuePairs); + fDecimalInputPattern = fDecimalParser.getDecimalFormatPattern(fDecimalSeparator); + } + if (hasValue("BigDecimalDisplayFormat", aKeyValuePairs)) { + fBigDecimalDisplayFormat = initThisString("BigDecimalDisplayFormat", aKeyValuePairs); + } + if (hasValue("BooleanTrueDisplayFormat", aKeyValuePairs)) { + fBooleanTrueDisplayFormat = initThisString("BooleanTrueDisplayFormat", aKeyValuePairs); + } + if (hasValue("BooleanFalseDisplayFormat", aKeyValuePairs)) { + fBooleanFalseDisplayFormat = initThisString("BooleanFalseDisplayFormat", aKeyValuePairs); + } + if (hasValue("EmptyOrNullDisplayFormat", aKeyValuePairs)) { + fEmptyOrNullDisplayFormat = initThisString("EmptyOrNullDisplayFormat", aKeyValuePairs); + } + if (hasValue("IntegerDisplayFormat", aKeyValuePairs)) { + fIntegerDisplayFormat = initThisString("IntegerDisplayFormat", aKeyValuePairs); + } + if (hasValue("IgnorableParamValue", aKeyValuePairs)) { + fIgnorableParamValue = initThisString("IgnorableParamValue", aKeyValuePairs); + } + if (hasValue("IsSQLPrecompilationAttempted", aKeyValuePairs)) { + fIsSQLPrecompilationAttempted = initThisDbConfig("IsSQLPrecompilationAttempted", aKeyValuePairs); + } + if (hasValue("MaxRows", aKeyValuePairs)) { + fMaxRows = initThisDbConfig("MaxRows", aKeyValuePairs); + } + if (hasValue("FetchSize", aKeyValuePairs)) { + fFetchSize = initThisDbConfig("FetchSize", aKeyValuePairs); + } + if (hasValue("HasAutoGeneratedKeys", aKeyValuePairs)) { + fHasAutoGeneratedKeys = initThisDbConfig("HasAutoGeneratedKeys", aKeyValuePairs); + } + if (hasValue("ErrorCodeForDuplicateKey", aKeyValuePairs)) { + fErrorCodeForDuplicateKey = initThisDbConfig("ErrorCodeForDuplicateKey", aKeyValuePairs); + } + if (hasValue("ErrorCodeForForeignKey", aKeyValuePairs)) { + fErrorCodeForForeignKey = initThisDbConfig("ErrorCodeForForeignKey", aKeyValuePairs); + } + if (hasValue("SqlFetcherDefaultTxIsolationLevel", aKeyValuePairs)) { + fSqlFetcherDefaultTxIsolationLevel = initThisDbConfig("SqlFetcherDefaultTxIsolationLevel", aKeyValuePairs); + } + if (hasValue("SqlEditorDefaultTxIsolationLevel", aKeyValuePairs)) { + fSqlEditorDefaultTxIsolationLevel = initThisDbConfig("SqlEditorDefaultTxIsolationLevel", aKeyValuePairs); + } + if (hasValue("DateTimeFormatForPassingParamsToDb", aKeyValuePairs)) { + fDateTimeFormatForPassingParamsToDb = initThisDbConfig("DateTimeFormatForPassingParamsToDb", aKeyValuePairs); + } + if (hasValue("IsTesting", aKeyValuePairs)) { + fIsTesting = initThisBoolean("IsTesting", aKeyValuePairs); + } + + if (! fErrors.isEmpty()){ + throw new RuntimeException("Errors in config data: " + fErrors); + } + } + + private static boolean hasValue(String aName, Map aKeyValuePairs){ + return Util.textHasContent(aKeyValuePairs.get(aName)); + } + + private static boolean hasValueNotNone(String aName, Map aKeyValuePairs){ + String value = aKeyValuePairs.get(aName); + return Util.textHasContent(value) && (! NONE.equalsIgnoreCase(value)); + } + + private static String initThisString(String aName, Map aKeyValuePairs){ + return aKeyValuePairs.get(aName); + } + + private static Long initThisLong(String aName, Map aKeyValuePairs){ + return asLong(aKeyValuePairs.get(aName)); + } + + private static TimeZone initThisTimeZone(String aName, Map aKeyValuePairs){ + return asTimeZone(aKeyValuePairs.get(aName)); + } + + private static List initThisStringList(String aName, Map aKeyValuePairs){ + return asStringListWithNone(aKeyValuePairs.get(aName)); + } + + private static String initThisStringNoDefault(final String aName, final Map aKeyValuePairs){ + String result = aKeyValuePairs.get(aName); + if (! Util.textHasContent(result)){ + fErrors.add(aName + " has no value set."); + } + return result; + } + + private static Boolean initThisBoolean(String aName, Map aKeyValuePairs){ + String value = aKeyValuePairs.get(aName); + return Util.parseBoolean(value); + } + + private static DbConfigParser initThisDbConfig(String aName, Map aKeyValuePairs){ + return new DbConfigParser(aKeyValuePairs.get(aName)); + } + + private static Locale initThisLocale(String aName, Map aKeyValuePairs){ + return asLocale(aKeyValuePairs.get(aName)); + } + + /* These declares define the default value of each item, if any. */ + + //used for iteration over raw values (logging) + private static Map fMap; + + private static String fWebmaster = ""; //no default + private static String fImplicitMappingRemoveBasePackage = ""; //no default + + private static String fMailServerConfig = "NONE"; + private static String fMailServerCredentials = "NONE"; + private static String fLoggingDirectory = "NONE"; + private static List fLoggingLevels = Arrays.asList("hirondelle.web4j.level=CONFIG"); + private static List fTroubleTicketMailingList = Collections.emptyList(); + private static Long fMinimumIntervalBetweenTroubleTickets = Long.valueOf(30); + private static Long fPoorPerformanceThreshold = 20L; + private static Long fMaxHttpRequestSize = 51200L; + private static Long fMaxFileUploadRequestSize = 51200L; + private static Long fMaxRequestParamValueSize = 51200L; + private static Boolean fSpamDetectionInFirewall = Boolean.FALSE; + private static Boolean fFullyValidateFileUploads = Boolean.FALSE; + private static Boolean fAllowStringAsBuildingBlock = Boolean.FALSE; + private static Map> fUntrustedProxyForUserId = new LinkedHashMap>(); + private static String fCharacterEncoding = "UTF-8"; + private static Locale fDefaultLocale = Util.buildLocale("en"); + private static TimeZone fDefaultUserTimeZone = TimeZone.getTimeZone("GMT"); + private static TimeZone fTimeZoneHint; //default is null in this case + + // these 3 are related to each other + private static String fDecimalSeparator = "PERIOD"; + private static ParseDecimalFormat fDecimalParser = new ParseDecimalFormat(); + private static Pattern fDecimalInputPattern = fDecimalParser.getDecimalFormatPattern(fDecimalSeparator);// the default, as usual + + private static String fBigDecimalDisplayFormat = "#,##0.00"; + private static String fBooleanTrueDisplayFormat = " ]]>"; + private static String fBooleanFalseDisplayFormat = "]]>"; + private static String fEmptyOrNullDisplayFormat = "-"; + private static String fIntegerDisplayFormat = "#,###"; + private static String fIgnorableParamValue = ""; + + //database items + private static DbConfigParser fIsSQLPrecompilationAttempted = new DbConfigParser("TRUE"); + private static DbConfigParser fMaxRows = new DbConfigParser("300"); + private static DbConfigParser fFetchSize = new DbConfigParser("25"); + private static DbConfigParser fHasAutoGeneratedKeys = new DbConfigParser("FALSE"); + private static DbConfigParser fErrorCodeForDuplicateKey = new DbConfigParser("1"); + private static DbConfigParser fErrorCodeForForeignKey = new DbConfigParser("2291"); + private static DbConfigParser fSqlFetcherDefaultTxIsolationLevel = new DbConfigParser("DATABASE_DEFAULT"); + private static DbConfigParser fSqlEditorDefaultTxIsolationLevel = new DbConfigParser("DATABASE_DEFAULT"); + private static DbConfigParser fDateTimeFormatForPassingParamsToDb = new DbConfigParser("YYYY-MM-DD^hh:mm:ss^YYYY-MM-DD hh:mm:ss"); + + private static Boolean fIsTesting = Boolean.FALSE; + + /* Simple conversion methods. */ + + private static Long asLong(String aValue){ + return Long.valueOf(aValue); + } + + private static Integer asInteger(String aValue){ + return Integer.valueOf(aValue); + } + + private static TimeZone asTimeZone(String aValue){ + TimeZone result = null; + try{ + result = Util.buildTimeZone(aValue); + } + catch(Throwable ex){ + fErrors.add(ex.getMessage()); + } + return result; + } + + private static Locale asLocale(String aValue){ + return Util.buildLocale(aValue); + } + + /** + Separated by a comma. + Special value 'NONE' denotes an empty list. + */ + private static List asStringListWithNone(String aValue){ + List result = new ArrayList(); + if (! "NONE".equalsIgnoreCase(aValue)){ + result.addAll(asStringList(aValue)); + } + return result; + } + + /** Separated by a comma. */ + private static List asStringList(String aValue){ + List result = new ArrayList(); + StringTokenizer parser = new StringTokenizer(aValue, ","); + while (parser.hasMoreElements()){ + String item = (String)parser.nextElement(); + result.add(item); + } + return result; + } + + + /** Separated by a comma. */ + private static List asIntegerList(String aValue){ + List result = new ArrayList(); + String DELIMITER = ","; + StringTokenizer parser = new StringTokenizer(aValue, DELIMITER); + while ( parser.hasMoreTokens() ) { + String errorCode = parser.nextToken(); + result.add(new Integer(errorCode.trim())); + } + return result; + } + + private static Boolean asBoolean(String aValue){ + return Util.parseBoolean(aValue); + } + + private static String asDateTimeFormat(String aDbSetting, int aPart){ + String[] formats = aDbSetting.split("\\^"); + if(formats.length != 3) { + fErrors.add( + "DateTimeFormatForPassingParamsToDb setting in web.xml does not have a valid value: " + Util.quote(aDbSetting) + + ". Does not have 3 entries, separated by a '^' character." + ); + } + return formats[aPart]; + } + + private static void checkTooLow(Long aValue, Long aMin, String aName) { + if (aValue < aMin){ + fErrors.add( + "Configured value of " + aValue + " in web.xml for " + aName + + " is less than " + aMin + ". Please see web.xml for more information." + ); + } + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/readconfig/ConfigReader.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/readconfig/ConfigReader.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,551 @@ +package hirondelle.web4j.readconfig; + +import java.util.*; +import java.util.logging.*; +import java.io.*; +import java.util.regex.*; +import java.lang.reflect.*; +import javax.servlet.ServletContext; + +import hirondelle.web4j.util.Args; +import hirondelle.web4j.util.Util; + +/** + (UNPUBLISHED) Reads text files (having specific formats) located under the WEB-INF directory, + and returns their contents as {@link Properties}. + +

In addition, this class returns {@link Set}s of {@link Class}es that are present under + /WEB-INF/classes. This unusual policy allows the caller to use reflection upon + such classes, to extract what would otherwise be configuration information. For example, the + default implementation of {@link hirondelle.web4j.request.RequestParser} uses this technique to + automatically map request URIs to concrete implementations of {@link hirondelle.web4j.action.Action}. + Thus, the action implementor can configure when the action is called simply by adding a + field satisfying some simple conventions. + +

Design Note: In addition to the configuration facilities present in + web.xml, it is sometimes useful to use properties files (see {@link Properties}), + or similar items. Such files may be placed in the same directory as the class + which uses them, but, in a web application, there is also the option of placing + such files in the WEB-INF directory. +*/ +public final class ConfigReader { + + /** Must be called upon startup in a web container. */ + public static void init(ServletContext aContext){ + fContext = aContext; + } + + /** Type-safe enumeration for the kinds of text file supported by {@link ConfigReader}. */ + public enum FileType { + + /** + Standard java .properties file. + See {@link Properties} for more information. + */ + PROPERTIES_FILE, + + /** + Text file containing blocks of text, constants, and comments. +

The {@link hirondelle.web4j.database.SqlStatement} class uses this style for + underlying SQL statements, but it may be used for any similar case, + where the format specified below is adequate. + +

The following terminology is used here : +

    +
  • Block - a multiline block of text with within braces, with an associated + identifier (similar to a typical Java block) +
  • Block Name - the identifier of a block, appearing on the first line, before the + opening brace +
  • Block Body - the text appearing between the opening and closing braces. One + advantage of using this file type, instead of a + {@link ConfigReader.FileType#PROPERTIES_FILE}, is + that no line continuation characters are needed. +
+ +

Example of a typical TEXT_BLOCK file (using + SQL statements for Block Bodies) : +

+    -- This is an example comment.
+    -- This item exercises slight textual variations.
+    -- The Block Name here is 'ADD_MESSAGE'.
+     ADD_MESSAGE  {
+     INSERT INTO MyMessage -- another example comment
+      (LoginName, Body, CreationDate)
+      -- this line and the following line are also commented out
+      -- VALUES (?,?,?)
+      VALUES (?,?,?)
+     }
+    
+    -- Here, 'constants' is a reserved Block Name.
+    -- Any number of 'constants' blocks can be defined, anywhere 
+    -- in the file. Such constants must be defined before being
+    -- referenced in a Block Body, however.
+    constants {
+      num_messages_to_view = 5
+    }
+    
+    -- Example of referring to a constant defined above.
+    FETCH_RECENT_MESSAGES {
+     SELECT 
+     LoginName, Body, CreationDate 
+     FROM MyMessage 
+     ORDER BY Id DESC LIMIT ${num_messages_to_view}
+    }
+    
+    FETCH_NUM_MSGS_FOR_USER {
+     SELECT COUNT(Id) FROM MyMessage WHERE LoginName=?
+    }
+    
+    -- Browse Messages according to various criteria.
+    -- Constants block used here just to demonstrate it 
+    -- is possible.
+    constants {
+      -- Each constant must appear on a *single line* only, which
+      -- is not the best for long SQL statements.
+      -- Typically, if a previously defined *sub-query* is needed, 
+      -- then a SQL statment may simply refer directly to that previously 
+      -- defined SQL statement. (See oracle.sql in the example application.)
+      base_query = SELECT Id, LoginName, Body, CreationDate FROM MyMessage 
+    }
+    
+    BROWSE_MESSAGES {
+     ${base_query}
+     ORDER BY {0} {1}
+    }
+    
+    BROWSE_MESSAGES_WITH_FILTER {
+     ${base_query}
+     WHERE {0} LIKE '{1}%' 
+     ORDER BY {2} {3}
+    }
+     
+ +

The format details are as follows : +

    +
  • empty lines can appear only outside of blocks +
  • the '--' character denotes a comment +
  • there are no multiline comments. (Such comments are easier for the writer, + but are much less clear for the reader.) +
  • the body of each item is placed in a named block, bounded by + '<name> {' on an initial line, and '}' on an end line. + The name given to the the block (ADD_MESSAGE for example) is how + WEB4J identifies each block. The Block Name may correspond one to + one with some item in code. For SQL statements, the Block Name must correspond + to a public static final SqlId field. Upon startup, this allows verification + that all items defined in the text source have a corresponding item in code. +
  • '--' comments can appear in the Block Body as well +
  • if desired, the Block Body may be indented, to make the Block more legible +
  • the Block Name of 'constants' is reserved. A constants + block defines one or more simple textual substitution constants, as + name = value pairs, one per line, that may be + referenced later on in the file (see example). They are defined only to allow + such substitution to occur later in the file. Any number of constants + blocks can appear in a file. +
  • Block Names and the names of constants satisfy + {@link hirondelle.web4j.util.Regex#SIMPLE_SCOPED_IDENTIFIER}. +
  • inside a Block Body, substitutions are denoted by the common + syntax '${blah}', where blah refers to an item appearing + earlier in the file, either the content + of a previously defined Block, or the value of a constant defined in a + constants Block +
  • an item must be defined before it can be used in a substitution ; that is, + it must appear earlier in the file +
  • no substitutions are permitted in a constants Block +
  • Block Names, and the names of constants, should be unique. +
+ */ + TEXT_BLOCK, + } + + /** + Fetch a single, specific file located in the WEB-INF + directory, and populate a corresponding Properties object. + + @param aConfigFileName has content and is the "simple" name (without path info) + of a readable file in the WEB-INF directory (for example, "config.properties" or + "statements.sql"). + */ + public static Properties fetch(String aConfigFileName, FileType aFileType){ + return basicFetch(fWEBINF + aConfigFileName, ! FOR_TESTING, aFileType); + } + + /** + Fetch all config files located anywhere under the WEB-INF directory whose + "simple" file name (without path info) matches aFileNamePattern, and + translate their content into a single {@link Properties} object. + +

If the caller needs only one file of a specific name, and that file is + located in the WEB-INF directory itself, then use {@link #fetch} instead. + +

The keys in all of the matching files should be unique. + If a duplicate key exists, then the second instance will overwrite the first (as in + {@link HashMap#put}), and a SEVERE warning is logged. + +

Design Note:
+ This method was initially created to reduce contention for the *.sql file, + on projects having more than one developer. See + {@link hirondelle.web4j.database.SqlStatement} and the example .sql files for + more information. + + @param aFileNamePattern regular expression for the desired file name, without path + information + @param aConfigFileType identifies the layout style of the config file + */ + public static Properties fetchMany(Pattern aFileNamePattern, FileType aConfigFileType){ + Properties result = new Properties(); + Set allFilePaths = getFilePathsBelow(fWEBINF); + Set matchingFilePaths = matchTargetPattern(allFilePaths, aFileNamePattern); + fLogger.config( + "Desired configuration files under /WEB-INF/: " + Util.logOnePerLine(matchingFilePaths) + ); + for (String matchingFilePath : matchingFilePaths){ + Properties properties = basicFetch(matchingFilePath, ! FOR_TESTING, aConfigFileType); + addProperties(properties, result); + } + fLogger.config( + "Total number of distinct keys in configuration files : " + result.keySet().size() + ); + logKeysFromManyFiles(result); + return result; + } + + /** + Intended for testing only, outside of a web container environment. + +

Fetches properties not from a file in the WEB-INF directory, + but from a hard-coded absolute file name. + + @param aConfigFileName absolute file name, including path information + */ + public static Properties fetchForTesting(String aConfigFileName, FileType aFileType){ + return basicFetch(aConfigFileName, FOR_TESTING, aFileType); + } + + /** + Return a possibly-empty {@link Set} of {@link Class} objects, for all concrete classes + under /WEB-INF/classes/ that implement aInterface. + +

More specifically, the classes returned here are + not abstract, and not an interface. Only such classes + should be of interest to the caller, since only such classes can be instantiated. + It is expected that almost all classes in an application will be concrete. + */ + public static Set> fetchConcreteClassesThatImplement(Class aInterface){ + if(aInterface != null){ + fLogger.config("Fetching concrete classes that implement " + aInterface); + } + else { + fLogger.config("Fetching all concrete classes."); + } + Set> result = new LinkedHashSet>(); + Set allFilePaths = getFilePathsBelow(fWEBINF_CLASSES); + Set classFilePaths = matchTargetPattern(allFilePaths, CLASS_FILES); + for (String classFilePath: classFilePaths){ + String className = classFilePath.replace('/','.'); + if( className.endsWith("package-info.class")){ + fLogger.finest("Ignoring package-info.class."); + } + else { + className = className.substring(fWEBINF_CLASSES.length()); + className = className.substring(0,className.lastIndexOf(fDOT_CLASS)); + try { + Class thisClass = (Class)Class.forName(className); //cast OK + if ( isConcrete(thisClass) ){ + if(aInterface == null){ + result.add(thisClass); + } + else { + if(aInterface.isAssignableFrom(thisClass)){ + result.add(thisClass); + } + } + } + } + catch(ClassNotFoundException ex){ + fLogger.severe("Cannot load class using name : " + className); + } + } + } + return result; + } + + /** + As in {@link #fetchConcreteClassesThatImplement(Class)}, but with no test for implementing + a specific interface. + */ + public static Set fetchConcreteClasses(){ + return fetchConcreteClassesThatImplement(null); + } + + /** + Return all public static final fields declared in an application of type aFieldClass. + +

Restrict the search to concrete classes that implement an interface aContainingClassInterface, + as in {@link #fetchConcreteClassesThatImplement(Class)}. If aContainingClassInterface is + null, then scan all classes for such fields. + +

Return a possibly-empty {@link Map} having : +
KEY - Class object of class from which the field is visible; see {@link Class#getFields()}. +
VALUE - {@link Set} of objects of class aFieldClass + */ + public static /*T(Contain),V(field)*/ Map/*, Set>*/ fetchPublicStaticFinalFields(Class aContainingClassInterface, Class aFieldClass){ + //see AppFirewallImpl for example + if(aContainingClassInterface == null){ + fLogger.config("Fetching public static final fields of " + aFieldClass + ", from all concrete classes."); + } + else { + fLogger.config("Fetching public static final fields of " + aFieldClass + ", from concrete classes that implement " + aContainingClassInterface); + } + Map result = new LinkedHashMap(); + Set classesToScan = null; + if(aContainingClassInterface == null){ + classesToScan = fetchConcreteClasses(); + } + else { + classesToScan = fetchConcreteClassesThatImplement(aContainingClassInterface); + } + Iterator iter = classesToScan.iterator(); + while (iter.hasNext()){ + Class thisClass = (Class)iter.next(); + result.put(thisClass, getFields(thisClass, aFieldClass)); + } + return result; + } + + /** + As in {@link #fetchPublicStaticFinalFields(Class, Class)}, but with null value + for aContainingClassInterface. + */ + public static Map fetchPublicStaticFinalFields(Class aFieldClass){ + return fetchPublicStaticFinalFields(null, aFieldClass); + } + + /** + Process all the raw .sql text data into a consolidated Map of SqlId to SQL statements. + @param aRawSql - the raw, unprocessed content of each .sql file (or data) + */ + public static Map processRawSql(List aRawSql){ + Map result = new LinkedHashMap(); + for(String rawSqlFile : aRawSql){ + result.putAll(processedSql(rawSqlFile)); + } + return result; + } + + // PRIVATE + + /** This field requires the servlet jar to be present. It will be init-ed when this class is loaded (?). */ + private static ServletContext fContext; + + private static final boolean FOR_TESTING = true; + + private static final String fWEBINF = "/WEB-INF/"; + private static final String fWEBINF_CLASSES = "/WEB-INF/classes/"; + + private static final Pattern CLASS_FILES = Pattern.compile("(?:.)*\\.class"); + private static final String fSLASH = "/"; + private static final String fDOT_CLASS = ".class"; + private static final Logger fLogger = Util.getLogger(ConfigReader.class); + + private ConfigReader (){ + //prevent construction + } + + /** + Return a Properties which reflects the contents of the + given config file located under WEB-INF. + */ + private static Properties basicFetch(String aConfigFilePath, boolean aIsTesting, FileType aFileType){ + Args.checkForContent(aConfigFilePath); + if ( aIsTesting ) { + checkIsAbsolute(aConfigFilePath); + } + Properties result = new Properties(); + InputStream input = null; + try { + if ( ! aIsTesting ) { + input = fContext.getResourceAsStream(aConfigFilePath); + } + else { + input = new FileInputStream(aConfigFilePath); + } + if (FileType.PROPERTIES_FILE == aFileType) { + result.load(input); + } + else { + result = loadTextBlockFile(input, aConfigFilePath); + } + } + catch (IOException ex){ + vomit(aConfigFilePath, aIsTesting); + } + finally { + shutdown(input); + } + fLogger.finest("Number of keys in properties object : " + result.keySet().size()); + return result; + } + + private static void checkIsAbsolute(String aConfigFileName){ + if ( !isAbsolute(aConfigFileName) ) { + throw new IllegalArgumentException( + "Configuration file name is not absolute, "+ aConfigFileName + ); + } + } + + private static boolean isAbsolute(String aFileName){ + File file = new File(aFileName); + return file.isAbsolute(); + } + + private static void shutdown(InputStream aInput){ + try { + if (aInput != null) aInput.close(); + } + catch (IOException ex ){ + throw new IllegalStateException("Cannot close config file in WEB-INF directory."); + } + } + + private static void vomit(String aConfigFileName, boolean aIsTesting){ + String message = null; + if ( ! aIsTesting ){ + message = ( + "Cannot open and load configuration file from WEB-INF directory: " + + Util.quote(aConfigFileName) + ); + } + else { + message = ( + "Cannot open and load configuration file named " + + Util.quote(aConfigFileName) + ); + } + throw new IllegalStateException(message); + } + + /** + Return a Set of Strings starting with aStartDirectory and containing the full + file path for all files under aStartDirectory. No sorting is performed. + + @param aStartDirectory starts with /WEB-INF/ + */ + private static Set getFilePathsBelow(String aStartDirectory){ + Set result = new LinkedHashSet(); + Set paths = fContext.getResourcePaths(aStartDirectory); + for ( String path : paths) { + if ( isDirectory(path) ) { + //recursive call !!! + result.addAll(getFilePathsBelow(path)); + } + else { + result.add(path); + } + } + return result; + } + + /** + Return a Set of paths (as Strings), starting with "/WEB-INF/", for files under + WEB-INF whose simple file name (without the path info) + matches aFileNamePattern. + + @param aFullFilePaths Set of Strings starting with "/WEB-INF/", which + denote paths to all files under the WEB-INF directory (recursive) + */ + private static Set matchTargetPattern(Set aFullFilePaths, Pattern aFileNamePattern) { + Set result = new LinkedHashSet(); + for ( String fullFilePath : aFullFilePaths){ + int lastSlash = fullFilePath.lastIndexOf(fSLASH); + assert(lastSlash != -1); + if ( ! isDirectory(fullFilePath) ){ + String simpleFileName = fullFilePath.substring(lastSlash + 1); + Matcher matcher = aFileNamePattern.matcher(simpleFileName); + if ( matcher.matches() ) { + result.add(fullFilePath); + } + } + } + return result; + } + + private static boolean isDirectory(String aFullFilePath){ + return aFullFilePath.endsWith(fSLASH); + } + + /** + Add all key-value pairs in aProperties to aResult. + +

If duplicate key is found, replaces old with new, and log the occurrence. + */ + private static void addProperties(Properties aProperties, Properties aResult){ + Enumeration keys = aProperties.propertyNames(); + while ( keys.hasMoreElements() ) { + String key = (String)keys.nextElement(); + if ( aResult.containsKey(key) ) { + fLogger.severe( + "WARNING. Same key found in more than one configuration file: " +Util.quote(key)+ + "This condition almost always indicates an error. " + + "Overwriting old key-value pair with new key-value pair." + ); + } + String value = aProperties.getProperty(key); + aResult.setProperty(key, value); + } + } + + /** + Log all key names in aProperties, in alphabetical order. + */ + private static void logKeysFromManyFiles(Properties aProperties){ + SortedSet sortedKeys = new TreeSet(aProperties.keySet()); + fLogger.config(Util.logOnePerLine(sortedKeys)); + } + + private static Properties loadTextBlockFile(InputStream aInput,String aSqlFileName) throws IOException { + TextBlockReader sqlReader = new TextBlockReader(aInput, aSqlFileName); + return sqlReader.read(); + } + + private static boolean isConcrete(Class aClass){ + int modifiers = aClass.getModifiers(); + return + ! Modifier.isInterface(modifiers) && + ! Modifier.isAbstract(modifiers) + ; + } + + private static Set getFields(Class aContainingClass, Class aFieldClass){ + Set result = new LinkedHashSet(); + List fields = Arrays.asList(aContainingClass.getFields()); + Iterator fieldsIter = fields.iterator(); + while (fieldsIter.hasNext()){ + Field field = (Field)fieldsIter.next(); + if( field.getType() == aFieldClass ){ + int modifiers = field.getModifiers(); + if(Modifier.isPublic(modifiers) && Modifier.isStatic(modifiers) && Modifier.isFinal(modifiers)){ + try { + result.add(field.get(null)); + } + catch (IllegalAccessException ex){ + fLogger.severe("Cannot get value of public static final field in " + aContainingClass); + } + } + } + } + return result; + } + + private static Map processedSql(String aRawSql){ + TextBlockReader textBlockReader = new TextBlockReader(aRawSql); + Properties props = null; + try { + props = textBlockReader.read(); + } + catch (IOException ex) { + ex.printStackTrace(); + } + return new LinkedHashMap(props); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/readconfig/DbConfigParser.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/readconfig/DbConfigParser.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,125 @@ +package hirondelle.web4j.readconfig; + +import hirondelle.web4j.util.Args; +import hirondelle.web4j.util.Util; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.StringTokenizer; + +/** + Parse database config settings. + +

The format of these settings has 2 syntactical forms: +

    +
  • 1 value, no ~ separator - the single value is applied to all databases. +
  • 1 default value + N name=value pairs, separated by ~; the name=value pairs represent overrides to the default value; + the default value is always present. +
+ +

Example of the second style: +

100~Translation=200~Access=300
+ + Here, the first entry represents the setting for the default database. + It also represents the setting for all other databases, unless an override value is + provided by one of the name-value pairs. + +

The other entries are name=value pairs; the name must be a name of one of the + non-default databases known to ConnectionSource. + +

Thus, the database named 'Translation' has a value of 200, different from the default + value of 100. If the setting instead was + +

100~Access=300
+ + then the value for the Translation database would be 100 - same as the default. + +

The ~ separator is used instead of a comma, since the DateTime format setting can use a comma internally. +*/ +final class DbConfigParser { + + /** + Constructor. + Parses a setting into its component parts. + @param aRawText see class comment. + */ + DbConfigParser(String aRawText){ + Args.checkForContent(aRawText); + fRawText = aRawText; + parse(); + } + + /** + Return the value appropriate to the given database. + If no value was explicitly set for the given database, then return the value used by + the default database. + If aDatabaseName has no content, then the default value is returned. + */ + String getValue(String aDatabaseName){ + String result = ""; + if(Util.textHasContent(aDatabaseName)) { + result = fValuesTable.get(aDatabaseName); + if (! Util.textHasContent(result)) { + result = getDefaultValue(); + } + } + else { + result = getDefaultValue(); + } + return result; + } + + /** + Return the database names mentioned in this setting. +

Always includes the default database, represented here as an empty String. + This method can be used to verify that the names are among the names defined by + ConnectionSource. + */ + Set getDbNames(){ + return Collections.unmodifiableSet(fValuesTable.keySet()); + } + + // PRIVATE + private String fRawText; + private Map fValuesTable = new LinkedHashMap(); + + //The default db is defined here as having an empty name; this doesn't necessarily + //have to match ConnectionSource. + private String DEFAULT_DB = ""; + + /** Return the setting's default value. */ + private String getDefaultValue(){ + return fValuesTable.get(DEFAULT_DB); + } + + private void parse(){ + String DELIM_TILDE = "~"; + String DELIM_EQUALS = "="; + if(! fRawText.contains(DELIM_TILDE)){ + fValuesTable.put(DEFAULT_DB, fRawText.trim()); + } + else { + StringTokenizer parts = new StringTokenizer(fRawText, DELIM_TILDE); + String firstPart = parts.nextToken().trim(); + if (firstPart.contains(DELIM_EQUALS)) { + throw new IllegalArgumentException("The first value contains an equals sign. Syntax of setting not correct : " + Util.quote(fRawText)); + } + fValuesTable.put(DEFAULT_DB, firstPart); + while (parts.hasMoreTokens()) { + String part = parts.nextToken(); + StringTokenizer nvp = new StringTokenizer(part, DELIM_EQUALS); + String name = nvp.nextToken(); + String value = nvp.nextToken(); + Args.checkForContent(name); + Args.checkForContent(value); + if(fValuesTable.containsKey(name)){ + throw new IllegalArgumentException("Setting has invalid syntax. Have you repeated the name of a database?: " + Util.quote(fRawText)); + } + fValuesTable.put(name.trim(), value.trim()); + } + } + //fLogger.fine("Parsed database setting: " + fValuesTable); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/readconfig/ParseDecimalFormat.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/readconfig/ParseDecimalFormat.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,55 @@ +package hirondelle.web4j.readconfig; + +import hirondelle.web4j.util.Regex; + +import java.util.regex.Pattern; + +final class ParseDecimalFormat { + + /** Used to validate user input of numbers having a decimal point. */ + Pattern getDecimalFormatPattern(String aDecimalSeparator){ + return buildDecimalFormat(aDecimalSeparator); + } + + // PRIVATE + private static final String COMMA = "COMMA"; + private static final String PERIOD = "PERIOD"; + private static final String PERIOD_OR_COMMA = "PERIOD,COMMA"; + + /** Return the pattern applicable to numeric input of a number with a possible decimal portion. */ + private Pattern buildDecimalFormat(String aDecimalSeparator){ + String pattern = ""; + String sign = "(?:-|\\+)?"; + String digits = "[0-9]+"; + String decimalSign = getDecimalSignPattern(aDecimalSeparator); + String places = "[0-9]+"; + // pattern = sign?(digits|digits.places|.places) + pattern = sign + "(" + digits + Regex.OR + digits + decimalSign + places + Regex.OR + decimalSign + places + ")"; + return Pattern.compile(pattern); + } + + private String getDecimalSignPattern(String fDecimalSeparator){ + String result = null; + if( PERIOD.equalsIgnoreCase(fDecimalSeparator) ) { + result = "(?:\\.)"; + } + else if ( COMMA.equalsIgnoreCase(fDecimalSeparator) ){ + result = "(?:,)"; + } + else if ( PERIOD_OR_COMMA.equalsIgnoreCase(fDecimalSeparator)){ + result = "(?:\\.|,)"; + } + else { + vomit( + "In web.xml, the setting for DecimalSeparator is not in the expected format. " + + "See web.xml for more information." + ); + } + return result; + } + + private void vomit(String aMessage){ + //fLogger.severe(aMessage); + throw new IllegalArgumentException(aMessage); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/readconfig/ParseUntrustedProxy.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/readconfig/ParseUntrustedProxy.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,85 @@ +package hirondelle.web4j.readconfig; + +import hirondelle.web4j.util.Util; +import hirondelle.web4j.util.WebUtil; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.StringTokenizer; +import java.util.logging.Logger; + +import hirondelle.web4j.security.UntrustedProxyForUserIdImpl; + +final class ParseUntrustedProxy { + + Map> parse(String aRawValue){ + List lines = parseSeparateLines(aRawValue); + for(String line: lines){ + parseNounsAndVerbs(line); + } + fLogger.config(Util.logOnePerLine(fRestrictedOperations)); + return fRestrictedOperations; + } + + // PRIVATE + private static final Logger fLogger = Util.getLogger(Config.class); // borrow a public logger, since this class is not public + + /** Maps nouns to corresponding verbs. */ + private Map> fRestrictedOperations = new LinkedHashMap>(); + + private static final String INIT_PARAM_NAME = "UntrustedProxyForUserId"; + + private List parseSeparateLines(String aRawValue){ + List result = new ArrayList(); + StringTokenizer parser = new StringTokenizer(aRawValue, "\n\r"); + while ( parser.hasMoreTokens() ) { + result.add( parser.nextToken().trim() ); + } + return result; + } + + private void parseNounsAndVerbs(String aLine){ + String verb = WebUtil.getFileExtension(aLine); + int verbStart = aLine.indexOf("."); + String noun = aLine.substring(0,verbStart); + if( isMissing(verb) || isMissing(noun) ){ + throw new RuntimeException( + "This line for the " + INIT_PARAM_NAME + " setting in web.xml does not have the expected form: " + Util.quote(aLine) + ); + } + add(noun.trim(), verb.trim()); + } + + private boolean isMissing(String aText){ + return ! Util.textHasContent(aText); + } + + private void add(String aNoun, String aVerb){ + if( fRestrictedOperations.containsKey(aNoun) ) { + addAnotherVerb(aNoun, aVerb); + } + else { + addNewNounAndVerb(aNoun, aVerb); + } + } + + private void addNewNounAndVerb(String aNoun, String aVerb){ + List verbs = new ArrayList(); + verbs.add(aVerb); + fRestrictedOperations.put(aNoun, verbs); + } + + private void addAnotherVerb(String aNoun, String aVerb){ + if( UntrustedProxyForUserIdImpl.ALL_OPERATIONS.equals(aVerb) ){ + throw new RuntimeException( + "When you use the '" + UntrustedProxyForUserIdImpl.ALL_OPERATIONS + "' character to represent ALL operations, then only one line can be present for that item. " + + "In web.xml, you have a redundant setting for the init-param named " + INIT_PARAM_NAME + + " which needs to be removed. It is related to " + Util.quote(aNoun) + " and " + Util.quote(aVerb) + ); + } + List verbs = fRestrictedOperations.get(aNoun); + verbs.add(aVerb); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/readconfig/TESTDbConfigParser.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/readconfig/TESTDbConfigParser.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,55 @@ +package hirondelle.web4j.readconfig; + +import junit.framework.TestCase; + +public final class TESTDbConfigParser extends TestCase { + + /** Run the test cases. */ + public static void main(String args[]) { + String[] testCaseName = {TESTDbConfigParser.class.getName()}; + junit.textui.TestRunner.main(testCaseName); + } + + public TESTDbConfigParser(String aName) { + super(aName); + } + + // TEST CASES + + public void testParse(){ + testParse("100", SUCCESS); + testParse("100~A=200", SUCCESS); + testParse("100~A=200~B=300", SUCCESS); + + testParse("=", FAIL); + testParse("100~200", FAIL); + testParse("A=100~B=200", FAIL); + testParse("100~A=200~A=300", FAIL); + } + + // PRIVATE + private static final boolean SUCCESS = true; + private static final boolean FAIL = false; + + private void testParse(String aRawText, boolean aSuccess) { + if(aSuccess){ + try { + DbConfigParser parser = new DbConfigParser(aRawText); + //parser.parse(); + } + catch(Throwable ex){ + fail(); + } + } + else { + try { + DbConfigParser parser = new DbConfigParser(aRawText); + //parser.parse(); + fail(); + } + catch(Throwable ex){ + //OK + } + } + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/readconfig/TextBlockReader.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/readconfig/TextBlockReader.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,278 @@ +package hirondelle.web4j.readconfig; + +import static hirondelle.web4j.util.Consts.NEW_LINE; +import hirondelle.web4j.util.Regex; +import hirondelle.web4j.util.Util; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.LineNumberReader; +import java.io.StringReader; +import java.util.Map; +import java.util.Properties; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + (UNPUBLISHED) Helper for {@link ConfigReader}, for reading {@link ConfigReader.FileType#TEXT_BLOCK} + files. + +

See {@link ConfigReader.FileType#TEXT_BLOCK} for specification of the format of such files. +*/ +final class TextBlockReader { + + /** + @param aInput has an underlying TextBlock file as source + @param aConfigFileName the underlying source file name + */ + TextBlockReader(InputStream aInput, String aConfigFileName) { + fReader = new LineNumberReader(new InputStreamReader(aInput)); + fConfigFileName = aConfigFileName; + fConstants = new Properties(); + } + + TextBlockReader(String aRawSqlFile) { + fReader = new LineNumberReader(new StringReader(aRawSqlFile)); + fConfigFileName = ""; + fConstants = new Properties(); + } + + /** + Parse the underlying TEXT_BLOCK file into a {@link Properties} object, which + uses key-value pairs of Strings. + +

Using this example entry in a *.sql file : +

+   FETCH_NUM_MSGS_FOR_USER {
+     SELECT COUNT(Id) FROM MyMessage WHERE LoginName=?
+   }
+  
+ the key is

FETCH_NUM_MSGS_FOR_USER

while the value is +

SELECT COUNT(Id) FROM MyMessage WHERE LoginName=?. + */ + Properties read() throws IOException { + fLogger.fine("Reading text block file : " + Util.quote(fConfigFileName)); + final Properties result = new Properties(); + String line = null; + while (( line = fReader.readLine()) != null){ + if ( isIgnorable(line) ) { + continue; + } + if ( ! isInBlock() ){ + startBlock(line); + } + else { + if ( ! isEndOfBlock(line) ){ + addLine(line); + } + else { + endBlock(result); + } + } + } + return result; + } + + // PRIVATE + private final LineNumberReader fReader; + private final String fConfigFileName; + private StringBuilder fBlockBody; + + /* + These two distinguish between regular blocks and 'constants' blocks. + sql statements, and those for constants. + */ + private boolean fIsReadingBlockBody; + private boolean fIsReadingConstants; + + /** + For regular Blocks, refers to the Block Name. + For constants, refers not to the name of the containing block (which is always + the same), but to the identifier for the constant itself; this is line-level, + not block-level. + */ + private String fKey; + + /** + Remembers where the current block started. Used for debugging messages. + */ + private int fBlockStartLineNumber; + + /** + Stores the constants, which are always intended for substitutions appearing + later in the file. These items are not added to the final Properties object + returned by this class; rather, they assist in performing substitutions of + simple constants. + +

There can be multiple Constants blocks in a TEXT_BLOCK file. This item + collects them all into one data structure. + */ + private final Properties fConstants; + + private static final String fCOMMENT = "--"; + private static final String fEND_BLOCK = "}"; + private static final String fSTART_BLOCK = "{"; + private static final Pattern fKEY_NAME_PATTERN = Pattern.compile(Regex.SIMPLE_SCOPED_IDENTIFIER); + private static final Pattern fSUBSTITUTION_PATTERN = Pattern.compile( + "(?:\\$\\{)" + Regex.SIMPLE_IDENTIFIER + "(?:\\})" + ); + private static final String fCONSTANTS_BLOCK = "constants"; //ignore case + private static final String fCONSTANTS_SEPARATOR = "="; + private static final int fNOT_FOUND = -1; + + private static final Logger fLogger = Util.getLogger(TextBlockReader.class); + + private boolean isIgnorable(String aLine){ + boolean result; + if ( isInBlock() ) { + //no empty lines within blocks allowed + result = isComment(aLine); + } + else { + result = ! Util.textHasContent(aLine) || isComment(aLine); + } + return result; + } + + private boolean isInBlock(){ + return fIsReadingBlockBody || fIsReadingConstants; + } + + private boolean isComment(String aLine){ + return aLine.trim().startsWith(fCOMMENT); + } + + private void startBlock(String aLine){ + fBlockStartLineNumber = fReader.getLineNumber(); + fKey = getBlockName(aLine); + if ( fCONSTANTS_BLOCK.equalsIgnoreCase(fKey) ) { + fIsReadingConstants = true; + fIsReadingBlockBody = false; + } + else { + fBlockBody = new StringBuilder(); + fIsReadingBlockBody = true; + fIsReadingConstants = false; + } + } + + private String getBlockName(String aLine){ + int indexOfBrace = aLine.indexOf(fSTART_BLOCK); + if ( indexOfBrace == -1 ){ + throw new IllegalArgumentException( + reportLineNumber() + + "Expecting to find line defining a block, containing a trailing " + + Util.quote(fSTART_BLOCK) + ". Found this line instead : " + Util.quote(aLine) + ); + } + String candidateKey = aLine.substring(0, indexOfBrace).trim(); + return verifiedKeyName(candidateKey); + } + + private String verifiedKeyName(String aCandidateKey){ + if (Util.matches(fKEY_NAME_PATTERN, aCandidateKey)) { + return aCandidateKey; + } + String message = reportLineNumber() + "The name " + + Util.quote(aCandidateKey) + " is not in the expected syntax. " + + "It does not match the regular expression " + fKEY_NAME_PATTERN.pattern() + ; + throw new IllegalArgumentException(message); + } + + private boolean isEndOfBlock(String aLine){ + return fEND_BLOCK.equals(aLine.trim()); + } + + private void addLine(String aLine){ + if ( fIsReadingBlockBody) { + addToBlockBody(aLine); + } + else if ( fIsReadingConstants ){ + addConstant(aLine); + } + } + + private void addToBlockBody(String aLine){ + fBlockBody.append(aLine); + fBlockBody.append(NEW_LINE); + } + + private void addConstant(String aLine){ + int idxFirstSeparator = aLine.indexOf(fCONSTANTS_SEPARATOR); + if ( idxFirstSeparator == fNOT_FOUND ) { + String message = + reportLineNumber() + "Cannot find expected constants separator character : " + + Util.quote(fCONSTANTS_SEPARATOR) + ; + throw new IllegalAccessError(message); + } + String key = verifiedKeyName(aLine.substring(0, idxFirstSeparator).trim()); + String value = aLine.substring(idxFirstSeparator + 1).trim(); + addToResult(key, value, fConstants); + } + + /** + Allow a duplicate key, but log as SEVERE. + */ + private void addToResult(String aKey, String aValue, Map aResult){ + if ( aResult.containsKey(aKey) ) { + fLogger.severe( + "DUPLICATE Value found for this Block Name or constant name " + Util.quote(aKey) + + ". This almost always indicates an error." + ); + } + aResult.put(aKey, aValue); + } + + private void endBlock(Properties aResult){ + if (fIsReadingBlockBody){ + String finalBlockBody = resolveAnySubstitutions(fBlockBody.toString().trim(), aResult); + addToResult(fKey, finalBlockBody, aResult); + } + fIsReadingBlockBody = false; + fIsReadingConstants = false; + fBlockStartLineNumber = 0; + } + + private String resolveAnySubstitutions(String aRawBlockBody, Properties aResult){ + StringBuffer result = new StringBuffer(); + Matcher matcher = fSUBSTITUTION_PATTERN.matcher(aRawBlockBody); + while ( matcher.find() ){ + matcher.appendReplacement(result, getReplacement(matcher, aResult)); + } + matcher.appendTail(result); + return result.toString(); + } + + private String getReplacement(Matcher aMatcher, Properties aProperties){ + String replacementKey = aMatcher.group(1); + //the replacement may be another Block Body, or it may be a constant + String replacement = aProperties.getProperty(replacementKey); + if ( ! Util.textHasContent(replacement) ) { + replacement = fConstants.getProperty(replacementKey); + } + if ( ! Util.textHasContent(replacement) ) { + throw new IllegalArgumentException( + reportBlockStartLineNumber() + + "The substitution variable ${" + replacementKey + + "} is not defined. Substitution variables must be defined before they " + + "are referenced. Please correct the ordering problem." + ); + } + fLogger.finest( + "Replacement for " + Util.quote(replacementKey) + " is " + Util.quote(replacement) + ); + return replacement; + } + + private String reportLineNumber(){ + return "[" + fConfigFileName + ":" + Integer.toString(fReader.getLineNumber()) + "] "; + } + + private String reportBlockStartLineNumber(){ + return "[" + fConfigFileName + ":" + Integer.toString(fBlockStartLineNumber) + "] "; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/readconfig/package.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/readconfig/package.html Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,13 @@ + + + + + + + Utilities + + +Access to configured items. +
  + + diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/request/DateConverter.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/request/DateConverter.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,158 @@ +package hirondelle.web4j.request; + +import hirondelle.web4j.model.DateTime; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; + +/** + Convert text into a {@link DateTime} or {@link Date} object, and vice versa. + +

See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. + {@link hirondelle.web4j.BuildImpl#forDateConverter()} + returns the configured implementation of this interface. + +

This interface has methods which occur in pairs, one for a {@link DateTime}, and one for a {@link Date}. + The term 'date' used below refers to generically to both of these classes. WEB4J recommeds + {@link DateTime} as the preferred class for representing dates. + +

The application programmer's implementation of this interface is used when WEB4J needs to + build a date from user input, or format an existing date. Here is an + example implementation. + +

Design Notes
+ Here are some forces concerning dates in Java web applications : +

    +
  • usually, users expect date input formats to be the same for all forms. +
  • an application may have only dates, only date-times, or a mixture of the two. +
  • user input of a date or date-time may be with a single control, or with many controls. For example, + the date may be entered with one control, and the hours and minutes with a second and third control, respectively. + (WEB4J works well with the one-control-only style. Using more than one control is still possible, since the application + programmer always has access to the underlying request parameters. But, when using WEB4J, + that style involves slightly more work for the application programmer.) +
  • the needs of the eye differ from the needs of the hand. That is, a date format + which is easy to read is usually not easy to enter into a control. Thus, applications should support two formats for + dates : a hand-friendly format (used for input), and an eye-friendly format, used for + presentation and for input as well (allowing user input with an eye-friendly format is + important for forms which change existing data). +
  • date formats may differ between Locales. For example, '01-31-2006' is natural for English speakers + (January 31, 2006), while '31-01-2006' is more natural for French speakers (le 31 janvier 2006). +
  • for parsing tasks, {@link java.text.SimpleDateFormat} is mediocre, and perhaps even untrustworthy. It should be used with great care. + One should also be aware that it's not thread-safe. +
  • if a new {@link Locale} is added to an application, then parsing and formatting of a date should not fail. + Instead, reasonable defaults should be defined for unknown Locales. +
+*/ +public interface DateConverter { + + /** + Parse textual user input of a "hand-friendly" format into a {@link Date} object. + +

A hand-friendly format might be '01312006', while an eye-friendly format might be + 'Jan 31, 2006'. +

The implementation must return null when the user input cannot be + successfully parsed into a {@link Date}. It is recommended that the implementation + have reasonable default behaviour for unexpected {@link Locale}s. +

This method is called by {@link RequestParser}. + + @param aInputValue user input value, as returned by {@link hirondelle.web4j.model.ConvertParam#filter(String)} + (always has content). + @param aLocale is obtained from the configured {@link LocaleSource}, and passed to this method + by the framework. + @param aTimeZone is obtained from the configured {@link TimeZoneSource}, and passed to this method + by the framework. + */ + Date parseHandFriendly(String aInputValue, Locale aLocale, TimeZone aTimeZone); + + /** + Parse textual user input of an "eye-friendly" format into a {@link Date} object. + +

A hand-friendly format might be '01312006', while an eye-friendly format might be + 'Jan 31, 2006'. +

The implementation must return null when the user input cannot be + successfully parsed into a {@link Date}. It is recommended that the implementation + have reasonable default behaviour for unexpected {@link Locale}s. +

This method is called by {@link RequestParser}. + + @param aInputValue user input value, as returned by {@link hirondelle.web4j.model.ConvertParam#filter(String)} + (always has content). + @param aLocale is obtained from the configured {@link LocaleSource}, and passed to this method + by the framework. + @param aTimeZone is obtained from the configured {@link TimeZoneSource}, and passed to this method + by the framework. + */ + Date parseEyeFriendly(String aInputValue, Locale aLocale, TimeZone aTimeZone); + + /** + Format a {@link Date} into an eye-friendly, legible format. + +

The implementation must return an empty String when the {@link Date} is null. + It is recommended that the implementation have reasonable default behaviour for unexpected {@link Locale}s. +

The framework will call this method when presenting listings using + {@link hirondelle.web4j.database.Report}, and when presenting a Model Object in a form + for a "change" operation. +

This method is called by {@link Formats}. + + @param aDate to be presented to the user in a legible format + @param aLocale is obtained from the configured {@link LocaleSource}, and passed to this method + by the framework. + @param aTimeZone is obtained from the configured {@link TimeZoneSource}, and passed to this method + by the framework. + @return text compatible with {@link #parseEyeFriendly(String, Locale, TimeZone)} + */ + String formatEyeFriendly(Date aDate, Locale aLocale, TimeZone aTimeZone); + + /** + Parse textual user input of a "hand-friendly" format into a {@link DateTime} object. + +

A hand-friendly format might be '01312006', while an eye-friendly format might be + 'Jan 31, 2006'. +

The implementation must return null when the user input cannot be + successfully parsed into a {@link DateTime}. It is recommended that the implementation + have reasonable default behaviour for unexpected {@link Locale}s. +

This method is called by {@link RequestParser}. + + @param aInputValue user input value, as returned by {@link hirondelle.web4j.model.ConvertParam#filter(String)} + (always has content). + @param aLocale is obtained from the configured {@link LocaleSource}, and passed to this method + by the framework. + */ + DateTime parseHandFriendlyDateTime(String aInputValue, Locale aLocale); + + /** + Parse textual user input of an "eye-friendly" format into a {@link DateTime} object. + +

A hand-friendly format might be '01312006', while an eye-friendly format might be + 'Jan 31, 2006'. +

The implementation must return null when the user input cannot be + successfully parsed into a {@link DateTime}. It is recommended that the implementation + have reasonable default behaviour for unexpected {@link Locale}s. +

This method is called by {@link RequestParser}. + + @param aInputValue user input value, as returned by {@link hirondelle.web4j.model.ConvertParam#filter(String)} + (always has content). + @param aLocale is obtained from the configured {@link LocaleSource}, and passed to this method + by the framework. + */ + DateTime parseEyeFriendlyDateTime(String aInputValue, Locale aLocale); + + /** + Format a {@link DateTime} into an eye-friendly, legible format. + +

The implementation must return an empty String when the {@link DateTime} is null. + It is recommended that the implementation have reasonable default behaviour for unexpected {@link Locale}s. +

The framework will call this method when presenting listings using + {@link hirondelle.web4j.database.Report}, and when presenting a Model Object in a form + for a "change" operation. +

This method is called by {@link Formats}. + + @param aDateTime to be presented to the user in a legible format + @param aLocale is obtained from the configured {@link LocaleSource}, and passed to this method + by the framework. + @return text compatible with {@link #parseEyeFriendlyDateTime(String, Locale)} + */ + String formatEyeFriendlyDateTime(DateTime aDateTime, Locale aLocale); + + + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/request/Formats.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/request/Formats.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,364 @@ +package hirondelle.web4j.request; + +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.model.Code; +import hirondelle.web4j.model.DateTime; +import hirondelle.web4j.model.Decimal; +import hirondelle.web4j.model.Id; +import hirondelle.web4j.readconfig.Config; +import hirondelle.web4j.security.SafeText; +import hirondelle.web4j.util.Consts; +import hirondelle.web4j.util.Util; + +import java.math.BigDecimal; +import java.text.DecimalFormat; +import java.text.NumberFormat; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; +import java.util.regex.Pattern; + +/** + Standard display formats for the application. + +

The formats used by this class are mostly configured in + web.xml, and are read by this class upon startup. + See the User Guide + for more information. + +

Most formats are localized using the {@link java.util.Locale} passed to this object. + See {@link LocaleSource} for more information. + +

These formats are intended for implementing standard formats for display of + data both in forms ({@link hirondelle.web4j.ui.tag.Populate}) and in + listings ({@link hirondelle.web4j.database.Report}). + +

See also {@link DateConverter}, which is also used by this class. +*/ +public final class Formats { + + /** + Construct with a {@link Locale} and {@link TimeZone} to be applied to non-localized patterns. + + @param aLocale almost always comes from {@link LocaleSource}. + @param aTimeZone almost always comes from {@link TimeZoneSource}. A defensive copy is made of + this mutable object. + */ + public Formats(Locale aLocale, TimeZone aTimeZone){ + fLocale = aLocale; + fTimeZone = TimeZone.getTimeZone(aTimeZone.getID()); //defensive copy + fDateConverter = BuildImpl.forDateConverter(); + } + + /** Return the {@link Locale} passed to the constructor. */ + public Locale getLocale(){ + return fLocale; + } + + /** Return a TimeZone of the same id as the one passed to the constructor. */ + public TimeZone getTimeZone(){ + return TimeZone.getTimeZone(fTimeZone.getID()); + } + + /** Return the format in which {@link BigDecimal}s and {@link Decimal}s are displayed in a form. */ + public DecimalFormat getBigDecimalDisplayFormat(){ + return getDecimalFormat(fConfig.getBigDecimalDisplayFormat()); + } + + /** + Return the regular expression for validating the format of numeric amounts input by the user, having a + possible decimal portion, with any number of decimals. + +

The returned {@link Pattern} is controlled by a setting in web.xml, + for decimal separator(s). It is suitable for both {@link Decimal} and {@link BigDecimal} values. + This item is not affected by a {@link Locale}. + +

See web.xml for more information. + */ + public Pattern getDecimalInputFormat(){ + return fConfig.getDecimalInputPattern(); + } + + /** Return the format in which integer amounts are displayed in a report. */ + public DecimalFormat getIntegerReportDisplayFormat(){ + return getDecimalFormat(fConfig.getIntegerDisplayFormat()); + } + + /** + Return the text used to render boolean values in a report. + +

The return value does not depend on {@link Locale}. + */ + public static String getBooleanDisplayText(Boolean aBoolean){ + Config config = new Config(); + return aBoolean ? config.getBooleanTrueDisplayFormat() : config.getBooleanFalseDisplayFormat(); + } + + /** + Return the text used to render empty or null values in a report. + +

The return value does not depend on {@link Locale}. See web.xml for more information. + */ + public static String getEmptyOrNullText() { + return new Config().getEmptyOrNullDisplayFormat(); + } + + /** + Translate an object into text, suitable for presentation in an HTML form. + +

The intent of this method is to return values matching those POSTed during form submission, + not the visible text presented to the user. + +

The returned text is not escaped in any way. + That is, if special characters need to be escaped, the caller must perform the escaping. + +

Apply these policies in the following order : +

+ +

If aObject is a Collection, then the caller must call + this method for every element in the Collection. + + @param aObject must not be a Collection. + */ + public String objectToText(Object aObject) { + String result = null; + if ( aObject == null ){ + result = Consts.EMPTY_STRING; + } + else if ( aObject instanceof DateTime ){ + DateTime dateTime = (DateTime)aObject; + result = fDateConverter.formatEyeFriendlyDateTime(dateTime, fLocale); + } + else if ( aObject instanceof Date ){ + Date date = (Date)aObject; + result = fDateConverter.formatEyeFriendly(date, fLocale, fTimeZone); + } + else if ( aObject instanceof BigDecimal ){ + BigDecimal amount = (BigDecimal)aObject; + result = renderBigDecimal(amount); + } + else if ( aObject instanceof Decimal ){ + Decimal money = (Decimal)aObject; + result = renderBigDecimal(money.getAmount()); + } + else if ( aObject instanceof TimeZone ) { + TimeZone timeZone = (TimeZone)aObject; + result = timeZone.getID(); + } + else if ( aObject instanceof Code ) { + Code code = (Code)aObject; + result = code.getId().getRawString(); + } + else if ( aObject instanceof Id ) { + Id id = (Id)aObject; + result = id.getRawString(); + } + else if ( aObject instanceof SafeText ) { + //The Populate tag will safely escape all such text data. + //To avoid double escaping, the raw form is returned. + SafeText safeText = (SafeText)aObject; + result = safeText.getRawString(); + } + else { + result = aObject.toString(); + } + return result; + } + + /** + Translate an object into text suitable for direct presentation in a JSP. + +

In general, a report can be rendered in various ways: HTML, XML, plain text. + Each of these styles has different needs for escaping special characters. + This method returns a {@link SafeText}, which can escape characters in + various ways. + +

This method applies the following policies to get the unescaped text : +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Type Action
SafeTextuse {@link SafeText#getRawString()}
Iduse {@link Id#getRawString()}
Codeuse {@link Code#getText()}.getRawString()
hirondelle.web4.model.DateTimeapply {@link DateConverter#formatEyeFriendlyDateTime(DateTime, Locale)}
java.util.Dateapply {@link DateConverter#formatEyeFriendly(Date, Locale, TimeZone)}
BigDecimaluse {@link #getBigDecimalDisplayFormat}
Decimaluse {@link #getBigDecimalDisplayFormat} on decimal.getAmount()
Booleanuse {@link #getBooleanDisplayText}
Integeruse {@link #getIntegerReportDisplayFormat}
Longuse {@link #getIntegerReportDisplayFormat}
Localeuse {@link Locale#getDisplayName(java.util.Locale)}
TimeZoneuse {@link TimeZone#getDisplayName(boolean, int, java.util.Locale)} (with no daylight savings hour, and in the SHORT style
..other... + use toString, and pass result to constructor of {@link SafeText}. +
+ +

In addition, the value returned by {@link #getEmptyOrNullText} is used if : +

    +
  • aObject is itself null +
  • the result of the above policies returns text which has no content +
+ */ + public SafeText objectToTextForReport(Object aObject) { + String result = null; + if ( aObject == null ){ + result = null; + } + else if (aObject instanceof SafeText){ + //it is odd to extract an identical object like this, + //but it safely avoids double escaping at the end of this method + SafeText text = (SafeText) aObject; + result = text.getRawString(); + } + else if (aObject instanceof Id){ + Id id = (Id) aObject; + result = id.getRawString(); + } + else if (aObject instanceof Code){ + Code code = (Code) aObject; + result = code.getText().getRawString(); + } + else if (aObject instanceof String) { + result = aObject.toString(); + } + else if ( aObject instanceof DateTime ){ + DateTime dateTime = (DateTime)aObject; + result = fDateConverter.formatEyeFriendlyDateTime(dateTime, fLocale); + } + else if ( aObject instanceof Date ){ + Date date = (Date)aObject; + result = fDateConverter.formatEyeFriendly(date, fLocale, fTimeZone); + } + else if ( aObject instanceof BigDecimal ){ + BigDecimal amount = (BigDecimal)aObject; + result = getBigDecimalDisplayFormat().format(amount.doubleValue()); + } + else if ( aObject instanceof Decimal ){ + Decimal money = (Decimal)aObject; + result = getBigDecimalDisplayFormat().format(money.getAmount().doubleValue()); + } + else if ( aObject instanceof Boolean ){ + Boolean value = (Boolean)aObject; + result = getBooleanDisplayText(value); + } + else if ( aObject instanceof Integer ) { + Integer value = (Integer)aObject; + result = getIntegerReportDisplayFormat().format(value); + } + else if ( aObject instanceof Long ) { + Long value = (Long)aObject; + result = getIntegerReportDisplayFormat().format(value.longValue()); + } + else if ( aObject instanceof Locale ) { + Locale locale = (Locale)aObject; + result = locale.getDisplayName(fLocale); + } + else if ( aObject instanceof TimeZone ) { + TimeZone timeZone = (TimeZone)aObject; + result = timeZone.getDisplayName(false, TimeZone.SHORT, fLocale); + } + else { + result = aObject.toString(); + } + //ensure that all empty results have configured content + if ( ! Util.textHasContent(result) ) { + result = fConfig.getEmptyOrNullDisplayFormat(); + } + return new SafeText(result); + } + + // PRIVATE + private final Locale fLocale; + private final TimeZone fTimeZone; + private final DateConverter fDateConverter; + private Config fConfig = new Config(); + + private DecimalFormat getDecimalFormat(String aFormat){ + DecimalFormat result = null; + NumberFormat format = NumberFormat.getNumberInstance(fLocale); + if (format instanceof DecimalFormat){ + result = (DecimalFormat)format; + } + else { + throw new AssertionError(); + } + result.applyPattern(aFormat); + return result; + } + + /** + Return the pattern applicable to numeric input of a number with a possible decimal portion. + */ + private String replacePeriodWithComma(String aValue){ + return aValue.replace(".", ","); + } + + private String renderBigDecimal(BigDecimal aBigDecimal){ + String result = aBigDecimal.toPlainString(); + if( "COMMA".equalsIgnoreCase(fConfig.getDecimalSeparator()) ){ + result = replacePeriodWithComma(result); + } + return result; + } + + /** Informal test harness. */ + private static void main(String... args){ + Formats formats = new Formats(Locale.CANADA, TimeZone.getTimeZone("Canada/Atlantic")); + System.out.println("en_fr: " + formats.objectToTextForReport(new Locale("en_fr"))); + System.out.println("Canada/Pacific: " + formats.objectToTextForReport(TimeZone.getTimeZone("Canada/Pacific"))); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/request/LocaleSource.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/request/LocaleSource.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,59 @@ +package hirondelle.web4j.request; + +import java.util.Locale; +import javax.servlet.http.HttpServletRequest; + +/** + Return the {@link Locale} associated with a given request. + +

See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. + {@link hirondelle.web4j.BuildImpl#forLocaleSource()} + returns the configured implementation of this interface. See {@link LocaleSourceImpl} for a default implementation. + This interface is similar to {@link TimeZoneSource}, and is used in much the same way. + +

In general, a {@link Locale} is used for two distinct operations : +

    +
  • render objects such as Integer, Date, and so on +
  • translate text from one language to another +
+ +

Since almost all applications at least render data, then almost all applications + will also use a {@link Locale} - even single-language applications. + +

In a single-language application, the returned Locale is used by WEB4J for + rendering these items : +

    +
  • user response messages containing numbers and dates +
  • presenting ResultSets as reports (dates, numbers) +
  • prepopulating forms (dates, numbers) +
  • displaying dates with {@link hirondelle.web4j.ui.tag.ShowDate} +
+ +

If the application is multilingual, then the returned Locale is also + used for translating text. See {@link hirondelle.web4j.ui.translate.Translator} and related classes + for more information. + +

A very large number of policies can be defined by implementations of this interface. + Possible sources of Locale information include : +

    +
  • a single setting in web.xml, place into application scope upon startup +
  • an object stored in session scope +
  • a request parameter +
  • a request header +
  • a cookie +
+ +

All WEB4J applications have at least one {@link Locale} - the DefaultLocale setting + configured in web.xml. (This allows the application's Locale to be set + explicitly, independent of the server's default Locale setting, or of browser + header settings.) For applications which use only a single language, that Locale defines + how WEB4J will format the items mentioned above. For multilingual applications, this web.xml setting is + reinterpreted as the default Locale, which is overridden by implementations of this + interface. +*/ +public interface LocaleSource { + + /** Return a {@link Locale} corresponding to a given underlying request. */ + public Locale get(HttpServletRequest aRequest); + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/request/LocaleSourceImpl.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/request/LocaleSourceImpl.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,31 @@ +package hirondelle.web4j.request; + +import java.util.Locale; +import javax.servlet.http.HttpServletRequest; + +import hirondelle.web4j.util.WebUtil; +import hirondelle.web4j.Controller; + +/** + Retrieve the {@link Locale} stored in any scope under the key + {@link hirondelle.web4j.Controller#LOCALE}. + +

Upon startup, the {@link hirondelle.web4j.Controller} will read in the DefaultLocale + configured in web.xml, and place it in application scope under the key + {@link hirondelle.web4j.Controller#LOCALE}, as a {@link Locale} object (not a {@link String}). + +

If desired, the application programmer can also store a user-specific + {@link Locale} in session scope, under the same key. Thus, + this class will first find the user-specific Locale, overriding the default + Locale stored in application scope. + +

If any other behavior is desired, then simply provide an alternate implementation of + {@link LocaleSource}. +*/ +public class LocaleSourceImpl implements LocaleSource { + + /** See class comment. */ + public Locale get(HttpServletRequest aRequest){ + return (Locale)WebUtil.findAttribute(Controller.LOCALE, aRequest); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/request/RequestParameter.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/request/RequestParameter.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,269 @@ +package hirondelle.web4j.request; + +import hirondelle.web4j.action.Action; +import hirondelle.web4j.model.ModelCtorException; +import hirondelle.web4j.model.ModelFromRequest; +import hirondelle.web4j.model.ModelUtil; +import hirondelle.web4j.readconfig.Config; +import hirondelle.web4j.security.ApplicationFirewall; +import hirondelle.web4j.util.Util; + +import java.util.regex.Pattern; + +/** + Request parameter as a name and + (usually) an associated regular expression. + +

This class does not directly provide access to the parameter value. + For such services, please see {@link RequestParser} and {@link ModelFromRequest}. + +

This class separates request parameters into two kinds : file upload request + parameters, and all others (here called "regular" request parameters). + +

Regular Request Parameters
+ Regular request parameters are associated with : +

    +
  • a name, corresponding to both an underlying HTTP request parameter name, and + an underlying control name - see naming convention. +
  • a regular expression, used by {@link ApplicationFirewall} to perform + hard validation +
+ +

File Upload Request Parameters
+ Files are uploaded using forms having : +

    +
  • method="POST" +
  • enctype="multipart/form-data" +
  • an <INPUT type="file"> control +
+ +

In addition, note that the Servlet API does not have extensive services for + processing file upload parameters. It is likely best to use a third party tool for + that task. + +

File upload request parameters, as represented by this class, have only a + name associated with them, and no regular expression. This is because WEB4J + cannot perform hard validation + on the value of a file upload parameter - since the user may select any file whatsoever, + validation of file contents can only be treated + as a soft validation. If there is a + problem, the response to the user must be polished, as part of the normal operation of + the application. + +

As an example, an {@link Action} might perform + soft validation on a file upload parameter + for these items : +

    +
  • file size does not exceed a maximum value +
  • MIME type matches a regular expression +
  • file name matches a regular expression +
  • text file content may be matched to a regular expression +
+ +

Naming Convention
+ Parameter names are usually not arbitrary in WEB4J. + Instead, a simple convention is used which allows for automated mapping between + request parameter names and corresponding getXXX methods of Model Objects + (see {@link hirondelle.web4j.ui.tag.Populate}). For example, a parameter + named 'Birth Date' (or 'birthDate') is mapped to a method named + getBirthDate() when prepopulating a form with the contents of + a Model Object. (The 'Birth Date' naming style is recommended, since it + has this advantage : when messages regarding form input are presented to the user, + the control name may be used directly, without trivial mapping + of a 'coder-friendly' parameter name into more user-friendly text.) + +

Some parameters - notably those passed to Template.jsp - are not + processed at all by the Controller, but are used directly in JSPs + instead. Such parameters do not undergo + hard validation by the + {@link hirondelle.web4j.security.ApplicationFirewall}, and are not represented by this class. + +

See {@link java.util.regex.Pattern} for more information on regular expressions. +*/ +public final class RequestParameter { + + /** + Return a regular parameter hard-validated only for + name and size. + +

The size is taken from the MaxRequestParamValueSize setting in web.xml. + + @param aName name of the underlying HTTP request parameter. See + naming convention. + */ + public static RequestParameter withLengthCheck(String aName){ + Config config = new Config(); + String regex = "(.){0," + config.getMaxRequestParamValueSize() + "}"; + Pattern lengthPattern = Pattern.compile(regex, Pattern.DOTALL); + return withRegexCheck(aName, lengthPattern); + } + + /** + Return a regular parameter hard-validated for name and + for value matching a regular expression. + + @param aName name of the underlying HTTP request parameter. See + naming convention. + @param aValueRegex regular expression for doing hard validation of the request + parameter value(s). + */ + public static RequestParameter withRegexCheck(String aName, Pattern aValueRegex){ + return new RequestParameter(aName, aValueRegex); + } + + /** + Return a regular parameter hard-validated for name and + for value matching a regular expression. + + @param aName name of the underlying HTTP request parameter. See + naming convention. + @param aValueRegex regular expression for doing hard validation of the request + parameter value(s). + */ + public static RequestParameter withRegexCheck(String aName, String aValueRegex){ + return new RequestParameter(aName, aValueRegex); + } + + /** + Constructor for a file upload request parameter. + + @param aName name of the underlying HTTP request parameter. See + naming convention. + */ + public static RequestParameter forFileUpload(String aName){ + return new RequestParameter(aName); + } + + /** Return the request parameter name. */ + public String getName(){ + return fName; + } + + /** + Return the regular expression associated with this RequestParameter. + +

This regular expression is used to perform + hard validation of this parameter's value(s). + +

This method will return null only for file upload parameters. + */ + public Pattern getRegex(){ + return fRegex; + } + + /** + Return true only if {@link #forFileUpload} was used to build this object. + */ + public boolean isFileUploadParameter() { + return ! fIsRegularParameter; + } + + /** + Return true only if aRawParamValue satisfies the regular expression + {@link #getRegex()}, or if this is a file upload + request parameter. + +

Always represents a hard validation, not a + soft validation. + */ + public boolean isValidParamValue(String aRawParamValue){ + boolean result = false; + if ( isFileUploadParameter() ){ + result = true; + } + else { + result = Util.matches(fRegex, aRawParamValue); + } + return result; + } + + @Override public boolean equals(Object aThat){ + if (this == aThat) return true; + if ( !(aThat instanceof RequestParameter) ) return false; + RequestParameter that = (RequestParameter)aThat; + return ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields()); + } + + @Override public int hashCode(){ + return ModelUtil.hashCodeFor(getSignificantFields()); + } + + /** Intended for debugging only. */ + @Override public String toString() { + String result = null; + if ( isFileUploadParameter() ) { + result = "Name[File Upload]: " + fName; + } + else { + result = "Name:" + fName + " Regex:" + fRegex; + } + return result; + } + + // PRIVATE + private final String fName; + private Pattern fRegex; //Patterns are immutable + private final boolean fIsRegularParameter; + + /** + Constructor for a "regular" request parameter. + + @param aName name of the underlying HTTP request parameter. See + naming convention. + @param aRegex regular expression for doing hard validation of the request + parameter value(s). + */ + private RequestParameter(String aName, String aRegex) { + this(aName, Pattern.compile(aRegex)); + validateState(); + } + + /** + Constructor for a "regular" request parameter. + + @param aName name of the underlying HTTP request parameter. See + naming convention. + @param aRegex regular expression for doing hard validation of the request + parameter value(s). + */ + private RequestParameter(String aName, Pattern aRegex) { + fName = aName; + fRegex = aRegex; + fIsRegularParameter = true; + validateState(); + } + + /** + Constructor for a file upload request parameter. + + @param aName name of the underlying HTTP request parameter. See + naming convention. + */ + private RequestParameter(String aName) { + fName = aName; + fIsRegularParameter = false; + validateState(); + } + + private void validateState(){ + //use the model ctor exception only to gather errors together + //it is never actually thrown. + ModelCtorException ex = new ModelCtorException(); + if ( ! Util.textHasContent(fName) ){ + ex.add("Name must have content."); + } + if ( fIsRegularParameter && (fRegex == null || ! Util.textHasContent(fRegex.pattern()) ) ){ + ex.add("For regular request parameters, regex pattern must be present."); + } + if ( ! fIsRegularParameter && fRegex != null ){ + ex.add("For file upload parameters, regex pattern must be null."); + } + if ( ex.isNotEmpty() ) { + throw new IllegalArgumentException(ex.getMessages().toString()); + } + } + + private Object[] getSignificantFields(){ + return new Object[] {fName, fRegex}; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/request/RequestParser.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/request/RequestParser.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,272 @@ +package hirondelle.web4j.request; + +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.action.Action; +import hirondelle.web4j.model.AppException; +import hirondelle.web4j.model.BadRequestException; +import hirondelle.web4j.model.ConvertParam; +import hirondelle.web4j.model.ConvertParamError; +import hirondelle.web4j.model.Id; +import hirondelle.web4j.model.ModelCtorException; +import hirondelle.web4j.security.SafeText; +import hirondelle.web4j.util.Util; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + Abstract Base Class (ABC) for mapping a request to an {@link Action}. + +

See the {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. + +

Almost all concrete implementations of this Abstract Base Class will need to + implement only a single method - {@link #getWebAction()}. WEB4J provides a default implementation + {@link RequestParserImpl}. + +

The role of this class is to view the request at a higher level than the underlying + Servlet API. In particular, its services include : +

    +
  • mapping a request to an {@link Action} +
  • parsing request parameters into common "building block" objects (and Collections thereof), + such as {@link Date}, {@link BigDecimal} and so on, using the configured implementation of + {@link hirondelle.web4j.model.ConvertParam}. (The application programmer will usually use + {@link hirondelle.web4j.model.ModelFromRequest} to build Model Objects.) +
+ +

File upload parameters are not returned + by this class. Such parameters must be examined in an {@link Action}. The Servlet API + before version 3.0 of the specification has poor support for file upload parameters, + and use of a third party tool is recommended. + +

The various toXXX methods are offered as a convenience for accessing String + and String-like data. All such toXXX methods apply the filtering (and possible + preprocessing) performed by {@link hirondelle.web4j.model.ConvertParam}. +*/ +public abstract class RequestParser { + + /** + Return the configured concrete instance of this Abstract Base Class. +

See the {@link hirondelle.web4j.BuildImpl} for important information on how + this item is configured. + */ + public static RequestParser getInstance(HttpServletRequest aRequest, HttpServletResponse aResponse){ + List args = new ArrayList(); + args.add(aRequest); + args.add(aResponse); + RequestParser result = (RequestParser)BuildImpl.forAbstractionPassCtorArgs( + RequestParser.class.getName(), + args + ); + return result; + } + + /** Constructor called by subclasses. */ + public RequestParser(HttpServletRequest aRequest, HttpServletResponse aResponse){ + fRequest = aRequest; + fResponse = aResponse; + fLocale = BuildImpl.forLocaleSource().get(aRequest); + fTimeZone = BuildImpl.forTimeZoneSource().get(aRequest); + fConvertUserInput = BuildImpl.forConvertParam(); + fConversionError = BuildImpl.forConvertParamError(); + } + + /** + Map a given request to a corresponding {@link Action}. + +

The mapping is determined entirely by concrete subclasses, and must + be implemented by the application programmer. {@link RequestParserImpl} is + provided as a default implementation, and is very likely adequate for most + applications. + +

If the incoming request does not map to a known {@link Action}, then throw + a {@link BadRequestException}. Such requests + are expected only for bugs and for malicious attacks, and never as part of the normal operation + of the program. + */ + abstract public Action getWebAction() throws BadRequestException; + + /** + Return the parameter value exactly as it appears in the request. + +

Can return null values, empty values, values containing + only whitespace, and values equal to the IgnorableParamValue configured in web.xml. + */ + public final String getRawParamValue(RequestParameter aReqParam){ + String result = fRequest.getParameter(aReqParam.getName()); + return result; + } + + /** + Return a multi-valued parameter's values exactly as they appear in the request. + +

Can return null values, empty values, values containing + only whitespace, and values equal to the IgnorableParamValue configured in web.xml. + */ + public final String[] getRawParamValues(RequestParameter aReqParam){ + String[] result = fRequest.getParameterValues(aReqParam.getName()); + return result; + } + + /** + Return a building block object. + +

Uses all methods of the configured implementation of {@link ConvertParam}. + @param aReqParam underlying request parameter + @param aSupportedTargetClass must be supported - see {@link ConvertParam#isSupported(Class)} + */ + public T toSupportedObject(RequestParameter aReqParam, Class aSupportedTargetClass) throws ModelCtorException { + T result = null; + if( ! fConvertUserInput.isSupported(aSupportedTargetClass) ){ + throw new AssertionError("This class is not supported by ConvertParam: " + Util.quote(aSupportedTargetClass)); + } + String filteredValue = fConvertUserInput.filter(getRawParamValue(aReqParam)); + if( Util.textHasContent(filteredValue) ){ + try { + result = fConvertUserInput.convert(filteredValue, aSupportedTargetClass, fLocale, fTimeZone); + } + catch (ModelCtorException ex){ + ModelCtorException conversionEx = fConversionError.get(aSupportedTargetClass, filteredValue, aReqParam); + throw conversionEx; + } + } + return result; + } + + /** + Return an ummodifiable List of building block objects. + +

Uses all methods of the configured implementation of {@link ConvertParam}. +

+ Design Note
+ List is returned here since HTML specs state that browsers submit param values + in the order of appearance of the corresponding controls in the web page. + @param aReqParam underlying request parameter + @param aSupportedTargetClass must be supported - see {@link ConvertParam#isSupported(Class)} + */ + public List toSupportedObjects(RequestParameter aReqParam, Class aSupportedTargetClass) throws ModelCtorException { + List result = new ArrayList(); + ModelCtorException conversionExceptions = new ModelCtorException(); + if( ! fConvertUserInput.isSupported(aSupportedTargetClass) ){ + throw new AssertionError("This class is not supported by ConvertParam: " + Util.quote(aSupportedTargetClass)); + } + String[] rawValues = getRawParamValues(aReqParam); + if(rawValues != null){ + for(String rawValue: rawValues){ + String filteredValue = fConvertUserInput.filter(rawValue); //possibly null + //is it possible to have a multi-valued boolean param??? + if ( Util.textHasContent(filteredValue) || Boolean.class == aSupportedTargetClass){ + try { + T convertedItem = fConvertUserInput.convert(filteredValue, aSupportedTargetClass, fLocale, fTimeZone); + result.add(convertedItem); + } + catch (ModelCtorException ex){ + AppException conversionEx = fConversionError.get(aSupportedTargetClass, filteredValue, aReqParam); + conversionExceptions.add(conversionEx); + } + } + else { + result.add(null); + } + } + if (conversionExceptions.isNotEmpty()) throw conversionExceptions; + } + return Collections.unmodifiableList(result); + } + + /** Return a single-valued request parameter as {@link SafeText}. */ + public final SafeText toSafeText(RequestParameter aReqParam) { + SafeText result = null; + try { + result = toSupportedObject(aReqParam, SafeText.class); + } + catch (ModelCtorException ex){ + changeToRuntimeException(ex); + } + return result; + } + + /** Return a multi-valued request parameter as a {@code Collection}. */ + public final Collection toSafeTexts(RequestParameter aReqParam) { + Collection result = null; + try { + result = toSupportedObjects(aReqParam, SafeText.class); + } + catch (ModelCtorException ex){ + changeToRuntimeException(ex); + } + return result; + } + + /** Return a single-valued request parameter as an {@link Id}. */ + public final Id toId(RequestParameter aReqParam) { + Id result = null; + try { + result = toSupportedObject(aReqParam, Id.class); + } + catch(ModelCtorException ex){ + changeToRuntimeException(ex); + } + return result; + } + + /** Return a multi-valued request parameter as a {@code Collection}. */ + public final Collection toIds(RequestParameter aReqParam) { + Collection result = null; + try { + result = toSupportedObjects(aReqParam, Id.class); + } + catch (ModelCtorException ex){ + changeToRuntimeException(ex); + } + return result; + } + + /** Return the underlying request. */ + public final HttpServletRequest getRequest(){ + return fRequest; + } + + /** Return the response associated with the underlying request. */ + public final HttpServletResponse getResponse(){ + return fResponse; + } + + /** + Return true only if the request is a POST, and has + content type starting with multipart/form-data. + */ + public final boolean isFileUploadRequest(){ + return + fRequest.getMethod().equalsIgnoreCase("POST") && + fRequest.getContentType().startsWith("multipart/form-data") + ; + } + + // PRIVATE // + private final HttpServletRequest fRequest; + private final HttpServletResponse fResponse; + private final Locale fLocale; + private final TimeZone fTimeZone; + private final ConvertParam fConvertUserInput; + private final ConvertParamError fConversionError; + + /** + Change from a checked to an unchecked ex. + +

This is unusual, and a bit ugly. For stringy data, there isn't any possibility of a + parse error. Requiring Action constructors to catch or throw a ModelCtorEx is distasteful + (this would happen for items that have an Operation built in the constructor.) + */ + private void changeToRuntimeException(ModelCtorException ex){ + throw new IllegalArgumentException(ex); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/request/RequestParserImpl.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/request/RequestParserImpl.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,294 @@ +package hirondelle.web4j.request; + +import static hirondelle.web4j.util.Consts.NOT_FOUND; +import hirondelle.web4j.action.Action; +import hirondelle.web4j.model.AppException; +import hirondelle.web4j.readconfig.Config; +import hirondelle.web4j.readconfig.ConfigReader; +import hirondelle.web4j.util.Util; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Modifier; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; + +import javax.servlet.ServletConfig; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + Maps each HTTP request to a concrete {@link Action}. + +

Default implementation of {@link RequestParser}. + +

This implementation extracts the URI Mapping String from the + underlying request, and maps it to a specific {@link Action} class, and calls its constructor by passing + a {@link RequestParser}. (Here, each {@link Action} must have a public constructor + which takes a {@link RequestParser} as its single parameter.) + +

There are two kinds of mapping available : +

+ +

URI Mapping String

+ The 'URI Mapping String' is extracted from the underlying request. It is simply the concatention of + {@link HttpServletRequest#getServletPath()} and {@link HttpServletRequest#getPathInfo()} + (minus the extension - .do, for example). + +

(The servlet path is the part of the URI which has been mapped to a servlet by the servlet-mapping + entries in the web.xml.) + +

Implicit Mapping

+ If no explicit mapping exists in an Action, then it will implicitly + map to the URI Mapping String that corresponds to a modified version of its + package-qualified name : +
    +
  • take the package-qualified class name +
  • change '.' characters to '/' +
  • remove the base package prefix, configured in web.xml as ImplicitMappingRemoveBasePackage +
+ +

Example of an implicit mapping : + + + + +
Class Name:hirondelle.fish.main.member.MemberEdit
ImplicitMappingRemoveBasePackage (web.xml):hirondelle.fish
Implicit Mapping calculated as:/main/member/MemberEdit
+ +

Which maps to the following requests: + +

+ + + +
Request 1:http://www.blah.com/fish/main/member/MemberEdit.list
Request 2:http://www.blah.com/fish/main/member/MemberEdit.do?Operation=List
URI Mapping String calculated as:/main/member/MemberEdit
+ +

Explicit Mapping

+ An Action may declare an explicit mapping to a URI Mapping String + simply by declaring a field of the form (for example) : +
+  public static final String EXPLICIT_URI_MAPPING = "/translate/basetext/BaseTextEdit";
+ 
+ Explicit mappings override implicit mappings. + +

Fine-Grained Security

+ Fine-grained security allows <security-constraint> items to be specifed for various extensions, + where the extensions represent various action verbs, such as .list, .change, and so on. + In that case, the conventional .do is replaced with several different extensions. + See the User Guide for more information on fine-grained security. + +

Looking Up Action, Given URI

+ It's a common requirement to look up an action class, given a URI. Various sources + can be used to perform that task: +
    +
  • the application's javadoc listing of Constant Field Values can be + quickly searched for an explicit EXPLICIT_URI_MAPPING +
  • all mappings are logged upon startup at CONFIG level +
  • the source code itself can be searched, if necessary +
+*/ +public class RequestParserImpl extends RequestParser { + + /** + Scan for {@link Action} mappings. Called by the framework upon startup. Scans for all classes + that implement {@link Action}. Stores either an implicit + or an explicit mapping. Implicit mappings are the recommended style. + +

If a problem with mapping is detected, then a {@link RuntimeException} is thrown, and + the application will not load. This protects the application, by forcing some important + errors to occur during startup, instead of during normal operation. Possible errors include : +

    +
  • the EXPLICIT_URI_MAPPING field is not a public static final String +
  • the same mapping is used for more than one {@link Action} +
+ */ + public static void initWebActionMappings(){ + scanMappings(); + fLogger.config("URI Mappings : " + Util.logOnePerLine(fUriToActionMapping)); + } + + /** + Constructor. + + @param aRequest passed to the super class. + @param aResponse passed to the super class. + */ + public RequestParserImpl(HttpServletRequest aRequest, HttpServletResponse aResponse) { + super(aRequest, aResponse); + if (aRequest.getPathInfo() != null){ + fURIMappingString = aRequest.getServletPath() + aRequest.getPathInfo(); + } + else { + fURIMappingString = aRequest.getServletPath(); + } + fLogger.fine("*** ________________________ NEW REQUEST _________________"); + fURIMappingString = removeExtension(fURIMappingString); + fLogger.fine("URL Mapping String: " + fURIMappingString); + } + + /** + Map an HTTP request to a concrete implementation of {@link Action}. + +

Extract the URI Mapping String from the underlying request, and + map it to an {@link Action}. + */ + @Override public final Action getWebAction() { + Action result = null; + AppException problem = new AppException(); + Class webAction = fUriToActionMapping.get(fURIMappingString); + if ( webAction == null ) { + throw new RuntimeException("Cannot map URI to an Action class : " + Util.quote(fURIMappingString)); + } + + Class[] ctorArgs = {RequestParser.class}; + try { + Constructor ctor = webAction.getConstructor(ctorArgs); + result = (Action)ctor.newInstance(new Object[]{this}); + } + catch(NoSuchMethodException ex){ + problem.add("Action does not have public constructor having single argument of type 'RequestParser'."); + } + catch(InstantiationException ex){ + problem.add("Cannot call Action constructor using reflection (class is abstract). " + ex); + } + catch(IllegalAccessException ex){ + problem.add("Cannot call Action constructor using reflection (constructor not public). " + ex); + } + catch(IllegalArgumentException ex){ + problem.add("Cannot call Action constructor using reflection. " + ex); + } + catch(InvocationTargetException ex){ + String message = ex.getCause() == null ? ex.toString() : ex.getCause().getMessage(); + problem.add("Cannot call Action constructor using reflection (constructor threw exception). " + message); + } + + if( problem.isNotEmpty() ){ + throw new RuntimeException("Problem constructing Action for URI " + Util.quote(fURIMappingString) + " " + Util.logOnePerLine(problem.getMessages())); + } + fLogger.info("URI " + Util.quote(fURIMappingString) + " successfully mapped to an instance of " + webAction); + + return result; + } + + /** + Return the String configured in web.xml as being the + base or root package that is to be ignored by the default Action mapping mechanism. + + See web.xml for more information. + */ + public static final String getImplicitMappingRemoveBasePackage(){ + //why is this method public? + return new Config().getImplicitMappingRemoveBasePackage(); + } + + // PRIVATE + + /** + Portion of the complete URL, which contains sufficient information to + to decide which {@link Action} is to be returned. + */ + private String fURIMappingString; + + /** + Conventional field name used in {@link Action} classes. + */ + private static final String EXPLICIT_URI_MAPPING = "EXPLICIT_URI_MAPPING"; + + /** + Maps URIs to implementations of {@link Action}. + +

Key - String, taken from public static final field named {@link #EXPLICIT_URI_MAPPING}. +
Value - Class for the {@link Action} having a EXPLICIT_URI_MAPPING field + of that given value. + +

At runtime, the request is inspected, and the corresponding {@link Action} is + created, using a constructor of a specific signature. + */ + private static final Map> fUriToActionMapping = new LinkedHashMap>(); + + private static final Logger fLogger = Util.getLogger(RequestParserImpl.class); + + private static void scanMappings(){ + fUriToActionMapping.clear(); //needed for reloading application : reloading app does not reload this class. + Set> actionClasses = ConfigReader.fetchConcreteClassesThatImplement(Action.class); + AppException problems = new AppException(); + for(Class actionClass: actionClasses){ + Field explicitMappingField = null; + try { + explicitMappingField = actionClass.getField(EXPLICIT_URI_MAPPING); + } + catch (NoSuchFieldException ex){ + addMapping(actionClass, getImplicitURI(actionClass), problems); + continue; + } + addExplicitMapping(actionClass, explicitMappingField, problems); + } + //ensure that any problems will cause a failure to startup + //thus, runtime exception are replaced with startup time exceptions + if ( problems.isNotEmpty() ) { + throw new RuntimeException("Problem(s) occurred while creating mapping of URIs to WebActions. " + Util.logOnePerLine(problems.getMessages())); + } + } + + private static void addExplicitMapping(Class aActionClass, Field aExplicitMappingField, AppException aProblems) { + int modifiers = aExplicitMappingField.getModifiers(); + if ( Modifier.isPublic(modifiers) && Modifier.isStatic(modifiers) && Modifier.isFinal(modifiers) ) { + try { + Object fieldValue = aExplicitMappingField.get(null); + if ( ! (fieldValue instanceof String) ){ + aProblems.add("Value for for " + EXPLICIT_URI_MAPPING + " field is not a String."); + } + addMapping(aActionClass, fieldValue.toString(), aProblems); + } + catch(IllegalAccessException ex){ + aProblems.add("Action " + aActionClass + ": cannot get value of field " + aExplicitMappingField); + } + } + else { + aProblems.add("Action " + aActionClass + ": field is not public static final : " + aExplicitMappingField); + } + } + + private static void addMapping(Class aClass, String aURI, AppException aProblems) { + if( ! fUriToActionMapping.containsKey(aURI) ){ + fUriToActionMapping.put(aURI, aClass); + } + else { + aProblems.add("Action " + aClass + ": mapping for URI " + aURI + " already in use by " + fUriToActionMapping.get(aURI)); + } + } + + private static String getImplicitURI(Class aActionClass){ + String result = aActionClass.getName(); //eg: com.blah.module.Whatever + + String prefix = getImplicitMappingRemoveBasePackage(); //com.blah + if( ! Util.textHasContent(prefix) ){ + throw new RuntimeException("Init-param ImplicitMappingRemoveBasePackage must have content. See web.xml."); + } + if( prefix.endsWith(".")){ + throw new RuntimeException("Init-param ImplicitMappingRemoveBasePackage must not include a trailing dot : " + Util.quote(prefix) + ". See web.xml."); + } + if ( ! result.startsWith(prefix) ){ + throw new RuntimeException("Class named " + Util.quote(aActionClass.getName()) + " does not start with expected base package " + Util.quote(prefix) + " See ImplicitMappingRemoveBasePackage in web.xml."); + } + + result = result.replace('.','/'); // com/blah/module/Whatever + result = result.substring(prefix.length()); // /module/Whatever + fLogger.finest("Implicit mapping for " + Util.quote(aActionClass) + " is : " + Util.quote(result)); + return result; + } + + private String removeExtension(String aURI){ + int firstPeriod = aURI.indexOf("."); + if ( firstPeriod == NOT_FOUND ) { + fLogger.severe("Cannot find extension for " + Util.quote(aURI)); + } + return aURI.substring(0,firstPeriod); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/request/TimeZoneSource.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/request/TimeZoneSource.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,82 @@ +package hirondelle.web4j.request; + +import hirondelle.web4j.action.ActionImpl; +import java.util.TimeZone; +import javax.servlet.http.HttpServletRequest; + +/** + Return the {@link TimeZone} associated with a given request. + +

See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. + {@link hirondelle.web4j.BuildImpl#forTimeZoneSource()} + returns the configured implementation of this interface. See {@link TimeZoneSourceImpl} for a default implementation. + This interface is similar to {@link LocaleSource}, and is used in much the same way. + +

In general, a {@link TimeZone} is used for two distinct operations : +

    +
  • render {@link java.util.Date} objects +
  • parse user input into a Date object +
+ +

By default, a JRE will perform such operations using the implicit value returned by + {@link TimeZone#getDefault()}. The main reason for defining this interface is to + provide an alternative to this mechanism, since it is inappropriate for most server applications. + +

For your Actions, the fastest way to access the time zone is usually via {@link ActionImpl#getTimeZone()}. + +

The TimeZone returned by this interface is used by WEB4J for : +

    +
  • user response messages containing dates +
  • presenting ResultSets as reports with {@link hirondelle.web4j.database.Report} +
  • displaying dates with {@link hirondelle.web4j.ui.tag.ShowDate} +
  • populating forms +
  • parsing form entries +
+ +

A very large number of policies can be defined by implementations of this interface. + Possible sources of TimeZone information include : +

    +
  • a single setting in web.xml, place into application scope upon startup +
  • an object stored in session scope +
  • a request parameter +
  • a request header +
  • a cookie +
+ +

Java versus Databases

+

Java always represents dates internally using the number of milliseconds from its epoch. In Java, a + {@link java.util.Date} is always an unambiguous instant. When parsing and formatting dates, it will always use + a {@link TimeZone} (either implicity or explicitly). On the other hand, it is often that the case that + a database column storing a date does not store dates internally in an unambiguous way. For example, + many dates are stored as just '05-31-2007 06:00', for example, without any time zone information. + +

If that is the case, then there is a mismatch : constructing a {@link java.util.Date} out of many + database columns will require a {@link TimeZone} to be specified, either explicitly or implicitly. + See {@link java.sql.ResultSet#getDate(int, java.util.Calendar)}, + {@link java.sql.PreparedStatement#setDate(int, java.sql.Date, java.util.Calendar)}, and related methods. + +

The storage of dates in a database is not handled by this interface. That is + treated as a separate issue. + +

web.xml

+ There are two settings related to time zones in web.xml. The two settings correspond to two + distinct ideas : the time zone appropriate for dates presented to the end user, and the time zone in + which the date is stored. + +

The DefaultUserTimeZone setting is used by {@link TimeZoneSourceImpl}. + For applications that use only a single time zone, then this setting is used to specify that time + zone. It provides independence of the default JRE time zone, which will vary according to the server location. + For applications that use more than one time zone, then this same setting can be reinterpreted as the + default time zone, which can be overridden by implementations of this interface. + +

The TimeZoneHint setting is used by the WEB4J data layer to indicate the time zone in + which a date should be stored. If specified, this setting is communicated to the underlying + database driver using {@link java.sql.PreparedStatement#setTimestamp(int, java.sql.Timestamp, java.util.Calendar)} + and {@link java.sql.ResultSet#getTimestamp(int, java.util.Calendar)}. +*/ +public interface TimeZoneSource { + + /** Return a {@link TimeZone} corresponding to a given underlying request. */ + public TimeZone get(HttpServletRequest aRequest); + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/request/TimeZoneSourceImpl.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/request/TimeZoneSourceImpl.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,32 @@ +package hirondelle.web4j.request; + +import java.util.TimeZone; +import javax.servlet.http.HttpServletRequest; + +import hirondelle.web4j.util.WebUtil; +import hirondelle.web4j.Controller; + +/** + Retrieve the {@link TimeZone} stored in any scope under the key + {@link hirondelle.web4j.Controller#TIME_ZONE}. + +

Upon startup, the {@link hirondelle.web4j.Controller} will read in the DefaultUserTimeZone + configured in web.xml, and place it in application scope under the key + {@link hirondelle.web4j.Controller#TIME_ZONE}, as a {@link TimeZone} object. + +

If desired, the application programmer can also store a user-specific + {@link TimeZone} in session scope, under the same key. Thus, + this class will first find the user-specific TimeZone, overriding the default + TimeZone stored in application scope. + +

If any other behavior is desired, then simply provide an alternate implementation of + {@link TimeZoneSource}. +*/ +public final class TimeZoneSourceImpl implements TimeZoneSource { + + /** See class comment. */ + public TimeZone get(HttpServletRequest aRequest){ + return (TimeZone)WebUtil.findAttribute(Controller.TIME_ZONE, aRequest); + } + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/request/package.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/request/package.html Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,19 @@ + + + + + + + User Interface + + +Items related to the underlying HTTP request. + +

Mapping of incoming requests to an {@link hirondelle.web4j.action.Action} is done by +{@link hirondelle.web4j.request.RequestParserImpl}. +By default, it uses a simple mapping scheme which requires no configuration. + +

The main tool for building full Model Objects out of submitted forms is {@link hirondelle.web4j.model.ModelFromRequest}. + + + diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/security/ApplicationFirewall.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/security/ApplicationFirewall.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,129 @@ +package hirondelle.web4j.security; + +import hirondelle.web4j.action.Action; +import hirondelle.web4j.model.BadRequestException; +import hirondelle.web4j.model.ConvertParamError; +import hirondelle.web4j.model.ModelFromRequest; +import hirondelle.web4j.request.RequestParser; + +/** + Perform hard validation on each incoming request. + +

See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. + {@link hirondelle.web4j.BuildImpl#forApplicationFirewall()} + returns the configured implementation of this interface. + +

The main intent of an application firewall is to defend against malicious attacks. As a side effect, + an application firewall will often detect obvious bugs during unit testing - usually unexpected request + parameters. + +

The Open Web Application Security Project + is a superb resource for learning about web application security. Implementors of this interface are + highly recommended to read and use its guidelines. + +

WEB4J divides validation tasks into hard validation and + soft validation. They are distinguished by : +

    +
  • when they are applied +
  • their behavior when they fail +
+ +

"Hard" Validation
+ Hard validation is applied earlier in processing. A failed hard validation + represents either a bug, or a malicious request (a "hack"). + When a hard validation fails, a curt and unpolished response can be sent. + Hard validations are performed by an ApplicationFirewall, ultimately called by the + {@link hirondelle.web4j.Controller}, as the first step in processing a request, + before any {@link Action} is executed. Items for hard validation might include : +

    +
  • overall request size +
  • parameter names +
  • parameter values (not business validations, however - see below) +
  • HTTP headers +
  • cookies +
+ +

For request parameters, hard validation must include only those checks whose failure + would constitute a bug or a malicious request. + +

"Soft" Validation
+ Soft validation is applied later in processing. + If a soft validation fails, resulting response pages use a polished presentation. + They are applied to items that are input directly by the user, for example the content + of a text input control. Invalid values are handled as part of the normal operation of + the program. Soft validations are "problem domain" validations, and are usually implemented in a + Model Object constructor. + +

To clarify, here are two examples, using the default implementation of + {@link ApplicationFirewallImpl}. + +

Example 1 +
A select control named Spin submits two fixed values, UP and + DOWN. Under normal operation of the program, no other values are expected. In this + case, the submitted request parameter should undergo these checks : + +

Hard validation - must be one of the two values UP or DOWN. + This is implemented by simply defining, in the + {@link Action} that handles this parameter, a single field : +

+public static final RequestParameter SPIN = RequestParameter.withRegexCheck("Spin", "(UP|DOWN)");
+
+ {@link ApplicationFirewallImpl} uses such fields to determine, for each {@link Action}, how to + do hard validation for request parameters. It checks permitted parameter names, and permitted parameter values + versus a regular expression. + +

Soft validation - none. In this case, the hard validation checks the parameter value completely, + so there is no further validation to be performed. + + +

Example 2 +
A text input control named Age accepts any text as input. That text should correspond to + an integer in the range 0..130. In this case, the validation is shared between + hard validation and soft validation : + +

Hard validation - can only make a basic sanity check. For instance, a check that the parameter value + is not an unreasonable size - under 5K, for instance. This is meant only to detect obvious hacks. It has + nothing to do with business logic. That is, this size check does not correspond to the maximum number of + characters expected (3), since failure of a hard validation produces a response which should not be seen by + the typical user during normal operation of the program. In this case, the field declared in the {@link Action} + is : +

+public static final RequestParameter AGE = RequestParameter.withLengthCheck("Age");
+
+ (The actual maximum length is set in web.xml.) + +

Soft validation #1 - first, make sure the user input can be translated into an {@link Integer}. This is a very + common task, and is implemented by {@link RequestParser}, using its various toXXX methods (and, + at a higher lever, by {@link ModelFromRequest}). When user input cannot be parsed into + an {@link Integer}, then an error message is displayed to the user. See {@link ConvertParamError}. + +

Soft validation #2 - make sure the {@link Integer} returned by the previous validation is in the + range 0..150. This is an example of a typical business validation. These are usually implemented + in the constructor of a Model Object. Again, if a problem is detected, then an error message + is displayed to to the user. + +

{@link hirondelle.web4j.model.Check} and {@link hirondelle.web4j.model.Validator} are provided to help you + implement soft validations. +*/ +public interface ApplicationFirewall { + + /** + Perform hard validation on each HTTP request. + +

If a problem is detected, then a {@link BadRequestException} is thrown, indicating the + standard HTTP status code, as defined in {@link javax.servlet.http.HttpServletRequest}. + (An error message may also be included, if desired.) + The response will then be sent immediately, without further processing, using + {@link javax.servlet.http.HttpServletResponse#sendError(int)}, or + {@link javax.servlet.http.HttpServletResponse#sendError(int, java.lang.String)} if + {@link BadRequestException#getErrorMessage()} has content. + + @param aAction corresponding to this request. If the underlying request is unknown + to {@link RequestParser#getWebAction()}, then that method will throw a + {@link BadRequestException}, and this method will not be called. + @param aRequestParser provides the raw underlying request, through + {@link RequestParser#getRequest()}; + */ + void doHardValidation(Action aAction, RequestParser aRequestParser) throws BadRequestException; + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/security/ApplicationFirewallImpl.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/security/ApplicationFirewallImpl.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,379 @@ +package hirondelle.web4j.security; + +import static hirondelle.web4j.util.Consts.FAILS; +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.action.Action; +import hirondelle.web4j.action.Operation; +import hirondelle.web4j.model.BadRequestException; +import hirondelle.web4j.model.Id; +import hirondelle.web4j.readconfig.Config; +import hirondelle.web4j.readconfig.ConfigReader; +import hirondelle.web4j.request.RequestParameter; +import hirondelle.web4j.request.RequestParser; +import hirondelle.web4j.util.Util; + +import java.util.Collection; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; + +import javax.servlet.ServletConfig; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +/** + Default implementation of {@link ApplicationFirewall}. + +

Upon startup, this class will inspect all {@link Action}s in the application. + All public static final {@link hirondelle.web4j.request.RequestParameter} fields accessible + to each {@link Action} will be collected, and treated here as the set of acceptable + {@link RequestParameter}s for each {@link Action} class. Thus, when this class is used to implement + {@link ApplicationFirewall}, each {@link Action} must declare all expected request + parameters as a public static final {@link RequestParameter} field, in order to pass hard validation. + +

File Upload Forms

+ If a POSTed request includes one or more file upload controls, then the underlying HTTP request has + a completely different structure from a regular request having no file upload controls. + Unfortunately, the Servlet API has very poor support for forms that include a file upload control: only the raw underlying request is available, in an unparsed form. + For such forms, POSTed data is not available in the usual way, and by default request.getParameter(String) will return null - + not only for the file upload control, but for all controls in the form. + +

An elegant way around this problem involves wrapping the request, + using {@link javax.servlet.http.HttpServletRequestWrapper}, such that POSTed data is parsed and made + available through the usual request methods. + If such a wrapper is used, then file upload forms can be handled in much the same way as any other form. + +

To indicate to this class if such a wrapper is being used for file upload requests, use the FullyValidateFileUploads setting + in web.xml. + +

Settings in web.xml affecting this class : +

    +
  • MaxHttpRequestSize +
  • MaxFileUploadRequestSize +
  • MaxRequestParamValueSize (used by {@link hirondelle.web4j.request.RequestParameter}) +
  • SpamDetectionInFirewall +
  • FullyValidateFileUploads +
+ +

The above settings control the validations performed by this class : + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CheckRegularFile Upload (Wrapped)File Upload
Overall request size <= MaxHttpRequestSize YNN
Overall request size <= MaxFileUploadRequestSize NYY
Every param name is among the {@link hirondelle.web4j.request.RequestParameter}s for that {@link Action}YY*N
Every param value satifies {@link hirondelle.web4j.request.RequestParameter#isValidParamValue(String)}YY**N
If created with {@link hirondelle.web4j.request.RequestParameter#withLengthCheck(String)}, then param value size <= MaxRequestParamValueSizeYY**N
If SpamDetectionInFirewall is on, then each param value is checked using the configured {@link hirondelle.web4j.security.SpamDetector}YY**N
If a request param named Operation exists and it returns true for {@link Operation#hasSideEffects()}, then the underlying request must be a POSTYYN
CSRF DefensesYYN
+ * For file upload controls, the param name is checked only if the return value of getParameterNames() (for the wrapper) includes it. +
**Except for file upload controls. For file upload controls, no checks on the param value are made by this class.
+ + +

Defending Against CSRF Attacks

+ If the usual WEB4J defenses against CSRF attacks are active (see package-level comments), + then for every POST request executed within a session the following will also be performed as a defense against CSRF attacks : +
    +
  • validate that a request parameter named + {@link hirondelle.web4j.security.CsrfFilter#FORM_SOURCE_ID_KEY} is present. (This + request parameter is deemed to be a special 'internal' parameter, and does not need to be explicitly declared in + your Action like other request parameters.) +
  • validate that its value matches items stored in session scope. First check versus an item stored + under the key {@link hirondelle.web4j.security.CsrfFilter#FORM_SOURCE_ID_KEY}; if that check fails, then + check versus an item stored under the key + {@link hirondelle.web4j.security.CsrfFilter#PREVIOUS_FORM_SOURCE_ID_KEY} +
+ + See {@link hirondelle.web4j.security.CsrfFilter} for more information. +*/ +public class ApplicationFirewallImpl implements ApplicationFirewall { + + /** Map actions to expected params. */ + public static void init(){ + fExpectedParams = ConfigReader.fetchPublicStaticFinalFields(Action.class, RequestParameter.class); + fLogger.config("Expected Request Parameters per Web Action." + Util.logOnePerLine(fExpectedParams)); + } + + /** + Perform checks on the incoming request. + +

See class description for more information. + +

Subclasses may extend this implementation, following the form : +

+  public void doHardValidation(Action aAction, RequestParser aRequestParser) throws BadRequestException {
+    super(aAction, aRequestParser);
+    //place additional validations here
+    //for example, one might check that a Content-Length header is present,
+    //or that all header values are within some size range
+  }
+   
+ */ + public void doHardValidation(Action aAction, RequestParser aRequestParser) throws BadRequestException { + if( aRequestParser.isFileUploadRequest() ){ + fLogger.fine("Validating a file upload request."); + } + checkForExtremeSize(aRequestParser); + if ( aRequestParser.isFileUploadRequest() && ! fConfig.getFullyValidateFileUploads() ) { + fLogger.fine("Unable to parse request in the usual way: file upload request is not wrapped. Cannot read parameter names and values. See FullyValidateFileUploads setting in web.xml."); + } + else { + checkParamNamesAndValues(aAction, aRequestParser); + checkSideEffectOperations(aAction, aRequestParser); + defendAgainstCSRFAttacks(aRequestParser); + } + } + + // PRIVATE + + private Config fConfig = new Config(); + + /** + Maps {@link Action} classes to a List of expected {@link hirondelle.web4j.request.RequestParameter} objects. + If the incoming request contains a request parameter whose name or value is not consistent with this + list, then a {@link hirondelle.web4j.model.BadRequestException} is thrown. + +

Key - class object +
Value - Set of RequestParameter objects; may be empty, but not null. + +

This is a mutable object field, but is not modified after startup, so this class is thread-safe. + */ + private static Map, Set> fExpectedParams = new LinkedHashMap, Set>(); + + /** Special, 'internal' request parameter, used by the framework to defend against CSRF attacks. */ + private static final RequestParameter fCSRF_REQ_PARAM = RequestParameter.withLengthCheck(CsrfFilter.FORM_SOURCE_ID_KEY); + + private static final String CURRENT_TOKEN_CSRF = CsrfFilter.FORM_SOURCE_ID_KEY; + private static final String PREVIOUS_TOKEN_CSRF = CsrfFilter.PREVIOUS_FORM_SOURCE_ID_KEY; + private static final Logger fLogger = Util.getLogger(ApplicationFirewallImpl.class); + + /** + Some denial-of-service attacks place large amounts of data in the request + params, in an attempt to overload the server. This method will check for + such requests. This check must be performed first, before any further + processing is attempted. + */ + private void checkForExtremeSize(RequestParser aRequest) throws BadRequestException { + fLogger.fine("Checking for extreme size."); + if ( isRequestExcessivelyLarge(aRequest) ) { + throw new BadRequestException(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE); + } + } + + private boolean isRequestExcessivelyLarge(RequestParser aRequestParser){ + boolean result = false; + if ( aRequestParser.isFileUploadRequest() ) { + result = aRequestParser.getRequest().getContentLength() > fConfig.getMaxFileUploadRequestSize(); + } + else { + result = aRequestParser.getRequest().getContentLength() > fConfig.getMaxHttpRequestSize(); + } + return result; + } + + void checkParamNamesAndValues(Action aAction, RequestParser aRequestParser) throws BadRequestException { + if ( fExpectedParams.containsKey(aAction.getClass()) ){ + Set expectedParams = fExpectedParams.get(aAction.getClass()); + //this method may return file upload controls - depends on interpretation, whether to include file upload controls in this method + Enumeration paramNames = aRequestParser.getRequest().getParameterNames(); + while ( paramNames.hasMoreElements() ){ + String incomingParamName = (String)paramNames.nextElement(); + fLogger.fine("Checking parameter named " + Util.quote(incomingParamName)); + RequestParameter knownParam = matchToKnownParam(incomingParamName, expectedParams); + if( knownParam == null ){ + fLogger.severe("*** Unknown Parameter *** : " + Util.quote(incomingParamName) + ". Please add public static final RequestParameter field for this item to your Action."); + throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST); + } + if ( knownParam.isFileUploadParameter() ) { + fLogger.fine("File Upload parameter - value not validatable here: " + knownParam.getName()); + continue; //prevents checks on values for file upload controls + } + Collection paramValues = aRequestParser.toSafeTexts(knownParam); + if( ! isInternalParam( knownParam) ) { + checkParamValues(knownParam, paramValues); + } + } + } + else { + String message = "Action " + aAction.getClass() + " not known to ApplicationFirewallImpl."; + fLogger.severe(message); + //this is NOT a BadRequestEx, since not outside the control of this framework. + throw new RuntimeException(message); + } + } + + /** If no match is found, return null. Matches to both regular and 'internal' request params. */ + private RequestParameter matchToKnownParam(String aIncomingParamName, Collection aExpectedParams){ + RequestParameter result = null; + for (RequestParameter reqParam: aExpectedParams){ + if ( reqParam.getName().equals(aIncomingParamName) ){ + result = reqParam; + break; + } + } + if( result == null && fCSRF_REQ_PARAM.getName().equals(aIncomingParamName) ){ + result = fCSRF_REQ_PARAM; + } + return result; + } + + private void checkParamValues(RequestParameter aKnownReqParam, Collection aParamValues) throws BadRequestException { + for(SafeText paramValue: aParamValues){ + if ( Util.textHasContent(paramValue) ) { + if ( ! aKnownReqParam.isValidParamValue(paramValue.getRawString()) ) { + fLogger.severe("Request parameter named " + aKnownReqParam.getName() + " has an invalid value. Its size is: " + paramValue.getRawString().length()); + throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST); + } + if( fConfig.getSpamDetectionInFirewall() ){ + SpamDetector spamDetector = BuildImpl.forSpamDetector(); + if( spamDetector.isSpam(paramValue.getRawString()) ){ + fLogger.fine("SPAM detected."); + throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST); + } + } + } + } + } + + private void checkSideEffectOperations(Action aAction, RequestParser aRequestParser) throws BadRequestException { + fLogger.fine("Checking for side-effect operations."); + Set expectedParams = fExpectedParams.get(aAction.getClass()); + for (RequestParameter reqParam : expectedParams){ + if ( "Operation".equals(reqParam.getName()) ){ + String rawValue = aRequestParser.getRawParamValue(reqParam); + if (Util.textHasContent(rawValue)){ + Operation operation = Operation.valueOf(rawValue); + if ( isAttemptingSideEffectOperationWithoutPOST(operation, aRequestParser) ){ + fLogger.severe("Security problem. Attempted operation having side effects outside of a POST. Please use a

with method='POST'."); + throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST); + } + } + } + } + } + + private boolean isAttemptingSideEffectOperationWithoutPOST(Operation aOperation, RequestParser aRequestParser){ + return aOperation.hasSideEffects() && !aRequestParser.getRequest().getMethod().equals("POST"); + } + + /** + An internal request param is not declared explicitly by the application programmer. Rather, it is defined and + used only by the framework. + */ + private boolean isInternalParam(RequestParameter aRequestParam) { + return aRequestParam.getName().equals(fCSRF_REQ_PARAM.getName()); + } + + private void defendAgainstCSRFAttacks(RequestParser aRequestParser) throws BadRequestException { + if( requestNeedsDefendingAgainstCSRFAttacks(aRequestParser) ) { + Id postedTokenValue = aRequestParser.toId(fCSRF_REQ_PARAM); + if ( FAILS == toIncludeCsrfTokenWithForm(postedTokenValue) ){ + fLogger.severe("CSRF token not included in POSTed request. Rejecting this request, since it is likely an attack."); + throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST); + } + + if( FAILS == matchCurrentCSRFToken(aRequestParser, postedTokenValue) ) { + if( FAILS == matchPreviousCSRFToken(aRequestParser, postedTokenValue) ) { + fLogger.severe("CSRF token does not match the expected value. Rejecting this request, since it is likely an attack."); + throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST); + } + } + fLogger.fine("Success: no CSRF problem detected."); + } + } + + private boolean requestNeedsDefendingAgainstCSRFAttacks(RequestParser aRequestParser){ + boolean isPOST = aRequestParser.getRequest().getMethod().equalsIgnoreCase("POST"); + boolean sessionPresent = isSessionPresent(aRequestParser); + boolean csrfFilterIsTurnedOn = false; + if( sessionPresent ) { + Id csrfTokenInSession = getCsrfTokenInSession(CURRENT_TOKEN_CSRF, aRequestParser); + csrfFilterIsTurnedOn = (csrfTokenInSession != null); + } + + if( isPOST && sessionPresent && ! csrfFilterIsTurnedOn ) { + fLogger.warning("POST operation, but no CSRF form token present in existing session. This application does not have WEB4J defenses against CSRF attacks configured in the recommended way."); + } + + boolean result = isPOST && sessionPresent && csrfFilterIsTurnedOn; + fLogger.fine("Session exists, and the CsrfFilter is turned on : " + csrfFilterIsTurnedOn); + fLogger.fine("Does the firewall need to check this request for CSRF attacks? : " + result); + return result; + } + + private boolean toIncludeCsrfTokenWithForm(Id aCsrfToken){ + return aCsrfToken != null; + } + + private boolean matchCurrentCSRFToken(RequestParser aRequestParser, Id aPostedTokenValue) { + Id currentToken = getCsrfTokenInSession(CURRENT_TOKEN_CSRF, aRequestParser); + return aPostedTokenValue.equals(currentToken); + } + + private boolean matchPreviousCSRFToken(RequestParser aRequestParser, Id aPostedTokenValue){ + //in the case of an anonymous session, with no login, this item will be null + Id previousToken = getCsrfTokenInSession(PREVIOUS_TOKEN_CSRF, aRequestParser); + return aPostedTokenValue.equals(previousToken); + } + + private boolean isSessionPresent(RequestParser aRequestParser){ + boolean DO_NOT_CREATE = false; + HttpSession session = aRequestParser.getRequest().getSession(DO_NOT_CREATE); + return session != null; + } + + /** Only called when session is present. No risk of null pointer exception. */ + private Id getCsrfTokenInSession(String aKey, RequestParser aRequestParser){ + boolean DO_NOT_CREATE = false; + HttpSession session = aRequestParser.getRequest().getSession(DO_NOT_CREATE); + return (Id)session.getAttribute(aKey); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/security/CsrfDAO.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/security/CsrfDAO.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,87 @@ +package hirondelle.web4j.security; + +import java.util.logging.Logger; +import javax.servlet.http.HttpSessionBindingEvent; +import javax.servlet.http.HttpSessionBindingListener; +import hirondelle.web4j.database.Db; +import hirondelle.web4j.database.SqlId; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.database.DAOException; +import hirondelle.web4j.model.Id; + +/** + Reads and writes the logged-in user's form-source id in the database. + +

Saves the users form-source id when the session ends. +*/ +final class CsrfDAO implements HttpSessionBindingListener { + + /** Read in the SqlIds needed to read and write the form-souce id. */ + static void init(String aReadSql, String aWriteSql){ + READ_SQL = aReadSql; + WRITE_SQL = aWriteSql; + } + + CsrfDAO(String aLoggedInUserName, Id aCurrentFormSourceId){ + fUserName = Id.from(aLoggedInUserName); + fCurrentSourceId = aCurrentFormSourceId; + } + + /** This implementation does nothing. */ + public void valueBound(HttpSessionBindingEvent aBindingEvent) { + //do nothing + } + + /** Save the user's current form-source id. */ + public void valueUnbound(HttpSessionBindingEvent aBindingEvent) { + saveCurrentFormSourceId(); + } + + /** + Return the form-source id for the user's immediately preceding session. + +

Returns null if there is no previous form-source id for the logged-in user. + */ + Id fetchPreviousFormSourceId() throws DAOException { + Id result = null; + fLogger.fine("Fetching previous form-source id for " + Util.quote(fUserName) + ", using SqlId " + Util.quote(READ_SQL)); + result = Db.fetchValue(Id.class, getReadSql(), fUserName); + if( result == null ) { + fLogger.fine("No previous form-source id found for this user."); + } + return result; + } + + // PRIVATE + + private static String READ_SQL; + private static String WRITE_SQL; + + /** Id is used since app may disallow using String. It's also more descriptive. */ + private final Id fUserName; + + /** Saved when session ends. */ + private final Id fCurrentSourceId; + + private static final Logger fLogger = Util.getLogger(CsrfFilter.class); + + /** Called only when a session is about to end. */ + private void saveCurrentFormSourceId() { + fLogger.finest("Saving current form-source id " + Util.quote(fCurrentSourceId) + ", for " + Util.quote(fUserName)); + fLogger.finest("Using SqlId " + Util.quote(getWriteSql())); + try { + Db.edit(getWriteSql(), fCurrentSourceId, fUserName); + } + catch (DAOException ex){ + fLogger.severe("Database problem encountered when attempting to save user's form-source id (when session ended)."); + } + } + + private SqlId getReadSql() { + return SqlId.fromStringId(READ_SQL); + } + + private SqlId getWriteSql() { + return SqlId.fromStringId(WRITE_SQL); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/security/CsrfFilter.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/security/CsrfFilter.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,334 @@ +package hirondelle.web4j.security; + +import hirondelle.web4j.Controller; +import hirondelle.web4j.database.DAOException; +import hirondelle.web4j.database.SqlId; +import hirondelle.web4j.model.Id; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.util.WebUtil; + +import java.io.CharArrayWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Random; +import java.util.logging.Logger; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletResponseWrapper; +import javax.servlet.http.HttpSession; + +/** + Protect your application from a + Cross Site Request Forgery (CSRF). + +

Please see the package overview for important information regarding CSRF attacks, and security in general. + +

This filter maintains various items needed to protect against CSRF attacks. It acts both as a + pre-processor and as a post-processor. The behavior of this class is controlled by detecting two important events: +

    +
  • the creation of new sessions (which does not necessarily imply a successful user login has also occured) +
  • a successful user login (which does imply a session has also been created) +
+ +

Pre-processing

+ When a new session is detected (but not necessarily a user login), then this class will do the following : +
    +
  • calculate a random form-source id, and place it in session scope, under the key {@link #FORM_SOURCE_ID_KEY}. + This value is difficult to guess. +
  • wrap the response in a custom wrapper, to implement the post-processing performed by this filter (see below) +
+ + In addition, if a new user login is detected, then this class will do the following : +
    +
  • if there is any 'old' form-source id, place it in session scope as well, under the + key {@link #PREVIOUS_FORM_SOURCE_ID_KEY}. The 'old' form-source id is simply the form-source id + used in the immediately preceding session for the same user. +
  • place in session scope an object which will store the form-source id when the session expires or is invalidated, under + the key {@link #FORM_SOURCE_DAO_KEY}. +
+ +

The above behavior of this class upon user login requires interaction with your database. + It's configured in web.xml using two items : + FormSourceIdRead and FormSourceIdWrite. These two items are + {@link hirondelle.web4j.database.SqlId} references. + They tell this class which SQL statements to use when reading and writing form-source ids + to the database. As usual, these {@link SqlId} items must be declared somewhere in your + application as public static final fields, and the corresponding SQL statements + must appear somewhere in an .sql file. + +

(Please see these items in the example application for an illustration : web.xml, + UserDAO, and csrf.sql.) + +

Post-processing

+ If a session is present, then this class will use a custom response wrapper to alter the response: +
    +
  • if the response has content-type of text/html (or null), then scan + the response for all {@code } tags with method='POST'. +
  • for each such {@code } tag, add a hidden parameter in the following style : +
    <input type='hidden' name='web4j_key_for_form_source_id' value='151jdk65654dasdf545sadf6a5s4f'>
    +
+ + The name of the hidden parameter is taken from {@link #FORM_SOURCE_ID_KEY}, + and the value of that hidden parameter is the random token created during the pre-processing stage. + +

ApplicationFirewall

+This class cooperates closely with {@link hirondelle.web4j.security.ApplicationFirewallImpl}. It is the +firewall which performs the actual test to make sure the POSTed form came from your web app. + +

Warning Regarding Error Pages

+ This Filter uses a wrapper for the response. When a Filter wraps the response, the error page + customization defined by web.xml will likely not function. + (This may be a defect of the Servlet API itself - see section 9.9.3.) That is, when an error occurs when using this + Filter, the generic error pages defined by the container may be served, instead of the custom + error pages you have configured in web.xml. + +

This filter will only affect the response if its content-type is text/html or null. + It will not affect any other type of response. +*/ +public class CsrfFilter implements Filter { + + /** + Key for item stored in session scope, and also name of hidden + request parameter added to POSTed forms. + +

Value - {@value}. +

The value of this item is generated randomly for each new user login, and contains a + simple token that is hard to guess. Each POSTed form will be required by {@link ApplicationFirewallImpl} + to include a hidden parameter of this name, and the value of such hidden parameters + are matched to the corresponding item stored in session scope under the same key. These checks verify that + POSTed forms have come from a trusted source. + */ + public static final String FORM_SOURCE_ID_KEY = "web4j_key_for_form_source_id"; + + /** + Key for item stored in session scope. + +

Value - {@value}. +

The value of this item is retrieved from the database for each new user login, and + represents the form-source id for the user's immediately preceding session. + When a match of form-source id against {@link #FORM_SOURCE_ID_KEY} fails, then a second + match is attempted against this item. + +

Please see the package description for an explanation of why this is necessary. + */ + public static final String PREVIOUS_FORM_SOURCE_ID_KEY = "web4j_key_for_previous_form_source_id"; + + /** + Key for item stored in session scope. + +

Value - {@value}. +

This item points to an {@link javax.servlet.http.HttpSessionBindingListener} object placed in each new session. + When the session ends, that object will be unbound from the session, and will save the user's current form-source id + to the database, for future use. + */ + public static final String FORM_SOURCE_DAO_KEY = "web4j_key_for_form_source_dao"; + + /** + Read in filter configuration. + +

Reads in {@link hirondelle.web4j.database.SqlId} references used to read and write the user's form-source id. +

See class comment and package-level description for further information. + */ + public void init(FilterConfig aFilterConfig) { + fLogger.config("INIT : " + this.getClass().getName() + ". Reading in SqlIds for reading and writing form-source ids."); + String read_sql = aFilterConfig.getInitParameter("FormSourceIdRead"); + String write_sql = aFilterConfig.getInitParameter("FormSourceIdWrite"); + checkValidSqlId(read_sql); + checkValidSqlId(write_sql); + CsrfDAO.init(read_sql, write_sql); + } + + /** This implementation does nothing. */ + public void destroy() { + fLogger.config("DESTROY : " + this.getClass().getName()); + } + + /** + Protect against CSRF attacks. + +

See class comment and package-level description for further information. + */ + public void doFilter(ServletRequest aRequest, ServletResponse aResponse, FilterChain aChain) throws IOException, ServletException { + fLogger.fine("START CSRF Filter."); + HttpServletRequest request = (HttpServletRequest)aRequest; + HttpServletResponse response = (HttpServletResponse)aResponse; + + addItemsForNewSessions(request); + + if(isServingHtml(response)){ + fLogger.fine("Serving html. Wrapping response."); + CharResponseWrapper wrapper = new CharResponseWrapper(response); + aChain.doFilter(aRequest, wrapper); //AppFirewall and BadRequest + + CharArrayWriter buffer = new CharArrayWriter(); + CsrfModifiedResponse modifiedResponse = new CsrfModifiedResponse(request, response); + String originalOutput = wrapper.toString(); + buffer.write(modifiedResponse.addNonceTo(originalOutput)); + String encoding = (String)WebUtil.findAttribute(Controller.CHARACTER_ENCODING, request); + aResponse.setContentLength(buffer.toString().getBytes(encoding).length); + + aResponse.getWriter().write(buffer.toString()); //this will use the response's encoding + aResponse.getWriter().close(); + } + else { + fLogger.fine("Not serving html. Not modifiying response."); + aChain.doFilter(aRequest, aResponse); //do nothing special + } + fLogger.fine("END CSRF Filter."); + } + + /** + Add a CSRF token to an existing session that has no user login. + +

This method is called only when a session created by an Action, instead of the usual login mechanism. + See {@link hirondelle.web4j.action.ActionImpl#createSessionAndCsrfToken()} for important information. + */ + public void addCsrfToken(HttpServletRequest aRequest) throws ServletException { + addItemsForNewSessions(aRequest); + } + + // PRIVATE + + //WARNING : Filters always need to be thread-safe !! + + private static final Logger fLogger = Util.getLogger(CsrfFilter.class); + private static final boolean DO_NOT_CREATE = false; + + private static void checkValidSqlId(String aSqlId){ + if ( ! Util.textHasContent(aSqlId) ) { + String message = "SqlId required as Filter init-param, but has no content: " + Util.quote(aSqlId); + fLogger.severe(message); + } + } + + private void addItemsForNewSessions(HttpServletRequest aRequest) throws ServletException { + HttpSession session = aRequest.getSession(DO_NOT_CREATE); + if ( sessionExists(session) ){ + if ( hasNoFormSourceIdInSession(session) ){ + Id currentFormSourceId = calcFormSourceId(); + addFormSourceIdToSession(session, currentFormSourceId); + if( userHasLoggedIn(aRequest) ){ + CsrfDAO formSourceDAO = new CsrfDAO(aRequest.getUserPrincipal().getName(), currentFormSourceId); + addPreviousFormSourceIdToSession(session, formSourceDAO); + addFormSourceDAOToSession(session, formSourceDAO); + } + } + } + } + + private boolean sessionExists(HttpSession aSession){ + return aSession != null; + } + + private boolean hasNoFormSourceIdInSession(HttpSession aSession){ + return aSession.getAttribute(FORM_SOURCE_ID_KEY) == null; + } + + private boolean userHasLoggedIn(HttpServletRequest aRequest){ + return aRequest.getUserPrincipal() != null; + } + + private void addFormSourceIdToSession(HttpSession aSession, Id aCurrentFormSourceId) { + fLogger.fine("Adding new form-source id to user's session."); + aSession.setAttribute(FORM_SOURCE_ID_KEY, aCurrentFormSourceId); + } + + private Id calcFormSourceId(){ + String token = getHashFor( getRandomNumber().toString() ); + return new Id(token); + } + + private void addPreviousFormSourceIdToSession(HttpSession aSession, CsrfDAO aDAO) throws ServletException { + fLogger.fine("Adding previous form-source id to session."); + try { + Id previousFormSourceId = aDAO.fetchPreviousFormSourceId(); + if( previousFormSourceId == null ) { + fLogger.fine("No previous form-source id found."); + } + else { + fLogger.fine("Adding previous form-source id to session."); + aSession.setAttribute(PREVIOUS_FORM_SOURCE_ID_KEY, previousFormSourceId); + } + } + catch (DAOException ex){ + throw new ServletException("Cannot fetch previous form-source id from database.", ex); + } + } + + private void addFormSourceDAOToSession(HttpSession aSession, CsrfDAO aDAO) { + fLogger.fine("Adding CsrfDAO object to session."); + aSession.setAttribute(FORM_SOURCE_DAO_KEY, aDAO); + } + + private synchronized Long getRandomNumber() { + Random random = new Random(); + return random.nextLong(); + } + + private String getHashFor(String aText) { + String result = null; + try { + MessageDigest sha = MessageDigest.getInstance("SHA-1"); + byte[] hashOne = sha.digest(aText.getBytes()); + result = hexEncode(hashOne); + } + catch (NoSuchAlgorithmException ex){ + String message = "MessageDigest cannot find SHA-1 algorithm."; + fLogger.severe(message); + throw new RuntimeException(message); + } + return result; + } + + /** + The byte[] returned by MessageDigest does not have a nice + textual representation, so some form of encoding is usually performed. + + This implementation follows the example of David Flanagan's book + "Java In A Nutshell", and converts a byte array into a String + of hex characters. + */ + static private String hexEncode( byte[] aInput){ + StringBuilder result = new StringBuilder(); + char[] digits = {'0', '1', '2', '3', '4','5','6','7','8','9','a','b','c','d','e','f'}; + for (int idx = 0; idx < aInput.length; ++idx) { + byte b = aInput[idx]; + result.append( digits[ (b&0xf0) >> 4 ] ); + result.append( digits[ b&0x0f] ); + } + return result.toString(); + } + + private static final String TEXT_HTML = "text/html"; + + /** Return true if content-type of reponse is null, or starts with 'text/html' (case-sensitive). */ + private boolean isServingHtml(HttpServletResponse aResponse){ + String contentType = aResponse.getContentType(); + boolean missingContentType = ! Util.textHasContent(contentType); + boolean startsWithHTML = Util.textHasContent(contentType) && contentType.startsWith(TEXT_HTML); + return missingContentType || startsWithHTML; + } + + private static final class CharResponseWrapper extends HttpServletResponseWrapper { + public String toString() { + return fOutput.toString(); + } + public CharResponseWrapper(HttpServletResponse response){ + super(response); + fOutput = new CharArrayWriter(); + } + public PrintWriter getWriter(){ + return new PrintWriter(fOutput); + } + private CharArrayWriter fOutput; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/security/CsrfModifiedResponse.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/security/CsrfModifiedResponse.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,116 @@ +package hirondelle.web4j.security; + +import hirondelle.web4j.model.Id; +import hirondelle.web4j.util.EscapeChars; +import hirondelle.web4j.util.Regex; +import hirondelle.web4j.util.Util; + +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +/** Add a nonce to POSTed forms in any 'text/html' response. */ +final class CsrfModifiedResponse { + + CsrfModifiedResponse(HttpServletRequest aRequest, HttpServletResponse aResponse){ + fResponse = aResponse; + fRequest = aRequest; + } + + String addNonceTo(String aUnmodifiedResponse){ + String result = aUnmodifiedResponse; + if(isServingHtml() && Util.textHasContent(aUnmodifiedResponse)) { + fLogger.fine("Adding nonce to forms having method=POST, if any."); + result = addHiddenParamToPostedForms(aUnmodifiedResponse); + } + return result; + } + + // PRIVATE + + private HttpServletRequest fRequest; + private HttpServletResponse fResponse; + + /** + Group 1 is the FORM start tag *plus the body*, and group 2 is the FORM end tag. + Note the reluctant qualifier for group 2, to ensure multiple forms are not glommed together. + */ + private static final String REGEX = + "(" + + Regex.ANY_CHARS + "?)" + + "()" + ; + private static final Pattern FORM_PATTERN = Pattern.compile(REGEX, Pattern.DOTALL | Pattern.CASE_INSENSITIVE); + + private static final String TEXT_HTML = "text/html"; + + /** + Problem: this class is package private. If we use the 'regular' logger, named for this class, then the output will not + show up. So, we use a logger attached to a closely related, public class. (Neat!) + */ + private static final Logger fLogger = Util.getLogger(CsrfFilter.class); + + /** Return true if content-type of reponse is null, or starts with 'text/html' (case-sensitive). */ + private boolean isServingHtml(){ + String contentType = fResponse.getContentType(); + boolean missingContentType = ! Util.textHasContent(contentType); + boolean startsWithHTML = Util.textHasContent(contentType) && contentType.startsWith(TEXT_HTML); + return missingContentType || startsWithHTML; + } + + private String addHiddenParamToPostedForms(String aOriginalInput) { + StringBuffer result = new StringBuffer(); + Matcher formMatcher = FORM_PATTERN.matcher(aOriginalInput); + while ( formMatcher.find() ){ + fLogger.fine("Found a POSTed form. Adding nonce."); + formMatcher.appendReplacement(result, getReplacement(formMatcher)); + } + formMatcher.appendTail(result); + return result.toString(); + } + + private String getReplacement(Matcher aMatcher){ + //escape, since '$' char may appear in input + return EscapeChars.forReplacementString(aMatcher.group(1) + getHiddenInputTag() + aMatcher.group(2)); + } + + private String getHiddenInputTag(){ + return ""; + } + + /** + Return the form-source id value, stored in the user's session. + If there is no session, or if there is no form-source id in the session, throw a RuntimeException. + */ + private Id getHiddenParamValue(){ + Id result = null; + boolean DO_NOT_CREATE = false; + HttpSession session = fRequest.getSession(DO_NOT_CREATE); + if ( session != null ) { + result = (Id)session.getAttribute(CsrfFilter.FORM_SOURCE_ID_KEY); + if( result == null ){ + String message = "Session exists, but no CSRF token value is stored in the session"; + fLogger.severe(message); + throw new RuntimeException(message); + } + } + else { + String message = + "No session exists! CsrfFilter can only work when a session is present, and the user has logged in. " + + "Ensure CsrfFilter is mapped (using url-pattern) only to URLs having mandatory login and/or a valid session." + ; + fLogger.severe(message); + throw new RuntimeException(message); + } + return result; + } + + /** Return the name of the hidden form parameter. */ + private String getHiddenParamName(){ + return CsrfFilter.FORM_SOURCE_ID_KEY; + } +} \ No newline at end of file diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/security/FetchIdentifierOwner.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/security/FetchIdentifierOwner.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,29 @@ +package hirondelle.web4j.security; + +import hirondelle.web4j.model.AppException; +import hirondelle.web4j.model.Id; + +/** + Look up the login name of the user who owns an + untrusted identifer. + +

See the User Guide + and {@link hirondelle.web4j.security.UntrustedProxyForUserId} for important information regarding + this interface. +*/ +public interface FetchIdentifierOwner { + + /** + Return the user login name of the user that 'owns' the untrusted proxy for the + user id used in the current request. If an owner cannot be found, then return null. + +

The meaning of the untrusted identifier depends on the context, and changes for each action/operation. + A typical implmentation will follow these steps: +

    +
  • use a request parameter value, whose value contains the untrusted identifier +
  • the value of the untrusted identifier is then passed to a SELECT statement, + which returns a single value - the owner's login name
+ */ + Id fetchOwner() throws AppException; + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/security/LoginTasks.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/security/LoginTasks.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,65 @@ +package hirondelle.web4j.security; + +import hirondelle.web4j.model.AppException; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; + +/** + Perform tasks required after successful user login. + +

See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. + {@link hirondelle.web4j.BuildImpl#forLoginTasks()} returns the configured implementation of this interface. + There is no default implementation of this interface. You must supply one. + (You can always supply an implmentation that does nothing, if you wish.) + +

This interface exists to allow an application to react to successful user logins. + At least one of those tasks must put an item in session scope. + The web4j Controller will use the presence/absence of that item to determine if the login tasks have already been performed. + +

Example tasks : +

  • place user preferences in session scope
  • place the user id in session scope; the id is different from the user login name (see important note below). + It's highly recommended that such an id be placed in session scope using {@link hirondelle.web4j.action.ActionImpl#USER_ID} as its key. + Doing so lets your Actions easily access the id using {@link hirondelle.web4j.action.ActionImpl#getUserId()}.
+ +

User Login Name Versus User Id

+ After a successful login, the container will always place the user's login name in session scope. + However, it's often convenient or desirable to have a corresponding user id in session scope as well. + In this context, the user id is a database identifier, the primary key of an underlying user record in the database (almost always an integer value). + Having the user id in session scope often helps to enforce data ownership constraints. + +

The User Id Is a Server Secret

+ The user login name and the user id must be treated very differently. + While the user login name can of course be displayed to the user, the user id is a server-side secret, and should never be made visible to the user. + It's nobody's business, including the user to whom it belongs. + You can place the user id in session scope, and your server-side code can use it, but its value can never be part of the HTTP response in any way. + Exposing it allows one user to access the data of another. + Always treat the user id as a server-side secret. Failure to do so is a huge security risk. + +

Consolidating User Preferences Into One Object

+ If there are many user preferences, and not just one, then it might make sense to place a single + object into session scope, which gathers together all such preferences into a single object. + Note, however, that the default implementations of + {@link hirondelle.web4j.request.LocaleSource} and {@link hirondelle.web4j.request.TimeZoneSource} + are not consistent with such a style, since they expect their data to be stored under separate + keys defined in {@link hirondelle.web4j.Controller}. +*/ +public interface LoginTasks { + + /** + React to a successful user log in. + Typically, implementations will look up data related to the user, and place it in session scope. + +

This method is called only if all of the following are true: +

  • a session already exists
  • the user has successfully logged in +
  • {@link #hasAlreadyReacted(HttpSession)} returns false
+ */ + void reactToUserLogin(HttpSession aExistingSession, HttpServletRequest aRequest) throws AppException; + + /** + Return true only if the user login has already been processed by {@link #reactToUserLogin(HttpSession, HttpServletRequest)}. + Typically, implementations will simply return true only if an item of a given name is already in session scope. + */ + boolean hasAlreadyReacted(HttpSession aSession); + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/security/PermittedCharacters.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/security/PermittedCharacters.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,36 @@ +package hirondelle.web4j.security; + +/** + Characters accepted by the {@link SafeText} class. + +

This interface exists because it is important for a web application + to defend strongly against Cross-Site Scripting (XSS) -- likely the single most + prevalent form of attack on the web. + +

As principal line of defense against XSS, WEB4J provides the {@link SafeText} class, + to be used to model all free form user input. SafeText escapes a large number of + special characters. If they are contained in a {@link SafeText} object, any scripts + that depend on one or more of these special characters will very likely be + rendered unexecutable. + +

As a second line of defense, this interface permits you to specify exactly which characters + should be accepted by the {@link SafeText} constructor. This is often called a + 'white list' of acceptable characters. + +

The default implementation of this interface + ({@link hirondelle.web4j.security.PermittedCharactersImpl}) + should be useful for a wide number of applications. +*/ +public interface PermittedCharacters { + + /** + Return true only if the given character is to be permitted by {@link SafeText}. + + @param aCodePoint character in the text being passed to the {@link SafeText} constructor. + The text, in turn, may come from user input, or from the database. For more information on + code points, please see {@link Character}. (Code points are used insteard of char since they are + more general than char.) + */ + public boolean isPermitted(int aCodePoint); + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/security/PermittedCharactersImpl.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/security/PermittedCharactersImpl.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,30 @@ +package hirondelle.web4j.security; + +/** + Default implementation of {@link hirondelle.web4j.security.PermittedCharacters}. + +

This class permits only those characters which return true for + {@link Character#isValidCodePoint(int)}. + +

Since {@link SafeText} already escapes a long list of special characters, those + special characters are automatically safe for inclusion here. + That is, you can usually accept almost any special character, because + SafeText already does so much escaping anyway. + +

Given the importance of this issue for web application security, however, + WEB4J still allows you to define your own implementation of this interface, as + desired. + +

This is a very liberal implementation. Applications should consider replacing this + implementation with something less liberal. For example, an alternate implementation + might disallow carriage returns and line feeds, or might specify the characters of + some particular block of Unicode. +*/ +public class PermittedCharactersImpl implements PermittedCharacters { + + /** See class comment. */ + public boolean isPermitted(int aCodePoint) { + return Character.isValidCodePoint(aCodePoint); + } + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/security/SafeText.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/security/SafeText.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,306 @@ +package hirondelle.web4j.security; + +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.model.ModelUtil; +import hirondelle.web4j.util.Consts; +import hirondelle.web4j.util.EscapeChars; +import hirondelle.web4j.util.Util; + +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.logging.Logger; + +/** + Models free-form text entered by the user, and + protects your application from + Cross Site Scripting (XSS). + +

Free-form text refers to text entered by the end user. + It differs from other data in that its content is not tightly + constrained. Examples of free-form text might include a user name, a description + of something, a comment, and so on. If you model free-form text as a simple + String, then when presenting that text in a web page, you must take + special precautions against Cross Site Scripting attacks, by escaping + special characters. When modeling such data as SafeText, + however, such special steps are not needed, since the escaping is built + directly into its {@link #toString} method. + +

It is worth noting that there are two defects with JSTL' s handling of this problem : +

    +
  • the {@code } tag escapes only 5 of the 12 special characters identified + by the Open Web App Security Project as being a concern. +
  • used in a JSP, the Expression Language allows pleasingly concise presentation, but + does not escape special characters in any way. Even when one is aware of this, + it is easy to forget to take precautions against Cross Site Scripting attacks. +
+ +

Using SafeText will protect you from both of these defects. + Since the correct escaping is built into {@link #toString}, you may freely use JSP + Expression Language, without needing to do any escaping in the view. Note that if you use + {@code } with SafeText (not recommeded), then you must + use escapeXml='false' to avoid double-escaping of special characters. + +

There are various ways of presenting text : +

    +
  • as HTML (most common) - use {@link #toString()} to escape a large number of + special characters. +
  • as XML - use {@link #getXmlSafe()} to escape 5 special characters. +
  • as JavaScript Object Notation (JSON) - use {@link #getJsonSafe()} to escape + a number of special characters +
  • as plain text - use {@link #getRawString()} to do no escaping at all. +
+ +

Checking For Vulnerabilities Upon Startup

+ WEB4J will perform checks for Cross-Site Scripting vulnerabilities + upon startup, by scanning your application's classes for public Model Objects + having public getXXX methods that return a String. It will log such + occurrences to encourage you to investigate them further. + +

Design Notes :
+ This class is final, immutable, {@link Serializable}, + and {@link Comparable}, in imitation of the other building block classes + such as {@link String}, {@link Integer}, and so on. + +

The reason why protection against Cross-Site Scripting is not implemented as a + Servlet Filter is because a filter would have no means of distinguishing between safe and + unsafe markup. + +

One might object to escaping special characters in the Model, instead of in the View. + However, from a practical point of view, it seems more likely that the programmer will + remember to use SafeText once in the Model, than remember to do the + escaping repeatedly in the View. +*/ +public final class SafeText implements Serializable, Comparable { + + /** + Returns true only if the given character is always escaped by + {@link #toString()}. For the list of characters, see {@link EscapeChars#forHTML(String)}. + +

Recommended that your implementation of {@link PermittedCharacters} + use this method. This will allow you to accept many special characters in your + list of permissible characters. + */ + public static boolean isEscaped(int aCodePoint){ + return ESCAPED_CODE_POINTS.contains(aCodePoint); + } + + /** + Constructor. + + @param aText free-form text input by the end user, which may contain + Cross Site Scripting attacks. Non-null. The text is trimmed by this + constructor. + */ + public SafeText(String aText) { + fText = Util.trimPossiblyNull(aText); + validateState(); + } + + /** + Factory method. + + Simply a slightly more compact way of building an object, as opposed to 'new'. + */ + public static SafeText from(String aText){ + return new SafeText(aText); + } + + /** + Return the text in a form safe for an HTML document. + + Passes the raw text through {@link EscapeChars#forHTML(String)}. + */ + @Override public String toString(){ + if( ! Util.textHasContent(fEscapedForHTML) ){ + fEscapedForHTML = EscapeChars.forHTML(fText); + } + return fEscapedForHTML; + } + + /** Return the (trimmed) text passed to the constructor. */ + public String getRawString(){ + return fText; + } + + /** + Return the text in a form safe for an XML element. + +

Arbitrary text can be rendered safely in an XML document in two ways : +

    +
  • using a CDATA block +
  • escaping special characters {@code &, <, >, ", '}. +
+ +

This method will escape the above five special characters, and replace them with + character entities, using {@link EscapeChars#forXML(String)} + */ + public String getXmlSafe(){ + return EscapeChars.forXML(fText); + } + + /** + Return the text in a form safe for JSON (JavaScript Object Notation) data. + +

This method is intended for the data elements of JSON. + It is intended for values of things, not for their names. + Typically, only the values will come from end user input, while the names will + be hard-coded. + */ + public String getJsonSafe(){ + return EscapeChars.forJSON(fText); + } + + @Override public boolean equals(Object aThat){ + Boolean result = ModelUtil.quickEquals(this, aThat); + if ( result == null ){ + SafeText that = (SafeText)aThat; + result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields()); + } + return result; + } + + @Override public int hashCode(){ + if ( fHashCode == 0){ + fHashCode = ModelUtil.hashCodeFor(getSignificantFields()); + } + return fHashCode; + } + + public int compareTo(SafeText aThat){ + final int EQUAL = 0; + if ( this == aThat ) return EQUAL; + + int comparison = this.fText.compareTo(aThat.fText); + if ( comparison != EQUAL ) return comparison; + + return EQUAL; + } + + // PRIVATE // + + /** @serial */ + private final String fText; + /** The return value of toString, cached like fHashCode. */ + private String fEscapedForHTML; + private int fHashCode; + private static final Logger fLogger = Util.getLogger(SafeText.class); + + private Object[] getSignificantFields(){ + return new Object[] {fText}; + } + + /** During deserialization, this method cannot be called, since the implementation of PermittedChars is null. */ + private void validateState() { + if (fText == null){ + throw new NullPointerException("Free form text cannot be null."); + } + String badCharacters = findBadCharacters(fText); + if( Util.textHasContent(badCharacters) ) { + throw new IllegalArgumentException("Unpermitted character(s) in text: " + Util.quote(badCharacters) ); + } + } + + private String findBadCharacters(String aArbitraryText){ + String result = Consts.EMPTY_STRING; //default + StringBuilder badCharacters = new StringBuilder(); + PermittedCharacters whitelist = getPermittedChars(); + + int idx = 0; + int length = aArbitraryText.length(); + while ( idx < length ) { + int codePoint = aArbitraryText.codePointAt(idx); + if( ! whitelist.isPermitted(codePoint) ) { + fLogger.severe("Bad Code Point : " + codePoint); + char[] badChar = Character.toChars(codePoint); + badCharacters.append(String.valueOf(badChar)); + } + idx = idx + Character.charCount(codePoint); + } + + if( Util.textHasContent(badCharacters.toString()) ) { + result = badCharacters.toString(); + fLogger.severe("Bad Characters found in request, disallowed by PermittedCharacters implementation: " + result); + } + return result; + } + + private PermittedCharacters getPermittedChars(){ + return BuildImpl.forPermittedCharacters(); + } + + /** + For evolution of this class, see Sun guidelines : + http://java.sun.com/j2se/1.5.0/docs/guide/serialization/spec/version.html#6678 + */ + private static final long serialVersionUID = 7526472295633676147L; + + /** + Always treat de-serialization as a full-blown constructor, by + validating the final state of the de-serialized object. + */ + private void readObject(ObjectInputStream aInputStream) throws ClassNotFoundException, IOException { + aInputStream.defaultReadObject(); + //partial validation only, without looking for 'bad' characters (BuildImpl not available): + if (fText == null){ + throw new NullPointerException("Free form text cannot be null."); + } + } + + /** + This is the default implementation of writeObject. + Customise if necessary. + */ + private void writeObject(ObjectOutputStream aOutputStream) throws IOException { + aOutputStream.defaultWriteObject(); + } + + /** List of characters that this class will always escape. */ + private static List ESCAPED = Arrays.asList( + '<', + '>' , + '&' , + '"' , + '\t' , + '!' , + '#' , + '$' , + '%' , + '\'' , + '(' , + ')' , + '*' , + '+' , + ',' , + '-' , + '.' , + '/' , + ':' , + ';' , + '=' , + '?' , + '@' , + '[' , + '\\' , + ']' , + '^' , + '_' , + '`' , + '{' , + '|' , + '}' , + '~' + ); + + /** As above, but translated into a form that uses code points. */ + private static List ESCAPED_CODE_POINTS = new ArrayList(); + static { + for (Character character : ESCAPED){ + ESCAPED_CODE_POINTS.add(Character.toString(character).codePointAt(0)); + } + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/security/SpamDetector.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/security/SpamDetector.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,39 @@ +package hirondelle.web4j.security; + +import hirondelle.web4j.request.RequestParser; + +/** + Determine if text is likely spam. + +

See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. + {@link hirondelle.web4j.BuildImpl#forSpamDetector()} + returns the configured implementation of this interface. + +

Spam refers to unwanted input from + undesirable parties (usually advertising of some sort) that is often POSTed to servers using automated means. + +

Most spam contains links. Implementations are encouraged to detect unwanted links. + +

The SpamDetectionInFirewall setting in web.xml can instruct the + {@link hirondelle.web4j.security.ApplicationFirewall} to use the configured SpamDetector + to reject all requests containing at least one parameter that appears to be spam. + Such filtering is applied as a + hard validation, and will not result in + a polished response to the end user. + +

If that policy is felt to be too aggressive, then the only alternative is to check all + items input as text using {@link hirondelle.web4j.model.Check#forSpam()} (usually + in a Model Object constructor). Such checks do not need to be applied to + numeric or date data, since the regular conversion validations done by {@link RequestParser} for + numbers and dates will already detect and reject any spam. +*/ +public interface SpamDetector { + + /** + Determine if the given text is very likely spam. + + @param aText value of a request parameter. + */ + boolean isSpam(String aText); + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/security/SpamDetectorImpl.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/security/SpamDetectorImpl.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,33 @@ +package hirondelle.web4j.security; + +import java.util.regex.*; +import hirondelle.web4j.util.Util; + +/** + Fails all text containing links, except those appearing in + wiki-style formatting. + +

Almost all spam contains links. This implementation will reject all text that contains + 'http://', except if the text contains any wiki-style links in the form + [link:http://www.google.ca Google]. +*/ +public class SpamDetectorImpl implements SpamDetector { + + /** See class comment. */ + public boolean isSpam(String aText) { + boolean result = false; + if( Util.contains(WIKI_LINK_PATTERN, aText)){ + //OK + } + else { + if ( Util.contains(STANDARD_LINK_PATTERN, aText)) { + result = true; + } + } + return result; + } + + // PRIVATE // + private static final Pattern WIKI_LINK_PATTERN = Pattern.compile("\\[link:http://"); + private static final Pattern STANDARD_LINK_PATTERN = Pattern.compile("http://"); +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/security/SuppressUnwantedSessions.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/security/SuppressUnwantedSessions.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,160 @@ +package hirondelle.web4j.security; + +import java.io.*; +import java.util.logging.*; +import javax.servlet.Filter; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.FilterChain; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletResponseWrapper; +import javax.servlet.http.HttpSession; +import hirondelle.web4j.util.Util; + +/** + Suppress the creation of unwanted sessions. + +

Using this filter means that browsers must have cookies enabled. + Some users believe that disabling cookies protects them. For web applications, + this seems unadvisable since its replacement -- URL rewriting -- has a + much higher security risk. URL rewriting is dangerous since it is a vector for + session hijacking and session fixation. + +

This class can be used only when form-based login is used. + When form-based login is used, the generation of the initial JSESSIONID + cookie is done only once per session, by the container. This filter helps you enforce the + policy that form-based login should be the only time a session cookie + is generated. + +

Superfluous sessions and session ids represent a security risk. + Here, the following approach is taken: +

    +
  • during form-based login, the container sends a session cookie to the browser +
  • at no other time does the web application itself send a JSESSIONID, either in a + cookie or in a rewritten URL +
  • upon logoff, the web application instructs the browser to delete the JSESSIONID cookie +
+ +

Note how the container and the web application work together to manage the JSESSIONID cookie. + +

It's unfortunate that the Servlet API and Java Server Pages make it + a bit too easy to create new sessions. To circumvent that, this filter uses + custom wrappers for the underlying HTTP request and response. + These wrappers alter the implementations of the following methods related to creating sessions : +

    +
  • {@link javax.servlet.http.HttpServletRequest#getSession()} +
  • {@link javax.servlet.http.HttpServletRequest#getSession(boolean)} +
  • {@link javax.servlet.http.HttpServletResponse#encodeRedirectURL(java.lang.String)} +
  • {@link javax.servlet.http.HttpServletResponse#encodeRedirectURL(java.lang.String)} +
  • {@link javax.servlet.http.HttpServletResponse#encodeRedirectUrl(java.lang.String)} +
  • {@link javax.servlet.http.HttpServletResponse#encodeURL(java.lang.String)} +
  • {@link javax.servlet.http.HttpServletResponse#encodeUrl(java.lang.String)} +
+ + Calls to the getSession methods are in effect all coerced to getSession(false). + Since this doesn't affect the form-based login mechanism, the user will + still receive a JSESSIONID cookie during form-based login. This policy ensures that your code + cannot mistakenly create a superfluous session. + +

The encodeXXX methods are no-operations, and simply return the given String unchanged. + This policy in effect disables URL rewriting. URL rewriting is a security risk since it allows + session ids to appear in simple links, which are subject to session hijacking. + +

As a convenience, this class will also detect sessions that do not have a user login, + and will log such occurrences as a warning. +*/ +public class SuppressUnwantedSessions implements Filter { + + public void doFilter(ServletRequest aRequest, ServletResponse aResponse, FilterChain aChain) throws IOException, ServletException { + fLogger.fine("START SuppressUnwantedSessions Filter."); + + HttpServletResponse response = (HttpServletResponse) aResponse; + HttpServletRequest request = (HttpServletRequest) aRequest; + + DisableSessionCreation requestWrapper = new DisableSessionCreation(request); + DisableUrlRewriting responseWrapper = new DisableUrlRewriting(response); + + aChain.doFilter(requestWrapper, responseWrapper); + + checkForSessionWithNoLogin((HttpServletRequest)aRequest); + + fLogger.fine("END SuppressUnwantedSessions Filter."); + } + + /** This implementation does nothing. */ + public void init(FilterConfig aFilterConfig) { + fLogger.config("INIT : " + this.getClass().getName()); + } + + /** This implementation does nothing. */ + public void destroy() { + fLogger.config("DESTROY : " + this.getClass().getName()); + } + + // PRIVATE // + private static final Logger fLogger = Util.getLogger(SuppressUnwantedSessions.class); + private static final boolean DO_NOT_CREATE = false; + + private void checkForSessionWithNoLogin(HttpServletRequest aRequest) { + HttpSession session = aRequest.getSession(DO_NOT_CREATE); + if ( sessionAlreadyExists(session) ){ + if( ! userHasLoggedIn(aRequest)){ + fLogger.warning("Session exists, but user has not yet logged in."); + } + } + } + + private boolean sessionAlreadyExists(HttpSession aSession){ + return aSession != null; + } + + private boolean userHasLoggedIn(HttpServletRequest aRequest){ + return aRequest.getUserPrincipal() != null; + } + + /** Alter the implementation of getSession() methods. */ + private static final class DisableSessionCreation extends HttpServletRequestWrapper { + DisableSessionCreation(HttpServletRequest aRequest){ + super(aRequest); + } + @Override public HttpSession getSession() { + // FYI - Tomcat 5.5 JSPs call getSession(). + fLog.fine("Calling getSession(). Method not supported by this wrapper. Coercing to getSession(false)."); + return getSession(false); + } + @Override public HttpSession getSession(boolean aCreateIfAbsent) { + if( aCreateIfAbsent ){ + fLog.warning("Calling getSession(true). Method call not supported by this wrapper. Coercing to getSession(false)."); + } + else { + //fLogger.finest("Calling getSession(false)."); + } + return super.getSession(NEVER_CREATE); + } + private static final Logger fLog = Util.getLogger(DisableSessionCreation.class); + private static final boolean NEVER_CREATE = false; + } + + /** Disable URL rewriting entirely. */ + private static final class DisableUrlRewriting extends HttpServletResponseWrapper { + DisableUrlRewriting(HttpServletResponse aResponse){ + super(aResponse); + } + @Override public String encodeUrl(String aURL) { + return aURL; + } + @Override public String encodeURL(String aURL) { + return aURL; + } + @Override public String encodeRedirectURL(String aURL) { + return aURL; + } + @Override public String encodeRedirectUrl(String aURL) { + return aURL; + } + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/security/TESTPermittedCharactersImpl.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/security/TESTPermittedCharactersImpl.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,81 @@ +package hirondelle.web4j.security; + +import junit.framework.*; + +public class TESTPermittedCharactersImpl extends TestCase { + + /** Run the test cases. */ + public static void main(String args[]) { + String[] testCaseName = { TESTPermittedCharactersImpl.class.getName() }; + junit.textui.TestRunner.main(testCaseName); + } + + public TESTPermittedCharactersImpl(String aName) { + super(aName); + } + + public void testChars(){ + PermittedCharacters permitted = new PermittedCharactersImpl(); + testSuccess('a', permitted); + testSuccess('A', permitted); + testSuccess('z', permitted); + testSuccess('Z', permitted); + testSuccess('0', permitted); + testSuccess('9', permitted); + testSuccess(' ', permitted); + testSuccess('~', permitted); + testSuccess('~', permitted); + testSuccess('\r', permitted); + testSuccess('\n', permitted); + testSuccess('\u00E9', permitted); //French e acute + testSuccess('?', permitted); + testSuccess('\u00E7', permitted); //French c cedilla + testSuccess('\t', permitted); + testSuccess('<', permitted); + testSuccess('\u0660', permitted); + testSuccess('\u0b87', permitted); + testSuccess('?', permitted); + testSuccess('$', permitted); + testSuccess('\u20AC', permitted); //euro symbol + } + + public void testThai(){ + //Thai is in the range 0E00..0E7F + //String thai ="\u0E01\u0E02"; //success + String thai ="\u0E48"; //success E39, E48, and E34 used to be a problem - they are vowels and tone marks + PermittedCharacters permitted = new PermittedCharactersImpl(); + int idx = 0; + int length = thai.length(); + while ( idx < length ) { + int codePoint = thai.codePointAt(idx); + if( permitted.isPermitted(codePoint)) { + //OK + } + else { + fail("Expected success with " + codePoint); + } + idx = idx + Character.charCount(codePoint); + } + } + + // PRIVATE // + private void testSuccess(char aChar, PermittedCharacters aPermitted){ + if ( aPermitted.isPermitted(aChar)){ + //OK + } + else { + fail("Expected success with " + aChar); + } + } + + + + private void testFailure(char aChar, PermittedCharacters aPermitted){ + if( aPermitted.isPermitted(aChar) ) { + fail("Expected failure for " + aChar); + } + else { + //OK + } + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/security/UntrustedProxyForUserId.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/security/UntrustedProxyForUserId.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,58 @@ +package hirondelle.web4j.security; + +import hirondelle.web4j.request.RequestParser; + +/** + Determines if a request has an ownership constraint which needs explicit validation for a user id proxy. + +

This interface addresses the issue of + Insecure Direct Object Reference, which + is an important security issue for web applications. The issue centers around proper enforcement of + data ownership constraints. + +

Please see the User Guide for + more information on this important topic. + + +

Untrusted Proxy For User Id

+ An untrusted proxy for the user id is defined here as satisfying these two criteria: +
  • it's "owned" by a specific user (that is, it has an associated data ownership constraint) +
  • it's open to manipulation by the end user (for example, by simply changing a request parameter)
+ +

An untrusted identifier typically appears in a link, or in a form's target URL. + This interface is for defining which requests use an untrusted identifier, and which need to enforce a data + ownership constraint in a particular way. + +

Note that, as explained in the User Guide, + not all data ownership constraints involve an untrusted proxy for the user id - only some do. + +

The {@link hirondelle.web4j.Controller} processes each request using your application's configured + implementation of this interface. Most applications will likely use the default implementation, + {@link hirondelle.web4j.security.UntrustedProxyForUserIdImpl}. + The Controller logic is roughly as follows: +

+get the configured implementation of UntrustedProxyForUserId
+if the current request has an untrusted id {
+  cast the Action to {@link hirondelle.web4j.security.FetchIdentifierOwner}
+  fetch the login name of the user who owns the untrusted id
+  compare it to the login name of the current user  
+  proceed with the Action only if there is a match
+}
+ +(Reminder: whenever a user logs in, the login name of the current user is always placed into session scope by the Servlet Container.) + +

Implementations of this interface will typically extract two items from the underlying request, to determine if the + request has an untrusted proxy for the user id : +

  • the 'noun' - identifies what data is being operated on
  • the 'verb' - what is being done to the data (the operation)
+ +

In some cases, only the noun will be important, since all operations on the data can be restricted to the owner. + In other cases, both the noun and the verb will be needed to determine if there is a data ownership constraint. +*/ +public interface UntrustedProxyForUserId { + + /** + Returns true only if the given request uses an untrusted proxy for the user id. + */ + boolean usesUntrustedIdentifier(RequestParser aRequestParser); + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/security/UntrustedProxyForUserIdImpl.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/security/UntrustedProxyForUserIdImpl.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,112 @@ +package hirondelle.web4j.security; + +import static hirondelle.web4j.util.Consts.NOT_FOUND; +import hirondelle.web4j.action.Operation; +import hirondelle.web4j.readconfig.Config; +import hirondelle.web4j.request.RequestParser; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.util.WebUtil; + +import java.util.List; + +/** + Default implementation of {@link UntrustedProxyForUserId}. + +

This implementation depends on settings in web.xml, which are read in on startup. + Later, each request URL is parsed by {@link #usesUntrustedIdentifier(RequestParser)}, + and an attempt is made to find a match to the aforementioned settings in web.xml. + +

This class uses settings in web.xml to define requests having ownership constraints that use an untrusted proxy + for the user id. It uses a roughly similar style as used for role-based constraints. + Here is an example of a number of several such ownership constraints defined in web.xml:

<init-param>
+  <description>
+    Operations having an ownership constraint that uses an untrusted identifier. 
+  </description>
+  <param-name>UntrustedProxyForUserId</param-name>
+  <param-value>
+    FoodAction.*
+    VacationAction.add
+    VacationAction.delete
+  </param-value>
+</init-param>
+
+ +

Each line is treated as a separate constraint, one per line. You can define as many as required. + The period character separates the 'noun' (the Action) from the 'verb' (the {@link Operation}). + +

The special '*' character refers to all verbs/operations attached to a given noun/action. +*/ +public final class UntrustedProxyForUserIdImpl implements UntrustedProxyForUserId { + + /** + Return true only if the given request matches one of the items defined by the UntrustedProxyForUserId setting + in web.xml. + +

For example, given the URL : +

'.../VacationAction.list?X=Y'
+ this method will parse the URL into a 'noun' and a 'verb' : +
noun: 'VacationAction'
+verb: 'list'
+ + It will then compare the noun-and-verb to the settings defined in web.xml. + If there's a match, then this method returns true. + */ + public boolean usesUntrustedIdentifier(RequestParser aRequestParser) { + boolean result = false; //by default, there is no ownership constraint + String noun = extractNoun(aRequestParser); //WebUtil needs response too! servlet path + path info? + if( isRestrictedRequest(noun) ){ + List restrictedVerbs = fConfig.getUntrustedProxyForUserId().get(noun); + if ( hasAllOperationsRestricted(restrictedVerbs) ) { + result = true; + } + else { + String verb = extractVerb(aRequestParser); + if ( restrictedVerbs.contains(verb) ) { + result = true; + } + } + } + return result; + } + + /** Special character denoting all operations/verbs. */ + public static final String ALL_OPERATIONS = "*"; + + // PRIVATE + + private Config fConfig = new Config(); + + private boolean isRestrictedRequest(String aNoun){ + return fConfig.getUntrustedProxyForUserId().containsKey(aNoun); + } + + private boolean hasAllOperationsRestricted(List aVerbs){ + return aVerbs.contains(ALL_OPERATIONS); + } + + /** + For the example URL '.../BlahAction.do?X=Y', this method returns 'BlahAction' as the noun. + Relies on the presence of '/' and '.' characters. + */ + private String extractNoun(RequestParser aRequestParser){ + String uri = getURI(aRequestParser); + int firstPeriod = uri.indexOf("."); + if( firstPeriod == NOT_FOUND ) { + throw new RuntimeException("Cannot find '.' character in URL: " + Util.quote(uri)); + } + int lastSlash = uri.lastIndexOf("/"); + if( lastSlash == NOT_FOUND ) { + throw new RuntimeException("Cannot find '/' character in URL: " + Util.quote(uri)); + } + return uri.substring(lastSlash + 1, firstPeriod); + } + + /** Return the part of the URL after the '.' character, but before any '?' character (if present). */ + private String extractVerb(RequestParser aRequestParser){ + return WebUtil.getFileExtension(getURI(aRequestParser)); + } + + private String getURI(RequestParser aRequestParser){ + return aRequestParser.getRequest().getRequestURI(); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/security/package.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/security/package.html Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,149 @@ + + + + + + + Application Security + + +Tools for making your web application more secure. + +

The Open Web App Security Project (OWASP) is an excellent guide +for increasing the security of your web application, and is highly recommended. + +

An important point to understand is the separation of validation into two distinct parts - +hard validation, and soft validation - see {@link hirondelle.web4j.security.ApplicationFirewall} +for more information. + +

Recommendations and Reminders

+ +

SafeText

+Free-form user input should always be modeled as {@link hirondelle.web4j.security.SafeText}, not String. +This provides protection against XSS (Cross Site Scripting) attacks, without forcing you to continually +escape special characters in JSPs. + +

POST versus GET

+Forms should always specify the correct method attribute. The general rule is that any +action that has a side effect (database edits, logging off) should be a POST, while an action without +any side-effect (list or search operations, reports) should be a GET. + +

Content-Type

+Always specify the content-type. This can be done in template JSPs, to reduce the repetition of +identical markup. Example using a directive to specify an HTTP header : + +
<%@ page contentType="text/html" %>
+ +Such directives must appear at the start of the page. + +

The jsp-property-group setting in web.xml can specify an include-prelude JSP, which +will be automatically included at the start of your JSPs. + +

Filter

+The {@link hirondelle.web4j.security.CsrfFilter} should be configured, to +protect against CSRF attacks. + +

This filter requires configuration for FormSourceIdRead and FormSourceIdWrite. These +items reference SqlIds. As usual, these SqlIds need to be declared in one of your application's classes, and +also appear in an .sql file. (This ensures the usual matching of .sql file content to SqlIds will not fail. +See hirondelle.web4j.database for more information.) + +

ApplicationFirewall

+The {@link hirondelle.web4j.security.ApplicationFirewallImpl} provided by the framework is an +excellent default for the {@link hirondelle.web4j.security.ApplicationFirewall} interface. It performs sanity checks on +every request, to make sure they aren't hacks. +You can subclass that implementation in order to add further checks, if desired. +(Replacing the implementation entirely is possible, but is usually an extensive task, and should likely be +attempted only if you are experienced.) + +

SpamDetector

+Implementations for the {@link hirondelle.web4j.security.SpamDetector} interface can vary widely. The +{@link hirondelle.web4j.security.SpamDetectorImpl} is a simple default, but you will occasionally need to replace it with +something more appropriate> This will usually be easy to implement. + + +

Cross Site Scripting (XSS) Attacks

+The {@link hirondelle.web4j.security.SafeText} class is provided as the main defense against +XSS attacks. The {@link hirondelle.web4j.security.SafeText#toString()} +method performs proper escaping for HTML, and {@link hirondelle.web4j.security.SafeText#getXmlSafe()} performs proper +escaping for XML documents. For the most common case of presenting data in a web page, a SafeText +object will be rendered safely by default. +It is highly recommended that all free-form text input by the user be represented in a Model Object using SafeText instead of String. + +

+Using SafeText will greatly increase your safety when using JSTL. +The JSTL is a bit defective when it comes to protecting you from XSS attacks: +

    +
  • the <c:out> tag escapes characters by default, but it only escapes for XML, and not for HTML. +That is, it escapes only for 5 of the 12 characters recommended +by the OWASP. The same is true for JSTL's fn:escapeXml() function. +
  • JSTL's Expression Language is nice and concise, but it doesn't escape any characters at all. It is dangerously open to XSS attacks, +since the application programmer always needs to remember to manually escape special characters. +
+ +When JSTL is used with a SafeText object, however, these problems do not occur, since by default SafeText.toString() +will do the correct escaping for you in the background. + +

Cross Site Request Forgery (CSRF) Attacks

+The central idea of protecting against CSRF attacks is that a +server should be able to answer the question "Did this POSTed form really come from me?", and not from some unknown third party. +The {@link hirondelle.web4j.security.CsrfFilter} is provided as a defense against CSRF attacks. + +

To use it, you must have the CsrfFilter configured as a Servlet Filter in web.xml. +As part of that configuration, you must provide two SqlId's, to tell the Filter what SQL statements to use to +read and write 'form-source ids', the tokens used to identify forms generated by your web app. + +

Although it is not really necessary for those simply wanting to use CsrfFilter, the following is a description of its +implementation. + +

Form-Source Ids

+When a user logs in, {@link hirondelle.web4j.security.CsrfFilter} will create a 'form-source id', and store it in the +user's session under the key 'web4j_key_for_form_source_id'. +This form-source id is constant for each session, and is hard to guess. +The CsrfFilter will automatically modify all of your forms having method='POST' +to include a hidden request parameter of the same name (web4j_key_for_form_source_id), +whose value is simply the value created upon login. + +

Verifying Form-Source Ids

+The default {@link hirondelle.web4j.security.ApplicationFirewallImpl} will verify that each POSTed +form includes a parameter named web4j_key_for_form_source_id, and that its value matches either +the current value stored in the user's session, or the form-source id used in the immediately preceding +session for the same user. + +

The form-source id used in the previous session is needed to ensure smooth behavior upon re-login. +Here is the use case in more detail : +

    +
  • the user logs in +
  • the user navigates to a form (containing a hidden form-source id param) +
  • the session expires +
  • the user posts the form, without knowing the session has expired +
+ +

When the form is POSTed, the user will of course need to log in a second time. After successful authentication, the action + will continue in the usual way. In this case, however, note that there is a new session. Thus, the old form-source id attached to the +first session is POSTed, not the current one. What is to be done? Well, if this use case is to be handled gracefully, +then the 'old' form-source id must be remembered (in the database) and treated as second valid value. +Then, a POSTed form will succeed only if its web4j_key_for_form_source_id has either +the current form-source id value, or the 'old' form-source id value of the immediately preceding session. + +

This is the reason for the two SqlId configuration settings for the CsrfFilter: they tell the Filter how to read and + write the form-source id for a given user. This allows the Filter to remember the previous form-source id value for each user. + +

When a user logs out, or when a session is about to expire, CsrfFilter will extract the user's form-source id from the session, + and store it in the database for possible future use. + +

Sessions With No Login

+ There are some common forms for which no valid login is possible : +
    +
  • creating an account +
  • recovering a lost password +
+In this case, a session is used, but without the usual corresponding login. +Since the usual login mechanism does not apply, the Action itself must ensure that a session exists, and that a CSRF token is created. +This is done simply by calling {@link hirondelle.web4j.action.ActionImpl#createSessionAndCsrfToken()} (typically in the Action's constructor). + +

There is one difference when a session does not include a user login: when a session expires, there's no +way to recover using a 'previous' token from a previous session. Again, this is because the session is not attached to a specific user. + + + diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ui/tag/AlternatingRow.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ui/tag/AlternatingRow.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,78 @@ +package hirondelle.web4j.ui.tag; + +import java.io.IOException; +import javax.servlet.jsp.JspException; +import java.util.regex.*; +import hirondelle.web4j.ui.tag.TagHelper; +import hirondelle.web4j.util.Regex; +import hirondelle.web4j.util.Util; + +/** + Generate table rows which alternate in appearance, to increase legibility. + +

The body of this tag contains one or more TR tags. Each TR tag contains a + class attribute, specifying a Cascading Style Sheet class. This tag + will simply remove or update the class attribute for alternate occurrences of + each TR tag found in its body. + +

If the optional altClass attribute is specified, then the class + attribute of each TR is updated to an alternate value, instead of being removed. +*/ +public final class AlternatingRow extends TagHelper { + + /** + Optional name of a CSS class. + +

The CSS class for each TR tag found in the body of this tag will be + updated to this value, for alternating rows. If this item is not specified, then the TR's + class attribute is simply removed instead of updated. + + @param aAltClass must have content. + */ + public void setAltClass(String aAltClass){ + checkForContent("AltClass", aAltClass); + fAltClass = aAltClass; + } + + /** + For each TR tag found in the body, remove or update the class attribute. + +

If no altClass is specified, then the class attribute is simply removed entirely. + Otherwise, it updated to the altClass value. + */ + protected String getEmittedText(String aOriginalBody) throws JspException, IOException { + StringBuffer result = new StringBuffer(); + Matcher matcher = TR_PATTERN.matcher(aOriginalBody); + int matchIdx = 0; + while (matcher.find()){ + ++ matchIdx; + if( isEvenRow(matchIdx) ){ + matcher.appendReplacement(result, getReplacement(matcher)); + } + else { + String noChange = matcher.group(0); + matcher.appendReplacement(result, noChange); + } + } + matcher.appendTail(result); + return result.toString(); + } + + // PRIVATE // + private String fAltClass; + private static final Pattern TR_PATTERN = Pattern.compile("", Pattern.CASE_INSENSITIVE); + + private String getReplacement(Matcher aMatcher){ + //construct replacement: + StringBuilder result = new StringBuilder(""); + return result.toString(); + } + + private boolean isEvenRow(int aMatchIdx){ + return aMatchIdx % 2 == 0; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ui/tag/HighlightCurrentPage.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ui/tag/HighlightCurrentPage.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,238 @@ +package hirondelle.web4j.ui.tag; + +import java.util.regex.*; +import java.util.logging.*; +import hirondelle.web4j.Controller; +import hirondelle.web4j.util.EscapeChars; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.util.Regex; + +/** + Suppress self-linking anchor tags (links), where a + link has the current page as its destination. + +

Self-linking is usually considered to be poor style, since the user is simply + led back to the current page. + +

Rendering links to the current page in a distinct manner has two benefits : +

    +
  • the user is prevented from following a useless link +
  • the user can see 'where they are' in a menu of items +
+ +

This tag provides two distinct techniques for identifying self-linking: +

    +
  • the current URI ends with the link's href target. + This is the default mechanism. The 'ends with' is used since the current URI + (stored by the Controller in request scope) is absolute, while most + HREF attributes contain relative links. +
  • the trimmed link text (the body of the anchor tag) matches the TTitle request parameter + used by the WEB4J templating mechanism. +
+ +

If these policies are inadequate, then {@link #isSelfLinkingByHref(String, String)} and + {@link #isSelfLinkingByTitle(String, String)} may be overridden as desired. + +

Example use case. +

+<w:highlightCurrentPage styleClass="highlight">
+  <a href='ShowHomePage.do'>Home</a>
+  <a href='ShowSearch.do'>Search</a>
+  <a href='ShowContact.do'>Contact</a>
+</w:highlightCurrentPage>
+
+ If the current URI is +
+ http://www.blah.com/fish/main/home/ShowHomePage.do
+ 
+ then the output of this tag will be +
+  <span class="highlight">Home</span>
+  <a href='ShowSearch.do'>Search</a>
+  <a href='ShowContact.do'>Contact</a>
+
+ If the styleClass attribute is not used, then the output would simply be : +
+  Home
+  <a href='ShowSearch.do'>Search</a>
+  <a href='ShowContact.do'>Contact</a>
+
+ +

This final example uses the TTitle mechanism : +

+<w:highlightCurrentPage useTitle="true">
+  <a href='ShowHomePage.do'>Home</a>
+  <a href='ShowSearch.do'>Search</a>
+  <a href='ShowContact.do'>Contact</a>
+</w:highlightCurrentPage>
+
+ For a page with TTitle parameter as 'Home', the output of this tag + would be as above: +
+  Home
+  <a href='ShowSearch.do'>Search</a>
+  <a href='ShowContact.do'>Contact</a>
+
+ +

Nuisance restriction : the href attribute must appear as the + first attribute in the <A> tags. +*/ +public class HighlightCurrentPage extends TagHelper { + + /** + Name of the Cascading Style Sheet class used for highlighting text related to the current + page (optional). + +

For example, the link text could be highlighted in yellow in order + to make it stand out more clearly from the other links. If this attribute is not + specified, then the link text is simply presented as is, with no special styling. + + @param aCascadingStyleSheetClassName satisfies {@link Util#textHasContent(String)}. + */ + public final void setStyleClass(String aCascadingStyleSheetClassName){ + checkForContent("StyleClass", aCascadingStyleSheetClassName); + fHighlightClass = aCascadingStyleSheetClassName; + } + + /** + Use the TTitle request parameter passed to this page. + +

Optional. Default is false. If set to true, + then the default mechanism is overridden, and self-linking pages will be identified + by comparing the link text to the page TTitle parameter. (The TTitle + parameter is used in the WEB4J page templating mechanism). + */ + public final void setUseTitle(Boolean aUseTitle) { + fUseTTitle = aUseTitle; + } + + /** + Alter any links in the tag body which refer to the current page. + +

See examples in the class comment. + */ + @Override protected final String getEmittedText(String aOriginalBody){ + StringBuffer result = new StringBuffer(); + Matcher matcher = LINK_PATTERN.matcher(aOriginalBody); + while ( matcher.find() ) { + matcher.appendReplacement(result, getReplacement(matcher)); + } + matcher.appendTail(result); + return result.toString(); + } + + /** + Determine if self-linking is present, using the current URI and the link's HREF target. + +

Overridable default implementation that simply checks if aCurrentURI ends with + aHrefTarget. In addition, aCurrentURI is passed through {@link EscapeChars#forHrefAmpersand(String)} + before the comparison is made. This ensures the text will match a valid HREF attribute. + */ + protected boolean isSelfLinkingByHref(String aCurrentURI, String aHrefTarget){ + String currentURI = EscapeChars.forHrefAmpersand(aCurrentURI); + return currentURI.endsWith(aHrefTarget); + } + + /** + Determine if self-linking is present, using the link text and the TTitle request parameter. + +

Overridable default implementation that checks if the trimmed aLinkText + (the body of the <A> tag) and aTTitle are the same. + */ + protected boolean isSelfLinkingByTitle(String aLinkText, String aTTitle){ + return aLinkText.trim().equals(aTTitle); + } + + /** Used for offline unit testing only. */ + void testSetCurrentURI(String aCurrentURI){ + fTestingCurrentURI = aCurrentURI; + } + + /** Used for offline unit testing only. */ + void testSetTitleParam(String aTTitleParam){ + fTestingTitleParam = aTTitleParam; + } + + // PRIVATE // + private String fHighlightClass; + private Boolean fUseTTitle = Boolean.FALSE; + + private String fTestingCurrentURI; + private String fTestingTitleParam; + + /** + Group 0 - entire link + Group 1 - href attribute + Group 2 - link text (untrimmed) + */ + private static final Pattern LINK_PATTERN = Pattern.compile( + "", + Pattern.CASE_INSENSITIVE + ); + + private static final int WHOLE_LINK = 0; + private static final int HREF_TARGET = 1; + private static final int LINK_TEXT = 2; + private static final String TITLE_PARAM = "TTitle"; + private static final Logger fLogger = Util.getLogger(HighlightCurrentPage.class); + + private String getReplacement(Matcher aMatcher){ + String result = aMatcher.group(WHOLE_LINK); + if ( isSelfLink(aMatcher) ){ + String linkText = aMatcher.group(LINK_TEXT); + result = getHighlightedText(linkText); + } + return EscapeChars.forReplacementString(result); + } + + private String getCurrentURI(){ + String result = null; + if( ! isTesting(fTestingCurrentURI) ){ + result = (String)getRequest().getAttribute(Controller.CURRENT_URI); + } + else { + result = fTestingCurrentURI; + } + return result; + } + + private String getTitleParam(){ + String result = null; + if( ! isTesting(fTestingTitleParam) ){ + result = getRequest().getParameter(TITLE_PARAM); + } + else { + result = fTestingTitleParam; + } + return result; + } + + private boolean isSelfLink(Matcher aMatcher){ + boolean result = false; + if( ! fUseTTitle ){ + String hrefTarget = aMatcher.group(HREF_TARGET); + String currentURI = getCurrentURI(); + result = isSelfLinkingByHref(currentURI, hrefTarget); + fLogger.finest("Is self-linking (href)? " + result); + fLogger.finest("Current URI " + Util.quote(currentURI) + " HREF Target : " + Util.quote(hrefTarget)); + } + else { + String linkText = aMatcher.group(LINK_TEXT); + result = isSelfLinkingByTitle(linkText, getTitleParam()); + fLogger.finest("Is self-linking (TTitle)? " + result); + } + return result; + } + + private String getHighlightedText(String aLinkText){ + String result = aLinkText; + if( Util.textHasContent(fHighlightClass)){ + result = "" + result + ""; + } + return result; + } + + private boolean isTesting(String aTestItem){ + return Util.textHasContent(aTestItem); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ui/tag/Pager.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ui/tag/Pager.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,298 @@ +package hirondelle.web4j.ui.tag; + +import java.util.regex.Pattern; +import java.util.regex.Matcher; +import java.util.logging.Logger; + +import hirondelle.web4j.database.SqlId; +import hirondelle.web4j.Controller; +import hirondelle.web4j.util.Consts; +import hirondelle.web4j.util.Regex; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.util.EscapeChars; + +/** + Generate links for paging through a long list of records. + +

With this class, paging is implemented in two steps : +

  • emitting the proper links (with the proper request parameters) to identify each page
  • extracting the data from the database, using the given request parameters, and perhaps subsetting the ResultSet
+ + The first step is performed by this tag, but the second step is not (see below). + For an example of paging, please see the 'Discussion' feature of the Fish & Chips Club application. + +

Emitting Links For Paging

+

This tag works by emitting various links which are modifications of the current URI. + +

Example +

+ <w:pager pageFrom="PageIndex"> 
+  <a href="placeholder_first_link">First</a> | 
+  <a href="placeholder_next_link">Next</a> | 
+  <a href="placeholder_previous_link">Previous</a> | 
+ </w:pager>
+ 
+ +

The placeholder_xxx items act as placeholders. + They are replaced by this tag with modified values of the current URI. + The <w:pager> tag has a single pageFrom attribute. + It tells this tag which request parameter it will need to modify, in order to generate the various links. + This modified request parameter represents the page index, in the range 1..9999 : for example, PageIndex=1, + PageIndex=2, and so on. + +

For example, if the following URI displays "page 5" : +

http://www.blah.com/main/SomeAction.do?PageIndex=5&Criteria=Blah
+ then this tag will let you emit the following links, derived from the above URI simply by replacing the value of the PageIndex request parameter : +
http://www.blah.com/main/SomeAction.do?PageIndex=1&Criteria=Blah
+http://www.blah.com/main/SomeAction.do?PageIndex=6&Criteria=Blah
+http://www.blah.com/main/SomeAction.do?PageIndex=4&Criteria=Blah
+ +

Of course, these generated links don't do the actual subsetting of the data. + Rather, these links simply alter the current URI to use the desired value for the page index request parameter. + The resulting request parameters are then used elsewhere to extract the desired data (as described below). + +

Link Suppression +

If the emission of a link would create a 'link to self', then this tag will not emit the link. + For example, if you are already on the first page, then the First and Previous links will not be emitted as links - only the bare text, without the link, is emitted. + + +

Extracting and Subsetting the Data

+ There are three alternatives to implementing the subsetting of the ResultSet : +
  • in the JSP itself
  • in the Data Access Object (DAO)
  • directly in the underlying SELECT statement +
+ +

Subsetting in the JSP
+

    +
  • this is the simplest style +
  • it can be applied quickly, to add paging to any existing listing +
  • the DAO performs a single, unchanging SELECT that always returns the same set of records for all pages +
  • it requires only a single change to the {@link hirondelle.web4j.action.Action} - the addition of a + public static final {@link hirondelle.web4j.request.RequestParameter} field for the page index parameter +
  • the JSP performs the subsetting to display a single page at a time, simply using <c:out> +
+ + The JSP performs the subsetting with simple JSTL. + In the following example, the page size is hard-coded to 25 items per page. + The <c:out> tag has begin and end attributes which can control the range of rendered items : +
+<c:forEach 
+  var="item" 
+  items="${items}" 
+  begin="${25 * (param.PageIndex - 1)}" 
+  end="${25 * param.PageIndex - 1}"
+>
+  ...display each item...
+</c:forEach>
+
+ +Note the different expressions for begin and end : +
begin = 25 * (PageIndex - 1)
+end = (25 * PageIndex) - 1
+
+ +which give the following values : +

+ + + + + + + + + + + + + + + + + + + + + +
PageBeginEnd
1024
22549
.........
+ +

An alternate variation would be to allow the page size to also come from a request parameter : +

+<c:forEach 
+  var="item" 
+  items="${items}" 
+  begin="${param.PageSize * (param.PageIndex - 1)}" 
+  end="${param.PageSize * param.PageIndex - 1}"
+>
+  ...display each line...
+</c:forEach>
+
+ + +

Subsetting in the DAO
+

    +
  • the full ResultSet is still returned by the SELECT, as in the JSP case above +
  • however, this time the DAO performs the subsetting, using + {@link hirondelle.web4j.database.Db#listRange(Class, SqlId, Integer, Integer, Object[])}. This avoids + the cost of unnecessarily parsing records into Model Objects. +
  • the <c:out> tag does not use any begin and end attributes, since the subsetting + has already been done. +
+ +

Subsetting in the SELECT
+

    +
  • the underlying SELECT is constructed to return only the desired records. (Such a SELECT + may be difficult to construct, according to the capabilities of the target database.) +
  • the SELECT will likely need two request parameters to form the proper query: a page index and + a page size, from which start and end indices may be calculated +
  • the DAO and JSP are implemented as in any regular listing +
+ +

Here are the kinds of calculations you may need when constructing such a SELECT statement +

For items enumerated with a 0-based index : +

+ start_index = page_size * (page_index - 1)
+ end_index = page_size * page_index - 1
+ 
+ +

For items enumerated with a 1-based index : +

+ start_index = page_size * (page_index - 1) + 1
+ end_index = page_size * page_index
+ 
+ +

Setting In web.xml

+ Note that there is a MaxRows setting in web.xml which controls the maximum number of records returned by + a SELECT. +*/ +public final class Pager extends TagHelper { + + /** + Name of request parameter that holds the index of the current page. + +

This request parameter takes values in the range 1..9999. + +

(The name of this method is confusing. It should rather be named setPageIndex.) + */ + public void setPageFrom(String aPageIndexParamName) { + checkForContent("PageFrom", aPageIndexParamName); + fPageIndexParamName = aPageIndexParamName.trim(); + fPageIdxPattern = Pattern.compile(fPageIndexParamName + "=(?:\\d){1,4}"); + } + + /** See class comment. */ + @Override protected String getEmittedText(String aOriginalBody) { + fCurrentPageIdx = getNumericParamValue(fPageIndexParamName); + fCurrentURI = getCurrentURI(); + fLogger.fine("Current URI: " + fCurrentURI); + fLogger.fine("Current Page : " + fCurrentPageIdx); + String firstURI = getFirstURI(); + String nextURI = getNextURI(); + String previousURI = getPreviousURI(); + String result = getBodyWithUpdatedPlaceholders(aOriginalBody, firstURI, nextURI, previousURI); + return result; + } + + // PRIVATE // + + private String fPageIndexParamName; + private Pattern fPageIdxPattern; + private String fCurrentURI; + private int fCurrentPageIdx; + + private static final String NO_LINK = Consts.EMPTY_STRING; + private static final int FIRST_PAGE = 1; + private static final String PLACEHOLDER_FIRST_LINK = "placeholder_first_link"; + private static final String PLACEHOLDER_NEXT_LINK = "placeholder_next_link"; + private static final String PLACEHOLDER_PREVIOUS_LINK = "placeholder_previous_link"; + /** Regex for an anchor tag 'A'. */ + private static final Pattern LINK_PATTERN = Pattern.compile(Regex.LINK, Pattern.CASE_INSENSITIVE); + private static final Logger fLogger = Util.getLogger(Pager.class); + + private String getCurrentURI() { + return (String)getRequest().getAttribute(Controller.CURRENT_URI); + } + + private boolean isFirstPage() { + return fCurrentPageIdx == FIRST_PAGE; + } + + private int getNumericParamValue(String aParamName) { + String value = getRequest().getParameter(aParamName); + Integer result = Integer.valueOf(value); + if (result < 1) { + throw new IllegalArgumentException("Param named " + Util.quote(aParamName) + " must be >= 1. Value: " + Util.quote(result) + ". Page Name: " + getPageName()); + } + return result.intValue(); + } + + private String getFirstURI() { + return isFirstPage() ? NO_LINK : forPage(FIRST_PAGE); + } + + private String getNextURI() { + return forPage(fCurrentPageIdx + 1); + } + + private String getPreviousURI() { + return isFirstPage() ? NO_LINK : forPage(fCurrentPageIdx - 1); + } + + private String forPage(int aNewPageIndex){ + String result = null; + StringBuffer uri = new StringBuffer(); + Matcher matcher = fPageIdxPattern.matcher(fCurrentURI); + while ( matcher.find() ){ + matcher.appendReplacement(uri, getReplacement(aNewPageIndex)); + } + matcher.appendTail(uri); + result = getResponse().encodeURL(uri.toString()); + return EscapeChars.forHTML(result); + } + + private String getReplacement(int aNewPageIdx){ + return EscapeChars.forReplacementString(fPageIndexParamName + "=" + aNewPageIdx); + } + + private String getBodyWithUpdatedPlaceholders(String aOriginalBody, String aFirstURI, String aNextURI, String aPreviousURI){ + fLogger.fine("First URI: " + aFirstURI); + fLogger.fine("Next URI: " + aNextURI); + fLogger.fine("Previous URI: " + aPreviousURI); + + StringBuffer result = new StringBuffer(); + Matcher matcher = LINK_PATTERN.matcher(aOriginalBody); + while ( matcher.find() ){ + fLogger.finest("Getting href as first group."); + String href = matcher.group(Regex.FIRST_GROUP); + String replacement = null; + if ( PLACEHOLDER_FIRST_LINK.equals(href) ){ + replacement = aFirstURI; + } + else if ( PLACEHOLDER_NEXT_LINK.equals(href) ){ + replacement = aNextURI; + } + else if ( PLACEHOLDER_PREVIOUS_LINK.equals(href) ){ + replacement = aPreviousURI; + } + else { + String message = "Body of pager tag can only contain links to special placeholder text. Page Name "+ getPageName(); + fLogger.severe(message); + throw new IllegalArgumentException(message); + } + matcher.appendReplacement(result, getReplacementHref(matcher, replacement)); + } + matcher.appendTail(result); + return result.toString(); + } + + private String getReplacementHref(Matcher aMatcher, String aReplacementHref){ + String result = null; + int LINK_BODY = 3; + int ATTRS_AFTER_HREF = 2; + if ( Util.textHasContent(aReplacementHref) ){ + result = "" + aMatcher.group(LINK_BODY) + ""; + } + else { + result = aMatcher.group(LINK_BODY); + } + return EscapeChars.forReplacementString(result); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ui/tag/Populate.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ui/tag/Populate.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,339 @@ +package hirondelle.web4j.ui.tag; + +import java.util.*; +import java.util.logging.*; +import javax.servlet.jsp.JspException; +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.action.Operation; +import hirondelle.web4j.request.Formats; +import hirondelle.web4j.util.Util; +import static hirondelle.web4j.util.Consts.EMPTY_STRING; + +/** + Custom tag which populates form controls in a simple, elegant way. + +

From the point of view of this tag, there are 3 sources of data for a form control: +

  • the HTML defined in your JSP can define an initial default value
  • Request parameter values
  • a Model Object +
+ +

For reference, here is the logic that defines which data source is used, and related + naming conventions : +

+if a Model Object of the given name is in any scope {
+  override the default HTML for each control
+  use the Model Object 
+  (match control names to getXXX methods of the Model Object)
+}
+else if the request is a POST  {
+  override the default HTML for each control
+  must populate every control using request parameter values
+  (match control names to request param names) 
+}
+else if the request is a GET {
+  if control name has a matching req param name {
+    override the default HTML for each control
+    populate control using request parameter values
+    (match control names to request param names) 
+  }
+  else {
+    use the default HTML for that control
+  }
+}
+ +

This tag simply wraps static HTML forms. + This is very economical since it does not force the page author to completely + replace well-known static HTML with a large set of custom tags. + +

Example use case

+ This use case corresponds to either an 'add' or a 'change' of a Model Object. The using + attribute signifies that a 'change' case is possible. (This example works with + an {@link hirondelle.web4j.action.ActionTemplateListAndEdit} action.) + +
+<c:url value="RestoAction.do" var="baseURL"/>
+<form action='${baseURL}' method="post" class="user-input"> 
+<w:populate using="itemForEdit">  
+<input name="Id" type="hidden">
+<table align="center">
+<tr>
+ <td><label>Name</label> *</td>
+ <td><input name="Name" type="text"></td>
+</tr>
+<tr>
+ <td><label>Location</label></td>
+ <td><input name="Location" type="text"></td>
+</tr>
+<tr>
+ <td><label>Price</label></td>
+ <td><input name="Price" type="text"></td>
+</tr>
+<tr>
+ <td><label>Comment</label></td>
+ <td><input name="Comment" type="text"></td>
+</tr>
+<tr>
+ <td align="center" colspan=2>
+  <input type='submit' value="Edit">
+ </td>
+</tr>
+</table>
+</w:populate>
+ <tags:hiddenOperationParam/>
+</form>
+
+ + Here, the itemForEdit Model Object has the following methods, corresponding to + the above populated controls : +
+  public Id getId() {...}
+  public SafeText getName() {...}
+  public SafeText getLocation() {...}
+  public BigDecimal getPrice() {...}
+  public SafeText getComment() {...}
+
+ +

Example without using attribute

+ No using attribute is specified when : +
    +
  • only an 'add' operation is performed, and not a 'change' operation. +
  • or, only a Search {@link Operation} is performed. In this case, a form with method="GET" is used + to specify parameters to a SELECT statement. +
+ +

Here is an example of a form used only for 'add' operations : +

+<w:populate>
+<c:url value="AddMessageAction.do?Operation=Apply" var="baseURL"/> 
+<form action='${baseURL}' method=post class="user-input">
+<table align="center">
+<tr>
+ <td>
+  <label>Message</label> *
+ </td>
+</tr>
+<tr>
+ <td>
+  <textarea name="Message Body">
+  </textarea>
+ </td>
+</tr>
+<tr>
+ <td colspan=2>
+  <label>Preview First ?</label> <input type="radio" name="Preview" value="true"> Yes
+ </td>
+</tr>
+<tr>
+ <td align="center" colspan=2>
+  <input type="submit" value="Add Message"> 
+ </td>
+</tr>
+</table>
+</form>
+</w:populate>
+ 
+ +

Supported Controls

+

The following form input items are called supported controls here, and + include all items which undergo population by this class : +

    +
  • INPUT tags with type=text, password, radio, + checkbox, hidden +
  • HTML5 input tags with type=search, email, url, number, tel, color, range +
  • SELECT tags +
  • TEXTAREA tags +
+ +

Population is implemented by editing these supported control attributes : +

    +
  • the checked attribute for INPUT tags of type radio + and checkbox +
  • the value attribute for the remaining INPUT tags (of the different types listed above) +
  • the selected attribute for OPTION tags appearing in a SELECT +
  • the body of a TEXTAREA tag +
+ +

The body of this tag is HTML, with the following minor restrictions: +

    +
  • all supported controls must include a name attribute +
  • all supported INPUT controls must include a type attribute +
  • all attributes must be quoted, using either single or double quotes. For example, + <input type='text' ... > is allowed but + <input type=text ... > is not +
  • for SELECT tags, the </option> end tag is not optional, and must be included. +
  • INPUT tags with type='email' are treated as always being single-valued +
  • the repetitive form of selected='selected' can't be used +
+ +HTML often allows alternate ways of expressing the exact same thing. +For example, the selected attribute can be expressed as selected='selected', or simply as +the single word selected - this tag only accepts the second form, not the first. +These sorts of restrictions are a nuisance, and result from the way the framework parses HTML internally. +Sometimes you will need to tweak your form hypertext in order to let this tag parse the form correctly. Sorry about that. + +

Warning: unfortunately, INPUT controls of type color and range can't represent nullable items in a database. +This is because the (draft) HTML5 specification doesn't allow such controls to POST non-empty +values when forms are submitted. +The only workaround for this defect of the specification is to define magic values which map to null. +Use such controls with caution. + +

Prepopulating only portions of a form

+ There is no requirement that the entire HTML form be wrapped by this tag. If + desired, only part of a form may be placed in the body of this tag. This is useful + when some form controls take a fixed, static value. + +

Convention Regarding Control Names

+ This tag depends on a specfic convention to allow automatic 'binding' between supported controls + and corresponding getXXX methods of the Model Object. This convention is explained in + {@link hirondelle.web4j.request.RequestParameter}. + +

Deriving values from getXXX() methods of the Model Object

+ The return value is found. Any primitives are converted into corresponding wrapper + objects. The {@link hirondelle.web4j.request.Formats#objectToText} method is then used to + translate the object into text. If the return value of the getXXX is a + Collection, then the above is applied to each element. + +

Escaping special characters

+ When this tag assigns a text value to the content of an INPUT or TEXTAREA tag, then the + value is always escaped for special characters using {@link hirondelle.web4j.util.EscapeChars#forHTML(String)}. + +

GET versus POST

+ This tag depends on the proper GET/POST behavior of forms : a POST + request must only be used when an edit to the database is being attempted. (This is the usual style, + and would not be regarded by most as being a restriction.) +*/ +public final class Populate extends TagHelper { + + /** + Key for the Model Object to be used for form population. + +

This attribute is specified only if the form can be used to edit or change an + existing Model Object. If the Model Object is present, then it will be used by this tag to + populate supported controls. + +

This tag searches for the Model Object in the same way as JspContext.findAttribute(String), + by searching scopes in a specific order : page scope, request scope, session scope, and finally + application scope. + + @param aModelObjectKey satisfies {@link Util#textHasContent(String)}. + */ + public void setUsing(String aModelObjectKey){ + checkForContent("Using", aModelObjectKey); + fModel = getPageContext().findAttribute(aModelObjectKey); + } + + /** + Emit the possibly-changed body of this tag, by possibly editing supported form controls + contained in the body of this tag. + */ + @Override protected String getEmittedText(String aOriginalBody) throws JspException { + String result = null; + setUseCaseStyle(); + if ( Style.ECHO == fStyle ){ + result = aOriginalBody; + } + else { + result = getEditedBody(aOriginalBody, fStyle); + } + return result; + } + + // PRIVATE + + /** + The ModelObject which is to be used to populate supported controls in the "edit" use case. + Is identified by the value of the 'using' attribute + */ + private Object fModel; + + /** Use case style */ + private Style fStyle; + + enum Style {ECHO, USE_MODEL_OBJECT, MUST_RECYCLE_PARAMS, RECYCLE_PARAM_IF_PRESENT} + + private static final String GET = "GET"; + private static final String POST = "POST"; + private static final Logger fLogger = Util.getLogger(Populate.class); + + private void setUseCaseStyle(){ + String PREAMBLE = "Form population use case: "; + if( fModel != null ) { + fLogger.fine(PREAMBLE +"'Using' object is specified and present. All controls will be populated using getXXX methods of the 'using' object."); + fStyle = Style.USE_MODEL_OBJECT; + } + else if( isRequest(POST) ){ + /* Minor Problem: delete ids infecting ADD operations. */ + fLogger.fine(PREAMBLE + "POST. All controls will be populated using request parameter values."); + fStyle = Style.MUST_RECYCLE_PARAMS; + } + else if( isRequest(GET) ) { + if ( hasNoRequestParameters() ) { + fLogger.fine(PREAMBLE + "GET, with no request parameters present. Echoing the HTML of entire form as is."); + fStyle = Style.ECHO; + } + else { + fLogger.fine(PREAMBLE + "GET. Any request parameter whose name matches a form control will be used to populate that control."); + fStyle = Style.RECYCLE_PARAM_IF_PRESENT; + } + } + else { + throw new AssertionError("Unexpected use case."); + } + } + + private boolean isRequest(String aRequestStyle){ + return getRequest().getMethod().equalsIgnoreCase(aRequestStyle); + } + + private String getEditedBody(String aOriginalBody, Style aUseCaseStyle) throws JspException { + PopulateHelper populator = new PopulateHelper(new Wrapper(), aOriginalBody, aUseCaseStyle); + return populator.getEditedBody(); + } + + /** This exists solely to provide a particular 'view' of this object that does not leak into the public API. */ + private class Wrapper implements PopulateHelper.Context { + public String getReqParamValue(String aParamName){ + String value = getRequest().getParameter(aParamName); + return value == null ? EMPTY_STRING : value; + } + public Collection getReqParamValues(String aParamName){ + Collection result = Collections.emptyList(); //default return value + String[] values = getRequest().getParameterValues(aParamName); + if ( values != null ) { + result = Collections.unmodifiableCollection( Arrays.asList(values) ); + } + return result; + } + public boolean hasRequestParamNamed(String aParamName) { + boolean result = false; + Enumeration allParamNames = getRequest().getParameterNames(); + while (allParamNames.hasMoreElements()){ + if (allParamNames.nextElement().equals(aParamName)) { + result = true; + break; + } + } + return result; + } + public boolean isModelObjectPresent(){ + return fModel != null; + } + public Object getModelObject(){ + return fModel; + } + public Formats getFormats(){ + Locale locale = BuildImpl.forLocaleSource().get(getRequest()); + TimeZone timeZone = BuildImpl.forTimeZoneSource().get(getRequest()); + return new Formats(locale, timeZone); + } + } + + private boolean hasNoRequestParameters(){ + Enumeration namesEnum = getRequest().getParameterNames(); + int numParams = 0; + while ( namesEnum.hasMoreElements() ){ + ++numParams; + namesEnum.nextElement(); + } + return numParams == 0; + } +} \ No newline at end of file diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ui/tag/PopulateHelper.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ui/tag/PopulateHelper.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,630 @@ +package hirondelle.web4j.ui.tag; + +import static hirondelle.web4j.util.Consts.DOUBLE_QUOTE; +import static hirondelle.web4j.util.Consts.EMPTY_STRING; +import hirondelle.web4j.request.Formats; +import hirondelle.web4j.ui.tag.Populate.Style; +import hirondelle.web4j.util.EscapeChars; +import hirondelle.web4j.util.Regex; +import hirondelle.web4j.util.Util; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.servlet.jsp.JspException; + +/** + Helper class for the custom tag {@link Populate}. + +

Performs most of the work of Populate, by editing the tag's body, as needed. +*/ +final class PopulateHelper { + + /** + Context of a given HTTP request, from which information regarding + request parameters and available Model Objects may be extracted. + +

This interface exists only to allow testing of {@link PopulateHelper} outside of a + servlet context, where a {@link Populate} object is not available. During testing, + fake implementations of this interface are created to simulate the context + of a given HTTP request. + +

Note that the Populate class does not implement this interface, since that would + pollute its public API with internal details. Instead, Populate uses an internal wrapper class + to provide the desired 'view'. + */ + interface Context { + + /** + Return the value of the request parameter corresponding to aParamName. + +

If the param is absent, or if its value is null, then return an empty String. + +

HTTP POSTs "missing" items inconsistently: +

    +
  • empty text/password: param posted, but value is empty String +
  • unselected radio/checkbox: no param is posted at all (null) +
  • unselected SELECT tags: param is posted using first item as value +
+ */ + String getReqParamValue(String aParamName); + + /** Returns true only if the request includes a parameter of the given name. */ + boolean hasRequestParamNamed(String aParamName); + + /** + Return the value of a multi-valued request parameter corresponding to + aParamName. Intended for use with multi-valued parameters. + + @return unmodifiable Collection of Strings; if the parameter is + missing from the request, then return an empty Collection. + */ + Collection getReqParamValues(String aParamName); + + /** Return true only if the using Model Object is present in some scope. */ + boolean isModelObjectPresent(); + + /** Return the using Model Object. */ + Object getModelObject(); + + /** Return the {@link hirondelle.web4j.request.Formats} needed to render objects. */ + Formats getFormats(); + } + + /** + Constructor. + @param aContext source of information regarding request parameters, + any 'using' Model Object, and formatting styles. + @param aOriginalBody the HTML content of the Populate tag, after + any JSP processing, but before any prepopulation. + @param aUseCaseStyle defines the high level use case. + */ + PopulateHelper(Context aContext, String aOriginalBody, Style aUseCaseStyle){ + fContext = aContext; + fOriginalBody = aOriginalBody; + fUseCaseStyle = aUseCaseStyle; + } + + /** Replace supported form controls with edited versions, as needed, reflecting any prepopulation. */ + String getEditedBody() throws JspException { + StringBuffer result = new StringBuffer(); + Matcher control = CONTROL_PATTERN.matcher(fOriginalBody); + while ( control.find() ) { + control.appendReplacement(result, getReplacement(control)); + } + control.appendTail(result); + return result.toString(); + } + + // PRIVATE + + private final Context fContext; + private final String fOriginalBody; + private final Style fUseCaseStyle; + + /* + All of these fields hold dynamic data, and are updated for each control + found in the tag body. Not all of these items apply to all controls. + */ + private String fControl; //entire text of the supported control, as extracted from body + private String fControlFlavor; //distinguishes INPUT, SELECT, OPTION, and TEXTAREA + private String fNameAttr; //required in all supported controls + private String fTypeAttr; //required in all INPUT tags only + private String fValueAttr; //optional for INPUT and OPTION tags + + /* The significant values of fControlFlavor. */ + private static final String SELECT = "select"; + private static final String INPUT = "input"; + private static final String TEXT_AREA = "textarea"; + + /* The significant values of the fTypeAttr. */ + private static final String RADIO = "radio"; + private static final String CHECKBOX = "checkbox"; + private static final String TEXT = "text"; + private static final String PASSWORD = "password"; + private static final String HIDDEN = "hidden"; + + /* HTML5 types (non date-time) */ + private static final String SEARCH = "search"; //same as text + private static final String EMAIL = "email"; //text, but can be multi-valued! + private static final String URL = "url"; //same as text + private static final String TELEPHONE = "tel"; //same as text + private static final String NUMBER = "number"; //can have a fractional part, floating point; same as Decimal? or BigDecimal? or both? + + /*HTML controls which are always submitted, and never null, according to the HTML5 draft specification. */ + private static final String RANGE = "range"; //can have a decimal + private static final String COLOR = "color"; + + /* HTML5 date-time types - not yet supported; browser support is poor, and inconsistencies lead to errors. */ + private static final String DATE = "date"; //no offset; 2012-05-01 + private static final String TIME = "time"; //no offset; 01:23|01:23:45|01:23:45.123 + private static final String DATETIME_LOCAL = "datetime-local"; //no offset 2012-05-31T00:01:05.123 (trailing parts optional) + private static final String MONTH = "month"; //no offset; 2012-05 actually includes year-and-month + private static final String WEEK = "week"; //no offset; 2012-W53 + private static final String DATETIME = "datetime"; //WITH offset DO I SUPPORT THIS? For DateTime, no. For general text, yes. + + /* Patterns which retrieve attribute values. */ + private static final Pattern NAME_PATTERN = getPatternForAttrValue("name"); + private static final Pattern TYPE_PATTERN = getPatternForAttrValue("type"); + private static final Pattern VALUE_PATTERN = getPatternForAttrValue("value"); + + /** Returns value of the attribute as group 1. */ + private static final Pattern getPatternForAttrValue(String aAttrName){ + String regex = " " + aAttrName + "=" + Regex.QUOTED_ATTR; + return Pattern.compile(regex, Pattern.CASE_INSENSITIVE); + } + + /** If match found, then the type attribute is supported by this class. */ + private static final Pattern SUPPORTED_TYPE_PATTERN = Pattern.compile( "(" + + RADIO + "|" + CHECKBOX + "|" + TEXT + "|" + HIDDEN + "|" + PASSWORD + "|" + + SEARCH + "|" + EMAIL + "|" + URL + "|" + TELEPHONE + "|" + NUMBER + "|" + COLOR + "|" + RANGE + "|" + + ")", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern CONTROL_PATTERN = Pattern.compile( + "(?:" + "<(input) " + Regex.ALL_BUT_END_OF_TAG + ">" + + "|" + + "<(textarea) " + Regex.ALL_BUT_END_OF_TAG + ">" + Regex.WS + + Regex.ALL_BUT_START_OF_TAG + + "" + + "|" + + "<(select)" + Regex.ALL_BUT_END_OF_TAG + ">" + Regex.WS + + "(?:" + Regex.ALL_BUT_START_OF_TAG + + "" + Regex.WS + ")+" + + ")", + Pattern.CASE_INSENSITIVE | Pattern.DOTALL + ); + + private static final Pattern FLAVOR_PATTERN = Pattern.compile( + Regex.START_TAG + "(input|select|option|textarea)", Pattern.CASE_INSENSITIVE + ); + + /** If match found, then item contains the 'checked' attribute. */ + private static final Pattern CHECKED_PATTERN = Pattern.compile( + "( checked)", Pattern.CASE_INSENSITIVE + ); + + /** If match found, then item contains the 'selected' attribute. */ + private static final Pattern SELECTED_PATTERN = Pattern.compile( + "( selected)", Pattern.CASE_INSENSITIVE + ); + + /** + Fetches an entire OPTION tag. + Group 1 is the value attr, group 2 is the trimmed text of the option tag's body. + */ + private static final Pattern OPTION_PATTERN = Pattern.compile( + Regex.START_TAG + "option" + + "(?: selected" + + "| value=" + Regex.QUOTED_ATTR +"|"+ Regex.ALL_BUT_END_OF_TAG + ")*" + + Regex.END_TAG + + Regex.TRIMMED_TEXT + "", + Pattern.CASE_INSENSITIVE + ); + + /** The body of the textarea tag, plus the bounding start and end tags. */ + private static final Pattern TEXT_AREA_BODY_PATTERN = Pattern.compile( + Regex.END_TAG + Regex.ALL_BUT_START_OF_TAG + Regex.START_TAG, + Pattern.CASE_INSENSITIVE + ); + + private static final Class[] NO_ARGS = new Class[]{}; + private static final Object[] NO_PARAMS = null; + private static final Logger fLogger = Util.getLogger(PopulateHelper.class); + + /** Get a possible replacement for the given control. */ + private String getReplacement(Matcher aMatchForControl) throws JspException { + setControl(aMatchForControl); + setControlFlavor(); + setOptionalItems(); + String result = isSupportedControl() ? EscapeChars.forRegex(getPossiblyEditedControl()) : fControl; + return result; + } + + private void setControl(Matcher aMatchForControl){ + fControl = aMatchForControl.group(Regex.ENTIRE_MATCH); + fLogger.finer("Found supported control: " + fControl); + } + + private void setControlFlavor() throws JspException { + Matcher matchForFlavor = FLAVOR_PATTERN.matcher(fControl); + if ( matchForFlavor.find() ) { + fControlFlavor = matchForFlavor.group(Regex.FIRST_GROUP); + fLogger.finer("Flavor of supported control: " + fControlFlavor); + } + else { + throw new JspException("No flavor found for supported control."); + } + } + + /** + This method serves as a defensive measure. It ensures that items which do + not apply to every control are set to null, and thus no "mixing" of data between + controls is possible. As well, erroneous use of an item which has not been + populated for that control will result in an immediate NullPointerException. + */ + private void setOptionalItems(){ + fNameAttr = null; + fTypeAttr = null; + fValueAttr = null; + } + + /** Some input controls are explicitly NOT supported: file, reset, submit. */ + private boolean isSupportedControl() throws JspException { + boolean result = true; + if ( isInputTag() ){ + fTypeAttr = getCompulsoryAttrValue(TYPE_PATTERN); + fLogger.finer("Type attr: " + fTypeAttr); + result = Util.matches(SUPPORTED_TYPE_PATTERN, fTypeAttr); + } + return result; + } + + + private String getPossiblyEditedControl() throws JspException { + String result = ""; + fNameAttr = getCompulsoryAttrValue(NAME_PATTERN); + fLogger.finer("Control name: " + Util.quote(fNameAttr)); + if( fUseCaseStyle == Style.USE_MODEL_OBJECT || fUseCaseStyle == Style.MUST_RECYCLE_PARAMS ) { + result = getEditedControl(); + } + else if(fUseCaseStyle == Style.RECYCLE_PARAM_IF_PRESENT){ + if( fContext.hasRequestParamNamed(fNameAttr) ) { + result = getEditedControl(); + } + else { + result = getUneditedControl(); + } + } + return result; + } + + /** Return the control without any editing - just a simple echo of the tag's content. */ + private String getUneditedControl(){ + return fControl; + } + + /** Perform the core task of editing a control. */ + private String getEditedControl() throws JspException { + String result = null; + if ( isInputTag() ){ + if ( isTextLike() ) { + //single-valued + fLogger.finer("Editing 'value' attr for text pr text-like field"); + result = editTextBox(EscapeChars.forHTML(getPrepopValue())); + } + else if ( isRadioButton() ){ + //single-valued + fLogger.finer("Editing 'checked' attr for radio field"); + result = editRadioButton(getPrepopValue()); + } + else if ( isCheckbox() ){ + //may be single- or multi-valued + fLogger.finer("Editing 'checked' attr for checkbox field"); + result = editCheckBox(getPrepopValues()); + } + } + else if ( isSelectTag() ) { + //may or may not be multi-valued, depending on the + //presence of the 'multiple' attribute + fLogger.finer("Editing 'selected' attr for option fields"); + result = editOption(getPrepopValues()); + } + else if ( isTextAreaTag() ) { + //single-valued + fLogger.finer("Editing tag body for textarea field"); + result = editTextArea(EscapeChars.forHTML(getPrepopValue())); + } + else { + throw new AssertionError("Unexpected control flavor: " + fControlFlavor); + } + fLogger.finest("Edited control: " + Util.quote(result)); + return result; + } + + private boolean isInputTag(){ + return fControlFlavor.equalsIgnoreCase(INPUT); + } + + private boolean isSelectTag(){ + return fControlFlavor.equalsIgnoreCase(SELECT); + } + + private boolean isTextAreaTag() { + return fControlFlavor.equalsIgnoreCase(TEXT_AREA); + } + + /** That is, is NOT a radio or check-box. Such controls use 'value', not 'checked'. */ + private boolean isTextLike(){ + return + fTypeAttr.equalsIgnoreCase(TEXT) || + fTypeAttr.equalsIgnoreCase(PASSWORD) || + fTypeAttr.equalsIgnoreCase(HIDDEN) || + fTypeAttr.equalsIgnoreCase(SEARCH) || + fTypeAttr.equalsIgnoreCase(EMAIL) || + fTypeAttr.equalsIgnoreCase(URL) || + fTypeAttr.equalsIgnoreCase(TELEPHONE) || + fTypeAttr.equalsIgnoreCase(NUMBER) || + fTypeAttr.equalsIgnoreCase(COLOR) || + fTypeAttr.equalsIgnoreCase(RANGE) + ; + } + + private boolean isRadioButton(){ + return fTypeAttr.equalsIgnoreCase(RADIO); + } + + private boolean isCheckbox(){ + return fTypeAttr.equalsIgnoreCase(CHECKBOX); + } + + /** Return the first group of the first match found in fControl. */ + private String getCompulsoryAttrValue(Pattern aPattern) throws JspException { + String result = null; + Matcher matcher = aPattern.matcher(fControl); + if ( matcher.find() ) { + result = matcher.group(Regex.FIRST_GROUP); + } + else { + throw new JspException( + "Cannot prepopulate, since does not contain expected attribute: " + fControl + " using Pattern " + aPattern + ); + } + return result; + } + + /** aPrepopValue is possibly-null. */ + private String editTextBox(String aPrepopValue){ + String result = null; + String target = getTargetValueAttr(aPrepopValue); + Matcher matchForValue = VALUE_PATTERN.matcher(fControl); + if ( matchForValue.find() ) { + fLogger.finest("Replacing value attribute using target : " + Util.quote(target)); + result = matchForValue.replaceAll(target); + } + else { + result = addAttribute(target); + } + return result; + } + + private String getTargetValueAttr(String aPrepopValue){ + String result = " value=" + DOUBLE_QUOTE + aPrepopValue + DOUBLE_QUOTE; + return EscapeChars.forRegex(result); + } + + /** Add an attribute immediately after the start of the tag. */ + private String addAttribute(String aTarget){ + fLogger.finest("Adding attribute, with target :" + aTarget); + Matcher matchForFlavor = FLAVOR_PATTERN.matcher(fControl); + return matchForFlavor.replaceAll("$0" + aTarget); + } + + /** aPrepopValue may be null. */ + private String editRadioButton(String aPrepopValue) throws JspException { + String result = fControl; //used if no edits needed + fValueAttr = getCompulsoryAttrValue(VALUE_PATTERN); + Matcher matchForChecked = CHECKED_PATTERN.matcher(fControl); + if ( valueMatchesPrepop(aPrepopValue) ) { + if ( ! matchForChecked.find() ) { + result = addAttribute(" checked"); + } + } + else { + if ( matchForChecked.find() ) { + result = matchForChecked.replaceAll(EMPTY_STRING); + } + } + return result; + } + + private String editCheckBox(Collection aPrepopValues) throws JspException { + String result = fControl; //used if no edits needed + fValueAttr = getCompulsoryAttrValue(VALUE_PATTERN); + Matcher matchForChecked = CHECKED_PATTERN.matcher(fControl); + if ( valueMatchesPrepop(aPrepopValues) ) { + if ( ! matchForChecked.find() ) { + result = addAttribute(" checked"); + } + } + else { + if ( matchForChecked.find() ) { + result = matchForChecked.replaceAll(EMPTY_STRING); + } + } + return result; + } + + /** aPrepopValue is possibly-null. */ + private boolean valueMatchesPrepop(String aPrepopValue){ + return fValueAttr.equals(aPrepopValue); + } + + private boolean valueMatchesPrepop(Collection aPrepopValues){ + return aPrepopValues.contains(fValueAttr); + } + + private String editTextArea(String aPrepopValue){ + String result = null; + Matcher matchForTextBody = TEXT_AREA_BODY_PATTERN.matcher(fControl); + if ( matchForTextBody.find() ) { + result = matchForTextBody.replaceAll( getTargetTextBody(aPrepopValue) ); + } + return result; + } + + private String getTargetTextBody(String aPrepopValue){ + String result = ">" + aPrepopValue + "<"; + return EscapeChars.forRegex(result); + } + + private String editOption(Collection aPrepopValues){ + String result = fControl; //used if no edits needed + Matcher matchForOption = OPTION_PATTERN.matcher(fControl); + while ( matchForOption.find() ){ + String option = matchForOption.group(Regex.ENTIRE_MATCH); + fLogger.finer("Found option :" + option); + Matcher matchForSelected = SELECTED_PATTERN.matcher(option); + String valueAttr = getValueAttrForOption(matchForOption); + fLogger.finest("Value attr: " + valueAttr); + if ( aPrepopValues.contains(valueAttr) ) { + String editedOption = ensureSelected(matchForSelected, option); + result = result.replaceAll(EscapeChars.forRegex(option), editedOption); + } + else { + String editedOption = ensureUnselected(matchForSelected, option); + result = result.replaceAll(EscapeChars.forRegex(option), editedOption); + } + } + fLogger.finest("Edited select tag: " + result); + return result; + } + + private String ensureSelected(Matcher aMatchForSelected, String aOption){ + String result = aOption; //use if no edit needed + if ( ! aMatchForSelected.find() ){ + Matcher matchForFlavor = FLAVOR_PATTERN.matcher(aOption); + result = matchForFlavor.replaceAll("$0" + " selected"); + } + fLogger.finest("Edited option: " + result); + return result; + } + + private String ensureUnselected(Matcher aMatchForSelected, String aOption) { + String result = aOption; //use if no edits needed + if ( aMatchForSelected.find() ){ + result = aMatchForSelected.replaceAll(EMPTY_STRING); + } + fLogger.finest("Edited option: " + result); + return result; + } + + private String getValueAttrForOption(Matcher aOptionMatcher){ + String value = aOptionMatcher.group(Regex.FIRST_GROUP); + String text = aOptionMatcher.group(Regex.SECOND_GROUP); + return Util.textHasContent(value) ? value : text; + } + + /* + + The remaining methods below relate to extracting data from the HTTP request context. + + Note that SELECT items are always modeled as Collections of Strings. In the common case + of not allowing multiple selections, the Collection will contain only one element. + + Several methods come in pairs, one of which is simply the plural form of another, + with a final 's' at the end. The 'plural' methods are called only for SELECT tags. + */ + + /** Returns possibly-null String. */ + private String getPrepopValue() throws JspException { + String result = null; + if ( fContext.isModelObjectPresent() ) { + fLogger.finer("Getting prepop from model object."); + result = getPropertyValue(); + } + else{ + fLogger.finer("Getting prepop from params."); + result = fContext.getReqParamValue(fNameAttr); + } + fLogger.finest("Prepop value: " + result); + return result; + } + + /** + Returns an immutable Collection of Strings (may have no elements). + + This method is called only for CHECKBOX and SELECT elements, which may allow multiple values. + In the common case when the SELECT does not include the 'multiple' attr, there + will be only one element in the returned value. + */ + private Collection getPrepopValues() throws JspException { + Collection result = null; + if ( fContext.isModelObjectPresent() ) { + fLogger.finer("Getting prepops from model object."); + result = getPropertyValues(); + } + else{ + fLogger.finer("Getting prepops from params."); + result = fContext.getReqParamValues(fNameAttr); + } + fLogger.finest("Prepop values: " + result); + return result; + } + + /** + Return getXXX value as string, after applying {@link Formats#objectToText}. + */ + private String getPropertyValue() throws JspException { + return fContext.getFormats().objectToText(getReturnValue()); + } + + /** + Return an non-empty unmodifiable Collection of Strings. + +

If the corresponding getXXX does not return a Collection: +

    +
  • call {@link Formats#objectToText} on the return value of getXXX +
  • return that single item wrapped in a Collection. +
+ +

If the corresponding getXXX method returns a Collection, then +

    +
  • call {@link Formats#objectToText} on each item in the Collection +
  • add each string to the result of this method +
+ */ + private Collection getPropertyValues() throws JspException { + Collection result = new ArrayList(); + Object returnValue = getReturnValue(); + if ( returnValue instanceof Collection) { + Collection returnValueCollection = (Collection) getReturnValue(); //OK + Iterator iter = returnValueCollection.iterator(); + while ( iter.hasNext() ) { + Object value = iter.next(); + result.add( fContext.getFormats().objectToText(value) ); + } + } + else { + result.add( fContext.getFormats().objectToText(returnValue) ); + } + return Collections.unmodifiableCollection(result); + } + + /** Returns possibly-null Object. */ + private Object getReturnValue() throws JspException { + Object result = null; + Class modelObjectClass = fContext.getModelObject().getClass(); + try { + String methodName = "get" + Util.withNoSpaces(Util.withInitialCapital(fNameAttr)); + Method getMethod = modelObjectClass.getMethod(methodName, NO_ARGS); + /* + Implementation Note + Here, the Reflection API wraps any primitive return value by using the + corresponding wrapper class. Thus, the underlying model object is not constrained + to use wrappers instead of primitives. + */ + result = getMethod.invoke(fContext.getModelObject(), NO_PARAMS); + } + catch (NoSuchMethodException ex){ + throw new JspException( + "Missing expected public method : get" + fNameAttr + " in " + modelObjectClass + ); + } + catch (IllegalAccessException ex ){ + throw new JspException("Illegal Access for method : get" + fNameAttr); + } + catch (InvocationTargetException ex){ + throw new JspException( + "Method through an exception : get" + fNameAttr + " named " + ex.getCause() + ); + } + return result; + } +} \ No newline at end of file diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ui/tag/ShowDate.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ui/tag/ShowDate.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,262 @@ +package hirondelle.web4j.ui.tag; + +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.util.TimeSource; +import hirondelle.web4j.request.DateConverter; +import hirondelle.web4j.request.TimeZoneSource; +import hirondelle.web4j.ui.translate.Translator; +import hirondelle.web4j.util.Util; +import static hirondelle.web4j.util.Consts.NOT_FOUND; +import static hirondelle.web4j.util.Consts.EMPTY_STRING; + +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.logging.Logger; + +/** + Display a {@link Date} in a particular format. + +

This class uses: +

    +
  • {@link hirondelle.web4j.request.LocaleSource} to determine the Locale associated with the current request +
  • {@link TimeZoneSource} for the time zone associated with the current request +
  • {@link DateConverter} to format the given date +
  • {@link Translator} for localizing the argument passed to {@link #setPatternKey}. +
+ +

Examples

+

Display the current system date : +

{@code 
+
+}
+ +

Display a specific Date object, present in any scope : +

<w:showDate name="dateOfBirth"/>
+ +

Display a date returned by some object in scope : +

{@code 
+
+
+}
+ +

Display with a non-default date format : +

<w:showDate name="lunchDate" pattern="E, MMM dd"/>
+ +

Display with a non-default date format sensitive to {@link Locale} : +

<w:showDate name="lunchDate" patternKey="next.visit.lunch.date"/>
+ +

Display in a specific time zone : +

<w:showDate name="lunchDate" timeZone="America/Montreal"/>
+ +

Suppress the display of midnight, using a pipe-separated list of 'midnights' : +

<w:showDate name="lunchDate" suppressMidnight="12:00 AM|00 h 00"/>
+*/ +public final class ShowDate extends TagHelper { + + /** + Optionally set the name of a {@link Date} object already present in some scope. + Searches from narrow to wide scope to find the corresponding Date. + +

If this method is called and no corresponding object can be found using the + given name, then this tag will emit an empty String. + +

If this method is not called at all, then the current system date is used, as + defined by the configured {@link TimeSource}. + + @param aName must have content. + */ + public void setName(String aName){ + checkForContent("Name", aName); + Object object = getPageContext().findAttribute(aName); + if ( object == null ) { + fDateObjectMissing = true; + fLogger.fine("Cannot find object named " + Util.quote(aName) + " in any scope. Page Name : " + getPageName()); + } + else { + if ( ! (object instanceof Date) ) { + throw new IllegalArgumentException( + "Object named " + Util.quote(aName) + " is not a java.util.Date. Page Name :" + getPageName() + ); + } + fDate = (Date)object; + } + } + + /** + Optionally set the format for rendering the date. + +

Setting this attribute will override the default format of + {@link DateConverter#formatEyeFriendly(Date, Locale, TimeZone)}. + +

Calling this method is suitable only when + the date format does not depend on {@link Locale}. Otherwise, + {@link #setPatternKey(String)} must be used instead. + +

Only one of {@link #setPattern(String)} and {@link #setPatternKey(String)} + can be called at a time. + + @param aDateFormat has content, and is in the form expected by + {@link java.text.SimpleDateFormat}. + */ + public void setPattern(String aDateFormat){ + checkForContent("Pattern", aDateFormat); + fDateFormat = new SimpleDateFormat(aDateFormat, getLocale()); + } + + /** + Optionally set the format for rendering the date according to {@link Locale}. + +

Setting this attribute will override the default format of + {@link DateConverter#formatEyeFriendly(Date, Locale, TimeZone)}. + +

This method uses a {@link Translator} to look up the "real" + date pattern to be used, according to the {@link Locale} returned + by {@link hirondelle.web4j.request.LocaleSource}. + +

For example, if the value 'format.next.lunch.date' is passed to + this method, then that value is passed to a {@link Translator}, which will return + a pattern specific to the {@link Locale} attached to this request, such as + 'EEE, dd MMM'. + +

Only one of {@link #setPattern(String)} and {@link #setPatternKey(String)} + can be called at a time. + + @param aFormatKey has content, and, when passed to {@link Translator}, will + return a date pattern in the form expected by {@link java.text.SimpleDateFormat}. + */ + public void setPatternKey(String aFormatKey){ + checkForContent("PatternKey", aFormatKey); + fDateFormatKey = aFormatKey; + } + + /** + Optionally set the {@link TimeZone} for formatting the date. + +

If this attribute is not set, then {@link TimeZoneSource} is used. + + @param aCustomTimeZone in the style expected by {@link TimeZone#getTimeZone(java.lang.String)}. + If the format is not in the expected style, then UTC is used (same as Greenwich Mean Time). + */ + public void setTimeZone(String aCustomTimeZone){ + fCustomTimeZone = TimeZone.getTimeZone(aCustomTimeZone); + } + + /** + Optionally suppress the display of midnight. + +

For example, set this attribute to '00:00:00' to force '1999-12-31 00:00:00' to display as + 1999-12-31, without the time. + +

If this attribute is set, and if any of the aMidnightStyles is found anywhere in the formatted date, + then the formatted date is truncated, starting from the given midnight style. That is, all text appearing after + the midnight style is removed, including any time zone information. (Then the result is trimmed.) + + @param aMidnightStyles is pipe-separated list of Strings which denote the possible forms of + midnight. Example value : '00:00|00 h 00'. + */ + public void setSuppressMidnight(String aMidnightStyles){ + StringTokenizer parser = new StringTokenizer(aMidnightStyles, "|"); + while ( parser.hasMoreElements() ){ + fMidnightStyles = new ArrayList(); + String midnightStyle = (String)parser.nextElement(); + if( Util.textHasContent(midnightStyle)){ + fMidnightStyles.add(midnightStyle.trim()); + } + } + fLogger.fine("Midnight styles: " + fMidnightStyles); + } + + protected void crossCheckAttributes() { + if(fDateFormatKey != null && fDateFormat != null){ + String message = "Cannot specify both 'pattern' and 'patternKey' attributes at the same time. Page Name : " + getPageName(); + fLogger.severe(message); + throw new IllegalArgumentException(message); + } + } + + @Override protected String getEmittedText(String aOriginalBody) { + String result = EMPTY_STRING; + if( fDateObjectMissing ) return result; + + Locale locale = getLocale(); + TimeZone timeZone = getTimeZone(); + if(fDateFormat == null && fDateFormatKey == null){ + DateConverter dateConverter = BuildImpl.forDateConverter(); + result = dateConverter.formatEyeFriendly(fDate, locale, timeZone); + } + else if(fDateFormat != null && fDateFormatKey == null){ + adjustForTimeZone(fDateFormat, timeZone); + result = fDateFormat.format(fDate); + } + else if(fDateFormat == null && fDateFormatKey != null){ + Translator translator = BuildImpl.forTranslator(); + String localPattern = translator.get(fDateFormatKey, locale); + DateFormat localDateFormat = new SimpleDateFormat(localPattern, locale); + adjustForTimeZone(localDateFormat, timeZone); + result = localDateFormat.format(fDate); + } + else { + throw new IllegalArgumentException("Cannot specify both 'pattern' and 'patternKey' attributes at the same time. Page Name : " + getPageName()); + } + if( hasMidnightStyles() ) { + result = removeMidnightIfPresent(result); + } + return result; + } + + // PRIVATE + private Date fDate = new Date(BuildImpl.forTimeSource().currentTimeMillis()); //defaults to 'now' + + /** Flags if a named object is not found in any scope. */ + private boolean fDateObjectMissing = false; + + private DateFormat fDateFormat; + private String fDateFormatKey; + private TimeZone fCustomTimeZone; + private List fMidnightStyles = new ArrayList(); + private static final Logger fLogger = Util.getLogger(ShowDate.class); + + private void adjustForTimeZone(DateFormat aFormat, TimeZone aTimeZone){ + aFormat.setTimeZone(aTimeZone); + } + + private Locale getLocale(){ + return BuildImpl.forLocaleSource().get(getRequest()); + } + + private TimeZone getTimeZone(){ + TimeZone result = null; + if( fCustomTimeZone != null ){ + result = fCustomTimeZone; + } + else { + TimeZoneSource timeZoneSource = BuildImpl.forTimeZoneSource(); + result = timeZoneSource.get(getRequest()); + } + return result; + } + + private boolean hasMidnightStyles(){ + return ! fMidnightStyles.isEmpty(); + } + + private String removeMidnightIfPresent(String aFormattedDate){ + String result = aFormattedDate; + for(String midnightStyle : fMidnightStyles){ + if ( hasMidnight(aFormattedDate, midnightStyle) ){ + result = removeMidnight(aFormattedDate, midnightStyle); + } + } + return result.trim(); + } + + private boolean hasMidnight(String aFormattedDate, String aMidnightStyle){ + return aFormattedDate.indexOf(aMidnightStyle) != NOT_FOUND; + } + + private String removeMidnight(String aFormattedDate, String aMidnightStyle){ + int midnight = aFormattedDate.indexOf(aMidnightStyle); + return aFormattedDate.substring(0,midnight); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ui/tag/ShowDateTime.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ui/tag/ShowDateTime.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,246 @@ +package hirondelle.web4j.ui.tag; + +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.util.TimeSource; +import hirondelle.web4j.request.DateConverter; +import hirondelle.web4j.ui.translate.Translator; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.model.DateTime; +import static hirondelle.web4j.util.Consts.NOT_FOUND; +import static hirondelle.web4j.util.Consts.EMPTY_STRING; + +import java.util.*; +import java.util.logging.Logger; + +/** + Custom tag to display a {@link DateTime} in a particular format. + +

This class uses: +

    +
  • {@link hirondelle.web4j.request.LocaleSource} to determine the Locale associated with the current request +
  • {@link DateConverter} to format the given date +
  • {@link Translator} for localizing the argument passed to {@link #setPatternKey}. +
+ +

Examples

+

Display the current system date, with the default format defined by {@link DateConverter} : +

{@code 
+
+}
+ +

Display a specific date object, present in any scope : +

<w:showDateTime name="dateOfBirth"/>
+ +

Display a date returned by some object in scope : +

{@code 
+
+
+}
+ +

Display with a non-default date format : +

<w:showDateTime name="lunchDate" pattern="YYYY-MM-DD"/>
+ +

Display with a non-default date format sensitive to {@link Locale} : +

<w:showDateTime name="lunchDate" patternKey="next.visit.lunch.date"/>
+ +

Suppress the display of midnight, using a pipe-separated list of 'midnights' : +

<w:showDateTime name="lunchDate" suppressMidnight="12:00 AM|00 h 00"/>
+*/ +public final class ShowDateTime extends TagHelper { + + /** + Optionally set the name of a {@link DateTime} object already present in some scope. + Searches from narrow to wide scope to find the corresponding object. + +

If this method is called and no corresponding object can be found using the + given name, then this tag will emit an empty String. + +

If this method is not called at all, then the current system date is used, as + defined by the configured {@link TimeSource}. + + @param aName must have content. + */ + public void setName(String aName){ + checkForContent("Name", aName); + Object object = getPageContext().findAttribute(aName); + if ( object == null ) { + handleErrorCondition("Cannot find object named " + Util.quote(aName) + " in any scope."); + } + else { + if (object instanceof DateTime){ + fTarget = Target.OBJECT_DATE_TIME; + fDateTime = (DateTime)object; + } + else { + handleErrorCondition( + "Object named " + Util.quote(aName) + " is not a hirondelle.web4j.model.DateTime. It is a " + + object.getClass().getName() + ); + } + } + } + + /** + Optionally set the format for rendering the date. + +

Setting this attribute will override the default format used by + {@link DateConverter}. + +

Calling this method is suitable only when + the date format does not depend on {@link Locale}. Otherwise, + {@link #setPatternKey(String)} must be used instead. + +

Only one of {@link #setPattern(String)} and {@link #setPatternKey(String)} + can be called at a time. + + @param aFormat has content, and is a date format suitable for the format . + methods of {@link DateTime}. + */ + public void setPattern(String aFormat){ + checkForContent("Pattern", aFormat); + fFormat = aFormat; + } + + /** + Optionally set the format for rendering the date according to {@link Locale}. + +

Setting this attribute will override the default format used by + {@link DateConverter}. + +

This method uses a {@link Translator} to look up the "real" + date pattern to be used, according to the {@link Locale} returned + by {@link hirondelle.web4j.request.LocaleSource}. + +

For example, if the value 'format.next.lunch.date' is passed to + this method, then that value is passed to a {@link Translator}, which will return + a pattern specific to the {@link Locale} attached to this request, such as + 'EEE, dd MMM' (for a Date) or YYYY-MM-DD (for a {@link DateTime}). + +

Only one of {@link #setPattern(String)} and {@link #setPatternKey(String)} + can be called at a time. + + @param aFormatKey has content, and, when passed to {@link Translator}, will + return a date format suitable for the format methods of {@link DateTime}. + */ + public void setPatternKey(String aFormatKey){ + checkForContent("PatternKey", aFormatKey); + fFormatKey = aFormatKey; + } + + /** + Optionally suppress the display of midnight. + +

For example, set this attribute to '00:00:00' to force '1999-12-31 00:00:00' to display as + 1999-12-31, without the time. + +

If this attribute is set, and if any of the aMidnightStyles is found anywhere in the formatted date, + then the formatted date is truncated, starting from the given midnight style. That is, all text appearing after + the midnight style is removed, including any time zone information. (Then the result is trimmed.) + + @param aMidnightStyles is pipe-separated list of Strings which denote the possible forms of + midnight. Example value : '00:00|00 h 00'. + */ + public void setSuppressMidnight(String aMidnightStyles){ + StringTokenizer parser = new StringTokenizer(aMidnightStyles, "|"); + while ( parser.hasMoreElements() ){ + fMidnightStyles = new ArrayList(); + String midnightStyle = (String)parser.nextElement(); + if( Util.textHasContent(midnightStyle)){ + fMidnightStyles.add(midnightStyle.trim()); + } + } + fLogger.fine("Midnight styles: " + fMidnightStyles); + } + + protected void crossCheckAttributes() { + if(fFormatKey != null && fFormat != null){ + handleErrorCondition("Cannot specify both 'pattern' and 'patternKey' attributes at the same time."); + } + } + + @Override protected String getEmittedText(String aOriginalBody) { + String result = EMPTY_STRING; + if( fFoundError ) return result; + + if(Target.CURRENT_DATE_TIME == fTarget){ + fDateTime = DateTime.now(BuildImpl.forTimeZoneSource().get(getRequest())); + } + result = formatDateTime(); + + if( hasMidnightStyles() ) { + result = removeMidnightIfPresent(result); + } + return result; + } + + // PRIVATE + private DateTime fDateTime; //defaults to now; cannot init here, since request does not exist yet + + /** The item to be formatted. */ + private enum Target { + /** If no object is specified at all, then the current date time is assumed. */ + CURRENT_DATE_TIME, + OBJECT_DATE_TIME, + } + private Target fTarget = Target.CURRENT_DATE_TIME; //default + + private String fFormat; + private String fFormatKey; + private List fMidnightStyles = new ArrayList(); + + /** Flags presence of error conditions. If true, then only an empty String is emitted. */ + private boolean fFoundError; + + private static final Logger fLogger = Util.getLogger(ShowDate.class); + + private String formatDateTime(){ + String result = ""; + Locale locale = getLocale(); + if(fFormat == null && fFormatKey == null){ + DateConverter dateConverter = BuildImpl.forDateConverter(); + result = dateConverter.formatEyeFriendlyDateTime(fDateTime, locale); + } + else if(fFormat != null && fFormatKey == null){ + result = fDateTime.format(fFormat, getLocale()); + } + else if(fFormat == null && fFormatKey != null){ + Translator translator = BuildImpl.forTranslator(); + String localPattern = translator.get(fFormatKey, locale); + result = fDateTime.format(localPattern, locale); + } + return result; + } + + private Locale getLocale(){ + return BuildImpl.forLocaleSource().get(getRequest()); + } + + private boolean hasMidnightStyles(){ + return ! fMidnightStyles.isEmpty(); + } + + private String removeMidnightIfPresent(String aFormattedDate){ + String result = aFormattedDate; + for(String midnightStyle : fMidnightStyles){ + if ( hasMidnight(aFormattedDate, midnightStyle) ){ + result = removeMidnight(aFormattedDate, midnightStyle); + } + } + return result.trim(); + } + + private boolean hasMidnight(String aFormattedDate, String aMidnightStyle){ + return aFormattedDate.indexOf(aMidnightStyle) != NOT_FOUND; + } + + private String removeMidnight(String aFormattedDate, String aMidnightStyle){ + int midnight = aFormattedDate.indexOf(aMidnightStyle); + return aFormattedDate.substring(0,midnight); + } + + private void handleErrorCondition(String aMessage){ + fFoundError = true; + String message = aMessage + " Page Name : " + getPageName(); + fLogger.severe(message); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ui/tag/ShowForRole.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ui/tag/ShowForRole.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,150 @@ +package hirondelle.web4j.ui.tag; + +import java.util.*; +import java.util.logging.*; +import java.security.Principal; +import hirondelle.web4j.util.Util; +import static hirondelle.web4j.util.Consts.PASSES; +import static hirondelle.web4j.util.Consts.FAILS; +import static hirondelle.web4j.util.Consts.EMPTY_STRING; + +/** + Toggle display according to the user's role. + +

It's important to note that the sole use of this + tag does not robustly enforce security constraints. + This tag is meant as a "cosmetic convenience" for removing items from JSPs (usually a link). + The problem is that a hacker can always construct any given URI manually and send it to the server. + Such malicious requests can only be handled robustly by a security-constraint + defined in web.xml. + +

Example: +

+ <w:show ifRole="webmaster,translator">
+   show tag content only if the user is logged in, 
+   and has at least 1 of the specified roles
+ </w:show>
+ 
+ + Example with role specified by negation: +
 
+ <w:show ifRoleNot="read-only">
+   show tag content only if the user is logged in, 
+   and has none of the specified roles
+ </w:show>
+ 
+ + Example with logic attached not to role, but simply whether or not the user has logged in: +
+ <w:show ifLoggedIn="true">
+   show tag content only if the user is logged in 
+ </w:show>
+ 
+ <w:show ifLoggedIn="false">
+   show tag content only if the user is not logged in 
+ </w:show>
+ + The above styles are all mutually exclusive. You can specify only 1 attribute at a time with this tag. + +

The body of this class is either echoed as is, or is suppressed entirely. + +

By definition (in the servlet specification), a user is logged in when request.getUserPrincipal() + returns a value having content. When a user is logged in, the container can assign + 1 or more roles to the user. Roles are only assigned after a successful login. +*/ +public final class ShowForRole extends TagHelper { + + /** Optional, comma-delimited list of accepted roles. */ + public void setIfRole(String aRoles){ + fAcceptedRoles = getRoles(aRoles); + } + + /** Optional, comma-delimited list of denied roles. */ + public void setIfRoleNot(String aRoles){ + fDeniedRoles = getRoles(aRoles); + } + + /** + Optional, simple flag indicating if user is or is not logged in. + @param aFlag - see {@link Util#parseBoolean(String)} for the list of accepted values. + */ + public void setIfLoggedIn(String aFlag){ + fIfLoggedIn = Util.parseBoolean(aFlag); //null if the flag is null + } + + /** + One and only one of the {@link #setIfRole}, {@link #setIfRoleNot}, or + {@link #setIfLoggedIn(String)} attributes must be set. + */ + protected void crossCheckAttributes() { + int numAttrsSpecified = 0; + if (isModeAcceptingRoles()) ++numAttrsSpecified; + if (isModeDenyingRoles()) ++numAttrsSpecified; + if (isModeLogin()) ++numAttrsSpecified; + + if(numAttrsSpecified != 1) { + String message = "Please specify 1 (and only 1) attribute for this tag: ifRole, ifRoleNot, or ifLoggedIn. Page Name: " + getPageName(); + fLogger.severe(message); + throw new IllegalArgumentException(message); + } + } + + /** See class comment. */ + @Override protected String getEmittedText(String aOriginalBody) { + boolean showBody = false; + Principal user = getRequest().getUserPrincipal(); + boolean isCurrentlyLoggedIn = (user != null); + + if (isModeAcceptingRoles() && isCurrentlyLoggedIn){ + showBody = hasOneOrMoreOfThe(fAcceptedRoles); + } + else if (isModeDenyingRoles() && isCurrentlyLoggedIn){ + showBody = ! hasOneOrMoreOfThe(fDeniedRoles); + } + else if (isModeLogin()){ + showBody = fIfLoggedIn ? isCurrentlyLoggedIn : ! isCurrentlyLoggedIn; + } + return showBody ? aOriginalBody : EMPTY_STRING; + } + + // PRIVATE + private List fAcceptedRoles = new ArrayList(); + private List fDeniedRoles = new ArrayList(); + /** The null-ity of this field is needed to indicate the 'unspecified' state. */ + private Boolean fIfLoggedIn; + + private static final String DELIMITER = ","; + private static final Logger fLogger = Util.getLogger(ShowForRole.class); + + private List getRoles(String aRawRoles){ + List result = new ArrayList(); + StringTokenizer parser = new StringTokenizer( aRawRoles, DELIMITER); + while ( parser.hasMoreTokens() ) { + result.add( parser.nextToken().trim() ); + } + return result; + } + + private boolean isModeAcceptingRoles(){ + return ! fAcceptedRoles.isEmpty(); + } + + private boolean isModeDenyingRoles(){ + return ! fDeniedRoles.isEmpty(); + } + + private boolean isModeLogin(){ + return fIfLoggedIn != null; + } + + private boolean hasOneOrMoreOfThe(List aRoles){ + boolean result = FAILS; + for (String role: aRoles){ + if ( getRequest().isUserInRole(role) ) { + result = PASSES; + break; + } + } + return result; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ui/tag/TESTFormPopulator.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ui/tag/TESTFormPopulator.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,1121 @@ +package hirondelle.web4j.ui.tag; + +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.TESTAll.DateConverterImpl; +import hirondelle.web4j.model.ModelCtorException; +import hirondelle.web4j.model.ModelUtil; +import hirondelle.web4j.request.DateConverter; +import hirondelle.web4j.request.Formats; +import hirondelle.web4j.ui.tag.Populate.Style; +import hirondelle.web4j.util.Consts; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.util.WebUtil; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Set; +import java.util.TimeZone; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Pattern; + +import javax.servlet.ServletException; +import javax.servlet.jsp.JspException; + +import junit.framework.TestCase; + + +/** + (UNPUBLISHED) JUnit test cases for + {@link PopulateHelper}. + + TO RUN THIS CLASS, some temporary adjustments need to be made to this class and the Formats class. + - 1 change to this class + - 2 changes to Formats + See TESTAll as well! +*/ +public final class TESTFormPopulator extends TestCase { + + /** Run the test cases. */ + public static void main(String args[]) throws FileNotFoundException, ServletException { + fLogger.setLevel(Level.OFF); + String[] testCaseName = { TESTFormPopulator.class.getName() }; + BuildImpl.adHocImplementationAdd(DateConverter.class, DateConverterImpl.class); + junit.textui.TestRunner.main(testCaseName); + } + + public TESTFormPopulator (String aName) { + super(aName); + } + + // TEST CASES // + + public void testSingleParamsWithInputTags() throws JspException, IOException { + //test no alteration of tag which is not related to input + testSingleParam("blah", "blah", "LoginName", "Strummer"); + //test no alteration of tag whose type(submit) is not supported + testSingleParam("", "", "LoginName", "Strummer"); + //text + testSingleParam("", "", "LoginName", "Strummer"); + //caps, spacing, quotes + testSingleParam("", "", "LoginName", "Strummer"); + //additional extraneous attr's + testSingleParam("", "", "LoginName", "Strummer"); + //password + testSingleParam("", "", "LoginPassword", "clash"); + //hidden + testSingleParam("", "", "LoginPassword", "clash"); + //search + testSingleParam("", "", "Bob", "clash"); + //email + testSingleParam("", "", "Bob", "joe@clash.com"); + //url + testSingleParam("", "", "Bob", "http://www.date4j.net"); + //tel + testSingleParam("", "", "Bob", "1-800-549-BLAH"); + //number + testSingleParam("", "", "Bob", "3.14"); + //color + testSingleParam("", "", "Bob", "#001122"); + //range + testSingleParam("", "", "Bob", "56"); + //radio with match + testSingleParam("", "", "StarRating", "1"); + //radio with no match + testSingleParam("", "", "StarRating", "1"); + //checkbox with match + testSingleParam("", "", "SendCard", "true"); + //checkbox with no match + testSingleParam("", "", "SendCard", "false"); + + //text with default is overwritten + testSingleParam("", "", "LoginName", "Mick"); + //as above, but with hidden + testSingleParam("", "", "LoginName", "Mick"); + //as above, but with search + testSingleParam("", "", "LoginName", "Mick"); + //as above, but with number + testSingleParam("", "", "LoginName", "7.95"); + //text with default is overwritten even if param is empty + testSingleParam("", "", "LoginName", ""); + //as above, but with hidden + testSingleParam("", "", "LoginName", ""); + //as above, but with search + testSingleParam("", "", "LoginName", ""); + //text with default is overwritten even if default is empty + testSingleParam("", "", "LoginName", "Mick"); + //radio is checked by default, but no match, so is unchecked + testSingleParam("", "", "StarRating", "2"); + //as previous, but checked attr at end + testSingleParam("", "", "StarRating", "2"); + //radio is checked by default, and matches, so is unchanged + testSingleParam("", "", "StarRating", "1"); + //checkbox is checked by default, but no match, so is removed + testSingleParam("", "", "SendCard", "false"); + //checkbox is checked by default, with match, so is retained + testSingleParam("", "", "SendCard", "true"); + } + + public void testInputTagWithSpecialRegexChar() throws JspException, IOException { + //this test failed with the first version of PopulateHelper, since an item + //was not 'escaped' for special regex chars. As well, this case was untested + //in the original test cases. + testSingleParam("", "", "DesiredSalary", "100.00U$"); + } + + public void testBigFailures() throws JspException, IOException { + //invalid flavor + testBigFailure("", "", "LoginName", "Strummer"); + //compulsory attr not found (type) + testBigFailure("", "", "LoginName", "Strummer"); + //compulsory attr not found (name) + testBigFailure("", "", "LoginName", "Strummer"); + //no param of expected name in scope + testBigFailure("", "", "Login", "Strummer"); + } + + public void testModelObjectWithInputTags() throws JspException, IOException, ModelCtorException { + User user = getUser(); + //text + testModelObject("", "", user); + //test no alteration of tag which is not related to input + testModelObject("blah", "blah", user); + //test no alteration of tag whose type(submit) is not supported + testModelObject("", "", user); + //caps, spacing, quotes + testModelObject("", "", user); + //additional extraneous attr's + testModelObject("", "", user); + //password + testModelObject("", "", user); + //hidden + testModelObject("", "", user); + //search + testModelObject("", "", user); + //email + testModelObject("", "", user); + //number + testModelObject("", "", user); + //range + testModelObject("", "", user); + //color + testModelObject("", "", user); + + //radio with match + testModelObject("", "", user); + //radio with no match + testModelObject("", "", user); + //checkbox with match + testModelObject("", "", user); + //checkbox with no match + testModelObject("", "", user); + + //text with default is overwritten + testModelObject("", "", user); + //as above, but for hidden + testModelObject("", "", user); + //text with default is overwritten even if default is empty + testModelObject("", "", user); + //as above, but with hidden + testModelObject("", "", user); + //as above, but with search + testModelObject("", "", user); + //radio is checked by default, but no match, so is unchecked + testModelObject("", "", user); + //as previous, but checked attr at end + testModelObject("", "", user); + //radio is checked by default, and matches, so is unchanged + testModelObject("", "", user); + //checkbox is checked by default, but no match, so is removed + testModelObject("", "", user); + //checkbox is checked by default, with match, so is retained + testModelObject("", "", user); + + //basic sanity for Locale and TimeZone + testModelObject("", "", user); + testModelObject("", "", user); + } + + public void testFailBeanMethod() throws JspException, IOException, ModelCtorException { + try { + //name of tag does not match any bean getXXX method + testModelObject("", "", getUser()); + } + catch(Throwable ex){ + return; + } + fail("Should have failed."); + } + + public void testParamsWithSelectTags() throws JspException, IOException { + //basic prepop + testSelect( + "", + + "", + + "FavoriteTheory", new Object[] {"Quantum Electrodynamics"} + ); + //extra attr's + testSelect( + "", + + "", + + "FavoriteTheory", new Object[] {"Quantum Electrodynamics"} + ); + //with value attr and selected + testSelect( + "", + + "", + + "FavoriteTheory", new Object[] {"Quantum Flavordynamics"} + ); + //with value attr and not selected + testSelect( + "", + + "", + + "FavoriteTheory", new Object[] {"Quantum Electrodynamics"} + ); + //caps, spaces, and quotes + testSelect( + "", + + "", + + "FavoriteTheory", new Object[] {"Quantum Chromodynamics"} + ); + //retain default selected + testSelect( + "", + + "", + + "FavoriteTheory", new Object[] {"Quantum Flavordynamics"} + ); + //retain default selected when other attr's present + testSelect( + "", + + "", + + "FavoriteTheory", new Object[] {"Quantum Flavordynamics"} + ); + //remove default selected when no match + testSelect( + "", + + "", + + "FavoriteTheory", new Object[] {"Quantum Chromodynamics"} + ); + //extra attrs in select tag + testSelect( + "", + + "", + + "FavoriteTheory", new Object[] {"Quantum Electrodynamics"} + ); + //test leading and trailing spaces in body of option tag + testSelect( + "", + + "", + + "FavoriteTheory", new Object[] {"Quantum Electrodynamics"} + ); + //as above, with different selected + testSelect( + "", + + "", + + "FavoriteTheory", new Object[] {"Quantum Flavordynamics"} + ); + //as above, with different selected + testSelect( + "", + + "", + + "FavoriteTheory", new Object[] {"Quantum Chromodynamics"} + ); + //multiple selects + testSelect( + "", + + "", + + "FavoriteTheory", new Object[] {"Quantum Chromodynamics"} + ); + //special regex chars in extra attrs in option tag + testSelect( + "", + + "", + + "FavoriteTheory", new Object[] {"Quantum Electrodynamics"} + ); + } + + public void testFailedSelects() throws JspException, IOException, ModelCtorException { + testFailSelect( + "", + + "", + + "FaveTheory", new Object[] {"Quantum Electrodynamics"} + ); + } + + + public void testMultipleParamsWithSelectTags() throws JspException, IOException { + //basic + testSelect( + "", + + "", + + "FavoriteTheory", new Object[] {"Quantum Flavordynamics","Quantum Chromodynamics"} + ); + //value attr + testSelect( + "", + + "", + + "FavoriteTheory", new Object[] {"Quantum Flavordynamics","Quantum Chromodynamics"} + ); + //retain default selected + testSelect( + "", + + "", + + "FavoriteTheory", new Object[] {"Quantum Flavordynamics","Quantum Chromodynamics"} + ); + //remove default selected + testSelect( + "", + + "", + + "FavoriteTheory", new Object[] {"Quantum Flavordynamics","Quantum Chromodynamics"} + ); + } + + public void testBeanWithSelectTags() throws JspException, IOException, ModelCtorException { + //basic prepop + testModelObject( + "", + + "", + + getUser() + ); + //value attr, but not selected + testModelObject( + "", + + "", + + getUser() + ); + //value attr, and selected + testModelObject( + "", + + "", + + getUser() + ); + //retain default selected + testModelObject( + "", + + "", + + getUser() + ); + //remove default selected + testModelObject( + "", + + "", + + getUser() + ); + } + + public void testBeanWithMultiSelectTags() throws JspException, IOException, ModelCtorException { + //User is taken as a bean which does not return a Set, but a String - this can + //of course cause only one item to be selected + testModelObject( + "", + + "", + + getUser() + ); + //bean which returns a Set + //this class the bean whose getFavoriteTheory method returns a Set + //(A fake bean was attempted, but PopulateHelper barfs on nested classes.) + testModelObject( + "", + + "", + + this + ); + //defaults retained and removed + testModelObject( + "", + + "", + + this + ); + + } + + /* + See above method. + */ + public Set getFavoriteTheory(){ + Set result = new LinkedHashSet(); + result.add("Quantum Electrodynamics"); + result.add("Quantum Chromodynamics"); + return result; + } + + public void testArgsWithTextAreaTag() throws JspException, IOException, ModelCtorException { + //with default text + testSingleParam("", "", "Comment", "The Clash"); + //add other attr's + testSingleParam("", "", "Comment", "The Clash"); + //caps, spacing, quotes + testSingleParam("", "", "Comment", "The Clash"); + testSingleParam("", "", "Comment", "The Clash"); + //without default text + testSingleParam("", "", "Comment", "The Clash"); + testSingleParam("", "", "Comment", "The Clash"); + testSingleParam("", "", "Comment", "The Clash"); + //with new lines and white space + testSingleParam("", "", "Comment", "The Clash"); + testSingleParam("", "", "Comment", "The Clash"); + testSingleParam("", "", "Comment", "The Clash"); + testSingleParam("", "", "Comment", "The Clash"); + testSingleParam("", "", "Comment", "The Clash"); + //special regex chars in default body + testSingleParam("", "", "Comment", "The Clash"); + testSingleParam("", "", "Comment", "The Clash"); + testSingleParam("", "", "Comment", "The Clash"); + testSingleParam("", "", "Comment", "The Clash"); + testSingleParam("", "", "Comment", "The Clash"); + //special regex chars in replacememt + testSingleParam("", "", "Comment", "The Cla$h"); + testSingleParam("", "", "Comment", "$The Clash$"); + testSingleParam("", "", "Comment", "\\The Clash"); + } + + public void testBeanWithTextAreaTag() throws JspException, IOException, ModelCtorException { + User user = getUser(); + //with default text + testModelObject("", "", user); + //with other attr's + testModelObject("", "", user); + //caps, spacing, quotes + testModelObject("", "", user); + testModelObject("", "", user); + //without default text + testModelObject("", "", user); + //with new lines and white space + testModelObject("", "", user); + testModelObject("", "", user); + } + + // FIXTURE // + + protected void setUp(){ + //empty + } + + protected void tearDown() { + //empty + } + + // PRIVATE // + + private static final String NL = Consts.NEW_LINE; + + private User getUser(){ + User result = null; + try { + result = new User( + "Strummer", + "clash", + "joe@clash.com", + new Integer(1), + "Quantum Electrodynamics", + Boolean.FALSE, + new Integer(42), + new BigDecimal("17500.00"), + new Date(0), + new BigDecimal("17.5"), + "#001122" + ); + } + catch (ModelCtorException ex){ + fail("Cannot build User object."); + } + return result; + } + + private static final Logger fLogger = Util.getLogger(TESTFormPopulator.class); + private static void log(Object aThing){ + System.out.println(String.valueOf(aThing)); + } + + + private void testSingleParam(String aIn, String aOut, String aParamName, String aParamValue) throws JspException, IOException { + PopulateHelper.Context fakeParam = new FakeSingleParam(aParamName, aParamValue); + PopulateHelper populator = new PopulateHelper( fakeParam, aIn, Style.MUST_RECYCLE_PARAMS ); + String editedBody = populator.getEditedBody(); + assertTrue( editedBody.equals(aOut) ); + } + + private void testModelObject(String aIn, String aOut, Object aBean) throws JspException, IOException { + PopulateHelper.Context myUserBean = new FakeBeanOnly(aBean); + PopulateHelper populator = new PopulateHelper( myUserBean, aIn, Style.USE_MODEL_OBJECT); + String populatedBody = populator.getEditedBody(); + if( ! populatedBody.equals(aOut) ){ + System.out.println("Populated Body : " + populatedBody); + } + assertTrue( populator.getEditedBody().equals(aOut) ); + } + + private void testSelect(String aIn, String aOut, String aParamName, Object[] aParamValues) throws JspException, IOException { + PopulateHelper.Context selectParams = new FakeSelectParams(aParamName, aParamValues); + PopulateHelper populator = new PopulateHelper( selectParams, aIn, Style.MUST_RECYCLE_PARAMS); + assertTrue( populator.getEditedBody().equals(aOut) ); + } + + private void testBigFailure(String aIn, String aOut, String aParamName, String aParamValue) throws JspException, IOException { + try { + testSingleParam(aIn, aOut, aParamName, aParamValue); + } + catch(Throwable ex){ + return; + } + fail("Should have failed."); + } + + private void testFailSelect(String aIn, String aOut, String aParamName, Object[] aParamValues) throws JspException, IOException { + try { + testSelect(aIn, aOut, aParamName, aParamValues); + } + catch(Throwable ex){ + return; + } + fail("Select should have failed."); + } + + /** Fake the case where a single request parameter is present. */ + static private final class FakeSingleParam implements PopulateHelper.Context { + FakeSingleParam(String aParamName, String aParamValue){ + fParamName = aParamName; + fParamValue = aParamValue; + } + public String getReqParamValue(String aName){ + return aName.equals(fParamName) ? fParamValue : null; + } + public boolean hasRequestParamNamed(String aParamName) { + return fParamName.equals(aParamName); + } + public Object getModelObject(){ + return null; + } + public boolean isModelObjectPresent() { + return false; + } + public Formats getFormats(){ + return new Formats(new Locale("en"), TimeZone.getTimeZone("GMT")); + } + public Collection getReqParamValues(String aParamName){ + Collection result = new ArrayList(); + result.add(fParamValue); + return aParamName.equals(fParamName) ? result : Collections.EMPTY_LIST; + } + private final String fParamName; + private final String fParamValue; + } + + /** Fake the case where a bean is present, but no request parameters. */ + static private final class FakeBeanOnly implements PopulateHelper.Context { + FakeBeanOnly(Object aObject){ + fBean = aObject; + } + public String getReqParamValue(String aName){ + return null; + } + public boolean hasRequestParamNamed(String aParamName) { + return false; + } + public boolean isModelObjectPresent(){ + return fBean != null; + } + public Object getModelObject(){ + return fBean; + } + public Formats getFormats(){ + return new Formats(new Locale("en"), TimeZone.getTimeZone("GMT") ); + } + public Collection getReqParamValues(String aParamName){ + return null; + } + private final Object fBean; + } + + /** Fake the case where request parameters are possibly multi-valued. */ + static private final class FakeSelectParams implements PopulateHelper.Context { + FakeSelectParams(String aParamName, Object[] aParamValues){ + fParamName = aParamName; + fParamValues = Collections.unmodifiableCollection( Arrays.asList(aParamValues) ); + } + public String getReqParamValue(String aName){ + return null; + } + public boolean hasRequestParamNamed(String aParamName) { + return fParamName.equals(aParamName); + } + public boolean isModelObjectPresent(){ + return false; + } + public Object getModelObject(){ + return null; + } + public Formats getFormats(){ + return new Formats(new Locale("en"), TimeZone.getTimeZone("GMT") ); + } + public Collection getReqParamValues(String aParamName){ + return aParamName.equals(fParamName) ? fParamValues : null; + } + private final String fParamName; + private final Collection fParamValues; + } + + private static final Pattern fLOGIN_NAME_PATTERN = Pattern.compile("(.){3,30}"); + private static final Pattern fLOGIN_PASSWORD_PATTERN = Pattern.compile("(\\S){3,20}"); + + /** + Typical model class stolen from exampleA. + Even though this is repetition, it is the only area where the library + uses the example. It would be a shame to have to link the projects for only + this reason, as it is dangerous to attach the library to an app. + */ + public static final class User { + + /** + @param aLoginName has trimmed length in range 3..30. + @param aLoginPassword has length in range 3..20, and contains + no whitespace. + @param aEmailAddress satisfies {@link WebUtil#isValidEmailAddress} + @param aStarRating rating of last meal in range 1..4; + optional - if null, replace with value of 0. + @param aFavoriteTheory favorite quantum field theory ; + optional - may be null. + @param aSendCard toggle for sending Christmas card to user ; + optional - if null, replace with false. + @param aAge of the user, in range 0..150 ; + optional - may be null. + @param aDesiredSalary is 0 or more ; optional - may be null. + @param aBirthDate is not in the future ; optional - may be null. + */ + public User( + String aLoginName, + String aLoginPassword, + String aEmailAddress, + Integer aStarRating, + String aFavoriteTheory, + Boolean aSendCard, + Integer aAge, + BigDecimal aDesiredSalary, + Date aBirthDate, + BigDecimal aRange, + String aColor + ) throws ModelCtorException { + + fLoginName = Util.trimPossiblyNull(aLoginName); + fLoginPassword = aLoginPassword; + fEmailAddress = Util.trimPossiblyNull(aEmailAddress); + + fStarRating = Util.replaceIfNull(aStarRating, ZERO); + fFavoriteTheory = aFavoriteTheory; + fSendCard = Util.replaceIfNull(aSendCard, Boolean.FALSE); + + fAge = aAge; + fDesiredSalary = aDesiredSalary; + //defensive copy + fBirthDate = aBirthDate == null ? null : new Long(aBirthDate.getTime()); + + fRange = aRange; + fColor = aColor; + + validateState(); + } + + public String getLoginName() { + return fLoginName; + } + + public String getLoginPassword() { + return fLoginPassword; + } + + public String getEmailAddress() { + return fEmailAddress; + } + + public Integer getStarRating(){ + return fStarRating; + } + + public String getFavoriteTheory(){ + return fFavoriteTheory; + } + + public Boolean getSendCard(){ + return fSendCard; + } + + public Integer getAge(){ + return fAge; + } + + public BigDecimal getDesiredSalary(){ + return fDesiredSalary; + } + + public Date getBirthDate(){ + //defensive copy + return fBirthDate == null ? null : new Date(fBirthDate.longValue()); + } + + public Locale getCountry(){ + return new Locale("en_ca"); + } + + public TimeZone getTZ(){ + return TimeZone.getTimeZone("Canada/Atlantic"); + } + + public BigDecimal getRange(){ + return fRange; + } + + public String getColor(){ + return fColor; + } + + /** + Intended for debugging only. + */ + public String toString() { + return ModelUtil.toStringFor(this); + } + + public boolean equals( Object aThat ) { + if ( this == aThat ) return true; + if ( !(aThat instanceof User) ) return false; + User that = (User)aThat; + return ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields()); + } + + public int hashCode() { + return ModelUtil.hashCodeFor(getSignificantFields()); + } + + private Object[] getSignificantFields(){ + return new Object[]{ + fLoginName, fLoginPassword, fEmailAddress, fStarRating, fFavoriteTheory, + fSendCard, fAge, fDesiredSalary, fBirthDate + }; + } + + // PRIVATE // + private final String fLoginName; + private final String fLoginPassword; + private final String fEmailAddress; + private final Integer fStarRating; + private final String fFavoriteTheory; + private final Boolean fSendCard; + private final Integer fAge; + private final BigDecimal fDesiredSalary; + private final Long fBirthDate; + private final BigDecimal fRange; + private final String fColor; + private static final Integer ZERO = new Integer(0); + + private void validateState() throws ModelCtorException { + ModelCtorException ex = new ModelCtorException(); + if ( ! isValidLoginName(fLoginName) ) { + ex.add("Login name must have 3..30 characters"); + } + if ( ! isValidLoginPassword(fLoginPassword) ) { + ex.add("Password must have 3..20 characters, with no spaces"); + } + if ( ! isValidEmailAddress(fEmailAddress) ){ + ex.add("Email address has an invalid form"); + } + if ( ! isValidStarRating(fStarRating) ){ + ex.add("Star Rating (optional) must be in range 1..4"); + } + //no validation needed for favoriteTheory, sendCard + if ( ! isValidAge(fAge) ) { + ex.add("Age must be in range 0..150"); + } + if ( ! isValidDesiredSalary(fDesiredSalary) ) { + ex.add("Desired Salary must be 0 or more."); + } + if ( ! isValidBirthDate(fBirthDate) ){ + ex.add("Birth Date must be in the past."); + } + if ( ! ex.isEmpty() ) throw ex; + } + + private boolean isValidLoginName(String aLoginName){ + return Util.matches(fLOGIN_NAME_PATTERN, aLoginName); + } + + private boolean isValidLoginPassword(String aLoginPassword){ + return Util.matches(fLOGIN_PASSWORD_PATTERN, aLoginPassword); + } + + private boolean isValidEmailAddress(String aEmailAddress){ + return WebUtil.isValidEmailAddress(aEmailAddress); + } + + private boolean isValidStarRating(Integer aStarRating){ + return Util.isInRange(aStarRating.intValue(), 0, 4); + } + + private boolean isValidAge(Integer aAge){ + boolean result = true; + if ( aAge != null ){ + if ( ! Util.isInRange(aAge.intValue(), 0, 150) ){ + result = false; + } + } + return result; + } + + private boolean isValidDesiredSalary(BigDecimal aDesiredSalary){ + final BigDecimal ZERO_MONEY = new BigDecimal("0"); + boolean result = true; + if ( aDesiredSalary != null ) { + if ( aDesiredSalary.compareTo(ZERO_MONEY) < 0 ) { + result = false; + } + } + return result; + } + + private boolean isValidBirthDate(Long aBirthDate){ + boolean result = true; + if ( aBirthDate != null ) { + if ( aBirthDate.longValue() > System.currentTimeMillis()) { + result = false; + } + } + return result; + } + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ui/tag/TESTHighlightCurrentPage.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ui/tag/TESTHighlightCurrentPage.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,148 @@ +package hirondelle.web4j.ui.tag; + +import junit.framework.*; +//import junit.ui.TestRunner; +//import junit.textui.TestRunner; + +import hirondelle.web4j.util.Consts; + +public final class TESTHighlightCurrentPage extends TestCase { + + public static void main(String args[]) { + String[] testCaseName = { TESTHighlightCurrentPage.class.getName()}; + junit.textui.TestRunner.main(testCaseName); + } + + public TESTHighlightCurrentPage(String aName) { + super( aName ); + } + + //href with highlight - match and no match + public void testHrefNoHighlight(){ + HighlightCurrentPage highlight = new HighlightCurrentPage(); + highlight.testSetCurrentURI("http://www.blah.com/ShowHomePage.do"); + + testHighlight(highlight, "Home", "Home"); + testHighlight(highlight, "Home", "Home"); + testHighlight(highlight, "Home", "Home"); + testHighlight(highlight, "Home", "Home"); + testHighlight(highlight, " Home", " Home"); + testHighlight(highlight, "Home ", "Home "); + testHighlight(highlight, "Home ", "Home "); + testHighlight(highlight, " Home ", " Home "); + //Multiple links + testHighlight(highlight, " Home Contacts", " Home Contacts"); + + testNoHighlight(highlight, "Home"); + testNoHighlight(highlight, "Home"); + testNoHighlight(highlight, "Home"); + testNoHighlight(highlight, "Home"); + //Nuisance restriction - HREF must come first : + testNoHighlight(highlight, "Home"); + //No highlight if session id present in one, but not in the other + testNoHighlight(highlight, "Home"); + //Multiple links + testNoHighlight(highlight, " Contacts Contacts "); + } + + public void testHrefWithHighlight(){ + HighlightCurrentPage highlight = new HighlightCurrentPage(); + highlight.testSetCurrentURI("http://www.blah.com/ShowHomePage.do"); + highlight.setStyleClass("highlight"); + + testHighlight(highlight, "Home", "Home"); + testHighlight(highlight, "Home", "Home"); + testHighlight(highlight, "Home", "Home"); + testHighlight(highlight, "Home", "Home"); + testHighlight(highlight, " Home", " Home"); + testHighlight(highlight, "Home ", "Home "); + testHighlight(highlight, "Home ", "Home "); + testHighlight(highlight, " Home ", " Home "); + + testNoHighlight(highlight, "Home"); + testNoHighlight(highlight, "Home"); + testNoHighlight(highlight, "Home"); + testNoHighlight(highlight, "Home"); + testNoHighlight(highlight, "Home"); + testNoHighlight(highlight, "Home"); + } + + public void testTitleNoHighlight(){ + HighlightCurrentPage highlight = new HighlightCurrentPage(); + highlight.setUseTitle(Boolean.TRUE); + highlight.testSetTitleParam("Home"); + + testHighlight(highlight, "Home", "Home"); + testHighlight(highlight, "Home", "Home"); + testHighlight(highlight, "Home", "Home"); + testHighlight(highlight, "Home", "Home"); + testHighlight(highlight, " Home", " Home"); + testHighlight(highlight, "Home ", "Home "); + //Will trim the link body before testing for match + testHighlight(highlight, "Home ", "Home "); + testHighlight(highlight, " Home ", " Home "); + //HREF target not relevant here - will match any HREF + testHighlight(highlight, "Home", "Home"); + + testNoHighlight(highlight, "My Home"); + testNoHighlight(highlight, "Acceuil"); + } + + public void testTitleWithHighlight(){ + HighlightCurrentPage highlight = new HighlightCurrentPage(); + highlight.setUseTitle(Boolean.TRUE); + highlight.setStyleClass("highlight"); + highlight.testSetTitleParam("Home"); + + testHighlight(highlight, "Home", "Home"); + testHighlight(highlight, "Home", "Home"); + testHighlight(highlight, "Home", "Home"); + testHighlight(highlight, "Home", "Home"); + testHighlight(highlight, " Home", " Home"); + testHighlight(highlight, "Home ", "Home "); + testHighlight(highlight, "Home ", "Home "); + testHighlight(highlight, " Home ", " Home "); + + testNoHighlight(highlight, "My Home"); + testNoHighlight(highlight, "Acceuil"); + } + + public void testAmpersandInURL(){ + HighlightCurrentPage highlight = new HighlightCurrentPage(); + highlight.testSetCurrentURI("http://www.blah.com/ShowHomePage.do?X=Y&A=B"); + + //Ampersand is escaped correctly in HREF : + testHighlight(highlight, "Home", "Home"); + + //Ampersand is NOT escaped correctly in HREF + testNoHighlight(highlight, "Home"); + //Missing parameters altogether : + testNoHighlight(highlight, "Home"); + } + + // FIXTURE // + + protected void setUp(){ + } + + protected void tearDown() { + //empty + } + + + // PRIVATE // + + private void testHighlight(HighlightCurrentPage aHighlighter, String aIn, String aOut){ + String out = aHighlighter.getEmittedText(aIn); + if ( ! out.equals(aOut) ) { + fail("Out was actually: " + Consts.DOUBLE_QUOTE + out + Consts.DOUBLE_QUOTE); + } + } + + private void testNoHighlight(HighlightCurrentPage aHighlighter, String aIn){ + String out = aHighlighter.getEmittedText(aIn); + if ( ! out.equals(aIn) ) { + fail("Out was actually: " + Consts.DOUBLE_QUOTE + out + Consts.DOUBLE_QUOTE); + } + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ui/tag/TagHelper.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ui/tag/TagHelper.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,135 @@ +package hirondelle.web4j.ui.tag; + +import java.io.*; +import java.util.logging.*; +import javax.servlet.Servlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.jsp.JspException; +import javax.servlet.jsp.tagext.SimpleTagSupport; +import javax.servlet.jsp.PageContext; +import javax.servlet.jsp.tagext.JspFragment; +import javax.servlet.jsp.JspContext; + +import hirondelle.web4j.util.Util; + +/** + Base class for implementing custom JSP tags. + +

The custom tag can optionally have a body. The .tld entry for these tags must + have their body-content set to scriptless. + +

Concrete subclasses of this class perform these tasks : +

    +
  • implement setXXX methods, one for each tag attribute; + each setXXX should validate its argument. +
  • optionally override the {@link #crossCheckAttributes} method, to + perform validations depending on more than one attribute. +
  • implement {@link #getEmittedText}, to return the text to be included in markup. +
+*/ +public abstract class TagHelper extends SimpleTagSupport { + + /** + Template method which calls {@link #getEmittedText(String)}. + +

The body of this tag is evaluated, passed to {@link #getEmittedText(String)}, + and the result is then written to the JSP output writer. In addition, this method will call + {@link #crossCheckAttributes()} at the start of processing. + */ + @Override public final void doTag() throws JspException { + try { + crossCheckAttributes(); + getJspContext().getOut().write(getEmittedText(getBody())); + } + catch (Throwable ex){ + fLogger.severe("Cannot execute custom tag. " + Util.quote(ex)); + throw new JspException("Cannot execute custom tag.", ex); + } + } + + /** + Return the text this tag will display in the resulting web page. + + @param aOriginalBody is the evaluated body of this tag. If there is no body, or + if the body is present but empty, then it is null. + @return the text to display in the resulting web page. + */ + abstract protected String getEmittedText(String aOriginalBody) throws JspException, IOException; + + /** + Perform validations that apply to more than one attribute. + +

This default implementation does nothing. + +

Validations that apply to a single attribute should be performed in its + corresponding setXXX method. + +

If a problem is detected, subclasses must emit a RuntimeException + describing the problem. If all validations apply to only to a single attribute, + then this method should not be overridden. + */ + protected void crossCheckAttributes() { + //do nothing in this default impl + } + + /** Return the underlying {@link HttpServletRequest}. */ + protected final HttpServletRequest getRequest(){ + return (HttpServletRequest)getPageContext().getRequest(); + } + + /** Return the underlying {@link HttpServletResponse}. */ + protected final HttpServletResponse getResponse(){ + return (HttpServletResponse)getPageContext().getResponse(); + } + + /** Return the underlying {@link PageContext}. */ + protected final PageContext getPageContext(){ + JspContext jspContext = getJspContext(); + return (PageContext)jspContext; + } + + /** + Return the name of the JSP implementation class. +

Intended for debugging only. + */ + protected final String getPageName(){ + Servlet servlet = (Servlet)getPageContext().getPage(); + return servlet.getClass().getName(); + } + + /** + Verify that an attribute value has content. + +

If no content, then log at SEVERE and throw an unchecked exception. + */ + protected final void checkForContent(String aAttributeName, String aAttributeValue){ + if( ! Util.textHasContent(aAttributeValue) ){ + String message = Util.quote(aAttributeName) + " attribute must have a value."; + fLogger.severe(message); + throw new IllegalArgumentException(message); + } + } + + // PRIVATE // + + private static final Logger fLogger = Util.getLogger(TagHelper.class); + + /** + Return the evaluated body of this tag. + +

The body of this tag cannot contain scriptlets or scriptlet expressions. + If this tag has no body, or has an empty body, then null is returned. + */ + private String getBody() throws IOException, JspException { + String result = null; + JspFragment body = getJspBody(); + if( body != null ){ + StringWriter writer = new StringWriter(); + getJspBody().invoke(writer); + writer.flush(); + result = writer.toString(); + } + return result; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ui/tag/package.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ui/tag/package.html Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,14 @@ + + + + + + + Custom Tags + + +Custom JSP tags of general utility. The +{@link hirondelle.web4j.ui.tag.TagHelper} base class is useful for creating a +wide number of custom tags. + + diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ui/translate/Messages.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ui/translate/Messages.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,83 @@ +package hirondelle.web4j.ui.translate; + +import java.util.logging.*; +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.request.LocaleSource; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.model.AppResponseMessage; +import hirondelle.web4j.model.MessageList; +import hirondelle.web4j.ui.tag.TagHelper; + +import java.util.*; + +/** + Custom tag for rendering a {@link hirondelle.web4j.model.MessageList}. + +

Example use case : +

+ <c:if test="${not empty web4j_key_for_messages}"> 
+  Message(s) :
+  <w:messages name="web4j_key_for_messages">
+   <span class="message">placeholder</span><br>
+  </w:messages>
+  <c:remove var="web4j_key_for_messages" scope="session"/>
+ </c:if>
+ 
+ +

The body of this tag is a simple template for emitting each element of the named + {@link MessageList}. The special placeholder text is the location where + each item returned by {@link MessageList#getMessages()} is displayed. +*/ +public final class Messages extends TagHelper { + + /** + Key for a {@link MessageList} (in any scope). + +

Required attribute. The {@link hirondelle.web4j.action.ActionImpl} class creates two such items, identified + with {@link hirondelle.web4j.action.ActionImpl#ERRORS} and {@link hirondelle.web4j.action.ActionImpl#MESSAGES}. + */ + public void setName(String aMessageListName){ + Object object = getPageContext().findAttribute(aMessageListName); + if ( object == null ) { + String message = "No object named " + Util.quote(aMessageListName) + " found in any scope."; + fLogger.severe(message); + throw new IllegalArgumentException(message); + } + fMessageList = (MessageList)object; + } + + /** + Emit given template text for each item in the {@link MessageList}. + +

Emit the text in this tag's body (which acts as a template), by replacing the + special {@link #PLACEHOLDER} text with the content of each message. The configured + implementations of both {@link LocaleSource} and {@link Translator} are used to render + each item. (This performs "last-second localization".) + */ + @Override protected String getEmittedText(String aOriginalBody){ + StringBuilder result = new StringBuilder(); + Locale locale = BuildImpl.forLocaleSource().get(getRequest()); + TimeZone timeZone = BuildImpl.forTimeZoneSource().get(getRequest()); + List messages = fMessageList.getMessages(); + for(AppResponseMessage message: messages){ + String messageText = message.getMessage(locale, timeZone); + String fullText = replacePlaceholder(aOriginalBody, messageText); + result.append(fullText); + } + return result.toString(); + } + + /** + Special text used by this class to conveniently identify where message text + is placed. + */ + public static final String PLACEHOLDER = "placeholder"; + + // PRIVATE + private MessageList fMessageList; + private static final Logger fLogger = Util.getLogger(Messages.class); + + private String replacePlaceholder(String aOriginalBody, String aMessageText){ + return Util.replace(aOriginalBody, PLACEHOLDER, aMessageText); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ui/translate/TESTText.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ui/translate/TESTText.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,88 @@ +package hirondelle.web4j.ui.translate; + +import junit.framework.TestCase; + +/** Test the processing of wiki markup. */ +public final class TESTText extends TestCase { + + public static void main(String args[]) { + String[] testCaseName = {TESTText.class.getName()}; + junit.textui.TestRunner.main(testCaseName); + } + + public TESTText(String aName) { + super(aName); + } + + // TEST CASES + + public void testEcho() { + Text text = new Text(); + String orig = "This is a test value"; + String result = text.processMarkup(orig); + assertTrue(orig.equals(result)); + } + + public void testLink() { + testOutput("This is a [link:http://www.blah.com blah]", "This is a blah"); + testOutput("This is a [link:http://www.blah.com blah1 blah2]", "This is a blah1 blah2"); + testOutput("This is a [link:http://www.blah.com blah1 (blah2)]", "This is a blah1 " + POPEN + "blah2" + PCLOSE + ""); + testOutput("This is a [link:http://www.blah.com/this_thing blah]", "This is a blah"); + testOutput("This is a [link:http://www.blah.com/this_thing_here blah]", "This is a blah"); + } + + public void testBold() { + testOutput("This is a test *bold* item", "This is a test bold item"); + testOutput("*This* is a test", "This is a test"); + testOutput("This is a *test*", "This is a test"); + testOutput("This is a *test*.", "This is a test" + PERIOD); + testOutput("This is a *test*. And", "This is a test" + PERIOD + " And"); + testOutput("This is a *test* here", "This is a test here"); + testOutput("This is a * test* here", "This is a test here"); + testOutput("This is a *test * here", "This is a test here"); + testOutput("This is a * test * here", "This is a test here"); + testOutput("This is a * test * here", "This is a test here"); + testOutput("This is a *test of this* here", "This is a test of this here"); + testOutput("This is a * test of this * here", "This is a test of this here"); + testOutput("This is a * test of this * here", "This is a test of this here"); + testOutput("This is a *test* of *reluctant* here", "This is a test of reluctant here"); + testOutput("This is a *test of* the *reluctant aspect* here", "This is a test of the reluctant aspect here"); + } + + public void testItalic() { + testOutput("This is a test _italic_ item", "This is a test italic item"); + testOutput("_This_ is a test", "This is a test"); + testOutput("This is a _test_", "This is a test"); + testOutput("This is a _test_.", "This is a test" + PERIOD); + testOutput("This is a _test_. And", "This is a test" + PERIOD + " And"); + testOutput("This is a _test_ here", "This is a test here"); + testOutput("This is a _ test_ here", "This is a test here"); + testOutput("This is a _test _ here", "This is a test here"); + testOutput("This is a _ test _ here", "This is a test here"); + testOutput("This is a _ test _ here", "This is a test here"); + testOutput("This is a _test of this_ here", "This is a test of this here"); + testOutput("This is a _ test of this _ here", "This is a test of this here"); + testOutput("This is a _ test of this _ here", "This is a test of this here"); + testOutput("This is a _test_ of _reluctant_ here", "This is a test of reluctant here"); + testOutput("This is a _test of_ the _reluctant aspect_ here", "This is a test of the reluctant aspect here"); + } + + // PRIVATE + + private static final String STAR = "*"; + private static final String UNDERLINE = "_"; + private static final String PERIOD = "."; + private static final String COLON = ":"; + private static final String FSLASH = "/"; + private static final String POPEN = "("; + private static final String PCLOSE = ")"; + + private void testOutput(String aInput, String aExpected) { + Text text = new Text(); + text.setWikiMarkup(true); + String result = text.processMarkup(aInput); + if(! aExpected.equals(result)){ + fail("Expected '" + aExpected + "' but received '" + result +"'"); + } + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ui/translate/TESTTranslateTextFlow.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ui/translate/TESTTranslateTextFlow.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,49 @@ +package hirondelle.web4j.ui.translate; + +import java.util.regex.*; + +import junit.framework.*; + +public final class TESTTranslateTextFlow extends TestCase { + + public static void main(String args[]) { + String[] testCaseName = {TESTTranslateTextFlow.class.getName()}; + junit.textui.TestRunner.main(testCaseName); + } + + public TESTTranslateTextFlow(String aName) { + super(aName); + } + + // TEST CASES // + + public void testMatches(){ + showGroups("1Yes"); + showGroups(" 2Yes "); + showGroups("3Yes "); + showGroups(" 4Yes"); + showGroups(" 5Yes "); + showGroups(" Yes No Maybe "); + } + + // FIXTURE // + + protected void setUp() { + } + + protected void tearDown() { + } + + // PRIVATE // + + private void showGroups(String aInput){ + Matcher matcher = TextFlow.TRIMMED_TEXT.matcher(aInput); + while ( matcher.find() ){ + for (int idx=0; idx <= matcher.groupCount(); ++idx) { + //System.out.println("Group " + idx + ": \"" + matcher.group(idx) + "\""); + } + } + } + + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ui/translate/TESTTranslateTooltip.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ui/translate/TESTTranslateTooltip.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,117 @@ +package hirondelle.web4j.ui.translate; + +import java.util.regex.*; +import junit.framework.*; +import hirondelle.web4j.util.Util; + +public final class TESTTranslateTooltip extends TestCase { + + public static void main(String args[]) { + String[] testCaseName = { TESTTranslateTooltip.class.getName() }; + junit.textui.TestRunner.main(testCaseName); + } + + public TESTTranslateTooltip( String aName) { + super(aName); + } + + // TEST CASES // + + public void testSuccess(){ + testSuccessfulMatches("text flow text flow" ); + testSuccessfulMatches("text flow text flow" ); + testSuccessfulMatches("text flow text flow" ); + testSuccessfulMatches("text flow text flow" ); + testSuccessfulMatches("text flow text flow" ); + testSuccessfulMatches(" text flow" ); + testSuccessfulMatches("" ); + testSuccessfulMatches("text flow Yes text flow" ); + testSuccessfulMatches("text flow \"Yes\" text flow" ); + testSuccessfulMatches("text flow Yes text flow" ); + + //testSuccessfulMatches("text flow Yes text flow

" ); + } + + public void testSuccessSubmitButton(){ + testSuccessfulMatchesSubmitButton(""); + testSuccessfulMatchesSubmitButton(""); + testSuccessfulMatchesSubmitButton(""); + testSuccessfulMatchesSubmitButton(""); + testSuccessfulMatchesSubmitButton(""); + testSuccessfulMatchesSubmitButton(""); + testSuccessfulMatchesSubmitButton(""); + testSuccessfulMatchesSubmitButton(""); + testSuccessfulMatchesSubmitButton("text flow "); + testSuccessfulMatchesSubmitButton(" text flow"); + testSuccessfulMatchesSubmitButton("text flow text flow"); + + testSuccessfulMatchesSubmitButton(""); + testSuccessfulMatchesSubmitButton(""); + testSuccessfulMatchesSubmitButton(""); + testSuccessfulMatchesSubmitButton(""); + testSuccessfulMatchesSubmitButton(""); + + testSuccessfulMatchesSubmitButton(""); + testSuccessfulMatchesSubmitButton(""); + testSuccessfulMatchesSubmitButton(""); + testSuccessfulMatchesSubmitButton(""); + + } + + public void testFailureSubmitButton(){ + testNoMatchesSubmitButton("< input type=\"submit\" value='add.button'>"); + testNoMatchesSubmitButton("< input type=\"submit\" value='add.button'>"); + testNoMatchesSubmitButton(""); //swap order fails + testNoMatchesSubmitButton(""); + testNoMatchesSubmitButton(""); + testNoMatchesSubmitButton(""); + testNoMatchesSubmitButton(""); + } + + // FIXTURE // + + protected void setUp(){ + } + + protected void tearDown() { + } + + // PRIVATE // + + /** Group 2 must be the text 'add.button'. */ + private void testSuccessfulMatches(String aInput){ + Matcher matcher = Tooltips.TOOLTIP.matcher(aInput); + if (matcher.find()) { + String groupTwo = matcher.group(2); + //System.out.println("Group Two: " + groupTwo); + groupTwo = Util.removeQuotes(groupTwo); + assertTrue(groupTwo.equalsIgnoreCase("Yes")); + } + else { + //System.out.println("Not found."); + } + } + + /** Group 2 must be the text 'add.button'. */ + private void testSuccessfulMatchesSubmitButton(String aInput){ + Matcher matcher = Tooltips.SUBMIT_BUTTON.matcher(aInput); + if (matcher.find()) { + String groupTwo = matcher.group(2); + //System.out.println("Group Two: " + groupTwo); + groupTwo = Util.removeQuotes(groupTwo); + assertTrue(groupTwo.equalsIgnoreCase("add.button")); + } + else { + fail("No match found for " + aInput); + } + } + + private void testNoMatchesSubmitButton(String aInput){ + Matcher matcher = Tooltips.SUBMIT_BUTTON.matcher(aInput); + if (matcher.find()) { + fail("Did not expect to find match for : " + aInput); + } + } + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ui/translate/Text.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ui/translate/Text.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,350 @@ +package hirondelle.web4j.ui.translate; + +import java.util.Locale; +import java.util.logging.Logger; +import java.util.regex.Pattern; +import java.util.regex.Matcher; +import javax.servlet.jsp.JspException; + +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.ui.tag.TagHelper; +import hirondelle.web4j.util.EscapeChars; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.util.Consts; + +/** + Custom tag for translating base language text (or a "coder key") into a localized form, and applying + "wiki-style" formatting. + +

This tag uses {@link hirondelle.web4j.ui.translate.Translator} and + {@link hirondelle.web4j.request.LocaleSource} to localize text. + +

There are several use cases for this tag. In general, the attributes control: +

+ +

Specifying Base Text +

The base text may be specified simply as the tag body, or as the value attribute. +

Example 1 :
+

+ <w:txt>
+  Of prayers, I am the prayer of silence.
+  Of things that move not, I am the Himalayas.
+ </w:txt>
+ 
+ + Example 2 uses a "coder key" :
+
+ <w:txt value="quotation.from.bhagavad.gita" />
+ 
+ +

This second form is intended especially for translating items appearing + inside a tag. + +

These two use cases are mutually exclusive : either a body must be specified, or + the value attribute must be specified, but not both. + +

Here is another example, combining the two styles : +

+ <span title='<w:txt value="quotation.from.bhagavad.gita" />'>
+  <w:txt>
+   Of prayers, I am the prayer of silence.
+   Of things that move not, I am the Himalayas.
+  </w:txt>
+ </span>
+ 
+ +

In either case, the item to be translated may be either some text in the + application's base language, or it may be a 'coder key' (see {@link Translator}). + +

Formatting of the Result +

By default, this tag will escape all special characters using {@link EscapeChars#forHTML(String)}. + Occasionally, it is desirable to allow some limited formatting of text input by the user. Even simple + effects such as bold and italic can measurably increase legibility and clarity, and allowing links + is also very useful. Most wikis allow such simple formatting. This tag uses the following special + characters to denote various effects : +

+ +

Example 3 has wiki style formatting: +

+ <w:txt wikiMarkup="true">
+ Default Locale |   
+ _Not all Locales are treated equally_ : there *must* be ... 
+ </w:txt>
+ 
+ + Is rendered as : +

Default Locale
+Not all Locales are treated equally : there must be ... + +

To allow the above rules to be interpreted as HTML by this tag, {@link #setWikiMarkup(boolean)} to true. + +

Turning Off Translation +

There are two cases in which it is useful to turn off translation altogether: +

    +
  • using only the formatting services of this tag. For example, a message board application may want to + provide basic wiki-style formatting, without any translation. +
  • as a workaround for database issues regarding large text. Sometimes databases treat large + text differently from small text. For example in MySQL, it is not possible for a TEXT field to be + assigned a UNIQUE constraint. This split between large text and small text can be a problem, since + it may mean that blocks of text are treated differently simply according to their length, and workarounds for + large blocks of text are needed. +
+ +

Example 4 has wiki style formatting for untranslated user input: +

+ <w:txt wikiMarkup="true" translate="false">
+   ...render some user input with wiki style formatting...
+ </w:txt>
+ 
+ +

Example 5 has wiki style formatting for large untranslated text, hard-coded in the JSP. + An example of such text may be an extended section of "help" information : +

+ <w:txt locale="en">
+   ..large amount of hard-coded text in English...
+ </w:txt>
+ 
+ <w:txt locale="fr">
+   ..large amount of hard-coded text in French...
+ </w:txt>
+ 
+ + The above style is outside the usual translation mechanism. It does not use the configured + {@link Translator}. It does not translate its content at all. Rather, it will echo the tag content only + when the specified locale matches that returned by the {@link hirondelle.web4j.request.LocaleSource}. + It is recommended that this style be used only when the above-mentioned problem regarding + database text size exists. This style implicitly has translate="false". +*/ +public final class Text extends TagHelper { + + /** + Set the item to be translated (optional). + +

aTextAsAttr takes two forms : user-visible text in the base language, + or a coder key (known to the programmer, but not seen by the end user). See {@link Translator} + for more information. + +

If this attribute is set, then the tag must not have a body. + + @param aTextAsAttr must have content; this value is always trimmed by this method. + */ + public void setValue(String aTextAsAttr){ + checkForContent("Value", aTextAsAttr); + fTextAsAttr = aTextAsAttr.trim(); + } + + /** + Toggle translation on and off (optional, default true). + +

By default, text will be translated. An example of setting this item to false is a + discussion board, where users input in a single language, and + wiki style formatting is desired. + +

An example of rendering such text is : +

+   <w:txt translate="false" wikiMarkup="true">
+     This is *bold*, and so on...
+   </w:txt>
+   
+ */ + public void setTranslate(boolean aValue) { + fIsTranslating = aValue; + } + + /** + Specify an explicit {@link Locale} (optional). + +

When this attribute is specified, this tag will never translate its content. + Instead, this tag will simply emit or suppress its content, according to whether aLocale matches + that returned by {@link hirondelle.web4j.request.LocaleSource}. + */ + public void setLocale(String aLocale){ + fSpecificLocale = Util.buildLocale(aLocale); + fIsTranslating = false; + } + + /** + Allow wiki style formatting to be used (optional, default false). + */ + public void setWikiMarkup(boolean aValue){ + fHasWikiMarkup = aValue; + } + + /** Validate attributes against each other. */ + protected void crossCheckAttributes() { + if( fSpecificLocale != null && fIsTranslating ){ + String message = "Cannot translate and specify a Locale at the same time. Page : " + getPageName(); + fLogger.severe(message); + throw new IllegalArgumentException(message); + } + } + + /** See class comment. */ + @Override protected String getEmittedText(String aOriginalBody) throws JspException { + if ( Util.textHasContent(fTextAsAttr) && Util.textHasContent(aOriginalBody) ){ + throw new JspException( + "Please specify text (or key) to be translated as either value attribute or tag body, but not both." + + " Value attribute: " +Util.quote(fTextAsAttr) + ". Tag body: " + Util.quote(aOriginalBody) + + " Page Name :" + getPageName() + ); + } + + String baseText = Util.textHasContent(fTextAsAttr) ? fTextAsAttr : aOriginalBody; + String result = Consts.EMPTY_STRING; + if(Util.textHasContent(baseText)){ + Locale locale = BuildImpl.forLocaleSource().get(getRequest()); + if(fIsTranslating){ + Translator translator = BuildImpl.forTranslator(); + result = translator.get(baseText, locale); + fLogger.finest("Translating base text : " + Util.quote(baseText) + " using Locale " + locale + " into " + Util.quote(result)); + } + else { + fLogger.finest("LocaleSource: " + locale + ", locale attribute: " + fSpecificLocale); + if (fSpecificLocale == null || fSpecificLocale.equals(locale)){ + fLogger.finest("Echoing tag content (possibly adding formatting)."); + result = baseText; + } + else { + fLogger.finest("Suppressing tag content."); + result = Consts.EMPTY_STRING; + } + } + result = processMarkup(result); + } + return result; + } + + /** + Translate the text into a result. Escapes characters, then optionally changes wiki markup into hypertext. + Made package-private for testing purposes. + */ + String processMarkup(String aText) { + String result = aText; + if(Util.textHasContent(result)){ + result = EscapeChars.forHTML(result); + if( fHasWikiMarkup ){ + result = changePseudoMarkupToHTML(result); + } + } + return result; + } + + // PRIVATE + private String fTextAsAttr; + private boolean fIsTranslating = true; + private Locale fSpecificLocale; + private boolean fHasWikiMarkup = false; + private static final Logger fLogger = Util.getLogger(Text.class); + + /* + The regexes don't refer to the original '*' and so on. Rather, they refer to the + escaped versions thereof - their 'fingerprints', so to speak. + + Implementation Note: + Seeing undesired wiki-formatting of _blah_ text in links. To fix, introduced second, small variation + for *blah* and _blah_, which matches to the beginning of the input. + + This is a rather hacky, and a more robust implmentation would use javacc. + */ + + private static final Pattern PSEUDO_LINK = Pattern.compile("[link:((\\S)*) ((.)+?)\\]"); + private static final Pattern PSEUDO_BOLD = Pattern.compile("(?: *)((?:.)+?)(?:*)"); + private static final Pattern PSEUDO_BOLD_START_OF_INPUT = Pattern.compile("(?:^*)((?:.)+?)(?:*)"); + private static final Pattern PSEUDO_ITALIC = Pattern.compile("(?: _)((?:.)+?)(?:_)"); + private static final Pattern PSEUDO_ITALIC_START_OF_INPUT = Pattern.compile("(?:^_)((?:.)+?)(?:_)"); + private static final Pattern PSEUDO_CODE = Pattern.compile("(?:\\^)((?:.)+?)(?:\\^)", Pattern.MULTILINE | Pattern.DOTALL); + private static final Pattern PSEUDO_HR = Pattern.compile("^(?:\\s)*~~~~(?:\\s)*$", Pattern.MULTILINE); + private static final Pattern PSEUDO_PARAGRAPH = Pattern.compile("(^(?:\\s)*$)", Pattern.MULTILINE); + private static final Pattern PSEUDO_LINE_BREAK = Pattern.compile("\\|(?: )*$", Pattern.MULTILINE); + private static final Pattern PSEUDO_LIST = Pattern.compile("^(?: )*(\\~)(?: )", Pattern.MULTILINE); + + private String changePseudoMarkupToHTML(String aText){ + String result = null; + result = addLink(aText); + result = addBold(result); + result = addBold2(result); + result = addItalic(result); + result = addItalic2(result); + result = addLineBreak(result); + result = addParagraph(result); + result = addList(result); + result = addCode(result); + result = addHorizontalRule(result); + return result; + } + + private String addBold(String aText){ + Matcher matcher = PSEUDO_BOLD.matcher(aText); + return matcher.replaceAll(" $1"); //extra space + } + + private String addBold2(String aText){ + Matcher matcher = PSEUDO_BOLD_START_OF_INPUT.matcher(aText); + return matcher.replaceAll("$1"); //extra space + } + + private String addItalic(String aText){ + Matcher matcher = PSEUDO_ITALIC.matcher(aText); + return matcher.replaceAll(" $1"); //extra space + } + + private String addItalic2(String aText){ + Matcher matcher = PSEUDO_ITALIC_START_OF_INPUT.matcher(aText); + return matcher.replaceAll("$1"); //extra space + } + + private String addCode(String aText){ + Matcher matcher = PSEUDO_CODE.matcher(aText); + return matcher.replaceAll("

$1
"); + } + + private String addHorizontalRule(String aText){ + Matcher matcher = PSEUDO_HR.matcher(aText); + return matcher.replaceAll("
"); + } + + private String addLink(String aText){ + Matcher matcher = PSEUDO_LINK.matcher(aText); + return matcher.replaceAll("$3"); + } + + private String addParagraph(String aText){ + Matcher matcher = PSEUDO_PARAGRAPH.matcher(aText); + return removeInitialAndFinalParagraphs(matcher.replaceAll("

")); + } + + /** Bit hacky - cannot find correct regex to take care of this cleanly. */ + private String removeInitialAndFinalParagraphs(String aText){ + String result = aText.trim(); + if (aText.startsWith("

")){ + result = result.substring(3); + } + if (aText.endsWith("

")){ + result = result.substring(0, result.length()-3); + } + return result; + } + + private String addLineBreak(String aText){ + Matcher matcher = PSEUDO_LINE_BREAK.matcher(aText); + return matcher.replaceAll("
"); + } + + private String addList(String aText){ + Matcher matcher = PSEUDO_LIST.matcher(aText); + return matcher.replaceAll("
  • "); //another version of bull has a non-standard number + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ui/translate/TextFlow.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ui/translate/TextFlow.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,232 @@ +package hirondelle.web4j.ui.translate; + +import java.text.CharacterIterator; +import java.text.StringCharacterIterator; +import java.util.Locale; +import java.util.regex.*; + +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.request.LocaleSource; +import hirondelle.web4j.ui.tag.TagHelper; +import hirondelle.web4j.util.EscapeChars; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.util.Regex; + +/** + Custom tag for translating regular text flow in large sections of a web page. + +

This tag treats every piece of free flow text delimited by a + tag as a unit of translatable base text, and passes it to {@link Translator}. + That is, all tags are treated as delimiters of units of translatable text. + +

This tag is suitable for translating most, but not all, of the + regular text flow in a web page. It is suitable for translating + markup that contains short, isolated snippets of text, that have no "structure", and + no dynamic data, such as the labels in a form, the column headers in a listing, + and so on. (For many intranet applications, this + makes up most of the free flow text appearing in the application.) Instead of using many + separate <w:txt> {@link Text} tags to translate each item one by one, + a single <w:txtFlow> tag can often be used to do the same thing in a single step. + +

Using this class has two strong advantages : +

    +
  • the effort needed to internationalize a page is greatly reduced +
  • the markup will be significantly easier to read and maintain, since most of the free flow text + remains unchanged from the single-language case +
+ +

+ This tag is not suitable when the base text to be translated : +

    +
  • contains markup +
  • has dynamic data of any sort +
  • contains a TEXTAREA with a non-empty body. Such text will be seen as a translatable + unit, which is usually undesired, since such text is usually not fixed, but dynamic (that is, from the database). + (To avoid this, simply nest this tag inside the <w:populate> tag surrounding the + form that contains the TEXTAREA. This ensures that the population is not affected by the action of this + tag.) +
+ +

For example, given this text containing markup : +

The <EM>raison-d'etre</EM> for this...
+ then this tag will split the text into three separate pieces, delimited by the EM tags. + Then, each piece will be translated. For such items, this is almost always undesirable. Instead, + one must use a <w:txt> {@link Text} tag, which can treat such items as + a single unit of translatable text, without chopping it up into three pieces. + +

Example
+ Here, all of the LABEL tags in this form will have their content translated by the + <w:txtFlow> tag : +

+<w:populate style='edit' using="myUser">
+<w:txtFlow>
+<form action='blah.do' method='post' class="user-input">
+<table align="center">
+<tr>
+ <td>
+  <label class="mandatory">Email</label>
+ </td>
+ <td>
+  <input type="text" name="Email Address" size='30'>
+ </td> 
+</tr>
+
+<tr>
+ <td>
+  <label>Age</label>
+ </td>
+ <td>
+  <input type="text" name="Age" size="30">
+ </td> 
+</tr>
+
+<tr>
+ <td>
+  <label>Desired Salary</label>
+ </td>
+ <td>
+  <input type="text" name="Desired Salary" size="30">
+ </td> 
+</tr>
+
+<tr>
+ <td>
+  <label> Birth Date </label>
+ </td>
+ <td>
+  <input type="text" name="Birth Date" size="30">
+ </td> 
+</tr>
+
+<tr>
+ <td>
+  <input type='submit' value='UPDATE'> 
+ </td>
+</tr>
+</table>
+</form>
+</w:txtFlow>
+</w:populate>
+
+*/ +public final class TextFlow extends TagHelper { + + /** + By default, this tag will escape any special characters appearing in the + text flow, using {@link EscapeChars#forHTML(String)}. To change that default + behaviour, set this value to false. + +

Exercise care that text is not doubly escaped. + For instance, if the text already contains + character entities, and setEscapeChars is true, then the text &amp; + will be emitted by this tag as &amp;amp;, for example. + */ + public void setEscapeChars(boolean aValue){ + fEscapeChars = aValue; + } + + /** + Translate each piece of free flow text appearing in aOriginalBody. + +

Each piece of text is delimited by one or more tags, and is translated using the configured + {@link Translator}. Leading or trailing white space is preserved. + */ + @Override protected String getEmittedText(String aOriginalBody) { + final StringBuffer result = new StringBuffer(); + final StringBuffer snippet = new StringBuffer(); + boolean isInsideTag = false; + + final StringCharacterIterator iterator = new StringCharacterIterator(aOriginalBody); + char character = iterator.current(); + while (character != CharacterIterator.DONE ){ + if (character == '<') { + doStartTag(result, snippet, character); + isInsideTag = true; + } + else if (character == '>') { + doEndTag(result, character); + isInsideTag = false; + } + else { + doRegularCharacter(result, snippet, isInsideTag, character); + } + character = iterator.next(); + } + if( Util.textHasContent(snippet.toString()) ) { + appendTranslation(snippet, result); + } + return result.toString(); + } + + // PRIVATE // + static Pattern TRIMMED_TEXT = Pattern.compile("((?:\\S(?:.)*\\S)|(?:\\S))"); + + private boolean fEscapeChars = true; + private LocaleSource fLocaleSource = BuildImpl.forLocaleSource(); + private Translator fTranslator = BuildImpl.forTranslator(); + + private void doStartTag(StringBuffer aResult, StringBuffer aSnippet, char aCharacter) { + if (Util.textHasContent(aSnippet.toString()) ){ + appendTranslation(aSnippet, aResult); + } + else { + //often contains just spaces and/or new lines, which are just appended + aResult.append(aSnippet.toString()); + } + aSnippet.setLength(0); + aResult.append(aCharacter); + } + + private void doEndTag(StringBuffer aResult, char aCharacter) { + aResult.append(aCharacter); + } + + private void doRegularCharacter(StringBuffer aResult, StringBuffer aSnippet, boolean aIsInsideTag, char aCharacter) { + if( aIsInsideTag ){ + aResult.append(aCharacter); + } + else { + aSnippet.append(aCharacter); + } + //fLogger.fine("Snippet : " + aSnippet); + } + + /** + The snippet may contain leading or trailing white space, or control chars (new lines), + which must be preserved. + */ + private void appendTranslation(StringBuffer aSnippet, StringBuffer aResult){ + if( Util.textHasContent(aSnippet.toString()) ) { + StringBuffer translatedSnippet = new StringBuffer(); + + Matcher matcher = TRIMMED_TEXT.matcher(aSnippet.toString()); + while ( matcher.find() ) { + matcher.appendReplacement(translatedSnippet, getReplacement(matcher)); + } + matcher.appendTail(translatedSnippet); + + if( fEscapeChars ) { + aResult.append(EscapeChars.forHTML(translatedSnippet.toString())); + } + else { + aResult.append(translatedSnippet); + } + } + else { + aResult.append(aSnippet.toString()); + } + } + + private String getReplacement(Matcher aMatcher){ + String result = null; + String baseText = aMatcher.group(Regex.FIRST_GROUP); + if (Util.textHasContent(baseText)){ + Locale locale = fLocaleSource.get(getRequest()); + result = fTranslator.get(baseText, locale); + } + else { + result = baseText; + } + return EscapeChars.forReplacementString(result); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ui/translate/Tooltips.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ui/translate/Tooltips.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,140 @@ +package hirondelle.web4j.ui.translate; + +import java.util.Locale; +import java.util.logging.Logger; +import java.util.regex.*; + +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.request.LocaleSource; +import hirondelle.web4j.ui.tag.TagHelper; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.util.Regex; +import hirondelle.web4j.util.EscapeChars; + +/** + Custom tag for translating + TITLE, + ALT + and submit-button + VALUE + attributes in markup. + +

By using this custom tag once in a template JSP, + it is often possible to translate all of the TITLE, + ALT, and submit-button VALUE attributes appearing in an entire application. + +

The VALUE attribute is translated only for SUBMIT controls. (This + VALUE attribute isn't really a tooltip, of course : it is rendered as the button text. + For this custom tag, the distinction is not very important.) + +

The TITLE attribute applies to a large number of HTML tags, and + the ALT attribute applies to several tags. In both cases, these + items generate pop-up "tool tips", which are visible to the end user. If the application is + multilingual, then they require translation. + +

This custom tag accepts HTML markup for its body, and will do a search and + replace on its content, replacing the values of all TITLE, ALT and + submit-button VALUE attributes (that have visible content) with translated values. + The translations are provided by the configured implementations of + {@link Translator} and {@link LocaleSource}. +*/ +public final class Tooltips extends TagHelper { + + /** + Control the escaping of special characters. + +

By default, this tag will escape any special characters appearing in the + TITLE or ALT attribute, using {@link EscapeChars#forHTML(String)}. + To override this default behaviour, set this value to false. + */ + public void setEscapeChars(boolean aValue){ + fEscapeChars = aValue; + } + + /** + Scan the body of this tag, and translate the values of all TITLE, ALT, + and submit-button VALUE attributes. + +

Uses the configured {@link Translator} and {@link LocaleSource}. + +

In addition, this method uses {@link EscapeChars#forHTML(String)} to ensure that + any special characters are escaped. This behavior can be overridden using {@link #setEscapeChars(boolean)}. + */ + @Override protected String getEmittedText(String aOriginalBody) { + //fLogger.finest("Original Body: " + aOriginalBody); + String result = translateTooltips(aOriginalBody); + result = translateSubmitButtons(result); + //fLogger.finest("TranslateTooltips translated : " + result.toString()); + return result; + } + + /** + Pattern which returns the value of TITLE and ALT attributes (including any quotes), + as group 2, which is to be translated. + */ + static final Pattern TOOLTIP = Pattern.compile( + "(<[^>]* (?:title=|alt=))" + Regex.ATTR_VALUE + "([^>]*>)", + Pattern.CASE_INSENSITIVE + ); + + /** + Pattern which returns the value of VALUE attribute (including any quotes) of a SUBMIT + control, as group 2, which is to be translated. + +

Small nuisance restriction : the general order of items must follow this style, where type + and value precede all other attributes, and type precedes value: +

+    <input type="submit" value="Add" [any other attributes go here]>
+   
+ */ + static final Pattern SUBMIT_BUTTON = Pattern.compile( + "(]*>)", + Pattern.CASE_INSENSITIVE + ); + + // PRIVATE // + + private static final Logger fLogger = Util.getLogger(Tooltips.class); + private boolean fEscapeChars = true; + private LocaleSource fLocaleSource = BuildImpl.forLocaleSource(); + private Translator fTranslator = BuildImpl.forTranslator(); + + private String translateTooltips(String aInput){ + return scanAndReplace(TOOLTIP, aInput); + } + + private String translateSubmitButtons(String aInput){ + return scanAndReplace(SUBMIT_BUTTON, aInput); + } + + private String scanAndReplace(Pattern aPattern, String aInput){ + StringBuffer result = new StringBuffer(); + Matcher matcher = aPattern.matcher(aInput); + while ( matcher.find() ) { + matcher.appendReplacement(result, getReplacement(matcher)); + } + matcher.appendTail(result); + return result.toString(); + } + + private String getReplacement(Matcher aMatcher){ + String result = null; + String baseText = Util.removeQuotes(aMatcher.group(Regex.SECOND_GROUP)); + if (Util.textHasContent(baseText)){ + String start = aMatcher.group(Regex.FIRST_GROUP); + String end = aMatcher.group(Regex.THIRD_GROUP); + Locale locale = fLocaleSource.get(getRequest()); + String translatedText = fTranslator.get(baseText, locale); + if ( fEscapeChars ){ + translatedText = EscapeChars.forHTML(translatedText); + } + result = start + Util.quote(translatedText) + end; + } + else { + result = aMatcher.group(Regex.ENTIRE_MATCH); + } + result = EscapeChars.forReplacementString(result); + fLogger.finest("TITLE/ALT base Text: " + Util.quote(baseText) + " has replacement text : " + Util.quote(result)); + return result; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ui/translate/Translation.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ui/translate/Translation.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,294 @@ +package hirondelle.web4j.ui.translate; + +import java.util.*; +import hirondelle.web4j.model.ModelCtorException; +import hirondelle.web4j.model.ModelUtil; +import hirondelle.web4j.model.Id; +import hirondelle.web4j.model.Check; +import hirondelle.web4j.security.SafeText; + +/** + Model Object for a translation. + +

This class is provided as a convenience. Implementations of {@link Translator} are not required to + use this class. + +

As one of its {@link hirondelle.web4j.StartupTasks}, a typical implementation of + {@link Translator} may fetch a {@code List} from some source + (usually a database, perhaps some properties files), and keep a cache in memory. + +

+ For looking up translations, the following nested {@link Map} structure is useful : +

+   Map[BaseText, Map[Locale, Translation]]
+ 
+ Here, BaseText and Translation are ordinary unescaped + Strings, not {@link SafeText}. This is because the various translation tags in this + package always first perform translation using ordinary unescaped Strings, and + then perform any necessary escaping on the result of the translation. + +

(See {@link Translator} for definition of 'base text'.) + +

The {@link #asNestedMap(Collection)} method will modify a {@code List} into just such a + structure. As well, {@link #lookUp(String, Locale, Map)} provides a simple default method for + performing the typical lookup with such a structure, given base text and target locale. + +

Usually String, but sometimes SafeText

+ The following style will remain consistent, and will not escape special characters twice : +
    +
  • unescaped : translations stored in the database. +
  • escaped : Translation objects (since they use {@link SafeText}). This allows end users + to edit such objects just like any other data, with no danger of scripts executing in their browser. +
  • unescaped : in-memory data, extracted from N Translation objects + using {@link SafeText#getRawString()}. This in-memory data implements a + Translator. Its data is not rendered directly + in a JSP, so it can remain as String. +
  • escaped : the various translation tags always perform the needed escaping on the raw String. +
+ + The translation text usually remains as a String, yet {@link SafeText} is available + when working with the data directly in a web page, in a form or listing. +*/ +public final class Translation implements Comparable { + + /** + Constructor with no explicit foreign keys. + + @param aBaseText item to be translated (required). See {@link Translator} for definition of 'base text'. + @param aLocale target locale for the translation (required) + @param aTranslation translation of the base text into the target locale (required) + */ + public Translation(SafeText aBaseText, Locale aLocale, SafeText aTranslation) throws ModelCtorException { + fBaseText = aBaseText; + fLocale = aLocale; + fTranslation = aTranslation; + validateState(); + } + + /** + Constructor with explict foreign keys. + +

This constructor allows carrying the foreign keys directly, instead of performing lookup later on. + (If the database does not support subselects, then use of this constructor will likely reduce + trivial lookup operations.) + + @param aBaseText item to be translated (required). See {@link Translator} for definition of 'base text'. + @param aLocale target locale for the translation (required) + @param aTranslation translation of the base text into the target locale (required) + @param aBaseTextId foreign key representing a BaseText item, 1..50 characters (optional) + @param aLocaleId foreign key representing a Locale, 1..50 characters (optional) + */ + public Translation(SafeText aBaseText, Locale aLocale, SafeText aTranslation, Id aBaseTextId, Id aLocaleId) throws ModelCtorException { + fBaseText = aBaseText; + fLocale = aLocale; + fTranslation = aTranslation; + fBaseTextId = aBaseTextId; + fLocaleId = aLocaleId; + validateState(); + } + + /** Return the base text passed to the constructor. */ + public SafeText getBaseText() { + return fBaseText; + } + + /** Return the locale passed to the constructor. */ + public Locale getLocale() { + return fLocale; + } + + /** Return the localized translation passed to the constructor. */ + public SafeText getTranslation() { + return fTranslation; + } + + /** Return the base text id passed to the constructor. */ + public Id getBaseTextId(){ + return fBaseTextId; + } + + /** Return the locale id passed to the constructor. */ + public Id getLocaleId(){ + return fLocaleId; + } + + /** + Return a {@link Map} having a structure + typically needed for looking up translations. + +

The caller will use the returned {@link Map} to look up first using BaseText, + and then using Locale. See {@link #lookUp(String, Locale, Map)}. + + @param aTranslations {@link Collection} of {@link Translation} objects. + @return {@link Map} of a structure suitable for looking up translations. + */ + public static Map> asNestedMap(Collection aTranslations){ + Map> result = new LinkedHashMap>(); + String currentBaseText = null; + Map currentTranslations = null; + for (Translation trans: aTranslations){ + if ( trans.getBaseText().getRawString().equals(currentBaseText) ){ + currentTranslations.put(trans.getLocale().toString(), trans.getTranslation().getRawString()); + } + else { + //finish old + if (currentBaseText != null) { + result.put(currentBaseText, currentTranslations); + } + //start new + currentBaseText = trans.getBaseText().getRawString(); + currentTranslations = new LinkedHashMap(); + currentTranslations.put(trans.getLocale().toString(), trans.getTranslation().getRawString()); + } + } + //ensure last one is added + if(currentBaseText != null && currentTranslations != null){ + result.put(currentBaseText, currentTranslations); + } + return result; + } + + /** + Look up a translation using a simple policy. + +

If aBaseText is not known, or if there is no explicit translation for + the exact {@link Locale}, then return aBaseText as is, without translation or + alteration. + +

The policy used here is simple. It may not be desirable for some applications. + In particular, if there is a need to implement a "best match" to aLocale + (after the style of {@link ResourceBundle}), then this method cannot be used. + + @param aBaseText text to be translated. See {@link Translator} for a definition of 'base text'. + @param aLocale whose toString result will be used to find the localized + translation of aBaseText. + @param aTranslations has the structure suitable for look up. + @return {@link LookupResult} carrying the text of the successful translation, or, in the case of a failed lookup, information + about the nature of the failure. + */ + public static LookupResult lookUp(String aBaseText, Locale aLocale, Map> aTranslations) { + LookupResult result = null; + Map allTranslations = aTranslations.get(aBaseText); + if ( allTranslations == null ) { + result = LookupResult.UNKNOWN_BASE_TEXT; + } + else { + String translation = allTranslations.get(aLocale.toString()); + result = (translation != null) ? new LookupResult(translation): LookupResult.UNKNOWN_LOCALE; + } + return result; + } + + /** + The result of {@link Translation#lookUp(String, Locale, Map)}. +

Encapsulates both the species of success/fail and the actual + text of the translation, if any. + +

Example of a typical use case : +

+    String text = null;
+    LookupResult lookup = Translation.lookUp(aBaseText, aLocale, fTranslations);
+    if( lookup.hasSucceeded() ){ 
+      text = lookup.getText();
+    }
+    else {
+      text = aBaseText;
+      if(LookupResult.UNKNOWN_BASE_TEXT == lookup){
+        addToListOfUnknowns(aBaseText);
+      }
+      else if (LookupResult.UNKNOWN_LOCALE == lookup){
+        //do nothing in this implementation
+      }
+    }
+  
+ */ + public static final class LookupResult { + /** BaseText is unknown. */ + public static final LookupResult UNKNOWN_BASE_TEXT = new LookupResult(); + /** BaseText is known, but no translation exists for the specified Locale*/ + public static final LookupResult UNKNOWN_LOCALE = new LookupResult(); + /** Returns true only if a specific translation exists for BaseText and Locale. */ + public boolean hasSucceeded(){ return fTranslationText != null; } + /** + Return the text of the successful translation. + Returns null only if {@link #hasSucceeded()} is false. + */ + public String getText(){ return fTranslationText; } + LookupResult(String aTranslation){ + fTranslationText = aTranslation; + } + private final String fTranslationText; + private LookupResult(){ + fTranslationText = null; + } + } + + /** Intended for debugging only. */ + @Override public String toString(){ + return ModelUtil.toStringFor(this); + } + + public int compareTo(Translation aThat) { + final int EQUAL = 0; + if ( this == aThat ) return EQUAL; + + int comparison = this.fBaseText.compareTo(aThat.fBaseText); + if ( comparison != EQUAL ) return comparison; + + comparison = this.fLocale.toString().compareTo(aThat.fLocale.toString()); + if ( comparison != EQUAL ) return comparison; + + comparison = this.fTranslation.compareTo(aThat.fTranslation); + if ( comparison != EQUAL ) return comparison; + + return EQUAL; + } + + @Override public boolean equals(Object aThat){ + Boolean result = ModelUtil.quickEquals(this, aThat); + if ( result == null ) { + Translation that = (Translation)aThat; + result = ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields()); + } + return result; + } + + @Override public int hashCode() { + if ( fHashCode == 0 ){ + fHashCode = ModelUtil.hashCodeFor(getSignificantFields()); + } + return fHashCode; + } + + // PRIVATE // + private final SafeText fBaseText; + private final Locale fLocale; + private final SafeText fTranslation; + private Id fLocaleId; + private Id fBaseTextId; + private int fHashCode; + + private void validateState() throws ModelCtorException { + ModelCtorException ex = new ModelCtorException(); + if( ! Check.required(fBaseText) ) { + ex.add("Base Text must have content."); + } + if( ! Check.required(fLocale) ) { + ex.add("Locale must have content."); + } + if( ! Check.required(fTranslation) ) { + ex.add("Translation must have content."); + } + if( ! Check.optional(fLocaleId, Check.min(1), Check.max(50)) ){ + ex.add("LocaleId optional, 1..50 characters."); + } + if( ! Check.optional(fBaseTextId, Check.min(1), Check.max(50)) ){ + ex.add("BaseTextId optional, 1..50 characters."); + } + if( ex.isNotEmpty() ) throw ex; + } + + private Object[] getSignificantFields(){ + return new Object[] {fBaseText, fLocale, fTranslation}; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ui/translate/Translator.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ui/translate/Translator.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,127 @@ +package hirondelle.web4j.ui.translate; + +import hirondelle.web4j.request.LocaleSource; + +import java.util.Locale; + +/** + Translate base text into a localized form. + +

See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. + {@link hirondelle.web4j.BuildImpl#forTranslator()} returns the configured implementation of this interface. + +

Here, "base text" refers to either : +

    +
  • a snippet of user-presentable text in the language being used to build the application +
  • a "coder key", such as "image.title" or "button.label". + Coder keys are never presented to the end user, and serve as an alias or shorthand + for something else. They are intended for the exclusive use of the programmer, and may + be thought of as being in a special "language" understood only by the programmer. +
+ +

In code, it is likely best to use user-presentable text + instead of coder keys, whenever possible - since lookups are never needed, it makes + code clearer at the point of call. + +

Please see the package overview for an interesting way of + evolving the implementation of this interface during development, allowing applications to assist in their + own translation. + +

One might validly object to a framework which requires an implementation of this + interface even for single-language applications. However, arguments in favor of + such a style include : +

    +
  • providing a do-nothing implementation for a single-language application is trivial. +
  • changing from a single language to multiple languages is a common requirement for + web applications. The presence of this implementation makes it clear to the maintainer + where such changes are made, if they become necessary. +
  • some programmers might prefer to name items as 'emailAddr' instead of + 'Email Address', for instance. In this case, a {@link Translator} can also + be used, even in a single-language application, to translate from such 'coder keys' + to user-presentable text. +
+ +

The recommended style is to implement this interface with a database, and to + avoid ResourceBundle. + +

Guidance For Implementing With A Database
+ Example of a web application with the following particular requirements + (see the example application for further illustration): +

    +
  • English is the base development language, used by the programmers and development team +
  • the user interface needs to be in both English and French +
  • in source code, the programmers use both regular text snippets in + user-presentable English, and (occasionally) 'coder keys', according to the needs of each particular case +
+ + Here is a style of ResultSet that can be used to implement this interface : +

+ + + + + + + + + +
BaseTextLocaleTranslation
Fish And ChipsenFish And Chips
Fish And ChipsfrPoisson et Frites
deleteendelete
deletefrsupprimer
add.edit.buttonenAdd/Edit
add.edit.buttonfrAjouter/Changer
+ +

Only the last two rows use a "coder key". + The BaseText column holds either the coder key, or the user-presentable English. + The BaseText is the lookup key. The fact that there is + repetition of data between the BaseText and English columns is not a real duplication problem, + since this is a ResultSet, not a table - the underlying tables will not have such + repetition (if designed properly, of course). + +

For example, such a ResultSet can be constructed from three underlying tables - + BaseText, Locale, and Translation. Translation is a cross-reference + table, with foreign keys to both BaseText and Locale. When such a scheme is + normalized, it will have no repeated data. (In underlying queries, however, the base + language will be necessarily treated differently than the other languages.) + +

Upon startup, the tables are read, the above ResultSet is created and + stored in a private static Map, represented schematically as + Map[BaseText, Map[Locale, Translation]]. When a translation is required, + this Map is used as an in-memory lookup table. This avoids + repeated fetching from the database of trivial data that rarely changes. + +

Note on Thread Safety +
In the suggested implementation style, the private static Map that stores translation data is + static, and thus shared by multiple threads. Implementations are free to implement + any desired policy regarding thread safety, from relaxed to strict. A relaxed implementation + might completely ignore the possibility of a mistaken read/write, since no writes are expected in + production, or simply because the consequences are usually trivial. A strict implementation would + take the opposite approach, and demand that the private static Map be used in an fully + thread-safe manner. +*/ +public interface Translator { + + /** + Translate aBaseText into text appropriate for aLocale. + +

aBaseText is either user-presentable text in the base language of + development, or a "coder key" known only to the programmer. + +

If aBaseText is unknown, then the implementation is free to define + any desired behavior. For example, this method might return +

    +
  • aBaseText passed to this method, in its raw, untranslated form +
  • aBaseText with special surrounding text, such as ???some text???, with + leading and trailing characters +
  • a fixed String such as '???' or '?untranslated?' +
  • it is also possible for an implementation to throw a {@link RuntimeException} when + an item is missing, but this is not recommended for applications in production +
+ +

If aBaseText is known, but there is no explicit translation for the + given {@link Locale}, then an implementation might choose to mimic the behavior of + {@link java.util.ResourceBundle}, and choose a translation from the "nearest available" + {@link Locale} instead. + + @param aBaseText is not null, but may be empty + @param aLocale comes from the configured {@link LocaleSource}. + */ + String get(String aBaseText, Locale aLocale); + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/ui/translate/package.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/ui/translate/package.html Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,86 @@ + + + + + + + Translate + + +Translation of text for multilingual applications. + +

Last-Second Localization

+WEB4J's goal is to make the task of changing a single-language application into a + multilingual one as painless and as focused as possible, with minimal + ripple effects. To support this idea, WEB4J has two main design goals : +
    +
  • Model Objects, actions, and Data Access Objects should be + exactly the same regardless of whether the application is multilingual or not. +
  • Markup in a multilingual application should appear almost the same + as in a single language application. +
+ +

+To achieve the first goal, WEB4J uses a policy of "last-second localization", +whereby translations are performed outside of normal code, and only in Java Server Pages, +using custom tags. This corresponds to the idea that a translation is "just a view". + +

Overview

+The various items are : +
    +
  • {@link hirondelle.web4j.request.LocaleSource} defines how an application deduces + a {@link java.util.Locale} from an incoming request (using a policy defined by your application). +
  • the Locale obtained from {@link hirondelle.web4j.request.LocaleSource} is passed to + a {@link hirondelle.web4j.ui.translate.Translator}, which defines how an application translates + base text into a given target language. +
  • the {@link hirondelle.web4j.ui.translate.Text} tag uses the above to perform the translation +"at the last second", in a JSP. +
  • the {@link hirondelle.web4j.ui.translate.TextFlow} tag translates all the text flow (the text appearing +outside of tags) appearing in its body. This tag is suitable for translating large sections +of markup whose text flow is simple and non-structured. +
  • the {@link hirondelle.web4j.ui.translate.Tooltips} tag translates all TITLE +and ALT attributes appearing in its body (it also translates the text of SUBMIT buttons). +If placed in a high-level template, it +translates those attributes across a large number of pages - perhaps even the whole site. +
  • the {@link hirondelle.web4j.ui.translate.Messages} tag translates the +{@link hirondelle.web4j.model.AppResponseMessage}s emitted by {@link hirondelle.web4j.action.Action}s. +
+ +

Translatable Items

+ Translatable items include : +
    +
  • static text in a web page +
  • tooltips - the TITLE and ALT attributes of tags +
  • links whose target is dependent on Locale +
  • dynamic messages generated by the application +
+ +

Suggested Development Style

+ For a new multilingual application, one may proceed as follows : +
    +
  • start development with a do-nothing {@link hirondelle.web4j.ui.translate.Translator}, + that simply returns the base text, unchanged and untranslated +
  • use the + {@link hirondelle.web4j.ui.translate.Text}, + {@link hirondelle.web4j.ui.translate.Tooltips}, + {@link hirondelle.web4j.ui.translate.TextFlow}, + and {@link hirondelle.web4j.ui.translate.Messages} tags during development, + to internationalize JSPs from the beginning. (The text is not localized at this point.) +
  • when nearing completion, begin localization by changing the {@link hirondelle.web4j.ui.translate.Translator} + implementation, to always record the base text in some way (save to the database, or to an in-memory List, + for example). Thus, by simply exercising all parts of the application + (including all possible error messages), one may generate a listing of all items that need translation. +
  • finally, translate all items, and provide the "correct" {@link hirondelle.web4j.ui.translate.Translator} + implementation. (See the example application for illustration.) +
+ +

If an existing application needs to be changed from a single-language style to a multilingual style, +then a similar technique may be used. + +

Database versus {@link java.util.ResourceBundle}

+Although a {@link hirondelle.web4j.ui.translate.Translator} may be backed by a +ResourceBundle, the recommended style is to use a database instead. +ResourceBundle is a +mediocre tool for web applications. + + diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/util/Args.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/util/Args.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,112 @@ +package hirondelle.web4j.util; + +import java.util.regex.*; + +/** + Utility methods for common argument validations. + +

Replaces if statements at the start of a method with + more compact method calls. + +

Example use case. +

Instead of : +

+ public void doThis(String aText){
+   if (!Util.textHasContent(aText)){
+     throw new IllegalArgumentException();
+   }
+   //..main body elided
+ }
+ 
+

One may instead write : +

+ public void doThis(String aText){
+   Args.checkForContent(aText);
+   //..main body elided
+ }
+ 
+*/ +public final class Args { + + /** + If aText does not satisfy {@link Util#textHasContent}, then + throw an IllegalArgumentException. + +

Most text used in an application is meaningful only if it has visible content. + */ + public static void checkForContent(String aText){ + if( ! Util.textHasContent(aText) ){ + throw new IllegalArgumentException("Text has no visible content"); + } + } + + /** + If {@link Util#isInRange} returns false, then + throw an IllegalArgumentException. + + @param aLow is less than or equal to aHigh. + */ + public static void checkForRange(int aNumber, int aLow, int aHigh) { + if ( ! Util.isInRange(aNumber, aLow, aHigh) ) { + throw new IllegalArgumentException(aNumber + " not in range " + aLow + ".." + aHigh); + } + } + + /** + If aNumber is less than 1, then throw an + IllegalArgumentException. + */ + public static void checkForPositive(int aNumber) { + if (aNumber < 1) { + throw new IllegalArgumentException(aNumber + " is less than 1"); + } + } + + /** + If {@link Util#matches} returns false, then + throw an IllegalArgumentException. + */ + public static void checkForMatch(Pattern aPattern, String aText){ + if (! Util.matches(aPattern, aText)){ + throw new IllegalArgumentException( + "Text " + Util.quote(aText) + " does not match '" +aPattern.pattern()+ "'" + ); + } + } + + /** + If aObject is null, then throw a NullPointerException. + +

Use cases : +

+   doSomething( Football aBall ){
+     //1. call some method on the argument : 
+     //if aBall is null, then exception is automatically thrown, so 
+     //there is no need for an explicit check for null.
+     aBall.inflate();
+    
+     //2. assign to a corresponding field (common in constructors): 
+     //if aBall is null, no exception is immediately thrown, so 
+     //an explicit check for null may be useful here
+     Args.checkForNull( aBall );
+     fBall = aBall;
+     
+     //3. pass on to some other method as parameter : 
+     //it may or may not be appropriate to have an explicit check 
+     //for null here, according the needs of the problem
+     Args.checkForNull( aBall ); //??
+     fReferee.verify( aBall );
+   }
+   
+ */ + public static void checkForNull(Object aObject) { + if (aObject == null) { + throw new NullPointerException(); + } + } + + // PRIVATE + private Args(){ + //empty - prevent construction + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/util/Consts.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/util/Consts.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,55 @@ +package hirondelle.web4j.util; + +/** + Collected constants of general utility. + +

All members of this class are immutable. + +

(This is an example of + class for constants.) +*/ +public final class Consts { + + /** Opposite of {@link #FAILS}. */ + public static final boolean PASSES = true; + /** Opposite of {@link #PASSES}. */ + public static final boolean FAILS = false; + + /** Opposite of {@link #FAILURE}. */ + public static final boolean SUCCESS = true; + /** Opposite of {@link #SUCCESS}. */ + public static final boolean FAILURE = false; + + /** + Useful for {@link String} operations, which return an index of -1 when + an item is not found. + */ + public static final int NOT_FOUND = -1; + + /** System property - line.separator*/ + public static final String NEW_LINE = System.getProperty("line.separator"); + /** System property - file.separator*/ + public static final String FILE_SEPARATOR = System.getProperty("file.separator"); + /** System property - path.separator*/ + public static final String PATH_SEPARATOR = System.getProperty("path.separator"); + + public static final String EMPTY_STRING = ""; + public static final String SPACE = " "; + public static final String TAB = "\t"; + public static final String SINGLE_QUOTE = "'"; + public static final String PERIOD = "."; + public static final String DOUBLE_QUOTE = "\""; + + // PRIVATE // + + /** + The caller references the constants using Consts.EMPTY_STRING, + and so on. Thus, the caller should be prevented from constructing objects of + this class, by declaring this private constructor. + */ + private Consts(){ + //this prevents even the native class from + //calling this ctor as well : + throw new AssertionError(); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/util/EscapeChars.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/util/EscapeChars.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,535 @@ +package hirondelle.web4j.util; + +import java.net.URLEncoder; +import java.io.UnsupportedEncodingException; +import java.text.CharacterIterator; +import java.text.StringCharacterIterator; +import java.util.regex.Pattern; +import java.util.regex.Matcher; + +import hirondelle.web4j.security.SafeText; +import hirondelle.web4j.ui.translate.Text; +import hirondelle.web4j.ui.translate.Tooltips; +import hirondelle.web4j.ui.translate.TextFlow; +import hirondelle.web4j.ui.tag.Populate; +import hirondelle.web4j.database.Report; + +/** + Convenience methods for escaping special characters related to HTML, XML, + and regular expressions. + +

To keep you safe by default, WEB4J goes to some effort to escape + characters in your data when appropriate, such that you usually + don't need to think too much about escaping special characters. Thus, you + shouldn't need to directly use the services of this class very often. + +

For Model Objects containing free form user input, + it is highly recommended that you use {@link SafeText}, not String. + Free form user input is open to malicious use, such as + Cross Site Scripting + attacks. + Using SafeText will protect you from such attacks, by always escaping + special characters automatically in its toString() method. + +

The following WEB4J classes will automatically escape special characters + for you, when needed : +

    +
  • the {@link SafeText} class, used as a building block class for your + application's Model Objects, for modeling all free form user input +
  • the {@link Populate} tag used with forms +
  • the {@link Report} class used for creating quick reports +
  • the {@link Text}, {@link TextFlow}, and {@link Tooltips} custom tags used + for translation +
+*/ +public final class EscapeChars { + + /** + Escape characters for text appearing in HTML markup. + +

This method exists as a defence against Cross Site Scripting (XSS) hacks. + The idea is to neutralize control characters commonly used by scripts, such that + they will not be executed by the browser. This is done by replacing the control + characters with their escaped equivalents. + See {@link hirondelle.web4j.security.SafeText} as well. + +

The following characters are replaced with corresponding + HTML character entities : + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Character Replacement
< &lt;
> &gt;
& &amp;
" &quot;
\t &#009;
! &#033;
# &#035;
$ &#036;
% &#037;
' &#039;
( &#040;
) &#041;
* &#042;
+ &#043;
, &#044;
- &#045;
. &#046;
/ &#047;
: &#058;
; &#059;
= &#061;
? &#063;
@ &#064;
[ &#091;
\ &#092;
] &#093;
^ &#094;
_ &#095;
` &#096;
{ &#123;
| &#124;
} &#125;
~ &#126;
+ +

Note that JSTL's {@code } escapes only the first + five of the above characters. + */ + public static String forHTML(String aText){ + final StringBuilder result = new StringBuilder(); + final StringCharacterIterator iterator = new StringCharacterIterator(aText); + char character = iterator.current(); + while (character != CharacterIterator.DONE ){ + if (character == '<') { + result.append("<"); + } + else if (character == '>') { + result.append(">"); + } + else if (character == '&') { + result.append("&"); + } + else if (character == '\"') { + result.append("""); + } + else if (character == '\t') { + addCharEntity(9, result); + } + else if (character == '!') { + addCharEntity(33, result); + } + else if (character == '#') { + addCharEntity(35, result); + } + else if (character == '$') { + addCharEntity(36, result); + } + else if (character == '%') { + addCharEntity(37, result); + } + else if (character == '\'') { + addCharEntity(39, result); + } + else if (character == '(') { + addCharEntity(40, result); + } + else if (character == ')') { + addCharEntity(41, result); + } + else if (character == '*') { + addCharEntity(42, result); + } + else if (character == '+') { + addCharEntity(43, result); + } + else if (character == ',') { + addCharEntity(44, result); + } + else if (character == '-') { + addCharEntity(45, result); + } + else if (character == '.') { + addCharEntity(46, result); + } + else if (character == '/') { + addCharEntity(47, result); + } + else if (character == ':') { + addCharEntity(58, result); + } + else if (character == ';') { + addCharEntity(59, result); + } + else if (character == '=') { + addCharEntity(61, result); + } + else if (character == '?') { + addCharEntity(63, result); + } + else if (character == '@') { + addCharEntity(64, result); + } + else if (character == '[') { + addCharEntity(91, result); + } + else if (character == '\\') { + addCharEntity(92, result); + } + else if (character == ']') { + addCharEntity(93, result); + } + else if (character == '^') { + addCharEntity(94, result); + } + else if (character == '_') { + addCharEntity(95, result); + } + else if (character == '`') { + addCharEntity(96, result); + } + else if (character == '{') { + addCharEntity(123, result); + } + else if (character == '|') { + addCharEntity(124, result); + } + else if (character == '}') { + addCharEntity(125, result); + } + else if (character == '~') { + addCharEntity(126, result); + } + else { + //the char is not a special one + //add it to the result as is + result.append(character); + } + character = iterator.next(); + } + return result.toString(); + } + + + /** + Escape all ampersand characters in a URL. + +

Replaces all '&' characters with '&amp;'. + +

An ampersand character may appear in the query string of a URL. + The ampersand character is indeed valid in a URL. + However, URLs usually appear as an HREF attribute, and + such attributes have the additional constraint that ampersands + must be escaped. + +

The JSTL <c:url> tag does indeed perform proper URL encoding of + query parameters. But it does not, in general, produce text which + is valid as an HREF attribute, simply because it does + not escape the ampersand character. This is a nuisance when + multiple query parameters appear in the URL, since it requires a little + extra work. + */ + public static String forHrefAmpersand(String aURL){ + return aURL.replace("&", "&"); + } + + /** + Synonym for URLEncoder.encode(String, "UTF-8"). + +

Used to ensure that HTTP query strings are in proper form, by escaping + special characters such as spaces. + +

It is important to note that if a query string appears in an HREF + attribute, then there are two issues - ensuring the query string is valid HTTP + (it is URL-encoded), and ensuring it is valid HTML (ensuring the + ampersand is escaped). + */ + public static String forURL(String aURLFragment){ + String result = null; + try { + result = URLEncoder.encode(aURLFragment, "UTF-8"); + } + catch (UnsupportedEncodingException ex){ + throw new RuntimeException("UTF-8 not supported", ex); + } + return result; + } + + /** + Escape characters for text appearing as XML data, between tags. + +

The following characters are replaced with corresponding character entities : + + + + + + + +
Character Encoding
< &lt;
> &gt;
& &amp;
" &quot;
' &#039;
+ +

Note that JSTL's {@code } escapes the exact same set of + characters as this method. That is, {@code } + is good for escaping to produce valid XML, but not for producing safe + HTML. + */ + public static String forXML(String aText){ + final StringBuilder result = new StringBuilder(); + final StringCharacterIterator iterator = new StringCharacterIterator(aText); + char character = iterator.current(); + while (character != CharacterIterator.DONE ){ + if (character == '<') { + result.append("<"); + } + else if (character == '>') { + result.append(">"); + } + else if (character == '\"') { + result.append("""); + } + else if (character == '\'') { + result.append("'"); + } + else if (character == '&') { + result.append("&"); + } + else { + //the char is not a special one + //add it to the result as is + result.append(character); + } + character = iterator.next(); + } + return result.toString(); + } + + /** + Escapes characters for text appearing as data in the + Javascript Object Notation + (JSON) data interchange format. + +

The following commonly used control characters are escaped : + + + + + + + + + + +
Character Escaped As
" \"
\ \\
/ \/
back space \b
form feed \f
line feed \n
carriage return \r
tab \t
+ +

See RFC 4627 for more information. + */ + public static String forJSON(String aText){ + final StringBuilder result = new StringBuilder(); + StringCharacterIterator iterator = new StringCharacterIterator(aText); + char character = iterator.current(); + while (character != StringCharacterIterator.DONE){ + if( character == '\"' ){ + result.append("\\\""); + } + else if(character == '\\'){ + result.append("\\\\"); + } + else if(character == '/'){ + result.append("\\/"); + } + else if(character == '\b'){ + result.append("\\b"); + } + else if(character == '\f'){ + result.append("\\f"); + } + else if(character == '\n'){ + result.append("\\n"); + } + else if(character == '\r'){ + result.append("\\r"); + } + else if(character == '\t'){ + result.append("\\t"); + } + else { + //the char is not a special one + //add it to the result as is + result.append(character); + } + character = iterator.next(); + } + return result.toString(); + } + + /** + Return aText with all '<' and '>' characters + replaced by their escaped equivalents. + */ + public static String toDisableTags(String aText){ + final StringBuilder result = new StringBuilder(); + final StringCharacterIterator iterator = new StringCharacterIterator(aText); + char character = iterator.current(); + while (character != CharacterIterator.DONE ){ + if (character == '<') { + result.append("<"); + } + else if (character == '>') { + result.append(">"); + } + else { + //the char is not a special one + //add it to the result as is + result.append(character); + } + character = iterator.next(); + } + return result.toString(); + } + + + /** + Replace characters having special meaning in regular expressions + with their escaped equivalents, preceded by a '\' character. + +

The escaped characters include : +

    +
  • . +
  • \ +
  • ?, * , and + +
  • & +
  • : +
  • { and } +
  • [ and ] +
  • ( and ) +
  • ^ and $ +
+ */ + public static String forRegex(String aRegexFragment){ + final StringBuilder result = new StringBuilder(); + + final StringCharacterIterator iterator = + new StringCharacterIterator(aRegexFragment) + ; + char character = iterator.current(); + while (character != CharacterIterator.DONE ){ + /* + All literals need to have backslashes doubled. + */ + if (character == '.') { + result.append("\\."); + } + else if (character == '\\') { + result.append("\\\\"); + } + else if (character == '?') { + result.append("\\?"); + } + else if (character == '*') { + result.append("\\*"); + } + else if (character == '+') { + result.append("\\+"); + } + else if (character == '&') { + result.append("\\&"); + } + else if (character == ':') { + result.append("\\:"); + } + else if (character == '{') { + result.append("\\{"); + } + else if (character == '}') { + result.append("\\}"); + } + else if (character == '[') { + result.append("\\["); + } + else if (character == ']') { + result.append("\\]"); + } + else if (character == '(') { + result.append("\\("); + } + else if (character == ')') { + result.append("\\)"); + } + else if (character == '^') { + result.append("\\^"); + } + else if (character == '$') { + result.append("\\$"); + } + else { + //the char is not a special one + //add it to the result as is + result.append(character); + } + character = iterator.next(); + } + return result.toString(); + } + + /** + Escape '$' and '\' characters in replacement strings. + +

Synonym for Matcher.quoteReplacement(String). + +

The following methods use replacement strings which treat + '$' and '\' as special characters: +

    +
  • String.replaceAll(String, String) +
  • String.replaceFirst(String, String) +
  • Matcher.appendReplacement(StringBuffer, String) +
+ +

If replacement text can contain arbitrary characters, then you + will usually need to escape that text, to ensure special characters + are interpreted literally. + */ + public static String forReplacementString(String aInput){ + return Matcher.quoteReplacement(aInput); + } + + /** + Disable all <SCRIPT> tags in aText. + +

Insensitive to case. + */ + public static String forScriptTagsOnly(String aText){ + String result = null; + Matcher matcher = SCRIPT.matcher(aText); + result = matcher.replaceAll("<SCRIPT>"); + matcher = SCRIPT_END.matcher(result); + result = matcher.replaceAll("</SCRIPT>"); + return result; + } + + // PRIVATE // + + private EscapeChars(){ + //empty - prevent construction + } + + private static final Pattern SCRIPT = Pattern.compile( + "", Pattern.CASE_INSENSITIVE + ); + + private static void addCharEntity(Integer aIdx, StringBuilder aBuilder){ + String padding = ""; + if( aIdx <= 9 ){ + padding = "00"; + } + else if( aIdx <= 99 ){ + padding = "0"; + } + else { + //no prefix + } + String number = padding + aIdx.toString(); + aBuilder.append("&#" + number + ";"); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/util/Regex.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/util/Regex.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,312 @@ +package hirondelle.web4j.util; + +/** + (UNPUBLISHED) Simple collection of commonly used regular expressions, as Strings. + +

Regular expressions are both cryptic and error-prone. Reuse of + the regular expressions in this class should increase legibility in the caller, and + reduce testing time. + +

(These are presented here as Strings, and not as Patterns, to + aid in constructing complex expressions out of simpler ones.) + +

Some items follow the style of Mastering Regular Expressions, by + Jeffrey Friedl (0-596-00289-0). + +

Grouping represents a problem for this class, since the caller may or + may not desire a particular element to be a capturing group. +*/ +public final class Regex { + + /** + Not a well-formed regex, but a symbolic name for alternation, intended + simply to improve legibility of regexes. + */ + public static final String OR = "|"; + + /** + Whitespace. + */ + public static final String WS = "\\s*"; + public static final String DOT = "\\."; + public static final String ANY_CHARS = ".*"; + public static final String START_TAG = "<"; + public static final String END_TAG = WS + ">"; + public static final String ALL_BUT_END_OF_TAG = "[^>]*"; + public static final String ALL_BUT_START_OF_TAG = "[^<]*"; + public static final String QUOTE = "(?:'|\")"; + public static final String NO_SPECIAL_HTML_CHAR = "[^<>'\"]"; + + /** Group 1 returns the attribute value. */ + public static final String QUOTED_ATTR = QUOTE+"((?:"+NO_SPECIAL_HTML_CHAR+")*)"+QUOTE; + + /** Group 1 returns the trimmed text. */ + public static final String TRIMMED_TEXT = "(?:\\s)*((?:\\S(?:.)*\\S)|(?:\\S))(?:\\s)*"; + + //Might be used for unquoted attributes: + //public static final String fNO_SPECIAL_HTML_CHARS_OR_SPACES = "[^<>'\"\\s]"; + + public static final int ENTIRE_MATCH = 0; + public static final int FIRST_GROUP = 1; + public static final int SECOND_GROUP = 2; + public static final int THIRD_GROUP = 3; + public static final int FOURTH_GROUP = 4; + + // NEW items added after web4j 1.3.0 : + + public static final String SINGLE_QUOTED_ATTR = "'[^']*'"; + public static final String DOUBLE_QUOTED_ATTR = "\"[^\"]*\""; + public static final String UNQUOTED_ATTR = "[-.:\\w]+"; + + /** + Group 1 is the attribute value, with quotes. +

Use {@link Util#removeQuotes(String)} to remove any quotes, if needed. +

The content of an HTML tag attribute is specified at + http://www.w3.org/TR/html4/intro/sgmltut.html#attributes + */ + public static final String ATTR_VALUE = + "(" + + SINGLE_QUOTED_ATTR + + OR + + DOUBLE_QUOTED_ATTR + + OR + + UNQUOTED_ATTR + + ")" + ; + + public static final String ATTR_NAME = "[a-zA-Z]+"; + public static final String TAG_NAME = "[a-zA-Z]+"; + public static final String ATTR = "\\s+" + ATTR_NAME + WS + "=" + WS + ATTR_VALUE; + public static final String FIRST_TAG = "<" + TAG_NAME + "(?:" + ATTR + ")*" + WS + ">"; + public static final String SECOND_TAG = ""; + public static final String TAG_BODY = "(.*?)"; + public static final String ENTIRE_TAG = FIRST_TAG + TAG_BODY + SECOND_TAG; + + public static final String BLANK_LINE = "^\\s*$"; + + /** + Finds positions where a comma should be placed in a number, in the style + 1,000,000. Intended for integers, but can also handle up to 3 decimal + places. For example, 10000.001 gives 10,000.001. + */ + public static final String COMMA_INSERTION = "(?<=\\d)(?=(\\d\\d\\d)+(?!\\d))"; + + /** + Either integer or floating point number. Has the following properties +

    +
  • digits with possible decimal point +
  • possible leading plus or minus sign +
  • no grouping delimiters (such as a comma) +
  • no leading or trailing whitespace +
+

Example matches: 1, 100, 2.3, -2.3, +2.3, -272.13, -.0, 2. +

Example mismatches: '1,000', '123 ', ' 123'. + */ + public static final String NUMBER = + "(?:-|\\+)?" + + "(" + + "[0-9]+" + "(" + DOT + "[0-9]*)?" + + OR + + DOT + "[0-9]+" + + ")" + ; + + /** + Similar to {@link #NUMBER}, except the number of decimals, if present, is always 2. + +

Example matches : 1000, 100.25, .25, 0.25, -.13, -0.13 +

Example mismatches : 1,000.00, 100., 100.0, 100.123, -56.000, .123 + */ + public static final String DOLLARS = + "(?:-|\\+)?" + + "(" + + "[0-9]+" + "(" + Regex.DOT + "[0-9]{2})?" + + Regex.OR + + Regex.DOT + "[0-9]{2}" + + ")" + ; + + /** + An amount in any currency. + +

There are two permitted decimal separators: '.' and ','. The + permitted number of decimals is 0,2,3. + +

Example matches : '1000', '100.25', '.253', '0.25', '-.13', '-0.00' +

Example mismatches : '1,000.00', '100.', '100.0', '100.1234', ',1', ',1234' + +

Note as well that '1,000' matches as well! A grouping separator + in one Locale is a decimal separator in others. + +

Any String that matches this pattern will be accepted by + {@link java.math.BigDecimal#BigDecimal(String)}. + */ + public static final String MONEY = + "(?:-|\\+)?" + + "(" + + "[0-9]+((?:\\.|,)[0-9]{2,3})?" + + Regex.OR + + "(?:\\.|,)[0-9]{2,3}" + + ")" + ; + + /** + An arbitrary number of digits. Has the following properties +

    +
  • value greater than or equal to 0 +
  • possible leading zeros, as in 0100, or 0002 +
  • no decimal point +
  • no leading plus or minus sign +
  • no leading or trailing whitespace +
+ +

Design Note:
+ Allowing leading zeros is not a problem for creating Integer objects, + since the Integer(String) constructor allows them. + +

Example matches: 0, 1, 2, 9, 10, 99, 789, 010, 0018.
+ Example mismatches: -1, +1, 2.0, ' 0', '2 '. + */ + public static final String DIGITS = "(\\d)+"; + + /** + Return a regular expression corresponding to DIGITS, but having + number of digits in range 1..aMaxNumDigits. + + @param aMaxNumDigits must be 1 or more. + */ + public static String forNDigits(int aMaxNumDigits){ + Args.checkForRange(aMaxNumDigits, 1, Integer.MAX_VALUE); + return "(\\d){1," + aMaxNumDigits + "}"; + } + + /** + Email address. + +

The {@link javax.mail.internet.InternetAddress} class permits validation of an + email address. See also + {@link hirondelle.web4j.util.WebUtil#isValidEmailAddress(String)}. Thus, this + regex should be used only when those classes are not available. + */ + public static final String EMAIL_ADDR = + "\\w[-.\\w]*@" + + "[a-z0-9]+(\\.[a-z0-9]+)*" + DOT + + "(com|org|net|edu|gov|int|mil|biz|info|name|museum|coop|aero|[a-z][a-z])" + ; + + /** + Matches numbers in the range 0-255. +

Example: 1, 001, 010, 199, 255. + */ + private static final String IP_ADDR_ITEM = + "(?:[01]?\\d\\d?" + + OR + + "2[0-4]\\d" + + OR + + "25[0-5])" + ; + + /** + IP addresses. +

Example match: 1.01.001.255 + */ + public static final String IP_ADDR = + "(?Intended for manipulation of text in camel hump style, which looks like this : + BlahBlahBlah, LoginName, EmailAddress. + +

Example:
+ To change 'LoginName' into the more user-friendly 'Login Name' (with an added space), + replace the matches returned by this regex with the replacement string ' $1'. + */ + public static final String CAMEL_HUMP_TEXT = "(?<=[a-z0-9])([A-Z])"; + + /** + Simple identifier. + One or more letters/underscores, with possible trailing digits. + Matching examples include : +

    +
  • blah +
  • blah42 +
  • blah_42 +
  • BlahBlah +
  • BLAH_BLAH +
+ */ + public static final String SIMPLE_IDENTIFIER = "([a-zA-Z_]+(?:\\d)*)"; + + /** + Scoped identifier. + +

Either two {@link #SIMPLE_IDENTIFIER}s separated by a period, or a single + {@link #SIMPLE_IDENTIFIER}. The item before the period represents an optional + scoping qualifier. (This style is used by SQL statement identifiers, where the + scoping qualifier represents the target database.) + */ + public static final String SIMPLE_SCOPED_IDENTIFIER = "(?:[a-zA-Z_]+(?:\\d)*\\.)?(?:[a-zA-Z_]+(?:\\d)*)"; + + /** + A link or anchor tag. + +

Here, HREF must be the first attribute to appear in the tag. + The following groups are defined : +

    +
  • group 1 - value of the HREF attr +
  • group 2 - all text after the HREF attr, but still inside the tag - the "remainder" + attributes +
  • group 3 - the body of the A tag +
+ */ + public static final String LINK = + "" + ; + + /** + Month in the Gregorian calendar: 01..12. + */ + public static final String MONTH = + "(01|02|03|04|05|06|07|08|09|10|11|12)" + ; + + /** + Day of the month in the Gregorian calendar: 01..31. + */ + public static final String DAY_OF_MONTH = + "(01|02|03|04|05|06|07|08|09|10|11|12|13|14|15|16|17|18|19|20|21|22|23|24|25|26|27|28|29|30|31)" + ; + + /** Hours in the day 00..23. */ + public static final String HOURS = + "(00|01|02|03|04|05|06|07|08|09|10|11|12|13|14|15|16|17|18|19|20|21|22|23)" + ; + + /** Minutes in an hour 00..59. */ + public static final String MINUTES = + "((0|1|2|3|4|5)\\d)" + ; + + /** Hours and minutes, in the form 00:59. */ + public static final String HOURS_AND_MINUTES = + HOURS + ":" + MINUTES + ; + + // PRIVATE // + + /** Prevents instantiation of this class. */ + private Regex(){ + //emtpy + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/util/Stopwatch.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/util/Stopwatch.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,124 @@ +package hirondelle.web4j.util; + +import java.math.BigDecimal; + +/** + Allows timing of the execution of any block of code. + + Example use case: +
+Stopwatch stopwatch = new Stopwatch();
+stopwatch.start();
+//..perform operations
+stopwatch.stop();
+
+//timed in nanos, but toString is expressed in millis,
+//to three decimal places, to reflect the real resolution of most systems:
+System.out.println("The reading on the stopwatch is: " + stopwatch);
+
+//reuse the same stopwatch again
+//Note that there is no need to call a reset method.
+stopwatch.start();
+//..perform operations
+stopwatch.stop();
+
+//perform a numeric comparison, using raw nanoseconds:
+if ( stopwatch.toValue() > 5 ) {
+  System.out.println("The reading is high: " + stopwatch);
+}
+
+ +

The value on the stopwatch may be inspected at any time using the + {@link #toString} (millis) and {@link #toValue} (nanos) methods. + +

Example 2 +
To time the various steps in a long startup or initialization task, your code may take the form: +

+Stopwatch stopwatch = new Stopwatch();
+stopwatch.start();
+//..perform operation 1
+log("Step 1 completed " + stopwatch + " after start.");
+
+//perform operation 2
+log("Step 2 completed " + stopwatch + " after start.");
+
+//perform the last operation, operation 3
+stopwatch.stop();
+log("Final Step 3 completed " + stopwatch + " after start") ;
+
+ +

Implementation Note:
+ This class uses {@link System#nanoTime()}, not {@link System#currentTimeMillis()}, to + perform its timing operations. +*/ +public final class Stopwatch { + + /** + Start the stopwatch. + +

You cannot call this method if the stopwatch is already started. + */ + public void start(){ + if (fIsRunning) { + throw new IllegalStateException("Must stop before calling start again."); + } + //reset both start and stop + fStart = getCurrentTime(); + fStop = 0; + fIsRunning = true; + } + + /** + Stop the stopwatch. + +

You can only call this method if the stopwatch has been started. + */ + public void stop() { + if (!fIsRunning) { + throw new IllegalStateException("Cannot stop if not currently running."); + } + fStop = getCurrentTime(); + fIsRunning = false; + } + + /** + Return the current "reading" on the stopwatch, in milliseconds, in a format suitable + typical use cases. + +

Example return value : '108.236 ms'. The underlying timing is in nanos, + but it's expressed here in millis, for two reasons: the resolution on most systems is + in the microsecond range, and the full 9 digits are less easy to reader. If you need + the raw nanos, just use {@link #toValue()} instead. + +

Ref: https://blogs.oracle.com/dholmes/entry/inside_the_hotspot_vm_clocks + */ + @Override public String toString() { + StringBuilder result = new StringBuilder(); + BigDecimal value = new BigDecimal(toValue());//scale is zero + //millis, with 3 decimals: + value = value.divide(MILLION, 3, BigDecimal.ROUND_HALF_EVEN); + result.append(value); + result.append(" ms"); + return result.toString(); + } + + /** + Return the current "reading" on the stopwatch in raw nanoseconds, as a numeric type. + */ + public long toValue() { + long result = fStop == 0 ? (getCurrentTime() - fStart) : (fStop -fStart); + return result; + } + + // PRIVATE + private long fStart; + private long fStop; + private boolean fIsRunning; + + /** Converts from nanos to millis. */ + private static final BigDecimal MILLION = new BigDecimal("1000000"); + + private long getCurrentTime(){ + return System.nanoTime(); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/util/TESTEscapeChars.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/util/TESTEscapeChars.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,231 @@ +package hirondelle.web4j.util; + +import junit.framework.*; +//import junit.ui.TestRunner; +//import junit.textui.TestRunner; +//import junit.swingui.TestRunner; +import java.util.regex.*; +import java.util.logging.*; +import hirondelle.web4j.util.Util; + +/** + JUnit test cases for {@link EscapeChars}. +*/ +public final class TESTEscapeChars extends TestCase { + + /** + Run the test cases. + */ + public static void main(String args[]) { + String[] testCaseName = { TESTEscapeChars.class.getName()}; + //Select one of several types of interfaces. + junit.textui.TestRunner.main(testCaseName); + //junit.swingui.TestRunner.main(testCaseName); + //junit.ui.TestRunner.main(testCaseName); + } + + public TESTEscapeChars(String aName) { + super(aName); + } + + // TEST CASES // + + public void testForRegex() { + + //escaped regex does not equal original literal, + //yet literal is a match for its own escaped pattern + testForRegexHasMatch("a.b"); + testForRegexHasMatch("a\\b"); + testForRegexHasMatch("a?b"); + testForRegexHasMatch("a*b"); + testForRegexHasMatch("a+b"); + testForRegexHasMatch("a&b"); + testForRegexHasMatch("{ab}"); + testForRegexHasMatch("ab{}"); + testForRegexHasMatch("a{b}"); + testForRegexHasMatch("{ab"); + testForRegexHasMatch("ab}"); + testForRegexHasMatch("[ab]"); + testForRegexHasMatch("ab]"); + testForRegexHasMatch("[ab"); + testForRegexHasMatch("a[]b"); + testForRegexHasMatch("a^b"); + testForRegexHasMatch("^ab"); + testForRegexHasMatch("ab^"); + testForRegexHasMatch("a$b"); + testForRegexHasMatch("$ab"); + testForRegexHasMatch("ab$"); + testForRegexHasMatch("ab:"); + testForRegexHasMatch(":b:"); + testForRegexHasMatch("a::"); + testForRegexHasMatch("(ab)"); + testForRegexHasMatch("(ab"); + testForRegexHasMatch("ab)"); + testForRegexHasMatch("a(b)"); + testForRegexHasMatch("()ab"); + testForRegexHasMatch("a()b"); + testForRegexHasMatch(".\\?*+&:{}[]()^$"); + } + + public void testForHTMLFlow(){ + testHTMLFlow("this", "this", Consts.SUCCESS); + testHTMLFlow("this ", "this ", Consts.SUCCESS); + testHTMLFlow(" this", " this", Consts.SUCCESS); + testHTMLFlow(" ", " ", Consts.SUCCESS); + testHTMLFlow("This is a test", "This is a test", Consts.SUCCESS); + testHTMLFlow("This is > test", "This is > test", Consts.SUCCESS); + testHTMLFlow("This is < test", "This is < test", Consts.SUCCESS); + testHTMLFlow("This is & test", "This is & test", Consts.SUCCESS); + testHTMLFlow("This is a 'test'", "This is a 'test'", Consts.SUCCESS); + testHTMLFlow("This is a \"test\"", "This is a "test"", Consts.SUCCESS); + testHTMLFlow("This is a (test)", "This is a (test)", Consts.SUCCESS); + testHTMLFlow("This is # test", "This is # test", Consts.SUCCESS); + testHTMLFlow("This is % test", "This is % test", Consts.SUCCESS); + testHTMLFlow("This is ; test", "This is ; test", Consts.SUCCESS); + testHTMLFlow("This is + test", "This is + test", Consts.SUCCESS); + testHTMLFlow("This is - test", "This is - test", Consts.SUCCESS); + + testHTMLFlow("This is ! test", "This is ! test", Consts.SUCCESS); + //testHTMLFlow("This is \n test", "This is test", Consts.SUCCESS); //this char no longer escaped + //testHTMLFlow("This is \r test", "This is test", Consts.SUCCESS); //this char no longer escaped + testHTMLFlow("This is $ test", "This is $ test", Consts.SUCCESS); + testHTMLFlow("This is * test", "This is * test", Consts.SUCCESS); + testHTMLFlow("This is . test", "This is . test", Consts.SUCCESS); + testHTMLFlow("This is / test", "This is / test", Consts.SUCCESS); + testHTMLFlow("This is : test", "This is : test", Consts.SUCCESS); + testHTMLFlow("This is = test", "This is = test", Consts.SUCCESS); + testHTMLFlow("This is ? test", "This is ? test", Consts.SUCCESS); + testHTMLFlow("This is @ test", "This is @ test", Consts.SUCCESS); + testHTMLFlow("This is [ test", "This is [ test", Consts.SUCCESS); + testHTMLFlow("This is \\ test", "This is \ test", Consts.SUCCESS); + testHTMLFlow("This is ] test", "This is ] test", Consts.SUCCESS); + testHTMLFlow("This is ^ test", "This is ^ test", Consts.SUCCESS); + testHTMLFlow("This is _ test", "This is _ test", Consts.SUCCESS); + testHTMLFlow("This is ` test", "This is ` test", Consts.SUCCESS); + testHTMLFlow("This is { test", "This is { test", Consts.SUCCESS); + testHTMLFlow("This is ~ test", "This is ~ test", Consts.SUCCESS); + + //first twelve + testHTMLFlow("<>&\"'()#%+-;", "<>&"'()#%+-;", Consts.SUCCESS); + //remainder + //testHTMLFlow("\t\n\r!$*./:=?@[\\]^_`{|}~", " !$*./:=?@[\]^_`{|}~", Consts.SUCCESS); + //testHTMLFlow("This is a test \t\n\r!$*./:=?@[\\]^_`{|}~ This is a test", "This is a test !$*./:=?@[\]^_`{|}~ This is a test", Consts.SUCCESS); + + testHTMLFlow("\t!$*./:=?@[\\]^_`{|}~", " !$*./:=?@[\]^_`{|}~", Consts.SUCCESS); + testHTMLFlow("This is a test \t!$*./:=?@[\\]^_`{|}~ This is a test", "This is a test !$*./:=?@[\]^_`{|}~ This is a test", Consts.SUCCESS); + + testHTMLFlow("This is a test", "This is a test ", Consts.FAILS); + testHTMLFlow("This is a test", " This is a test", Consts.FAILS); + testHTMLFlow("This is a test", "This is a test", Consts.FAILS); + testHTMLFlow("This is a test", " This is a test ", Consts.FAILS); + testHTMLFlow("This is > test", "This is > test", Consts.FAILS); + testHTMLFlow("This is > test", "This is > test", Consts.FAILS); + testHTMLFlow("This is > test", "This is gt; test", Consts.FAILS); + testHTMLFlow("This is + test", "This is &043; test", Consts.FAILS); + testHTMLFlow("This is + test", "This is + test", Consts.FAILS); + testHTMLFlow("This is + test", "This is + test", Consts.FAILS); + testHTMLFlow("This is ~ test", "This is  test", Consts.FAILS); + } + + public void testForReplacementString(){ + testReplacementString("abc", "abc", Consts.SUCCESS); + testReplacementString("a$b", "a\\$b", Consts.SUCCESS); + testReplacementString("$1", "\\$1", Consts.SUCCESS); + testReplacementString("a\\b", "a\\\\b", Consts.SUCCESS); + testReplacementString("[ab]", "[ab]", Consts.SUCCESS); + testReplacementString("ab{}*&*()%#^@", "ab{}*&*()%#^@", Consts.SUCCESS); + + testReplacementString("abc", "abcd", Consts.FAILS); + testReplacementString("a$b", "a$b", Consts.FAILS); + testReplacementString("$1", "$1", Consts.FAILS); + testReplacementString("a\\b", "a\\b", Consts.FAILS); + } + + public void testForJson(){ + testJson("abc", "abc", Consts.SUCCESS); + testJson(" \"abc\" ", " \\\"abc\\\" ", Consts.SUCCESS); + testJson("This is one 'New York Ago'", "This is one 'New York Ago'", Consts.SUCCESS); + testJson("Joel" + '\t' + "Plaskett", "Joel\\tPlaskett", Consts.SUCCESS); + testJson("Joel" + '\f' + "Plaskett", "Joel\\fPlaskett", Consts.SUCCESS); + testJson("JoelPlaskett" + '\b', "JoelPlaskett\\b", Consts.SUCCESS); + testJson("JoelPlaskett" + '\n', "JoelPlaskett\\n", Consts.SUCCESS); + testJson("Joel Plaskett" + '\r', "Joel Plaskett\\r", Consts.SUCCESS); + testJson("Joel Plaskett" + '\r' + '\n' , "Joel Plaskett\\r\\n", Consts.SUCCESS); + testJson("My name is Flann O'Brien", "My name is Flann O'Brien", Consts.SUCCESS); + testJson("My \"name\" is Flann O'Brien", "My \\\"name\\\" is Flann O'Brien", Consts.SUCCESS); + testJson("I am \\ slash", "I am \\\\ slash", Consts.SUCCESS); + testJson("I am / backslash", "I am \\/ backslash", Consts.SUCCESS); + testJson("This is \"the\" most \\complex\\ test /of/ all; it has lot's going in inside" + '\n' + '\r', "This is \\\"the\\\" most \\\\complex\\\\ test \\/of\\/ all; it has lot's going in inside\\n\\r" , Consts.SUCCESS); + } + + // FIXTURE // + + protected void setUp(){ + //empty + } + + /** + Re-set test objects. + */ + protected void tearDown() { + //empty + } + + // PRIVATE // + + private static final Logger fLogger = Util.getLogger(TESTEscapeChars.class); + + private void testForRegexHasMatch(String aUnescapedRegex){ + Pattern pattern = Pattern.compile(EscapeChars.forRegex(aUnescapedRegex)); + assertTrue(! pattern.pattern().equals(aUnescapedRegex) ); + Matcher matcher = pattern.matcher(aUnescapedRegex); + fLogger.fine("Escaped pattern: " + pattern.pattern()); + if ( !matcher.matches() ){ + fail(aUnescapedRegex + " does not match pattern " + pattern.pattern()); + } + } + + private void testHTMLFlow(String aInput, String aExpectedOutput, Boolean aSeekingSuccess){ + String output = EscapeChars.forHTML(aInput); + if ( aSeekingSuccess ) { + if ( ! output.equals(aExpectedOutput) ){ + fail("Fail: Output was " + Util.quote(output) + ", expected successful match with " + Util.quote(aExpectedOutput)); + } + } + else { + if ( output.equals(aExpectedOutput) ){ + fail("Fail: Output was " + Util.quote(output) + ", expected failed match with " + Util.quote(aExpectedOutput)); + } + } + } + + private void testReplacementString(String aInput, String aExpectedOutput, Boolean aSeekingSuccess){ + String output = EscapeChars.forReplacementString(aInput); + if ( aSeekingSuccess ) { + if( ! output.equals(aExpectedOutput)) { + fail("Fail: Output was " + Util.quote(output) + ", but expected " + Util.quote(aExpectedOutput)); + } + } + else { + if ( output.equals(aExpectedOutput) ){ + fail("Fail: Output was " + Util.quote(output) + ", but expected failure to match " + Util.quote(aExpectedOutput)); + } + } + } + + private void testJson(String aInput, String aExpectedOutput, Boolean aSeekingSuccess){ + String output = EscapeChars.forJSON(aInput); + if(aSeekingSuccess){ + if( !output.equals(aExpectedOutput) ) { + fail("Fail: Output was " + Util.quote(output) + ", but expected " + Util.quote(aExpectedOutput)); + } + } + else { + if( output.equals(aExpectedOutput) ){ + fail("Fail: Output was " + Util.quote(output) + ", but expected failure to match " + Util.quote(aExpectedOutput)); + } + } + } +} + + diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/util/TESTRegex.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/util/TESTRegex.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,580 @@ +package hirondelle.web4j.util; + +import junit.framework.*; +//import junit.ui.TestRunner; +//import junit.textui.TestRunner; +import java.util.regex.*; + +/** + JUnit test cases for {@link Regex}. +*/ +public final class TESTRegex extends TestCase { + + /** + Run the test cases. + */ + public static void main(String args[]) { + String[] testCaseName = {TESTRegex.class.getName()}; + //Select one of several types of interfaces. + junit.textui.TestRunner.main(testCaseName); + //junit.swingui.TestRunner.main(testCaseName); + //junit.ui.TestRunner.main(testCaseName); + } + + public TESTRegex(String aName) { + super(aName); + } + + // TEST CASES // + + public void testWhitespace() { + testIsMatch(Regex.WS, ""); + testIsMatch(Regex.WS, " "); + testIsMatch(Regex.WS, Consts.NEW_LINE); + testIsMatch(Regex.WS, Consts.TAB); + + testIsNotMatch(Regex.WS, "A"); + testIsNotMatch(Regex.WS, "0"); + testIsNotMatch(Regex.WS, " 0 "); + } + + public void testAnyCharsExceptNewLine(){ + testIsMatch(Regex.ANY_CHARS, ""); + testIsMatch(Regex.ANY_CHARS, " "); + testIsMatch(Regex.ANY_CHARS, "."); + testIsMatch(Regex.ANY_CHARS, "A"); + testIsMatch(Regex.ANY_CHARS, "9"); + testIsMatch(Regex.ANY_CHARS, "@#$%@#%@#*%@#(%@)_#(%"); + testIsMatch(Regex.ANY_CHARS, Consts.TAB); + + testIsNotMatch(Regex.ANY_CHARS, Consts.NEW_LINE); + } + + public void testStartTag(){ + testIsMatch(Regex.START_TAG, "<"); + + testIsNotMatch(Regex.START_TAG, "< "); + testIsNotMatch(Regex.START_TAG, " <"); + testIsNotMatch(Regex.START_TAG, ">"); + } + + public void testEndTag(){ + testIsMatch(Regex.END_TAG, ">"); + testIsMatch(Regex.END_TAG, " >"); + + testIsNotMatch(Regex.END_TAG, "> "); + testIsNotMatch(Regex.END_TAG, "<"); + } + + public void testAllButEndOfTag(){ + testIsMatch(Regex.ALL_BUT_END_OF_TAG, ""); + testIsMatch(Regex.ALL_BUT_END_OF_TAG, " "); + testIsMatch(Regex.ALL_BUT_END_OF_TAG, "value='blah'"); + testIsMatch(Regex.ALL_BUT_END_OF_TAG, " value='blah' "); + testIsMatch(Regex.ALL_BUT_END_OF_TAG, " value=blah "); + testIsMatch(Regex.ALL_BUT_END_OF_TAG, " value=\"blah\" "); + testIsMatch(Regex.ALL_BUT_END_OF_TAG, "checked value='blah'"); + testIsMatch(Regex.ALL_BUT_END_OF_TAG, " checked value='blah' "); + + testIsNotMatch(Regex.ALL_BUT_END_OF_TAG, "value='blah'>" ); + testIsNotMatch(Regex.ALL_BUT_END_OF_TAG, " checked value='blah' >" ); + testIsNotMatch(Regex.ALL_BUT_END_OF_TAG, " checked value='blah' > " ); + } + + public void testQuote(){ + testIsMatch(Regex.QUOTE, "\""); + testIsMatch(Regex.QUOTE, "'"); + + testIsNotMatch(Regex.QUOTE, ""); + testIsNotMatch(Regex.QUOTE, " "); + testIsNotMatch(Regex.QUOTE, "A"); + testIsNotMatch(Regex.QUOTE, "'A'"); + testIsNotMatch(Regex.QUOTE, "\"A\""); + } + + public void testNoSpecialHtmlChars(){ + testIsMatch(Regex.NO_SPECIAL_HTML_CHAR, "p"); + testIsMatch(Regex.NO_SPECIAL_HTML_CHAR, "#"); + testIsMatch(Regex.NO_SPECIAL_HTML_CHAR, " "); + + testIsNotMatch(Regex.NO_SPECIAL_HTML_CHAR, "<"); + testIsNotMatch(Regex.NO_SPECIAL_HTML_CHAR, ">"); + testIsNotMatch(Regex.NO_SPECIAL_HTML_CHAR, "'"); + testIsNotMatch(Regex.NO_SPECIAL_HTML_CHAR, "\""); + testIsNotMatch(Regex.NO_SPECIAL_HTML_CHAR, "blah"); // >1 char + } + + public void testQuotedAttr(){ + testIsMatch(Regex.QUOTED_ATTR, "'blah'"); + testIsMatch(Regex.QUOTED_ATTR, "'blah blah'"); + testIsMatch(Regex.QUOTED_ATTR, "'blah '"); + testIsMatch(Regex.QUOTED_ATTR, "' blah'"); + testIsMatch(Regex.QUOTED_ATTR, "' blah '"); + testIsMatch(Regex.QUOTED_ATTR, "\"blah\""); + testIsMatch(Regex.QUOTED_ATTR, "\"blah \""); + testIsMatch(Regex.QUOTED_ATTR, "\"blah blah\""); + testIsMatch(Regex.QUOTED_ATTR, "\" blah \""); + testIsNotMatch(Regex.QUOTED_ATTR, "blah"); + testIsNotMatch(Regex.QUOTED_ATTR, " blah "); + testIsNotMatch(Regex.QUOTED_ATTR, "blah blah"); + testIsNotMatch(Regex.QUOTED_ATTR, "''"); + testIsNotMatch(Regex.QUOTED_ATTR, "'blah''"); + testGroupOneIsMatch(Regex.QUOTED_ATTR, "'blah'", "blah"); + testGroupOneIsMatch(Regex.QUOTED_ATTR, "' blah'", " blah"); + testGroupOneIsMatch(Regex.QUOTED_ATTR, "'blah '", "blah "); + testGroupOneIsMatch(Regex.QUOTED_ATTR, "' blah '", " blah "); + testGroupOneIsMatch(Regex.QUOTED_ATTR, "'blah blah'", "blah blah"); + } + + public void testTrimmedText(){ + testGroupOneIsMatch(Regex.TRIMMED_TEXT, " blah ", "blah"); + testGroupOneIsMatch(Regex.TRIMMED_TEXT, "blah ", "blah"); + testGroupOneIsMatch(Regex.TRIMMED_TEXT, " blah ", "blah"); + testGroupOneIsMatch(Regex.TRIMMED_TEXT, " blah blah ", "blah blah"); + testGroupOneIsMatch(Regex.TRIMMED_TEXT, "#", "#"); + testGroupOneIsMatch(Regex.TRIMMED_TEXT, " #", "#"); + testGroupOneIsMatch(Regex.TRIMMED_TEXT, " # ", "#"); + testGroupOneIsMatch(Regex.TRIMMED_TEXT, "this is good", "this is good"); + testGroupOneIsMatch(Regex.TRIMMED_TEXT, " this is good", "this is good"); + testGroupOneIsMatch(Regex.TRIMMED_TEXT, " this is good", "this is good"); + testGroupOneIsMatch(Regex.TRIMMED_TEXT, " this is good ", "this is good"); + testGroupOneIsMatch(Regex.TRIMMED_TEXT, " this is good ", "this is good"); + testGroupOneIsMatch(Regex.TRIMMED_TEXT, " this is good ", "this is good"); + testGroupOneIsMatch(Regex.TRIMMED_TEXT, " this is good ", "this is good"); + } + + public void testAttrValue(){ + testIsMatch(Regex.UNQUOTED_ATTR, "blah"); + testIsMatch(Regex.UNQUOTED_ATTR, "-_.:blah"); + testIsMatch(Regex.UNQUOTED_ATTR, "BLAH"); + testIsMatch(Regex.UNQUOTED_ATTR, "0Blah9"); + testIsNotMatch(Regex.UNQUOTED_ATTR, " "); + testIsNotMatch(Regex.UNQUOTED_ATTR, ""); + testIsNotMatch(Regex.UNQUOTED_ATTR, "blah!"); + testIsNotMatch(Regex.UNQUOTED_ATTR, "blah{"); + testIsNotMatch(Regex.UNQUOTED_ATTR, "blah*"); + + testIsMatch(Regex.SINGLE_QUOTED_ATTR, "'blah'"); + testIsMatch(Regex.SINGLE_QUOTED_ATTR, "''"); + testIsMatch(Regex.SINGLE_QUOTED_ATTR, "'blah blah < > '"); + testIsMatch(Regex.SINGLE_QUOTED_ATTR, "'This is not a \"bongo\" drum.'"); + testIsMatch(Regex.SINGLE_QUOTED_ATTR, EscapeChars.forRegex("'!@#$!@#$%@$^&*()'") ); + testIsNotMatch(Regex.SINGLE_QUOTED_ATTR, "'This isn't a match'"); + + testIsMatch(Regex.DOUBLE_QUOTED_ATTR, "\"blah\""); + testIsMatch(Regex.DOUBLE_QUOTED_ATTR, "\"\""); + testIsMatch(Regex.DOUBLE_QUOTED_ATTR, "\"This is Billy's bongo .\""); + testIsMatch(Regex.DOUBLE_QUOTED_ATTR, "\"This is <> :; - . _ ! @ \""); + testIsMatch(Regex.DOUBLE_QUOTED_ATTR, "\"This is a 'so-called' quote.\""); + testIsNotMatch(Regex.DOUBLE_QUOTED_ATTR, "\"This is not a \"so-called\" match.\""); + + testIsMatch(Regex.ATTR_VALUE, "\"blah\""); + testIsMatch(Regex.ATTR_VALUE, "\"This is Billy's bongo.\""); + testIsMatch(Regex.ATTR_VALUE, "\"This is a 'so-called' quote.\""); + testIsMatch(Regex.ATTR_VALUE, "\"This is a ! (thing) of 2.5.% _- , !?:;{}\""); + testIsMatch(Regex.ATTR_VALUE, "\"\""); + + testIsMatch(Regex.ATTR_VALUE, "'blah'"); + testIsMatch(Regex.ATTR_VALUE, "''"); + testIsMatch(Regex.ATTR_VALUE, "'This is \"quoted\" like so.'"); + testIsMatch(Regex.ATTR_VALUE, "'This is a ! (thing) of 2.5.% _- , !?:;{}'"); + + testIsMatch(Regex.ATTR_VALUE, "blah"); + testIsMatch(Regex.ATTR_VALUE, "-._:BLAH09"); + } + + public void testCommaInsertion(){ + testCommas("1", "1"); + testCommas("10", "10"); + testCommas("100", "100"); + testCommas("1000", "1,000"); + testCommas("10000", "10,000"); + testCommas("100000", "100,000"); + testCommas("1000000", "1,000,000"); + testCommas("0", "0"); + testCommas("0.0", "0.0"); + testCommas("0.01", "0.01"); + testCommas("0.001", "0.001"); + testCommas("100000.001", "100,000.001"); + } + + public void testEmailAddress(){ + testIsMatch(Regex.EMAIL_ADDR, "bob@blah.com"); + testIsMatch(Regex.EMAIL_ADDR, "bob@blah.blah.com"); + testIsMatch(Regex.EMAIL_ADDR, "bob-.@blah.com"); + testIsMatch(Regex.EMAIL_ADDR, "bob@blah.org"); + testIsMatch(Regex.EMAIL_ADDR, "bob@blah.aero"); + testIsMatch(Regex.EMAIL_ADDR, "bob@blah.uk"); + testIsMatch(Regex.EMAIL_ADDR, "bob@blah.boo.uk"); + + testIsNotMatch(Regex.EMAIL_ADDR, "-.bob@blah.com"); + testIsNotMatch(Regex.EMAIL_ADDR, "bob"); + testIsNotMatch(Regex.EMAIL_ADDR, ""); + testIsNotMatch(Regex.EMAIL_ADDR, " "); + testIsNotMatch(Regex.EMAIL_ADDR, "bob@"); + testIsNotMatch(Regex.EMAIL_ADDR, "bob@blah"); + testIsNotMatch(Regex.EMAIL_ADDR, "bob@blah."); + testIsNotMatch(Regex.EMAIL_ADDR, "bob@blah.joe"); + } + + public void testIpAddress(){ + testIsMatch(Regex.IP_ADDR, "0.0.0.0"); + testIsMatch(Regex.IP_ADDR, "10.0.0.0"); + testIsMatch(Regex.IP_ADDR, "100.0.0.0"); + testIsMatch(Regex.IP_ADDR, "255.0.0.0"); + testIsMatch(Regex.IP_ADDR, "219.64.54.0"); + testIsMatch(Regex.IP_ADDR, "127.0.0.1"); + testIsMatch(Regex.IP_ADDR, "255.255.255.255"); + testIsMatch(Regex.IP_ADDR, "220.230.240.250"); + + testIsNotMatch(Regex.IP_ADDR, ""); + testIsNotMatch(Regex.IP_ADDR, " "); + testIsNotMatch(Regex.IP_ADDR, "1"); + testIsNotMatch(Regex.IP_ADDR, "255"); + testIsNotMatch(Regex.IP_ADDR, "255."); + testIsNotMatch(Regex.IP_ADDR, "0.0.0."); + testIsNotMatch(Regex.IP_ADDR, "0.0.0.0."); + testIsNotMatch(Regex.IP_ADDR, ".0.0.0.0"); + } + + public void testNumber(){ + testIsMatch(Regex.NUMBER, "0"); + testIsMatch(Regex.NUMBER, "123"); + testIsMatch(Regex.NUMBER, "+123"); + testIsMatch(Regex.NUMBER, "-123"); + testIsMatch(Regex.NUMBER, "3.14159"); + testIsMatch(Regex.NUMBER, "-3.14159"); + testIsMatch(Regex.NUMBER, "+0"); + testIsMatch(Regex.NUMBER, "-0"); + testIsMatch(Regex.NUMBER, "+0.0"); + testIsMatch(Regex.NUMBER, "-0.0"); + testIsMatch(Regex.NUMBER, ".2310"); + testIsMatch(Regex.NUMBER, "-.2310"); + testIsMatch(Regex.NUMBER, "+.235"); + testIsMatch(Regex.NUMBER, "+0.2561"); + testIsMatch(Regex.NUMBER, "-0.21365"); + testIsMatch(Regex.NUMBER, "2."); + + testIsNotMatch(Regex.NUMBER, ""); + testIsNotMatch(Regex.NUMBER, " "); + testIsNotMatch(Regex.NUMBER, "A"); + testIsNotMatch(Regex.NUMBER, "A123"); + testIsNotMatch(Regex.NUMBER, "123 "); + testIsNotMatch(Regex.NUMBER, " 123"); + testIsNotMatch(Regex.NUMBER, "1.2 "); + testIsNotMatch(Regex.NUMBER, "1,000"); + testIsNotMatch(Regex.NUMBER, "1,000.00"); + testIsNotMatch(Regex.NUMBER, "+ 123"); + testIsNotMatch(Regex.NUMBER, "- 123"); + } + + /* + *

Example matches : 1000, 100.25, .25, 0.25, -.13, -0.13 +

Example mismatches : 1,000.00, 100., 100.0, 100.123, -56.000, .123 + + */ + public void testDollars(){ + testIsMatch(Regex.DOLLARS, "1000"); + testIsMatch(Regex.DOLLARS, "+1000"); + testIsMatch(Regex.DOLLARS, "-1000"); + testIsMatch(Regex.DOLLARS, "100.25"); + testIsMatch(Regex.DOLLARS, "+100.25"); + testIsMatch(Regex.DOLLARS, "-100.25"); + testIsMatch(Regex.DOLLARS, ".25"); + testIsMatch(Regex.DOLLARS, "+.25"); + testIsMatch(Regex.DOLLARS, "-.25"); + testIsMatch(Regex.DOLLARS, "0.25"); + testIsMatch(Regex.DOLLARS, "+0.25"); + testIsMatch(Regex.DOLLARS, "-0.25"); + testIsMatch(Regex.DOLLARS, ".13"); + testIsMatch(Regex.DOLLARS, "+.13"); + testIsMatch(Regex.DOLLARS, "-.13"); + + testIsNotMatch(Regex.DOLLARS, "1000 "); + testIsNotMatch(Regex.DOLLARS, " 1000"); + testIsNotMatch(Regex.DOLLARS, "1,000"); + testIsNotMatch(Regex.DOLLARS, "1,0000.00"); + testIsNotMatch(Regex.DOLLARS, "100."); + testIsNotMatch(Regex.DOLLARS, "100.0"); + testIsNotMatch(Regex.DOLLARS, "100.123"); + testIsNotMatch(Regex.DOLLARS, "-56.000"); + testIsNotMatch(Regex.DOLLARS, ".123"); + testIsNotMatch(Regex.DOLLARS, ".2"); + testIsNotMatch(Regex.DOLLARS, ".222"); + testIsNotMatch(Regex.DOLLARS, ".22A"); + } + + public void testMoney(){ + testIsMatch(Regex.MONEY, "1000"); + testIsMatch(Regex.MONEY, "+1000"); + testIsMatch(Regex.MONEY, "-1000"); + testIsMatch(Regex.MONEY, "100.01"); + testIsMatch(Regex.MONEY, "+100.01"); + testIsMatch(Regex.MONEY, "-100.01"); + testIsMatch(Regex.MONEY, "100,12"); + testIsMatch(Regex.MONEY, "100,123"); + testIsMatch(Regex.MONEY, "1000,123"); + testIsMatch(Regex.MONEY, ".253"); + testIsMatch(Regex.MONEY, "0.25"); + testIsMatch(Regex.MONEY, "-.13"); + testIsMatch(Regex.MONEY, "-0.000"); + + testIsNotMatch(Regex.MONEY, "1,000.00"); + testIsNotMatch(Regex.MONEY, "1.000,00"); + testIsNotMatch(Regex.MONEY, "+1 000"); + //testIsNotMatch(Regex.MONEY, "-1,000"); //passes! maybe this is ok... an English 1,000 is a French 1.000 + //testIsNotMatch(Regex.MONEY, "-1,00"); //passes! + testIsNotMatch(Regex.MONEY, "-1,000.00"); + testIsNotMatch(Regex.MONEY, "1 000"); + testIsNotMatch(Regex.MONEY, "100."); + testIsNotMatch(Regex.MONEY, "100,"); + testIsNotMatch(Regex.MONEY, "100.0"); + testIsNotMatch(Regex.MONEY, "100,0"); + testIsNotMatch(Regex.MONEY, "100.1234"); + testIsNotMatch(Regex.MONEY, "-100,1234"); + testIsNotMatch(Regex.MONEY, ",1"); + testIsNotMatch(Regex.MONEY, ",1234"); + } + + public void testOrdinal(){ + testIsMatch(Regex.DIGITS, "0"); + testIsMatch(Regex.DIGITS, "1"); + testIsMatch(Regex.DIGITS, "9"); + testIsMatch(Regex.DIGITS, "10"); + testIsMatch(Regex.DIGITS, "11"); + testIsMatch(Regex.DIGITS, "19"); + testIsMatch(Regex.DIGITS, "20"); + testIsMatch(Regex.DIGITS, "1000"); + testIsMatch(Regex.DIGITS, "987988"); + testIsMatch(Regex.DIGITS, "128568521657894"); + testIsMatch(Regex.DIGITS, "01"); + testIsMatch(Regex.DIGITS, "09"); + testIsMatch(Regex.DIGITS, "018"); + testIsMatch(Regex.DIGITS, "0018"); + testIsMatch(Regex.DIGITS, "00018"); + + testIsNotMatch(Regex.DIGITS, ""); + testIsNotMatch(Regex.DIGITS, " "); + testIsNotMatch(Regex.DIGITS, "A"); + testIsNotMatch(Regex.DIGITS, "-1"); + testIsNotMatch(Regex.DIGITS, " 1"); + testIsNotMatch(Regex.DIGITS, "1 "); + testIsNotMatch(Regex.DIGITS, "+1"); + testIsNotMatch(Regex.DIGITS, "-0"); + testIsNotMatch(Regex.DIGITS, "+0"); + testIsNotMatch(Regex.DIGITS, "0.0"); + testIsNotMatch(Regex.DIGITS, "2.0"); + testIsNotMatch(Regex.DIGITS, "27.15"); + } + + public void testOrdinalOfLimitedLength(){ + testIsMatch(Regex.forNDigits(1), "0"); + testIsMatch(Regex.forNDigits(1), "1"); + testIsMatch(Regex.forNDigits(1), "9"); + + testIsMatch(Regex.forNDigits(2), "0"); + testIsMatch(Regex.forNDigits(2), "10"); + testIsMatch(Regex.forNDigits(2), "01"); + testIsMatch(Regex.forNDigits(2), "99"); + + testIsMatch(Regex.forNDigits(3), "0"); + testIsMatch(Regex.forNDigits(3), "1"); + testIsMatch(Regex.forNDigits(3), "11"); + testIsMatch(Regex.forNDigits(3), "01"); + testIsMatch(Regex.forNDigits(3), "100"); + testIsMatch(Regex.forNDigits(3), "001"); + testIsMatch(Regex.forNDigits(3), "568"); + testIsMatch(Regex.forNDigits(3), "999"); + + testIsNotMatch(Regex.forNDigits(1), ""); + testIsNotMatch(Regex.forNDigits(1), " "); + testIsNotMatch(Regex.forNDigits(1), "a"); + testIsNotMatch(Regex.forNDigits(1), "-1"); + testIsNotMatch(Regex.forNDigits(1), "+1"); + testIsNotMatch(Regex.forNDigits(1), "2.0"); + testIsNotMatch(Regex.forNDigits(1), "0.0"); + testIsNotMatch(Regex.forNDigits(1), "01"); + testIsNotMatch(Regex.forNDigits(1), "10"); + + testIsNotMatch(Regex.forNDigits(2), "a"); + testIsNotMatch(Regex.forNDigits(2), ""); + testIsNotMatch(Regex.forNDigits(2), " "); + testIsNotMatch(Regex.forNDigits(2), "-1"); + testIsNotMatch(Regex.forNDigits(2), "+1"); + testIsNotMatch(Regex.forNDigits(2), "2.0"); + testIsNotMatch(Regex.forNDigits(2), "0.0"); + testIsNotMatch(Regex.forNDigits(2), "100"); + testIsNotMatch(Regex.forNDigits(2), "001"); + + testIsNotMatch(Regex.forNDigits(3), "2.0"); + testIsNotMatch(Regex.forNDigits(3), "1000"); + testIsNotMatch(Regex.forNDigits(3), "0111"); + testIsNotMatch(Regex.forNDigits(3), " 111"); + testIsNotMatch(Regex.forNDigits(3), "111 "); + + //test that param cannot be less than one + try { + Regex.forNDigits(0); + } + catch (RuntimeException ex){ + return; + } + fail("Should not be able to create regex for ordinal of length 0."); + } + + public void testCamelHump(){ + testCamelHump(fSUCCESS, "LoginName", "Login Name"); + testCamelHump(fSUCCESS, "EmailAddress", "Email Address"); + testCamelHump(fSUCCESS, "BlahBlahBlahBlah", "Blah Blah Blah Blah"); + testCamelHump(fSUCCESS, " LoginName", " Login Name"); + testCamelHump(fSUCCESS, "LoginName ", "Login Name "); + testCamelHump(fSUCCESS, "Login6Name", "Login6 Name"); + + testCamelHump(fFAIL, "LoginName", "LoginNAme"); + testCamelHump(fFAIL, "LoginName", "Login NAme"); + testCamelHump(fFAIL, "LoginName", "Login Name "); + testCamelHump(fFAIL, "LoginName", " Login Name"); + } + + public void testSimpleScopedIdentifier(){ + testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "blah"); + testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "blah42"); + testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "blah_42"); + testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "BlahBlah"); + testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "BLAH_BLAH"); + testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "BLAH_BLAH_42"); + testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "blah.sola"); + testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "TRANSLATE.FETCH_ALL_TRANSLATIONS"); + testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "SECURITY.fetch_users_and_roles"); + testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "SECURITY_BLAH.fetch_users_and_roles"); + testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "SECURITY_42.fetch_users_and_roles_42"); + testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "_"); + testIsMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "b"); + + testIsNotMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "blah.sola.foin"); + testIsNotMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "blah..sola"); + testIsNotMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, ".blah.sola"); + testIsNotMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "blah.sola."); + testIsNotMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "*.blah"); + testIsNotMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, "blah.*"); + testIsNotMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, ""); + testIsNotMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, " "); + testIsNotMatch(Regex.SIMPLE_SCOPED_IDENTIFIER, " "); + } + + public void testHours(){ + testIsMatch(Regex.HOURS, "00"); + testIsMatch(Regex.HOURS, "01"); + testIsMatch(Regex.HOURS, "02"); + testIsMatch(Regex.HOURS, "03"); + testIsMatch(Regex.HOURS, "04"); + testIsMatch(Regex.HOURS, "05"); + testIsMatch(Regex.HOURS, "06"); + testIsMatch(Regex.HOURS, "07"); + testIsMatch(Regex.HOURS, "08"); + testIsMatch(Regex.HOURS, "09"); + testIsMatch(Regex.HOURS, "10"); + testIsMatch(Regex.HOURS, "11"); + testIsMatch(Regex.HOURS, "12"); + testIsMatch(Regex.HOURS, "13"); + testIsMatch(Regex.HOURS, "19"); + testIsMatch(Regex.HOURS, "20"); + testIsMatch(Regex.HOURS, "23"); + + testIsNotMatch(Regex.HOURS, "0"); + testIsNotMatch(Regex.HOURS, "00 "); + testIsNotMatch(Regex.HOURS, " 00"); + testIsNotMatch(Regex.HOURS, "12:00"); + } + + public void testMinutes(){ + testIsMatch(Regex.MINUTES, "00"); + testIsMatch(Regex.MINUTES, "01"); + testIsMatch(Regex.MINUTES, "09"); + testIsMatch(Regex.MINUTES, "10"); + testIsMatch(Regex.MINUTES, "59"); + + testIsNotMatch(Regex.MINUTES, "0"); + testIsNotMatch(Regex.MINUTES, "1"); + testIsNotMatch(Regex.MINUTES, "60"); + testIsNotMatch(Regex.MINUTES, "-1"); + testIsNotMatch(Regex.MINUTES, "-00"); + testIsNotMatch(Regex.MINUTES, "100"); + } + + public void testHoursAndMinutes(){ + testIsMatch(Regex.HOURS_AND_MINUTES, "00:00"); + testIsMatch(Regex.HOURS_AND_MINUTES, "23:59"); + testIsMatch(Regex.HOURS_AND_MINUTES, "01:00"); + testIsMatch(Regex.HOURS_AND_MINUTES, "00:01"); + testIsMatch(Regex.HOURS_AND_MINUTES, "00:59"); + testIsMatch(Regex.HOURS_AND_MINUTES, "23:00"); + testIsMatch(Regex.HOURS_AND_MINUTES, "23:01"); + testIsMatch(Regex.HOURS_AND_MINUTES, "12:45"); + + testIsNotMatch(Regex.HOURS_AND_MINUTES, "0:00"); + testIsNotMatch(Regex.HOURS_AND_MINUTES, " 00:00"); + testIsNotMatch(Regex.HOURS_AND_MINUTES, "00:00 "); + testIsNotMatch(Regex.HOURS_AND_MINUTES, "1:00"); + testIsNotMatch(Regex.HOURS_AND_MINUTES, "3:00"); + testIsNotMatch(Regex.HOURS_AND_MINUTES, "00:60"); + testIsNotMatch(Regex.HOURS_AND_MINUTES, "24:00"); + } + + // FIXTURE // + + protected void setUp(){ + } + + protected void tearDown() { + } + + // PRIVATE // + + private static final boolean fSUCCESS = true; + private static final boolean fFAIL = false; + + private void testIsMatch(String aPattern, String aInput){ + Pattern pattern = Pattern.compile(aPattern); + Matcher matcher = pattern.matcher(aInput); + assertTrue(matcher.matches()); + } + + private void testIsNotMatch(String aPattern, String aInput){ + Pattern pattern = Pattern.compile(aPattern); + Matcher matcher = pattern.matcher(aInput); + assertTrue(!matcher.matches()); + } + + private void testGroupOneIsMatch(String aPattern, String aInput, String aGroupOne){ + Pattern pattern = Pattern.compile(aPattern); + Matcher matcher = pattern.matcher(aInput); + assertTrue(matcher.matches()); + assertTrue(matcher.group(Regex.FIRST_GROUP).equals(aGroupOne)); + } + + private void testCommas(String aText, String aOutput){ + Pattern commas = Pattern.compile(Regex.COMMA_INSERTION); + Matcher matchForCommas = commas.matcher(aText); + StringBuffer result = new StringBuffer(); + while ( matchForCommas.find() ){ + matchForCommas.appendReplacement(result, ","); + } + matchForCommas.appendTail(result); + assertTrue(result.toString().equals(aOutput)); + } + + private void testCamelHump(boolean aSuccessDesired, String aIn, String aOut){ + String replacement = " $1"; + + Pattern pattern = Pattern.compile(Regex.CAMEL_HUMP_TEXT); + Matcher matcher = pattern.matcher(aIn); + if (aSuccessDesired) { + assertTrue( matcher.replaceAll(replacement).equals(aOut) ); + } + else { + assertTrue( ! matcher.replaceAll(replacement).equals(aOut) ); + } + } + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/util/TESTUtil.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/util/TESTUtil.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,335 @@ +package hirondelle.web4j.util; + +import junit.framework.*; +//import junit.ui.TestRunner; +//import junit.textui.TestRunner; +import java.util.logging.*; +import java.util.*; +import java.math.BigDecimal; +import hirondelle.web4j.util.Util; + +/** + JUnit test cases for {@link Util}. +*/ +public final class TESTUtil extends TestCase { + + /** + Run the test cases. + */ + public static void main(String args[]) { + String[] testCaseName = {TESTUtil.class.getName()}; + //Select one of several types of interfaces. + junit.textui.TestRunner.main(testCaseName); + //junit.swingui.TestRunner.main(testCaseName); + //junit.ui.TestRunner.main(testCaseName); + } + + public TESTUtil(String aName) { + super(aName); + } + + // TEST CASES // + + public void testReplace(){ + testReplaceError(null, null, null); + testReplaceError(null, SUBSTR, REPLACEMENT); + testReplaceError(INPUT, null, REPLACEMENT); + testReplaceError(INPUT, SUBSTR, null); + + testReplaceSuccess(INPUT, SUBSTR, REPLACEMENT, RESULT); + testReplaceSuccess("this is silly this is silly", SUBSTR, REPLACEMENT, "this *IS* silly this *IS* silly"); + testReplaceFailure(INPUT, SUBSTR, REPLACEMENT, "this *IS* silly"); + testReplaceFailure(INPUT, SUBSTR, REPLACEMENT, "this *IS* silly"); + testReplaceFailure(INPUT, SUBSTR, REPLACEMENT, "this *IS* silly"); + testReplaceFailure(INPUT, SUBSTR, REPLACEMENT, "this *IS* silly "); + testReplaceFailure(INPUT, SUBSTR, REPLACEMENT, " this *IS* silly"); + testReplaceFailure(INPUT, SUBSTR, REPLACEMENT, "this *is* silly"); + testReplaceFailure(INPUT, SUBSTR, REPLACEMENT, "this IS silly"); + } + + public void testLogOnePerLine(){ + List list = new ArrayList(); + list.add(null); + list.add("Zebra"); + list.add("aardvark"); + list.add("Banana"); + list.add(""); + list.add("aardvark"); + list.add(new BigDecimal("5.00")); + String listResult = "(7) {" + NL + + " ''" + NL + + " '5.00'" + NL + + " 'aardvark'" + NL + + " 'aardvark'" + NL + + " 'Banana'" + NL + + " 'null'" + NL + + " 'Zebra'" + NL + + "}"; + testLogOnePerLineSucceed(list, listResult); + list = new ArrayList(); + listResult = "(0) {" + NL + + "}"; + testLogOnePerLineSucceed(list, listResult); + + Map map = new LinkedHashMap(); + map.put("b", "blah"); + map.put("a", new BigDecimal("5.00")); + map.put("Z", null); + map.put(null, new Integer(3)); + String mapResult = "(4) {" + NL + + " 'a' = '5.00'" + NL + + " 'b' = 'blah'" + NL + + " 'null' = '3'" + NL + + " 'Z' = 'null'" + NL + + "}"; + testLogOnePerLineSucceed(map, mapResult); + + map = new LinkedHashMap(); + mapResult = "(0) {" + NL + + "}"; + testLogOnePerLineSucceed(map, mapResult); + } + + public void testRemoveQuotes(){ + assertTrue(Util.removeQuotes("Yes").equals("Yes")); + assertTrue(Util.removeQuotes("'Yes'").equals("Yes")); + assertTrue(Util.removeQuotes("'Yes").equals("Yes")); + assertTrue(Util.removeQuotes("'Yes").equals("Yes")); + assertTrue(Util.removeQuotes("Yes'").equals("Yes")); + assertTrue(Util.removeQuotes("\"Yes\"").equals("Yes")); + assertTrue(Util.removeQuotes("\"Yes").equals("Yes")); + assertTrue(Util.removeQuotes("Yes\"").equals("Yes")); + + assertTrue(Util.removeQuotes(" Yes").equals(" Yes")); + assertTrue(Util.removeQuotes(" 'Yes").equals(" 'Yes")); + assertTrue(Util.removeQuotes(" 'Yes'").equals(" 'Yes")); + } + + public void testNumDecimals(){ + assertTrue(Util.hasMaxDecimals(new BigDecimal("0"), 2)); + assertTrue(Util.hasMaxDecimals(new BigDecimal("0.0"), 2)); + assertTrue(Util.hasMaxDecimals(new BigDecimal("0.00"), 2)); + assertTrue(Util.hasMaxDecimals(new BigDecimal("1"), 2)); + assertTrue(Util.hasMaxDecimals(new BigDecimal("100"), 2)); + assertTrue(Util.hasMaxDecimals(new BigDecimal("1.0"), 2)); + assertTrue(Util.hasMaxDecimals(new BigDecimal("1.12"), 2)); + assertTrue(Util.hasMaxDecimals(new BigDecimal("100.1"), 2)); + assertTrue(Util.hasMaxDecimals(new BigDecimal("100.23"), 2)); + assertTrue(Util.hasMaxDecimals(new BigDecimal("-100"), 2)); + assertTrue(Util.hasMaxDecimals(new BigDecimal("-1.0"), 2)); + assertTrue(Util.hasMaxDecimals(new BigDecimal("-1.12"), 2)); + assertTrue(Util.hasMaxDecimals(new BigDecimal("-100.1"), 2)); + assertTrue(Util.hasMaxDecimals(new BigDecimal("-100.23"), 2)); + + assertFalse(Util.hasMaxDecimals(new BigDecimal("-100.234"), 2)); + assertFalse(Util.hasMaxDecimals(new BigDecimal("1.234"), 2)); + assertFalse(Util.hasMaxDecimals(new BigDecimal("1.2345"), 2)); + assertFalse(Util.hasMaxDecimals(new BigDecimal("0.000"), 2)); + assertFalse(Util.hasMaxDecimals(new BigDecimal("-0.000"), 2)); + assertFalse(Util.hasMaxDecimals(new BigDecimal("-0.0000"), 2)); + + boolean hasThrownEx = false; + try { + Util.hasMaxDecimals(new BigDecimal("23.23"), 0); + } + catch (Throwable ex){ + hasThrownEx = true; + } + assertTrue(hasThrownEx); + } + + public void testHasNumDecimals(){ + testHasNumDecimals("100", 0, PASS); + testHasNumDecimals("999.82", 2, PASS); + testHasNumDecimals("100.1", 1, PASS); + testHasNumDecimals("-89", 0, PASS); + testHasNumDecimals("-89.56321", 5, PASS); + testHasNumDecimals("8.1234567890", 10, PASS); + + testHasNumDecimals("-89.56321", 4, FAIL); + } + + public void testWithNoSpaces(){ + testWithNoSpacesSuccess("Is Coder Key", "IsCoderKey"); + } + + public void testBuildTimeZone(){ + testTimeZoneSuccess("GMT"); + testTimeZoneSuccess("America/Halifax"); + testTimeZoneSuccess(" GMT "); + testTimeZoneSuccess("PST"); + + testTimeZoneFailure("Pacific"); + testTimeZoneFailure(""); + testTimeZoneFailure(null); + } + + public void testInitialCapital(){ + testCap("blah", "Blah"); + testCap(" blah", " blah"); + testCap(" blah ", " blah "); + testCap("Blah", "Blah"); + testCap("b", "B"); + testCap("b ", "B "); + + //failures + testCapFails("blah", "blah"); + testCapFails("blah", "BLAH"); + testCapFails("blah", "Blah "); + testCapFails("blah", " Blah"); + + //testCapFails(" ", "B "); fails + } + + public void testQuote(){ + assertTrue(Util.quote("blah").equals("'blah'")); + assertTrue(Util.quote("").equals("''")); + assertTrue(Util.quote(null).equals("'null'")); + assertTrue(Util.quote(" ").equals("' '")); + + char[] word = null; + assertTrue(Util.quote(word).equals("'null'")); + char[] word2 = {'a', 'b'}; + assertTrue(Util.quote(word2).startsWith("'[C@")); //arrays of primitives don't work well + + String thing = null; + assertTrue(Util.quote(thing).equals("'null'")); + + List names = Arrays.asList("blah", null); + assertTrue(Util.quote(names).equals("'[blah, null]'")); + } + + // FIXTURE // + protected void setUp(){ + } + + /** + Re-set test objects. + */ + protected void tearDown() { + } + + // PRIVATE // + private static final String INPUT = "this is silly"; + private static final String SUBSTR = " is "; + private static final String REPLACEMENT = " *IS* "; + private static final String RESULT = "this *IS* silly"; + private static final String NL = Consts.NEW_LINE; + private static final boolean PASS = true; + private static final boolean FAIL = false; + + private static final Logger fLogger = Util.getLogger(TESTUtil.class); + + /** + Tests conditions which should generate an exception. + */ + private void testReplaceError(String aInput, String aOld, String aNew){ + boolean hasError = false; + try { + Util.replace(aInput, aOld, aNew); + } + catch (Throwable ex){ + hasError = true; + } + if ( ! hasError ){ + fail( + "Input : " + Util.quote(aInput) + + " Old : " + Util.quote(aOld) + + " New : " + Util.quote(aNew) + ); + } + } + + private void testReplaceSuccess( + String aInput, String aSubstr, String aReplacement, String aExpectedResult + ){ + String result = Util.replace(aInput, aSubstr, aReplacement); + if ( ! result.equals(aExpectedResult) ) { + fail( + Util.quote(result) + " does not equal expected result : " + + Util.quote(aExpectedResult) + ); + } + } + + private void testReplaceFailure( + String aInput, String aSubstr, String aReplacement, String aExpectedMismatch + ){ + String result = Util.replace(aInput, aSubstr, aReplacement); + if ( result.equals(aExpectedMismatch) ) { + fail( + Util.quote(result) + " equals, but expected mismatch : " + + Util.quote(aExpectedMismatch) + ); + } + } + + private void testLogOnePerLineSucceed(Collection aInput, String aExpectedMatch){ + String result = Util.logOnePerLine(aInput); + if( ! result.equals(aExpectedMatch) ){ + fail( + Util.quote(result) + " doesn't equal, but expected match : " + + Util.quote(aExpectedMatch) + ); + } + } + + private void testLogOnePerLineSucceed(Map aInput, String aExpectedMatch){ + String result = Util.logOnePerLine(aInput); + if( ! result.equals(aExpectedMatch) ){ + fail( + Util.quote(result) + " doesn't equal, but expected match : " + + Util.quote(aExpectedMatch) + ); + } + } + + private void testWithNoSpacesSuccess(String aInput, String aExpectedMatch){ + String result = Util.withNoSpaces(aInput); + if( ! result.equals(aExpectedMatch) ){ + fail( + Util.quote(result) + " doesn't equal, but expected match : " + + Util.quote(aExpectedMatch) + ); + } + } + + private void testTimeZoneSuccess(String aTimeZone){ + Util.buildTimeZone(aTimeZone); + } + + private void testTimeZoneFailure(String aTimeZone){ + try { + Util.buildTimeZone(aTimeZone); + fail("Expected failure for time zone " + Util.quote(aTimeZone)); + } + catch (Throwable ex){ + //expected failure + } + } + + private void testCap(String aInput, String aExpectedValue){ + String value = Util.withInitialCapital(aInput); + assertTrue( value.equals(aExpectedValue)); + } + + private void testCapFails(String aInput, String aExpectedValue){ + String value = Util.withInitialCapital(aInput); + assertFalse( value.equals(aExpectedValue)); + } + + private void testHasNumDecimals(String aAmount, int aNumDecimals, boolean aSucceed){ + BigDecimal amount = new BigDecimal(aAmount); + if (aSucceed){ + assertTrue(Util.hasNumDecimals(amount, aNumDecimals)); + } + else { + assertFalse(Util.hasNumDecimals(amount, aNumDecimals)); + } + } + + private static void log(Object aObject){ + System.out.println(String.valueOf(aObject)); + } + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/util/TESTWebUtil.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/util/TESTWebUtil.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,108 @@ +package hirondelle.web4j.util; + +import junit.framework.*; +//import junit.ui.TestRunner; +//import junit.textui.TestRunner; +//import junit.swingui.TestRunner; +//import java.util.logging.*; + +/** + JUnit tests for {@link WebUtil}. +*/ +public final class TESTWebUtil extends TestCase { + + /** + Run the test cases. + */ + public static void main(String args[]) { + String[] testCaseName = { TESTWebUtil.class.getName()}; + //Select one of several types of interfaces. + junit.textui.TestRunner.main(testCaseName); + //junit.swingui.TestRunner.main(testCaseName); + //junit.ui.TestRunner.main(testCaseName); + } + + /** + Canonical form of constructor. + */ + public TESTWebUtil( String aName) { + super( aName ); + } + + // TEST CASES // + + public void testSetQueryParam() { + testForSetQueryParam("blah.do", "artist", "Tom Thomson", "blah.do?artist=Tom+Thomson"); + testForSetQueryParam("blah.do", "Fav Artist", "Tom Thomson", "blah.do?Fav+Artist=Tom+Thomson"); + testForSetQueryParam("blah.do?artist=Tom+Thomson", "artist", "A Y Jackson", "blah.do?artist=A+Y+Jackson"); + testForSetQueryParam("blah.do?Fav+Artist=Tom+Thomson", "Fav Artist", "A Y Jackson", "blah.do?Fav+Artist=A+Y+Jackson"); + testForSetQueryParam("blah.do?country=Canada&artist=Tom+Thomson", "artist", "A Y Jackson", "blah.do?country=Canada&artist=A+Y+Jackson"); + testForSetQueryParam("blah.do?country=Canada&artist=Tom+Thomson&color=Purple", "artist", "A Y Jackson", "blah.do?country=Canada&artist=A+Y+Jackson&color=Purple"); + } + + + public void testValidEmail(){ + assertTrue( WebUtil.isValidEmailAddress("blah@blah.com") ); + assertTrue( WebUtil.isValidEmailAddress("blah@blah.net") ); + assertTrue( WebUtil.isValidEmailAddress("blah@blah.org") ); + assertTrue( WebUtil.isValidEmailAddress("blah@blah.x") ); + assertTrue( WebUtil.isValidEmailAddress("b@b.com") ); + assertTrue( WebUtil.isValidEmailAddress("blah@blah.blah.com") ); + assertTrue( WebUtil.isValidEmailAddress("blah@blah.blah.blah.com") ); + } + + public void testInvalidEmail(){ + //assertTrue( ! WebUtil.isValidEmailAddress(null) ); //not allowed + assertTrue( ! WebUtil.isValidEmailAddress("") ); + assertTrue( ! WebUtil.isValidEmailAddress(" ") ); + assertTrue( ! WebUtil.isValidEmailAddress("@blah") ); + assertTrue( ! WebUtil.isValidEmailAddress("blah@blah@blah") ); + assertTrue( ! WebUtil.isValidEmailAddress("@blah.com") ); + assertTrue( ! WebUtil.isValidEmailAddress(" @blah.com") ); + assertTrue( ! WebUtil.isValidEmailAddress("blah@") ); + assertTrue( ! WebUtil.isValidEmailAddress("blah@ ") ); + assertTrue( ! WebUtil.isValidEmailAddress("blah@@blah.com") ); + assertTrue( ! WebUtil.isValidEmailAddress("@blah@blah.com") ); + assertTrue( ! WebUtil.isValidEmailAddress("blah;@blah.org") ); + assertTrue( ! WebUtil.isValidEmailAddress("blah") ); + } + + public void testFileExtension(){ + testFileExt("http://localhost:8081/predict/pub/search/SearchAction.show", "show"); + testFileExt("http://localhost:8081/predict/pub/search/SearchAction.show;jsessionid=464653E", "show"); + testFileExt("http://localhost:8081/predict/pub/search/SearchAction.list;jsessionid=464653E?Id=3&Text=Blah", "list"); + testFileExt("http://localhost:8081/predict/pub/search/SearchAction.list?Id=3&Text=Blah", "list"); + testFileExt("http://www.bazoodi.com/main/backup/ZipAllLists.zip?OpSys=Win&Style=FixedWidth", "zip"); + } + + // FIXTURE + + protected void setUp(){ + //empty + } + + protected void tearDown() { + //empty + } + + // PRIVATE // + + //private static final Logger fLogger = Util.getLogger(TESTWebUtil.class); + + private void testForSetQueryParam(String aInputURL, String aParamName, String aParamValue, String aOutputURL){ + String newURL = WebUtil.setQueryParam(aInputURL, aParamName, aParamValue); + if ( ! newURL.equals(aOutputURL) ) { + fail( + "URL does not match. Expected : '" + aOutputURL + + "' but computed '" + newURL + "'" + ); + } + } + + private void testFileExt(String aURLInput, String aExpectedResult){ + String result = WebUtil.getFileExtension(aURLInput); + if(!result.equals(aExpectedResult)) { + fail("Extension does not match. Expected : '" + aExpectedResult + "' but computed : '" + result + "'"); + } + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/util/TimeSource.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/util/TimeSource.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,33 @@ +package hirondelle.web4j.util; + +import java.util.TimeZone; + +/** + Return a possibly-fake value for the system clock. + +

When testing, it is often useful to use a + fake system clock, + in order to exercise code that uses date logic. When you implement this interface, + you are instructing WEB4J classes on what time value they should use as the system clock. + This allows your application to share its fake system clock with the framework, + so that they can both use the exact same clock. + +

See {@link hirondelle.web4j.BuildImpl} for instructions on how to configure an implementation + of this interface. + +

The following WEB4J framework classes use TimeSource : +

    +
  • {@link hirondelle.web4j.model.DateTime#now(TimeZone)} - returns the current date-time +
  • {@link hirondelle.web4j.ui.tag.ShowDate} - displays the current date-time +
  • {@link hirondelle.web4j.webmaster.LoggingConfigImpl} - both the name of the logging + file and the date-time attached to each logging record are affected +
  • {@link hirondelle.web4j.webmaster.TroubleTicket} - uses the current date-time +
  • {@link hirondelle.web4j.Controller} - upon startup, it places the current date-time in application scope +
+*/ +public interface TimeSource { + + /** Return the possibly-fake system time, in milliseconds. */ + long currentTimeMillis(); + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/util/TimeSourceImpl.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/util/TimeSourceImpl.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,17 @@ +package hirondelle.web4j.util; + +/** + Default implementation of {@link TimeSource}. + +

Simply returns the normal system time, without alteration. + If you don't define your own {@link TimeSource}, then this + default implementation will automatically be used by WEB4J. +*/ +public final class TimeSourceImpl implements TimeSource { + + /** Return {@link System#currentTimeMillis()}, with no alteration. */ + public long currentTimeMillis() { + return System.currentTimeMillis(); + } + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/util/Util.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/util/Util.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,817 @@ +package hirondelle.web4j.util; + +import java.util.*; +import java.util.logging.*; +import java.util.regex.*; +import java.math.BigDecimal; +import java.text.CharacterIterator; +import java.text.StringCharacterIterator; +import java.lang.reflect.Array; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import hirondelle.web4j.security.SafeText; +import hirondelle.web4j.model.Decimal; + +/** + Static convenience methods for common tasks, which eliminate code duplication. + +

{@link Args} wraps certain methods of this class into a form suitable for checking arguments. + +

{@link hirondelle.web4j.util.WebUtil} includes utility methods particular to web applications. +*/ +public final class Util { + + /** + Return true only if aNumEdits is greater than 0. + +

This method is intended for database operations. + */ + public static boolean isSuccess(int aNumEdits){ + return aNumEdits > 0; + } + + /** + Return true only if aText is not null, + and is not empty after trimming. (Trimming removes both + leading/trailing whitespace and ASCII control characters. See {@link String#trim()}.) + +

For checking argument validity, {@link Args#checkForContent} should + be used instead of this method. + + @param aText possibly-null. + */ + public static boolean textHasContent(String aText) { + return (aText != null) && (aText.trim().length() > 0); + } + + /** + Return true only if aText is not null, + and if its raw String is not empty after trimming. (Trimming removes both + leading/trailing whitespace and ASCII control characters. See {@link String#trim()}.) + + @param aText possibly-null. + */ + public static boolean textHasContent(SafeText aText){ + return (aText != null) && (aText.getRawString().trim().length() > 0); + } + + /** + If aText is null, return null; else return aText.trim(). + + This method is especially useful for Model Objects whose String + parameters to its constructor can take any value whatsoever, including + null. Using this method lets null params remain + null, while trimming all others. + + @param aText possibly-null. + */ + public static String trimPossiblyNull(String aText){ + return aText == null ? null : aText.trim(); + } + + /** + Return true only if aNumber is in the range + aLow..aHigh (inclusive). + +

For checking argument validity, {@link Args#checkForRange} should + be used instead of this method. + + @param aLow less than or equal to aHigh. + */ + static public boolean isInRange( int aNumber, int aLow, int aHigh ){ + if (aLow > aHigh) { + throw new IllegalArgumentException( + "Low: " + aLow + " is greater than High: " + aHigh + ); + } + return (aLow <= aNumber && aNumber <= aHigh); + } + + /** + Return true only if the number of decimal places in aAmount is in the range + 0..aMaxNumDecimalPlaces (inclusive). + + @param aAmount any amount, positive or negative.. + @param aMaxNumDecimalPlaces is 1 or more. + */ + static public boolean hasMaxDecimals(BigDecimal aAmount, int aMaxNumDecimalPlaces){ + Args.checkForPositive(aMaxNumDecimalPlaces); + int numDecimals = aAmount.scale(); + return 0 <= numDecimals && numDecimals <= aMaxNumDecimalPlaces; + } + + /** + Return true only if the number of decimal places in aAmount is in the range + 0..aMaxNumDecimalPlaces (inclusive). + + @param aAmount any amount, positive or negative.. + @param aMaxNumDecimalPlaces is 1 or more. + */ + static public boolean hasMaxDecimals(Decimal aAmount, int aMaxNumDecimalPlaces){ + return hasMaxDecimals(aAmount.getAmount(), aMaxNumDecimalPlaces); + } + + /** + Return true only if aAmount has exactly the number + of specified decimals. + + @param aNumDecimals is 0 or more. + */ + public static boolean hasNumDecimals(BigDecimal aAmount, int aNumDecimals){ + if( aNumDecimals < 0 ){ + throw new IllegalArgumentException("Number of decimals must be 0 or more: " + quote(aNumDecimals)); + } + return aAmount.scale() == aNumDecimals; + } + + /** + Return true only if aAmount has exactly the number + of specified decimals. + + @param aNumDecimals is 0 or more. + */ + public static boolean hasNumDecimals(Decimal aAmount, int aNumDecimals){ + if( aNumDecimals < 0 ){ + throw new IllegalArgumentException("Number of decimals must be 0 or more: " + quote(aNumDecimals)); + } + return aAmount.getAmount().scale() == aNumDecimals; + } + + /** + Parse text commonly used to denote booleans into a {@link Boolean} object. + +

The parameter passed to this method is first trimmed (if it is non-null), + and then compared to the following Strings, ignoring case : +

    +
  • {@link Boolean#TRUE} : 'true', 'yes', 'on' +
  • {@link Boolean#FALSE} : 'false', 'no', 'off' +
+ +

Any other text will cause a RuntimeException. (Note that this behavior + is different from that of {@link Boolean#valueOf(String)}). + +

(This method is clearly biased in favor of English text. It is hoped that this is not too inconvenient for the caller.) + + @param aBooleanAsText possibly-null text to be converted into a {@link Boolean}; if null, then the + return value is null. + */ + public static Boolean parseBoolean(String aBooleanAsText){ + Boolean result = null; + String value = trimPossiblyNull(aBooleanAsText); + if ( value == null ) { + //do nothing - return null + } + else if ( value.equalsIgnoreCase("false") || value.equalsIgnoreCase("no") || value.equalsIgnoreCase("off") ) { + result = Boolean.FALSE; + } + else if ( value.equalsIgnoreCase("true") || value.equalsIgnoreCase("yes") || value.equalsIgnoreCase("on") ) { + result = Boolean.TRUE; + } + else { + throw new IllegalArgumentException( + "Cannot parse into Boolean: " + quote(aBooleanAsText) + ". Accepted values are: true/false/yes/no/on/off" + ); + } + return result; + } + + /** + Coerce a possibly-null {@link Boolean} value into {@link Boolean#FALSE}. + +

This method is usually called in Model Object constructors that have two-state Boolean fields. + +

This method is supplied specifically for request parameters that may be missing from the request, + during normal operation of the program. +

Example : a form has a checkbox for 'yes, send me your newsletter', and the data is modeled has having two states - + true and false. If the checkbox is not checked , however, the browser will + likely not POST any corresponding request parameter - it will be null. In that case, calling this method + will coerce such null parameters into {@link Boolean#FALSE}. + +

There are other cases in which data is modeled as having not two states, but three : + true, false, and null. The null value usually means 'unknown'. + In that case, this method should not be called. + */ + public static Boolean nullMeansFalse(Boolean aBoolean){ + return aBoolean == null ? Boolean.FALSE : aBoolean; + } + + /** + Create a {@link Pattern} corresponding to a List. + + Example: if the {@link List} contains "cat" and "dog", then the returned + Pattern will correspond to the regular expression "(cat|dog)". + + @param aList is not empty, and contains objects whose toString() + value represents each item in the pattern. + */ + public static final Pattern getPatternFromList( List aList ){ + if ( aList.isEmpty() ){ + throw new IllegalArgumentException(); + } + StringBuilder regex = new StringBuilder("("); + Iterator iter = aList.iterator(); + while (iter.hasNext()){ + Object item = iter.next(); + regex.append( item.toString() ); + if ( iter.hasNext() ) { + regex.append( "|" ); + } + } + regex.append(")"); + return Pattern.compile( regex.toString() ); + } + + /** + Return true only if aMoney equals 0 + or 0.00. + */ + public static boolean isZeroMoney( BigDecimal aMoney ){ + final BigDecimal ZERO_MONEY = new BigDecimal("0"); + final BigDecimal ZERO_MONEY_WITH_DECIMAL = new BigDecimal("0.00"); + return + aMoney.equals(ZERO_MONEY) || + aMoney.equals(ZERO_MONEY_WITH_DECIMAL) + ; + } + + /** + Return true only if aText is non-null, and matches + aPattern. + +

Differs from {@link Pattern#matches} and {@link String#matches}, + since the regex argument is a compiled {@link Pattern}, not a + String. + */ + public static boolean matches(Pattern aPattern, String aText){ + /* + Implementation Note: + Patterns are thread-safe, while Matchers are not. Thus, a Pattern may + be compiled by a class once upon startup, then reused safely in a + multi-threaded environment. + */ + if (aText == null) return false; + Matcher matcher = aPattern.matcher(aText); + return matcher.matches(); + } + + /** + Return true only if aText is non-null, and contains + a substring that matches aPattern. + */ + public static boolean contains(Pattern aPattern, String aText){ + if (aText == null) return false; + Matcher matcher = aPattern.matcher(aText); + return matcher.find(); + } + + /** + If aPossiblyNullItem is null, then return aReplacement ; + otherwise return aPossiblyNullItem. + +

Intended mainly for occasional use in Model Object constructors. It is used to + coerce null items into a more appropriate default value. + */ + public static E replaceIfNull(E aPossiblyNullItem, E aReplacement){ + return aPossiblyNullItem == null ? aReplacement : aPossiblyNullItem; + } + + /** +

Convert end-user input into a form suitable for {@link BigDecimal}. + +

The idea is to allow a wide range of user input formats for monetary amounts. For + example, an amount may be input as '$1,500.00', 'U$1500.00', + or '1500.00 U$'. These entries can all be converted into a + BigDecimal by simply stripping out all characters except for digits + and the decimal character. + +

Removes all characters from aCurrencyAmount which are not digits or + aDecimalSeparator. Finally, if aDecimalSeparator is not + a period (expected by BigDecimal) then it is replaced with a period. + + @param aDecimalSeparator must have content, and must have length of 1. + */ + static public String trimCurrency(String aCurrencyAmount, String aDecimalSeparator){ + Args.checkForContent(aDecimalSeparator); + if ( aDecimalSeparator.length() != 1) { + throw new IllegalArgumentException( + "Decimal separator is not a single character: " + Util.quote(aDecimalSeparator) + ); + } + + StringBuilder result = new StringBuilder(); + StringCharacterIterator iter = new StringCharacterIterator(aCurrencyAmount); + char character = iter.current(); + while (character != CharacterIterator.DONE){ + if ( Character.isDigit(character) ){ + result.append(character); + } + else if (aDecimalSeparator.charAt(0) == character){ + result.append(Consts.PERIOD.charAt(0)); + } + else { + //do not append any other chars + } + character = iter.next(); + } + return result.toString(); + } + + /** + Return a {@link Logger} whose name follows a specific naming convention. + +

The conventional logger names used by WEB4J are taken as + aClass.getPackage().getName(). + +

Logger names appearing in the logging.properties config file + must match the names returned by this method. + +

If an application requires an alternate naming convention, then an + alternate implementation can be easily constructed. Alternate naming conventions + might account for : +

    +
  • pre-pending the logger name with the name of the application (this is useful + where log handlers are shared between different applications) +
  • adding version information +
+ */ + public static Logger getLogger(Class aClass){ + return Logger.getLogger(aClass.getPackage().getName()); + } + + /** + Call {@link String#valueOf(Object)} on aObject, and place the result in single quotes. +

This method is a bit unusual in that it can accept a null + argument : if aObject is null, it will return 'null'. + +

This method reduces errors from leading and trailing spaces, by placing + single quotes around the returned text. Such leading and trailing spaces are + both easy to create and difficult to detect (a bad combination). + +

Note that such quotation is likely needed only for String data, + since trailing or leading spaces will not occur for other types of data. + */ + public static String quote(Object aObject){ + String result = "EXCEPTION OCCURED"; + try { + result = Consts.SINGLE_QUOTE + String.valueOf(aObject) + Consts.SINGLE_QUOTE; + } + catch (Throwable ex){ + //errors were seen in this branch - depends on the impl of the passed object/array + //do nothing - use the default + } + return result; + } + + /** + Remove any initial or final quote characters from aText, either a single quote or + a double quote. + +

This method will not trim the text passed to it. Furthermore, it will examine only + the very first character and the very last character. It will remove the first or last character, + but only if they are a single quote or a double quote. + +

If aText has no content, then it is simply returned by this method, as is, + including possibly-null values. + @param aText is possibly null, and is not trimmed by this method + */ + public static String removeQuotes(String aText){ + String result = null; + if ( ! textHasContent(aText)) { + result = aText; + } + else { + int length = aText.length(); + String firstChar = aText.substring(0,1); + String lastChar = aText.substring(length-1); + boolean startsWithQuote = firstChar.equalsIgnoreCase("\"") || firstChar.equalsIgnoreCase("'"); + boolean endsWithQuote = lastChar.equalsIgnoreCase("\"") || lastChar.equalsIgnoreCase("'"); + int startIdx = startsWithQuote ? 1 : 0; + int endIdx = endsWithQuote ? length-1 : length; + result = aText.substring(startIdx, endIdx); + } + return result; + } + + /** + Ensure the initial character of aText is capitalized. + +

Does not trim aText. + + @param aText has content. + */ + public static String withInitialCapital(String aText) { + Args.checkForContent(aText); + final int FIRST = 0; + final int ALL_BUT_FIRST = 1; + StringBuilder result = new StringBuilder(); + result.append( Character.toUpperCase(aText.charAt(FIRST)) ); + result.append(aText.substring(ALL_BUT_FIRST)); + return result.toString(); + } + + /** + Ensure aText contains no spaces. + +

Along with {@link #withInitialCapital(String)}, this method is useful for + mapping request parameter names into corresponding getXXX methods. + For example, the text 'Email Address' and 'emailAddress' + can both be mapped to a method named 'getEmailAddress()', by using : +

 
+   String methodName = "get" + Util.withNoSpaces(Util.withInitialCapital(name));
+   
+ + @param aText has content + */ + public static String withNoSpaces(String aText){ + Args.checkForContent(aText); + return replace(aText.trim(), Consts.SPACE, Consts.EMPTY_STRING); + } + + /** + Replace every occurrence of a fixed substring with substitute text. + +

This method is distinct from {@link String#replaceAll}, since it does not use a + regular expression. + + @param aInput may contain substring aOldText; satisfies {@link #textHasContent(String)} + @param aOldText substring which is to be replaced; possibly empty, but never null + @param aNewText replacement for aOldText; possibly empty, but never null + */ + public static String replace(String aInput, String aOldText, String aNewText){ + if ( ! textHasContent(aInput) ) { + throw new IllegalArgumentException("Input must have content."); + } + if ( aNewText == null ) { + throw new NullPointerException("Replacement text may be empty, but never null."); + } + final StringBuilder result = new StringBuilder(); + //startIdx and idxOld delimit various chunks of aInput; these + //chunks always end where aOldText begins + int startIdx = 0; + int idxOld = 0; + while ((idxOld = aInput.indexOf(aOldText, startIdx)) >= 0) { + //grab a part of aInput which does not include aOldPattern + result.append( aInput.substring(startIdx, idxOld) ); + //add replacement text + result.append( aNewText ); + //reset the startIdx to just after the current match, to see + //if there are any further matches + startIdx = idxOld + aOldText.length(); + } + //the final chunk will go to the end of aInput + result.append( aInput.substring(startIdx) ); + return result.toString(); + } + + /** + Return a String suitable for logging, having one item from aCollection + per line. + +

For the Collection containing
+ [null, "Zebra", "aardvark", "Banana", "", "aardvark", new BigDecimal("5.00")], + +

the return value is : +

+   (7) {
+     ''
+     '5.00'
+     'aardvark'
+     'aardvark'
+     'Banana'
+     'null'
+     'Zebra'
+   }
+   
+ +

The text for each item is generated by calling {@link #quote}, and by appending a new line. + +

As well, this method reports the total number of items, and places items in + alphabetical order (ignoring case). (The iteration order of the Collection + passed by the caller will often differ from the order of items presented in the return value.) + + */ + public static String logOnePerLine(Collection aCollection){ + int STANDARD_INDENTATION = 1; + return logOnePerLine(aCollection, STANDARD_INDENTATION); + } + + /** + As in {@link #logOnePerLine(Collection)}, but with specified indentation level. + + @param aIndentLevel greater than or equal to 1, acts as multiplier for a + "standard" indentation level of two spaces. + */ + public static String logOnePerLine(Collection aCollection, int aIndentLevel){ + Args.checkForPositive(aIndentLevel); + String indent = getIndentation(aIndentLevel); + StringBuilder result = new StringBuilder(); + result.append("(" + aCollection.size() + ") {" + Consts.NEW_LINE); + List lines = new ArrayList(aCollection.size()); + for (Object item: aCollection){ + StringBuilder line = new StringBuilder(indent); + line.append( quote(item) ); //nulls ok + line.append( Consts.NEW_LINE ); + lines.add(line.toString()); + } + addSortedLinesToResult(result, lines); + result.append(getFinalIndentation(aIndentLevel)); + result.append("}"); + return result.toString(); + } + + /** + Return a String suitable for logging, having one item from aMap + per line. + +

For a Map containing
+ ["b"="blah", "a"=new BigDecimal(5.00), "Z"=null, null=new Integer(3)], + +

the return value is : +

+   (4) {
+     'a' = '5.00'
+     'b' = 'blah'
+     'null' = '3'
+     'Z' = 'null'
+   }
+   
+ +

The text for each key and value is generated by calling {@link #quote}, and + appending a new line after each entry. + +

As well, this method reports the total number of items, and places items in + alphabetical order of their keys (ignoring case). (The iteration order of the + Map passed by the caller will often differ from the order of items in the + return value.) + +

An attempt is made to suppress the emission of passwords. Values in a Map are + presented as **** if the following conditions are all true : +

    +
  • {@link String#valueOf(java.lang.Object)} applied to the key contains the word password or + credential (ignoring case) +
  • the value is not an array or a Collection +
+ */ + public static String logOnePerLine(Map aMap){ + StringBuilder result = new StringBuilder(); + result.append("(" + aMap.size() + ") {" + Consts.NEW_LINE); + List lines = new ArrayList(aMap.size()); + String SEPARATOR = " = "; + Iterator iter = aMap.keySet().iterator(); + while ( iter.hasNext() ){ + Object key = iter.next(); + StringBuilder line = new StringBuilder(INDENT); + line.append(quote(key)); //nulls ok + line.append(SEPARATOR); + Object value = aMap.get(key); + int MORE_INDENTATION = 2; + if ( value != null && value instanceof Collection) { + line.append( logOnePerLine((Collection)value, MORE_INDENTATION)); + } + else if ( value != null && value.getClass().isArray() ){ + List valueItems = Arrays.asList( (Object[])value ); + line.append( logOnePerLine(valueItems, MORE_INDENTATION) ); + } + else { + value = suppressPasswords(key, value); + line.append(quote(value)); //nulls ok + } + line.append(Consts.NEW_LINE); + lines.add(line.toString()); + } + addSortedLinesToResult(result, lines); + result.append("}"); + return result.toString(); + } + + /** + Return a {@link Locale} object by parsing aRawLocale. + +

The format of aRawLocale follows the + language_country_variant style used by {@link Locale}. The value is not checked against + {@link Locale#getAvailableLocales()}. + */ + public static Locale buildLocale(String aRawLocale){ + int language = 0; + int country = 1; + int variant = 2; + Locale result = null; + fLogger.finest("Raw Locale: " + aRawLocale); + String[] parts = aRawLocale.split("_"); + if (parts.length == 1) { + result = new Locale( parts[language] ); + } + else if (parts.length == 2) { + result = new Locale( parts[language], parts[country] ); + } + else if (parts.length == 3 ) { + result = new Locale( parts[language], parts[country], parts[variant] ); + } + else { + throw new AssertionError("Locale identifer has unexpected format: " + aRawLocale); + } + fLogger.finest("Parsed Locale : " + Util.quote(result.toString())); + return result; + } + + /** + Return a {@link TimeZone} corresponding to a given {@link String}. + +

If the given String does not correspond to a known TimeZone id, + as determined by {@link TimeZone#getAvailableIDs()}, then a + runtime exception is thrown. (This differs from the behavior of the + {@link TimeZone} class itself, and is the reason why this method exists.) + */ + public static TimeZone buildTimeZone(String aTimeZone) { + TimeZone result = null; + List timeZones = Arrays.asList(TimeZone.getAvailableIDs()); + if( timeZones.contains(aTimeZone.trim()) ) { + result = TimeZone.getTimeZone(aTimeZone.trim()); + } + else { + fLogger.severe("Unknown Time Zone : " + quote(aTimeZone)); + //fLogger.severe("Known Time Zones : " + logOnePerLine(timeZones)); + throw new IllegalArgumentException("Unknown TimeZone Id : " + quote(aTimeZone)); + } + return result; + } + + /** + Convenience method for producing a simple textual + representation of an array. + +

The format of the returned {@link String} is the same as + {@link java.util.AbstractCollection#toString} : +

    +
  • non-empty array: [blah, blah] +
  • empty array: [] +
  • null array: null +
+ +

Thanks to Jerome Lacoste for improving the implementation of this method. + + @param aArray is a possibly-null array whose elements are + primitives or objects. Arrays of arrays are also valid, in which case + aArray is rendered in a nested, recursive fashion. + + */ + public static String getArrayAsString(Object aArray){ + final String fSTART_CHAR = "["; + final String fEND_CHAR = "]"; + final String fSEPARATOR = ", "; + final String fNULL = "null"; + + if ( aArray == null ) return fNULL; + checkObjectIsArray(aArray); + + StringBuilder result = new StringBuilder( fSTART_CHAR ); + int length = Array.getLength(aArray); + for ( int idx = 0 ; idx < length ; ++idx ) { + Object item = Array.get(aArray, idx); + if ( isNonNullArray(item) ){ + //recursive call! + result.append( getArrayAsString(item) ); + } + else{ + result.append( item ); + } + if ( ! isLastItem(idx, length) ) { + result.append(fSEPARATOR); + } + } + result.append(fEND_CHAR); + return result.toString(); + } + + /** + Transform a List into a Map. + +

This method exists because it is sometimes necessary to transform a + List into a lookup table of some sort, using unique keys already + present in the List data. + +

The List to be transformed contains objects having a method named + aKeyMethodName, and which returns objects of class aKeyClass. + Thus, data is extracted from each object to act as its key. Furthermore, the + key must be unique. If any duplicates are detected, then an + exception is thrown. This ensures that the returned Map will be the + same size as the given List, and that no data is silently discarded. + +

The iteration order of the returned Map is identical to the iteration order of + the input List. + */ + public static Map asMap(List aList, Class aClass, String aKeyMethodName){ + Map result = new LinkedHashMap(); + for(V value: aList){ + K key = getMethodValue(value, aClass, aKeyMethodName); + if( result.containsKey(key) ){ + throw new IllegalArgumentException("Key must be unique. Duplicate detected : " + quote(key)); + } + result.put(key, value); + } + return result; + } + + /** + Reverse the keys and values in a Map. + +

This method exists because sometimes a lookup operation needs to be performed in a + style opposite to an existing Map. + +

There is an unusual requirement on the Map argument: the map values must be + unique. Thus, the returned Map will be the same size as the input Map. If any + duplicates are detected, then an exception is thrown. + +

The iteration order of the returned Map is identical to the iteration order of + the input Map. + */ + public static Map reverseMap(Map aMap){ + Map result = new LinkedHashMap(); + for(Map.Entry entry: aMap.entrySet()){ + if( result.containsKey(entry.getValue())){ + throw new IllegalArgumentException("Value must be unique. Duplicate detected : " + quote(entry.getValue())); + } + result.put(entry.getValue(), entry.getKey()); + } + return result; + } + + + // PRIVATE // + + private Util(){ + //empty - prevents construction by the caller. + } + + private static final Logger fLogger = Util.getLogger(Util.class); + private static final String INDENT = Consts.SPACE + Consts.SPACE; + private static final Pattern PASSWORD = Pattern.compile("(password|credential)", Pattern.CASE_INSENSITIVE); + + private static void addSortedLinesToResult(StringBuilder aResult, List aLines) { + Collections.sort(aLines, String.CASE_INSENSITIVE_ORDER); + for (String line: aLines){ + aResult.append( line ); + } + } + + private static String getIndentation(int aIndentLevel){ + StringBuilder result = new StringBuilder(); + for (int idx = 1; idx <= aIndentLevel; ++idx){ + result.append(INDENT); + } + return result.toString(); + } + + private static String getFinalIndentation(int aIndentLevel){ + return getIndentation(aIndentLevel - 1); + } + + /** + Replace likely password values with a fixed string. + */ + private static Object suppressPasswords(Object aKey, Object aValue){ + Object result = aValue; + String key = String.valueOf(aKey); + Matcher matcher = PASSWORD.matcher(key); + if ( matcher.find() ){ + result = "*****"; + } + return result; + } + + private static void checkObjectIsArray(Object aArray){ + if ( ! aArray.getClass().isArray() ) { + throw new IllegalArgumentException("Object is not an array."); + } + } + + private static boolean isNonNullArray(Object aItem){ + return aItem != null && aItem.getClass().isArray(); + } + + private static boolean isLastItem(int aIdx, int aLength){ + return (aIdx == aLength - 1); + } + + private static K getMethodValue(Object aValue, Class aClass, String aKeyMethodName){ + K result = null; + try { + Method method = aValue.getClass().getMethod(aKeyMethodName); //no args + result = (K)method.invoke(aValue); + } + catch (NoSuchMethodException ex){ + handleInvocationEx(aValue.getClass(), aKeyMethodName); + } + catch (IllegalAccessException ex){ + handleInvocationEx(aValue.getClass(), aKeyMethodName); + } + catch (InvocationTargetException ex){ + handleInvocationEx(aValue.getClass(), aKeyMethodName); + } + return result; + } + + private static void handleInvocationEx(Class aClass, String aKeyMethodName){ + throw new IllegalArgumentException("Cannot invoke method named " + quote(aKeyMethodName) + " on object of class " + quote(aClass)); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/util/WebUtil.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/util/WebUtil.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,281 @@ +package hirondelle.web4j.util; + +import static hirondelle.web4j.util.Consts.NOT_FOUND; + +import java.util.regex.*; +import javax.mail.internet.AddressException; +import javax.mail.internet.InternetAddress; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import javax.servlet.ServletContext; +import javax.servlet.ServletConfig; + +/** + Static convenience methods for common web-related tasks, which eliminate code duplication. + +

Similar to {@link hirondelle.web4j.util.Util}, but for methods particular to the web. +*/ +public final class WebUtil { + + /** Called only upon startup, by the framework. */ + public static void init(ServletConfig aConfig){ + fContext = aConfig.getServletContext(); + } + + /** + Validate the form of an email address. + +

Return true only if +

    +
  • aEmailAddress can successfully construct an + {@link javax.mail.internet.InternetAddress} +
  • when parsed with "@" as delimiter, aEmailAddress contains + two tokens which satisfy {@link hirondelle.web4j.util.Util#textHasContent}. +
+ +

The second condition arises since local email addresses, simply of the form + "albert", for example, are valid for {@link javax.mail.internet.InternetAddress}, + but almost always undesired. + */ + public static boolean isValidEmailAddress(String aEmailAddress){ + if (aEmailAddress == null) return false; + boolean result = true; + try { + InternetAddress emailAddr = new InternetAddress(aEmailAddress); + if ( ! hasNameAndDomain(aEmailAddress) ) { + result = false; + } + } + catch (AddressException ex){ + result = false; + } + return result; + } + + /** + Ensure a particular name-value pair is present in a URL. + +

If the parameter does not currently exist in the URL, then the name-value + pair is appended to the URL; if the parameter is already present in the URL, + however, then its value is changed. + +

Any number of query parameters can be added to a URL, one after the other. + Any special characters in aParamName and aParamValue will be + escaped by this method using {@link EscapeChars#forURL}. + +

This method is intended for cases in which an Action requires + a redirect after processing, and the redirect in turn requires dynamic + query parameters. (With a redirect, this is the only way to + pass data to the destination page. Items placed in request scope for the + original request will no longer be available to the second request caused + by the redirect.) + +

Example 1, where a new parameter is added :

+ setQueryParam("blah.do", "artist", "Tom Thomson") +
+ returns the value :
blah.do?artist=Tom+Thomson + +

Example 2, where an existing parameter is updated :

+ setQueryParam("blah.do?artist=Tom+Thomson", "artist", "A Y Jackson") +
+ returns the value :
blah.do?artist=A+Y+Jackson + +

Example 3, with a parameter name of slightly different form :

+ setQueryParam("blah.do?Favourite+Artist=Tom+Thomson", "Favourite Artist", "A Y Jackson") +
+ returns the value :
blah.do?Favourite+Artist=A+Y+Jackson + + @param aURL a base URL, with escaped parameter names and values + @param aParamName unescaped parameter name + @param aParamValue unescaped parameter value + */ + public static String setQueryParam(String aURL, String aParamName, String aParamValue){ + String result = null; + if ( aURL.indexOf(EscapeChars.forURL(aParamName) + "=") == -1) { + result = appendParam(aURL, aParamName, aParamValue); + } + else { + result = replaceParam(aURL, aParamName, aParamValue); + } + return result; + } + + /** + Return {@link HttpServletRequest#getRequestURL}, optionally concatenated with + ? and {@link HttpServletRequest#getQueryString}. + +

Query parameters are added only if they are present. + +

If the underlying method is a GET which does NOT edit the database, + then presenting the return value of this method in a link is usually acceptable. + +

If the underlying method is a POST, or if it is a GET which + (erroneously) edits the database, it is recommended that the return value of + this method NOT be placed in a link. + +

Warning : if this method is called in JSP or custom tag, then it + is likely that the original query string has been overwritten by the server, + as result of an internal forward operation. + */ + public static String getURLWithQueryString(HttpServletRequest aRequest){ + StringBuilder result = new StringBuilder(); + result.append(aRequest.getRequestURL()); + String queryString = aRequest.getQueryString(); + if ( Util.textHasContent(queryString) ) { + result.append("?"); + result.append(queryString); + } + return result.toString(); + } + + /** + Return the original, complete URL submitted by the browser. + +

Session id is included in the return value. + +

Somewhat frustratingly, the original client request is not directly available from + the Servlet API. +

This implementation is based on an example in the + Java Almanac. + */ + public static String getOriginalRequestURL(HttpServletRequest aRequest, HttpServletResponse aResponse){ + String result = null; + //http://hostname.com:80/mywebapp/servlet/MyServlet/a/b;c=123?d=789 + String scheme = aRequest.getScheme(); // http + String serverName = aRequest.getServerName(); // hostname.com + int serverPort = aRequest.getServerPort(); // 80 + String contextPath = aRequest.getContextPath(); // /mywebapp + String servletPath = aRequest.getServletPath(); // /servlet/MyServlet + String pathInfo = aRequest.getPathInfo(); // /a/b;c=123 + String queryString = aRequest.getQueryString(); // d=789 + + // Reconstruct original requesting URL + result = scheme + "://" + serverName + ":" + serverPort + contextPath + servletPath; + if (Util.textHasContent(pathInfo)) { + result = result + pathInfo; + } + if (Util.textHasContent(queryString)) { + result = result + "?" + queryString; + } + return aResponse.encodeURL(result); + } + + /** + Find an attribute by searching request scope, session scope (if it exists), and application scope (in that order). + +

If there is no session, then this method will not create one. + +

If no Object corresponding to aKey is found, then null is returned. + */ + public static Object findAttribute(String aKey, HttpServletRequest aRequest){ + //This method is similar to {@link javax.servlet.jsp.JspContext#findAttribute(java.lang.String)} + Object result = null; + result = aRequest.getAttribute(aKey); + if( result == null ) { + HttpSession session = aRequest.getSession(DO_NOT_CREATE); + if( session != null ) { + result = session.getAttribute(aKey); + } + } + if( result == null ) { + result = fContext.getAttribute(aKey); + } + return result; + } + + /** + Returns the 'file extension' for a given URL. + +

Some example return values for this method : + + + + + + + + + + + + + + + + + + +
URL'File Extension'
.../VacationAction.dodo
.../VacationAction.fetchForChange?Id=103fetchForChange
.../VacationAction.list?Start=Now&End=Neverlist
.../SomethingAction.show;jsessionid=32131?SomeId=123456show
+ + @param aURL has content, and contains a '.' character (which defines the start of the 'file extension'.) + */ + public static String getFileExtension(String aURL) { + String result = null; + int lastPeriod = aURL.lastIndexOf("."); + if( lastPeriod == NOT_FOUND ) { + throw new RuntimeException("Cannot find '.' character in URL: " + Util.quote(aURL)); + } + int jsessionId = aURL.indexOf(";jsessionid"); + int firstQuestionMark = aURL.indexOf("?"); + if( jsessionId != NOT_FOUND){ + result = aURL.substring(lastPeriod + 1, jsessionId); + } + else if( firstQuestionMark != NOT_FOUND){ + result = aURL.substring(lastPeriod + 1, firstQuestionMark); + } + else { + result = aURL.substring(lastPeriod + 1); + } + return result; + } + + // PRIVATE + + private WebUtil(){ + //empty - prevent construction + } + + /** Needed for searching application scope for attributes. */ + private static ServletContext fContext; + + private static final boolean DO_NOT_CREATE = false; + + private static String appendParam(String aURL, String aParamName, String aParamValue){ + StringBuilder result = new StringBuilder(aURL); + if (aURL.indexOf("?") == -1) { + result.append("?"); + } + else { + result.append("&"); + } + result.append( EscapeChars.forURL(aParamName) ); + result.append("="); + result.append( EscapeChars.forURL(aParamValue) ); + return result.toString(); + } + + private static String replaceParam(String aURL, String aParamName, String aParamValue){ + String regex = "(\\?|\\&)(" + EscapeChars.forRegex(EscapeChars.forURL(aParamName)) + "=)([^\\&]*)"; + StringBuffer result = new StringBuffer(); + Pattern pattern = Pattern.compile( regex ); + Matcher matcher = pattern.matcher(aURL); + while ( matcher.find() ) { + matcher.appendReplacement(result, getReplacement(matcher, aParamValue)); + } + matcher.appendTail(result); + return result.toString(); + } + + private static String getReplacement(Matcher aMatcher, String aParamValue){ + return aMatcher.group(1) + aMatcher.group(2) + EscapeChars.forURL(aParamValue); + } + + private static boolean hasNameAndDomain(String aEmailAddress){ + String[] tokens = aEmailAddress.split("@"); + return + tokens.length == 2 && + Util.textHasContent( tokens[0] ) && + Util.textHasContent( tokens[1] ) ; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/util/package.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/util/package.html Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,13 @@ + + + + + + + Utilities + + +General utility classes, useful for many applications. +
  + + diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/webmaster/BadResponseDetector.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/webmaster/BadResponseDetector.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,203 @@ +package hirondelle.web4j.webmaster; + +import static hirondelle.web4j.util.Consts.FAILS; +import java.util.regex.Pattern; +import hirondelle.web4j.model.AppException; +import hirondelle.web4j.model.Check; +import hirondelle.web4j.model.ModelCtorException; +import java.util.logging.*; + +import hirondelle.web4j.util.Util; +import java.util.*; +import java.io.*; +import java.net.MalformedURLException; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.net.URLConnection; +import static hirondelle.web4j.util.Consts.NOT_FOUND; +import static hirondelle.web4j.util.Consts.EMPTY_STRING; +import static hirondelle.web4j.util.Consts.NEW_LINE; +import hirondelle.web4j.webmaster.TroubleTicket; +import hirondelle.web4j.util.Stopwatch; + +/** Detects problems by pinging a target URL every few minutes. */ +final class BadResponseDetector extends TimerTask { + + /** + Build a BadResponseDetector using a setting of the same name in + web.xml. + + These settings are simply passed to the regular + {@link #BadResponseDetector(String, int, int)} constructor. + */ + static BadResponseDetector getInstanceUsing(String aConfig){ + Scanner parser = new Scanner(aConfig); + parser.useDelimiter(",\\s*"); + String targetURL = parser.next(); + int pingFrequency = parser.nextInt(); + int timeout = parser.nextInt(); + return new BadResponseDetector(targetURL, pingFrequency, timeout); + } + + /** + Constructor. + + @param aTargetURL the URL to be tested periodically. Required, starts with 'http://'. Must be able + to form a {@link java.net.URL}. + @param aPingFrequency number of minutes between each test (1..60). Required. + @param aTimeout number of seconds to wait for a response (1..60). Required. + */ + BadResponseDetector(String aTargetURL, int aPingFrequency, int aTimeout) { + fTargetURL = aTargetURL; + fPingFrequency = aPingFrequency; + fTimeout = aTimeout; + validateState(); + } + + /** + Ping the target URL by attempting to fetch its HTTP header. + Email the webmaster if the response status code in the header is 400 or more, or if the ping times out. + */ + @Override public void run() { + fLogger.fine("Pinging the target URL " + Util.quote(fTargetURL)); + Stopwatch stopwatch = new Stopwatch(); + String problem = EMPTY_STRING; + String pageContent = null; + int statusCode = 0; + URLConnection connection = null; + try { + stopwatch.start(); + URL target = new URL(fTargetURL); + connection = target.openConnection(); + connection.setConnectTimeout(fTimeout*1000); + connection.connect(); + statusCode = extractStatusCode(connection); + pageContent = getEntireContent(connection); + if( statusCode == NOT_FOUND ) { + problem = "Cannot extract status code for : " + fTargetURL; + } + else if ( isError(statusCode) ) { + problem = "URL " + fTargetURL + " is returning an error status code : " + statusCode; + } + } + catch(MalformedURLException ex){ + fLogger.fine("Malformed URL : " + fTargetURL); //this is checked in ctor + } + catch(SocketTimeoutException ex){ + problem = "Connection timed out."; + } + catch (IOException ex) { + problem = "Cannot open connection to the URL : " + fTargetURL; + } + + stopwatch.stop(); + long BILLION = 1000 * 1000 * 1000; + if( stopwatch.toValue() > (fTimeout * BILLION)) { //nanos + problem = problem + "Response took too long : " + stopwatch; + } + + if( Util.textHasContent(problem) ) { + mailTroubleTicket(problem); + } + else { + fLogger.info("No problem detected. Status code : " + statusCode + ". Response time : " + stopwatch + ". Content length: " + pageContent.length()); + } + } + + /** Return the target URL passed to the constructor. */ + String getTargetURL(){ return fTargetURL; } + + /** Return the ping frequency passed to the constructor. */ + int getPingFrequency(){ return fPingFrequency; } + + /** Return the timeout passed to the constructor. */ + int getTimeout(){ return fTimeout; } + + // PRIVATE // + private final String fTargetURL; + private final int fPingFrequency; + private final int fTimeout; + private static final Pattern HTTP_TARGET = Pattern.compile("http://.*"); + private static final int ENTIRE_FIRST_LINE = 0; + private static final int STATUS_CODE = 1; + private static final String END_OF_INPUT = "\\Z"; + //note the user of a logger attached to a public class + private static final Logger fLogger = Util.getLogger(PerformanceMonitor.class); + + private void validateState(){ + ModelCtorException ex = new ModelCtorException(); + + if ( FAILS == Check.required(fTargetURL, Check.pattern(HTTP_TARGET)) ) { + ex.add("Target URL is required, must start with 'http://'."); + } + if( Util.textHasContent(fTargetURL)) { + try { + URL testUrl = new URL(fTargetURL); + } + catch(MalformedURLException exception){ + ex.add("Target URL is malformed."); + } + } + + if ( FAILS == Check.required(fPingFrequency, Check.range(1,60))) { + ex.add("Ping Frequency is required, must be in range 1..60 minutes."); + } + if ( FAILS == Check.required(fTimeout, Check.range(1,60))) { + ex.add("Timeout is required, must be in range 1..60 seconds."); + } + + if ( ! ex.isEmpty() ) { + throw new IllegalArgumentException("Cannot construct BadResponseDetector : " + ex.toString(), ex); + } + } + + /** Returns -1 if no status code can be detected. */ + private int extractStatusCode(URLConnection aURLConnection){ + //Typical value, first line of response : 'HTTP/1.1 200 OK' + int result = -1; + String firstLine = aURLConnection.getHeaderField(ENTIRE_FIRST_LINE); + StringTokenizer parser = new StringTokenizer(firstLine, " "); + List items = new ArrayList(); + while (parser.hasMoreTokens()) { + items.add(parser.nextToken()); + } + String status = items.get(STATUS_CODE); + if( Util.textHasContent(status) ) { + try { + result = Integer.valueOf(status); + } + catch (NumberFormatException ex){ + //do nothing - return value will reflect the inability to detect status code + } + } + return result; + } + + private boolean isError(int aStatusCode) { + return aStatusCode >= 400; + } + + private void mailTroubleTicket(String aProblem) { + StringBuilder problem = new StringBuilder(); + problem.append("Bad response detected." + NEW_LINE); + problem.append(NEW_LINE); + problem.append("URL : " + fTargetURL + NEW_LINE); + problem.append("Problem : " + aProblem); + fLogger.severe(problem.toString()); + TroubleTicket ticket = new TroubleTicket(problem.toString()); + try { + ticket.mailToRecipients(); + } + catch(AppException ex){ + fLogger.severe("Cannot send email regarding bad response."); + } + } + + private String getEntireContent(URLConnection aConnection) throws IOException { + String result = null; + Scanner scanner = new Scanner(aConnection.getInputStream()); + scanner.useDelimiter(END_OF_INPUT); + result = scanner.next(); + return result; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/webmaster/Emailer.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/webmaster/Emailer.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,41 @@ +package hirondelle.web4j.webmaster; + +import java.util.*; + +import hirondelle.web4j.model.AppException; +import hirondelle.web4j.util.WebUtil; + +/** + Send a simple email from the webmaster to a list of receivers. + +

See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. + {@link hirondelle.web4j.BuildImpl#forEmailer()} + returns the configured implementation of this interface. + +

Implementations of this interface will be called by the framework when it needs + to send an email. For example, {@link TroubleTicket} uses such an implementation to + send diagnostic information to the webmaster. The from-address is taken from the + webmaster email configured in web.xml. + +

Sending Email on a Separate Thread
+ Some implementations might send emails on a separate worker thread. + However, such implementations can be tricky. If your application creates a thread on its own, then + the Servlet Container will likely not be able to shut down in the regular way. The thread + will be unknown to the Container, and will likely prevent it from shutting down. + +

The {@link EmailerImpl} default implementation of this interface doesn't use a + separate worker thread. See web.xml in the example applications for more details. +*/ +public interface Emailer { + + /** + Send an email from the webmaster to a list of receivers. + + @param aToAddresses contains email addresses of the receivers, as a List of Strings that satisfy + {@link WebUtil#isValidEmailAddress(String)} + @param aSubject satisfies {@link hirondelle.web4j.util.Util#textHasContent} + @param aBody satisfies {@link hirondelle.web4j.util.Util#textHasContent} + */ + void sendFromWebmaster(List aToAddresses, String aSubject, String aBody) throws AppException; + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/webmaster/EmailerImpl.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/webmaster/EmailerImpl.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,202 @@ +package hirondelle.web4j.webmaster; + +import hirondelle.web4j.model.AppException; +import hirondelle.web4j.readconfig.Config; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.util.WebUtil; + +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; +import java.util.StringTokenizer; +import java.util.logging.Logger; + +import javax.mail.Authenticator; +import javax.mail.Message; +import javax.mail.PasswordAuthentication; +import javax.mail.Session; +import javax.mail.Transport; +import javax.mail.internet.InternetAddress; +import javax.mail.internet.MimeMessage; + +/** + Default implementation of {@link Emailer}. + +

Uses these init-param settings in web.xml: +

    +
  • Webmaster : the email address of the webmaster. +
  • MailServerConfig : configuration data to be passed to the mail server, as a list of name=value pairs. + Each name=value pair appears on a single line by itself. Used for mail.host settings, and so on. + The special value NONE indicates that emails are suppressed, and will not be sent. +
  • MailServerCredentials : user name and password for access to the outgoing mail server. + The user name is separated from the password by a pipe character '|'. + The special value NONE means that no credentials are needed (often the case when the wep app + and the outgoing mail server reside on the same network). +
+ +

Example web.xml settings, using a Gmail account: +

    <init-param>
+      <param-name>Webmaster</param-name>
+      <param-value>myaccount@gmail.com</param-value> 
+    </init-param>
+    
+    <init-param>
+      <param-name>MailServerConfig</param-name>
+      <param-value>
+        mail.smtp.host=smtp.gmail.com       
+        mail.smtp.auth=true
+        mail.smtp.port=465
+        mail.smtp.socketFactory.port=465
+        mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory
+      </param-value> 
+    </init-param>
+
+    <init-param>
+      <param-name>MailServerCredentials</param-name>
+      <param-value>myaccount@gmail.com|mypassword</param-value> 
+    </init-param>
+  
+*/ +public final class EmailerImpl implements Emailer { + + public void sendFromWebmaster(List aToAddresses, String aSubject, String aBody) throws AppException { + if (isMailEnabled()) { + validateState(getWebmasterEmailAddress(), aToAddresses, aSubject, aBody); + fLogger.fine("Sending email using request thread."); + sendEmail(getWebmasterEmailAddress(), aToAddresses, aSubject, aBody); + } + else { + fLogger.fine("Mailing is disabled, since mail server is configured as " + Util.quote(Config.NONE)); + } + } + + // PRIVATE + + private Config fConfig = new Config(); + private static final Logger fLogger = Util.getLogger(EmailerImpl.class); + + private boolean isMailEnabled() { + return fConfig.isEnabled(fConfig.getMailServerConfig()); + } + + private boolean areCredentialsEnabled() { + return fConfig.isEnabled(fConfig.getMailServerCredentials()); + } + + /** Return the mail server config in the form of a Properties object. */ + private Properties getMailServerConfigProperties() { + Properties result = new Properties(); + String rawValue = fConfig.getMailServerConfig(); + /* Example data: mail.smtp.host = smtp.blah.com */ + if(Util.textHasContent(rawValue)){ + List lines = getAsLines(rawValue); + for(String line : lines){ + int delimIdx = line.indexOf("="); + String name = line.substring(0,delimIdx); + String value = line.substring(delimIdx+1); + if(isMissing(name) || isMissing(value)){ + throw new RuntimeException( + "This line for the MailServerConfig setting in web.xml does not have the expected form: " + Util.quote(line) + ); + } + result.put(name.trim(), value.trim()); + } + } + return result; + } + + private List getAsLines(String aRawValue){ + List result = new ArrayList(); + StringTokenizer parser = new StringTokenizer(aRawValue, "\n\r"); + while ( parser.hasMoreTokens() ) { + result.add( parser.nextToken().trim() ); + } + return result; + } + + private static boolean isMissing(String aText){ + return ! Util.textHasContent(aText); + } + + private String getWebmasterEmailAddress() { + return fConfig.getWebmaster(); + } + + private void validateState(String aFrom, List aToAddresses, String aSubject, String aBody) throws AppException { + AppException ex = new AppException(); + if (!WebUtil.isValidEmailAddress(aFrom)) { + ex.add("From-Address is not a valid email address."); + } + if (!Util.textHasContent(aSubject)) { + ex.add("Email subject has no content."); + } + if (!Util.textHasContent(aBody)) { + ex.add("Email body has no content."); + } + if (aToAddresses.isEmpty()){ + ex.add("To-Address is empty."); + } + for(String email: aToAddresses){ + if (!WebUtil.isValidEmailAddress(email)) { + ex.add("To-Address is not a valid email address: " + Util.quote(email)); + } + } + if (ex.isNotEmpty()) { + fLogger.severe("Cannot send email : " + ex); + throw ex; + } + } + + private void sendEmail(String aFrom, List aToAddresses, String aSubject, String aBody) throws AppException { + fLogger.fine("Sending mail from " + Util.quote(aFrom)); + fLogger.fine("Sending mail to " + Util.quote(aToAddresses)); + Properties props = getMailServerConfigProperties(); + //fLogger.fine("Properties: " + props); + try { + Authenticator auth = getAuthenticator(); + //fLogger.fine("Authenticator: " + auth); + Session session = Session.getDefaultInstance(props, auth); + //session.setDebug(true); + MimeMessage message = new MimeMessage(session); + message.setFrom(new InternetAddress(aFrom)); + for(String toAddr: aToAddresses){ + message.addRecipient(Message.RecipientType.TO, new InternetAddress(toAddr)); + } + message.setSubject(aSubject); + message.setText(aBody); + Transport.send(message); // thread-safe? throttling makes the question irrelevant + } + catch (Throwable ex) { + fLogger.severe("CANNOT SEND EMAIL: " + ex); + throw new AppException("Cannot send email", ex); + } + fLogger.fine("Mail is sent."); + } + + private Authenticator getAuthenticator(){ + Authenticator result = null; + if( areCredentialsEnabled() ){ + result = new SMTPAuthenticator(); + } + return result; + } + + private static final class SMTPAuthenticator extends Authenticator { + SMTPAuthenticator() {} + public PasswordAuthentication getPasswordAuthentication() { + PasswordAuthentication result = null; + /** Format is pipe separated : bob|passwd. */ + String rawValue = new Config().getMailServerCredentials(); + int delimIdx = rawValue.indexOf("|"); + if(delimIdx != -1){ + String userName = rawValue.substring(0,delimIdx); + String password = rawValue.substring(delimIdx+1); + result = new PasswordAuthentication(userName, password); + } + else { + throw new RuntimeException("Missing pipe separator between user name and password: " + rawValue); + } + return result; + } + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/webmaster/LoggingConfig.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/webmaster/LoggingConfig.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,41 @@ +package hirondelle.web4j.webmaster; + +import hirondelle.web4j.model.AppException; + +import java.util.Map; + +/** + Configure the logging system used in your application. + +

See {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured. + {@link hirondelle.web4j.BuildImpl#forLoggingConfig()} + returns the configured implementation of this interface. + +

Here, implementations configure the logging system using code, not a configuration file. + WEB4J itself uses JDK logging. Your application may also use JDK logging, or any other logging system. + +

If your application does not require any logging config performed in code, + then just set the LoggingDirectory in web.xml set to 'NONE'. + +

Implementations of this interface are called by the framework only once, upon startup. + +

Independent Logging On Servers
+ In the JDK logging.properties config file, it is important to remember that + the handlers setting creates Handlers and attaches them to the root logger. + In general, those default handlers will be shared by all applications running + in that JRE. This is not appropriate for most server environments. + In a servlet environment, however, each application uses a private class loader. + This means that each application can perform its own custom logging + config in code, instead of in logging.properties, and retain independence + from other applications running in the same JRE. +*/ +public interface LoggingConfig { + + /** + Configure application logging. + @param aConfig - in a servlet environment, this map will hold the init-param items for the servlet, + as stated in web.xml. + */ + void setup(Map aConfig) throws AppException; + +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/webmaster/LoggingConfigImpl.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/webmaster/LoggingConfigImpl.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,210 @@ +package hirondelle.web4j.webmaster; + +import static hirondelle.web4j.util.Consts.FILE_SEPARATOR; +import static hirondelle.web4j.util.Consts.NOT_FOUND; +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.model.AppException; +import hirondelle.web4j.model.DateTime; +import hirondelle.web4j.readconfig.Config; +import hirondelle.web4j.util.TimeSource; +import hirondelle.web4j.util.Util; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; +import java.util.logging.FileHandler; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import java.util.logging.SimpleFormatter; + +/** + Default implementation of {@link LoggingConfig}, to set up simple logging. + +

This implementation uses JDK logging, and appends logging output to a single file, + with no size limit on the file. It uses two settings in web.xml: +

    +
  • LoggingDirectory - the absolute directory which will hold the logging + output file. This class will always use a file name using the system date/time, as + returned by {@link DateTime#now(TimeZone)} using the DefaultUserTimeZone setting in + web.xml, in the form 2007_12_31_59_59.txt. If the directory does not exist, WEB4J will + attempt to create it upon startup. If set to the special value of 'NONE', then + this class will not configure JDK logging in any way. +
  • LoggingLevels - a comma-separated list of logger names and their corresponding + levels. To verify operation, this class will emit test logging entries for each of these loggers, + at the stated logging levels. +
+*/ +public final class LoggingConfigImpl implements LoggingConfig { + + /** See class comment. */ + public void setup(Map aConfig) throws AppException { + /* This impl uses the unpublished Config class, not the given map. Custom impls will need the given map. */ + logStdOut("Logging directory from web.xml : " + Util.quote(fConfig.getLoggingDirectory())); + logStdOut("Logging levels from web.xml : " + Util.quote(fConfig.getLoggingLevels())); + if( isTurnedOff() ) { + logStdOut("Default logging config is turned off, since directory is set to " + Util.quote(NONE)); + } + else { + logStdOut("Setting up logging config..."); + validateDirectorySetting(); + parseLoggers(); + createFileHandler(); + attachLoggersToFileHandler(); + tryTestMessages(); + fLogger.config("Logging to directory : " + Util.quote(fConfig.getLoggingDirectory())); + DateTime now = DateTime.now(fConfig.getDefaultUserTimeZone()); + fLogger.config("Current date-time: " + now.format("YYYY-MM-DD hh:mm:ss.fffffffff") + " (uses your TimeSource implementation and the DefaultUserTimeZone setting in web.xml)"); + fLogger.config("Raw value of System.currentTimeMillis(): " + System.currentTimeMillis()); + showLoggerLevels(); + } + } + + // PRIVATE + private Config fConfig = new Config(); + private static final int NO_SIZE_LIMIT = 0; + private static final int MAX_BYTES = NO_SIZE_LIMIT; + private static final int NUM_FILES = 1; + private static final boolean APPEND_TO_EXISTING = true; + private static final String NONE = "NONE"; + private static final String SEPARATOR = "="; + + /** List of loggers. Each Logger stores its own Level as part of its state. */ + private final List fLoggers = new ArrayList(); + private FileHandler fHandler; + private static final Logger fLogger = Util.getLogger(LoggingConfigImpl.class); + + private boolean isTurnedOff(){ + return NONE.equalsIgnoreCase(fConfig.getLoggingDirectory()); + } + + private void validateDirectorySetting() { + if( ! fConfig.getLoggingDirectory().endsWith(FILE_SEPARATOR) ){ + String message = "*** PROBLEM *** LoggingDirectory setting in web.xml does not end in with a directory separator : " + Util.quote(fConfig.getLoggingDirectory()); + logStdOut(message); + throw new IllegalArgumentException(message); + } + if( ! targetDirectoryExists() ){ + String message = "LoggingDirectory setting in web.xml does not refer to an existing, writable directory. Will attempt to create directory : " + Util.quote(fConfig.getLoggingDirectory()); + logStdOut(message); + File directory = new File(fConfig.getLoggingDirectory()); + boolean success = directory.mkdirs(); + if (success) { + logStdOut("Directory created successfully"); + } + else { + logStdOut("*** PROBLEM *** : Unable to create LoggingDirectory specified in web.xml! Permissions problem? Directory already exists, but not writable?"); + } + } + } + + private void parseLoggers(){ + for(String logLevel : fConfig.getLoggingLevels()){ + int separator = logLevel.indexOf(SEPARATOR); + String logger = logLevel.substring(0, separator).trim(); + String level = logLevel.substring(separator + 1).trim(); + addLogger(removeSuffix(logger), level); + } + } + + private String removeSuffix(String aLogger){ + int suffix = aLogger.indexOf(".level"); + if ( suffix == NOT_FOUND ) { + throw new IllegalArgumentException("*** PROBLEM *** LoggingLevels setting in web.xml does not end with '.level'"); + } + return aLogger.substring(0, suffix); + } + + private void addLogger(String aLogger, String aLevel){ + if( ! Util.textHasContent(aLogger) ){ + throw new IllegalArgumentException("Logger name specified in web.xml has no content."); + } + Logger logger = Logger.getLogger(aLogger); //creates Logger if does not yet exist + logger.setLevel(Level.parse(aLevel)); + fLogger.config("Adding Logger " + Util.quote(logger.getName() ) + " with level " + Util.quote(logger.getLevel()) ); + fLoggers.add(logger); + } + + private void createFileHandler() throws AppException { + try { + fHandler = new FileHandler(getFileName(), MAX_BYTES, NUM_FILES, APPEND_TO_EXISTING); + fHandler.setLevel(Level.FINEST); + fHandler.setFormatter(new TimeSensitiveFormatter()); + } + catch (IOException ex){ + throw new AppException("Cannot create FileHandler: " + ex.toString() , ex); + } + } + + private void attachLoggersToFileHandler(){ + for (Logger logger: fLoggers){ + if( hasNoFileHandler(logger) ){ + logger.addHandler(fHandler); + } + } + } + + private boolean hasNoFileHandler(Logger aLogger){ + boolean result = true; + Handler[] handlers = aLogger.getHandlers(); + fLogger.config("Logger " + aLogger.getName() + " has this many existing handlers: " + handlers.length); + for (int idx = 0; idx < handlers.length; ++idx){ + if ( FileHandler.class.isAssignableFrom(handlers[idx].getClass()) ){ + fLogger.config("FileHandler already exists for Logger " + Util.quote(aLogger.getName()) + ". Will not add a new one."); + result = false; + break; + } + } + return result; + } + + /** Log a test message at each logger's configured level. */ + private void tryTestMessages(){ + logStdOut("Sending test messages to configured loggers. Please confirm output to above log file."); + for(Logger logger: fLoggers){ + logger.log(logger.getLevel(), "This is a test message for Logger " + Util.quote(logger.getName())); + } + } + + /** + Return the complete name of the logging file. + Example file name : C:\log\fish_and_chips\2007_12_31_23_59.txt + */ + private String getFileName(){ + String result = null; + DateTime now = DateTime.now(fConfig.getDefaultUserTimeZone()); + result = fConfig.getLoggingDirectory() + now.format("YYYY|_|MM|_|DD|_|hh|_|mm"); + result = result + ".txt"; + logStdOut("Logging file name : " + Util.quote(result)); + return result; + } + + private boolean targetDirectoryExists(){ + File directory = new File(fConfig.getLoggingDirectory()); + return directory.exists() && directory.isDirectory() && directory.canWrite(); + } + + private void logStdOut(Object aObject){ + String message = String.valueOf(aObject); + System.out.println(message); + } + + private void showLoggerLevels() { + for(Logger logger : fLoggers){ + fLogger.config("Logger " + logger.getName() + " has level " + logger.getLevel()); + } + } + + private static final class TimeSensitiveFormatter extends SimpleFormatter { + public TimeSensitiveFormatter() { } + @Override public String format(LogRecord aLogRecord) { + aLogRecord.setMillis(fTimeSource.currentTimeMillis()); + return super.format(aLogRecord); + } + private TimeSource fTimeSource = BuildImpl.forTimeSource(); + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/webmaster/PerformanceMonitor.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/webmaster/PerformanceMonitor.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,220 @@ +package hirondelle.web4j.webmaster; + +import hirondelle.web4j.util.Stopwatch; +import hirondelle.web4j.util.Util; +import hirondelle.web4j.util.WebUtil; + +import java.io.IOException; +import java.util.Enumeration; +import java.util.LinkedList; +import java.util.List; +import java.util.logging.Logger; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; + +/** + Compile simple performance statistics, and use periodic pings to detect trouble. + +

See web.xml for more information on how to configure this {@link Filter}. + +

Performance Statistics

+ This class stores a Collection of {@link PerformanceSnapshot} objects in + memory (not in a database). + +

The presentation of these performance statistics in a JSP is always "one behind" this class. + This {@link Filter} examines the response time of each fully processed + request. Any JSP presenting the response times, however, is not fully processed from the + point of view of this filter, and has not yet contributed to the statistics. + +

It is important to note that {@link Filter} objects + must be designed to operate safely in a multi-threaded environment. + Using the nomenclature of + Effective Java, this class is 'conditionally thread safe' : the responsibility + for correct operation in a multi-threaded environment is shared between + this class and its caller. See {@link #getPerformanceHistory} for more information. + +

If desired, you can use also external tools such as +SiteUptime.com to monitor your site. +*/ +public final class PerformanceMonitor implements Filter { + + /** + Read in the configuration of this filter from web.xml. + +

The config is validated, gathering of statistics is begun, and + any periodic ping operations are initialized. + */ + public void init(FilterConfig aFilterConfig) { + /* + The logging performed here is not showing up in the expected manner. + */ + Enumeration items = aFilterConfig.getInitParameterNames(); + while ( items.hasMoreElements() ) { + String name = (String)items.nextElement(); + String value = aFilterConfig.getInitParameter(name); + fLogger.fine("Filter param " + name + " = " + Util.quote(value)); + } + + fEXPOSURE_TIME = new Integer( aFilterConfig.getInitParameter(EXPOSURE_TIME) ); + fNUM_PERFORMANCE_SNAPSHOTS = new Integer( + aFilterConfig.getInitParameter(NUM_PERFORMANCE_SNAPSHOTS) + ); + validateConfigParamValues(); + + fPerformanceHistory.addFirst(new PerformanceSnapshot(fEXPOSURE_TIME)); + } + + /** This implementation does nothing. */ + public void destroy() { + //do nothing + } + + /** Calculate server response time, and store relevant statistics in memory. */ + public void doFilter(ServletRequest aRequest, ServletResponse aResponse, FilterChain aChain) throws IOException, ServletException { + fLogger.fine("START PerformanceMonitor Filter."); + + Stopwatch stopwatch = new Stopwatch(); + stopwatch.start(); + + aChain.doFilter(aRequest, aResponse); + + stopwatch.stop(); + long millis = stopwatch.toValue()/(1000*1000); + addResponseTime(millis, aRequest); + fLogger.fine("END PerformanceMonitor Filter. Response Time: " + stopwatch); + } + + /** + Return statistics on recent application performance. + +

A static method is the only way an {@link hirondelle.web4j.action.Action} + can access this data, since it has no access to the {@link Filter} object + itself (which is built by the container). + +

The typical task for the caller is iteration over the return value. The caller + must synchronize this iteration, by obtaining the lock on the return value. + The typical use case of this method is : +

+   List history = PerformanceMonitor.getPerformanceHistory();
+   synchronized(history) {
+     for(PerformanceSnapshot snapshot : history){
+       //..elided
+     }
+   }
+   
+ */ + public static List getPerformanceHistory(){ + /* + Note that using Collections.synchronizedList here is not possible : the + API for that method states that when used, the returned reference must be used + for ALL interactions with the backing list. + */ + return fPerformanceHistory; + } + + // PRIVATE + + /** + Holds queue of {@link PerformanceSnapshot} objects, including the "current" one. + +

The queue grows until it reaches a configured maximum length, after which stale + items are removed when new ones are added. + +

This mutable item must always have synchronized access, to ensure thread-safety. + */ + private static final LinkedList fPerformanceHistory = new LinkedList(); + + private static Integer fEXPOSURE_TIME; + private static Integer fNUM_PERFORMANCE_SNAPSHOTS; + + /* + Names of configuration parameters. + */ + private static final String NUM_PERFORMANCE_SNAPSHOTS = "NumPerformanceSnapshots"; + private static final String EXPOSURE_TIME = "ExposureTime"; + + private static final Logger fLogger = Util.getLogger(PerformanceMonitor.class); + + /** + Validate the configured parameter values. + */ + private void validateConfigParamValues(){ + StringBuilder message = new StringBuilder(); + if ( ! Util.isInRange(fNUM_PERFORMANCE_SNAPSHOTS, 1, 1000) ) { + message.append( + "web.xml: " + NUM_PERFORMANCE_SNAPSHOTS + " has value of " + + fNUM_PERFORMANCE_SNAPSHOTS + ", which is outside the accepted range of 1..1000." + ); + } + int exposure = fEXPOSURE_TIME; + if ( exposure != 10 && exposure != 20 && exposure != 30 && exposure != 60){ + message.append( + " web.xml: " + EXPOSURE_TIME + " has a value of " + exposure + "." + + " The only accepted values are 10, 20, 30, and 60." + ); + } + if ( Util.textHasContent(message.toString()) ){ + throw new IllegalArgumentException(message.toString()); + } + } + + private static void addResponseTime(long aResponseTime /*millis*/, ServletRequest aRequest){ + long now = System.currentTimeMillis(); + HttpServletRequest request = (HttpServletRequest)aRequest; + String url = WebUtil.getURLWithQueryString(request); + //this single synchronization block implements the *internal* thread-safety + //responsibilities of this class + synchronized( fPerformanceHistory ){ + if ( now > getCurrentSnapshot().getEndTime().getTime() ){ + //start a new 'current' snapshot + addToPerformanceHistory( new PerformanceSnapshot(fEXPOSURE_TIME) ); + } + updateCurrentSnapshotStats(aResponseTime, url); + } + } + + private static void addToPerformanceHistory(PerformanceSnapshot aNewSnapshot){ + while ( hasGap(aNewSnapshot, getCurrentSnapshot() ) ) { + fLogger.fine("Gap detected. Adding empty snapshot."); + PerformanceSnapshot filler = PerformanceSnapshot.forGapInActivity(getCurrentSnapshot()); + addNewSnapshot(filler); + } + addNewSnapshot(aNewSnapshot); + } + + private static boolean hasGap(PerformanceSnapshot aNewSnapshot, PerformanceSnapshot aCurrentSnapshot){ + return aNewSnapshot.getEndTime().getTime() - aCurrentSnapshot.getEndTime().getTime() > fEXPOSURE_TIME*60*1000; + } + + private static void addNewSnapshot(PerformanceSnapshot aNewSnapshot){ + fPerformanceHistory.addFirst(aNewSnapshot); + ensureSizeRemainsLimited(); + } + + private static void ensureSizeRemainsLimited() { + if ( fPerformanceHistory.size() > fNUM_PERFORMANCE_SNAPSHOTS ){ + fPerformanceHistory.removeLast(); + } + } + + private static PerformanceSnapshot getCurrentSnapshot(){ + return fPerformanceHistory.getFirst(); + } + + private static void updateCurrentSnapshotStats(long aResponseTime /*millis*/, String aURL){ + PerformanceSnapshot updatedSnapshot = getCurrentSnapshot().addResponseTime( + aResponseTime, aURL + ); + //this style is needed only because the PerfomanceSnapshot objects are immutable. + //Immutability is advantageous, since it guarantees that the caller + //cannot change the internal state of this class. + fPerformanceHistory.removeFirst(); + fPerformanceHistory.addFirst(updatedSnapshot); + } +} \ No newline at end of file diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/webmaster/PerformanceSnapshot.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/webmaster/PerformanceSnapshot.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,219 @@ +package hirondelle.web4j.webmaster; + +import java.util.*; +import java.util.logging.*; +import hirondelle.web4j.util.Consts; +import hirondelle.web4j.model.ModelUtil; +import hirondelle.web4j.util.Util; + +/** + Statistics on server response time. + +

This class uses the metaphor of a 'photographic exposure' of, say, 10 minutes, whereby response times + for a specific time interval are grouped together in a single bin, and average and maximum response times for + that interval are derived for that group. + +

A particular PerformanceSnapshot is used only if its 'exposure time' + has not yet ended. A typical 'exposure' lasts a few minutes. + (See {@link hirondelle.web4j.webmaster.PerformanceMonitor} and the web.xml + of the example application for more information.) + By inspecting the return value of {@link #getEndTime}, the caller + determines if a PerformanceSnapshot object can still be used, or if a new + PerformanceSnapshot object must be created for the 'next exposure'. + +

This class is immutable. In particular, {@link #addResponseTime} returns a new object, + instead of changing the state of an existing one. +*/ +public final class PerformanceSnapshot { + + /** + @param aExposureTime number of minutes to gather statistics ; see web.xml + for more information. + */ + public PerformanceSnapshot(Integer aExposureTime){ + fMaxUrl = Consts.EMPTY_STRING; + fAvgResponseTime = 0L; + fMaxResponseTime = 0L; + fNumRequests = 0; + fExposureTime = aExposureTime.intValue(); + fEndTime = calcEndTime(); + } + + /** + Return a PerformanceSnapshot having no activity. + Such objects are used to explicitly 'fill in the gaps' during periods of no activity. + +

The returned object has the same exposure time as aCurrentSnapshot. + Its end time is taken as aCurrentSnapshot.getEndTime(), plus the exposure time. + All other items are 0 or empty. + */ + public static PerformanceSnapshot forGapInActivity(PerformanceSnapshot aCurrentSnapshot){ + return new PerformanceSnapshot( + aCurrentSnapshot.getEndTime().getTime() + aCurrentSnapshot.getExposureTime()*60*1000, + Consts.EMPTY_STRING, + 0L, + 0L, + 0, + aCurrentSnapshot.getExposureTime() + ); + } + + /** + Return a new PerformanceSnapshot whose state reflects an additional + data point. + + @param aResponseTime response time of a particular server request. + @param aURL URL of the underlying request. + */ + public PerformanceSnapshot addResponseTime(long aResponseTime, String aURL){ + long maxResponseTime = + aResponseTime > fMaxResponseTime ? aResponseTime : fMaxResponseTime + ; + String maxUrl = aResponseTime > fMaxResponseTime ? aURL : fMaxUrl; + int numRequests = fNumRequests + 1; + return new PerformanceSnapshot( + fEndTime, + maxUrl, + getNewAvgResponseTime(aResponseTime), + maxResponseTime, + numRequests, + fExposureTime + ); + } + + /** + Return the time that this snapshot will 'end'. After this time, + a new PerformanceSnapshot must be created by the caller (using the + constructor). + */ + public Date getEndTime(){ + return new Date(fEndTime); + } + + /** + Return the number of server requests this snapshot has recorded. + +

If a page contains two images, for example, the server will likely count + 3 requests, not 1 (one page and two images). + */ + public Integer getNumRequests(){ + return new Integer(fNumRequests); + } + + /** Return the average response time recorded during this snapshot. */ + public Long getAvgResponseTime(){ + return new Long(fAvgResponseTime); + } + + /** Return the maximum response time recorded during this snapshot. */ + public Long getMaxResponseTime(){ + return new Long(fMaxResponseTime); + } + + /** Return the exposure time in minutes, as passed to the constructor. */ + public Integer getExposureTime(){ + return new Integer(fExposureTime); + } + + /** Return the URL of the request responsible for {@link #getMaxResponseTime}. */ + public String getURLWithMaxResponseTime(){ + return fMaxUrl; + } + + /** Intended for debugging only. */ + @Override public String toString(){ + return ModelUtil.toStringFor(this); + } + + @Override public boolean equals(Object aThat){ + if ( this == aThat ) return true; + if ( !(aThat instanceof PerformanceSnapshot) ) return false; + PerformanceSnapshot that = (PerformanceSnapshot)aThat; + return ModelUtil.equalsFor(this.getSignificantFields(), that.getSignificantFields()); + } + + @Override public int hashCode(){ + return ModelUtil.hashCodeFor(getSignificantFields()); + } + + // PRIVATE // + private final int fNumRequests; + private final long fEndTime; + private final long fAvgResponseTime; + private final long fMaxResponseTime; + private final int fExposureTime; + private final String fMaxUrl; + + private static final Logger fLogger = Util.getLogger(PerformanceSnapshot.class); + + private PerformanceSnapshot( + long aEndTime, + String aMaxUrl, + long aAvgResponseTime, + long aMaxResponseTime, + int aNumRequests, + int aExposureTime + ){ + fEndTime = aEndTime; + fMaxUrl = aMaxUrl; + fAvgResponseTime = aAvgResponseTime; + fMaxResponseTime = aMaxResponseTime; + fNumRequests = aNumRequests; + fExposureTime = aExposureTime; + } + + /** + Return a new average response time, rounded to the nearest millisecond. + */ + private long getNewAvgResponseTime(long aNewResponseTime){ + //use integer division to do the rounding + //here, all previous requests are treated as having the same average response time + long numerator = (fAvgResponseTime * fNumRequests) + aNewResponseTime; + long denominator = fNumRequests + 1; + return numerator/denominator; + } + + /** + Return the time this snapshot's exposure will end. + +

The end time is the next 'round' minute of the hour in agreement with the + configured exposure time. For example, if the exposure time is 20 minutes, and + the current time is 32 minutes past the hour, the end time will be 40 minutes past + the hour. (If the current time is 40 minutes past the hour, the end time will be + 60 minutes past the hour.) + */ + private long calcEndTime(){ + //this item is used as a 'workspace', and its state is changed to find the + //desired end point : + final Calendar result = new GregorianCalendar(); + //leniency will increment hour, day, and so on, if necessary : + result.setLenient(true); + result.setTimeInMillis(System.currentTimeMillis()); + //these items are not relevant to the result, since we need to return an even minute + result.set(Calendar.MILLISECOND, 0); + result.set(Calendar.SECOND, 0); + fLogger.finest("Initial calendar, minus seconds : " + result.toString()); + //increase the minutes until the desired end point is reached + //note this item in non-final + int minute = result.get(Calendar.MINUTE); + fLogger.finest("Initial minute: " + minute); + + //Note that the minute is always incremented at least once + //This avoids error when the new Snapshot is created at a 'whole' minute (a + //common occurrence). + do { + ++minute; + } + while (minute % fExposureTime != 0); + fLogger.finest("Final minute : " + minute); + + result.set(Calendar.MINUTE, minute); + return result.getTimeInMillis(); + } + + private Object[] getSignificantFields(){ + return new Object[]{ + fNumRequests, fEndTime, fAvgResponseTime, fMaxResponseTime, fExposureTime, fMaxUrl + }; + } +} diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/webmaster/TroubleTicket.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/webmaster/TroubleTicket.java Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,628 @@ +package hirondelle.web4j.webmaster; + +import hirondelle.web4j.ApplicationInfo; +import hirondelle.web4j.BuildImpl; +import hirondelle.web4j.model.AppException; +import hirondelle.web4j.model.DateTime; +import hirondelle.web4j.readconfig.Config; +import hirondelle.web4j.util.Args; +import hirondelle.web4j.util.Consts; +import hirondelle.web4j.util.Util; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.io.Writer; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.text.DecimalFormat; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TimeZone; +import java.util.TreeMap; +import java.util.regex.Pattern; + +import javax.servlet.ServletConfig; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; + +/** + Email diagnostic information to support staff when an error occurs. + +

Uses the following settings in web.xml: +

    +
  • Webmaster - the 'from' address. +
  • TroubleTicketMailingList - the 'to' addresses for the support staff. +
  • PoorPerformanceThreshold - when the response time exceeds this level, then + a TroubleTicket is sent +
  • MinimumIntervalBetweenTroubleTickets - throttles down emission of + TroubleTickets, where many might be emitted in rapid succession, from the + same underlying cause +
+ +

The {@link hirondelle.web4j.Controller} will create and send a TroubleTicket when: +

    +
  • an unexpected problem (a bug) occurs. The bug corresponds to an unexpected + {@link Throwable} emitted by either the application or the framework. +
  • the response time exceeds the PoorPerformanceThreshold configured in + web.xml. +
+ +

Warning: some e-mail spam filters may incorrectly treat the default content of these trouble + tickets (example below) as spam. + +

Example content of a TroubleTicket, as returned by {@link #toString()}: +

+{@code
+Error for web application Fish And Chips Club/4.6.2.0
+*** java.lang.RuntimeException: Testing application behavior upon failure. ***
+--------------------------------------------------------
+Time of error : 2011-09-12 19:59:32
+Occurred for user : blah
+Web application Build Date: Sat Jul 09 00:00:00 ADT 2011
+Web application Author : Hirondelle Systems
+Web application Link : http://www.web4j.com/
+Web application Message : Uses web4j.jar version 4.6.2
+
+Request Info:
+--------------------------------------------------------
+HTTP Method: GET
+Context Path: /fish
+ServletPath: /webmaster/testfailure/ForceFailure.do
+URI: /fish/webmaster/testfailure/ForceFailure.do
+URL: http://localhost:8081/fish/webmaster/testfailure/ForceFailure.do
+Header accept = text/html,application/xhtml+xml,application/xml;q=0.9
+Header accept-charset = UTF-8,*
+Header accept-encoding = gzip,deflate
+Header accept-language = en-us,en;q=0.5
+Header connection = keep-alive
+Header cookie = JSESSIONID=2C326412C32F6F823673A5FBD1C883A7
+Header host = localhost:8081
+Header keep-alive = 115
+Header referer = http://localhost:8081/fish/webmaster/performance/ShowPerformance.do
+Header user-agent = Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.9.2.22) ..[elided]..
+Cookie JSESSIONID=2C326412C32F6F823673A5FBD1C883A7
+
+Client Info:
+--------------------------------------------------------
+User IP: 127.0.0.1
+User hostname: 127.0.0.1
+
+Session Info
+--------------------------------------------------------
+Logged in user name : blah
+Timeout : 900 seconds.
+Session Attributes javax.servlet.jsp.jstl.fmt.request.charset = UTF-8
+Session Attributes web4j_key_for_form_source_id = 214c125310311a6e4eda5aa6448b3c47d0a85d31
+Session Attributes web4j_key_for_locale = en
+Session Attributes web4j_key_for_previous_form_source_id = f2d6ae8487e555f90ae7c186f370b05bcf46f0d0
+
+Memory Info:
+--------------------------------------------------------
+JRE Memory
+ total:  66,650,112
+ used:    7,515,624 (11%)
+ free:   59,134,488 (89%)
+
+
+Server And Servlet Info:
+--------------------------------------------------------
+Name: localhost
+Port: 8081
+Info: Apache Tomcat/6.0.10
+JRE default TimeZone: America/Halifax
+JRE default Locale: English (Canada)
+awt.toolkit: sun.awt.windows.WToolkit
+catalina.base: C:\johanley\Projects\TomcatInstance
+catalina.home: C:\Program Files\Tomcat6
+catalina.useNaming: true
+common.loader: ${catalina.home}/lib,${catalina.home}/lib/*.jar
+file.encoding: UTF-8
+file.encoding.pkg: sun.io
+file.separator: \
+java.awt.graphicsenv: sun.awt.Win32GraphicsEnvironment
+java.awt.printerjob: sun.awt.windows.WPrinterJob
+java.class.path: .;C:\Program Files\Java\jre1.6.0_07\lib\ext\QTJava.zip;C:\Program Files\Tomcat6\bin\bootstrap.jar
+java.class.version: 49.0
+java.endorsed.dirs: C:\Program Files\Tomcat6\endorsed
+java.ext.dirs: C:\jdk1.5.0\jre\lib\ext
+java.home: C:\jdk1.5.0\jre
+java.io.tmpdir: C:\johanley\Projects\TomcatInstance\temp
+java.library.path: C:\jdk1.5.0\bin;.;C:\WINDOWS\system32;C:\WINDOWS;C:\jdk1.5.0\bin;..e[lided]..
+java.naming.factory.initial: org.apache.naming.java.javaURLContextFactory
+java.naming.factory.url.pkgs: org.apache.naming
+java.runtime.name: Java(TM) 2 Runtime Environment, Standard Edition
+java.runtime.version: 1.5.0_07-b03
+java.specification.name: Java Platform API Specification
+java.specification.vendor: Sun Microsystems Inc.
+java.specification.version: 1.5
+java.util.logging.config.file: C:\johanley\Projects\TomcatInstance\conf\logging.properties
+java.util.logging.manager: org.apache.juli.ClassLoaderLogManager
+java.vendor: Sun Microsystems Inc.
+java.vendor.url: http://java.sun.com/
+java.vendor.url.bug: http://java.sun.com/cgi-bin/bugreport.cgi
+java.version: 1.5.0_07
+java.vm.info: mixed mode, sharing
+java.vm.name: Java HotSpot(TM) Client VM
+java.vm.specification.name: Java Virtual Machine Specification
+java.vm.specification.vendor: Sun Microsystems Inc.
+java.vm.specification.version: 1.0
+java.vm.vendor: Sun Microsystems Inc.
+java.vm.version: 1.5.0_07-b03
+line.separator: 
+
+os.arch: x86
+os.name: Windows XP
+os.version: 5.1
+package.access: sun.,org.apache.catalina.,org.apache.coyote.,org.apache.tomcat.,org.apache.jasper.,sun.beans.
+package.definition: sun.,java.,org.apache.catalina.,org.apache.coyote.,org.apache.tomcat.,org.apache.jasper.
+path.separator: ;
+server.loader: 
+shared.loader: 
+sun.arch.data.model: 32
+sun.boot.class.path: C:\jdk1.5.0\jre\lib\rt.jar;...[elided]
+sun.boot.library.path: C:\jdk1.5.0\jre\bin
+sun.cpu.endian: little
+sun.cpu.isalist: 
+sun.desktop: windows
+sun.io.unicode.encoding: UnicodeLittle
+sun.jnu.encoding: Cp1252
+sun.management.compiler: HotSpot Client Compiler
+sun.os.patch.level: Service Pack 3
+tomcat.util.buf.StringCache.byte.enabled: true
+user.country: CA
+user.dir: C:\johanley\Projects\TomcatInstance
+user.home: C:\Documents and Settings\John
+user.language: en
+user.name: John
+user.timezone: America/Halifax
+user.variant: 
+java.class.path: 
+.
+C:\Program Files\Java\jre1.6.0_07\lib\ext\QTJava.zip
+C:\Program Files\Tomcat6\bin\bootstrap.jar
+Servlet : Controller
+Servlet init-param:  AccessControlDbConnectionString = java:comp/env/jdbc/fish_access
+Servlet init-param:  AllowStringAsBuildingBlock = YES
+Servlet init-param:  BigDecimalDisplayFormat = #,##0.00
+Servlet init-param:  BooleanFalseDisplayFormat = 
+Servlet init-param:  BooleanTrueDisplayFormat = 
+Servlet init-param:  CharacterEncoding = UTF-8
+Servlet init-param:  DateTimeFormatForPassingParamsToDb = YYYY-MM-DD^hh:mm:ss^YYYY-MM-DD hh:mm:ss
+Servlet init-param:  DecimalSeparator = PERIOD
+Servlet init-param:  DecimalStyle = HALF_EVEN,2
+Servlet init-param:  DefaultDbConnectionString = java:comp/env/jdbc/fish
+Servlet init-param:  DefaultLocale = en
+Servlet init-param:  DefaultUserTimeZone = America/Halifax
+Servlet init-param:  EmptyOrNullDisplayFormat = -
+Servlet init-param:  ErrorCodeForDuplicateKey = 1062
+Servlet init-param:  ErrorCodeForForeignKey = 1216,1217,1451,1452
+Servlet init-param:  FetchSize = 25
+Servlet init-param:  FullyValidateFileUploads = ON
+Servlet init-param:  HasAutoGeneratedKeys = true
+Servlet init-param:  IgnorableParamValue = 
+Servlet init-param:  ImplementationFor.hirondelle.web4j.security.LoginTasks = hirondelle.fish.all.preferences.Login
+Servlet init-param:  ImplicitMappingRemoveBasePackage = hirondelle.fish
+Servlet init-param:  IntegerDisplayFormat = #,###
+Servlet init-param:  IsSQLPrecompilationAttempted = true
+Servlet init-param:  LoggingDirectory = C:\log\fish\
+Servlet init-param:  LoggingLevels = hirondelle.fish.level=FINE, hirondelle.web4j.level=FINE
+Servlet init-param:  MailServerConfig = mail.host=mail.blah.com
+Servlet init-param:  MailServerCredentials = NONE
+Servlet init-param:  MaxFileUploadRequestSize = 1048576
+Servlet init-param:  MaxHttpRequestSize = 51200
+Servlet init-param:  MaxRequestParamValueSize = 51200
+Servlet init-param:  MaxRows = 300
+Servlet init-param:  MinimumIntervalBetweenTroubleTickets = 30
+Servlet init-param:  PoorPerformanceThreshold = 20
+Servlet init-param:  SpamDetectionInFirewall = OFF
+Servlet init-param:  SqlEditorDefaultTxIsolationLevel = DATABASE_DEFAULT
+Servlet init-param:  SqlFetcherDefaultTxIsolationLevel = DATABASE_DEFAULT
+Servlet init-param:  TimeZoneHint = NONE
+Servlet init-param:  TranslationDbConnectionString = java:comp/env/jdbc/fish_translation
+Servlet init-param:  TroubleTicketMailingList = blah@blah.com
+Servlet init-param:  Webmaster = blah@blah.com
+
+Stack Trace:
+--------------------------------------------------------
+java.lang.RuntimeException: Testing application behavior upon failure.
+  at hirondelle.fish.webmaster.testfailure.ForceFailure.execute(ForceFailure.java:29)
+  at hirondelle.web4j.Controller.checkOwnershipThenExecuteAction(Unknown Source)
+  at hirondelle.web4j.Controller.processRequest(Unknown Source)
+  at hirondelle.web4j.Controller.doGet(Unknown Source)
+  ...[elided]...
+ }
+
+*/ +public final class TroubleTicket { + + /** Called by the framework upon startup, to save the name of the server (Servlet 3.0 wouldn't need this). */ + public static void init(ServletConfig aConfig){ + fConfig = aConfig; + } + + /** + Constructor. + + @param aException has caused the problem. + @param aRequest original underlying HTTP request. + */ + public TroubleTicket(Throwable aException, HttpServletRequest aRequest){ + fException = aException; + fRequest = aRequest; + buildBodyOfMessage(); + } + + /** + Constuctor sets custom content for the body of the email. + +

When using this constructor, the detailed information shown in the class + comment is not generated. + @param aCustomBody the desired body of the email. + */ + public TroubleTicket(String aCustomBody) { + Args.checkForContent(aCustomBody); + fRequest = null; + fException = null; + fBody.append(aCustomBody); + } + + /** + Return extensive listing of items which may be useful in solving the problem. + +

See example in the class comment. + */ + @Override public String toString(){ + return fBody.toString(); + } + + /** + Send an email to the TroubleTicketMailingList recipients configured in + web.xml. + +

If sufficient time has passed since the last email of a TroubleTicket, + then send an email to the webmaster whose body is {@link #toString}; otherwise do + nothing. + +

Here, "sufficient time" is defined by a setting in web.xml named + MinimumIntervalBetweenTroubleTickets. The intent is to throttle down on + emails which likely have the same cause. + */ + public void mailToRecipients() throws AppException { + if ( hasEnoughTimePassedSinceLastEmail() ) { + sendEmail(); + updateMostRecentTime(); + } + } + + // PRIVATE + private static ServletConfig fConfig; + private final HttpServletRequest fRequest; + private final Throwable fException; + private final ApplicationInfo fAppInfo = BuildImpl.forApplicationInfo(); + + private static final boolean DO_NOT_CREATE_SESSION = false; + private static final Pattern PASSWORD_PATTERN = Pattern.compile( + "password", Pattern.CASE_INSENSITIVE + ); + + /** + The text which contains all relevant information which may be useful in solving + the problem. + */ + private StringBuilder fBody = new StringBuilder(); + + /** + The time of the last send of a TroubleTicket email, expressed in + milliseconds since the Java epoch. + + This static data is shared among requests, and all access to this + field must be synchronized. This synchronization should not be a problem in practice, + since this class is used only when there's a problem. + */ + private static long fTimeLastEmail; + + /** Build fBody from its various parts. */ + private void buildBodyOfMessage() { + addExceptionSummary(); + addRequestInfo(); + addClientInfo(); + addSessionInfo(); + addMemoryInfo(); + addServerInfo(); + addStackTrace(); + } + + private void addLine(String aLine){ + fBody.append(aLine + Consts.NEW_LINE); + } + + private void addStartOfSection(String aHeader){ + addLine(Consts.EMPTY_STRING); + addLine(aHeader); + addLine("--------------------------------------------------------"); + } + + private void addExceptionSummary(){ + addStartOfSection( + "Error for web application " + fAppInfo.getName() + "/" + fAppInfo.getVersion() + + "." + Consts.NEW_LINE + "*** " + fException.toString() + " ***" + ); + long nowMillis = BuildImpl.forTimeSource().currentTimeMillis(); + TimeZone tz = new Config().getDefaultUserTimeZone(); + DateTime now = DateTime.forInstant(nowMillis, tz); + addLine("Time of error : " + now.format("YYYY-MM-DD hh:mm:ss")); + addLine("Occurred for user : " + getLoggedInUser() ); + addLine("Web application Build Date: " + fAppInfo.getBuildDate()); + addLine("Web application Author : " + fAppInfo.getAuthor()); + addLine("Web application Link : " + fAppInfo.getLink()); + addLine("Web application Message : " + fAppInfo.getMessage()); + if ( fException instanceof AppException ) { + AppException appEx = (AppException)fException; + Iterator errorsIter = appEx.getMessages().iterator(); + while ( errorsIter.hasNext() ) { + addLine( errorsIter.next().toString() ); + } + } + } + + private void addRequestInfo(){ + addStartOfSection("Request Info:"); + addLine("HTTP Method: " + fRequest.getMethod()); + addLine("Context Path: " + fRequest.getContextPath()); + addLine("ServletPath: " + fRequest.getServletPath()); + addLine("URI: " + fRequest.getRequestURI()); + addLine("URL: " + fRequest.getRequestURL().toString()); + addRequestParams(); + addRequestHeaders(); + addCookies(); + } + + private void addClientInfo(){ + addStartOfSection("Client Info:"); + addLine("User IP: " + fRequest.getRemoteAddr()); + addLine("User hostname: " + fRequest.getRemoteHost()); + } + + private void addServerInfo(){ + addStartOfSection("Server And Servlet Info:"); + addLine("Name: " + fRequest.getServerName()); + addLine("Port: " + fRequest.getServerPort()); + addLine("Info: " + fConfig.getServletContext().getServerInfo()); //in Servlet 3.0 this is attached to the request + addLine("JRE default TimeZone: " + TimeZone.getDefault().getID()); + addLine("JRE default Locale: " + Locale.getDefault().getDisplayName()); + + addAllSystemProperties(); + addClassPath(); + addLine("Servlet : " + fConfig.getServletName()); + + Map servletParams = new Config().getRawMap(); + servletParams = sortMap(servletParams); + addMap(servletParams, "Servlet init-param: "); + } + + private void addAllSystemProperties(){ + Map properties = sortMap(System.getProperties()); + Set props = properties.entrySet(); + Iterator iter = props.iterator(); + while ( iter.hasNext() ) { + Map.Entry entry = (Map.Entry)iter.next(); + addLine(entry.getKey() + ": " + entry.getValue()); + } + } + + /** + Since this item tends to be very long, it is useful to place each entry + on a separate line. + */ + private void addClassPath(){ + String JAVA_CLASS_PATH = "java.class.path"; + String classPath = System.getProperty(JAVA_CLASS_PATH); + List pathElements = Arrays.asList( classPath.split(Consts.PATH_SEPARATOR) ); + StringBuilder result = new StringBuilder(Consts.NEW_LINE); + Iterator pathElementsIter = pathElements.iterator(); + while ( pathElementsIter.hasNext() ) { + String pathElement = (String)pathElementsIter.next(); + result.append(pathElement); + if ( pathElementsIter.hasNext() ) { + result.append(Consts.NEW_LINE); + } + } + addLine(JAVA_CLASS_PATH + ": " + result.toString()); + } + + private void addStackTrace(){ + addStartOfSection("Stack Trace:"); + addLine( getStackTrace(fException) ); + } + + private void addRequestParams(){ + Map paramMap = new HashMap(); + Enumeration namesEnum = fRequest.getParameterNames(); + while ( namesEnum.hasMoreElements() ){ + String name = (String)namesEnum.nextElement(); + String values = Util.getArrayAsString( fRequest.getParameterValues(name) ); + if( isPassword(name)){ + paramMap.put(name, "***(masked)***"); + } + else { + paramMap.put(name, values); + } + } + paramMap = sortMap(paramMap); + addMap(paramMap, "Req Param"); + } + + private void addMemoryInfo(){ + addStartOfSection("Memory Info:"); + addLine(getMemory()); + } + + private String getMemory(){ + return getTotalUsedFree(Runtime.getRuntime().totalMemory(), Runtime.getRuntime().freeMemory(), "JRE Memory"); + } + + private String getTotalUsedFree(long aTotal, long aFree, String aDescription){ + StringBuilder result = new StringBuilder(); + BigDecimal total = new BigDecimal(aTotal); + BigDecimal used = new BigDecimal(aTotal - aFree); + BigDecimal free = new BigDecimal(aFree); + + BigDecimal percentUsed = percent(used, total); + BigDecimal percentFree = percent(free, total); + + result.append(aDescription + Consts.NEW_LINE) ; + String totalText = format(total); + String format = "%-9s%" + totalText.length() + "s"; + result.append(String.format(format, " total: ", totalText) + Consts.NEW_LINE) ; + result.append(String.format(format, " used: ", format(used)) + " (" + percentUsed.intValue() + "%)" + Consts.NEW_LINE) ; + result.append(String.format(format, " free: ", format(free)) + " (" + percentFree.intValue() + "%)" + Consts.NEW_LINE) ; + return result.toString(); + } + + private BigDecimal percent(BigDecimal aA, BigDecimal aB){ + BigDecimal ZERO = new BigDecimal("0"); + BigDecimal result = ZERO; + BigDecimal HUNDRED = new BigDecimal("100"); + if( aB.compareTo(ZERO) != 0) { + result = aA.divide(aB, 2, RoundingMode.HALF_EVEN); + result = result.multiply(HUNDRED); + } + return result; + } + + private String format(BigDecimal aNumber){ + DecimalFormat format = new DecimalFormat("#,###"); + return format.format(aNumber); + } + + private boolean isPassword(String aName){ + return Util.contains(PASSWORD_PATTERN, aName); + } + + private void addRequestHeaders(){ + Map headerMap = new HashMap(); + Enumeration namesEnum = fRequest.getHeaderNames(); + while ( namesEnum.hasMoreElements() ) { + String name = (String) namesEnum.nextElement(); + Enumeration valuesEnum = fRequest.getHeaders(name); + while ( valuesEnum.hasMoreElements() ) { + String value = (String)valuesEnum.nextElement(); + headerMap.put(name, value); + } + } + headerMap = sortMap(headerMap); + addMap(headerMap, "Header"); + } + + private void addCookies(){ + if (fRequest.getCookies() == null) return; + + List cookies = Arrays.asList(fRequest.getCookies()); + Iterator cookiesIter = cookies.iterator(); + while ( cookiesIter.hasNext() ) { + Cookie cookie = (Cookie)cookiesIter.next(); + addLine("Cookie " + cookie.getName() + "=" + cookie.getValue()); + } + } + + private String getStackTrace( Throwable aThrowable ) { + final Writer result = new StringWriter(); + final PrintWriter printWriter = new PrintWriter( result ); + aThrowable.printStackTrace( printWriter ); + return result.toString(); + } + + private void addSessionInfo(){ + addStartOfSection("Session Info"); + + HttpSession session = fRequest.getSession(DO_NOT_CREATE_SESSION); + if ( session == null ){ + addLine("No session existed for this request."); + } + else { + addLine("Logged in user name : " + getLoggedInUser()); + addLine("Timeout : " + session.getMaxInactiveInterval() + " seconds."); + Map sessionMap = new HashMap(); + Enumeration sessionAttrs = session.getAttributeNames(); + while (sessionAttrs.hasMoreElements()){ + String name = (String)sessionAttrs.nextElement(); + Object value = session.getAttribute(name); + if( isPassword(name) ){ + sessionMap.put(name, "***(masked)***"); + } + else { + sessionMap.put(name, value.toString()); + } + } + sessionMap = sortMap(sessionMap); + addMap(sessionMap, "Session Attributes"); + } + } + + private String getLoggedInUser(){ + String result = null; + if (fRequest.getUserPrincipal() != null) { + result = fRequest.getUserPrincipal().getName(); + } + else { + result = "NONE"; + } + return result; + } + + private static synchronized boolean hasEnoughTimePassedSinceLastEmail(){ + return (System.currentTimeMillis()-fTimeLastEmail >getMinimumIntervalBetweenEmails()); + } + + private static synchronized void updateMostRecentTime(){ + fTimeLastEmail = System.currentTimeMillis(); + } + + private void sendEmail() throws AppException { + Emailer emailer = BuildImpl.forEmailer(); + emailer.sendFromWebmaster(new Config().getTroubleTicketMailingList(), getSubject(), toString()); + } + + /** Text to appear in all TroubleTicket emails as the "Subject" of the email. */ + private String getSubject(){ + return + "Servlet Error. Application : " + fAppInfo.getName() + "" + + "/" + fAppInfo.getVersion() + ; + } + + /** + Convert the number of minutes configured in web.xml into milliseconds. + */ + private static long getMinimumIntervalBetweenEmails(){ + final long MILLISECONDS_PER_SECOND = 1000; + final int SECONDS_PER_MINUTE = 60; + Long MINIMUM_INTERVAL_BETWEEN_TICKETS = new Config().getMinimumIntervalBetweenTroubleTickets(); + return + MILLISECONDS_PER_SECOND * SECONDS_PER_MINUTE * + MINIMUM_INTERVAL_BETWEEN_TICKETS + ; + } + + private void addMap(Map aMap, String aLineHeader){ + Iterator iter = aMap.keySet().iterator(); + while (iter.hasNext()){ + String name = (String)iter.next(); + String value = (String)aMap.get(name); + addLine(aLineHeader + " " + name + " = " + value); + } + } + + private Map sortMap(Map aInput){ + Map result = new TreeMap(String.CASE_INSENSITIVE_ORDER); + result.putAll(aInput); + return result; + } +} \ No newline at end of file diff -r 000000000000 -r 3060119b1292 classes/hirondelle/web4j/webmaster/package.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/hirondelle/web4j/webmaster/package.html Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,12 @@ + + + + + + + Webmaster + + +Administrative items for support staff. + + diff -r 000000000000 -r 3060119b1292 classes/overview.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/classes/overview.html Wed Dec 04 17:00:31 2013 +0100 @@ -0,0 +1,26 @@ + + + + + + + WEB4J + + +WEB4J is a full stack framework for connecting a browser to a database, using Java. +It was created by Hirondelle Systems (John O'Hanley). + +

+ "Great software requires a fanatical devotion to beauty." +
- Paul Graham
+
+ +

For a comprehensive overview, please see web4j.com. + +

Version History + +

WEB4J is open source software. +It is released under a BSD license. + + + diff -r 000000000000 -r 3060119b1292 lib/activation.jar Binary file lib/activation.jar has changed diff -r 000000000000 -r 3060119b1292 lib/jsp-api.jar Binary file lib/jsp-api.jar has changed diff -r 000000000000 -r 3060119b1292 lib/junit.jar Binary file lib/junit.jar has changed diff -r 000000000000 -r 3060119b1292 lib/mail.jar Binary file lib/mail.jar has changed diff -r 000000000000 -r 3060119b1292 lib/servlet-api.jar Binary file lib/servlet-api.jar has changed