A developer working on an Alfresco migration to OpenSearch asked a question that is worth answering carefully, because they made heavy use of the = prefix and wanted to know whether it would keep working:
My understanding was that using the exact term syntax was forcing the query to execute as a TMDQ query, but after reading some more of the documentation, I'm not clear if that is the case or not.
The answer is "yes, effectively" and also "no, not by the mechanism you think", and the gap between those two answers is exactly where working queries break during a migration.
The advice itself is good advice. Prefix your property predicates with = and your metadata query stays in the database instead of going to the search engine. It has been circulating in forum threads for years, it is what experienced Alfresco developers reach for, and under the shipped default configuration it works reliably. It behaves identically on Solr and on OpenSearch, so nothing you have learned here is invalidated by a migration.
The refinement is this: = is a precondition, not a switch. Under the default configuration those two descriptions predict the same outcome, which is why the folklore is both widespread and useful. Nobody has been getting bad results. But they diverge in three specific situations, and each one is a way a query that works today breaks later, usually silently.
= actually doesIt sets the tokenisation mode of a predicate. Without =, the predicate carries the default analysis mode. With =, it carries identifier mode. That is the entirety of the difference.
The same prefix applies to terms, phrases, prefix terms and wildcard terms, and in every case it has that single effect. The syntax is real, in the sense that the AFTS grammar has distinct exact-term and exact-phrase productions, but by the time there is a query to execute, that distinction has collapsed into one flag on an otherwise identical predicate.
So = is not an operator. It is not a function. It is not a hint to an optimiser, and there is no "exact" concept in the query model at all. It sets a flag, and everything else in this post is a consequence of who reads that flag and what they do with it.
query.fts.queryConsistency picks the engine, and the query string plays no part in it. The AFTS text is not parsed until after an engine has already been chosen, so nothing you write in the query can influence the routing decision. There are four behaviours:
EVENTUAL - index only, never the database.TRANSACTIONAL - database only. Failures propagate to the caller rather than being retried.TRANSACTIONAL_IF_POSSIBLE and DEFAULT - try the database, fall back to the index. This is the shipped default for the elasticsearch subsystem, set in its elasticsearch.properties.HYBRID - disabled by default, and Solr-only in practice.There is a second exclusion that is less well known: if a request asks for facets, the database is skipped entirely, no matter what the query contains. This is the single most common reason a query that used to run in the database quietly moves to the index.
The routing wiring is subsystem-agnostic, and more so than you might expect. It is defined once and inherited by all five search subsystems: solr, solr4, solr6, elasticsearch and even noindex. The elasticsearch and solr6 definitions differ only in the subsystem name and the index-side component, and both default to TRANSACTIONAL_IF_POSSIBLE. Switching search engines changes none of this.
Figure 1: how the engine is chosen. The query string plays no part in it.

Now the two halves connect. The database engine refuses to handle any predicate that is not in identifier mode. Without =, it rejects the predicate with QueryModelException("Analysis mode not supported for DB <mode>"), and the switching layer catches exactly that exception type and retries the query on the index without telling anyone. With =, the database is allowed to proceed.
That is why the advice works, and why it became folklore. Under the shipped default, = is the last thing standing between your query and the database. But the accurate description is that = is a permission slip, not a routing instruction: it removes the last obstacle to a decision the consistency setting has already made.
Three cases where the permission-slip model and the routing model predict different outcomes:
= the query contains.EVENTUAL goes to the index no matter what.= is necessary but not sufficient. The database can only answer a subset of AFTS, described next.One detail explains why the folklore is so entrenched. The v1 Search REST API never sets query consistency at all, so every REST search runs at the default. For a developer working through that API, = is genuinely the only lever available. The advice is not a workaround born of confusion; it is the only control the interface offers.
Not database-eligible in any form, with or without =: TEXT:, ALL:, bare unfielded terms, PATH:, ANCESTOR:, SITE:, TAG:, ranges, mid-term wildcards, fuzzy and proximity terms, IN_TREE(), joins, multiple stores, ordering by relevance, and anything with facets.
Eligibility is finer-grained than most people expect. An unquoted prefix term with = can be database-eligible where the quoted form is not, so =name:Content* and =name:"Content*" do not necessarily route the same way. That is the granularity at which this operates.
PATH: deserves a note because it is annoying to diagnose. It is not registered in the database query model at all, so instead of a message about paths you get a generic Unknown property <qname>.
Two exceptions in your favour: TYPE, EXACTTYPE, ASPECT, EXACTASPECT and PARENT are handled before the exactness check is reached, so they work with or without =.
And one prerequisite that gates all of it: if the 4.2 metadata-query-indexes patch was not applied, every query is rejected from the database path, no matter what it looks like. A site that suppressed that patch gets no database execution at all.
Exact Term Search shares nothing with TMDQ except the = character. It is an index mapping feature, and it exists only on the index side.
For a covered property it adds a sibling top-level field with an _exact suffix, populated by copy_to. The mapping you end up with looks like this:
{
"test%3Aproperty": {
"type": "text",
"analyzer": "locale_text_index",
"search_analyzer": "locale_text_query",
"copy_to": [ "test%3Aproperty_exact", "test%3Aproperty_untokenized" ]
},
"test%3Aproperty_untokenized": { "type": "keyword" },
"test%3Aproperty_exact": {
"type": "text",
"analyzer": "locale_cross_text_index",
"search_analyzer": "locale_cross_text_query"
}
}
The locale_cross_text_* analyzers use a whitespace tokenizer with asciifolding, a word delimiter graph filter, lowercase and flatten_graph. What matters is what is absent: there is no stemmer. Note that the word delimiter step is fairly aggressive - it catenates, splits on case change and on numerics, and preserves originals - so this is not a lightly-processed field.
Three things readers reliably get wrong about it.
"Exact" means unstemmed, not untokenized. It is still a text field and it is still analyzed.
The _untokenized keyword field is a different thing, and CMIS uses that one. CMIS equality routes to _untokenized rather than _exact, so CMIS = does not need any of the configuration below. It throws a different message too, for tokenised-only fields: Exact field search is not supported for tokenized-only fields. Worth knowing when grepping logs, since it is easy to confuse with the AFTS message.
It is off by default, and the switch is not where you would look. The shipped exactTermSearch.properties has every line commented out:
# Exact Term search is switched off by default as it introduces index size overhead.
# to enable it, please uncomment the following on a datatype or property name basis
#alfresco.cross.locale.datatype.0={http://www.alfresco.org/model/dictionary/1.0}text
#alfresco.cross.locale.datatype.1={http://www.alfresco.org/model/dictionary/1.0}content
#alfresco.cross.locale.datatype.2={http://www.alfresco.org/model/dictionary/1.0}mltext
#alfresco.cross.locale.property.0={http://www.alfresco.org/model/content/1.0}content
The override path is classpath:/alfresco/extension/exactTermSearch.properties, and it is not mentioned anywhere in elasticsearch.properties, which is where everyone looks first.
Coverage can be declared per datatype or per property, and either satisfies the check. Per datatype is usually what you want.
Two configuration gotchas. Entries are read as a dense zero-based sequence, stopping at the first missing index. So a gap in the numbering silently truncates the list: define datatype.0 and datatype.2, and you get one entry, with no warning. And a malformed entry is logged at WARN rather than failing the startup. Because both sets are populated together, a parse failure partway through can leave you with the datatype set filled and the property set empty, rather than cleanly disabling the feature.
One more thing worth knowing if you enable this for d:content. Content properties ask for content-flavoured cross-locale analyzers, and those do not exist in shipped configuration, which defines only the locale_cross_text_* pair. When the lookup against the live index comes back empty, the _exact field for content silently falls back to the plain standard analyzer, which is a rather different analyzer than the one you were expecting.
This is the part to put on a migration checklist.
If a query using = reaches the index and touches a property that Exact Term Search does not cover, you do not get a fallback and you do not get an empty result set. You get a hard error: UnsupportedOperationException("Exact term search is not supported for property: <name>").
The reason there is no recovery is that the switching layer catches only QueryModelException. An UnsupportedOperationException is unrelated to that type, so it passes straight through, and the index side re-throws it deliberately ahead of its generic handlers so that it escapes un-wrapped.
Traced through the REST layer, nothing registers a resolver for this exception type and the catch-all maps it to an internal server error, so it should surface as an HTTP 500 rather than a 4xx. That is strongly implied by the code path rather than measured, so treat the status code accordingly.
Figure 2: the trap. The dashed branch is what people expect and does not exist.

Two aggravating factors.
One uncovered property fails the whole query, including inside an OR. With only d:text and d:mltext covered, cm:name:run OR cm:persondescription:run returns results while =cm:name:run OR =cm:persondescription:run throws, because cm:persondescription is d:content. There is no partial evaluation.
=TEXT: and =ALL: expand, and every expanded field must be covered. TEXT: fans out to four default fields - cm:name, cm:title, cm:description and cm:content - and ALL: fans out over every property in the dictionary. Both propagate the exactness flag into the per-field check, so =ALL: throws on the first unconfigured property it meets.
This one is worth being careful about, because =TEXT: looks like it works once you enable the d:text and d:mltext datatypes. It does not. Of the four default fields, cm:name is d:text and cm:title and cm:description are d:mltext, but cm:content is d:content and needs its own entry:
alfresco.cross.locale.property.0={http://www.alfresco.org/model/content/1.0}content
Remove that one line and =TEXT: throws.
= query means different things on the two enginesThis is the most important migration point, and it does not depend on anyone's database configuration.
On the database path, = is whole-value equality. The generated SQL compares the stored property against your value in full, against alf_node_properties.string_value.
On the index path, = is token matching on an analyzed field. So =cm:content:"train fit" matches a document whose content is I love to train fit. Whole-value equality never would.
The same query can therefore return one row from the database and several from the index. If you are migrating, take a representative set of your real = queries and diff the result sets on both engines before cutting over. That single exercise is worth more than any amount of reading.
Case and accent sensitivity differ too, but with an honest hedge. The index always lowercases and folds accents, so it is reliably insensitive. The database depends on your collation: no COLLATE clause appears in the Alfresco create scripts, so you inherit whatever your instance was created with (PostgreSQL is normally case-sensitive; MySQL historically was not). Record which database you tested on.
Dates are a smaller trap in the same family. On the database path, d:date and d:datetime are compared as strings. The exceptions are cm:created, cm:modified, cm:creator and cm:modifier, which are special-cased to their own audit columns.
One rough edge, mentioned because it will otherwise cost someone an afternoon: _exact routing is applied inconsistently across query shapes. Term and phrase queries guard on the property being a text type; prefix and wildcard queries apply the routing with no type guard at all; range queries ignore the analysis mode entirely. The practical consequence is that a prefix or wildcard query with = on a non-text property throws where the plain-term equivalent works fine.
Generalising past the original question: what happens when there is no index at all? The source default is index.subsystem.name=noindex, and the chain is instructive.
The no-index query language throws AlfrescoRuntimeException("There is no index to execute the query"). That is not a QueryModelException, so it escapes the switching layer's fallback. But the no-index search service then catches it - along with searcher errors and query-model errors - and returns an empty result set for all three.
So under noindex, anything TMDQ cannot answer returns zero rows with no error. The silence comes from the no-index search service specifically, which is why the identical failure becomes loud the moment the subsystem points at OpenSearch. The only trace is a DEBUG line, and that is deliberate: it was demoted years ago because INFO-level logging of these fallbacks clogged the log on JBoss when the WQS module was installed.
The practical implication is uncomfortable and worth stating plainly. If you are running = queries against a repository with no index and default consistency, some of your "no results" may be silent failures rather than genuine absences. Audit that before a migration, because it changes what "working today" means.
If you want database-only behaviour, ask for it explicitly. Set query.fts.queryConsistency=TRANSACTIONAL and get errors instead of silence. The trade-off is real and you should know it up front: no full-text content search, no facets, no highlighting, no relevance ranking, no PATH or IN_TREE, no wildcards or ranges, no accurate total counts, and paging limited by the permission-check cap. Anything that currently appears to work only because it falls through to the index will start failing. That is the point of the setting, but it should not be a surprise in production.
Three different things, one character:
| TMDQ | Exact Term Search | CMIS = |
|
|---|---|---|---|
| What it is | Repository execution strategy | Search index mapping feature | CMIS equality predicate |
| What selects it | query.fts.queryConsistency |
Always on if configured | The CMIS query language |
What = means to it |
Precondition: only identifier-mode predicates accepted | Route the query to the _exact field |
Route the query to _untokenized |
| Field queried | alf_node_properties.string_value |
<field>_exact (text, unstemmed) |
<field>_untokenized (keyword) |
| Case sensitivity | Database collation | Insensitive (lowercase plus asciifold) | Sensitive (keyword) |
| Configuration required | The metadata query index patch | alfresco.cross.locale.*, off by default |
Tokenisation both or false |
The one line to take away: = does not route your query to the database. It removes the last obstacle to a decision the consistency setting has already made. Which is why it works, and why knowing the difference tells you when it will stop working.
Three experiments, in increasing order of setup cost. These are recipes rather than results, because the interesting part is what your configuration does.
Which stack. For the first experiment you need only a repository and a database. The ACS repo's own scripts/ci/docker-compose/docker-compose.yaml --profile with-elasticsearch brings up a search engine, PostgreSQL and ActiveMQ but no indexing connector, so nothing is ever indexed automatically. That is exactly right for experiment 1, and workable for 2 and 3 if you index a handful of documents by hand. One footnote: that profile pulls an Elasticsearch image rather than OpenSearch, which is harmless for these demos but worth knowing if you quote the results.
Experiment 1: = does not route. Two calls to /alfresco/api/-default-/public/search/versions/1/search with identical bodies and the same = query, one of them adding a facet. The REST response does not tell you which engine ran, so the log is your only observation point:
logger.org.alfresco.repo.search.impl.DbOrIndexSwitchingQueryLanguage=DEBUG
The non-faceted request logs the database branch. The faceted one does not. That is the entire thesis in two curl calls.
Experiment 2: the trap. Mount an alfresco/extension/exactTermSearch.properties that enables only d:text. Then =cm:name:foo succeeds while =cm:content:foo fails, because cm:content is d:content and not covered. Follow it with the OR case to watch one clause poison the query, then flip query.fts.queryConsistency=TRANSACTIONAL and watch the behaviour change.
Experiment 3: the semantics. Create one node with cm:name set to Report Alpha, then run these under TRANSACTIONAL and again under EVENTUAL:
| Query | What it is testing |
|---|---|
=cm:name:"Report Alpha" |
baseline, should match on both |
=cm:name:alpha |
whole-value equality against token matching |
=cm:name:"report alpha" |
case sensitivity, and therefore your collation |
The middle row is the one that matters, because it isolates the semantic difference without depending on your database configuration. Keep the third as a footnote and record which database you ran on.
If you enable Exact Term Search on an index that already has documents in it, two operational notes. Adding copy_to to an already-mapped field and adding the _exact sibling are both accepted on a live index, but documents indexed before the change will not match _exact queries until they are reindexed. A reindex is sufficient; a full index recreate is not required. The supporting analyzers are a different story: adding locale_cross_text_* to a live index is rejected outright and succeeds only with the index closed. So enabling this feature needs a maintenance window, not just a configuration change.
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.