Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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
* <a href="http://docs.marklogic.com/guide/search-dev/cts_query#id_28038">cts:query</a> by the
* server, each annotation becomes a {@code cts:annotation} element.
* <p>
* 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:
* <pre>{@code
* qb.build(
* qb.and(qb.term("hello"), qb.term("world")),
* qb.annotation("<a:marker xmlns:a='http://example.org/annotations'>roles-and-rights</a:marker>"));
* }</pre>
* @param annotations the content of one or more annotation elements
* @return the StructuredQueryDefinition for the annotations
* @see <a href="http://docs.marklogic.com/guide/search-dev/cts_query#id_28038">
* Composing cts:query Expressions: cts:annotation</a>
*/
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
Expand Down Expand Up @@ -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-content>" + annotation + "</annotation-content>";
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;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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("<a:marker xmlns:a=\"http://example.org/annotations\">roles-and-rights</a:marker>")
).toString();
parser.parse(new StringInputStream(q), handler);
assertXMLEqual("<query xmlns=\"http://marklogic.com/appservices/search\">"
+ "<and-query><term-query><text>one</text></term-query></and-query>"
+ "<annotation><a:marker xmlns:a=\"http://example.org/annotations\">roles-and-rights</a:marker></annotation>"
+ "</query>", q);

// Annotation-only query.
q = qb.build(qb.annotation("<marker/>")).toString();
parser.parse(new StringInputStream(q), handler);
assertXMLEqual("<query xmlns=\"http://marklogic.com/appservices/search\">"
+ "<annotation><marker/></annotation></query>", q);

// The serialize() convenience method on the annotation query definition itself.
q = qb.annotation("<marker/>").serialize();
parser.parse(new StringInputStream(q), handler);
assertXMLEqual("<query xmlns=\"http://marklogic.com/appservices/search\">"
+ "<annotation><marker/></annotation></query>", 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("<query xmlns=\"http://marklogic.com/appservices/search\">"
+ "<term-query><text>one</text></term-query>"
+ "<annotation>a plain text note</annotation></query>", q);

// Multiple annotation elements from a single call.
q = qb.build(qb.term("one"), qb.annotation("<a/>", "<b/>")).toString();
parser.parse(new StringInputStream(q), handler);
assertXMLEqual("<query xmlns=\"http://marklogic.com/appservices/search\">"
+ "<term-query><text>one</text></term-query>"
+ "<annotation><a/></annotation><annotation><b/></annotation></query>", q);

// Annotations must serialize after the queries even when passed before them.
q = qb.build(qb.annotation("<marker/>"), qb.and(qb.term("one"))).toString();
parser.parse(new StringInputStream(q), handler);
assertXMLEqual("<query xmlns=\"http://marklogic.com/appservices/search\">"
+ "<and-query><term-query><text>one</text></term-query></and-query>"
+ "<annotation><marker/></annotation></query>", 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 {
Expand Down
Loading