Adverts
First off two quick notes: 1) although the title claims this is a method to use with f:selectOne tags this should also work with h:selectMany as well. 2) All the code on this page should compile and run. If you want to make the f:selectOneMenu work you have to use the Countries class at the bottom of this page.
I had terrible trouble trying to get a converter to work with an h:selectOneMenu. The problems were practically endless at first and there didn't seem to be any solution. I even considered ditching Java Server Faces for a while. I will first describe the setup I was using and then how I got round the problem.
I have a database table that contains a number of rows that define countries. Each row contains a numerical key and a name thus defining a country. I have written a wrapper class called Country (what a supprise) and a class that acts like a country enumeration called countries (basically I cache the never changing country table in memory). See the code below.
Country.class
public class Country {
private Integer key;
private String name;
public Country() {}
public Country( Integer key, String name ) {
setKey( key );
setName( name );
}
public Integer getKey() { return key; }
public void setKey(Integer key) { this.key = key; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String toString() { return name; }
public boolean equals(Object obj) {
if( !( obj instanceof Country ) ) {
return false;
}
return key.equals( ((Country)obj).key );
}
public int hashCode() {
return key.hashCode();
}
}
Countries.class
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.faces.model.SelectItem;
public class Countries {
protected Logger log = Logger.getLogger( "com.crazysquirrel" );
private Map<Country, Integer> countries =
new TreeMap<Country, Integer>( new CountryComparator<Country>() );
private Map<Integer, Country> reverseCountries = new HashMap<Integer, Country>();
public Countries() {}
public Countries( QueryRunner qr ) {
Connection conn = null;
try {
conn = qr.getDataSource().getConnection();
PreparedStatement query = conn.prepareStatement( "SELECT * FROM countries" );
ResultSet rs = query.executeQuery();
while( rs.next() ) {
Country c = new Country( new Integer( rs.getInt( 1 ) ), rs.getString( 2 ) );
countries.put( c, c.getKey() );
reverseCountries.put( c.getKey(), c );
}
} catch( Exception e ) {
log.log( Level.SEVERE, "Unable to select country information", e );
} finally {
try {
if( conn != null ) {
conn.close();
}
} catch( SQLException sqle ) {
log.log( Level.SEVERE, "Unable to close database connection", sqle );
}
}
}
public Map getCountries() { return countries; }
public void setCountries(Map<Country, Integer> countries) {
this.countries = countries;
}
public Country getCountry( Integer key ) {
return (Country)reverseCountries.get( key );
}
}
A Fragment of JSF Page
<h:selectOneMenu id="country">
<f:selectItems value="#{countries.countries}"/>
</h:selectOneMenu>
This works fine so the next step is to then apply a value binding to the h:selectOneMenu so that the menu shows the correct value selected. Here's where the fun starts and where you have to start making changes to the Countries class.
I am trying to set a Country in a Client so for completeness here is the Client. It's a somewhat cut down version of the one I am using but it will do for the example. It is dealt with as a manged bean with request scope and is called new_client. Note: don't call it new-client (with a dash) as it confuses the hell out of JSF :o).
Client.class
import javax.faces.context.FacesContext;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpSession;
public class Client {
private Integer key;
private Country country;
public Client() {
FacesContext fc = FacesContext.getCurrentInstance();
ServletContext sc = (ServletContext)fc.getExternalContext().getContext();
Countries c = (Countries)sc.getAttribute( ContextBindings.COUNTRIES );
setCountry( c.getCountry( new Integer( 243 ) ) );
}
public Country getCountry() {
return country;
}
public void setCountry(Country country) {
this.country = country;
}
public String add() {
FacesContext fc = FacesContext.getCurrentInstance();
ServletContext sc = (ServletContext)fc.getExternalContext().getContext();
ClientFactory cf = (ClientFactory)sc.getAttribute( ContextBindings.CLIENT_FACTORY );
Integer added = cf.add( this );
Client c = cf.get( added );
HttpSession session = (HttpSession)fc.getExternalContext().getSession( false );
session.setAttribute( SessionBindings.CURRENT_CLIENT, c );
return "added-client";
}
}
My first attempt, and I didn't think this would work was to just dumbly bind the value like this:
<h:selectOneMenu id="country" value="#{new_client.country}">
<f:selectItems value="#{countries.countries}"/>
</h:selectOneMenu>
I wasn't at all supprised when that didn't result in country 243 (England by the way) being selected in the menu. JSF didn't throw an exception either though which I find a little strange. It was fairly obvious to me that I required a converter to convert between the Country that was stored in the client and the Integer that was being used as the value in the generated option (I want to call it a key but in HTML parlance it is a value and label combination). So I wrote a Converter:
CountryKeyConverter.class
import java.util.logging.Logger;
import javax.faces.application.FacesMessage;
import javax.faces.component.UIComponent;
import javax.faces.context.FacesContext;
import javax.faces.convert.Converter;
import javax.faces.convert.ConverterException;
import javax.servlet.ServletContext;
public class CountryKeyConverter implements Converter {
protected Logger log = Logger.getLogger( "com.crazysquirrel" );
public CountryKeyConverter() {}
public String getAsString( FacesContext facesContext,
UIComponent uiComponent, Object obj) {
log.severe( "Object recieved was: " + obj + " Type: " + obj.getClass().getName() );
return ((Country)obj).getKey().toString();
}
public Object getAsObject( FacesContext facesContext,
UIComponent uIComponent, String str) throws ConverterException {
FacesContext fc = FacesContext.getCurrentInstance();
ServletContext sc = (ServletContext)fc.getExternalContext().getContext();
Countries c = (Countries)sc.getAttribute( ContextBindings.COUNTRIES );
Country country = null;
try {
country = c.getCountry( new Integer( str ) );
} catch( NumberFormatException nfe ) {
FacesMessage message = new FacesMessage( FacesMessage.SEVERITY_ERROR,
"Unknown Country", "The value supplied was not an country key" );
throw new ConverterException( message );
}
if( country == null ) {
FacesMessage message = new FacesMessage( FacesMessage.SEVERITY_ERROR,
"Unknown Country", "The country value chosen was not recognized" );
throw new ConverterException( message );
}
return country;
}
}
I then registered in faces-config.xml
<converter> <converter-id>com.crazysquirrel.CountryKeyConverter</converter-id> <converter-class>com.crazysquirrel.CountryKeyConverter</converter-class> </converter>
and set about using it
<h:selectOneMenu id="country" value="#{new_client.country}"
converter="com.crazysquirrel.CountryKeyConverter">
<f:selectItems value="#{countries.countries}"/>
</h:selectOneMenu>
This is where it stopped working though :o(. When ever I loaded the page I would get this exception (this is the root cause).
java.lang.ClassCastException: java.lang.String com.crazysquirrel.CountryKeyConverter. getAsString(CountryKeyConverter.java:31) com.sun.faces.renderkit.html_basic.HtmlBasicRenderer. getFormattedValue(HtmlBasicRenderer.java:278) com.sun.faces.renderkit.html_basic.MenuRenderer. renderOption(MenuRenderer.java:539) com.sun.faces.renderkit.html_basic.MenuRenderer. renderOptions(MenuRenderer.java:525) com.sun.faces.renderkit.html_basic.MenuRenderer. renderSelect(MenuRenderer.java:481) com.sun.faces.renderkit.html_basic.MenuRenderer. encodeEnd(MenuRenderer.java:430) ...more...
The Solution
After much head scratching and diagnosis code I knew what was going on but not why. Every single one of the Country instances that was stored in countries was being passed to the converter getAsString method. The strange thing though was that what was being passed in was a String representation of the Integer key (have a look at Countries above - notice the map is storing the value and an Integer not a String). From reading Core JSF I didn't expect these values to ever actually see the Converter but I could cope with the fact they were. I figured that I was probably mixing my types a bit here. On the one hand I was trying to set the HTML option value with an Integer in Countries and a Country from client and that probably wasn't going to work so I changed the Map in Countries to accept a Country for both its key and value thus:
new TreeMap<Country, Country>( new CountryComparator<Country>() );
and then populated as you would expect. I expected it to then automatically build options like this:
However that doesn't seem to be what actually happens. Instead when you use the automagical map builder it seems to call toString on your map values and use that String as the first argument to the new SelectItem. The only way to get round that is to create your own SelectItem array or List which you then pass to the h:selectItems tag. I can sort of understand why this is the case. The JSF frame work doesn't have to go hunting for converters for arbitary types because it can just treat it as a String but there isn't a lot of warning that this is going to happen. More importantly though the only way to get around this behaviour is to provide your own SelectItem instances which you have created with something other than a String as the first argument. This then couples your code to the JSF RI which is a pain if you ever want to move. The complete and correct Countries class and JSF fragment can be seen below.
The Corrected Fragment
<h:selectOneMenu id="country" value="#{new_client.country}"
converter="com.crazysquirrel.CountryKeyConverter">
<f:selectItems value="#{countries.countryItems}"/>
</h:selectOneMenu>
The Corrected Countries Class
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.faces.model.SelectItem;
public class Countries {
protected Logger log = Logger.getLogger( "com.crazysquirrel" );
private List<SelectItem> countryItems = new LinkedList<SelectItem>();
private Map<Integer, Country> countries = new HashMap<Integer, Country>();
/** Creates a new instance of Countries */
public Countries() {}
public Countries( QueryRunner qr ) {
Connection conn = null;
try {
conn = qr.getDataSource().getConnection();
PreparedStatement query = conn.prepareStatement( "SELECT * FROM countries" );
ResultSet rs = query.executeQuery();
while( rs.next() ) {
Country c = new Country( new Integer( rs.getInt( 1 ) ), rs.getString( 2 ) );
countryItems.add( new SelectItem( c, c.getName() ) );
countries.put( c.getKey(), c );
}
} catch( Exception e ) {
log.log( Level.SEVERE, "Unable to select country information", e );
} finally {
try {
if( conn != null ) {
conn.close();
}
} catch( SQLException sqle ) {
log.log( Level.SEVERE, "Unable to close database connection", sqle );
}
}
}
public Country getCountry( Integer key ) {
return (Country)countries.get( key );
}
public List<SelectItem> getCountryItems() {
return countryItems;
}
public void setCountryItems(List<SelectItem> countryItems) {
this.countryItems = countryItems;
}
}