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.List;
13 import org.apache.commons.logging.Log;
14 import org.apache.commons.logging.LogFactory;
15 import org.collectionspace.services.client.workflow.WorkflowClient;
16 import org.collectionspace.services.common.api.RefNameUtils;
17 import org.collectionspace.services.common.api.Tools;
18 import org.collectionspace.services.common.query.QueryContext;
19 import org.collectionspace.services.common.storage.JDBCTools;
20 import org.collectionspace.services.movement.nuxeo.MovementConstants;
21 import org.collectionspace.services.nuxeo.util.NuxeoUtils;
22 import org.nuxeo.ecm.core.api.ClientException;
23 import org.nuxeo.ecm.core.api.CoreSession;
24 import org.nuxeo.ecm.core.api.DocumentModel;
25 import org.nuxeo.ecm.core.api.DocumentModelList;
26 import org.nuxeo.ecm.core.event.Event;
27 import org.nuxeo.ecm.core.event.EventContext;
28 import org.nuxeo.ecm.core.event.EventListener;
29 import org.nuxeo.ecm.core.event.impl.DocumentEventContext;
31 public class UpdateObjectLocationOnMove implements EventListener {
33 // FIXME: We might experiment here with using log4j instead of Apache Commons Logging;
34 // am using the latter to follow Ray's pattern for now
35 final Log logger = LogFactory.getLog(UpdateObjectLocationOnMove.class);
36 private final String DATABASE_RESOURCE_DIRECTORY_NAME = "db";
37 // FIXME: Currently hard-coded; get this database name value from JDBC utilities or equivalent
38 private final String DATABASE_SYSTEM_NAME = "postgresql";
39 private final String STORED_FUNCTION_NAME = "computecurrentlocation";
40 private final String SQL_FILENAME_EXTENSION = ".sql";
41 private final String SQL_RESOURCE_PATH =
42 DATABASE_RESOURCE_DIRECTORY_NAME + "/"
43 + DATABASE_SYSTEM_NAME + "/"
44 + STORED_FUNCTION_NAME + SQL_FILENAME_EXTENSION;
45 // The name of the relevant column in the JDBC ResultSet is currently identical
46 // to the function name, regardless of the 'SELECT ... AS' clause in the SQL query.
47 private final String COMPUTED_CURRENT_LOCATION_COLUMN = STORED_FUNCTION_NAME;
48 // FIXME: Get this line separator value from already-declared constant elsewhere, if available
49 private final String LINE_SEPARATOR = System.getProperty("line.separator");
51 // ####################################################################
52 // FIXME: Per Rick, what happens if a relation record is updated,
53 // that either adds or removes a relation between a Movement
54 // record and a CollectionObject record? Do we need to listen
55 // for that event as well and update the CollectionObject record's
56 // computedCurrentLocation accordingly?
58 // The following code is currently checking only for creates or
59 // updates to Movement records.
60 // ####################################################################
62 public void handleEvent(Event event) throws ClientException {
64 logger.trace("In handleEvent in UpdateObjectLocationOnMove ...");
66 // FIXME: Check for database product type here.
67 // If our database type is one for which we don't yet
68 // have tested SQL code to perform this operation, return here.
70 EventContext eventContext = event.getContext();
71 if (eventContext == null) {
74 DocumentEventContext docEventContext = (DocumentEventContext) eventContext;
75 DocumentModel docModel = docEventContext.getSourceDocument();
76 if (isMovementDocument(docModel) && isActiveDocument(docModel)) {
77 logger.debug("A create or update event for an active Movement document was received by UpdateObjectLocationOnMove ...");
79 // Test whether a SQL function exists to supply the computed
80 // current location of a CollectionObject.
82 // If the function does not exist in the database, load the
83 // SQL command to create that function from a resource
84 // available to this class, and run a JDBC command to create
85 // that function in the database.
87 // For now, assume this function will be created in the
90 // FIXME: Future work to create per-tenant repositories will
91 // likely require that our JDBC statements connect to the
92 // appropriate tenant-specific database.
94 // It doesn't appear we can reliably create this function via
95 // 'ant create_nuxeo db' during the build process, because
96 // there's a substantial likelihood at that point that
97 // tables referred to by the function (e.g. movements_common
98 // and collectionobjects_common) will not yet exist.
99 // (PostgreSQL will not permit the function to be created if
100 // any of its referred-to tables do not exist.)
101 if (!storedFunctionExists(STORED_FUNCTION_NAME)) {
102 logger.debug("Stored function " + STORED_FUNCTION_NAME + " does NOT exist.");
103 String sql = getStringFromResource(SQL_RESOURCE_PATH);
104 if (Tools.isBlank(sql)) {
105 logger.warn("Could not obtain SQL command to create stored function.");
106 logger.warn("Actions in this event listener will NOT be performed, as a result of a previous error.");
112 result = JDBCTools.executeUpdate(JDBCTools.getDataSource(JDBCTools.NUXEO_REPOSITORY_NAME), sql);
113 } catch (Exception e) {
115 // FIXME: Need to verify that the original '-1' value is preserved if an Exception is caught here.
117 logger.debug("Result of executeUpdate=" + result);
119 logger.warn("Could not create stored function.");
120 logger.warn("Actions in this event listener will NOT be performed, as a result of a previous error.");
123 logger.debug("Stored function " + STORED_FUNCTION_NAME + " was successfully created.");
126 logger.debug("Stored function " + STORED_FUNCTION_NAME + " exists.");
129 String movementCsid = NuxeoUtils.getCsid(docModel);
130 logger.debug("Movement record CSID=" + movementCsid);
134 // Find CollectionObject records that are related to this Movement record:
136 // Via an NXQL query, get a list of (non-deleted) relation records where:
137 // * This movement record's CSID is the subject CSID of the relation.
138 // * The object document type is a CollectionObject doctype.
140 // Note: this assumes that every such relation is captured by
141 // relations with Movement-as-subject and CollectionObject-as-object;
142 // that may NOT always be the case.
144 // The following is boilerplate and may be incorrect:
146 CoreSession coreSession = docEventContext.getCoreSession();
147 final String RELATION_DOCTYPE = "Relation"; // FIXME: Get from external constant
148 final String COLLECTIONOBJECT_DOCTYPE = "CollectionObject"; // FIXME: Get from external constant
149 String query = String.format(
150 "SELECT * FROM %1$s WHERE " // collectionspace_core:tenantId = 1 "
151 + "(relations_common:subjectCsid = '%2$s' "
152 + "AND relations_common:objectDocumentType = '%3$s') "
153 + "AND (ecm:currentLifeCycleState <> 'deleted') "
154 + "AND ecm:isProxy = 0 "
155 + "AND ecm:isCheckedInVersion = 0", RELATION_DOCTYPE, movementCsid, COLLECTIONOBJECT_DOCTYPE);
156 DocumentModelList relatedDocModels = coreSession.query(query);
157 if (relatedDocModels == null || relatedDocModels.isEmpty()) {
160 logger.debug("Found " + relatedDocModels.size() + " related documents.");
163 // Iterate through the list of Relation records found and build
164 // a list of CollectionObject CSIDs, by extracting the object CSIDs
165 // from those Relation records.
167 final String RELATIONS_COMMON_SCHEMA = "relations_common"; // FIXME: Get from external constant
168 final String OBJECT_CSID_PROPERTY = "objectCsid"; // FIXME: Get from external constant
169 List<String> collectionObjectCsids = new ArrayList<String>();
170 for (DocumentModel relatedDocModel : relatedDocModels) {
171 csid = (String) relatedDocModel.getProperty(RELATIONS_COMMON_SCHEMA, OBJECT_CSID_PROPERTY);
172 if (Tools.notBlank(csid)) {
173 collectionObjectCsids.add(csid);
176 if (collectionObjectCsids == null || collectionObjectCsids.isEmpty()) {
179 logger.debug("Found " + collectionObjectCsids.size() + " CollectionObject CSIDs.");
182 // Iterate through the list of CollectionObject CSIDs found.
183 DocumentModel collectionObjectDocModel = null;
184 String computedCurrentLocationRefName = "";
185 for (String collectionObjectCsid : collectionObjectCsids) {
187 // Verify that the CollectionObject record is active.
188 // Code below untried; likely needs work.
191 collectionObjectDocModel = NuxeoUtils.getDocFromCsid(null, null, csid);
192 } catch (Exception e) {
193 logger.warn("Exception in getDocFromCsid: ", e);
195 if (!isActiveDocument(collectionObjectDocModel)) {
201 // Via a JDBC call, invoke the SQL function to obtain the computed
202 // current location of that CollectionObject.
203 computedCurrentLocationRefName = computeCurrentLocation(collectionObjectCsid);
204 logger.debug("computedCurrentLocationRefName=" + computedCurrentLocationRefName);
206 // Check that the value returned from this SQL function, which
207 // is expected to be a reference (refName) to a storage location
208 // authority term, is, at a minimum:
209 // * Non-null and non-blank. (We need to verify this assumption; can a
210 // CollectionObject's computed current location meaningfully be 'un-set'?)
211 // * Capable of being successfully parsed by an authority item parser;
212 // that is, returning a non-null parse result.
213 if ((Tools.notBlank(computedCurrentLocationRefName)
214 && (RefNameUtils.parseAuthorityTermInfo(computedCurrentLocationRefName) != null))) {
215 logger.debug("refName passes basic validation tests.");
217 // If the value returned from the function passes validation,
218 // compare that value to the value in the computedCurrentLocation
219 // field of that CollectionObject
221 // If the two values differ, update the CollectionObject record,
222 // setting the value of the computedCurrentLocation field of that
223 // CollectionObject record to the value returned from the SQL function.
233 * Identifies whether a document is a Movement document
235 * @param docModel a document model
236 * @return true if the document is a Movement document; false if it is not.
238 private boolean isMovementDocument(DocumentModel docModel) {
239 return documentMatchesType(docModel, MovementConstants.NUXEO_DOCTYPE);
242 // FIXME: Generic methods like many of those below might be split off,
243 // into an event utilities class, base classes, or otherwise. - ADR 2012-12-05
245 // FIXME: Identify whether the equivalent of the documentMatchesType utility
246 // method is already implemented and substitute a call to the latter if so.
247 // This may well already exist.
249 * Identifies whether a document matches a supplied document type.
251 * @param docModel a document model.
252 * @param docType a document type string.
253 * @return true if the document matches the supplied document type; false if
256 private boolean documentMatchesType(DocumentModel docModel, String docType) {
257 if (docModel == null || Tools.isBlank(docType)) {
260 if (docModel.getType().startsWith(docType)) {
268 * Identifies whether a document is an active document; that is, if it is
269 * not a versioned record; not a proxy (symbolic link to an actual record);
270 * and not in the 'deleted' workflow state.
272 * (A note relating the latter: Nuxeo appears to send 'documentModified'
273 * events even on workflow transitions, such when records are 'soft deleted'
274 * by being transitioned to the 'deleted' workflow state.)
277 * @return true if the document is an active document; false if it is not.
279 private boolean isActiveDocument(DocumentModel docModel) {
280 if (docModel == null) {
283 boolean isActiveDocument = false;
285 if (!docModel.isVersion()
286 && !docModel.isProxy()
287 && !docModel.getCurrentLifeCycleState().equals(WorkflowClient.WORKFLOWSTATE_DELETED)) {
288 isActiveDocument = true;
290 } catch (ClientException ce) {
291 logger.warn("Error while identifying whether document is an active document: ", ce);
293 return isActiveDocument;
296 // FIXME: The following method is specific to PostgreSQL, because of
297 // the SQL command executed; it may need to be generalized.
298 // Note: It may be necessary in some cases to provide additional
299 // parameters beyond a function name (such as a function signature)
300 // to uniquely identify a function. So far, however, this need
301 // hasn't arisen in our specific use case here.
303 * Identifies whether a stored function exists in a database.
305 * @param functionname the name of the function.
306 * @return true if the function exists in the database; false if it does
309 private boolean storedFunctionExists(String functionname) {
310 if (Tools.isBlank(functionname)) {
313 boolean storedFunctionExists = false;
314 String sql = "SELECT proname FROM pg_proc WHERE proname='" + functionname + "'";
315 Connection conn = null;
316 Statement stmt = null;
319 conn = JDBCTools.getConnection(JDBCTools.getDataSource(JDBCTools.NUXEO_REPOSITORY_NAME));
320 stmt = conn.createStatement();
321 rs = stmt.executeQuery(sql);
323 storedFunctionExists = true;
328 } catch (Exception e) {
329 logger.debug("Error when identifying whether stored function " + functionname + "exists :", e);
341 } catch (SQLException sqle) {
342 logger.debug("SQL Exception closing statement/connection in "
343 + "UpdateObjectLocationOnMove.storedFunctionExists: "
344 + sqle.getLocalizedMessage());
347 return storedFunctionExists;
351 * Returns the computed current location of a CollectionObject (aka
352 * Cataloging) record.
354 * @param csid the CSID of a CollectionObject record.
355 * @return the computed current location of the CollectionObject record.
357 private String computeCurrentLocation(String csid) {
358 String computedCurrentLocation = "";
359 if (Tools.isBlank(csid)) {
360 return computedCurrentLocation;
362 String sql = String.format("SELECT %1$s('%2$s')", STORED_FUNCTION_NAME, csid);
363 Connection conn = null;
364 Statement stmt = null;
367 conn = JDBCTools.getConnection(JDBCTools.getDataSource(JDBCTools.NUXEO_REPOSITORY_NAME));
368 stmt = conn.createStatement();
369 rs = stmt.executeQuery(sql);
371 computedCurrentLocation = rs.getString(COMPUTED_CURRENT_LOCATION_COLUMN);
376 } catch (Exception e) {
377 logger.debug("Error when attempting to obtain the computed current location of an object :", e);
389 } catch (SQLException sqle) {
390 logger.debug("SQL Exception closing statement/connection in "
391 + "UpdateObjectLocationOnMove.computeCurrentLocation: "
392 + sqle.getLocalizedMessage());
395 return computedCurrentLocation;
399 * Returns a string representation of the contents of an input stream.
401 * @param instream an input stream.
402 * @return a string representation of the contents of the input stream.
403 * @throws an IOException if an error occurs when reading the input stream.
405 private String stringFromInputStream(InputStream instream) throws IOException {
406 if (instream == null) {
408 BufferedReader bufreader = new BufferedReader(new InputStreamReader(instream));
409 StringBuilder sb = new StringBuilder();
411 while (line != null) {
413 line = bufreader.readLine();
414 sb.append(LINE_SEPARATOR);
416 return sb.toString();
420 * Returns a string representation of a resource available to the current
423 * @param resourcePath a path to the resource.
424 * @return a string representation of the resource. Returns null if the
425 * resource cannot be read, or if it cannot be successfully represented as a
428 private String getStringFromResource(String resourcePath) {
430 ClassLoader classLoader = getClass().getClassLoader();
431 InputStream instream = classLoader.getResourceAsStream(resourcePath);
432 if (instream == null) {
433 logger.warn("Could not read from resource from path " + resourcePath);
437 str = stringFromInputStream(instream);
438 } catch (IOException ioe) {
439 logger.warn("Could not create string from stream: ", ioe);