Skip to content

refactor: reflection-based search mapping + location geopoint - #3345

Open
dschmidt wants to merge 31 commits into
mainfrom
refactor/search-mapping
Open

refactor: reflection-based search mapping + location geopoint#3345
dschmidt wants to merge 31 commits into
mainfrom
refactor/search-mapping

Conversation

@dschmidt

Copy link
Copy Markdown
Contributor

Supersedes #2659. Moved to an upstream (opencloud-eu) branch and rebased cleanly onto latest main so the dependent search PRs can stack on top of it. The non-bot discussion from #2659 is carried over in the comments below (quoted, since it can't be reposted under the original authors).

Summary

Merges the bleve and OpenSearch index mappings into one reflection-based package (services/search/pkg/mapping) driven by the search.Resource struct + a small overrides map. Pulls services/graph's duplicated reflection walker onto the same helpers and, as a showcase of what the refactor enables, turns on location_geopoint indexing for spatial queries on both backends.

Adding a new facet (motionPhoto, ...) is now roughly: one struct field on content.Document, one line in service.go, one line in graph, one line per backend hit converter, plus whatever tika extraction logic the facet actually needs. Everything else falls out of reflection.

Behavior changes (deliberate)

Existing indexes keep their stored mapping; the new shape only applies to newly-created indexes.

  • OpenSearch Tags / Favorites: dynamic keyword → explicit keyword (unified with bleve), searched case-insensitively via the _lowercase siblings described below. No analyzer.

  • OpenSearch facet sub-strings (audio.*, photo.*, image.*): dynamic text + keyword multi-field → keyword-only. The tokenized path was never reachable from KQL anyway (no dot-syntax + pre-fix(search): preserve value case for non-lowercased bleve fields #2633 lowercasing), so no working query regresses; aggregations now produce correct case-preserving buckets on both backends.

  • location: the libregraph {longitude, latitude, altitude} object is preserved at the location key on both backends (numeric sub-field queries like location.latitude:>49 keep working). A sibling location_geopoint is added for geo-distance / bounding-box / polygon queries.

  • path: queries are case-sensitive on both backends; Path gets no _lowercase sibling. Paths act as references (location scoping, deep links) where /Foo and /foo are distinct siblings, and case-insensitive folder discovery is served by name:. This matches bleve on main, which always matched paths case-sensitively; only OpenSearch loses its case-insensitive path matching. Dropping the sibling also removes its costliest maintenance: Path is the one mutable sibling field, so the OpenSearch move script no longer has to rebuild a lowercased copy for a whole subtree. With paths case-sensitive, the ref path scope is applied at query level on both backends (term/prefix on bleve's keyword Path, term filter on OpenSearch's path_hierarchy tokens), replacing the old post-filter: totals and paging now respect the scope instead of being computed over the whole space, and a wrong-cased scope matches nothing.

  • graph facet parsing: fail-soft per field. A malformed value drops only that field; the rest of the facet still populates.

  • Audio facet is now shown whenever libre.graph.audio.* metadata is present: the read-side audio/ guard is dropped from all three readers (bleve, OpenSearch, graph), so the facet follows the metadata rather than re-checking the MIME type. Extraction still only produces audio metadata for audio/* files.

  • OpenSearch Name/Tags tokenization now matches bleve: a single case-preserved keyword token (was word-tokenized), with case-insensitive matching handled by the _lowercase sibling. No impact on the product: web always searches wildcarded (name:"*term*", see web useSearch.ts), which matches regardless of tokenization; bleve has always been single-token. Only affects non-web clients sending a bare name:report and expecting a substring match.

  • Mtime is typed as a date on both backends, so mtime:>... ranges are chronological (was a keyword field / lexicographic compare on OpenSearch). Note: bleve has no sub-second date field, so a returned/re-indexed Mtime is second-precision (range queries stay exact). Previously the RFC3339Nano keyword round-tripped exactly.

  • Resource.Hidden now survives Move/Delete/Restore on bleve. The old hand-rolled deserializer never read Hidden, so those ops silently reset it to false; the reflection deserializer reads every field, preserving it. Latent-bug fix.

  • Case-insensitive search via per-field _lowercase siblings. Every keyword/path field indexes its case-preserved base (returned to clients, used for exact ops like the move/delete cascade) plus, when enabled, a lowercased sibling used only for matching. Queries route to the sibling and lowercase the value with the same Go strings.ToLower used at index time, so index and query stay consistent without an analyzer. The sibling is never read back, so it need not be stored: in bleve it is not stored, out of _all, no doc values; in OpenSearch it deliberately stays in _source, because excluding it would force every update-by-query script (move/delete/restore) to rebuild all siblings from the document via painless toLowerCase, which lowercases differently than Go and would drift from the query side. A lowercased copy of a name/path is negligible disk in a cluster. The OpenSearch Move script keeps base and sibling in sync via Go-lowercased params, so case-insensitive search still finds a file after it moves (the sibling used to go stale); bleve re-indexes whole documents on move/delete/restore and stays fresh for free. Also fixes a latent main bug: the Move/Delete cascade matched Path exactly against the lowercased index, so re-homing a mixed-case folder (e.g. /Photos) skipped its descendants; Path is now case-preserved. bleve path queries additionally match a folder and its descendants like OpenSearch's path_hierarchy.

  • OpenSearch full-text (content:) search now analyzes the query. Single-term queries used an unanalyzed term query, so once this refactor dropped the old blanket query-value lowercasing, content:Foo (any uppercase) missed on OpenSearch (a regression introduced here; bleve was unaffected because its query analyzes). Fielded full-text queries now use an analyzed match query. Content on OpenSearch also gets a porter stemming analyzer matching bleve's (it previously used the default standard analyzer and never stemmed, a pre-existing inconsistency), so content:running / content:run / content:RUNNING behave the same on both backends.

  • To decide - content wildcards (content:foo*) are unanalyzed on both backends, so they match the stemmed, lowercased term dictionary literally: content:run* finds a document containing Running (indexed term run), but content:running* (past the stem) and content:Run* (uppercase) do not. This is inherent to a wildcard over an analyzed field and is now consistent across bleve and OpenSearch (previously OpenSearch degraded content:foo* to an exact match). Whether content wildcards should additionally be case-folded is left open.

  • mediatype search is now case-insensitive on both backends: categories (mediatype:Folder, mediatype:IMAGE, ...) and literal MIME types are lowercased in the KQL lowering. Related behavior change: the raw MimeType field no longer expands category words. On main MimeType:folder / MimeType:file expanded to the folder / non-folder MIME set (a quirk of field-based expansion); now mediatype: is the way to query categories and MimeType: only matches a literal MIME type.

Upgrade note: the OpenSearch mapping (index resource_v2) now lists all properties explicitly, so it differs from any existing resource_v2 index. On startup Apply returns ErrManualActionRequired with a clear message; operators upgrading in place must drop and reindex resource_v2. Fresh installs and the bleve backend are unaffected.

Follow-ups (not in this PR)

  • NOT a OR b on OpenSearch: per [MS-KQL] (2.1.13, NOT has highest precedence) this is (NOT a) OR b, but a flat OpenSearch bool query cannot OR-combine a must_not clause, so it collapses to NOT a AND b. bleve represents it correctly (a must_not sub-query can be a disjunct). Rare (a NOT directly followed by OR) and a flat-bool limitation, not the NOT binding, which is now spec-correct on both backends.

  • Media-type category + operator precedence: X OR mediatype:<multi-type-category> AND Y (document/spreadsheet/presentation/archive) constrains only the last MIME type of the category, because the bleve compiler's mapBinary redistributes a left disjunction as an OR-chain. Pre-existing (main had the same), a pure query-compiler issue.

  • []struct round-trip: a json-tagged slice-of-struct field is mapped as a nested object but not read back (fillStruct/casing/geo do not descend into slices), and its _lowercase/_geopoint siblings would never be written. Latent: no current facet is a []struct (facets are *struct, tags are []string); harden before adding one.

  • bleve _all: unused (every query is fielded: the resolver always resolves a field, the compiler always emits field:value, and a bare term resolves to NameName_lowercase). Disabling it shrinks the bleve index and turns the per-field IncludeInAll handling into dead code to remove.

  • KQL typed-value parity (booleans/numerics): Hidden:T/Size:>1000 are bleve query-string leakage: they parse as plain string restrictions and only work because the bleve compiler concatenates values into bleve's own query-string syntax; on OpenSearch the same literals hit typed fields and 400. The KQL-canonical hidden:true compiles to a query-string Hidden:true on bleve and silently never matches (bleve indexes booleans as the term "T"); it should compile to a typed bool-field query. Canonical numeric ranges (Size>1000) don't parse at all, the grammar has range operators only for datetime values. Consolidate in the query layer: typed BooleanNode compile on bleve, grammar-level numeric ranges, then decide whether the query-string extras stay.

  • ? single-char wildcard parity: bleve treats ? as a wildcard, OpenSearch does not (the wildcard check only looks for *), so name:Fo? diverges. Pre-existing, not introduced here.

  • Field-name notation: KQL vs graph sortProperties: the queryString exposes internal index field names (Name:foo*, Size:>1000, Tags:bar, Mtime:...), while the graph search endpoint's sortProperties deliberately accepts the property names a client sees on the hit resource (name, size, lastModifiedDateTime, mimeType, photo.takenDateTime, ...) and translates them to index fields via an alias table in pkg/search. Align the two surfaces: accept graph-notation field names in KQL (on top of that alias table) and decide whether the internal index names remain part of the public query surface.

@dschmidt

Copy link
Copy Markdown
Contributor Author

Discussion carried over from #2659

Reposting the non-bot discussion from the original PR so it isn't lost with the move to this upstream branch. Quoted verbatim with attribution; the resolved bot review nits (codacy / copilot: embedded-pointer unwrap, IsNil on non-nilable kinds, a few doc/typo fixes) were all addressed there and aren't repeated here.

Why reflection? (design rationale)

@dragonchaser:

Why the extensive usage of reflections?

@dschmidt answered (click to expand)

Short version:
It's there so the search.Resource struct (plus a small overrides map) becomes the single source of truth for everything schema-shaped: the bleve DocumentMapping, the OpenSearch properties JSON, and the hit→struct deserialization on read. One struct, two backends, no parallel schemas to keep in sync.

Keeping all the mappings in sync for the existing facets and having to do a lot of copy paste for adding new facets, is tedious and error prone. The new code is not completely trivial to read, I agree, but it's basically write once and probably/hopefully never touch again. Basically it pulls together different pieces of reflection usage in a central location and concentrates it in the mapping package, so actually there's less reflection stuff spread around the code base.

Long version

  1. No drift between bleve and OpenSearch. Right now there are two hand-maintained mapping definitions plus a third hand-maintained "read fields back out of hit.Fields" path. They have already drifted. With reflection both backends walk the same fields with the same json-tag names via walkFields (infer.go), so they can't disagree by accident.
  2. Kills a duplicated walker. services/graph had its own reflection walker doing essentially the same thing; this PR collapses it onto the shared helpers in mapping/infer.go.
  3. Adding a facet is now ~5 lines. One field on content.Document, one line in service.go, one in graph, one per backend hit converter. Deserialize[T] handles it from the json tags.
  4. json tags are already the contract. Field names on the wire are already driven by struct tags, so reusing them via reflection for index field names just removes a second, hand-typed copy of the same names.
  5. Cost is bounded. Reflection runs once at index-mapping build time (startup) and once per hit on read. Not on a hot inner loop, not in the query path.

The alternative would be either codegen (more machinery for the same outcome) or keeping the three parallel hand-written schemas (which is what already doesn't work right now and what motivated the refactor).

@dschmidt on the diff size:

By the way looking at the line counts is a bit misleading. It's not 1.8k lines plus for a simple refactor. [...] a lot of the new lines are also for tests we simply didn't have before and it also adds the geopoint feature (we can of course discuss to split it out of this PR if you prefer, it's basically a demonstrator for the concept).

Related: #2715

Review feedback

@fschade:

Okay, the PR is huge, but I can understand that it's hard to logically split up such a massive topic. The only thing bothering me right now is the schema update issue; I can't think of a clean way to handle that independently of the deployment [...] I think we just need to document clearly what needs to be done for schema updates (and we'd face that problem regardless of this PR). From my side: thumbs up.

Follow-up documentation issue opened by @fschade: #3092

@aduffeck (workflow):

it would be great if you would just add commits instead of force-pushing refinements, that makes it easier to figure out what changed after starting the review.

@dschmidt:

Yes, will do from now on [...] Will only add commits on top from now on!

Note for this new PR: it is a clean rebase of the same commits onto latest main (the earlier one-shot history rewrite was agreed with the maintainers). From here it's add-commits-on-top again.

Open item: breaking index change / re-indexing (unresolved)

@fschade:

Since the PR contains a breaking change, it's just taking a bit longer than it should! [...] we can only ship the PR as a major version because the index is change breaking! Our idea is that in the case of breaking index changes [...] we can create a new, fresh index and have the operator re-index the data. Downside: the search returns no results for some time, so we need beforehand: the operator must be able to send a message to the users (sticky, web-ui); and a function that allows us to automatically generate a fresh index in the case of breaking index changes.

@dschmidt:

I've worked on parts of that already, let's have a call about everything asap :)

@aduffeck (on services/search/pkg/opensearch/index.go):

Requiring another re-indexing of the whole tree right after the stable 7.2 release is unfortunate [...] But maybe this is the time to spend some time on making index upgrades less intrusive. It should be possible to add a read alias on the v2 index while building a v3 index and then flip the alias over [...]. At the very least we should use "v3" as the index though [...] so that the new index could be built up in a pre-deployment hook in kubernetes, for example.

@dschmidt:

True, but requiring it right before the release would have been risky on the other hand - there's never a perfect timing for this kind of change :) We'll discuss soft upgrade options with @fschade next week :)

Resolved: ginkgo test conversion

@aduffeck (on geo_verify_test.go):

Could you change the tests to ginkgo/gomega tests? That's (largely) what we settled on.

@dschmidt: Converted geo_verify + mtime and the new mapping package tests to ginkgo/gomega. (This carried into the rebase: the opensearch backend suite is now ginkgo too.)

Open item: Mtime typed as a date (unresolved)

@dschmidt (on services/search/pkg/content/content.go):

Mtime was the only Go string mapped as a date. Basic.Extract leaves it unset when the resource info carries no mtime [...] which serialised to "Mtime": "" -- and an empty string is not a date, so OpenSearch rejected the whole document. The bulk API reports that per item, so Batch.Push returned nil and the file silently vanished from the index (that swallowing is #3142). Typing it as *time.Time fixes the cause rather than the symptom [...] Reproduced against a real cluster: before this, upsert without mtime indexed 1 of 2 documents.

An earlier symptom of the same typing (@aduffeck spotted the fixture choking the unit tests with a mapper_parsing_exception on Mtime) was fixed by switching the fixture to RFC3339.

@codacy-production

codacy-production Bot commented Aug 18, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 363 complexity · 127 duplication

Metric Results
Complexity 363
Duplication 127

View in Codacy

🟢 Coverage 83.85% diff coverage · +0.34% coverage variation

Metric Results
Coverage variation +0.34% coverage variation (-1.00%)
Diff coverage 83.85% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (8a9889c) 84276 19595 23.25%
Head commit (e5c12a9) 84675 (+399) 19977 (+382) 23.59% (+0.34%)

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#3345) 991 831 83.85%

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

Build the bleve and OpenSearch index mappings from the Go struct via
reflection (json tags + per-field overrides) instead of hand-rolled
mappings and hit deserializers. New mapping package: BleveBuildMapping,
OpenSearchBuildMapping, Deserialize[T], PrepareForIndex; field decoding is
fail-soft. Mtime is typed as a date so mtime ranges are chronological on
both backends. Route CS3 facet parsing through mapping.DeserializeStringMap.

The any-valued (bleve hit) and string-valued (CS3 metadata) deserializers
share one generic fillStruct walker with a per-value setLeaf callback.
Add a TypeGeopoint field type. The libregraph Location facet is kept as an
object (retrieval / numeric queries) and a sibling <name>_geopoint field
carries the {lat,lon} form for geo-distance / bbox / polygon queries,
uniform across bleve and OpenSearch via the shared mapping. PrepareForIndex
splices the sibling in at write time.
Mtime is now a date field; the fixture's Go-format string fails
OpenSearch date parsing.
The package's engine suite is ginkgo; these new tests were plain.
New package, so use the repo's standard test framework.
The Mtime field is mapped as an OpenSearch `date`, which rejects an
empty value with `mapper_parsing_exception: cannot parse empty date`.
The folder and root fixtures had no Mtime, so serializing them to
`"Mtime": ""` made TestEngine_Purge/purge_resource_trees fail when the
document was indexed. Give both a valid RFC3339 Mtime, matching the
file fixture.
Both backends carry a shared search.SchemaVersion in the index name
(OpenSearch <base>-vN) and data path (bleve-vN). A breaking schema change
bumps the version so the service builds a fresh index instead of colliding
with the incompatible previous one; the old index is left in place.
…penSearch

OpenSearch lowercased every KQL query value, so exact-match queries on
case-preserved keyword fields (facet values, ids) never matched their stored
token. Fold the value only for fields with a lowercasing analyzer, mirroring the
bleve backend. The field set is derived once in search.LowercaseValueFields and
shared by both backends (bleve's local buildLowercaseFields is dropped).
The KQL parser produced its own validation errors but imported them from the
search service's query package. Move them into pkg/kql and let the search
backend consume kql.IsValidationError, so the parser stops depending on a
service package.
…ource struct

mapping.FieldNameIndex walks the struct and maps a lowercased field path to the
real field name, including nested facet sub-fields. Backend-neutral.
query.Normalize resolves field names (query.ResolveField, from the derived
index + a small alias overlay) and expands media-type restrictions
(mimetype.Expand) once, between parse and backend compilation.
The bleve Creator runs query.Normalize before compiling; the compiler consumes a
canonical AST with no field resolution or media-type special-casing.
KQLToOpenSearchBoolQuery runs query.Normalize, then only value lowercasing stays
backend-specific; remapKey and unfoldValue are gone.
Keyword and path fields always index their case-preserved base and, when CaseInsensitive is set, an additional <field>_lowercase sibling used only for matching. The KQL lowering marks a restriction case-insensitive; each backend searches the sibling and lowercases the query value the same way the sibling is precomputed at index time (Go strings.ToLower on both sides, so non-ASCII stays consistent).

Search always returns the case-preserved base, so the sibling never has to be read back. In bleve it is indexed but not stored, kept out of _all, and without doc values. In OpenSearch it deliberately stays in _source: excluding it would make every update-by-query script rebuild all siblings from the document via painless toLowerCase, which lowercases differently than Go and would drift from the query side. Keeping it in _source avoids that, and a lowercased copy of a name or path is negligible disk in a cluster.

The OpenSearch move script keeps the base and its sibling in sync by swapping the moved prefix in Path_lowercase and setting Name_lowercase from Go-lowercased params, so case-insensitive search still finds a file after it moves (previously the sibling went stale). bleve re-indexes the whole document on move/delete/restore, so its siblings stay fresh for free.

This also repairs OpenSearch path search (the query value was no longer folded to lowercase, so path:<Foo> returned nothing) and makes bleve path queries match a folder and its descendants like OpenSearch's path_hierarchy. The Path base stays case-preserved so the move/delete descendant update (an exact TermQuery on Path) matches mixed-case folders.
…bleve

Single-term `content:` built an unanalyzed term query, so once this branch dropped the blanket query-value lowercasing, `content:Foo` missed on OpenSearch (bleve was unaffected, its query analyzes). Fielded full-text queries now use a match query. OpenSearch `Content` also gets a porter stemming analyzer (it used the default standard analyzer and never stemmed), so full-text search matches bleve on both case and stemming.
bleve compiled a path restriction to a DisjunctionQuery, which mapBinary redistributes as an OR-chain, so `path:/Foo AND name:bar` matched the folder itself unconditionally. It is now a BooleanQuery (should: folder OR descendants), which mapBinary keeps atomic under an enclosing AND.

The OpenSearch full-text branch ran before the wildcard check, so `content:foo*` degraded to a phrase match and diverged from bleve; the wildcard check now comes first.

Adds the missing coverage the review flagged: path AND term, content wildcard, case-insensitive tags (the array sibling branch), and a spaced path with descendants on OpenSearch.
…rays

The []any branch skipped the sibling for an empty array while the []string branch wrote an empty one; both now write it, matching the base field.
CaseInsensitive routes queries to a <field>_lowercase sibling that is only generated for keyword/path fields, so marking any other type CaseInsensitive would silently match nothing. Validate now rejects it up front.
…ends

Adds bleve and OpenSearch coverage for category (image), literal MIME (image/svg+xml, with + and /), and raw MimeType: queries. Documents why MimeType skips the bleve escaper: it is not a bug, bleve treats / and + as literals mid-term, so a literal MIME still matches exactly while the category wildcard image/* keeps its *.
mediatype:Folder / mediatype:IMAGE resolved to a literal MimeType search and matched nothing because Expand switched on the raw value. The value is now lowercased in the lowering pass, so categories and literal MIME types match regardless of case, consistently on both backends.
…them

resolveField marked every anonymous field embedded, so walkFields (mapping, field index, validate) and fillStruct (deserializer) flattened a json-tagged embedded struct, while conversions.To/encoding/json on the write path nests it under the tag, mapping and deserializing it at the wrong path. An anonymous field is now embedded only without a json tag name, matching encoding/json; fillStruct also recurses into a value nested struct. No current type has a tagged embedded struct, so runtime behavior is unchanged; this hardens the reflection walker.
mediatype:file expands to a NOT restriction. Spliced inline as `NOT MimeType:httpd/unix-directory`, the bleve compiler's NOT branch left a stale operand, so `mediatype:file AND name:x` dropped `name:x` and matched nothing (the web Files filter). It is now wrapped in a group so the negation stays atomic; verified fixing both bleve and OpenSearch.
The guard only rejected CaseInsensitive when a non-keyword/path Type was set explicitly. With no Type, isCasedType treated the field as cased, so CaseInsensitive on an inferred numeric/bool/datetime field passed validation but produced no _lowercase sibling, and the query would silently match nothing. Validate now falls back to the inferred Go type.
…th start

The move script rewrote Path/Path_lowercase with painless String.replace, which replaces every occurrence of the old path, not just the leading prefix. OpenCloud paths are ./-prefixed so the full old path only occurs at the start and the result is byte-identical, but startsWith + substring makes the prefix-only intent explicit and robust to any path format. Not a live bug fix, a hardening.
A path value with spaces went through a match_phrase query, which analyzes
the query with the path_hierarchy analyzer; the resulting "." prefix token
matches every document in the space, breaking descendant matching and the
stale-path check after a move.
Cold boots take well over the 5s startup timeout, and a full host disk
tripped the flood-stage create-index block mid-run; test indexes are tiny.
…ackends

A leading NOT next to an operator was miscompiled: the bleve compiler left the consumed term in `next`, so `NOT x AND y` dropped `y` and produced a self-contradicting clause; the OpenSearch transpiler checked nextOp==AND before prevOp==NOT, so the negated term landed in `must` instead of `must_not`. NOT is unary and binds to the node directly after it regardless of what follows. This also fixes `mediatype:file AND <term>` (the web Files filter) at the root, so the earlier mediatype:file group workaround is dropped.
…ry level

Paths act as references (location scoping, deep links): /Foo and /foo
are distinct siblings, so path: matching must be exact. Case-insensitive
folder discovery is served by name: and its lowercase sibling. This also
matches bleve on main, where path queries have always been
case-sensitive.

Dropping the sibling removes its biggest maintenance cost: Path is the
one mutable sibling field, a move rewrites the paths of a whole subtree
and the OpenSearch move script had to rebuild Path_lowercase alongside
the base field.

With paths case-sensitive, the ref path scope moves into the query
itself: bleve as a term/prefix disjunction on the keyword Path,
OpenSearch as a term filter on its path_hierarchy tokens. The
post-filter that used to drop out-of-scope hits after the query ran is
gone; totals and paging now respect the scope instead of being computed
over the whole space, and a wrong-cased scope simply matches nothing.
@dschmidt
dschmidt force-pushed the refactor/search-mapping branch from a7ed42f to e5c12a9 Compare August 19, 2026 12:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant