Java tutorial
//| Copyright - The University of Edinburgh 2011 | //| | //| Licensed under the Apache License, Version 2.0 (the "License"); | //| you may not use this file except in compliance with the License. | //| You may obtain a copy of the License at | //| | //| http://www.apache.org/licenses/LICENSE-2.0 | //| | //| Unless required by applicable law or agreed to in writing, software | //| distributed under the License is distributed on an "AS IS" BASIS, | //| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.| //| See the License for the specific language governing permissions and | //| limitations under the License. | /******************************************************************************* * Copyright (c) - The University of Edinburgh 2010 *******************************************************************************/ package uk.ac.ed.epcc.webapp.model.data; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.lang.ref.SoftReference; import java.sql.Blob; import java.sql.Connection; import java.sql.DatabaseMetaData; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.SQLException; import java.sql.Statement; import java.sql.Timestamp; import java.sql.Types; import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Collection; import java.util.Date; import java.util.HashMap; import java.util.HashSet; 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 org.apache.commons.codec.binary.Base64; import uk.ac.ed.epcc.webapp.AppContext; import uk.ac.ed.epcc.webapp.AppContextCleanup; import uk.ac.ed.epcc.webapp.Feature; import uk.ac.ed.epcc.webapp.Indexed; import uk.ac.ed.epcc.webapp.exceptions.ConsistencyError; import uk.ac.ed.epcc.webapp.jdbc.DatabaseService; import uk.ac.ed.epcc.webapp.jdbc.SQLContext; import uk.ac.ed.epcc.webapp.jdbc.exception.DataException; import uk.ac.ed.epcc.webapp.jdbc.filter.OrderClause; import uk.ac.ed.epcc.webapp.logging.LoggerService; import uk.ac.ed.epcc.webapp.model.data.Exceptions.DataError; import uk.ac.ed.epcc.webapp.model.data.Exceptions.DataFault; import uk.ac.ed.epcc.webapp.model.data.Exceptions.DataNotFoundException; import uk.ac.ed.epcc.webapp.model.data.convert.TypeProducer; import uk.ac.ed.epcc.webapp.model.data.reference.IndexedProducer; import uk.ac.ed.epcc.webapp.model.data.reference.IndexedTypeProducer; import uk.ac.ed.epcc.webapp.model.data.stream.BlobStreamData; import uk.ac.ed.epcc.webapp.model.data.stream.ByteArrayMimeStreamData; import uk.ac.ed.epcc.webapp.model.data.stream.ByteArrayStreamData; import uk.ac.ed.epcc.webapp.model.data.stream.StreamData; import uk.ac.ed.epcc.webapp.timer.TimerService; /** <code>Repository</code> encapsulates a Database table. * * This class is used to hold the * Connection (via the <code>AppContext</code>) and to cache information about the table * including the name and meta-data. Actual rows are stored in <code>Record</code> objects * which are inner classes of <code>Repository</code> to allow them to access the cached * information. * <p> * Each record is assumed to have a unique integer identifier that can be used to reference the * appropriate table record. * The {@link FieldInfo} class records information about each field in the table. This class attempts to capture * where a field is used to reference the integer identifier of a different table. This can be determined automatically * where there is a foreign key constraint or can be specified using properties. A property * <b>reference.</b><em>repository-tag</em><b>.</b><em>DBfield</em> defines the name of the table the field references. * If a handler class is registered for the remote table this is used to create a {@link TypeProducer} for the field. * Alternatively a reference can be registered explicitly with the Repository by registering a {@link TypeProducer} for the field. * This will reduce overhead where an instance of the remote handler class is already available. * <p> * At the moment the integer identifier is implemented by requiring each * tables to have a primary key consisting of a mysql * auto_increment field if the is more than one auto_increment field the first * is taken. This dependency on mysql specific features is encapsulated within * the <code>MysqlRepository</code> class and could be replaced by a different class. * Alternative DB back-ends can be less flexible than mysql when it comes to table and field names * so SQL statements should be constructed using the addUniqueName addTable and FieldInfo.addName * method calls as this allows the Repository class to perform name mangling via the following properties: * <ul> * <li><b>table.</b><em>tag</em> defines the table name corresponding to tag. Defaults to tag</li> * <li><b>rename.</b><em>table-name.field-name</em> defines the name the java code uses to refer to a database field. Defaults to field-name</li> * </ul> * The {@link #getParamTag()} method returns a String to be used in looking up configuration parameters for the * enclosing object. This defaults to tag but can be overridden by setting the <b>config.</b><em>tag</em> property. * * * <p> * New <code>Repository</code> objects are obtained using a static method. * <code> <pre> AppContext c; Repository rep = Repository.getInstance(c,tag_name); </pre> </code> * * Repositories created in this way are cached in the <code>AppContext</code> so only a single <code>Repository</code> * for each tag is ever created per <code>AppContext</code>. The tag is normally the same as the database table * and should match the tag used to create the corresponding {@link DataObjectFactory} from the AppContext. However the tag should not * be used as a table name in SQL as it is possible * for table renaming and name-mangling to occur. * <p> * Repository can implement a cache of Record data based on id. This is enabled on a table by table basis via the * <em>cache.<i>tag-name</i></em> property. * The cache is populated * whenever <code>SetContents</code> is called with the record ejected whenever <code>put</code> is called. * This is to ensure the cache never contains a dirty record. * The <code>setID</code> checks for cached data and populates itself by copying the cached record instead of fetching a database record. * * The cache only holds non-dirty data so could hold normal Map objects rather than actual Records. * Currently this is left as a future optimisation. * <p> * Note that lookups via a {@link TypeProducer} may also utilise this caching mechanism. * <p> * Repositories are intended to be local to a parent AppContext which should represent a * single thread of execution. However synchronisation is implemented within the code. * The cost of this should be quite low and it also seems to prevent optimisation bugs in some JVMs * <p> * The Repository class implements some additional automatic type conversions beyond those implemented in * JDBC. * <ul> * <li> Boolean values can be stored as Strings taking the (<b>T/F</b> or <b>True/False</b>).</li> * <li>Date time values can be stored as integers. These are always relative to the Unix epoch and default to * being seconds. A different time unit can be selected by setting <b>repository.resolution.<i>table-name</i></b> to be the * number of milliseconds in the desired unit.</li> * <li>StreamData values are always added as binary streams so they can be stored in blob types.</li> * </ul> * * If a string attribute {@link Repository#BACKUP_SUFFIX_ATTR} is stored in the {@link AppContext} * then this name will be used as a table name suffix to create backup tables where deleted records will be * copied before being deleted in the main table. This is intended to allow * old data to be backed up as part of a purge of data from a live table. * @author spb * */ public final class Repository implements AppContextCleanup { /** modes supported by {@link Record#setID} * * @author spb * */ public enum IdMode { RequireExisting, UseExistingIfPresent, IgnoreExisting }; /** * */ public static final String BACKUP_SUFFIX_ATTR = "BackupSuffix"; /** * */ public static final String REFERENCE_PREFIX = "reference."; /** * */ private static final String USE_ID_PREFIX = "use_id."; public static final String CACHE_FEATURE_PREFIX = "cache."; private static final int DEFAULT_RESOLUTION = 1000; public static final Feature REQUIRE_ID_KEY = new Feature("require.id_key", true, "Require all tables to have an integer primary key"); public static final Feature READ_ONLY_FEATURE = new Feature("read-only", false, "supress (most) database writes"); public static final Feature BACKUP_WITH_SELECT = new Feature("repository.backup_by_select", true, "Use select/insert when backing up a record"); private static final DateFormat dump_format = new SimpleDateFormat("dd-MM-yyyy HH:mm:ss SSS"); /** information about indexes * * @author spb * */ public class IndexInfo { private final String name; private final boolean unique; private final ArrayList<String> cols; public IndexInfo(String name, boolean unique) { this.name = name; this.unique = unique; cols = new ArrayList<String>(); } public String getName() { return name; } public boolean getUnique() { return unique; } public Iterator<String> getCols() { return cols.iterator(); } void addCol(int pos, String name) { pos--; while (pos > cols.size()) { cols.add(""); } cols.add(pos, name); } } /** Information about fields */ public class FieldInfo { /** field name as stored in database*/ private final String name; /** SQL type of field */ private final int type; /** max display width of field */ private final int max; private final boolean can_null; /** Name of the table this field references if known; * */ private String references = null; /** Is there a foreign key for this reference * */ private boolean indexed = false; /** name of the foreign key. * */ private String key_name = null; /** TypeProducer for the table this field references (if known) * */ private TypeProducer producer = null; private FieldInfo(String name, int type, int max, boolean can_null) { this.name = name; this.type = type; this.max = max; this.can_null = can_null; } /** get the max display width for the field * * @return int width */ public int getMax() { return max; } /** Can this field be null * * @return boolean true if can be null. */ public boolean getNullable() { return can_null; } /** return column type as generated by getColumnType from {@link ResultSet} * * @return int */ public int getType() { return type; } /** get the name of the field. * We don't support quoting in this method so it can be optimised for speed. * Use addName when constructing a query where quoting may be required. * * * @param qualify * @return String field name */ public String getName(boolean qualify) { if (qualify) { return addName(new StringBuilder(), qualify, false).toString(); } return name; } /** Add the field name to a StringBuilder * * @param sb StringBuilder to append to * @param qualify boolean Should field name be qualified with table * @param quote boolean apply quoting for SQL fragment * @return modified StringBuilder */ public StringBuilder addName(StringBuilder sb, boolean qualify, boolean quote) { if (qualify) { if (quote) { sql.quoteQualified(sb, alias_name, name); } else { sb.append(alias_name); sb.append("."); sb.append(name); } } else { if (quote) { sql.quote(sb, name); } else { sb.append(name); } } return sb; } public boolean isNumeric() { return type == Types.DOUBLE || type == Types.FLOAT || type == Types.INTEGER || type == Types.BIGINT || type == Types.REAL; } public boolean isData() { return type == Types.BLOB || type == Types.VARBINARY || type == Types.LONGVARBINARY; } /** Get the tag that will-be/has-been used to create the referenced {@link IndexedTypeProducer}. * * if the producer has been installed explicitly this will return null * because there is no guarantee the producers tag can be used to * construct a {@link TypeProducer}. * So always use {@link #getTypeProducer()} * in preference to constructing via this tag. * * @return */ public String getReferencedTable() { if (references != null) { return references; } if (producer != null && producer instanceof IndexedTypeProducer) { return ((IndexedTypeProducer) producer).getInnerTag(); } return null; } @SuppressWarnings("unchecked") public TypeProducer getTypeProducer() { if (producer == null) { if (references != null) { Class<? extends IndexedProducer> clazz = getContext().getPropertyClass(IndexedProducer.class, null, references); if (clazz != null) { // use lazy creation constructor producer = new IndexedTypeProducer(name, getContext(), clazz, references); } } } return producer; } public boolean isString() { return type == Types.CHAR || type == Types.VARCHAR || type == Types.LONGVARCHAR; } public boolean isDate() { return type == Types.DATE || type == Types.TIMESTAMP || type == Types.TIME; } public boolean isBoolean() { return type == Types.BIT || type == Types.TINYINT; } public boolean isIndexed() { return indexed; } public String getForeignKeyName() { return key_name; } private void setReference(boolean indexed, String fk, String table) { this.indexed = indexed; this.key_name = fk; references = table; } private void setTypeProducer(TypeProducer producer) { if (producer.getField().equals(name)) { this.producer = producer; } else { throw new ConsistencyError("Producer field does not match"); } } public boolean isReference() { return producer != null || references != null; } /** method to dump the field value to a canonical text * representation. * * @param r * @return * @throws DataFault * @throws IOException */ String dump(Record r) throws DataFault, IOException { if (r.getProperty(name) == null) { return null; } if (isString()) { return r.getStringProperty(name); } else if (isBoolean()) { return Boolean.toString(r.getBooleanProperty(name)); } else if (isDate()) { return dump_format.format(r.getDateProperty(name)); } else if (isNumeric()) { return r.getNumberProperty(name).toString(); } else if (isData()) { ByteArrayOutputStream stream = new ByteArrayOutputStream(); StreamData data = r.getStreamDataProperty(name); data.write(stream); return Base64.encodeBase64String(stream.toByteArray()); } return null; } /** method to set a field value from the canonical text representation * * @param r * @param text */ void unDump(Record r, String text) throws Exception { if (text == null) { return; } if (isString()) { r.setProperty(name, text); } else if (isBoolean()) { r.setProperty(name, Boolean.parseBoolean(text)); } else if (isDate()) { try { r.setProperty(name, dump_format.parse(text)); } catch (ParseException e) { // try a numeric timestamp r.setProperty(name, Long.parseLong(text)); } } else if (isNumeric()) { try { r.setProperty(name, Integer.parseInt(text)); return; } catch (NumberFormatException e) { } try { r.setProperty(name, Long.parseLong(text)); return; } catch (NumberFormatException e) { } try { r.setProperty(name, Double.parseDouble(text)); return; } catch (NumberFormatException e) { } // Try a date r.setProperty(name, dump_format.parse(text)); } else if (isData()) { ByteArrayStreamData data = new ByteArrayStreamData(Base64.decodeBase64(text)); r.setProperty(name, data); } } } /** Record encapsulates a Database record. * It is essentially a Map container where database field names are used as * the key and the field data is the value referenced. * <p> * In general this code is fairly flexible as to the type of objects stored in * the record. The type of object returned from the DB depends pretty much on * the DB layer and the DB schema. Note that different DB drivers will handle * the same schema in different ways, for example different mysql * drivers/versions returned Integers or Longs for the same field. * <p> * Record supports a default conversion between integer and Date properties/Fields * based on the Repository Resolution property. The resolution defaults to 1 second * but can be set using the <b>repository.resolution.<it>[table-name]</it></b> property. Provided that the * java side consistently uses Date or integers the java code does not need to know the * conversion factor explicitly. * <p> * Records are always created empty. * They can then either be populated using a ResultSet or created from * scratch using put/get methods. An existing database record can be * retrieved by using the <code>setID</code> method on an empty Record. * In all cases no changes are written to * the database until the <code>commit()</code> method is called. * <p> * The class also supports get/set Property methods that support * additional type conversions and casting. These are more useful than the * <code>put</code>/<code>get</code> calls in the <code>Map</code> interface * as the type of Object returned from the map may change if the database schema (or JDBC driver) * is changed. * <p> * For Example: <code> <pre> Repository res = Repository.getInstance(ctx,"my_table"); Record rec = res.new Record(); rec.setProperty("Number",12.0); rec.commit(); // create record rec.setProperty("Number",24.0); rec.commit(); // update record Record rec2 = res.new Record(); rec2.setID(rec.getID()); // retrieve new copy from DB by ID. double d = rec2.getDoubleProperty("Number"); // should be 24.0 </pre> </code> * *In many cases the values of a database field are actually codes denoting one of a *finite set of objects.In this case a class implementing <code>TypeProducer</code> should be *used to express this mapping. There are special <code>getProperty</code> and <code>setProperty</code> *calls that use this interface. *Java Enum types can be supported using <code>EnumProducer</code> * where the text value of the Enum will be stored in the database. * however much of the existing code base uses sub classes of <code>BasicType</code> to implement * type-safe enumeration. * *<p> * * The intention is that this class is more database facing and that model * classes contain an instance of Record rather than sub-classing it. * * * @author spb * */ public final class Record extends HashMap<String, Object> { /** * */ private static final long serialVersionUID = 2L; /** does this Record exist in the database */ private boolean have_id = false; private int id; private Set<String> dirty = null; public Record() { super(); } /** * clear all dirty state. * * */ synchronized private void clean() { if (dirty != null) { dirty.clear(); } } /** * reset to uninitialised state. * */ @Override public synchronized void clear() { deCache(); super.clear(); clean(); have_id = false; id = 0; } @SuppressWarnings("unchecked") @Override public synchronized Object clone() { Record copy = (Record) super.clone(); if (have_id) { copy.have_id = true; copy.id = id; } if (dirty != null) { copy.dirty = new HashSet(dirty); } return copy; } /** * commit changes made to this object to the * * @return boolean true if change made * @throws uk.ac.ed.epcc.webapp.model.data.Exceptions.DataFault * * */ public synchronized boolean commit() throws uk.ac.ed.epcc.webapp.model.data.Exceptions.DataFault { // Don't check for dirty flags here but in update // It should be legal to commit a new object without setting any // fields boolean changed = false; try { if (have_id) { // write changes to existing record changed = update(); assert (id > 0); assert (have_id); // clear cache if we have modified the DB. if (changed && use_cache) { ejectCache(id); } assert (id > 0); assert (have_id); } else { // create new record int tmp = insert(this); if (use_id) { setInitialID(tmp); changed = true; assert (id > 0); assert (have_id); } } // reset dirty flags. clean(); } catch (Exception e) { throw new DataFault("Error in commit", e); } assert (id > 0); assert (have_id); return changed; } /** Set this record to the same state as an existing Record * * @param r */ @SuppressWarnings("unchecked") synchronized void copy(Record r) { // We allow copy to backup tables. if (Repository.this != r.getRepository()) { throw new ConsistencyError("copying Record from different repository"); } clear(); putAll(r); if (r.have_id) { have_id = true; id = r.id; } if (r.dirty != null) { dirty = new HashSet(r.dirty); } } public void backup() throws DataFault { Repository store = getBackup(); if (store == null) { return; } if (BACKUP_WITH_SELECT.isEnabled(getContext())) { StringBuilder sb = new StringBuilder(); try { sb.append("REPLACE INTO "); store.addTable(sb, true); sb.append(" SELECT * FROM "); getRepository().addSource(sb, true); sb.append(" WHERE "); getRepository().addUniqueName(sb, true, true); sb.append("=?"); PreparedStatement stmt = sql.getConnection().prepareStatement(sb.toString()); stmt.setInt(1, getID()); stmt.executeUpdate(); return; } catch (SQLException e) { throw new DataFault("Error in backup " + sb.toString(), e); } } Record b = store.new Record(); try { b.setID(getID(), IdMode.IgnoreExisting); } catch (DataException e) { // should not get this if require existing is false throw new ConsistencyError("unexpected exception", e); } b.putAll(this); b.commit(); } /** Get a map of the contents without the UniqueID field * * @return Map */ public Map<String, Object> getValues() { Map<String, Object> res = new HashMap<String, Object>(); for (String key : getFields()) { if (!key.equals(getUniqueIdName())) { Object value = get(key); if (value != null) { res.put(key, value); } } } return res; } /** * delete the corresponding database entry and restore the Record to * uninitialised state. * * @throws ConsistencyError * @throws DataFault */ public synchronized void delete() throws ConsistencyError, DataFault { if (!have_id) { clear(); return; } backup(); try { Connection conn = sql.getConnection(); if (conn == null) { throw new DataFault("No connection"); } deCache(); if (Repository.READ_ONLY_FEATURE.isEnabled(getContext())) { return; } StringBuilder sb = new StringBuilder(); sb.append("DELETE FROM "); addTable(sb, false); sb.append(" WHERE "); addUniqueName(sb, false, true); sb.append("="); sb.append(getID()); Statement stmt = conn.createStatement(); String query = sb.toString(); int results = stmt.executeUpdate(query); if (DatabaseService.LOG_UPDATE.isEnabled(getContext())) { LoggerService serv = getContext().getService(LoggerService.class); if (serv != null) { serv.getLogger(getClass()).debug("delete query is " + query); } } stmt.close(); // Destroy the connection to reduce strangeness of mis-use boolean ok = (results == 1); if (ok) { clear(); } } catch (SQLException e) { throw new DataFault("SQL Exception", e); } } /** * Compare with a Map of values for all the fields they have in common * test for equality. * * @param r * Map to compare to * @return return false if any common fields are different. */ public boolean equals(Map<String, Object> r) { for (Iterator<String> it = getFields().iterator(); it.hasNext();) { String key = it.next(); if (r.containsKey(key)) { Object o1 = get(key); Object o2 = r.get(key); if (o1 != null) { if (o2 == null || !compare(o1, o2)) { return false; } } else { if (o2 != null) { return false; } } } } return true; } /** * Query a property that should contain a single Y/N character and retrun * this as a boolean. A missing property is returned as true * * @param name * String name of property * @return boolean true for Y */ public final boolean getBooleanProperty(String name) { return getBooleanProperty(name, true); } /** * Query a property that should contain a single Y/N character and retrun * this as a boolean. A missing property is returned as the default value * * * @param name * String name of property * @param default_value * value to return for missing property * @return boolean true for Y */ public final boolean getBooleanProperty(String name, boolean default_value) { return convertBoolean(getProperty(name), default_value).booleanValue(); } public final Date getDateProperty(String name) { Object o = getProperty(name); return convertDate(o); } /** * get a property as a double value * * @param name * String Property to get * @return double value */ public final double getDoubleProperty(String name) { return getDoubleProperty(name, 0.0); } public final double getDoubleProperty(String name, double default_value) { Number n = getNumberProperty(name); if (n == null) { return default_value; } return n.doubleValue(); } /** * get a property as a float value * * @param name * String Property to get * @return float value */ public final float getFloatProperty(String name) { return getFloatProperty(name, 0.0f); } public final float getFloatProperty(String name, float default_value) { Number n = getNumberProperty(name); if (n == null) { return default_value; } return n.floatValue(); } public final int getIntProperty(String name) { return getIntProperty(name, -1); } public final int getIntProperty(String name, int default_value) { Number n = getNumberProperty(name); if (n == null) { return default_value; } return n.intValue(); } /** * get the unique id value for this Record * * @return int id * @throws ConsistencyError * if id not defined yet */ public final int getID() throws ConsistencyError { if (have_id) { return id; } throw new ConsistencyError("Cannot get ID of uncommited object"); } /** * get a property as a long value * * @param name * String Property to get * @return int value */ public final long getLongProperty(String name) { return getLongProperty(name, 0L); } public final long getLongProperty(String name, long default_value) { Number n = getNumberProperty(name); if (n == null) { return default_value; } return n.longValue(); } public final Number getNumberProperty(String name) { Object o = getProperty(name); return convertNumber(o); } /** * Return the .Value associated with the specified TypeProducer * @param <F> The type of value returned * @param <D> Type stored in DB * * @param t * @return TheValue */ @SuppressWarnings("unchecked") public final <F, D> F getProperty(TypeProducer<F, D> t) { return t.find((D) getProperty(t.getField())); } /** * Return the .Value associated with the specified TypeProducer * @param <F> The type of value returned * @param <D> Type stored in DB * * @param t * @param def default result * @return TheValue */ @SuppressWarnings("unchecked") public final <F, D> F getProperty(TypeProducer<F, D> t, F def) { D val = (D) getProperty(t.getField()); if (val == null) { return def; } try { return t.find(val); } catch (Exception e) { getContext().error(e, "Error converting via TypeProducer"); return def; } } /** * Returns the value associated with this database column name * * Where possible you should use one of the get<Type>Property methods as * these will take care of any conversion from the underlying database * object. * * @param name * The name of the field * @return an Object representing the value of the field */ public final Object getProperty(String name) { return get(name); } /** * Returns the value associated with this database column name Unlike * getProperty the returned value should never be null to additional error * reporting is invoked if it is. * * @param name * The name of the field * @return an Object representing the value of the field */ public final Object getRequiredProperty(String name) { Object o = getProperty(name); if (o == null) { throw new ConsistencyError("Error retrieving required property " + name + " from " + getClass().getName() + ":" + getID()); } return o; } public final StreamData getStreamDataProperty(String name) throws DataFault { Object o = getProperty(name); if (o instanceof StreamData) { return (StreamData) o; } if (o == null) { return null; } if (o instanceof String) { return new ByteArrayMimeStreamData(((String) o).getBytes()); } // assume a byte array return new ByteArrayStreamData((byte[]) o); } /** * get a property as a string. * * @param name * Property to get * @return String value * @throws */ public final String getStringProperty(String name) { Object o = getProperty(name); if (o == null) return null; if (o instanceof String) return (String) o; if (o instanceof StreamData) { StreamData s = (StreamData) o; ByteArrayOutputStream dat = new ByteArrayOutputStream(); try { s.write(dat); } catch (Exception e) { getContext().error(e, "error converting StreamData to string"); return null; } return dat.toString(); } if (o instanceof byte[]) { return new String((byte[]) o); } return o.toString(); } /** * get a property value as a string with a default value to return if the * property is not set or the string is empty * * @param name * @param default_value * @return String */ public final String getStringProperty(String name, String default_value) { String s = getStringProperty(name); if (s == null || s.length() == 0) { return default_value; } return s; } /** * get the enclosing Repository * * @return the Repository used to create this Record. */ public Repository getRepository() { return Repository.this; } boolean hasID() { return have_id; } /** * is the object in any way modified. * * @return boolean */ synchronized boolean isDirty() { return dirty != null && !dirty.isEmpty(); } /** * is the specified field modified. * * @param key * @return boolean */ synchronized boolean isDirty(String key) { if (dirty == null) { return false; } return dirty.contains(key); } @Override public Object put(String key, Object value) { return put(key, value, allow_bogus_put); } synchronized private Object put(String key, Object value, boolean optional) { // if( (! have_id || ! allow_null_value ) && value == null ){ // // never allow null in initial object // throw new UnsupportedOperationException("Null value stored in "+key); // } if (!hasField(key)) { if (optional && !key.equals(id_name)) { // this allows us to temporarily cache values for example store the name of // an object and later convert to to a DB reference return super.put(key, value); } // this includes attempts to put new values for the ID field throw new UnsupportedOperationException( "Invalid field specified " + getTable() + "." + key + ":" + value); } // Length check for optional if (optional && value instanceof String) { FieldInfo info = getInfo(key); if (info.isString() && info.getMax() < ((String) value).length()) { // Skip optional strings that are too long return get(key); } } if (!isDirty()) { // once dirty it should not be in cache. I'm assuming the dirty check is slightly cheaper // than deCache as there is no synchronisation deCache(); // do this before potentially modifying state to avoid race condition } value = convert(key, value); Object previous = super.put(key, value); if (!compare(previous, value)) { setDirty(key, true); } return previous; } /** Force contents to a non dirty value * * @param key * @param value * @return */ private Object rawPut(String key, Object value) { setDirty(key, false); return super.put(key, value); } /* * (non-Javadoc) * * @see java.util.HashMap#putAll(java.util.Map) */ @Override public void putAll(Map<? extends String, ? extends Object> m) { // only insert valid keys for (Iterator<String> it = getFields().iterator(); it.hasNext();) { String key = it.next(); if (m.containsKey(key)) { put(key, m.get(key)); } } } synchronized public Object remove(String key) { if (!hasField(key)) { // this includes attempts to put new values for the ID field throw new UnsupportedOperationException("Invalid field specified"); } Object o = super.remove(key); if (o != null) { setDirty(key, true); } return o; } /** * Like putAll but does full synchronisation removing fields that are * missing as well. * * @param m * Map to sync to */ public void set(Map<? extends String, ? extends Object> m) { // only insert valid keys for (Iterator<String> it = getFields().iterator(); it.hasNext();) { String key = it.next(); if (m.containsKey(key)) { put(key, m.get(key)); } else { remove(key); } } } /** * populate an object from a ResultSet * * @param rs * ResultSet * @throws DataFault * * @throws ConsistencyError */ void setContents(ResultSet rs) throws DataException { setFromResultSet(this, rs, false); } /** * populate an object from a ResultSet * * It seems to work if we always qualify the field names but its slower * * @param rs * ResultSet * @param qualify * boolean qualify the field names with the table name as * ResultSet is from a join * @throws DataFault * * @throws ConsistencyError */ void setContents(ResultSet rs, boolean qualify) throws DataException { setFromResultSet(this, rs, qualify); } /** * mark the dirty state (does it need flushing to the DB) for a * specified field * * @param key * @param val */ synchronized void setDirty(String key, boolean val) { if (dirty == null) { if (!val) { return; } dirty = new HashSet<String>(); } if (val) { dirty.add(key); } else { dirty.remove(key); } } private final void deCache() { if (have_id && use_cache) { // this may be the same object as was in the cache // so eject it. Not worth checking if it really is the same. ejectCache(id); } } protected void setInitialID(int id) { this.id = id; // Easier to test value comparison if ID not stored in hash super.put(getUniqueIdName(), Integer.valueOf(id)); have_id = true; } /** * * This method returns a reference to itself so we can initialise the record * as part of a constructor. * * @param id2 * @return reference to self * @throws DataException */ public Record setID(int id2) throws DataException { return setID(id2, IdMode.RequireExisting); } /**initialise a record using the id integer. * * If an existing record is not required then this will set the id that should be used when the * record is eventually inserted. Care needs to be taken to ensure the id is not created between this call and the commit. * * This method returns a reference to itself so we can initialise the record * as part of a constructor. * * @param id2 * @param require_existing * @return * @throws DataException */ Record setID(int id2, IdMode mode) throws DataException { if (have_id) { throw new ConsistencyError("Resetting id of Record"); } if (!use_id) { throw new ConsistencyError("Setting id on non indexed table"); } if (use_cache && mode == IdMode.RequireExisting) { synchronized (Repository.this) { Map<Integer, Record> cache = getCache(); if (cache != null) { Record peer = cache.get(id2); if (peer != null) { assert (peer.id == id2 && !peer.isDirty()); // We have to copy fully. If the cache and this object hold any data in common // then we could hit problems if one instance is modified when the other should not be. // e.g one instance is changed but decides not to commit the other may call commit later copy(peer); assert (id == id2); } else { // set contents will store in cache. populate(id2, true); } } else { // set contents will store in cache. populate(id2, true); } cache = null; } } else { if (mode == IdMode.IgnoreExisting) { // just remember desired id id = id2; } else { populate(id2, mode == IdMode.RequireExisting); } } assert (id == id2); if (id != id2) { // extra debug as assertion failed once throw new ConsistencyError( "Error setting Record ID " + getTag() + " " + id + "!=" + id2 + " cache " + use_cache); } return this; } private void populate(int id2, boolean require_existing) throws DataException { ResultSet set = findRecord(id2, require_existing); if (set != null) { setContents(set); } else { id = id2; // remember id we want to use. } } /** Store data in record * * @param key * @param value */ public final void setProperty(String key, Object value) { put(key, value); } /** * Set a property where it is acceptable for the property not to exist in the * underlying database. * * @param name * @param value */ public final void setOptionalProperty(String name, Object value) { put(name, value, true); } /** * Set the property associated with a {@link TypeProducer} * @param <F> Type corresponding to the {@link TypeProducer} * @param <D> Type stored in Database * * @param t * the BasicType to set * @param v * The Value to set. */ public final <F, D> void setProperty(TypeProducer<? super F, D> t, F v) { D tag = t.getIndex(v); if (tag != null) { setProperty(t.getField(), tag); } else { throw new IllegalArgumentException(v.toString() + " wrong type for " + t.getField()); } } /** * Set the property associated with a {@link TypeProducer} * where it is acceptable for the property not to exist in the * underlying database * @param <F> Type corresponding to {@link TypeProducer} * @param <D> Type stored in Database * * @param t * the BasicType to set * @param v * The Value to set. */ public final <F, D> void setOptionalProperty(TypeProducer<? super F, D> t, F v) { D tag = t.getIndex(v); if (tag != null) { setOptionalProperty(t.getField(), tag); } else { throw new IllegalArgumentException(v.toString() + " wrong type for " + t.getField()); } } /** * store a Y/N value based on boolean input. * This allows single character fields to be used to store boolean values. * A true boolean field will also work as the strings will be converted appropriately * @see Repository#convertBoolean(Object, Boolean) * * @param name * String property to set * @param val * boolean value */ public final void setProperty(String name, boolean val) { if (val) { setProperty(name, "Y"); } else { setProperty(name, "N"); } } /** * set a property to a double value * * @param name * String property to set. * @param val */ public final void setProperty(String name, double val) { setProperty(name, new Double(val)); } /** * set a property to an float value * * @param name * String property to set. * @param val */ public final void setProperty(String name, float val) { setProperty(name, new Float(val)); } /** * set a property to an integer value * * @param name * String property to set. * @param val */ public final void setProperty(String name, int val) { setProperty(name, new Integer(val)); } /** * set a property to an long value * * @param name * String property to set. * @param val */ public final void setProperty(String name, long val) { setProperty(name, new Long(val)); } /** * output the value of a field to a prepared statement * * Note that {@link StreamData} objects are added as binary streams. * * @param buff * query buffer. We append additional info to this to provide * better debug messages * @param stms * PreparedStatement fo populate * @param pos * statement position to set * @param key * Field to output * @throws SQLException * @throws DataFault */ protected final void setValue(StringBuilder buff, PreparedStatement stmt, int pos, String key) throws DataFault { try { Object value = get(key); buff.append(' '); buff.append(pos); buff.append(':'); buff.append(key); buff.append('='); if (value instanceof StreamData) { StreamData s = (StreamData) value; stmt.setBinaryStream(pos, s.getInputStream(), (int) s.getLength()); buff.append("file"); } else { // needs to work with null values buff.append(String.valueOf(value)); setObject(stmt, pos, key, value); } } catch (SQLException e) { throw new DataFault("Error adding object to stmt", e); } } /** * update an existing record * * @return boolean true if updates were made * @throws ConsistencyError * @throws DataFault */ synchronized private boolean update() throws ConsistencyError, DataFault { int pattern_count = 1; if (READ_ONLY_FEATURE.isEnabled(ctx)) { return false; } if (!isDirty()) { return false; } TimerService time = ctx.getService(TimerService.class); if (time != null) { time.startTimer(getTag() + "-update"); } StringBuilder buff = new StringBuilder("UPDATE "); sql.quote(buff, table_name); buff.append(" SET "); try { boolean update = false; boolean atleastone = false; Set<String> fields = getFields(); for (String key : fields) { FieldInfo info = getInfo(key); if (isDirty(key)) { if (atleastone) { buff.append(", "); } info.addName(buff, false, true); buff.append("=?"); update = true; atleastone = true; pattern_count++; } } buff.append(" WHERE "); addUniqueName(buff, false, true); buff.append('='); buff.append(getID()); boolean updated = false; if (update) { PreparedStatement stmt = sql.getConnection().prepareStatement(buff.toString()); int pos = 1; for (String key : fields) { if (isDirty(key)) { setValue(buff, stmt, pos, key); pos++; } } if (DatabaseService.LOG_UPDATE.isEnabled(getContext())) { LoggerService serv = getContext().getService(LoggerService.class); if (serv != null) { serv.getLogger(getClass()).debug("update query is " + buff.toString()); } } //ctx.getService(LoggerService.class).getLogger(getClass()).debug("update :"+buff+" pos="+pos+" count="+pattern_count+" fieldcount="+fields.size()+" dirty"+dirty.size()); assert (pos == pattern_count); int rows_changed = stmt.executeUpdate(); stmt.close(); if (rows_changed > 1 || rows_changed < 0) { throw new ConsistencyError( "incorrect number of rows changes " + rows_changed + " " + buff.toString()); } else if (rows_changed == 0) { LoggerService serv = getContext().getService(LoggerService.class); if (serv != null) { // Same update may have happened between read and commit. serv.getLogger(getClass()).warn("No update when one was expected " + buff.toString()); } } else { updated = true; } } return updated; } catch (SQLException e) { // report the problem SQL to aid debugging throw new DataFault("SQL Exception " + buff.toString(), e); } finally { if (time != null) { time.stopTimer(getTag() + "-update"); } } } /** Get the id of an identical (up to id) record in the database * normally called on an uncommitted record so only the set fields * are used to generate the query * This is not efficient but can be used when merging data. * * @return id * @throws ConsistencyError * @throws DataFault */ public int findDuplicate() throws ConsistencyError, DataFault { return findDuplicate(-1, getFields(), false); } /** Get the id of an identical (up to id) record in the database * normally called on an uncommitted record so only the set fields * are used to generate the query. If a +ve id value is specified this will * be included in the selection effectively looking for a particular record that matches * the uncommitted record. * This is not efficient but can be used when merging data. * * @param id to match (if greater than zero) * @param fields {@link Set} of field names to check. * @param check_all Should all fields be checked not just the set/dirty fields. * @return id of duplicate * @throws ConsistencyError * @throws DataFault */ synchronized public int findDuplicate(int id, Set<String> fields, boolean check_all) throws ConsistencyError, DataFault { if (have_id && !(check_all || isDirty()) && this.id == id) { return id; } int pattern_count = 1; StringBuilder buff = new StringBuilder("SELECT "); addUniqueName(buff, false, true); buff.append(" FROM "); sql.quote(buff, table_name); buff.append(" WHERE "); try { boolean atleastone = false; if (id > 0) { addUniqueName(buff, false, true); buff.append("="); buff.append(Integer.toString(id)); atleastone = true; } for (String key : fields) { FieldInfo info = getInfo(key); if (check_all || isDirty(key)) { if (atleastone) { buff.append(" AND "); } if (containsKey(key)) { info.addName(buff, false, true); buff.append("=?"); pattern_count++; } else { if (info.isReference()) { // various was a reference can be missing/null // but mysql specific buff.append("COALESCE("); info.addName(buff, false, true); buff.append(",0)<=0"); } else { info.addName(buff, false, true); buff.append(" IS NULL "); } } atleastone = true; } } PreparedStatement stmt = sql.getConnection().prepareStatement(buff.toString()); int pos = 1; for (String key : fields) { if (isDirty(key)) { setValue(buff, stmt, pos, key); pos++; } } if (DatabaseService.LOG_QUERY_FEATURE.isEnabled(getContext())) { LoggerService serv = getContext().getService(LoggerService.class); if (serv != null) { serv.getLogger(getClass()).debug("query is " + buff.toString()); } } //ctx.getService(LoggerService.class).getLogger(getClass()).debug("update :"+buff+" pos="+pos+" count="+pattern_count+" fieldcount="+fields.size()+" dirty"+dirty.size()); assert (pos == pattern_count); ResultSet rs = stmt.executeQuery(); if (rs.first()) { return rs.getInt(1); } else { return 0; } } catch (SQLException e) { // report the problem SQL to aid debugging throw new DataFault("SQL Exception " + buff.toString(), e); } } @Override public String toString() { StringBuilder sb = new StringBuilder(); sb.append("Record[ "); sb.append(" tag="); sb.append(getTag()); sb.append(" id="); sb.append(id); for (String field : getFields()) { sb.append(" "); sb.append(field); sb.append("="); Object o = get(field); if (o != null) { sb.append(o.toString()); } else { sb.append("null"); } if (isDirty(field)) { sb.append("[X]"); } } sb.append(" ]"); return sb.toString(); } } /** Class representing an Order clause * * @author spb * */ public final class Order implements OrderClause { /* (non-Javadoc) * @see java.lang.Object#hashCode() */ @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + getOuterType().hashCode(); result = prime * result + (desc ? 1231 : 1237); result = prime * result + ((info == null) ? 0 : info.hashCode()); return result; } /* (non-Javadoc) * @see java.lang.Object#equals(java.lang.Object) */ @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (getClass() != obj.getClass()) return false; Order other = (Order) obj; if (!getOuterType().equals(other.getOuterType())) return false; if (desc != other.desc) return false; if (info == null) { if (other.info != null) return false; } else if (!info.equals(other.info)) return false; return true; } private final boolean desc; private final FieldInfo info; /** * * @param desc descending order * @param info {@link FieldInfo} to order by (null for primary key) */ private Order(boolean desc, FieldInfo info) { this.desc = desc; this.info = info; } public StringBuilder addClause(StringBuilder sb, boolean qualify) { if (info != null) { info.addName(sb, qualify, true); } else { addUniqueName(sb, qualify, true); } if (desc) { sb.append(" DESC"); } return sb; } private Repository getOuterType() { return Repository.this; } } final private AppContext ctx; final private SQLContext sql; /**This is the tag used to find this repository. * */ final private String tag_name; /**This is the actual database table name used in SQL * */ final private String table_name; /** This is the table alias used in selects * * If this is the same as table_name no alias is used */ final private String alias_name; /** This is the tag used in configuration parameters. * This is normally the same as the creation tag but may be re-directed * to make it easy to support table rolling. * */ final private String param_name; /** Tag for the database. Only needed for applications that cross multiple databases. * */ final private String db_tag; /** can we use the unique id code for this table * */ protected final boolean use_id; /** Name of the unique ID field * */ private String id_name = null; private String qualified_id_name = null; /** is it OK to put a key that does not have a corresponding DB field */ private boolean allow_bogus_put = false; /** is it OK to store a null value in a Record */ private boolean allow_null_value = true; /** * default number of milliseconds per tick when using an integer type to * specify a date or vice versa. */ private long resolution = DEFAULT_RESOLUTION; private Map<String, IndexInfo> indexes = null; /** * A map of field names to info about the field * */ private Map<String, FieldInfo> fields = null; /** Prepared statement used to find Record by id. * * As this is so common we keep a reference to this statement. */ private PreparedStatement find_statement = null; /** Should this repository use a Record cache * */ private final boolean use_cache; /** reference to implement find caches. * */ private SoftReference<Map<Integer, Record>> cache_ref = null; /** * Create a Repository object private to force the use of the Factory * method. * * @param c * AppContext * @param tag String tag used to find repository. * @throws SQLException * */ protected Repository(AppContext c, String tag) throws SQLException { super(); ctx = c; db_tag = ctx.getInitParameter("db-tag." + tag, null); sql = ctx.getService(DatabaseService.class).getSQLContext(db_tag); tag_name = TableToTag(c, tag); param_name = c.getInitParameter("config." + tag, tag); table_name = tagToTable(ctx, tag); // Setting this allows multiple joins to the same table by // having more than one tag for the same table with different aliases alias_name = c.getInitParameter("table_alias." + tag, table_name); setResolution(c.getIntegerParameter("repository.resolution." + tag_name, DEFAULT_RESOLUTION)); use_cache = new Feature(CACHE_FEATURE_PREFIX + tag_name, false, "Use record cache for " + tag_name) .isEnabled(c); use_id = REQUIRE_ID_KEY.isEnabled(c) || new Feature(USE_ID_PREFIX + tag_name, true, "Use integer id-key for table " + tag_name) .isEnabled(c); } public void createBackupTable(String name) throws SQLException { Connection c = sql.getConnection(); Statement stmt = c.createStatement(); StringBuilder sb = new StringBuilder(); sb.append("CREATE TABLE IF NOT EXISTS "); sql.quote(sb, name); sb.append(" LIKE "); addTable(sb, true); stmt.executeUpdate(sb.toString()); stmt.close(); } private Repository backup = null; /** create a backup table structured like this one if backups are enabled. * * @return Repository or null * @throws DataFault */ public Repository getBackup() throws DataFault { try { String suffix = (String) getContext().getAttribute(BACKUP_SUFFIX_ATTR); if (suffix == null) { return null; } if (backup == null) { String backup_name = table_name + suffix; createBackupTable(backup_name); backup = getInstance(getContext(), backup_name); } return backup; } catch (Throwable t) { throw new DataFault("Error creating backup repository", t); } } public static String TableToTag(AppContext c, String tag) { return c.getInitParameter("tag." + tag, tag); } /** Add the primary key name of this table to a query * * @param sb StringBuilder * @param qualify boolean should we qualify with table name * @param quote boolean request quoting if supported * @return modified StringBuilder */ public StringBuilder addUniqueName(StringBuilder sb, boolean qualify, boolean quote) { if (qualify) { addAlias(sb, quote); sb.append("."); } return sql.quote(sb, getUniqueIdName()); } /** Add the table name to a query. * * If this method is used to construct SQL statements then Repository sub-classes * can implement table name mangling if required. * * Use this for schema updates but use{@link #addSource(StringBuilder, boolean)} in preference * for select/update statements as qualified field names will use the alias * * @see #addSource(StringBuilder, boolean) * * @param sb StringBuilder * @param quote request quoting if supported * @return modified StringBuilder */ public StringBuilder addTable(StringBuilder sb, boolean quote) { if (quote) { return sql.quote(sb, table_name); } else { return sb.append(table_name); } } /** Add the table alias * * @param sb * @param quote * @return */ public StringBuilder addAlias(StringBuilder sb, boolean quote) { if (quote) { return sql.quote(sb, alias_name); } else { return sb.append(alias_name); } } /** Add the table as a query source. * * Normally the same as {@link #addTable(StringBuilder, boolean)} * but is allowed to add an "AS alias" clause as well * * * * @param sb * @param quote * @return */ public StringBuilder addSource(StringBuilder sb, boolean quote) { addTable(sb, quote); if (!table_name.equals(alias_name)) { sb.append(" AS "); addAlias(sb, quote); } return sb; } public String getTable() { return addTable(new StringBuilder(), false).toString(); } @SuppressWarnings("unchecked") public IndexedTypeProducer getSelfProducer() { IndexedTypeProducer producer = null; Class<? extends IndexedProducer> clazz = getContext().getPropertyClass(IndexedProducer.class, null, getTag()); if (clazz != null) { // use lazy creation constructor producer = new IndexedTypeProducer(getUniqueIdName(), ctx, clazz, getTag()); } return producer; } /** * Performs automatic type conversions to the canonical type as specified by * the database table. This is to provide automatic casting based on the DB * field type beyond that supported intrinsically in JDBC e.g. between Date * and integer time fields. * * Note that Filters that generate their own SQL fragments will need to call * this explicitly * * @param key * Database field to base conversion on * @param value * input object * @return converted object */ public Object convert(String key, Object value) { //Logger log = ctx.getLogger(getClass()); if (value == null || key == null) { return value; } if (value instanceof StreamData) { // StreamData objects are always written as binary streams return value; } FieldInfo info = getInfo(key); if (info == null) { return value; } else if (info.isDate()) { return convertDate(value); } else if (info.isNumeric()) { return convertNumber(value); } else if (info.isBoolean()) { return convertBoolean(value, null); } else if (info.isString()) { if (value instanceof Boolean) { return ((Boolean) value).booleanValue() ? "Y" : "N"; } // Don't coerce to string here return value; } else { return value; } } @SuppressWarnings("unchecked") public <T> T convert(Class<? extends T> target, Object value) { //Logger log = ctx.getLogger(getClass()); if (value == null || target == null) { return (T) value; } if (Date.class.isAssignableFrom(target)) { return (T) convertDate(value); } else if (Number.class.isAssignableFrom(target)) { return (T) convertNumber(value); } else if (String.class.isAssignableFrom(target)) { return (T) value.toString(); } else if (Boolean.class.isAssignableFrom(target)) { return (T) convertBoolean(value, null); } else { return (T) value; } } /** clear the Record cache. * */ protected void clearCache() { if (cache_ref == null) { return; } synchronized (this) { Map<Integer, Record> cache = cache_ref.get(); if (cache != null) { cache.clear(); cache = null; } cache_ref.clear(); cache_ref = null; } } protected synchronized void clearFields() { fields.clear(); fields = null; } /** perform any conversions to Date supported by this Repository. * This is public so external code that queries directly can use them. * * @param o Input Object * @return Number */ public final Date convertDate(Object o) { if (o != null && o instanceof Number) { // assume unix timestamp return new Date(getResolution() * ((Number) o).longValue()); } return (Date) o; } /** Perform any object conversions to Number appropriate to thisRepository. * This is public so external code that queries directly can also use them * * @param o input Object * @return Number */ public final Number convertNumber(Object o) { if (o == null) { return null; } if (o instanceof Number) { return (Number) o; } if (o instanceof Date) { // assume unix date in seconds return new Long(((Date) o).getTime() / getResolution()); } if (o instanceof Indexed) { return new Long(((Indexed) o).getID()); } if (o instanceof String) { return new Double((String) o); } return (Number) o; } public final Boolean convertBoolean(Object o, Boolean def) { if (o == null) { return def; } if (o instanceof Boolean) { return (Boolean) o; } if (o instanceof Number) { return Boolean.valueOf(((Number) o).intValue() != 0); } if (o instanceof String) { String s = (String) o; if (s.equalsIgnoreCase("Y") || s.equalsIgnoreCase("True")) { return Boolean.TRUE; } if (s.equalsIgnoreCase("N") || s.equalsIgnoreCase("False")) { return Boolean.FALSE; } } return def; } /** Cache a {@link TypeProducer} in the repository. * This allows the basic database meta-data to be augmented by * run-time information. * * @param producer */ public void addTypeProducer(TypeProducer producer) { FieldInfo info = getInfo(producer.getField()); if (info != null) { info.setTypeProducer(producer); } } public boolean hasTypeProducer(String field) { FieldInfo info = getInfo(field); return info.getTypeProducer() != null; } /** Test if we have cached metadata. * * @return */ boolean hasMetaData() { return fields != null; } /** eject a Record form the record cache. * * @param id */ private void ejectCache(int id) { if (!use_cache) { return; } synchronized (this) { Map<Integer, Record> cache = getCache(); if (cache != null) { cache.remove(id); cache = null; } } } /** Query cache presence for testing. * * @param id */ boolean isCached(int id) { if (use_cache) { synchronized (this) { Map<Integer, Record> cache = getCache(); if (cache != null) { boolean result = cache.containsKey(id); cache = null; return result; } } } return false; } /** Get the ResultSet corresponding to a Record id * * @param id record-id to find. * @param required Do we need a result * @return ResultSet * @throws SQLException */ synchronized private ResultSet findRecord(int id, boolean required) throws DataException { AppContext conn = getContext(); TimerService timer = conn.getService(TimerService.class); try { if (timer != null) { timer.startTimer(getTag() + "-find"); } if (find_statement == null) { StringBuilder sb = new StringBuilder(); sb.append("SELECT * FROM "); addTable(sb, true); sb.append(" WHERE "); addUniqueName(sb, false, true); sb.append("=?"); find_statement = sql.getConnection().prepareStatement(sb.toString(), ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY); } find_statement.setInt(1, id); ResultSet rs = find_statement.executeQuery(); if (!rs.next()) { if (!required) { return null; } throw new DataNotFoundException( "No record with specified ID " + getTag() + ":" + getUniqueIdName() + "=" + id); } return rs; } catch (SQLException e) { throw new DataFault("Exception in findRecord", e); } finally { if (timer != null) { timer.stopTimer(getTag() + "-find"); } } } /** * get the AppContext associated with this Repository. * * @return the AppContext */ public AppContext getContext() { return ctx; } /** return the Record cache or null * Even if caching is enabled this may still return null under * heavy memory pressure. * * @return */ private final Map<Integer, Record> getCache() { if (!use_cache) { return null; } synchronized (this) { if (cache_ref == null || cache_ref.get() == null) { cache_ref = new SoftReference<Map<Integer, Record>>(new HashMap<Integer, Record>()); } // there is a potential race condition here as the reference may be cleared // by the gc between the previous line and the next. return cache_ref.get(); } } /** * get the list of Record fields for this table in canonical order. * * @return Set of field names or null if Repository invalid */ public Set<String> getFields() { if (fields == null) { setMetaData(); } if (fields == null) { return null; } return fields.keySet(); } public boolean hasField(FieldInfo info) { return fields.containsValue(info); } public Set<String> getIndexNames() { if (indexes == null) { setIndexes(); } if (indexes == null) { return null; } // preserve order return new LinkedHashSet<String>(indexes.keySet()); } public IndexInfo getIndexInfo(String name) { if (indexes == null) { setIndexes(); } if (indexes == null) { return null; } return indexes.get(name); } /** Get all the FieldInfo objects for this Repository * * @return Collection */ public Collection<FieldInfo> getInfo() { if (fields == null) { setMetaData(); } if (fields == null) { return null; } return fields.values(); } /** * get the FieldInfo object for a field null key always returns a null * result * * @param key * @return FieldInfo */ public FieldInfo getInfo(String key) { if (key == null) { return null; } if (fields == null) { setMetaData(); } return fields.get(key); } /** get a {@link Order} element corresponding to a key. * * @param key field to order by (null for primary key). * @param desc * @return {@link Order} */ public Order getOrder(String key, boolean desc) { FieldInfo info = getInfo(key); if (info == null) { return new Order(desc, null); } return new Order(desc, info); } public final SQLContext getSQLContext() { return sql; } /** Return the number of milliseconds per tick to use when using an integer type to * specify a date or vice versa. * @return the resolution */ public long getResolution() { return resolution; } /** Get the Tag string used to reference this Repository * * @return String */ public String getTag() { return tag_name; } /** get the tag used to qualify configuration parameters. * This is normally the same as the reference tag but can be overridden * to make it easy to support rolled-tables. * * @return String tag */ public String getParamTag() { return param_name; } /** Get the tag used to retrieve the correct database connection for this table from a * {@link DatabaseService} * * @return tag */ public final String getDBTag() { return db_tag; } int getType(String key) { return getInfo(key).type; } /** * get the field name of the primary key * * @return String */ protected String getUniqueIdName() { if (id_name == null) { setMetaData(); } return id_name; } /** Is this the primary key field. * * Safer to provide a test than a query method * as it can't be used to write a ad-hoc sql query. * * @param name * @return boolean */ public boolean isUniqueIdName(String name) { return id_name.equals(name); } protected String getUniqueIdName(boolean qualify) { if (id_name == null) { setMetaData(); } if (qualify) { return qualified_id_name; } return id_name; } /** * Is the specified object a valid Field key for this table. * * @param key * @return boolean true if key is valid */ public boolean hasField(String key) { if (fields == null) { setMetaData(); } assert (key != null); return fields.containsKey(key); } /** Is there a named index of the specified name for this table. * * @param index * @return boolean */ public boolean hasIndex(String index) { if (indexes == null) { setIndexes(); } if (indexes == null) { return false; } return indexes.containsKey(index); } /** get a {@link NumberFieldExpression} for a field. * * @param filter_type type of the hosting object * @param target desired numeric type for the field. * @param key field name * @return {@link NumberFieldExpression} */ public <T extends Number, X extends DataObject> NumberFieldExpression<T, X> getNumberExpression( Class<? super X> filter_type, Class<T> target, String key) { FieldInfo info = getInfo(key); if (info == null || !info.isNumeric()) { throw new ConsistencyError("Invalid numeric field " + getTag() + "." + key); } return new NumberFieldExpression<T, X>(filter_type, target, this, key); } /** get a {@link BooleanFieldExpression} for a field * * @param filter_type type of hosting object * @param key field name * @return {@link BooleanFieldExpression} */ public <X extends DataObject> BooleanFieldExpression<X> getBooleanExpression(Class<? super X> filter_type, String key) { FieldInfo info = getInfo(key); if (info == null || !info.isBoolean()) { throw new ConsistencyError("Invalid boolean field " + getTag() + "." + key); } return new BooleanFieldExpression(filter_type, this, key); } /** get a {@link StringFieldExpression} for a field * * @param filter_type type of hosting object * @param key field name * @return {@link StringFieldExpression} */ public <X extends DataObject> StringFieldExpression<X> getStringExpression(Class<? super X> filter_type, String key) { FieldInfo info = getInfo(key); if (info == null) { // note all types can be treated as strings throw new ConsistencyError("Invalid string field " + getTag() + "." + key); } return new StringFieldExpression<X>(filter_type, this, key); } /** get a {@link Date} valued {@link FieldValue} for a field. * * The underlying database field may be a time-stamp or a numeric field. * * @param target type of hosting object * @param key field name * @return {@link FieldValue} */ public <X extends DataObject> FieldValue<Date, X> getDateExpression(Class<? super X> target, String key) { FieldInfo info = getInfo(key); if (info == null) { throw new ConsistencyError("Invalid date field"); } if (info.isDate()) { return new DateFieldExpression<X>(target, this, key); } if (info.isNumeric()) { return getTimeStampDateFieldExpression(target, key); } throw new ConsistencyError("Invalid date field"); } protected <X extends DataObject> FieldValue<Date, X> getTimeStampDateFieldExpression(Class<? super X> target, String key) { return new TimestampDateFieldExpression<X>(target, this, key); } /** Get a {@link IndexedFieldValue} for a reference field * @param self type of owning object * * @param key * @return IndexedFieldValue */ @SuppressWarnings("unchecked") public <T extends DataObject> IndexedFieldValue getReferenceExpression(Class<? super T> self, String key) { FieldInfo info = getInfo(key); if (info == null || !info.isNumeric() || !info.isReference()) { throw new ConsistencyError("Invalid reference field " + getTag() + "." + key); } TypeProducer prod = info.getTypeProducer(); if (prod != null && prod instanceof IndexedTypeProducer) { return new IndexedFieldValue(self, this, (IndexedTypeProducer) prod); } throw new ConsistencyError("Invalid reference field " + getTag() + "." + key); } public TypeProducerFieldValue getTypeProducerExpression(TypeProducer prod) { return new TypeProducerFieldValue(this, prod); } /** Get a {@link IndexedFieldValue} for a field * The field does not have to be tagged as a reference field * @param self * @param key * @param prod * @return IndexedFieldValue */ @SuppressWarnings("unchecked") public <T extends DataObject, I extends DataObject> IndexedFieldValue<T, I> getReferenceExpression( Class<? super T> self, String key, IndexedProducer<I> prod) { FieldInfo info = getInfo(key); if (info == null || !info.isNumeric()) { throw new ConsistencyError("Invalid reference/numeric field " + getTag() + "." + key); } IndexedTypeProducer producer = new IndexedTypeProducer(ctx, key, prod); TypeProducer typeProducer = info.getTypeProducer(); if (info.isReference() && !typeProducer.equals(producer)) { throw new ConsistencyError("Incompatible producer specified for field " + getTag() + "." + key + " " + typeProducer.toString() + "!=" + producer.toString()); } return new IndexedFieldValue<T, I>(self, this, producer); } /** Default insert operation that uses Generated Keys to * obtain the unique id. Not all DBs support this but it is a good default. * * If the id value is greater than zero this is taken as a required id to be inserted. * * @param r * @return * @throws DataFault */ protected int insert(Record r) throws DataFault { if (READ_ONLY_FEATURE.isEnabled(ctx)) { return -1; } TimerService time = ctx.getService(TimerService.class); if (time != null) { time.startTimer(getTag() + "-insert"); } int id; // Ok, now we should save the object in the database before // anything else happens StringBuilder query = new StringBuilder("INSERT INTO "); addTable(query, true); query.append(" ("); StringBuilder query_values = new StringBuilder(") VALUES ("); boolean atleastone = false; if (r.id > 0) { addUniqueName(query, false, true); query_values.append('?'); atleastone = true; } for (Iterator<String> it = getFields().iterator(); it.hasNext();) { String field = it.next(); FieldInfo info = getInfo(field); // as this is an insert we should skip null fields and allow the database default to // take precedence if (r.get(field) != null) { if (atleastone) { query.append(", "); query_values.append(", "); } else { atleastone = true; } info.addName(query, false, true); query_values.append('?'); } } query.append(query_values.toString()); query.append(')'); if (!atleastone) { throw new DataFault("Insert with no values"); } try { PreparedStatement stmt = sql.getConnection().prepareStatement(query.toString(), Statement.RETURN_GENERATED_KEYS); int pos = 1; if (r.id > 0) { stmt.setInt(pos, r.id); pos++; } for (Iterator it = getFields().iterator(); it.hasNext();) { String field = (String) it.next(); if (r.get(field) != null) { r.setValue(query, stmt, pos, field); pos++; } } if (DatabaseService.LOG_INSERT_FEATURE.isEnabled(getContext())) { LoggerService serv = getContext().getService(LoggerService.class); if (serv != null) { serv.getLogger(getClass()).debug("insert query is " + query.toString()); } } int count = stmt.executeUpdate(); if (time != null) { time.stopTimer(getTag() + "-insert"); } if (count != 1) { throw new DataFault("Wrong count from INSERT"); } if (r.id > 0) { //know the id return r.id; } if (use_id) { ResultSet rs = stmt.getGeneratedKeys(); if (rs.next()) { id = rs.getInt(1); } else { throw new DataFault("cannot retrieve auto_key"); } rs.close(); stmt.close(); return id; } else { return 0; } } catch (SQLException e) { throw new DataFault("Insert exception " + query.toString(), e); } } /** * control if it is an error to set a property with no corresponding * database field. * * @param f * boolean * @return previous value */ public boolean setAllowBogusPut(boolean f) { boolean old = allow_bogus_put; allow_bogus_put = f; return old; } /** * control if it is an error to set a property with a null value * * @param f * boolean * @return previous value */ public boolean setAllowNull(boolean f) { boolean old = allow_null_value; allow_null_value = f; return old; } /** * populate an object from a ResultSet * * It seems to work if we always qualify the field names but its slower * * @param rs * ResultSet * @param qualify * boolean qualify the field names with the table name as * ResultSet is from a join * @throws DataFault * * @throws ConsistencyError */ public void setFromResultSet(Record r, ResultSet rs, boolean qualify) throws DataFault, DataNotFoundException { int id; synchronized (r) { r.clear(); try { /* * This is more robust though its slightly faster to get fields * by col number */ if (use_id) { String uniqueIdName = getUniqueIdName(qualify); id = rs.getInt(uniqueIdName); r.setInitialID(id); // for backwards compatibility if (id <= 0) { // This can happen with a join used to pre-populate a cached link // when the reference value is invalid throw new DataNotFoundException("No ID value found " + uniqueIdName + ":" + id); } } else { id = 0; } // Logger log=null; // if( ctx.isFeatureOn("log_fetch")){ // log=ctx.getService(LoggerService.class).getLogger(getClass()); // } for (Iterator<String> i = getFields().iterator(); i.hasNext();) { String field = i.next(); FieldInfo info = fields.get(field); Object value = rs.getObject(info.getName(qualify)); if (value != null) { if (info.getType() == Types.BLOB) { if (value instanceof Blob) { value = new BlobStreamData(ctx, (Blob) value); } else { throw new DataFault("Unexpected Blob type " + value.getClass().getName()); } } // if( log != null ){ // log.debug(info.getName(true)+" is "+value.toString()+" "+value.getClass().getCanonicalName()); // } // want these to be clean by default r.rawPut(field, value); } } } catch (SQLException e) { r.clear(); throw new DataFault("Exception in setContents", e); } // store this object in the cache if appropriate if (use_cache && use_id) { synchronized (this) { Map<Integer, Record> cache; Integer key = id; if ((cache = getCache()) != null && !cache.containsKey(key)) { // Store copy so cached copy not changed cache.put(key, (Record) r.clone()); cache = null; } } } } } /** * populate the MetaData from scratch * */ synchronized private void setMetaData() { if (fields == null) { try { Connection c = sql.getConnection(); Statement stmt = c.createStatement(); StringBuilder sb = new StringBuilder(); sb.append("SELECT * FROM "); addTable(sb, true); sb.append(" WHERE 1=0"); ResultSet rs = stmt.executeQuery(sb.toString()); setMetaData(rs); stmt.close(); setReferences(ctx, c); } catch (Exception e) { //ctx.error(e, "Error creating MetaData for " + getTag()); throw new DataError("Error in setMetaData for " + getTag(), e); } } } synchronized private void setIndexes() { try { Map<String, IndexInfo> result = new LinkedHashMap<String, Repository.IndexInfo>(); Connection c = getContext().getService(DatabaseService.class).getSQLContext().getConnection(); DatabaseMetaData md = c.getMetaData(); ResultSet rs = md.getIndexInfo(null, null, table_name, false, true); while (rs.next()) { String name = rs.getString("INDEX_NAME"); if (!name.equals("PRIMARY")) { boolean unique = !rs.getBoolean("NON_UNIQUE"); IndexInfo info = result.get(name); if (info == null) { info = new IndexInfo(name, unique); result.put(name, info); } int pos = rs.getInt("ORDINAL_POSITION"); String col = rs.getString("COLUMN_NAME"); info.addCol(pos, col); } } indexes = result; } catch (SQLException e) { throw new DataError("Error getting index names", e); } } /** Set the table References for the fields * * @param ctx * @param c * @throws SQLException */ private void setReferences(AppContext ctx, Connection c) throws SQLException { //Logger log = ctx.getService(LoggerService.class).getLogger(getClass()); //log.debug("SetReferences for "+getTable()); // look for foreign keys to identify remote tables. DatabaseMetaData meta = c.getMetaData(); ResultSet rs = meta.getImportedKeys(c.getCatalog(), null, table_name); if (rs.first()) { //log.debug("Have foreign key"); do { String field = rs.getString("FKCOLUMN_NAME"); String table = rs.getString("PKTABLE_NAME"); String key_name = rs.getString("FK_NAME"); short seq = rs.getShort("KEY_SEQ"); if (seq == 1) { FieldInfo info = fields.get(field); if (info.isNumeric()) { String name = REFERENCE_PREFIX + param_name + "." + info.getName(false); table = ctx.getInitParameter(name, table); // use param in preference because of windows case mangle String tag = TableToTag(ctx, table); //log.debug("field "+field+" references "+table); info.setReference(true, key_name, tag); } } } while (rs.next()); } // now try explicit references set from properties for (FieldInfo i : fields.values()) { if (i.getReferencedTable() == null) { //use param name for table rename String tag = REFERENCE_PREFIX + param_name + "." + i.getName(false); String table = ctx.getInitParameter(tag); //log.debug("tag "+tag+" resolves to "+table); i.setReference(false, null, table); } } } /** * Use a ResultSet to populate the MetaData information * * @param rs * @throws SQLException * @throws ConsistencyError */ private void setMetaData(ResultSet rs) throws SQLException, ConsistencyError { assert (fields == null); fields = new LinkedHashMap<String, FieldInfo>(); ResultSetMetaData meta_data = rs.getMetaData(); int md_columns = meta_data.getColumnCount(); boolean seen_key = false; // Logger log = ctx.getLogger(getClass()); for (int i = 1; i <= md_columns; i++) { String returned_name = meta_data.getTableName(i); if (returned_name.length() > 0 && !returned_name.equalsIgnoreCase(table_name)) { throw new ConsistencyError("Table names do not match " + getTag() + "!=" + returned_name); } // if we don't know for sure assume no nulls boolean can_null = (meta_data.isNullable(i) == ResultSetMetaData.columnNullable); String name = meta_data.getColumnName(i); if (!seen_key && meta_data.isAutoIncrement(i)) { seen_key = true; id_name = name; } else { // log.debug("Metadata "+name+" "+meta_data.getColumnType(i)); int columnType = meta_data.getColumnType(i); int columnDisplaySize = meta_data.getColumnDisplaySize(i); fields.put(dbFieldtoTag(name), new FieldInfo(name, columnType, columnDisplaySize, can_null)); } } if (use_id && !seen_key) { // Note we need an up-to-date mysql driver for the isAutoIncrement // method to work properly. otherwise default to first col and hope id_name = meta_data.getColumnName(1); fields.remove(dbFieldtoTag(id_name)); } // cache the qualified form as this is used frequently StringBuilder sb = new StringBuilder(); sb.append(alias_name); sb.append("."); sb.append(id_name); qualified_id_name = sb.toString(); } /** Map the actual name of the DB field to the tag used in the code. * Normally this is the identity but this method allows field renaming. * */ protected String dbFieldtoTag(String name) { return ctx.getInitParameter("rename." + table_name + "." + name, name); } /** Map a tag name to the actual database table. * @param ctx * * @param tag * @return table name */ public static String tagToTable(AppContext ctx, String tag) { return ctx.getInitParameter("table." + tag, tag); } /** * Add an object to a PreparedStatement using metadata info to get the * desired type. This does <em>not</em> call convert implicitly. * * @param stmt * PreparedStatement * @param pos * position to add * @param key * field to match type to or null if unspecified * @param value * value to add * @throws SQLException */ final void setObject(PreparedStatement stmt, int pos, String key, Object value) throws SQLException { FieldInfo f = getInfo(key); if (f != null) { //ctx.getLogger().debug("setObject "+pos+","+value+","+f.getType()); stmt.setObject(pos, value, f.getType()); } else { //ctx.getLogger().debug("setObject "+pos+","+value); stmt.setObject(pos, value); } } /** * @param resolution * the resolution to set */ public void setResolution(long resolution) { this.resolution = resolution; } /** Does this repository support id fields. * * Without this the table must be read only * * @return boolean */ public boolean useID() { return use_id; } public boolean usesCache() { return use_cache; } /** Static method to result a field from a result set * with a specified target class * * @param <T> * @param target * @param rs * @param pos * @return T * @throws DataFault */ @SuppressWarnings("unchecked") public static <T> T makeTargetObject(Class<T> target, ResultSet rs, int pos) throws DataFault { try { T result; if (target == Date.class) { Timestamp timeStamp = rs.getTimestamp(pos); if (timeStamp != null) { //Use Timestamp so as not to get date and time info. // For safety convert to a proper java.util.date result = (T) new Date(timeStamp.getTime()); } else { result = null; } } else if (target == String.class) { result = (T) rs.getString(pos); } else if (target == Double.class) { result = (T) Double.valueOf(rs.getDouble(pos)); } else if (target == Float.class) { result = (T) Float.valueOf(rs.getFloat(pos)); } else if (target == Integer.class) { result = (T) Integer.valueOf(rs.getInt(pos)); } else if (target == Long.class) { result = (T) Long.valueOf(rs.getLong(pos)); } else if (target == Duration.class) { result = (T) new Duration(rs.getLong(pos), 1); } else { result = (T) rs.getObject(pos); } assert (result == null || target.isAssignableFrom(result.getClass())); return result; } catch (SQLException e) { throw new DataFault("Error making field result", e); } } /** * compare two objects by their String Number or Date value Other kinds of * object are assumed to be different, even if they are the same object * their internal state might have changed since it was returned so err on * the side of caution * * @param o1 * first Object * @param o2 * second Object * @return boolean true if the same */ private static boolean compare(Object o1, Object o2) { if (o1 == null) { return o2 == null; } else { if (o2 == null) { return false; } } if (o1 instanceof Number && o2 instanceof Number) { return ((Number) o1).doubleValue() == ((Number) o2).doubleValue(); } if (o1.getClass() == o2.getClass()) { return o1.equals(o2); } return false; } /** Get a foreign key descriptor by tag. * * @param c AppContext * @param tag String * @return descriptor or null */ public static String getForeignKeyDescriptor(AppContext c, String tag, boolean quote) { try { Repository res = getInstance(c, tag); if (res == null) { return null; } StringBuilder sb = new StringBuilder(); res.addTable(sb, quote); sb.append("("); res.addUniqueName(sb, false, quote); sb.append(")"); return sb.toString(); } catch (Throwable t) { // by default just skip foreign key // probably a missing table return null; } } /** * Factory method for Repositories this is to cache Repositories in the * AppContext to reduce MetaData queries This also means that for a given * AppContext there is only one Repository per table. * If we wished to add additional Repository sub-classes we would need to add parameter based class selection * here. * @param c * @param tag * @return Repository */ static Repository getInstance(AppContext c, String tag) { Repository r = null; if (tag == null || tag.trim().length() == 0) { throw new ConsistencyError("Tried to create Repository with empty tag"); } synchronized (c) { Tag key = new Tag(tag); r = (Repository) c.getAttribute(key); if (r == null) { try { r = new Repository(c, tag); c.setAttribute(key, r); } catch (Exception e) { c.error(e, "Error making repository"); return null; } } } return r; } /** Clear the cached repository * * @param c * @param tag */ public static void reset(AppContext c, String tag) { Tag key = new Tag(tag); Repository res = (Repository) c.getAttribute(key); if (res != null) { res.cleanup(); } c.removeAttribute(key); } /** Used as AppContext attribute key for Repository. * * As the class is private other classes cannot access the attribute. * * @author spb * */ private static final class Tag { @Override public boolean equals(Object obj) { return obj.getClass() == getClass() && ((Tag) obj).tag.equals(tag); } @Override public int hashCode() { return tag.hashCode(); } private final String tag; private Tag(String s) { // we match tags case insensitive so case flattened tables names will retrieve the // same repository as directly generated tags. // Only used in tests where we loop over all tables to create dumps. tag = s.toLowerCase(Locale.ENGLISH); } } @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + ((tag_name == null) ? 0 : tag_name.hashCode()); return result; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (getClass() != obj.getClass()) return false; Repository other = (Repository) obj; if (tag_name == null) { if (other.tag_name != null) return false; } else if (!tag_name.equals(other.tag_name)) return false; return true; } public String toString() { return "Repository-" + table_name; } /* (non-Javadoc) * @see uk.ac.ed.epcc.webapp.AppContextCleanup#cleanup() */ @Override public void cleanup() { try { if (find_statement != null && !find_statement.isClosed()) { find_statement.close(); } } catch (SQLException e) { e.printStackTrace(); } find_statement = null; } }