diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/query/StructuredQueryBuilder.java b/marklogic-client-api/src/main/java/com/marklogic/client/query/StructuredQueryBuilder.java index 25960978d..475ad6fdb 100644 --- a/marklogic-client-api/src/main/java/com/marklogic/client/query/StructuredQueryBuilder.java +++ b/marklogic-client-api/src/main/java/com/marklogic/client/query/StructuredQueryBuilder.java @@ -1,10 +1,11 @@ /* - * Copyright (c) 2010-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. + * Copyright (c) 2010-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. */ package com.marklogic.client.query; import com.marklogic.client.MarkLogicIOException; import com.marklogic.client.impl.AbstractQueryDefinition; +import com.marklogic.client.impl.DOMWriter; import com.marklogic.client.impl.RawQueryDefinitionImpl; import com.marklogic.client.impl.XmlFactories; import com.marklogic.client.io.BaseHandle; @@ -20,13 +21,18 @@ import javax.xml.XMLConstants; import javax.xml.namespace.QName; +import javax.xml.parsers.DocumentBuilder; import javax.xml.stream.XMLOutputFactory; import javax.xml.stream.XMLStreamException; import javax.xml.stream.XMLStreamWriter; import javax.xml.transform.Templates; +import org.w3c.dom.Document; +import org.xml.sax.SAXException; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; +import java.nio.charset.StandardCharsets; import java.util.Calendar; import java.util.Date; import java.util.HashMap; @@ -289,6 +295,35 @@ public RawStructuredQueryDefinition build(StructuredQueryDefinition... queries) ); } + /** + * Defines one or more annotation elements to include in the query. An annotation is a marker + * element that is ignored when the query is evaluated; it can hold arbitrary XML (or plain text) + * and is typically used to document a query or to mark parts of a query so they can be located and + * manipulated later. When the structured query is turned into a + * cts:query by the + * server, each annotation becomes a {@code cts:annotation} element. + *

+ * Each argument becomes the content of a separate {@code annotation} element and may be a well-formed + * XML fragment (including elements in their own namespaces) or plain text. Annotations are valid at + * the top level of a query, so the resulting query definition should be passed to + * {@link #build(StructuredQueryDefinition...)} alongside the other query definitions, for example: + *

{@code
+   * qb.build(
+   *     qb.and(qb.term("hello"), qb.term("world")),
+   *     qb.annotation("roles-and-rights"));
+   * }
+ * @param annotations the content of one or more annotation elements + * @return the StructuredQueryDefinition for the annotations + * @see + * Composing cts:query Expressions: cts:annotation + */ + public StructuredQueryDefinition annotation(String... annotations) { + if (annotations == null) { + throw new IllegalArgumentException("annotations cannot be null"); + } + return new AnnotationQuery(annotations); + } + /** * Defines an AND query over the list of query definitions. * @param queries the query definitions @@ -1272,6 +1307,65 @@ public void innerSerialize(XMLStreamWriter serializer) throws XMLStreamException } } + protected class AnnotationQuery + extends AbstractStructuredQuery { + private String[] annotations; + + public AnnotationQuery(String... annotations) { + super(); + this.annotations = annotations; + } + + @Override + public void innerSerialize(XMLStreamWriter serializer) throws XMLStreamException { + if (annotations == null) { + return; + } + for (String annotation: annotations) { + writeSearchElement(serializer, "annotation"); + if (annotation != null) { + writeAnnotationContent(serializer, annotation); + } + serializer.writeEndElement(); + } + } + + // The content of an annotation is arbitrary XML (or plain text). Attempt to parse it as an XML + // fragment and copy the resulting nodes into the serializer; if it is not well-formed XML, fall + // back to writing it as text so plain-text annotations are still supported. + private void writeAnnotationContent(XMLStreamWriter serializer, String annotation) + throws XMLStreamException + { + Document document = parseAnnotationContent(annotation); + if (document == null) { + serializer.writeCharacters(annotation); + return; + } + new DOMWriter(serializer).serializeNodeList(document.getDocumentElement().getChildNodes()); + } + + private Document parseAnnotationContent(String annotation) { + String wrapped = "" + annotation + ""; + try { + DocumentBuilder builder = XmlFactories.getDocumentBuilderFactory().newDocumentBuilder(); + return builder.parse( + new ByteArrayInputStream(wrapped.getBytes(StandardCharsets.UTF_8))); + } catch (SAXException e) { + // Not well-formed XML; treat the content as plain text. + return null; + } catch (IOException | RuntimeException | javax.xml.parsers.ParserConfigurationException e) { + throw new MarkLogicIOException(e); + } + } + + @Override + public boolean canSerializeQueryAsJSON() { + // Annotations carry arbitrary XML content that has no JSON structured-query representation, + // so force the XML serialization path. + return false; + } + } + protected class OrQuery extends AbstractStructuredQuery { private StructuredQueryDefinition[] queries; @@ -2600,7 +2694,16 @@ private void writeStructuredQueryImpl(OutputStream out, Object... objects) { } } for (AbstractStructuredQuery query: (AbstractStructuredQuery[]) objects) { - query.innerSerialize(serializer); + if (!(query instanceof AnnotationQuery)) { + query.innerSerialize(serializer); + } + } + // The search schema requires annotation elements to follow the query elements within the + // top-level query element, so serialize any annotations last regardless of caller order. + for (AbstractStructuredQuery query: (AbstractStructuredQuery[]) objects) { + if (query instanceof AnnotationQuery) { + query.innerSerialize(serializer); + } } } else if (objects instanceof RegionImpl[]) { for (RegionImpl region: (RegionImpl[]) objects) { diff --git a/marklogic-client-api/src/test/java/com/marklogic/client/test/StructuredQueryBuilderTest.java b/marklogic-client-api/src/test/java/com/marklogic/client/test/StructuredQueryBuilderTest.java index a4ca06944..2734b9346 100644 --- a/marklogic-client-api/src/test/java/com/marklogic/client/test/StructuredQueryBuilderTest.java +++ b/marklogic-client-api/src/test/java/com/marklogic/client/test/StructuredQueryBuilderTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. + * Copyright (c) 2010-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. */ package com.marklogic.client.test; @@ -562,6 +562,75 @@ public void testBuilder() throws IOException, SAXException, ParserConfigurationE } } + @Test + public void testAnnotation() throws Exception { + StructuredQueryBuilder qb = new StructuredQueryBuilder(); + + SAXParser parser = newValidatingParser(); + ParseHandler handler = new ParseHandler(); + + // Annotation containing a namespaced marker element, following a query. + String q = qb.build( + qb.and(qb.term("one")), + qb.annotation("roles-and-rights") + ).toString(); + parser.parse(new StringInputStream(q), handler); + assertXMLEqual("" + + "one" + + "roles-and-rights" + + "", q); + + // Annotation-only query. + q = qb.build(qb.annotation("")).toString(); + parser.parse(new StringInputStream(q), handler); + assertXMLEqual("" + + "", q); + + // The serialize() convenience method on the annotation query definition itself. + q = qb.annotation("").serialize(); + parser.parse(new StringInputStream(q), handler); + assertXMLEqual("" + + "", q); + + // Plain-text annotation content. + q = qb.build(qb.term("one"), qb.annotation("a plain text note")).toString(); + parser.parse(new StringInputStream(q), handler); + assertXMLEqual("" + + "one" + + "a plain text note", q); + + // Multiple annotation elements from a single call. + q = qb.build(qb.term("one"), qb.annotation("", "")).toString(); + parser.parse(new StringInputStream(q), handler); + assertXMLEqual("" + + "one" + + "", q); + + // Annotations must serialize after the queries even when passed before them. + q = qb.build(qb.annotation(""), qb.and(qb.term("one"))).toString(); + parser.parse(new StringInputStream(q), handler); + assertXMLEqual("" + + "one" + + "", q); + } + + private static SAXParser newValidatingParser() throws Exception { + File schemaFile = new File("src/test/resources/search.xsd"); + try (InputStream fileInputStream = new FileInputStream(schemaFile)) { + StreamSource[] sources = new StreamSource[1]; + sources[0] = new StreamSource(fileInputStream); + sources[0].setSystemId(schemaFile); + + SchemaFactory schemaFactory = SchemaFactory.newInstance("http://www.w3.org/XML/XMLSchema/v1.1"); + Schema schema = schemaFactory.newSchema(sources); + + SAXParserFactory parserFactory = SAXParserFactory.newInstance(); + parserFactory.setNamespaceAware(true); + parserFactory.setSchema(schema); + return parserFactory.newSAXParser(); + } + } + static private class ParseHandler extends DefaultHandler { @Override public void fatalError(SAXParseException spe) throws SAXParseException {