Translating SRU CQL Queries to ILS Search Parameters
This page answers one narrow operational question: why does an incoming SRU request that carries a well-formed CQL query — something like dc.title all "climate" and bath.author = "Smith" — come back with wrong or zero results once your gateway hands it to the native ILS search API, and how do you translate it correctly? It sits under Z39.50 and SRU Catalog Search Integration, which covers the search-integration surface end to end, and within the broader Circulation Protocols & Interoperability architecture. If you operate an SRU endpoint that fronts a Sierra, Alma, Koha, or FOLIO search API, the mistranslation below is one every gateway hits the first time a client sends a boolean query.
Problem Framing
The symptom is silent wrongness, not an error. The SRU response is a valid searchRetrieveResponse with a numberOfRecords, the HTTP status is 200, and no diagnostic is raised. The count is simply wrong — usually far too low, often zero — and occasionally too high because a boolean was dropped and the ILS OR-ed everything together. A client that asked for climate-titled works by Smith gets the whole catalogue, or nothing.
The failure shows the moment you log what the gateway actually forwarded. Turn on a debug line at the point where the CQL string is placed onto the ILS request and compare the inbound query to the outbound parameters:
$ grep -A1 'forwarding_search' gateway.log | tail -4
inbound cql=dc.title all "climate" and bath.author = "Smith"
outbound q=dc.title+all+%22climate%22+and+bath.author+%3D+%22Smith%22
ILS numberOfRecords=0 (expected ~40)
The outbound q is the CQL text, URL-encoded and dropped verbatim into a native parameter. The ILS search parser has never heard of dc.title, all, or bath.author; it treats the whole string as a single phrase against its default index, matches nothing, and returns zero. The boolean and, the relation =, and the index prefixes were all meaningful CQL tokens, and every one of them was flattened into an opaque blob before the ILS ever saw it.
Root Cause
The root cause is a category error: treating CQL as a flat string when it is a structured grammar. Contextual Query Language is a small formal language, not a search box. A CQL query is a tree of search clauses — each an index relation term triple such as dc.title all "climate" — joined by boolean operators (and, or, not, prox) that carry precedence and can be parenthesised, with optional relation modifiers like =/relevant hanging off a relation. Structure is the payload. A string substitution keeps the characters and discards the meaning.
Three distinct things break when you flatten it:
- Booleans lose precedence.
a and b or cis nota and (b or c); CQL binds left-to-right unless parenthesised, and the ILS query language has its own precedence rules. Concatenating the tokens hands the ILS a grouping you never intended. - Indexes are not portable.
dc.title,bath.author, andbath.isbnare CQL context-set-qualified index names. The native API calls the same fieldst,a, andnb(Sierra), ortitle,contributor,identifiers.value(FOLIO CQL differs again). Passing the SRU index name through unchanged targets an index the ILS does not have. - Relations and terms are not escaped for the target. CQL
all,any,=, and==map to different native operators (all-words AND, any-words OR, exact phrase, exact string). A quoted term containing a space, a quote, or the literal wordandmust be escaped for the ILS query syntax, which is not CQL’s syntax.
Flattening loses all three at once. The fix is to stop treating the query as text and start treating it as the tree it already is: parse it once, then walk the tree emitting native parameters, mapping each index and escaping each term for the target as you go.
Solution
Parse the CQL into a small typed abstract syntax tree, then walk that tree to emit the ILS query. A parser turns the token stream into BoolNode and SearchClause objects; a translator visits each node, resolves the CQL index through an explicit index map, chooses the native operator for the relation, escapes the term, and composes the result respecting boolean structure. Anything the map does not know about raises an explicit exception rather than being guessed at — the same fail-closed discipline the parent Z39.50 and SRU Catalog Search Integration contract applies to every inbound request.
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
logger = logging.getLogger("sru.cql")
class CQLTranslationError(Exception):
"""Raised when a CQL query cannot be safely mapped to the ILS."""
# --- AST -------------------------------------------------------------------
@dataclass(frozen=True)
class SearchClause:
index: str # e.g. "dc.title"
relation: str # e.g. "all", "=", "=="
term: str # unquoted term value
@dataclass(frozen=True)
class BoolNode:
op: str # "and" | "or" | "not"
left: Node
right: Node
Node = SearchClause | BoolNode
# --- Target mapping --------------------------------------------------------
# CQL context-set index name -> native ILS index code (Sierra-style here).
INDEX_MAP: dict[str, str] = {
"dc.title": "t",
"bath.author": "a",
"dc.creator": "a",
"bath.isbn": "nb",
"dc.subject": "d",
"cql.anywhere": "q",
}
# CQL relation -> (native operator, whether the term is an exact phrase).
RELATION_MAP: dict[str, tuple[str, bool]] = {
"all": ("AND", False), # all words present
"any": ("OR", False), # any word present
"=": ("AND", False), # word match
"==": ("PHRASE", True), # exact string
"adj": ("PHRASE", True),
}
_BOOL_NATIVE = {"and": "AND", "or": "OR", "not": "NOT"}
_NEEDS_ESCAPE = re.compile(r'["\\()]')
def _escape_term(term: str) -> str:
"""Escape a term value for the ILS query grammar (not CQL's)."""
escaped = _NEEDS_ESCAPE.sub(lambda m: "\\" + m.group(0), term)
return f'"{escaped}"' if " " in escaped else escaped
def _emit_clause(clause: SearchClause) -> str:
native_index = INDEX_MAP.get(clause.index)
if native_index is None:
raise CQLTranslationError(f"unmapped CQL index: {clause.index!r}")
relation = RELATION_MAP.get(clause.relation)
if relation is None:
raise CQLTranslationError(f"unsupported CQL relation: {clause.relation!r}")
native_op, exact = relation
term = _escape_term(clause.term)
if exact:
return f"({native_index}:{term})"
words = clause.term.split()
joined = f" {native_op} ".join(f"{native_index}:{_escape_term(w)}" for w in words)
return f"({joined})"
def translate(node: Node) -> str:
"""Walk the AST depth-first, emitting a parenthesised ILS query."""
if isinstance(node, SearchClause):
return _emit_clause(node)
if isinstance(node, BoolNode):
left = translate(node.left)
right = translate(node.right)
native = _BOOL_NATIVE[node.op]
# Parentheses preserve CQL's binding so the ILS cannot re-group.
return f"({left} {native} {right})"
raise CQLTranslationError(f"unknown AST node: {type(node).__name__}")
def cql_to_ils(ast: Node, *, request_id: str) -> str:
try:
query = translate(ast)
except CQLTranslationError:
logger.warning(
"cql_translation_failed",
extra={"request_id": request_id, "node_type": type(ast).__name__},
)
raise
logger.info(
"cql_translated",
extra={"request_id": request_id, "clause_count": _count_clauses(ast)},
)
return query
def _count_clauses(node: Node) -> int:
if isinstance(node, SearchClause):
return 1
return _count_clauses(node.left) + _count_clauses(node.right)
The behavioural change is that structure now drives the output. dc.title all "climate" and bath.author = "Smith" parses to a BoolNode("and", SearchClause("dc.title","all","climate"), SearchClause("bath.author","=","Smith")), and translate emits ((t:climate) AND (a:"Smith")). Each index is resolved through INDEX_MAP, each relation picks the right native operator, each term is escaped for the ILS grammar, and every subtree is wrapped in parentheses so the ILS can never re-group the booleans. An index the gateway has not mapped — say a client sends bath.notes — raises CQLTranslationError and produces an SRU diagnostic, rather than silently searching the wrong field.
Compliance or Privacy Impact
Translation runs on the query text a patron typed, so the log line is the exposure. A CQL term can be directly patron-attributable — a name, a card number, an address searched against a patron-facing discovery layer, or a subject that reveals a reading interest. The logging calls above deliberately record only structural metadata: the request_id, the offending index name, the clause count. They never place the raw term or the assembled q string into a log extra. A debug dump that prints the outbound query — convenient while chasing a zero-result bug — becomes a durable record of what individuals searched for, which is exactly what circulation-privacy practice forbids retaining.
If you must capture query text to reproduce a translation fault, route it through the redaction discipline described in PII Masking in Patron Data Exports: mask term values before they reach any sink, keep the structural shape (index and relation) that you actually need to debug, and hold the captured sample only for the life of the investigation. The index names and boolean operators are safe to log; the terms are not. Keeping that line bright means a search-gateway log can be shared with a vendor or kept for troubleshooting without becoming a patron-reading-history disclosure.
Verification
Confirm the translator preserves boolean structure, maps indexes, and refuses unknown ones, rather than round-tripping a string. Assert on the emitted query for a boolean case, and assert that an unmapped index raises instead of guessing.
import pytest
def test_boolean_structure_is_preserved() -> None:
ast = BoolNode(
"and",
SearchClause("dc.title", "all", "climate change"),
SearchClause("bath.author", "=", "Smith"),
)
query = cql_to_ils(ast, request_id="t1")
# Both title words AND-ed, author mapped, booleans parenthesised.
assert query == "((t:climate AND t:change) AND (a:Smith))"
def test_exact_relation_uses_phrase_operator() -> None:
ast = SearchClause("dc.title", "==", "climate report")
assert cql_to_ils(ast, request_id="t2") == '(t:"climate report")'
def test_unmapped_index_raises_not_guesses() -> None:
ast = SearchClause("bath.notes", "=", "internal")
with pytest.raises(CQLTranslationError, match="unmapped CQL index"):
cql_to_ils(ast, request_id="t3")
For a running endpoint, add a golden-file test that drives real client queries through the parser and translator and compares the ILS numberOfRecords against a known-good baseline for each. A boolean query whose count equals the count of one of its clauses is the signature of a dropped operator; a count of zero on a query that should match is the signature of an unmapped index. Track cql_translation_failed events over time — a spike after a client upgrade usually means a new context set or index that INDEX_MAP has not yet been taught, and the fail-closed exception is doing its job by surfacing it instead of returning quiet nonsense.
Related
- Z39.50 and SRU Catalog Search Integration — the parent guide to the SRU/CQL search-integration contract this translator plugs into.
- Circulation Protocols & Interoperability — the wider protocol-interoperability architecture that frames how gateways front an ILS.
- Parsing Z39.50 MARC Responses with pymarc — the other half of the round trip: turning the records the ILS returns back into structured MARC.
- PII Masking in Patron Data Exports — how to redact patron-attributable query terms before they reach any log or export.