--- /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.
--- /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.
+
--- /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 @@
+<project name="web4j-jar" default='all' basedir="." >
+
+ <!--
+ This is a courtesy build script provided for your use.
+ Please edit to suit your needs.
+
+ You can use whatever compiler suits your needs.
+ The 'official' web4j.jar uses JDK 1.5, in order to remain compatible
+ with older environments. But you can use more modern JDK's if you wish.
+ -->
+
+ <description>
+ Web4j.jar
+ </description>
+
+ <!-- Override default property values with an external properties file, if present. -->
+ <property file='build.properties'/>
+
+ <!-- Default property values, if not overridden elsewhere: -->
+ <property name='build' location='build' />
+ <property name='app.version' value='1.0.0'/>
+ <property name='app.name' value='web4j'/>
+ <property name='distro-name' value='web4j'/>
+ <tstamp><format property='build.time' pattern='yyyy-MM-dd HH:mm:ss'/></tstamp>
+
+ <path id='compile.classpath'>
+ <fileset dir='lib'>
+ <include name='*.jar'/>
+ </fileset>
+ </path>
+
+ <!-- Simply extends the compile.classpath with your own compiled classes. -->
+ <path id='run.classpath'>
+ <path refid='compile.classpath'/>
+ <path location='classes'/>
+ </path>
+
+ <fileset id='class.files' dir='classes'>
+ <include name='**/*.class'/>
+ </fileset>
+
+ <fileset id='files.for.jar' dir='classes'>
+ <exclude name='**/*.java'/>
+ <exclude name='**/doc-files/'/>
+ </fileset>
+
+ <fileset id='test.classes' dir='classes'>
+ <include name='**/TEST*.java'/>
+ </fileset>
+
+ <!-- A connection to this URL is used when building javadoc. -->
+ <condition property='jdk.javadoc.visible' value='true' else='false'>
+ <http url='http://docs.oracle.com/javase/6/docs/api/' />
+ </condition>
+
+ <echo>
+ 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}
+ </echo>
+
+ <echo message='Create build directory, and its subdirectories.'/>
+ <mkdir dir="${build}/javadoc"/>
+ <mkdir dir="${build}/dist"/>
+
+ <!-- Now define the targets, which use the properties and datatypes defined above. -->
+
+ <target name='clean' description="Delete all build artifacts." >
+ <delete dir='${build}'/>
+ <delete>
+ <fileset refid='class.files'/>
+ </delete>
+ <mkdir dir="${build}/javadoc"/>
+ <mkdir dir="${build}/dist"/>
+ </target>
+
+ <target name='compile' description='Compile source files and place beside source.'>
+ <javac srcdir="classes">
+ <classpath refid='compile.classpath'/>
+ </javac>
+ <!-- Here's a simple way of debugging a path, fileset, or patternset, using its refid: -->
+ <echo>Classpath: ${toString:compile.classpath}</echo>
+ </target>
+
+ <target name='jar' description='Create a jar file for distribution.' depends='compile'>
+ <jar destfile='${build}/dist/${distro-name}.jar' duplicate='preserve'>
+ <fileset refid='files.for.jar'/>
+ <manifest>
+ <attribute name='Specification-Version' value='${app.version}'/>
+ <attribute name='Specification-Title' value='${app.name}' />
+ <attribute name='Implementation-Version' value='${app.version}'/>
+ <attribute name='Implementation-Title' value='${app.name}' />
+ </manifest>
+ </jar>
+ </target>
+
+ <target name='javadoc' description='Generate javadoc.' >
+ <javadoc
+ use='true' author='true' version='true'
+ overview='classes\overview.html'
+ access='package'
+ sourcepath='classes'
+ packagenames='*.*'
+ destdir='${build}/javadoc'
+ windowtitle='${app.name} ${app.version}'
+ noqualifier='java.*:javax.*:com.sun.*'
+ linksource='true'
+ >
+ <classpath refid='compile.classpath'/>
+ <link href='http://docs.oracle.com/javase/6/docs/api/'/>
+ <header><![CDATA[<h1>${app.name} ${app.version}</h1>]]></header>
+ </javadoc>
+ </target>
+
+ <target name='distro-binary' description='Create zip file with executable jar, docs.' depends='jar, javadoc'>
+ <zip destfile='${build}/dist/${distro-name}-binary.zip' duplicate='preserve'>
+ <zipfileset dir='${build}/dist/' includes='${distro-name}.jar'/>
+ <zipfileset dir='${build}/javadoc' prefix='javadoc' />
+ </zip>
+ </target>
+
+ <target name='distro-source' description='Create zip file with project source code.'>
+ <zip destfile='${build}/dist/${distro-name}-src.zip' duplicate='preserve' >
+ <!-- exclude items specific to the author's IDE setup: -->
+ <zipfileset dir='.' excludes='.classpath, .project'/>
+ </zip>
+ </target>
+
+ <target name='all' description='Create all build artifacts.' depends='clean, compile, jar, javadoc, distro-binary, distro-source'>
+ <echo>Finished creating all build artifacts.</echo>
+ </target>
+
+</project>
\ No newline at end of file
--- /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.
+
+ <P>Implemenations of this interface are usually quite simple.
+ Here is the
+ <a href="http://www.javapractices.com/apps/fish/javadoc/src-html/hirondelle/web4j/config/AppInfo.html">implementation</a>
+ used in the WEB4J example application.
+
+ <P>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.
+
+<P>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.
+
+ <P>No method in this interface returns a <tt>null</tt> object reference.
+*/
+public interface ApplicationInfo {
+
+ /**
+ The name of this web application.
+ */
+ String getName();
+
+ /**
+ The version of this web application.
+
+ <P>The content is arbitrary, and make take any desired form. Examples :
+ "<tt>1.2.3</tt>", "<tt>Build 1426</tt>".
+ */
+ 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 <tt>ApplicationInfo</tt> object placed in application scope.
+ */
+ public static final String KEY = "web4j_key_for_app_info";
+}
--- /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.
+
+ <P>WEB4J requires the application programmer to supply concrete implementations of a number of
+ interfaces and a single abstract class. <tt>BuildImpl</tt> 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.
+
+ <P>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.)
+
+ <P><h3>Configuration Styles</h3>
+ Concrete implementation classes can be configured in three ways:
+ <ul>
+ <li>do nothing at all. In this case, a default implementation defined by <tt>WEB4J</tt> will be used.
+ Several of the WEB4J abstractions (such as {@link ConnectionSource}) do
+ not have a default implementation, so this style of configuration is not always possible.
+ <li>implement a concrete class <em>of a conventional package and name</em>. The conventional <em>package</em> name
+ is always '<tt>hirondelle.web4j.config</tt>', while the conventional <em>class</em> name varies -
+ see <a href="#Listing">below</a>.
+ <li>implement a concrete class of a <em>non-conventional</em> package and name,
+ and add an <tt>init-param</tt> setting to <tt>web.xml</tt> of the form:
+ <PRE>
+ <init-param>
+ <param-name>ImplementationFor.hirondelle.web4j.ApplicationInfo</param-name>
+ <param-value>com.xyz.MyAppInfo</param-value>
+ <description>
+ Package-qualified name of class describing simple,
+ high level information about this application.
+ </description>
+ </init-param>
+ </PRE>
+ </ul>
+
+ <P>The {@link #init(Map)} method will look for implementations in the reverse of the above order.
+ That is,
+<ol>
+ <li>an <em>explicit</em> <tt>ImplementationFor.*</tt> setting in <tt>web.xml</tt>
+ <li>a class of a <em>conventional</em> package and name
+ <li>the <em>default</em> WEB4J implementation (if a default implementation exists)
+</ol>
+
+ <P><a name="Listing"></a><h3>Listing of Interfaces and Conventional Names</h3>
+ 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 '<tt>hirondelle.web4j.config</tt>'.
+
+ <P><table BORDER CELLSPACING=0 CELLPADDING=3 >
+ <tr>
+ <th>Question</th>
+ <th>Interface</th>
+ <th>Conventional Impl Name, in <tt>hirondelle.web4j.config</tt></th>
+ <th>Default/Example Implementation</th>
+ </tr>
+ <tr valign="top">
+ <td>What is the application's name, version, build date, and so on?</td>
+ <td>{@link hirondelle.web4j.ApplicationInfo}</td>
+ <td><tt>AppInfo</tt></td>
+ <td><a href="http://www.javapractices.com/apps/fish/javadoc/src-html/hirondelle/web4j/config/AppInfo.html">example</a></td>
+ </tr>
+ <tr valign="top">
+ <td>What tasks need to be performed during startup?</td>
+ <td>{@link hirondelle.web4j.StartupTasks}</td>
+ <td><tt>Startup</tt></td>
+ <td><a href="http://www.javapractices.com/apps/fish/javadoc/src-html/hirondelle/web4j/config/Startup.html">example</a></td>
+ </tr>
+ <tr valign="top">
+ <td>What tasks need to be performed after user login?</td>
+ <td>{@link hirondelle.web4j.security.LoginTasks}</td>
+ <td><tt>Login</tt></td>
+ <td><a href="http://www.javapractices.com/apps/fish/javadoc/src-html/hirondelle/web4j/config/Login.html">example</a></td>
+ </tr>
+ <tr valign="top">
+ <td>What <tt>Action</tt> is related to each request?</td>
+ <td>{@link hirondelle.web4j.request.RequestParser} (an ABC)</td>
+ <td><tt>RequestToAction</tt></td>
+ <td>{@link hirondelle.web4j.request.RequestParserImpl}</a></td>
+ </tr>
+ <tr valign="top">
+ <td>Which requests should be treated as malicious attacks?</td>
+ <td>{@link hirondelle.web4j.security.ApplicationFirewall}</td>
+ <td><tt>AppFirewall</tt></td>
+ <td>{@link hirondelle.web4j.security.ApplicationFirewallImpl}</td>
+ </tr>
+ <tr valign="top">
+ <td>Which requests use untrusted proxies for the user id?</td>
+ <td>{@link hirondelle.web4j.security.UntrustedProxyForUserId}</td>
+ <td><tt>OwnerFirewall</tt></td>
+ <td>{@link hirondelle.web4j.security.UntrustedProxyForUserIdImpl}</td>
+ </tr>
+ <tr valign="top">
+ <td>How is spam distinguished from regular user input?</td>
+ <td>{@link hirondelle.web4j.security.SpamDetector}</td>
+ <td><tt>SpamDetect</tt></td>
+ <td>{@link hirondelle.web4j.security.SpamDetectorImpl}</td>
+ </tr>
+ <tr valign="top">
+ <td>How is a request param translated into a given target type?</td>
+ <td>{@link hirondelle.web4j.model.ConvertParam}</td>
+ <td><tt>ConvertParams</tt></td>
+ <td>{@link hirondelle.web4j.model.ConvertParamImpl}</td>
+ </tr>
+ <tr valign="top">
+ <td>How does the application respond when a low level conversion error takes place when parsing user input?</td>
+ <td>{@link hirondelle.web4j.model.ConvertParamError}</td>
+ <td><tt>ConvertParamErrorImpl</tt></td>
+ <td><a href="http://www.javapractices.com/apps/fish/javadoc/src-html/hirondelle/web4j/config/ConvertParamErrorImpl.html">example</a></td>
+ </tr>
+ <tr valign="top">
+ <td>What characters are permitted for text input fields?</td>
+ <td>{@link hirondelle.web4j.security.PermittedCharacters}</td>
+ <td><tt>PermittedChars</tt></td>
+ <td>{@link hirondelle.web4j.security.PermittedCharactersImpl}</td>
+ </tr>
+ <tr valign="top">
+ <td>How is a date formatted and parsed?</td>
+ <td>{@link hirondelle.web4j.request.DateConverter}</td>
+ <td><tt>DateConverterImpl</tt></td>
+ <td><a href="http://www.javapractices.com/apps/fish/javadoc/src-html/hirondelle/web4j/config/DateConverterImpl.html">example</a></td>
+ </tr>
+ <tr valign="top">
+ <td>How is a <tt>Locale</tt> derived from the request?</td>
+ <td>{@link hirondelle.web4j.request.LocaleSource}</td>
+ <td><tt>LocaleSrc</tt></td>
+ <td>{@link hirondelle.web4j.request.LocaleSourceImpl}</td>
+ </tr>
+ <tr valign="top">
+ <td>How is the system clock defined?</td>
+ <td>{@link hirondelle.web4j.util.TimeSource}</td>
+ <td><tt>TimeSrc</tt></td>
+ <td>{@link hirondelle.web4j.util.TimeSourceImpl}</td>
+ </tr>
+ <tr valign="top">
+ <td>How is a <tt>TimeZone</tt> derived from the request?</td>
+ <td>{@link hirondelle.web4j.request.TimeZoneSource}</td>
+ <td><tt>TimeZoneSrc</tt></td>
+ <td>{@link hirondelle.web4j.request.TimeZoneSourceImpl}</td>
+ </tr>
+ <tr valign="top">
+ <td>What is the translation of this text, for a given <tt>Locale</tt>?</td>
+ <td>{@link hirondelle.web4j.ui.translate.Translator}</td>
+ <td><tt>TranslatorImpl</tt></td>
+ <td><a href="http://www.javapractices.com/apps/fish/javadoc/src-html/hirondelle/web4j/config/TranslatorImpl.html">example</a></td>
+ </tr>
+ <tr valign="top">
+ <td>How does the application obtain a database <tt>Connection</tt>?</td>
+ <td>{@link hirondelle.web4j.database.ConnectionSource}</td>
+ <td><tt>ConnectionSrc</tt></td>
+ <td><a href="http://www.javapractices.com/apps/fish/javadoc/src-html/hirondelle/web4j/config/ConnectionSrc.html">example</a></td>
+ </tr>
+ <tr valign="top">
+ <td>How is a <tt>ResultSet</tt> column translated into a given target type?</td>
+ <td>{@link hirondelle.web4j.database.ConvertColumn}</td>
+ <td><tt>ColToObject</tt></td>
+ <td>{@link hirondelle.web4j.database.ConvertColumnImpl}</td>
+ </tr>
+ <tr valign="top">
+ <td>How should an email be sent when a problem occurs?</td>
+ <td>{@link hirondelle.web4j.webmaster.Emailer}</td>
+<td><tt>Email</tt></td>
+ <td>{@link hirondelle.web4j.webmaster.EmailerImpl}</td>
+ </tr>
+ <tr valign="top">
+ <td>How should the logging system be configured?</td>
+ <td>{@link hirondelle.web4j.webmaster.LoggingConfig}</td>
+ <td><tt>LogConfig</tt></td>
+ <td>{@link hirondelle.web4j.webmaster.LoggingConfigImpl}</td>
+ </tr>
+ <tr valign="top">
+ <td>Does this request/operation have a data ownership constraint?</td>
+ <td>{@link hirondelle.web4j.security.UntrustedProxyForUserId}</td>
+ <td><tt>OwnerFirewall</tt></td>
+ <td>{@link hirondelle.web4j.security.UntrustedProxyForUserIdImpl}</td>
+ </tr>
+ </table>
+
+ <p><span class="highlight">No conflict between the classes of different
+ applications will result, if application code is placed in the usual locations
+ under <tt>WEB-INF</span></tt>, and not in <i>shared</i> 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).
+
+ <P>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.
+
+ <P>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.
+
+ <P>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.
+
+ <P>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 <tt>web.xml</tt>.
+ */
+ public static void init(Map<String, String> 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:
+ <ul>
+ <li>{@link TimeSource}
+ <li>{@link LoggingConfig}
+ <li>{@link ConnectionSource}
+ <li>{@link ConvertColumn}
+ <li>{@link ConvertParam} (for the list of supported types)
+ <li>{@link PermittedCharacters} (used by the <tt>Id</tt> class in the model objects returned by the database)
+ <li>{@link DateConverter} (if supplied in the config; used when generating formatted reports)
+ <li>{@link SpamDetector} (used when checking input for spam)
+ </ul>
+ Usually, the caller will need to supply only one of the above - <tt>ConnectionSource</tt>.
+ For the other items, the default implementations will usually be adequate, and no action is required.
+ */
+ public static void initDatabaseLayer(Map<String, String> 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 <tt>aAbstractionName</tt> into a concrete implementation.
+
+ <P>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.
+
+ <P>Implementation classes accessed by this method must have a <tt>public</tt> no-argument
+ constructor. (This method is best suited for interfaces, and not abstract base classes.)
+
+ <P>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
+ "<tt>hirondelle.web4j.ApplicationInfo</tt>".
+ */
+ 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 <tt>aAbstractBaseClassName</tt> into a concrete implementation.
+
+ <P>Intended for abstract base classes (ABC's) having a <tt>public</tt>
+ 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 <tt>request</tt>
+ and <tt>response</tt> object. (Implementations of that ABC are always expected to
+ take those two particular constructor arguments.)
+
+ <P>If a problem occurs, a {@link RuntimeException} is thrown.
+
+ @param aAbstractBaseClassName package-qualified name of an Abstract Base Class, as in
+ "<tt>hirondelle.web4j.ui.RequestParser</tt>".
+ @param aCtorArguments <tt>List</tt> 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<Object> 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}.
+
+ <P>When testing, an application may call this method in order to use a 'fake'
+ system time.
+
+ <P>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.
+
+ <P>This method allows testing code to configure a specific implementation class.
+ Example: <PRE>BuildImpl.adHocImplementationAdd(TimeSource.class, MyTimeSource.class);</PRE>
+ Calls to this method (often in a JUnit <tt>setUp()</tt> 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.
+
+ <P>This method allows testing code to configure a specific implementation class.
+ Example: <PRE>BuildImpl.adHocImplementationRemove(TimeSource.class);</PRE>
+ Calls to this method (often in a JUnit <tt>tearDown()</tt> 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<String, Class<?>> fClassMapping = new LinkedHashMap<String, Class<?>>();
+
+ 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<String, String> 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<String, String> 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<String, String> 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;
+ }
+}
--- /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.
+
+ <P>Here, 'base object' refers to <tt>Integer</tt>, <tt>Date</tt>,
+ and so on -- the basic building blocks passed to a Model Object constructor.
+
+ <P>Model Objects are defined as public concrete classes having a constructor that throws
+ {@link hirondelle.web4j.model.ModelCtorException}.
+
+ <P>The checks performed by this class are :
+<ul>
+ <li>any <tt>public</tt> <tt>getXXX</tt> methods that return a <tt>String</tt> are identified as
+ Cross-Site Scripting vulnerabilities. Model Objects that return text in general should use {@link SafeText} instead
+ of {@link String}.
+ <li>any constructors taking an argument whose class is not supported according to
+ {@link hirondelle.web4j.model.ConvertParam#isSupported(Class)} are identified as errors.
+</ul>
+
+ <P>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<Class> 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<Class> 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<Class> 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).");
+ }
+ }
+}
--- /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.
+
+ <P>The application can serve content both directly (by simple, direct reference to
+ a JSP's URL), and indirectly, through this <tt>Controller</tt>.
+
+ <P>Like almost all servlets, this class is safe for multi-threaded environments.
+
+ <P>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}.
+
+ <P>Emails are sent to the webmaster when :
+ <ul>
+ <li>an unexpected problem occurs (the email will include extensive diagnostic
+ information, including a stack trace)
+ <li>servlet response times degrade to below a configured level
+ </ul>
+
+ <P>This class is in a distinct package for two reasons :
+ <ul>
+ <li>to make it easier to find, since it is at the very top of the hierarchy
+ <li>to force the <tt>Controller</tt> to use only the public aspects of
+ the <tt>ui</tt> package. This ensures it remains at a high level of abstraction.
+ </ul>
+
+ <P>There are key-names defined in this class (see below). Their names need to be
+ long-winded (<tt>web4j_key_for_...</tt>), 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.
+
+ <P>Value: {@value}.
+ <P>Upon startup, this item is logged at <tt>CONFIG</tt> level. (This item is
+ is simply a hard-coded field in this class. It is not configured in <tt>web.xml</tt>.)
+ */
+ public static final String WEB4J_VERSION = "WEB4J/4.10.0";
+
+ /**
+ Key name for the application's character encoding, placed in application scope
+ as a <tt>String</tt> upon startup. This character encoding (charset) is set
+ as an HTTP header for every reponse.
+
+ <P>Key name: {@value}.
+ <P>Configured in <tt>web.xml</tt>. The value <tt>UTF-8</tt> 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 <tt>String</tt> upon startup.
+
+ <P>Key name: {@value}.
+ <P>Configured in <tt>web.xml</tt>.
+ */
+ public static final String WEBMASTER = "web4j_key_for_webmaster";
+
+ /**
+ Key name for the default {@link Locale}, placed in application scope
+ as a <tt>Locale</tt> upon startup.
+
+ <P>Key name: {@value}.
+ <P>The application programmer is encouraged to use this key for any
+ <tt>Locale</tt> stored in <em>session</em> scope : the <em>default</em> 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 <tt>Locale</tt> as an override to
+ the default one.
+
+ <P>Configured in <tt>web.xml</tt>.
+ */
+ public static final String LOCALE = "web4j_key_for_locale";
+
+ /**
+ Key name for the default {@link TimeZone}, placed in application scope
+ as a <tt>TimeZone</tt> upon startup.
+
+ <P>Key name: {@value}.
+ <P>The application programmer is encouraged to use this key for any
+ <tt>TimeZone</tt> stored in <em>session</em> scope : the <em>default</em> 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 <tt>TimeZone</tt> as an override to
+ the default one.
+
+ <P>Configured in <tt>web.xml</tt>.
+ */
+ 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.
+ <P>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.
+ <P>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 <tt>String</tt>.
+
+ <P>Key name: {@value}.
+ <P>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.
+
+ <P>Operations include :
+ <ul>
+ <li>log version and configuration information
+ <li>distribute configuration information in <tt>web.xml</tt> to the various
+ parts of WEB4J
+ <li>place an {@link ApplicationInfo} object into application scope
+ <li>place the configured character encoding into application scope, for use in JSPs
+ <li>call {@link StartupTasks#startApplication(ServletConfig, String)}, to
+ allow the application to perform its own startup tasks
+ <li>perform various validations
+ </ul>
+
+ <P>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.
+
+ <P>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.
+
+ <P>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<String, String> 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 <tt>XmlHttpRequest</tt>. */
+ @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 <tt>XmlHttpRequest</tt>. */
+ @Override public final void doDelete(HttpServletRequest aRequest, HttpServletResponse aResponse) throws IOException {
+ logClasses(aRequest, aResponse);
+ processRequest(aRequest, aResponse);
+ }
+
+ /**
+ Handle all HTTP requests for <tt>GET</tt>, <tt>POST</tt>, <tt>PUT</tt>, and <tt>DELETE</tt> 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()}.
+
+ <P>This method can be overridden, if desired. The great majority of applications will not need
+ to override this method.
+
+ <P>Operations include :
+ <ul>
+ <li>set the request character encoding (using the value configured in <tt>web.xml</tt>)
+ <li>set the <tt>charset</tt> HTTP header for the response (using the value configured in <tt>web.xml</tt>)
+ <li>react to a successful user login, using the configured implementation of {@link hirondelle.web4j.security.LoginTasks}
+ <li>get an instance of {@link RequestParser}
+ <li>get its {@link Action}, and execute it
+ <li>check for an ownership constraint (see {@link UntrustedProxyForUserId})
+ <li>perform either a forward or a redirect to the Action's {@link hirondelle.web4j.action.ResponsePage}
+ <li>if an unexpected problem occurs, create a {@link TroubleTicket}, log it, and
+ email it to the webmaster email address configured in <tt>web.xml</tt>
+ <li>if the response time exceeds a configured threshold, build a
+ {@link TroubleTicket}, log it, and email it to the webmaster address configured in <tt>web.xml</tt>
+ </ul>
+ */
+ 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}.
+
+ <P>This overridable default implementation does nothing, and returns <tt>null</tt>.
+ If the return value of this method is <tt>null</tt>, then the nominal <tt>ResponsePage</tt>
+ will be used without alteration. If the return value of this method is not <tt>null</tt>,
+ then it will be used to override the nominal <tt>ResponsePage</tt>.
+
+ <P>This method is intended for applications that use different JSPs for different Locales.
+ For example, if the nominal response is a forward to <tt>Blah_en.jsp</tt>, and the "real"
+ response should be <tt>Blah_fr.jsp</tt>, then this method can be overridden to return the
+ appropriate {@link ResponsePage}. <span class="highlight">This method is called only for
+ forward operations. If it is overridden, then its return value must also correspond to a forward
+ operation.</span>
+
+ <P><span class="highlight">This style of implementing translation is not recommended.</span>
+ Instead, please use the services of the <tt>hirondelle.web4j.ui.translate</tt> package.
+ */
+ protected ResponsePage swapResponsePage(ResponsePage aResponsePage, Locale aLocale){
+ return null; //does nothing
+ }
+
+ /**
+ Inform the webmaster of an unexpected problem with the deployed application.
+
+ <P>Typically called when an unexpected <tt>Exception</tt> occurs in
+ {@link #processRequest}. Uses {@link TroubleTicket#mailToRecipients()}.
+
+ <P>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.
+
+ <P>Called only when the response time of a request is above the threshold
+ value configured in <tt>web.xml</tt>.
+
+ <P>Builds a <tt>Throwable</tt> 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<String> fBadDatabases = new LinkedHashSet<String>();
+
+ /** 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.
+//
+// <P>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.
+//
+// <P>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.
+//
+// <P>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.
+//
+// <P>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<String, String> asMap(ServletConfig aConfig){
+ Map<String, String> result = new LinkedHashMap<String, String>();
+ 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<String, String> info = new LinkedHashMap<String, String>();
+ 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<String, String> ctxParams = new LinkedHashMap<String, String>();
+ 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<String, String> initParams = new LinkedHashMap<String, String>();
+ 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.
+
+ <P>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<String, Object> getApplicationScopeObjectsForLogging(HttpServletRequest aRequest){
+ Map<String, Object> result = new LinkedHashMap<String, Object>();
+ 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<String, Object> getSessionScopeObjectsForLogging(HttpServletRequest aRequest){
+ Map<String, Object> result = new LinkedHashMap<String, Object>();
+ 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<String, Object> getRequestParamNamesForLogging(HttpServletRequest aRequest) {
+ Map<String, Object> result = new LinkedHashMap<String, Object>();
+ 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<String> 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<String> 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<String> 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}.
+
+ <P>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.");
+ }
+}
--- /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;
+ }
+}
--- /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.
+
+ <P>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.
+
+ <P>Allows the application programmer to perform any needed initialization tasks.
+
+ <P>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
+ <a href="http://www.javapractices.com/apps/fish/javadoc/src-html/hirondelle/web4j/config/Startup.html">implementation</a>
+ taken from an example application.
+
+ <P>
+ See {@link hirondelle.web4j.database.ConnectionSource#init(java.util.Map)}, for startup
+ tasks related to database connections.
+
+ <P>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.
+
+ <P>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.
+ <P>Possible tasks include:
+ <ul>
+ <li>disseminate values configured in <tt>web.xml</tt>
+ <li>place items into application scope, such as code tables read from the database
+ <li>set the default {@link java.util.TimeZone}
+ </ul>
+
+ <P>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 <tt>Set</tt> returned by that method).
+
+ <P>Example of a typical implementation:
+<pre>
+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
+ }
+}</pre>
+
+ <P>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 <tt>web.xml</tt> - for example, placing code tables in app scope will
+ need access to the <tt>ServletContext</tt>.
+ @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;
+
+}
--- /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 <a href="http://www.junit.org">JUnit</a> tests defined for WEB4J.
+
+ <P>This class is not useful for the application programmer.
+
+ <P>Note that:
+<ul>
+ <li>not all classes have an associated JUnit test.
+ <li>these tests may depend on details regarding the development environment, such as
+ file locations.
+</ul>
+*/
+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;
+ }
+ }
+
+}
--- /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;
+
+/**
+ <span class="highlight">
+ Process an HTTP request, and return a {@link ResponsePage}.
+ </span>
+
+ <P><b>This interface is likely the most important
+ abstraction in WEB4J.</b> Almost every feature implemented by the programmer will
+ need an implementation of this interface.
+
+ <P>Typically, one of the <em>ActionXXX</em> abstract base classes are used to
+ build implementations of this interface.
+*/
+public interface Action {
+
+ /**
+ Execute desired operation.
+
+ <P>Typical operations include :
+ <ul>
+ <li>validate user input
+ <li>interact with the database
+ <li>place required objects in the appropriate scope
+ <li>set the appropriate {@link ResponsePage}.
+ </ul>
+
+ <P>Returns an identifier for the resource (for example a JSP) which
+ will display the end result of this <tt>Action</tt> (using either a
+ forward or a redirect).
+ */
+ ResponsePage execute() throws AppException;
+
+}
--- /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.
+
+ <P>This ABC provides concise methods for common operations, which will make
+ implementations read more clearly, concisely, and at a higher level of abstraction.
+
+ <P>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}.
+
+ <P>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 <em>redirect</em> 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.
+
+<P><em>This class assumes that a session already exists</em>.
+ 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
+ <PRE>getRequestParser.getRequest().getSession(true);</PRE>
+*/
+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.
+
+ <P>Many applications will benefit from having <i>both</i> the user id <i>and</i> the user login name
+ placed in session scope upon login. The Servlet Container will place the user <i>login name</i>
+ in session scope upon login, but it will not place the corresponding <i>user id</i>
+ (the database's primary key of the user record) in session scope.
+
+ <P>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.
+ <P>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.
+ <P>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.
+ <P>Not mandatory to use this generic key. Provided simply as a convenience.
+ */
+ public static final String DATA = "data";
+
+ /**
+ Constructor.
+
+ <P>This constructor will add an attribute named <tt>'Operation'</tt> 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 <tt>Operation</tt> 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 <tt>Action</tt>, if any.
+
+ <P>The <tt>Operation</tt> is found as follows :
+ <ol>
+ <li>if there is a request parameter named <tt>'Operation'</tt>, and it has a value, pass its value to
+ {@link Operation#valueFor(String)}
+ <li>if the above style fails, then the 'extension' is examined. For example, a request to <tt>.../MyAction.list?x=1</tt>
+ would result in {@link Operation#List} being added to request scope, since the extension value <tt>list</tt> is known to
+ {@link Operation#valueFor(String)}.
+ This style is useful for implementing fine-grained <tt><security-constraint></tt>
+ items in <tt>web.xml</tt>. See the User Guide for more information.
+ <li>if both of the above methods fail, return <tt>null</tt>
+ </ol>
+
+ <P>When using the 'extension' style, please note that <tt>web.xml</tt> contains related <tt>servlet-mapping</tt> settings.
+ Such settings control which HTTP requests (as defined by a <tt>url-pattern</tt>) are passed from the Servlet Container to
+ your application in the first place. Thus, <b>any 'extensions' which your application intends to use must have a corresponding
+ <tt>servlet-mapping</tt> setting in your <tt>web.xml</tt></b>.
+ */
+ protected final Operation getOperation(){
+ return fOperation;
+ }
+
+ /**
+ Return the name of the logged in user.
+
+ <P>By definition in the servlet specification, a successfully logged in user
+ will always have a non-<tt>null</tt> return value for
+ {@link javax.servlet.http.HttpServletRequest#getUserPrincipal()}.
+
+ <P>If the user is not logged in, this method will always return <tt>null</tt>.
+
+ <P>This method returns {@link SafeText}, not a <tt>String</tt>.
+ 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
+ <tt>SafeText</tt>.
+ */
+ 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 <tt>null</tt>.
+
+ <P><style class='highlight'>This internal database identifier should never be served to the client, since that
+ would be a grave security risk.</style> 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.
+
+ <P>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.
+
+ <P>If the pair already exists, it is <em>updated</em> with <tt>aObject</tt>.
+
+ @param aName satisfies {@link hirondelle.web4j.util.Util#textHasContent(String)}.
+ @param aObject if <tt>null</tt> and a corresponding name-object pair exists, then
+ the pair is <em>removed</em> from request scope.
+ */
+ protected final void addToRequest(String aName, Object aObject){
+ Args.checkForContent(aName);
+ fRequestParser.getRequest().setAttribute(aName, aObject);
+ }
+
+ /**
+ Return the existing session.
+ <P>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.
+
+ <P>If the pair already exists, it is <em>updated</em> with <tt>aObject</tt>.
+
+ @param aName satisfies {@link hirondelle.web4j.util.Util#textHasContent(String)}.
+ @param aObject if <tt>null</tt> and a corresponding name-object pair exists, then
+ the pair is <em>removed</em> from session scope.
+ */
+ protected final void addToSession(String aName, Object aObject){
+ Args.checkForContent(aName);
+ getExistingSession().setAttribute(aName, aObject);
+ }
+
+ /** Synonym for <tt>addToSession(aName, null)</tt>. */
+ protected final void removeFromSession(String aName){
+ addToSession(aName, null);
+ }
+
+ /**
+ Retrieve an object from an existing session, or <tt>null</tt> if no
+ object is paired with <tt>aName</tt>.
+
+ @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.
+
+ <P>When serving the last page in a session, some session
+ items may still be needed for rendering the final page.
+
+ <P>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 <tt>Object</tt> 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 <tt>null</tt> 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.
+ <P>One of the <tt>addError</tt> 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.
+ <P>One of the <tt>addError</tt> 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 <tt>aEx</tt>.
+ <P>One of the <tt>addError</tt> 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 <tt>addError</tt> methods.
+ */
+ protected final MessageList getErrors(){
+ return fErrors;
+ }
+
+ /**
+ Return <tt>true</tt> only if at least one <tt>addError</tt> 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 <tt>addMessage</tt> methods
+ */
+ protected final MessageList getMessages(){
+ return fMessages;
+ }
+
+ /**
+ Return the {@link Locale} associated with the underlying request.
+
+ <P>The configured implementation of {@link LocaleSource} defines how
+ <tt>Locale</tt> is looked up.
+ */
+ protected final Locale getLocale(){
+ return fLocale;
+ }
+
+ /**
+ Return the {@link TimeZone} associated with the underlying request.
+
+ <P>The configured implementation of {@link TimeZoneSource} defines how
+ <tt>TimeZone</tt> 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 <tt>Id</tt>.
+
+ <P>Synonym for <tt>getRequestParser().toId(RequestParameter)</tt>.
+ */
+ protected final Id getIdParam(RequestParameter aReqParam){
+ return fRequestParser.toId(aReqParam);
+ }
+
+ /**
+ Convenience method for retrieving a multivalued parameter as a simple {@code Collection<Id>}.
+
+ <P>Synonym for <tt>getRequestParser().toIds(RequestParameter)</tt>.
+ */
+ protected final Collection<Id> getIdParams(RequestParameter aReqParam){
+ return fRequestParser.toIds(aReqParam);
+ }
+
+ /**
+ Convenience method for retrieving a parameter as {@link SafeText}.
+
+ <P>Synonym for <tt>getRequestParser().toSafeText(RequestParameter)</tt>.
+ */
+ protected final SafeText getParam(RequestParameter aReqParam){
+ return fRequestParser.toSafeText(aReqParam);
+ }
+
+ /**
+ Convenience method for retrieving a parameter as raw text, with no escaped
+ characters.
+
+ <P>This method call is unsafe in the sense that it returns <tt>String</tt>
+ instead of {@link SafeText}. It is usually preferable to use {@link SafeText},
+ since it protects against Cross-Site Scripting attacks.
+
+ <P>If, however, the caller needs to use a request parameter
+ value <em>to perform a computation</em>, 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 <tt>ORDER BY</tt> clause for an SQL statement.
+
+ <P>Provided as a convenience for the common task of creating an
+ <tt>ORDER BY</tt> clause from request parameters.
+
+ @param aSortColumn carries a <tt>ResultSet</tt> column identifer, either a
+ numeric column index, or the name of the column itself.
+ @param aOrder carries the value <tt>ASC</tt> or <tt>DESC</tt> (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) <b>outside of the usual user login</b>,
+ and add a CSRF token to the new session to defend against Cross-Site Request Forgery (CSRF) attacks.
+
+ <P>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.
+
+ <P><b>Warning:</b> you can only call this method in Actions for which the
+ {@link hirondelle.web4j.security.SuppressUnwantedSessions} filter is <i>NOT</i> in effect.
+
+ <P><b>Warning:</b> 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 <i>new</i>
+ session id. (See <a href='http://www.owasp.org/'>OWASP</a> for more information.)
+ The problem is that Tomcat 5 and 6 do <i>not</i> follow this rule, and will retain any existing
+ session id when the user logs in.
+
+ <P><b>This method is needed only when the user has not yet logged in.</b>
+ An excellent example of operations <i>not</i> requiring a login are operations that deal with
+ account management on a typical public web site :
+ <ul>
+ <li>registering users
+ <li>regaining lost passwords
+ </ul>
+
+ <P>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 <i>redirect</i> 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 <tt>final</tt> 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 <tt>final</tt>, except
+ for the <tt>abstract</tt> 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 <tt>null</tt>.
+ */
+ 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());
+ }
+ }
+}
--- /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;
+
+/**
+ <b>Template</b> for "all-in-one" {@link hirondelle.web4j.action.Action}s, which perform
+ common operations on a Model Object.
+
+ <P>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 :
+ <ul>
+ <li>the number of items in the listing is not excessively large.
+ <li>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.)
+ </ul>
+
+ <P>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.
+
+ <P>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}.
+
+ <P>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}).
+
+ <P><span class="highlight">To communicate messages to the end user, the implementation
+ must use the various <tt>addMessage</tt> and <tt>addError</tt> methods</span>.
+*/
+public abstract class ActionTemplateListAndEdit extends ActionImpl {
+
+ /**
+ Constructor.
+
+ @param aForward used for {@link Operation#List} and {@link Operation#FetchForChange}
+ operations, and also for <em>failed</em> {@link Operation#Add}, {@link Operation#Change},
+ and {@link Operation#Delete} operations. This is the default response.
+ @param aRedirect used for <em>successful</em> {@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.
+
+ <P>This action supports :
+ <ul>
+ <li> {@link Operation#List}
+ <li> {@link Operation#Add}
+ <li> {@link Operation#FetchForChange}
+ <li> {@link Operation#Change}
+ <li> {@link Operation#Delete}
+ </ul>
+
+ The source of the <tt>Operation</tt> is described by {@link ActionImpl#getOperation()}.
+ */
+ public static final RequestParameter SupportedOperation = RequestParameter.withRegexCheck(
+ "Operation", "(" +
+ Operation.List + "|" + Operation.Add + "|" + Operation.FetchForChange + "|" +
+ Operation.Change + "|" + Operation.Delete +
+ ")"
+ );
+
+ /**
+ <b>Template</b> method.
+
+ <P>In order to clearly understand the operation of this method, here is the
+ core of its implementation, with all abstract methods in <em>italics</em> :
+ <PRE>
+ if (Operation.List == getOperation() ){
+ <em>doList();</em>
+ }
+ else if (Operation.FetchForChange == getOperation()){
+ <em>attemptFetchForChange();</em>
+ }
+ else if (Operation.Add == getOperation()) {
+ <em>validateUserInput();</em>
+ if ( ! hasErrors() ){
+ <em>attemptAdd();</em>
+ ifNoErrorsRedirectToListing();
+ }
+ }
+ else if (Operation.Change == getOperation()) {
+ <em>validateUserInput();</em>
+ if ( ! hasErrors() ){
+ <em>attemptChange();</em>
+ ifNoErrorsRedirectToListing();
+ }
+ }
+ else if(Operation.Delete == getOperation()) {
+ <em>attemptDelete();</em>
+ ifNoErrorsRedirectToListing();
+ }
+ //Fresh listing WITHOUT a redirect is required if there is an error,
+ //and for successful FetchForChange operations.
+ if( hasErrors() || Operation.FetchForChange == getOperation() ){
+ <em>doList();</em>
+ }
+ </PRE>
+ */
+ @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.
+
+ <P>Applied to {@link Operation#Add} and {@link Operation#Change}. If an error occurs, then
+ <tt>addError</tt> must be called.
+
+ <P>Example of a typical implementation :
+ <PRE>
+ 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);
+ }
+ }
+ </PRE>
+
+ <P>Note that the Model Object constructed in this example (<tt>fResto</tt>) 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 (<tt>SELECT</tt> operation).
+ */
+ protected abstract void doList() throws DAOException;
+
+ /**
+ Attempt an <tt>INSERT</tt> 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 (<tt>SELECT</tt> operation).
+ */
+ protected abstract void attemptFetchForChange() throws DAOException;
+
+ /**
+ Attempt an <tt>UPDATE</tt> operation on the database. The data will first be validated
+ using {@link #validateUserInput()}.
+ */
+ protected abstract void attemptChange() throws DAOException;
+
+ /**
+ Attempt a <tt>DELETE</tt> operation on the database.
+ */
+ protected abstract void attemptDelete() throws DAOException;
+
+ /**
+ Add a dynamic query parameter to the redirect {@link ResponsePage}.
+
+ <P>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);
+ }
+ }
+}
--- /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;
+
+/**
+ <b>Template</b> for search screens.
+
+ <P>Here, a search action has the following :
+ <ul>
+ <li>it uses a form that allows the user to input search criteria
+ <li>the form must have <tt>GET</tt> as its method, not <tt>POST</tt>
+ <li>the underlying database operation is a <tt>SELECT</tt>, and does not edit the database in any way
+ </ul>
+
+ <P>Search operations never require a redirect operation (since they do not edit the database).
+
+ <P>Search operations have an interesting property : if you build a Model Object to validate and represent
+ user input into the search form, then its <tt>getXXX</tt> methods can usually be made package-private, instead
+ of <tt>public</tt>. The reason is that such Model Objects are usually not used by JSPs directly.
+ If desired, such methods can safely return <tt>String</tt> 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 <tt>public</tt> 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.
+
+ <P>The supported operations are :
+ <ul>
+ <li> {@link Operation#Show}
+ <li> {@link Operation#Search}
+ </ul>
+
+ The source of the <tt>Operation</tt> is described by {@link ActionImpl#getOperation()}.
+ */
+ public static final RequestParameter SUPPORTED_OPERATION = RequestParameter.withRegexCheck(
+ "Operation", Pattern.compile("(" + Operation.Show + "|" + Operation.Search + ")")
+ );
+
+ /**
+ <b>Template</b> method.
+
+ <P>In order to clearly understand the operation of this method, here is the
+ core of its implementation, with all abstract methods in <em>italics</em> :
+ <PRE>
+ if (Operation.Show == getOperation() ){
+ //default forward
+ }
+ else if ( Operation.Search == getOperation() ){
+ <em>validateUserInput();</em>
+ if( ! hasErrors() ){
+ <em>listSearchResults();</em>
+ if ( ! hasErrors() ){
+ fLogger.fine("List executed successfully.");
+ }
+ }
+ }
+ </PRE>
+ */
+ @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.
+
+ <P>The form is used to define the criteria for the search (if any).
+
+ <P>Applies only for {@link Operation#Search}. If an error occurs, then
+ <tt>addError</tt> 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);
+}
--- /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;
+
+/**
+ <b>Template</b> for "first show, then validate and apply" groups of operations.
+
+ <P>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.
+
+ <P>There are two operations in such cases :
+ <ul>
+ <li>show me the form with the current data (if any)
+ <li>allow me to post my (validated) changes
+ </ul>
+*/
+public abstract class ActionTemplateShowAndApply extends ActionImpl {
+
+ /**
+ Constructor.
+
+ @param aForward used for {@link Operation#Show} operations, and also for <em>failed</em>
+ {@link Operation#Apply} operations. This is the default response.
+ @param aRedirect used for <em>successful</em> {@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.
+
+ <P>The supported operations are :
+ <ul>
+ <li> {@link Operation#Show}
+ <li> {@link Operation#Apply}
+ </ul>
+
+ The source of the <tt>Operation</tt> is described by {@link ActionImpl#getOperation()}.
+ */
+ public static final RequestParameter SUPPORTED_OPERATION = RequestParameter.withRegexCheck(
+ "Operation", Pattern.compile("(" + Operation.Show + "|" + Operation.Apply + ")")
+ );
+
+ /**
+ <b>Template</b> method.
+
+ <P>In order to clearly understand the operation of this method, here is the
+ core of its implementation, with all abstract methods in <em>italics</em> :
+ <PRE>
+ if ( Operation.Show == getOperation() ){
+ <em>show();</em>
+ }
+ else if ( Operation.Apply == getOperation() ){
+ <em>validateUserInput();</em>
+ if( ! hasErrors() ){
+ <em>apply();</em>
+ if ( ! hasErrors() ){
+ setResponsePage(fRedirect);
+ }
+ }
+ }
+ </PRE>
+ */
+ @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.
+
+ <P>The form may or may not be populated.
+ */
+ protected abstract void show() throws AppException;
+
+ /**
+ Validate items input by the user into a form.
+
+ <P>Applied to {@link Operation#Apply}.
+ If an error occurs, then <tt>addError</tt> 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 <tt>addError</tt> must be called.
+ */
+ protected abstract void apply() throws AppException;
+
+ // PRIVATE //
+ private ResponsePage fRedirect;
+ private static final Logger fLogger = Util.getLogger(ActionTemplateShowAndApply.class);
+}
--- /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.
+
+ <P>This <a href="http://www.javapractices.com/Topic1.cjp">type-safe enumeration</a>
+ 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
+ <tt>true</tt> only for specified items.
+
+ <P>Many {@link Action} implementations can benefit from using a request parameter named
+ <tt>'Operation'</tt>, whose value corresponds to a specific <em>subset</em> 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.
+
+ <P>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.
+ <P>
+ Define an <tt>Operation</tt> {@link hirondelle.web4j.request.RequestParameter} in the {@link Action} using :
+<PRE>
+ {@code
+public static final RequestParameter SUPPORTED_OP = RequestParameter.withRegexCheck(
+ "Operation",
+ Pattern.compile("(" + Operation.Show + "|" + Operation.Search + ")")
+ );
+ }
+</PRE>
+ Set the value of a corresponding <tt>Operation</tt> field in the {@link Action} constructor using :
+<PRE>
+ {@code
+fOperation = Operation.valueOf(aRequestParser.toString(SUPPORTED_OP));
+ }
+</PRE>
+
+ <P>Your {@link Action#execute} method will then branch according to the value of
+ the <tt>fOperation</tt> field.
+
+ <P><em>Note regarding forms submitted by hitting the Enter key.</em><br>
+ 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 <tt>Operation</tt> in a
+ <tt>HIDDEN</tt> form item, to ensure that it is always submitted, regardless of
+ the submission mechanism.
+
+ <P>See as well this
+ <a href="http://www.javapractices.com/Topic203.cjp">discussion</a> of <tt>Submit</tt>
+ 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.
+
+ <P>The reverse of {@link #Inactivate}
+ */
+ Activate,
+
+ /**
+ Inactivate an item.
+
+ <P>Often used to implement an <em>abstract</em> <tt>delete</tt> 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.
+
+ <P>The reverse of {@link #Stop}.
+ */
+ Start,
+
+ /**
+ Stop some process.
+
+ <P>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 <tt>true</tt> only if this <tt>Operation</tt> 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}.
+
+ <P>Intended to identify actions which very likely require
+ <a href="http://www.javapractices.com/Topic181.cjp">a redirect instead of a forward</a>.
+ */
+ public boolean isDatastoreEdit(){
+ return (
+ this == Add || this == Change || this == Delete || this == DeleteAll ||
+ this == Save || this == Apply || this == Inactivate || this == Activate
+ );
+ }
+
+ /**
+ Returns <tt>true</tt> only if this <tt>Operation</tt> <tt>isDataStoreEdit()</tt>,
+ or is {@link #Start} or {@link #Stop}.
+
+ <P>Intended to identify cases that need a <tt>POST</tt> request.
+ */
+ public boolean hasSideEffects(){
+ return isDatastoreEdit() || this == Start || this == Stop;
+ }
+
+ /**
+ Parse a parameter value into an <tt>Operation</tt> (not case-sensitive).
+
+ <P>Similar to {@link #valueOf}, but not case-sensitive, and has alternate behavior when a problem is found.
+ If <tt>aOperation</tt> has no content, or has an unknown value, then a message
+ is logged at <tt>SEVERE</tt> level, and <tt>null</tt> 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);
+}
--- /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;
+
+/**
+ <span class="highlight">Response page served to the user at the end of an {@link Action}.</span>
+
+ <P>Identifies the page as a resource. Does not include its content, but is rather
+ a <em>reference</em> to the page.
+
+ <P>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:
+<ul>
+ <li>{@link #ResponsePage(String)} defaults to a <em>redirect</em>
+ <li>all other constructors default to a <em>forward</em>
+</ul>
+
+ <P>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.
+
+ <P>See the <a href="http://www.javapractices.com/Topic181.cjp">forward-versus-redirect</a>
+ topic on javapractices.com for more information.
+*/
+public final class ResponsePage {
+
+ /**
+ Constructor which uses the WEB4J template mechanism.
+
+ @param aTitle text of the <tt><TITLE></tt> tag to be presented in the
+ template page ; appended to <tt>aTemplateURL</tt> as a query parameter
+ @param aBodyJsp body of the templated page, where most of the content of interest
+ lies ; appended to <tt>aTemplateURL</tt> as a query parameter
+ @param aTemplateURL identifies the templated page which dynamically includes
+ <tt>aBodyJsp</tt> 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.
+ <P>As in {@link #ResponsePage(String, String, String, Class)},
+ but with the template URL taking the conventional value of <tt>'../Template.jsp'</tt>.
+ */
+ public ResponsePage(String aTitle, String aBodyJsp, Class aRepresentativeClass) {
+ this(aTitle, aBodyJsp, "../Template.jsp", aRepresentativeClass);
+ }
+
+ /**
+ Constructor which uses the WEB4J template mechanism.
+
+ <P><span class="highlight">
+ This constructor allows for an unusual but useful policy : placing JSPs in the
+ same directory as related code, under <tt>WEB-INF/classes</tt>, instead
+ of under the document root of the application.</span>
+ 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 <tt>.sql</tt> files.
+ This is the recommended style. It allows
+ <a href="http://www.javapractices.com/Topic205.cjp">package-by-feature</a>.
+
+ @param aTitle text of the <tt><TITLE></tt> tag to be presented in the
+ template page ; appended to <tt>aTemplateURL</tt> as a query parameter
+ @param aBodyJsp body of the templated page, where most of the content of interest
+ lies ; appended to <tt>aTemplateURL</tt> as a query parameter
+ @param aTemplateURL identifies the templated page which dynamically includes
+ <tt>aBodyJsp</tt> in its content
+ @param aClass class literal of any java class related to the given feature; the
+ <em>package</em> of this class will be used to construct the 'real' path to <tt>aBodyJsp</tt>,
+ as in '<tt>WEB-INF/classes/<package-as-directory-path>/<aBodyJsp></tt>'. 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.
+
+ <P>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.
+
+ <P><tt>aURL</tt> identifies the resource which will be used by an {@link Action}
+ as its destination page.
+ Example values for <tt>aURL</tt> :
+ <ul>
+ <li><tt>ViewAccount.do</tt> (suitable for a redirect)
+ <li><tt>/ProblemHasBeenLogged.html</tt> (suitable for a forward to an item
+ which is <em>not</em> templated)
+ </ul>
+
+ <P>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 <tt>/WEB-INF/</tt>.
+
+ <P><span class="highlight">
+ This constructor allows for an unusual but useful policy : placing JSPs in the
+ same directory as related code, under <tt>WEB-INF/classes</tt>, instead
+ of under the document root of the application.</span>
+ 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 <tt>.sql</tt> files.
+ This is the recommended style. It allows
+ <a href="http://www.javapractices.com/Topic205.cjp">package-by-feature</a>.
+
+ <P>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 <em>package</em> of this class will be used to construct the 'real' path to the JSP,
+ as in <PRE>WEB-INF/classes/<package-as-directory-path>/<aJsp></PRE>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 <tt>.pdf</tt> format.
+
+ <P>With such <tt>ResponsePage</tt>s, the normal templating mechanism is not
+ available (since it is based on text), and no forward/redirect is performed by the
+ <tt>Controller</tt>. In essence, the <tt>Action</tt> becomes entirely responsible
+ for generating the response.
+
+ <P>When serving a binary response, your <tt>Action</tt> will typically :
+ <ul>
+ <li>set the <tt>content-type</tt> response header.
+ <li>generate the output stream containing the desired binary content.
+ <li>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.
+ </ul>
+ */
+ public static ResponsePage withBinaryData(){
+ return new ResponsePage();
+ }
+
+ /** Return <tt>true</tt> 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 <tt>ResponsePage</tt>, having the specified forward/redirect behavior.
+ See class comment.
+
+ <P>This method returns a new <tt>ResponsePage</tt> having the updated URL.
+ (This will ensure that <tt>ResponsePage</tt> objects are immutable.)
+ */
+ public ResponsePage setIsRedirect(Boolean aValue){
+ return new ResponsePage(fURL, aValue);
+ }
+
+ /**
+ Append a query parameter to the URL of a <tt>ResponsePage</tt>.
+
+ <P>This method returns a new <tt>ResponsePage</tt> having the updated URL.
+ (This will ensure that <tt>ResponsePage</tt> objects are immutable.)
+ <P>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};
+ }
+}
--- /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 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+ <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+ <meta name="Author" content="Hirondelle System">
+ <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+ <title>User Interface</title>
+</head>
+<body>
+Execute the desired operation.
+
+<P>In general, <tt>Action</tt> 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 <tt>Action</tt> typically
+references all of these kinds of items, in some way.
+
+<P>{@link hirondelle.web4j.action.ActionImpl} is a base implementation of <tt>Action</tt>,
+and has a number of methods for common operations. It in turn has several
+template subclasses (<em>ActionTemplateXXX</em>), corresponding to specific combinations of operations.
+
+<P>An important point to understand is the separation of validation into two distinct parts -
+<em>hard validation</em>, and <em>soft validation</em> - see {@link hirondelle.web4j.security.ApplicationFirewall}
+for more information.
+
+<P>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.
+
+<P>The main tool for building Model Objects out of submitted forms is {@link hirondelle.web4j.model.ModelFromRequest}.
+</body>
+</html>
--- /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.
+
+ <P>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.
+
+ <P>Here is an example
+ <a href="http://www.javapractices.com/apps/fish/javadoc/src-html/hirondelle/web4j/config/ConnectionSrc.html">implementation</a>,
+ taken from the WEB4J example application.
+
+ <P>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 :
+ <ul>
+ <li>use a connection pool configured in the server environment (the style
+ used in the example application)
+ <li>create a {@link Connection} directly, using the database driver. (Some modern
+ drivers have connection pooling built directly into the driver.)
+ <li>use some other means
+ </ul>
+
+ <P><span class="highlight">The design of this interface is slightly skewed towards applications
+ that use more than one database.</span>
+ 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.
+
+ <P>If no database is used at all, then {@link #getDatabaseNames()} returns an emtpy <tt>Set</tt>.
+
+ <P>See {@link SqlId} for related information.
+*/
+public interface ConnectionSource {
+
+ /**
+ Read in any necessary configuration parameters.
+
+ <P>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 <tt>Map</tt> parameter.
+
+ <P>In the context of a web application, the given <tt>Map</tt> is populated
+ with the servlet's <tt>init-param</tt> settings in <tt>web.xml</tt>.
+ */
+ void init(Map<String, String> aConfig);
+
+ /**
+ Return the database names accepted by {@link #getConnection(String)}.
+
+ <P>Return a <tt>Set</tt> 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 <tt>Set</tt>.
+ */
+ Set<String> getDatabaseNames();
+
+ /**
+ Return a {@link Connection} to the <em>default</em> database.
+
+ <P>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 <tt>.sql</tt> 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.
+
+ <P>Implementations must translate any exceptions into a {@link DAOException}.
+ Examples of exceptions that may need translation are <tt>SQLException</tt>
+ and <tt>javax.naming.NamingException</tt>.
+ */
+ Connection getConnection() throws DAOException;
+
+ /**
+ Return a {@link Connection} for a specified database (default or non-default).
+
+ <P>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).
+
+ <P>See {@link SqlId} for related information.
+
+ <span class="highlight">Here, <tt>aDatabaseName</tt> is a prefix used
+ in <tt>.sql</tt> files as a qualifer to a SQL statement identifier.</span>
+ For example, in an <tt>.sql</tt> file, the SQL statement identifier:
+ <ul>
+ <li><tt>LIST_MEMBERS</tt> refers to the default database (no qualifier)
+ <li><tt>TRANSLATION.LOCALE_LIST</tt> refers to the '<tt>TRANSLATION</tt>' database.
+ <em>It is this string </em>- '<tt>TRANSLATION</tt>' - <em>which is passed to this method</em>.
+ </ul>
+
+ <P>The values taken by <tt>aDatabaseName</tt> 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 <tt>.sql</tt> file(s).
+ Typically, an implementation of this interface will internally map these database
+ identifiers into specific connection strings, such as
+ '<tt>java:comp/env/jdbc/blah</tt>' (or whatever is required).
+
+ <P>Implementations must translate any exceptions into a {@link DAOException}.
+ Examples of exceptions that may need translation are <tt>SQLException</tt>
+ and <tt>javax.naming.NamingException</tt>.
+ */
+ Connection getConnection(String aDatabaseName) throws DAOException;
+}
--- /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 <tt>ResultSet</tt> column values into common 'building block' objects.
+ <P>
+ Here, a <em>building block</em> class is one of the 'base' objects from which Model
+ Objects can in turn be built - <tt>Integer</tt>, <tt>BigDecimal</tt>, <tt>Date</tt>,
+ and so on.
+
+ <P>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.
+
+ <P>{@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 <tt>ResultSet</tt> into a possibly-null 'building block' object.
+
+ <P>A building block object is like <tt>Integer</tt>, <tt>BigDecimal</tt>, <tt>Date</tt>, and so on.
+
+ <P>It is required that implementations use
+ {@link hirondelle.web4j.model.ConvertParam#isSupported(Class)} to
+ verify that <tt>aSupportedTargetType</tt> 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 <tt>ResultSet</tt>.
+ @param aColumnIdx specific column in <tt>aRow</tt>.
+ @param aSupportedTargetType class of the desired return value. Implementations are not required to
+ support all possible target classes.
+ */
+ public <T> T convert(ResultSet aRow, int aColumnIdx, Class<T> aSupportedTargetType) throws SQLException;
+
+}
--- /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.
+
+ <P>This class converts non-<tt>null</tt> items using :
+ <table border='1' cellspacing='0' cellpadding='3'>
+ <tr><th>Target Class</th><th>Use</th></tr>
+ <tr><td><tt> SafeText </tt></td><td><tt> ResultSet.getString(), or ResultSet.getClob() </tt></td></tr>
+ <tr><td><tt> String (if allowed) </tt></td><td><tt> ResultSet.getString(), or ResultSet.getClob() </tt></td></tr>
+ <tr><td><tt> Integer </tt></td><td><tt>ResultSet.getInt()</tt></td></tr>
+ <tr><td><tt> Long </tt></td><td><tt> ResultSet.getLong() </tt></td></tr>
+ <tr><td><tt> Boolean </tt></td><td><tt> ResultSet.getBoolean() </tt></td></tr>
+ <tr><td><tt> BigDecimal </tt></td><td><tt> ResultSet.getBigDecimal() </tt></td></tr>
+ <tr><td><tt> Decimal</tt></td><td><tt> ResultSet.getBigDecimal()</tt></td></tr>
+ <tr><td><tt> Id </tt></td><td><tt> ResultSet.getString(), new Id(String)</tt></td></tr>
+ <tr><td><tt> DateTime </tt></td><td><tt> ResultSet.getString()</tt>, pass to {@link DateTime#DateTime(String)}</td></tr>
+ <tr><td><tt> Date </tt></td><td><tt> ResultSet.getTimestamp()</tt>, possibly with hint provided in <tt>web.xml</tt></td></tr>
+ <tr><td><tt> Locale </tt></td><td><tt> ResultSet.getString(), {@link hirondelle.web4j.util.Util#buildLocale(String)}</tt></td></tr>
+ <tr><td><tt> TimeZone </tt></td><td><tt> ResultSet.getString(), {@link hirondelle.web4j.util.Util#buildTimeZone(String)}</tt></td></tr>
+ <tr><td><tt> InputStream</tt></td><td><tt> ResultSet.getBinaryStream()</tt></td></tr>
+ </table>
+
+ <P>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 <tt>String</tt>.
+*/
+public class ConvertColumnImpl implements ConvertColumn {
+
+ /**
+ Defines policies for converting a column of a <tt>ResultSet</tt> into a possibly-null
+ <tt>Object</tt>.
+
+ @param aRow of a <tt>ResultSet</tt>
+ @param aColumnIdx particular column of aRow
+ @param aSupportedTargetType is a class supported by the configured implementation of
+ {@link ConvertParam#isSupported(Class)}.
+ */
+ public <T> T convert(ResultSet aRow, int aColumnIdx, Class<T> 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);
+ }
+}
--- /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.
+
+ <P>This class is an example of
+ <a href="http://www.javapractices.com/Topic77.cjp">Data Exception Wrapping</a>,
+ and hides the various exceptions which arise from the various flavors
+ of datastore implementation, such as <tt>SQLException</tt>,
+ <tt>IOException</tt>, and <tt>BackingStoreException</tt>.
+
+ <P>Thrown when a low-level, unusual problem is encountered with the data store.
+ Examples of such a problem might include :
+ <ul>
+ <li>faulty db connection
+ <li>failed file input-output
+ <li>inaccesible network connection
+</ul>
+*/
+public class DAOException extends AppException {
+
+ /**
+ Constructor.
+
+ <P>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);
+ }
+}
--- /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;
+
+/**
+ <span class="highlight">Utility class for the most common database tasks.</span>
+
+ <P>This class allows many DAO methods to be implemented in one or two lines
+ of simple code.
+
+<h3><a name="Parameters"></a>SQL Parameters</h3>
+ SQL statement parameters are passed to this class using an <tt>Object...</tt>
+ sequence parameter. The objects in these arrays must be one of the classes
+ supported by {@link hirondelle.web4j.database.ConvertColumn}.
+
+ <span class="highlight">The number and order of these parameter objects must match
+ the number and order of the '<tt>?</tt>' parameters in the underlying SQL
+ statement</span>.
+
+ <P>For <tt>Id</tt> 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}.
+
+ <P><tt>Locale</tt> and <tt>TimeZone</tt> 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:
+ <ul>
+ <li>just place the <tt>Locale</tt> and <tt>TimeZone</tt> 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.
+ <li>define code tables for <tt>Locale</tt> and <tt>TimeZone</tt>, to define the accepted values, and link the user preferences
+ table to them, using a foreign key.
+ </ul>
+
+ <P>The second form is usually more robust, since it's normalized. However, when it is used, passing <tt>Locale</tt> and
+ <tt>TimeZone</tt> objects directly to an <tt>INSERT</tt> 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 <tt>Db</tt> supports <tt>Locale</tt> and <tt>TimeZone</tt>
+ becomes irrelevant, since you will translate them into an <tt>Id</tt> anyway.
+
+<h3><a name="ConvertColumn"></a>Parsing Columns Into Objects</h3>
+ For operations involving a <tt>ResultSet</tt>, this class will always use the application's
+ {@link ConvertColumn} implementation to convert columns into various building block objects.
+
+ <P>In addition, it uses an ordering convention to map <tt>ResultSet</tt> columns to Model
+ Object constructor arguments. See the package overview for more information on this important
+ point.
+
+ <h3><a name="CompoundObjects"></a>Compound Objects</h3>
+ 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 <em>Compound Objects</em>.
+
+ <P>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.
+
+ <P><em>For the simplest cases</em>, this can be performed conveniently in a single step, using
+ the <tt>fetchCompound</tt> and <tt>listCompound</tt> methods of this class. These
+ methods process a <tt>ResultSet</tt> in a fundamentally different way : instead of translating a
+ <em>single</em> row into a single Model Object, they can translate <em>groups</em> of 1..N rows into
+ a single Model Object instead.
+
+ <P>Here is an illustration. The target Model Object constructor has the form (for example):
+ <PRE>
+ public UserRole (String aUserName, List<Id> aRoles) {
+ ...
+ }
+ </PRE>
+ <span class="highlight">That is, the constructor takes a <em>single</em> {@link List} of Model Objects at
+ the <em>end</em> of its list of arguments.</span> Here, a <tt>List</tt> of {@link Id} objects appears at the end.
+ The <tt>List</tt> can be a <tt>List</tt> of Model Objects, or a <tt>List</tt> of Base Objects supported by
+ {@link hirondelle.web4j.model.ConvertParam}.
+
+ <P><span class="highlight">The underlying SELECT statement returns data across a <tt>0..N</tt> relation, with data
+ in the first N columns repeating the parent data, and with the remaining M columns containing the child data</span>.
+ For example:
+ <PRE>
+ SELECT Name, Role FROM UserRole ORDER BY Role
+ </PRE>
+ which has a <tt>ResultSet</tt> of the form :
+ <table border=1 cellpadding=3 cellspacing=1>
+ <tr><th>Name</th><th>Role</th></tr>
+ <tr><td>kenarnold</td><td>access-control</td></tr>
+ <tr><td>kenarnold</td><td>user-general</td></tr>
+ <tr><td>kenarnold</td><td>user-president</td></tr>
+ <tr><td>davidholmes</td><td>user-general</td></tr>
+ </table>
+
+ <P>That is, the repeated parent data (Name) comes first and is attached to the parent, while the
+ child data (Role) <em>appears only in the final columns</em>. <span class="highlight">In addition, changes to the
+ value in the <em>first</em> column must indicate that a new parent has started.</span>
+
+ <P>If the above requirements are satisfied, then a {@code List<UserRole>} is built using
+ {@link #listCompound(Class, Class, int, SqlId, Object[])}, as in:
+ <PRE>
+ Db.listCompound(UserRole.class, Id.class, 1, ROLES_LIST_SQL);
+ </PRE>
+*/
+public final class Db {
+
+ /**
+ <tt>SELECT</tt> operation which returns a single Model Object.
+
+ @param aClass class of the returned Model Object.
+ @param aSqlId identifies the underlying SQL statement.
+ @param aParams <a href="#Parameters">parameters</a> for the SQL statement.
+ @return <tt>null</tt> if no record is found.
+ */
+ public static <T> T fetch(Class<T> aClass, SqlId aSqlId, Object... aParams) throws DAOException {
+ SqlFetcher fetcher = SqlFetcher.forSingleOp(aSqlId, aParams);
+ ModelFromRow<T> builder = new ModelFromRow<T>(aClass);
+ return fetcher.fetchObject(builder);
+ }
+
+ /**
+ <tt>SELECT</tt> operation which returns a single 'building block' value such as <tt>Integer</tt>, <tt>BigDecimal</tt>, and so on.
+
+ @param aSupportedTargetClass class supported by the configured
+ implementation of {@link ConvertColumn}.
+ @param aSqlId identifies the underlying SQL statement.
+ @param aParams <a href="#Parameters">parameters</a> for the SQL statement.
+ @return <tt>null</tt> if no record is found.
+ */
+ public static <T> T fetchValue(Class<T> aSupportedTargetClass, SqlId aSqlId, Object... aParams) throws DAOException {
+ SqlFetcher fetcher = SqlFetcher.forSingleOp(aSqlId, aParams);
+ ModelBuilder<T> builder = new ValueFromRow<T>(aSupportedTargetClass);
+ return fetcher.fetchObject(builder);
+ }
+
+ /**
+ <tt>SELECT</tt> operation which returns <tt>0..N</tt> Model Objects, one per row.
+
+ @param aClass class of the returned Model Objects.
+ @param aSqlId identifies the underlying SQL statement.
+ @param aParams <a href="#Parameters">parameters</a> for the SQL statement.
+ @return an unmodifiable {@link List} of Model Objects. The list may be empty.
+ */
+ public static <T> List<T> list(Class<T> aClass, SqlId aSqlId, Object... aParams) throws DAOException {
+ List<T> result = new ArrayList<T>();
+ SqlFetcher fetcher = SqlFetcher.forSingleOp(aSqlId, aParams);
+ ModelBuilder<T> builder = new ModelFromRow<T>(aClass);
+ fetcher.fetchObjects(builder, result);
+ return Collections.unmodifiableList(result);
+ }
+
+ /**
+ <tt>SELECT</tt> operation which returns a <tt>List</tt> of 'building block' values such
+ as <tt>Integer</tt>, <tt>BigDecimal</tt>, and so on.
+
+ @param aSupportedTargetClass class supported by the configured
+ implementation of {@link ConvertColumn}.
+ @param aSqlId identifies the underlying SQL statement.
+ @param aParams <a href="#Parameters">parameters</a> for the SQL statement.
+ @return an unmodifiable {@link List} of building block objects. The list may be empty.
+ */
+ public static <T> List<T> listValues(Class<T> aSupportedTargetClass, SqlId aSqlId, Object... aParams) throws DAOException {
+ List<T> result = new ArrayList<T>();
+ SqlFetcher fetcher = SqlFetcher.forSingleOp(aSqlId, aParams);
+ ModelBuilder<T> builder = new ValueFromRow<T>(aSupportedTargetClass);
+ fetcher.fetchObjects(builder, result);
+ return Collections.unmodifiableList(result);
+ }
+
+ /**
+ <tt>SELECT</tt> operation that returns a <tt>List</tt> of Model Objects "subsetted" to
+ a particular range of rows.
+
+ <P>This method is intended for paging through long listings. When the underlying
+ <tt>SELECT</tt> returns many pages of items, the records can be "subsetted" by
+ calling this method.
+
+ <P>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 <a href="#Parameters">parameters</a> for the SQL statement.
+ @return an unmodifiable {@link List} of Model Objects. The list may be empty.
+ */
+ public static <T> List<T> listRange(Class<T> aClass, SqlId aSqlId, Integer aStartIndex, Integer aPageSize, Object... aParams) throws DAOException {
+ List<T> result = new ArrayList<T>();
+ SqlFetcher fetcher = SqlFetcher.forSingleOp(aSqlId, aParams);
+ fetcher.limitRowsToRange(aStartIndex, aPageSize);
+ ModelBuilder<T> builder = new ModelFromRow<T>(aClass);
+ fetcher.fetchObjects(builder, result);
+ return Collections.unmodifiableList(result);
+ }
+
+ /**
+ <tt>SELECT</tt> operation for listing the result of a user's search with the given {@link DynamicSql}
+ and corresponding parameter values.
+
+ <P>This method is called only if the exact underlying criteria are not known beforehand, but are rather
+ determined <em>dynamically</em> 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 <tt>WHERE</tt> and <tt>ORDER BY</tt> clauses.
+ @param aParams <a href="#Parameters">parameters</a> 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 <T> List<T> search(Class<T> aClass, SqlId aSqlId, DynamicSql aSearchCriteria, Object... aParams) throws DAOException {
+ List<T> result = new ArrayList<T>();
+ SqlFetcher fetcher = SqlFetcher.forSearch(aSqlId, aSearchCriteria, aParams);
+ ModelBuilder<T> builder = new ModelFromRow<T>(aClass);
+ fetcher.fetchObjects(builder, result);
+ return Collections.unmodifiableList(result);
+ }
+
+ /**
+ <tt>INSERT</tt>, <tt>UPDATE</tt>, or <tt>DELETE</tt> operations which take parameters.
+
+ @param aSqlId identifies the underlying SQL statement.
+ @param aParams <a href="#Parameters">parameters</a> 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();
+ }
+
+ /**
+ <tt>INSERT</tt> operation which returns the database identifier of the added record.
+
+ <P>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 <a href="#Parameters">parameters</a> 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());
+ }
+
+ /**
+ <tt>DELETE</tt> 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();
+ }
+
+ /**
+ <tt>SELECT</tt> operation which typically returns a single item with a <tt>0..N</tt> relation.
+
+ <P>The <tt>ResultSet</tt> is parsed into a single parent Model Object having a <tt>List</tt> of
+ <tt>0..N</tt> child Model Objects.
+ See note on <a href="#CompundObjects">compound objects</a> 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 <tt>ResultSet</tt> which
+ are passed to the <em>child</em> constructor.
+ @param aSqlId identifies the underlying SQL statement.
+ @param aParams <a href="#Parameters">parameters</a> to the underlying SQL statement.
+ */
+ public static <T> T fetchCompound(Class<T> aClassParent, Class<?> aClassChild, int aNumTrailingColsForChildList, SqlId aSqlId, Object... aParams) throws DAOException {
+ SqlFetcher fetcher = SqlFetcher.forSingleOp(aSqlId, aParams);
+ ModelBuilder<T> builder = new ModelFromRow<T>(aClassParent, aClassChild, aNumTrailingColsForChildList);
+ return fetcher.fetchObject(builder);
+ }
+
+ /**
+ <tt>SELECT</tt> operation which typically returns mutliple items item with a <tt>0..N</tt> relation.
+
+ <P>The <tt>ResultSet</tt> is parsed into a <tt>List</tt> of parent Model Objects, each having <tt>0..N</tt>
+ child Model Objects. See note on <a href="#CompoundObjects">compound objects</a> 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 <tt>ResultSet</tt> which
+ are passed to the <em>child</em> constructor.
+ @param aSqlId identifies the underlying SQL statement.
+ @param aParams <a href="#Parameters">parameters</a> to the underlying SQL statement.
+ */
+ public static <T> List<T> listCompound(Class<T> aClassParent, Class<?> aClassChild, int aNumTrailingColsForChildList, SqlId aSqlId, Object... aParams) throws DAOException {
+ List<T> result = new ArrayList<T>();
+ SqlFetcher fetcher = SqlFetcher.forSingleOp(aSqlId, aParams);
+ ModelBuilder<T> builder = new ModelFromRow<T>(aClassParent, aClassChild, aNumTrailingColsForChildList);
+ fetcher.fetchObjects(builder, result);
+ return result;
+ }
+
+ /**
+ Add an <tt>Id</tt> to a list of parameters already extracted from a Model Object.
+
+ <P>This method exists to avoid repetition in your DAOs regarding the parameters
+ passed to <em>add</em> and <em>change</em> operations.
+
+ <P>Take the following example :
+ <PRE>
+ INSERT INTO Resto (Name, Location, Price, Comment) VALUES (?,?,?,?)
+ UPDATE Resto SET Name=?, Location=?, Price=?, Comment=? WHERE Id=?
+ </PRE>
+ In this case, the parameters are exactly the same, and appear in the same order,
+ <em>except</em> for the <tt>Id</tt> at the end of the <tt>UPDATE</tt> statement.
+
+ <P>In such cases, this method can be used to simply append the <tt>Id</tt> to an
+ already existing list of parameters.
+
+ @param aBaseParams all parameters used in an <tt>INSERT</tt> statement
+ @param aId the <tt>Id</tt> parameter to append to <tt>aBaseParams</tt>,
+ @return parameters needed for a <em>change</em> operation
+ */
+ public static Object[] addIdTo(Object[] aBaseParams, Id aId){
+ List<Object> result = new ArrayList<Object>();
+ 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).
+
+ <P>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 <tt>Reports</tt> 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 <tt>web.xml</tt>, and take the same values.
+ Callers should pay particular attention to the settings related to the database.
+ See the <a href='http://www.web4j.com/UserGuide.jsp'>User Guide</a> for more information.
+ @param aRawSql each String in this list corresponds to the contents of a single <tt>.sql</tt> file.
+ */
+ public static void initStandalone(Map<String, String> aSettings, List<String> aRawSql) throws AppException{
+ Config.init(aSettings);
+ BuildImpl.initDatabaseLayer(aSettings); //not all interfaces init-ed
+ Map<String, String> processedSql = ConfigReader.processRawSql(aRawSql);
+ SqlStatement.initSqlStatementsManually(processedSql);
+ }
+
+ // PRIVATE
+
+ private Db() {
+ //prevent construction by the caller
+ }
+}
--- /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.
+
+ <P>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 <tt>ServletConfig</tt>.
+
+ <P>This class carries simple static, immutable data, populated upon startup.
+ It is safe to use this class in a multi-threaded environment.
+
+ <P>In addition to reading in <tt>web.xml</tt> settings, this class :
+<ul>
+ <li>initializes connection sources
+ <li>logs the name and version of both the database and the database driver
+ <li>logs the support for transaction isolation levels (see {@link TxIsolationLevel})
+ <li>reads in the <tt>*.sql</tt> file(s) (see package summary for more information)
+</ul>
+
+ <P>See <tt>web.xml</tt> for more information on the items encapsulated by this class.
+
+ @un.published
+*/
+public final class DbConfig {
+
+ /**
+ Configure the data layer. Called upon startup.
+
+ <P> If <tt>aIndicator</tt> is <tt>YES</tt>, 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 <tt>aConfig</tt> must be <tt>null</tt>.)
+
+ @return the names of all databases that were seen to be <em>up</em>.
+ */
+ public static Set<String> initDataLayer() throws DAOException {
+ Set<String> result = new LinkedHashSet<String>();
+ //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 <tt>true</tt> 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.
+
+ <P>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<Children>' 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();
+ }
+}
--- /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.
+
+ <P>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.
+
+ <P>This class is identical to {@link Db}, except that it uses a {@link Connection} passed
+ by the caller.
+
+<h3><a name="Parameters"></a>SQL Parameters</h3>
+ The parameters for SQL statements used by this class have the same behavior as defined by
+ the <a href='Db.html#Parameters'>Db class</a>.
+*/
+public final class DbTx {
+
+ /**
+ <tt>SELECT</tt> 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 <a href="#Parameters">parameters</a> for the SQL statement.
+ */
+ public static <T> T fetch(Connection aConnection, Class<T> aClass, SqlId aSqlId, Object... aParams) throws DAOException {
+ SqlFetcher fetcher = SqlFetcher.forTx(aSqlId, aConnection, aParams);
+ ModelFromRow<T> builder = new ModelFromRow<T>(aClass);
+ return fetcher.fetchObject(builder);
+ }
+
+ /**
+ <tt>SELECT</tt> operation which returns a single 'building block' value such as <tt>Integer</tt>, <tt>BigDecimal</tt>, 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 <a href="#Parameters">parameters</a> for the SQL statement.
+ */
+ public static <T> T fetchValue(Connection aConnection, Class<T> aSupportedTargetClass, SqlId aSqlId, Object... aParams) throws DAOException {
+ SqlFetcher fetcher = SqlFetcher.forTx(aSqlId, aConnection, aParams);
+ ModelBuilder<T> builder = new ValueFromRow<T>(aSupportedTargetClass);
+ return fetcher.fetchObject(builder);
+ }
+
+ /**
+ <tt>SELECT</tt> operation which returns <tt>0..N</tt> 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 <a href="#Parameters">parameters</a> for the SQL statement.
+ @return an unmodifiable {@link List} of Model Objects. The list may be empty.
+ */
+ public static <T> List<T> list(Connection aConnection, Class<T> aClass, SqlId aSqlId, Object... aParams) throws DAOException {
+ List<T> result = new ArrayList<T>();
+ SqlFetcher fetcher = SqlFetcher.forTx(aSqlId, aConnection, aParams);
+ ModelBuilder<T> builder = new ModelFromRow<T>(aClass);
+ fetcher.fetchObjects(builder, result);
+ return Collections.unmodifiableList(result);
+ }
+
+ /**
+ <tt>SELECT</tt> operation which returns a <tt>List</tt> of 'building block' values such
+ as <tt>Integer</tt>, <tt>BigDecimal</tt>, 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 <a href="#Parameters">parameters</a> for the SQL statement.
+ @return an unmodifiable {@link List} of building block objects. The list may be empty.
+ */
+ public static <T> List<T> listValues(Connection aConnection, Class<T> aSupportedTargetClass, SqlId aSqlId, Object... aParams) throws DAOException {
+ List<T> result = new ArrayList<T>();
+ SqlFetcher fetcher = SqlFetcher.forTx(aSqlId, aConnection, aParams);
+ ModelBuilder<T> builder = new ValueFromRow<T>(aSupportedTargetClass);
+ fetcher.fetchObjects(builder, result);
+ return Collections.unmodifiableList(result);
+ }
+
+ /**
+ <tt>SELECT</tt> operation that returns a <tt>List</tt> of Model Objects "subsetted" to
+ a particular range of rows.
+
+ <P>This method is intended for paging through long listings. When the underlying
+ <tt>SELECT</tt> returns many pages of items, the records can be "subsetted" by
+ calling this method.
+
+ <P>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 <a href="#Parameters">parameters</a> for the SQL statement.
+ @return an unmodifiable {@link List} of Model Objects. The list may be empty.
+ */
+ public static <T> List<T> listRange(Connection aConnection, Class<T> aClass, SqlId aSqlId, Integer aStartIndex, Integer aPageSize, Object... aParams) throws DAOException {
+ List<T> result = new ArrayList<T>();
+ SqlFetcher fetcher = SqlFetcher.forTx(aSqlId, aConnection, aParams);
+ fetcher.limitRowsToRange(aStartIndex, aPageSize);
+ ModelBuilder<T> builder = new ModelFromRow<T>(aClass);
+ fetcher.fetchObjects(builder, result);
+ return Collections.unmodifiableList(result);
+ }
+
+ /**
+ <tt>INSERT</tt>, <tt>UPDATE</tt>, or <tt>DELETE</tt> operations which take parameters.
+
+ @param aConnection single connection shared by all operations in the transaction.
+ @param aSqlId identifies the underlying SQL statement.
+ @param aParams <a href="#Parameters">parameters</a> 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();
+ }
+
+ /**
+ <tt>INSERT</tt> operation which returns the database identifier of the added record.
+
+ <P>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 <a href="#Parameters">parameters</a> 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());
+ }
+
+ /**
+ <tt>DELETE</tt> 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();
+ }
+
+ /**
+ <tt>SELECT</tt> operation which typically returns a single item with a <tt>0..N</tt> relation.
+
+ <P>The <tt>ResultSet</tt> is parsed into a single parent Model Object having a <tt>List</tt> of
+ <tt>0..N</tt> child Model Objects.
+ See note on <a href="#CompundObjects">compound objects</a> 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 <tt>ResultSet</tt> which
+ are passed to the <em>child</em> constructor.
+ @param aSqlId identifies the underlying SQL statement.
+ @param aParams <a href="#Parameters">parameters</a> to the underlying SQL statement.
+ */
+ public static <T> T fetchCompound(Connection aConnection, Class<T> aClassParent, Class<?> aClassChild, int aNumTrailingColsForChildList, SqlId aSqlId, Object... aParams) throws DAOException {
+ SqlFetcher fetcher = SqlFetcher.forTx(aSqlId, aConnection, aParams);
+ ModelBuilder<T> builder = new ModelFromRow<T>(aClassParent, aClassChild, aNumTrailingColsForChildList);
+ return fetcher.fetchObject(builder);
+ }
+
+ /**
+ <tt>SELECT</tt> operation which typically returns mutliple items item with a <tt>0..N</tt> relation.
+
+ <P>The <tt>ResultSet</tt> is parsed into a <tt>List</tt> of parent Model Objects, each having <tt>0..N</tt>
+ child Model Objects. See note on <a href="#CompundObjects">compound objects</a> 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 <tt>ResultSet</tt> which
+ are passed to the <em>child</em> constructor.
+ @param aSqlId identifies the underlying SQL statement.
+ @param aParams <a href="#Parameters">parameters</a> to the underlying SQL statement.
+ */
+ public static <T> List<T> listCompound(Connection aConnection, Class<T> aClassParent, Class<?> aClassChild, int aNumTrailingColsForChildList, SqlId aSqlId, Object... aParams) throws DAOException {
+ List<T> result = new ArrayList<T>();
+ SqlFetcher fetcher = SqlFetcher.forTx(aSqlId, aConnection, aParams);
+ ModelBuilder<T> builder = new ModelFromRow<T>(aClassParent, aClassChild, aNumTrailingColsForChildList);
+ fetcher.fetchObjects(builder, result);
+ return result;
+ }
+
+ // PRIVATE
+
+ private DbTx() {
+ //prevent construction by the caller
+ }
+}
--- /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 <tt>Statement</tt> first, then its associated <tt>Connection</tt>.
+ <P>Parameters are permitted to be <tt>null</tt>.
+ */
+ 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 <tt>Connection</tt>.
+ */
+ static void close(Connection aConnection) throws DAOException {
+ close(null, aConnection);
+ }
+
+ /** Close a possibly-null <tt>Statement</tt>. */
+ static void close(PreparedStatement aStatement) throws DAOException {
+ close(aStatement, null);
+ }
+
+ /**
+ If <tt>aStatement</tt> 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 <tt>aConnection</tt> 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());
+ }
+}
--- /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 <tt>ADD</tt> or <tt>CHANGE</tt> operation.
+
+ <P>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.
+
+ <P>For relational databases, this exception should be thrown for <tt>INSERT</tt>
+ and <tt>UPDATE</tt> operations which may violate a <tt>UNIQUE</tt> or
+ <tt>PRIMARY KEY</tt> constraint, or similar item.
+ {@link Db}, {@link DbTx}, and {@link TxTemplate} will throw a <tt>DuplicateException</tt>
+ exception for {@link java.sql.SQLException}s having an error code matching the
+ <tt>ErrorCodeForDuplicateKey</tt> configured in <tt>web.xml</tt>.
+ See <tt>web.xml</tt> for more information.
+
+ <h3>Typical Use Case</h3>
+ Here, an {@link hirondelle.web4j.action.Action} is calling a DAO method which may throw
+ a <tt>DuplicateException</tt>:
+<PRE>
+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.");
+ }
+}
+</PRE>
+ <P>Note that if the operation cannot have a duplicate problem, then the <tt>Action</tt>
+ should not attempt to catch <tt>DuplicateException</tt>.
+<P>
+ Here is the DAO operation which may have a duplicate problem.
+<PRE>
+//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
+}
+</PRE>
+ Again, if the operation cannot have a duplicate problem, then the DAO should not
+ declare a <tt>DuplicateException</tt> in its throws clause.
+
+ <P>The {@link Db#add(SqlId, Object[])} and {@link Db#edit(SqlId, Object[])} methods can throw a
+ <tt>DuplicateException</tt>.
+*/
+public final class DuplicateException extends DAOException {
+
+ /**
+ Constructor.
+
+ <P>Arguments are passed to {@link DAOException#DAOException(String, Throwable)}.
+ */
+ public DuplicateException(String aMessage, Throwable aRootCause) {
+ super(aMessage, aRootCause);
+ }
+
+}
--- /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:
+ <ul>
+ <li>a complete statement
+ <li>a fragment of a statement, to be appended to the end of a static base statement
+ </ul>
+
+ <P>This class is intended for two use cases:
+ <ul>
+ <li>creating a statement entirely in code
+ <li>creating <tt>WHERE</tt> and <tt>ORDER BY</tt> clauses dynamically, by building sort and filter criteria from
+ user input
+ </ul>
+
+ <P><b>The creation of SQL in code is dangerous.
+ You have to exercise care that your code will not be subject to
+ <a href='http://en.wikipedia.org/wiki/SQL_injection'>SQL Injection attacks</a>.
+ 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.</b>
+
+ <P>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 <tt>parameterizing</tt> user input, and proceeding in 2 steps:
+ <ol>
+ <li>create an SQL statement that always uses a <tt>?</tt> placeholder for data entered by the user
+ <li>pass all user-entered data as parameters to the above statement
+ </ol>
+
+ The above corresponds to the correct use of a <tt>PreparedStatement</tt>.
+ 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.
+
+ <h3>Entries in .sql Files</h3>
+ <P>The SQL string you pass to this class is always <em>appended</em> (using {@link #toString}) to
+ a (possibly-empty) base SQL statement already defined (as usual), in your <tt>.sql</tt> file.
+ That entry can take several forms. The criteria on the entry are:
+ <ul>
+ <li>it can be precompiled by WEB4J upon startup, if desired.
+ <li>it contains only <i>static</i> elements of the final SQL statement
+ </ul>
+
+ <em>It's important to note that the static base SQL can be completely empty.</em>
+ For example, the entry in your <tt>.sql file</tt> can look something like this:
+ <pre>MY_DYNAMIC_REPORT {
+ -- this sql is generated in code
+}</pre>
+ 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 <tt>SqlId</tt> objects will remain in effect, which is useful.)
+
+ <P>You are encouraged to implement joins between tables using the <tt>JOIN</tt> syntax.
+ The alternative is to implement joins using expressions in the <tt>WHERE</tt> clause.
+ This usually isn't desirable, since it mixes up two distinct items - joins and actual criteria.
+ Using <tt>JOIN</tt> allows these items to remain separate and distinct.
+
+ <h3>See Also</h3>
+ Other items closely related to this class are :
+ <ul>
+ <li> {@link hirondelle.web4j.action.ActionImpl#getOrderBy(hirondelle.web4j.request.RequestParameter,hirondelle.web4j.request.RequestParameter, String)} -
+ convenience method for constructing an <tt>ORDER BY</tt> clause from request parameters.
+ <li> {@link hirondelle.web4j.database.Db#search(Class, SqlId, DynamicSql, Object[])}
+ <li> the {@link hirondelle.web4j.database.Report} class.
+ </ul>
+
+ <h3>Constants</h3>
+ 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.
+
+ <P>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 <tt>WHERE</tt> clause.*/
+ public static final String WHERE = " WHERE ";
+
+ /** Value - {@value}, convenience value for building a <tt>WHERE</tt> clause. */
+ public static final String AND = " AND ";
+
+ /** Value - {@value}, convenience value for building a <tt>WHERE</tt> clause. */
+ public static final String OR = " OR ";
+
+ /** Value - {@value}, convenience value for building an <tt>ORDER BY</tt> clause. */
+ public static final String ORDER_BY = " ORDER BY ";
+
+ /** Value - {@value}, convenience value for building an <tt>ORDER BY</tt> clause. */
+ public static final String ASC = " ASC ";
+
+ /** Value - {@value}, convenience value for building an <tt>ORDER BY</tt> clause. */
+ public static final String DESC = " DESC ";
+
+ /**
+ Represents the absence of any criteria. The value of this item is simply <tt>null</tt>.
+ <P>If a method allows a <tt>null</tt> object to indicate the absence of any criteria,
+ then it is recommended that this reference be used instead of <tt>null</tt>.
+ */
+ public static final DynamicSql NONE = null;
+
+ /**
+ Constructor.
+ <P>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.
+
+ <P>The returned value is appended by the framework to an existing (possibly empty) entry in an <tt>.sql</tt> file.
+ */
+ @Override final public String toString(){
+ return fSql;
+ }
+
+ // PRIVATE
+
+ private String fSql = "";
+
+}
--- /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 <tt>ADD</tt>, <tt>CHANGE</tt>, or <tt>DELETE</tt> operation.
+
+ <P>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.
+
+ <P>For relational databases, this exception should be thrown for <tt>INSERT</tt>,
+ <tt>UPDATE</tt>, or <tt>DELETE</tt> operation which may violate a foreign key constraint.
+ {@link Db}, {@link DbTx}, and {@link TxTemplate} will throw a <tt>ForeignKeyException</tt>
+ exception for {@link java.sql.SQLException}s having an error code matching the
+ <tt>ErrorCodeForForeignKey</tt> setting configured in <tt>web.xml</tt>.
+ See <tt>web.xml</tt> for more information.
+
+ <h3>Typical Use Case</h3>
+ Here, an {@link hirondelle.web4j.action.Action} is calling a DAO method which may throw
+ a <tt>ForeignKeyException</tt>:
+<PRE>
+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.");
+ }
+}
+</PRE>
+<P>
+ Here is the DAO operation which may have a foreign key constraint problem.
+<PRE>
+//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
+}
+</PRE>
+*/
+public final class ForeignKeyException extends DAOException {
+
+ /**
+ Constructor.
+
+ <P>Arguments are passed to {@link DAOException#DAOException(String, Throwable)}.
+ */
+ public ForeignKeyException(String aMessage, Throwable aRootCause) {
+ super(aMessage, aRootCause);
+ }
+
+}
--- /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 <tt>ResultSet</tt>.
+
+ <P>This class exists for two reasons :
+ <ul>
+ <li>to translate exceptions
+ <li>to allow the construction of both Model Objects and building block
+ objects such as <tt>Date</tt>, <tt>Integer</tt>, and so on. These two differ
+ since building block objects usually have multiple constructors with the same
+ number of arguments.
+ </ul>
+*/
+abstract class ModelBuilder<E> {
+
+ /**
+ <b>Template Method</b> for parsing a single row into an arbitrary Object.
+ The returned <tt>Object</tt> often represents a Model Object, but can also
+ represent an <tt>Integer</tt> value, a <tt>BigDecimal</tt> value, a <tt>Map</tt>,
+ or any other object whatsoever.
+
+ <P>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 <tt>E</tt>.
+ */
+ 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.
+
+ <P>The argument and return value satisfy the same conditions as in {@link #buildObject}.
+ */
+ abstract E build(ResultSet aRow) throws SQLException, ModelCtorException;
+}
--- /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 <tt>ResultSet</tt>.
+
+ <P>Uses all columns. See package level comment for more details.
+ <P>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<E> extends ModelBuilder<E> {
+
+ /**
+ 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<E> aClass){
+ fClass = aClass;
+ }
+
+ /**
+ Extended case of a compound Model Object, which takes a single <tt>List</tt> 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 <tt>List</tt>
+ at the end of the parent's constructor.
+ @param aNumTrailingColsForChildList number of columns at the end of the <tt>ResultSet</tt> that are
+ used to construct a child object.
+ */
+ ModelFromRow(Class<E> 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<E> 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<E> modelCtor = ModelCtorUtil.getConstructor(fClass, getNumColsInResultSet(aRow));
+ List<Object> 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<Object> getCtorArgValues(Constructor<E> aConstructor, ResultSet aRow, boolean aIncludeLastArg) throws SQLException {
+ List<Object> result = new ArrayList<Object>();
+ 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<E> parentCtor = ModelCtorUtil.getConstructor(fClass, getNumColsForParent(aRow) + 1);
+ List<Object> 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<Object> childList = new ArrayList<Object>();
+ 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<Object> 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<Object> aChildren, ResultSet aRow) throws SQLException, ModelCtorException {
+ fLogger.fine("Building a regular child, and adding to the child list.");
+ Class<?>[] targetTypes = aChildCtor.getParameterTypes();
+ List<Object> argValues = new ArrayList<Object>();
+ 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 <T> void addBaseChildToList(Class<T> aBaseClass, List<Object> 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<E> aParentCtor, List<Object> aArgValuesMinusChildren, List<Object> aChildren) throws ModelCtorException {
+ fLogger.fine("Building complete Parent with Child List.");
+ List<Object> allArgs = new ArrayList<Object>();
+ allArgs.addAll(aArgValuesMinusChildren);
+ allArgs.add(aChildren);
+ fLogger.finest("Building complete Parent Model Object.");
+ return ModelCtorUtil.buildModelObject(aParentCtor, allArgs);
+ }
+}
--- /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 <tt>ResultSet</tt>.
+
+ <P>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}.
+
+ <P>If a Model Object does <em>not</em> 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.
+
+ <P>This class translates each <tt>ResultSet</tt> row into a <tt>Map</tt> of some form. This <tt>Map</tt> is meant not as a
+ robust Model Object, but rather as a rather dumb data carrier, built only for the purpose of reporting.
+ The <tt>Map</tt> key is always the column name, and the <tt>Map</tt> value takes various forms, according
+ to how the <tt>ResultSet</tt> is processed :
+ <ul>
+ <li><tt>formatted</tt> - column values are parsed using
+ {@link ConvertColumn} into <tt>Integer</tt>, <tt>Date</tt>, and so on, and then standard formatting is applied
+ using {@link Formats#objectToText(Object)}. (This is the recommended style.)
+ <li><tt>unformatted</tt> - column values are parsed using {@link ConvertColumn} into
+ <tt>Integer</tt>, <tt>Date</tt>, and so on, but formatting is deferred to the JSP.
+ <li><tt>raw</tt> - 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.
+ </ul>
+
+ <P>Example of using the <tt>Map</tt> in a JSP. Here, column values are assumed to be already formatted, using
+ either <tt>raw</tt> or <tt>formatted</tt>:
+<PRE>
+{@code
+<c:forEach var="row" items="${reportMap}" >
+ <tr>
+ <td>${row['Name']}</td>
+ <td>${row['Visits']}</td>
+ </tr>
+</c:forEach>
+ }
+</PRE>
+
+ <P>If <tt>unformatted</tt> is used to build the <tt>Map</tt>, then formatting
+ of the resulting objects must be applied in the JSP.
+
+ <h3>Recommended Style</h3>
+ The recommended style is to use <tt>formatted</tt>.
+
+ <P>If <tt>raw</tt> or <tt>unformatted</tt> is used, then
+ the question usually arises of where to apply formatting:
+ <ul>
+ <li>format with database formatting functions - then there will often be much repetition of formatting function calls
+ across different <tt>SELECT</tt>s.
+ <li>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.
+ </ul>
+
+<h3><a name="emptyContent"></a>Empty Values</h3>
+ When the <tt>Map</tt> returned by this class has values as text, then any <tt>Strings</tt> 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 <tt>web.xml</tt>). This is a workaround for the fact that most browsers do
+ not render empty <tt>TD</tt> tags very well when the cell has a border. An alternate
+ (and likely superior) workaround is to set the
+ <tt><a href="http://www.w3.org/TR/REC-CSS2/tables.html#empty-cells">empty-cells</a></tt>
+ property of Cascading Style Sheets to <tt>'show'</tt>.
+*/
+public final class Report {
+
+ /**
+ Return column values without any processing.
+
+ <P>For the returned {@code Map<String, SafeText>} objects,
+ <ul>
+ <li>key is the column name
+ <li>value is the unprocessed column value, passed to a {@link SafeText}. <tt>SafeText</tt> is
+ used instead of <tt>String</tt> to allow easy escaping of special characters in the view.
+ </ul>
+
+ @param aSqlId identifies the underlying <tt>SELECT</tt> statement
+ @param aCriteria possible <em>dynamic</em> <tt>WHERE</tt> or <tt>ORDER BY</tt> clause. If no dynamic criteria, then
+ just pass {@link DynamicSql#NONE}.
+ @param aParams parameters for the <tt>SELECT</tt> statement, in the same order as in the underlying <tt>SELECT</tt> statement
+ */
+ public static List<Map<String, SafeText>> raw(SqlId aSqlId, DynamicSql aCriteria, Object... aParams) throws DAOException {
+ List<Map<String,SafeText>> result = new ArrayList<Map<String, SafeText>>();
+ ModelBuilder<Map<String, SafeText>> 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.
+
+ <P>For the returned {@code Map<String, SafeText>} objects,
+ <ul>
+ <li>key is the column name
+ <li>value is the processed column value, as <tt>SafeText</tt>. 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}.
+ </ul>
+
+ @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 <tt>ResultSet</tt>.
+ 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 <tt>Locale</tt> returned by {@link hirondelle.web4j.request.LocaleSource}
+ @param aTimeZone <tt>TimeZone</tt> returned by {@link hirondelle.web4j.request.TimeZoneSource}
+ @param aSqlId identifies the underlying <tt>SELECT</tt> statement
+ @param aCriteria possible <em>dynamic</em> <tt>WHERE</tt> or <tt>ORDER BY</tt> clause. If no dynamic criteria, then
+ just pass {@link DynamicSql#NONE}.
+ @param aParams parameters for the <tt>SELECT</tt> statement, in the same order as in the underlying <tt>SELECT</tt> statement
+ */
+ public static List<Map<String, SafeText>> formatted(Class<?>[] aTargetClasses, Locale aLocale, TimeZone aTimeZone, SqlId aSqlId, DynamicSql aCriteria, Object... aParams) throws DAOException {
+ List<Map<String, SafeText>> result = new ArrayList<Map<String, SafeText>>();
+ //any date columns must be formatted in a Locale-sensitive manner
+ Formats formats = new Formats(aLocale, aTimeZone);
+ ModelBuilder<Map<String, SafeText>> 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.
+
+ <P>For the returned {@code Map<String, Object>} objects,
+ <ul>
+ <li>key is the column name
+ <li>value is the processed column value, parsed into a building block Object using {@link ConvertColumn}.
+ </ul>
+
+ @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 <tt>ResultSet</tt>.
+ 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 <tt>SELECT</tt> statement
+ @param aCriteria possible <em>dynamic</em> <tt>WHERE</tt> or <tt>ORDER BY</tt> clause. If no dynamic criteria, then
+ just pass {@link DynamicSql#NONE}.
+ @param aParams parameters for the <tt>SELECT</tt> statement, in the same order as in the underlying <tt>SELECT</tt> statement
+ */
+ public static List<Map<String, Object>> unformatted(Class<?>[] aTargetClasses, SqlId aSqlId, DynamicSql aCriteria, Object... aParams) throws DAOException {
+ List<Map<String, Object>> result = new ArrayList<Map<String, Object>>();
+ ModelBuilder<Map<String, Object>> 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;
+ }
+}
--- /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 <tt>ResultSet</tt> row into a <tt>Map</tt>.
+
+ <P>The returned {@code Map<String, SafeText>} has :
+ <ul>
+ <li>key - column name.
+ <li>value - column value as {@link SafeText}. The column values may be formatted
+ using {@link Formats#objectToTextForReport(Object)}, according to which constructor
+ is called.
+ </ul>
+
+ <P><tt>SafeText</tt> is used to protect the caller against unescaped special characters,
+ and Cross Site Scripting attacks.
+*/
+final class ReportBuilder extends ModelBuilder<Map<String, SafeText>> {
+
+ /**
+ Builds a <tt>Map</tt> whose values are simply the <em>raw, unprocessed text</em> of the
+ underlying <tt>ResultSet</tt>. These values are placed into {@link SafeText}
+ objects, to allow for various styles of escaping special characters when presenting
+ the data in the view.
+
+ <P>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.
+
+ <P><tt>aColumnTypes</tt> is used to convert each column into an <tt>Object</tt>.
+ The supported types are the same as for {@link ConvertColumn}.
+
+ <P>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 <tt>ResultSet</tt>; contains <tt>N</tt> class literals,
+ where <tt>N</tt> is the number of columns in the <tt>ResultSet</tt>; 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 <tt>web.xml</tt> 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 <tt>Map</tt>.
+
+ <P>See class description.
+ */
+ Map<String, SafeText> build(ResultSet aRow) throws SQLException {
+ LinkedHashMap<String, SafeText> result = new LinkedHashMap<String, SafeText>();
+ 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]);
+ }
+}
--- /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 <tt>ResultSet</tt> row into a <tt>Map</tt>.
+
+ <P>The returned {@code Map<String, Object>} has :
+ <ul>
+ <li>key - column name.
+ <li>value - column value as an unformatted object. The class of each object is controlled
+ by the class array passed to the constructor.
+ </ul>
+*/
+final class ReportBuilderUnformatted extends ModelBuilder<Map<String, Object>> {
+
+ /**
+ Constructor for applying some processing to raw column values.
+
+ <P><tt>aColumnTypes</tt> is used to convert each column into an <tt>Object</tt>.
+ 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 <tt>ResultSet</tt>; contains <tt>N</tt> class literals,
+ where <tt>N</tt> is the number of columns in the <tt>ResultSet</tt>; 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 <tt>Map</tt>.
+
+ <P>See class constructor.
+ */
+ Map<String, Object> build(ResultSet aRow) throws SQLException {
+ LinkedHashMap<String, Object> result = new LinkedHashMap<String, Object>();
+ 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]);
+ }
+}
--- /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.
+
+ <P>Here, an edit usually corresponds to a single INSERT, UPDATE, or DELETE operation.
+ (Note that {@link SqlFetcher} is used for SELECT operations.)
+
+<P>The convenient utilities in the {@link Db} class should always be considered as a
+ simpler alternative to this class.
+
+ <P>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 <tt>SqlEditor</tt>
+ objects returned by the <tt>forTx</tt> factory methods.
+
+ <P>If an internal {@link Connection} is used, then it will use the isolation
+ level configured in <tt>web.xml</tt> by default. The level can be overridden
+ by calling {@link #setTxIsolationLevel}.
+
+ <P>Internal <tt>Connection</tt>s are obtained from
+ {@link hirondelle.web4j.database.ConnectionSource}, using {@link SqlId#getDatabaseName()}
+ to identify the database.
+
+ <P><tt>INSERT</tt> operations have a special character, since they often create
+ identifiers used elsewhere. An <tt>INSERT</tt> can be performed in various ways :
+<ul>
+ <li>one of the two <tt>addRecord</tt> methods, in which auto-generated
+ keys, if present, are returned to the caller
+ <li><tt>editDatabase</tt>, in which auto-generated keys are NOT returned
+ to the caller
+</ul>
+
+<P>The user of this class is able to edit the database in a very
+ compact style, without concern for details regarding
+<ul>
+ <li>database connections
+ <li>manipulating raw SQL text
+ <li>inserting parameters into SQL statements
+ <li>retrieving auto-generated keys
+ <li>error handling
+ <li>logging of warnings
+ <li>closing statements and connections
+</ul>
+<P>Example use case taken from a DAO :
+<PRE>
+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();
+}
+</PRE>
+ <P>Note that, given the reusable database utility classes in this package,
+ the above feature is implemented using only
+<ul>
+ <li>one entry in an <tt>.sql</tt> text file
+ <li>one <tt>public static final SqlId</tt> constant (<tt>ADD_MESSAGE</tt> in the example)
+ <li>three lines of straight-line code in a data access object (DAO)
+</ul>
+ Given these database utility classes, many DAO implementations
+ become impressively compact. (The {@link Db} class can often reduce the size of
+ implementations even further.)
+
+ <P>The <tt>forSingleOp</tt> methods may actually refer to a stored procedure.
+ In that case, many operations can actually be performed, not just one.
+
+ <P>If the operation throws a {@link SQLException} having the specific error code
+ configured in <tt>web.xml</tt>, 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 <tt>DAOException</tt> and
+ <tt>DuplicateException</tt> in their <tt>throws</tt> clauses. If the operation cannot
+ have a duplicate problem, then the DAO must not declare <tt>DuplicateException</tt>
+ in its <tt>throws</tt> clause.
+
+ @used.By {@link Db}, DAO implementation classes which edit the database in some way, usually with
+ an <tt>INSERT</tt>, <tt>UPDATE</tt>, or <tt>DELETE</tt> command.
+ @author <a href="http://www.javapractices.com/">javapractices.com</a>
+*/
+final class SqlEditor {
+
+ /**
+ Factory method for a single <tt>INSERT</tt>, <tt>DELETE</tt>, or <tt>UPDATE</tt>
+ operation having parameters.
+ */
+ static SqlEditor forSingleOp(SqlId aSqlId, Object... aParams){
+ return new SqlEditor(aSqlId, INTERNAL_CONNECTION, aParams);
+ }
+
+ /**
+ Factory method for a single <tt>INSERT</tt>, <tt>DELETE</tt>, or <tt>UPDATE</tt>
+ 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 <tt>INSERT</tt>, <tt>DELETE</tt>, or <tt>UPDATE</tt> 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 <tt>1..100</tt>, 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
+ <tt>1</tt> for its single argument.
+ */
+ String addRecord() throws DAOException {
+ return addRecord(fDEFAULT_AUTO_GENERATED_COLUMN_IDX);
+ }
+
+ /**
+ Override the default transaction isolation level specified in <tt>web.xml</tt>.
+
+ <P>This setting is applied only if this class is using its own internal connection, and
+ has not received a connection from the caller.
+
+ <P><span class="highlight">If the user passed an external
+ <tt>Connection</tt> to this class, then this method
+ cannot be called.</span> 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;
+ }
+}
--- /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.
+
+<P>To perform single INSERT, DELETE, or UPDATE operation, use {@link SqlEditor} instead.
+
+<P><span class="highlight">The utilities in the {@link Db} class should always
+ be considered as a simpler alternative to this class.</span>
+
+<P>The user of this class is able to fetch records in a very
+ compact style, without concern for details regarding
+<ul>
+ <li>database connections
+ <li>manipulating raw SQL text
+ <li>inserting parameters into SQL statements
+ <li>error handling
+ <li>logging of warnings
+ <li>closing statements and connections
+</ul>
+<P>Example use case in a Data Access Object(DAO), where a <tt>Person</tt> object
+is fetched using <tt>aName</tt> as a simple business identifier :
+<PRE>
+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 );
+}
+</PRE>
+ <P>Note that the above feature is implemented using only
+<ul>
+ <li>one entry in a <tt>.sql</tt> text file, which stores the underlying SQL
+ statement as text, outside compiled code
+ <li>one <tt>public static final</tt> {@link SqlId} field declared in some class
+ (usually the {@link hirondelle.web4j.action.Action} of the DAO itself)
+ <li>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,
+ <em>regardless of the number of fields in the underlying Model Object</em>)
+</ul>
+ Given these database utility classes, many DAO implementations
+ become very compact. (The {@link Db} class can reduce the size of
+ implementations even further.)
+
+<P> Almost all callers of <tt>SqlFetcher</tt> will also use a {@link ModelBuilder},
+ which is closely related to this class.
+
+ <P><tt>SqlFetcher</tt> can participate in a transaction by passing a
+ {@link Connection} to one of its static factory methods. Otherwise, an internal
+ <tt>Connection</tt> is obtained from {@link hirondelle.web4j.database.ConnectionSource},
+ using {@link SqlId#getDatabaseName()} to identify the database.
+*/
+final class SqlFetcher {
+
+ /**
+ Factory method for single <tt>SELECT</tt> operation which takes parameters.
+ */
+ static SqlFetcher forSingleOp(SqlId aSqlId, Object... aParams){
+ return new SqlFetcher(aSqlId, INTERNAL_CONNECTION, NO_SEARCH_CRITERIA, aParams);
+ }
+
+ /**
+ Factory method for <tt>SELECT</tt> 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 <tt>SELECT</tt> 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.
+
+ <P>Intended for use with SELECT statements which usually return one row.<br>
+ If <tt>1..N</tt> rows are returned, parse the first row into a Model Object and
+ return it. Ignore any other rows.<br> If no rows are returned, return <tt>null</tt>.
+ (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> T fetchObject(ModelBuilder<T> aModelBuilder) throws DAOException {
+ List<T> list = new ArrayList<T>();
+ fetchObjects(aModelBuilder, list);
+ return list.isEmpty()? null : list.get(0);
+ }
+
+ /**
+ Perform a SELECT and return a corresponding <tt>Collection</tt> of Model Objects.
+
+ <P>Intended for use with SELECT statements which usually return multiple rows.<br>
+ If <tt>1..N</tt> rows are found, parse each row into a Model Object and
+ place it in <tt>aResult</tt>.<br> Items are added to <tt>aResult</tt>
+ in the order returned from the underlying <tt>ResultSet</tt>.<br>
+ If <tt>0</tt> rows are found, then <tt>aResult</tt> will remain empty.
+
+ @param aModelBuilder parses a row into a corresponding Model Object.
+ @param aResult acts as an "out" parameter, and is initially empty.
+ */
+ <T> void fetchObjects(ModelBuilder<T> aModelBuilder, Collection<T> 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.
+
+ <P><em>This method is not usually called</em>. It is intended only for those
+ cases in which only a subset of the underlying <tt>ResultSet</tt> 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 <tt>web.xml</tt>.
+
+ <P>This setting is applied only if this class is using its own internal connection, and
+ has not received a connection from the caller.
+
+ <P><span class="highlight">If the user passed an external
+ <tt>Connection</tt> to a constructor of this class, then this method
+ must not be called.</span> (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 <tt>ResultSet</tt>, 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<T> {
+ void cycleRows(ResultSet aResultSet, ModelBuilder<T> 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<T> aModelBuilder, ResultSet aResultSet) throws DAOException, SQLException;
+ }
+
+ private final class CollectionRowCycler<T> extends RowCycler<T> {
+ CollectionRowCycler(Collection<T> aCollection){
+ fCollection = aCollection;
+ }
+ void addItemToResult(ModelBuilder<T> aModelBuilder, ResultSet aResultSet) throws DAOException {
+ T item = aModelBuilder.buildObject(aResultSet);
+ fCollection.add( item );
+ }
+ private Collection<T> 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();
+ }
+}
--- /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;
+
+/**
+<span class="highlight">Identifier of an SQL statement block in an <tt>.sql</tt> file.</span>
+(Such identifiers must be unique.)
+
+ <P>This class does <em>not</em> contain the text of the underlying SQL statement.
+ Rather, this class allows a code friendly way of <em>referencing</em> SQL statements.
+ Since <tt>.sql</tt> 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.
+
+ <P> Please see the package summary for important information regarding <tt>.sql</tt> files.
+
+ <P>Typical use case :
+<PRE>public static final SqlId MEMBER_FETCH = new SqlId("MEMBER_FETCH");</PRE>
+This corresponds to an entry in an <tt>.sql</tt> file :
+<PRE>
+MEMBER_FETCH {
+ SELECT Id, Name, IsActive, DispositionFK
+ FROM Member WHERE Id=?
+}
+</PRE>
+
+ <P>This class is unusual, since there is only one way to use these objects.
+ That is, they <span class="highlight">must be declared
+ as <tt>public static final</tt> fields in a <tt>public</tt> class.</span>
+ They should never appear <i>only</i> 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 <tt>.sql</tt> file. Such identifiers must match a specific
+ {@link #FORMAT}.
+
+ <P><a name="StartupChecks"></a><b>Startup Checks</b><br>
+ To discover simple typographical errors as quickly as possible,
+ the framework will run diagnostics upon startup : <span class="highlight">there must be an exact, one-to-one
+ correspondence between the SQL statement identifiers defined in the <tt>.sql</tt> file(s),
+ and the <tt>public static final SqlId</tt> fields declared by the
+ application.</span> 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.)
+
+ <P><a name="DeclarationLocation"></a><b>Where To Declare <tt>SqlId</tt> Fields</b><br>
+ Where should <tt>SqlId</tt> fields be declared? The only real restriction is that
+ they must be declared in a <tt>public</tt> class. With the most recommended first, one may declare
+ <tt>SqlId</tt> fields in :
+ <ul>
+ <li>a <tt>public</tt> {@link hirondelle.web4j.action.Action}
+ <li>a <tt>public</tt> Data Access Object
+ <li>a <tt>public</tt> constants class, one per package/feature.
+ <li>a <tt>public</tt> 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.
+ </ul>
+
+ <P><em>Design Note</em>
+ <br>The justification for recommending that <tt>SqlId</tt> fields appear in a
+ {@link hirondelle.web4j.action.Action} is as follows :
+ <ul>
+ <li>it is highly satisfying to have mostly <tt>package-private</tt> 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 <tt>SqlId</tt> is declared in a DAO, however, then that DAO must be
+ changed to <tt>public</tt>, just to render the <tt>SqlId</tt> fields accessible by reflection,
+ which is distasteful.
+ <li>the {@link hirondelle.web4j.action.Action} is always <tt>public</tt> anyway, so adding a <tt>SqlId</tt> will
+ not change its scope.
+ <li>{@link hirondelle.web4j.action.Action} is intended as the <tt>public</tt> 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 <em>"What does it do?"</em>. In a typical database application, the answer to that
+ question is usually <em>"these SQL operations"</em>.
+ </ul>
+*/
+public final class SqlId {
+
+ /**
+ Format of SQL statement identifiers.
+
+ <P>Matching examples include :
+ <ul>
+ <li><tt>ADD_MESSAGE</tt>
+ <li><tt>fetch_member</tt>
+ <li><tt>LIST_RESTAURANTS_2</tt>
+ </ul>
+
+ <P>One or more letters/underscores, with possible trailing digits.
+ <P>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 <tt>'TRANSLATION_DB.ADD_BASE_TEXT'</tt>.
+ */
+ public static final String FORMAT = Regex.SIMPLE_IDENTIFIER;
+
+ /**
+ Constructor for statement against the <em>default</em> database.
+
+ @param aStatementName identifier of an SQL statement, satisfies {@link #FORMAT},
+ and matches the name attached to an SQL statement appearing in an <tt>.sql</tt> file.
+ */
+ public SqlId(String aStatementName) {
+ fStatementName = aStatementName;
+ fDatabaseName = null;
+ validateState();
+ }
+
+ /**
+ Constructor for statement against a <em>named</em> 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 <tt>aStatementName</tt>. 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 <tt>.sql</tt> file.
+ */
+ public SqlId(String aDatabaseName, String aStatementName) {
+ fStatementName = aStatementName;
+ fDatabaseName = aDatabaseName;
+ validateState();
+ }
+
+ /**
+ Factory method for building an <tt>SqlId</tt> from a <tt>String</tt> 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 <tt>aDatabaseName</tt> passed to the constructor.
+
+ <P>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 <tt>aStatementName</tt> passed to the constructor. */
+ public String getStatementName(){
+ return fStatementName;
+ }
+
+ /**
+ Return the SQL statement identifier as it appears in the <tt>.sql</tt> file.
+
+ <P>Example return values :
+ <ul>
+ <li><tt>MEMBER_FETCH</tt> (against the default database)
+ <li><tt>TRANSLATION.FETCH_ALL_TRANSLATIONS</tt> (against a database named <tt>TRANSLATION</tt>)
+ </ul>
+ */
+ @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};
+ }
+}
--- /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.
+
+ <P><span class='highlight'>See package overview for important information</span>.
+
+ <P>This class hides details regarding all SQL statements used by the application. These items
+ are hidden from the caller of this class :
+ <ul>
+ <li>the retrieval of SQL statements from an underlying textual <tt>.sql</tt> file or
+ files
+ <li>the textual content of SQL statements
+ <li>the details of placing parameters into a {@link PreparedStatement}
+ </ul>
+
+ <P> Only {@link PreparedStatement} objects are used here, since they <a
+ href="http://www.javapractices.com/Topic212.cjp">are usually preferable</a> to
+ {@link Statement} objects.
+ */
+final class SqlStatement {
+
+ /**
+ Called by the framework upon startup, to read and validate all SQL statements from the
+ underlying <tt>*.sql</tt> text file(s).
+
+ <P>Verifies that there is no mismatch
+ whatsoever between the <tt>public static final</tt> {@link SqlId} fields used in the
+ application, and the keys of the corresponding <tt>*.sql</tt> 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.
+
+ <P> 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 <tt>Connection.prepareStatement()</tt>
+ <em>might</em> 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 <tt>SEVERE</tt>.
+
+ <P>A setting in <tt>web.xml</tt> can disable this pre-compilation, if desired.
+ */
+ static void readSqlFile() {
+ readSqlText();
+ checkStoredProcedures();
+ checkSqlFilesVersusSqlIdFields();
+ precompileAll();
+ }
+
+ public static void initSqlStatementsManually(Map<String, String> aStatements){
+ if (fSqlProperties == null){
+ fSqlProperties = new Properties();
+ }
+ fSqlProperties.clear();
+ fSqlProperties.putAll(aStatements);
+ }
+
+ /**
+ SQL statement which takes parameters.
+
+ <P>This class supports the same classes as parameters as {@link ConvertColumnImpl}.
+ That is, only objects of the those classes can be present in the <tt>aSqlParams</tt>
+ list. A parameter may also be <tt>null</tt>.
+
+ <P> For <tt>Id</tt> 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 <tt>.sql</tt> file
+ @param aSearchCriteria is possibly <tt>null</tt>, 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 <tt>.sql</tt> files.
+ @param aSqlParams contains at least one object of the supported classes noted above;
+ <span class="highlight">the number and order of these parameter objects matches the
+ number and order of "?" parameters in the underlying SQL</span>.
+ */
+ 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 <tt>aSqlParams</tt> passed to the constructor.
+
+ <P>If the underlying database auto-generates any keys by executing the returned
+ <tt>PreparedStatement</tt>, they will be available from the returned value using
+ {@link Statement#getGeneratedKeys}.
+
+ <P>If the returned statement is a <tt>SELECT</tt>, then a limit, as configured in
+ <tt>web.xml</tt>, 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 <tt>'?'</tt> 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<Object> 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<String> 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 <tt>aSqlIdFields</tt> contains KEY - containing Class VALUE - Set of SqlId Fields
+ <P>
+ 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<String> convertToSetOfStrings(Map<Class<?>, Set<SqlId>> aSqlIdFields) {
+ Set<String> result = new LinkedHashSet<String>();
+ Set classes = aSqlIdFields.keySet();
+ Iterator classesIter = classes.iterator();
+ while (classesIter.hasNext()) {
+ Class containingClass = (Class)classesIter.next();
+ Set<SqlId> fields = aSqlIdFields.get(containingClass);
+ result.addAll(getSqlIdFieldsAsStrings(fields));
+ }
+ return result;
+ }
+
+ private static Set<String> getSqlIdFieldsAsStrings(Set<SqlId> aSqlIds) {
+ Set<String> result = new LinkedHashSet<String>();
+ for (SqlId sqlId : aSqlIds) {
+ result.add(sqlId.toString());
+ }
+ return result;
+ }
+
+ private static AppException getMismatches(Set<String> aSqlIdStrings,
+ Collection<Object> 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.
+ <P>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<String> successIds = new ArrayList<String>();
+ List<String> failIds = new ArrayList<String>();
+ Set<Object> statementIds = fSqlProperties.keySet();
+ Iterator<Object> 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));
+ }
+ }
+}
--- /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 <tt>CallableStatement</tt>s.
+
+ <P>The purpose of this class is to reduce code repetition related to
+ <tt>CallableStatement</tt>s : 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.
+
+ <P>This abstract base class is an example of the template design pattern.
+
+ <P>The two constructors of this class correspond to whether or not this task
+ is being performed as part of a transaction.
+
+ <P>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 :
+<PRE>
+ //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();
+</PRE>
+
+<P>There are many ways to retrieve data from a call to a stored procedure, <em>and
+ this task is left entirely to subclasses of </em><tt>StoredProcedureTemplate</tt>.
+
+ <P>In the rare cases where the default <tt>ResultSet</tt> properties are not adequate,
+ the <tt>customizeResultSet</tt> methods may be used to alter them.
+
+<P><em>Design Note :</em><br>
+ Although this class is still useful, it is not completely satisfactory for two
+ reasons :
+<ul>
+ <li>there are many different ways to return values from stored procedures :
+ a return value expliclty defined by the stored procedure itself,
+ <tt>OUT</tt> parameters, <tt>INOUT</tt> parameters, the <tt>executeUpdate</tt> method,
+ and the <tt>executeQuery</tt> 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.
+ <li>although this class does eliminate code repetition, the amount of code which the
+ caller needs is still a bit large.
+</ul>
+*/
+public abstract class StoredProcedureTemplate {
+
+ /**
+ Constructor for case where this task is <em>not</em> part of a transaction.
+
+ @param aTextForCallingStoredProc text such as <tt>'{call do_this(?,?)}'</tt> (for
+ more information on valid values, see
+ <a href="http://java.sun.com/j2se/1.5.0/docs/api/java/sql/CallableStatement.html">
+ CallableStatement</a>)
+ */
+ protected StoredProcedureTemplate(String aTextForCallingStoredProc) {
+ fTextForCallingStoredProc = aTextForCallingStoredProc;
+ }
+
+ /**
+ Constructor for case where this task is part of a transaction.
+
+ <P>The task performed by {@link #executeStoredProc} will use <tt>aConnection</tt>,
+ and will thus participate in any associated transaction being used by the caller.
+
+ @param aTextForCallingStoredProc text such as <tt>'{call do_this(?,?)}'</tt> (for
+ more information on valid values, see
+ <a href="http://java.sun.com/j2se/1.5.0/docs/api/java/sql/CallableStatement.html">
+ CallableStatement</a>).
+ @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;
+ }
+
+ /** <b>Template</b> 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.
+
+ <P>Implementations of this method do not fetch a connection, catch exceptions, or
+ call <tt>close</tt> methods. Those tasks are handled by this base class.
+
+ <P>See class description for an example.
+ */
+ protected abstract void executeStoredProc(CallableStatement aCallableStatement) throws SQLException;
+
+ /**
+ Change to a non-default database.
+
+ <P>Use this method to force this class to use an
+ <em>internal</em> connection a non-default database. It does not make sense to call this method when using
+ an <em>external</em> {@link Connection} - that is, when using {@link StoredProcedureTemplate#StoredProcedureTemplate(String, Connection)}.
+
+ <P>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 <tt>ResultSet</tt>, in exactly the same manner as
+ {@link java.sql.Connection#prepareCall(java.lang.String, int, int)}.
+
+ <P>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 <tt>ResultSet</tt>, in exactly the same manner as
+ {@link java.sql.Connection#prepareCall(java.lang.String, int, int, int)}.
+
+ <P>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;
+ }
+}
--- /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.
+
+ <P>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;
+
+}
--- /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.
+
+ <P>For more information on transaction isolation levels, see {@link Connection} and the
+ <a href="http://en.wikipedia.org/wiki/Isolation_%28computer_science%29">wikipedia<a/>
+ article.
+
+ <P>See {@link TxTemplate}, which is closely related to this class.
+
+ <a name="PermittedValues"></a><h3>Permitted Values</h3>
+ <P>In order of decreasing strictness (and increasing performance), the levels are :
+<ul>
+ <li><tt>SERIALIZABLE</tt> (most strict, slowest)
+ <li><tt>REPEATABLE_READ</tt>
+ <li><tt>READ_COMMITTED</tt>
+ <li><tt>READ_UNCOMMITTED</tt> (least strict, fastest)
+</ul>
+
+<P>In addition, this class includes another item called <tt>DATABASE_DEFAULT</tt>. It
+ indicates to the WEB4J data layer that, unless instructed otherwise,
+ the default isolation level defined by the database instance is to be used.
+
+ <h3>Differences In The Top 3 Levels</h3>
+ It is important to understand that the top 3 levels
+ listed above differ only in one principal respect : behavior for any
+ <em>re</em>-SELECTs performed in a transaction. <span class="highlight">If no re-SELECT is
+ performed in a given transaction, then the there is no difference in the
+ behavior of the top three levels</span> (except for performance).
+
+<P>If a SELECT is repeated in a given transaction, it may see a different
+ <tt>ResultSet</tt>, since some second transaction may have committed changes
+ to the underlying data. Three questions can be asked of the second <tt>ResultSet</tt>,
+ and each isolation level responds to these three questions in a different way :
+<P><table border=1 cellspacing="0" cellpadding="3" width="75%">
+ <tr valign="top">
+ <th>Level</th>
+ <th>1. Can a new record appear?</th>
+ <th>2. Can an old record disappear?</th>
+ <th>3. Can an old record change?</th>
+ </tr>
+ <tr><td><tt>SERIALIZABLE</tt></td><td>Never</td><td>Never</td><td>Never</td></tr>
+ <tr><td><tt>REPEATABLE_READ</tt></td><td>Possibly</td><td>Never</td><td>Never</td></tr>
+ <tr><td><tt>READ_COMMITTED</tt></td><td>Possibly</td><td>Possibly</td><td>Possibly</td></tr>
+</table>
+ <P>(Note : 1 is called a <em>phantom read</em>, while both 2 and 3 are called a
+ <em>non-repeatable read</em>.)
+
+ <h3>Configuration In <tt>web.xml</tt></h3>
+ <em>When no external <tt>Connection</tt> is passed by the application</em>, then
+ the WEB4J data layer will use an internal <tt>Connection</tt>
+ set to the isolation level configured in <tt>web.xml</tt>.
+
+<h3>General Guidelines</h3>
+<ul>
+ <li>consult both your database administrator and your database documentation
+ for guidance regarding these levels
+ <li><span class="highlight">since support for these levels is highly variable,
+ setting the transaction isolation level explicitly has low portability</span>
+ (see {@link #set} for some help in this regard). The <tt>DATABASE_DEFAULT</tt>
+ setting is an attempt to hide these variations in support
+ <li>for a WEB4J application, it is likely a good choice to use the
+ <tt>DATABASE_DEFAULT</tt>, and to alter that level only under special circumstances
+ <li>selecting a specific level is always a trade-off between level of data
+ integrity and execution speed
+</ul>
+
+<h3>Support For Some Popular Databases</h3>
+ (Taken from <em>
+ <a href="http://www.amazon.com/exec/obidos/ASIN/0596004818/ref=nosim/javapractices-20">SQL
+ in a Nutshell</a></em>, by Kline, 2004. <span class="highlight">Please confirm with
+ your database documentation</span>).<P>
+<table border=1 cellspacing="0" cellpadding="3" width="60%">
+<tr valign="top">
+ <td> </td>
+ <td>DB2</td>
+ <td>MySQL</td>
+ <td>Oracle</td>
+ <td>PostgreSQL</td>
+ <td>SQL Server</td>
+</tr>
+<tr>
+ <td><tt>SERIALIZABLE</tt></td>
+ <td>Y</td>
+ <td>Y</td>
+ <td>Y</td>
+ <td>Y</td>
+ <td>Y</td>
+</tr>
+<tr>
+ <td><tt>REPEATABLE_READ</tt></td>
+ <td>Y</td>
+ <td>Y*</td>
+ <td>N</td>
+ <td>N</td>
+ <td>Y</td>
+</tr>
+<tr>
+ <td><tt>READ_COMMITTED</tt></td>
+ <td>Y</td>
+ <td>Y</td>
+ <td>Y*</td>
+ <td>Y*</td>
+ <td>Y*</td>
+</tr>
+<tr>
+ <td><tt>READ_UNCOMMITTED</tt></td>
+ <td>Y</td>
+ <td>Y</td>
+ <td>N</td>
+ <td>N</td>
+ <td>Y</td>
+</tr>
+</table>
+ ∗ Database Default<br>
+*/
+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 <tt>int</tt> value used by {@link Connection} to identify the isolation level.
+
+ <P>For {@link #DATABASE_DEFAULT}, return <tt>-1</tt>.
+ */
+ public int getInt(){
+ return fIntValue;
+ }
+
+ /** Return one of the <a href="#PermittedValues">permitted values</a>, including <tt>'DATABASE_DEFAULT'</tt>. */
+ public String toString(){
+ return fText;
+ }
+
+ /**
+ Set a particular isolation level for <tt>aConnection</tt>.
+
+ <P>This method exists because database support for isolation levels varies
+ widely.<span class="highlight"> If any error occurs because <tt>aLevel</tt> is not supported, then
+ the error will be logged at a <tt>SEVERE</tt> level, but
+ the application will continue to run</span>. 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.
+
+ <P>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);
+}
--- /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.
+
+ <P>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 <tt>INSERT</tt>, <tt>DELETE</tt>, or <tt>UPDATE</tt> operation.
+ No <tt>SELECT</tt>s are allowed.
+
+ <P><span class="highlight">It is likely that a significant fraction (perhaps as high as 50%) of all
+ transactions can be implemented with this class. </span>
+
+ <P>Example use case:
+ <PRE>
+ 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();
+ </PRE>
+
+ <P>In this example, <tt>blah</tt> will usually represent a Model Object. The data in <tt>params</tt>
+ is divided up among the various <tt>sqlIds</tt>. (This division is performed internally by this class.)
+
+ <P>This class uses strong ordering conventions. <span class="highlight">The order of
+ items in each array passed to the constructor is very important</span> (see {@link #TxSimple(SqlId[], Object[])}).
+*/
+public final class TxSimple implements Tx {
+
+ /**
+ Constructor.
+
+ <P><tt>aAllParams</tt> includes the parameters for all operations, concatenated in a single array. Has at least
+ one member.
+
+ <P>In general, successive pieces of <tt>aAllParams</tt> are used to populate each corresponding
+ statement, in their order of execution. The order of items is here <em>doubly</em> 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.)
+
+ <P>And as usual, within each subset of <tt>aAllParams</tt>
+ corresponding to an SQL statement, the order of parameters matches the order
+ of <tt>'?'</tt> placeholders appearing in the underlying SQL statement.
+
+ @param aSqlIds identifiers for the operations to be performed, <em>in the order of their intended execution</em>. 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<SqlId, Object[]>();
+ divideUpParams();
+ }
+
+ public int executeTx() throws DAOException {
+ Tx transaction = new SimpleTransaction(getDbName());
+ return transaction.executeTx();
+ }
+
+ // PRIVATE //
+ private SqlId[] fSqlIds;
+ private Object[] fParams;
+ private Map<SqlId, Object[]> 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));
+ }
+ }
+}
--- /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.
+
+ <P>This abstract base class implements the template method design pattern.
+
+ <P>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.
+
+ <P>See {@link TxIsolationLevel} for remarks on selection of correct isolation level. The {@link DbTx} class
+ is often useful for implementors.
+
+ <P>Do not use this class in the context of a <tt>UserTransaction</tt>.
+
+ <h3>Example Use Case</h3>
+ A DAO method which uses a <tt>TxTemplate</tt> called <tt>AddAllUnknowns</tt> to perform multiple <tt>INSERT</tt> operations :
+<PRE>
+{@code
+public int addAll(Set<String> aUnknowns) throws DAOException {
+ Tx addTx = new AddAllUnknowns(aUnknowns);
+ return addTx.executeTx();
+}
+ }
+</PRE>
+
+ The <tt>TxTemplate</tt> class itself, defined inside the same DAO, as an inner class :
+<PRE>
+{@code
+ private static final class AddAllUnknowns extends TxTemplate {
+ AddAllUnknowns(Set<String> 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<String> fUnknowns;
+ private void addUnknown(String aUnknown, Connection aConnection) throws DAOException {
+ DbTx.edit(aConnection, UnknownBaseTextEdit.ADD, aUnknown);
+ }
+ }
+ }
+</PRE>
+*/
+public abstract class TxTemplate implements Tx {
+
+ /**
+ Constructor for a transaction versus the default database, at the
+ default isolation level.
+
+ <P>The default transaction isolation level is configured in <tt>web.xml</tt>.
+ */
+ public TxTemplate(){
+ fDatabaseName = DEFAULT_DB;
+ fTxIsolationLevel = fConfig.getSqlEditorDefaultTxIsolationLevel(DEFAULT_DB);
+ }
+
+ /**
+ Constructor for transaction versus the default database, at a custom
+ isolation level.
+
+ <P>The default transaction isolation level is configured in <tt>web.xml</tt>.
+ */
+ 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 <tt>web.xml</tt>.
+
+ @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.
+
+ <P>The default transaction isolation level is configured in <tt>web.xml</tt>.
+
+ @param aDatabaseName one of the return values of {@link ConnectionSource#getDatabaseNames()}
+ */
+ public TxTemplate(String aDatabaseName, TxIsolationLevel aTxIsolationLevel){
+ fDatabaseName = aDatabaseName;
+ fTxIsolationLevel = aTxIsolationLevel;
+ }
+
+ /**
+ <b>Template</b> method calls the abstract method {@link #executeMultipleSqls}.
+ <P>Returns the same value as <tt>executeMultipleSqls</tt>.
+
+ <P>A <tt>rollback</tt> is performed if <tt>executeMultipleSqls</tt> 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.
+
+ <P>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.)
+
+ <P><em>Design Note</em>: allowing <tt>SQLException</tt> in the <tt>throws</tt>
+ clause simplifies the implementor significantly, since no <tt>try-catch</tt> 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);
+ }
+ }
+}
--- /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 <em>first</em> column of a <tt>ResultSet</tt>.
+
+ <P>This implementation violates the rule that all columns must be used to construct the
+ target object.
+*/
+final class ValueFromRow<E> extends ModelBuilder<E>{
+
+ ValueFromRow(Class<E> aSupportedTargetClass){
+ fClass = aSupportedTargetClass;
+ }
+
+ E build(ResultSet aRow) throws SQLException {
+ return fColumnToObject.convert(aRow, FIRST_COLUMN, fClass);
+ }
+
+ // PRIVATE //
+ private final Class<E> fClass;
+
+ /** Translates column values into desired objects. */
+ private ConvertColumn fColumnToObject = BuildImpl.forConvertColumn();
+
+ private static final int FIRST_COLUMN = 1;
+}
--- /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 = <input type="checkbox" name="blah" value="blah" readonly>
+ render_true_html = <input type="checkbox" name="blah" value="blah" checked readonly>
+}
+
+-- 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}
+}
+
--- /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 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+ <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+ <meta name="Author" content="Hirondelle Systems">
+ <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+ <title>Datastore</title>
+</head>
+<body>
+Interaction with the database(s).
+
+<p>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.
+
+<P>An application is not required to use this package to implement
+persistence. Other persistence tools can be used instead, if desired.
+
+<p><span class="highlight">Please see the Data Access Object (DAOs) of the
+example application for effective illustration of how to use the services of
+this package.</span>
+
+<P>Many DAO methods can be implemented using only two classes :
+<ul>
+<li>{@link hirondelle.web4j.database.Db} - common utility methods
+<li>{@link hirondelle.web4j.database.SqlId} - identifiers for underlying SQL statements in <a href="#SqlFiles">.sql files</a>
+</ul>
+
+
+The WEB4J data layer :
+<ul>
+<li>uses one or more relational databases
+<li>uses simple <a href="#SqlFiles">.sql text files</a>, containing SQL statements
+<li><em>does not use any .xml files</em>
+<li><em>does not use any object-relational mapping</em>
+<li><em>does not use any annotations</em>
+<li>is instructed on how to obtain a <tt>Connection</tt> through the application's implementation of {@link hirondelle.web4j.database.ConnectionSource}
+<li>uses only <tt>PreparedStatement</tt>s, since they are <a href="http://www.javapractices.com/Topic212.cjp">preferred</a>
+<li>is configured using some items in <tt>web.xml</tt>
+<li>can precompile SQL statements upon startup (if supported by the driver). This allows syntax errors to be found upon startup.
+<li>allows many DAO methods to be implemented in just one or two lines of code
+</ul>
+
+<P>This package does not currently use distributed transactions <em>internally</em>. However, since a
+{@link hirondelle.web4j.database.ConnectionSource} can return a <tt>Connection</tt> for any database,
+the caller can still implement operations against multiple databases, including distributed
+transactions, if desired.
+
+<h2><a name="OrderingConvention">Ordering Convention</h2>
+When creating Model Objects from a <tt>ResultSet</tt>, WEB4J is unusual since it does <em>not</em> use a
+naming convention to map columns. Instead, it uses a much more effective <em>ordering</em>
+convention. This turns out to be a significant advantage for the caller, since trivial column
+mapping is no longer required.
+
+<P>(Ordering conventions seem to be very effective for human understanding. For example, would you rather
+do arithmetic with roman numerals, which use a <em>naming</em> convention to define magnitudes,
+or hindu-arabic numerals, which use an <em>ordering</em> convention to define place value?)
+
+<P><span class="highlight">The convention is that the N columns
+of the <tt>ResultSet</tt> must map, in order, to the N arguments passed to
+a constructor of the Model Object.</span> This is not as restrictive as it first sounds,
+since the order of the <tt>ResultSet</tt> columns is controlled by the application's
+SQL statement, and <em>not</em> by the structure of the underlying table.
+
+<P>More specifically : when building a Model Object from a <tt>ResultSet</tt>, the number of columns in the
+<tt>ResultSet</tt> is used to find the appropriate <tt>public</tt> 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 <tt>Integer</tt>,
+<tt>Date</tt>, and so on, and passed to the Model Object constructor.
+
+<P>Many authors recommended that the <tt>SELECT * FROM X</tt> 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.
+
+
+<h2><a name="SqlFiles"></a>The <tt>.sql</tt> Files</h2>
+<a href="#BasicIdea">Basic Idea</a><br>
+<a href="#DesignBenefits">Benefits Of This Design</a><br>
+<a href="#NameLocation">File Name And Location</a><br>
+<a href="#StartupProcessing">Startup Processing</a><br>
+<a href="#Portability">Portability</a><br>
+<a href="#PassingParameters">Passing Parameters</a><br>
+<a href="#DetailedSyntax">Detailed Syntax For <tt>.sql</tt> Files</a><br>
+<a href="#StoredProcedures">Stored Procedures</a><br>
+
+<h3><a name="BasicIdea">Basic Idea</a></h3>
+The WEB4J data layer uses text <tt>.sql</tt> files. SQL statements are not placed directly in classes.
+Instead, they are placed in regular text files (of a certain format), and <em>referenced</em>
+from code using {@link hirondelle.web4j.database.SqlId} objects.
+
+<P>Here is a quick example.
+
+<P>An entry in an <tt>.sql</tt> file :
+<PRE>
+MEMBER_FETCH {
+ SELECT Id, Name, IsActive FROM Member WHERE Id=?
+}
+</PRE>
+This SQL statement is referenced in code using an {@link hirondelle.web4j.database.SqlId} object, created using a
+corresponding <tt>String</tt> identifier <tt>"MEMBER_FETCH"</tt>. Each <tt>SqlId</tt> must be defined as a
+<tt>public static final</tt> field:
+<PRE>
+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);
+}
+</PRE>
+
+That is the basic idea.
+
+<P>(When this technique is used, most implementations of DAO methods are compact - usually just
+one or two lines, as shown above.)
+
+<h3><a name="DesignBenefits">Benefits Of This Design</a></h3>
+This design has many benefits :
+<ul>
+ <li><span class="highlight">it uses no tedious object-relational mapping, no .xml files, and no annotations.</span>
+ This greatly reduces the effort needed to implement DAOs.
+ <li>SQL statements created and verified in other tools can be easily copied over,
+ with <em>very few</em> textual edits
+ <li><span class='highlight'>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.</span>
+ <li>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.
+ <li>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.)
+</ul>
+
+<h3><a name="NameLocation">File Name And Location</a></h3>
+All files under the <tt>WEB-INF</tt> directory whose name matches the regular
+expression "<tt>(?:.)*\\.sql</tt>"
+will be treated as <tt>.sql</tt> files, and will be read in upon startup.
+
+<P>Examples of valid <tt>*.sql</tt> file names include :
+<ul>
+<li><tt>statements.sql</tt>
+<li><tt>config.sql</tt>
+<li><tt>REPORTS.sql</tt>
+<li><tt>pick-lists.sql</tt>
+<li><tt>accounts.payable.sql</tt>
+<li><tt>2005-06-15blah0-_$blah.sql</tt>
+</ul>
+Any other files under <tt>WEB-INF</tt> will be <em>ignored</em> by this mechanism. Note that
+a file named <tt>something.SQL</tt> (upper case <tt>.SQL</tt>), will <em>not</em> be
+read by WEB4J upon startup. (This is a quick way of disabling a given file.)
+
+<P>It is recommended to use more than one <tt>*.sql</tt> file, to allow :
+<ul>
+<li>placing <tt>.sql</tt> files in the same package as the Data Access Object
+ that uses it (<a href="http://www.javapractices.com/Topic205.cjp">package-by-feature</a>)
+<li>eliminating or reducing developer contention for commonly needed files
+<li>splitting up a large file into various parts of more reasonable size
+<li>swapping files that target different databases
+</ul>
+
+<P>If you use one <tt>.sql</tt> file per directory/feature, then it's recommended to consider using a
+fixed, conventional name such as <tt>statements.sql</tt>.
+
+<h3><a name="StartupProcessing">Startup Processing</a> Of <tt>.sql</tt> Files</h3>
+Upon startup, WEB4J will read in all <tt>.sql</tt> files under the WEB-INF directory, and will
+match entries to {@link hirondelle.web4j.database.SqlId} objects - <a href="SqlId.html#StartupChecks">more info</a>.
+
+<P>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 <tt>web.xml</tt> called <tt>IsSQLPrecompilationAttempted</tt> which
+turns this behavior on and off.
+
+<h3><a name="Portability">Portability</a></h3>
+To maximize portability, SQL statements should be validated using a tool such as
+the Mimer SQL-92
+<a href="http://developer.mimer.se/validator/parser92/index.tml#parser">validator</a>.
+
+<h3><a name="PassingParameters">Passing Parameters</a></h3>
+There are constraints on how an application passes parameters to SQL statements.
+They are listed in <a href="Db.html#Parameters">Db</a>.
+
+<h3><a name="DetailedSyntax">Detailed Syntax For .sql Files</a></h3>
+Here is an example of the syntax expected by WEB4J for <tt>.sql</tt> files.
+<PRE>
+-- 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}
+}
+</PRE>
+
+<P>
+To describe the syntax more precisely, the following terminology is used here :
+<ul>
+ <li>Block - a multiline block of text with within braces, with an associated
+ identifier (similar to a typical Java block)
+ <li>Block Name - the identifier of a block, appearing on the first line, before the
+ opening brace
+ <li>Block Body - the text appearing between the opening and closing braces.
+</ul>
+
+<P>The format details are as follows :
+<ul>
+ <li>empty lines can appear only outside of blocks
+ <li>the '<tt>--</tt>' character denotes a comment
+ <li>there are no multiline comments. (Such comments are easier for the writer,
+ but are much less clear for the reader.)
+ <li>the body of each item is placed in a named block, bounded by
+ '<tt><name> {</tt>' on an initial line, and '<tt>}</tt>' on an end line.
+ The name given to the block (<tt>ADD_MESSAGE</tt> for example) is how
+ <tt>WEB4J</tt> identifies each block. The Block Name must correspond
+ to a <tt>public static final SqlId</tt> field. Upon startup, this allows verification
+ that all items defined in the text source have a corresponding item in code.
+ <li>'<tt>--</tt>' comments can appear in the Block Body as well
+ <li>if desired, the Block Body may be indented, to make the Block more legible
+ <li>the Block Name of '<tt>constants</tt>' is reserved. A <tt>constants</tt>
+ block defines one or more simple textual substitution constants, as
+ <tt>name = value</tt> 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 <em>later</em> in the file. Any number of <tt>constants</tt>
+ blocks can appear in a file.
+ <li>Block Names and the names of constants satisfy
+ {@link hirondelle.web4j.database.SqlId#FORMAT}.
+ <li>inside a Block Body, substitutions are denoted by the common
+ syntax '<tt>${blah}</tt>', where <tt>blah</tt> refers to an item appearing
+ earlier in the file, either <em>the content
+ of a previously defined Block</em>, or the value of a constant defined in a
+ <tt>constants</tt> Block
+ <li>an item must be defined before it can be used in a substitution ; that is,
+ it must appear <em>earlier</em> in the file
+ <li>no substitutions are permitted in a <tt>constants</tt> Block
+ <li>Block Names, and the names of constants, should be unique.
+</ul>
+
+<h3><a name="StoredProcedures">Stored Procedures</a></h3>
+For calling stored procedures in general, please see {@link hirondelle.web4j.database.StoredProcedureTemplate}.
+
+<P><em>If the stored procedure is particularly simple in nature</em>, it may also be referenced in an
+<tt>.sql</tt> 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 <tt>.sql</tt> file,
+and be treated by WEB4J as any other SQL statement.
+
+<P>More specifically, <em>such stored procedures have no
+ <tt>OUT</tt> parameters of any kind, either <tt>OUT</tt>, <tt>INOUT</tt>, or an
+ explicit return value</em> (which must be registered as an <tt>OUT</tt>). On the
+ other hand, they <em>must have a single implicit return value</em>. Here, 'implicit
+ return value' refers to the return values of <tt>executeQuery</tt> and
+ <tt>executeUpdate</tt> (either a <tt>ResultSet</tt> or an <tt>int</tt> count,
+ respectively).
+
+</body>
+</html>
--- /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.
+
+ <P>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.
+
+ <P>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<AppResponseMessage> getMessages(){
+ return fErrorMessages.getMessages();
+ }
+
+ /** Intended for debugging only. */
+ @Override public String toString(){
+ return fErrorMessages.toString();
+ }
+
+ // PRIVATE
+
+ /**
+ List of error messages attached to this exception.
+ <P>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();
+}
--- /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.
+
+ <P>This class exists in order to hide the difference between <em>simple</em> and
+ <em>compound</em> messages.
+
+ <P><a name="SimpleMessage"></a><b>Simple Messages</b><br>
+ Simple messages are a single {@link String}, such as <tt>'Item deleted successfully.'</tt>.
+ They are created using {@link #forSimple(String)}.
+
+ <P><a name="CompoundMessage"></a><b>Compound Messages</b><br>
+ 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}. <span class="highlight">
+ However, <tt>MessageFormat</tt> is not used by this class, to avoid the following issues </span>:
+<ul>
+ <li> the dreaded apostrophe problem. In <tt>MessageFormat</tt>, 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 <tt>MessageFormat</tt>.)
+ <li>the <tt>{0}</tt> placeholders start at <tt>0</tt>, not <tt>1</tt>. Again, this is
+ unnatural for translators.
+ <li>the number of parameters cannot exceed <tt>10</tt>. (Granted, it is not often
+ that a large number of parameters are needed, but there is no reason why this
+ restriction should exist.)
+ <li>in general, {@link MessageFormat} is rather complicated in its details.
+</ul>
+
+ <P><a name="CustomFormat"></a><b>Format of Compound Messages</b><br>
+ This class defines an alternative format to that defined by {@link java.text.MessageFormat}.
+ For example,
+ <PRE>
+ "At this restaurant, the _1_ meal costs _2_."
+ "On _2_, I am going to Manon's place to see _1_."
+ </PRE>
+ Here,
+<ul>
+ <li>the placeholders appear as <tt>_1_</tt>, <tt>_2_</tt>, and so on.
+ They start at <tt>1</tt>, not <tt>0</tt>, and have no upper limit. There is no escaping
+ mechanism to allow the placeholder text to appear in the message 'as is'. The <tt>_i_</tt>
+ placeholders stand for an <tt>Object</tt>, and carry no format information.
+ <li>apostrophes can appear anywhere, and do not need to be escaped.
+ <li>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.)
+ <li>the number of parameters passed at runtime must match exactly the number of <tt>_i_</tt>
+ placeholders
+</ul>
+
+ <P><b>Multilingual Applications</b><br>
+ Multilingual applications will need to ensure that messages can be successfully translated when
+ presented in JSPs. In particular, some care must be exercised to <em>not</em> create
+ a <em>simple</em> message out of various pieces of data when a <em>compound</em> message
+ should be used instead. See {@link #getMessage(Locale, TimeZone)}.
+ As well, see the <a href="../ui/translate/package-summary.html">hirondelle.web4j.ui.translate</a>
+ package for more information, in particular the
+ {@link hirondelle.web4j.ui.translate.Messages} tag used for rendering <tt>AppResponseMessage</tt>s,
+ even in single language applications.
+
+ <P><b>Serialization</b><br>
+ This class implements {@link Serializable} to allow messages stored in session scope to
+ be transferred over a network, and thus survive a failover operation.
+ <i>However, this class's implementation of Serializable interface has a minor defect.</i>
+ This class accepts <tt>Object</tt>s 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.
+
+ <P>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 {
+
+ /**
+ <a href="#SimpleMessage">Simple message</a> having no parameters.
+ <tt>aSimpleText</tt> must have content.
+ */
+ public static AppResponseMessage forSimple(String aSimpleText){
+ return new AppResponseMessage(aSimpleText, NO_PARAMS);
+ }
+
+ /**
+ <a href="#CompoundMessage">Compound message</a> having parameters.
+
+ <P><tt>aPattern</tt> follows the <a href="#CustomFormat">custom format</a> defined by this class.
+ {@link Formats#objectToTextForReport} will be used to format all parameters.
+
+ @param aPattern must be in the style of the <a href="#CustomFormat">custom format</a>, and
+ the number of placeholders must match the number of items in <tt>aParams</tt>.
+ @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 <em>formatted</em> pattern with all parameter data rendered,
+ according to which factory method was called.
+
+ <P>The configured {@link Translator} is used to localize
+ <ul>
+ <li>the text passed to {@link #forSimple(String)}
+ <li>the pattern passed to {@link #forCompound(String, Object...)}
+ <li>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.)
+ </ul>
+
+ <P>It is highly recommended that this method be called <em>late</em> in processing, in a JSP.
+
+ <P>The <tt>Locale</tt> should almost always come from
+ {@link hirondelle.web4j.BuildImpl#forLocaleSource()}.
+ The <tt>aLocale</tt> 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<String> formattedParams = new ArrayList<String>();
+ 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 <tt>List</tt> corresponding to the <tt>aParams</tt> passed to
+ the constructor.
+
+ <P>If no parameters are being used, then return an empty list.
+ */
+ public List<Object> getParams(){
+ return Collections.unmodifiableList(fParams);
+ }
+
+ /**
+ Return either the 'simple text' or the pattern, according to which factory method
+ was called. Typically, this method is <em>not</em> 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<Object> 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 <tt>i</tt> of the
+ List matches the <tt>_i_</tt> placeholder. The size of aFormattedParams must match the number of
+ placeholders.
+ */
+ private String populateParamsIntoCustomFormat(String aPattern, List<String> 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<String> 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();
+ }
+}
--- /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.
+
+ <P><span class="highlight">This class is intended only for bugs and malicious attacks.
+ It is not intended for normal business logic.</span> If a <tt>BadRequestException</tt> 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.
+
+ <P>See {@link hirondelle.web4j.security.ApplicationFirewall} for more information.
+
+ <P><em>Design Note</em>
+ <br>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.
+
+ <P>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).
+
+ <P>See <a href="http://www.w3.org/Protocols/rfc2616/rfc2616.html">W3C HTTP Specification</a>
+ 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 <tt>null</tt>.
+ */
+ 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;
+}
--- /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;
+
+/**
+ <span class="highlight">Returns commonly needed {@link Validator} objects.</span>
+
+ <P>In general, the number of possible validations is <em>very</em> large. It is not appropriate
+ for a framework to attempt to implement <em>all</em> possible validations. Rather, a framework should
+ provide the most common validations, and allow the application programmer to extend
+ the validation mechanism as needed.
+
+ <P>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.
+
+ <P>If a specific validation is not provided here, other options include :
+<ul>
+ <li>performing the validation directly in the Model Object, without using <tt>Check</tt> or
+ a {@link Validator}
+ <li>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.
+ <li>subclassing this class, and adding new <tt>static</tt> methods
+ </ul>
+
+ <P>The {@link #range(long, long)}, {@link #min(long)} and {@link #max(long)} methods return {@link Validator}s
+ that perform checks on a <tt>long</tt> value. <span class='highlight'>The <em>source</em> of the <tt>long</tt> value varies
+ according to the type of <tt>Object</tt> passed to the {@link Validator}</span>, and is taken as follows
+ (<tt>int</tt> is internally converted to <tt>long</tt> when necessary) :
+ <ul>
+ <li>{@link Integer#intValue()}
+ <li>{@link Long#longValue()}
+ <li>length of a trimmed {@link String} having content; the same is applied to {@link Id#toString()},
+ {@link Code#getText()}, and {@link SafeText#getRawString()}.
+ <li>{@link Collection#size()}
+ <li>{@link Map#size()}
+ <li>{@link Date#getTime()} - underlying millisecond value
+ <li>{@link Calendar#getTimeInMillis()} - underlying millisecond value
+ <li>any other class will cause an exception to be thrown by {@link #min(long)} and {@link #max(long)}
+ </ul>
+
+ <P>The {@link #required(Object)}, {@link #required(Object, Validator...)} and {@link #optional(Object, Validator...)}
+ methods are important, and are separated out as distinct validations. <span class='highlight'>In addition, the
+ required/optional character of a field is always the <em>first</em> validation performed</span> (see examples below).
+
+ <P><span class="highlight">In general, it is highly recommended that applications
+ aggressively perform all possible validations.</span>
+
+ <P> 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 <tt>ResultSet</tt>.
+
+ <P><b>Example 1</b><br>
+ Example of a required field in a Model Object (that is, the field is of any type, and
+ must be non-<tt>null</tt>) :
+ <PRE>
+if ( ! Check.required(fStartDate) ) {
+ ex.add("Start Date is Required.");
+}
+ </PRE>
+
+
+ <P><b>Example 2</b><br>
+ Example of a required <em>text</em> field, which must have visible content
+ (as in {@link Util#textHasContent(String)}) :
+ <PRE>
+if ( ! Check.required(fTitle) ) {
+ ex.add("Title is required, and must have content.");
+}
+ </PRE>
+
+ <P><b>Example 3</b><br>
+ Example of a required text field, whose length must be in the range <tt>2..50</tt> :
+ <PRE>
+if ( ! Check.required(fTitle, Check.range(2,50)) ) {
+ ex.add("Title is required, and must have between 2 and 50 characters.");
+}
+ </PRE>
+
+ <P><b>Example 4</b><br>
+ Example of an optional <tt>String</tt> field that matches the format '<tt>1234-5678</tt>' :
+ <PRE>
+//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'.");
+}
+ </PRE>
+
+ <P><b>Example 5</b><br>
+ The initial <tt>!</tt> negation operator is easy to forget. Many will prefer a more explicit style, which seems
+ to be more legible :
+ <PRE>
+import static hirondelle.web4j.util.Consts.FAILS;
+...
+if ( FAILS == Check.required(fStartDate) ) {
+ ex.add("Start Date is Required.");
+}
+ </PRE>
+
+ <P>Here is one style for implementing custom validations for your application :
+ <PRE>
+//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();
+}
+ </PRE>
+*/
+public class Check {
+
+ /**
+ Return <tt>true</tt> only if <tt>aObject</tt> is non-<tt>null</tt>.
+
+ <P><em><tt>String</tt> and {@link SafeText} objects are a special case</em> : instead of just
+ being non-<tt>null</tt>, 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 <tt>true</tt> only if <tt>aObject</tt> satisfies {@link #required(Object)},
+ <em>and</em> 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 <tt>true</tt> only if <tt>aObject</tt> is <tt>null</tt>, OR if <tt>aObject</tt> is non-<tt>null</tt>
+ and passes all validations.
+
+ <P><em><tt>String</tt> and {@link SafeText} objects are a special case</em> : instead of just
+ being non-<tt>null</tt>, 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 <tt>true</tt>.
+ Note that the single parameter is a sequence parameter, so you may pass in many booleans, not just one.
+ <P>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 <tt>false</tt>.
+ Note that the single parameter is a sequence parameter, so you may pass in many booleans, not just one.
+ <P>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 <tt>aMinimumValue</tt>.
+ 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 <tt>aMinimumValue</tt>.
+ */
+ 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 <tt>aMinimumValue</tt>. This methods allows comparisons between
+ <tt>Money</tt> 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 <tt>aMinimumValue</tt>.
+ */
+ 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 <tt>aMaximumValue</tt>.
+ 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 <tt>aMaximumValue</tt>.
+ */
+ 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 <tt>aMaximumValue</tt>. This methods allows comparisons between
+ <tt>Money</tt> 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 <tt>aMaximumValue</tt>.
+ */
+ 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 <tt>Money</tt> 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 <em>less than or equal to</em> <tt>aMaxNumberOfDecimalPlaces</tt>.
+
+ @param aMaxNumberOfDecimalPlaces is greater than or equal to <tt>1</tt>.
+ */
+ 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 <em>exactly equal to</em> <tt>aNumDecimals</tt>.
+
+ @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}.
+
+ <P>This method might be used to validate a zip code, phone number, and so on - any text which has a
+ well defined format.
+
+ <P>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.
+
+ <P>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.
+
+ <P>This constructor exists only because of it has <tt>protected</tt> scope.
+ Having <tt>protected</tt> scope has two desirable effects:
+ <ul>
+ <li>typical callers cannot create <tt>Check</tt> objects. This is appropriate since this class contains
+ only static methods.
+ <li>if needed, this class may be subclassed. This is useful when you need to add custom validations.
+ </ul>
+ */
+ 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;
+ }
+}
--- /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.
+
+ <P>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.
+
+ <P>Please see the example application for an example of one way of implementing code tables.
+
+ <h3>Code Tables</h3>
+ <P>A code table is an informal term describing a set of related values. It resembles
+ an enumeration. Simple examples :
+<ul>
+ <li>geographical divisions - countries, states or provinces
+ <li>list of accepted credit cards - Mastercard, Visa, and so on
+ <li>the account types offered by a bank - chequing, savings, and so on
+</ul>
+
+ Other important aspects of code tables :
+ <ul>
+ <li>most applications use them.
+ <li>they often appear in the user interface as drop-down <tt>SELECT</tt> controls.
+ <li>they usually don't change very often.
+ <li>they may have a specific sort order, unrelated to alphabetical ordering.
+ <li>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.
+ <li>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.
+ <li>they are usually closely related to various foreign keys in the database.
+ </ul>
+
+ <h3>Underlying Data</h3>
+ Code tables may be implemented in various ways, including
+ <ul>
+ <li>database tables constructed explicitly for that purpose
+ <li>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.
+ <li>simple in-memory data. For example, a <tt>1..10</tt> rating system might use simple <tt>Integer</tt>
+ objects created upon startup.
+ </ul>
+
+ <h3>Avoiding Double-Escaping</h3>
+ This class uses {@link SafeText}, which escapes special characters.
+ When rendering a <tt>Code</tt> object in a JSP, some care must be taken to ensure that
+ special characters are not mistakenly escaped <em>twice</em>.
+
+ <P>In a single language app, it's usually safe to render a <tt>Code</tt> by simply using <tt>${code}</tt>. This
+ calls {@link #toString()}, which returns escaped text, safe for direct rendering in a JSP.
+
+ <P>In a multilingual app, however, the various translation tags
+ (<tt><w:txt>, <w:txtFlow>, <w:tooltip></tt>) <em>already escape special characters</em>.
+ So, if a translation tag encounters a <tt>Code</tt> somewhere its body, the <tt>Code</tt> must be in
+ an <em>unescaped</em> form, otherwise it wil be escaped <em>twice</em>, which undesirable.
+ <span class='highlight'>In a multilingual app, you should usually render a <tt>Code</tt> using <tt>${code.text.rawString}</tt>.</span>
+
+ <P>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 <tt>Code</tt> class
+ whose <tt>toString</tt> returns a <tt>String</tt> 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 <tt>ORDER BY</tt> 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().
+
+ <P>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();
+ }
+}
--- /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.
+
+ <P>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.
+
+<h3> <a name="BuildingBlock"></a>Building Block Classes</h3>
+ Here, a <em>building block</em> class is one of the 'base' objects from which Model
+ Objects can in turn be built - <tt>Integer</tt>, <tt>BigDecimal</tt>, <tt>Date</tt>,
+ 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.
+
+ <P>{@link ConvertParamImpl} is provided as a default implementation, and is likely
+ suitable for many applications.
+
+ <P>
+ In addition, implementations may optionally choose to apply <em>universal pre-processing</em>
+ 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 <tt>aTargetClass</tt> is a supported <a href="#BuildingBlock">building block</a> class.
+
+ <P>If <tt>aTargetClass</tt> is supported, then an underlying request parameter can be converted into
+ an object of that class.
+
+ <P>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 <tt>null</tt>, empty, or equal to
+ <tt>IgnorableParamValue</tt> in <tt>web.xml</tt>.
+ <P>
+ When a 'blank' form is submitted, items are not treated in a
+ uniform manner. For example, a popular browser exhibits this behavior :
+ <ul>
+ <li>blank text area : param submitted, empty <tt>String</tt>
+ <li>radio button, with none selected : no param submitted (<tt>null</tt>)
+ <li>checkbox, unselected : no param submitted (<tt>null</tt>)
+ <li>drop-down selection, with first pre-selected by browser : param submitted, with value
+ </ul>
+ <P>
+ Moreover, the W3C <a href=http://www.w3.org/TR/html4/interact/forms.html#successful-controls>spec</a> seems
+ to allow for some ambiguity in what exactly is posted, so the above behavior may not be
+ seen for all browsers.
+
+ <P>This method is used to impose uniformity upon all such 'blank' items.
+
+ <P><span class="highlight">This method can return <tt>null</tt>. Any non-<tt>null</tt>
+ values returned by this method must have content.</span> (That is, an implementation cannot return a
+ <tt>String</tt> 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 <a href="#BuildingBlock">building block</a> object.
+ <P>
+ The value passed to this method is first <em>filtered</em>, using {@link #filter(String)}.
+ <span class="highlight">Implementations must throw a {@link ModelCtorException} when a parsing problem occurs</span>.
+ <P>
+ @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> T convert(String aFilteredParamValue, Class<T> aSupportedTargetClass, Locale aLocale, TimeZone aTimeZone) throws ModelCtorException;
+}
--- /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 <tt>Integer</tt>, <tt>Date</tt>, and so on.
+
+ <P>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.
+
+ <P>On behalf of the application, {@link RequestParser} and related classes will parse
+ user input into various target types such as <tt>Integer</tt>, <tt>Date</tt> 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 <tt>try..catch</tt> block has
+ already been written.
+
+ <P>WEB4J performs such parsing as a <a href="../request/ApplicationFirewall.html#SoftValidation">soft validation</a>.
+ For example, if user input should be an {@link Integer} in the range <tt>0..150</tt>, then
+ WEB4J will attempt to parse the raw user input (a <tt>String</tt>) first into an
+ {@link Integer}. If that parse <em>succeeds</em>,
+ then WEB4J will pass the <tt>Integer</tt> 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 <tt>0..150</tt>.
+ 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.
+
+ <P>However, if user input <em>cannot be parsed</em> 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.
+
+ <P>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);
+
+}
--- /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 <tt>true</tt> only if <tt>aTargetClass</tt> is supported by this implementation.
+ <P>
+ The following classes are supported by this implementation as building block classes:
+ <ul>
+ <li><tt>{@link SafeText}</tt>
+ <li><tt>String</tt> (conditionally, see below)
+ <li><tt>Integer</tt>
+ <li><tt>Long</tt>
+ <li><tt>Boolean</tt>
+ <li><tt>BigDecimal</tt>
+ <li><tt>{@link Decimal}</tt>
+ <li><tt>{@link Id}</tt>
+ <li><tt>{@link DateTime}</tt>
+ <li><tt>java.util.Date</tt>
+ <li><tt>Locale</tt>
+ <li><tt>TimeZone</tt>
+ <li><tt>InputStream</tt>
+ </ul>
+
+ <P><i>You are not obliged to use this class to model Locale and TimeZone.
+ Many will choose to implement them as just another
+ <a href='http://www.web4j.com/UserGuide.jsp#StartupTasksAndCodeTables'>code table</a>
+ instead.</i> 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.
+
+ <P><b>String is supported only when explicitly allowed.</b>
+ The <tt>AllowStringAsBuildingBlock</tt> setting in <tt>web.xml</tt>
+ controls whether or not this class allows <tt>String</tt> as a supported class.
+ By default, its value is <tt>FALSE</tt>, since {@link SafeText} is the recommended
+ replacement for <tt>String</tt>.
+ */
+ 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 <tt>null</tt>.
+
+ <P>In addition, any raw input value that matches <tt>IgnorableParamValue</tt> in <tt>web.xml</tt> is
+ also coerced to <tt>null</tt>. See <tt>web.xml</tt> for more information.
+
+ <P>Any non-<tt>null</tt> 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.
+
+ <P>Roughly, the policies are:
+ <ul>
+ <li><tt>SafeText</tt> uses {@link SafeText#SafeText(String)}
+ <li><tt>String</tt> just return the filtered value as is
+ <li><tt>Integer</tt> uses {@link Integer#Integer(String)}
+ <li><tt>BigDecimal</tt> uses {@link Formats#getDecimalInputFormat()}
+ <li><tt>Decimal</tt> uses {@link Formats#getDecimalInputFormat()}
+ <li><tt>Boolean</tt> uses {@link Util#parseBoolean(String)}
+ <li><tt>DateTime</tt> uses {@link DateConverter#parseEyeFriendlyDateTime(String, Locale)}
+ and {@link DateConverter#parseHandFriendlyDateTime(String, Locale)}
+ <li><tt>Date</tt> uses {@link DateConverter#parseEyeFriendly(String, Locale, TimeZone)}
+ and {@link DateConverter#parseHandFriendly(String, Locale, TimeZone)}
+ <li><tt>Long</tt> uses {@link Long#Long(String)}
+ <li><tt>Id</tt> uses {@link Id#Id(String)}
+ <li><tt>Locale</tt> uses {@link Locale#getAvailableLocales()} and {@link Locale#toString()}, case sensitive.
+ <li><tt>TimeZone</tt> uses {@link TimeZone#getAvailableIDs()}, case sensitive.
+ </ul>
+ <tt>InputStream</tt>s are not converted by this class, and need to be handled separately by the caller.
+ */
+ public final <T> T convert(String aFilteredInputValue, Class<T> 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 <tt>IgnorableParamValue</tt> configured in <tt>web.xml</tt>.
+ See <tt>web.xml</tt> for more information.
+ */
+ public final String getIgnorableParamValue(){
+ return fConfig.getIgnorableParamValue();
+ }
+
+ // PRIVATE
+
+ private Config fConfig = new Config();
+ private static List<Class<?>> 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<Class<?>>();
+ 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<String> 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<Locale> 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;
+ }
+}
--- /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.
+
+ <P>
+ 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.
+
+<P>This class can hold :
+<ul>
<li>a date-and-time : <tt>1958-03-31 18:59:56.123456789</tt>
<li>a date only : <tt>1958-03-31</tt>
<li>a time only : <tt>18:59:56.123456789</tt>
+</ul>
+
+ <P>
+ <a href='#Examples'>Examples</a><br>
+ <a href='#JustificationForThisClass'>Justification For This Class</a><br>
+ <a href='#DatesAndTimesInGeneral'>Dates and Times In General</a><br>
+ <a href='#TheApproachUsedByThisClass'>The Approach Used By This Class</a><br>
+ <a href='#TwoSetsOfOperations'>Two Sets Of Operations</a><br>
+ <a href='#ParsingDateTimeAcceptedFormats'>Parsing DateTime - Accepted Formats</a><br>
+ <a href='#FormattingLanguage'>Mini-Language for Formatting</a><br>
+ <a href='#InteractionWithTimeSource'>Interaction with {@link TimeSource}</a><br>
+ <a href='#PassingDateTimeToTheDatabase'>Passing DateTime Objects to the Database</a>
+
+ <a name='Examples'></a>
+ <h3> Examples</h3>
+ Some quick examples of using this class :
+ <PRE>
+ 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());
+ </PRE>
+
+ <a name='JustificationForThisClass'></a>
+ <h3> Justification For This Class</h3>
+ The fundamental reasons why this class exists are :
+ <ul>
+ <li>to avoid the embarrassing number of distasteful inadequacies in the JDK's date classes
+ <li>to oppose the very "mental model" of the JDK's date-time classes with something significantly simpler
+ </ul>
+
+ <a name='MentalModels'></a>
+ <P><b>There are 2 distinct mental models for date-times, and they don't play well together</b> :
+ <ul>
+ <li><b>timeline</b> - 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, <i> as seen and understood by
+ the end user</i>, can change according to "who's looking at it". It's important to understand that a timeline instant,
+ before being presented to the user, <i>must always have an associated time zone - even in the case of
+ a date only, with no time.</i>
+ <li><b>everyday</b> - 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, <i>the time zone is always both implicit and immutable</i>.
+ </ul>
+
+ <P>The problem is that java.util.{@link java.util.Date} uses <i>only</i> the timeline style, while <i>most</i> users, <i>most</i>
+ of the time, think in terms of the <i>other</i> mental model - the 'everday' style.
+
+ In particular, there are a large number of applications which experience
+ <a href='http://martinfowler.com/bliki/TimeZoneUncertainty.html'>problems with time zones</a>, because the timeline model
+ is used instead of the everday model.
+ <i>Such problems are often seen by end users as serious bugs, because telling people the wrong date or time is often a serious issue.</i>
+ <b>These problems make you look stupid.</b>
+
+ <a name='JDKDatesMediocre'></a>
+ <h4>Date Classes in the JDK are Mediocre</h4>
+ The JDK's classes related to dates are widely regarded as frustrating to work with, for various reasons:
+ <ul>
+ <li>mistakes regarding time zones are very common
+ <li>month indexes are 0-based, leading to off-by-one errors
+ <li>difficulty of calculating simple time intervals
+ <li><tt>java.util.Date</tt> is mutable, but 'building block' classes should be
+ immutable
+ <li>numerous other minor nuisances
+ </ul>
+
+ <a name='JodaTimeDrawbacks'></a>
+ <h4>Joda Time Has Drawbacks As Well</h4>
+ The <a href='http://joda-time.sourceforge.net/'>Joda Time</a> library is used by some programmers as an alternative
+ to the JDK classes. Joda Time has the following drawbacks :
+ <ul>
+ <li>it limits precision to milliseconds. Database timestamp values almost always have a precision of microseconds
+ or even nanoseconds. This is a serious defect: <b>a library should never truncate your data, for any reason.</b>
+ <li>it's large, with well over 100 items in its <a href='http://joda-time.sourceforge.net/api-release/index.html'>javadoc</a>
+ <li>in order to stay current, it needs to be manually updated occasionally with fresh time zone data
+ <li>it has mutable versions of classes
+ <li>it always coerces March 31 + 1 Month to April 30 (for example), without giving you any choice in the matter
+ <li>some databases allow invalid date values such as '0000-00-00', but Joda Time doesn't seem to be able to handle them
+ </ul>
+
+
+ <a name='DatesAndTimesInGeneral'></a>
+ <h3>Dates and Times in General</h3>
+
+ <h4>Civil Timekeeping Is Complex</h4>
+ Civil timekeeping is a byzantine hodge-podge of arcane and arbitrary rules. Consider the following :
+ <ul>
+ <li>months have varying numbers of days
+ <li>one month (February) has a length which depends on the year
+ <li>not all years have the same number of days
+ <li>time zone rules spring forth arbitrarily from the fecund imaginations of legislators
+ <li>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
+ <li>summer hour logic varies widely across various jurisdictions
+ <li>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
+ <li>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)
+ <li>there is no year 0 (1 BC is followed by 1 AD), except in the reckoning used by
+ astronomers
+ </ul>
+
+ <h4>How Databases Treat Dates</h4>
+ <b>Most databases model dates and times using the Gregorian Calendar in an aggressively simplified form</b>,
+ in which :
+ <ul>
+ <li>the Gregorian calendar is extended back in time as if it was in use previous to its
+ inception (the 'proleptic' Gregorian calendar)
+ <li>the transition between Julian and Gregorian calendars is entirely ignored
+ <li>leap seconds are entirely ignored
+ <li>summer hours are entirely ignored
+ <li>often, even time zones are ignored, in the sense that <i>the underlying database
+ column doesn't usually explicitly store any time zone information</i>.
+ </ul>
+
+ <P><a name='NoTimeZoneInDb'></a>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 <i>external</i> to the data
+ stored in the particular column.
+
+ <P> For example, the following items might be used in the calculation of a time zone difference :
+ <ul>
+ <li>time zone setting for the client (or JDBC driver)
+ <li>time zone setting for the client's connection to the database server
+ <li>time zone setting of the database server
+ <li>time zone setting of the host where the database server resides
+ </ul>
+
+ <P>(Note as well what's <i>missing</i> from the above list: your own application's logic, and the user's time zone preference.)
+
+ <P>When an end user sees such changes to a date-time, all they will say to you is
+ <i>"Why did you change it? That's not what I entered"</i> - and this is a completely valid question.
+ Why <i>did</i> 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.
+
+<a name='TheApproachUsedByThisClass'></a>
+ <h3>The Approach Used By This Class</h3>
+
+ This class takes the following design approach :
+ <ul>
+ <li>it models time in the "everyday" style, not in the "timeline" style (see <a href='#MentalModels'>above</a>)
+ <li>its precision matches the highest precision used by databases (nanosecond)
+ <li>it uses only the proleptic Gregorian Calendar, over the years <tt>1..9999</tt>
+ <li><i>it ignores all non-linearities</i>: summer-hours, leap seconds, and the cutover
+ from Julian to Gregorian calendars
+ <li><i>it ignores time zones</i>. Most date-times are stored in columns whose type
+ does <i>not</i> include time zone information (see note <a href='#NoTimeZoneInDb'>above</a>).
+ <li>it has (very basic) support for wonky dates, such as the magic value <tt>0000-00-00</tt> used by MySQL
+ <li>it's immutable
+ <li>it lets you choose among 4 policies for 'day overflow' conditions during calculations
+ <li>it talks to your {@link TimeSource} implementation when returning the current moment, allowing you to customise dates during testing
+ </ul>
+
+ <P>Even though the above list may appear restrictive, it's very likely true that
+ <tt>DateTime</tt> can handle the dates and times you're currently storing in your database.
+
+<a name='TwoSetsOfOperations'></a>
+ <h3>Two Sets Of Operations</h3>
+ This class allows for 2 sets of operations: a few "basic" operations, and many "computational" ones.
+
+ <P><b>Basic operations</b> 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 <tt>ResultSet</tt>, with
+ absolutely no modification for time zone, locale, or for anything else.
+
+ <P>This is meant as a back-up, to ensure that <i>your application will always be able
+ to, at the very least, display a date-time exactly as it appears in your
+ <tt>ResultSet</tt> from the database</i>. This style is particularly useful for handling invalid
+ dates such as <tt>2009-00-00</tt>, 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
+ <a href='http://dev.mysql.com/doc/refman/5.1/en/time.html'>TIME</a> datatype.
+
+ <P>The basic operations are represented by {@link #DateTime(String)}, {@link #toString()}, and {@link #getRawDateString()}.
+
+ <P><b>Computational operations</b> 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.
+
+ <a name="ParsingDateTimeAcceptedFormats"></a>
+ <h3>Parsing DateTime - Accepted Formats</h3>
+ The {@link #DateTime(String)} constructor accepts a <tt>String</tt> 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.
+
+ <a name="FormattingLanguage"></a>
+ <h3>Mini-Language for Formatting</h3>
+ This class defines a simple mini-language for formatting a <tt>DateTime</tt>, used by the various <tt>format</tt> methods.
+
+ <P>The following table defines the symbols used by this mini-language, and the corresponding text they
+ would generate given the date:
+ <PRE>1958-04-09 Wednesday, 03:05:06.123456789 AM</PRE>
+ in an English Locale. (Items related to date are in upper case, and items related to time are in lower case.)
+
+ <P><table border='1' cellpadding='3' cellspacing='0'>
+ <tr><th>Format</th><th>Output</th> <th>Description</th><th>Needs Locale?</th></tr>
+ <tr><td>YYYY</td> <td>1958</td> <td>Year</td><td>...</td></tr>
+ <tr><td>YY</td> <td>58</td> <td>Year without century</td><td>...</td></tr>
+ <tr><td>M</td> <td>4</td> <td>Month 1..12</td><td>...</td></tr>
+ <tr><td>MM</td> <td>04</td> <td>Month 01..12</td><td>...</td></tr>
+ <tr><td>MMM</td> <td>Apr</td> <td>Month Jan..Dec</td><td>Yes</td></tr>
+ <tr><td>MMMM</td> <td>April</td> <td>Month January..December</td><td>Yes</td></tr>
+ <tr><td>DD</td> <td>09</td> <td>Day 01..31</td><td>...</td></tr>
+ <tr><td>D</td> <td>9</td> <td>Day 1..31</td><td>...</td></tr>
+ <tr><td>WWWW</td> <td>Wednesday</td> <td>Weekday Sunday..Saturday</td><td>Yes</td></tr>
+ <tr><td>WWW</td> <td>Wed</td> <td>Weekday Sun..Sat</td><td>Yes</td></tr>
+ <tr><td>hh</td> <td>03</td> <td>Hour 01..23</td><td>...</td></tr>
+ <tr><td>h</td> <td>3</td> <td>Hour 1..23</td><td>...</td></tr>
+ <tr><td>hh12</td> <td>03</td> <td>Hour 01..12</td><td>...</td></tr>
+ <tr><td>h12</td> <td>3</td> <td>Hour 1..12</td><td>...</td></tr>
+ <tr><td>a</td> <td>AM</td> <td>AM/PM Indicator</td><td>Yes</td></tr>
+ <tr><td>mm</td> <td>05</td> <td>Minutes 01..59</td><td>...</td></tr>
+ <tr><td>m</td> <td>5</td> <td>Minutes 1..59</td><td>...</td></tr>
+ <tr><td>ss</td> <td>06</td> <td>Seconds 01..59</td><td>...</td></tr>
+ <tr><td>s</td> <td>6</td> <td>Seconds 1..59</td><td>...</td></tr>
+ <tr><td>f</td> <td>1</td> <td>Fractional Seconds, 1 decimal</td><td>...</td></tr>
+ <tr><td>ff</td> <td>12</td> <td>Fractional Seconds, 2 decimals</td><td>...</td></tr>
+ <tr><td>fff</td> <td>123</td> <td>Fractional Seconds, 3 decimals</td><td>...</td></tr>
+ <tr><td>ffff</td> <td>1234</td> <td>Fractional Seconds, 4 decimals</td><td>...</td></tr>
+ <tr><td>fffff</td> <td>12345</td> <td>Fractional Seconds, 5 decimals</td><td>...</td></tr>
+ <tr><td>ffffff</td> <td>123456</td> <td>Fractional Seconds, 6 decimals</td><td>...</td></tr>
+ <tr><td>fffffff</td> <td>1234567</td> <td>Fractional Seconds, 7 decimals</td><td>...</td></tr>
+ <tr><td>ffffffff</td> <td>12345678</td> <td>Fractional Seconds, 8 decimals</td><td>...</td></tr>
+ <tr><td>fffffffff</td> <td>123456789</td> <td>Fractional Seconds, 9 decimals</td><td>...</td></tr>
+ <tr><td>|</td> <td>(no example)</td> <td>Escape character</td><td>...</td></tr>
+ </table>
+
+ <P>As indicated above, some of these symbols can only be used with an accompanying <tt>Locale</tt>.
+ In general, if the output is text, not a number, then a <tt>Locale</tt> will be needed.
+ For example, 'September' is localizable text, while '09' is a numeric representation, which doesn't require a <tt>Locale</tt>.
+ Thus, the symbol 'MM' can be used without a <tt>Locale</tt>, while 'MMMM' and 'MMM' both require a <tt>Locale</tt>, since they
+ generate text, not a number.
+
+ <P>The fractional seconds 'f' does not perform any rounding.
+
+<P> 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.
+
+ <P>Examples :
+ <table border='1' cellpadding='3' cellspacing='0'>
+ <tr><th>Format</th><th>Output</th></tr>
+ <tr><td>YYYY-MM-DD hh:mm:ss.fffffffff a</td> <td>1958-04-09 03:05:06.123456789 AM</td></tr>
+ <tr><td>YYYY-MM-DD hh:mm:ss.fff a</td> <td>1958-04-09 03:05:06.123 AM</td></tr>
+ <tr><td>YYYY-MM-DD</td> <td>1958-04-09</td></tr>
+ <tr><td>hh:mm:ss.fffffffff</td> <td>03:05:06.123456789</td></tr>
+ <tr><td>hh:mm:ss</td> <td>03:05:06</td></tr>
+ <tr><td>YYYY-M-D h:m:s</td> <td>1958-4-9 3:5:6</td></tr>
+ <tr><td>WWWW, MMMM D, YYYY</td> <td>Wednesday, April 9, 1958</td></tr>
+ <tr><td>WWWW, MMMM D, YYYY |at| D a</td> <td>Wednesday, April 9, 1958 at 3 AM</td></tr>
+ </table>
+
+ <P>In the last example, the escape characters are needed only because 'a', the formating symbol for am/pm, appears in the text.
+
+ <a name='InteractionWithTimeSource'></a>
+ <h3>Interaction with {@link TimeSource}</h3>
+ 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.
+
+ <a name='PassingDateTimeToTheDatabase'></a>
+ <h3>Passing DateTime Objects to the Database</h3>
+ When a <tt>DateTime</tt> is passed as a parameter to an SQL statement, the <tt>DateTime</tt> is
+ formatted into a <tt>String</tt> of a form accepted by the database. There are two mechanisms to
+ accomplish this
+ <ul>
<li>in your DAO code, format the <tt>DateTime</tt> explicitly as a String, using one of the <tt>format</tt> methods,
+ and pass the <tt>String</tt> as the parameter to the SQL statement, and not the actual <tt>DateTime</tt>
<li>pass the <tt>DateTime</tt> itself. In this case, WEB4J will use the setting in <tt>web.xml</tt> named
+ <tt>DateTimeFormatForPassingParamsToDb</tt> 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 <tt>DateTime</tt> as a String explicitly instead.
</ul>
+ */
+public final class DateTime implements Comparable<DateTime>, Serializable {
+
+ /** The seven parts of a <tt>DateTime</tt> object. The <tt>DAY</tt> 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.
+
+ <P>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 :
+
+ <PRE>May 31 + 1 month = ?</PRE>
+
+ <P>What's the answer? Since there is no such thing as June 31, the result of this operation is inherently ambiguous.
+ This <tt>DayOverflow</tt> enumeration lists the various policies for treating such situations, as supported by
+ <tt>DateTime</tt>.
+
+ <P>This table illustrates how the policies behave :
+ <P><table BORDER="1" CELLPADDING="3" CELLSPACING="0">
+ <tr>
+ <th>Date</th>
+ <th>DayOverflow</th>
+ <th>Result</th>
+ </tr>
+ <tr>
+ <td>May 31 + 1 Month</td>
+ <td>LastDay</td>
+ <td>June 30</td>
+ </tr>
+ <tr>
+ <td>May 31 + 1 Month</td>
+ <td>FirstDay</td>
+ <td>July 1</td>
+ </tr>
+ <tr>
+ <td>December 31, 2001 + 2 Months</td>
+ <td>Spillover</td>
+ <td>March 3</td>
+ </tr>
+ <tr>
+ <td>May 31 + 1 Month</td>
+ <td>Abort</td>
+ <td>RuntimeException</td>
+ </tr>
+ </table>
+ */
+ 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.
+
+ <P>This constructor is called when WEB4J's data layer needs to translate a column in a <tt>ResultSet</tt>
+ into a <tt>DateTime</tt>.
+
+ <P> 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.
+
+ <P><i>However</i>, the moment you attempt to call <a href='#TwoSetsOfOperations'>almost any method</a>
+ 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 <tt>RuntimeException</tt> will be thrown.
+
+ <P>Before calling this constructor, you may wish to call {@link #isParseable(String)} to explicitly test whether a
+ given String is parseable by this class.
+
+ <P>The full date format expected by this class is <tt>'YYYY-MM-YY hh:mm:ss.fffffffff'</tt>.
+ 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.
+
+ <P>All of the following dates can be parsed by this class to make a <tt>DateTime</tt> :
+ <ul>
+ <li><tt>2009-12-31 00:00:00.123456789</tt>
+ <li><tt>2009-12-31T00:00:00.123456789</tt>
+ <li><tt>2009-12-31 00:00:00.12345678</tt>
+ <li><tt>2009-12-31 00:00:00.1234567</tt>
+ <li><tt>2009-12-31 00:00:00.123456</tt>
+ <li><tt>2009-12-31 23:59:59.12345</tt>
+ <li><tt>2009-01-31 16:01:01.1234</tt>
+ <li><tt>2009-01-01 16:59:00.123</tt>
+ <li><tt>2009-01-01 16:00:01.12</tt>
+ <li><tt>2009-02-28 16:25:17.1</tt>
+ <li><tt>2009-01-01 00:01:01</tt>
+ <li><tt>2009-01-01 16:01</tt>
+ <li><tt>2009-01-01 16</tt>
+ <li><tt>2009-01-01</tt>
+ <li><tt>2009-01</tt>
+ <li><tt>2009</tt>
+ <li><tt>0009</tt>
+ <li><tt>9</tt>
+ <li><tt>00:00:00.123456789</tt>
+ <li><tt>00:00:00.12345678</tt>
+ <li><tt>00:00:00.1234567</tt>
+ <li><tt>00:00:00.123456</tt>
+ <li><tt>23:59:59.12345</tt>
+ <li><tt>01:59:59.1234</tt>
+ <li><tt>23:01:59.123</tt>
+ <li><tt>00:00:00.12</tt>
+ <li><tt>00:59:59.1</tt>
+ <li><tt>23:59:00</tt>
+ <li><tt>23:00:10</tt>
+ <li><tt>00:59</tt>
+ </ul>
+
+ <P>The range of each field is :
+ <ul>
+ <li>year: 1..9999 (leading zeroes are optional)
+ <li>month: 01..12
+ <li>day: 01..31
+ <li>hour: 00..23
+ <li>minute: 00..59
+ <li>second: 00..59
+ <li>nanosecond: 0..999999999
+ </ul>
+
+ <P>Note that <b>database format functions</b> 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 <tt>ResultSet</tt>.
+ */
+ 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 <tt>true</tt> only if the given String follows one of the formats documented by {@link #DateTime(String)}.
+ <P>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.
+
+ <P>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 <tt>DateTime</tt> having year-month-day only, with no time portion.
+ <P>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 <tt>DateTime</tt> having hour-minute-second-nanosecond only, with no date portion.
+ <P>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 <tt>java.util.Date</tt> into a <tt>DateTime</tt>.
+
+ <P>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 <tt>DateTime</tt>.
+
+ <P>This method is meant to help you convert between a <tt>DateTime</tt> 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.
+ <P>Since <tt>DateTime</tt> 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.
+ <P>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}.
+
+ <P>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 <tt>DateTime</tt>.
+
+ <P>For conversion between a <tt>DateTime</tt> and the JDK's date-time classes,
+ you should likely use {@link #getMilliseconds(TimeZone)} instead.
+ <P>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 <tt>null</tt> 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.
+ <P>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).
+
+ <P>Using the Modified Julian Day Number instead of the Julian Date has 2 advantages:
+ <ul>
+ <li>it's a smaller number
+ <li>it starts at midnight, not noon (Julian Date starts at noon)
+ </ul>
+
+ <P>Does not reflect any time portion, if present.
+
+ <P>(In spite of its name, this method, like all other methods in this class, uses the
+ proleptic Gregorian calendar - not the Julian calendar.)
+
+ <P>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 <tt>DateTime</tt>.
+ Returns 1..7 for Sunday..Saturday.
+ <P>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.
+ <P>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.
+ <P>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 <tt>DateTime</tt>.
+ <P>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 <tt>DateTime</tt> with respect to a given starting <tt>DateTime</tt>.
+ <P>The single parameter to this method defines first day of week number 1.
+ See {@link #getWeekIndex()} as well.
+ <P>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 <tt>DateTime</tt>, taking day 1 of week 1 as Sunday, January 2, 2000.
+ <P>See {@link #getWeekIndex(DateTime)} as well, which takes an arbitrary date to define
+ day 1 of week 1.
+ <P>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 <tt>true</tt> only if this <tt>DateTime</tt> has the same year-month-day as the given parameter.
+ Time is ignored by this method.
+ <P> Requires year-month-day to be present, both for this <tt>DateTime</tt> and for
+ <tt>aThat</tt>; 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 <tt>true</tt> only if this <tt>DateTime</tt> 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 <tt>true</tt> only if this <tt>DateTime</tt> comes before the given parameter, according to {@link #compareTo(DateTime)},
+ or this <tt>DateTime</tt> equals the given parameter.
+ */
+ public boolean lteq(DateTime aThat) {
+ return compareTo(aThat) < EQUAL || equals(aThat);
+ }
+
+ /**
+ 'Greater than' comparison.
+ Return <tt>true</tt> only if this <tt>DateTime</tt> 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 <tt>true</tt> only if this <tt>DateTime</tt> comes after the given parameter, according to {@link #compareTo(DateTime)},
+ or this <tt>DateTime</tt> 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 <tt>DateTime</tt>. */
+ 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 <tt>DateTime</tt> to the given precision.
+ <P>The return value will have all items lower than the given precision simply set to
+ <tt>null</tt>. In addition, the return value will not include any date-time String passed to the
+ {@link #DateTime(String)} constructor.
+
+ @param aPrecision takes any value <i>except</i> {@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 <tt>true</tt> only if all of the given units are present in this <tt>DateTime</tt>.
+ If a unit is <i>not</i> included in the argument list, then no test is made for its presence or absence
+ in this <tt>DateTime</tt> 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 <tt>true</tt> only if this <tt>DateTime</tt> has a non-null values for year, month, and day.
+ */
+ public boolean hasYearMonthDay() {
+ return unitsAllPresent(Unit.YEAR, Unit.MONTH, Unit.DAY);
+ }
+
+ /**
+ Return <tt>true</tt> only if this <tt>DateTime</tt> has a non-null values for hour, minute, and second.
+ */
+ public boolean hasHourMinuteSecond() {
+ return unitsAllPresent(Unit.HOUR, Unit.MINUTE, Unit.SECOND);
+ }
+
+ /**
+ Return <tt>true</tt> only if all of the given units are absent from this <tt>DateTime</tt>.
+ If a unit is <i>not</i> included in the argument list, then no test is made for its presence or absence
+ in this <tt>DateTime</tt> 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 <tt>DateTime</tt> with the time portion coerced to '00:00:00.000000000'.
+ <P>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 <tt>DateTime</tt> with the time portion coerced to '23:59:59.999999999'.
+ <P>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 <tt>DateTime</tt> with the time portion coerced to '00:00:00.000000000',
+ and the day coerced to 1.
+ <P>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 <tt>DateTime</tt> with the time portion coerced to '23:59:59.999999999',
+ and the day coerced to the end of the month.
+ <P>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 <tt>DateTime</tt> by adding an interval to this one.
+
+ <P>See {@link #plusDays(Integer)} as well.
+
+ <P>Changes are always applied by this class <i>in order of decreasing units of time</i>:
+ 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.
+
+ <P>Afterwards, the day is then changed in the usual way, followed by the remaining items (hour, minute, second, and nanosecond).
+
+ <P><em>The mental model for this method is very similar to that of a car's odometer.</em> When a limit is reach for one unit of time,
+ then a rollover occurs for a neighbouring unit of time.
+
+ <P>The returned value cannot come after <tt>9999-12-13 23:59:59</tt>.
+
+ <P>This class works with <tt>DateTime</tt>'s having the following items present :
+ <ul>
+ <li>year-month-day and hour-minute-second (and optional nanoseconds)
+ <li>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 <tt>DateTime</tt> returned by this class will include a time part.
+ <li>hour-minute-second (and optional nanoseconds) only. In this case, the calculation is done starting with the
+ the arbitrary date <tt>0001-01-01</tt> (in order to remain within a valid state space of <tt>DateTime</tt>).
+ </ul>
+
+ @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 <tt>DateTime</tt> by subtracting an interval to this one.
+
+ <P>See {@link #minusDays(Integer)} as well.
+ <P>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 <tt>0001-01-01 00:00:00</tt>.
+ */
+ 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 <tt>DateTime</tt> by adding an integral number of days to this one.
+
+ <P>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 <tt>DateTime</tt> by subtracting an integral number of days from this one.
+
+ <P>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 <tt>DateTime</tt> and the given parameter.
+ <P>Requires year-month-day to be present, both for this <tt>DateTime</tt> and for the <tt>aThat</tt>
+ parameter; if not, a runtime exception is thrown.
+ */
+ public int numDaysFrom(DateTime aThat) {
+ return aThat.getModifiedJulianDayNumber() - this.getModifiedJulianDayNumber();
+ }
+
+ /**
+ The number of seconds between this <tt>DateTime</tt> and the given argument.
+ <P>If only time information is present in both this <tt>DateTime</tt> and <tt>aThat</tt>, then there are
+ no restrictions on the values of the time units.
+ <P>If any date information is present, in either this <tt>DateTime</tt> or <tt>aThat</tt>,
+ 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 <tt>DateTime</tt> as a formatted String using numbers, with no localizable text.
+
+ <P>Example:
+ <PRE>dt.format("YYYY-MM-DD hh:mm:ss");</PRE>
+ would generate text of the form
+ <PRE>2009-09-09 18:23:59</PRE>
+
+ <P>If months, weekdays, or AM/PM indicators are output as localizable text, you must use {@link #format(String, Locale)}.
+ @param aFormat uses the <a href="#FormattingLanguage">formatting mini-language</a> defined in the class comment.
+ */
+ public String format(String aFormat) {
+ DateTimeFormatter format = new DateTimeFormatter(aFormat);
+ return format.format(this);
+ }
+
+ /**
+ Output this <tt>DateTime</tt> as a formatted String using numbers and/or localizable text.
+
+ <P>This method is intended for alphanumeric output, such as '<tt>Sunday, November 14, 1858 10:00 AM</tt>'.
+ <P>If months and weekdays are output as numbers, you are encouraged to use {@link #format(String)} instead.
+
+ @param aFormat uses the <a href="#FormattingLanguage">formatting mini-language</a> 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 <tt>DateTime</tt> as a formatted String using numbers and explicit text for months, weekdays, and AM/PM indicator.
+
+ <P>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 <a href="#FormattingLanguage">formatting mini-language</a> 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<String> aMonths, List<String> aWeekdays, List<String> aAmPmIndicators) {
+ DateTimeFormatter format = new DateTimeFormatter(aFormat, aMonths, aWeekdays, aAmPmIndicators);
+ return format.format(this);
+ }
+
+ /**
+ Return the current date-time.
+ <P>Combines the configured implementation of {@link TimeSource} with the given {@link TimeZone}.
+ The <tt>TimeZone</tt> will typically come from your implementation of {@link TimeZoneSource}.
+
+ <P>In an Action, the current date-time date can be referenced using
+ <PRE>DateTime.now(getTimeZone())</PRE>
+ See {@link ActionImpl#getTimeZone()}.
+
+ <P>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.
+ <P>As in {@link #now(TimeZone)}, but truncates the time portion, leaving only year-month-day.
+ <P>In an Action, today's date can be referenced using
+ <PRE>DateTime.today(getTimeZone())</PRE>
+ See {@link ActionImpl#getTimeZone()}.
+ */
+ public static DateTime today(TimeZone aTimeZone) {
+ DateTime result = now(aTimeZone);
+ return result.truncate(Unit.DAY);
+ }
+
+ /** Return <tt>true</tt> 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 <tt>true</tt> 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 <tt>DateTime</tt> corresponding to a change from one {@link TimeZone} to another.
+
+ <P>A <tt>DateTime</tt> 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.
+
+ <P>Example :
+ <PRE>
+TimeZone fromUK = TimeZone.getTimeZone("Europe/London");
+TimeZone toIndonesia = TimeZone.getTimeZone("Asia/Jakarta");
+DateTime newDt = oldDt.changeTimeZone(fromUK, toIndonesia);
+ </PRE>
+
+ <P>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 <tt>DateTime</tt> 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.
+ <P> 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.
+
+ <P>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.
+
+ <P> 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 <i>debugging and logging</i> only.
+
+ <P><b>To format this <tt>DateTime</tt> for presentation to the user, see the various <tt>format</tt> methods.</b>
+
+ <P>If the {@link #DateTime(String)} constructor was called, then return that String.
+
+ <P>Otherwise, the return value is constructed from each date-time element, in a fixed format, depending
+ on which time units are present. Example values :
+ <ul>
+ <li>2011-04-30 13:59:59.123456789
+ <li>2011-04-30 13:59:59
+ <li>2011-04-30
+ <li>2011-04-30 13:59
+ <li>13:59:59.123456789
+ <li>13:59:59
+ <li>and so on...
+ </ul>
+
+ <P>In the great majority of cases, this will give reasonable output for debugging and logging statements.
+
+ <P>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 <i>except</i> for minutes, the return value has this form:
+ <PRE>Y:2001 M:1 D:31 h:13 m:null s:59 f:123456789</PRE>
+ */
+ @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 <tt>null</tt> 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
--- /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)}.
+
+ <P>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.
+
+ <P>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.
+
+ <P>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<String> aMonths, List<String> aWeekdays, List<String> aAmPmIndicators){
+ fFormat = aFormat;
+ fLocale = null;
+ fCustomLocalization = new CustomLocalization(aMonths, aWeekdays, aAmPmIndicators);
+ validateState();
+ }
+
+ /** Format a {@link DateTime}. */
+ String format(DateTime aDateTime){
+ fEscapedRanges = new ArrayList<EscapedRange>();
+ fInterpretedRanges = new ArrayList<InterpretedRange>();
+ findEscapedRanges();
+ interpretInput(aDateTime);
+ return produceFinalOutput();
+ }
+
+ // PRIVATE
+ private final String fFormat;
+ private final Locale fLocale;
+ private Collection<InterpretedRange> fInterpretedRanges;
+ private Collection<EscapedRange> 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<Locale, List<String>> fMonths = new LinkedHashMap<Locale, List<String>>();
+
+ /**
+ 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<Locale, List<String>> fWeekdays = new LinkedHashMap<Locale, List<String>>();
+
+ /**
+ 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<Locale, List<String>> fAmPm = new LinkedHashMap<Locale, List<String>>();
+
+ private final CustomLocalization fCustomLocalization;
+
+ private final class CustomLocalization{
+ CustomLocalization(List<String> aMonths, List<String> aWeekdays, List<String> 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<String> Months;
+ List<String> Weekdays;
+ List<String> 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<String> TOKENS = new ArrayList<String>();
+ 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<String> months = new ArrayList<String>();
+ 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<String> weekdays = new ArrayList<String>();
+ 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<String> indicators = new ArrayList<String>();
+ 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.");
+ }
+ }
+}
--- /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();
+ }
+ }
+ }
+
+
+}
--- /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 <tt>ResultSet</tt>
+ 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 <tt>DateTime</tt>, 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();
+ }
+}
--- /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.
+
+<P>Decimal amounts are typically used to represent two kinds of items :
+<ul>
+ <li>monetary amounts
+ <li>measurements such as temperature, distance, and so on
+ </ul>
+
+ <P>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 <tt>Id</tt>
+ to store a currency, if needed).
+
+<P>This class exists for these reasons:
+<ul>
+ <li>to simplify calculations, and build on top of what's available from the {@link BigDecimal} class
+ <li>to allow your code to read at a higher level
+ <li>to define a more natural, pleasing syntax
+ <li>to help you avoid floating-point types,
+ which have many <a href='http://www.ibm.com/developerworks/java/library/j-jtp0114/'>pitfalls</a>
+</ul>
+
+ <P><tt>Decimal</tt> objects are immutable.
+ Many operations return new <tt>Decimal</tt> objects.
+
+ <h3>Currency Is Unspecified</h3>
+ This class can be used to model amounts of money.
+<P><em>Many will be surprised that this class does not make any reference to currency.</em>
+ The reason for this is adding currency would render this class a poor <em>building block</em>.
+ Building block objects such as <tt>Date</tt>, <tt>Integer</tt>, and so on, are
+ <em>atomic</em>, in the sense of representing a <em>single</em> 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.
+
+ <P>If a feature needs to explicitly distinguish between <em>multiple</em> currencies
+ such as US Dollars and Japanese Yen, then a <tt>Decimal</tt> object
+ will need to be paired by the caller with a <em>second</em> item representing the
+ underlying currency (perhaps modeled as an <tt>Id</tt>).
+ See the {@link Currency} class for more information.
+
+ <h3>Number of Decimal Places</h3>
+ To validate the number of decimals in your Model Objects,
+ call the {@link Check#numDecimalsAlways(int)} or {@link Check#numDecimalsMax(int)} methods.
+
+ <h3>Different Numbers of Decimals</h3>
+ <P>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, <em>ad hoc</em> notation) :
+ <PRE>10 + 1.23 = 11.23
+10.00 + 1.23 = 11.23
+10 - 1.23 = 8.77
+(10 > 1.23) => true </PRE>
+ This corresponds to typical user expectations.
+
+ <P>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,
+ <PRE>10.equals(10.00) => false
+10.eq(10.00) => true</PRE>
+
+ <h3>Terse Method Names</h3>
+ Various methods in this class have unusually terse names, such as
+ <tt>lt</tt> for 'less than', and <tt>gt</tt> for 'greater than', and so on.
+ The intent of such names is to improve the legibility of mathematical
+ expressions.
+
+ <P>Example:
+<PRE>if (amount.lt(hundred)) {
+ cost = amount.times(price);
+}</PRE>
+
+ <h3>Prefer Decimal forms</h3>
+ <P>Many methods in this class are overloaded to perform the same operation with various types:
+<ul>
+ <li>Decimal
+ <li>long (which will also accept an int)
+ <li>double (which will also accept a float)
+</ul>
+ 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.
+
+ <h3>Extends Number</h3>
+ <P>This class extends {@link Number}. This allows other parts of the JDK to treat a <tt>Decimal</tt> just like any other
+ <tt>Number</tt>.
+*/
+public final class Decimal extends Number implements Comparable<Decimal>, Serializable {
+
+ /**
+ The default rounding mode used by this class (<tt>HALF_EVEN</tt>).
+ 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.
+ <P>Instead of :
+ <PRE>Decimal decimal = new Decimal(new BigDecimal("100"));</PRE>
+ one may instead use this more compact form:
+ <PRE>Decimal decimal = Decimal.from("100");</PRE>
+ 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 <tt>Decimal</tt> in a style suitable for debugging.
+ <em>Intended for debugging only.</em>
+
+ <P>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.
+
+ <P>That is, <tt>10</tt> and <tt>10.00</tt> are <em>not</em>
+ considered equal by this method. <b>Such behavior is often undesired; in most
+ practical cases, it's likely best to use the {@link #eq(Decimal)} method instead.</b>,
+ which has no such monkey business.
+
+ <P>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.
+
+ <P>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 <tt>Decimal</tt> amount, a simple convenience constant.
+
+ <P>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 <a href='http://en.wikipedia.org/wiki/Pi_Day'>taste good</a> 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 <tt>true</tt> 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 <tt>true</tt> only if the amount is positive. */
+ public boolean isPlus(){
+ return fAmount.compareTo(ZERO_BD) > 0;
+ }
+
+ /** Return <tt>true</tt> only if the amount is negative. */
+ public boolean isMinus(){
+ return fAmount.compareTo(ZERO_BD) < 0;
+ }
+
+ /** Return <tt>true</tt> only if the amount is zero. */
+ public boolean isZero(){
+ return fAmount.compareTo(ZERO_BD) == 0;
+ }
+
+ /**
+ Equals (insensitive to number of decimals).
+ That is, <tt>10</tt> and <tt>10.00</tt> are considered equal by this method.
+
+ <P>Return <tt>true</tt> only if the amounts are equal.
+ This method is <em>not</em> synonymous with the <tt>equals</tt> 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.
+ <P>Return <tt>true</tt> 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.
+ <P>Return <tt>true</tt> 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.
+ <P>Return <tt>true</tt> 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.
+ <P>Return <tt>true</tt> 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 <tt>aThat</tt> <tt>Decimal</tt> to this <tt>Decimal</tt>.
+ */
+ 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 <tt>aThat</tt> <tt>Decimal</tt> from this <tt>Decimal</tt>.
+ */
+ 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 <tt>Decimal</tt> objects.
+
+ @param aDecimals collection of <tt>Decimal</tt> objects.
+ If the collection is empty, then a zero value is returned.
+ */
+ public static Decimal sum(Collection<Decimal> aDecimals){
+ Decimal sum = new Decimal(ZERO_BD);
+ for(Decimal decimal : aDecimals){
+ sum = sum.plus(decimal);
+ }
+ return sum;
+ }
+
+ /** Multiply this <tt>Decimal</tt> 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 <tt>Decimal</tt> by a divisor.
+ <p>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:
+ <tt>
+ Decimal amount = Decimal.from("1710.12");
+ amount.round2(0.05); // 1710.10
+ amount.round2(100); // 1700
+ </tt>
+ @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 <b>integral</b> power; the power can be of either sign.
+
+ <P>Special cases regarding 0:
+ <ul>
+ <li> <tt>0^-n</tt> is undefined (<tt>n > 0</tt>).
+ <li> <tt>x^0<tt/> always returns 1, even for <tt>x = 0</tt>.
+ </ul>
+
+ @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.
+ <P>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}.
+
+ <P><em>Use of floating point data is highly discouraged.</em>
+ This method is provided only because it's required by <tt>Number</tt>.
+ */
+ @Override public double doubleValue() {
+ return fAmount.doubleValue();
+ }
+
+ /**
+ Required by {@link Number}.
+
+ <P><em>Use of floating point data is highly discouraged.</em>
+ This method is provided only because it's required by <tt>Number</tt>.
+ */
+ @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 <a href=http://java.sun.com/products/jdk/1.1/docs/guide
+ /serialization/spec/version.doc.html> details. </a>
+
+ 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) + "'";
+ }
+}
--- /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.
+
+ <P>Identifiers are both common and important. Unfortunately, there is no class in the
+ JDK specifically for identifiers.
+
+ <P>An <tt>Id</tt> class is useful for these reasons :
+<ul>
+ <li>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
+ <li>it avoids a common <a href="http://www.javapractices.com/Topic192.cjp">problem</a>
+ in modeling identifiers as numbers.
+</ul>
+
+ <P>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 <tt>Id</tt> parameters to {@link hirondelle.web4j.database.Db}
+ using {@link #asInteger} or {@link #asLong}.
+
+ <P><em>Design Note :</em><br>
+ This class is <tt>final</tt>, 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<Id> {
+
+ /**
+ 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.
+
+ <P>See class comment.
+
+ <P>If this <tt>Id</tt> 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.
+
+ <P>See class comment.
+
+ <P>If this <tt>Id</tt> 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.
+
+ <P>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};
+ }
+}
--- /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.
+
+<P>Used for error messages, success messages, or any such item communicated
+ to the user. See <tt>displayMessages.tag</tt> in the example application
+ for an illustration of rendering a <tt>MessageList</tt>.
+
+ <P>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
+ <a href='http://www.javapractices.com/Topic181.cjp'>duplicate-upon-browser-reload</a>
+ problem), so success messages will almost always be placed in session
+ scope. Conversely, failure messages will usually be placed in request
+ scope.
+
+ <P><em>Design Note</em><br>
+ The forces which WEB4J resolves are :
+ <ul>
+ <li>messages can be shown to the user upon both success and failure
+ <li>redirect/forward behavior can occur for either success or failure
+ <li>rendering may be the same for both success and failure messages
+ <li>rendering may differ between success and failure messages
+ <li>the message may or may not include parameters
+</ul>
+
+ <P>This item is provided as an interface because
+ {@link AppException} needs to be both an <tt>Exception</tt>
+ and a <tt>MessageList</tt>.
+*/
+public interface MessageList {
+
+ /**
+ Add a simple {@link AppResponseMessage} to this list.
+
+ <P>The argument satisfies the same conditions as {@link AppResponseMessage#forSimple}.
+ */
+ void add(String aErrorMessage);
+
+ /**
+ Add a compound {@link AppResponseMessage} to this list.
+
+ <P>The arguments satisfy the same conditions as {@link AppResponseMessage#forCompound}.
+ */
+ void add(String aErrorMessage, Object... aParams);
+
+ /** Add all {@link AppResponseMessage}s attached to <tt>aAppEx</tt> to this list. */
+ void add(AppException aAppEx);
+
+ /**
+ Return <tt>true</tt> only if there are no messages in this list.
+
+ <P>Note that this method name conflicts with the <tt>empty</tt> keyword
+ of JSTL. Thus, {@link #isNotEmpty} is supplied as an alternative.
+ */
+ boolean isEmpty();
+
+ /** Return the negation of {@link #isEmpty}. */
+ boolean isNotEmpty();
+
+ /**
+ Return an unmodifiable <tt>List</tt> of {@link AppResponseMessage}s.
+ */
+ List<AppResponseMessage> getMessages();
+
+}
--- /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}.
+
+ <P>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}.
+
+ <P>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<AppResponseMessage> 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<AppResponseMessage> fAppResponseMessages = new ArrayList<AppResponseMessage>();
+
+ /** 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();
+ }
+}
--- /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.
+
+ <P>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 - (<tt>ModelCtorException</tt>).
+
+ <P>Using a checked exception has the advantage that
+ it cannot be ignored by the caller.
+
+ Example use case:<br>
+<PRE>
+ //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
+ }
+</PRE>
+
+ <P>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
+}
+
--- /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;
+
+/**
+ <b>UNPUBLISHED</b> - Utilities for constructing Model Objects.
+
+ <P>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.
+
+ <P>In WEB4J, Model Objects have <tt>public</tt> 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.
+
+ <P>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 <tt>aNumArguments</tt>
+ @param aNumArguments number of arguments taken by a constructor, must be greater
+ than <tt>0</tt>
+ */
+ public static <T> Constructor<T> getConstructor(Class<T> aMOClass, int aNumArguments) {
+ Args.checkForPositive(aNumArguments);
+ Constructor<T> result = null;
+ //Original line did not compile under JDK 6 Constructor<T>[] allCtors = aMOClass.getConstructors();
+ Constructor<T>[] allCtors = (Constructor<T>[]) 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<T> 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 <tt>public</tt> {@link java.lang.reflect.Constructor} whose
+ <em>exact</em> argument types are listed, in order, in <tt>aArgClasses</tt>.
+
+ <P><em>This method is intended only for cases in which specifying the number of
+ arguments would not uniquely identify the desired <tt>public</tt> constructor.</em>
+
+ <P>Example values for <tt>aArgClasses</tt> : <P>
+ <tt>
+ <table cellpadding="3" cellspacing="0" border="1">
+ <tr><th>public Constructor</th><th>aArgClasses array</th></tr>
+ <tr><td>Account(String, int)</td><td>{String.class, int.class}</td></tr>
+ <tr><td>Account(String, BigDecimal)</td><td>{String.class, BigDecimal.class}</td></tr>
+ </table>
+ </tt>
+ @param aArgClasses has the same restrictions as the arguments to
+ {@link Class#getConstructor(Class[])}, and must have at least one element.
+ */
+ public static <T> Constructor<T> getExactConstructor(Class<T> aMOClass, Class<?>[] aArgClasses) {
+ Args.checkForPositive(aArgClasses.length);
+ Constructor<T> 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 <tt>aMOCtor</tt> ;
+ 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> T buildModelObject(Constructor<T> aMOCtor, List<Object> 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<Object> 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();
+ }
+}
--- /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;
+
+/**
+ <span class="highlight">Parse a set of request parameters into a Model Object.</span>
+
+ <P>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 <tt>hirondelle.web4j.database</tt>
+ package for the similar problem of translating rows of a <tt>ResultSet</tt> into a Model Object.)
+
+ <P>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 (<tt>String</tt>s) into target types (<tt>Integer</tt>, <tt>Boolean</tt>,
+ etc), and then to in turn build complete Model Objects. This usually results in
+ much code repetition.
+
+ <P>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.
+
+ <P>Example use case of building a <tt>'Visit'</tt> Model Object out of four
+ {@link hirondelle.web4j.request.RequestParameter} objects (ID, RESTAURANT, etc.):
+ <PRE>
+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);
+ }
+}
+ </PRE>
+
+ <P><span class="highlight">The order of the sequence params passed to {@link #build(Class, Object...)}
+ must match the order of arguments passed to the Model Object constructor</span>.
+ This mechanism is quite effective and compact.
+
+ <P>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 :
+ <PRE>
+ 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
+ </PRE>
+
+ <P> 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 <tt>ModelCtorException</tt>.
+ In fact, in order for this class to be well-behaved, <span class="highlight">the MO
+ constructor cannot throw anything other than a <tt>ModelCtorException</tt> as part of
+ its contract. This includes
+ <tt>RuntimeException</tt>s</span>. For example, if a <tt>null</tt> is not permitted
+ by a MO constructor, it should not throw a <tt>NullPointerException</tt> (unchecked).
+ Rather, it should throw a <tt>ModelCtorException</tt> (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 <tt>ModelCtorException</tt>, instead
+ of a mixture of checked and unchecked exceptions.
+
+ <P>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.
+ <em>However, irregular user input is not a bug</em>.
+
+ <P>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}.
+
+<P>In summary, to work with this class, a Model Object must :
+<ul>
+ <li>be <tt>public</tt>
+ <li>have a <tt>public</tt> constructor, whose number of arguments matches the number of <tt>Object[]</tt> params
+ passed to {@link #build(Class, Object...)}
+ <li>the constructor is allowed to throw only {@link hirondelle.web4j.model.ModelCtorException} - no
+ unchecked exceptions should be (knowingly) permitted
+</ul>
+*/
+public final class ModelFromRequest {
+
+ /*<em>Design Note (for background only)</em> :
+ The design of this mechanism is a result of the following issues :
+ <ul>
+ <li>model objects (MO's) need to be constructed out of a textual source
+ <li>that textual source (the HTTP request) is not necessarily the <em>sole</em>
+ 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.
+ <li>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.
+ <li>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.
+ <li>error messages should use names meaningful to the user; for example
+ <tt>'Number of planetoids is not an integer'</tt> is preferred over the more
+ generic <tt>'Item is not an integer'</tt>.
+ <li>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.
+ <li>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 <tt>ModelCtorException</tt> 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'.
+ </ul>
+ */
+
+ /**
+ Constructor.
+
+ @param aRequestParser translates parameter values into <tt>Integer</tt>,
+ <tt>Date</tt>, 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 <em>ordered</em> list of items to be passed
+ to the Model Object's constructor, and can contain <tt>null</tt> 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 <tt>aCandidateArgs</tt>
+ cannot be translated into the target type, or if all such translations succeed,
+ but the call to the MO constructor itself fails.
+ */
+ public <T> T build(Class<T> aMOClass, Object... aCandidateArgs) throws ModelCtorException {
+ fLogger.finest("Constructing a Model Object using request param values.");
+ Constructor<T> ctor = ModelCtorUtil.getConstructor(aMOClass, aCandidateArgs.length);
+ Class<?>[] targetClasses = ctor.getParameterTypes();
+
+ List<Object> argValues = new ArrayList<Object>(); //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;
+ }
+}
--- /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}.
+
+ <P>All Model Objects should override the above {@link Object} methods.
+ All Model Objects that are being sorted in code should implement {@link Comparable}.
+
+ <P>In general, it is easier to use this class with <em>object</em> fields (<tt>String</tt>, <tt>Date</tt>,
+ <tt>BigDecimal</tt>, and so on), instead of <em>primitive</em> fields (<tt>int</tt>, <tt>boolean</tt>, and so on).
+
+ <P>See below for example implementations of :
+ <ul>
+ <li><a href="#ToString">toString()</a>
+ <li><a href="#HashCode">hashCode()</a>
+ <li><a href="#Equals">equals()</a>
+ <li><a href="#Comparable">compareTo()</a>
+ </ul>
+
+ <a name="ToString"><P><b>toString()</b><br>
+ This class is intended for the most common case, where <tt>toString</tt> is used in
+ an <em>informal</em> manner (usually for logging and stack traces). That is, <span class="highlight">
+ the caller should not rely on the <tt>toString()</tt> text returned by this class to define program logic.</span>
+
+ <P>Typical example :
+<PRE>
+ @Override public String toString() {
+ return ModelUtil.toStringFor(this);
+ }
+</PRE>
+
+ <P>There is one <em>occasional</em> variation, used only when two model objects reference each other. To avoid
+ a problem with cyclic references and infinite looping, implement as :
+ <PRE>
+ @Override public String toString() {
+ return ModelUtil.toStringAvoidCyclicRefs(this, Product.class, "getId");
+ }
+ </PRE>
+
+ Here, the usual behavior is overridden for any method in 'this' object
+ which returns a <tt>Product</tt> : instead of calling <tt>Product.toString()</tt>,
+ the return value of <tt>Product.getId()</tt> is used instead.
+
+ <a name="HashCode"><P><b>hashCode()</b><br>
+ Example of the simplest style :
+ <pre>
+ @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};
+ }
+ </pre>
+
+ <P><a name="GetSignificantFields"></a><span class="highlight">Since the {@link Object#equals} and
+ {@link Object#hashCode} methods are so closely related, and should always refer to the same fields,
+ defining a <tt>private</tt> method to return the <tt>Object[]</tt> of significant fields is highly
+ recommended.</span> Such a method would be called by <em>both</em> <tt>equals</tt> and <tt>hashCode</tt>.
+
+ <P>If an object is <a href="http://www.javapractices.com/Topic29.cjp">immutable</a>,
+ then the result may be calculated once, and then cached, as a small performance
+ optimization :
+ <pre>
+ @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};
+ }
+ </pre>
+
+ The most verbose style does not require wrapping primitives in an <tt>Object</tt> array:
+ <pre>
+ @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;
+ }
+ </pre>
+
+ <a name="Equals"><P><b>equals()</b><br>
+ Simplest example, in a class called <tt>Visit</tt> (this is the recommended style):
+ <PRE>
+ @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};
+ }
+ </PRE>
+
+ Second example, in a class called <tt>Member</tt> :
+ <PRE>
+ @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};
+ }
+ </PRE>
+ See note above regarding <a href="#GetSignificantFields">getSignificantFields()</a>.
+
+ <P>More verbose example, in a class called <tt>Planet</tt> :
+ <PRE>
+ @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!
+ }
+ </PRE>
+
+ <a name="Comparable"><P><b>compareTo()</b><br>
+ The {@link Comparable} interface is distinct, since it is not an overridable method of the
+ {@link Object} class.
+
+ <P>Example use case of using <a href='#comparePossiblyNull(T, T, hirondelle.web4j.model.ModelUtil.NullsGo)'>comparePossiblyNull</a>,
+ (where <tt>EQUAL</tt> takes the value <tt>0</tt>) :
+ <PRE>
+ 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;
+ }
+ </PRE>
+
+ @author Hirondelle Systems
+ @author with a contribution by an anonymous user of javapractices.com
+*/
+public final class ModelUtil {
+
+ // TO STRING //
+
+ /**
+ Implements an override of <tt>Object.toString()</tt> (see class comment).
+
+ <P>Example output format, for an <tt>Rsvp</tt> object with 4 fields :
+ <PRE>
+ hirondelle.fish.main.rsvp.Rsvp {
+ Response: null
+ MemberId: 4
+ MemberName: Tom Thumb
+ VisitId: 13
+ }
+ </PRE>
+ (There is no indentation since it causes problems when there is nesting.)
+
+ <P>The only items which contribute to the result are :
+ <ul>
+ <li>the full class name
+ <li>all no-argument <tt>public</tt> methods which return a value
+ </ul>
+
+ <P>These items are excluded from the result :
+ <ul>
+ <li>methods defined in {@link Object}
+ <li>factory methods which return an object of the native class ("<tt>getInstance()</tt>" methods)
+ </ul>
+
+ <P>Reflection is used to access field values. Items are converted to a <tt>String</tt> simply by calling
+ their <tt>toString method</tt>, with the following exceptions :
+ <ul>
+ <li>for arrays, the {@link Util#getArrayAsString(Object)} is used
+ <li>for methods whose name contains the text <tt>"password"</tt> (case-insensitive),
+ their return values hard-coded to '<tt>****</tt>'.
+ </ul>
+
+ <P>If the method name follows the pattern '<tt>getXXX</tt>', then the word '<tt>get</tt>'
+ is removed from the result.
+
+ <P><span class="highlight">WARNING</span>: If two classes have cyclic references
+ (that is, each has a reference to the other), then infinite looping will result
+ if <em>both</em> call this method! To avoid this problem, use <tt>toStringFor</tt>
+ for one of the classes, and {@link #toStringAvoidCyclicRefs} for the other class.
+
+ @param aObject the object for which a <tt>toString()</tt> result is required.
+ */
+ public static String toStringFor(Object aObject) {
+ return ToStringUtil.getText(aObject);
+ }
+
+ /**
+ As in {@link #toStringFor}, but avoid problems with cyclic references.
+
+ <P>Cyclic references occur when one Model Object references another, and both Model Objects have
+ their <tt>toString()</tt> methods implemented with this utility class.
+
+ <P>Behaves as in {@link #toStringFor}, with one exception: for methods of <tt>aObject</tt> that
+ return instances of <tt>aSpecialClass</tt>, then call <tt>aMethodName</tt> on such instances,
+ instead of <tt>toString()</tt>.
+ */
+ 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.
+
+ <P>(This is the recommended way of implementing <tt>hashCode</tt>.)
+
+ <P>Each element of <tt>aFields</tt> must be an {@link Object}, or an array containing
+ possibly-null <tt>Object</tt>s. These items will each contribute to the
+ result. (It is not a requirement to use <em>all</em> fields related to an object.)
+
+ <P>If the caller is using a <em>primitive</em> field, then it must be converted to a corresponding
+ wrapper object to be included in <tt>aFields</tt>. For example, an <tt>int</tt> 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 <tt>hashCode</tt>.
+
+ Contributions from individual fields are 'added' to this initial value.
+ (Using a non-zero value decreases collisons of <tt>hashCode</tt> values.)
+ */
+ public static final int HASH_SEED = 23;
+
+ /** Hash code for <tt>boolean</tt> primitives. */
+ public static int hash( int aSeed, boolean aBoolean ) {
+ return firstTerm( aSeed ) + ( aBoolean ? 1 : 0 );
+ }
+
+ /** Hash code for <tt>char</tt> primitives. */
+ public static int hash( int aSeed, char aChar ) {
+ return firstTerm( aSeed ) + aChar;
+ }
+
+ /**
+ Hash code for <tt>int</tt> primitives.
+ <P>Note that <tt>byte</tt> and <tt>short</tt> are also handled by this method, through implicit conversion.
+ */
+ public static int hash( int aSeed , int aInt ) {
+ return firstTerm( aSeed ) + aInt;
+ }
+
+ /** Hash code for <tt>long</tt> primitives. */
+ public static int hash( int aSeed , long aLong ) {
+ return firstTerm(aSeed) + (int)( aLong ^ (aLong >>> 32) );
+ }
+
+ /** Hash code for <tt>float</tt> primitives. */
+ public static int hash( int aSeed , float aFloat ) {
+ return hash( aSeed, Float.floatToIntBits(aFloat) );
+ }
+
+ /** Hash code for <tt>double</tt> primitives. */
+ public static int hash( int aSeed , double aDouble ) {
+ return hash( aSeed, Double.doubleToLongBits(aDouble) );
+ }
+
+ /**
+ Hash code for an Object.
+
+ <P><tt>aObject</tt> is a possibly-null object field, and possibly an array.
+
+ <P>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 <em>possibly</em> determining equality of two objects.
+
+ <P>This method exists to make <tt>equals</tt> implementations read more legibly,
+ and to avoid multiple <tt>return</tt> statements.
+
+ <P><em>It cannot be used by itself to fully implement <tt>equals</tt>. </em>
+ It uses <tt>==</tt> and <tt>instanceof</tt> to determine if equality can be
+ found cheaply, without the need to examine field values in detail. It is
+ <em>always</em> paired with some other method
+ (usually {@link #equalsFor(Object[], Object[])}), as in the following example :
+ <PRE>
+ public boolean equals(Object aThat){
+ Boolean result = ModelUtil.quickEquals(this, aThat);
+ <b>if ( result == null ){</b>
+ //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;
+ }
+ </PRE>
+
+ <P>This method is unusual since it returns a <tt>Boolean</tt> that takes
+ <em>3</em> values : <tt>true</tt>, <tt>false</tt>, and <tt>null</tt>. Here,
+ <tt>true</tt> and <tt>false</tt> mean that a simple quick check was able to
+ determine equality. <span class='highlight'>The <tt>null</tt> 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.</span>
+ */
+ 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.
+
+ <P>Both <tt>Object[]</tt> parameters are the same size. Each includes all fields that have been
+ deemed by the caller to contribute to the <tt>equals</tt> method. <em>None of those fields are
+ array fields.</em> 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.
+
+ <P>If a primitive field is significant, then it must be converted to a corresponding
+ wrapper <tt>Object</tt> 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 <tt>boolean</tt> fields. */
+ static public boolean areEqual(boolean aThis, boolean aThat){
+ return aThis == aThat;
+ }
+
+ /** Equals for <tt>char</tt> fields. */
+ static public boolean areEqual(char aThis, char aThat){
+ return aThis == aThat;
+ }
+
+ /**
+ Equals for <tt>long</tt> fields.
+
+ <P>Note that <tt>byte</tt>, <tt>short</tt>, and <tt>int</tt> are handled by this method, through
+ implicit conversion.
+ */
+ static public boolean areEqual(long aThis, long aThat){
+ return aThis == aThat;
+ }
+
+ /** Equals for <tt>float</tt> fields. */
+ static public boolean areEqual(float aThis, float aThat){
+ return Float.floatToIntBits(aThis) == Float.floatToIntBits(aThat);
+ }
+
+ /** Equals for <tt>double</tt> fields. */
+ static public boolean areEqual(double aThis, double aThat){
+ return Double.doubleToLongBits(aThis) == Double.doubleToLongBits(aThat);
+ }
+
+ /**
+ Equals for an Object.
+ <P>The objects are possibly-null, and possibly an array.
+ <P>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<T>
+
+ /**
+ Define hows <tt>null</tt> items are treated in a comparison. Controls if <tt>null</tt>
+ items appear first or last.
+
+ <P>See <a href='#comparePossiblyNull(T, T, hirondelle.web4j.model.ModelUtil.NullsGo)'>comparePossiblyNull</a>.
+ */
+ public enum NullsGo {FIRST,LAST}
+
+ /**
+ Utility for implementing {@link Comparable}. See <a href='#Comparable'>class example</a>
+ for illustration.
+
+ <P>The {@link Comparable} interface specifies that
+ <PRE>
+ blah.compareTo(null)
+ </PRE> should throw a {@link NullPointerException}. You should follow that
+ guideline. Note that this utility method itself
+ accepts nulls <em>without</em> throwing a {@link NullPointerException}.
+ In this way, this method can handle nullable fields just like any other field.
+
+ <P>There are
+ <a href='http://www.javapractices.com/topic/TopicAction.do?Id=207'>special issues</a>
+ 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 <tt>aThis</tt>
+ @param aNullsGo defines if <tt>null</tt> items should be placed first or last
+ */
+ static public <T extends Comparable<T>> 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();
+ }
+
+}
--- /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<String, String> map = new HashMap<String, String>();
+ 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<String> getList(String... aStrings){
+ return Arrays.asList(aStrings);
+ }
+}
--- /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<Person> items = new TreeSet<Person>(); //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> {
+ 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;
+ }
+}
--- /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
--- /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<String> months = Arrays.asList("J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D");
+ List<String> weekdays = Arrays.asList("sunday", "monday", "tuesday", "humpday", "thursday", "friday", "saturday");
+ List<String> 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<String> aMonths, List<String> aWeekdays, List<String> 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));
+ }
+ }
+}
--- /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) );
+ }
+
+ }
+}
--- /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<Decimal> values = new ArrayList<Decimal>();
+ 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);
+ }
+ }
+
+
+}
--- /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.*;
+
+/**
+ <a href="http://www.junit.org">JUnit</a> 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<String> bands = new ArrayList<String>();
+ bands.add("French Funk Federation");
+ bands.add("Les Rita Mitsouko");
+
+ List<String> books = new ArrayList<String>();
+ books.add("L'Education Sentimentale");
+
+ List<String> books2 = new ArrayList<String>();
+ books2.add("L'Education Sentimentale");
+
+ List<String> books3 = new ArrayList<String>();
+ 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));
+ }
+ }
+}
--- /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.*;
+
+/**
+ <a href="http://www.junit.org">JUnit</a> test cases for {@link ModelUtil}.
+
+ <P>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<String> girlfriends = new ArrayList<String>();
+ 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;
+}
--- /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.
+ <P>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 <T> 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();
+ }
+ }
+}
--- /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;
+
+/**
+ <a href="http://www.junit.org">JUnit</a> tests for the
+ {@link PerformanceSnapshot} class.
+
+ <P>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));
+ }
+}
--- /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 <tt>toString</tt> method for some common cases.
+
+ <P>This class is intended only for cases where <tt>toString</tt> is used in
+ an informal manner (usually for logging and stack traces). It is especially
+ suited for <tt>public</tt> classes which model domain objects.
+
+ Here is an example of a return value of the {@link #getText} method :
+ <PRE>
+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
+}
+ </PRE>
+ (Previous versions of this classes used indentation within the braces. That has
+ been removed, since it displays poorly when nesting occurs.)
+
+ <P>Here are two more examples, using classes taken from the JDK :
+ <PRE>
+java.util.StringTokenizer {
+nextElement: This
+hasMoreElements: true
+countTokens: 3
+nextToken: is
+hasMoreTokens: true
+}
+
+java.util.ArrayList {
+size: 3
+toArray: [blah, blah, blah]
+isEmpty: false
+}
+ </PRE>
+
+ There are two use cases for this class. The typical use case is :
+ <PRE>
+ public String toString() {
+ return ToStringUtil.getText(this);
+ }
+ </PRE>
+
+ <span class="highlight">However, there is a case where this typical style can
+ fail catastrophically</span> : when two objects reference each other, and each
+ has <tt>toString</tt> implemented as above, then the program will loop
+ indefinitely!
+
+ <P>As a remedy for this problem, the following variation is provided :
+ <PRE>
+ public String toString() {
+ return ToStringUtil.getTextAvoidCyclicRefs(this, Product.class, "getId");
+ }
+ </PRE>
+ Here, the usual behavior is overridden for any method
+ which returns a <tt>Product</tt> : instead of calling <tt>Product.toString</tt>,
+ the return value of <tt>Product.getId()</tt> is used to textually represent
+ the object.
+*/
+final class ToStringUtil {
+
+ /**
+ Return an informal textual description of an object.
+ <P>It is highly recommened that the caller <em>not</em> rely on details
+ of the returned <tt>String</tt>. See class description for examples of return
+ values.
+
+ <P><span class="highlight">WARNING</span>: If two classes have cyclic references
+ (that is, each has a reference to the other), then infinite looping will result
+ if <em>both</em> call this method! To avoid this problem, use <tt>getText</tt>
+ for one of the classes, and {@link #getTextAvoidCyclicRefs} for the other class.
+
+ <P>The only items which contribute to the result are the class name, and all
+ no-argument <tt>public</tt> methods which return a value. As well, methods
+ defined by the <tt>Object</tt> class, and factory methods which return an
+ <tt>Object</tt> of the native class ("<tt>getInstance</tt>" methods) do not contribute.
+
+ <P>Items are converted to a <tt>String</tt> simply by calling their
+ <tt>toString method</tt>, with these exceptions :
+ <ul>
+ <li>{@link Util#getArrayAsString(Object)} is used for arrays
+ <li>a method whose name contain the text <tt>"password"</tt> (not case-sensitive) have
+ their return values hard-coded to <tt>"****"</tt>.
+ </ul>
+
+ <P>If the method name follows the pattern <tt>getXXX</tt>, then the word 'get'
+ is removed from the presented result.
+
+ @param aObject the object for which a <tt>toString</tt> 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
+ <tt>aSpecialClass</tt>, then call <tt>aMethodName</tt> instead of <tt>toString</tt>.
+
+ <P> If <tt>aSpecialClass</tt> and <tt>aMethodName</tt> are <tt>null</tt>, 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 <tt>Object</tt> 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 <tt>true</tt> only if <tt>aMethod</tt> is public, takes no args,
+ returns a value whose class is not the native class, is not a method of
+ <tt>Object</tt>.
+ */
+ 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<String> list = new ArrayList<String>();
+ 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 );
+ }
+}
--- /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.
+
+ <P>Model Objects are <em>not</em> 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.
+
+ <P>This interface is appropriate only for checks on a single field.
+
+ <P>Please see {@link Check} for more information, and for some useful implementations.
+*/
+public interface Validator {
+
+ /**
+ Return <tt>true</tt> only if <tt>aObject</tt> passes this validation.
+
+ <P><tt>aObject</tt> is a field in some Model Object, being validated in a constructor. If the
+ field is a primitive value (such as <tt>int</tt>), then it must be converted by the caller into
+ a corresponding wrapper object (such as {@link Integer}).
+
+ @param aObject may be <tt>null</tt>.
+ */
+ boolean isValid(Object aObject);
+
+}
--- /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 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+ <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+ <meta name="Author" content="Hirondelle Systems">
+ <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+ <title>Model</title>
+</head>
+<body>
+Tools for building business domain <a href="http://www.javapractices.com/Topic187.cjp">Model Objects</a> (MOs).
+
+<P>The important things about Model Objects in WEB4J are :
+<ul>
+ <li>Model Objects are <a href="http://www.javapractices.com/Topic29.cjp">immutable</a> (highly recommended, but not required)
+ <li>Model Objects implement their own <a href="http://www.javapractices.com/Topic209.cjp">validation logic</a>
+ <li>in general, Model Objects are not dumb data carriers.
+ <li>if desired, Model Objects can easily avoid the <a href="http://www.javapractices.com/Topic84.cjp">Java Beans anti-pattern</a>.
+</ul>
+
+<h3>Restrictions on Model Objects</h3>
+These are the restrictions on Model Objects in WEB4J :
+<ul>
+ <li>they must be <tt>public</tt> classes
+ <li>the constructors must be <tt>public</tt>, and always take one or more arguments
+ <li>the constructors <em>perform all validation</em>, and must throw
+ {@link hirondelle.web4j.model.ModelCtorException} if any problems occur
+ <li>to be easily used with WEB4J utility classes, the constructor arguments must belong
+ to a set of common building block classes (<tt>Integer</tt>, <tt>BigDecimal</tt>, and so on)
+</ul>
+
+<P>These items can be added to Model Objects, if desired, but they are never used by WEB4J :
+<ul>
+ <li>no-argument constructors
+ <li><tt>setXXX</tt> methods
+</ul>
+
+<h3>Validation</h3>
+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.
+
+<h3>Object Methods</h3>
+<P>It is highly recommended that all Model Objects override <tt>equals</tt>, <tt>hashCode</tt>, and
+<tt>toString</tt>. ({@link hirondelle.web4j.model.ModelUtil} can help you implement these methods.)
+
+<h3>Building Model Objects</h3>
+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 <em>unreliable</em>. 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.
+
+<P>Thus, a Model Object must allow for all possible input when creating objects from both these sources.
+
+<P>WEB4J has two main tools for this task :
+<ul>
+<li>{@link hirondelle.web4j.model.ModelFromRequest}, for building Model Objects from underlying request parameters
+<li>{@link hirondelle.web4j.database.Db}, for building Model Objects from an underlying <tt>ResultSet</tt>
+</ul>
+
+<P>Both of these tools are simple to use because they use effective <em>ordering</em> conventions for data.
+</body>
+</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 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+ <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+ <meta name="Author" content="Hirondelle Systems">
+ <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+ <title>WEB4J</title>
+</head>
+<body>
+Important items which apply to the whole application.
+
+<p>The top of a package hierarchy (here, the <tt>hirondelle.web4j</tt>
+package) is a good place for such items, since it is easy to find, and
+is often the first package examined by the reader.
+</body>
+</html>
--- /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.
+
+ <P>Assumptions:
+ <ul>
+ <li><tt>init</tt> is called only once, upon startup, when the system is in single-threaded mode
+ <li>there is no re-init or reload mechanism
+ <li>after <tt>init</tt> is called, the data will never change, and this class is safe to be used
+ in a multi-threaded environment
+ <li>all data returned by the getXXX methods is immutable, or a defensive copy is passed
+ </ul>
+
+ <P>The typical user of this class is simple:
+<pre>Config config = new Config();
+ String blah = config.getBlah;
+</pre>
+ The caller usually has no need to store the data in their own static private field.
+
+ <P>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.
+
+ <P>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 <tt>init</tt>.
+ There is no method to revert to the original defaults.
+
+ <P>
+ 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.
+
+ <P>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.
+
+ <P>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.
+
+ <P>Note than this takes a generic map, not <tt>ServletContext</tt>. That's important,
+ since it allows this Config to be used in any context, not just a servlet container.
+ <P>The key is case-sensitive.
+ */
+ public static void init(Map<String, String> aKeyValuePairs){
+ initAllItems(aKeyValuePairs);
+ }
+
+ /**
+ Confirm that database settings in <tt>web.xml</tt> are known to {@link ConnectionSource}.
+ Order dependency: this method is called after <tt>init</tt>, since it needs to know the database names,
+ which in turn requires a call to BuildImpl.init.
+ */
+ public static void checkDbNamesInSettings(Set<String> aValidDbNames){
+ Set<String> namesInSettings = new LinkedHashSet<String>();
+
+ 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<String> unknownNames = new LinkedHashSet<String>();
+ 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<String, String> 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<String> getLoggingLevels(){
+ return fLoggingLevels;
+ }
+
+ public List<String> 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<String, List<String>> 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<Integer> getErrorCodesForDuplicateKey(String aDbName){
+ return asIntegerList(fErrorCodeForDuplicateKey.getValue(aDbName));
+ }
+
+ public List<Integer> 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<String> fErrors = new ArrayList<String>();
+
+ private static void initAllItems(Map<String, String> 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<String, String> aKeyValuePairs){
+ return Util.textHasContent(aKeyValuePairs.get(aName));
+ }
+
+ private static boolean hasValueNotNone(String aName, Map<String, String> aKeyValuePairs){
+ String value = aKeyValuePairs.get(aName);
+ return Util.textHasContent(value) && (! NONE.equalsIgnoreCase(value));
+ }
+
+ private static String initThisString(String aName, Map<String, String> aKeyValuePairs){
+ return aKeyValuePairs.get(aName);
+ }
+
+ private static Long initThisLong(String aName, Map<String, String> aKeyValuePairs){
+ return asLong(aKeyValuePairs.get(aName));
+ }
+
+ private static TimeZone initThisTimeZone(String aName, Map<String, String> aKeyValuePairs){
+ return asTimeZone(aKeyValuePairs.get(aName));
+ }
+
+ private static List<String> initThisStringList(String aName, Map<String, String> aKeyValuePairs){
+ return asStringListWithNone(aKeyValuePairs.get(aName));
+ }
+
+ private static String initThisStringNoDefault(final String aName, final Map<String, String> 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<String, String> aKeyValuePairs){
+ String value = aKeyValuePairs.get(aName);
+ return Util.parseBoolean(value);
+ }
+
+ private static DbConfigParser initThisDbConfig(String aName, Map<String, String> aKeyValuePairs){
+ return new DbConfigParser(aKeyValuePairs.get(aName));
+ }
+
+ private static Locale initThisLocale(String aName, Map<String, String> 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<String, String> 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<String> fLoggingLevels = Arrays.asList("hirondelle.web4j.level=CONFIG");
+ private static List<String> 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<String, List<String>> fUntrustedProxyForUserId = new LinkedHashMap<String, List<String>>();
+ 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 = "<![CDATA[ <input type='checkbox' name='true' value='true' checked readonly notab> ]]>";
+ private static String fBooleanFalseDisplayFormat = "<![CDATA[ <input type='checkbox' name='false' value='false' checked readonly notab>]]>";
+ 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<String> asStringListWithNone(String aValue){
+ List<String> result = new ArrayList<String>();
+ if (! "NONE".equalsIgnoreCase(aValue)){
+ result.addAll(asStringList(aValue));
+ }
+ return result;
+ }
+
+ /** Separated by a comma. */
+ private static List<String> asStringList(String aValue){
+ List<String> result = new ArrayList<String>();
+ 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<Integer> asIntegerList(String aValue){
+ List<Integer> result = new ArrayList<Integer>();
+ 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."
+ );
+ }
+ }
+}
--- /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 <tt>WEB-INF</tt> directory,
+ and returns their contents as {@link Properties}.
+
+ <P>In addition, this class returns {@link Set}s of {@link Class}es that are present under
+ <tt>/WEB-INF/classes</tt>. 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.
+
+<P><em>Design Note</em>: In addition to the configuration facilities present in
+ <tt>web.xml</tt>, 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 <tt>WEB-INF</tt> 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 <tt>.properties</tt> file.
+ See {@link Properties} for more information.
+ */
+ PROPERTIES_FILE,
+
+ /**
+ Text file containing blocks of text, constants, and comments.
+ <P>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.
+
+ <P>The following terminology is used here :
+ <ul>
+ <li>Block - a multiline block of text with within braces, with an associated
+ identifier (similar to a typical Java block)
+ <li>Block Name - the identifier of a block, appearing on the first line, before the
+ opening brace
+ <li>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.
+ </ul>
+
+ <P>Example of a typical <tt>TEXT_BLOCK</tt> file (using
+ SQL statements for Block Bodies) :
+ <PRE>
+ -- 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}
+ }
+ </PRE>
+
+ <P>The format details are as follows :
+ <ul>
+ <li>empty lines can appear only outside of blocks
+ <li>the '<tt>--</tt>' character denotes a comment
+ <li>there are no multiline comments. (Such comments are easier for the writer,
+ but are much less clear for the reader.)
+ <li>the body of each item is placed in a named block, bounded by
+ '<tt><name> {</tt>' on an initial line, and '<tt>}</tt>' on an end line.
+ The name given to the the block (<tt>ADD_MESSAGE</tt> for example) is how
+ <tt>WEB4J</tt> 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 <tt>public static final SqlId</tt> field. Upon startup, this allows verification
+ that all items defined in the text source have a corresponding item in code.
+ <li>'<tt>--</tt>' comments can appear in the Block Body as well
+ <li>if desired, the Block Body may be indented, to make the Block more legible
+ <li>the Block Name of '<tt>constants</tt>' is reserved. A <tt>constants</tt>
+ block defines one or more simple textual substitution constants, as
+ <tt>name = value</tt> 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 <em>later</em> in the file. Any number of <tt>constants</tt>
+ blocks can appear in a file.
+ <li>Block Names and the names of constants satisfy
+ {@link hirondelle.web4j.util.Regex#SIMPLE_SCOPED_IDENTIFIER}.
+ <li>inside a Block Body, substitutions are denoted by the common
+ syntax '<tt>${blah}</tt>', where <tt>blah</tt> 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
+ <tt>constants</tt> Block
+ <li>an item must be defined before it can be used in a substitution ; that is,
+ it must appear earlier in the file
+ <li>no substitutions are permitted in a <tt>constants</tt> Block
+ <li>Block Names, and the names of constants, should be unique.
+ </ul>
+ */
+ TEXT_BLOCK,
+ }
+
+ /**
+ Fetch a single, specific file located in the <tt>WEB-INF</tt>
+ directory, and populate a corresponding <tt>Properties</tt> object.
+
+ @param aConfigFileName has content and is the "simple" name (without path info)
+ of a readable file in the <tt>WEB-INF</tt> directory (for example, <tt>"config.properties"</tt> or
+ <tt>"statements.sql"</tt>).
+ */
+ public static Properties fetch(String aConfigFileName, FileType aFileType){
+ return basicFetch(fWEBINF + aConfigFileName, ! FOR_TESTING, aFileType);
+ }
+
+ /**
+ Fetch all config files located anywhere under the <tt>WEB-INF</tt> directory whose
+ "simple" file name (without path info) matches <tt>aFileNamePattern</tt>, and
+ translate their content into a single {@link Properties} object.
+
+ <P>If the caller needs only one file of a specific name, and that file is
+ located in the <tt>WEB-INF</tt> directory itself, then use {@link #fetch} instead.
+
+ <P><span class="highlight">The keys in all of the matching files should be unique.</span>
+ If a duplicate key exists, then the second instance will overwrite the first (as in
+ {@link HashMap#put}), and a <tt>SEVERE</tt> warning is logged.
+
+ <P><em>Design Note:</em><br>
+ This method was initially created to reduce contention for the <tt>*.sql</tt> file,
+ on projects having more than one developer. See
+ {@link hirondelle.web4j.database.SqlStatement} and the example <tt>.sql</tt> 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<String> allFilePaths = getFilePathsBelow(fWEBINF);
+ Set<String> 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;
+ }
+
+ /**
+ <em>Intended for testing only</em>, outside of a web container environment.
+
+ <P>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 <tt>/WEB-INF/classes/</tt> that implement <tt>aInterface</tt>.
+
+ <P>More specifically, the classes returned here are
+ not <tt>abstract</tt>, and not an <tt>interface</tt>. 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 <T> Set<Class<T>> fetchConcreteClassesThatImplement(Class<T> aInterface){
+ if(aInterface != null){
+ fLogger.config("Fetching concrete classes that implement " + aInterface);
+ }
+ else {
+ fLogger.config("Fetching all concrete classes.");
+ }
+ Set<Class<T>> result = new LinkedHashSet<Class<T>>();
+ Set<String> allFilePaths = getFilePathsBelow(fWEBINF_CLASSES);
+ Set<String> 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<T> thisClass = (Class<T>)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 <tt>public static final</tt> fields declared in an application of type <tt>aFieldClass</tt>.
+
+ <P>Restrict the search to concrete classes that implement an interface <tt>aContainingClassInterface</tt>,
+ as in {@link #fetchConcreteClassesThatImplement(Class)}. If <tt>aContainingClassInterface</tt> is
+ <tt>null</tt>, then scan all classes for such fields.
+
+ <P>Return a possibly-empty {@link Map} having :
+ <br>KEY - Class object of class from which the field is visible; see {@link Class#getFields()}.
+ <br>VALUE - {@link Set} of objects of class aFieldClass
+ */
+ public static /*T(Contain),V(field)*/ Map/*<Class<T>, Set<V>>*/ 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 <tt>null</tt> value
+ for <tt>aContainingClassInterface</tt>.
+ */
+ public static Map fetchPublicStaticFinalFields(Class aFieldClass){
+ return fetchPublicStaticFinalFields(null, aFieldClass);
+ }
+
+ /**
+ Process all the raw <tt>.sql</tt> text data into a consolidated Map of SqlId to SQL statements.
+ @param aRawSql - the raw, unprocessed content of each <tt>.sql</tt> file (or data)
+ */
+ public static Map<String, String> processRawSql(List<String> aRawSql){
+ Map<String, String> result = new LinkedHashMap<String, String>();
+ 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 <tt>Properties</tt> 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 <tt>aStartDirectory</tt> and containing the full
+ file path for all files under <tt>aStartDirectory</tt>. No sorting is performed.
+
+ @param aStartDirectory starts with <tt>/WEB-INF/</tt>
+ */
+ private static Set<String> getFilePathsBelow(String aStartDirectory){
+ Set<String> result = new LinkedHashSet<String>();
+ Set<String> 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
+ <tt>WEB-INF</tt> whose simple file name (<em>without</em> the path info)
+ matches <tt>aFileNamePattern</tt>.
+
+ @param aFullFilePaths Set of Strings starting with "<tt>/WEB-INF/</tt>", which
+ denote paths to <b>all</b> files under the <tt>WEB-INF</tt> directory (recursive)
+ */
+ private static Set<String> matchTargetPattern(Set<String> aFullFilePaths, Pattern aFileNamePattern) {
+ Set<String> result = new LinkedHashSet<String>();
+ 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 <tt>aProperties</tt> to <tt>aResult</tt>.
+
+ <P>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 <tt>aProperties</tt>, 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<String, String> processedSql(String aRawSql){
+ TextBlockReader textBlockReader = new TextBlockReader(aRawSql);
+ Properties props = null;
+ try {
+ props = textBlockReader.read();
+ }
+ catch (IOException ex) {
+ ex.printStackTrace();
+ }
+ return new LinkedHashMap(props);
+ }
+}
--- /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.
+
+ <P>The format of these settings has 2 syntactical forms:
+ <ul>
+ <li>1 value, no ~ separator - the single value is applied to all databases.
+ <li>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.
+ </ul>
+
+ <P>Example of the second style:
+ <PRE>100~Translation=200~Access=300</PRE>
+
+ 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.
+
+ <P>The other entries are name=value pairs; the name must be a name of one of the
+ non-default databases known to ConnectionSource.
+
+ <P>Thus, the database named 'Translation' has a value of 200, different from the default
+ value of 100. If the setting instead was
+
+ <PRE>100~Access=300</PRE>
+
+ then the value for the Translation database would be 100 - same as the default.
+
+ <P>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 <tt>aDatabaseName</tt> 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.
+ <P>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<String> getDbNames(){
+ return Collections.unmodifiableSet(fValuesTable.keySet());
+ }
+
+ // PRIVATE
+ private String fRawText;
+ private Map<String, String> fValuesTable = new LinkedHashMap<String, String>();
+
+ //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);
+ }
+}
--- /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);
+ }
+}
--- /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<String, List<String>> parse(String aRawValue){
+ List<String> 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<String, List<String>> fRestrictedOperations = new LinkedHashMap<String, List<String>>();
+
+ private static final String INIT_PARAM_NAME = "UntrustedProxyForUserId";
+
+ private List<String> parseSeparateLines(String aRawValue){
+ List<String> result = new ArrayList<String>();
+ 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<String> verbs = new ArrayList<String>();
+ 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<String> verbs = fRestrictedOperations.get(aNoun);
+ verbs.add(aVerb);
+ }
+}
--- /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
+ }
+ }
+ }
+}
--- /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.
+
+ <P>See {@link ConfigReader.FileType#TEXT_BLOCK} for specification of the format of such files.
+*/
+final class TextBlockReader {
+
+ /**
+ @param aInput has an underlying <tt>TextBlock</tt> 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 = "<file name unspecified>";
+ fConstants = new Properties();
+ }
+
+ /**
+ Parse the underlying <tt>TEXT_BLOCK</tt> file into a {@link Properties} object, which
+ uses key-value pairs of <tt>String</tt>s.
+
+ <P>Using this example entry in a <tt>*.sql</tt> file :
+ <PRE>
+ FETCH_NUM_MSGS_FOR_USER {
+ SELECT COUNT(Id) FROM MyMessage WHERE LoginName=?
+ }
+ </PRE>
+ the key is <P><tt>FETCH_NUM_MSGS_FOR_USER</tt> <P> while the value is
+ <P><tt>SELECT COUNT(Id) FROM MyMessage WHERE LoginName=?</tt>.
+ */
+ 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.
+
+ <P>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) + "] ";
+ }
+}
--- /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 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+ <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+ <meta name="Author" content="Hirondelle Systems">
+ <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+ <title>Utilities</title>
+</head>
+<body>
+Access to configured items.
+<br>
+</body>
+</html>
--- /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.
+
+ <P>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.
+
+ <P>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.
+
+ <P>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
+ <a href="http://www.javapractices.com/apps/fish/javadoc/src-html/hirondelle/web4j/config/DateConverterImpl.html">example implementation</a>.
+
+ <P><b><em>Design Notes</em></b><br>
+ Here are some forces concerning dates in Java web applications :
+<ul>
+ <li>usually, users expect date input formats to be the same for all forms.
+ <li>an application may have only dates, only date-times, or a mixture of the two.
+ <li>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.)
+ <li><span class="highlight">the needs of the eye differ from the needs of the hand.</span> That is, a date format
+ which is easy to <em>read</em> is usually not easy to <em>enter</em> into a control. Thus, applications should support <em>two</em> formats for
+ dates : a <em>hand-friendly</em> format (used for input), and an <em>eye-friendly</em> format, used for
+ presentation <em>and</em> for input as well (allowing user input with an eye-friendly format is
+ important for forms which change existing data).
+ <li>date formats may differ between Locales. For example, <tt>'01-31-2006'</tt> is natural for English speakers
+ (January 31, 2006), while <tt>'31-01-2006'</tt> is more natural for French speakers (le 31 janvier 2006).
+ <li>for <em>parsing</em> 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.
+ <li>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 <tt>Locale</tt>s.
+</ul>
+*/
+public interface DateConverter {
+
+ /**
+ Parse textual user input of a "hand-friendly" format into a {@link Date} object.
+
+ <P>A hand-friendly format might be <tt>'01312006'</tt>, while an eye-friendly format might be
+ <tt>'Jan 31, 2006'</tt>.
+ <P>The implementation must return <tt>null</tt> 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.
+ <P>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.
+
+ <P>A hand-friendly format might be <tt>'01312006'</tt>, while an eye-friendly format might be
+ <tt>'Jan 31, 2006'</tt>.
+ <P>The implementation must return <tt>null</tt> 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.
+ <P>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.
+
+ <P>The implementation must return an empty <tt>String</tt> when the {@link Date} is null.
+ It is recommended that the implementation have reasonable default behaviour for unexpected {@link Locale}s.
+ <P>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.
+ <P>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.
+
+ <P>A hand-friendly format might be <tt>'01312006'</tt>, while an eye-friendly format might be
+ <tt>'Jan 31, 2006'</tt>.
+ <P>The implementation must return <tt>null</tt> 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.
+ <P>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.
+
+ <P>A hand-friendly format might be <tt>'01312006'</tt>, while an eye-friendly format might be
+ <tt>'Jan 31, 2006'</tt>.
+ <P>The implementation must return <tt>null</tt> 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.
+ <P>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.
+
+ <P>The implementation must return an empty <tt>String</tt> when the {@link DateTime} is null.
+ It is recommended that the implementation have reasonable default behaviour for unexpected {@link Locale}s.
+ <P>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.
+ <P>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);
+
+
+
+}
--- /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.
+
+ <P>The formats used by this class are <em>mostly</em> configured in
+ <tt>web.xml</tt>, and are read by this class upon startup.
+ <span class="highlight">See the <a href='http://www.web4j.com/UserGuide.jsp#ConfiguringWebXml'>User Guide</tt>
+ for more information.</span>
+
+ <P>Most formats are localized using the {@link java.util.Locale} passed to this object.
+ See {@link LocaleSource} for more information.
+
+ <P>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}).
+
+ <P>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.
+
+ <P>The returned {@link Pattern} is controlled by a setting in <tt>web.xml</tt>,
+ for decimal separator(s). It is suitable for both {@link Decimal} and {@link BigDecimal} values.
+ This item is not affected by a {@link Locale}.
+
+ <P>See <tt>web.xml</tt> 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.
+
+ <P>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 <tt>null</tt> values in a report.
+
+ <P>The return value does not depend on {@link Locale}. See <tt>web.xml</tt> for more information.
+ */
+ public static String getEmptyOrNullText() {
+ return new Config().getEmptyOrNullDisplayFormat();
+ }
+
+ /**
+ Translate an object into text, suitable for presentation <em>in an HTML form</em>.
+
+ <P>The intent of this method is to return values matching those POSTed during form submission,
+ not the visible text presented to the user.
+
+ <P>The returned text is not escaped in any way.
+ That is, <em>if special characters need to be escaped, the caller must perform the escaping</em>.
+
+ <P>Apply these policies in the following order :
+ <ul>
+ <li>if <tt>null</tt>, return an empty <tt>String</tt>
+ <li>if a {@link DateTime}, apply {@link DateConverter#formatEyeFriendlyDateTime(DateTime, Locale)}
+ <li>if a {@link Date}, apply {@link DateConverter#formatEyeFriendly(Date, Locale, TimeZone)}
+ <li>if a {@link BigDecimal}, display in the form of {@link BigDecimal#toString}, with
+ one exception : the decimal separator will be as configured in <tt>web.xml</tt>.
+ (If the setting for the decimal separator allows for <em>both</em> a period and a comma,
+ then a period is used.)
+ <li>if a {@link Decimal}, display the amount only, using the same rendering as for <tt>BigDecimal</tt>
+ <li>if a {@link TimeZone}, return {@link TimeZone#getID()}
+ <li>if a {@link Code}, return {@link Code#getId()}.toString()
+ <li>if a {@link Id}, return {@link Id#getRawString()}
+ <li>if a {@link SafeText}, return {@link SafeText#getRawString()}
+ <li>otherwise, return <tt>aObject.toString()</tt>
+ </ul>
+
+ <P>If <tt>aObject</tt> is a <tt>Collection</tt>, then the caller must call
+ this method for every element in the <tt>Collection</tt>.
+
+ @param aObject must not be a <tt>Collection</tt>.
+ */
+ 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.
+
+ <P>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.
+
+ <P>This method applies the following policies to get the <em>unescaped</em> text :
+ <P>
+ <table border=1 cellpadding=3 cellspacing=0>
+ <tr><th>Type</th> <th>Action</th></tr>
+ <tr>
+ <td><tt>SafeText</tt></td>
+ <td>use {@link SafeText#getRawString()}</td>
+ </tr>
+ <tr>
+ <td><tt>Id</tt></td>
+ <td>use {@link Id#getRawString()}</td>
+ </tr>
+ <tr>
+ <td><tt>Code</tt></td>
+ <td>use {@link Code#getText()}.getRawString()</td>
+ </tr>
+ <tr>
+ <td><tt>hirondelle.web4.model.DateTime</tt></td>
+ <td>apply {@link DateConverter#formatEyeFriendlyDateTime(DateTime, Locale)} </td>
+ </tr>
+ <tr>
+ <td><tt>java.util.Date</tt></td>
+ <td>apply {@link DateConverter#formatEyeFriendly(Date, Locale, TimeZone)} </td>
+ </tr>
+ <tr>
+ <td><tt>BigDecimal</tt></td>
+ <td>use {@link #getBigDecimalDisplayFormat} </td>
+ </tr>
+ <tr>
+ <td><tt>Decimal</tt></td>
+ <td>use {@link #getBigDecimalDisplayFormat} on <tt>decimal.getAmount()</tt></td>
+ </tr>
+ <tr>
+ <td><tt>Boolean</tt></td>
+ <td>use {@link #getBooleanDisplayText} </td>
+ </tr>
+ <tr>
+ <td><tt>Integer</tt></td>
+ <td>use {@link #getIntegerReportDisplayFormat} </td>
+ </tr>
+ <tr>
+ <td><tt>Long</tt></td>
+ <td>use {@link #getIntegerReportDisplayFormat} </td>
+ </tr>
+ <tr>
+ <td><tt>Locale</tt></td>
+ <td>use {@link Locale#getDisplayName(java.util.Locale)} </td>
+ </tr>
+ <tr>
+ <td><tt>TimeZone</tt></td>
+ <td>use {@link TimeZone#getDisplayName(boolean, int, java.util.Locale)} (with no daylight savings hour, and in the <tt>SHORT</tt> style </td>
+ </tr>
+ <tr>
+ <td>..other...</td>
+ <td>
+ use <tt>toString</tt>, and pass result to constructor of {@link SafeText}.
+ </td>
+ </tr>
+ </table>
+
+ <P>In addition, the value returned by {@link #getEmptyOrNullText} is used if :
+ <ul>
+ <li><tt>aObject</tt> is itself <tt>null</tt>
+ <li>the result of the above policies returns text which has no content
+ </ul>
+ */
+ 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")));
+ }
+}
--- /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.
+
+ <P>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.
+
+ <P>In general, a {@link Locale} is used for two distinct operations :
+<ul>
+ <li>render objects such as <tt>Integer</tt>, <tt>Date</tt>, and so on
+ <li>translate text from one language to another
+</ul>
+
+ <P>Since almost all applications at least <em>render</em> data, then almost all applications
+ will also use a {@link Locale} - <em>even single-language applications.</em>
+
+ <P>In a single-language application, the returned <tt>Locale</tt> is used by WEB4J for
+ rendering these items :
+<ul>
+ <li>user response messages containing numbers and dates
+ <li>presenting <tt>ResultSets</tt> as reports (dates, numbers)
+ <li>prepopulating forms (dates, numbers)
+ <li>displaying dates with {@link hirondelle.web4j.ui.tag.ShowDate}
+</ul>
+
+ <P>If the application is multilingual, then the returned <tt>Locale</tt> is <em>also</em>
+ used for translating text. See {@link hirondelle.web4j.ui.translate.Translator} and related classes
+ for more information.
+
+ <P>A very large number of policies can be defined by implementations of this interface.
+ Possible sources of <tt>Locale</tt> information include :
+<ul>
+ <li>a single setting in <tt>web.xml</tt>, place into application scope upon startup
+ <li>an object stored in session scope
+ <li>a request parameter
+ <li>a request header
+ <li>a cookie
+</ul>
+
+ <P>All WEB4J applications have at least one {@link Locale} - the <tt>DefaultLocale</tt> setting
+ configured in <tt>web.xml</tt>. (This allows the application's <tt>Locale</tt> to be set
+ explicitly, independent of the server's default <tt>Locale</tt> setting, or of browser
+ header settings.) For applications which use only a single language, that <tt>Locale</tt> defines
+ how WEB4J will format the items mentioned above. For multilingual applications, this <tt>web.xml</tt> setting is
+ reinterpreted as the <em>default</em> <tt>Locale</tt>, 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);
+
+}
--- /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}.
+
+ <P>Upon startup, the {@link hirondelle.web4j.Controller} will read in the <tt>DefaultLocale</tt>
+ configured in <tt>web.xml</tt>, and place it in application scope under the key
+ {@link hirondelle.web4j.Controller#LOCALE}, as a {@link Locale} object (not a {@link String}).
+
+ <P><em>If desired</em>, 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 <tt>Locale</tt>, overriding the default
+ <tt>Locale</tt> stored in application scope.
+
+ <P>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);
+ }
+}
--- /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;
+
+/**
+ <span class="highlight">Request parameter as a name and
+ (usually) an associated <em>regular expression</em>.</span>
+
+ <P>This class does not <em>directly</em> provide access to the parameter <em>value</em>.
+ For such services, please see {@link RequestParser} and {@link ModelFromRequest}.
+
+ <P>This class separates request parameters into two kinds : file upload request
+ parameters, and all others (here called "regular" request parameters).
+
+ <P><b><a name="Regular">Regular Request Parameters</a></b><br>
+ Regular request parameters are associated with :
+ <ul>
+ <li>a name, corresponding to both an underlying HTTP request parameter name, and
+ an underlying control name - see <a href="#NamingConvention">naming convention</a>.
+ <li>a regular expression, used by {@link ApplicationFirewall} to perform
+ <a href="ApplicationFirewall.html#HardValidation">hard validation</a>
+ </ul>
+
+ <P><b><a name="FileUpload">File Upload Request Parameters</a></b><br>
+ Files are uploaded using forms having :
+<ul>
+ <li> <tt>method="POST"</tt>
+ <li> <tt>enctype="multipart/form-data"</tt>
+ <li> an <tt><INPUT type="file"></tt> control
+</ul>
+
+ <P>In addition, note that the Servlet API does <em>not</em> have extensive services for
+ processing file upload parameters. It is likely best to use a third party tool for
+ that task.
+
+ <P>File upload request parameters, <em>as represented by this class</em>, have only a
+ name associated with them, and no regular expression. This is because WEB4J
+ cannot perform <a href="ApplicationFirewall.html#HardValidation">hard validation</a>
+ 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 <a href="ApplicationFirewall.html#SoftValidation">soft validation</a>. If there is a
+ problem, the response to the user must be polished, as part of the normal operation of
+ the application.
+
+ <P>As an example, an {@link Action} might perform
+ <a href="ApplicationFirewall.html#SoftValidation">soft validation</a> on a file upload parameter
+ for these items :
+ <ul>
+ <li>file size does not exceed a maximum value
+ <li>MIME type matches a regular expression
+ <li>file name matches a regular expression
+ <li>text file content may be matched to a regular expression
+ </ul>
+
+ <P><b><a name="NamingConvention">Naming Convention</a></b><br>
+ <span class="highlight">Parameter names are usually not arbitrary in WEB4J.</span>
+ Instead, a simple convention is used which allows for automated mapping between
+ request parameter names and corresponding <tt>getXXX</tt> methods of Model Objects
+ (see {@link hirondelle.web4j.ui.tag.Populate}). For example, a parameter
+ named <tt>'Birth Date'</tt> (or <tt>'birthDate'</tt>) is mapped to a method named
+ <tt>getBirthDate()</tt> when prepopulating a form with the contents of
+ a Model Object. (The <tt>'Birth Date'</tt> 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.)
+
+ <P> Some parameters - notably those passed to <tt>Template.jsp</tt> - are not
+ processed at all by the <tt>Controller</tt>, but are used directly in JSPs
+ instead. Such parameters do not undergo
+ <a href="ApplicationFirewall.html#HardValidation">hard validation</a> by the
+ {@link hirondelle.web4j.security.ApplicationFirewall}, and are not represented by this class.
+
+ <P> See {@link java.util.regex.Pattern} for more information on regular expressions.
+*/
+public final class RequestParameter {
+
+ /**
+ Return a <a href="#Regular">regular parameter</a> hard-validated only for
+ name and size.
+
+ <P>The size is taken from the <tt>MaxRequestParamValueSize</tt> setting in <tt>web.xml</tt>.
+
+ @param aName name of the underlying HTTP request parameter. See
+ <a href="#NamingConvention">naming convention</a>.
+ */
+ 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 <a href="#Regular">regular parameter</a> hard-validated for name and
+ for value matching a regular expression.
+
+ @param aName name of the underlying HTTP request parameter. See
+ <a href="#NamingConvention">naming convention</a>.
+ @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 <a href="#Regular">regular parameter</a> hard-validated for name and
+ for value matching a regular expression.
+
+ @param aName name of the underlying HTTP request parameter. See
+ <a href="#NamingConvention">naming convention</a>.
+ @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 <a href="#FileUpload">file upload</a> request parameter.
+
+ @param aName name of the underlying HTTP request parameter. See
+ <a href="#NamingConvention">naming convention</a>.
+ */
+ 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 <tt>RequestParameter</tt>.
+
+ <P>This regular expression is used to perform
+ <a href="ApplicationFirewall.html#HardValidation">hard validation</a> of this parameter's value(s).
+
+ <P>This method will return <tt>null</tt> only for <a href="#FileUpload">file upload</a> parameters.
+ */
+ public Pattern getRegex(){
+ return fRegex;
+ }
+
+ /**
+ Return <tt>true</tt> only if {@link #forFileUpload} was used to build this object.
+ */
+ public boolean isFileUploadParameter() {
+ return ! fIsRegularParameter;
+ }
+
+ /**
+ Return <tt>true</tt> only if <tt>aRawParamValue</tt> satisfies the regular expression
+ {@link #getRegex()}, <em>or</em> if this is a <a href="#FileUpload">file upload</a>
+ request parameter.
+
+ <P>Always represents a <a href="ApplicationFirewall.html#HardValidation">hard validation</a>, 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 <a href="#Regular">"regular"</a> request parameter.
+
+ @param aName name of the underlying HTTP request parameter. See
+ <a href="#NamingConvention">naming convention</a>.
+ @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 <a href="#Regular">"regular"</a> request parameter.
+
+ @param aName name of the underlying HTTP request parameter. See
+ <a href="#NamingConvention">naming convention</a>.
+ @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 <a href="#FileUpload">file upload</a> request parameter.
+
+ @param aName name of the underlying HTTP request parameter. See
+ <a href="#NamingConvention">naming convention</a>.
+ */
+ 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};
+ }
+}
--- /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}.
+
+ <P>See the {@link hirondelle.web4j.BuildImpl} for important information on how this item is configured.
+
+ <P><span class="highlight">Almost all concrete implementations of this Abstract Base Class will need to
+ implement only a single method</span> - {@link #getWebAction()}. WEB4J provides a default implementation
+ {@link RequestParserImpl}.
+
+ <P>The role of this class is to view the request at a higher level than the underlying
+ Servlet API. In particular, its services include :
+<ul>
+ <li>mapping a request to an {@link Action}
+ <li>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.)
+</ul>
+
+ <P><a href="RequestParameter.html#FileUpload">File upload</a> 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.
+
+ <P>The various <tt>toXXX</tt> methods are offered as a convenience for accessing <tt>String</tt>
+ and <tt>String</tt>-like data. All such <tt>toXXX</tt> 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.
+ <P>See the {@link hirondelle.web4j.BuildImpl} for important information on how
+ this item is configured.
+ */
+ public static RequestParser getInstance(HttpServletRequest aRequest, HttpServletResponse aResponse){
+ List<Object> args = new ArrayList<Object>();
+ 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}.
+
+ <P>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.
+
+ <P>If the incoming request does not map to a known {@link Action}, then throw
+ a {@link BadRequestException}. <span class="highlight">Such requests
+ are expected only for bugs and for malicious attacks, and never as part of the normal operation
+ of the program.</span>
+ */
+ abstract public Action getWebAction() throws BadRequestException;
+
+ /**
+ Return the parameter value exactly as it appears in the request.
+
+ <P>Can return <tt>null</tt> values, empty values, values containing
+ only whitespace, and values equal to the <tt>IgnorableParamValue</tt> configured in <tt>web.xml</tt>.
+ */
+ 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.
+
+ <P>Can return <tt>null</tt> values, empty values, values containing
+ only whitespace, and values equal to the <tt>IgnorableParamValue</tt> configured in <tt>web.xml</tt>.
+ */
+ public final String[] getRawParamValues(RequestParameter aReqParam){
+ String[] result = fRequest.getParameterValues(aReqParam.getName());
+ return result;
+ }
+
+ /**
+ Return a building block object.
+
+ <P>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> T toSupportedObject(RequestParameter aReqParam, Class<T> 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 <tt>List</tt> of building block objects.
+
+ <P>Uses all methods of the configured implementation of {@link ConvertParam}.
+ <P>
+ <em>Design Note</em><br>
+ <tt>List</tt> 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 <T> List<T> toSupportedObjects(RequestParameter aReqParam, Class<T> aSupportedTargetClass) throws ModelCtorException {
+ List<T> result = new ArrayList<T>();
+ 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<SafeText>}. */
+ public final Collection<SafeText> toSafeTexts(RequestParameter aReqParam) {
+ Collection<SafeText> 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<Id>}. */
+ public final Collection<Id> toIds(RequestParameter aReqParam) {
+ Collection<Id> 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 <tt>true</tt> only if the request is a <tt>POST</tt>, and has
+ content type starting with <tt>multipart/form-data</tt>.
+ */
+ 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.
+
+ <P>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);
+ }
+}
--- /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;
+
+/**
+ <span class="highlight">Maps each HTTP request to a concrete {@link Action}.</span>
+
+ <P> Default implementation of {@link RequestParser}.
+
+ <P>This implementation extracts the <a href="#URIMappingString">URI Mapping String</a> 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 <tt>public</tt> constructor
+ which takes a {@link RequestParser} as its single parameter.)
+
+ <P>There are two kinds of mapping available :
+<ul>
+ <li><a href="#ImplicitMapping">implicit mapping</a> - simple, and recommended
+ <li><a href="#ExplicitMapping">explicit mapping</a> - requires an extra step, and overrides the implicit mapping
+</ul>
+
+ <P><a name="URIMappingString"><h3>URI Mapping String</h3>
+ 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 - <tt>.do</tt>, for example).
+
+ <P>(The servlet path is the part of the URI which has been mapped to a servlet by the <tt>servlet-mapping</tt>
+ entries in the <tt>web.xml</tt>.)
+
+ <P><a name="ImplicitMapping"><h3>Implicit Mapping</h3>
+ If no <a href="#ExplicitMapping">explicit mapping</a> exists in an <tt>Action</tt>, then it will <em>implicitly</em>
+ map to the <a href="#URIMappingString">URI Mapping String</a> that corresponds to a <em>modified</em> version of its
+ package-qualified name :
+<ul>
+ <li>take the package-qualified class name
+ <li>change '.' characters to '/'
+ <li><em>remove</em> the base package prefix, configured in <tt>web.xml</tt> as <tt>ImplicitMappingRemoveBasePackage</tt>
+</ul>
+
+ <P>Example of an implicit mapping :
+ <table cellpadding="3" cellspacing="0" border="1">
+ <tr><td>Class Name:</td><td>hirondelle.fish.main.member.MemberEdit</th></tr>
+ <tr><td><tt>ImplicitMappingRemoveBasePackage</tt> (web.xml):</td><td>hirondelle.fish</th></tr>
+ <tr><td>Implicit Mapping calculated as:</td><td>/main/member/MemberEdit</th></tr>
+ </table>
+
+ <P>Which maps to the following requests:
+
+ <P><table cellpadding="3" cellspacing="0" border="1">
+ <tr><td>Request 1:</td><td>http://www.blah.com/fish/main/member/MemberEdit.list</th></tr>
+ <tr><td>Request 2:</td><td>http://www.blah.com/fish/main/member/MemberEdit.do?Operation=List</th></tr>
+ <tr><td>URI Mapping String calculated as:</td><td>/main/member/MemberEdit</th></tr>
+ </table>
+
+ <P><a name="ExplicitMapping"><h3>Explicit Mapping</h3>
+ An <tt>Action</tt> may declare an explicit mapping to a <a href="#URIMappingString">URI Mapping String</a>
+ simply by declaring a field of the form (for example) :
+ <PRE>
+ public static final String EXPLICIT_URI_MAPPING = "/translate/basetext/BaseTextEdit";
+ </PRE>
+ Explicit mappings override implicit mappings.
+
+<P><h3>Fine-Grained Security</h3>
+ Fine-grained security allows <tt><security-constraint></tt> items to be specifed for various extensions,
+ where the extensions represent various action verbs, such as <tt>.list</tt>, <tt>.change</tt>, and so on.
+ In that case, the conventional <tt>.do</tt> is replaced with several different extensions.
+ See the User Guide for more information on fine-grained security.
+
+ <P><h3>Looking Up Action, Given URI</h3>
+ It's a common requirement to look up an action class, given a URI. Various sources
+ can be used to perform that task:
+<ul>
+ <li>the application's javadoc listing of Constant Field Values can be
+ quickly searched for an explicit <tt>EXPLICIT_URI_MAPPING</tt>
+ <li>all mappings are logged upon startup at <tt>CONFIG</tt> level
+ <li>the source code itself can be searched, if necessary
+</ul>
+*/
+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 <a href="ImplicitMapping">implicit</a>
+ or an <a href="#ExplicitMapping">explicit</a> mapping. Implicit mappings are the recommended style.
+
+ <P>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 :
+ <ul>
+ <li>the <tt>EXPLICIT_URI_MAPPING</tt> field is not a <tt>public static final String</tt>
+ <li>the same mapping is used for more than one {@link Action}
+ </ul>
+ */
+ 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}.
+
+ <P>Extract the <a href="#URIMappingString">URI Mapping String</a> 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 <tt>String</tt> configured in <tt>web.xml</tt> as being the
+ base or root package that is to be ignored by the default Action mapping mechanism.
+
+ See <tt>web.xml</tt> 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}.
+
+ <P>Key - String, taken from public static final field named {@link #EXPLICIT_URI_MAPPING}.
+ <br>Value - Class for the {@link Action} having a <tt>EXPLICIT_URI_MAPPING</tt> field
+ of that given value.
+
+ <P>At runtime, the request is inspected, and the corresponding {@link Action} is
+ created, using a constructor of a specific signature.
+ */
+ private static final Map<String, Class<Action>> fUriToActionMapping = new LinkedHashMap<String, Class<Action>>();
+
+ 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<Class<Action>> actionClasses = ConfigReader.fetchConcreteClassesThatImplement(Action.class);
+ AppException problems = new AppException();
+ for(Class<Action> 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<Action> 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<Action> 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<Action> 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);
+ }
+}
--- /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.
+
+ <P>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.
+
+ <P>In general, a {@link TimeZone} is used for two distinct operations :
+<ul>
+ <li>render {@link java.util.Date} objects
+ <li>parse user input into a <tt>Date</tt> object
+</ul>
+
+ <P>By default, a JRE will perform such operations using the <em>implicit</em> 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.
+
+ <P><i>For your Actions, the fastest way to access the time zone is usually via {@link ActionImpl#getTimeZone()}.</i>
+
+ <P>The <tt>TimeZone</tt> returned by this interface is used by WEB4J for :
+<ul>
+ <li>user response messages containing dates
+ <li>presenting <tt>ResultSets</tt> as reports with {@link hirondelle.web4j.database.Report}
+ <li>displaying dates with {@link hirondelle.web4j.ui.tag.ShowDate}
+ <li>populating forms
+ <li>parsing form entries
+</ul>
+
+ <P>A very large number of policies can be defined by implementations of this interface.
+ Possible sources of <tt>TimeZone</tt> information include :
+<ul>
+ <li>a single setting in <tt>web.xml</tt>, place into application scope upon startup
+ <li>an object stored in session scope
+ <li>a request parameter
+ <li>a request header
+ <li>a cookie
+</ul>
+
+ <h3>Java versus Databases</h3>
+ <P>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 <em>not</em> store dates internally in an unambiguous way. For example,
+ many dates are stored as just '<tt>05-31-2007 06:00</tt>', for example, without any time zone information.
+
+ <P>If that is the case, then there is a mismatch : constructing a {@link java.util.Date} out of many
+ database columns will <em>require</em> 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.
+
+ <P>The storage of dates in a database is <em>not</em> handled by this interface. That is
+ treated as a separate issue.
+
+ <h3>web.xml</h3>
+ There are two settings related to time zones in <tt>web.xml</tt>. The two settings correspond to two
+ distinct ideas : the time zone appropriate for dates <em>presented</em> to the end user, and the time zone in
+ which the date is <em>stored</em>.
+
+ <P>The <tt>DefaultUserTimeZone</tt> 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
+ <em>default</em> time zone, which can be overridden by implementations of this interface.
+
+ <P>The <tt>TimeZoneHint</tt> 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);
+
+}
--- /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}.
+
+ <P>Upon startup, the {@link hirondelle.web4j.Controller} will read in the <tt>DefaultUserTimeZone</tt>
+ configured in <tt>web.xml</tt>, and place it in application scope under the key
+ {@link hirondelle.web4j.Controller#TIME_ZONE}, as a {@link TimeZone} object.
+
+ <P><em>If desired</em>, the application programmer can also store a user-specific
+ {@link TimeZone} in session scope, <em>under the same key</em>. Thus,
+ this class will first find the user-specific <tt>TimeZone</tt>, overriding the default
+ <tt>TimeZone</tt> stored in application scope.
+
+ <P>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);
+ }
+
+}
--- /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 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+ <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+ <meta name="Author" content="Hirondelle Systems">
+ <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+ <title>User Interface</title>
+</head>
+<body>
+Items related to the underlying HTTP request.
+
+<P>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.
+
+<P>The main tool for building full Model Objects out of submitted forms is {@link hirondelle.web4j.model.ModelFromRequest}.
+
+</body>
+</html>
--- /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 <a href="#HardValidation">hard validation</a> on each incoming request.
+
+ <P>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.
+
+ <P>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.
+
+ <P><span class="highlight">The <a href="http://www.owasp.org/">Open Web Application Security Project</a>
+ is a superb resource for learning about web application security. Implementors of this interface are
+ highly recommended to read and use its guidelines.</span>
+
+ <P><span class="highlight">WEB4J divides validation tasks into <em>hard validation</em> and
+ <em>soft validation</em>.</span> They are distinguished by :
+ <ul>
+ <li>when they are applied
+ <li>their behavior when they fail
+ </ul>
+
+ <P><b><a name="HardValidation">"Hard" Validation</a></b><br>
+ 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 <tt>ApplicationFirewall</tt>, 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 :
+ <ul>
+ <li>overall request size
+ <li>parameter names
+ <li>parameter values (<em>not business validations</em>, however - see below)
+ <li>HTTP headers
+ <li>cookies
+ </ul>
+
+ <P><span class="highlight">For request parameters, hard validation must include only those checks whose failure
+ would constitute a bug or a malicious request.</span>
+
+ <P><b><a name="SoftValidation">"Soft" Validation</b><br>
+ 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 <em>directly</em> by the user, for example the content
+ of a <tt>text</tt> 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.
+
+ <P>To clarify, here are two examples, using the default implementation of
+ {@link ApplicationFirewallImpl}.
+
+ <P><b>Example 1</b>
+ <br>A <tt>select</tt> control named <tt>Spin</tt> submits two fixed values, <tt>UP</tt> and
+ <tt>DOWN</tt>. Under normal operation of the program, no other values are expected. In this
+ case, the submitted request parameter should undergo these checks :
+
+ <P> <em>Hard validation</em> - must be one of the two values <tt>UP</tt> or <tt>DOWN</tt>.
+ This is implemented by simply defining, in the
+ {@link Action} that handles this parameter, a single field :
+<PRE>
+public static final RequestParameter SPIN = RequestParameter.<a href="RequestParameter.html#withRegexCheck(java.lang.String,%20java.lang.String)">withRegexCheck</a>("Spin", "(UP|DOWN)");
+</PRE>
+ {@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.
+
+ <P> <em>Soft validation</em> - none. In this case, the hard validation checks the parameter value completely,
+ so there is no further validation to be performed.
+ </ul>
+
+ <P><b>Example 2</b>
+ <br>A text input control named <tt>Age</tt> accepts any text as input. That text should correspond to
+ an integer in the range <tt>0..130</tt>. In this case, the validation is <em>shared</em> between
+ hard validation and soft validation :
+
+ <P><em>Hard validation</em> - 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 <em>not</em> correspond to the maximum number of
+ characters expected (3), since failure of a hard validation produces a response which should <em>not</em> be seen by
+ the typical user during normal operation of the program. In this case, the field declared in the {@link Action}
+ is :
+<PRE>
+public static final RequestParameter AGE = RequestParameter.<a href="RequestParameter.html#withLengthCheck(java.lang.String)">withLengthCheck</a>("Age");
+</PRE>
+ (The actual maximum length is set in <tt>web.xml</tt>.)
+
+ <P><em>Soft validation #1</em> - 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 <tt>toXXX</tt> 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}.
+
+ <P><em>Soft validation #2</em> - make sure the {@link Integer} returned by the previous validation is in the
+ range <tt>0..150</tt>. 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.
+
+ <P>{@link hirondelle.web4j.model.Check} and {@link hirondelle.web4j.model.Validator} are provided to help you
+ implement soft validations.
+*/
+public interface ApplicationFirewall {
+
+ /**
+ Perform <a href="#HardValidation">hard validation</a> on each HTTP request.
+
+ <P>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;
+
+}
--- /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}.
+
+ <P>Upon startup, this class will inspect all {@link Action}s in the application.
+ All <tt>public static final</tt> {@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}, <span class="highlight">each {@link Action} must declare all expected request
+ parameters as a <tt>public static final</tt> {@link RequestParameter} field, in order to pass hard validation.</span>
+
+ <h3>File Upload Forms</h3>
+ 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, <em>in an unparsed form</em>.
+ For such forms, POSTed data is not available in the usual way, and by default <tt>request.getParameter(String)</tt> will return <tt>null</tt> -
+ <em>not only for the file upload control, but for all controls in the form</em>.
+
+ <P>An elegant way around this problem involves <em>wrapping</em> the request,
+ using {@link javax.servlet.http.HttpServletRequestWrapper}, such that POSTed data is parsed and made
+ available through the usual <tt>request</tt> methods.
+ If such a wrapper is used, then file upload forms can be handled in much the same way as any other form.
+
+ <P>To indicate to this class if such a wrapper is being used for file upload requests, use the <tt>FullyValidateFileUploads</tt> setting
+ in <tt>web.xml</tt>.
+
+ <P>Settings in <tt>web.xml</tt> affecting this class :
+ <ul>
+ <li><tt>MaxHttpRequestSize</tt>
+ <li><tt>MaxFileUploadRequestSize</tt>
+ <li><tt>MaxRequestParamValueSize</tt> (used by {@link hirondelle.web4j.request.RequestParameter})
+ <li><tt>SpamDetectionInFirewall</tt>
+ <li><tt>FullyValidateFileUploads</tt>
+ </ul>
+
+ <P>The above settings control the validations performed by this class :
+ <table border="1" cellpadding="3" cellspacing="0">
+ <tr>
+ <th>Check</th>
+ <th>Regular</th>
+ <th>File Upload (Wrapped)</th>
+ <th>File Upload</th>
+ </tr>
+ <tr>
+ <td>Overall request size <= <tt>MaxHttpRequestSize</tt> </td>
+ <td>Y</td>
+ <td>N</td>
+ <td>N</td>
+ </tr>
+ <tr>
+ <td>Overall request size <= <tt>MaxFileUploadRequestSize</tt> </td>
+ <td>N</td>
+ <td>Y</td>
+ <td>Y</td>
+ </tr>
+ <tr>
+ <td>Every param <em>name</em> is among the {@link hirondelle.web4j.request.RequestParameter}s for that {@link Action}</td>
+ <td>Y</td>
+ <td>Y*</td>
+ <td>N</td>
+ </tr>
+ <tr>
+ <td>Every param <em>value</em> satifies {@link hirondelle.web4j.request.RequestParameter#isValidParamValue(String)}</td>
+ <td>Y</td>
+ <td>Y**</td>
+ <td>N</td>
+ </tr>
+ <tr>
+ <td>If created with {@link hirondelle.web4j.request.RequestParameter#withLengthCheck(String)}, then param value size <= <tt>MaxRequestParamValueSize</tt></td>
+ <td>Y</td>
+ <td>Y**</td>
+ <td>N</td>
+ </tr>
+ <tr>
+ <td>If <tt>SpamDetectionInFirewall</tt> is on, then each param value is checked using the configured {@link hirondelle.web4j.security.SpamDetector}</td>
+ <td>Y</td>
+ <td>Y**</td>
+ <td>N</td>
+ </tr>
+ <tr>
+ <td>If a request param named <tt>Operation</tt> exists and it returns <tt>true</tt> for {@link Operation#hasSideEffects()}, then the underlying request must be a <tt>POST</tt></td>
+ <td>Y</td>
+ <td>Y</td>
+ <td>N</td>
+ </tr>
+ <tr>
+ <td><a href='#CSRF'>CSRF Defenses</a></td>
+ <td>Y</td>
+ <td>Y</td>
+ <td>N</td>
+ </tr>
+ </table>
+ * For file upload controls, the param name is checked only if the return value of <tt>getParameterNames()</tt> (for the wrapper) includes it.
+ <br>**Except for file upload controls. For file upload <em>controls</em>, no checks on the param <em>value</em> are made by this class.<br>
+
+ <a name='CSRF'></a>
+<h3>Defending Against CSRF Attacks</h3>
+ If the usual WEB4J defenses against CSRF attacks are active (see package-level comments),
+ then <i>for every <tt>POST</tt> request executed within a session</i> the following will also be performed as a defense against CSRF attacks :
+<ul>
+ <li>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 <tt>Action</tt> like other request parameters.)
+ <li>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}
+</ul>
+
+ 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.
+
+ <P>See class description for more information.
+
+ <P>Subclasses may extend this implementation, following the form :
+ <PRE>
+ 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
+ }
+ </PRE>
+ */
+ 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.
+
+ <P>Key - class object
+ <br>Value - Set of RequestParameter objects; may be empty, but not null.
+
+ <P>This is a mutable object field, but is not modified after startup, so this class is thread-safe.
+ */
+ private static Map<Class<Action>, Set<RequestParameter>> fExpectedParams = new LinkedHashMap<Class<Action>, Set<RequestParameter>>();
+
+ /** 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<RequestParameter> 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<SafeText> 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 <tt>null</tt>. Matches to both regular and 'internal' request params. */
+ private RequestParameter matchToKnownParam(String aIncomingParamName, Collection<RequestParameter> 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<SafeText> 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<RequestParameter> 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 <FORM> 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);
+ }
+}
--- /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.
+
+ <P>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.
+
+ <P>Returns <tt>null</tt> 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);
+ }
+}
--- /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
+ <a href='http://en.wikipedia.org/wiki/Cross-site_request_forgery'>Cross Site Request Forgery</a> (CSRF).
+
+ <P>Please see the package overview for important information regarding CSRF attacks, and security in general.
+
+ <P>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:
+ <ul>
+ <li>the creation of new sessions (which does <i>not</i> necessarily imply a successful user login has also occured)
+ <li>a successful user login (which <i>does</i> imply a session has also been created)
+ </ul>
+
+ <h4>Pre-processing</h4>
+ When <i>a new session</i> is detected (but not necessarily a user login), then this class will do the following :
+ <ul>
+ <li>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.
+ <li>wrap the response in a custom wrapper, to implement the post-processing performed by this filter (see below)
+ </ul>
+
+ In addition, if <i>a new user login</i> is detected, then this class will do the following :
+ <ul>
+ <li>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 <em>immediately preceding session for the same user</em>.
+ <li>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}.
+ </ul>
+
+ <P>The above behavior of this class upon user login requires interaction with your database.
+ It's configured in <tt>web.xml</tt> using two items :
+ <tt>FormSourceIdRead</tt> and <tt>FormSourceIdWrite</tt>. 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 <tt>public static final</tt> fields, and the corresponding SQL statements
+ must appear somewhere in an <tt>.sql</tt> file.
+
+ <P>(Please see these items in the example application for an illustration : <tt>web.xml</tt>,
+ <tt>UserDAO</tt>, and <tt>csrf.sql</tt>.)
+
+ <h4>Post-processing</h4>
+ If a session is present, then this class will use a custom response wrapper to alter the response:
+ <ul>
+ <li>if the response has <tt>content-type</tt> of <tt>text/html</tt> (or <tt>null</tt>), then scan
+ the response for all {@code <FORM>} tags with <tt>method='POST'</tt>.
+ <li>for each such {@code <FORM>} tag, add a hidden parameter in the following style :
+<PRE><input type='hidden' name='web4j_key_for_form_source_id' value='151jdk65654dasdf545sadf6a5s4f'></PRE>
+</ul>
+
+ The name of the hidden parameter is taken from {@link #FORM_SOURCE_ID_KEY},
+ and the <tt>value</tt> of that hidden parameter is the random token created during the pre-processing stage.
+
+<h4>ApplicationFirewall</h4>
+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.
+
+ <h4>Warning Regarding Error Pages</h4>
+ This Filter uses a wrapper for the response. When a Filter wraps the response, the error page
+ customization defined by <tt>web.xml</tt> 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 <tt>web.xml</tt>.
+
+ <P>This filter will only affect the response if its content-type is <tt>text/html</tt> or <tt>null</tt>.
+ It will not affect any other type of response.
+*/
+public class CsrfFilter implements Filter {
+
+ /**
+ <em>Key</em> for item stored in session scope, and also <em>name</em> of hidden
+ request parameter added to POSTed forms.
+
+ <P>Value - {@value}.
+ <P>The <em>value</em> 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 <em>name</em>, and the <em>value</em> 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.
+
+ <P>Value - {@value}.
+ <P>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 <em>immediately preceding</em> session.
+ When a match of form-source id against {@link #FORM_SOURCE_ID_KEY} fails, then a second
+ match is attempted against this item.
+
+ <P>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.
+
+ <P>Value - {@value}.
+ <P>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.
+
+ <P>Reads in {@link hirondelle.web4j.database.SqlId} references used to read and write the user's form-source id.
+ <P>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.
+
+ <P>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 <i>that has no user login</i>.
+
+ <P><i>This method is called only when a session created by an Action, instead of the usual login mechanism.</i>
+ 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;
+ }
+}
--- /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 =
+ "(<form" + Regex.ALL_BUT_END_OF_TAG +"method=" + Regex.QUOTE + "POST" + Regex.QUOTE + Regex.ALL_BUT_END_OF_TAG + ">"
+ + Regex.ANY_CHARS + "?)" +
+ "(</form>)"
+ ;
+ 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 "<input type='hidden' name='" + getHiddenParamName() + "' value='" + getHiddenParamValue().toString() + "'>";
+ }
+
+ /**
+ 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
--- /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
+ <a href='UntrustedProxyForUserId.html#UntrustedIdentifier'>untrusted identifer</a>.
+
+ <P>See the <a href='http://www.web4j.com/UserGuide.jsp#DataOwnershipConstraints'>User Guide</a>
+ 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 <a href='UntrustedProxyForUserId.html#UntrustedIdentifier'>untrusted proxy for the
+ user id</a> used in the current request. If an owner cannot be found, then return <tt>null</tt>.
+
+ <P>The meaning of the untrusted identifier depends on the context, and changes for each action/operation.
+ A typical implmentation will follow these steps:
+ <ul>
+ <li>use a request parameter value, whose value contains the untrusted identifier
+ <li>the value of the untrusted identifier is then passed to a <tt>SELECT</tt> statement,
+ which returns a single value - the owner's login name
</ul>
+ */
+ Id fetchOwner() throws AppException;
+
+}
--- /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.
+
+ <P>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.)
+
+ <P>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 <tt>Controller</tt> will use the presence/absence of that item to determine if the login tasks have already been performed.
+
+ <P>Example tasks :
+ <ul>
<li>place user preferences in session scope
<li>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()}.
</ul>
+
+ <h3>User Login Name Versus User Id</h3>
+ After a successful login, the container will always place the user's <i>login name</i> in session scope.
+ However, it's often convenient or desirable to have a corresponding user <i>id</i> 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 <a href='http://www.web4j.com/UserGuide.jsp#DataOwnershipConstraints'>data ownership constraints</a>.
+
+ <h3>The User Id Is a Server Secret</h3>
+ 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, <b>the user id is a server-side secret, and should never be made visible to the user.</b>
+ 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.
+ <b>Always treat the user id as a server-side secret. Failure to do so is a huge security risk.</b>
+
+ <h3>Consolidating User Preferences Into One Object</h3>
+ 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 <em>default</em> 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.
+
+ <P>This method is called only if all of the following are true:
+ <ul>
<li>a session already exists
<li>the user has successfully logged in
+ <li>{@link #hasAlreadyReacted(HttpSession)} returns <tt>false</tt>
</ul>
+ */
+ void reactToUserLogin(HttpSession aExistingSession, HttpServletRequest aRequest) throws AppException;
+
+ /**
+ Return <tt>true</tt> only if the user login has already been processed by {@link #reactToUserLogin(HttpSession, HttpServletRequest)}.
+ Typically, implementations will simply return <tt>true</tt> only if an item of a given name is already in session scope.
+ */
+ boolean hasAlreadyReacted(HttpSession aSession);
+
+}
--- /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.
+
+ <P>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.
+
+ <P>As principal line of defense against XSS, WEB4J provides the {@link SafeText} class,
+ to be used to model all free form user input. <tt>SafeText</tt> 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.
+
+ <P>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.
+
+ <P>The default implementation of this interface
+ ({@link hirondelle.web4j.security.PermittedCharactersImpl})
+ should be useful for a wide number of applications.
+*/
+public interface PermittedCharacters {
+
+ /**
+ Return <tt>true</tt> 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);
+
+}
--- /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}.
+
+ <P>This class permits only those characters which return <tt>true</tt> for
+ {@link Character#isValidCodePoint(int)}.
+
+ <P>Since {@link SafeText} already escapes a long list of special characters, those
+ special characters are automatically safe for inclusion here.
+ <em>That is, you can usually accept almost any special character, because
+ <tt>SafeText</tt> already does so much escaping anyway.</em>
+
+ <P>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.
+
+ <P>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);
+ }
+
+}
--- /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
+ <a href='http://www.owasp.org/index.php/Cross_Site_Scripting'>Cross Site Scripting</a> (XSS).
+
+ <P>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
+ <tt>String</tt>, 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 <tt>SafeText</tt>,
+ however, such special steps are not needed, since the escaping is built
+ directly into its {@link #toString} method.
+
+ <P>It is worth noting that there are two defects with JSTL' s handling of this problem :
+ <ul>
+ <li>the {@code <c:out>} tag <em>escapes only 5 of the 12 special characters</em> identified
+ by the Open Web App Security Project as being a concern.
+ <li>used in a JSP, the Expression Language allows pleasingly concise presentation, but
+ <em>does not escape special characters in any way</em>. Even when one is aware of this,
+ it is easy to forget to take precautions against Cross Site Scripting attacks.
+ </ul>
+
+ <P>Using <tt>SafeText</tt> 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 <c:out>} with <tt>SafeText</tt> (not recommeded), then you must
+ use <tt>escapeXml='false'</tt> to avoid double-escaping of special characters.
+
+ <P>There are various ways of presenting text :
+ <ul>
+ <li>as HTML (most common) - use {@link #toString()} to escape a large number of
+ special characters.
+ <li>as XML - use {@link #getXmlSafe()} to escape 5 special characters.
+ <li>as JavaScript Object Notation (JSON) - use {@link #getJsonSafe()} to escape
+ a number of special characters
+ <li>as plain text - use {@link #getRawString()} to do no escaping at all.
+ </ul>
+
+ <h4>Checking For Vulnerabilities Upon Startup</h4>
+ WEB4J will perform checks for Cross-Site Scripting vulnerabilities
+ upon startup, by scanning your application's classes for <tt>public</tt> Model Objects
+ having <tt>public getXXX</tt> methods that return a <tt>String</tt>. It will log such
+ occurrences to encourage you to investigate them further.
+
+ <P><em>Design Notes :</em><br>
+ This class is <tt>final</tt>, immutable, {@link Serializable},
+ and {@link Comparable}, in imitation of the other building block classes
+ such as {@link String}, {@link Integer}, and so on.
+
+ <P>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.
+
+ <P>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 <tt>SafeText</tt> once in the Model, than remember to do the
+ escaping repeatedly in the View.
+*/
+public final class SafeText implements Serializable, Comparable<SafeText> {
+
+ /**
+ Returns <tt>true</tt> only if the given character is always escaped by
+ {@link #toString()}. For the list of characters, see {@link EscapeChars#forHTML(String)}.
+
+ <P>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.
+
+ <P>Arbitrary text can be rendered safely in an XML document in two ways :
+ <ul>
+ <li>using a <tt>CDATA</tt> block
+ <li>escaping special characters {@code &, <, >, ", '}.
+ </ul>
+
+ <P>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 <a href='http://www.json.org/'>JSON</a> (JavaScript Object Notation) data.
+
+ <P>This method is intended for the <i>data</i> elements of JSON.
+ It is intended for <i>values</i> of things, not for their <i>names</i>.
+ 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<Character> ESCAPED = Arrays.asList(
+ '<',
+ '>' ,
+ '&' ,
+ '"' ,
+ '\t' ,
+ '!' ,
+ '#' ,
+ '$' ,
+ '%' ,
+ '\'' ,
+ '(' ,
+ ')' ,
+ '*' ,
+ '+' ,
+ ',' ,
+ '-' ,
+ '.' ,
+ '/' ,
+ ':' ,
+ ';' ,
+ '=' ,
+ '?' ,
+ '@' ,
+ '[' ,
+ '\\' ,
+ ']' ,
+ '^' ,
+ '_' ,
+ '`' ,
+ '{' ,
+ '|' ,
+ '}' ,
+ '~'
+ );
+
+ /** As above, but translated into a form that uses code points. */
+ private static List<Integer> ESCAPED_CODE_POINTS = new ArrayList<Integer>();
+ static {
+ for (Character character : ESCAPED){
+ ESCAPED_CODE_POINTS.add(Character.toString(character).codePointAt(0));
+ }
+ }
+}
--- /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.
+
+ <P>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.
+
+ <P><a href="http://en.wikipedia.org/wiki/Forum_spam">Spam</a> refers to unwanted input from
+ undesirable parties (usually advertising of some sort) that is often POSTed to servers using automated means.
+
+ <P>Most spam contains <em>links</em>. Implementations are encouraged to detect unwanted links.
+
+ <P>The <tt>SpamDetectionInFirewall</tt> setting in <tt>web.xml</tt> can instruct the
+ {@link hirondelle.web4j.security.ApplicationFirewall} to use the configured <tt>SpamDetector</tt>
+ to reject <em>all</em> requests containing at least one parameter that appears to be spam.
+ Such filtering is applied as a
+ <a href="ApplicationFirewall.html#HardValidation">hard validation</a>, and will <em>not</em> result in
+ a polished response to the end user.
+
+ <P>If that policy is felt to be too aggressive, then the only alternative is to check <em>all
+ items input as text</em> using {@link hirondelle.web4j.model.Check#forSpam()} (usually
+ in a Model Object constructor). Such checks do <em>not</em> 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);
+
+}
--- /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
+ <a href='../ui/translate/Text.html#WikiStyleFormatting'>wiki-style formatting</a>.
+
+ <P>Almost all spam contains links. This implementation will reject all text that contains
+ '<tt>http://</tt>', <em>except</em> if the text contains any wiki-style links in the form
+ <tt>[link:http://www.google.ca Google]</tt>.
+*/
+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://");
+}
--- /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.
+
+ <P><b>Using this filter means that browsers must have cookies enabled.</b>
+ 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 <em>dangerous</em> since it is a vector for
+ session hijacking and session fixation.
+
+ <P>This class can be used only when form-based login is used.
+ When form-based login is used, the generation of the initial <tt>JSESSIONID</tt>
+ 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.
+
+ <P>Superfluous sessions and session ids represent a security risk.
+ Here, the following approach is taken:
+ <ul>
+ <li>during form-based login, the <em>container</em> sends a session cookie to the browser
+ <li>at no other time does the <em>web application</em> itself send a <tt>JSESSIONID</tt>, either in a
+ cookie or in a rewritten URL
+ <li>upon logoff, the <em>web application</em> instructs the browser to delete the <tt>JSESSIONID</tt> cookie
+ </ul>
+
+ <P>Note how the container and the web application work together to manage the <tt>JSESSIONID</tt> cookie.
+
+ <P>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 :
+ <ul>
+ <li><tt>{@link javax.servlet.http.HttpServletRequest#getSession()}</tt>
+ <li><tt>{@link javax.servlet.http.HttpServletRequest#getSession(boolean)}</tt>
+ <li><tt>{@link javax.servlet.http.HttpServletResponse#encodeRedirectURL(java.lang.String)}</tt>
+ <li><tt>{@link javax.servlet.http.HttpServletResponse#encodeRedirectURL(java.lang.String)}</tt>
+ <li><tt>{@link javax.servlet.http.HttpServletResponse#encodeRedirectUrl(java.lang.String)}</tt>
+ <li><tt>{@link javax.servlet.http.HttpServletResponse#encodeURL(java.lang.String)}</tt>
+ <li><tt>{@link javax.servlet.http.HttpServletResponse#encodeUrl(java.lang.String)}</tt>
+ </ul>
+
+ <b>Calls to the <tt>getSession</tt> methods are in effect all coerced to <tt>getSession(false)</tt>.</b>
+ <b>Since this doesn't affect the form-based login mechanism, the user will
+ still receive a <tt>JSESSIONID</tt> cookie during form-based login</b>. This policy ensures that your code
+ cannot mistakenly create a superfluous session.
+
+ <P>The <tt>encodeXXX</tt> methods are no-operations, and simply return the given String unchanged.
+ This policy in effect <b>disables URL rewriting</b>. URL rewriting is a security risk since it allows
+ session ids to appear in simple links, which are subject to session hijacking.
+
+ <P>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 <tt>getSession()</tt> 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;
+ }
+ }
+}
--- /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
+ }
+ }
+}
--- /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.
+
+ <P>This interface addresses the issue of
+ <a href='http://www.owasp.org/index.php/Top_10_2007-A4'>Insecure Direct Object Reference</a>, which
+ is an important security issue for web applications. The issue centers around proper enforcement of
+ <b>data ownership constraints</b>.
+
+ <P>Please see the <a href='http://www.web4j.com/UserGuide.jsp#DataOwnershipConstraints'>User Guide</a> for
+ more information on this important topic.
+
+ <a name='UntrustedProxyForUserId'></a>
+ <h3>Untrusted Proxy For User Id</h3>
+ An untrusted proxy for the user id is defined here as satisfying these two criteria:
+ <ul>
<li>it's "owned" by a specific user (that is, it has an associated data ownership constraint)
+ <li>it's open to manipulation by the end user (for example, by simply changing a request parameter)
</ul>
+
+ <P>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.
+
+ <P>Note that, as explained in the <a href='http://www.web4j.com/UserGuide.jsp#DataOwnershipConstraints'>User Guide</a>,
+ not all data ownership constraints involve an untrusted proxy for the user id - only some do.
+
+ <P>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 <tt>Controller</tt> logic is roughly as follows:
+ <PRE>
+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
+}</PRE>
+
+(Reminder: whenever a user logs in, the login name of the current user is always placed into session scope by the Servlet Container.)
+
+ <P>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 :
+ <ul>
<li>the 'noun' - identifies <i>what data</i> is being operated on
<li>the 'verb' - what is being <i>done</i> to the data (the operation)
</ul>
+
+ <P>In some cases, only the noun will be important, since <i>all</i> operations on the data can be restricted to the owner.
+ In other cases, both the noun <i>and</i> the verb will be needed to determine if there is a data ownership constraint.
+*/
+public interface UntrustedProxyForUserId {
+
+ /**
+ Returns <tt>true</tt> only if the given request uses an untrusted proxy for the user id.
+ */
+ boolean usesUntrustedIdentifier(RequestParser aRequestParser);
+
+}
--- /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}.
+
+ <P>This implementation depends on settings in <tt>web.xml</tt>, 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 <tt>web.xml</tt>.
+
+ <P>This class uses settings in <tt>web.xml</tt> to define requests having ownership constraints that use an untrusted proxy
+ for the user id. It uses a <i>roughly</i> similar style as used for role-based constraints.
+ Here is an example of a number of several such ownership constraints defined in <tt>web.xml</tt>:<PRE><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>
+</PRE>
+
+ <P>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}).
+
+ <P>The special '*' character refers to all verbs/operations attached to a given noun/action.
+*/
+public final class UntrustedProxyForUserIdImpl implements UntrustedProxyForUserId {
+
+ /**
+ Return <tt>true</tt> only if the given request matches one of the items defined by the <tt>UntrustedProxyForUserId</tt> setting
+ in <tt>web.xml</tt>.
+
+ <P>For example, given the URL :
+ <PRE>'.../VacationAction.list?X=Y'</PRE>
+ this method will parse the URL into a 'noun' and a 'verb' :
+ <PRE>noun: 'VacationAction'
+verb: 'list'</PRE>
+
+ It will then compare the noun-and-verb to the settings defined in <tt>web.xml</tt>.
+ If there's a match, then this method returns <tt>true</tt>.
+ */
+ 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<String> 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<String> 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();
+ }
+}
--- /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 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+ <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+ <meta name="Author" content="Hirondelle Systems">
+ <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+ <title>Application Security</title>
+</head>
+<body>
+Tools for making your web application more secure.
+
+<P>The <a href='http://www.owasp.org/index.php/Main_Page'>Open Web App Security Project</a> (OWASP) is an excellent guide
+for increasing the security of your web application, and is highly recommended.
+
+<P>An important point to understand is the separation of validation into two distinct parts -
+<em>hard validation</em>, and <em>soft validation</em> - see {@link hirondelle.web4j.security.ApplicationFirewall}
+for more information.
+
+<h3>Recommendations and Reminders</h3>
+
+<h4>SafeText</h4>
+Free-form user input should always be modeled as {@link hirondelle.web4j.security.SafeText}, not <tt>String</tt>.
+This provides protection against XSS (Cross Site Scripting) attacks, without forcing you to continually
+escape special characters in JSPs.
+
+<h4>POST versus GET</h4>
+Forms should always specify the correct <tt>method</tt> attribute. The general rule is that <em>any
+action that has a side effect</em> (database edits, logging off) should be a <tt>POST</tt>, while an action <em>without</em>
+any side-effect (list or search operations, reports) should be a <tt>GET</tt>.
+
+<h4>Content-Type</h4>
+Always specify the <tt>content-type</tt>. This can be done in template JSPs, to reduce the repetition of
+identical markup. Example using a directive to specify an HTTP header :
+
+<PRE><%@ page contentType="text/html" %></PRE>
+
+Such directives must appear at the <em>start</em> of the page.
+
+<P>The <tt>jsp-property-group</tt> setting in <tt>web.xml</tt> can specify an <tt>include-prelude</tt> JSP, which
+will be automatically included at the start of your JSPs.
+
+<h4>Filter</h4>
+The {@link hirondelle.web4j.security.CsrfFilter} should be configured, to
+protect against CSRF attacks.
+
+<P>This filter requires configuration for <tt>FormSourceIdRead</tt> and <tt>FormSourceIdWrite</tt>. These
+items reference <tt>SqlId</tt>s. As usual, these <tt>SqlId</tt>s need to be declared in one of your application's classes, and
+also appear in an <tt>.sql</tt> file. (This ensures the usual matching of <tt>.sql</tt> file content to <tt>SqlId</tt>s will not fail.
+See hirondelle.web4j.database for more information.)
+
+<h4>ApplicationFirewall</h4>
+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 <em>add</em> 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.)
+
+<h4>SpamDetector</h4>
+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.
+
+
+<h3>Cross Site Scripting (XSS) Attacks</h3>
+The {@link hirondelle.web4j.security.SafeText} class is provided as the main defense against
+<a href='http://www.owasp.org/index.php/Cross_Site_Scripting'>XSS</a> 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 <tt>SafeText</tt>
+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 <tt>SafeText</tt> instead of <tt>String</tt>.
+
+<P>
+<span class='highlight'>Using <tt>SafeText</tt> will greatly increase your safety when using JSTL</span>.
+The JSTL is a bit defective when it comes to protecting you from XSS attacks:
+<ul>
+<li>the <tt><c:out></tt> tag escapes characters by default, but it only escapes for XML, <em>and not for HTML</em>.
+That is, it escapes only for 5 of the 12 characters <a href='http://www.owasp.org/index.php/Cross_Site_Scripting'>recommended</a>
+by the OWASP. The same is true for JSTL's <tt>fn:escapeXml()</tt> function.
+<li>JSTL's Expression Language is nice and concise, but <em>it doesn't escape any characters at all</em>. It is dangerously open to XSS attacks,
+since the application programmer always needs to remember to manually escape special characters.
+</ul>
+
+When JSTL is used with a <tt>SafeText</tt> object, however, these problems do not occur, since by default <tt>SafeText.toString()</tt>
+will do the correct escaping for you in the background.
+
+<h3>Cross Site Request Forgery (CSRF) Attacks</h3>
+The central idea of protecting against <a href='http://www.owasp.org/index.php/CSRF'>CSRF</a> 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.
+
+<P>To use it, you must have the <tt>CsrfFilter</tt> configured as a Servlet Filter in <tt>web.xml</tt>.
+As part of that configuration, you must provide two <tt>SqlId</tt>'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.
+
+<P>Although it is not really necessary for those simply wanting to use <tt>CsrfFilter</tt>, the following is a description of its
+implementation.
+
+<h4>Form-Source Ids</h4>
+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 <tt>'web4j_key_for_form_source_id'</tt>.
+This form-source id is constant for each session, and is hard to guess.
+The <tt>CsrfFilter</tt> will automatically modify all of your forms having <tt>method='POST'</tt>
+to include a hidden request parameter of the same name (<tt>web4j_key_for_form_source_id</tt>),
+whose value is simply the value created upon login.
+
+ <h4>Verifying Form-Source Ids</h4>
+The default {@link hirondelle.web4j.security.ApplicationFirewallImpl} will verify that each POSTed
+form includes a parameter named <tt>web4j_key_for_form_source_id</tt>, and that its value matches <em>either</em>
+the current value stored in the user's session, <em>or</em> the form-source id used in the <em>immediately preceding</em>
+session for the same user.
+
+<P><em>The form-source id used in the previous session is needed to ensure smooth behavior upon re-login.</em>
+Here is the use case in more detail :
+<ul>
+ <li>the user logs in
+ <li>the user navigates to a form (containing a hidden form-source id param)
+ <li>the session expires
+ <li>the user posts the form, without knowing the session has expired
+</ul>
+
+<P>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 <em>old</em> form-source id attached to the
+first session is POSTed, not the <em>current</em> 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 <tt>web4j_key_for_form_source_id</tt> has <em>either</em>
+the current form-source id value, <em>or</em> the 'old' form-source id value of the <em>immediately preceding</em> session.
+
+ <P>This is the reason for the two <tt>SqlId</tt> configuration settings for the <tt>CsrfFilter</tt>: 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.
+
+ <P>When a user logs out, or when a session is about to expire, <tt>CsrfFilter</tt> will extract the user's form-source id from the session,
+ and store it in the database for possible future use.
+
+ <h4>Sessions With No Login</h4>
+ There are some common forms for which no valid login is possible :
+<ul>
+ <li>creating an account
+ <li>recovering a lost password
+</ul>
+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).
+
+<P>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.
+
+</body>
+</html>
--- /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.
+
+ <P>The body of this tag contains one or more <tt>TR</tt> tags. Each <tt>TR</tt> tag contains a
+ <tt>class</tt> attribute, specifying a Cascading Style Sheet class. This tag
+ will simply remove or update the <tt>class</tt> attribute for alternate occurrences of
+ each <tt>TR</tt> tag found in its body.
+
+ <P>If the optional <tt>altClass</tt> attribute is specified, then the <tt>class</tt>
+ attribute of each <tt>TR</tt> is updated to an alternate value, instead of being removed.
+*/
+public final class AlternatingRow extends TagHelper {
+
+ /**
+ Optional name of a CSS class.
+
+ <P>The CSS class for each <tt>TR</tt> 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 <tt>TR</tt>'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 <tt>TR</tt> tag found in the body, remove or update the <tt>class</tt> attribute.
+
+ <P>If no <tt>altClass</tt> is specified, then the <tt>class</tt> attribute is simply removed entirely.
+ Otherwise, it updated to the <tt>altClass</tt> 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("<tr" + "("+ Regex.ALL_BUT_END_OF_TAG + ")" + "class=" + Regex.QUOTED_ATTR + "("+ Regex.ALL_BUT_END_OF_TAG + ")"+ ">", Pattern.CASE_INSENSITIVE);
+
+ private String getReplacement(Matcher aMatcher){
+ //construct replacement: <TR + G1 + class='G2' + G3 + >
+ StringBuilder result = new StringBuilder("<TR" + aMatcher.group(1));
+ if( Util.textHasContent(fAltClass) ){
+ result.append("class='" + fAltClass + "'");
+ }
+ result.append(aMatcher.group(3) + ">");
+ return result.toString();
+ }
+
+ private boolean isEvenRow(int aMatchIdx){
+ return aMatchIdx % 2 == 0;
+ }
+}
--- /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.
+
+ <P>Self-linking is usually considered to be poor style, since the user is simply
+ led back to the current page.
+
+ <P>Rendering links to the current page in a distinct manner has two benefits :
+<ul>
+ <li>the user is prevented from following a useless link
+ <li>the user can see 'where they are' in a menu of items
+</ul>
+
+ <P>This tag provides two distinct techniques for identifying self-linking:
+<ul>
+ <li>the current URI <em>ends with</em> the link's <tt>href</tt> target.
+ This is the default mechanism. The 'ends with' is used since the current URI
+ (stored by the <tt>Controller</tt> in request scope) is absolute, while most
+ <tt>HREF</tt> attributes contain relative links.
+ <li>the trimmed link text (the body of the anchor tag) matches the <tt>TTitle</tt> request parameter
+ used by the WEB4J templating mechanism.
+</ul>
+
+ <P>If these policies are inadequate, then {@link #isSelfLinkingByHref(String, String)} and
+ {@link #isSelfLinkingByTitle(String, String)} may be overridden as desired.
+
+ <P>Example use case.
+<PRE>
+<w:highlightCurrentPage styleClass="highlight">
+ <a href='ShowHomePage.do'>Home</a>
+ <a href='ShowSearch.do'>Search</a>
+ <a href='ShowContact.do'>Contact</a>
+</w:highlightCurrentPage>
+</PRE>
+ If the current URI is
+ <PRE>
+ http://www.blah.com/fish/main/home/ShowHomePage.do
+ </PRE>
+ then the output of this tag will be
+<PRE>
+ <span class="highlight">Home</span>
+ <a href='ShowSearch.do'>Search</a>
+ <a href='ShowContact.do'>Contact</a>
+</PRE>
+ If the <tt>styleClass</tt> attribute is not used, then the output would simply be :
+<PRE>
+ Home
+ <a href='ShowSearch.do'>Search</a>
+ <a href='ShowContact.do'>Contact</a>
+</PRE>
+
+ <P>This final example uses the <tt>TTitle</tt> mechanism :
+<PRE>
+<w:highlightCurrentPage useTitle="true">
+ <a href='ShowHomePage.do'>Home</a>
+ <a href='ShowSearch.do'>Search</a>
+ <a href='ShowContact.do'>Contact</a>
+</w:highlightCurrentPage>
+</PRE>
+ For a page with <tt>TTitle</tt> parameter as <tt>'Home'</tt>, the output of this tag
+ would be as above:
+<PRE>
+ Home
+ <a href='ShowSearch.do'>Search</a>
+ <a href='ShowContact.do'>Contact</a>
+</PRE>
+
+ <P><em>Nuisance restriction</em> : the <tt>href</tt> 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).
+
+ <P>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 <tt>TTitle</tt> request parameter passed to this page.
+
+ <P>Optional. Default is <tt>false</tt>. If set to <tt>true</tt>,
+ then the default mechanism is overridden, and self-linking pages will be identified
+ by comparing the link text to the page <tt>TTitle</tt> parameter. (The <tt>TTitle</tt>
+ 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.
+
+ <P>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 <tt>HREF</tt> target.
+
+ <P>Overridable default implementation that simply checks if <tt>aCurrentURI</tt> <em>ends with</em>
+ <tt>aHrefTarget</tt>. In addition, <tt>aCurrentURI</tt> is passed through {@link EscapeChars#forHrefAmpersand(String)}
+ before the comparison is made. This ensures the text will match a valid <tt>HREF</tt> 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 <tt>TTitle</tt> request parameter.
+
+ <P>Overridable default implementation that checks if the trimmed <tt>aLinkText</tt>
+ (the body of the <A> tag) and <tt>aTTitle</tt> 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(
+ "<a" + "(?:\\s)* href=" + Regex.QUOTED_ATTR + Regex.ALL_BUT_END_OF_TAG + Regex.END_TAG + "(" + Regex.ALL_BUT_END_OF_TAG + ")" + "</a>",
+ 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 = "<span class=\"" + fHighlightClass + "\">" + result + "</span>";
+ }
+ return result;
+ }
+
+ private boolean isTesting(String aTestItem){
+ return Util.textHasContent(aTestItem);
+ }
+}
--- /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.
+
+ <P>With this class, paging is implemented in two steps :
+ <ul>
<li>emitting the proper links (with the proper request parameters) to identify each page
<li>extracting the data from the database, using the given request parameters, and perhaps subsetting the <tt>ResultSet</tt>
</ul>
+
+ 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 <i>Fish & Chips Club</i> application.
+
+ <h3>Emitting Links For Paging</h3>
+ <P>This tag works by emitting various links which <i>are modifications of the current URI</i>.
+
+ <P><b>Example</b>
+ <PRE>
+ <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>
+ </PRE>
+
+ <P>The <tt>placeholder_xxx</tt> items act as placeholders.
+ <em>They are replaced by this tag with modified values of the current URI.</em>
+ The <w:pager> tag has a single <tt>pageFrom</tt> 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 <tt>1..9999</tt> : for example, <tt>PageIndex=1</tt>,
+ <tt>PageIndex=2</tt>, and so on.
+
+ <P>For example, if the following URI displays "page 5" :
+ <PRE>http://www.blah.com/main/SomeAction.do?PageIndex=5&Criteria=Blah</PRE>
+ then this tag will let you emit the following links, derived from the above URI simply by replacing the value of the <tt>PageIndex</tt> request parameter :
+ <PRE>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</PRE>
+
+ <P>Of course, these generated links don't do the actual subsetting of the data.
+ Rather, these links simply <i>alter the current URI to use the desired value for the page index request parameter.</i>
+ The resulting request parameters are then used elsewhere to extract the desired data (as described below).
+
+ <P><b>Link Suppression</b>
+ <P>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.
+
+
+<h3>Extracting and Subsetting the Data</h3>
+ There are three alternatives to implementing the subsetting of the <tt>ResultSet</tt> :
+ <ul>
<li>in the JSP itself
<li>in the Data Access Object (DAO)
<li>directly in the underlying <tt>SELECT</tt> statement
+ </ul>
+
+ <P><b>Subsetting in the JSP</b><br>
+<ul>
+ <li>this is the simplest style
+ <li>it can be applied quickly, to add paging to any existing listing
+ <li>the DAO performs a single, unchanging <tt>SELECT</tt> that always returns the <i>same</i> set of records for all pages
+ <li>it requires only a single change to the {@link hirondelle.web4j.action.Action} - the addition of a
+ <tt>public static final</tt> {@link hirondelle.web4j.request.RequestParameter} field for the page index parameter
+ <li>the JSP performs the subsetting to display a single page at a time, simply using <tt><c:out></tt>
+</ul>
+
+ The JSP performs the subsetting with simple JSTL.
+ In the following example, the page size is hard-coded to 25 items per page.
+ The <tt><c:out></tt> tag has <tt>begin</tt> and <tt>end</tt> attributes which can control the range of rendered items :
+<PRE>
+<c:forEach
+ var="item"
+ items="${items}"
+ begin="${25 * (param.PageIndex - 1)}"
+ end="${25 * param.PageIndex - 1}"
+>
+ ...display each item...
+</c:forEach>
+</PRE>
+
+Note the different expressions for begin and end :
+<PRE>begin = 25 * (PageIndex - 1)
+end = (25 * PageIndex) - 1
+</PRE>
+
+which give the following values :
+<P>
+<table border='1' cellspacing='0' cellpadding='3'>
+ <tr>
+ <th>Page</th>
+ <th>Begin</th>
+ <th>End</th>
+ </tr>
+ <tr>
+ <td>1</td>
+ <td>0</td>
+ <td>24</td>
+ </tr>
+ <tr>
+ <td>2</td>
+ <td>25</td>
+ <td>49</td>
+ </tr>
+ <tr>
+ <td>...</td>
+ <td>...</td>
+ <td>...</td>
+ </tr>
+</table>
+
+<P>An alternate variation would be to allow the page <i>size</i> to also come from a request parameter :
+<PRE>
+<c:forEach
+ var="item"
+ items="${items}"
+ begin="${param.PageSize * (param.PageIndex - 1)}"
+ end="${param.PageSize * param.PageIndex - 1}"
+>
+ ...display each line...
+</c:forEach>
+</PRE>
+
+
+ <P><b>Subsetting in the DAO</b><br>
+<ul>
+ <li>the full <tt>ResultSet</tt> is still returned by the <tt>SELECT</tt>, as in the JSP case above
+ <li>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.
+ <li>the <tt><c:out></tt> tag does not use any <tt>begin</tt> and <tt>end</tt> attributes, since the subsetting
+ has already been done.
+</ul>
+
+ <P><b>Subsetting in the SELECT</b><br>
+<ul>
+ <li>the underlying <tt>SELECT</tt> is constructed to return only the desired records. (Such a <tt>SELECT</tt>
+ may be difficult to construct, according to the capabilities of the target database.)
+ <li>the <tt>SELECT</tt> will likely need two request parameters to form the proper query: a page index and
+ a page size, from which <tt>start</tt> and <tt>end</tt> indices may be calculated
+ <li>the DAO and JSP are implemented as in any regular listing
+</ul>
+
+<P>Here are the kinds of calculations you may need when constructing such a SELECT statement
+<P>For items enumerated with a 0-based index :
+ <PRE>
+ start_index = page_size * (page_index - 1)
+ end_index = page_size * page_index - 1
+ </PRE>
+
+ <P>For items enumerated with a 1-based index :
+ <PRE>
+ start_index = page_size * (page_index - 1) + 1
+ end_index = page_size * page_index
+ </PRE>
+
+ <h3>Setting In <tt>web.xml</tt></h3>
+ Note that there is a <tt>MaxRows</tt> setting in <tt>web.xml</tt> which controls the maximum number of records returned by
+ a <tt>SELECT</tt>.
+*/
+public final class Pager extends TagHelper {
+
+ /**
+ Name of request parameter that holds the index of the current page.
+
+ <P>This request parameter takes values in the range <tt>1..9999</tt>.
+
+ <P>(The name of this method is confusing. It should rather be named <tt>setPageIndex</tt>.)
+ */
+ 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 = "<A HREF=\"" + aReplacementHref + "\" " + aMatcher.group(ATTRS_AFTER_HREF)+" >" + aMatcher.group(LINK_BODY) + "</A>";
+ }
+ else {
+ result = aMatcher.group(LINK_BODY);
+ }
+ return EscapeChars.forReplacementString(result);
+ }
+}
--- /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.
+
+ <P>From the point of view of this tag, there are 3 sources of data for a form control:
+ <ul>
<li>the HTML defined in your JSP can define an initial default value
<li>Request parameter values
<li>a Model Object
+ </ul>
+
+ <P>For reference, here is the logic that defines which data source is used, and related
+ naming conventions :
+<PRE>
+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 <i>every</i> 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
+ }
+}
</PRE>
+
+ <P><span class='highlight'>This tag simply wraps static HTML forms</span>.
+ 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.
+
+<h3>Example use case</h3>
+ This use case corresponds to either an 'add' or a 'change' of a Model Object. The <tt>using</tt>
+ attribute signifies that a 'change' case is possible. (This example works with
+ an {@link hirondelle.web4j.action.ActionTemplateListAndEdit} action.)
+
+<PRE>
+<c:url value="RestoAction.do" var="baseURL"/>
+<form action='${baseURL}' method="post" class="user-input">
+<b><w:populate using="itemForEdit"></b>
+<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>
+<b></w:populate></b>
+ <tags:hiddenOperationParam/>
+</form>
+</PRE>
+
+ Here, the <tt>itemForEdit</tt> Model Object has the following methods, corresponding to
+ the above populated controls :
+ <PRE>
+ public Id getId() {...}
+ public SafeText getName() {...}
+ public SafeText getLocation() {...}
+ public BigDecimal getPrice() {...}
+ public SafeText getComment() {...}
+</PRE>
+
+ <h3>Example without <tt>using</tt> attribute</h3>
+ No <tt>using</tt> attribute is specified when :
+ <ul>
+ <li>only an 'add' operation is performed, and not a 'change' operation.
+ <li>or, only a <tt>Search</tt> {@link Operation} is performed. In this case, a form with <tt>method="GET"</tt> is used
+ to specify parameters to a <tt>SELECT</tt> statement.
+ </ul>
+
+ <P>Here is an example of a form used only for 'add' operations :
+<PRE>
+<b><w:populate></b>
+<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>
+<b></w:populate></b>
+ </PRE>
+
+<h3>Supported Controls</h3>
+ <P>The following form input items are called <em>supported controls</em> here, and
+ include all items which undergo population by this class :
+<ul>
+ <li><tt>INPUT</tt> tags with type=<tt>text</tt>, <tt>password</tt>, <tt>radio</tt>,
+ <tt>checkbox</tt>, <tt>hidden</tt>
+ <li>HTML5 input tags with type=<tt>search</tt>, <tt>email</tt>, <tt>url</tt>, <tt>number</tt>, <tt>tel</tt>, <tt>color</tt>, <tt>range</tt>
+ <li><tt>SELECT</tt> tags
+ <li><tt>TEXTAREA</tt> tags
+</ul>
+
+ <P>Population is implemented by editing these supported control attributes :
+<ul>
+ <li>the <tt>checked</tt> attribute for INPUT tags of type <tt>radio</tt>
+ and <tt>checkbox</tt>
+ <li>the <tt>value</tt> attribute for the remaining INPUT tags (of the different types listed above)
+ <li>the <tt>selected</tt> attribute for OPTION tags appearing in a SELECT
+ <li>the body of a TEXTAREA tag
+</ul>
+
+<P>The body of this tag is HTML, with the following minor restrictions:
+<ul>
+ <li>all supported controls must include a <tt>name</tt> attribute
+ <li>all supported INPUT controls must include a <tt>type</tt> attribute
+ <li>all attributes must be quoted, using either single or double quotes. For example,
+ <tt><input type='text' ... ></tt> is allowed but
+ <tt><input type=text ... ></tt> is not
+ <li> for SELECT tags, the </option> end tag is not optional, and must be included.
+ <li>INPUT tags with <tt>type='email'</tt> are treated as always being single-valued
+ <li>the repetitive form of <tt>selected='selected'</tt> can't be used
+</ul>
+
+HTML often allows alternate ways of expressing the exact same thing.
+For example, the <tt>selected</tt> attribute can be expressed as <tt>selected='selected'</tt>, or simply as
+the single word <tt>selected</tt> - 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.
+
+<P><b>Warning: unfortunately, INPUT controls of type color and range can't represent nullable items in a database.</b>
+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.
+
+<h3>Prepopulating only portions of a form</h3>
+ 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.
+
+ <h3>Convention Regarding Control Names</h3>
+ This tag depends on a specfic convention to allow automatic 'binding' between supported controls
+ and corresponding <tt>getXXX</tt> methods of the Model Object. This convention is explained in
+ {@link hirondelle.web4j.request.RequestParameter}.
+
+ <h3>Deriving values from <tt>getXXX()</tt> methods of the Model Object</h3>
+ 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 <tt>getXXX</tt> is a
+ <tt>Collection</tt>, then the above is applied to each element.
+
+ <h3>Escaping special characters</h3>
+ When this tag assigns a text value to the content of an <tt>INPUT</tt> or <tt>TEXTAREA</tt> tag, then the
+ value is always escaped for special characters using {@link hirondelle.web4j.util.EscapeChars#forHTML(String)}.
+
+ <h3><tt>GET</tt> versus <tt>POST</tt></h3>
+ This tag depends on the proper <tt>GET/POST</tt> behavior of forms : a <tt>POST</tt>
+ 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.
+
+ <P>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.
+
+ <P>This tag searches for the Model Object in the same way as <tt>JspContext.findAttribute(String)</tt>,
+ 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<String> getReqParamValues(String aParamName){
+ Collection<String> 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
--- /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}.
+
+ <P>Performs most of the work of <tt>Populate</tt>, 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.
+
+ <P>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.
+
+ <P>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 <tt>aParamName</tt>.
+
+ <P>If the param is absent, or if its value is null, then return an empty String.
+
+ <P>HTTP POSTs "missing" items inconsistently:
+ <ul>
+ <li>empty text/password: param posted, but value is empty <tt>String</tt>
+ <li>unselected radio/checkbox: no param is posted at all (null)
+ <li>unselected SELECT tags: param is posted using first item as value
+ </ul>
+ */
+ String getReqParamValue(String aParamName);
+
+ /** Returns <tt>true</tt> 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
+ <tt>aParamName</tt>. Intended for use with multi-valued parameters.
+
+ @return unmodifiable <tt>Collection</tt> of <tt>Strings</tt>; if the parameter is
+ missing from the request, then return an empty <tt>Collection</tt>.
+ */
+ Collection<String> getReqParamValues(String aParamName);
+
+ /** Return <tt>true</tt> only if the <tt>using</tt> Model Object is present in some scope. */
+ boolean isModelObjectPresent();
+
+ /** Return the <tt>using</tt> 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 '<tt>using</tt>' Model Object, and formatting styles.
+ @param aOriginalBody the HTML content of the <tt>Populate</tt> 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 +
+ "</textarea>" +
+ "|" +
+ "<(select)" + Regex.ALL_BUT_END_OF_TAG + ">" + Regex.WS +
+ "(?:<option" + Regex.ALL_BUT_END_OF_TAG + ">" + Regex.ALL_BUT_START_OF_TAG +
+ "</option>" + Regex.WS + ")+" +
+ "</select>)",
+ 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 + "</option>",
+ 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 <tt>fControl</tt>. */
+ 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;
+ }
+
+ /** <tt>aPrepopValue</tt> 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);
+ }
+
+ /** <tt>aPrepopValue</tt> 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<String> 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;
+ }
+
+ /** <tt>aPrepopValue</tt> is possibly-null. */
+ private boolean valueMatchesPrepop(String aPrepopValue){
+ return fValueAttr.equals(aPrepopValue);
+ }
+
+ private boolean valueMatchesPrepop(Collection<String> 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<String> 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<String> getPrepopValues() throws JspException {
+ Collection<String> 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.
+
+ <P>If the corresponding getXXX does not return a Collection:
+ <ul>
+ <li>call {@link Formats#objectToText} on the return value of getXXX
+ <li>return that single item wrapped in a Collection.
+ </ul>
+
+ <P>If the corresponding getXXX method returns a Collection, then
+ <ul>
+ <li>call {@link Formats#objectToText} on each item in the Collection
+ <li>add each string to the result of this method
+ </ul>
+ */
+ private Collection<String> getPropertyValues() throws JspException {
+ Collection<String> result = new ArrayList<String>();
+ 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 <tt>Object</tt>. */
+ 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
--- /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.
+
+ <P>This class uses:
+ <ul>
+ <li>{@link hirondelle.web4j.request.LocaleSource} to determine the Locale associated with the current request
+ <li>{@link TimeZoneSource} for the time zone associated with the current request
+ <li>{@link DateConverter} to format the given date
+ <li>{@link Translator} for localizing the argument passed to {@link #setPatternKey}.
+ </ul>
+
+ <h3>Examples</h3>
+ <P>Display the current system date :
+<PRE>{@code
+<w:showDate/>
+}</PRE>
+
+ <P>Display a specific <tt>Date</tt> object, present in any scope :
+<PRE><w:showDate <a href="#setName(java.lang.String)">name</a>="dateOfBirth"/></PRE>
+
+ <P>Display a date returned by some object in scope :
+<PRE>{@code
+<c:set value="${visit.lunchDate}" var="lunchDate"/>
+<w:showDate name="lunchDate"/>
+}</PRE>
+
+ <P>Display with a non-default date format :
+<PRE><w:showDate name="lunchDate" <a href="#setPattern(java.lang.String)">pattern</a>="E, MMM dd"/></PRE>
+
+ <P>Display with a non-default date format sensitive to {@link Locale} :
+<PRE><w:showDate name="lunchDate" <a href="#setPatternKey(java.lang.String)">patternKey</a>="next.visit.lunch.date"/></PRE>
+
+ <P>Display in a specific time zone :
+<PRE><w:showDate name="lunchDate" <a href="#setTimeZone(java.lang.String)">timeZone</a>="America/Montreal"/></PRE>
+
+ <P>Suppress the display of midnight, using a pipe-separated list of 'midnights' :
+<PRE><w:showDate name="lunchDate" <a href="#setSuppressMidnight(java.lang.String)">suppressMidnight</a>="12:00 AM|00 h 00"/></PRE>
+*/
+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 <tt>Date</tt>.
+
+ <P>If this method is called and no corresponding object can be found using the
+ given name, then this tag will emit an empty String.
+
+ <P>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.
+
+ <P>Setting this attribute will override the default format of
+ {@link DateConverter#formatEyeFriendly(Date, Locale, TimeZone)}.
+
+ <P><span class="highlight">Calling this method is suitable only when
+ the date format does not depend on {@link Locale}.</span> Otherwise,
+ {@link #setPatternKey(String)} must be used instead.
+
+ <P>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}.
+
+ <P>Setting this attribute will override the default format of
+ {@link DateConverter#formatEyeFriendly(Date, Locale, TimeZone)}.
+
+ <P>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}.
+
+ <P>For example, if the value '<tt>format.next.lunch.date</tt>' 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
+ '<tt>EEE, dd MMM</tt>'.
+
+ <P>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.
+
+ <P>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.
+
+ <P>For example, set this attribute to '<tt>00:00:00</tt>' to force '<tt>1999-12-31 00:00:00</tt>' to display as
+ <tt>1999-12-31</tt>, without the time.
+
+ <P>If this attribute is set, and if any of the <tt>aMidnightStyles</tt> is found <em>anywhere</em> 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>();
+ 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<String> fMidnightStyles = new ArrayList<String>();
+ 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);
+ }
+}
--- /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.
+
+ <P>This class uses:
+ <ul>
+ <li>{@link hirondelle.web4j.request.LocaleSource} to determine the Locale associated with the current request
+ <li>{@link DateConverter} to format the given date
+ <li>{@link Translator} for localizing the argument passed to {@link #setPatternKey}.
+ </ul>
+
+ <h3>Examples</h3>
+ <P>Display the current system date, with the default format defined by {@link DateConverter} :
+<PRE>{@code
+<w:showDateTime/>
+}</PRE>
+
+ <P>Display a specific date object, present in any scope :
+<PRE><w:showDateTime <a href="#setName(java.lang.String)">name</a>="dateOfBirth"/></PRE>
+
+ <P>Display a date returned by some object in scope :
+<PRE>{@code
+<c:set value="${visit.lunchDate}" var="lunchDate"/>
+<w:showDateTime name="lunchDate"/>
+}</PRE>
+
+ <P>Display with a non-default date format :
+<PRE><w:showDateTime name="lunchDate" <a href="#setPattern(java.lang.String)">pattern</a>="YYYY-MM-DD"/></PRE>
+
+ <P>Display with a non-default date format sensitive to {@link Locale} :
+<PRE><w:showDateTime name="lunchDate" <a href="#setPatternKey(java.lang.String)">patternKey</a>="next.visit.lunch.date"/></PRE>
+
+ <P>Suppress the display of midnight, using a pipe-separated list of 'midnights' :
+<PRE><w:showDateTime name="lunchDate" <a href="#setSuppressMidnight(java.lang.String)">suppressMidnight</a>="12:00 AM|00 h 00"/></PRE>
+*/
+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.
+
+ <P>If this method is called and no corresponding object can be found using the
+ given name, then this tag will emit an empty String.
+
+ <P>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.
+
+ <P>Setting this attribute will override the default format used by
+ {@link DateConverter}.
+
+ <P><span class="highlight">Calling this method is suitable only when
+ the date format does not depend on {@link Locale}.</span> Otherwise,
+ {@link #setPatternKey(String)} must be used instead.
+
+ <P>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 <tt>format</tt> .
+ 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}.
+
+ <P>Setting this attribute will override the default format used by
+ {@link DateConverter}.
+
+ <P>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}.
+
+ <P>For example, if the value '<tt>format.next.lunch.date</tt>' 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
+ '<tt>EEE, dd MMM</tt>' (for a <tt>Date</tt>) or <tt>YYYY-MM-DD</tt> (for a {@link DateTime}).
+
+ <P>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 <tt>format</tt> methods of {@link DateTime}.
+ */
+ public void setPatternKey(String aFormatKey){
+ checkForContent("PatternKey", aFormatKey);
+ fFormatKey = aFormatKey;
+ }
+
+ /**
+ Optionally suppress the display of midnight.
+
+ <P>For example, set this attribute to '<tt>00:00:00</tt>' to force '<tt>1999-12-31 00:00:00</tt>' to display as
+ <tt>1999-12-31</tt>, without the time.
+
+ <P>If this attribute is set, and if any of the <tt>aMidnightStyles</tt> is found <em>anywhere</em> 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>();
+ 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<String> fMidnightStyles = new ArrayList<String>();
+
+ /** 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);
+ }
+}
--- /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.
+
+ <P><span class="highlight">It's important to note that the <em>sole</em> use of this
+ tag does <em>not</em> robustly enforce security constraints.</span>
+ 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 <tt>security-constraint</tt>
+ defined in <tt>web.xml</tt>.
+
+ <P>Example:
+ <PRE>
+ <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>
+ </PRE>
+
+ Example with role specified by negation:
+ <PRE>
+ <w:show ifRoleNot="read-only">
+ show tag content only if the user is logged in,
+ and has none of the specified roles
+ </w:show>
+ </PRE>
+
+ Example with logic attached not to role, but simply whether or not the user has logged in:
+ <PRE>
+ <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></pre>
+
+ The above styles are all mutually exclusive. You can specify only 1 attribute at a time with this tag.
+
+ <P>The body of this class is either echoed as is, or is suppressed entirely.
+
+ <P>By definition (in the servlet specification), a user is logged in when <tt>request.getUserPrincipal()</tt>
+ 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<String> fAcceptedRoles = new ArrayList<String>();
+ private List<String> fDeniedRoles = new ArrayList<String>();
+ /** 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<String> getRoles(String aRawRoles){
+ List<String> result = new ArrayList<String>();
+ 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<String> aRoles){
+ boolean result = FAILS;
+ for (String role: aRoles){
+ if ( getRequest().isUserInRole(role) ) {
+ result = PASSES;
+ break;
+ }
+ }
+ return result;
+ }
+}
--- /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) <a href="http://www.junit.org">JUnit</a> 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("<IMG src='http://www.blah.com/images/blah.gif' ALT='blah'>", "<IMG src='http://www.blah.com/images/blah.gif' ALT='blah'>", "LoginName", "Strummer");
+ //test no alteration of tag whose type(submit) is not supported
+ testSingleParam("<input type=\"submit\" value='REGISTER'>", "<input type=\"submit\" value='REGISTER'>", "LoginName", "Strummer");
+ //text
+ testSingleParam("<input name='LoginName' type='text' size='30'>", "<input value=\"Strummer\" name='LoginName' type='text' size='30'>", "LoginName", "Strummer");
+ //caps, spacing, quotes
+ testSingleParam("<INPUT NAME='LoginName' TYPE='TEXT' SIZE='30'>", "<INPUT value=\"Strummer\" NAME='LoginName' TYPE='TEXT' SIZE='30'>", "LoginName", "Strummer");
+ //additional extraneous attr's
+ testSingleParam("<input name='LoginName' type='text' size='30' class='blah'>", "<input value=\"Strummer\" name='LoginName' type='text' size='30' class='blah'>", "LoginName", "Strummer");
+ //password
+ testSingleParam("<input name='LoginPassword' type='password' size='30'>", "<input value=\"clash\" name='LoginPassword' type='password' size='30'>", "LoginPassword", "clash");
+ //hidden
+ testSingleParam("<input name='LoginPassword' type='hidden'>", "<input value=\"clash\" name='LoginPassword' type='hidden'>", "LoginPassword", "clash");
+ //search
+ testSingleParam("<input name='Bob' type='search'>", "<input value=\"clash\" name='Bob' type='search'>", "Bob", "clash");
+ //email
+ testSingleParam("<input name='Bob' type='email'>", "<input value=\"joe@clash.com\" name='Bob' type='email'>", "Bob", "joe@clash.com");
+ //url
+ testSingleParam("<input name='Bob' type='url'>", "<input value=\"http://www.date4j.net\" name='Bob' type='url'>", "Bob", "http://www.date4j.net");
+ //tel
+ testSingleParam("<input name='Bob' type='tel'>", "<input value=\"1-800-549-BLAH\" name='Bob' type='tel'>", "Bob", "1-800-549-BLAH");
+ //number
+ testSingleParam("<input name='Bob' type='number'>", "<input value=\"3.14\" name='Bob' type='number'>", "Bob", "3.14");
+ //color
+ testSingleParam("<input name='Bob' type='color'>", "<input value=\"#001122\" name='Bob' type='color'>", "Bob", "#001122");
+ //range
+ testSingleParam("<input name='Bob' type='range'>", "<input value=\"56\" name='Bob' type='range'>", "Bob", "56");
+ //radio with match
+ testSingleParam("<INPUT type='RADIO' name=\"StarRating\" value='1'>", "<INPUT checked type='RADIO' name=\"StarRating\" value='1'>", "StarRating", "1");
+ //radio with no match
+ testSingleParam("<INPUT type='RADIO' name=\"StarRating\" value='2'>", "<INPUT type='RADIO' name=\"StarRating\" value='2'>", "StarRating", "1");
+ //checkbox with match
+ testSingleParam("<input type=\"CHECKBOX\" name=\"SendCard\" value='true'>", "<input checked type=\"CHECKBOX\" name=\"SendCard\" value='true'>", "SendCard", "true");
+ //checkbox with no match
+ testSingleParam("<input type=\"CHECKBOX\" name=\"SendCard\" value='true'>", "<input type=\"CHECKBOX\" name=\"SendCard\" value='true'>", "SendCard", "false");
+
+ //text with default is overwritten
+ testSingleParam("<input name='LoginName' value='Joe' type='text' size='30'>", "<input name='LoginName' value=\"Mick\" type='text' size='30'>", "LoginName", "Mick");
+ //as above, but with hidden
+ testSingleParam("<input name='LoginName' value='Joe' type='hidden'>", "<input name='LoginName' value=\"Mick\" type='hidden'>", "LoginName", "Mick");
+ //as above, but with search
+ testSingleParam("<input name='LoginName' value='Joe' type='search'>", "<input name='LoginName' value=\"Mick\" type='search'>", "LoginName", "Mick");
+ //as above, but with number
+ testSingleParam("<input name='LoginName' value='3.14' type='number'>", "<input name='LoginName' value=\"7.95\" type='number'>", "LoginName", "7.95");
+ //text with default is overwritten even if param is empty
+ testSingleParam("<input name='LoginName' value='Mick' type='text' size='30'>", "<input name='LoginName' value=\"\" type='text' size='30'>", "LoginName", "");
+ //as above, but with hidden
+ testSingleParam("<input name='LoginName' value='Mick' type='hidden'>", "<input name='LoginName' value=\"\" type='hidden'>", "LoginName", "");
+ //as above, but with search
+ testSingleParam("<input name='LoginName' value='Mick' type='search'>", "<input name='LoginName' value=\"\" type='search'>", "LoginName", "");
+ //text with default is overwritten even if default is empty
+ testSingleParam("<input name='LoginName' value='' type='text' size='30'>", "<input name='LoginName' value=\"Mick\" type='text' size='30'>", "LoginName", "Mick");
+ //radio is checked by default, but no match, so is unchecked
+ testSingleParam("<INPUT type='RADIO' checked name=\"StarRating\" value='1'>", "<INPUT type='RADIO' name=\"StarRating\" value='1'>", "StarRating", "2");
+ //as previous, but checked attr at end
+ testSingleParam("<INPUT type='RADIO' name=\"StarRating\" value='1' checked>", "<INPUT type='RADIO' name=\"StarRating\" value='1'>", "StarRating", "2");
+ //radio is checked by default, and matches, so is unchanged
+ testSingleParam("<INPUT type='RADIO' checked name=\"StarRating\" value='1'>", "<INPUT type='RADIO' checked name=\"StarRating\" value='1'>", "StarRating", "1");
+ //checkbox is checked by default, but no match, so is removed
+ testSingleParam("<input type=\"CHECKBOX\" name=\"SendCard\" checked value='true'>", "<input type=\"CHECKBOX\" name=\"SendCard\" value='true'>", "SendCard", "false");
+ //checkbox is checked by default, with match, so is retained
+ testSingleParam("<input type=\"CHECKBOX\" name=\"SendCard\" value='true' checked>", "<input type=\"CHECKBOX\" name=\"SendCard\" value='true' checked>", "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("<input name='DesiredSalary' type='text' size='30'>", "<input value=\"100.00U$\" name='DesiredSalary' type='text' size='30'>", "DesiredSalary", "100.00U$");
+ }
+
+ public void testBigFailures() throws JspException, IOException {
+ //invalid flavor
+ testBigFailure("<inputt name='LoginName' type='text' size='30'>", "<input value=\"Strummer\" name='LoginName' type='text' size='30'>", "LoginName", "Strummer");
+ //compulsory attr not found (type)
+ testBigFailure("<input name='LoginName' size='30'>", "<input value=\"Strummer\" name='LoginName' type='text' size='30'>", "LoginName", "Strummer");
+ //compulsory attr not found (name)
+ testBigFailure("<input type='text' size='30'>", "<input value=\"Strummer\" name='LoginName' type='text' size='30'>", "LoginName", "Strummer");
+ //no param of expected name in scope
+ testBigFailure("<input name='LoginName' type='text' size='30'>", "<input value=\"Strummer\" name='LoginName' type='text' size='30'>", "Login", "Strummer");
+ }
+
+ public void testModelObjectWithInputTags() throws JspException, IOException, ModelCtorException {
+ User user = getUser();
+ //text
+ testModelObject("<input name='LoginName' type='text' size='30'>", "<input value=\"Strummer\" name='LoginName' type='text' size='30'>", user);
+ //test no alteration of tag which is not related to input
+ testModelObject("<IMG src='http://www.blah.com/images/blah.gif' ALT='blah'>", "<IMG src='http://www.blah.com/images/blah.gif' ALT='blah'>", user);
+ //test no alteration of tag whose type(submit) is not supported
+ testModelObject("<input type=\"submit\" value='REGISTER'>", "<input type=\"submit\" value='REGISTER'>", user);
+ //caps, spacing, quotes
+ testModelObject("<INPUT NAME='LoginName' TYPE='TEXT' SIZE='30'>", "<INPUT value=\"Strummer\" NAME='LoginName' TYPE='TEXT' SIZE='30'>", user);
+ //additional extraneous attr's
+ testModelObject("<input name='LoginName' type='text' size='30' class='blah'>", "<input value=\"Strummer\" name='LoginName' type='text' size='30' class='blah'>", user);
+ //password
+ testModelObject("<input name='LoginPassword' type='password' size='30'>", "<input value=\"clash\" name='LoginPassword' type='password' size='30'>", user);
+ //hidden
+ testModelObject("<input name='LoginPassword' type='hidden'>", "<input value=\"clash\" name='LoginPassword' type='hidden'>", user);
+ //search
+ testModelObject("<input name='LoginPassword' type='search'>", "<input value=\"clash\" name='LoginPassword' type='search'>", user);
+ //email
+ testModelObject("<input name='EmailAddress' type='email'>", "<input value=\"joe@clash.com\" name='EmailAddress' type='email'>", user);
+ //number
+ testModelObject("<input name='DesiredSalary' type='number'>", "<input value=\"17500.00\" name='DesiredSalary' type='number'>", user);
+ //range
+ testModelObject("<input name='Range' type='range'>", "<input value=\"17.5\" name='Range' type='range'>", user);
+ //color
+ testModelObject("<input name='Color' type='color'>", "<input value=\"#001122\" name='Color' type='color'>", user);
+
+ //radio with match
+ testModelObject("<INPUT type='RADIO' name=\"StarRating\" value='1'>", "<INPUT checked type='RADIO' name=\"StarRating\" value='1'>", user);
+ //radio with no match
+ testModelObject("<INPUT type='RADIO' name=\"StarRating\" value='2'>", "<INPUT type='RADIO' name=\"StarRating\" value='2'>", user);
+ //checkbox with match
+ testModelObject("<input type=\"CHECKBOX\" name=\"SendCard\" value='false'>", "<input checked type=\"CHECKBOX\" name=\"SendCard\" value='false'>", user);
+ //checkbox with no match
+ testModelObject("<input type=\"CHECKBOX\" name=\"SendCard\" value='true'>", "<input type=\"CHECKBOX\" name=\"SendCard\" value='true'>", user);
+
+ //text with default is overwritten
+ testModelObject("<input name='LoginName' value='Mick' type='text' size='30'>", "<input name='LoginName' value=\"Strummer\" type='text' size='30'>", user);
+ //as above, but for hidden
+ testModelObject("<input name='LoginName' value='Mick' type='hidden'>", "<input name='LoginName' value=\"Strummer\" type='hidden'>", user);
+ //text with default is overwritten even if default is empty
+ testModelObject("<input name='LoginName' value='' type='text' size='30'>", "<input name='LoginName' value=\"Strummer\" type='text' size='30'>", user);
+ //as above, but with hidden
+ testModelObject("<input name='LoginName' value='' type='hidden'>", "<input name='LoginName' value=\"Strummer\" type='hidden'>", user);
+ //as above, but with search
+ testModelObject("<input name='LoginName' value='' type='search'>", "<input name='LoginName' value=\"Strummer\" type='search'>", user);
+ //radio is checked by default, but no match, so is unchecked
+ testModelObject("<INPUT type='RADIO' checked name=\"StarRating\" value='2'>", "<INPUT type='RADIO' name=\"StarRating\" value='2'>", user);
+ //as previous, but checked attr at end
+ testModelObject("<INPUT type='RADIO' name=\"StarRating\" value='2' checked>", "<INPUT type='RADIO' name=\"StarRating\" value='2'>", user);
+ //radio is checked by default, and matches, so is unchanged
+ testModelObject("<INPUT type='RADIO' checked name=\"StarRating\" value='1'>", "<INPUT type='RADIO' checked name=\"StarRating\" value='1'>", user);
+ //checkbox is checked by default, but no match, so is removed
+ testModelObject("<input type=\"CHECKBOX\" name=\"SendCard\" checked value='true'>", "<input type=\"CHECKBOX\" name=\"SendCard\" value='true'>", user);
+ //checkbox is checked by default, with match, so is retained
+ testModelObject("<input type=\"CHECKBOX\" name=\"SendCard\" value='false' checked>", "<input type=\"CHECKBOX\" name=\"SendCard\" value='false' checked>", user);
+
+ //basic sanity for Locale and TimeZone
+ testModelObject("<input name='Country' type='text' size='30'>", "<input value=\"en_ca\" name='Country' type='text' size='30'>", user);
+ testModelObject("<input name='TZ' type='text' size='30'>", "<input value=\"Canada/Atlantic\" name='TZ' type='text' size='30'>", user);
+ }
+
+ public void testFailBeanMethod() throws JspException, IOException, ModelCtorException {
+ try {
+ //name of tag does not match any bean getXXX method
+ testModelObject("<input name='LoginNameB' type='text' size='30'>", "<input value=\"Strummer\" name='LoginName' type='text' size='30'>", getUser());
+ }
+ catch(Throwable ex){
+ return;
+ }
+ fail("Should have failed.");
+ }
+
+ public void testParamsWithSelectTags() throws JspException, IOException {
+ //basic prepop
+ testSelect(
+ "<select name='FavoriteTheory'>"+NL+
+ " <option>Quantum Electrodynamics</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select name='FavoriteTheory'>"+NL+
+ " <option selected>Quantum Electrodynamics</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "FavoriteTheory", new Object[] {"Quantum Electrodynamics"}
+ );
+ //extra attr's
+ testSelect(
+ "<select name='FavoriteTheory'>"+NL+
+ " <option class='blah'>Quantum Electrodynamics</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select name='FavoriteTheory'>"+NL+
+ " <option selected class='blah'>Quantum Electrodynamics</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "FavoriteTheory", new Object[] {"Quantum Electrodynamics"}
+ );
+ //with value attr and selected
+ testSelect(
+ "<select name='FavoriteTheory'>"+NL+
+ " <option class='blah'>Quantum Electrodynamics</option>"+NL+
+ " <option value='Quantum Flavordynamics'>QFD</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select name='FavoriteTheory'>"+NL+
+ " <option class='blah'>Quantum Electrodynamics</option>"+NL+
+ " <option selected value='Quantum Flavordynamics'>QFD</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "FavoriteTheory", new Object[] {"Quantum Flavordynamics"}
+ );
+ //with value attr and not selected
+ testSelect(
+ "<select name='FavoriteTheory'>"+NL+
+ " <option class='blah'>Quantum Electrodynamics</option>"+NL+
+ " <option value='Quantum Flavordynamics'>QFD</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select name='FavoriteTheory'>"+NL+
+ " <option selected class='blah'>Quantum Electrodynamics</option>"+NL+
+ " <option value='Quantum Flavordynamics'>QFD</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "FavoriteTheory", new Object[] {"Quantum Electrodynamics"}
+ );
+ //caps, spaces, and quotes
+ testSelect(
+ "<SELECT NAME=\"FavoriteTheory\">"+NL+
+ " <OPTION class=\"blah\" >Quantum Electrodynamics</OPTION>"+NL+
+ " <OPTION value=\"Quantum Flavordynamics\" >QFD</OPTION>"+NL+
+ " <OPTION >Quantum Chromodynamics</OPTION>"+NL+
+ "</SELECT>",
+
+ "<SELECT NAME=\"FavoriteTheory\">"+NL+
+ " <OPTION class=\"blah\" >Quantum Electrodynamics</OPTION>"+NL+
+ " <OPTION value=\"Quantum Flavordynamics\" >QFD</OPTION>"+NL+
+ " <OPTION selected >Quantum Chromodynamics</OPTION>"+NL+
+ "</SELECT>",
+
+ "FavoriteTheory", new Object[] {"Quantum Chromodynamics"}
+ );
+ //retain default selected
+ testSelect(
+ "<select name='FavoriteTheory'>"+NL+
+ " <option>Quantum Electrodynamics</option>"+NL+
+ " <option selected>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select name='FavoriteTheory'>"+NL+
+ " <option>Quantum Electrodynamics</option>"+NL+
+ " <option selected>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "FavoriteTheory", new Object[] {"Quantum Flavordynamics"}
+ );
+ //retain default selected when other attr's present
+ testSelect(
+ "<select name='FavoriteTheory'>"+NL+
+ " <option>Quantum Electrodynamics</option>"+NL+
+ " <option value='Quantum Flavordynamics' selected class='blah'>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select name='FavoriteTheory'>"+NL+
+ " <option>Quantum Electrodynamics</option>"+NL+
+ " <option value='Quantum Flavordynamics' selected class='blah'>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "FavoriteTheory", new Object[] {"Quantum Flavordynamics"}
+ );
+ //remove default selected when no match
+ testSelect(
+ "<select name='FavoriteTheory'>"+NL+
+ " <option>Quantum Electrodynamics</option>"+NL+
+ " <option value='Quantum Flavordynamics' selected class='blah'>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select name='FavoriteTheory'>"+NL+
+ " <option>Quantum Electrodynamics</option>"+NL+
+ " <option value='Quantum Flavordynamics' class='blah'>Quantum Flavordynamics</option>"+NL+
+ " <option selected>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "FavoriteTheory", new Object[] {"Quantum Chromodynamics"}
+ );
+ //extra attrs in select tag
+ testSelect(
+ "<select class='blah' name='FavoriteTheory' size='30'>"+NL+
+ " <option>Quantum Electrodynamics</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select class='blah' name='FavoriteTheory' size='30'>"+NL+
+ " <option selected>Quantum Electrodynamics</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "FavoriteTheory", new Object[] {"Quantum Electrodynamics"}
+ );
+ //test leading and trailing spaces in body of option tag
+ testSelect(
+ "<select name='FavoriteTheory'>"+NL+
+ " <option> Quantum Electrodynamics </option>"+NL+
+ " <option> Quantum Flavordynamics </option>"+NL+
+ " <option> Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select name='FavoriteTheory'>"+NL+
+ " <option selected> Quantum Electrodynamics </option>"+NL+
+ " <option> Quantum Flavordynamics </option>"+NL+
+ " <option> Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "FavoriteTheory", new Object[] {"Quantum Electrodynamics"}
+ );
+ //as above, with different selected
+ testSelect(
+ "<select name='FavoriteTheory'>"+NL+
+ " <option> Quantum Electrodynamics </option>"+NL+
+ " <option> Quantum Flavordynamics </option>"+NL+
+ " <option> Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select name='FavoriteTheory'>"+NL+
+ " <option> Quantum Electrodynamics </option>"+NL+
+ " <option selected> Quantum Flavordynamics </option>"+NL+
+ " <option> Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "FavoriteTheory", new Object[] {"Quantum Flavordynamics"}
+ );
+ //as above, with different selected
+ testSelect(
+ "<select name='FavoriteTheory'>"+NL+
+ " <option> Quantum Electrodynamics </option>"+NL+
+ " <option> Quantum Flavordynamics </option>"+NL+
+ " <option> Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select name='FavoriteTheory'>"+NL+
+ " <option> Quantum Electrodynamics </option>"+NL+
+ " <option> Quantum Flavordynamics </option>"+NL+
+ " <option selected> Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "FavoriteTheory", new Object[] {"Quantum Chromodynamics"}
+ );
+ //multiple selects
+ testSelect(
+ "<select multiple name='FavoriteTheory'>"+NL+
+ " <option>Quantum Electrodynamics</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select multiple name='FavoriteTheory'>"+NL+
+ " <option>Quantum Electrodynamics</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option selected>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "FavoriteTheory", new Object[] {"Quantum Chromodynamics"}
+ );
+ //special regex chars in extra attrs in option tag
+ testSelect(
+ "<select class='blah' name='FavoriteTheory'>"+NL+
+ " <option title='Aliases [utf-1-1-unicode]'>Quantum Electrodynamics</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select class='blah' name='FavoriteTheory'>"+NL+
+ " <option selected title='Aliases [utf-1-1-unicode]'>Quantum Electrodynamics</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "FavoriteTheory", new Object[] {"Quantum Electrodynamics"}
+ );
+ }
+
+ public void testFailedSelects() throws JspException, IOException, ModelCtorException {
+ testFailSelect(
+ "<select name='FaveTheory'>"+NL+
+ " <option>Quantum Electrodynamics</option>"+NL+
+ " <option>Quantum Flavordynamics"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select name='FaveTheory'>"+NL+
+ " <option selected>Quantum Electrodynamics</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "FaveTheory", new Object[] {"Quantum Electrodynamics"}
+ );
+ }
+
+
+ public void testMultipleParamsWithSelectTags() throws JspException, IOException {
+ //basic
+ testSelect(
+ "<select multiple name='FavoriteTheory'>"+NL+
+ " <option>Quantum Electrodynamics</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select multiple name='FavoriteTheory'>"+NL+
+ " <option>Quantum Electrodynamics</option>"+NL+
+ " <option selected>Quantum Flavordynamics</option>"+NL+
+ " <option selected>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "FavoriteTheory", new Object[] {"Quantum Flavordynamics","Quantum Chromodynamics"}
+ );
+ //value attr
+ testSelect(
+ "<select multiple name='FavoriteTheory'>"+NL+
+ " <option>Quantum Electrodynamics</option>"+NL+
+ " <option value='Quantum Flavordynamics'>QFD</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select multiple name='FavoriteTheory'>"+NL+
+ " <option>Quantum Electrodynamics</option>"+NL+
+ " <option selected value='Quantum Flavordynamics'>QFD</option>"+NL+
+ " <option selected>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "FavoriteTheory", new Object[] {"Quantum Flavordynamics","Quantum Chromodynamics"}
+ );
+ //retain default selected
+ testSelect(
+ "<select multiple name='FavoriteTheory'>"+NL+
+ " <option>Quantum Electrodynamics</option>"+NL+
+ " <option selected value='Quantum Flavordynamics'>QFD</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select multiple name='FavoriteTheory'>"+NL+
+ " <option>Quantum Electrodynamics</option>"+NL+
+ " <option selected value='Quantum Flavordynamics'>QFD</option>"+NL+
+ " <option selected>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "FavoriteTheory", new Object[] {"Quantum Flavordynamics","Quantum Chromodynamics"}
+ );
+ //remove default selected
+ testSelect(
+ "<select multiple name='FavoriteTheory'>"+NL+
+ " <option selected>Quantum Electrodynamics</option>"+NL+
+ " <option selected value='Quantum Flavordynamics'>QFD</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select multiple name='FavoriteTheory'>"+NL+
+ " <option>Quantum Electrodynamics</option>"+NL+
+ " <option selected value='Quantum Flavordynamics'>QFD</option>"+NL+
+ " <option selected>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "FavoriteTheory", new Object[] {"Quantum Flavordynamics","Quantum Chromodynamics"}
+ );
+ }
+
+ public void testBeanWithSelectTags() throws JspException, IOException, ModelCtorException {
+ //basic prepop
+ testModelObject(
+ "<select name='FavoriteTheory'>"+NL+
+ " <option>Quantum Electrodynamics</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select name='FavoriteTheory'>"+NL+
+ " <option selected>Quantum Electrodynamics</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ getUser()
+ );
+ //value attr, but not selected
+ testModelObject(
+ "<select name='FavoriteTheory'>"+NL+
+ " <option>Quantum Electrodynamics</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option value='Quantum Chromodynamics'>QCD</option>"+NL+
+ "</select>",
+
+ "<select name='FavoriteTheory'>"+NL+
+ " <option selected>Quantum Electrodynamics</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option value='Quantum Chromodynamics'>QCD</option>"+NL+
+ "</select>",
+
+ getUser()
+ );
+ //value attr, and selected
+ testModelObject(
+ "<select name='FavoriteTheory'>"+NL+
+ " <option value='Quantum Electrodynamics'>QED</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option value='Quantum Chromodynamics'>QCD</option>"+NL+
+ "</select>",
+
+ "<select name='FavoriteTheory'>"+NL+
+ " <option selected value='Quantum Electrodynamics'>QED</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option value='Quantum Chromodynamics'>QCD</option>"+NL+
+ "</select>",
+
+ getUser()
+ );
+ //retain default selected
+ testModelObject(
+ "<select name='FavoriteTheory'>"+NL+
+ " <option selected value='Quantum Electrodynamics'>QED</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select name='FavoriteTheory'>"+NL+
+ " <option selected value='Quantum Electrodynamics'>QED</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ getUser()
+ );
+ //remove default selected
+ testModelObject(
+ "<select name='FavoriteTheory'>"+NL+
+ " <option value='Quantum Electrodynamics'>QED</option>"+NL+
+ " <option selected>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select name='FavoriteTheory'>"+NL+
+ " <option selected value='Quantum Electrodynamics'>QED</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ 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(
+ "<select multiple name='FavoriteTheory'>"+NL+
+ " <option>Quantum Electrodynamics</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select multiple name='FavoriteTheory'>"+NL+
+ " <option selected>Quantum Electrodynamics</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ 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(
+ "<select multiple name='FavoriteTheory'>"+NL+
+ " <option>Quantum Electrodynamics</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select multiple name='FavoriteTheory'>"+NL+
+ " <option selected>Quantum Electrodynamics</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option selected>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ this
+ );
+ //defaults retained and removed
+ testModelObject(
+ "<select multiple name='FavoriteTheory'>"+NL+
+ " <option selected>Quantum Electrodynamics</option>"+NL+
+ " <option selected>Quantum Flavordynamics</option>"+NL+
+ " <option selected>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ "<select multiple name='FavoriteTheory'>"+NL+
+ " <option selected>Quantum Electrodynamics</option>"+NL+
+ " <option>Quantum Flavordynamics</option>"+NL+
+ " <option selected>Quantum Chromodynamics</option>"+NL+
+ "</select>",
+
+ 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("<textarea name='Comment'> This is a comment. </textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+ //add other attr's
+ testSingleParam("<textarea name='Comment' wrap='virtual' rows=20 cols=50> This is a comment. </textarea>", "<textarea name='Comment' wrap='virtual' rows=20 cols=50>The Clash</textarea>", "Comment", "The Clash");
+ //caps, spacing, quotes
+ testSingleParam("<TEXTAREA NAME='Comment'> This is a comment. </TEXTAREA>", "<TEXTAREA NAME='Comment'>The Clash</TEXTAREA>", "Comment", "The Clash");
+ testSingleParam("<textarea NAME='Comment'> This is a comment. </TEXTAREA>", "<textarea NAME='Comment'>The Clash</TEXTAREA>", "Comment", "The Clash");
+ //without default text
+ testSingleParam("<textarea name='Comment'> </textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+ testSingleParam("<textarea name='Comment'></textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+ testSingleParam("<textarea name='Comment'> </textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+ //with new lines and white space
+ testSingleParam("<textarea name='Comment'>" +Consts.NEW_LINE+ " This is a comment. " +Consts.NEW_LINE+ " </textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+ testSingleParam("<textarea name='Comment'>" +Consts.NEW_LINE+ "</textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+ testSingleParam("<textarea name='Comment'> " +Consts.NEW_LINE+ "</textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+ testSingleParam("<textarea name='Comment'> " +Consts.NEW_LINE+ " </textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+ testSingleParam("<textarea name='Comment'> " +Consts.NEW_LINE+ " </textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+ //special regex chars in default body
+ testSingleParam("<textarea name='Comment'> $This is a comment. </textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+ testSingleParam("<textarea name='Comment'> This is a comment.$ </textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+ testSingleParam("<textarea name='Comment'> $This is a comment.$ </textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+ testSingleParam("<textarea name='Comment'> Thi$s is a comment. </textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+ testSingleParam("<textarea name='Comment'> \\This is a comment. </textarea>", "<textarea name='Comment'>The Clash</textarea>", "Comment", "The Clash");
+ //special regex chars in replacememt
+ testSingleParam("<textarea name='Comment'>This is a comment. </textarea>", "<textarea name='Comment'>The Cla$h</textarea>", "Comment", "The Cla$h");
+ testSingleParam("<textarea name='Comment'>This is a comment. </textarea>", "<textarea name='Comment'>$The Clash$</textarea>", "Comment", "$The Clash$");
+ testSingleParam("<textarea name='Comment'>This is a comment. </textarea>", "<textarea name='Comment'>\The Clash</textarea>", "Comment", "\\The Clash");
+ }
+
+ public void testBeanWithTextAreaTag() throws JspException, IOException, ModelCtorException {
+ User user = getUser();
+ //with default text
+ testModelObject("<textarea name='LoginName'>This is a comment</textarea>", "<textarea name='LoginName'>Strummer</textarea>", user);
+ //with other attr's
+ testModelObject("<textarea name='LoginName' cols='20' rows='35'>This is a comment</textarea>", "<textarea name='LoginName' cols='20' rows='35'>Strummer</textarea>", user);
+ //caps, spacing, quotes
+ testModelObject("<TEXTAREA NAME='LoginName'>This is a comment</TEXTAREA>", "<TEXTAREA NAME='LoginName'>Strummer</TEXTAREA>", user);
+ testModelObject("<textarea NAME='LoginName'>This is a comment</TEXTAREA>", "<textarea NAME='LoginName'>Strummer</TEXTAREA>", user);
+ //without default text
+ testModelObject("<textarea name='LoginName'></textarea>", "<textarea name='LoginName'>Strummer</textarea>", user);
+ //with new lines and white space
+ testModelObject("<textarea name='LoginName'>" +Consts.NEW_LINE+ "</textarea>", "<textarea name='LoginName'>Strummer</textarea>", user);
+ testModelObject("<textarea name='LoginName'> " +Consts.NEW_LINE+ "This is a comment" + Consts.NEW_LINE + " </textarea>", "<textarea name='LoginName'>Strummer</textarea>", 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<String> getReqParamValues(String aParamName){
+ Collection<String> result = new ArrayList<String>();
+ 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<String> 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<String> 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 <tt>3..30</tt>.
+ @param aLoginPassword has length in range <tt>3..20</tt>, and contains
+ no whitespace.
+ @param aEmailAddress satisfies {@link WebUtil#isValidEmailAddress}
+ @param aStarRating rating of last meal in range <tt>1..4</tt>;
+ optional - if null, replace with value of <tt>0</tt>.
+ @param aFavoriteTheory favorite quantum field theory ;
+ optional - may be <tt>null</tt>.
+ @param aSendCard toggle for sending Christmas card to user ;
+ optional - if <tt>null</tt>, replace with <tt>false</tt>.
+ @param aAge of the user, in range <tt>0..150</tt> ;
+ optional - may be <tt>null</tt>.
+ @param aDesiredSalary is <tt>0</tt> or more ; optional - may be <tt>null</tt>.
+ @param aBirthDate is not in the future ; optional - may be <tt>null</tt>.
+ */
+ 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;
+ }
+ }
+}
--- /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, "<a href='ShowHomePage.do'>Home</a>", "Home");
+ testHighlight(highlight, "<a href='ShowHomePage.do'>Home</a>", "Home");
+ testHighlight(highlight, "<a href='ShowHomePage.do' title='Fred'>Home</a>", "Home");
+ testHighlight(highlight, "<a href='ShowHomePage.do' >Home</a>", "Home");
+ testHighlight(highlight, " <a href='ShowHomePage.do'>Home</a>", " Home");
+ testHighlight(highlight, "<a href='ShowHomePage.do'>Home</a> ", "Home ");
+ testHighlight(highlight, "<a href='ShowHomePage.do'>Home </a>", "Home ");
+ testHighlight(highlight, "<a href='ShowHomePage.do'> Home </a>", " Home ");
+ //Multiple links
+ testHighlight(highlight, "<a href='ShowHomePage.do'> Home </a> <a href='ShowContacts.do'>Contacts</a>", " Home <a href='ShowContacts.do'>Contacts</a>");
+
+ testNoHighlight(highlight, "<a href='ShowContactPage.do'>Home</a>");
+ testNoHighlight(highlight, "<a href='ShowHomePage.do?X=Y'>Home</a>");
+ testNoHighlight(highlight, "<a href='ShowHomePage.do?X=Y'>Home</a>");
+ testNoHighlight(highlight, "<a href='ShowHomePage.do?X=Y'>Home</a>");
+ //Nuisance restriction - HREF must come first :
+ testNoHighlight(highlight, "<a title='Fred' href='ShowHomePage.do'>Home</a>");
+ //No highlight if session id present in one, but not in the other
+ testNoHighlight(highlight, "<a href='ShowHomePage.do;jsessionid=321651651313'>Home</a>");
+ //Multiple links
+ testNoHighlight(highlight, " <a href='ShowContacts.do'>Contacts</a> <a href='ShowThis.do'>Contacts</a> ");
+ }
+
+ public void testHrefWithHighlight(){
+ HighlightCurrentPage highlight = new HighlightCurrentPage();
+ highlight.testSetCurrentURI("http://www.blah.com/ShowHomePage.do");
+ highlight.setStyleClass("highlight");
+
+ testHighlight(highlight, "<a href='ShowHomePage.do'>Home</a>", "<span class=\"highlight\">Home</span>");
+ testHighlight(highlight, "<a href='ShowHomePage.do'>Home</a>", "<span class=\"highlight\">Home</span>");
+ testHighlight(highlight, "<a href='ShowHomePage.do' title='Fred'>Home</a>", "<span class=\"highlight\">Home</span>");
+ testHighlight(highlight, "<a href='ShowHomePage.do' >Home</a>", "<span class=\"highlight\">Home</span>");
+ testHighlight(highlight, " <a href='ShowHomePage.do'>Home</a>", " <span class=\"highlight\">Home</span>");
+ testHighlight(highlight, "<a href='ShowHomePage.do'>Home</a> ", "<span class=\"highlight\">Home</span> ");
+ testHighlight(highlight, "<a href='ShowHomePage.do'>Home </a>", "<span class=\"highlight\">Home </span>");
+ testHighlight(highlight, "<a href='ShowHomePage.do'> Home </a>", "<span class=\"highlight\"> Home </span>");
+
+ testNoHighlight(highlight, "<a href='ShowContactPage.do'>Home</a>");
+ testNoHighlight(highlight, "<a href='ShowHomePage.do?X=Y'>Home</a>");
+ testNoHighlight(highlight, "<a href='ShowHomePage.do?X=Y'>Home</a>");
+ testNoHighlight(highlight, "<a href='ShowHomePage.do?X=Y'>Home</a>");
+ testNoHighlight(highlight, "<a title='Fred' href='ShowHomePage.do'>Home</a>");
+ testNoHighlight(highlight, "<a href='ShowHomePage.do;jsessionid=321651651313'>Home</a>");
+ }
+
+ public void testTitleNoHighlight(){
+ HighlightCurrentPage highlight = new HighlightCurrentPage();
+ highlight.setUseTitle(Boolean.TRUE);
+ highlight.testSetTitleParam("Home");
+
+ testHighlight(highlight, "<a href='ShowHomePage.do'>Home</a>", "Home");
+ testHighlight(highlight, "<a href='ShowHomePage.do'>Home</a>", "Home");
+ testHighlight(highlight, "<a href='ShowHomePage.do' title='Fred'>Home</a>", "Home");
+ testHighlight(highlight, "<a href='ShowHomePage.do' >Home</a>", "Home");
+ testHighlight(highlight, " <a href='ShowHomePage.do'>Home</a>", " Home");
+ testHighlight(highlight, "<a href='ShowHomePage.do'>Home</a> ", "Home ");
+ //Will trim the link body before testing for match
+ testHighlight(highlight, "<a href='ShowHomePage.do'>Home </a>", "Home ");
+ testHighlight(highlight, "<a href='ShowHomePage.do'> Home </a>", " Home ");
+ //HREF target not relevant here - will match any HREF
+ testHighlight(highlight, "<a href='ShowContact.do'>Home</a>", "Home");
+
+ testNoHighlight(highlight, "<a href='ShowContactPage.do'>My Home</a>");
+ testNoHighlight(highlight, "<a href='ShowContactPage.do'>Acceuil</a>");
+ }
+
+ public void testTitleWithHighlight(){
+ HighlightCurrentPage highlight = new HighlightCurrentPage();
+ highlight.setUseTitle(Boolean.TRUE);
+ highlight.setStyleClass("highlight");
+ highlight.testSetTitleParam("Home");
+
+ testHighlight(highlight, "<a href='ShowHomePage.do'>Home</a>", "<span class=\"highlight\">Home</span>");
+ testHighlight(highlight, "<a href='ShowHomePage.do'>Home</a>", "<span class=\"highlight\">Home</span>");
+ testHighlight(highlight, "<a href='ShowHomePage.do' title='Fred'>Home</a>", "<span class=\"highlight\">Home</span>");
+ testHighlight(highlight, "<a href='ShowHomePage.do' >Home</a>", "<span class=\"highlight\">Home</span>");
+ testHighlight(highlight, " <a href='ShowHomePage.do'>Home</a>", " <span class=\"highlight\">Home</span>");
+ testHighlight(highlight, "<a href='ShowHomePage.do'>Home</a> ", "<span class=\"highlight\">Home</span> ");
+ testHighlight(highlight, "<a href='ShowHomePage.do'>Home </a>", "<span class=\"highlight\">Home </span>");
+ testHighlight(highlight, "<a href='ShowHomePage.do'> Home </a>", "<span class=\"highlight\"> Home </span>");
+
+ testNoHighlight(highlight, "<a href='ShowContactPage.do'>My Home</a>");
+ testNoHighlight(highlight, "<a href='ShowContactPage.do'>Acceuil</a>");
+ }
+
+ 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, "<a href='ShowHomePage.do?X=Y&A=B'>Home</a>", "Home");
+
+ //Ampersand is NOT escaped correctly in HREF
+ testNoHighlight(highlight, "<a href='ShowHomePage.do?X=Y&A=B'>Home</a>");
+ //Missing parameters altogether :
+ testNoHighlight(highlight, "<a href='ShowHomePage.do?X=Y'>Home</a>");
+ }
+
+ // 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);
+ }
+ }
+}
--- /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.
+
+ <P>The custom tag can optionally have a body. The <tt>.tld</tt> entry for these tags must
+ have their <tt>body-content</tt> set to <tt>scriptless</tt>.
+
+ <P>Concrete subclasses of this class perform these tasks :
+<ul>
+ <li>implement <tt>setXXX</tt> methods, one for each tag attribute;
+ each <tt>setXXX</tt> should validate its argument.
+ <li>optionally override the {@link #crossCheckAttributes} method, to
+ perform validations depending on more than one attribute.
+ <li>implement {@link #getEmittedText}, to return the text to be included in markup.
+</ul>
+*/
+public abstract class TagHelper extends SimpleTagSupport {
+
+ /**
+ <b>Template</b> method which calls {@link #getEmittedText(String)}.
+
+ <P>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 <tt>null</tt>.
+ @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.
+
+ <P>This default implementation does nothing.
+
+ <P>Validations that apply to a single attribute should be performed in its
+ corresponding <tt>setXXX</tt> method.
+
+ <P>If a problem is detected, subclasses must emit a <tt>RuntimeException</tt>
+ 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.
+ <P>Intended for debugging only.
+ */
+ protected final String getPageName(){
+ Servlet servlet = (Servlet)getPageContext().getPage();
+ return servlet.getClass().getName();
+ }
+
+ /**
+ Verify that an attribute value has content.
+
+ <P>If no content, then log at <tt>SEVERE</tt> 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.
+
+ <P>The body of this tag cannot contain scriptlets or scriptlet expressions.
+ If this tag has no body, or has an empty body, then <tt>null</tt> 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;
+ }
+}
--- /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 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+ <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+ <meta name="Author" content="Hirondelle Systems">
+ <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+ <title>Custom Tags</title>
+</head>
+<body>
+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.
+</body>
+</html>
--- /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}.
+
+ <P>Example use case :
+ <PRE>
+ <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>
+ </PRE>
+
+ <P>The body of this tag is a simple template for emitting each element of the named
+ {@link MessageList}. The special <tt>placeholder</tt> 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).
+
+ <P>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}.
+
+ <P>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<AppResponseMessage> 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);
+ }
+}
--- /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 <a href='http" + COLON + FSLASH + FSLASH + "www" + PERIOD + "blah" + PERIOD + "com'>blah</a>");
+ testOutput("This is a [link:http://www.blah.com blah1 blah2]", "This is a <a href='http" + COLON + FSLASH + FSLASH + "www" + PERIOD + "blah" + PERIOD + "com'>blah1 blah2</a>");
+ testOutput("This is a [link:http://www.blah.com blah1 (blah2)]", "This is a <a href='http" + COLON + FSLASH + FSLASH + "www" + PERIOD + "blah" + PERIOD + "com'>blah1 " + POPEN + "blah2" + PCLOSE + "</a>");
+ testOutput("This is a [link:http://www.blah.com/this_thing blah]", "This is a <a href='http" + COLON + FSLASH + FSLASH + "www" + PERIOD + "blah" + PERIOD + "com" + FSLASH + "this" + UNDERLINE + "thing'>blah</a>");
+ testOutput("This is a [link:http://www.blah.com/this_thing_here blah]", "This is a <a href='http" + COLON + FSLASH + FSLASH + "www" + PERIOD + "blah" + PERIOD + "com" + FSLASH + "this" + UNDERLINE + "thing" + UNDERLINE + "here'>blah</a>");
+ }
+
+ public void testBold() {
+ testOutput("This is a test *bold* item", "This is a test <b>bold</b> item");
+ testOutput("*This* is a test", "<b>This</b> is a test");
+ testOutput("This is a *test*", "This is a <b>test</b>");
+ testOutput("This is a *test*.", "This is a <b>test</b>" + PERIOD);
+ testOutput("This is a *test*. And", "This is a <b>test</b>" + PERIOD + " And");
+ testOutput("This is a *test* here", "This is a <b>test</b> here");
+ testOutput("This is a * test* here", "This is a <b> test</b> here");
+ testOutput("This is a *test * here", "This is a <b>test </b> here");
+ testOutput("This is a * test * here", "This is a <b> test </b> here");
+ testOutput("This is a * test * here", "This is a <b> test </b> here");
+ testOutput("This is a *test of this* here", "This is a <b>test of this</b> here");
+ testOutput("This is a * test of this * here", "This is a <b> test of this </b> here");
+ testOutput("This is a * test of this * here", "This is a <b> test of this </b> here");
+ testOutput("This is a *test* of *reluctant* here", "This is a <b>test</b> of <b>reluctant</b> here");
+ testOutput("This is a *test of* the *reluctant aspect* here", "This is a <b>test of</b> the <b>reluctant aspect</b> here");
+ }
+
+ public void testItalic() {
+ testOutput("This is a test _italic_ item", "This is a test <em>italic</em> item");
+ testOutput("_This_ is a test", "<em>This</em> is a test");
+ testOutput("This is a _test_", "This is a <em>test</em>");
+ testOutput("This is a _test_.", "This is a <em>test</em>" + PERIOD);
+ testOutput("This is a _test_. And", "This is a <em>test</em>" + PERIOD + " And");
+ testOutput("This is a _test_ here", "This is a <em>test</em> here");
+ testOutput("This is a _ test_ here", "This is a <em> test</em> here");
+ testOutput("This is a _test _ here", "This is a <em>test </em> here");
+ testOutput("This is a _ test _ here", "This is a <em> test </em> here");
+ testOutput("This is a _ test _ here", "This is a <em> test </em> here");
+ testOutput("This is a _test of this_ here", "This is a <em>test of this</em> here");
+ testOutput("This is a _ test of this _ here", "This is a <em> test of this </em> here");
+ testOutput("This is a _ test of this _ here", "This is a <em> test of this </em> here");
+ testOutput("This is a _test_ of _reluctant_ here", "This is a <em>test</em> of <em>reluctant</em> here");
+ testOutput("This is a _test of_ the _reluctant aspect_ here", "This is a <em>test of</em> the <em>reluctant aspect</em> 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 +"'");
+ }
+ }
+}
--- /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) + "\"");
+ }
+ }
+ }
+
+
+}
--- /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 <input title='Yes' name='bogus'> text flow" );
+ testSuccessfulMatches("text flow <input title=Yes name='bogus'> text flow" );
+ testSuccessfulMatches("text flow <input title=\"Yes\" name='bogus'> text flow" );
+ testSuccessfulMatches("text flow <input title='Yes' name='bogus'> text flow" );
+ testSuccessfulMatches("text flow <input title='Yes' name='bogus'> text flow" );
+ testSuccessfulMatches("<input title='Yes' name='bogus'> text flow" );
+ testSuccessfulMatches("<input title='Yes' name='bogus'>" );
+ testSuccessfulMatches("text flow <img alt='Yes' name='bogus'> text flow" );
+ testSuccessfulMatches("text flow <img alt=\"Yes\" name='bogus'> text flow" );
+ testSuccessfulMatches("text flow <img alt=Yes name='bogus'> text flow" );
+
+ //testSuccessfulMatches("text flow <img alt=Yes name='bogus'> text flow <form title='Yes'>" );
+ }
+
+ public void testSuccessSubmitButton(){
+ testSuccessfulMatchesSubmitButton("<input type=\"submit\" value=\"add.button\">");
+ testSuccessfulMatchesSubmitButton("<input type=\"submit\" value='add.button'>");
+ testSuccessfulMatchesSubmitButton("<input type=\"submit\" value=add.button>");
+ testSuccessfulMatchesSubmitButton("<input type=\"submit\" value=\"add.button\" >");
+ testSuccessfulMatchesSubmitButton("<input type=\"submit\" value=\"add.button\" >");
+ testSuccessfulMatchesSubmitButton("<input type=\"SUBMIT\" value=\"add.button\">");
+ testSuccessfulMatchesSubmitButton("<input type=\"submit\" VALUE=\"add.button\">");
+ testSuccessfulMatchesSubmitButton("<INPUT TYPE=\"SUBMIT\" VALUE=\"add.button\">");
+ testSuccessfulMatchesSubmitButton("text flow <input type=\"submit\" value=\"add.button\">");
+ testSuccessfulMatchesSubmitButton("<input type=\"submit\" value=\"add.button\"> text flow");
+ testSuccessfulMatchesSubmitButton("text flow <input type=\"submit\" value=\"add.button\"> text flow");
+
+ testSuccessfulMatchesSubmitButton("<input type=\"submit\" value=\"add.button\">");
+ testSuccessfulMatchesSubmitButton("<input type=\"submit\" value=\"add.button\">");
+ testSuccessfulMatchesSubmitButton("<input type=\"submit\" value=\"add.button\">");
+ testSuccessfulMatchesSubmitButton("<input type=\"submit\" value=\"add.button\">");
+ testSuccessfulMatchesSubmitButton("<input type=\"submit\" value=\"add.button\" >");
+
+ testSuccessfulMatchesSubmitButton("<input type='submit' value=\"add.button\">");
+ testSuccessfulMatchesSubmitButton("<input type=submit value=\"add.button\">");
+ testSuccessfulMatchesSubmitButton("<input type='submit' value=\"add.button\">");
+ testSuccessfulMatchesSubmitButton("<input type='submit' value=\"add.button\" title='xyz'>");
+
+ }
+
+ public void testFailureSubmitButton(){
+ testNoMatchesSubmitButton("< input type=\"submit\" value='add.button'>");
+ testNoMatchesSubmitButton("< input type=\"submit\" value='add.button'>");
+ testNoMatchesSubmitButton("<input value='add.button' type=\"submit\" >"); //swap order fails
+ testNoMatchesSubmitButton("<input type=\"checkbox\" value='add.button'>");
+ testNoMatchesSubmitButton("<input type=\"password\" value='add.button'>");
+ testNoMatchesSubmitButton("<select type=\"submit\" value='add.button'>");
+ testNoMatchesSubmitButton("<input type=\"submit\" >");
+ testNoMatchesSubmitButton("<input value='add.button'>");
+ }
+
+ // 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);
+ }
+ }
+
+}
--- /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.
+
+ <P>This tag uses {@link hirondelle.web4j.ui.translate.Translator} and
+ {@link hirondelle.web4j.request.LocaleSource} to localize text.
+
+ <P>There are several use cases for this tag. In general, the attributes control:
+ <ul>
+ <li><a href="#BaseText">specifying base text</a> to be translated
+ <li><a href="#WikiStyleFormatting">formatting</a> of the translated result
+ <li><a href="#TurningOffTranslation">turning off translation</a> altogether
+ </ul>
+
+ <P><b><a name="BaseText">Specifying Base Text</a></b>
+ <P>The base text may be specified simply as the tag body, or as the <tt>value</tt> attribute.
+ <P>Example 1 : <br>
+ <PRE>
+ <w:txt>
+ Of prayers, I am the prayer of silence.
+ Of things that move not, I am the Himalayas.
+ </w:txt>
+ </PRE>
+
+ Example 2 uses a "coder key" : <br>
+ <PRE>
+ <w:txt value="quotation.from.bhagavad.gita" />
+ </PRE>
+
+ <P>This second form is intended especially for translating items appearing
+ inside a tag.
+
+ <P>These two use cases are mutually exclusive : either a body must be specified, or
+ the <tt>value</tt> attribute must be specified, <em>but not both</em>.
+
+ <P>Here is another example, combining the two styles :
+ <PRE>
+ <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>
+ </PRE>
+
+ <P>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}).
+
+ <P><a name="WikiStyleFormatting"><b>Formatting of the Result</b>
+ <P>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 :
+<ul>
+ <li> *bold* for <b>bold</b> (needs intial space before first '*')
+ <li> _italic_ for <em>italic</em> (needs initial space before first '_')
+ <li>^preserve formatting^ for preserving whitespace (<PRE>)
+ <li>~~~~ on an otherwise blank line for a horizontal rule
+ <li>[link:http:\\www.javapractices.com\Topic1.cjp enumerations] produces this link: <a href="http:\\www.javapractices.com\Topic1.cjp">enumerations</a>
+ <li>one or more empty lines for a paragraph (<P>)
+ <li>a bar | at the end of a line for a line break (<BR>)
+ <li>a line starting with ' ~ ' for a bullet entry in a list
+</ul>
+
+ <P>Example 3 has wiki style formatting:
+ <PRE>
+ <w:txt wikiMarkup="true">
+ Default Locale |
+ _Not all Locales are treated equally_ : there *must* be ...
+ </w:txt>
+ </PRE>
+
+ Is rendered as :
+<P>Default Locale<br>
+<em>Not all Locales are treated equally</em> : there <b>must</b> be ...
+
+ <P>To allow the above rules to be interpreted as HTML by this tag, {@link #setWikiMarkup(boolean)} to <tt>true</tt>.
+
+ <P><b><a name="TurningOffTranslation">Turning Off Translation</a></b>
+ <P>There are two cases in which it is useful to turn off translation altogether:
+<ul>
+ <li>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.
+ <li>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 <tt>TEXT</tt> field to be
+ assigned a <tt>UNIQUE</tt> 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.
+</ul>
+
+ <P>Example 4 has wiki style formatting for untranslated user input:
+ <PRE>
+ <w:txt wikiMarkup="true" translate="false">
+ ...render some user input with wiki style formatting...
+ </w:txt>
+ </PRE>
+
+ <P>Example 5 has wiki style formatting for <em>large</em> untranslated text, hard-coded in the JSP.
+ An example of such text may be an extended section of "help" information :
+ <PRE>
+ <w:txt locale="en">
+ ..<em>large</em> amount of hard-coded text in English...
+ </w:txt>
+
+ <w:txt locale="fr">
+ ..<em>large</em> amount of hard-coded text in French...
+ </w:txt>
+ </PRE>
+
+ <em>The above style is outside the usual translation mechanism.</em> 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 <tt>locale</tt> matches that returned by the {@link hirondelle.web4j.request.LocaleSource}.
+ <span class="highlight">It is recommended that this style be used only when the above-mentioned problem regarding
+ database text size exists.</span> This style implicitly has <tt>translate="false"</tt>.
+*/
+public final class Text extends TagHelper {
+
+ /**
+ Set the item to be translated (optional).
+
+ <P><tt>aTextAsAttr</tt> 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.
+
+ <P>If this attribute is set, then the tag must <em>not</em> 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 <tt>true</tt>).
+
+ <P>By default, text will be translated. An example of setting this item to <tt>false</tt> is a
+ discussion board, where users input in a single language, and
+ <a href="#WikiStyleFormatting">wiki style formatting</a> is desired.
+
+ <P>An example of rendering such text is :
+ <PRE>
+ <w:txt translate="false" wikiMarkup="true">
+ This is *bold*, and so on...
+ </w:txt>
+ </PRE>
+ */
+ public void setTranslate(boolean aValue) {
+ fIsTranslating = aValue;
+ }
+
+ /**
+ Specify an explicit {@link Locale} (optional).
+
+ <P><span class='highlight'>When this attribute is specified, this tag will never translate its content.</span>
+ Instead, this tag will simply <em>emit or suppress</em> its content, according to whether <tt>aLocale</tt> matches
+ that returned by {@link hirondelle.web4j.request.LocaleSource}.
+ */
+ public void setLocale(String aLocale){
+ fSpecificLocale = Util.buildLocale(aLocale);
+ fIsTranslating = false;
+ }
+
+ /**
+ Allow <a href="#WikiStyleFormatting">wiki style formatting</a> to be used (optional, default <tt>false</tt>).
+ */
+ 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(" <b>$1</b>"); //extra space
+ }
+
+ private String addBold2(String aText){
+ Matcher matcher = PSEUDO_BOLD_START_OF_INPUT.matcher(aText);
+ return matcher.replaceAll("<b>$1</b>"); //extra space
+ }
+
+ private String addItalic(String aText){
+ Matcher matcher = PSEUDO_ITALIC.matcher(aText);
+ return matcher.replaceAll(" <em>$1</em>"); //extra space
+ }
+
+ private String addItalic2(String aText){
+ Matcher matcher = PSEUDO_ITALIC_START_OF_INPUT.matcher(aText);
+ return matcher.replaceAll("<em>$1</em>"); //extra space
+ }
+
+ private String addCode(String aText){
+ Matcher matcher = PSEUDO_CODE.matcher(aText);
+ return matcher.replaceAll("<PRE>$1</PRE>");
+ }
+
+ private String addHorizontalRule(String aText){
+ Matcher matcher = PSEUDO_HR.matcher(aText);
+ return matcher.replaceAll("<hr>");
+ }
+
+ private String addLink(String aText){
+ Matcher matcher = PSEUDO_LINK.matcher(aText);
+ return matcher.replaceAll("<a href='$1'>$3</a>");
+ }
+
+ private String addParagraph(String aText){
+ Matcher matcher = PSEUDO_PARAGRAPH.matcher(aText);
+ return removeInitialAndFinalParagraphs(matcher.replaceAll("<P>"));
+ }
+
+ /** Bit hacky - cannot find correct regex to take care of this cleanly. */
+ private String removeInitialAndFinalParagraphs(String aText){
+ String result = aText.trim();
+ if (aText.startsWith("<P>")){
+ result = result.substring(3);
+ }
+ if (aText.endsWith("<P>")){
+ result = result.substring(0, result.length()-3);
+ }
+ return result;
+ }
+
+ private String addLineBreak(String aText){
+ Matcher matcher = PSEUDO_LINE_BREAK.matcher(aText);
+ return matcher.replaceAll("<BR>");
+ }
+
+ private String addList(String aText){
+ Matcher matcher = PSEUDO_LIST.matcher(aText);
+ return matcher.replaceAll("<br> • "); //another version of bull has a non-standard number
+ }
+}
--- /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.
+
+ <P><span class="highlight">This tag treats every piece of free flow text delimited by a
+ tag as a unit of translatable base text</span>, and passes it to {@link Translator}.
+ That is, all tags are treated as <em>delimiters</em> of units of translatable text.
+
+ <P>This tag is suitable for translating most, but not all, of the
+ regular text flow in a web page. <span class="highlight">It is suitable for translating
+ markup that contains short, isolated snippets of text, that have no "structure", and
+ no dynamic data</span>, 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 <tt><w:txt></tt> {@link Text} tags to translate each item one by one,
+ a single <tt><w:txtFlow></tt> tag can often be used to do the same thing in a single step.
+
+ <P><span class="highlight">Using this class has two strong advantages</span> :
+<ul>
+ <li>the effort needed to internationalize a page is greatly reduced
+ <li>the markup will be significantly easier to read and maintain, since most of the free flow text
+ remains unchanged from the single-language case
+</ul>
+
+ <P>
+ This tag is <em>not suitable</em> when the base text to be translated :
+<ul>
+ <li>contains markup
+ <li>has dynamic data of any sort
+ <li>contains a <tt>TEXTAREA</tt> with a <em>non-empty</em> 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 <em>inside</em> the <tt><w:populate></tt> tag surrounding the
+ form that contains the <tt>TEXTAREA</tt>. This ensures that the population is not affected by the action of this
+ tag.)
+</ul>
+
+ <P>For example, given this text containing markup :
+ <PRE>The <EM>raison-d'etre</EM> for this...</PRE>
+ then this tag will split the text into three separate pieces, delimited by the <tt>EM</tt> tags.
+ Then, each piece will be translated. For such items, this is almost always undesirable. Instead,
+ one must use a <tt><w:txt></tt> {@link Text} tag, which can treat such items as
+ a single unit of translatable text, without chopping it up into three pieces.
+
+ <P><b>Example</b><br>
+ Here, all of the <tt>LABEL</tt> tags in this form will have their content translated by the
+ <tt><w:txtFlow></tt> tag :
+ <PRE>
+<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>
+</PRE>
+*/
+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 <tt>false</tt>.
+
+ <P><span class="highlight">Exercise care that text is not doubly escaped.</span>
+ For instance, if the text already contains
+ character entities, and <tt>setEscapeChars</tt> is true, then the text <tt>&amp;</tt>
+ will be emitted by this tag as <tt>&amp;amp;</tt>, for example.
+ */
+ public void setEscapeChars(boolean aValue){
+ fEscapeChars = aValue;
+ }
+
+ /**
+ Translate each piece of free flow text appearing in <tt>aOriginalBody</tt>.
+
+ <P>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);
+ }
+}
--- /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
+ <a href="http://www.w3.org/TR/html4/struct/global.html#adef-title"><tt>TITLE</tt></a>,
+ <a href="http://www.w3.org/TR/html4/struct/objects.html#adef-alt"><tt>ALT</tt></a>
+ and submit-button
+ <a href="http://www.w3.org/TR/html4/interact/forms.html#adef-value-INPUT"><tt>VALUE</tt></a>
+ attributes in markup.
+
+ <P><span class="highlight">By using this custom tag <em>once</em> in a template JSP,
+ it is often possible to translate <em>all</em> of the <tt>TITLE</tt>,
+ <tt>ALT</tt>, and submit-button <tt>VALUE</tt> attributes appearing in an entire application.</span>
+
+ <P>The <tt>VALUE</tt> attribute is translated only for <tt>SUBMIT</tt> controls. (This
+ <tt>VALUE</tt> 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.)
+
+ <P>The <tt>TITLE</tt> attribute applies to a large number of HTML tags, and
+ the <tt>ALT</tt> 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.
+
+ <P>This custom tag accepts HTML markup for its body, and will do a search and
+ replace on its content, replacing the values of all <tt>TITLE</tt>, <tt>ALT</tt> and
+ submit-button <tt>VALUE</tt> 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.
+
+ <P>By default, this tag will escape any special characters appearing in the
+ <tt>TITLE</tt> or <tt>ALT</tt> attribute, using {@link EscapeChars#forHTML(String)}.
+ To override this default behaviour, set this value to <tt>false</tt>.
+ */
+ public void setEscapeChars(boolean aValue){
+ fEscapeChars = aValue;
+ }
+
+ /**
+ Scan the body of this tag, and translate the values of all <tt>TITLE</tt>, <tt>ALT</tt>,
+ and submit-button <tt>VALUE</tt> attributes.
+
+ <P>Uses the configured {@link Translator} and {@link LocaleSource}.
+
+ <P>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 <tt>TITLE</tt> and <tt>ALT</tt> 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 <tt>VALUE</tt> attribute (including any quotes) of a <tt>SUBMIT</tt>
+ control, as group 2, which is to be translated.
+
+ <P>Small nuisance restriction : the general order of items must follow this style, where <tt>type</tt>
+ and <tt>value</tt> precede all other attributes, and <tt>type</tt> precedes <tt>value</tt>:
+ <PRE>
+ <input type="submit" value="Add" [any other attributes go here]>
+ </PRE>
+ */
+ static final Pattern SUBMIT_BUTTON = Pattern.compile(
+ "(<input(?:\\s)* (?:type=\"submit\"|type='submit'|type=submit)(?:\\s)* value=)" + Regex.ATTR_VALUE + "([^>]*>)",
+ 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;
+ }
+}
--- /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.
+
+ <P>This class is provided as a convenience. Implementations of {@link Translator} are not required to
+ use this class.
+
+ <P>As one of its {@link hirondelle.web4j.StartupTasks}, a typical implementation of
+ {@link Translator} may fetch a {@code List<Translation>} from some source
+ (usually a database, perhaps some properties files), and keep a cache in memory.
+
+ <P><a name="MapStructure"></a>
+ For looking up translations, the following nested {@link Map} structure is useful :
+ <PRE>
+ Map[BaseText, Map[Locale, Translation]]
+ </PRE>
+ Here, <tt>BaseText</tt> and <tt>Translation</tt> are ordinary <em>unescaped</em>
+ Strings, not {@link SafeText}. This is because the various translation tags in this
+ package always <em>first</em> perform translation using ordinary unescaped Strings, and
+ <em>then</em> perform any necessary escaping on the result of the translation.
+
+ <P>(See {@link Translator} for definition of 'base text'.)
+
+ <P>The {@link #asNestedMap(Collection)} method will modify a {@code List<Translation>} into just such a
+ structure. As well, {@link #lookUp(String, Locale, Map)} provides a simple <em>default</em> method for
+ performing the typical lookup with such a structure, given base text and target locale.
+
+ <h3>Usually String, but sometimes SafeText</h3>
+ The following style will remain consistent, and will not escape special characters twice :
+<ul>
+ <li>unescaped : translations stored in the database.
+ <li>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.
+ <li>unescaped : in-memory data, extracted from N <tt>Translation</tt> objects
+ using {@link SafeText#getRawString()}. This in-memory data implements a
+ <tt>Translator</tt>. Its data is not rendered <em>directly</em>
+ in a JSP, so it can remain as String.
+ <li>escaped : the various translation tags always perform the needed escaping on the raw String.
+</ul>
+
+ 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<Translation> {
+
+ /**
+ 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.
+
+ <P>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 <tt>BaseText</tt> item, <tt>1..50</tt> characters (optional)
+ @param aLocaleId foreign key representing a <tt>Locale</tt>, <tt>1..50</tt> 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 <a href="#MapStructure">structure</a>
+ typically needed for looking up translations.
+
+ <P>The caller will use the returned {@link Map} to look up first using <tt>BaseText</tt>,
+ and then using <tt>Locale</tt>. See {@link #lookUp(String, Locale, Map)}.
+
+ @param aTranslations {@link Collection} of {@link Translation} objects.
+ @return {@link Map} of a <a href="#MapStructure">structure suitable for looking up translations</a>.
+ */
+ public static Map<String, Map<String, String>> asNestedMap(Collection<Translation> aTranslations){
+ Map<String, Map<String, String>> result = new LinkedHashMap<String, Map<String, String>>();
+ String currentBaseText = null;
+ Map<String, String> 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<String, String>();
+ 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.
+
+ <P>If <tt>aBaseText</tt> is not known, or if there is no <em>explicit</em> translation for
+ the exact {@link Locale}, then return <tt>aBaseText</tt> as is, without translation or
+ alteration.
+
+ <P>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 <tt>aLocale</tt>
+ (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 <tt>toString</tt> result will be used to find the localized
+ translation of <tt>aBaseText</tt>.
+ @param aTranslations has the <a href="#MapStructure">structure suitable for look up</a>.
+ @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<String, Map<String, String>> aTranslations) {
+ LookupResult result = null;
+ Map<String, String> 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)}.
+ <P>Encapsulates both the species of success/fail and the actual
+ text of the translation, if any.
+
+ <P>Example of a typical use case :
+ <PRE>
+ 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
+ }
+ }
+ </PRE>
+ */
+ public static final class LookupResult {
+ /** <tt>BaseText</tt> is unknown. */
+ public static final LookupResult UNKNOWN_BASE_TEXT = new LookupResult();
+ /** <tt>BaseText</tt> is known, but no translation exists for the specified <tt>Locale</tt>*/
+ public static final LookupResult UNKNOWN_LOCALE = new LookupResult();
+ /** Returns <tt>true</tt> only if a specific translation exists for <tt>BaseText</tt> and <tt>Locale</tt>. */
+ public boolean hasSucceeded(){ return fTranslationText != null; }
+ /**
+ Return the text of the successful translation.
+ Returns <tt>null</tt> only if {@link #hasSucceeded()} is <tt>false</tt>.
+ */
+ 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};
+ }
+}
--- /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.
+
+ <P>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.
+
+ <P>Here, "<b>base text</b>" refers to either :
+ <ul>
+ <li>a snippet of user-presentable text in the language being used to build the application
+ <li>a "coder key", such as <tt>"image.title"</tt> or <tt>"button.label"</tt>.
+ 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.
+ </ul>
+
+ <P><span class="highlight">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.</span>
+
+ <P>Please see the package <a href="package-summary.html">overview</a> for an interesting way of
+ evolving the implementation of this interface during development, allowing applications to assist in their
+ own translation.
+
+ <P>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 :
+<ul>
+ <li>providing a do-nothing implementation for a single-language application is trivial.
+ <li>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.
+ <li>some programmers might prefer to name items as <tt>'emailAddr'</tt> instead of
+ <tt>'Email Address'</tt>, 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.
+</ul>
+
+ <P>The recommended style is to implement this interface with a database, and to
+ <a href="http://www.javapractices.com/Topic208.cjp">avoid <tt>ResourceBundle</tt></a>.
+
+ <P><b>Guidance For Implementing With A Database</b><br>
+ Example of a web application with the following particular requirements
+ (see the example application for further illustration):
+ <ul>
+ <li>English is the base development language, used by the programmers and development team
+ <li>the user interface needs to be in both English and French
+ <li>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
+ </ul>
+
+ Here is a style of <tt>ResultSet</tt> that can be used to implement this interface :
+ <P><table border=1 cellspacing=0 cellpadding=3>
+ <tr>
+ <th>BaseText</th><th>Locale</th><th>Translation</th>
+ </tr>
+ <tr><td>Fish And Chips</td><td>en</td><td>Fish And Chips</td></tr>
+ <tr><td>Fish And Chips</td><td>fr</td><td>Poisson et Frites</td></tr>
+ <tr><td>delete</td><td>en</td><td>delete</td></tr>
+ <tr><td>delete</td><td>fr</td><td>supprimer</td></tr>
+ <tr><td>add.edit.button</td><td>en</td><td>Add/Edit</td></tr>
+ <tr><td>add.edit.button</td><td>fr</td><td>Ajouter/Changer</td></tr>
+ </table>
+
+ <P>Only the last two rows use a "coder key".
+ The <tt>BaseText</tt> column holds either the coder key, or the user-presentable English.
+ The <tt>BaseText</tt> is the lookup key. The fact that there is
+ repetition of data between the <tt>BaseText</tt> and <tt>English</tt> columns is not a real duplication problem,
+ since <em>this is a <tt>ResultSet</tt>, not a table</em> - the underlying tables will not have such
+ repetition (if designed properly, of course).
+
+ <P>For example, such a <tt>ResultSet</tt> can be constructed from three underlying tables -
+ <tt>BaseText</tt>, <tt>Locale</tt>, and <tt>Translation</tt>. <tt>Translation</tt> is a cross-reference
+ table, with foreign keys to both <tt>BaseText</tt> and <tt>Locale</tt>. 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.)
+
+ <P>Upon startup, the tables are read, the above <tt>ResultSet</tt> is created and
+ stored in a <tt>private static Map</tt>, represented schematically as
+ <tt>Map[BaseText, Map[Locale, Translation]]</tt>. When a translation is required,
+ this <tt>Map</tt> is used as an in-memory lookup table. This avoids
+ repeated fetching from the database of trivial data that rarely changes.
+
+ <P><b>Note on Thread Safety</b>
+ <br>In the suggested implementation style, the <tt>private static Map</tt> that stores translation data is
+ <tt>static</tt>, 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 <tt>private static Map</tt> be used in an fully
+ thread-safe manner.
+*/
+public interface Translator {
+
+ /**
+ Translate <tt>aBaseText</tt> into text appropriate for <tt>aLocale</tt>.
+
+ <P><tt>aBaseText</tt> is either user-presentable text in the base language of
+ development, or a "coder key" known only to the programmer.
+
+ <P>If <tt>aBaseText</tt> is unknown, then the implementation is free to define
+ any desired behavior. For example, this method might return
+ <ul>
+ <li><tt>aBaseText</tt> passed to this method, in its raw, untranslated form
+ <li><tt>aBaseText</tt> with special surrounding text, such as <tt>???some text???</tt>, with
+ leading and trailing characters
+ <li>a fixed <tt>String</tt> such as <tt>'???'</tt> or <tt>'?untranslated?'</tt>
+ <li>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
+ </ul>
+
+ <P>If <tt>aBaseText</tt> 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 <tt>null</tt>, but may be empty
+ @param aLocale comes from the configured {@link LocaleSource}.
+ */
+ String get(String aBaseText, Locale aLocale);
+
+}
--- /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 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+ <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+ <meta name="Author" content="Hirondelle Systems">
+ <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+ <title>Translate</title>
+</head>
+<body>
+Translation of text for multilingual applications.
+
+ <h3>Last-Second Localization</h3>
+WEB4J's goal is to make the task of changing a single-language application into a
+ multilingual one as <em>painless</em> and as <em>focused</em> as possible, with minimal
+ ripple effects. To support this idea, WEB4J has two main design goals :
+ <ul>
+ <li><span class="highlight">Model Objects, actions, and Data Access Objects should be
+ exactly the same</span> regardless of whether the application is multilingual or not.
+ <li><span class="highlight">Markup in a multilingual application should appear almost the same</span>
+ as in a single language application.
+ </ul>
+
+ <P>
+To achieve the first goal, WEB4J uses a policy of "last-second localization",
+whereby <span class="highlight">translations are performed outside of normal code, and only in Java Server Pages,
+using custom tags</span>. This corresponds to the idea that a translation is "just a view".
+
+<h3>Overview</h3>
+The various items are :
+<ul>
+ <li>{@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).
+ <li>the <tt>Locale</tt> 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.
+ <li>the {@link hirondelle.web4j.ui.translate.Text} tag uses the above to perform the translation
+"at the last second", in a JSP.
+<li>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.
+<li>the {@link hirondelle.web4j.ui.translate.Tooltips} tag translates all <tt>TITLE</tt>
+and <tt>ALT</tt> attributes appearing in its body (it also translates the text of <tt>SUBMIT</tt> buttons).
+If placed in a high-level template, it
+translates those attributes across a large number of pages - perhaps even the whole site.
+<li>the {@link hirondelle.web4j.ui.translate.Messages} tag translates the
+{@link hirondelle.web4j.model.AppResponseMessage}s emitted by {@link hirondelle.web4j.action.Action}s.
+ </ul>
+
+<h3>Translatable Items</h3>
+ Translatable items include :
+ <ul>
+ <li>static text in a web page
+ <li>tooltips - the <tt>TITLE</tt> and <tt>ALT</tt> attributes of tags
+ <li>links whose target is dependent on <tt>Locale</tt>
+ <li>dynamic messages generated by the application
+ </ul>
+
+ <h3>Suggested Development Style</h3>
+ For a new multilingual application, one may proceed as follows :
+ <ul>
+ <li>start development with a do-nothing {@link hirondelle.web4j.ui.translate.Translator},
+ that simply returns the base text, unchanged and untranslated
+ <li>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.)
+ <li>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 <tt>List</tt>,
+ for example). Thus, <span class="highlight">by simply exercising all parts of the application
+ (including all possible error messages), one may generate a listing of all items that need translation.</span>
+ <li>finally, translate all items, and provide the "correct" {@link hirondelle.web4j.ui.translate.Translator}
+ implementation. (See the example application for illustration.)
+ </ul>
+
+<P>If an existing application needs to be changed from a single-language style to a multilingual style,
+then a similar technique may be used.
+
+<h3>Database versus {@link java.util.ResourceBundle} </h3>
+Although a {@link hirondelle.web4j.ui.translate.Translator} may be backed by a
+<tt>ResourceBundle</tt>, the recommended style is to use a database instead.
+<a href="http://www.javapractices.com/Topic208.cjp"><tt>ResourceBundle</tt> is a
+mediocre tool</a> for web applications.
+</body>
+</html>
--- /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.
+
+<P>Replaces <tt>if</tt> statements at the start of a method with
+ more compact method calls.
+
+ <P>Example use case.
+ <P>Instead of :
+ <PRE>
+ public void doThis(String aText){
+ if (!Util.textHasContent(aText)){
+ throw new IllegalArgumentException();
+ }
+ //..main body elided
+ }
+ </PRE>
+ <P>One may instead write :
+ <PRE>
+ public void doThis(String aText){
+ Args.checkForContent(aText);
+ //..main body elided
+ }
+ </PRE>
+*/
+public final class Args {
+
+ /**
+ If <code>aText</code> does not satisfy {@link Util#textHasContent}, then
+ throw an <code>IllegalArgumentException</code>.
+
+ <P>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 <code>false</code>, then
+ throw an <code>IllegalArgumentException</code>.
+
+ @param aLow is less than or equal to <code>aHigh</code>.
+ */
+ 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 <tt>aNumber</tt> is less than <tt>1</tt>, then throw an
+ <tt>IllegalArgumentException</tt>.
+ */
+ public static void checkForPositive(int aNumber) {
+ if (aNumber < 1) {
+ throw new IllegalArgumentException(aNumber + " is less than 1");
+ }
+ }
+
+ /**
+ If {@link Util#matches} returns <tt>false</tt>, then
+ throw an <code>IllegalArgumentException</code>.
+ */
+ 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 <code>aObject</code> is null, then throw a <code>NullPointerException</code>.
+
+ <P>Use cases :
+ <pre>
+ 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 );
+ }
+ </pre>
+ */
+ public static void checkForNull(Object aObject) {
+ if (aObject == null) {
+ throw new NullPointerException();
+ }
+ }
+
+ // PRIVATE
+ private Args(){
+ //empty - prevent construction
+ }
+}
--- /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.
+
+<P>All members of this class are immutable.
+
+ <P>(This is an example of
+ <a href='http://www.javapractices.com/Topic2.cjp'>class for constants</a>.)
+*/
+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 <tt>-1</tt> when
+ an item is not found.
+ */
+ public static final int NOT_FOUND = -1;
+
+ /** System property - <tt>line.separator</tt>*/
+ public static final String NEW_LINE = System.getProperty("line.separator");
+ /** System property - <tt>file.separator</tt>*/
+ public static final String FILE_SEPARATOR = System.getProperty("file.separator");
+ /** System property - <tt>path.separator</tt>*/
+ 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 <tt>Consts.EMPTY_STRING</tt>,
+ 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();
+ }
+}
--- /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.
+
+ <P>To keep you safe by default, WEB4J goes to some effort to escape
+ characters in your data when appropriate, such that you <em>usually</em>
+ don't need to think too much about escaping special characters. Thus, you
+ shouldn't need to <em>directly</em> use the services of this class very often.
+
+ <P><span class='highlight'>For Model Objects containing free form user input,
+ it is highly recommended that you use {@link SafeText}, not <tt>String</tt></span>.
+ Free form user input is open to malicious use, such as
+ <a href='http://www.owasp.org/index.php/Cross_Site_Scripting'>Cross Site Scripting</a>
+ attacks.
+ Using <tt>SafeText</tt> will protect you from such attacks, by always escaping
+ special characters automatically in its <tt>toString()</tt> method.
+
+ <P>The following WEB4J classes will automatically escape special characters
+ for you, when needed :
+ <ul>
+ <li>the {@link SafeText} class, used as a building block class for your
+ application's Model Objects, for modeling all free form user input
+ <li>the {@link Populate} tag used with forms
+ <li>the {@link Report} class used for creating quick reports
+ <li>the {@link Text}, {@link TextFlow}, and {@link Tooltips} custom tags used
+ for translation
+ </ul>
+*/
+public final class EscapeChars {
+
+ /**
+ Escape characters for text appearing in HTML markup.
+
+ <P>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.
+
+ <P>The following characters are replaced with corresponding
+ HTML character entities :
+ <table border='1' cellpadding='3' cellspacing='0'>
+ <tr><th> Character </th><th>Replacement</th></tr>
+ <tr><td> < </td><td> &lt; </td></tr>
+ <tr><td> > </td><td> &gt; </td></tr>
+ <tr><td> & </td><td> &amp; </td></tr>
+ <tr><td> " </td><td> &quot;</td></tr>
+ <tr><td> \t </td><td> &#009;</td></tr>
+ <tr><td> ! </td><td> &#033;</td></tr>
+ <tr><td> # </td><td> &#035;</td></tr>
+ <tr><td> $ </td><td> &#036;</td></tr>
+ <tr><td> % </td><td> &#037;</td></tr>
+ <tr><td> ' </td><td> &#039;</td></tr>
+ <tr><td> ( </td><td> &#040;</td></tr>
+ <tr><td> ) </td><td> &#041;</td></tr>
+ <tr><td> * </td><td> &#042;</td></tr>
+ <tr><td> + </td><td> &#043; </td></tr>
+ <tr><td> , </td><td> &#044; </td></tr>
+ <tr><td> - </td><td> &#045; </td></tr>
+ <tr><td> . </td><td> &#046; </td></tr>
+ <tr><td> / </td><td> &#047; </td></tr>
+ <tr><td> : </td><td> &#058;</td></tr>
+ <tr><td> ; </td><td> &#059;</td></tr>
+ <tr><td> = </td><td> &#061;</td></tr>
+ <tr><td> ? </td><td> &#063;</td></tr>
+ <tr><td> @ </td><td> &#064;</td></tr>
+ <tr><td> [ </td><td> &#091;</td></tr>
+ <tr><td> \ </td><td> &#092;</td></tr>
+ <tr><td> ] </td><td> &#093;</td></tr>
+ <tr><td> ^ </td><td> &#094;</td></tr>
+ <tr><td> _ </td><td> &#095;</td></tr>
+ <tr><td> ` </td><td> &#096;</td></tr>
+ <tr><td> { </td><td> &#123;</td></tr>
+ <tr><td> | </td><td> &#124;</td></tr>
+ <tr><td> } </td><td> &#125;</td></tr>
+ <tr><td> ~ </td><td> &#126;</td></tr>
+ </table>
+
+ <P>Note that JSTL's {@code <c:out>} escapes <em>only the first
+ five</em> 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.
+
+ <P>Replaces all <tt>'&'</tt> characters with <tt>'&amp;'</tt>.
+
+ <P>An ampersand character may appear in the query string of a URL.
+ The ampersand character is indeed valid in a URL.
+ <em>However, URLs usually appear as an <tt>HREF</tt> attribute, and
+ such attributes have the additional constraint that ampersands
+ must be escaped.</em>
+
+ <P>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 <tt>HREF</tt> 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 <tt>URLEncoder.encode(String, "UTF-8")</tt>.
+
+ <P>Used to ensure that HTTP query strings are in proper form, by escaping
+ special characters such as spaces.
+
+ <P>It is important to note that if a query string appears in an <tt>HREF</tt>
+ 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.
+
+ <P>The following characters are replaced with corresponding character entities :
+ <table border='1' cellpadding='3' cellspacing='0'>
+ <tr><th> Character </th><th> Encoding </th></tr>
+ <tr><td> < </td><td> &lt; </td></tr>
+ <tr><td> > </td><td> &gt; </td></tr>
+ <tr><td> & </td><td> &amp; </td></tr>
+ <tr><td> " </td><td> &quot;</td></tr>
+ <tr><td> ' </td><td> &#039;</td></tr>
+ </table>
+
+ <P>Note that JSTL's {@code <c:out>} escapes the exact same set of
+ characters as this method. <span class='highlight'>That is, {@code <c:out>}
+ is good for escaping to produce valid XML, but not for producing safe
+ HTML.</span>
+ */
+ 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
+ <a href='http://www.json.org/'>Javascript Object Notation</a>
+ (JSON) data interchange format.
+
+ <P>The following commonly used control characters are escaped :
+ <table border='1' cellpadding='3' cellspacing='0'>
+ <tr><th> Character </th><th> Escaped As </th></tr>
+ <tr><td> " </td><td> \" </td></tr>
+ <tr><td> \ </td><td> \\ </td></tr>
+ <tr><td> / </td><td> \/ </td></tr>
+ <tr><td> back space </td><td> \b </td></tr>
+ <tr><td> form feed </td><td> \f </td></tr>
+ <tr><td> line feed </td><td> \n </td></tr>
+ <tr><td> carriage return </td><td> \r </td></tr>
+ <tr><td> tab </td><td> \t </td></tr>
+ </table>
+
+ <P>See <a href='http://www.ietf.org/rfc/rfc4627.txt'>RFC 4627</a> 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 <tt>aText</tt> with all <tt>'<'</tt> and <tt>'>'</tt> 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.
+
+ <P>The escaped characters include :
+ <ul>
+ <li>.
+ <li>\
+ <li>?, * , and +
+ <li>&
+ <li>:
+ <li>{ and }
+ <li>[ and ]
+ <li>( and )
+ <li>^ and $
+ </ul>
+ */
+ 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 <tt>'$'</tt> and <tt>'\'</tt> characters in replacement strings.
+
+ <P>Synonym for <tt>Matcher.quoteReplacement(String)</tt>.
+
+ <P>The following methods use replacement strings which treat
+ <tt>'$'</tt> and <tt>'\'</tt> as special characters:
+ <ul>
+ <li><tt>String.replaceAll(String, String)</tt>
+ <li><tt>String.replaceFirst(String, String)</tt>
+ <li><tt>Matcher.appendReplacement(StringBuffer, String)</tt>
+ </ul>
+
+ <P>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 <tt><SCRIPT></tt> tags in <tt>aText</tt>.
+
+ <P>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(
+ "<SCRIPT>", Pattern.CASE_INSENSITIVE
+ );
+ private static final Pattern SCRIPT_END = Pattern.compile(
+ "</SCRIPT>", 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 + ";");
+ }
+}
--- /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 <tt>String</tt>s.
+
+ <P>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.
+
+<P>(These are presented here as <tt>String</tt>s, and not as <tt>Pattern</tt>s, to
+ aid in constructing complex expressions out of simpler ones.)
+
+<P>Some items follow the style of <em>Mastering Regular Expressions</em>, by
+ Jeffrey Friedl (0-596-00289-0).
+
+ <P>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, <em>with</em> quotes.
+ <P>Use {@link Util#removeQuotes(String)} to remove any quotes, if needed.
+ <P>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 = "</" + TAG_NAME + ">";
+ 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
+ <tt>1,000,000</tt>. Intended for integers, but can also handle up to 3 decimal
+ places. For example, <tt>10000.001</tt> gives <tt>10,000.001</tt>.
+ */
+ public static final String COMMA_INSERTION = "(?<=\\d)(?=(\\d\\d\\d)+(?!\\d))";
+
+ /**
+ Either integer or floating point number. Has the following properties
+ <ul>
+ <li>digits with possible decimal point
+ <li>possible leading plus or minus sign
+ <li>no grouping delimiters (such as a comma)
+ <li>no leading or trailing whitespace
+ </ul>
+ <P>Example matches: 1, 100, 2.3, -2.3, +2.3, -272.13, -.0, 2.
+ <P>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.
+
+ <P>Example matches : <tt>1000, 100.25, .25, 0.25, -.13, -0.13</tt>
+ <P>Example mismatches : <tt>1,000.00, 100., 100.0, 100.123, -56.000, .123</tt>
+ */
+ public static final String DOLLARS =
+ "(?:-|\\+)?" +
+ "(" +
+ "[0-9]+" + "(" + Regex.DOT + "[0-9]{2})?" +
+ Regex.OR +
+ Regex.DOT + "[0-9]{2}" +
+ ")"
+ ;
+
+ /**
+ An amount in any currency.
+
+ <P>There are two permitted decimal separators: <tt>'.'</tt> and <tt>','</tt>. The
+ permitted number of decimals is <tt>0,2,3</tt>.
+
+ <P>Example matches : <tt>'1000', '100.25', '.253', '0.25', '-.13', '-0.00'</tt>
+ <P>Example mismatches : <tt>'1,000.00', '100.', '100.0', '100.1234', ',1', ',1234'</tt>
+
+ <P>Note as well that <tt>'1,000'</tt> matches as well! A grouping separator
+ in one <tt>Locale</tt> is a decimal separator in others.
+
+ <P>Any <tt>String</tt> 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
+ <ul>
+ <li>value greater than or equal to <tt>0</tt>
+ <li>possible leading zeros, as in <tt>0100</tt>, or <tt>0002</tt>
+ <li>no decimal point
+ <li>no leading plus or minus sign
+ <li>no leading or trailing whitespace
+ </ul>
+
+ <P><em>Design Note:</em><br>
+ Allowing leading zeros is not a problem for creating <tt>Integer</tt> objects,
+ since the <tt>Integer(String)</tt> constructor allows them.
+
+ <P>Example matches: 0, 1, 2, 9, 10, 99, 789, 010, 0018.<br>
+ Example mismatches: -1, +1, 2.0, ' 0', '2 '.
+ */
+ public static final String DIGITS = "(\\d)+";
+
+ /**
+ Return a regular expression corresponding to <tt>DIGITS</tt>, but having
+ number of digits in range <tt>1..aMaxNumDigits</tt>.
+
+ @param aMaxNumDigits must be <tt>1</tt> or more.
+ */
+ public static String forNDigits(int aMaxNumDigits){
+ Args.checkForRange(aMaxNumDigits, 1, Integer.MAX_VALUE);
+ return "(\\d){1," + aMaxNumDigits + "}";
+ }
+
+ /**
+ Email address.
+
+ <P>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 <em>should be used only when those classes are not available</em>.
+ */
+ 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.
+ <P>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.
+ <P>Example match: 1.01.001.255
+ */
+ public static final String IP_ADDR =
+ "(?<![\\w.])" +
+ IP_ADDR_ITEM + DOT +
+ IP_ADDR_ITEM + DOT +
+ IP_ADDR_ITEM + DOT +
+ IP_ADDR_ITEM +
+ "(?![\\w.])"
+ ;
+
+
+ /**
+ A positional regex which returns the position where lower case text is
+ immediately followed by upper case text.
+
+ <P>Intended for manipulation of text in camel hump style, which looks like this :
+ BlahBlahBlah, LoginName, EmailAddress.
+
+ <P>Example:<br>
+ 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 <tt>' $1'</tt>.
+ */
+ 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 :
+ <ul>
+ <li><tt>blah</tt>
+ <li><tt>blah42</tt>
+ <li><tt>blah_42</tt>
+ <li><tt>BlahBlah</tt>
+ <li><tt>BLAH_BLAH</tt>
+ </ul>
+ */
+ public static final String SIMPLE_IDENTIFIER = "([a-zA-Z_]+(?:\\d)*)";
+
+ /**
+ Scoped identifier.
+
+ <P>Either two {@link #SIMPLE_IDENTIFIER}s separated by a period, or a single
+ {@link #SIMPLE_IDENTIFIER}. The item before the period represents an <em>optional</em>
+ 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.
+
+ <P>Here, <tt>HREF</tt> must be the first attribute to appear in the tag.
+ The following groups are defined :
+ <ul>
+ <li> group 1 - value of the HREF attr
+ <li> group 2 - all text after the HREF attr, but still inside the tag - the "remainder"
+ attributes
+ <li> group 3 - the body of the A tag
+ </ul>
+ */
+ public static final String LINK =
+ "<a href=" + Regex.QUOTED_ATTR + "(" + Regex.ALL_BUT_END_OF_TAG + ")" + Regex.END_TAG
+ + Regex.TRIMMED_TEXT + "</a>"
+ ;
+
+ /**
+ Month in the Gregorian calendar: <tt>01..12</tt>.
+ */
+ 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: <tt>01..31</tt>.
+ */
+ 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 <tt>00..23</tt>. */
+ 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 <tt>00..59</tt>. */
+ public static final String MINUTES =
+ "((0|1|2|3|4|5)\\d)"
+ ;
+
+ /** Hours and minutes, in the form <tt>00:59</tt>. */
+ public static final String HOURS_AND_MINUTES =
+ HOURS + ":" + MINUTES
+ ;
+
+ // PRIVATE //
+
+ /** Prevents instantiation of this class. */
+ private Regex(){
+ //emtpy
+ }
+}
--- /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:
+<PRE>
+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);
+}
+</PRE>
+
+ <P>The value on the stopwatch may be inspected at any time using the
+ {@link #toString} (millis) and {@link #toValue} (nanos) methods.
+
+ <P><b>Example 2</b>
+ <br>To time the various steps in a long startup or initialization task, your code may take the form:
+<PRE>
+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") ;
+</PRE>
+
+<P><i>Implementation Note:</i></br>
+ This class uses {@link System#nanoTime()}, not {@link System#currentTimeMillis()}, to
+ perform its timing operations.
+*/
+public final class Stopwatch {
+
+ /**
+ Start the stopwatch.
+
+ <P>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.
+
+ <P>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.
+
+ <P>Example return value : '<tt>108.236 ms</tt>'. 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.
+
+ <P>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();
+ }
+}
--- /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;
+
+/**
+ <a href="http://www.junit.org">JUnit</a> 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));
+ }
+ }
+ }
+}
+
+
--- /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.*;
+
+/**
+ <a href="http://www.junit.org">JUnit</a> 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, "'<blah>'");
+ 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 <drum>.\"");
+ 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");
+ }
+
+ /*
+ * <P>Example matches : <tt>1000, 100.25, .25, 0.25, -.13, -0.13</tt>
+ <P>Example mismatches : <tt>1,000.00, 100., 100.0, 100.123, -56.000, .123</tt>
+
+ */
+ 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) );
+ }
+ }
+
+}
--- /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;
+
+/**
+ <a href="http://www.junit.org">JUnit</a> 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<String> 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));
+ }
+
+}
--- /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.*;
+
+/**
+ <a href="http://www.junit.org">JUnit</a> 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 + "'");
+ }
+ }
+}
--- /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.
+
+ <P>When testing, it is often useful to use a
+ <a href='http://www.javapractices.com/topic/TopicAction.do?Id=234'>fake system clock</a>,
+ 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 <i>share</i> its fake system clock with the framework,
+ so that they can both use the exact same clock.
+
+ <P>See {@link hirondelle.web4j.BuildImpl} for instructions on how to configure an implementation
+ of this interface.
+
+ <P>The following WEB4J framework classes use <tt>TimeSource</tt> :
+ <ul>
+ <li>{@link hirondelle.web4j.model.DateTime#now(TimeZone)} - returns the current date-time
+ <li>{@link hirondelle.web4j.ui.tag.ShowDate} - displays the current date-time
+ <li>{@link hirondelle.web4j.webmaster.LoggingConfigImpl} - both the name of the logging
+ file and the date-time attached to each logging record are affected
+ <li>{@link hirondelle.web4j.webmaster.TroubleTicket} - uses the current date-time
+ <li>{@link hirondelle.web4j.Controller} - upon startup, it places the current date-time in application scope
+ </ul>
+*/
+public interface TimeSource {
+
+ /** Return the possibly-fake system time, in milliseconds. */
+ long currentTimeMillis();
+
+}
--- /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}.
+
+<P> 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();
+ }
+
+}
--- /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.
+
+ <P>{@link Args} wraps certain methods of this class into a form suitable for checking arguments.
+
+ <P>{@link hirondelle.web4j.util.WebUtil} includes utility methods particular to web applications.
+*/
+public final class Util {
+
+ /**
+ Return true only if <tt>aNumEdits</tt> is greater than <tt>0</tt>.
+
+ <P>This method is intended for database operations.
+ */
+ public static boolean isSuccess(int aNumEdits){
+ return aNumEdits > 0;
+ }
+
+ /**
+ Return <tt>true</tt> only if <tt>aText</tt> is not null,
+ and is not empty after trimming. (Trimming removes both
+ leading/trailing whitespace and ASCII control characters. See {@link String#trim()}.)
+
+ <P> 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 <tt>true</tt> only if <tt>aText</tt> is not null,
+ and if its raw <tt>String</tt> 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 <tt>aText</tt> is null, return null; else return <tt>aText.trim()</tt>.
+
+ This method is especially useful for Model Objects whose <tt>String</tt>
+ parameters to its constructor can take any value whatsoever, including
+ <tt>null</tt>. Using this method lets <tt>null</tt> params remain
+ <tt>null</tt>, while trimming all others.
+
+ @param aText possibly-null.
+ */
+ public static String trimPossiblyNull(String aText){
+ return aText == null ? null : aText.trim();
+ }
+
+ /**
+ Return <tt>true</tt> only if <tt>aNumber</tt> is in the range
+ <tt>aLow..aHigh</tt> (inclusive).
+
+ <P> For checking argument validity, {@link Args#checkForRange} should
+ be used instead of this method.
+
+ @param aLow less than or equal to <tt>aHigh</tt>.
+ */
+ 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 <tt>true</tt> only if the number of decimal places in <tt>aAmount</tt> is in the range
+ 0..<tt>aMaxNumDecimalPlaces</tt> (inclusive).
+
+ @param aAmount any amount, positive or negative..
+ @param aMaxNumDecimalPlaces is <tt>1</tt> or more.
+ */
+ static public boolean hasMaxDecimals(BigDecimal aAmount, int aMaxNumDecimalPlaces){
+ Args.checkForPositive(aMaxNumDecimalPlaces);
+ int numDecimals = aAmount.scale();
+ return 0 <= numDecimals && numDecimals <= aMaxNumDecimalPlaces;
+ }
+
+ /**
+ Return <tt>true</tt> only if the number of decimal places in <tt>aAmount</tt> is in the range
+ 0..<tt>aMaxNumDecimalPlaces</tt> (inclusive).
+
+ @param aAmount any amount, positive or negative..
+ @param aMaxNumDecimalPlaces is <tt>1</tt> or more.
+ */
+ static public boolean hasMaxDecimals(Decimal aAmount, int aMaxNumDecimalPlaces){
+ return hasMaxDecimals(aAmount.getAmount(), aMaxNumDecimalPlaces);
+ }
+
+ /**
+ Return <tt>true</tt> only if <tt>aAmount</tt> 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 <tt>true</tt> only if <tt>aAmount</tt> 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.
+
+ <P>The parameter passed to this method is first trimmed (if it is non-null),
+ and then compared to the following Strings, ignoring case :
+ <ul>
+ <li>{@link Boolean#TRUE} : 'true', 'yes', 'on'
+ <li>{@link Boolean#FALSE} : 'false', 'no', 'off'
+ </ul>
+
+ <P>Any other text will cause a <tt>RuntimeException</tt>. (Note that this behavior
+ is different from that of {@link Boolean#valueOf(String)}).
+
+ <P>(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-<tt>null</tt> {@link Boolean} value into {@link Boolean#FALSE}.
+
+ <P>This method is usually called in Model Object constructors that have two-state <tt>Boolean</tt> fields.
+
+ <P>This method is supplied specifically for request parameters that may be <em>missing</em> from the request,
+ during normal operation of the program.
+ <P>Example : a form has a checkbox for 'yes, send me your newsletter', and the data is modeled has having two states -
+ <tt>true</tt> and <tt>false</tt>. If the checkbox is <em>not checked</em> , however, the browser will
+ likely not POST any corresponding request parameter - it will be <tt>null</tt>. In that case, calling this method
+ will coerce such <tt>null</tt> parameters into {@link Boolean#FALSE}.
+
+ <P>There are other cases in which data is modeled as having not two states, but <em>three</em> :
+ <tt>true</tt>, <tt>false</tt>, and <tt>null</tt>. The <tt>null</tt> value usually means 'unknown'.
+ In that case, this method should <em>not</em> be called.
+ */
+ public static Boolean nullMeansFalse(Boolean aBoolean){
+ return aBoolean == null ? Boolean.FALSE : aBoolean;
+ }
+
+ /**
+ Create a {@link Pattern} corresponding to a <tt>List</tt>.
+
+ Example: if the {@link List} contains "cat" and "dog", then the returned
+ <tt>Pattern</tt> will correspond to the regular expression "(cat|dog)".
+
+ @param aList is not empty, and contains objects whose <tt>toString()</tt>
+ 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 <tt>true</tt> only if <tt>aMoney</tt> equals <tt>0</tt>
+ or <tt>0.00</tt>.
+ */
+ 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 <tt>aText</tt> is non-null, and matches
+ <tt>aPattern</tt>.
+
+ <P>Differs from {@link Pattern#matches} and {@link String#matches},
+ since the regex argument is a compiled {@link Pattern}, not a
+ <tt>String</tt>.
+ */
+ 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 <tt>aText</tt> is non-null, and contains
+ a substring that matches <tt>aPattern</tt>.
+ */
+ public static boolean contains(Pattern aPattern, String aText){
+ if (aText == null) return false;
+ Matcher matcher = aPattern.matcher(aText);
+ return matcher.find();
+ }
+
+ /**
+ If <tt>aPossiblyNullItem</tt> is <tt>null</tt>, then return <tt>aReplacement</tt> ;
+ otherwise return <tt>aPossiblyNullItem</tt>.
+
+ <P>Intended mainly for occasional use in Model Object constructors. It is used to
+ coerce <tt>null</tt> items into a more appropriate default value.
+ */
+ public static <E> E replaceIfNull(E aPossiblyNullItem, E aReplacement){
+ return aPossiblyNullItem == null ? aReplacement : aPossiblyNullItem;
+ }
+
+ /**
+ <P>Convert end-user input into a form suitable for {@link BigDecimal}.
+
+ <P>The idea is to allow a wide range of user input formats for monetary amounts. For
+ example, an amount may be input as <tt>'$1,500.00'</tt>, <tt>'U$1500.00'</tt>,
+ or <tt>'1500.00 U$'</tt>. These entries can all be converted into a
+ <tt>BigDecimal</tt> by simply stripping out all characters except for digits
+ and the decimal character.
+
+ <P>Removes all characters from <tt>aCurrencyAmount</tt> which are not digits or
+ <tt>aDecimalSeparator</tt>. Finally, if <tt>aDecimalSeparator</tt> is not
+ a period (expected by <tt>BigDecimal</tt>) then it is replaced with a period.
+
+ @param aDecimalSeparator must have content, and must have length of <tt>1</tt>.
+ */
+ 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.
+
+ <P>The conventional logger names used by WEB4J are taken as
+ <tt>aClass.getPackage().getName()</tt>.
+
+ <P>Logger names appearing in the <tt>logging.properties</tt> config file
+ must match the names returned by this method.
+
+ <P>If an application requires an alternate naming convention, then an
+ alternate implementation can be easily constructed. Alternate naming conventions
+ might account for :
+ <ul>
+ <li>pre-pending the logger name with the name of the application (this is useful
+ where log handlers are shared between different applications)
+ <li>adding version information
+ </ul>
+ */
+ public static Logger getLogger(Class<?> aClass){
+ return Logger.getLogger(aClass.getPackage().getName());
+ }
+
+ /**
+ Call {@link String#valueOf(Object)} on <tt>aObject</tt>, and place the result in single quotes.
+ <P>This method is a bit unusual in that it can accept a <tt>null</tt>
+ argument : if <tt>aObject</tt> is <tt>null</tt>, it will return <tt>'null'</tt>.
+
+ <P>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).
+
+ <P>Note that such quotation is likely needed only for <tt>String</tt> 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 <tt>aText</tt>, either a single quote or
+ a double quote.
+
+ <P>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.
+
+ <P>If <tt>aText</tt> has no content, then it is simply returned by this method, as is,
+ including possibly-<tt>null</tt> values.
+ @param aText is possibly <tt>null</tt>, 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 <tt>aText</tt> is capitalized.
+
+ <P>Does not trim <tt>aText</tt>.
+
+ @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 <tt>aText</tt> contains no spaces.
+
+ <P>Along with {@link #withInitialCapital(String)}, this method is useful for
+ mapping request parameter names into corresponding <tt>getXXX</tt> methods.
+ For example, the text <tt>'Email Address'</tt> and <tt>'emailAddress'</tt>
+ can <em>both</em> be mapped to a method named <tt>'getEmailAddress()'</tt>, by using :
+ <PRE>
+ String methodName = "get" + Util.withNoSpaces(Util.withInitialCapital(name));
+ </PRE>
+
+ @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.
+
+ <P>This method is distinct from {@link String#replaceAll}, since it does not use a
+ regular expression.
+
+ @param aInput may contain substring <tt>aOldText</tt>; satisfies {@link #textHasContent(String)}
+ @param aOldText substring which is to be replaced; possibly empty, but never null
+ @param aNewText replacement for <tt>aOldText</tt>; 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 <tt>String</tt> suitable for logging, having one item from <tt>aCollection</tt>
+ per line.
+
+ <P>For the <tt>Collection</tt> containing <br>
+ <tt>[null, "Zebra", "aardvark", "Banana", "", "aardvark", new BigDecimal("5.00")]</tt>,
+
+ <P>the return value is :
+ <PRE>
+ (7) {
+ ''
+ '5.00'
+ 'aardvark'
+ 'aardvark'
+ 'Banana'
+ 'null'
+ 'Zebra'
+ }
+ </PRE>
+
+ <P>The text for each item is generated by calling {@link #quote}, and by appending a new line.
+
+ <P>As well, this method reports the total number of items, <em>and places items in
+ alphabetical order</em> (ignoring case). (The iteration order of the <tt>Collection</tt>
+ passed by the caller will often differ from the order of items presented in the return value.)
+ </PRE>
+ */
+ 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<String> lines = new ArrayList<String>(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 <tt>String</tt> suitable for logging, having one item from <tt>aMap</tt>
+ per line.
+
+ <P>For a <tt>Map</tt> containing <br>
+ <tt>["b"="blah", "a"=new BigDecimal(5.00), "Z"=null, null=new Integer(3)]</tt>,
+
+ <P>the return value is :
+ <PRE>
+ (4) {
+ 'a' = '5.00'
+ 'b' = 'blah'
+ 'null' = '3'
+ 'Z' = 'null'
+ }
+ </PRE>
+
+ <P>The text for each key and value is generated by calling {@link #quote}, and
+ appending a new line after each entry.
+
+ <P>As well, this method reports the total number of items, <em>and places items in
+ alphabetical order of their keys</em> (ignoring case). (The iteration order of the
+ <tt>Map</tt> passed by the caller will often differ from the order of items in the
+ return value.)
+
+ <P>An attempt is made to suppress the emission of passwords. Values in a Map are
+ presented as <tt>****</tt> if the following conditions are all true :
+ <ul>
+ <li>{@link String#valueOf(java.lang.Object)} applied to the <em>key</em> contains the word <tt>password</tt> or
+ <tt>credential</tt> (ignoring case)
+ <li>the <em>value</em> is not an array or a <tt>Collection</tt>
+ </ul>
+ */
+ public static String logOnePerLine(Map<?,?> aMap){
+ StringBuilder result = new StringBuilder();
+ result.append("(" + aMap.size() + ") {" + Consts.NEW_LINE);
+ List<String> lines = new ArrayList<String>(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 <tt>aRawLocale</tt>.
+
+ <P>The format of <tt>aRawLocale</tt> follows the
+ <tt>language_country_variant</tt> style used by {@link Locale}. The value is <i>not</i> 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}.
+
+ <P>If the given <tt>String</tt> does not correspond to a known <tt>TimeZone</tt> 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<String> 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.
+
+ <P>The format of the returned {@link String} is the same as
+ {@link java.util.AbstractCollection#toString} :
+ <ul>
+ <li>non-empty array: <tt>[blah, blah]</tt>
+ <li>empty array: <tt>[]</tt>
+ <li>null array: <tt>null</tt>
+ </ul>
+
+ <P>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
+ <tt>aArray</tt> 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 <tt>List</tt> into a <tt>Map</tt>.
+
+ <P>This method exists because it is sometimes necessary to transform a
+ <tt>List</tt> into a lookup table of some sort, using <em>unique</em> keys already
+ present in the <tt>List</tt> data.
+
+ <P>The <tt>List</tt> to be transformed contains objects having a method named
+ <tt>aKeyMethodName</tt>, and which returns objects of class <tt>aKeyClass</tt>.
+ Thus, data is extracted from each object to act as its key. Furthermore, the
+ key must be <em>unique</em>. If any duplicates are detected, then an
+ exception is thrown. This ensures that the returned <tt>Map</tt> will be the
+ same size as the given <tt>List</tt>, and that no data is silently discarded.
+
+ <P>The iteration order of the returned <tt>Map</tt> is identical to the iteration order of
+ the input <tt>List</tt>.
+ */
+ public static <K,V> Map<K,V> asMap(List<V> aList, Class<K> aClass, String aKeyMethodName){
+ Map<K,V> result = new LinkedHashMap<K,V>();
+ 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 <tt>Map</tt>.
+
+ <P>This method exists because sometimes a lookup operation needs to be performed in a
+ style opposite to an existing <tt>Map</tt>.
+
+ <P>There is an unusual requirement on the <tt>Map</tt> argument: the map <em>values</em> must be
+ unique. Thus, the returned <tt>Map</tt> will be the same size as the input <tt>Map</tt>. If any
+ duplicates are detected, then an exception is thrown.
+
+ <P>The iteration order of the returned <tt>Map</tt> is identical to the iteration order of
+ the input <tt>Map</tt>.
+ */
+ public static <K,V> Map<V,K> reverseMap(Map<K,V> aMap){
+ Map<V,K> result = new LinkedHashMap<V,K>();
+ for(Map.Entry<K,V> 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<String> 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> K getMethodValue(Object aValue, Class<K> 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));
+ }
+}
--- /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.
+
+<P> 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.
+
+ <P>Return <tt>true</tt> only if
+ <ul>
+ <li> <tt>aEmailAddress</tt> can successfully construct an
+ {@link javax.mail.internet.InternetAddress}
+ <li> when parsed with "@" as delimiter, <tt>aEmailAddress</tt> contains
+ two tokens which satisfy {@link hirondelle.web4j.util.Util#textHasContent}.
+ </ul>
+
+ <P> The second condition arises since local email addresses, simply of the form
+ "<tt>albert</tt>", 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.
+
+ <P>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.
+
+ <P>Any number of query parameters can be added to a URL, one after the other.
+ Any special characters in <tt>aParamName</tt> and <tt>aParamValue</tt> will be
+ escaped by this method using {@link EscapeChars#forURL}.
+
+ <P>This method is intended for cases in which an <tt>Action</tt> requires
+ a redirect after processing, and the redirect in turn requires <em>dynamic</em>
+ 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.)
+
+ <P>Example 1, where a new parameter is added :<P>
+ <tt>setQueryParam("blah.do", "artist", "Tom Thomson")</tt>
+ <br>
+ returns the value :<br> <tt>blah.do?artist=Tom+Thomson</tt>
+
+ <P>Example 2, where an existing parameter is updated :<P>
+ <tt>setQueryParam("blah.do?artist=Tom+Thomson", "artist", "A Y Jackson")</tt>
+ <br>
+ returns the value :<br> <tt>blah.do?artist=A+Y+Jackson</tt>
+
+ <P>Example 3, with a parameter name of slightly different form :<P>
+ <tt>setQueryParam("blah.do?Favourite+Artist=Tom+Thomson", "Favourite Artist", "A Y Jackson")</tt>
+ <br>
+ returns the value :<br> <tt>blah.do?Favourite+Artist=A+Y+Jackson</tt>
+
+ @param aURL a base URL, with <em>escaped</em> parameter names and values
+ @param aParamName <em>unescaped</em> parameter name
+ @param aParamValue <em>unescaped</em> 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
+ <tt>?</tt> and {@link HttpServletRequest#getQueryString}.
+
+ <P>Query parameters are added only if they are present.
+
+ <P>If the underlying method is a <tt>GET</tt> which does NOT edit the database,
+ then presenting the return value of this method in a link is usually acceptable.
+
+ <P>If the underlying method is a <tt>POST</tt>, or if it is a <tt>GET</tt> which
+ (erroneously) edits the database, it is recommended that the return value of
+ this method NOT be placed in a link.
+
+ <P><em>Warning</em> : 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 <tt>forward</tt> 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.
+
+ <P>Session id is included in the return value.
+
+ <P>Somewhat frustratingly, the original client request is not directly available from
+ the Servlet API.
+ <P>This implementation is based on an example in the
+ <a href="http://www.exampledepot.com/egs/javax.servlet/GetReqUrl.html">Java Almanac</a>.
+ */
+ 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).
+
+ <P>If there is no session, then this method will not create one.
+
+ <P>If no <tt>Object</tt> corresponding to <tt>aKey</tt> is found, then <tt>null</tt> 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.
+
+ <P>Some example return values for this method :
+ <table border='1' cellpadding='3' cellspacing='0'>
+ <tr><th>URL</th><th>'File Extension'</th></tr>
+ <tr>
+ <td>.../VacationAction.do</td>
+ <td>do</td>
+ </tr>
+ <tr>
+ <td>.../VacationAction.fetchForChange?Id=103</td>
+ <td>fetchForChange</td>
+ </tr>
+ <tr>
+ <td>.../VacationAction.list?Start=Now&End=Never</td>
+ <td>list</td>
+ </tr>
+ <tr>
+ <td>.../SomethingAction.show;jsessionid=32131?SomeId=123456</td>
+ <td>show</td>
+ </tr>
+ </table>
+
+ @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] ) ;
+ }
+}
--- /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 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+ <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+ <meta name="Author" content="Hirondelle Systems">
+ <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+ <title>Utilities</title>
+</head>
+<body>
+General utility classes, useful for many applications.
+<br>
+</body>
+</html>
--- /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 <tt>BadResponseDetector</tt> using a setting of the same name in
+ <tt>web.xml</tt>.
+
+ 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 <em>minutes</em> between each test (1..60). Required.
+ @param aTimeout number of <i>seconds</i> 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<String> items = new ArrayList<String>();
+ 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;
+ }
+}
--- /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.
+
+ <P>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.
+
+ <P>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 <tt>web.xml</tt>.
+
+ <P><b>Sending Email on a Separate Thread</b><br>
+ 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.
+
+ <P>The {@link EmailerImpl} default implementation of this interface doesn't use a
+ separate worker thread. See <tt>web.xml</tt> 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<String> aToAddresses, String aSubject, String aBody) throws AppException;
+
+}
--- /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}.
+
+ <P>Uses these <tt>init-param</tt> settings in <tt>web.xml</tt>:
+ <ul>
+ <li><tt>Webmaster</tt> : the email address of the webmaster.
+ <li><tt>MailServerConfig</tt> : 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 <tt>mail.host</tt> settings, and so on.
+ The special value <tt>NONE</tt> indicates that emails are suppressed, and will not be sent.
+ <li><tt>MailServerCredentials</tt> : 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 <tt>NONE</tt> means that no credentials are needed (often the case when the wep app
+ and the outgoing mail server reside on the same network).
+ </ul>
+
+ <P>Example <tt>web.xml</tt> settings, using a Gmail account:
+<PRE> <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>
+ </pre>
+*/
+public final class EmailerImpl implements Emailer {
+
+ public void sendFromWebmaster(List<String> 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<String> 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<String> getAsLines(String aRawValue){
+ List<String> result = new ArrayList<String>();
+ 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<String> 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<String> 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;
+ }
+ }
+}
--- /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.
+
+ <P>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.
+
+ <P>Here, implementations configure the logging system using <em>code</em>, not a configuration file.
+ WEB4J itself uses JDK logging. Your application may also use JDK logging, or any other logging system.
+
+ <P>If your application does not require any logging config performed in code,
+ then just set the <tt>LoggingDirectory</tt> in <tt>web.xml</tt> set to <tt>'NONE'</tt>.
+
+ <P>Implementations of this interface are called by the framework only <em>once</em>, upon startup.
+
+ <P><b>Independent Logging On Servers</b><br>
+ In the JDK <tt>logging.properties</tt> config file, it is important to remember that
+ the <tt>handlers</tt> setting creates Handlers and <em>attaches them to the root logger</em>.
+ In general, those default handlers will be <em>shared</em> by all applications running
+ in that JRE. This is not appropriate for most server environments.
+ In a servlet environment, however, each application uses a <em>private class loader</em>.
+ This means that each application can perform its own custom logging
+ config in <em>code</em>, instead of in <tt>logging.properties</tt>, and <em>retain independence</em>
+ 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 <tt>init-param</tt> items for the servlet,
+ as stated in <tt>web.xml</tt>.
+ */
+ void setup(Map<String, String> aConfig) throws AppException;
+
+}
--- /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.
+
+ <P>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 <tt>web.xml</tt>:
+<ul>
+ <li><tt>LoggingDirectory</tt> - 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 <tt>DefaultUserTimeZone</tt> setting in
+ <tt>web.xml</tt>, in the form <tt>2007_12_31_59_59.txt</tt>. If the directory does not exist, WEB4J will
+ attempt to create it upon startup. If set to the special value of <tt>'NONE'</tt>, then
+ this class will not configure JDK logging in any way.
+ <li><tt>LoggingLevels</tt> - 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.
+</ul>
+*/
+public final class LoggingConfigImpl implements LoggingConfig {
+
+ /** See class comment. */
+ public void setup(Map<String, String> 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<Logger> fLoggers = new ArrayList<Logger>();
+ 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 : <tt>C:\log\fish_and_chips\2007_12_31_23_59.txt</tt>
+ */
+ 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();
+ }
+}
--- /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.
+
+ <P>See <tt>web.xml</tt> for more information on how to configure this {@link Filter}.
+
+ <h3>Performance Statistics</h3>
+ This class stores a <tt>Collection</tt> of {@link PerformanceSnapshot} objects in
+ memory (not in a database).
+
+ <P>The presentation of these performance statistics in a JSP is always "one behind" this class.
+ This {@link Filter} examines the response time of each <em>fully processed</em>
+ request. Any JSP presenting the response times, however, is not fully processed <i>from the
+ point of view of this filter</i>, and has not yet contributed to the statistics.
+
+ <P><span class="highlight">It is important to note that {@link Filter} objects
+ must be designed to operate safely in a multi-threaded environment</span>.
+ Using the <a href='http://www.javapractices.com/Topic48.cjp'>nomenclature</a> of
+ <em>Effective Java</em>, this class is 'conditionally thread safe' : the responsibility
+ for correct operation in a multi-threaded environment is <em>shared</em> between
+ this class and its caller. See {@link #getPerformanceHistory} for more information.
+
+<P> If desired, you can use also external tools such as
+<a href='http://www.siteuptime.com/'>SiteUptime.com</a> to monitor your site.
+*/
+public final class PerformanceMonitor implements Filter {
+
+ /**
+ Read in the configuration of this filter from <tt>web.xml</tt>.
+
+ <P>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.
+
+ <P>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).
+
+ <P>The typical task for the caller is iteration over the return value. The caller
+ <b>must</b> synchronize this iteration, by obtaining the lock on the return value.
+ The typical use case of this method is :
+ <PRE>
+ List history = PerformanceMonitor.getPerformanceHistory();
+ synchronized(history) {
+ for(PerformanceSnapshot snapshot : history){
+ //..elided
+ }
+ }
+ </PRE>
+ */
+ public static List<PerformanceSnapshot> 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.
+
+ <P>The queue grows until it reaches a configured maximum length, after which stale
+ items are removed when new ones are added.
+
+ <P>This mutable item must always have synchronized access, to ensure thread-safety.
+ */
+ private static final LinkedList<PerformanceSnapshot> fPerformanceHistory = new LinkedList<PerformanceSnapshot>();
+
+ 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
--- /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.
+
+ <P>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.
+
+ <P>A particular <tt>PerformanceSnapshot</tt> 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 <tt>web.xml</tt>
+ of the example application for more information.)
+ By inspecting the return value of {@link #getEndTime}, <em>the caller
+ determines if a <tt>PerformanceSnapshot</tt> object can still be used</em>, or if a new
+ <tt>PerformanceSnapshot</tt> object must be created for the 'next exposure'.
+
+ <P>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 <tt>web.xml</tt>
+ for more information.
+ */
+ public PerformanceSnapshot(Integer aExposureTime){
+ fMaxUrl = Consts.EMPTY_STRING;
+ fAvgResponseTime = 0L;
+ fMaxResponseTime = 0L;
+ fNumRequests = 0;
+ fExposureTime = aExposureTime.intValue();
+ fEndTime = calcEndTime();
+ }
+
+ /**
+ Return a <tt>PerformanceSnapshot</tt> having no activity.
+ Such objects are used to explicitly 'fill in the gaps' during periods of no activity.
+
+ <P>The returned object has the same exposure time as <tt>aCurrentSnapshot</tt>.
+ Its end time is taken as <tt>aCurrentSnapshot.getEndTime()</tt>, plus the exposure time.
+ All other items are <tt>0</tt> 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 <tt>PerformanceSnapshot</tt> 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 <tt>PerformanceSnapshot</tt> 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.
+
+ <P>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.
+
+ <P>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
+ };
+ }
+}
--- /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.
+
+ <P>Uses the following settings in <tt>web.xml</tt>:
+<ul>
+ <li> <tt>Webmaster</tt> - the 'from' address.
+ <li> <tt>TroubleTicketMailingList</tt> - the 'to' addresses for the support staff.
+ <li> <tt>PoorPerformanceThreshold</tt> - when the response time exceeds this level, then
+ a <tt>TroubleTicket</tt> is sent
+ <li> <tt>MinimumIntervalBetweenTroubleTickets</tt> - throttles down emission of
+ <tt>TroubleTicket</tt>s, where many might be emitted in rapid succession, from the
+ same underlying cause
+</ul>
+
+ <P>The {@link hirondelle.web4j.Controller} will create and send a <tt>TroubleTicket</tt> when:
+ <ul>
+ <li>an unexpected problem (a bug) occurs. The bug corresponds to an unexpected
+ {@link Throwable} emitted by either the application or the framework.
+ <li>the response time exceeds the <tt>PoorPerformanceThreshold</tt> configured in
+ <tt>web.xml</tt>.
+ </ul>
+
+ <P><em>Warning</em>: some e-mail spam filters may incorrectly treat the default content of these trouble
+ tickets (example below) as spam.
+
+ <P>Example content of a <tt>TroubleTicket</tt>, as returned by {@link #toString()}:
+<PRE>
+{@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 = <input type='checkbox' name='false' value='false' readonly notab>
+Servlet init-param: BooleanTrueDisplayFormat = <input type='checkbox' name='true' value='true' checked readonly notab>
+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]...
+ }
+</PRE>
+*/
+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.
+
+ <P>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.
+
+ <P>See example in the class comment.
+ */
+ @Override public String toString(){
+ return fBody.toString();
+ }
+
+ /**
+ Send an email to the <tt>TroubleTicketMailingList</tt> recipients configured in
+ <tt>web.xml</tt>.
+
+ <P>If sufficient time has passed since the last email of a <tt>TroubleTicket</tt>,
+ then send an email to the webmaster whose body is {@link #toString}; otherwise do
+ nothing.
+
+ <P>Here, "sufficient time" is defined by a setting in <tt>web.xml</tt> named
+ <tt>MinimumIntervalBetweenTroubleTickets</tt>. 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<String, String> 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<String, String> sessionMap = new HashMap<String, String>();
+ 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
--- /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 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+ <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+ <meta name="Author" content="Hirondelle Systems">
+ <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+ <title>Webmaster</title>
+</head>
+<body>
+Administrative items for support staff.
+</body>
+</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 @@
+<!doctype html public "-//w3c//dtd html 4.0 transitional//en">
+<html>
+<head>
+ <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
+ <meta name="Author" content="Hirondelle Systems">
+ <meta name="GENERATOR" content="Mozilla/4.76 [en] (WinNT; U) [Netscape]">
+ <title>WEB4J</title>
+</head>
+<body>
+<b>WEB4J</b> is a full stack framework for connecting a browser to a database, using Java.
+It was created by Hirondelle Systems (John O'Hanley).
+
+<div class='opening-quote'>
+ <b>"Great software requires a fanatical devotion to beauty."</b>
+ <div class='author'>- Paul Graham</div>
+</div>
+
+<P>For a comprehensive overview, please see <a href='http://www.web4j.com/Java_Web_Application_Framework_Overview.jsp'>web4j.com</a>.
+
+<P><a href="hirondelle/web4j/doc-files/VersionHistory.html">Version History</a>
+
+<P>WEB4J is open source software.
+It is released under a <a href="hirondelle/web4j/doc-files/LICENSE.txt">BSD license</a>.
+
+</body>
+</html>
Binary file lib/activation.jar has changed
Binary file lib/jsp-api.jar has changed
Binary file lib/junit.jar has changed
Binary file lib/mail.jar has changed
Binary file lib/servlet-api.jar has changed