diff --git a/dspace-api/src/main/java/org/dspace/content/authority/DSpaceControlledVocabulary.java b/dspace-api/src/main/java/org/dspace/content/authority/DSpaceControlledVocabulary.java
index 444332df97d2..13b6a88d06b7 100644
--- a/dspace-api/src/main/java/org/dspace/content/authority/DSpaceControlledVocabulary.java
+++ b/dspace-api/src/main/java/org/dspace/content/authority/DSpaceControlledVocabulary.java
@@ -34,36 +34,38 @@
* from {@code ${dspace.dir}/config/controlled-vocabularies/*.xml} and turns
* them into autocompleting authorities.
*
- * Configuration: This MUST be configured as a self-named plugin, e.g.: {@code
- * plugin.selfnamed.org.dspace.content.authority.ChoiceAuthority = \
+ *
Configuration: This MUST be configured as a self-named plugin, e.g.: {@code
+ * plugin.selfnamed.org.dspace.content.authority.ChoiceAuthority =
* org.dspace.content.authority.DSpaceControlledVocabulary
* }
*
- * It AUTOMATICALLY configures a plugin instance for each XML file in the
+ *
It AUTOMATICALLY configures a plugin instance for each XML file in the
* controlled vocabularies directory. The name of the plugin is the basename of
* the file; e.g., {@code ${dspace.dir}/config/controlled-vocabularies/nsi.xml}
* would generate a plugin called "nsi".
*
- * Each configured plugin comes with three configuration options: {@code
- * vocabulary.plugin._plugin_.hierarchy.store =
- * # Store entire hierarchy along with selected value. Default: TRUE
- * vocabulary.plugin._plugin_.hierarchy.suggest =
- * # Display entire hierarchy in the suggestion list. Default: TRUE
- * vocabulary.plugin._plugin_.delimiter = ""
- * # Delimiter to use when building hierarchy strings. Default: "::"
- * }
+ * Each configured plugin comes with three configuration options:
+ *
+ * - {@code vocabulary.plugin._plugin_.hierarchy.store = }
+ * # Store entire hierarchy along with selected value. Default: TRUE
+ * - {@code vocabulary.plugin._plugin_.hierarchy.suggest =
+ * # Display entire hierarchy in the suggestion list. Default: TRUE}
+ * - {@code vocabulary.plugin._plugin_.delimiter = ""
+ * # Delimiter to use when building hierarchy strings. Default: "::"}
+ *
*
* @author Michael B. Klein
*/
public class DSpaceControlledVocabulary extends SelfNamedPlugin implements HierarchicalAuthority {
- private static Logger log = org.apache.logging.log4j.LogManager.getLogger(DSpaceControlledVocabulary.class);
+ private static final Logger log = org.apache.logging.log4j.LogManager.getLogger();
protected static String xpathTemplate = "//node[contains(translate(@label,'ABCDEFGHIJKLMNOPQRSTUVWXYZ'," +
- "'abcdefghijklmnopqrstuvwxyz'),'%s')]";
- protected static String idTemplate = "//node[@id = '%s']";
- protected static String labelTemplate = "//node[@label = '%s']";
- protected static String idParentTemplate = "//node[@id = '%s']/parent::isComposedBy/parent::node";
+ "'abcdefghijklmnopqrstuvwxyz'),%s)]";
+ protected static String idTemplate = "//node[@id = %s]";
+ protected static String idTemplateQuoted = "//node[@id = $nodeId]";
+ protected static String labelTemplate = "//node[@label = %s]";
+ protected static String idParentTemplate = "//node[@id = $nodeId]/parent::isComposedBy/parent::node";
protected static String rootTemplate = "/node";
protected static String pluginNames[] = null;
@@ -106,7 +108,7 @@ public boolean accept(File dir, String name) {
File.separator + "config" +
File.separator + "controlled-vocabularies";
String[] xmlFiles = (new File(vocabulariesPath)).list(new xmlFilter());
- List names = new ArrayList();
+ List names = new ArrayList<>();
for (String filename : xmlFiles) {
names.add((new File(filename)).getName().replace(".xml", ""));
}
@@ -162,14 +164,23 @@ protected String buildString(Node node) {
public Choices getMatches(String text, int start, int limit, String locale) {
init();
log.debug("Getting matches for '" + text + "'");
- String xpathExpression = "";
String[] textHierarchy = text.split(hierarchyDelimiter, -1);
+ StringBuilder xpathExpressionBuilder = new StringBuilder();
for (int i = 0; i < textHierarchy.length; i++) {
- xpathExpression += String.format(xpathTemplate, textHierarchy[i].replaceAll("'", "'").toLowerCase());
+ xpathExpressionBuilder.append(String.format(xpathTemplate, "$var" + i));
}
+ String xpathExpression = xpathExpressionBuilder.toString();
XPath xpath = XPathFactory.newInstance().newXPath();
- int total = 0;
- List choices = new ArrayList();
+ xpath.setXPathVariableResolver(variableName -> {
+ String varName = variableName.getLocalPart();
+ if (varName.startsWith("var")) {
+ int index = Integer.parseInt(varName.substring(3));
+ return textHierarchy[index].toLowerCase();
+ }
+ throw new IllegalArgumentException("Unexpected variable: " + varName);
+ });
+ int total;
+ List choices;
try {
NodeList results = (NodeList) xpath.evaluate(xpathExpression, vocabulary, XPathConstants.NODESET);
total = results.getLength();
@@ -185,14 +196,23 @@ public Choices getMatches(String text, int start, int limit, String locale) {
@Override
public Choices getBestMatch(String text, String locale) {
init();
- log.debug("Getting best matches for '" + text + "'");
- String xpathExpression = "";
+ log.debug("Getting best matches for {}'", text);
String[] textHierarchy = text.split(hierarchyDelimiter, -1);
+ StringBuilder xpathExpressionBuilder = new StringBuilder();
for (int i = 0; i < textHierarchy.length; i++) {
- xpathExpression += String.format(labelTemplate, textHierarchy[i].replaceAll("'", "'"));
+ xpathExpressionBuilder.append(String.format(labelTemplate, "$var" + i));
}
+ String xpathExpression = xpathExpressionBuilder.toString();
XPath xpath = XPathFactory.newInstance().newXPath();
- List choices = new ArrayList();
+ xpath.setXPathVariableResolver(variableName -> {
+ String varName = variableName.getLocalPart();
+ if (varName.startsWith("var")) {
+ int index = Integer.parseInt(varName.substring(3));
+ return textHierarchy[index];
+ }
+ throw new IllegalArgumentException("Unexpected variable: " + varName);
+ });
+ List choices;
try {
NodeList results = (NodeList) xpath.evaluate(xpathExpression, vocabulary, XPathConstants.NODESET);
choices = getChoicesFromNodeList(results, 0, 1);
@@ -240,16 +260,14 @@ public Choices getTopChoices(String authorityName, int start, int limit, String
@Override
public Choices getChoicesByParent(String authorityName, String parentId, int start, int limit, String locale) {
init();
- String xpathExpression = String.format(idTemplate, parentId);
- return getChoicesByXpath(xpathExpression, start, limit);
+ return getChoicesByXpathWithId(idTemplateQuoted, parentId, start, limit);
}
@Override
public Choice getParentChoice(String authorityName, String childId, String locale) {
init();
try {
- String xpathExpression = String.format(idParentTemplate, childId);
- Choice choice = createChoiceFromNode(getNodeFromXPath(xpathExpression));
+ Choice choice = createChoiceFromNode(getNodeByIdFromXPath(idParentTemplate, childId));
return choice;
} catch (XPathExpressionException e) {
log.error(e.getMessage(), e);
@@ -264,17 +282,23 @@ public Integer getPreloadLevel() {
}
private boolean isRootElement(Node node) {
- if (node != null && node.getOwnerDocument().getDocumentElement().equals(node)) {
- return true;
- }
- return false;
+ return node != null && node.getOwnerDocument().getDocumentElement().equals(node);
}
private Node getNode(String key) throws XPathExpressionException {
init();
- String xpathExpression = String.format(idTemplate, key);
- Node node = getNodeFromXPath(xpathExpression);
- return node;
+ return getNodeByIdFromXPath(idTemplateQuoted, key);
+ }
+
+ private Node getNodeByIdFromXPath(String xpathTemplate, String nodeId) throws XPathExpressionException {
+ XPath xpath = XPathFactory.newInstance().newXPath();
+ xpath.setXPathVariableResolver(variableName -> {
+ if ("nodeId".equals(variableName.getLocalPart())) {
+ return nodeId;
+ }
+ throw new IllegalArgumentException("Unexpected variable: " + variableName);
+ });
+ return (Node) xpath.evaluate(xpathTemplate, vocabulary, XPathConstants.NODE);
}
private Node getNodeFromXPath(String xpathExpression) throws XPathExpressionException {
@@ -284,7 +308,7 @@ private Node getNodeFromXPath(String xpathExpression) throws XPathExpressionExce
}
private List getChoicesFromNodeList(NodeList results, int start, int limit) {
- List choices = new ArrayList();
+ List choices = new ArrayList<>();
for (int i = 0; i < results.getLength(); i++) {
if (i < start) {
continue;
@@ -303,14 +327,14 @@ private List getChoicesFromNodeList(NodeList results, int start, int lim
private Map addOtherInformation(String parentCurr, String noteCurr,
List childrenCurr, String authorityCurr) {
- Map extras = new HashMap();
+ Map extras = new HashMap<>();
if (StringUtils.isNotBlank(parentCurr)) {
extras.put("parent", parentCurr);
}
if (StringUtils.isNotBlank(noteCurr)) {
extras.put("note", noteCurr);
}
- if (childrenCurr.size() > 0) {
+ if (!childrenCurr.isEmpty()) {
extras.put("hasChildren", "true");
} else {
extras.put("hasChildren", "false");
@@ -368,7 +392,7 @@ private String getNote(Node node) {
}
private List getChildren(Node node) {
- List children = new ArrayList();
+ List children = new ArrayList<>();
NodeList childNodes = node.getChildNodes();
for (int ci = 0; ci < childNodes.getLength(); ci++) {
Node firstChild = childNodes.item(ci);
@@ -391,7 +415,7 @@ private List getChildren(Node node) {
private boolean isSelectable(Node node) {
Node selectableAttr = node.getAttributes().getNamedItem("selectable");
if (null != selectableAttr) {
- return Boolean.valueOf(selectableAttr.getNodeValue());
+ return Boolean.parseBoolean(selectableAttr.getNodeValue());
} else { // Default is true
return true;
}
@@ -418,7 +442,7 @@ private String getAuthority(Node node) {
}
private Choices getChoicesByXpath(String xpathExpression, int start, int limit) {
- List choices = new ArrayList();
+ List choices = new ArrayList<>();
XPath xpath = XPathFactory.newInstance().newXPath();
try {
Node parentNode = (Node) xpath.evaluate(xpathExpression, vocabulary, XPathConstants.NODE);
@@ -448,6 +472,43 @@ private Choices getChoicesByXpath(String xpathExpression, int start, int limit)
return new Choices(false);
}
+ private Choices getChoicesByXpathWithId(String xpathTemplate, String nodeId, int start, int limit) {
+ List choices = new ArrayList<>();
+ XPath xpath = XPathFactory.newInstance().newXPath();
+ xpath.setXPathVariableResolver(variableName -> {
+ if ("nodeId".equals(variableName.getLocalPart())) {
+ return nodeId;
+ }
+ throw new IllegalArgumentException("Unexpected variable: " + variableName);
+ });
+ try {
+ Node parentNode = (Node) xpath.evaluate(xpathTemplate, vocabulary, XPathConstants.NODE);
+ int count = 0;
+ if (parentNode != null) {
+ NodeList childNodes = (NodeList) xpath.evaluate(".//isComposedBy", parentNode, XPathConstants.NODE);
+ if (null != childNodes) {
+ for (int i = 0; i < childNodes.getLength(); i++) {
+ Node childNode = childNodes.item(i);
+ if (childNode != null && "node".equals(childNode.getNodeName())) {
+ if (count < start || choices.size() >= limit) {
+ count++;
+ continue;
+ }
+ count++;
+ choices.add(createChoiceFromNode(childNode));
+ }
+ }
+ }
+ return new Choices(choices.toArray(new Choice[choices.size()]), start, count,
+ Choices.CF_AMBIGUOUS, false);
+ }
+ } catch (XPathExpressionException e) {
+ log.warn(e.getMessage(), e);
+ return new Choices(true);
+ }
+ return new Choices(false);
+ }
+
private Choice createChoiceFromNode(Node node) {
if (node != null && !isRootElement(node)) {
Choice choice = new Choice(getAuthority(node), getLabel(node), getValue(node),
diff --git a/dspace-api/src/test/data/dspaceFolder/config/controlled-vocabularies/farm.xml b/dspace-api/src/test/data/dspaceFolder/config/controlled-vocabularies/farm.xml
index c77d1c5ff291..e4c1469b6fd7 100644
--- a/dspace-api/src/test/data/dspaceFolder/config/controlled-vocabularies/farm.xml
+++ b/dspace-api/src/test/data/dspaceFolder/config/controlled-vocabularies/farm.xml
@@ -2,6 +2,10 @@
-
+
+
+
+
+
diff --git a/dspace-api/src/test/java/org/dspace/content/authority/DSpaceControlledVocabularyTest.java b/dspace-api/src/test/java/org/dspace/content/authority/DSpaceControlledVocabularyTest.java
index 255b070e5eac..b774aacbd8ee 100644
--- a/dspace-api/src/test/java/org/dspace/content/authority/DSpaceControlledVocabularyTest.java
+++ b/dspace-api/src/test/java/org/dspace/content/authority/DSpaceControlledVocabularyTest.java
@@ -8,7 +8,9 @@
package org.dspace.content.authority;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
import java.io.IOException;
@@ -86,9 +88,42 @@ public void testGetMatches() throws IOException, ClassNotFoundException {
CoreServiceFactory.getInstance().getPluginService().getNamedPlugin(Class.forName(PLUGIN_INTERFACE), "farm");
assertNotNull(instance);
Choices result = instance.getMatches(text, start, limit, locale);
+ assertNotEquals("At least one match expected", 0, result.values.length);
assertEquals("north 40", result.values[0].value);
}
+ /**
+ * Node ids (entryID, parent, child) must be compared as plain strings, not spliced into the XPath expression.
+ * @throws java.lang.ClassNotFoundException passed through.
+ */
+ @Test
+ public void testNodeIdIsNotEvaluatedAsXPath() throws ClassNotFoundException {
+ DSpaceControlledVocabulary instance = (DSpaceControlledVocabulary)
+ CoreServiceFactory.getInstance().getPluginService()
+ .getNamedPlugin(Class.forName("org.dspace.content.authority.ChoiceAuthority"), "farm");
+ assertNotNull(instance);
+
+ Choice choice = instance.getChoice("s40", null);
+ assertNotNull(choice);
+ assertEquals("south 40", choice.label);
+
+ // Spliced into the expression this would select //node[@id = 'x' or @label='south 40']
+ assertNull(instance.getChoice("x' or @label='south 40", null));
+ // ... and here the root node, returning its children
+ Choices children = instance.getChoicesByParent("farm", "x' or @label='the farm", 0, 10, null);
+ assertEquals(0, children.values.length);
+
+ children = instance.getChoicesByParent("farm", "s40", 0, 10, null);
+ assertEquals(1, children.values.length);
+ assertEquals("pond", children.values[0].authority);
+
+ Choice parent = instance.getParentChoice("farm", "pond", null);
+ assertNotNull(parent);
+ assertEquals("s40", parent.authority);
+ // ... and here the parent of 'pond'
+ assertNull(instance.getParentChoice("farm", "x' or @id='pond", null));
+ }
+
/**
* Test of getBestMatch method, of class DSpaceControlledVocabulary.
*/