Skip to content

Commit a082d43

Browse files
Merge pull request #1973 from marklogic/MLE-32111
MLE-32111 Add annotation support to StructuredQueryBuilder (#1648)
2 parents e7b9260 + 4b4c8f6 commit a082d43

2 files changed

Lines changed: 175 additions & 3 deletions

File tree

‎marklogic-client-api/src/main/java/com/marklogic/client/query/StructuredQueryBuilder.java‎

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
/*
2-
* Copyright (c) 2010-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.
2+
* Copyright (c) 2010-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.
33
*/
44
package com.marklogic.client.query;
55

66
import com.marklogic.client.MarkLogicIOException;
77
import com.marklogic.client.impl.AbstractQueryDefinition;
8+
import com.marklogic.client.impl.DOMWriter;
89
import com.marklogic.client.impl.RawQueryDefinitionImpl;
910
import com.marklogic.client.impl.XmlFactories;
1011
import com.marklogic.client.io.BaseHandle;
@@ -20,13 +21,18 @@
2021

2122
import javax.xml.XMLConstants;
2223
import javax.xml.namespace.QName;
24+
import javax.xml.parsers.DocumentBuilder;
2325
import javax.xml.stream.XMLOutputFactory;
2426
import javax.xml.stream.XMLStreamException;
2527
import javax.xml.stream.XMLStreamWriter;
2628
import javax.xml.transform.Templates;
29+
import org.w3c.dom.Document;
30+
import org.xml.sax.SAXException;
31+
import java.io.ByteArrayInputStream;
2732
import java.io.ByteArrayOutputStream;
2833
import java.io.IOException;
2934
import java.io.OutputStream;
35+
import java.nio.charset.StandardCharsets;
3036
import java.util.Calendar;
3137
import java.util.Date;
3238
import java.util.HashMap;
@@ -289,6 +295,35 @@ public RawStructuredQueryDefinition build(StructuredQueryDefinition... queries)
289295
);
290296
}
291297

298+
/**
299+
* Defines one or more annotation elements to include in the query. An annotation is a marker
300+
* element that is ignored when the query is evaluated; it can hold arbitrary XML (or plain text)
301+
* and is typically used to document a query or to mark parts of a query so they can be located and
302+
* manipulated later. When the structured query is turned into a
303+
* <a href="http://docs.marklogic.com/guide/search-dev/cts_query#id_28038">cts:query</a> by the
304+
* server, each annotation becomes a {@code cts:annotation} element.
305+
* <p>
306+
* Each argument becomes the content of a separate {@code annotation} element and may be a well-formed
307+
* XML fragment (including elements in their own namespaces) or plain text. Annotations are valid at
308+
* the top level of a query, so the resulting query definition should be passed to
309+
* {@link #build(StructuredQueryDefinition...)} alongside the other query definitions, for example:
310+
* <pre>{@code
311+
* qb.build(
312+
* qb.and(qb.term("hello"), qb.term("world")),
313+
* qb.annotation("<a:marker xmlns:a='http://example.org/annotations'>roles-and-rights</a:marker>"));
314+
* }</pre>
315+
* @param annotations the content of one or more annotation elements
316+
* @return the StructuredQueryDefinition for the annotations
317+
* @see <a href="http://docs.marklogic.com/guide/search-dev/cts_query#id_28038">
318+
* Composing cts:query Expressions: cts:annotation</a>
319+
*/
320+
public StructuredQueryDefinition annotation(String... annotations) {
321+
if (annotations == null) {
322+
throw new IllegalArgumentException("annotations cannot be null");
323+
}
324+
return new AnnotationQuery(annotations);
325+
}
326+
292327
/**
293328
* Defines an AND query over the list of query definitions.
294329
* @param queries the query definitions
@@ -1272,6 +1307,65 @@ public void innerSerialize(XMLStreamWriter serializer) throws XMLStreamException
12721307
}
12731308
}
12741309

1310+
protected class AnnotationQuery
1311+
extends AbstractStructuredQuery {
1312+
private String[] annotations;
1313+
1314+
public AnnotationQuery(String... annotations) {
1315+
super();
1316+
this.annotations = annotations;
1317+
}
1318+
1319+
@Override
1320+
public void innerSerialize(XMLStreamWriter serializer) throws XMLStreamException {
1321+
if (annotations == null) {
1322+
return;
1323+
}
1324+
for (String annotation: annotations) {
1325+
writeSearchElement(serializer, "annotation");
1326+
if (annotation != null) {
1327+
writeAnnotationContent(serializer, annotation);
1328+
}
1329+
serializer.writeEndElement();
1330+
}
1331+
}
1332+
1333+
// The content of an annotation is arbitrary XML (or plain text). Attempt to parse it as an XML
1334+
// fragment and copy the resulting nodes into the serializer; if it is not well-formed XML, fall
1335+
// back to writing it as text so plain-text annotations are still supported.
1336+
private void writeAnnotationContent(XMLStreamWriter serializer, String annotation)
1337+
throws XMLStreamException
1338+
{
1339+
Document document = parseAnnotationContent(annotation);
1340+
if (document == null) {
1341+
serializer.writeCharacters(annotation);
1342+
return;
1343+
}
1344+
new DOMWriter(serializer).serializeNodeList(document.getDocumentElement().getChildNodes());
1345+
}
1346+
1347+
private Document parseAnnotationContent(String annotation) {
1348+
String wrapped = "<annotation-content>" + annotation + "</annotation-content>";
1349+
try {
1350+
DocumentBuilder builder = XmlFactories.getDocumentBuilderFactory().newDocumentBuilder();
1351+
return builder.parse(
1352+
new ByteArrayInputStream(wrapped.getBytes(StandardCharsets.UTF_8)));
1353+
} catch (SAXException e) {
1354+
// Not well-formed XML; treat the content as plain text.
1355+
return null;
1356+
} catch (IOException | RuntimeException | javax.xml.parsers.ParserConfigurationException e) {
1357+
throw new MarkLogicIOException(e);
1358+
}
1359+
}
1360+
1361+
@Override
1362+
public boolean canSerializeQueryAsJSON() {
1363+
// Annotations carry arbitrary XML content that has no JSON structured-query representation,
1364+
// so force the XML serialization path.
1365+
return false;
1366+
}
1367+
}
1368+
12751369
protected class OrQuery
12761370
extends AbstractStructuredQuery {
12771371
private StructuredQueryDefinition[] queries;
@@ -2600,7 +2694,16 @@ private void writeStructuredQueryImpl(OutputStream out, Object... objects) {
26002694
}
26012695
}
26022696
for (AbstractStructuredQuery query: (AbstractStructuredQuery[]) objects) {
2603-
query.innerSerialize(serializer);
2697+
if (!(query instanceof AnnotationQuery)) {
2698+
query.innerSerialize(serializer);
2699+
}
2700+
}
2701+
// The search schema requires annotation elements to follow the query elements within the
2702+
// top-level query element, so serialize any annotations last regardless of caller order.
2703+
for (AbstractStructuredQuery query: (AbstractStructuredQuery[]) objects) {
2704+
if (query instanceof AnnotationQuery) {
2705+
query.innerSerialize(serializer);
2706+
}
26042707
}
26052708
} else if (objects instanceof RegionImpl[]) {
26062709
for (RegionImpl region: (RegionImpl[]) objects) {

‎marklogic-client-api/src/test/java/com/marklogic/client/test/StructuredQueryBuilderTest.java‎

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2010-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.
2+
* Copyright (c) 2010-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.
33
*/
44
package com.marklogic.client.test;
55

@@ -562,6 +562,75 @@ public void testBuilder() throws IOException, SAXException, ParserConfigurationE
562562
}
563563
}
564564

565+
@Test
566+
public void testAnnotation() throws Exception {
567+
StructuredQueryBuilder qb = new StructuredQueryBuilder();
568+
569+
SAXParser parser = newValidatingParser();
570+
ParseHandler handler = new ParseHandler();
571+
572+
// Annotation containing a namespaced marker element, following a query.
573+
String q = qb.build(
574+
qb.and(qb.term("one")),
575+
qb.annotation("<a:marker xmlns:a=\"http://example.org/annotations\">roles-and-rights</a:marker>")
576+
).toString();
577+
parser.parse(new StringInputStream(q), handler);
578+
assertXMLEqual("<query xmlns=\"http://marklogic.com/appservices/search\">"
579+
+ "<and-query><term-query><text>one</text></term-query></and-query>"
580+
+ "<annotation><a:marker xmlns:a=\"http://example.org/annotations\">roles-and-rights</a:marker></annotation>"
581+
+ "</query>", q);
582+
583+
// Annotation-only query.
584+
q = qb.build(qb.annotation("<marker/>")).toString();
585+
parser.parse(new StringInputStream(q), handler);
586+
assertXMLEqual("<query xmlns=\"http://marklogic.com/appservices/search\">"
587+
+ "<annotation><marker/></annotation></query>", q);
588+
589+
// The serialize() convenience method on the annotation query definition itself.
590+
q = qb.annotation("<marker/>").serialize();
591+
parser.parse(new StringInputStream(q), handler);
592+
assertXMLEqual("<query xmlns=\"http://marklogic.com/appservices/search\">"
593+
+ "<annotation><marker/></annotation></query>", q);
594+
595+
// Plain-text annotation content.
596+
q = qb.build(qb.term("one"), qb.annotation("a plain text note")).toString();
597+
parser.parse(new StringInputStream(q), handler);
598+
assertXMLEqual("<query xmlns=\"http://marklogic.com/appservices/search\">"
599+
+ "<term-query><text>one</text></term-query>"
600+
+ "<annotation>a plain text note</annotation></query>", q);
601+
602+
// Multiple annotation elements from a single call.
603+
q = qb.build(qb.term("one"), qb.annotation("<a/>", "<b/>")).toString();
604+
parser.parse(new StringInputStream(q), handler);
605+
assertXMLEqual("<query xmlns=\"http://marklogic.com/appservices/search\">"
606+
+ "<term-query><text>one</text></term-query>"
607+
+ "<annotation><a/></annotation><annotation><b/></annotation></query>", q);
608+
609+
// Annotations must serialize after the queries even when passed before them.
610+
q = qb.build(qb.annotation("<marker/>"), qb.and(qb.term("one"))).toString();
611+
parser.parse(new StringInputStream(q), handler);
612+
assertXMLEqual("<query xmlns=\"http://marklogic.com/appservices/search\">"
613+
+ "<and-query><term-query><text>one</text></term-query></and-query>"
614+
+ "<annotation><marker/></annotation></query>", q);
615+
}
616+
617+
private static SAXParser newValidatingParser() throws Exception {
618+
File schemaFile = new File("src/test/resources/search.xsd");
619+
try (InputStream fileInputStream = new FileInputStream(schemaFile)) {
620+
StreamSource[] sources = new StreamSource[1];
621+
sources[0] = new StreamSource(fileInputStream);
622+
sources[0].setSystemId(schemaFile);
623+
624+
SchemaFactory schemaFactory = SchemaFactory.newInstance("http://www.w3.org/XML/XMLSchema/v1.1");
625+
Schema schema = schemaFactory.newSchema(sources);
626+
627+
SAXParserFactory parserFactory = SAXParserFactory.newInstance();
628+
parserFactory.setNamespaceAware(true);
629+
parserFactory.setSchema(schema);
630+
return parserFactory.newSAXParser();
631+
}
632+
}
633+
565634
static private class ParseHandler extends DefaultHandler {
566635
@Override
567636
public void fatalError(SAXParseException spe) throws SAXParseException {

0 commit comments

Comments
 (0)