Skip to content

feat(search): geohash aggregation (bleve + opensearch) - #3272

Draft
dschmidt wants to merge 59 commits into
feat/graph-search-queryfrom
feat/search-geohash-aggregation
Draft

feat(search): geohash aggregation (bleve + opensearch)#3272
dschmidt wants to merge 59 commits into
feat/graph-search-queryfrom
feat/search-geohash-aggregation

Conversation

@dschmidt

@dschmidt dschmidt commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Adds a geohash-grid aggregation to search, so a client can get map-cell density (bucket key = geohash cell, value = doc count) across all accessible spaces in one query.

New optional AggregationOption.geohash_precision (1-12). Clients aggregate on location; the server resolves it to the indexed geo field and rejects non-geo fields.

  • OpenSearch: native geohash_grid on the geo-point sibling.
  • bleve (no native geohash): index precomputes geohash-prefix keyword siblings (location_geohash_1..12) and runs a terms facet on the requested length. Same cells and counts as OpenSearch.

No schema-version bump: the mapping change is additive, so the existing reconcile extends the index in place and warns to reindex; no fresh index needed.

Stacked on #3211 (graph search query + aggregations). Independent of geo-KQL (#3212): the only shared helper (ResolveGeoField) is included here.

@codacy-production

codacy-production Bot commented Aug 8, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 38 complexity · 12 duplication

Metric Results
Complexity 38
Duplication 12

View in Codacy

🟢 Coverage 72.85% diff coverage · +0.06% coverage variation

Metric Results
Coverage variation +0.06% coverage variation (-1.00%)
Diff coverage 72.85% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (fb1e41b) 86102 20543 23.86%
Head commit (c775513) 86239 (+137) 20629 (+86) 23.92% (+0.06%)

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 (#3272) 151 110 72.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.
…egations

Rebased dschmidt/libre-graph-api feat/graph-search-full (PR #34) onto
opencloud-eu main and regenerated via the repo's woodpecker build-go recipe
(openapi-generator v7.23.0, --api-name-suffix Api).
Adds the graph /v1beta1/search/query endpoint, the aggregation proto messages,
the service-layer aggregation forwarding/merging, and the recursive bleve
aggregation implementation (terms, range, metric, sub-aggregations).
Implements terms, range, metric and sub-aggregations for the OpenSearch backend
via a dedicated aggs builder, wiring them through the shared search service.
Range aggregations parsed from/to with ParseFloat only, so date bounds on
datetime fields like photo.takenDateTime silently degraded to unbounded
numeric ranges. Detect date-formatted bounds (RFC3339 or YYYY-MM-DD),
switch the facet to bleve date ranges and read DateRanges from the facet
result. Malformed bounds in date mode are rejected.
Metric aggregations (sum/min/max/avg) only worked as sub-aggregations
under a terms bucket. Compute top-level metrics by folding the matched
hits through the existing accumulator and allow them through the graph
layer's numeric field validation.
The cross-space merge only carried buckets, dropping metric results
(value/metricKind) from the per-space responses. Reduce metrics with
their kind's reducer, keyed by field and kind.
@dschmidt
dschmidt force-pushed the feat/graph-search-query branch from 1b61ef6 to fb1e41b Compare August 18, 2026 16:51
@dschmidt
dschmidt force-pushed the feat/search-geohash-aggregation branch from 59aacc8 to c775513 Compare August 18, 2026 17:00
@dschmidt
dschmidt force-pushed the feat/graph-search-query branch 9 times, most recently from 2513263 to 71ca404 Compare September 3, 2026 08:33
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