1 package org.collectionspace.services.listener;
3 import java.io.BufferedReader;
4 import java.io.IOException;
5 import java.io.InputStream;
6 import java.io.InputStreamReader;
7 import java.sql.Connection;
8 import java.sql.ResultSet;
9 import java.sql.SQLException;
10 import java.sql.Statement;
11 import java.util.ArrayList;
12 import java.util.Calendar;
13 import java.util.GregorianCalendar;
14 import java.util.HashMap;
15 import java.util.List;
17 import org.apache.commons.logging.Log;
18 import org.apache.commons.logging.LogFactory;
19 import org.collectionspace.services.client.workflow.WorkflowClient;
20 import org.collectionspace.services.common.api.GregorianCalendarDateTimeUtils;
21 import org.collectionspace.services.common.api.RefNameUtils;
22 import org.collectionspace.services.common.api.Tools;
23 import org.collectionspace.services.common.storage.JDBCTools;
24 import org.collectionspace.services.movement.nuxeo.MovementConstants;
25 import org.collectionspace.services.nuxeo.util.NuxeoUtils;
26 import org.nuxeo.ecm.core.api.ClientException;
27 import org.nuxeo.ecm.core.api.CoreSession;
28 import org.nuxeo.ecm.core.api.DocumentModel;
29 import org.nuxeo.ecm.core.api.DocumentModelList;
30 import org.nuxeo.ecm.core.event.Event;
31 import org.nuxeo.ecm.core.event.EventContext;
32 import org.nuxeo.ecm.core.event.EventListener;
33 import org.nuxeo.ecm.core.event.impl.DocumentEventContext;
35 public class UpdateObjectLocationOnMove implements EventListener {
37 // FIXME: We might experiment here with using log4j instead of Apache Commons Logging;
38 // am using the latter to follow Ray's pattern for now
39 private final Log logger = LogFactory.getLog(UpdateObjectLocationOnMove.class);
40 // FIXME: Make the following message, or its equivalent, a constant usable by all event listeners
41 private final String NO_FURTHER_PROCESSING_MESSAGE =
42 "This event listener will not continue processing this event ...";
43 private final List<String> relevantDocTypesList = new ArrayList<String>();
44 private final String DATABASE_RESOURCE_DIRECTORY_NAME = "db";
45 // FIXME: Currently hard-coded; get this database name value from JDBC utilities or equivalent
46 private final String DATABASE_SYSTEM_NAME = "postgresql";
47 private final static String STORED_FUNCTION_NAME = "computecurrentlocation";
48 private final static String SQL_FILENAME_EXTENSION = ".sql";
49 private final String SQL_RESOURCE_PATH =
50 DATABASE_RESOURCE_DIRECTORY_NAME + "/"
51 + DATABASE_SYSTEM_NAME + "/"
52 + STORED_FUNCTION_NAME + SQL_FILENAME_EXTENSION;
53 // The name of the relevant column in the JDBC ResultSet is currently identical
54 // to the function name, regardless of the 'SELECT ... AS' clause in the SQL query.
55 private final static String COMPUTED_CURRENT_LOCATION_COLUMN = STORED_FUNCTION_NAME;
56 // FIXME: Get this line separator value from already-declared constant elsewhere, if available
57 private final String LINE_SEPARATOR = System.getProperty("line.separator");
58 private final static String RELATIONS_COMMON_SCHEMA = "relations_common"; // FIXME: Get from external constant
59 final String RELATION_DOCTYPE = "Relation"; // FIXME: Get from external constant
60 private final static String OBJECT_CSID_PROPERTY = "objectCsid"; // FIXME: Get from external constant
61 private final static String COLLECTIONOBJECTS_COMMON_SCHEMA = "collectionobjects_common"; // FIXME: Get from external constant
62 final String COLLECTIONOBJECT_DOCTYPE = "CollectionObject"; // FIXME: Get from external constant
63 final String COMPUTED_CURRENT_LOCATION_PROPERTY = "computedCurrentLocation"; // FIXME: Create and then get from external constant
64 final String MOVEMENTS_COMMON_SCHEMA = "movements_common"; // FIXME: Get from external constant
65 final String LOCATION_DATE_PROPERTY = "locationDate"; // FIXME: Get from external constant
67 // ####################################################################
68 // FIXME: Per Rick, what happens if a relation record is updated,
69 // that either adds or removes a relation between a Movement
70 // record and a CollectionObject record? Do we need to listen
71 // for that event as well and update the CollectionObject record's
72 // computedCurrentLocation accordingly?
74 // The following code is currently only handling create and
75 // update events affecting Movement records.
76 // ####################################################################
77 // FIXME: We'll likely also need to handle workflow state transition and
78 // deletion events, where the soft or hard deletion of a Movement or
79 // Relation record effectively changes the current location for a CollectionObject.
81 public void handleEvent(Event event) throws ClientException {
83 logger.trace("In handleEvent in UpdateObjectLocationOnMove ...");
85 // FIXME: Check for database product type here.
86 // If our database type is one for which we don't yet
87 // have tested SQL code to perform this operation, return here.
89 EventContext eventContext = event.getContext();
90 if (eventContext == null) {
94 if (!(eventContext instanceof DocumentEventContext)) {
95 logger.debug("This event does not involve a document ...");
96 logger.debug(NO_FURTHER_PROCESSING_MESSAGE);
99 DocumentEventContext docEventContext = (DocumentEventContext) eventContext;
100 DocumentModel docModel = docEventContext.getSourceDocument();
102 // If this event does not involve one of our relevant doctypes,
103 // return without further handling the event.
104 boolean involvesRelevantDocType = false;
105 relevantDocTypesList.add(MovementConstants.NUXEO_DOCTYPE);
106 // FIXME: We will likely need to add the Relation doctype here,
107 // along with additional code to handle such changes.
108 for (String docType : relevantDocTypesList) {
109 if (documentMatchesType(docModel, docType)) {
110 involvesRelevantDocType = true;
114 logger.debug("This event involves a document of type " + docModel.getDocumentType().getName());
115 if (!involvesRelevantDocType) {
116 logger.debug("This event does not involve a document of a relevant type ...");
117 logger.debug(NO_FURTHER_PROCESSING_MESSAGE);
120 if (!isActiveDocument(docModel)) {
121 logger.debug("This event does not involve an active document ...");
122 logger.debug(NO_FURTHER_PROCESSING_MESSAGE);
126 logger.debug("An event involving an active document of the relevant type(s) was received by UpdateObjectLocationOnMove ...");
128 // Test whether a SQL function exists to supply the computed
129 // current location of a CollectionObject.
131 // If the function does not exist in the database, load the
132 // SQL command to create that function from a resource
133 // available to this class, and run a JDBC command to create
134 // that function in the database.
136 // For now, assume this function will be created in the
139 // FIXME: Future work to create per-tenant repositories will
140 // likely require that our JDBC statements connect to the
141 // appropriate tenant-specific database.
143 // It doesn't appear we can reliably create this function via
144 // 'ant create_nuxeo db' during the build process, because
145 // there's a substantial likelihood at that point that
146 // tables referred to by the function (e.g. movements_common
147 // and collectionobjects_common) will not yet exist.
148 // (PostgreSQL will not permit the function to be created if
149 // any of its referred-to tables do not exist.)
150 if (!storedFunctionExists(STORED_FUNCTION_NAME)) {
151 logger.trace("Stored function " + STORED_FUNCTION_NAME + " does NOT exist in the database.");
152 String sql = getStringFromResource(SQL_RESOURCE_PATH);
153 if (Tools.isBlank(sql)) {
154 logger.warn("Could not obtain SQL command to create stored function.");
155 logger.debug(NO_FURTHER_PROCESSING_MESSAGE);
161 result = JDBCTools.executeUpdate(JDBCTools.getDataSource(JDBCTools.NUXEO_REPOSITORY_NAME), sql);
162 } catch (Exception e) {
164 // FIXME: Need to verify that the original '-1' value is preserved if an Exception is caught here.
166 logger.trace("Result of executeUpdate=" + result);
168 logger.warn("Could not create stored function in the database.");
169 logger.debug(NO_FURTHER_PROCESSING_MESSAGE);
172 logger.info("Stored function " + STORED_FUNCTION_NAME + " was successfully created in the database.");
175 logger.trace("Stored function " + STORED_FUNCTION_NAME + " already exists in the database.");
178 String movementCsid = NuxeoUtils.getCsid(docModel);
179 logger.debug("Movement record CSID=" + movementCsid);
181 // FIXME: Temporary, for debugging: check whether the movement record's
182 // location date field value is stale in Nuxeo at this point
183 GregorianCalendar cal = (GregorianCalendar) docModel.getProperty(MOVEMENTS_COMMON_SCHEMA, LOCATION_DATE_PROPERTY);
184 logger.debug("location date=" + GregorianCalendarDateTimeUtils.formatAsISO8601Date(cal));
186 // Find CollectionObject records that are related to this Movement record:
188 // Via an NXQL query, get a list of (non-deleted) relation records where:
189 // * This movement record's CSID is the subject CSID of the relation.
190 // * The object document type is a CollectionObject doctype.
192 // Note: this assumes that every such relation is captured by
193 // relations with Movement-as-subject and CollectionObject-as-object,
194 // logic that matches that of the SQL function to obtain the computed
195 // current location of the CollectionObject.
197 // That may NOT always be the case; it's possible some such relations may
198 // exist only with CollectionObject-as-subject and Movement-as-object.
199 CoreSession coreSession1 = docEventContext.getCoreSession();
200 CoreSession coreSession = docModel.getCoreSession();
201 if (coreSession1 == coreSession || coreSession1.equals(coreSession)) {
202 logger.debug("Core sessions are equal.");
204 logger.debug("Core sessions are NOT equal.");
207 // Check whether closing and opening a transaction here might
208 // flush any hypothetical caching that Nuxeo is doing at this point
210 String query = String.format(
211 "SELECT * FROM %1$s WHERE " // collectionspace_core:tenantId = 1 "
212 + "(relations_common:subjectCsid = '%2$s' "
213 + "AND relations_common:objectDocumentType = '%3$s') "
214 + "AND (ecm:currentLifeCycleState <> 'deleted') "
215 + "AND ecm:isProxy = 0 "
216 + "AND ecm:isCheckedInVersion = 0", RELATION_DOCTYPE, movementCsid, COLLECTIONOBJECT_DOCTYPE);
217 DocumentModelList relatedDocModels = coreSession.query(query);
218 if (relatedDocModels == null || relatedDocModels.isEmpty()) {
221 logger.trace("Found " + relatedDocModels.size() + " related documents.");
224 // Iterate through the list of Relation records found and build
225 // a list of CollectionObject CSIDs, by extracting the object CSIDs
226 // from those Relation records.
228 // FIXME: The following code might be refactored into a generic 'get property
229 // values from a list of document models' method, if this doesn't already exist.
231 List<String> collectionObjectCsids = new ArrayList<String>();
232 for (DocumentModel relatedDocModel : relatedDocModels) {
233 csid = (String) relatedDocModel.getProperty(RELATIONS_COMMON_SCHEMA, OBJECT_CSID_PROPERTY);
234 if (Tools.notBlank(csid)) {
235 collectionObjectCsids.add(csid);
238 if (collectionObjectCsids == null || collectionObjectCsids.isEmpty()) {
239 logger.warn("Could not obtain any CSIDs of related CollectionObject records.");
240 logger.debug(NO_FURTHER_PROCESSING_MESSAGE);
243 logger.debug("Found " + collectionObjectCsids.size() + " CSIDs of related CollectionObject records.");
246 // Iterate through the list of CollectionObject CSIDs found.
247 DocumentModel collectionObjectDocModel = null;
248 String computedCurrentLocationRefName = "";
249 Map<DocumentModel, String> docModelsToUpdate = new HashMap<DocumentModel, String>();
250 for (String collectionObjectCsid : collectionObjectCsids) {
252 // Verify that the CollectionObject record is active.
253 collectionObjectDocModel = getDocModelFromCsid(coreSession, collectionObjectCsid);
254 if (!isActiveDocument(collectionObjectDocModel)) {
258 // Via a JDBC call, invoke the SQL function to obtain the computed
259 // current location of that CollectionObject.
260 computedCurrentLocationRefName = computeCurrentLocation(collectionObjectCsid);
261 logger.debug("computedCurrentLocation refName=" + computedCurrentLocationRefName);
263 // Check that the value returned from the SQL function, which
264 // is expected to be a reference (refName) to a storage location
265 // authority term, is, at a minimum:
266 // * Non-null and non-blank. (We need to verify this assumption; can a
267 // CollectionObject's computed current location meaningfully be 'un-set'?)
268 // * Capable of being successfully parsed by an authority item parser;
269 // that is, returning a non-null parse result.
270 if ((Tools.notBlank(computedCurrentLocationRefName)
271 && (RefNameUtils.parseAuthorityTermInfo(computedCurrentLocationRefName) != null))) {
272 logger.debug("refName passes basic validation tests.");
274 // If the value returned from the function passes validation,
275 // compare that value to the value in the computedCurrentLocation
276 // field of that CollectionObject
278 // If the CollectionObject does not already have a
279 // computedCurrentLocation value, or if the two values differ ...
280 String existingComputedCurrentLocationRefName =
281 (String) collectionObjectDocModel.getProperty(COLLECTIONOBJECTS_COMMON_SCHEMA, COMPUTED_CURRENT_LOCATION_PROPERTY);
282 if (Tools.isBlank(existingComputedCurrentLocationRefName)
283 || !computedCurrentLocationRefName.equals(existingComputedCurrentLocationRefName)) {
284 logger.debug("Existing computedCurrentLocation refName=" + existingComputedCurrentLocationRefName);
285 logger.debug("computedCurrentLocation refName requires updating.");
286 // ... store this CollectionObject's docModel and new field value for subsequent updating
287 docModelsToUpdate.put(collectionObjectDocModel, computedCurrentLocationRefName);
290 logger.debug("computedCurrentLocation refName does NOT require updating.");
295 // For each CollectionObject record that has been stored for updating,
296 // update its computedCurrentLocation field with its computed current
297 // location value returned from the SQL function.
298 for (Map.Entry<DocumentModel, String> entry : docModelsToUpdate.entrySet()) {
299 DocumentModel dmodel = entry.getKey();
300 String newValue = entry.getValue();
301 dmodel.setProperty(COLLECTIONOBJECTS_COMMON_SCHEMA, COMPUTED_CURRENT_LOCATION_PROPERTY, newValue);
302 coreSession.saveDocument(dmodel);
303 if (logger.isDebugEnabled()) {
304 String afterUpdateComputedCurrentLocationRefName =
305 (String) dmodel.getProperty(COLLECTIONOBJECTS_COMMON_SCHEMA, COMPUTED_CURRENT_LOCATION_PROPERTY);
306 logger.debug("Following update, new computedCurrentLocation refName value=" + afterUpdateComputedCurrentLocationRefName);
312 // FIXME: Generic methods like many of those below might be split off,
313 // into an event utilities class, base classes, or otherwise. - ADR 2012-12-05
315 // FIXME: Identify whether the equivalent of the documentMatchesType utility
316 // method is already implemented and substitute a call to the latter if so.
317 // This may well already exist.
319 * Identifies whether a document matches a supplied document type.
321 * @param docModel a document model.
322 * @param docType a document type string.
323 * @return true if the document matches the supplied document type; false if
326 private boolean documentMatchesType(DocumentModel docModel, String docType) {
327 if (docModel == null || Tools.isBlank(docType)) {
330 if (docModel.getType().startsWith(docType)) {
338 * Identifies whether a document is an active document; that is, if it is
339 * not a versioned record; not a proxy (symbolic link to an actual record);
340 * and not in the 'deleted' workflow state.
342 * (A note relating the latter: Nuxeo appears to send 'documentModified'
343 * events even on workflow transitions, such when records are 'soft deleted'
344 * by being transitioned to the 'deleted' workflow state.)
347 * @return true if the document is an active document; false if it is not.
349 private boolean isActiveDocument(DocumentModel docModel) {
350 if (docModel == null) {
353 boolean isActiveDocument = false;
355 if (!docModel.isVersion()
356 && !docModel.isProxy()
357 && !docModel.getCurrentLifeCycleState().equals(WorkflowClient.WORKFLOWSTATE_DELETED)) {
358 isActiveDocument = true;
360 } catch (ClientException ce) {
361 logger.warn("Error while identifying whether document is an active document: ", ce);
363 return isActiveDocument;
366 // FIXME: The following method is specific to PostgreSQL, because of
367 // the SQL command executed; it may need to be generalized.
368 // Note: It may be necessary in some cases to provide additional
369 // parameters beyond a function name (such as a function signature)
370 // to uniquely identify a function. So far, however, this need
371 // hasn't arisen in our specific use case here.
373 * Identifies whether a stored function exists in a database.
375 * @param functionname the name of the function.
376 * @return true if the function exists in the database; false if it does
379 private boolean storedFunctionExists(String functionname) {
380 if (Tools.isBlank(functionname)) {
383 boolean storedFunctionExists = false;
384 String sql = "SELECT proname FROM pg_proc WHERE proname='" + functionname + "'";
385 Connection conn = null;
386 Statement stmt = null;
389 conn = JDBCTools.getConnection(JDBCTools.getDataSource(JDBCTools.NUXEO_REPOSITORY_NAME));
390 stmt = conn.createStatement();
391 rs = stmt.executeQuery(sql);
393 storedFunctionExists = true;
398 } catch (Exception e) {
399 logger.debug("Error when identifying whether stored function " + functionname + "exists :", e);
411 } catch (SQLException sqle) {
412 logger.debug("SQL Exception closing statement/connection in "
413 + "UpdateObjectLocationOnMove.storedFunctionExists: "
414 + sqle.getLocalizedMessage());
417 return storedFunctionExists;
421 * Returns the computed current location of a CollectionObject (aka
422 * Cataloging) record.
424 * @param csid the CSID of a CollectionObject record.
425 * @return the computed current location of the CollectionObject record.
427 private String computeCurrentLocation(String csid) {
428 String computedCurrentLocation = "";
429 if (Tools.isBlank(csid)) {
430 return computedCurrentLocation;
432 String sql = String.format("SELECT %1$s('%2$s')", STORED_FUNCTION_NAME, csid);
433 Connection conn = null;
434 Statement stmt = null;
437 conn = JDBCTools.getConnection(JDBCTools.getDataSource(JDBCTools.NUXEO_REPOSITORY_NAME));
438 stmt = conn.createStatement();
439 rs = stmt.executeQuery(sql);
441 computedCurrentLocation = rs.getString(COMPUTED_CURRENT_LOCATION_COLUMN);
446 } catch (Exception e) {
447 logger.debug("Error when attempting to obtain the computed current location of an object :", e);
459 } catch (SQLException sqle) {
460 logger.debug("SQL Exception closing statement/connection in "
461 + "UpdateObjectLocationOnMove.computeCurrentLocation: "
462 + sqle.getLocalizedMessage());
465 return computedCurrentLocation;
469 * Returns a string representation of the contents of an input stream.
471 * @param instream an input stream.
472 * @return a string representation of the contents of the input stream.
473 * @throws an IOException if an error occurs when reading the input stream.
475 private String stringFromInputStream(InputStream instream) throws IOException {
476 if (instream == null) {
478 BufferedReader bufreader = new BufferedReader(new InputStreamReader(instream));
479 StringBuilder sb = new StringBuilder();
481 while (line != null) {
483 line = bufreader.readLine();
484 sb.append(LINE_SEPARATOR);
486 return sb.toString();
490 * Returns a string representation of a resource available to the current
493 * @param resourcePath a path to the resource.
494 * @return a string representation of the resource. Returns null if the
495 * resource cannot be read, or if it cannot be successfully represented as a
498 private String getStringFromResource(String resourcePath) {
500 ClassLoader classLoader = getClass().getClassLoader();
501 InputStream instream = classLoader.getResourceAsStream(resourcePath);
502 if (instream == null) {
503 logger.warn("Could not read from resource from path " + resourcePath);
507 str = stringFromInputStream(instream);
508 } catch (IOException ioe) {
509 logger.warn("Could not create string from stream: ", ioe);
515 private DocumentModel getDocModelFromCsid(CoreSession coreSession, String collectionObjectCsid) {
516 DocumentModelList collectionObjectDocModels = null;
518 final String query = "SELECT * FROM "
519 + NuxeoUtils.BASE_DOCUMENT_TYPE
521 + NuxeoUtils.getByNameWhereClause(collectionObjectCsid);
522 collectionObjectDocModels = coreSession.query(query);
523 } catch (Exception e) {
524 logger.warn("Exception in query to get document model for CollectionObject: ", e);
526 if (collectionObjectDocModels == null || collectionObjectDocModels.isEmpty()) {
527 logger.warn("Could not get document models for CollectionObject(s).");
528 } else if (collectionObjectDocModels.size() != 1) {
529 logger.debug("Found more than 1 document with CSID=" + collectionObjectCsid);
531 return collectionObjectDocModels.get(0);