Best practise to serialize QueryRoot?

I use a DataServices client to talk to DS server.
On the client side I create the Query using the builder like this:

@Override protected QueryRoot getQuery() {
		QueryRoot.QueryRootBuilder builder = QueryRoot.builder();

		builder.projectionName(PROJECTION_DOCUMENT)
			.targetDocumentModel(docRef.getDocumentModelName());
		builder.constraint(ExactMatchOperator.builder()
			.field("/__meta/docRef")
			.value(docRef.toString())
			.build());
		builder.paging(Paging.builder().pageNumber(0).pageSize(10).build());
		return builder.build();
	}

For the JSON-RPC call I create the params like this:

record Query(QueryRoot query) {	}
var param = objectMapper.valueToTree(new Query(getQuery()));	

This gets serialized to:

{
    "query": {
        "constraint": {
            "operator": "ExactMatchOperator",
            "field": "/__meta/docRef",
            "value": "TestCase-dm/4fc453e2-c17f-40c5-9986-39f9b0294fb6",
            "caseSensitive": true
        },
        "paging": {
            "pageNumber": 0,
            "pageSize": 10
        },
        "projectionName": "document",
        "targetDocumentModel": "TestCase-dm"
    }
}

Almost usable, but the “ExactMatchOperator” is not valid. Is there a way to get the correct value exact_match appear there?

Hi @markus-async-dune,

The operator type name is declared via @QueryOperator("exact_match") (an A12 annotation Jackson doesn’t know about) so a plain ObjectMapper falls back to the simple class name "ExactMatchOperator". You need to register the @QueryOperator-annotated classes as Jackson NamedTypes on your client-side ObjectMapper (the A12 server does the same thing internally at startup).

1. What’s happening

ILogicOperator declares polymorphic type info as @JsonTypeInfo(use = Id.NAME, property = "operator", defaultImpl = UnknownOperator.class). With Id.NAME, Jackson resolves the discriminator value from @JsonTypeName or from registered subtypes. ExactMatchOperator carries @QueryOperator("exact_match") instead — an A12-specific annotation Jackson doesn’t see — so it falls back to the simple class name.

2. Register operator subtypes on your ObjectMapper

Option A — explicit (fine if you only use a handful of operators):

objectMapper.registerSubtypes(
    new NamedType(ExactMatchOperator.class, "exact_match"),
    new NamedType(AndOperator.class, "and"),
    new NamedType(OrOperator.class, "or")
    // … any other operators you use
);

Option B — scan like the server does (recommended; picks up every operator without you maintaining a list). This is exactly what DefaultQueryGeneratorContext does on the server using org.reflections:reflections:

Reflections reflections = new Reflections("com.mgmtp.a12.dataservices");
reflections.getTypesAnnotatedWith(QueryOperator.class).stream()
    .filter(c -> !Modifier.isAbstract(c.getModifiers()))
    .map(c -> new NamedType(c, c.getAnnotation(QueryOperator.class).value()))
    .forEach(objectMapper::registerSubtypes);

If you also use aggregation functions, repeat the scan for @QueryAggregationFunction — the server does that in the same method.

3. Apply it to both directions

Register subtypes on the ObjectMapper you use for both serialization and deserialization. Otherwise polymorphic reads will silently fall back to UnknownOperator (the defaultImpl on ILogicOperator) and you’ll lose constraint info on the way back.

Hint: If you prefer not to hand-roll the JSON-RPC envelope, RequestBuilderFactory.newJsonRpc2RequestBuilder() from dataservices-client gives you a typed builder; it still delegates to the injected ObjectMapper, so the registration above remains the prerequisite.

Further reading:

  • com.mgmtp.a12.dataservices.query.constraint.ILogicOperator — the @JsonTypeInfo declaration and the @JsonIgnore getOperator() default.
  • com.mgmtp.a12.dataservices.query.generator.sql.internal.DefaultQueryGeneratorContext — the reference reflection-based registration used by the server.

Cheers,
Markus